8.6 KiB
Agent Note: Subagent capability seam
Status: implemented
English | 中文
The full seam is shipped: the
dsh-subagentinterface anddsh-tool-subagentconsumer; the two in-process backends (dsh-subagent-spawn,dsh-subagent-fork); the nested-agent snapshot infrastructure (per-session snapshot replay); and the out-of-process ACP, Codex, and Claude Code backends (ACP Agent Note, product-provider Agent Note).
Problem
The harness has a long-deferred seam for subagents — an agent delegating work to another agent. The intent was sketched in the Agent/AgentLoop interfaces (packages/core/agent/src/types.ts, packages/core/agent-loop/src/index.ts): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an Agent handle so steering and event subscription work uniformly.
Multiple subagent implementations must coexist at runtime. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports:
- in-process — a child concrete
Agenton the sameContext(the cheapest, and nearly free given the existing agent factory); - ACP — act as an ACP client driving another agent process (which can be another instance of ourselves);
- Codex app-server and Claude Code Agent SDK — current one-shot siblings that apply the same named-provider contract to official product processes (product-provider Agent Note);
- later: A2A using the same out-of-process "start a child, prompt it, settle, cancel" shape.
Alternatives considered
Why not the bash seam shape
The bash seam (capability seams) registers exactly one BashExecutor per context; loading a second throws. That is correct for bash (one machine, one way to run a command) but wrong here: coexistence is the requirement. So the subagent service is a named-provider registry — each implementation registers under a unique name and a caller picks one by name — mirroring the LLM adapter registry (LlmService.registerAdapter), not the single-service bash executor. The seam is still three-package (Service Definition / Service provider / Consumer); only the "one vs. many implementations" axis differs.
Decision
The three-package boundary
A new package group packages/subagent/:
| Package | Role |
|---|---|
@deepseek-ai/dsh-subagent |
interface: SubagentService (ctx.subagents), SubagentProvider, SubagentRun, the request/result/capability vocabulary, the subagent/* events |
@deepseek-ai/dsh-subagent-spawn |
implementation: a fresh in-process child via ctx.agents.create |
@deepseek-ai/dsh-subagent-fork |
implementation: an in-process child seeded with a snapshot of the parent's log |
@deepseek-ai/dsh-subagent-acp |
implementation: an ACP client driving a configured child process |
@deepseek-ai/dsh-subagent-codex |
implementation: a one-shot official Codex app-server process |
@deepseek-ai/dsh-subagent-claude-code |
implementation: a one-shot official Claude Code process through the Agent SDK |
@deepseek-ai/dsh-tool-subagent |
consumer: the model-facing subagent tool over ctx.subagents |
The primitive: async start → SubagentRun
A provider exposes start(request) → Promise<SubagentRun>. Fulfillment publishes a child and transfers its run handle to the caller. Work that fails before publication rejects start(), while prompt, turn, cancellation, and infrastructure outcomes after publication settle through run.result without hiding the child id. One signal covers cancellation before and after publication; dispose() cancels remaining work and awaits quiescence. A rejected start cleans unpublished resources and emits no lifecycle event, while a post-publication result failure closes the published lifecycle pair. start is transport-neutral; spawn names only the fresh in-process backend.
Two kinds of optional capability, discovered two ways
- Start-time features (
outputSchema,depthLimit,toolFilter,persona) ride on a staticprovider.capabilitiesdescriptor. The service checks every requested one BEFORE delegating and rejects loud (SubagentError('UNSUPPORTED_CAPABILITY')) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. - Continuable creation is the optional
SubagentProvider.prepareContinuablemethod; presence is the capability and TypeScript narrowing is the discovery mechanism, so no separate flag can drift from the implementation. The continuation manager owns later delivery and cold resume directly throughAgentHandle, while one-shotSubagentRunhas no steering or resume operation, as refined by continuable subagents.
Fork vs. fresh are separate backends, not a flag
Fresh and forked children are separate providers, not a request flag. dsh-subagent-spawn starts an isolated child; dsh-subagent-fork seeds a balanced prefix containing only completed parent turns. The in-flight turn is excluded because its subagent call has no result yet and cannot form valid replay history.
Child isolation and the parent log
Each in-process subagent runs in its own Session (own id, parentSession lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local Agent or child Session; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn tool/call and its tool/result (the child's final output), while child steps and tool calls remain outside the parent log.
Synchronous collect (first cut)
dsh-tool-subagent passes its execution signal to start(), awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output, and independent result and disposal rejections retain both diagnostics.
Provider selection is config, not model-facing
dsh-tool-subagent binds to exactly one provider name (Config.provider); the model sees only { description, prompt }. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct toolName (the tool registry rejects a duplicate name). The service holds the multi-provider registry; the tool picks one — the schema carries no provider/type parameter.
Testing
Registry and tool tests replace only the nondeterministic child with a package-local scripted provider while exercising the real SubagentService, lifecycle, task integration, and model-facing tool. Loader regression tests still cover the provider and consumer exports for the failure described in postmortem 0001. Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through per-session snapshot replay; in-process backends also have real-loop unit tests and a with-key e2e.
Consequences
- Recursion. Without a bound, an in-process child can see the delegation tool and recurse. The in-process backends implement the optional absolute depth limit and scoped live-global
toolFilter; ACP advertises both capabilities off and rejects such a request. The subagent composition-controls Agent Note owns their exact semantics and security limits. - Blocking the parent turn. Foreground collection holds the parent's step open for the child's full duration. Background delegation uses the shared
ctx.tasksruntime and generictask_*tools, the same collection mechanism as background bash; the subagent seam itself remains task-agnostic. - Live progress. Only lifecycle + the final result surface; a per-chunk child→parent update stream is deferred with the background redesign.
- ACP client surface. Proxying
fs/terminalfrom the ACP child back to the parent (a shared-workspace mode) is future work; the backend advertises neither capability, so the child self-serves in its own process.