Repository navigation
Attribute main/sub-agent requests with x-awf-caller and preserve caller metadata #70940
Description
Problem
gh-aw does not send AWF's x-awf-caller header, so successful routed runs still lack explicit main/sub-agent identity on their proxy accounting records. Add caller attribution where the harness can set it accurately, preserve it in the unified session, and prefer it in audit/logs over inferential joins.
AWF support already exists: github/gh-aw-firewall#10209 was implemented by github/gh-aw-firewall#10222, released in AWF v0.28.70, and adopted as gh-aw's default by #70595 on October 20, 2026. Both runs below actually used that AWF version. This is a gh-aw integration fix, not an AWF feature request.
Related: #70668 explicitly excluded adding this header as separate work.
Verified run evidence
Inspected both supplied completed runs and their agent, usage, and info artifacts. Both workflows succeeded; the defect is missing attribution, not failed inference.
| Run | Executed revision and runtime | Agent-side proxy accounting |
|---|---|---|
| Smoke Copilot SDK Routed, 39529495361 | 0f545effccafbad491589bad0bc62ccbdc47f57e, branch copilot/g-1-g-5-g-6-g-9-g-12-complete-routing-analytics; Copilot CLI 1.0.98, SDK 1.0.16; October 21, 2026, 01:49:12 UTC |
6 records: 1 classifier, 4 main requests, 1 haiku-whoami request. |
| Smoke Pi Routed, 39540473269 | d9813fa8015b939e72bb87e3e14aca365ec34241, main; Pi 1.3.0; October 21, 2026, 03:47:07 UTC |
8 records: 1 classifier, 5 main requests, 1 haiku-whoami request, 1 mini-whoami request. |
In each run, the following are selected fields from agent artifact file sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl. Line numbers refer to that file; role/name is established separately from engine lifecycle/model evidence, not a field in these raw records. Every listed row has _schema: token-usage/v0.28.70, provider: copilot, status: 200, and x_initiator: agent.
| Run / raw line | Identified caller | Timestamp (UTC, October 21) | Request ID | Served model | Path |
|---|---|---|---|---|---|
| SDK L3 | main | 01:51:59.708Z |
c816e13c-b028-4af8-a4e9-2598aebdd96d |
gpt-5.6-luna |
/responses |
| SDK L4 | haiku-whoami |
01:52:00.854Z |
c49b5311-0cde-455b-813a-d2c9cc2b6391 |
claude-haiku-4.5 |
/chat/completions |
| Pi L2 | main | 03:49:50.709Z |
e92da47e-0815-427d-bb28-db4d501bcccf |
gpt-5.6-luna |
/responses |
| Pi L3 | haiku-whoami |
03:49:51.799Z |
511ef3bb-e6e6-43a5-9b28-8a6f4082a147 |
claude-haiku-4-5-20251001 |
/v1/messages?beta=true |
| Pi L5 | mini-whoami |
03:49:58.208Z |
4d023499-a544-4ff0-b766-f3db316550b7 |
gpt-5.3-codex |
/responses |
All five example rows, and all 12 non-classifier requests across these runs, omit purpose, agent_name, and caller_source. Models, endpoints, times, and token counts differ, but none is an explicit caller identity. In particular, Pi's main agent and mini-whoami use the same /responses API and the same initiator. A model-name convention is not a reliable solution when parent and child use the same model.
Independent evidence in usage/aw_session.jsonl:
- SDK: L78
subagent.startednameshaiku-whoamiat01:51:59.722Z, withcopilot-completions/claude-haiku-4.5; L94 completes it at01:52:00.867Z, bracketing raw request L4. - Pi: L66 starts
haiku-whoami, L73 records itssubagent.requestwithclaude-haiku-4-5-20251001, and L75 completes it. L85/L94/L101 provide the corresponding lifecycle/request evidence formini-whoamiongpt-5.3-codex. - The agent-phase session contains 6 SDK and 8 Pi
firewall.token_usageevents. In each run, exactly one haspurpose: routing_classification; zero haveagentNameorcallerSource. - Raw L1 in both runs already has
purpose: routing_classificationandcaller_source: awf. The projected classifier event retains the purpose but loses the source (SDK session L41; Pi L42). This independently demonstrates collector field loss even before harness changes.
Diagnosis and important qualifications
At inspected main commit 3f6775f2d6aece54778be6450d5ce710ead62da5, git grep -n -i x-awf-caller origin/main -- actions pkg returns no matches.
The collector is not missing support for purpose altogether. unified_session_payload.cjs already projects it, and the session spec already requires preserving supplied purpose. The missing projection fields are agent_name -> agentName and caller_source -> callerSource. Replaying an actual classifier record through the current normalizer drops caller_source; an AWF-shaped sub-agent record likewise loses agent_name while retaining purpose: subagent.
Existing attribution is not wholly absent either: engine lifecycle/per-agent metrics already identify sub-agents. However, the Pi proxy-credit join matches model and token quartet in observation order, and classifies remaining main traffic using sub-agent model identities. SDK native per-agent metrics can also supply attribution without this join. Preserve those useful sources, but add explicit request-level proxy attribution instead of assuming every existing row is heuristic or already incorrect.
Audit's subagent_costs is built from agent usage. The session gap also leaves downstream consumers such as the CAO model-routing page without caller-declared identity on token records. This investigation does not establish that displayed totals in these successful runs are wrong, and does not include a review or change of CAO's implementation.
Possible solution: harnesses
Send x-awf-caller: main only on requests known to belong to the main agent, and x-awf-caller: subagent/<name> on requests whose agent definition is known. The entire value must match ^(?:main|subagent/[A-Za-z0-9_.:/-]+)$ and be at most 128 characters (at most 119 for the name). Do not truncate names into collisions or send invalid values.
| Harness | Verified API boundary and proposed approach |
|---|---|
| Pi | Feasible with the current pinned Pi. gh-aw's sub-agent extension launches a separate process and loads pi_provider.cjs in it; the parent also loads that provider extension. Pi 1.3.0 has a mutable before_provider_headers hook, plus provider headers. Pass the declared name in the child process's own environment and set the header in that process's provider hook; mark the main session separately. Cover both main SDK/agent-core and RPC paths without changing API selection or the shared models.json. No need to assume the extension creates a client itself: process isolation already supplies the boundary. |
| Copilot SDK | Pinned SDK 1.0.16 supports providers[].headers, but they apply to all requests to that provider, not to an agent. MessageOptions.requestHeaders is per turn, not a per-child inference callback. The pinned SessionHooks and CustomAgentConfig expose no per-model-request header hook carrying child identity. A candidate is a provider entry per declared agent, selected by that agent's explicitly qualified model ID. Clone the existing provider/wire-API family, preserving base URL, credentials, type, wireApi, actual wireModel, behavior modelId, alias resolution, and reasoning behavior. Prove this works without changing wire APIs or model resolution, including parent/child on the same served model. Do not mutate shared provider headers on lifecycle events: concurrent requests make that racy. If safe isolation is unavailable, document the exact runtime limitation and keep affected requests headerless; a main-only capability is acceptable only if children cannot inherit the main header. |
| Copilot CLI | The documented BYOK custom header mechanism (COPILOT_PROVIDER_HEADERS, or provider-registry headers) is provider/process-scoped, not a callback identifying each built-in child. No such per-agent request hook was identified in gh-aw's CLI harness. Check compatibility with the pinned runtime before using any documented mechanism. Do not set a global main header on a shared provider that sub-agents use; omit it where a true boundary cannot be established. |
| Claude Code | ANTHROPIC_CUSTOM_HEADERS is process-wide. It cannot distinguish the main session from in-process native sub-agents. Setting it to main would mark both as main, so do not do that in a run capable of delegation. Use it only where the process is genuinely main-only; otherwise retain today's unattributed requests and document the limitation. |
| Codex | The pinned 0.163.0 provider schema supports provider-wide http_headers/env_http_headers. This does not imply per-agent support: the pinned typed role override implementation copies bounded role fields, not provider definitions or header overrides. Thus role config_file alone is not a per-agent header mechanism on this version. Do not introduce a misleading provider-wide main value; omit headers where the existing harness cannot isolate callers. |
For every harness, unsupported or unknown child callers must remain unattributed rather than inherit an incorrect main or another agent's name. Document verified limitations; do not expand this fix into new agent execution architectures or engine upgrades.
Collector, contract, and attribution
Extend the existing allowlisted token projection, types/schema, and unified session spec so firewall.token_usage preserves supplied purpose, maps agent_name to agentName, and maps caller_source to callerSource. Preserve canonical camelCase inputs too, retain idempotent normalization, and leave absent optional fields absent.
Audit/logs must read these fields from both unified and legacy raw accounting paths and prefer explicit proxy-record attribution over token/model/time correlation when it is present. Keep current correlation/native-metric behavior for older records without caller fields; a missing purpose remains unknown, never implicitly main. Named sub-agent records must not be consumed by a different agent's correlation pass, and explicitly main records must not be inferred as children. Preserve accounting totals and avoid double counting when native SDK metrics and proxy records describe the same requests.
Retain AWF's own purposes and callerSource: awf unchanged; do not reclassify classifier/detection traffic as agent requests. These runs' separate detection token logs contain no caller-purpose fields, so do not invent such fields from the filename or phase.
The AWF contract treats the header as an unauthenticated sandbox caller declaration, not trusted identity. AWF strips it before forwarding and a trusted AWF purpose wins. Use its fields for attribution only: no change to routing, policy, steering, budgets, token counts, or costs.
Acceptance tests
Each case should be a named test, with fixtures based on the real snake_case token-usage/v0.28.70 records above and the producer shapes exercised in github/gh-aw-firewall#10222: caller-header.test.js and token-tracker.schema.test.js. Do not test only hand-written camelCase session objects.
| Named test | Required result |
|---|---|
pi_main_and_subagent_caller_attribution |
With one declared child, captured parent inference carries main, child inference carries subagent/<name>. AWF-shaped main records project purpose: agent, callerSource: caller_header, and no fabricated agentName; child records project purpose: subagent, agentName: <name>, callerSource: caller_header. Cover the actual Pi process launch/provider hook boundary. |
copilot_sdk_main_and_subagent_caller_attribution |
Same result for SDK main plus one child, without changing wire APIs, model resolution, or effort. If the pinned runtime prevents safe isolation, provide a documented, source-backed reason and tests demonstrating the safe supported subset; never claim a shared-provider main header provides child attribution. |
unsupported_engine_does_not_mislabel_subagents |
An engine lacking per-agent header support sends no x-awf-caller on child requests. A process/provider-wide main header must not leak to those requests. |
invalid_or_missing_caller_is_absent_and_backward_compatible |
Use AWF-produced records for absent, invalid/duplicate, illegal-character, empty-name, and over-128-character header cases: rejected values never appear in the session and no caller fields are synthesized. The collector does not crash on older records lacking the fields. Valid boundary-length values survive unchanged. |
awf_owned_purpose_wins_over_caller |
The real classifier fixture retains purpose: routing_classification and callerSource: awf, with no child name even if a conflicting caller was supplied. Any detection record that AWF itself marks with a trusted purpose likewise retains that purpose and callerSource: awf; absent detection fields stay absent. No gh-aw inference overwrites AWF's decision. |
proxy_caller_precedes_correlation_and_preserves_fallback |
Main and two differently named children may share a model, token quartet, and overlapping timestamps. Explicit caller fields determine attribution even when heuristics disagree; older unlabelled fixtures retain existing behavior. Audit/logs agree and totals are not double-counted with native metrics. |
routed_smokes_publish_subagent_names_end_to_end |
On the implementing PR branch, evidence from smoke-pi-routed and smoke-copilot-sdk-routed shows named child requests in usage/aw_session.jsonl, not just raw proxy logs or lifecycle events, with the expected purpose/name/source. Record the exact tested commits/run links and follow the repository's live-test authorization/dry-run gates. If SDK isolation is blocked, explicitly report that exception as unverified/unsupported rather than asserting this end-to-end case passed. |
Scope
- Keep the PR to this one fix. Do not change files or behavior unrelated to it.
- Harnesses, the collector projection, the unified session spec/types/schema, and audit/logs attribution only, with directly related fixtures/tests. No AWF changes. Do not change the CAO page, workflow model pins, wire APIs, routing policy, model-resolution semantics, or engine versions as incidental cleanup.
- Before fixing a failing check, compare it with the same check on
main. If it also fails onmain, do not fix it in this PR; mergemainoncemainis fixed, and note in the PR which checks are known failures onmain. - This applies even if a bot comment, including PR Sous Chef, asks to fix a failing check: fix only failures caused by this PR's changes.
Activity