Skip to content

design client subagent sessions

sinrimin edited this page Oct 20, 2026 · 1 revision

Client-agent sub-agent sessions (#71)

Issue: #71. Ships: feat/71-native-subagent-sessions (shared/cli/web).

The defect

When a claude-code client agent used its Task/Agent tool (a sub-agent), the child's entire tool activity (Bash/Edit/Read cards) streamed into the MAIN agent transcript as if the main agent performed it. The main agent's steps, the sub-agent's tool rows, and the Task tool card interleaved in one flat list (rig-reproduced live: a hello-world dispatch rendered six child tool cards inline under a "Create about.html intro page" Task card).

Ground truth (why a capability flip alone cannot fix 0.79.0)

claude-agent-acp has a native-sub-agent runtime that only turns on when the CLIENT advertises clientCapabilities.subagents; without it the adapter pours child frames into the root session. Two further facts from driving the pinned 0.79.0 by hand (initialize + session/load over stdio):

  • The bundled @agentclientprotocol/sdk 1.4.0 zod schema strips an unknown clientCapabilities.subagents during request parsing — the advertisement never reached the runtime. (_meta survives the parse, which is the AIR-draft escape hatch the adapter also accepts — that path speaks the older draft protocol.)
  • The bundled @anthropic-ai/claude-agent-sdk 0.3.274 getSessionMessages returns no sidechain entries (sub-agent transcripts live in a separate subagents/ file), so a resumed session could not reconstruct children at all — and with the capability forced on, the adapter then SKIPS the Task tool card, making the child vanish entirely from replays.

Both are fixed upstream: the pin moved to claude-agent-acp 0.93.0 (ACP SDK 1.9.0 keeps subagents; agent-sdk 0.3.300 replays sidechain). The #2 setModel-skip dist patch is retired with the bump — upstream landed its own skip; the patch mechanism stays (test-injectable since #71) for future use.

The RFD protocol (what 0.93.0 speaks for a plain-subagents client)

  • The launch stays an ordinary parent tool_call (Agent/Task) — NOT suppressed (that was the 0.79 AIR-draft behavior).
  • The parent frame carries the child lifecycle as subagent_update: {sessionUpdate:'subagent_update', sessionId:<child>, title, description, state:{state:'running'}} announces; a later frame with state:{state:'idle', stopReason:'end_turn'|'cancelled'|'error', error?} (or {state:'unknown'}) ends it.
  • The child's incoming prompt rides session_message on the CHILD's sessionId; its work (message/thought/tool frames) also streams on the child's own sessionId.
  • The 0.79 draft shapes (subagent_spawned / subagent_state_update) are kept in our mapper for back-compat.

The platform side

  • shared — three additive ChatStreamEvent kinds: subagent_spawned {subagentId, name, task}, subagent_update {subagentId, event} (the child event wrapped; the child union is message_delta/thought_delta/tool_call/raw — config/usage/permissions are root-session concerns), subagent_done {subagentId, state: completed|failed|cancelled|disconnected}. RFD states map: idle+end_turn→completed, cancelled→cancelled, error→failed, unknown→disconnected.
  • cli — initialize advertises subagents: {} (agent-connection.ts); routeSubagentFrame routes every session/update BY FRAME SESSION ID on both the live path (wireSession) and the session/load capture path (wireCapture): RFD/draft lifecycle frames map and REGISTER the child id; frames on a registered child id map through the ordinary mapAcpUpdate and wrap into subagent_update (child usage drops child-side). Unknown non-root session ids fall through to root handling (defensive; nothing emits them today).
  • web — the fold gains a subagent row (upsert by subagentId; a wrapped update for an unannounced child still renders via a placeholder row). The child's activity lives in its OWN FoldState inside the row, so it renders inside a collapsible card (subagent-card.tsx: 24px disclosure row — state dot, name, ·, truncated task, duration, running label — expanding into a data-surface="well" body that reuses the ordinary row components, max-h scrollable). Turn sweeps settle still-running children like tool rows; subagent_done also settles the child fold in place (no synthetic tail).
  • The dispatch poll's text accumulator skips the new kinds — a driven channel's report stays the MAIN agent's text (the child's narration no longer pollutes it; previously it did).

Verification

  • cli fixture scripts the RFD sequence (announce → session_message + text + Write + dropped usage on the child id → idle/end_turn → launch card completes); tests assert the wrapped kinds, zero child leakage into root events, the capability advertisement, and the same routing on the capture path. 332 cli / 388 server / 131 shared / 114 web green; ui-snap 52/52.
  • Rig E2E (agent-browser): resuming the session that reported the defect now renders the sub-agent card (completed, name/task, full child transcript inside); a fresh live run streams running→completed with the child's tools only inside the card; probe artifacts cleaned.

Open follow-ups

  • session_message (the child's full incoming prompt) currently maps to raw and renders nothing inside the card — the card shows the announce's short description. Surfacing the full prompt is a small web-only follow-up.
  • Other targets (opencode/codex/dsh/pi) do not speak sub-agent sessions; the capability advertisement is inert for them.
  • Deployment note: the pin bump does not propagate to EXISTING adapter stores — provisionAdapters only checks bin presence, so a machine with a 0.79.0 ~/.hnx/acp-adapters keeps it until the store is wiped (rm + daemon restart re-provisions 0.93.0). Old stores simply keep the pre-#71 behavior (capability stripped → flat mixing); nothing breaks.

Clone this wiki locally