Skip to content

fix(claude): parent an in-stream subagent's spans under its Agent call - #139

Merged
drewstone merged 1 commit into
mainfrom
fix/inline-subagent-parent
Sep 25, 2026
Merged

drewstone merged 1 commit into
mainfrom
fix/inline-subagent-parent

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

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 separate subagents/*.jsonl file), naming the Agent/Task tool_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.required rules failed on tools the subagent really called, and tools.forbidden/maxCalls rules passed vacuously on calls that were actually there — both verdicts backwards.

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: [ 'tool.Read's own span id' ]        (flat, not under Agent)
        Read is under Agent: false
after:  Read span ancestor chain: [tool.Read's llm.turn, tool.Agent, root]
        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)"

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-eval pin (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: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.

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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@drewstone
drewstone merged commit a93cfb0 into main Sep 25, 2026
2 checks passed
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