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

子代理的血缘——subagent 怎么知道自己是谁的孩子

每个 subagent 都是一个独立 session,靠 subagent/descriptor 事件证明自己是谁、以什么模式(one-shot/continuable)出生;深度由会话头 delegationDepth 单调累加,resume 回来的孩子不会被当作顶层;目录投影 subagentCatalog 只列直接孩子、剔除 fork 继承的事实;foldSubagentDescriptor 取首个事件权威,未知版本整份判不可分类。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 0 秒,父 agent 调用 subagent 工具委派一个任务出去。你以为它开了个”子线程”——其实账本上是另开了一份独立的 session,这份 session 的第一回合开头写了一条 subagent/descriptor,宣告”我是某个 provider 建出来的孩子,我是 one-shot 还是 continuable,我的 label 是什么”。这条事件就是孩子的出生证。

subagent 血缘:父 session 委派 → 子 session 写 descriptor(log-only、version=3、首条权威);深度由 header.delegationDepth 单调地板累加,runtime 只能加深;目录投影 subagentCatalog 只列直接孩子,剔除 fork 继承的祖先事实;身份投影用 seq + isOwnSeq 证明"这条身份是自己日志后缀的"。

descriptor:孩子的出生证

subagent/descriptor 是一条被刻意做”小而硬”的事件。它在孩子第一个回合的开头、第一次 request 之前由建立它的 provider append 进去。docstring 写得很清楚:它是 log-only——不进 model 历史、不带 surfaceOp、过 compaction。

它故意只快照显式字段,而不是把整个 merge-extensible 的 AgentOptions 塞进去。原因在注释里:一个无关的扩展字段不能因为不是 JSON 就让 continuation 失败;以后要加 composition 输入,必须显式升 SUBAGENT_DESCRIPTOR_VERSION。

SUBAGENT_DESCRIPTOR_VERSION = 3 是当前格式版本。fold 时拿到一条 version ≠ 3 的事件,函数直接返回 undefined——孩子被判定为”本运行时不可分类”,绝不静默忽略或做字段级降级。这是 fail-loud 的统一原则。

本节源码依据(4 处)

两种孩子:one-shot 与 continuable

descriptor 的 mode 字段把孩子分成两类:

modelabel冷启恢复composition 快照
one-shot可选不可只记 version/mode/provider/label
continuable必填可额外快照 agentProvider/agentModel/agentReasoningEffort/persona/toolFilter

one-shot 是一次性委派——父调一次,孩子跑完一个回合,结果回传,孩子就此终止。continuable 是可恢复会话——父可以之后再给它发消息,冷启时从 descriptor 重建 composition。

continuable 的字段集比 one-shot 多了五个可选 composition 槽。为什么 one-shot 不需要这些?因为 one-shot 只跑一次,composition 已经在 run 里;continuable 要能冷启,必须把”当时部署给它的模型/persona/工具过滤”记下来,恢复时原样重放。

注意 descriptor 故意不存 subagentDepth 和 outputSchema。depth 由会话头 delegationDepth 单调地板承担,冷启信头;outputSchema 是单次 activation 的结果契约,不是 durable composition。

把两种孩子的所有差异并排看:

维度one-shotcontinuable
生命周期一个回合跑完即终止多轮对话,可 followup/interrupt
结果交付SubagentRun.result promisecontinuation manager 持 AgentHandle,按 inbox 投递
descriptor.label可选必填
composition 快照只记 version/mode/provider/label额外快照 agentProvider/agentModel/agentReasoningEffort/persona/toolFilter
冷启恢复不支持支持——persistence load + manager 再物化
provider 参与start() 全程参与prepareContinuable 只贡献 seed,后续由 manager 接管
可观测事件subagent/start + subagent/end 配对同一对事件,observer 无需区分形态
本节源码依据(3 处)

深度:会话头是地板,runtime 只能加深

委派深度是防止”孙代无限递归”的预算。规则在 delegationDepthOf 里:

return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)

两个来源:

  • 会话头 delegationDepth:持久化的、在 session 边界校验过的、单调地板。
  • runtime AgentOptions.subagentDepth:可选加深,但绝不能降低。

为什么要”绝不能降低”?docstring 解释了:resume 回来的孩子拿到新鲜的 options,如果从 0 开始计数,它就会假装自己是顶层、可以再往下委派无限层。所以 header 是硬地板,runtime 只能往上顶。

resolveChildDepth(parent, maxDepth) = delegationDepthOf(parent) + 1。超过 maxDepth 就抛 SubagentDepthError,消息是 subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}。

本节源码依据(3 处)

目录投影:直接孩子,剔除 fork 继承

父 session 的 subagentCatalog 投影列它的直接孩子。关键规矩在 docstring 里:“excluding fork-inherited facts”。

为什么要剔除 fork 继承?因为 fork 一份 session 会把祖先的事件流作为 seed 带进新日志——包括祖先曾经生过的孩子的事件。如果目录 fold 时把这些也当”我的孩子”列出来,fork 出来的新 session 就会突然继承一堆它从未真正委派过的孩子。所以目录只列”这条 session 自己 suffix 里”的孩子。

身份投影 subagent 也用同一招:它记录 seq——这条身份是从哪个 subagent/descriptor 事件 fold 出来的。用 session.isOwnSeq(seq) 证明它来自孩子自己的日志后缀,而不是 fork seed 重放出来的祖先 descriptor。

身份投影的取值是 SubagentIdentityProjection | null:null ⟺ 没有合法 descriptor(缺失/畸形/版本不识别——这三种情况故意不区分)。为什么用 null 而不是 undefined?因为它要过 JSON 传输,undefined 会被 JSON.stringify 丢掉,接收端会以为自己有个”旧的身份”还在。

本节源码依据(3 处)

runtime 投影:孩子跑了多久,回合是否正常

subagentTiming 是孩子自己的时间投影,两个字段:

  • settledMs:descriptor 之后所有已完成回合累计的毫秒数。
  • active.since / through:当前 open 回合的开始与最新事件时间(没开回合时 absent)。
  • lastTurnCompleted:最近一个 closed 回合是否正常完成。

这是 UI 用来画”这个孩子跑了多久、是不是卡住了”的依据。注意它只 fold descriptor 之后的事件——descriptor 之前的(比如 fork seed 带进来的祖先回合)不算数。

本节源码依据(1 处)

interrupt 鉴权:user 与 ancestor 两条路径

对一个 live 的 continuable 孩子发 interrupt,鉴权不是单一路径。SubagentInterruptAuthority 是个 union:

  • {kind:'user', parentSessionId}——人在客户端上按了”停”,带来的是 durable 的直接父 session id。
  • {kind:'ancestor', agent}——某条链上的 live 祖先 agent 要求中断,验证方式是这个 agent 的 recorded lineage 里确实包含发起者。

为什么要分两路?因为 user 来自 UI 层,没有 live agent 对象可查,只能信 durable 的 session id;ancestor 来自进程内,需要校验”你真的是它祖先”这条运行时关系。两条路各自的证据形态不同,不能混用。

本节源码依据(2 处)

provider 契约:能力声明,fail loud

SubagentProvider 是”怎么把孩子跑起来”的传输层。它声明一组 SubagentCapabilities:

{ agentOptions, outputSchema, depthLimit, toolFilter, persona }

service 在 start 之前先校验请求需要的能力是否被 provider 支持——不支持就直接拒绝,不接受-then-忽略。docstring 叫它 “fail loud, no silent degradation”。每个能力 flag 对应 SubagentStartRequest 的一个字段。

provider 还声明 inheritsParentContext——描述性地告诉工具层”孩子是不是继承父的 completed-turn prefix”,但它不是 service 校验的 start capability,也不影响工具注册、注入服务或授权继承。

本节源码依据(2 处)

推理段:为什么”出生证 + 目录 + 深度”要分开记?

把这三件事并排看,会发现它们各管一件事:

  • descriptor 管”这个 session 自己是不是孩子、是什么 mode”——身份问题。
  • catalog 管”我有哪些直接孩子”——关系问题。
  • delegationDepth 管”我能再往下派几层”——预算问题。

为什么不合并?因为它们的生命周期不同。descriptor 写一次就 immutable;catalog 随委派/终止不断增删;depth 是 header 上的单调地板,resume 时不能丢。把它们塞进一份字段,任何一个变更都会触发版本升级——这是把”多变”与”不变”混在一起的典型反模式。

更深一层:这套设计和前四章的 goal/plan/todo/schedule 是同一种哲学——身份是事件、关系是投影、预算是头部字段。孩子是谁由它自己日志里的 descriptor 证明(不靠父记忆),父有哪些孩子由目录 fold 出来(不靠主索引),深度由 header 单调累加(不靠 runtime 计数器)。三者独立可 replay、独立可 compaction、独立可 fork。

本节源码依据(4 处)

小结

  1. 孩子是独立 session:靠 subagent/descriptor 事件出生,log-only、过 compaction。
  2. 版本锁死:SUBAGENT_DESCRIPTOR_VERSION=3,加字段必升版本,未知版本判不可分类。
  3. 深度单调:header.delegationDepth 是地板,runtime 只能加深;resume 回来不会退化顶层。
  4. 目录剔 fork:catalog 只列直接孩子,身份投影用 seq + isOwnSeq 证明是自己日志后缀。
  5. fail loud:provider 能力不支持就拒绝,不接受-then-忽略。

下一章离开”怎么生”,看”生出来之后怎么和外部 agent 通信”:第 33 章。

只读验证

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD   # 应输出 477b4f420553e8a52c2fbccc464d7561b239c443

grep -n "SUBAGENT_DESCRIPTOR_VERSION" "$repo/packages/subagent/subagent/src/descriptor.ts"
grep -n "delegationDepth" "$repo/packages/subagent/subagent/src/depth.ts"
grep -n "SubagentDepthError" "$repo/packages/subagent/subagent/src/child-agent.ts"