ACP subagent backend (out-of-process delegation)
ACP subagent 后端(进程外委派)
The subagent seam ([the seam Agent Note](2026-06-21-subagent-capability-seam.md)) 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,
English
Problem
The subagent seam (the seam Agent Note) 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 Agent Note 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 (initialize → newSession → prompt), 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), so only the child's final answer surfaces. session/request_permission is auto-answered by a configured policy (reject declines every prompt, allow approves via the first allow-shaped option) — no prompt is surfaced to a human. Proxying fs/terminal back to the parent (a shared-workspace mode) remains future work, as the seam Agent Note 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); the ONE thing it reads off request.parent is the session header's cwd (see the workspace resolution below) — no conversation context, depth, or tool state crosses the process boundary.
Workspace cwd resolution
The child's working directory is an explicit resolution, never the harness process cwd: the deployment cwd override when configured (made absolute against the launch directory and validated at load), else the parent session header's cwd (validated at start), and a loud rejection before anything spawns when neither exists. One ACP server process serves sessions from many workspaces, so process.cwd() cannot stand in for a session's workspace — the old implicit fallback ran children in the server's launch directory. A candidate must be an absolute path naming a directory the harness can ENTER (X_OK — statSync().isDirectory() alone accepts a mode-600 directory that spawn would fail with EACCES), and the same resolved path becomes both the subprocess cwd and the ACP session/new workspace.
StopReason mapping
ACP StopReason → harness SubagentStopReason: 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.
Security: scrubbed child environment
The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (/KEY|PASSWORD|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
- Keyless unit/integration: A scripted ACP subprocess exercises real stdio for prompt/output flow, every stop-reason mapping, signal and disposal cancellation (including pre-abort, pre-session race, and torn-pipe cases), both permission policies, ignored non-message updates, missing-command cleanup, provider reload, and namespace exports.
- Keyless Loader composition: A test-only cordis.yml boots the stdio app through the real Loader with the backend's
cwdomitted; a scripted model delegates once and the scripted child proves it ran in — and was announced — the parent session's workspace (the cwd-inheritance branch end to end). - With-key e2e: The backend spawns the real ACP example; its model answers
PONG, writesproof.txt, and the parent verifies the file. - Snapshot gap: Each ACP child is a separate process with its own replay session, unlike in-process per-session replay. Deterministic mock-server coverage exists, while
TODO(acp-subagent-replay)tracks parent replay against a replaying child.
Alternatives considered
Why stay on SDK 0.25.1?
The backend needs only ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, and the client protocol types, all supported in 0.25.1. The 0.28 fluent API would require migrating both client and server connection classes across the ACP layer without improving this backend, so that upgrade remains a separate change.
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.
Product-provider siblings
The Codex app-server and Claude Code Agent SDK providers apply the same out-of-process spawn/prompt/settle/cancel boundary as siblings registered by name. A2A remains a future sibling transport; the ACP backend proves that the subagent seam supports this boundary without owning product-private protocols.
中文
问题
subagent seam(seam Agent Note)的设计使多个后端可以按名称共存于 ctx.subagents。进程内后端(-spawn/-fork)将子 agent(智能体)作为第二个 Agent 运行在同一个 Cordis 上下文中:开销低,但子 agent 与父 agent 共享进程、模型客户端和工具。seam 的核心意义在于同时支持通过协议到达的进程外子 agent,以证明该抽象可跨进程边界适用。本 Agent Note 添加第一个此类后端:一个 ACP(Agent Client Protocol)客户端。
决策
@deepseek-ai/dsh-subagent-acp 注册一个 SubagentProvider,将每个子 agent 运行在一个通过 spawn 启动的子进程中,并以 ACP 客户端身份驱动它。它是现有服务端桥接 @deepseek-ai/dsh-acp(ACP agent)的方向反转孪生体:桥接应答 initialize/newSession/prompt;本后端调用它们并实现 Client 回调(sessionUpdate、requestPermission)。将配置的 spawn 命令指向 acp-agent 示例,即可让 harness 与自身进程通信。
每次运行启动全新进程
每次 start 都 spawn 一个新的子进程,运行恰好一个 ACP 会话(initialize → newSession → prompt),dispose 杀死子进程并等待其退出。这是最简单的生命周期,与进程内「每次运行一个子 agent」的形态一致。
最小化客户端桩
客户端不声明任何可选能力(无 fs、无 terminal):子 agent 在自己的进程中自行处理文件/终端访问。session/update 通知被消费:后端将 agent_message_chunk 文本累积为结果输出,忽略其余内容(思考、工具调用卡片),因此仅暴露子 agent 的最终回答。session/request_permission 由配置的策略自动应答(reject 拒绝所有提示,allow 通过第一个表示允许的选项批准)——不向人类暴露任何权限提示。将 fs/terminal 代理回父进程(共享工作区模式)仍为后续工作,如 seam Agent Note 所述。
无启动时能力
提供方的 capabilities 全部为 false。进程外子 agent 无法遵守父 agent 的 maxDepth(它无权访问 parent.options.subagentDepth)或 toolFilter(它拥有自己的工具注册表),本阶段也未实现 outputSchema。如果请求需要其中任何一项,服务在 start 运行前即拒绝。后端仅注入 subagents(而非 ctx.agents);它从 request.parent 读取的唯一内容是会话 header 的 cwd(见下方工作区解析)——对话上下文、深度和工具状态都不会跨越进程边界。
工作区 cwd 解析
子进程工作目录来自显式解析,绝不使用 harness 进程的 cwd:若已配置部署 cwd 覆盖,则相对于启动目录将其转为绝对路径并在加载时验证;否则使用父会话 header 的 cwd 并在启动时验证;如果两者都不存在,则在 spawn 任何进程前响亮拒绝。一个 ACP 服务端进程会服务来自多个工作区的会话,因此 process.cwd() 不能代替会话工作区——旧的隐式回退会让子进程在服务端启动目录中运行。候选路径必须是 harness 可以进入的绝对目录(要求 X_OK;仅 statSync().isDirectory() 会接受 mode-600 的目录,而 spawn 会因 EACCES 失败);解析出的同一路径同时用作子进程 cwd 与 ACP session/new 工作区。
StopReason 映射
ACP StopReason → harness SubagentStopReason:end_turn→completed、max_tokens→max-tokens、refusal→refusal、cancelled→aborted、max_turn_requests→error(无对等语义,任务未完成)、未知→error。spawn/传输/RPC 失败时,结果为 error(如果已请求取消则为 aborted);按 seam 约定,result 在子 agent 级别失败时从不 reject。
安全:清洗子进程环境
子 agent 是独立进程,因此会继承环境变量。形如凭证的环境变量(/KEY|PASSWORD|SECRET|TOKEN/i)默认不转发——父 harness 自身的密钥不得隐式泄露到 spawn 启动的进程中(与 bash 执行器采用的策略相同)。子 agent 自己的凭证(它需要模型密钥)通过 config.env 显式提供,在清洗之后叠加,因此有意传入的 DEEPSEEK_API_KEY 得以保留,而偶然存在的 AWS_SECRET_ACCESS_KEY 则不会。子进程的 stderr 继承到父进程的 stderr(诊断信息自然浮现);spawn 级别的 error 事件(如命令不存在时的 ENOENT)被捕获并与 ACP 驱动竞速,因此错误命令的结果为 error 而非以未处理错误崩溃父进程。
测试
- 无需密钥的单元/集成测试: 一个脚本化的 ACP 子进程通过真实 stdio 测试提示词输入/输出流程、所有 stop-reason 映射、信号与 dispose 取消(包括 pre-abort、会话前竞态和管道断裂场景)、两种权限策略、被忽略的非消息更新、命令缺失时的清理、提供方重载以及命名空间导出。
- 无需密钥的 Loader 组合测试: 仅用于测试的 cordis.yml 通过真实 Loader 启动 stdio 应用,并省略后端的
cwd;脚本化模型委派一次,脚本化子进程则证明它在父会话工作区中运行,且 ACP 也对外公布了该工作区,从而端到端覆盖 cwd 继承分支。 - 需要密钥的 e2e 测试: 后端 spawn 真实的 ACP 示例;其模型回答
PONG,写入proof.txt,父进程验证该文件。 - 快照缺口: 每个 ACP 子 agent 是独立进程,拥有自己的回放会话,不同于进程内的按会话回放。已有确定性 mock 服务器覆盖;
TODO(acp-subagent-replay)跟踪父进程对回放中子 agent 的回放支持。
曾考虑的替代方案
为何继续使用 SDK 0.25.1?
后端只需要 ClientSideConnection、ndJsonStream、PROTOCOL_VERSION 和客户端协议类型,0.25.1 全部支持。0.28 的 fluent API 需要在 ACP 层同时迁移客户端和服务端连接类,却不会改善本后端,因此升级作为独立变更保留。
为何不使用持久子进程?
持久进程池(跨运行复用热子进程)是一项性能优化,推迟到后续工作。它增加了会话生命周期和崩溃恢复的复杂度,本阶段不需要;每次 start spawn 全新子进程与进程内「每次运行一个子 agent」的形态一致。
后果
每次运行都要付出一个全新子进程的代价(spawn + initialize + newSession)。父进程仅暴露子 agent 的最终回答:session/update 中的思考和工具调用卡片被消费后丢弃,权限提示从不到达人类——由配置的策略应答。子进程环境默认经过凭证清洗,因此其自身的模型密钥需通过 config.env 显式提供。
兄弟产品提供方
Codex app-server 与 Claude Code Agent SDK 提供方作为按名称注册的兄弟提供方,采用同样的进程外启动/提示词/结算/取消边界。A2A 仍是未来的兄弟传输方式;ACP 后端证明了 subagent seam 能够支持这项边界,而无需负责产品私有协议。