Parent Mailbox:父 Agent 怎样接住子 Agent 的结果
深度解析父子Agent消息投递机制——reportFrom的wakeup/quiet双模式、notifySettlement的顺序约束(必须先于releaseOwnership)、ChildLock的per-child Promise链串行化、以及cold resume恢复路径。这是harness多Agent协作中最容易出现竞态bug的关键子系统。
子 agent 跑完任务后,父 agent 不能只靠一个简单的 await childResult 拿结果。这里要处理的是跨 agent 的消息投递:内容要进父的队列,投递时机要避开 idle 误判,子 agent 结束时还要先通知父、再释放资源。
全错。
这一章拆解的是 harness 里父 Agent 接收子 Agent 结果的完整机制。它不是一个 inbox,不是一个 queue,甚至不是传统意义上的 mailbox——它是一套精心设计的、带有严格顺序约束的消息投递协议。这套协议解决的核心问题是:在异步并发环境中,如何保证子 agent 的最终结果不会在投递途中被丢弃?
答案藏在三个关键设计里:reportFrom 的双模式投递、notifySettlement 的顺序约束、以及 ChildLock 的 per-child 串行化。
两条消息路径:主动报告 vs 结算通知
子 agent 向父传递信息有两条完全不同的路径,它们的触发时机、投递语义、消息归属都不同:
路径一:主动报告(report tool / reportFrom)——子 agent 在运行过程中主动选择发送的内容。这是子的意志行为,source.kind 是 'subagent-report',内容是子自己写的文字。子可以在一轮 turn 中调用多次,也可以一次都不调用。报告不结束 turn,不终止 Activation,不阻止后续父的 followup。
路径二:结算通知(notifySettlement)——子 agent 结束时,manager 代替子自动发送的终止通知。这不是子的选择,source.kind 是 'subagent-settled',内容是 manager 生成的结算摘要。不管子正常完成、报错、token 超限、被 cancel——只要它曾经被成功创建(announced = true),manager 就一定会发。
这两条路径在代码里完全独立。report 走 reportFrom -> deliverReport;settlement 走 finishDisposal -> notifySettlement。它们共享 sendWaking 这个底层投递原语,但调用时机和错误处理截然不同。
reportFrom 的内部流水线
当子 agent 的 LLM 决定调用 report 工具时,执行路径是这样的:
report工具的execute函数把 args.output 包装成ContentBlock[]- 调用
ctx.subagents.reportFrom(exec.agent, content, { delivery, signal }) SubagentRuntime.reportFrom转发到SubagentContinuationManager.reportFrom- manager 做三件事:检查 signal 未 abort、检查 admission 未关闭、然后进入核心链路
核心链路分三步——authorizeReporter -> resolveReportParent -> deliverReport:
authorizeReporter:验证 child 是当前活跃的 Activation 的确切 Agent 实例。不是同 id 的另一个实例,是 === 同一个对象。如果 Activation 的 disposal 已经开始(activation.disposal !== undefined),报告被拒绝——不能往一个正在拆除的 handle 里塞东西。
resolveReportParent:从子的 session header 里读 parentSession,然后在 ctx.agents 注册表里查找这个 id 对应的活跃 Agent。如果父不在注册表里(已经 dispose 了、进程重启了),调用失败:“direct parent is not live; report was not delivered”。
deliverReport:构造消息并投递。消息是 user-role,内容是 "Background subagent <childId> reported:" 加上子发送的内容,source 是 { kind: 'subagent-report', form: 'relay', senderSessionId: childId }。
// 消息构造——deliverReport 里的核心逻辑
const message = createUserMessage({
content: [
{ type: 'text', text: `Background subagent ${activation.childId} reported:` },
...content,
],
source: {
kind: 'subagent-report',
form: 'relay',
senderSessionId: activation.childId,
},
})
注意 source.form 是 'relay' 而不是 'notice'。relay 表示”另一个 agent 主动向你发送的消息”,notice 表示”系统级的状态通知”。report 是前者,settlement 是后者。这个区别影响父 agent 的 LLM 如何解读这条消息——relay 展开全文显示,notice 可能只显示 summary。
wakeup vs quiet:两种投递调度
SubagentReportDelivery 是部署级配置,不是子 agent 每次调用可以选择的参数。tool-subagent-report 包在 apply() 时读取 config 的 reportDelivery 字段,然后每次 report 都用同一个策略。
wakeup(默认)——调用 parent.followup(message):
这会在父的 inbox 里加入一条消息,并且唤醒父。如果父当前 idle,followup 触发一个新 turn。效果是:父立刻看到这条消息,为它单独开一轮模型请求来处理。
为什么是默认?因为一个 parked(空闲等待中的)父 agent 如果没人唤醒它,它永远不会主动去检查 inbox。quiet 模式下一条报告可能永远不被处理,除非恰好有其他事件唤醒父。对大多数场景来说,子 agent 发了报告就是希望父立刻知道。
quiet——调用 parent.inject(message):
inject 把消息加入父的日志/上下文,但不唤醒父、不开新 turn。如果父当前 idle,这条消息就静静躺在那里等下次有人唤醒父时一并处理。如果父正在 running,消息会在下一个安全的日志边界被加入。
quiet 用于不紧急的进度通知——你不希望每个子 agent 的每次进度更新都让父开一轮模型请求(那样开销太大),只希望父下次自然醒来时能看到这些更新。
但注意 settlement 通知里的投递逻辑更复杂。它不只是 wakeup/quiet 二选一,而是有四种情况:
// notifySettlement 里的投递策略(简化)
if (父在 teardown) {
parent.inject(message) // 不唤醒,父马上要 dispose 了
} else if (父 idle) {
parent.followup(message) // 开新 turn
} else {
parent.steer(message) // 注入当前 turn
}
steer 是第三种投递方式——把消息加入父当前正在执行的 turn,作为当前步骤的额外输入。多个子同时 settle,它们的通知会被 steer 批量注入到父的同一个 step 里,只花一轮模型请求处理所有。这是一个重要的效率优化:如果每个子的 settlement 都 followup 开新 turn,10 个子同时结束就是 10 轮模型请求。
admitWaking:防止 idle 窗口误判
sendWaking 里有个很容易写错的顺序。看代码:
private sendWaking(
parent: Agent,
message: ReturnType<typeof createUserMessage>,
send: () => void,
): void {
const parentActivation = this.activations.get(parent.id)
if (parentActivation !== undefined && parentActivation.handle.agent === parent) {
this.admitWaking(parentActivation, message.id, send)
} else {
send()
}
}
如果父本身也是一个 continuable 子 agent(有自己的 Activation),投递必须通过 admitWaking。为什么不能直接 send?
问题在于 Agent.status 的 idle 窗口。当你调用 parent.followup(message) 时,这个消息进入 inbox,但 Agent 的状态不会立刻从 idle 变成 running——这之间有一个 microtask 的间隙。在这个间隙里,如果 settlement watcher 运行了 stateOf(parentActivation),它会看到 status 是 idle 并且 inbox 里的消息还没被 claim,从而错误地判定这个父也可以 settle 了。
admitWaking 解决这个问题:
private admitWaking(
activation: Activation,
messageId: MessageId,
send: () => void,
): MessageId {
// 先注册——在 send 之前
activation.accepted.add(messageId)
try {
send()
} catch (error: unknown) {
activation.accepted.delete(messageId)
throw error
}
this.wake(activation)
return messageId
}
在实际发送之前,先把 messageId 加入 activation.accepted 集合。stateOf 检查 settlement 时,如果 accepted.size > 0 就返回 'running' 而不是 'settled':
private stateOf(activation: Activation): ActivationState {
if (activation.handle.agent.status === 'running' || activation.accepted.size > 0) return 'running'
if (activation.ownedChildren.size > 0) return 'waiting'
return 'settled'
}
当消息被 inbox claim(Agent 开始处理它)或 discard 时,对应的 listener 会从 accepted 集合里删除这个 id。这就关闭了 idle 窗口——从消息发送到消息被处理之间的整个时段,父都不会被误判为 settled。
notifySettlement:顺序是一切
子 agent 的 Activation disposal 流程在 finishDisposal 方法中:
- cancel 当前 turn(同步,top-down 传播到所有后代)
- await 所有 owned children 的 disposal(child-first 释放)
- await 子 agent 自己 idle(quiesce)
- best-effort flush session 到持久化层
- observer.capture——在子还在注册表时抓取它的最终输出
- await handle.dispose()——释放 Agent Handle,从注册表移除
- activations.delete(childId)——从活跃 map 移除
- notifySettlement——向父发送结算通知
- releaseOwnership——从父的 ownedChildren 集合移除
- observer.settle——发布终端生命周期事件
步骤 8 和 9 的顺序是这个系统里最关键的不变量。代码注释用整整一段话解释为什么不能反过来:
“BEFORE releasing ownership, while the parent still counts this child and therefore cannot be judged settled. Delivering after the release would race a parent watcher that resumes one microtask later, finds itself childless and quiet, and disposes an Agent whose cancel() clears the inbox this notice is sitting in.”
让我把这个竞态条件完整画出来。假设反过来——先 releaseOwnership,再 notifySettlement:
时间线(假设顺序错误):
T1: releaseOwnership(childId)
→ 父的 ownedChildren 变空
→ wake(parentActivation) 触发 poke resolve
T2: 父的 settlement watcher 被唤醒(一个 microtask 后)
→ stateOf(parentActivation) 检查:
- agent.status === 'idle'? 是的
- accepted.size > 0? 不是
- ownedChildren.size > 0? 不是了!刚被释放
→ 返回 'settled'
T3: settlement watcher 进入 locks.run 开始 dispose 父
→ 父的 disposal 被赋值
→ 父的 agent.cancel({ kind: 'parent' }) 被调用
→ cancel 清空父的 inbox(keepInbox: false)
T4: notifySettlement 尝试向父发送消息
→ 但父已经在 disposal 中,inbox 已被清空
→ 消息丢失!
先通知再释放 ownership 的正确顺序:
时间线(正确顺序):
T1: notifySettlement → 构造 subagent-settled 消息
→ sendWaking → admitWaking(把 messageId 加入父的 accepted)
→ parent.followup(message)(消息进入父 inbox)
→ 或者 parent.steer(message)(如果父在 busy)
T2: releaseOwnership(childId)
→ 父的 ownedChildren 变空
→ wake(parentActivation)
T3: settlement watcher 被唤醒
→ stateOf 检查:accepted.size > 0?
→ 可能还是 > 0(消息还没被 claim)
→ 返回 'running',不 settle
T4: Agent inbox claim 这条 settlement 消息
→ accepted.delete(messageId)
→ 父处理消息,了解到子已结束
→ 之后 stateOf 才可能判定 settled
这个顺序保证了:settlement 通知在父的”子计数”归零之前就已经安全到达 inbox。父的 settlement watcher 不可能在通知到达之前判定父可以 settle。
settlementSummary:结算消息的文本生成
notifySettlement 构造的消息内容不是子自己写的,是 manager 根据子的终止原因生成的摘要:
function settlementSummary(childId: SessionId, stopReason: SubagentResult['stopReason']): string {
const subject = `Background subagent ${childId}`
switch (stopReason) {
case 'completed':
return `${subject} finished and will do no further work unless you send it more.`
case 'aborted':
return `${subject} was stopped before it finished.`
case 'max-tokens':
return `${subject} ran out of room before it finished.`
case 'refusal':
return `${subject} declined the task.`
case 'error':
return `${subject} failed before it finished.`
default:
return `${subject} ended abnormally (${String(stopReason)}) before it finished.`
}
}
消息还会附上子的 closing message(如果有的话)。closing message 来自 observer.terminal(failure) 返回的 ActivationTerminal.output——这是子最后一轮 turn 的 assistant 输出。如果子异常退出没有产出,就会附上 “It left no closing message.”。
注意 default 分支——SubagentStopReason 是 merge-extensible 的(后端可以添加新的变体),所以 default 把未知原因报告为”abnormally ended”而不是静默地报告为 completed。这是”fail loud”哲学的体现:一个不认识的终止原因一定比正常完成更严重,报告为 completed 会让父误以为任务完成了。
ChildLock:per-child Promise 链
同一个子 agent 可能同时面临多个操作请求:父发 followup、settlement watcher 准备 dispose、另一个消息想 cold resume。这些操作如果不串行化会产生灾难性竞态。
ChildLock 是解决方案——一个极简的 per-child Promise 链:
class ChildLock {
private tails = new Map<SessionId, Promise<unknown>>()
run<T>(childId: SessionId, operation: () => Promise<T>): Promise<T> {
const previous = this.tails.get(childId) ?? Promise.resolve()
const result = previous.then(operation, operation)
const tail = result.then(() => undefined, () => undefined)
this.tails.set(childId, tail)
void tail.then(() => {
if (this.tails.get(childId) === tail) this.tails.delete(childId)
})
return result
}
}
关键设计点:
previous.then(operation, operation)——onFulfilled 和 onRejected 都是同一个 operation。意味着前一个操作无论成功还是失败,下一个操作都会执行。一次 delivery 失败不应该”毒化” Promise 链导致后续 disposal 被跳过;disposal 失败也不应该阻止后续的 followup 尝试(虽然正常情况下 disposal 后不会再有 followup,但代码防御性地处理了这种情况)。
tail = result.then(() => undefined, () => undefined)——tail 吞掉 rejection,这样链接到 tail 上的后续操作不会收到前序的 rejection reason。每个操作的 rejection 只影响它自己的 caller,不会传播到链的下游。
自动清理——当 tail settle 后,如果 map 里的值还是这个 tail(没有被新操作覆盖),就从 map 里删除。这防止了内存泄漏——一个创建后再也没被操作过的子 agent 不会在 tails map 里留下垃圾条目。
per-child 而非全局——不同子的操作可以完全并发。你可以同时给 10 个不同的子发 followup,它们不会互相阻塞。只有对同一个子的操作才串行。
哪些操作走 ChildLock?看 continuation.ts 里的调用点:
startContinuable->locks.run(childId, ...)包裹 materialize + submitfollowup->locks.run(childId, ...)包裹 cold-resume 或 submitAdmittedwatchSettlement->locks.run(childId, ...)包裹 settlement 判定 + disposal
这意味着对同一个子:创建完成后才能接收第一条 followup,一条 followup 处理完才能处理下一条,settlement 判定不会和 followup 投递交叉执行。
watchSettlement:自动 settlement 检测
每个 Activation 在 materialize 成功后都会启动一个 settlement watcher(异步自运行的循环):
private watchSettlement(activation: Activation): void {
void (async () => {
while (disposalOf(activation) === undefined) {
const poked = activation.poke.promise
await Promise.race([activation.handle.agent.whenIdle(), poked])
if (disposalOf(activation) !== undefined) return
const settling = await this.locks.run<SettlementAttempt>(activation.childId, () => {
if (disposalOf(activation) !== undefined || this.stateOf(activation) !== 'settled') {
return Promise.resolve({ settling: false })
}
return Promise.resolve({ settling: true, done: this.dispose(activation) })
})
if (!settling.settling) {
if (activation.handle.agent.status !== 'running') await poked
continue
}
// disposal 完成或失败后返回
await settling.done.catch(...)
return
}
})()
}
这个循环不停地检查:子 agent 空闲了吗?它的所有后代都 dispose 了吗?如果两者都是,就自动开始 disposal。
关键细节:settlement 判定在 locks.run 里面做,并且在同一个 critical section 里开始 disposal。这保证了”我判定可以 settle” 和 “我开始 dispose” 之间不会有 followup 插入——因为 followup 也走 ChildLock,它会在 disposal 开始后排队等待,然后发现 activation.disposal 已经被赋值,于是等 disposal 完成后 cold-resume 一个新 Activation。
poke 是一个 PromiseWithResolvers——每次 ownership 变化(子的子 dispose 了)或 accepted 变化(消息被 claim 了)时被 resolve,然后重新创建一个新的。这让 watcher 不需要定时轮询,只在状态可能变化时醒来检查。
cold resume:从持久化恢复
当 followup 到达一个不在内存中的子 agent(没有活跃 Activation)时,manager 不会报错说”子不存在”——它会尝试 cold resume:
persistence.inspect(childId, signal)—— 从持久化层加载子 session 的 header 和事件序列authorizeLineage(parent, childId, loaded.meta.parentSession)—— 验证发送方确实是这个子的直系父 agentfoldSubagentDescriptor(events.slice(seedLength))—— 从子的 own suffix(跳过 seed)里 fold 出 descriptor,确认它是 continuable 模式materialize(...)—— 通过ctx.agents.resume()重建 Agent Handle,创建新 ActivationsubmitMaterialized(...)—— 把等待的消息投递到新建的 inbox
注意步骤 3 里的 events.slice(seedLength)。子 session 可能有一个来自父的 seed(fork provider 会把父的 completed-turn prefix 复制到子里)。seed 里可能包含父的祖先的 descriptor(如果父本身也是 continuable 子 agent)。fold 只看子自己的 suffix,这保证了拿到的是这个子自己的 descriptor,不是它祖先的。
cold resume 不经过 provider——这是设计上的关键。provider 只在第一次创建时通过 prepareContinuable 提供 seed,之后再也不参与这个子的生命周期。即使 provider 插件被卸载了,已经持久化的子 agent 依然可以 cold resume。恢复只需要持久化层和 Agent 注册表,不需要任何 provider 实例。
report 工具的注册机制
report 工具不是全局注册的——它只存在于 continuable 子 agent 的 scope 内。注册通过 SubagentActivationSetupRegistry 的 continuable setup contribution 机制:
// tool-subagent-report/src/index.ts
export function apply(ctx: Context, config: Config = {}): void {
const { reportDelivery } = Config(config)
ctx.subagents.registerContinuableSetup(childCtx =>
installReportTool(childCtx, ctx, reportDelivery))
}
registerContinuableSetup 注册一个函数,这个函数在每个 continuable 子 agent 被 materialize 时执行。它把 report 工具和对应的 prompt guidance section 都安装到子的 scope context 里。
这意味着:
- Root agent 看不到 report 工具
- One-shot 子 agent 看不到 report 工具
- 远程 provider(如 ACP)的子 agent 看不到
- 兄弟 agent 看不到
只有 in-process continuable 子 agent 才有这个工具。而且它故意设计为不受 toolFilter 影响——即使父通过 toolFilter allow-list 限制了子的工具集,report 依然可用。因为 report 是子唯一的返回通道,如果允许 toolFilter 把它移除,子就彻底失去了和父通信的能力。
prompt guidance section(order 117,排在所有 per-tool section 之后)告诉子 agent:
“Deliver your result with the report tool before you finish: call it once with a self-contained answer.”
这是指导而非强制——mechanism 接受零次或多次调用,不调用 report 的子 agent 不会被运行时拒绝。但如果子不调用,父只能通过 settlement 通知知道子结束了,可能看不到子的具体发现。
没有持久化 mailbox
这套机制有一个明确的设计边界:没有持久化 mailbox。README 里写得很清楚:
“Acceptance is weaker than durable delivery — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim.”
reportFrom 返回 MessageId 表示”父已接受这条消息”,但这不是:
- 已读回执(父可能接受了但还没处理)
- 持久化刷盘确认(进程 crash 可能丢失)
- 恰好一次保证(重试可能重复)
- 投递成功证明(
tools/post-executeveto 可能让一个已接受的 report 以工具失败的方式返回给子)
反过来,工具调用失败也不能证明未送达——report 可能已经被接受了,但后续的 post-execute hook 否决了这次工具调用。所以 report 工具的描述里说 “A failed call may still have arrived, so do not blindly repeat it.”
恢复依赖的不是 mailbox replay,而是子 agent 的持久化 transcript。如果父和子都 crash 了然后恢复,子的 session 日志里记录了它所有的输出,父可以通过 cold resume 子来重新获取它的状态。这是一个”子的日志就是真相来源”的设计,而不是依赖中间投递层的可靠性。
失败模式速查
父不在线时子 report:reportFrom 同步检查 ctx.agents.get(parentId) 是否返回有效 Agent。如果父已经从注册表消失,立刻抛 SubagentError code PARENT_UNAVAILABLE。子的工具调用收到 error result。子的 transcript 里记录了它的输出,持久化后仍然可恢复。
settlement 通知投递失败:notifySettlement 整体在 try-catch 里,失败只 log warning(ctx.logger.warn),绝不阻止 disposal 继续。这是刻意的——如果让 notification 失败阻止 disposal,一个已经死掉的子 agent 会永远卡在 ownedChildren 里,钉住整条祖先链无法 settle,造成永久内存泄漏。
并发 followup 和 dispose 撞车:ChildLock 保证它们串行。更具体地说,followup 进入 locks.run 后检查 activation.disposal !== undefined——如果 disposal 已经开始,followup 会等 disposal 完成,然后 cold resume 一个新 Activation。代码注释称这为”cold-resumes a delivery that lost the race with final disposal”。
父在 host-owned disposal 中但还在注册表里:这是一个 known limitation。AgentHandle.dispose() 的流程是 cancel -> await idle -> unwind scope -> 离开注册表。在 cancel 和离开注册表之间有一个窗口,report 仍然可以成功投递(因为父还在注册表里),但投递的消息永远不会被处理(因为父已经 cancel 了不会再开新 turn)。对于 continuation-manager-owned 的父,manager 的 admission boundary 会拒绝这个窗口里的操作。但 host-owned 的父没有这个保护——这是文档里列出的 known limitation。
wakeup 放大效应:如果有深层嵌套的 continuable 子 agent 树,每层子 settle 时 wakeup 唤醒父,父处理后自己也 settle 唤醒祖父——每一层都花一轮模型请求。对于不需要立即响应的部署,选择 quiet 模式可以避免这种级联唤醒。
从消息归属理解系统设计
对比两种消息的 source 结构,你能看出 harness 对”谁说了什么”的严格归属要求:
| 属性 | 主动报告 | 结算通知 |
|---|---|---|
| source.kind | 'subagent-report' | 'subagent-settled' |
| source.form | 'relay' | 'notice' |
| source.senderSessionId | 子 id | 子 id |
| source.summary | 无 | 有(一句话摘要) |
| 内容来源 | 子 agent 自己写的 | manager 生成的 |
| 语义 | ”另一个 agent 主动对你说话" | "系统告诉你某个子怎么结束的” |
把它们设计为不同的 kind 是刻意的:如果合并成同一种,transcript 里就分不清哪些话是子亲口说的、哪些是系统代子转述的。注释明确说:“a report is content the child chose, while this message is the manager stating what became of the child, and a transcript that merged them would credit the child with words it never wrote.”
这种区分对 LLM 很重要——模型需要知道”这是子 agent 给我的分析结论”还是”系统告诉我子 agent 出错了”,两者需要不同的后续处理策略。
收口:几条不能破的不变量
- reportFrom 是同步的(no-await):admission、authorization、delivery 在一个不让出执行权的 span 里完成,消除了 TOCTOU 竞态
- notifySettlement 必须在 releaseOwnership 之前:顺序反转导致消息在 idle 窗口被丢弃
- admitWaking 在 send 之前注册 accepted id:关闭 Agent.status idle 窗口的 settlement 误判
- ChildLock 串行化同一子的所有操作:then(op, op) 保证前序失败不阻断后续
- 没有持久化 mailbox:子的 session transcript 是唯一的真相来源,投递是 best-effort
- report 工具只存在于 continuable 子 scope 内:不受 toolFilter 影响,不被其他 agent 看到
这五条规则组合起来,构成了一个在单进程异步并发环境中可靠工作的父子通信协议。它不保证恰好一次,不保证持久投递,但它保证:只要双方都还活着,消息不会因为并发窗口而无声消失。
下一章进入 Worker 线程看 Workflow 编排——一种完全不同的多 Agent 组合方式,不依赖模型驱动的 tool calls,而是在沙箱里执行预定义的 parallel/pipeline 脚本。