fix(claude): parent an in-stream subagent's spans under its Agent call - #139
Merged
Merged
Conversation
Review finding on row 7 (contract scoping): the stream-json reader read
`parent_tool_use_id` nowhere. Claude Code writes this field on every event
that belongs to a subagent running inline in the same stream (not a
separate `subagents/*.jsonl` file), and it names the `Agent`/`Task`
tool_use id that started it — but every such event was parented to the
trace root regardless, so a subagent's tool calls landed as root-level
siblings of the Agent call instead of its descendants.
A contract scoped to `{kind: TOOL, tool: Agent}` therefore saw only the
Agent span itself: `tools.required` rules failed on tools the subagent
really called, and `tools.forbidden`/`maxCalls` rules passed vacuously on
calls that were actually there — the opposite of both correct verdicts.
Fix: read `parent_tool_use_id` off assistant and user stream-json events;
when it resolves to a TOOL span already seen in this stream, parent the
new `llm.turn`/`user.prompt` span there (its own tool calls nest under
that span, so the whole sidechain moves as a unit) and stamp
`agent.parent.confidence: explicit`, reusing the confidence vocabulary
#133 introduced for the cross-file case. An id that resolves to nothing
falls back to the trace root, same as before this field was read at all.
Proof (real trace, no unit tests):
```
$ node proof-parent.mjs subagent.jsonl # model calls Agent; subagent calls Read
before: Read span ancestor chain: [root:...tool.Read's own span] (flat)
Read is under Agent: false
after: Read span ancestor chain: [tool.Read, llm.turn, tool.Agent]
Read is under Agent: true
llm.turn housing Read: parentConfidence = explicit
$ node proof-parent-contract.mjs subagent.jsonl # via agent-eval's checkTraceContracts
scope Agent, tools.required[Read]:
before: FAIL "no span matches eventually(kind=TOOL,tool=Read) over 1 span(s)"
after: PASS 1/1
scope Agent, tools.forbidden[Read]:
before: PASS 1/1 (vacuous — Read was invisible in scope)
after: FAIL "span ... matches never(kind=TOOL,tool=Read)"
```
(second proof run against a scratch build with agent-eval's contracts API,
since this branch pins published agent-eval 0.186.2, which predates it)
Gates: `pnpm install --frozen-lockfile`, `pnpm typecheck`, `pnpm build`,
`pnpm check:source` all clean. Full existing suite: 866/866 tests across
62 files pass, including `tests/claude-subagents.test.ts`,
`tests/claude-task-scope.test.ts` and `tests/claude-workflow.test.ts`. No
unit tests added or changed.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review finding on row 7 (contract scoping): the stream-json reader never read
parent_tool_use_id. Claude Code writes this field on every event belonging to a subagent running inline in the same stream (not a separatesubagents/*.jsonlfile), naming theAgent/Tasktool_use id that started it — but every such event was parented to the trace root regardless.A contract scoped to
{kind: TOOL, tool: Agent}therefore saw only the Agent span itself:tools.requiredrules failed on tools the subagent really called, andtools.forbidden/maxCallsrules passed vacuously on calls that were actually there — both verdicts backwards.Fix
Read
parent_tool_use_idoff assistant and user stream-json events. When it resolves to a TOOL span already seen in this stream, parent the newllm.turn/user.promptspan there (its own tool calls nest under that span, so the whole sidechain moves as a unit), and stampagent.parent.confidence: explicit, reusing the confidence vocabulary #133 introduced for the cross-file case. An id that resolves to nothing falls back to the trace root, same as before this field was read at all.Proof (real trace, no unit tests)
The contract-level proof ran against a scratch build using agent-eval's trace-contracts API (agent-eval#846), since this branch's published
agent-evalpin (0.186.2) predates that API; the span-hierarchy proof (proof-parent.mjs) ran directly in this worktree with no substitution.Gates:
pnpm install --frozen-lockfile,pnpm typecheck,pnpm build,pnpm check:sourceall clean. Full existing suite: 866/866 tests across 62 files pass, includingtests/claude-subagents.test.ts,tests/claude-task-scope.test.tsandtests/claude-workflow.test.ts. No unit tests added or changed.