Files
deepseek-harness/docs/rfc/implemented/feature/2026-06-30-subagent-observe-enrich.md
T
Tianyi Cui 93106b87b4 fix(subagent): deep-clone lastAssistantMessage onto subagent/end (observe-only)
Codex review caught an observe-only violation: the subagent/end emit fires from a
detached `.then` registered BEFORE start() returns — so before the caller's own
`await run.result` continuation runs. Carrying `result.output` by reference let a
mutating subagent/end listener corrupt the SubagentResult.output the caller/tool
then consumes. structuredClone() makes the event a read-only snapshot. Added a
regression test that mutates the event's array and asserts the caller's result is
untouched; proven to fail red without the clone. Updated the RFC + READMEs to note
the clone is load-bearing for the observe-only guarantee.
2026-06-30 21:52:16 +08:00

4.7 KiB

RFC: Subagent lifecycle enrichment — agentType + lastAssistantMessage (observe-only)

Status: implemented (accepted 2026-06-30)

Context

The hooks subsystem (interception seams RFC) lets a plugin observe and gate the agent at lifecycle points. Claude Code and Codex both expose SubagentStart / SubagentStop hooks, and CC's carry a subagent_type (which named subagent kind ran) and the subagent's final message. The harness already emits subagent/start and subagent/end lifecycle events (the subagent capability-seam), but their payloads were minimal (provider, id, and on end stopReason) — not enough for a hooks bridge to report which KIND of subagent ran, or WHAT it produced, without separately reaching for the live run.

This RFC enriches those two payloads. It is deliberately observe-only: no control-flow change, no waterfall, no start() restructure. A run-affecting subagent-stop decision (continuation, injection that changes the run) is a separate, larger redesign and stays out of scope.

Decision

Add two pieces of information to the subagent lifecycle surface:

  1. agentType — a caller-supplied subagent-kind label, the harness analogue of CC's subagent_type. It is optional on SubagentStartRequest, carried VERBATIM onto both subagent/start (SubagentRunInfo) and subagent/end (SubagentRunEndInfo). The seam never interprets it. The model-facing dsh-tool-subagent tool threads it from a new optional Config.agentType, so a deployment that exposes multiple subagent kinds (one tool load per kind) labels each. Absent when the caller does not distinguish kinds (the spread omits the key — exactOptionalPropertyTypes-correct).

  2. lastAssistantMessage — the child's final output, added to SubagentRunEndInfo. On the settle path it is a DEEP CLONE of SubagentResult.output (so an observer sees WHAT the subagent produced without holding the run). On the REJECT path (an infrastructure fault where no SubagentResult was produced — the seam only knows stopReason: 'error') it is absent. The clone is load-bearing for observe-only: the subagent/end emit fires from a detached .then registered before start() returns, i.e. before the caller's own await run.result continuation — handing listeners the same array reference would let a mutating listener corrupt the caller's SubagentResult.output. structuredClone makes the event a read-only view (a regression test mutates the event's array and asserts the caller's result is untouched).

Both events stay plain emits. subagent/end fires from a detached .then on run.result and awaits no listener, so it is genuinely observe-only by construction — a subagent/start listener can still reach the live child via ctx.agents.get(info.id) and inject() into it; a subagent/end listener can only observe (the run has settled). Per-listener containment (already in place) keeps one bad subscriber from stranding a live run or surfacing as an unhandled rejection on the detached settle hook.

Why observe-only, and what is deferred

A control-flow subagent/end (an awaited waterfall returning a stop/continue decision, like the other interception seams) would require: reshaping subagent/end from emit to waterfall, restructuring SubagentService.start to await listeners before settling, and implementing the resume capability in the in-process provider so a "continue" can actually re-run the child. That belongs to the background/steering subagent redesign the capability-seam RFC already defers (the same redesign that unifies long-running-tool handling across subagents and bash). This RFC ships the observe-only enrichment a hooks bridge needs today; FIXME(subagent-continuation) / TODO anchors mark where the control-flow version would land if and when that redesign happens.

Consequences

A hooks bridge (or a native plugin) can now translate SubagentStart/SubagentStop faithfully: it reports agentType, matches its hook config on it, and forwards the child's lastAssistantMessage to a SubagentStop handler — all by subscribing to the existing emits, no new control-flow surface. The vocabulary addition is documented in docs/core-data-structures/subagent.md (the SubagentStartRequest type-equiv block + the events prose) and the two subagent READMEs; the catalog is regenerated. No production behavior changes — the events fire exactly as before, with two more (optional) fields on their payloads — so no snapshot or e2e change is needed.