Skip to content

Commit b508103

Browse files
authored
Add steering message rendering in unified log view (#38277)
1 parent 3702645 commit b508103

5 files changed

Lines changed: 552 additions & 58 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# ADR-38277: Render AWF Steering Events in the Unified Log Timeline
2+
3+
**Date**: 2026-06-10
4+
**Status**: Draft
5+
6+
## Context
7+
8+
The `gh aw view` / `gh aw audit` unified timeline merges JSONL events from the MCP Gateway, the AWF firewall, and the agent session into a single wall-clock-ordered view. The AWF API proxy emits steering events (`token_steering`, `timeout_steering`) when a run approaches its token budget or time limit; these were already counted in `TotalSteeringEvents` metrics but were silently dropped from the timeline, so an operator reading the unified log could see neither when nor why a run was steered. The steering records live in `api-proxy-logs/events.jsonl`, whose schema varies by proxy version — the event name appears under one of four field names (`event`, `type`, `event_name`, `eventName`) — and whose log directory exists in both a canonical (`sandbox/firewall/logs/`) and a legacy (`firewall-audit-logs/`) layout.
9+
10+
## Decision
11+
12+
We will surface AWF steering events as first-class entries in the unified timeline by adding a new `TimelineKindSteering` event kind. Concretely:
13+
14+
1. Steering events are collected by a new `collectSteeringTimelineEvents`, parsed via a `proxyEventsEntry` struct whose `eventName()` helper checks all four field-name variants, and validated against the AWF spec message prefixes (`[AWF TOKEN WARNING]` / `[AWF TIME WARNING]`) before admission.
15+
2. Steering events reuse the existing `TimelineSourceFirewall` source rather than introducing a new source, and encode their subtype in the existing `Status` field (`"token"` / `"time"`) rather than adding new struct fields.
16+
3. The renderer dispatches the new kind to `renderSteeringRow` (table) and a warning-styled `⚠ <message>` line (stream), and the summary appends `steering=N` to the existing Firewall line only when the count is non-zero.
17+
18+
This favors extending the established per-kind timeline pattern and reusing the firewall source/status plumbing over introducing parallel structures, keeping steering integration consistent with how gateway and agent events are already handled.
19+
20+
## Alternatives Considered
21+
22+
### Alternative 1: Introduce a dedicated `TimelineSourceProxy` source and bespoke struct fields
23+
Model the API proxy as its own timeline source with a new summary line and dedicated fields for steering subtype and message. Rejected because steering is conceptually a firewall/guard concern already grouped under the Firewall summary, and a new source would add a fourth summary block plus renderer branching for a single event kind, increasing surface area without a clear operator benefit.
24+
25+
### Alternative 2: Parse only the canonical event-name field and single directory layout
26+
Read `event_name` from `sandbox/firewall/logs/api-proxy-logs/events.jsonl` only, treating the other field-name variants and the legacy layout as out of scope. Rejected because real proxy logs in the field use all four field-name spellings and both directory layouts; a strict reader would silently drop steering events from older runs and proxy versions, reintroducing the very gap this change closes.
27+
28+
## Consequences
29+
30+
### Positive
31+
- Operators can now see when and why a run was steered directly in the unified timeline, closing the gap between `TotalSteeringEvents` metrics and the visible log.
32+
- Defensive multi-variant field parsing and dual-layout collection make steering rendering robust across proxy versions and historical runs.
33+
34+
### Negative
35+
- Reusing the `Status` field to carry the steering subtype overloads a field whose semantics now depend on `Kind`, so future readers must know that `Status` means something different for steering events than for other kinds.
36+
- The collector adds another file scan (`events.jsonl`) to every `BuildUnifiedTimeline` call, with the spec-prefix validation and four-field probing duplicating event-name knowledge that must stay in sync with the AWF proxy spec.
37+
38+
### Neutral
39+
- Steering events are grouped under the existing Firewall summary line rather than a new section; the `steering=N` suffix appears only when non-zero, leaving output unchanged for runs without steering.
40+
- Steering entries with no parseable timestamp sort with a zero time, placing them at the start of the wall-clock ordering.
41+
42+
---
43+
44+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27252641413) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

‎pkg/cli/gateway_logs_timeline.go‎

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ const (
6666
TimelineKindAssistantMessage TimelineEventKind = "assistant_message"
6767
// TimelineKindReasoning is a model reasoning/thinking trace (reasoning or assistant.reasoning event).
6868
TimelineKindReasoning TimelineEventKind = "reasoning"
69+
// TimelineKindSteering is a budget or time pressure steering message injected by the AWF
70+
// API proxy (token_steering or timeout_steering event from api-proxy-logs/events.jsonl).
71+
TimelineKindSteering TimelineEventKind = "steering"
6972
)
7073

7174
// UnifiedTimelineEvent represents a single event from the MCP Gateway, the AWF
@@ -520,9 +523,82 @@ func collectAgentTimelineEvents(logDir string, verbose bool) ([]UnifiedTimelineE
520523
return events, nil
521524
}
522525

526+
// steeringEntryToTimelineEvent converts a proxyEventsEntry into a
527+
// UnifiedTimelineEvent with Kind == TimelineKindSteering.
528+
// Returns (zero, false) when the entry is not a recognised steering event.
529+
//
530+
// The Status field is set to "token" for token_steering events and "time" for
531+
// timeout_steering events so that the renderer can apply appropriate icons.
532+
// The Reason field carries the full message text.
533+
// The Time field is set from the Timestamp field when present; zero otherwise.
534+
func steeringEntryToTimelineEvent(entry proxyEventsEntry) (UnifiedTimelineEvent, bool) {
535+
name := entry.eventName()
536+
msg := strings.TrimSpace(entry.Message)
537+
if !isSteeringEvent(name, msg) {
538+
return UnifiedTimelineEvent{}, false
539+
}
540+
541+
var status string
542+
switch name {
543+
case tokenSteeringEventName:
544+
status = "token"
545+
case timeoutSteeringEventName:
546+
status = "time"
547+
}
548+
549+
var t time.Time
550+
if entry.Timestamp != "" {
551+
if parsed, ok := gatewayTimestampToTime(entry.Timestamp); ok {
552+
t = parsed
553+
}
554+
}
555+
556+
return UnifiedTimelineEvent{
557+
Time: t,
558+
Source: TimelineSourceFirewall,
559+
Kind: TimelineKindSteering,
560+
Status: status,
561+
Reason: msg,
562+
}, true
563+
}
564+
565+
// collectSteeringTimelineEvents reads the api-proxy events.jsonl from logDir and
566+
// returns a slice of TimelineKindSteering timeline events, one per recognised steering
567+
// record. Returns nil (not an error) when no proxy events file is found.
568+
func collectSteeringTimelineEvents(logDir string, verbose bool) ([]UnifiedTimelineEvent, error) {
569+
eventsPath := findAPIProxyEventsFile(logDir)
570+
if eventsPath == "" {
571+
gatewayLogsLog.Printf("No api-proxy events.jsonl found in %s; skipping steering timeline collection", logDir)
572+
return nil, nil
573+
}
574+
575+
gatewayLogsLog.Printf("Collecting steering timeline events from: %s", eventsPath)
576+
577+
f, err := os.Open(filepath.Clean(eventsPath))
578+
if err != nil {
579+
return nil, fmt.Errorf("failed to open proxy events file: %w", err)
580+
}
581+
defer f.Close()
582+
583+
entries, err := scanSteeringEntries(f)
584+
if err != nil {
585+
return nil, fmt.Errorf("scanner error reading proxy events: %w", err)
586+
}
587+
588+
events := make([]UnifiedTimelineEvent, 0, len(entries))
589+
for _, entry := range entries {
590+
if evt, ok := steeringEntryToTimelineEvent(entry); ok {
591+
events = append(events, evt)
592+
}
593+
}
594+
595+
gatewayLogsLog.Printf("Collected %d steering timeline events from %s", len(events), filepath.Base(eventsPath))
596+
return events, nil
597+
}
598+
523599
// BuildUnifiedTimeline collects all JSONL events from the MCP Gateway, the AWF
524-
// firewall, and the agent session in logDir, merges them into a single slice, and
525-
// sorts the slice in ascending wall-clock order (oldest first).
600+
// firewall, the agent session, and the AWF API proxy in logDir, merges them into a
601+
// single slice, and sorts the slice in ascending wall-clock order (oldest first).
526602
//
527603
// If a source is unavailable (no matching file), it is silently skipped; collection
528604
// errors are logged but do not prevent events from the other sources from being returned.
@@ -542,10 +618,17 @@ func BuildUnifiedTimeline(logDir string, verbose bool) ([]UnifiedTimelineEvent,
542618
gatewayLogsLog.Printf("collectAgentTimelineEvents error: %v", agErr)
543619
}
544620

545-
events := make([]UnifiedTimelineEvent, 0, len(gatewayEvents)+len(firewallEvents)+len(agentEvents))
621+
steeringEvents, stErr := collectSteeringTimelineEvents(logDir, verbose)
622+
if stErr != nil {
623+
gatewayLogsLog.Printf("collectSteeringTimelineEvents error: %v", stErr)
624+
}
625+
626+
events := make([]UnifiedTimelineEvent, 0,
627+
len(gatewayEvents)+len(firewallEvents)+len(agentEvents)+len(steeringEvents))
546628
events = append(events, gatewayEvents...)
547629
events = append(events, firewallEvents...)
548630
events = append(events, agentEvents...)
631+
events = append(events, steeringEvents...)
549632

550633
slices.SortFunc(events, func(a, b UnifiedTimelineEvent) int {
551634
switch {
@@ -558,8 +641,8 @@ func BuildUnifiedTimeline(logDir string, verbose bool) ([]UnifiedTimelineEvent,
558641
}
559642
})
560643

561-
gatewayLogsLog.Printf("Built unified timeline: %d events (gateway=%d, firewall=%d, agent=%d)",
562-
len(events), len(gatewayEvents), len(firewallEvents), len(agentEvents))
644+
gatewayLogsLog.Printf("Built unified timeline: %d events (gateway=%d, firewall=%d, agent=%d, steering=%d)",
645+
len(events), len(gatewayEvents), len(firewallEvents), len(agentEvents), len(steeringEvents))
563646

564647
return events, nil
565648
}

‎pkg/cli/gateway_logs_timeline_render.go‎

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// TimelineKindAgentToolDone – renderAgentToolDoneRow
1515
// TimelineKindAssistantMessage – renderAgentAssistantMessageRow
1616
// TimelineKindReasoning – renderAgentReasoningRow
17+
// TimelineKindSteering – renderSteeringRow
1718
//
1819
// renderTimelineEventRow dispatches to the appropriate primitive and returns a
1920
// []string suitable for inclusion in a console.TableConfig.Rows slice.
@@ -57,6 +58,8 @@ func timelineEventIcon(kind TimelineEventKind) string {
5758
return "●"
5859
case TimelineKindReasoning:
5960
return "◐"
61+
case TimelineKindSteering:
62+
return "⚠"
6063
default:
6164
return "·"
6265
}
@@ -85,6 +88,8 @@ func timelineEventKindLabel(kind TimelineEventKind) string {
8588
return "assistant_message"
8689
case TimelineKindReasoning:
8790
return "reasoning"
91+
case TimelineKindSteering:
92+
return "steering"
8893
default:
8994
return string(kind)
9095
}
@@ -338,6 +343,20 @@ func renderAgentReasoningRow(evt UnifiedTimelineEvent) []string {
338343
return []string{ts, src, kind, detail, ""}
339344
}
340345

346+
// renderSteeringRow renders a TimelineKindSteering event as a table row.
347+
//
348+
// Columns: Time | Src | Kind | Detail | Status
349+
//
350+
// Detail shows a truncated preview of the steering message. Status shows the
351+
// steering type: "token" for token budget warnings, "time" for timeout warnings.
352+
func renderSteeringRow(evt UnifiedTimelineEvent) []string {
353+
ts := formatTimelineTime(evt)
354+
src := timelineSourceLabel(evt.Source)
355+
kind := timelineEventIcon(TimelineKindSteering) + " " + timelineEventKindLabel(TimelineKindSteering)
356+
detail := stringutil.Truncate(evt.Reason, 48)
357+
return []string{ts, src, kind, detail, evt.Status}
358+
}
359+
341360
// renderTimelineEventRow dispatches to the appropriate per-kind rendering primitive and
342361
// returns a []string table row with columns: Time | Src | Kind | Detail | Status.
343362
func renderTimelineEventRow(evt UnifiedTimelineEvent) []string {
@@ -362,6 +381,8 @@ func renderTimelineEventRow(evt UnifiedTimelineEvent) []string {
362381
return renderAgentAssistantMessageRow(evt)
363382
case TimelineKindReasoning:
364383
return renderAgentReasoningRow(evt)
384+
case TimelineKindSteering:
385+
return renderSteeringRow(evt)
365386
default:
366387
// Fallback for any future event kinds not yet handled.
367388
ts := formatTimelineTime(evt)
@@ -580,6 +601,11 @@ func renderUnifiedTimelineStream(events []UnifiedTimelineEvent) string {
580601
}
581602
fmt.Fprintf(&sb, " %s %s%s\n", icon, detail, annotationStr)
582603

604+
case TimelineKindSteering:
605+
icon := streamColor(styles.Warning, timelineEventIcon(TimelineKindSteering))
606+
msg := stringutil.Truncate(evt.Reason, streamMaxAnnotationLen)
607+
fmt.Fprintf(&sb, " %s %s\n", icon, msg)
608+
583609
default:
584610
fmt.Fprintf(&sb, " · [%s] %s %s\n", ts, string(evt.Kind), timelineSourceLabel(evt.Source))
585611
}
@@ -604,38 +630,44 @@ func renderUnifiedTimeline(events []UnifiedTimelineEvent) string {
604630

605631
// Tally event counts for the summary header.
606632
var gwCount, fwCount, agCount int
607-
var toolCalls, difcFiltered, guardBlocked, netAllowed, netBlocked int
633+
var toolCalls, difcFiltered, guardBlocked, netAllowed, netBlocked, steeringCount int
608634
var agentTurns, agentToolStarts, agentToolDones, assistantMessages, reasoningCount int
609635
for _, evt := range events {
610636
switch evt.Source {
611637
case TimelineSourceGateway:
612638
gwCount++
639+
switch evt.Kind {
640+
case TimelineKindToolCall:
641+
toolCalls++
642+
case TimelineKindDIFCFiltered:
643+
difcFiltered++
644+
case TimelineKindGuardPolicyBlocked:
645+
guardBlocked++
646+
}
613647
case TimelineSourceFirewall:
614648
fwCount++
649+
switch evt.Kind {
650+
case TimelineKindNetworkAllowed:
651+
netAllowed++
652+
case TimelineKindNetworkBlocked:
653+
netBlocked++
654+
case TimelineKindSteering:
655+
steeringCount++
656+
}
615657
case TimelineSourceAgent:
616658
agCount++
617-
}
618-
switch evt.Kind {
619-
case TimelineKindToolCall:
620-
toolCalls++
621-
case TimelineKindDIFCFiltered:
622-
difcFiltered++
623-
case TimelineKindGuardPolicyBlocked:
624-
guardBlocked++
625-
case TimelineKindNetworkAllowed:
626-
netAllowed++
627-
case TimelineKindNetworkBlocked:
628-
netBlocked++
629-
case TimelineKindAgentTurn:
630-
agentTurns++
631-
case TimelineKindAgentToolStart:
632-
agentToolStarts++
633-
case TimelineKindAgentToolDone:
634-
agentToolDones++
635-
case TimelineKindAssistantMessage:
636-
assistantMessages++
637-
case TimelineKindReasoning:
638-
reasoningCount++
659+
switch evt.Kind {
660+
case TimelineKindAgentTurn:
661+
agentTurns++
662+
case TimelineKindAgentToolStart:
663+
agentToolStarts++
664+
case TimelineKindAgentToolDone:
665+
agentToolDones++
666+
case TimelineKindAssistantMessage:
667+
assistantMessages++
668+
case TimelineKindReasoning:
669+
reasoningCount++
670+
}
639671
}
640672
}
641673

@@ -651,8 +683,11 @@ func renderUnifiedTimeline(events []UnifiedTimelineEvent) string {
651683
gwCount, toolCalls, difcFiltered, guardBlocked)
652684
}
653685
if fwCount > 0 {
654-
fmt.Fprintf(&sb, " Firewall : %d (allowed=%d, blocked=%d)\n",
655-
fwCount, netAllowed, netBlocked)
686+
fwDetail := fmt.Sprintf("allowed=%d, blocked=%d", netAllowed, netBlocked)
687+
if steeringCount > 0 {
688+
fwDetail += fmt.Sprintf(", steering=%d", steeringCount)
689+
}
690+
fmt.Fprintf(&sb, " Firewall : %d (%s)\n", fwCount, fwDetail)
656691
}
657692
if agCount > 0 {
658693
fmt.Fprintf(&sb, " Agent : %d (turns=%d, tool_start=%d, tool_done=%d, messages=%d, reasoning=%d)\n",

0 commit comments

Comments
 (0)