青雲的博客
深入浅出 DeepSeek Harness 第五部:Goal与多Agent——谁在掌舵 第 29 章

Subagent 出生与 lineage 管理

揭示 subagent 的完整父子树结构:depth 单调递增、lineage 持久化、one-shot 与 continuable 两种生命周期形态、以及 parent mailbox 通信机制。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

导言:不只是 new Agent()

你可能以为”启动一个子 agent”就是调 ctx.agents.create() 然后拿回一个 handle。事实上,在这个调用落地之前,harness 做了远比你想象中更多的事:它计算 delegation depth、在 session header 上打上 parent lineage、为子 agent 快照 policy、把 descriptor 写进子 session 日志、并在 Cordis scope 树上挂好 owner-child 关系。本章拆解这整条链路——从 depth 预算到 child-first 的有序拆除。

Subagent 这章看起来细节很多,但你先记两件事就不会迷路:它一边防你无限套娃,一边保证父子关系能追。

第一,防无限递归:depth 和 maxDepth 控制 child 树别没完没了往下长。 第二,保父子可追踪:lineage、session header、mailbox、settlement 让每个 child 从出生到结束都能被父级解释、被父级接住。

把这两个目标带在脑子里,再去看 depth 计算、lineage 持久化、parent mailbox、child-first dispose,就不会觉得是在看一堆工程碎片。


第一节:Delegation Depth——递归预算的单调守恒

1.1 depth 的含义

每个 agent 有一个非负整数 delegationDepth,表示它在祖先链上的位置。顶层 agent 是 0,它创建的 child 是 1,孙子是 2,以此类推。这个数值的核心用途是递归预算:防止 agent 无限地嵌套自己。

关键实现在 delegationDepthOf 函数中:

export function delegationDepthOf(agent: Agent): number {
  const runtime = agent.options.subagentDepth
  // ...验证 runtime 合法性...
  return Math.max(agent.session.header.delegationDepth ?? 0, runtime ?? 0)
}

注意 Math.max——这是一个单调守恒设计。header 里的 delegationDepth 在 session 创建时冻结,不可修改;runtime 的 AgentOptions.subagentDepth 只能把 depth 往上抬,绝不能往下压。这意味着一个被 resume 的 child 永远不可能伪装成顶层 agent:即使你手动把 subagentDepth 设为 0,header 里的持久值仍然作为 floor。

1.2 resolveChildDepth——子 agent 的 depth 计算

当父 agent 启动一个 child 时,resolveChildDepth 执行三步检查:

export function resolveChildDepth(parent: Agent, maxDepth: number | undefined): number {
  const childDepth = delegationDepthOf(parent) + 1
  if (!Number.isSafeInteger(childDepth)) {
    throw new RangeError('subagent child depth exceeds the safe-integer range')
  }
  if (maxDepth !== undefined && childDepth > maxDepth) {
    throw new SubagentDepthError(childDepth, maxDepth)
  }
  return childDepth
}

三个保护层:

  1. 整数溢出保护——Number.isSafeInteger 防止极端嵌套场景下精度丢失。
  2. maxDepth cap——调用方可传入绝对上限,比如部署配置为 3,意味着最多三层子 agent。
  3. SubagentDepthError——一个带结构的错误类,carry attemptedDepthmaxDepth,让上游 tool 层生成有意义的模型反馈。

1.3 assertSubagentMaxDepth——输入卫兵

startInProcessRun 的第一行就调用了 assertSubagentMaxDepth(request.maxDepth),对 config 层传来的 cap 做类型/范围验证(非负安全整数),把非法值拦截在 child 创建之前:

export function assertSubagentMaxDepth(maxDepth: unknown): void {
  if (maxDepth !== undefined && (
    typeof maxDepth !== 'number'
    || !Number.isSafeInteger(maxDepth)
    || maxDepth < 0
    || Object.is(maxDepth, -0)
  )) {
    throw new TypeError('subagent maxDepth must be a non-negative safe integer')
  }
}

第二节:Lineage 持久化——session header 上的父子链

2.1 childSessionMeta——子 session 的出生证明

每个 child 的 session header 都通过 childSessionMeta 一次性计算并冻结:

export function childSessionMeta(
  parent: Agent,
  childDepth: number,
  lineageSeedLength: number,
): NonNullable<CreateAgentOptions['meta']> {
  const parentHeader = parent.session.header
  const agentPreset = parent.ctx.get('agentPresets')?.composedPreset(parent.ctx)
  return {
    ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {},
    ...agentPreset === undefined ? {} : { agentPreset },
    parentSession: parentHeader.id,
    origin: 'subagent',
    delegationDepth: childDepth,
    ...lineageSeedLength > 0 ? { seedLength: lineageSeedLength } : {},
  }
}

四个关键字段:

  • parentSession——父的 session id,构成持久化链。通过逐级 parentSession 你可以从任意 child 回溯到 root。
  • origin: 'subagent'——session store 在创建时验证此值只能是字面量 'subagent',其他值直接 throw。
  • delegationDepth——冻结在 header 里的递归预算,session 重建/resume 时不需要再次计算。
  • seedLength——fork seed 的事件数,区分继承的父历史和 child 自己的工作。

2.2 session store 的验证边界

session store 对 header meta 做两道验证:

if (record.origin !== undefined && record.origin !== 'subagent') {
  throw new Error('session header origin must be "subagent"')
}
if (record.delegationDepth !== undefined
  && (typeof record.delegationDepth !== 'number'
    || !Number.isSafeInteger(record.delegationDepth)
    || record.delegationDepth < 0)) {
  throw new Error('session header delegationDepth must be a non-negative safe integer')
}

这意味着 lineage 信息在存储层就被守护——一个 malformed 的持久化文件不能让 child 变成 top-level。

2.3 resume 时的 lineage 保留

当 session 从 persistence 恢复时,delegationDepthparentSessionseedLength 都从冻结的 header 中直接读出。测试明确断言了这一点:

expect(a2.session.header.delegationDepth).toBe(1)

resume 不重新计算 depth——它从持久化来,到持久化去。这是”单调守恒”在时间轴上的延伸:不管 child 被杀多少次、resume 多少次,它的 depth 永远是出生时的那个值。


第三节:Parent-Child 树的运行时表示

3.1 AgentRegistry 中的 owner 字段

AgentRegistryAgentEntry 结构维护了运行时 owner 关系:

interface AgentEntry {
  readonly id: SessionId
  readonly agent: Agent
  readonly owner: Agent | undefined  // runtime parent
  readonly carrier: Scoped<Agent>
  announced: boolean
  announcing: boolean
  detachRequested: boolean
}

注意注释的措辞:“independent of durable session lineage”。这里存在两条平行链:

维度存储位置用途
Durable lineagesession.header.parentSession跨进程、跨 restart 的持久回溯
Runtime ownershipAgentEntry.owner进程内 scope 拆除顺序

两者通常一致,但在 cold resume 场景下可以不同:一个 resumed child 的 durable parent 可能不在当前进程中(比如 ACP 跨 worker 场景),这时 owner 就是 undefined

3.2 Activation 中的 ancestry WeakSet

对于 continuable child,Activation 结构里维护了一个更强的祖先追踪:

interface Activation {
  readonly childId: SessionId
  readonly parentSession: SessionId
  readonly provider: string
  readonly handle: AgentHandle
  readonly ancestry: WeakSet<Agent>
  readonly ownedChildren: Set<SessionId>
  // ...
}

ancestry 是一个 WeakSet<Agent>——它记录了这个 Activation 物化时所有 live 的祖先 Agent。为什么用 WeakSet?

  1. 不阻止 GC——祖先 Agent 被注销后自然回收,不因为后代还引用它就保留内存。
  2. O(1) membership check——interrupt 操作需要验证发起者是否是目标的祖先,WeakSet 提供常数时间检查。
  3. 不可枚举——注释明确说”a WeakSet is not enumerable”,所以 parentSession 字段另外存了 durable 直接父 id,供 settlement delivery 使用。

第四节:Two Subagent Shapes——One-Shot 与 Continuable

4.1 One-Shot:一次性委托

One-shot 子 agent 由 SubagentProvider.start() 建立,返回 SubagentRun

interface SubagentRun {
  readonly id: SessionId
  readonly localAgent: Agent | undefined
  readonly result: Promise<SubagentResult>
  dispose(): Promise<void>
}

你拿到 result promise,await 它得到 SubagentResult(含 output、structured、stopReason),然后 dispose。整个生命周期是同步的一个 turn——child 只做一轮工作,无论成功或失败,run 都 settle。

startInProcessRun 的流程:

  1. assertSubagentMaxDepth + resolveChildDepth 检查预算
  2. captureDelegatedPolicyOverrides 快照 parent 的 sandbox mode + approval policy
  3. ctx.agents.create()childSessionMeta 和 setup callback
  4. setup 中 appendDelegatedPolicyOverrides + applyChildComposition + structured attach
  5. 返回 drivePublishedRun,它 followup() 一条 user message,whenIdle() 等结束

4.2 Continuable:持久对话

Continuable 子 agent 由 SubagentContinuationManager 管理。没有 SubagentRun——manager 直接持有 AgentHandle,通过 child inbox 送入多条消息。关键区别:

特性One-ShotContinuable
生命周期一个 turn,结果即终多轮对话,可 followup/interrupt
结果交付SubagentRun.result promisesettlement delivery 到 parent inbox
持久化可选必须(descriptor 写入子 session 日志)
cold resume不支持支持——persistence load + Activation 再物化
Parent 通知tool result 直接返回SubagentSettledMessageSource 消息

continuable child 的物化通过 startContinuable 进入 manager:manager 预留 child id、调 prepareContinuable 从 provider 取 seed、组装 descriptor、seedDescriptorTurn 写入日志、然后 ctx.agents.create() 建立 Activation。


第五节:Parent Mailbox——子向父的通信

5.1 Report:子 agent 主动上报

Continuable child 可以在任意时刻通过 ctx.subagents.reportFrom(child, content, options) 向直接父发消息。这不是 tool call return,而是往 parent 的 Agent inbox 里投递一条带 SubagentReportMessageSource 的 user message:

interface SubagentReportMessageSource {
  readonly kind: 'subagent-report'
  readonly form: 'relay'
  readonly senderSessionId: SessionId
}

delivery 参数控制 parent 的反应:'quiet' 只存入 inbox 等 parent 下次轮询到它;'wakeup' 触发 parent 的 admission(如果 parent 正在 idle 则启动新 turn)。

5.2 Settlement Notification——子 agent 终结通知

当 continuable child settle 后(quiescent + 所有 owned children disposed),manager 向 parent inbox 投递一条 SubagentSettledMessageSource

interface SubagentSettledMessageSource {
  readonly kind: 'subagent-settled'
  readonly form: 'notice'
  readonly summary: string
  readonly senderSessionId: SessionId
}

form: 'notice' 意味着 UI 层展示时不会展开全文——它是一行摘要,告诉 parent “你的 child X 已完成/失败/被中断”。

5.3 Followup 与 Interrupt——父向子的通信

父对 continuable child 的操控通过两个 API:

  • followup(parent, childId, content, options)——投递下一轮 user message 到 child inbox。如果 child 当前不在内存(cold),manager 会 cold-resume 它。
  • interrupt(targetSessionId, authority)——向 live child 发 cancel signal,停止当前 turn。authority 可以是 { kind: 'user', parentSessionId }{ kind: 'ancestor', agent }——后者用 ancestry WeakSet 验证调用者确实是目标的祖先。

第六节:Child Composition——子 agent 的作用域组装

6.1 applyChildComposition

每个 child 在 unpublished creation window 内完成作用域组装:

export function applyChildComposition(
  childCtx: Context,
  parent: Agent,
  composition: ChildComposition,
): void {
  childCtx.get('agentPresets')?.composeFrom(childCtx, parent.ctx)
  childCtx.systemPrompt.context({
    name: 'subagent:delegation',
    order: 120,
    text: SUBAGENT_DELEGATION_CONTEXT,
  })
  if (composition.persona !== undefined) {
    childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona })
  }
  if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter)
}

三层组装:

  1. Join parent preset——composeFrom 让 child 继承 parent 的 tool registry 和 prompt sections。没有这步,child 看到的是空 tool 列表。
  2. Delegation context statement——固定文本告诉模型”你是被委托的 subagent,权限边界已固定”。
  3. Per-child persona 和 tool filter——可选的遮蔽层,让不同 child 有不同能力窗口。

6.2 Policy Snapshot——delegation 时的安全快照

captureDelegatedPolicyOverrides 在第一个 await 之前同步捕获 parent 的 sandbox mode 和 approval policy:

export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides {
  return {
    sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session),
    approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never',
  }
}

注意 approvalPolicy 永远是 'never'——子 agent 不能请求人类审批。这是一条硬性安全边界:一个被委托的 child 只能在 delegation 时固定的 sandbox scope 内操作,任何需要提权的操作都会被自动拒绝。

这些 policy 随后通过 appendDelegatedPolicyOverrides 写入 child session 日志:

export function appendDelegatedPolicyOverrides(
  childSession: Session,
  overrides: DelegatedPolicyOverrides,
): void {
  if (overrides.sandboxMode !== undefined) {
    childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' })
  }
  if (overrides.approvalPolicy !== undefined) {
    childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' })
  }
}

source: 'delegation' 标记让 resume 时能区分”这是出生时继承的 policy”和”child 运行中自己切换的 policy”。


第七节:Child-First Disposal——有序拆除

7.1 drainContinuableDescendants

当 host 需要关停一组 parent 时,不能简单 kill——必须先停所有后代:

async drainContinuableDescendants(parents: readonly Agent[]): Promise<void> {
  const manager = this.continuations
  if (manager === undefined) return
  await manager.drainDescendants(parents)
}

Manager 的 drainDescendants 做三件事:

  1. Close admission——标记 parent 下不再接受新 child 的创建。
  2. Stop visible descendants synchronously——遍历 ownedChildren,逐层向下 cancel。
  3. Await child-first disposal——等待最深层的 child 先 dispose,然后逐层向上释放 AgentHandle

这就是”child-first disposal order”:永远是叶子节点先死,parent 最后才放手。原因很直白——如果 parent 先被拆,child 的 settlement delivery 就找不到目标 inbox 了。

7.2 Activation 的 settled 判定

ActivationState 有三态:runningwaitingsettled。关键转换:

  • running -> waiting:Agent loop idle + inbox 空 + accepted 集合空,但 ownedChildren 非空
  • waiting -> settledownedChildren 被清空(所有 child handle 已 dispose)
  • settled 触发:observer capture -> terminal -> handle dispose -> observer settle -> parent settlement delivery

第八节:Descriptor Seed——子 agent 的出生记录

8.1 为什么需要 descriptor

一个 cold-resumed child 需要知道自己出生时的 composition(provider、label、mode、tool filter、persona)才能正确重建作用域。这些信息以 subagent/descriptor 事件写入 child session 日志的最前面(seed 之后、第一个 turn 之前)。

export function seedDescriptorTurn(
  childId: SessionId,
  seed: readonly SessionEvent[] | undefined,
  descriptor: SubagentDescriptorData,
): SessionEvent[] {
  const staged = Session.create(childId, seed)
  staged.append('subagent/descriptor', descriptor)
  return [...staged.events]
}

对于 one-shot child,descriptor 是在第一个 agent/pre-step hook 中 append 的(因为 one-shot 不走 seed)。两种路径最终效果相同——child session 日志里总有一条 subagent/descriptor 记录它的出生配置。


第九节:Client-Side Lineage 聚合

9.1 indexSubagentDescendants

Client runtime 维护了一个轻量的后代计数索引:

export function indexSubagentDescendants(
  summaries: Readonly<Record<SessionId, SessionSummary>>,
): ReadonlyMap<SessionId, SubagentDescendantSummary> {
  const indexed = new Map<SessionId, { count: number; runningCount: number }>()
  for (const descendant of Object.values(summaries)) {
    if (descendant.origin !== 'subagent') continue
    const seen = new Set<SessionId>()
    let current: SessionSummary | undefined = descendant
    while (current?.origin === 'subagent' && current.parentId !== undefined
      && !seen.has(current.id)) {
      seen.add(current.id)
      // ... 逐层向上累加 count/runningCount ...
    }
  }
  return indexed
}

注意 seen 集合——它防止 parentId 形成环路(虽然正常情况不会,但 corrupt persistence 可能产生)。算法对每个后代沿 parentId 链向上走,直到碰到非 subagent origin 的 session 就停。

这意味着 ordinary fork 会截断传播:一个 fork origin 的 session 不会被当成 subagent 后代,它的子树独立计数。


第十节:Lifecycle Events——可观测的出生与死亡

Harness 通过两个 scoped events 让外部观察者跟踪子 agent 生命周期:

  • subagent/start——child 的 Activation 就绪后 emit,payload 是 SubagentRunInfo(含 runId、provider、childId、local flag)
  • subagent/end——child settle 后 emit,payload 是 SubagentRunEndInfo(增加 stopReason、lastAssistantMessage)

两者通过 runId 配对。Scope-filtered dispatch 以 delegating parent 为 carrier,所以一个 parent-scoped listener 只能看到自己直接子代的生命周期,看不到 grandchild。

One-shot 和 continuable 共用同一对事件——外部观察者无需区分实现形态。


第十一节:几个关键设计选择

决策理由
depth 单调递增(Math.max)防止 resumed child 降权
header 冻结 lineage跨 restart 可追溯,无需重算
Runtime owner 独立于 durable lineage支持跨进程/ACP 场景
ancestry WeakSetO(1) 祖先验证 + 不阻止 GC
Child-first disposal保证 settlement delivery 有目标 inbox
Policy snapshot before first awaitParent 的后续变更不影响已委托的 child
Descriptor 写入 child logCold resume 可自包含重建,不依赖 parent
origin 严格验证存储层拒绝 malformed lineage

收口:子 agent 不是 new 出来的

Subagent 的“出生”不是一个简单的 new。它更像一笔事务:先算 depth 预算,把 lineage 写进 header,把 policy 做成快照,再把 scope 和 descriptor 组装起来。

父子关系同时在两条线上被跟踪:runtime 里用 Cordis scope owner 挂住所有权;durable 里用 session header 的 parentSession 把 lineage 固化下来。拆除的时候用 child-first disposal 保证顺序,祖先鉴权用 ancestry WeakSet 做到 O(1),对外可观测的生命周期则靠 scoped events。

整条链路最后只落到一条硬约束上:child 永远不能逃逸出 parent 设定的权限边界