The second PR of the subagent seam: the two in-process backends that run a
child agent on the same cordis context, reusing the agent factory's quiescent
AgentHandle teardown. Both register on ctx.subagents (PR1's named-provider
registry) and share one run driver.
- dsh-subagent-spawn: a FRESH child via ctx.agents.create — own session, the
parent's model by default (overridable), zero inherited conversation. Also
exports the shared in-process run driver (startInProcessRun): mint ids, stamp
cwd/parentSession-lineage/depth, drive the one-shot (send → whenIdle), read
the last assistant/message + turn/end reason, dispose to quiescence.
- dsh-subagent-fork: a child SEEDED with the parent's balanced completed-turn
prefix (the log up to and including its last turn/end), so the child inherits
context. The in-flight unbalanced turn is excluded — a raw seed would fail the
invariants replay. Proven: a regression test goes red if the boundary seeds
the open turn.
- Seam extension: CreateAgentOptions.seed, threaded through AgentLoop.createAgent
→ ctx.sessions.prepare({ seed }) (the primitive resume already used). This is
the fork-lineage path the TODO(sub-agents) markers anticipated.
- Depth: a merge-extensible AgentOptions.subagentDepth (0 top-level, parent+1 for
a child); the depthLimit capability refuses a spawn past request.maxDepth.
Tests: real-loop unit tests for both backends (mock MODEL only, real loop +
invariants), a multi-subagent test (one parent drives a fork AND a spawn child
then keeps working), and a with-key e2e (a real parent delegates via the
`subagent` tool to a real child that writes a file on disk — world-verified).
100% per-file coverage. The coding-agent demo wires the spawn backend + tool.
Snapshot coverage of nested agents is deferred to a stacked follow-up
(TODO(subagent-snapshots)): dsh-llm-replay is a single global positional cursor
that cannot route calls to a parent vs. a child on one context. Recorded in the
RFC's deferrals and a new AGENTS.md rule: designing a subsystem must design its
test infrastructure END TO END up front, verifying the snapshot/e2e harness can
express the new shape — a gap this plan hit.
11 KiB
RFC: Subagent capability seam
Status: proposed
Implementation status: PR1 (this proposal + the
dsh-subagentinterface, thedsh-subagent-mocktest backend, and thedsh-tool-subagentconsumer) is the first of three PRs. The two in-process backends (dsh-subagent-spawn,dsh-subagent-fork) and the out-of-processdsh-subagent-acpbackend land in PR2 and PR3. Status staysproposeduntil all three ship; the file moves toimplemented/feature/then, amended to describe what actually landed.
Problem
The harness has a long-deferred seam for subagents — an agent delegating work to another agent. The intent is sketched in two TODO(sub-agents) markers (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. No service, vocabulary, or implementation exists yet.
The distinctive requirement — the one that shapes the whole design — is that 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 we foresee:
- in-process — a child
ReactLoopAgenton 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);
- later: A2A, the Codex app-server, and the Claude Code Agent SDK — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend.
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 (interface / implementation / consumer); only the "one vs. many implementations" axis differs.
Proposal
The three-package seam
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 (PR2) |
@deepseek-ai/dsh-subagent-fork |
implementation: an in-process child seeded with a snapshot of the parent's log (PR2) |
@deepseek-ai/dsh-subagent-acp |
implementation: an ACP client driving a configured child process (PR3) |
@deepseek-ai/dsh-subagent-mock |
support: a scripted provider for testing the seam through the real load path (PR1) |
@deepseek-ai/dsh-tool-subagent |
consumer: the model-facing subagent tool over ctx.subagents (PR1) |
The primitive: start → SubagentRun
A provider exposes start(request) → SubagentRun. The run carries a result promise (the terminal SubagentResult), cancel(), and dispose(). The transport-neutral verb is start; "spawn" is reserved for the in-process dsh-subagent-spawn backend's identity, not the service verb. The service's start(name, request) resolves the named provider, validates capabilities, delegates, and emits subagent/start / subagent/end around the run.
Two kinds of optional capability, discovered two ways
- Start-time features (
outputSchema,depthLimit,toolFilter) 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. - Runtime features (steering via
sendMessage, follow-up viaresume) are optional methods onSubagentRun. The method's presence IS the capability, and TypeScript narrowing is the discovery mechanism: a consumer cannot call an absent method without narrowing first, so there is no silent-degradation path and no separate flags object to keep in sync.
Fork vs. fresh are separate backends, not a flag
Rather than a context: 'fresh' | 'fork' request field, the distinction is the provider's identity: dsh-subagent-spawn (fresh, isolated, own system prompt) and dsh-subagent-fork (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism.
Child isolation and the parent log
Each subagent runs in its own Session (own id, parentSession lineage), persisted independently. The parent's log records only the spawn tool/call and its tool/result (the child's final output) — the child's internal steps and tool calls stay in the child's own session, never injected into the parent log. This is the only design that is identical across transports: an ACP child's internal events physically cannot be injected into our parent log, so making in-process behave the same keeps the seam transport-agnostic.
Synchronous collect (first cut)
The dsh-tool-subagent consumer awaits run.result and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. It does so inside a try/finally that always dispose()s the run (no leaked idle child/session on any path), bridges exec.signal to run.cancel(), and maps a non-completed stop reason to an isError result rather than returning partial output as success. Steering (sendMessage) is part of the contract but intentionally unused this cut.
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 — no provider/type parameter in the schema this cut.
Plan (three PRs, each converged with Codex separately)
- PR1 — interface + tool + mock. This RFC,
dsh-subagent(service, registry, vocabulary,subagent/*events),dsh-subagent-mock(scripted provider),dsh-tool-subagent. Wire the newpackages/subagent/group into the tsconfigs, the build references, the package hierarchy docs, and the module graph. Tests: registry HMR-safety, duplicate-name rejection, start-time capability rejection, and at least one test driving the tool through the real cordis Loader / export path (a hand-builtctx.pluginmount bypassesunwrapExportsand cannot catch a broken export shape — see postmortem 0001). - PR2 — in-process backends.
dsh-subagent-spawnanddsh-subagent-forkoverctx.agents.create+AgentHandle.dispose. The fork backend must seed only a balanced, completed-turn prefix of the parent log: at tool-execute time the parent's turn is open (it holds theassistant/messageand the dangling spawntool/callwith notool/result), and seeding that raw prefix gives the child an unbalanced turn the invariants freeze-check rejects. Depth tracking (parent depth + 1, refused pastmaxDepth) and its exact storage are settled in PR2. - PR3 — ACP backend.
dsh-subagent-acpas an ACP client over a configured spawn command (stdio); point it at our ownacp-agentexample to "talk to our own process". Minimal client stub: advertise no optional client capabilities, auto-resolvesession/request_permissionvia a configured default, consumesession/updatewithout surfacing it this cut. Decide the@agentclientprotocol/sdkversion (recommended: bump to 0.28.x for the fluent client API; the bump is shared with the existingdsh-acpbridge, so re-run its snapshot + e2e).
Risks and deferrals
- Recursion. Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a
maxDepthrequest); tool-filtering is likewise optional. Tool-filtering, when implemented, needs atools/executeveto in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name. - Blocking the parent turn. Synchronous collect holds the parent's
runStepopen for the child's full duration. This is acceptable for the first cut; background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash (a sub-agent and a longbashbackground task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). - Live progress. This cut surfaces only lifecycle + final result; 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 first cut advertises neither, so the child self-serves in its own process. - Snapshot coverage of nested agents. The snapshot tier (
pnpm run test:snapshot) replays a recorded session throughdsh-llm-replay, whose dispatch is a single GLOBAL positional cursor (the Nthllm/streamcall serves the Nth recorded entry) and whose harness harvests a single session log file. A subagent runs as a second agent with its own session log, so a parent→child scenario needs per-session-keyed replay (or a call-ordered merge of both logs, sound because subagent execution is strictly nested/non-concurrent — the parent blocks on the child) plus harvest-all-logs and plural-session-id plumbing in the harness. This is self-contained infrastructure orthogonal to the backends, so it lands as a dedicated stacked follow-up rather than in the in-process-backends PR. Until it lands, in-process subagents are covered by real-loop unit tests (a parent driving a fork AND a spawn child) and a with-key e2e (a parent delegating to a child that writes a file), not by the snapshot transcript tier. Tracked byTODO(subagent-snapshots).