Skip to content

Claude OTEL: per-event agent.name and query_source stop becoming session facts - #895

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-881
Aug 19, 2026
Merged

Claude OTEL: per-event agent.name and query_source stop becoming session facts#895
philcunliffe merged 3 commits into
masterfrom
fix/issue-881

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Reproduced first

test/plugins/claude-telemetry-subagent-attribution.test.js is new and fails on master in all three cases:

  1. a subagent event in the batch does not relabel the main loop's rows - the main-loop user_prompt and assistant_response rows come back with agent_id: 'general-purpose' and is_sidechain: true.
  2. a body-derived block keeps its identity whether or not a subagent shares the batch - the tool_result block's message_id is 609f8b102be68be3 with the subagent event in the batch and 02ac34a4c318e1fe without it.
  3. query_source follows the event that carries it, whatever the batch order - with the subagent event first, the main-loop assistant row reads query_source: 'agent'.

All three pass with the fix.

Root cause

A Task subagent runs under its parent's session.id. mergeSessionFacts (projection.js) hoisted the per-event agent.name and query_source into session-level facts with ??=, and buildProjection stamped agent_id / is_sidechain: true and attributes.claude.query_source on the exchange, which every row of the batch inherits. Batch composition is an artifact of the exporter's flush timer, so what else happened to share a POST decided a row's label.

The second-order effect is real: a body-derived gap block has no native uuid, so message_projector.js computes its message_id as a content hash scoped by (threadScope, agent_id). The hoisted agent_id moved that scope, the part_id dedupe missed, and the same block was stored twice.

Fix

Both attributes ride the message now:

  • attributeMessageToEvent stamps agent_id / is_sidechain and claude.query_source from the event that produced the message. Body gap blocks get their own body event's attribution.
  • query_source keeps a session-level default for events that carry none (a user_prompt event does not, and the claude_telemetry_capture smoke asserts every row carries one). That default is now taken only from events a subagent did not emit, so a borrowed value is always the main loop's.
  • The exchange no longer sets agent_id, is_sidechain, or attributes.claude.query_source. AiGatewayProjectedMessage already carries all three and the gateway already prefers the per-message value, so nothing downstream changed.
  • ClaudeTelemetrySessionFacts.agentName is gone.

One deviation from the issue's acceptance text

The issue asks that main-loop rows assert is_sidechain: false. They stay unset (null) instead, and the test asserts that. The proxy path (claude/src/projector.js) also stamps is_sidechain only for a positively identified subagent, and LLP 0252 turns on the two producers' rows being indistinguishable; stamping an explicit false on the OTEL path alone would break that parity and would assert "not a sidechain" from the mere absence of an attribute, which is exactly the silent-drift failure LLP 0262 open question 5 is watching for. The invariant the acceptance actually names, batch composition must not change a row's identity or its label, is what the test pins.

Checks

  • npm test: 4486 pass, 0 fail, 1 skipped.
  • npm run typecheck: clean.
  • Smokes claude_telemetry_capture, claude_telemetry_hypignore_drop, claude_telemetry_session_ignore, gateway_claude_capture: all ok.

Fixes#881

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

neutral review round: f7f9edaa - findings (1 fixed, 4 recorded)

Verdict: findings. The core fix is correct and the new test genuinely fails on the parent commit (b1afa7c2): a mixed batch there stamps agent_id='Explore' on the main-loop rows. npm test (4487 pass / 0 fail) and npm run typecheck are green at the reviewed head. Style holds (no semicolons, no U+2014), and the @ref anchors LLP 0262#field-parity-r1, LLP 0252#projection-unchanged, LLP 0252#consequences all resolve.

One finding was actionable and is fixed in a091f802, pushed to this branch. The other four are recorded below for a human call; none of them is a safe mechanical edit inside this PR's scope.

1. FIXED - medium: the session query_source default was still lent to subagent rows

hypaware-core/plugins-workspace/claude/src/telemetry/projection.js:146. The gate added at line 435 (if (!stringAttr(event, 'agent.name')) facts.querySource ??= ...) controls only where the default is read from, not where it is written to. The stamping loop applied it to every message lacking a query_source, including messages the same pass had just labeled a subagent's.

Reproduced against the reviewed head: a batch with main-loop assistant_response{query_source:'user'} plus a subagent assistant_response{agent.name:'Explore'} carrying no query_source of its own yields

m2 agent=Explore sidechain=true query_source="user"

a sidechain row wearing the main loop's attribution, which is the same cross-attribution defect class #881 exists to remove.

Fix (a091f802): skip messages already carrying is_sidechain / agent_id before applying the default. A subagent event that carries its own query_source is unaffected; one that does not now reads null instead of the parent's value. A regression test (test/plugins/claude-telemetry-subagent-attribution.test.js, the session query_source default is never lent to a subagent row) fails on f7f9edaa and passes on a091f802; full suite and typecheck green.

2. NOT FIXED - medium: body-derived gap rows lose subagent attribution

projection.js:115. Gap messages take their attribution solely from the body event (attributeMessageToEvent(gap, event)), so the fix depends on api_request_body / api_response_body carrying agent.name. Nothing in the repo shows that they do: agent.name appears in no fixture, smoke, or acceptance doc, and LLP 0262's spike list of universally-present attributes does not include it. Repro on a subagent-only batch (api_request_body{request_id:'r2'} + assistant_response{request_id:'r2', agent.name:'Explore', query_source:'agent'}): pre-PR the tool_use/tool_result rows read agent_id='Explore', is_sidechain=true; post-PR all read null, while the subagent's own text row is still sidechain, so one subagent turn is split across two attributions.

Deliberately not fixed here. The obvious repair (a request_id -> {agentName} map) would reintroduce exactly the defect this PR removes: a body-derived block's message_id is a content hash scoped by agent_id, so deriving that scope from a sibling event makes the row's identity depend on whether that sibling shared the flush, or arrived first. Attributing from the body event itself is the only batch-composition-stable choice available, and null attribution is the better failure than unstable identity. The real follow-up is empirical: confirm on a real Claude Code whether the body events carry agent.name, and record it in the claude_otel_shape_check procedure (docs/ACCEPTANCE.md). If they do not, the gap-row attribution gap is permanent on this path and should be documented rather than papered over.

3. NOT FIXED - low/medium: facts.model is the same hoist, two lines below the new guard

projection.js:436, facts.model ??= stringAttr(event, 'model'). model is per-request on this stream, and messageFromEvent stamps it only on assistant messages, so user_prompt and body-derived rows fall back to the exchange model. Repro: a subagent assistant_response{model:'claude-haiku-4-5', agent.name:'Explore'} ordered before the main loop's makes the main-loop user_prompt row read claude-haiku-4-5 while the main assistant row reads claude-opus-4-6. Pre-existing, not introduced by this PR, and unhoisting it changes rows other tests pin, so it wants its own issue rather than a ride-along.

4. NOT FIXED - low: main-loop is_sidechain reads null, not false

projection.js:241 never sets false. Issue #881's acceptance text asks for is_sidechain: false on main-loop rows and the new test asserts undefined (claude-telemetry-subagent-attribution.test.js:126,131,164), so the PR does not literally satisfy the issue as written. It is a defensible deviation and the commit message argues it: the Claude proxy producer also stamps only true (claude/src/projector.js:354), and LLP 0028 already records is_sidechain as NULL for about 37% of rows. But gateway_codex_capture.js:291 asserts is_sidechain === false on the Codex path and transcripts.js:692 writes the real boolean, so a consumer filtering where is_sidechain = false silently drops every OTEL main-loop row while keeping the transcript ones. Flagging for a human call: either accept the deviation and note it on the issue, or make both Claude producers stamp the boolean.

5. NOT FIXED - low: content-hash scope moves across the upgrade

projection.js:239. Removing the exchange-level agent_id changes the fallback content-hash scope for body-derived blocks (measured: the same tool_use hashes 11668d8be8cfd962 before, 1a31dee7a01163c2 after). A session live across the upgrade replays its request-body history under the new scope, so previously-written blocks land a second time; the rows also differ in agent_id, so compaction's _hyp_cache_row_id content-hash dedupe will not collapse them, and the live path has no pre-write part_id dedupe (that is backfill-only, ai-gateway/src/dataset.js:578). Bounded and one-time, but worth a line in the release notes.

Note

rowsFor in the new test builds a fresh conversation state per call, so it verifies identity stability across independent runs rather than through the shared live-listener state where the part_id dedupe its comment describes actually happens. The invariant asserted is still the right one.

philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
Review round on #895 found the same batch-composition hazard the PR fixes
for `agent.name` / `query_source` still open on two more paths:
- `mergeSessionFacts` hoisted `model` unguarded, so a subagent answering
first in a flush set the exchange's model, and every row without its
own (prompt rows, body gap blocks) read the sidechain's.
- `mergeBodyFacts` ran for a subagent's `api_request_body`, so its system
prompt and narrowed tool list became the exchange's `system_text` /
`tools`. Worse, `sessionBodyFacts` remembered them, restating the
subagent's prompt on every later batch of that session.
Both are now read only off events a subagent did not emit. Gap messages
from a subagent body are still projected, attributed to the subagent.
Also corrects the comment and type doc that called `facts.querySource`
a session-wide value: `bySession` is rebuilt per POST and there is no
carry-over map for it, so the borrow reaches one batch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review round: a091f802 - findings (3 fixed, 1 left open)

Ran /code-review at high effort over the three changed files, then verified every claim by reproduction in a throwaway worktree.

Verdict on the core change: sound.agent_id / is_sidechain and query_source are correctly moved to per-message, message_projector.js already honours message-level agent_id for the row column, the (threadScope, agent_id) chain key and the fallback content-hash scope; stampQuerySource correctly avoids clobbering the usage / claude blocks an api_request already put on the message. What the review found is that the same batch-composition hazard the PR fixes was still open on two neighbouring paths.

Fixed in 30f60d27

1. medium - projection.js:440 (pre-fix): model was still a hoisted session fact.
facts.model ??= stringAttr(event, 'model') sat one line below the new agent.name guard, unguarded. Reproduced: batch ordered [subagent assistant_response (model=claude-haiku-4-5, agent.name=general-purpose), main user_prompt, main assistant_response (model=claude-opus-4-6)] gave projection.model = 'claude-haiku-4-5', and the main loop's user_prompt row read the subagent's model. Every row without a per-message model (user rows, body-derived gap rows) inherited whichever model appeared first in the flush. Exactly the invariant the PR states, enforced for query_source but not model.
Fix: model joins query_source behind the !stringAttr(event, 'agent.name') guard.

2. medium - projection.js:114 (pre-fix): a subagent's request body became the exchange's system prompt and tools.
mergeBodyFacts ran for a subagent's api_request_body with no guard. Reproduced: subagent request body first in the batch, and every main-loop row read system_text = "SUBAGENT SYSTEM PROMPT" / tools = [{name:'Read'}] instead of the main loop's. Worse than the model case: mergeBodyFacts also writes into opts.sessionBodyFacts keyed by the parent session.id, so the wrong system prompt and tool set were then restated on every subsequent batch of that session, including batches with no subagent in them, for the listener's lifetime.
Fix: the body branch skips mergeBodyFacts when the body event carries agent.name. The subagent's gap messages are still projected, attributed to the subagent.

3. low - projection.js:143 and types.d.ts:44: the new comment and type doc overstated the guarantee.
Both called facts.querySource "the session's main-loop value", but bySession is rebuilt per POST and, unlike systemText / tools, querySource has no carry-over map. Concrete: the exporter flushes user_prompt + api_request_body in one POST and the assistant_response that carries query_source in the next, so the user row reads null while the assistant row of the same turn reads 'user'. Behaviour is unchanged from before the PR; only the doc claimed more than the code does.
Fix: comment and type doc now say the borrow reaches one batch, and say so for model too.

Two regression tests added to test/plugins/claude-telemetry-subagent-attribution.test.js. Both were confirmed to fail on a091f802 and pass after the fix (not ok 5 / not ok 6 pre-fix, ok post-fix), so a green suite is not what is being claimed here.

Left open

4. low - projection.js:245: is_sidechain is written true or left unset, never false.
Real observation, deliberately not changed. The transcript producer does write an explicit false (transcripts.js:692, applied at transcripts.js:520), so a consumer writing where is_sidechain = false gets zero main-loop rows on the OTEL path, and LLP 0028#343 already flags null is_sidechain as an unreliable graph signal. Against that: the proxy producer (claude/src/projector.js:354) also stamps only true, and LLP 0252 turns on the two producers' rows being indistinguishable, so stamping false on the OTEL path alone breaks the parity this PR exists to hold. This is the deviation from the issue's acceptance text that the PR description already calls out.

Stick it: whether the OTEL and proxy paths should both start stamping is_sidechain: false for a positively-identified main-loop row (a two-producer change, matching the transcript path and closing the "true or null" column), or whether the three-valued column stays and hypaware-analyst guidance moves to is_not_distinct_from / is null instead. Either way it is a change to the settled shape in LLP 0252 / 0262 and wants its own request, not a drive-by in this PR.

Checks (in the review worktree, on 30f60d27)

  • npm test: 4489 pass, 0 fail, 1 skipped (4488 files).
  • npm run typecheck: clean.
  • New tests verified failing on the pre-fix head and passing after.
  • Style: no semicolons, no em dashes, @ref LLP 0262#field-parity-r1 resolves (## Field parity (R1)).

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral triage at the review-round cap: head 5309d1dc

Outcome: no residual finding blocks the merge. The two deferred items are tracked in #921.

Both review rounds' fixable findings were fixed on the branch (round 1 findings 1-2 in 4fe9620b, round 2 finding 1's record-honesty half in 5309d1dc, which is docs-only relative to the last fully test-verified commit). What remains open at this head:

  1. Stale carried digest (LLP 0266#open-stale-carried-digest, src/core/runtime/client_assets.js:423, :595) - non-blocking. Pre-existing on master, conservative failure (an asset is withheld from deletion with a misleading message, never wrongly deleted), and the fix requires a ledger-design decision of its own.
  2. Kept dest is not refreshed (LLP 0266#open-kept-not-refreshed, src/core/runtime/client_assets.js:385-388) - non-blocking. Strictly better than master, where the same scoped run deleted the copy outright; a manual refresh path exists (hyp skills install, hyp attach claude-desktop).

Neither could cause a production defect this PR is responsible for: both are pre-existing or strictly-improved failure modes, both are recorded as open questions in the branch's LLP 0266 with the rejected quick fixes and why, and the PR's core behaviour (a client-scoped run no longer deletes a dest another client still contributes, while still pruning genuinely retired dests) is pinned by tests in test/core/client-assets-prune.test.js and was reproduced both ways by review round 1.

Merge-time caveat: #907 - this branch's llp/0266-* collides by number with two other open branches. Renumber per #907 whenever this is not the first of the three to merge (mechanical edit under LLP 0156).

Deferred-findings issue: #921.
ee are enumerated with file:line detail in #920.

@philcunliffe
philcunliffe marked this pull request as ready for review August 19, 2026 07:37
@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 19, 2026
testand others added 3 commits August 19, 2026 14:31
…ion facts (#881)
A Task subagent runs under its parent's `session.id`, so one exporter
flush can mix main-loop events with a subagent's. `mergeSessionFacts`
hoisted the per-event `agent.name` and `query_source` to session-level
facts, and `buildProjection` then stamped `agent_id` / `is_sidechain:
true` and `attributes.claude.query_source` on the whole exchange, which
every row of the batch inherits.
Two consequences, both proven by the new test:
- Main-loop `user_prompt` / `assistant_response` rows were labeled
sidechain whenever a subagent event happened to share the POST.
- A body-derived gap block has no native uuid, so its `message_id` is a
content hash scoped by `(thread, agent_id)`. The hoisted `agent_id`
moved that scope, so the same block got a different `message_id` /
`part_id` depending on batch composition, and the `part_id` dedupe
missed: the block was stored twice.
Both attributes now ride the message. `attributeMessageToEvent` stamps
`agent_id` / `is_sidechain` and `claude.query_source` from the event
that produced the message, body gap blocks included (they take their
body event's attribution). `query_source` keeps a session-level default
for events that carry none of their own (a `user_prompt` event does
not), and that default is now taken only from events a subagent did not
emit, so a borrowed value is always the main loop's.
Main-loop rows keep `is_sidechain` unset rather than an explicit
`false`: that matches the proxy path, which also stamps `true` only for
a positively identified subagent, so a report still cannot tell the two
producers apart.
npm test (4486 pass), npm run typecheck, and the claude_telemetry_capture,
claude_telemetry_hypignore_drop, claude_telemetry_session_ignore and
gateway_claude_capture smokes are green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… rows
The per-session `query_source` default is there for main-loop events that
carry none of their own (`user_prompt`), and `mergeSessionFacts` already
takes it only from events a subagent did not emit, so the value is the main
loop's by construction. The stamping loop then applied it to every message
without one, including messages the same pass had just labeled a subagent's
via `agent.name`: a row could come out `is_sidechain: true, agent_id:
'Explore'` carrying the parent's `query_source: 'user'`, which is the same
cross-attribution class of defect #881 exists to remove.
Skip messages already attributed to a subagent. A subagent event that
carries its own `query_source` is unaffected (it never lacked one); one that
does not now reads null instead of the parent's value.
Regression test fails on f7f9eda and passes here. npm test (4487 pass) and
npm run typecheck are green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round on #895 found the same batch-composition hazard the PR fixes
for `agent.name` / `query_source` still open on two more paths:
- `mergeSessionFacts` hoisted `model` unguarded, so a subagent answering
first in a flush set the exchange's model, and every row without its
own (prompt rows, body gap blocks) read the sidechain's.
- `mergeBodyFacts` ran for a subagent's `api_request_body`, so its system
prompt and narrowed tool list became the exchange's `system_text` /
`tools`. Worse, `sessionBodyFacts` remembered them, restating the
subagent's prompt on every later batch of that session.
Both are now read only off events a subagent did not emit. Gap messages
from a subagent body are still projected, attributed to the subagent.
Also corrects the comment and type doc that called `facts.querySource`
a session-wide value: `bySession` is rebuilt per POST and there is no
carry-over map for it, so the borrow reaches one batch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe
philcunliffe merged commit 8755b0d into masterAug 19, 2026
10 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-881 branch August 19, 2026 21:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-event agent.name and query_source are hoisted to session facts, mislabeling main-loop rows as sidechain and destabilizing message_id

1 participant

@philcunliffe