M6.2
Subagent architecture: four kinds of child
1,176 words · about 7 min
Concept
"Subagent" names four structurally different things. Getting the distinction right determines your token bill, your isolation properties, and what happens when the child dies.
Fork (in-process, inherits context). The child starts from a snapshot of the parent's completed conversation and continues from there with a new instruction. Cheapest to instruct — you need not explain anything the parent already knows — and most expensive to run, because every child request carries the inherited history.
Spawn (in-process, fresh context). The child starts with a system prompt, its tool schemas, and your prompt. Nothing else. Cheapest to run, most expensive to instruct, and the only kind with true context isolation.
Subprocess (out-of-process, protocol). The child is a separate OS process speaking a wire protocol over stdio. Fresh context necessarily — there is no shared memory to inherit. Adds process lifecycle (spawn, handshake, teardown, orphan risk) to everything else.
Cross-vendor driver. A subprocess child that is someone else's agent: a different vendor's CLI or SDK, driven through its own protocol. Fresh context, foreign capability set, foreign permission model, foreign stop reasons that must be translated into yours.
Mechanism: the provider seam
MechanismThe clean way to hold four kinds behind one call site is a provider interface where the differences are declared data, not branching code. In dsh that seam is SubagentProvider (docs/subsystems/subagent.md):
interface SubagentProvider {
readonly name: string // e.g. `spawn`, `fork`, `acp`
readonly capabilities: SubagentCapabilities
readonly inheritsParentContext: boolean // fork: true; spawn and acp: false
start(request: ResolvedSubagentStartRequest): Promise<SubagentRun>
prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
}Two fields carry the whole taxonomy. inheritsParentContext is the fork/everything-else axis, exposed as a boolean the orchestrator can read before choosing a provider — Ralph-style loops require it to be false, and refuse to start otherwise. capabilities is the negotiation surface covered in M6.4.
The run handle is equally deliberate:
interface SubagentRun {
readonly id: SessionId
readonly localAgent: Agent | undefined
readonly result: Promise<SubagentResult> // does NOT reject on child failure
dispose(): Promise<void>
}Read the comment twice. result does not reject when the child fails. A child that errors, refuses, is aborted, or exhausts its token budget still settles with a result carrying a stop reason. Rejection is reserved for the orchestration machinery itself breaking. This is the single most important API decision in the subsystem: it makes "the child failed" a value you must handle rather than an exception you can accidentally let propagate and kill the parent turn.
Worked example — the same task through three providers
Worked exampleTask: "Summarize what changed in the authentication module in the last ten commits."
Fork. The parent has already been discussing authentication for twenty turns. The fork inherits that: it knows which module, which conventions matter, what the user cares about. Prompt is one line. But the child's every request re-sends twenty turns of history. If the child takes eight turns, you have paid for that history eight more times.
Spawn. You must write the prompt properly: which paths constitute "the authentication module," what "changed" means, what shape the summary should take. Fifteen lines instead of one. The child then runs cheap and returns a structured summary. If it will take eight turns, this is almost always the better trade.
Cross-vendor driver. You want a second opinion from a different vendor's model. Fresh context necessarily, foreign permission mode, and a stop-reason vocabulary you must map: dsh's Codex driver translates contextWindowExceeded into the local max-tokens stop reason, and documents that the driver produces no refusal at all. That last point is the trap. If your orchestrator branches on refusal, that branch is dead code for this provider — not because refusals cannot happen, but because this driver cannot report them.
Tradeoffs
Fork trades running cost for instruction cost, and trades isolation away entirely — the child sees everything the parent saw, including anything untrusted the parent absorbed. Spawn trades instruction cost for isolation and cheapness. Subprocess adds crash isolation and language independence at the cost of process lifecycle and a serialization boundary. Cross-vendor drivers buy model diversity and pay in capability erosion: the foreign agent typically cannot honour your output schema, your depth limit, your tool filter, or your persona.
The subtle fork tradeoff is staleness. dsh's fork README states it precisely: "The seed is a one-time snapshot — the child sees the parent's completed turns as of the fork and nothing the parent logs afterwards; there is no live context sharing." A fork is a photocopy, not a window.
How `dsh` does it
How dsh does itEleven packages under packages/subagent/ implement the seam. The in-process pair:
packages/subagent/subagent-fork-in-process— provider name defaults tofork,inheritsParentContext: true, all four capabilitiestrue. Seeding usesCreateAgentOptions.seedwith a balanced completed-turn prefix — history through the lastturn/end, never a half-finished turn — andSessionHeader.seedLengthrecords the fork-lineage boundary.packages/subagent/subagent-spawn-in-process— provider name defaults tospawn,inheritsParentContext: false, same four capabilities. Its README makes the authority point explicitly: the child "receives zero parent conversation messages; the filter is visibility/composition, not an authority grant inherited from the parent."
The subprocess and cross-vendor drivers:
packages/subagent/subagent-acp— a generic ACP client. Config:command(required),args,cwd,permission,env, plus two teardown grace windows (disposeEofGraceMs,disposeGraceMs). It advertises no start-time capabilities. Every run uses a fresh process; process pooling is not implemented.packages/subagent/subagent-codex— drives a pinned OpenAI Codex CLI viaapp-server --stdioin an ephemeral thread, with apermissionModeofnever,approve-for-me, ordangerously-bypass-approvals-and-sandbox.packages/subagent/subagent-claude-code— drives the Claude Agent SDK withpersistSession: false, disables the SDK'sAskUserQuestiontool (there is no user to ask, three levels down), and in plan mode putsExitPlanModeindisallowedTools. It produces neithermax-tokensnorrefusal.packages/subagent/subagent-dsh-sdk—dshdriving anotherdshas a subprocess. Deployments setmaxDepth: 'provider-managed'here, because the child harness owns its own recursion budget and a locally-enforced number would be wrong.
One structural note worth its own paragraph: ctx.subagents is a named multi-provider registry. Contrast ctx.workflowEngine, where exactly one implementation may be installed per context. Delegation is genuinely plural — a single agent may reasonably hold fork, spawn, and claude-code simultaneously and choose per call — while an orchestration engine is not.
How others differ
Claude Code's subagents are markdown-defined with YAML front matter and per-subagent tool allow-lists; forks are a separate mechanism. Its documented prompt-cache TTL asymmetry is a real cost signal: five minutes for subagents versus an hour for the main conversation, which means a fan-out of short-lived children re-pays cache-miss costs the main loop would not. Codex CLI gates multi-agent behaviour behind a multi_agent feature flag. Goose composes via subrecipes; Hermes uses isolated subagents.
The genuinely different architecture is the handoff, as in the OpenAI Agents SDK: control transfers wholesale to another agent, which then owns the conversation and replies to the user directly. That is not delegation — nothing returns. Every design decision in this module assumes a child that returns a result to a parent that continues. Handoffs are a state machine over agents; subagents are a call stack. Do not mix the vocabularies.
Check yourself
4 questions on M6.2