Skip to content

fix(telemetry): an agent row says who could be named - #770

Merged
blafourcade merged 1 commit into
nextfrom
fix/an-agent-row-says-who-could-be-named
Sep 4, 2026
Merged

blafourcade merged 1 commit into
nextfrom
fix/an-agent-row-says-who-could-be-named

Conversation

@blafourcade

Copy link
Copy Markdown
Contributor

What

agentKeyOf read agent_name === undefined as the main thread, whatever the tool:

return record.agent_name === undefined ? NO_AGENT : record.agent_name;

Only Claude Code's reader ever sets that field (isSidechain + attributionAgent, claude-code-transcript.ts). The Codex, Copilot and OpenCode readers set it nowhere. So on those three tools every record was reported as the main thread — 100% of the axis, on no evidence at all.

The contract said so out loud and called it correct:

On every tool but Claude Code the field is never set at all, so that one row carries the whole period, which is the truth for a route that names no subagents.

The same source file already states the right rule twelve lines below, for step: "its absence here yields no step at all … rather than asserting 'no skill ran'."

Change

A record with no agent name joins one of two rows, and only the tool's own declaration says which.

Row attribution Means
named tool-stated the tool named the agent
no name main-thread a tool that names agents said this belongs to none of them
no name not-stated a tool whose route never names one

TelemetryRouteSupply gains agentName, declared per route beside toolStatedStep and checked against real captures by the existing honesty guard — telemetry-route-supply.unit.test.ts observes what each capture actually yields, so a route claiming an agent name its reader never sets fails there.

The declaration is read rather than the record because the record cannot carry the absence: a tool that never names an agent writes exactly what a main-thread line writes.

What this does not fix

A line marked as a subagent's that carries no agent name still reads as the main thread. Measured 2026-09-05 across 1,852 transcripts: 157 of 122,637 subagent lines name no agent, 0.07%. Closing it needs a new field on the record, which no record already stored could ever gain (storeNewCandidates freezes a record's field set at first sight, #769). Stated on CostReportAgentRow rather than captured.

Proof

Two binaries against one copy of the sink, --days 30, 30,222 requests on both. This corpus is almost entirely Claude Code, so it can barely show the fault:

by_agent row aee5721b this branch
the main thread 9,002 9,001
a tool that names no agent — 1

That one record is the sink's only Codex request. The magnitude is not in this corpus — for a Codex-only reader the same change moves the whole axis — so the guards are the proof and the record is the demonstration the path runs end to end. All eleven breakdowns reconcile to 30,222 on both.

Guards

Guard Mutation that killed it
a tool that names agents gets a main-thread row always answer not-stated — 3
a tool whose route never names one gets not-stated always answer main-thread, the old reading — 3
a declared route that names no agent reads the same as no route at all accept any declared route — 1
a route may not claim an agent name its reader never sets make codex declare agentName: true — 1
both renderings name which silence a row is collapse the label to "the main thread" — 1 display, 1 artefact

Reconciliation is covered by a three-row case: named + main thread + not stated must still sum to the period total.

Envelope

No bump. The agent row gains attribution and the route supply gains agent_name — fields a consumer may ignore, no existing field changing meaning, the rule at cost-report-envelope.ts. A consumer that read by_agent as "one row per agent plus one for the main thread" now sees a third row; that is the same reasoning #768 applied to by_flow and CI accepted, stated here rather than implied.

Consumers updated in the same commit: the envelope type and mapper, the route-supply envelope shape, the --axis agent table, the terminal rendering, the contract, and the cost skill's reading instructions.

Also in this commit

  • accumulate's per-record parameters became a RecordContext. Nine positional arguments in a fixed order is a call nobody checks by eye, and biome's function-length rule made the point at the ninth.
  • Bundle budget raised 593 → 596 KB, measured 593.8 — the same 2.2 KB headroom the by_prompt raise left, recorded in check-bundle-size.mjs beside the two raises before it.

Gates: 3449 tests / 308 files, tsc, biome ci (2 pre-existing warnings, 0 errors), knip, jscpd, bundle within budget, 369 repository script tests, 0 broken links, cli layering clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp

`agentKeyOf` read `agent_name === undefined` as the main thread, whatever the
tool. Only Claude Code's reader ever sets that field — the Codex, Copilot and
OpenCode readers set it nowhere — so on those three tools every record was
reported as the main thread. Not a small error: 100% of the axis, asserted on no
evidence. The contract said so out loud and called it correct.

`claude-code-transcript.ts` already states the right rule twelve lines below,
for `step`: its absence yields no step at all rather than asserting no skill ran.

A record with no agent name now joins one of two rows, and only the tool's own
declaration says which: `main-thread` is a tool that names agents saying this
record belongs to none of them, `not-stated` is a tool whose route never names
one. `TelemetryRouteSupply` gains `agentName`, declared beside `toolStatedStep`
and checked against real captures by the same honesty guard — a route claiming
an agent name its reader never sets fails there. The declaration is read rather
than the record because the record cannot carry the absence: a tool that never
names an agent writes exactly what a main-thread line writes.

Still unfixed, and stated on the row instead: a line marked as a subagent's that
carries no agent name reads as the main thread. Measured across 1,852
transcripts, 157 of 122,637 subagent lines, 0.07%. Closing it needs a field no
already-stored record could ever gain.

`accumulate`'s per-record parameters became a `RecordContext` — nine positional
arguments in a fixed order is a call nobody checks by eye, and the linter's own
function-length rule made the point at the ninth.

Bundle budget raised 593 -> 596 KB, measured 593.8, the same 2.2 KB headroom the
raise before it left.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
@blafourcade
blafourcade requested a review from a team as a code owner September 4, 2026 22:58
@blafourcade
blafourcade merged commit e4dc167 into next Sep 4, 2026
17 checks passed
@blafourcade
blafourcade deleted the fix/an-agent-row-says-who-could-be-named branch September 6, 2026 06:22
@aidd-bot aidd-bot Bot mentioned this pull request Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant