Files
deepseek-harness/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md
T
Tianyi Cui e6fad266a6 docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus
Define the in-file RFC contract in docs/rfc/README.md § The file format:
the header block (`# RFC: <title>` plus a dateless Status enum
cross-checked against the lifecycle folder), the per-lifecycle body
skeleton (a Problem opener everywhere; Proposal/Alternatives considered/
Acceptance criteria/Risks in proposed/; present-tense Decision/
Consequences with proposal-era headings banned in implemented/; the
frozen proposal shape in rejected/), and a mandatory Alternatives
considered section with a date-fenced grandfather comment for pre-format
RFCs whose alternatives are not reconstructible from the record.

Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and
normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the
enum, 29 Context openers become Problem, the 39 legacy-format XXX debt
markers are resolved and banned from reappearing, proposal-era sections
in implemented RFCs are rewritten to shipped reality (including the
web/fs/subagent seam RFCs' migration plans and test checklists, closing
the doc-tiers deferred-work item on the web seam), every RFC gains an
Alternatives considered section or the grandfather comment, and the
bilingual pair is re-mirrored and re-recorded.

Move the generated index tables out of README.md into a fully generated
docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and
verify-rfc-classification checks its freshness and rejects index-shaped
rows in the curated README — which makes room for the format contract to
live in the README front door instead of a separate FORMAT.md.

The decision record, and the first RFC written in the new format, is
docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
2026-07-05 22:58:25 +08:00

8.0 KiB

RFC: ACP subagent backend (out-of-process delegation)

Status: implemented

Problem

The subagent seam (the seam RFC) was built so multiple backends coexist by name on ctx.subagents. The in-process backends (-spawn/-fork) run a child as a second Agent on the SAME cordis context — cheap, but the child shares the parent's process, model client, and tools. The seam's whole point was to also support an OUT-OF-PROCESS child reached over a protocol, proving the abstraction generalizes across a process boundary. This RFC adds the first such backend: an Agent Client Protocol (ACP) client.

Decision

@deepseek-ai/dsh-subagent-acp registers a SubagentProvider that runs each child agent in a SPAWNED SUBPROCESS, driven over ACP as the client. It is the direction-inverted twin of the existing server-side bridge @deepseek-ai/dsh-acp (the ACP agent): the bridge ANSWERS initialize/newSession/prompt; this backend CALLS them and IMPLEMENTS the Client callbacks (sessionUpdate, requestPermission). Pointing the configured spawn command at the acp-agent example makes the harness talk to its own process.

Fresh process per run

Each start spawns a new child, runs exactly one ACP session (initializenewSessionprompt), and dispose kills the subprocess and awaits its exit. This is the simplest lifecycle and mirrors the in-process one-child-per-run shape.

Minimal client stub

The client advertises NO optional capabilities (no fs, no terminal): the child self-serves file/terminal access in its own process. session/update notifications are consumed — the backend accumulates agent_message_chunk text as the result output and ignores the rest (thoughts, tool-call cards) in this cut, which surfaces only the child's final answer. session/request_permission is auto-answered by a configured policy (reject declines every prompt, allow approves via the first allow-shaped option) — the first cut surfaces no prompt to a human. Proxying fs/terminal back to the parent (a shared-workspace mode) remains future work, as the seam RFC noted.

No start-time capabilities

The provider's capabilities are all false. An out-of-process child cannot honor the parent's maxDepth (it has no access to parent.options.subagentDepth) or toolFilter (it owns its own tool registry), and the first cut does not implement outputSchema. The service rejects a request needing any of them before start runs. The backend injects only subagents (not ctx.agents) and ignores request.parent.

StopReason mapping

ACP StopReason → harness SubagentStopReason: end_turncompleted, max_tokensmax-tokens, refusalrefusal, cancelledaborted, max_turn_requestserror (no clean equivalent — the task did not finish), unknown→error. A spawn/transport/RPC failure resolves error (or aborted if a cancel was requested); result never rejects on a child-level failure, per the seam contract.

Security: scrubbed child environment

The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (/KEY|SECRET|TOKEN/i) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via config.env, layered AFTER the scrub, so an intended DEEPSEEK_API_KEY survives while an incidental AWS_SECRET_ACCESS_KEY does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level error event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles error instead of crashing the parent with an unhandled error.

Testing

Designed at every tier the backend touches, per the root AGENTS.md rule that a new capability shape names its coverage at every tier at plan time:

  • Keyless unit/integration (subagent-acp.spec.ts): spawns a scripted mock ACP server subprocess (tests/mock-acp-server.ts) and drives it through the real backend over real ACP stdio. Covers: the prompt round-trip + output accumulation; every StopReason mapping; cancellation via run.cancel() and via the request signal; the already-aborted-before-start case; the cancel-races-ahead-of-newSession case; a torn-pipe-after-cancel (child crashes on cancel) settling aborted; permission auto-answer under both policies (including the allow-policy-no-allow-option fallback); a non-message update consumed but not accumulated; a nonexistent-command spawn failure settling error; HMR provider cleanup; and the namespace export shape. 100% per-file coverage.
  • With-key e2e (subagent-acp.e2e.ts): the harness drives ITSELF — the backend spawns the real acp-agent example process and a real model in that child answers a prompt (PONG) and does real file work (writes proof.txt, verified on disk). Self-skips without DEEPSEEK_API_KEY. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e.
  • Snapshot: deferred as TODO(acp-subagent-replay). An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under DSH_SNAPSHOT=replay with its own sessions-root + fixture), unlike the in-process per-session keying that the per-session replay RFC added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child.

Alternatives considered

Why not the 0.28.x SDK bump?

The plan proposed bumping @agentclientprotocol/sdk 0.25.1 → 0.28.x for the new fluent acp.client() / ActiveSession.nextUpdate() API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs ClientSideConnection + ndJsonStream + PROTOCOL_VERSION + the Client/Agent/StopReason types, all present and non-deprecated in 0.25.1. The fluent API and unstable_forkSession that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x deprecates both ClientSideConnection AND AgentSideConnection (it wants all callers on the fluent builders), which turns the no-deprecated lint red across the entire existing ACP layer — 33 usages including the server bridge this backend has no business rewriting. That cross-cutting connection-API migration is its own change, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to ClientSideConnection (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up.

Why not a persistent child process?

Persistent-process pooling (reuse a warm child across runs) is a performance optimization deferred to future work — it adds session-lifecycle and crash-recovery complexity the first cut does not need; each start spawning a fresh child mirrors the in-process one-child-per-run shape.

Consequences

Every run pays a fresh subprocess (spawn + initialize + newSession). The parent surfaces only the child's final answer: session/update thoughts and tool-call cards are consumed and dropped, and permission prompts never reach a human — the configured policy answers them. The child's environment is credential-scrubbed by default, so its own model key is supplied explicitly via config.env.

Future providers

The same out-of-process spawn/prompt/stream/cancel shape generalizes to other transports named in the seam RFC — A2A, the Codex app-server, and the Claude Code Agent SDK — each a sibling provider registered by name. The ACP backend is the proof that the seam supports the boundary; those are mechanically similar.