Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/adr/38277-render-awf-steering-events-in-unified-timeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-38277: Render AWF Steering Events in the Unified Log Timeline

**Date**: 2026-06-10
**Status**: Draft

## Context

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.

## Decision

We will surface AWF steering events as first-class entries in the unified timeline by adding a new `TimelineKindSteering` event kind. Concretely:

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.
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.
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.

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.

## Alternatives Considered

### Alternative 1: Introduce a dedicated `TimelineSourceProxy` source and bespoke struct fields
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.

### Alternative 2: Parse only the canonical event-name field and single directory layout
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.

## Consequences

### Positive
- 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.
- Defensive multi-variant field parsing and dual-layout collection make steering rendering robust across proxy versions and historical runs.

### Negative
- 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.
- 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.

### Neutral
- 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.
- Steering entries with no parseable timestamp sort with a zero time, placing them at the start of the wall-clock ordering.

---

*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.*
93 changes: 88 additions & 5 deletions pkg/cli/gateway_logs_timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const (
TimelineKindAssistantMessage TimelineEventKind = "assistant_message"
// TimelineKindReasoning is a model reasoning/thinking trace (reasoning or assistant.reasoning event).
TimelineKindReasoning TimelineEventKind = "reasoning"
// TimelineKindSteering is a budget or time pressure steering message injected by the AWF
// API proxy (token_steering or timeout_steering event from api-proxy-logs/events.jsonl).
TimelineKindSteering TimelineEventKind = "steering"
)

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

// steeringEntryToTimelineEvent converts a proxyEventsEntry into a
// UnifiedTimelineEvent with Kind == TimelineKindSteering.
// Returns (zero, false) when the entry is not a recognised steering event.
//
// The Status field is set to "token" for token_steering events and "time" for
// timeout_steering events so that the renderer can apply appropriate icons.
// The Reason field carries the full message text.
// The Time field is set from the Timestamp field when present; zero otherwise.
func steeringEntryToTimelineEvent(entry proxyEventsEntry) (UnifiedTimelineEvent, bool) {
name := entry.eventName()
msg := strings.TrimSpace(entry.Message)
if !isSteeringEvent(name, msg) {
return UnifiedTimelineEvent{}, false
}

var status string
switch name {
case tokenSteeringEventName:
status = "token"
case timeoutSteeringEventName:
status = "time"
}

var t time.Time
if entry.Timestamp != "" {
if parsed, ok := gatewayTimestampToTime(entry.Timestamp); ok {
t = parsed
}
}

return UnifiedTimelineEvent{
Time: t,
Source: TimelineSourceFirewall,
Kind: TimelineKindSteering,
Status: status,
Reason: msg,
}, true
}

// collectSteeringTimelineEvents reads the api-proxy events.jsonl from logDir and
// returns a slice of TimelineKindSteering timeline events, one per recognised steering
// record. Returns nil (not an error) when no proxy events file is found.
func collectSteeringTimelineEvents(logDir string, verbose bool) ([]UnifiedTimelineEvent, error) {
eventsPath := findAPIProxyEventsFile(logDir)
if eventsPath == "" {
gatewayLogsLog.Printf("No api-proxy events.jsonl found in %s; skipping steering timeline collection", logDir)
return nil, nil
}

gatewayLogsLog.Printf("Collecting steering timeline events from: %s", eventsPath)

f, err := os.Open(filepath.Clean(eventsPath))
if err != nil {
return nil, fmt.Errorf("failed to open proxy events file: %w", err)
}
defer f.Close()

entries, err := scanSteeringEntries(f)
if err != nil {
return nil, fmt.Errorf("scanner error reading proxy events: %w", err)
}

events := make([]UnifiedTimelineEvent, 0, len(entries))
for _, entry := range entries {
if evt, ok := steeringEntryToTimelineEvent(entry); ok {
events = append(events, evt)
}
}

gatewayLogsLog.Printf("Collected %d steering timeline events from %s", len(events), filepath.Base(eventsPath))
return events, nil
}

// BuildUnifiedTimeline collects all JSONL events from the MCP Gateway, the AWF
// firewall, and the agent session in logDir, merges them into a single slice, and
// sorts the slice in ascending wall-clock order (oldest first).
// firewall, the agent session, and the AWF API proxy in logDir, merges them into a
// single slice, and sorts the slice in ascending wall-clock order (oldest first).
//
// If a source is unavailable (no matching file), it is silently skipped; collection
// errors are logged but do not prevent events from the other sources from being returned.
Expand All @@ -542,10 +618,17 @@ func BuildUnifiedTimeline(logDir string, verbose bool) ([]UnifiedTimelineEvent,
gatewayLogsLog.Printf("collectAgentTimelineEvents error: %v", agErr)
}

events := make([]UnifiedTimelineEvent, 0, len(gatewayEvents)+len(firewallEvents)+len(agentEvents))
steeringEvents, stErr := collectSteeringTimelineEvents(logDir, verbose)
if stErr != nil {
gatewayLogsLog.Printf("collectSteeringTimelineEvents error: %v", stErr)
}

events := make([]UnifiedTimelineEvent, 0,
len(gatewayEvents)+len(firewallEvents)+len(agentEvents)+len(steeringEvents))
events = append(events, gatewayEvents...)
events = append(events, firewallEvents...)
events = append(events, agentEvents...)
events = append(events, steeringEvents...)

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

gatewayLogsLog.Printf("Built unified timeline: %d events (gateway=%d, firewall=%d, agent=%d)",
len(events), len(gatewayEvents), len(firewallEvents), len(agentEvents))
gatewayLogsLog.Printf("Built unified timeline: %d events (gateway=%d, firewall=%d, agent=%d, steering=%d)",
len(events), len(gatewayEvents), len(firewallEvents), len(agentEvents), len(steeringEvents))

return events, nil
}
85 changes: 60 additions & 25 deletions pkg/cli/gateway_logs_timeline_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// TimelineKindAgentToolDone – renderAgentToolDoneRow
// TimelineKindAssistantMessage – renderAgentAssistantMessageRow
// TimelineKindReasoning – renderAgentReasoningRow
// TimelineKindSteering – renderSteeringRow
//
// renderTimelineEventRow dispatches to the appropriate primitive and returns a
// []string suitable for inclusion in a console.TableConfig.Rows slice.
Expand Down Expand Up @@ -57,6 +58,8 @@ func timelineEventIcon(kind TimelineEventKind) string {
return "●"
case TimelineKindReasoning:
return "◐"
case TimelineKindSteering:
return "⚠"
default:
return "·"
}
Expand Down Expand Up @@ -85,6 +88,8 @@ func timelineEventKindLabel(kind TimelineEventKind) string {
return "assistant_message"
case TimelineKindReasoning:
return "reasoning"
case TimelineKindSteering:
return "steering"
default:
return string(kind)
}
Expand Down Expand Up @@ -338,6 +343,20 @@ func renderAgentReasoningRow(evt UnifiedTimelineEvent) []string {
return []string{ts, src, kind, detail, ""}
}

// renderSteeringRow renders a TimelineKindSteering event as a table row.
//
// Columns: Time | Src | Kind | Detail | Status
//
// Detail shows a truncated preview of the steering message. Status shows the
// steering type: "token" for token budget warnings, "time" for timeout warnings.
func renderSteeringRow(evt UnifiedTimelineEvent) []string {
ts := formatTimelineTime(evt)
src := timelineSourceLabel(evt.Source)
kind := timelineEventIcon(TimelineKindSteering) + " " + timelineEventKindLabel(TimelineKindSteering)
detail := stringutil.Truncate(evt.Reason, 48)
return []string{ts, src, kind, detail, evt.Status}
}

// renderTimelineEventRow dispatches to the appropriate per-kind rendering primitive and
// returns a []string table row with columns: Time | Src | Kind | Detail | Status.
func renderTimelineEventRow(evt UnifiedTimelineEvent) []string {
Expand All @@ -362,6 +381,8 @@ func renderTimelineEventRow(evt UnifiedTimelineEvent) []string {
return renderAgentAssistantMessageRow(evt)
case TimelineKindReasoning:
return renderAgentReasoningRow(evt)
case TimelineKindSteering:
return renderSteeringRow(evt)
default:
// Fallback for any future event kinds not yet handled.
ts := formatTimelineTime(evt)
Expand Down Expand Up @@ -580,6 +601,11 @@ func renderUnifiedTimelineStream(events []UnifiedTimelineEvent) string {
}
fmt.Fprintf(&sb, " %s %s%s\n", icon, detail, annotationStr)

case TimelineKindSteering:
icon := streamColor(styles.Warning, timelineEventIcon(TimelineKindSteering))
msg := stringutil.Truncate(evt.Reason, streamMaxAnnotationLen)
fmt.Fprintf(&sb, " %s %s\n", icon, msg)

default:
fmt.Fprintf(&sb, " · [%s] %s %s\n", ts, string(evt.Kind), timelineSourceLabel(evt.Source))
}
Expand All @@ -604,38 +630,44 @@ func renderUnifiedTimeline(events []UnifiedTimelineEvent) string {

// Tally event counts for the summary header.
var gwCount, fwCount, agCount int
var toolCalls, difcFiltered, guardBlocked, netAllowed, netBlocked int
var toolCalls, difcFiltered, guardBlocked, netAllowed, netBlocked, steeringCount int
var agentTurns, agentToolStarts, agentToolDones, assistantMessages, reasoningCount int
for _, evt := range events {
switch evt.Source {
case TimelineSourceGateway:
gwCount++
switch evt.Kind {
case TimelineKindToolCall:
toolCalls++
case TimelineKindDIFCFiltered:
difcFiltered++
case TimelineKindGuardPolicyBlocked:
guardBlocked++
}
case TimelineSourceFirewall:
fwCount++
switch evt.Kind {
case TimelineKindNetworkAllowed:
netAllowed++
case TimelineKindNetworkBlocked:
netBlocked++
case TimelineKindSteering:
steeringCount++
}
case TimelineSourceAgent:
agCount++
}
switch evt.Kind {
case TimelineKindToolCall:
toolCalls++
case TimelineKindDIFCFiltered:
difcFiltered++
case TimelineKindGuardPolicyBlocked:
guardBlocked++
case TimelineKindNetworkAllowed:
netAllowed++
case TimelineKindNetworkBlocked:
netBlocked++
case TimelineKindAgentTurn:
agentTurns++
case TimelineKindAgentToolStart:
agentToolStarts++
case TimelineKindAgentToolDone:
agentToolDones++
case TimelineKindAssistantMessage:
assistantMessages++
case TimelineKindReasoning:
reasoningCount++
switch evt.Kind {
case TimelineKindAgentTurn:
agentTurns++
case TimelineKindAgentToolStart:
agentToolStarts++
case TimelineKindAgentToolDone:
agentToolDones++
case TimelineKindAssistantMessage:
assistantMessages++
case TimelineKindReasoning:
reasoningCount++
}
}
}

Expand All @@ -651,8 +683,11 @@ func renderUnifiedTimeline(events []UnifiedTimelineEvent) string {
gwCount, toolCalls, difcFiltered, guardBlocked)
}
if fwCount > 0 {
fmt.Fprintf(&sb, " Firewall : %d (allowed=%d, blocked=%d)\n",
fwCount, netAllowed, netBlocked)
fwDetail := fmt.Sprintf("allowed=%d, blocked=%d", netAllowed, netBlocked)
if steeringCount > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

steeringCount is source-independent but reported only inside if fwCount > 0: the counter is incremented in the flat switch evt.Kind block (no source guard), but emitted only when fwCount > 0. Today this is harmless because steeringEntryToTimelineEvent hardcodes Source: TimelineSourceFirewall. But if any steering event ever arrives from a different source, steeringCount will be non-zero while fwCount may be zero, silently swallowing the steering tally from the summary.

💡 Suggested fix

Count steering under the firewall source bucket explicitly so the coupling is visible and consistent:

case TimelineSourceFirewall:
    fwCount++
    switch evt.Kind {
    case TimelineKindNetworkAllowed:
        netAllowed++
    case TimelineKindNetworkBlocked:
        netBlocked++
    case TimelineKindSteering:
        steeringCount++
    }

Or, at minimum, add a standalone if steeringCount > 0 && fwCount == 0 branch in the summary formatter so the count is never silently dropped.

fwDetail += fmt.Sprintf(", steering=%d", steeringCount)
}
fmt.Fprintf(&sb, " Firewall : %d (%s)\n", fwCount, fwDetail)
}
if agCount > 0 {
fmt.Fprintf(&sb, " Agent : %d (turns=%d, tool_start=%d, tool_done=%d, messages=%d, reasoning=%d)\n",
Expand Down
Loading
Loading