深入浅出 DeepSeek Harness 第五部:Goal与多Agent——谁在掌舵 Chapter 33

另一个产品里的 Agent,当你的子 Agent

把 Claude Code、Codex、ACP Agent 当子 Agent:provider 能力声明与拒绝。外部 Agent 每次运行都是独立进程、独立会话、独立模型,五个启动期能力一个都不认;父会话没有工作目录、请求要求了不支的能力、或者 provider 没注册,都在启动前被响亮拒绝。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,一个会话决定把活交给 Codex:ctx.subagents.start('codex', { prompt, parent })。子 Agent 服务先查注册表——没注册直接 NO_PROVIDER;注册了,逐面核对请求要求的能力:agentOptions、outputSchema、depthLimit、toolFilter、persona,Codex 一旗都没有,要求了任何一面就是 UNSUPPORTED_CAPABILITY。这些失败都在 provider.start() 之前发生:没有 run、没有生命周期事件,只有一句固定格式的错误。真正启动时,Codex 的包内命令是写死的 app-server --stdio,先初始化、再 thread/start 建一条临时线程,线程存在之后才发布 run;权限默认 never——没人点头,Codex 不碰文件。子进程的环境是凭据擦除后的副本加显式层叠,父会话能传给孩子的只有工作目录。

这一章接着第 32 章的”哪种孩子”:外部 Agent 是出进程的孩子。上一章讲谱系、目录与生命周期事件;这一章讲 provider 契约的四个边界——能力声明、工作目录、环境、失败语义。

外部 Agent 作子 Agent:子 Agent 服务 start() 先 expectProvider(NO_PROVIDER)→ assertCapabilities(五面旗逐项,UNSUPPORTED_CAPABILITY)→ 深度/输出校验 → provider.start;外部 provider 全部 NO_START_CAPABILITIES、inheritsParentContext=false,唯一读父会话的是 workspace cwd(resolveChildCwd 拒绝而非回退);进程归共享子进程服务。三种产品差异:Codex 包内固定命令 app-server --stdio、先 initialize 再 thread/start 才 publish、权限默认 never;Claude Code SDK + dontAsk;ACP 独立进程、permission 默认 reject 自动应答。发布前失败抛固定安全事实(product/stage/category);发布后 result 永不 reject、扁平化为 stopReason(aborted/error)、诊断 4 KiB 上限。

provider 契约:五面旗先于一切

SubagentProvider 契约只有三个成员:capabilities(启动期能力声明)、inheritsParentContext(是否继承父上下文)、start()(把已解析的请求变成 run)。SubagentCapabilities 的五面旗一一对应 SubagentStartRequest 的一个选项:

旗请求字段兑现的含义
agentOptionsagentOptions?启动期 Agent 选项覆盖(进程内孩子要能照做)
outputSchemaoutputSchema?按 schema 产出结构化结果(provider 要能保证输出形状)
depthLimitmaxDepth?子 Agent 自身的深度限制
toolFiltertoolFilter?子 Agent 的工具白名单
personapersona?给这个孩子的部署 persona 前缀(覆盖部署级 persona)

服务侧 assertCapabilities 在 start() 里第一个跑:expectProvider(未注册 → NO_PROVIDER)之后,逐项 when && !cap → UNSUPPORTED_CAPABILITY。顺序写死:先证明 provider 存在且能兑现,再谈别的(然后才是深度断言与输出 schema 校验)。

外部 provider 的声明全部一样:Object.freeze({ agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false })——NO_START_CAPABILITIES。out-of-process.ts 的注释把为什么写明白了:“A child in another process cannot honor parent-enforced start features … so the service rejects a request needing any of them before start runs — never accepted-then-ignored”。接受然后忽略是契约欺诈:模型以为 persona 生效了,子进程根本没看它。宁可启动前响亮失败,不让调用方带着错误预期跑完整场。

发布前失败还有一个额外语义:start() 的注释说”Provider ownership lasts until its promise fulfills; a rejection therefore has no run for the caller to dispose and emits no run lifecycle events”——没有 run 可处置,目录里不留孩子、不触发生命周期事件。调用方拿到的就是那个错误本身。

本节源码依据(6 处)

出进程的孩子,只继承一个东西:工作目录

inheritsParentContext = false 是外部 provider 的第二个共同点。子进程是新环境:新进程、新会话、新模型、新工具集——父会话的对话、消息、投影一概不跨进程边界。那父会话能给孩子什么?只有一样:workspace cwd。

resolveChildCwd 的解析顺序写死:

  1. 部署配置的 cwd 覆盖(加载时已校验:空串拒绝——path.resolve('') 是进程 cwd,会静默重新引入启动目录回退;相对路径按 harness 启动目录解析;必须是可进入的目录);
  2. 没有配置 → 父会话的 workspace cwd(request.parent.session.header.cwd);
  3. 都没有 → 响亮失败:“no working directory for the child — configure cwd or delegate from a parent session that has one”。

第 3 步是刻意的:注释说”falling back to the harness process cwd would silently bind the child to the server’s launch directory instead of the delegating session’s workspace (one server process serves many sessions, each with its own cwd)“——服务器进程是共享的,每个会话有自己的 cwd;回退到服务器目录等于把孩子的文件操作绑到无关的目录上。assertUsableCwd 还会做可进入性探测:statSync().isDirectory() 为真的 mode-600 目录,accessSync(X_OK) 会失败——子进程 cwd 需要执行权限,不然 spawn 阶段 EACCES;探测在进程边界前做,而不是让 spawn 抛一个模糊的 ENOENT。

所以 Codex provider 的 start() 第一件事就是:const parentCwd = request.parent.session.header.cwd;没有就直接拒绝(“delegate from a parent session that has one”)。cwd 同时也是孩子的 workspace 身份——它双重身份:进程工作目录 + 孩子会话的工作区,绝对路径要求因此不是洁癖,是语义。

本节源码依据(6 处)

三种产品,三种接线

外部 provider 的差异全在”怎么把外部产品跑起来、怎么回答它的交互”上:

产品启动权限默认关键差异
Codex包内固定命令 [execPath, bin, 'app-server', '--stdio'],stdio 全 pipenever先 initialize 再 thread/start,临时线程存在后才 publish;权限映射官方 thread/start 字段
Claude Code官方 Agent SDK 起 CLI,进程归共享子进程 ownerdontAsk权限模式 dontAsk(还有 acceptEdits/auto/bypassPermissions)
ACPcommand + args 起子进程(可执行由部署配置)reject独立 process/session/model;session/request_permission 自动应答,reject 一律拒绝、不浮提示

Codex 的命令是包内写死的:codexAppServerArgv() 从 @openai/codex 的 package.json 的 bin.codex 解析出绝对路径,拼上 app-server --stdio——“Fixed package-local app-server command, independent of the host PATH”。为什么?PATH 是 Host 的执行世界(第 08 章起一路强调),依赖 PATH 找 codex 等于让环境决定孩子是谁;包内路径钉死版本与位置。启动顺序也写死:wire.initialize(request.signal) 过了才 wire.startThread(spec.cwd, request.signal),startupStage 从 'initialize' 推进到 'thread-start'——发布前失败能报出是哪一步。

权限默认是最小面:Codex 的 never(approve-for-me、dangerously-bypass-approvals-and-sandbox 要显式改)、Claude Code 的 dontAsk、ACP 的 reject——三个产品三个默认,但方向一致:不配置就不给。ACP 的 reject 语义值得单独说:子 Agent 的 session/request_permission 提示被自动应答为拒绝,没有任何提示浮给人类——无人值守的子 Agent 不许停下来等人。

进程归共享子进程服务:三个 provider 的 spawn 都走 this.ctx.subprocess.spawn(spawnSpec)——不是自己 fork,是借 第 18 章的共享子进程服务。spill、grace、managed range 的清理语义对孩子进程同样生效:孩子也在 managed range 里,Host 退出/租约到期会按同一套阶梯收掉。

环境是显式给的:env 配置”layered over the subprocess seam’s credential-scrubbed parent environment”——子进程拿到的是凭据擦除后的副本加显式层叠。ACP 的注释给了一个具体场景:孩子要自己的 DEEPSEEK_API_KEY,必须显式配进 env,父环境里的同类凭据不会隐式泄漏进子进程。

本节源码依据(8 处)

失败的两副面孔:发布前固定事实,发布后扁平化

外部孩子的失败有两副面孔,分界点是发布。

发布前(注册、能力、启动阶段):错误直接抛给调用方,消息只含固定安全事实——Codex 的 failureDiagnostic 只拼 product: Codex、stage、category、可选的 HTTP status 与进程 exit code/signal。注释原话:“Hide an unpublished Host failure behind fixed safe startup facts.” 为什么?启动失败的根因可能包含 Host 内部信息(配置路径、内部服务名);在孩子还没发布、调用方还不需要处置它的时候,把这些细节原样抛出去等于把 Host 内部泄露给外部组件。固定事实足够调用方记日志、上报,但不携带内部拓扑。ACP 的 acpConfigurationFailure 同理。

发布后:settleRunResult 的契约是一句话——result never rejects。三种结局:

  • attempt 正常完成且未取消 → 正常结果(stopReason 由 attempt 提供);
  • 本地取消已抢先定案 → { output: collectOutput(), stopReason: 'aborted' };
  • 其它 rejection → 扁平化为 { output, diagnostic?, stopReason: 'error' }——diagnostic 走被包含的诊断槽(onError 里再抛错也不影响 result,注释:“The diagnostic sink cannot reject the run result”),且强制 4 KiB 字节上限(MAX_SUBAGENT_DIAGNOSTIC_BYTES,截断不拆 UTF-8 序列,加 [diagnostic truncated] 后缀)。

为什么发布后永不 reject?因为发布那一刻,调用方已经把 run 接进生命周期系统(目录、事件、取消信号)。一个会 reject 的 result 会从哪个面炸出来不可控;永不 reject + stopReason 让所有下游都能按同一形状处理终局。

subprocessRunHandle 的 dispose() 也钉死三件事:摘 abort 监听 → requestCancel()(本地取消定案,“there is no assumption the child cooperates”)→ 后端 teardown() 到进程静默。幂等:一次 memoized teardown,重复 dispose 返回同一个 promise。外部孩子没有 localAgent(localAgent: undefined)——没有会话可挂目录,run 就是全部。

本节源码依据(7 处)

为什么是”绝不接受后忽略”

把这一章和第 32 章并起来看:进程内孩子能兑现父强制的启动特性(persona、toolFilter、depthLimit),所以它们的 capabilities 全 true;外部孩子一旗不认,所以服务在启动前就拒绝。这不是能力缺失的妥协,是契约的分界:外部 Agent 的边界不是”少了几面旗”,而是”子进程是另一个执行世界”——不共享 Cordis context、不共享凭据、不共享对话,只有 cwd 和显式给的环境跨过去。

下一章是这条线的自然延续:第 34 章讲孩子与父会话之间怎样发消息——进程边界之外,消息是唯一的联系。

本节源码依据(1 处)