DSH / Atlas
2026-06-21implementedfeature

Subagent capability seam

subagent 能力 seam

The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with t

English

Problem

The harness has a long-deferred seam for subagents — an agent delegating work to another agent. The intent was sketched in the Agent/AgentLoop interfaces (packages/core/agent/src/types.ts, packages/core/agent-loop/src/index.ts): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an Agent handle so steering and event subscription work uniformly.

Multiple subagent implementations must coexist at runtime. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports:

  • in-process — a child concrete Agent on the same Context (the cheapest, and nearly free given the existing agent factory);
  • ACP — act as an ACP client driving another agent process (which can be another instance of ourselves);
  • Codex app-server and Claude Code Agent SDK — current one-shot siblings that apply the same named-provider contract to official product processes (product-provider Agent Note);
  • later: A2A using the same out-of-process "start a child, prompt it, settle, cancel" shape.

Alternatives considered

Why not the bash seam shape

The bash seam (capability seams) registers exactly one ShellExecutor per context; loading a second throws. That is correct for bash (one machine, one way to run a command) but wrong here: coexistence is the requirement. So the subagent service is a named-provider registry — each implementation registers under a unique name and a caller picks one by name — mirroring the LLM adapter registry (LlmRuntime.registerAdapter), not the single-service bash executor. The seam is still three-package (Service Definition / Service Provider / Consumer); only the "one vs. many implementations" axis differs.

Decision

The three-package boundary

A new package group packages/subagent/:

PackageRole
@deepseek-ai/dsh-subagentinterface: SubagentRuntime (ctx.subagents), SubagentProvider, SubagentRun, the request/result/capability vocabulary, the subagent/* events
@deepseek-ai/dsh-subagent-spawn-in-processimplementation: a fresh in-process child via ctx.agents.create
@deepseek-ai/dsh-subagent-fork-in-processimplementation: an in-process child seeded with a snapshot of the parent's log
@deepseek-ai/dsh-subagent-acpimplementation: an ACP client driving a configured child process
@deepseek-ai/dsh-subagent-codeximplementation: a one-shot official Codex app-server process
@deepseek-ai/dsh-subagent-claude-codeimplementation: a one-shot official Claude Code process through the Agent SDK
@deepseek-ai/dsh-tool-subagentconsumer: the model-facing subagent tool over ctx.subagents

The primitive: async start → SubagentRun

A provider exposes start(request) → Promise<SubagentRun>. Fulfillment publishes a child and transfers its run handle to the caller. Work that fails before publication rejects start(), while prompt, turn, cancellation, and infrastructure outcomes after publication settle through run.result without hiding the child id. One signal covers cancellation before and after publication; dispose() cancels remaining work and awaits quiescence. A rejected start cleans unpublished resources and emits no lifecycle event, while a post-publication result failure closes the published lifecycle pair. start is transport-neutral; spawn names only the fresh in-process backend.

Two kinds of optional capability, discovered two ways

  • Start-time features (outputSchema, depthLimit, toolFilter, persona) ride on a static provider.capabilities descriptor. The service checks every requested one BEFORE delegating and rejects loud (SubagentError('UNSUPPORTED_CAPABILITY')) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods.
  • Continuable creation is the optional SubagentProvider.prepareContinuable method; presence is the capability and TypeScript narrowing is the discovery mechanism, so no separate flag can drift from the implementation. The continuation manager owns later delivery and cold resume directly through AgentHandle, while one-shot SubagentRun has no steering or resume operation, as refined by continuable subagents.

Fork vs. fresh are separate backends, not a flag

Fresh and forked children are separate providers, not a request flag. dsh-subagent-spawn-in-process starts an isolated child; dsh-subagent-fork-in-process seeds a balanced prefix containing only completed parent turns. The in-flight turn is excluded because its subagent call has no result yet and cannot form valid replay history.

Child isolation and the parent log

Each in-process subagent runs in its own Session (own id, parentSession lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local Agent or child Session; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn tool/call and its tool/result (the child's final output, or a failed result with optional provider diagnostic), while child steps and tool calls remain outside the parent log.

Synchronous collect (first cut)

dsh-tool-subagent passes its execution signal to start(), awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output; they present the optional safe diagnostic owned by the non-interactive permissions decision separately from partial assistant text. Independent result and disposal rejections remain independently observable.

Provider selection is config, not model-facing

dsh-tool-subagent binds to exactly one provider name (Config.provider); the model sees only { description, prompt }. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct toolName (the tool registry rejects a duplicate name). The service holds the multi-provider registry; the tool picks one — the schema carries no provider/type parameter.

Testing

Registry and tool tests replace only the nondeterministic child with a package-local scripted provider while exercising the real SubagentRuntime, lifecycle, task integration, and model-facing tool. Loader regression tests still cover the provider and consumer exports for the failure described in postmortem 0001. Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through per-session snapshot replay; in-process backends also have real-loop unit tests and a with-key e2e.

Consequences

  • Recursion. Without a bound, an in-process child can see the delegation tool and recurse. The in-process backends implement the optional absolute depth limit and scoped live-global toolFilter; ACP advertises both capabilities off and rejects such a request. The subagent composition-controls Agent Note owns their exact semantics and security limits.
  • Blocking the parent turn. Foreground collection holds the parent's step open for the child's full duration. Background delegation uses the shared ctx.jobs runtime and generic job_* tools, the same collection mechanism as background bash; the subagent seam itself remains task-agnostic.
  • Live progress. Only lifecycle + the final result surface; a per-chunk child→parent update stream is deferred with the background redesign.
  • ACP client surface. Proxying fs/terminal from the ACP child back to the parent (a shared-workspace mode) is future work; the backend advertises neither capability, so the child self-serves in its own process.

中文

问题

harness 有一个长期搁置的 seam 用于 subagent:一个 agent 将工作委派给另一个 agent。这一意图在 Agent/AgentLoop 接口中已有草案(packages/core/agent/src/types.tspackages/core/agent-loop/src/index.ts):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 Agent 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。

**多种 subagent 实现必须在运行时共存。**一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。传输方式:

  • 进程内:在同一个 Context 上创建一个具体的子 Agent(最廉价,且鉴于现有 agent 工厂几乎零成本);
  • ACP:作为 ACP 客户端驱动另一个 agent 进程(可以是自身的另一个实例);
  • Codex app-server 与 Claude Code Agent SDK:当前的一次性同类提供方,将同一个命名提供方约定应用于官方产品进程(产品提供方 Agent Note);
  • 后续:A2A,采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」。

曾考虑的替代方案

为何不采用 bash seam 的形状

bash seam(能力 seam)在每个上下文中只注册恰好一个 ShellExecutor;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个命名提供方注册表——每个实现以唯一名称注册,调用方按名称选择——镜像 LLM(大语言模型)适配器注册表LlmRuntime.registerAdapter),而非单服务的 bash 执行器。seam 仍然是由三类包构成的结构(Service Definition / Service Provider / Consumer);只是「一个 vs. 多个实现」这个维度不同。

决策

由三类包构成的边界

新建包组 packages/subagent/

角色
@deepseek-ai/dsh-subagent接口:SubagentRuntimectx.subagents)、SubagentProviderSubagentRun、请求、结果、能力词汇、subagent/* 事件
@deepseek-ai/dsh-subagent-spawn-in-process实现:通过 ctx.agents.create 创建全新的进程内子 agent
@deepseek-ai/dsh-subagent-fork-in-process实现:用父 agent 日志快照初始化的进程内子 agent
@deepseek-ai/dsh-subagent-acp实现:作为 ACP 客户端驱动已配置的子进程
@deepseek-ai/dsh-subagent-codex实现:一次性官方 Codex app-server 进程
@deepseek-ai/dsh-subagent-claude-code实现:通过 Agent SDK 运行的一次性官方 Claude Code 进程
@deepseek-ai/dsh-tool-subagent消费方:基于 ctx.subagents 的面向模型的 subagent 工具

原语:异步 start → SubagentRun

提供方暴露 start(request) → Promise<SubagentRun>。完成时发布一个子 agent,并将其运行句柄转交给调用方。发布前失败的工作会拒绝 start(),而发布后的提示词、轮次、取消与基础设施结果会通过 run.result 结算,且不会隐藏 child id。同一个信号覆盖发布前后的取消;dispose()(资源释放)取消剩余工作并等待完全停稳。启动被拒绝时会清理未发布资源,且不发出生命周期事件;发布后的结果失败则会结束已经发布的生命周期事件对。start 与传输方式无关;spawn 仅指代全新的进程内后端。

两类可选能力,两种发现方式

  • 启动时功能outputSchemadepthLimittoolFilterpersona)挂在静态的 provider.capabilities 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则响亮拒绝SubagentError('UNSUPPORTED_CAPABILITY')),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。
  • 可继续创建使用可选的 SubagentProvider.prepareContinuable 方法;方法是否存在本身即为能力,TypeScript 类型收窄即为发现机制,因此不需要可能与实现失同步的独立 flag。继续执行管理器直接通过 AgentHandle 负责后续投递与冷恢复,而一次性 SubagentRun 没有 steering 或 resume 操作,具体由可继续 subagent 细化。

Fork 与 fresh 是独立后端,而非一个 flag

全新子 agent 与 fork 子 agent 是独立的提供方,而非请求中的一个 flag。dsh-subagent-spawn-in-process 启动隔离的子 agent;dsh-subagent-fork-in-process 用一个平衡前缀初始化子 agent,该前缀仅包含已完成的父轮次。进行中的轮次被排除,因为其 subagent 调用尚无结果,无法构成有效的回放历史。

子 agent 隔离与父日志

每个进程内 subagent 运行在自己的 Session 中(独立 id、parentSession 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 Agent 或子 Session;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn tool/call 及其 tool/result(子 agent 的最终输出,或带可选提供方诊断的失败结果),而子 agent 的步骤和工具调用均留在父日志之外。

同步收集(首版)

dsh-tool-subagent 将其执行信号传给 start(),等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;它会把由非交互权限决策负责的可选安全诊断与部分 assistant 文本分开呈现。结果与 dispose 的拒绝仍可彼此独立地观察。

提供方选择是配置,不面向模型

dsh-tool-subagent 绑定到恰好一个提供方名称(Config.provider);模型只看到 { description, prompt }。若要暴露多种传输方式,请多次加载该工具插件,每次绑定不同的提供方和不同的 toolName(工具注册表拒绝重名)。服务持有多提供方注册表;工具选择其中一个——schema 中没有提供方/type 参数。

测试

注册表与工具测试仅用包内脚本化提供方替换非确定性的子 agent,同时测试真实的 SubagentRuntime、生命周期、任务集成和面向模型的工具。loader 回归测试仍覆盖提供方与消费方的 export,以防止事故复盘(postmortem)0001 中描述的失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过逐会话快照回放进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。

后果

  • 递归。 如果不设限制,进程内子 agent 能看到委派工具并递归调用。进程内后端实现了可选的绝对深度限制和有作用域的实时全局 toolFilter;ACP 声明这两项能力为关闭状态,并拒绝此类请求。subagent 组合控制 Agent Note 负责定义它们的确切语义和安全边界。
  • 阻塞父轮次。 前台收集在子 agent 的整个持续时间内保持父 agent 的步骤打开。后台委派使用共享的 ctx.jobs 运行时与通用 job_* 工具,与后台 bash 共用同一套收集机制;subagent seam 本身仍不感知任务。
  • 实时进度。 仅暴露生命周期事件与最终结果;逐分片的子→父更新流推迟到后台重新设计时一并处理。
  • ACP 客户端接口。 将 ACP 子 agent 的 fs/terminal 代理回父 agent(共享工作区模式)是后续工作;该后端不声明这两项能力,子 agent 在自己的进程中自行服务。