Skip to content

fix: sub-agents recorded in the parent's agent span (Claude Code background, forked and resumed agents; ACP agents) - #200

Draft
tbroadley wants to merge 4 commits into
meridianlabs-ai:mainfrom
tbroadley:fix/subagent-spans
Draft

tbroadley wants to merge 4 commits into
meridianlabs-ai:mainfrom
tbroadley:fix/subagent-spans

Conversation

@tbroadley

@tbroadley tbroadley commented Oct 20, 2026 •

Copy link
Copy Markdown

On hold: Claude Code sends x-claude-code-agent-id on each sub-agent's requests. Once inspect_ai exposes bridged request headers (PR to follow), attribution will use it, with the history matching here kept as a fallback.

✨ claude-opus-5-5 | Comment not reviewed by tbroadley

Part of #131. Replaces #114, which fixes only the first case below. Touches the same code as #111, so whichever lands second needs a rebase.

Problem

Some sub-agents record their model calls in the parent's agent span:

  • Claude Code background agents (the default). Their span closed at the launch acknowledgement.
  • Claude Code forks, resumed agents (SendMessage or a queued message), and sub-agents that compact.
  • All sub-agents of the ACP Claude Code and Codex agents, which installed no event consumer.

Tools that rebuild each agent from its span (e.g. Inspect Scout) lose the sub-agent's work, and can replace the main agent's history when a run ends mid-sub-agent.

Separately, the ACP agents were hard to drive: a caller awaiting ready after start_soon blocked forever, and the default cwd="/home/user" doesn't exist in most images.

Fix

One commit per change:

  1. ACP ready and cwd. ready is renewed at session teardown. With no cwd, all three ACP agents use resolve_agent_cwd, like the CLI agents. Behaviour change: sessions, skills and ACP Codex's AGENTS.md/.codex/ go to the image's working directory instead of /home/user.
  2. Claude Code background agents. Spans stay open until the task notification or reset().
  3. Claude Code forks, resumed and compacted agents. Forks are matched by their placeholder and directive. Each resumed run gets its own agent-<id>-runN span. A compacted sub-agent is recognised by its summary. Claude Code's own in-run turns (output-limit recovery, hook feedback, compaction) don't count as new runs. The consumer is split into subagents (state and invariants), attribution (which sub-agent made a call), shapes (Claude Code's version-specific text) and live_consumer (span lifecycle).
  4. ACP Claude Code and Codex. Both install their consumer. Without stream-json, Claude Code uses BridgeOnlyLiveConsumer, which closes spans when the run ends or when the parent sees its result. As with the CLI agents, this turns off the bridge's streaming of partial model output to the transcript for these agents.

Tests

  • About 140 new tests on stubbed transcripts and fakes, with no API calls. make check is clean.
  • Real runs on the final code (claude-haiku-4-5), in local Docker. In each, every sub-agent call was in its own span, resumed runs in -runN spans, main-agent calls in the outer span, and every span closed when its run ended:
    • Claude Code 2.1.303: foreground, background, SendMessage and queued resumes, parallel background agents, two agents with the same prompt, parallel and resumed forks, a nested agent, a blocking SubagentStop hook, a sub-agent that compacts, and the main agent compacting beside a running one. 2.1.304: a resumed fork.
    • ACP Claude Code: foreground, background with a SendMessage resume, parallel background agents, two agents with the same prompt, a sub-agent that compacts. ACP Codex (gpt-5.4-mini): a spawned sub-agent.
  • Those runs are recorded (system prompts, reminder text and long command output dropped) and replayed in test_claude_code_subagent_spans_replay.py, which checks each call's span and when each span opens and closes.
  • A bug in this tracking is logged and sends the call to the outer span, rather than failing the model call.

Agent review

Two pairs of independent reviewers (gpt-6-astra, claude-opus-5-5), seven rounds, about 50 findings, each reproduced with a failing test. The correctness findings are fixed with regression tests, including ones that put calls in the wrong span: agents with the same prompt, the main agent after compaction, and lagging or failed resumes.

@tbroadley
tbroadley force-pushed the fix/subagent-spans branch 10 times, most recently from b49fefb to 77d755a Compare October 20, 2026 20:37
… CLI agents

`ACPAgent.__call__` replaced `self.ready`, so a caller that started the
agent with `start_soon` and then awaited `ready` waited on a stale event
forever. `ready` is now renewed at session teardown instead. (A caller
still waits forever if the session fails to start, as before.)

The default `cwd="/home/user"` doesn't exist in most images. With no
`cwd`, all three ACP agents (Claude Code, Codex, Gemini CLI) now use
`resolve_agent_cwd`, like the CLI agents: the sandbox's working
directory, or the user's home when that is `/`.

Behaviour change, for callers that pass no `cwd`:
- the session runs there rather than in `/home/user`;
- skills (`.claude/skills`, `.gemini/skills`) and ACP Codex's
  `AGENTS.md`/`.codex/` are written there;
- `agent.cwd` is "" until the agent is called.

`cwd=""` is resolved the same way as no `cwd`.
Claude Code launches sub-agents in the background by default. Their span
closed at the `async_launched` tool result, which is only a launch
acknowledgement, so every model call the sub-agent made afterwards was
recorded in the parent's span.

Background spans now stay open until the sub-agent's task notification
(or `reset()`): the `system` task_notification event, or, for Claude
Code versions that print none, the `<task-notification>` in the parent's
`user` line. A call whose history holds the launching tool call (the
parent, quoting the prompt) is not attributed to the sub-agent.
Remote agents and per-block tool results are handled. A notification
with no tool_use_id is matched by the task id, read from the structured
result or, failing that, the result text.
@tbroadley
tbroadley force-pushed the fix/subagent-spans branch 7 times, most recently from 36f08b1 to d91d1c0 Compare October 21, 2026 14:21
… own spans

- Forks are matched by their "Fork started" placeholder and directive,
  so parallel forks are no longer attributed to each other.
- Each resumed run (SendMessage or a queued message) gets its own
  `agent-<id>-runN` span, opened from the SendMessage, lazily from a run
  boundary in the call's history, or from `task_started`. Run ids are
  matched to runs in order, so a lagging `task_started` doesn't open a
  spurious run, and a failed SendMessage starts no run.
- Claude Code's own in-run turns (output-limit recovery, stop-hook
  feedback, compaction, ...) are not counted as run boundaries; the
  classifier mirrors Claude Code 2.1.303's.
- A sub-agent that compacts is recognised by its summary, which starts
  its history from then on.
- A call whose history holds a tool call one of a sub-agent's earlier
  calls made, starts with its prompt or summary, and holds no launches
  but its own, is that sub-agent's.
  So agents launched with the same prompt get their own spans: identical
  first calls go to each in launch order (a failed or refused call's retry
  keeps its agent).
- A finished sub-agent only matches a call whose history starts with its
  prompt, so the main agent quoting it (e.g. after compaction) stays in
  its own span. Prompt matches are ranked, ties between agents with the
  same prompt go to the outer span, and agent names are scoped to the
  launching agent.
- A run we open without seeing who resumed it goes under its launcher's
  current span, not the span current at launch, which may have ended
  (a checkpoint span, or an earlier run of the launcher).

The consumer is split up to keep this tractable:
- `subagents`: the sub-agents and their runs, with lookups by each id
  and the invariants in one place;
- `attribution`: which sub-agent made a call (pure);
- `shapes`: what Claude Code's messages look like (all the
  version-specific text);
- `live_consumer`: span lifecycle, from the bridge and stream-json.

A failure in this tracking is logged, and costs the call its span (it
goes to the outer one) rather than failing the model call. Tests make
such failures raise.

Recorded Claude Code runs are replayed in tests, checking each call's
span and when each span opens and closes. 2.1.303: foreground,
background, SendMessage and queued resumes, parallel background agents,
agents with the same prompt, parallel and resumed forks, a nested agent,
a blocking SubagentStop hook, and compaction of a sub-agent and of the
main agent beside one; 2.1.304: a resumed fork, and a compacting
sub-agent (its continuation lists the tools used before the summary).
The ACP agents (`interactive_claude_code`, `interactive_codex_cli`)
installed no `ModelEventSink`, so every sub-agent model call landed in
the parent's span.

Both now install their consumer and reset it after the bridge exits.
ACP Claude Code has no stream-json, so `BridgeOnlyLiveConsumer` closes
spans when the sub-agent's run ends, or from the result or
`<task-notification>` the parent sees. Calls after a reply-closed run
stay in that run's span. Each parent message is handled once, so a
retried request (e.g. after a refusal) doesn't close a later run, and a
launch acknowledgement that arrives after the run ended is still counted.

Installing a `ModelEventSink` turns off the bridge's streaming of partial
model output to the transcript for these agents, as for the CLI agents.

This branch has not been deployed

No deployments
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