Files
deepseek-harness/docs/core-data-structures/subagent.md
Tianyi Cui f393043b03 Add the ACP subagent backend: out-of-process delegation (PR3)
The first OUT-OF-PROCESS subagent backend, proving the seam generalizes past the
in-process backends. @deepseek-ai/dsh-subagent-acp runs each child agent in a
spawned subprocess, driven over the Agent Client Protocol as the CLIENT — the
direction-inverted twin of the dsh-acp server bridge. Point the configured
command at the acp-agent example and the harness talks to its own process.

- Fresh process per run: start spawns, runs one ACP session (initialize →
  newSession → prompt), dispose kills the subprocess and awaits its exit.
- Minimal client stub: advertises no fs/terminal; accumulates agent_message_chunk
  text as the result output; auto-answers session/request_permission by a
  configured policy (reject default / allow). No start-time capabilities (an
  out-of-process child can't enforce the parent's depth/tool-filter); ignores
  request.parent; injects only `subagents`.
- StopReason mapping (end_turn→completed, cancelled→aborted, …); result resolves
  error/aborted on a child failure, never rejects (seam contract).
- Security: credential-shaped ambient env vars are scrubbed; the child's own key
  is forwarded only via explicit config.env. A spawn-level error (ENOENT) is
  captured and raced against the ACP drive so a bad command settles error rather
  than crashing the parent.

Testing designed at every tier: keyless integration drives a scripted mock ACP
server subprocess (cancellation incl. the pre-newSession race and a
torn-pipe-after-cancel, permission auto-answer, non-message updates, spawn
failure, HMR, export shape) at 100% coverage; a with-key e2e drives the REAL
acp-agent example process (PONG + real file write, verified on disk) — the
harness driving itself. Snapshot coverage of an ACP child is deferred as
TODO(acp-subagent-replay) (each child is its own process with its own replay).

Stayed on @agentclientprotocol/sdk 0.25.1: the proposed 0.28.x bump only
deprecates the stable ClientSideConnection/AgentSideConnection API this layer
uses (33 sites incl. the server bridge), turning no-deprecated red across code
this PR shouldn't rewrite — that fluent-API migration is its own follow-up. The
backend needs nothing 0.28.x adds.

This completes the subagent seam stack (PR1 interface → PR2 in-process → PR2.5
snapshot infra → PR3 ACP); the seam RFC moves to implemented/, amended.
2026-06-22 10:47:02 +08:00

6.3 KiB

Subagent

The subagent seam — an agent delegating work to a child agent. Like bash it is one optional capability, not part of the agent-loop spine, so its vocabulary lives here rather than in core.md. But it differs from every other seam on one axis: multiple provider implementations coexist in one context, registered by name (ctx.subagents), where bash allows only one executor. The registry shape mirrors the LLM adapter registry, not the single-service bash executor.

Interface: dsh-subagent (ctx.subagents + the vocabulary below). Implementations are sibling packages (dsh-subagent-spawn, -fork, -acp); the model-facing consumer is dsh-tool-subagent. The proposal and rationale: the subagent RFC.

Source: packages/subagent/subagent/src/types.ts

Two kinds of capability, discovered two ways

A provider advertises its start-time features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (SubagentError('UNSUPPORTED_CAPABILITY')), never accepted-then-ignored. Runtime features (steering, resume) are instead optional methods on SubagentRun — the method's presence IS the capability, and TS narrowing is the discovery mechanism.

interface SubagentCapabilities {
  outputSchema: boolean
  depthLimit: boolean
  toolFilter: boolean
}

The start request

What a caller asks for when starting a subagent. The tool layer builds this from the model's { description, prompt } plus its own config; the service validates the start-time capabilities against the named provider, then passes it to provider.start. parent is REQUIRED — in-process backends read parent.session.header for the working directory, the parentSession lineage, and the delegation depth. The three optional fields (outputSchema, maxDepth, toolFilter) each gate on the matching SubagentCapabilities flag.

interface SubagentStartRequest {
  prompt: ContentBlock[]
  parent: Agent
  signal?: AbortSignal
  agentOptions?: AgentOptions
  outputSchema?: SchemaSpec
  maxDepth?: number
  toolFilter?: { allow?: string[]; deny?: string[] }
}

The terminal result: SubagentResult

The outcome of a run, resolved by SubagentRun.result. structured is present iff the request carried an outputSchema AND the provider honored it. A non-completed stopReason means output may be partial — the consumer maps it to an isError tool result rather than reporting partial output as success.

interface SubagentResult {
  output: ContentBlock[]
  structured?: unknown
  stopReason: SubagentStopReason
}

SubagentStopReason is a merge-extensible derived union — a backend may add variants, so consumers branch on the known cases and treat an unknown terminal reason as a failure:

interface SubagentStopReasonMap {
  completed: 'completed'
  aborted: 'aborted'
  error: 'error'
  'max-tokens': 'max-tokens'
  refusal: 'refusal'
}

A live run: SubagentRun

The handle the consumer holds while a child executes. The consumer awaits result, may cancel mid-flight, and MUST dispose on every path to reach child quiescence (no leaked idle child / session). result does NOT reject on a child-level failure — a model/transport failure resolves with stopReason: 'error' — so the consumer maps a non-completed reason to an isError result; it rejects only on an infrastructure fault the seam cannot represent. sendMessage and resume are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it.

interface SubagentRun {
  readonly id: AgentId
  readonly result: Promise<SubagentResult>
  cancel(reason?: string): void
  dispose(): Promise<void>
  sendMessage?(content: ContentBlock[]): void
  resume?(content: ContentBlock[]): SubagentRun
}

The provider seam: SubagentProvider

One transport for running a child agent. Implementations register under a unique name via SubagentService.registerProvider; multiple coexist in one context. The service validates every requested start-time capability before calling start, so an implementation may assume e.g. request.maxDepth is honorable when present.

interface SubagentProvider {
  readonly name: string
  readonly capabilities: SubagentCapabilities
  start(request: SubagentStartRequest): SubagentRun
}

The service (ctx.subagents) emits subagent/start when a run begins and subagent/end when it settles (see the events catalog). Both emits contain a thrown listener per listener (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it.

In-process backends: depth and seed

The two in-process backends (dsh-subagent-spawn fresh, dsh-subagent-fork seeded) run the child as a child Agent on the same context via ctx.agents.create. Two pieces of vocabulary ride on the existing agent/session types rather than new core types:

  • Delegation depth is a merge-extensible AgentOptions.subagentDepth field (0 for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from parent.options.subagentDepth and the depthLimit capability caps the tree by refusing a child whose depth would exceed request.maxDepth.
  • Fork seeding uses CreateAgentOptions.seed (a SessionEvent[] prefix threaded through AgentLoop.createAgentctx.sessions.prepare({ seed }), the same primitive resume uses). The fork backend passes a balanced completed-turn prefix of the parent's log — the parent's events up to and including its last turn/end — so the seed is contiguous-from-0 and the invariants replay accepts it (the in-flight, unbalanced turn is excluded).