Skip to content

Commit 2ab45fb

Browse files
authored
Emit AWF agentTimeout for literal timeouts and show delivered steering notices in audit/logs (#67597)
1 parent 1314d42 commit 2ab45fb

52 files changed

Lines changed: 1826 additions & 118 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎actions/setup/js/types/unified_session.d.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@ export interface RuntimeObservationData {
9999
requestId?: JsonValue;
100100
}
101101

102+
/** AWF API proxy steering event-log observation. */
103+
export interface FirewallSteeringData extends RuntimeObservationData {
104+
threshold?: JsonValue;
105+
}
106+
107+
/** AWF steering notice delivered on a model request. */
108+
export interface SteeringNotice {
109+
type: "ai_credit" | "timeout" | "token";
110+
threshold: 80 | 90 | 95 | 99;
111+
}
112+
102113
export interface ModelRoutingData {
103114
schema?: JsonValue;
104115
stage?: JsonValue;
@@ -213,6 +224,7 @@ export interface UsageReportData extends UnifiedSessionContextData {
213224
totalAic?: JsonValue;
214225
premiumRequests?: JsonValue;
215226
durationMs?: JsonValue;
227+
steering?: SteeringNotice;
216228
usage?: UnifiedSessionUsage | null;
217229
}
218230

@@ -478,7 +490,7 @@ export interface UnifiedSessionEventDataMap {
478490
"firewall.model_routing": ModelRoutingData;
479491
"model_routing.outcome": ModelRoutingOutcomeData;
480492
"model_endpoint.mismatch": ModelEndpointMismatchData;
481-
"firewall.steering": RuntimeObservationData;
493+
"firewall.steering": FirewallSteeringData;
482494
"firewall.event": RuntimeObservationData;
483495
"safe_output.request": SafeOutputData;
484496
"safe_output.result": SafeOutputData;

‎actions/setup/js/unified_session.cjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ function normalizeRuntimeEvent(component, record) {
106106
if (kind === "http_access" || record.decision !== undefined) type = "firewall.http_access";
107107
else if (kind === "token_usage") type = "firewall.token_usage";
108108
else if (kind === "model_routing") type = "firewall.model_routing";
109-
else if (["token_steering", "timeout_steering"].includes(kind)) type = "firewall.steering";
109+
else if (["ai_credit_steering", "token_steering", "timeout_steering"].includes(kind)) type = "firewall.steering";
110110
}
111111
return {
112112
type,

‎actions/setup/js/unified_session.test.cjs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,33 @@ describe("Unified conclusion session", () => {
718718
}
719719
);
720720

721+
it("carries AWF steering notices from token usage and steering event logs", () => {
722+
write("sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl", [
723+
{ timestamp: "2026-10-02T00:00:01Z", event: "token_usage", request_id: "credit", purpose: "agent", model: "claude-sonnet-5", input_tokens: 1, steering: { type: "ai_credit", threshold: 80 } },
724+
{ timestamp: "2026-10-02T00:00:02Z", event: "token_usage", request_id: "plain", purpose: "agent", model: "claude-sonnet-5", input_tokens: 1 },
725+
{ timestamp: "2026-10-02T00:00:03Z", event: "token_usage", request_id: "time", purpose: "agent", model: "claude-sonnet-5", input_tokens: 1, steering: { type: "timeout", threshold: 90 } },
726+
]);
727+
write("sandbox/firewall/logs/api-proxy-logs/events.jsonl", [
728+
{ timestamp: "2026-10-02T00:00:01Z", level: "info", event: "ai_credit_steering", request_id: "credit", provider: "copilot", threshold: 80, message: "[AWF AI CREDIT WARNING] budget" },
729+
{ timestamp: "2026-10-02T00:00:03Z", level: "info", event: "timeout_steering", request_id: "time", provider: "copilot", threshold: 90, message: "[AWF TIME WARNING] time" },
730+
]);
731+
const { events } = collectUnifiedSession({ rootDir: root });
732+
const usage = events.filter(event => event.type === "firewall.token_usage");
733+
expect(usage.map(event => [event.data.requestId, event.data.steering])).toEqual([
734+
["credit", { type: "ai_credit", threshold: 80 }],
735+
["plain", undefined],
736+
["time", { type: "timeout", threshold: 90 }],
737+
]);
738+
expect(usage[1].data).not.toHaveProperty("steering");
739+
expect(events.filter(event => event.type === "firewall.steering").map(event => event.data)).toEqual([
740+
{ event: "ai_credit_steering", level: "info", message: "[AWF AI CREDIT WARNING] budget", requestId: "credit", threshold: 80 },
741+
{ event: "timeout_steering", level: "info", message: "[AWF TIME WARNING] time", requestId: "time", threshold: 90 },
742+
]);
743+
const validate = req("./scripts/validate_session.cjs").createSessionValidator("unified").event;
744+
for (const event of events) expect(validate(event), JSON.stringify(validate.errors)).toBe(true);
745+
expect(validate({ ...usage[0], data: { ...usage[0].data, steering: { type: "ai_credit", threshold: 80, message: "PRIVATE" } } })).toBe(false);
746+
});
747+
721748
it("prefers persisted canonical events over raw logs and avoids replicated firewall accounting", () => {
722749
write("agent-session.jsonl", [{ type: "vendor.extension", data: { preserved: true } }]);
723750
write("sandbox/agent/logs/copilot-session-state/uuid/events.jsonl", [{ type: "user.message", data: { content: "duplicate" } }]);

‎actions/setup/js/unified_session_payload.cjs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ const RUNTIME_FIELDS = {
5858
requestId: ["requestId", "request_id", "rid"],
5959
};
6060
/** @type {Fields} */
61+
const STEERING_FIELDS = { type: ["type"], threshold: ["threshold"] };
62+
const STEERING_TYPES = new Set(["ai_credit", "timeout", "token"]);
63+
const STEERING_THRESHOLDS = new Set([80, 90, 95, 99]);
64+
/** @type {Fields} */
6165
const MCP_FIELDS = {
6266
serverName: ["serverName", "server_name", "server_id"],
6367
direction: ["direction"],
@@ -271,7 +275,7 @@ const EVENT_FIELDS = {
271275
bytes: ["bytes"],
272276
durationMs: ["durationMs", "duration_ms"],
273277
},
274-
"firewall.steering": RUNTIME_FIELDS,
278+
"firewall.steering": { ...RUNTIME_FIELDS, threshold: ["threshold"] },
275279
"firewall.event": RUNTIME_FIELDS,
276280
"firewall.token_usage": {
277281
provider: ["provider"],
@@ -285,6 +289,7 @@ const EVENT_FIELDS = {
285289
totalAic: ["totalAic", "aiCreditsTotal", "ai_credits_total", "ai_credits", "aiCredits"],
286290
premiumRequests: ["premiumRequests", "premium_requests"],
287291
durationMs: ["durationMs", "duration_ms"],
292+
steering: ["steering"],
288293
},
289294
"firewall.model_routing": {
290295
schema: ["_schema", "schema"],
@@ -506,6 +511,12 @@ function normalizeUnifiedSessionEvent(event, phase) {
506511
}
507512
}
508513
if (event.type === "usage.report" && phase !== undefined && AIC_RESOLVABLE_PHASES.has(phase)) resolveUsageAic(source, data, usage);
514+
if (Object.hasOwn(data, "steering")) {
515+
// Keep only the AWF steering notice type and threshold; drop unrecognized notices.
516+
const steering = data.steering && typeof data.steering === "object" && !Array.isArray(data.steering) ? selectFields(data.steering, STEERING_FIELDS) : {};
517+
if (STEERING_TYPES.has(steering.type) && STEERING_THRESHOLDS.has(steering.threshold)) data.steering = steering;
518+
else delete data.steering;
519+
}
509520
}
510521
if (event.type === "tool.execution_complete" && (source.is_error === true || source.result?.isError === true || source.result?.is_error === true)) data.isError = true;
511522
if (event.type === "experiment.assignment") {

‎actions/setup/js/unified_session_payload.test.cjs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,11 @@ describe("essential unified session payloads", () => {
352352
["session.init", { sourceEngine: "copilot", model: "fixture", session_id: "session", tools: Array(100).fill("large descriptor") }, { sourceEngine: "copilot", model: "fixture", sessionId: "session" }],
353353
["firewall.http_access", { domain: "example.com", http_status: 0, squid_request_status: "DENIED", credentials: "omit" }, { host: "example.com", status: 0, decision: "DENIED" }],
354354
["firewall.steering", { eventName: "token_steering", message: "budget warning", reason: null, body: "omit" }, { event: "token_steering", message: "budget warning", reason: null }],
355+
[
356+
"firewall.steering",
357+
{ event: "ai_credit_steering", request_id: "steered", provider: "copilot", threshold: 80, message: "[AWF AI CREDIT WARNING] budget" },
358+
{ event: "ai_credit_steering", requestId: "steered", threshold: 80, message: "[AWF AI CREDIT WARNING] budget" },
359+
],
355360
["mcp.tool_call", { tool_call_id: "call", tool_name: "lookup", duration: 5, input_size: 0, output_size: 10, status: "error" }, { toolCallId: "call", toolName: "lookup", durationMs: 5, inputSize: 0, outputSize: 10, status: "error" }],
356361
["execution.result", { exit_code: 0, duration_ms: 0, outcome: "success", extra: "omit" }, { exitCode: 0, durationMs: 0, outcome: "success" }],
357362
[
@@ -517,6 +522,21 @@ describe("essential unified session payloads", () => {
517522
expect(normalizeUnifiedSessionEvent(runtime)).toEqual(runtime);
518523
});
519524

525+
it("keeps only the AWF steering notice type and threshold on token usage events", () => {
526+
const steered = normalizeUnifiedSessionEvent({
527+
type: "firewall.token_usage",
528+
data: { request_id: "steered", model: "claude-sonnet-5", steering: { type: "ai_credit", threshold: 90, message: "omit" } },
529+
});
530+
expect(steered.data).toEqual({ requestId: "steered", model: "claude-sonnet-5", steering: { type: "ai_credit", threshold: 90 } });
531+
const report = normalizeUnifiedSessionEvent({ type: "usage.report", data: { request_id: "steered", steering: { type: "timeout", threshold: 99 } } });
532+
expect(report.data).toEqual({ requestId: "steered", steering: { type: "timeout", threshold: 99 } });
533+
expect(normalizeUnifiedSessionEvent(steered)).toEqual(steered);
534+
for (const steering of [null, "ai_credit", [], {}, { message: "omit" }, { type: "ai_credit" }, { type: "unknown", threshold: 80 }, { type: "token", threshold: "90" }, { type: "token", threshold: 50 }]) {
535+
expect(normalizeUnifiedSessionEvent({ type: "firewall.token_usage", data: { request_id: "plain", steering } }).data).not.toHaveProperty("steering");
536+
}
537+
expect(normalizeUnifiedSessionEvent({ type: "firewall.token_usage", data: { request_id: "plain" } }).data).not.toHaveProperty("steering");
538+
});
539+
520540
it("retains normalized subagent invocation and per-request usage fields", () => {
521541
const source = {
522542
type: "subagent.request",

‎actions/setup/js/unified_session_render.cjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ function eventDetail(event) {
145145
case "firewall.http_access":
146146
return fields(data, ["host", "method", "status", "decision"]);
147147
case "firewall.steering":
148-
return fields(data, ["event", "message", "reason"]);
148+
return fields(data, ["event", "threshold", "message", "reason"]);
149149
case "firewall.token_usage":
150150
case "usage.report":
151151
return fields(data, ["provider", "model", "aic", "totalAic", "premiumRequests", "durationMs"]) + " " + fields(data.usage, ["inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens"]);
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# ADR-67597: Version-Gated AWF `agentTimeout` and First-Class Steering Notices in the Unified Session
2+
3+
**Date**: 2026-10-11
4+
**Status**: Proposed
5+
**Deciders**: the gh-aw maintainers
6+
7+
---
8+
9+
### Context
10+
11+
The agentic firewall (AWF) can steer a running agent by warning it at 80/90/95/99% of its AI-credit budget,
12+
token budget, or runtime deadline. In gh-aw, the runtime (deadline) warnings never fire on the default Docker
13+
runtime, because `container.agentTimeout` is only emitted for the Cloud Hypervisor and NVX runtimes
14+
(`pkg/workflow/awf_config_build.go`). Separately, the per-request `steering` record that AWF writes into its
15+
token-usage stream is dropped by the unified-session projection (`actions/setup/js/unified_session_payload.cjs`),
16+
so `gh aw audit` and `gh aw logs` cannot show which warnings were actually delivered to the agent. Two
17+
constraints shape the fix: AWF's deadline is counted from agent start while GitHub's `timeout-minutes` is
18+
counted from step start, so a naive copy of the step timeout would let AWF's `exit 124` truncate the agent's
19+
final output; and the behaviour depends on an AWF release (v0.28.51) that is not published yet, so it must not
20+
change any currently compiled workflow. Fixes #67596.
21+
22+
### Decision
23+
24+
We will (a) emit `container.agentTimeout` on all runtimes behind an AWF version gate, and (b) promote AWF
25+
steering notices to a first-class, schema-described field of the unified session that flows through to audit
26+
and logs output.
27+
28+
For (a), a new `AWFAgentTimeoutSteeringMinVersion` constant plus an `awfSupportsAgentTimeoutSteering` check
29+
follow the existing AWF feature-flag pattern (`pkg/workflow/awf_feature_flags.go`). `agentTimeout` is derived
30+
from the resolved `timeout-minutes` when that value is a **literal**, and the value is raised to the step
31+
timeout so the GitHub step timeout always fires first. When the step timeout is an expression (including the
32+
`vars.GH_AW_DEFAULT_TIMEOUT_MINUTES` default), the config omits `agentTimeout`; instead, under the same version
33+
gate, the agent step exposes the expression as `GH_AW_ENGINE_STEP_TIMEOUT_MINUTES` and the `awf` invocation passes
34+
`--agent-timeout "$GH_AW_ENGINE_STEP_TIMEOUT_MINUTES"`, so AWF receives the step timeout resolved at run time. Threat detection gets its own detection-job timeout.
35+
36+
For (b), `firewall.token_usage` (and its `usage.report` alias) retains `steering`, projected to
37+
`{type, threshold}` with unknown or malformed values dropped; `ai_credit_steering` event-log lines map to a
38+
dedicated `firewall.steering` event instead of the generic `firewall.event`. The unified-session spec is bumped
39+
to 1.8.0 with requirement **T-UAS-071** (a missing field means "no notice recorded", not "no notice delivered"),
40+
and a new `steering_notices` list is surfaced in `gh aw audit` and `gh aw logs --json`, with the audit and logs
41+
JSON schemas updated to match.
42+
43+
Two further rules keep `steering_notices` meaningful:
44+
45+
- **Exact thresholds.** The Go decoder accepts only thresholds that are exactly 80, 90, 95 or 99, matching the
46+
unified-session schema and the JS projector. Other values (for example `79.6`, `94.6`, `100` or the string
47+
`"90"`) are rejected rather than rounded, so malformed telemetry is never reported as a budget warning. The same
48+
rule applies to `threshold` on gateway steering events.
49+
- **Visible read failures.** If `aw_session.jsonl` cannot be read or parsed (for example, a truncated record), the
50+
CLI does not report "no notices". It sets `steering_notices_unavailable: true` in audit and logs output and adds
51+
a token usage warning, so the only outcomes are notices present, no notices, or an explicit unavailable state.
52+
53+
The `awfVersionAtLeast` semantics are shared by every AWF feature flag and are unchanged: `version: latest` is
54+
treated as satisfying the gate, and `latest` resolves to v0.28.51 or newer once that release is published.
55+
56+
The compiled behaviour is documented in `docs/src/content/docs/reference/sandbox.md`: a literal `timeout-minutes`
57+
produces `container.agentTimeout`; an expression (including the default) produces the runtime `--agent-timeout`
58+
from the step timeout; AWF older than v0.28.51 gets neither (outside Cloud Hypervisor and NVX). Notices arrive on
59+
the next eligible model request after a threshold, and the GitHub step timeout fires before AWF's deadline.
60+
61+
### Alternatives Considered
62+
63+
#### Alternative 1: Always send `agentTimeout`, including for expression-valued timeouts
64+
65+
We could approximate non-literal `timeout-minutes` at compile time (for example, defaulting to 20 minutes as
66+
the issue suggested). This was rejected because the compiler cannot know the value of
67+
`${{ vars.GH_AW_DEFAULT_TIMEOUT_MINUTES }}` at compile time; guessing would either under-shoot the real step
68+
timeout — letting AWF kill the agent before GitHub does, truncating final output — or over-shoot and make the
69+
warnings meaningless. Passing the runtime-resolved step timeout through `--agent-timeout` avoids the guess.
70+
71+
#### Alternative 2: Read steering records directly from `token-usage.jsonl` in the CLI
72+
73+
The audit/logs commands could parse AWF's raw `token-usage.jsonl` artifact when rendering, leaving the unified
74+
session untouched. This was rejected because it reintroduces a second, undocumented ingestion path for firewall
75+
data, bypasses the unified-session schema and its versioned contract, and would not be available to any other
76+
consumer of the session artifact. Keeping the projection in `unified_session_payload.cjs` means one producer,
77+
one schema, and one validated shape.
78+
79+
#### Alternative 3: Keep `agentTimeout` restricted to Cloud Hypervisor/NVX and document the gap
80+
81+
The smallest change is to leave the Docker runtime without runtime steering and simply document that timeout
82+
warnings require a microVM runtime. This was rejected because Docker is the default runtime, so the feature
83+
would remain effectively unreachable for most users, and the issue explicitly asks for parity.
84+
85+
### Consequences
86+
87+
#### Positive
88+
- Runtime budget warnings now reach agents on the default Docker runtime, so agents can wrap up work before the
89+
deadline instead of being cut off.
90+
- Delivered steering notices become visible and queryable in `gh aw audit` and `gh aw logs --json`, closing the
91+
observability gap between "AWF warned the agent" and "we can prove it".
92+
- The version gate and the unchanged default AWF version (v0.28.50) mean recompiling this repository's workflows
93+
produces no lock-file changes, so the rollout is inert until AWF v0.28.51 ships.
94+
- The steering shape is schema-described (`unified-session.schema.json`, `audit.schema.json`, `logs.schema.json`)
95+
and spec-versioned (1.8.0, T-UAS-071), so downstream consumers have a stable contract.
96+
97+
#### Negative
98+
- Expression timeouts reach AWF through a CLI flag rather than the config file, so the deadline lives in two
99+
places depending on whether `timeout-minutes` is a literal.
100+
- The behaviour depends on an unreleased AWF version, so the code path is untestable end-to-end until v0.28.51
101+
is published, and the min-version constant may need correcting if the release slips or renumbers.
102+
- Another AWF feature flag plus a spec version bump adds to the growing version-compatibility matrix that every
103+
future AWF integration must reason about.
104+
- Notices attached to requests that never produced a usage record (for example a failed upstream call) are not
105+
reported, so `steering_notices` under-counts in failure scenarios.
106+
- Workflows pinned to `version: latest` take the new path before v0.28.51 is published, because the shared
107+
`latest` handling is not special-cased for this flag.
108+
- A runtime notice whose next eligible model request would come after the GitHub step timeout is never delivered.
109+
110+
#### Neutral
111+
- `pkg/cli/token_usage_subagent_session.go` was refactored to split helpers out of the session reader; this is
112+
structural only and does not change the reading semantics.
113+
- A new synthetic golden case (`claude-awf-steering-notices`) was added; existing `expected*.json` files are
114+
unchanged and `logs_expected.json` changes only because it enumerates the new case.
115+
- Runs without a `steering` field serialize exactly as before, so existing artifacts and dashboards are
116+
unaffected until AWF starts emitting the field.

0 commit comments

Comments
 (0)