Skip to content
Prev Previous commit
fix: render scoped subagent session accounting
Preserve retry source scopes in structured attribution and publish subagent hierarchy, model configuration, requests, tokens, and credits in session step summaries.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
  • Loading branch information
pelikhan and Copilot committed Oct 8, 2026
commit d1c961794a3b23c4c4be71a32fd343895e8a6e09
2 changes: 2 additions & 0 deletions .changeset/patch-unified-subagent-sessions.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion actions/setup/js/log_parser_format.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { buildStepSummaryDetailsSection } = require("./log_parser_step_summary_bu
const { normalizeSessionUsage, sessionTokenTotal, sessionOutputText, observedSessionModel, projectSessionResult } = require("./agent_session.cjs");
const { collapseStreamedMessages, escapeSummaryText, renderInitializationLines, toolOutcome, boundSummaryLines } = require("./agent_session_render.cjs");
const { isUnifiedSessionTrace, publicationAgentSessions, renderUnifiedSession } = require("./unified_session_render.cjs");
const { renderSubagentSummary } = require("./subagent_session_render.cjs");

/**
* Minimal dependency contract injected from log_parser_shared.cjs.
Expand Down Expand Up @@ -705,7 +706,10 @@ function createLogParserFormatters(deps) {
lines.push("");
}

if (includeStatistics) appendStatistics(lines, renderEntries, toolUsePairs, state.standard);
if (includeStatistics) {
appendStatistics(lines, renderEntries, toolUsePairs, state.standard);
lines.push(...renderSubagentSummary(logEntries));
}

return lines;
}
Expand Down Expand Up @@ -788,6 +792,7 @@ function createLogParserFormatters(deps) {
const model = observedSessionModel(entries);
const lines = [...(model ? [`Model: ${model}`] : []), ...renderInitializationLines(projected.find(entry => entry.type === "system" && entry.subtype === "init"))];
appendStatistics(lines, projected, collectToolUsePairs(projected), true);
lines.push(...renderSubagentSummary(entries));
return lines;
},
agentConversation: entries => generateSummaryLines(entries, false),
Expand Down
84 changes: 84 additions & 0 deletions actions/setup/js/subagent_session_render.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// @ts-check

const { isMetric, isTokenCount, sessionOutputText } = require("./agent_session.cjs");
const { collectArtifactSecretValues, redactManifestValue } = require("./safe_output_manifest.cjs");
const { ERR_VALIDATION } = require("./error_codes.cjs");

/** @param {any} value @returns {string} */
const text = value => sessionOutputText(value).replace(/[\u0000-\u001f\u007f]/g, " ");

/** @param {any} value @returns {boolean} */
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);

/**
* Accounting snapshots replace earlier observations; lifecycle metadata is
* correlated by instance ID, never by potentially repeated display names.
* @param {Array<any>} events
* @returns {string[]}
*/
function renderSubagentSummary(events) {
const redacted = redactManifestValue(events, collectArtifactSecretValues());
if (!Array.isArray(redacted)) throw new Error(`${ERR_VALIDATION}: Expected subagent session events`);
const agents = new Map();
let metrics;
let sessionId;
for (const event of redacted) {
const data = event.data ?? {};
if (["session.start", "session.init"].includes(event.type) && !event.agentId) {
if ((data.sessionId !== undefined && data.sessionId !== sessionId) || (event.type === "session.init" && data.sessionId === undefined && data.sourceEngine === "copilot")) {
agents.clear();
metrics = undefined;
sessionId = data.sessionId;
}
}
if (["session.result", "session.shutdown"].includes(event.type) && object(data.agentMetrics)) metrics = data.agentMetrics;
if (!event.type?.startsWith("subagent.")) continue;
const id = event.agentId ?? data.toolCallId;
if (id === undefined) continue;
const previous = agents.get(id) ?? {};
const fields = ["agentName", "agentDisplayName", "parentId", "model", "reasoningEffort", "executionMode"];
for (const field of fields) if (data[field] !== undefined) previous[field] = data[field];
agents.set(id, previous);
}
if (object(metrics)) {
for (const [id, metric] of Object.entries(metrics)) {
if (id === "main" || !object(metric)) continue;
agents.set(id, { agentName: metric.agentName, agentDisplayName: metric.agentDisplayName, ...agents.get(id) });
}
}
if (!agents.size) return [];
const lines = ["Subagents (per-agent snapshots, not additional session usage):"];
const totalCredits = object(metrics) && Object.values(metrics).every(metric => isMetric(metric?.totalNanoAiu)) ? Object.values(metrics).reduce((sum, metric) => sum + metric.totalNanoAiu, 0) / 1e9 : undefined;
if (object(metrics?.main)) lines.push(...agentUsageLines("main", metrics.main, totalCredits));
for (const [id, agent] of agents) {
const metadata = ["agentName", "parentId", "model", "reasoningEffort", "executionMode"].filter(field => agent[field] !== undefined).map(field => `${field}=${text(agent[field])}`);
lines.push(` ${text(agent.agentDisplayName ?? agent.agentName ?? id)} (agentId=${text(id)}) ${metadata.join(" ")}`.trimEnd());
const metric = metrics?.[id];
lines.push(...(object(metric) ? agentUsageLines(id, metric, totalCredits) : [" Usage: unavailable"]));
}
return lines;
}

/** @param {string} id @param {any} metric @param {number | undefined} totalCredits @returns {string[]} */
function agentUsageLines(id, metric, totalCredits) {
const lines = [];
if (isMetric(metric.totalNanoAiu)) {
const credits = metric.totalNanoAiu / 1e9;
const share = totalCredits !== undefined && isMetric(totalCredits) && totalCredits > 0 ? ` (${((credits / totalCredits) * 100).toFixed(1)}% of agent credits)` : "";
lines.push(` ${text(id)} credits: ${credits.toFixed(3)}${share}`);
}
if (object(metric.modelMetrics)) {
for (const [model, usage] of Object.entries(metric.modelMetrics)) {
if (!object(usage)) continue;
const fields = [];
if (isTokenCount(usage.requests?.count)) fields.push(`requests=${usage.requests.count}`);
for (const key of ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"]) {
if (isTokenCount(usage.usage?.[key])) fields.push(`${key}=${usage.usage[key]}`);
}
if (fields.length) lines.push(` ${text(model)}: ${fields.join(" ")}`);
}
}
return lines.length ? lines : [" Usage: unavailable"];
}

module.exports = { renderSubagentSummary };
135 changes: 135 additions & 0 deletions actions/setup/js/subagent_session_render.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { renderSubagentSummary } from "./subagent_session_render.cjs";
import { generatePlainTextSummary, generateCopilotCliStyleSummary, MAX_STEP_SUMMARY_SIZE } from "./log_parser_shared.cjs";
import { publishUnifiedSessionSummary } from "./unified_session_render.cjs";
import { writeUnifiedSession } from "./unified_session.cjs";

const modelMetrics = {
opus: { requests: { count: 3 }, usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 20, cacheWriteTokens: 0 } },
};
const metrics = {
main: { totalNanoAiu: 1000000000, modelMetrics },
child: { agentName: "research", agentDisplayName: "Research <routing>", totalNanoAiu: 3000000000, modelMetrics },
};
const lifecycle = [
{ type: "session.start", data: { sessionId: "root", selectedModel: "opus" } },
{ type: "subagent.started", agentId: "child", data: { agentName: "research", agentDisplayName: "Research <routing>", model: "opus", executionMode: "background", agentDescription: "PRIVATE_PROMPT" } },
{ type: "subagent.configured", agentId: "child", data: { model: "opus", reasoningEffort: "xhigh" } },
{ type: "subagent.started", agentId: "nested", parentId: "event-chain-parent", data: { agentName: "explore", parentId: "child", model: "haiku", executionMode: "sync" } },
];

describe("subagent session summaries", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

it("renders exact snapshots once with identity, hierarchy, tokens and credits in both views", () => {
const events = [...lifecycle, { type: "session.shutdown", data: { agentMetrics: metrics } }, { type: "session.result", data: { agentMetrics: metrics } }];
const original = structuredClone(events);
for (const render of [generatePlainTextSummary, generateCopilotCliStyleSummary]) {
const output = render(events);
expect(output).toContain("Subagents (per-agent snapshots, not additional session usage)");
expect(output).toContain("main credits: 1.000 (25.0% of agent credits)");
expect(output).toContain("child credits: 3.000 (75.0% of agent credits)");
expect(output).toContain("agentId=child");
expect(output).toContain("agentName=research model=opus reasoningEffort=xhigh executionMode=background");
expect(output).toContain("parentId=child model=haiku executionMode=sync");
expect(output).toContain("requests=3 inputTokens=100 outputTokens=10 cacheReadTokens=20 cacheWriteTokens=0");
expect(output.match(/child credits:/g)).toHaveLength(1);
expect(output).toContain("Usage: unavailable");
expect(output).not.toContain("PRIVATE_PROMPT");
}
expect(generateCopilotCliStyleSummary(events)).toContain("Research <routing>");
expect(events).toEqual(original);
});

it("keeps repeated instances distinct, accepts zero usage and does not invent absent fields", () => {
const events = [
...lifecycle,
{ type: "subagent.started", agentId: "second", data: { agentName: "research", agentDisplayName: "Research <routing>" } },
{ type: "session.result", data: { agentMetrics: { child: { totalNanoAiu: 0, modelMetrics: { opus: { requests: { count: 0 }, usage: { inputTokens: 0 } } } }, second: { modelMetrics: {} } } } },
];
const output = renderSubagentSummary(events).join("\n");
expect(output).toContain("child credits: 0.000");
expect(output).toContain("requests=0 inputTokens=0");
expect(output).toContain("agentId=second");
expect(output).not.toContain("outputTokens=");
expect(output).not.toContain("% of agent credits");
expect(output).toContain("Usage: unavailable");
});

it("uses only the final attempt and final snapshot, even when a projection repeats initialization", () => {
const events = [...lifecycle, { type: "session.result", data: { agentMetrics: metrics } }, { type: "session.start", data: { sessionId: "last" } }, { type: "session.init", data: { sessionId: "last", sourceEngine: "copilot" } }];
expect(renderSubagentSummary(events)).toEqual([]);
events.push({ type: "subagent.started", agentId: "final", data: { agentName: "explore" } });
events.push({ type: "session.result", data: { agentMetrics: { final: { totalNanoAiu: 0 } } } });
events.push({ type: "session.init", data: { sessionId: "last", sourceEngine: "copilot" } });
const output = renderSubagentSummary(events).join("\n");
expect(output).toContain("agentId=final");
expect(output).not.toContain("agentId=child");
expect(output).toContain("final credits: 0.000");
});

it("redacts secrets and strips control characters from identity fields", () => {
vi.stubEnv("GH_AW_SECRET_NAMES", "TOKEN");
vi.stubEnv("SECRET_TOKEN", "secret-subagent-token");
vi.stubGlobal("core", { debug: vi.fn(), info: vi.fn(), warning: vi.fn() });
const events = [{ type: "subagent.started", agentId: "child", data: { agentDisplayName: "secret-subagent-token\ninjected", model: "opus" } }];
const output = renderSubagentSummary(events).join("\n");
expect(output).not.toContain("secret-subagent-token");
expect(output).not.toContain("\ninjected");
expect(events[0].data.agentDisplayName).toContain("secret-subagent-token");
});

it("replaces earlier accounting rather than retaining missing agents in the latest snapshot", () => {
const output = renderSubagentSummary([...lifecycle, { type: "session.shutdown", data: { agentMetrics: metrics } }, { type: "session.result", data: { agentMetrics: { main: metrics.main } } }]).join("\n");
expect(output).toContain("agentId=child");
expect(output).not.toContain("child credits:");
expect(output).toContain("Usage: unavailable");
});

it("bounds published summary output including oversized subagent names", () => {
const events = [
{ type: "session.format", data: { version: 1 }, provenance: { component: "collector", phase: "conclusion", path: "metadata", index: 0 } },
{ type: "subagent.started", agentId: "large", data: { agentDisplayName: "x".repeat(MAX_STEP_SUMMARY_SIZE * 2) }, provenance: { component: "agent", phase: "agent", path: "events.jsonl", index: 0 } },
];
const output = generateCopilotCliStyleSummary(events, { maxBytes: 8192 });
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(8192);
expect(output).toContain("Unified session summary omitted: remaining step-summary byte limit reached.");
const bounded = generateCopilotCliStyleSummary(events, { maxBytes: 512 * 1024 });
expect(Buffer.byteLength(bounded)).toBeLessThanOrEqual(512 * 1024);
expect(bounded).toContain("Subagents");
expect(bounded).toContain("truncated");
});

it("persists and publishes subagent accounting into the actual GitHub step summary", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "gh-aw-subagent-summary-"));
try {
const native = path.join(root, "sandbox/agent/logs/copilot-session-state/root/events.jsonl");
fs.mkdirSync(path.dirname(native), { recursive: true });
fs.writeFileSync(native, [...lifecycle, { type: "assistant.message", data: { content: "Done" } }, { type: "session.shutdown", data: { agentMetrics: metrics } }].map(JSON.stringify).join("\n") + "\n");
writeUnifiedSession({ rootDir: root, engine: "copilot" });
const summaryPath = path.join(root, "summary.md");
fs.writeFileSync(summaryPath, "Existing summary\n");
vi.stubEnv("GITHUB_STEP_SUMMARY", summaryPath);
const core = { info: vi.fn(), warning: vi.fn() };
vi.stubGlobal("core", core);
await publishUnifiedSessionSummary(path.join(root, "usage/aw_session.jsonl"));
const output = fs.readFileSync(summaryPath, "utf8");
expect(output).toContain("Existing summary");
expect(output).toContain("Research &lt;routing&gt;");
expect(output).toContain("child credits: 3.000 (75.0% of agent credits)");
expect(output).toContain("reasoningEffort=xhigh executionMode=background");
expect(output).toContain("cacheWriteTokens=0");
expect(output).not.toContain("PRIVATE_PROMPT");
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(MAX_STEP_SUMMARY_SIZE);
expect(core.info.mock.calls.flat().join("\n")).toContain("child credits: 3.000");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
2 changes: 1 addition & 1 deletion actions/setup/js/unified_session_render.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function fields(value, keys) {
function eventDetail(event) {
const data = normalizeUnifiedSessionEvent({ ...event, data: event.data ?? {} }).data;
if (COPILOT_WORKFLOW_EVENT_TYPES.has(event.type)) {
return fields({ ...data, agentId: event.agentId, ...(event.parentId !== undefined ? { parentId: event.parentId } : {}) }, ["agentId", ...Object.keys(COPILOT_WORKFLOW_EVENT_FIELDS[event.type])]);
return fields({ ...data, agentId: event.agentId }, ["agentId", ...Object.keys(COPILOT_WORKFLOW_EVENT_FIELDS[event.type])]);
}
if (Object.values(DYNAMIC_WORKFLOW_EVENT_TYPES).includes(event.type)) {
if (event.type === DYNAMIC_WORKFLOW_EVENT_TYPES.background_tasks_changed) {
Expand Down
10 changes: 8 additions & 2 deletions actions/setup/js/unified_session_render.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,16 @@ describe("unified session publication views", () => {
}
});

it("renders top-level parent correlation for Copilot subagent events in both views", () => {
const events = [header, { ...event("subagent.started", { agentName: "child" }, "agent", 1, 1), agentId: "child-id", parentId: "parent-id" }];
it("renders parent-agent hierarchy without confusing it with event-chain parent correlation", () => {
const events = [
header,
{ ...event("subagent.started", { agentName: "child", parentId: "parent-id" }, "agent", 1, 1), agentId: "child-id", parentId: "previous-event-id" },
{ ...event("subagent.started", { agentName: "root" }, "agent", 2, 2), agentId: "root-id", parentId: "another-event-id" },
];
for (const output of [generatePlainTextSummary(events), generateCopilotCliStyleSummary(events)]) {
expect(output).toContain("subagent.started agentId=child-id agentName=child parentId=parent-id");
expect(output).not.toContain("parentId=previous-event-id");
expect(output).not.toContain("parentId=another-event-id");
}
});

Expand Down
1 change: 1 addition & 0 deletions actions/setup/session_parsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import "embed"
//go:embed js/provider_refusal.cjs
//go:embed js/codex_session.cjs js/codex_log_framing.cjs js/copilot_session.cjs
//go:embed js/copilot_workflow_events.cjs js/dynamic_workflow_session.cjs
//go:embed js/subagent_session_render.cjs
//go:embed js/gemini_session.cjs
//go:embed js/pi_session.cjs js/pi_session_redaction.cjs js/session_artifact.cjs
//go:embed js/log_parser_bootstrap.cjs js/log_parser_format.cjs js/log_parser_shared.cjs
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# ADR-66746: Prefer Structured Unified Sessions over Stdio Heuristics for Subagent Model Attribution

**Date**: 2026-11-24
**Status**: Draft
**Deciders**: pelikhan [TODO: verify full decider list]
**Date**: 2026-10-08
**Status**: Proposed
**Deciders**: pelikhan

---

Expand Down Expand Up @@ -50,4 +50,4 @@ Have the workflow runtime write an aggregated subagent attribution file that the

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
*Proposed for maintainer review; acceptance is not implied by implementation.*
13 changes: 13 additions & 0 deletions docs/src/content/docs/specs/unified-agent-session-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,19 @@ heuristic warning; only CLI dispatch-marker lines are considered, supporting
both `Name(model)` and `Name (model: model)` layouts. Invalid structured evidence
produces a warning before falling back.

Subagent attribution is scoped to the final retry session; earlier source
records cannot contribute even when merged timestamps interleave them.
Malformed preferred artifacts produce diagnostics while the reader continues
to the next structured source before attempting legacy inference.

Step summaries display subagent instance IDs, definition and display names,
parent-agent identity, model configuration, per-model requests and token counts,
and per-agent credits. Credit percentages use only the complete per-agent
snapshot, not proxy or router totals. Snapshots replace earlier observations;
native and projected copies are not added together. Missing accounting remains
unavailable, distinct from observed zero usage. Publication applies the existing
redaction, escaping, and byte limits.

### 7.3 Codex

**T-UAS-038 — Codex adapter.** The Codex adapter MUST support recognized `thread.*`, `turn.*`, and `item.*` JSONL records and the existing recognized legacy text layouts. It MUST preserve native item/tool IDs, independent starts/completions, text/reasoning, explicit tool failures, and session errors. Distinct `turn.completed.usage` reports MUST accumulate as specified in Section 6.
Expand Down
Loading
Loading