Subagent 出生与 lineage 管理
揭示 subagent 的完整父子树结构:depth 单调递增、lineage 持久化、one-shot 与 continuable 两种生命周期形态、以及 parent mailbox 通信机制。
导言:不只是 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
}
三个保护层:
- 整数溢出保护——
Number.isSafeInteger防止极端嵌套场景下精度丢失。 - maxDepth cap——调用方可传入绝对上限,比如部署配置为 3,意味着最多三层子 agent。
- SubagentDepthError——一个带结构的错误类,carry
attemptedDepth和maxDepth,让上游 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 恢复时,delegationDepth、parentSession、seedLength 都从冻结的 header 中直接读出。测试明确断言了这一点:
expect(a2.session.header.delegationDepth).toBe(1)
resume 不重新计算 depth——它从持久化来,到持久化去。这是”单调守恒”在时间轴上的延伸:不管 child 被杀多少次、resume 多少次,它的 depth 永远是出生时的那个值。
第三节:Parent-Child 树的运行时表示
3.1 AgentRegistry 中的 owner 字段
AgentRegistry 的 AgentEntry 结构维护了运行时 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 lineage | session.header.parentSession | 跨进程、跨 restart 的持久回溯 |
| Runtime ownership | AgentEntry.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?
- 不阻止 GC——祖先 Agent 被注销后自然回收,不因为后代还引用它就保留内存。
- O(1) membership check——
interrupt操作需要验证发起者是否是目标的祖先,WeakSet 提供常数时间检查。 - 不可枚举——注释明确说”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 的流程:
assertSubagentMaxDepth+resolveChildDepth检查预算captureDelegatedPolicyOverrides快照 parent 的 sandbox mode + approval policyctx.agents.create()带childSessionMeta和 setup callback- setup 中
appendDelegatedPolicyOverrides+applyChildComposition+ structured attach - 返回
drivePublishedRun,它followup()一条 user message,whenIdle()等结束
4.2 Continuable:持久对话
Continuable 子 agent 由 SubagentContinuationManager 管理。没有 SubagentRun——manager 直接持有 AgentHandle,通过 child inbox 送入多条消息。关键区别:
| 特性 | One-Shot | Continuable |
|---|---|---|
| 生命周期 | 一个 turn,结果即终 | 多轮对话,可 followup/interrupt |
| 结果交付 | SubagentRun.result promise | settlement 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)
}
三层组装:
- Join parent preset——
composeFrom让 child 继承 parent 的 tool registry 和 prompt sections。没有这步,child 看到的是空 tool 列表。 - Delegation context statement——固定文本告诉模型”你是被委托的 subagent,权限边界已固定”。
- 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 做三件事:
- Close admission——标记 parent 下不再接受新 child 的创建。
- Stop visible descendants synchronously——遍历
ownedChildren,逐层向下 cancel。 - Await child-first disposal——等待最深层的 child 先 dispose,然后逐层向上释放
AgentHandle。
这就是”child-first disposal order”:永远是叶子节点先死,parent 最后才放手。原因很直白——如果 parent 先被拆,child 的 settlement delivery 就找不到目标 inbox 了。
7.2 Activation 的 settled 判定
ActivationState 有三态:running、waiting、settled。关键转换:
running->waiting:Agent loop idle + inbox 空 +accepted集合空,但ownedChildren非空waiting->settled:ownedChildren被清空(所有 child handle 已 dispose)settled触发:observercapture->terminal-> handle dispose -> observersettle-> 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 WeakSet | O(1) 祖先验证 + 不阻止 GC |
| Child-first disposal | 保证 settlement delivery 有目标 inbox |
| Policy snapshot before first await | Parent 的后续变更不影响已委托的 child |
| Descriptor 写入 child log | Cold 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 设定的权限边界。