Files
deepseek-harness/packages/subagent/subagent-acp
Tianyi Cui 3529b3c166 fix(scope): close final ownership races
Drain idle injection flushes before agent teardown, snapshot approval and subagent provider inputs, and gate subagent lifecycle events on real child readiness. Align the RFCs and generated contracts with the hardened behavior.
2026-07-12 00:31:46 +08:00
..

@deepseek-ai/dsh-subagent-acp

The out-of-process ACP subagent backend: runs each child agent in a spawned subprocess, driven over the Agent Client Protocol (ACP) as the client. Registers a SubagentProvider on ctx.subagents (the subagent seam), alongside the in-process -spawn/-fork backends — multiple backends coexist by name.

It is the direction-inverted twin of the server-side bridge in @deepseek-ai/dsh-acp: that package is the ACP agent (it answers initialize/newSession/prompt); this one is the ACP client (it calls them and implements the sessionUpdate/requestPermission callbacks). Point the configured command at the acp-agent example to "talk to our own process".

What it does

start(request) spawns the configured command, wraps its stdio in an ACP ClientSideConnection, and drives one session: initializenewSessionprompt. run.started resolves after newSession publishes the remote session and rejects when initialization fails or cancellation wins first; the service emits no start/end pair for a child that never became live. The child's streamed agent_message_chunk text becomes the SubagentResult.output; the prompt's terminal StopReason maps to the stop reason. dispose() kills the subprocess and awaits its exit.

Fresh process per run. Each start spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC).

Unlike the in-process backends, the child does NOT share this cordis context — it is a separate process with its own session, model client, and tools. So this backend:

  • injects only subagents (no ctx.agents);
  • advertises NO start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter);
  • ignores request.parent.

Config

Key Type Default Notes
providerName string acp Registry name on ctx.subagents.
command string — (required) The executable to spawn for each run (the child ACP agent).
args string[] [] Arguments passed to command.
cwd string process cwd Working directory for the child process and its ACP session.
permission 'allow' | 'reject' reject How to auto-answer the child's session/request_permission prompts. reject declines every prompt (answer cancelled); allow approves via the first allow-shaped option. The first cut surfaces no prompt to a human.
env Record<string,string> {} Extra env vars for the child (e.g. its own DEEPSEEK_API_KEY). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly.
disposeEofGraceMs number 6000 Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM.
disposeGraceMs number 3000 Dispose ladder tier 2: grace between SIGTERM and the SIGKILL escalation.
- id: subagent-acp
  name: '@deepseek-ai/dsh-subagent-acp'
  config:
    providerName: acp
    command: node
    args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml']
    permission: reject
    env:
      DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY

StopReason mapping

ACP StopReason → harness SubagentStopReason:

ACP harness
end_turn completed
max_tokens max-tokens
refusal refusal
cancelled aborted
max_turn_requests error (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.

Environment scrub

The child env is built by buildChildEnv from @deepseek-ai/dsh-subagent-subprocess — the ambient env minus credential-shaped vars, with config.env layered on top after the scrub; the pattern and full semantics live there. For this backend that means the parent harness's own secrets never leak into the spawned agent implicitly, while the child's OWN DEEPSEEK_API_KEY is supplied deliberately via config.env and survives.

Testing

  • Keyless (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 — connection setup, client callbacks, the prompt round-trip, stop-reason mapping, cancellation (including the early-cancel race and a torn-pipe-after-cancel), permission auto-answer, and quiescent disposal. No model, no key.
  • 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 and does real file work (verified on disk). Self-skips without DEEPSEEK_API_KEY.

TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a separate replay shape (each child is its own PROCESS with its own single-agent replay, distinct from the in-process per-session keying), deferred — see the RFC.

Plugin export shape

Named name / inject / Config / apply, with no default export: the cordis Loader's unwrapExports does exports.default ?? exports, so a stray default would collapse the module to the bare function and drop the inject namespace (see docs/postmortem/0001).