Agent 之间的消息:send_message 的直邻路由
子 Agent 中途想给父回话,靠的是全局 send_message 工具——它只是薄适配,真正的路由全在 ctx.subagents.sendMessage:先核对精确 live sender,再按"驻留子→父"与"父→子"两个直邻方向分流;投递目标只有 steer(最近一步)与 queue(新 turn)两种,收件方 Agent inbox 是唯一 turn 队列。投递与 disposal 共用 ChildLock 临界区,错过截断就重试;收尾通知是 runtime 自己的话(subagent-settled),不是孩子的发言。
本章以中文撰写
第 0 秒,一个跑了三分钟的驻留子 Agent 中途发现了关键事实,它在自己的回合里调了 send_message。模型以为这像普通工具调用——参数一填,消息就发出去了。实际上这通调用穿过三层:工具层只做一件事——把 exec.agent 和 agent_id 原样交给 ctx.subagents.sendMessage;服务层先核对发信人是不是那个精确的 live Agent(不是就 UNAUTHORIZED),再判断你是”驻留子要找父”还是”父要找孩子”;投递层把消息放进收件方的 Agent inbox——steer 插最近一步,queue 排一个新 turn,然后 wake。你以为消息是”一封发出去的信”,其实消息是一次对 inbox 的写入,外加一次对 settlement watcher 的唤醒——没有中间信箱、没有消息队列、没有 Task 包装。
这一章接着第 33 章:外部 Agent 是出进程的孩子,这一章讲进程内驻留孩子与父之间那根唯一的消息线——send_message 与它背后的路由、投递和收尾通知。
一个全局控制 API:send_message 不是谁的私货
send_message 住在一个独立的小包里——@deepseek-ai/dsh-tool-subagent-control,而不是绑在某个 provider 上。这个包的头部注释把定位说得很直白:它是 “The globally named send_message and interrupt_agent tools: thin model-facing adapters over ctx.subagents.sendMessage() and ctx.subagents.interrupt()”,并且 “they perform no lifecycle routing of their own — residency, cold resume, and interrupt authorization belong to the subagent service”。为什么要独立出来?注释接着说:“they live apart from the provider-bound @deepseek-ai/dsh-tool-subagent instances so multiple delegation tools share one control API”——不管你用 subagent、subagent_fork 还是以后的别的委派工具,控制面只有一个,模型看到的永远是同一个 send_message。
工具注册本身薄到什么程度?apply(ctx) 里 inject = ['tools', 'subagents'],然后就两个 ctx.tools.register。send_message 的描述写得很克制:“Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation, not the agent’s answer.”——返回的是投递确认,不是对方的回答。agent_id 参数描述圈死了合法目标:“The agent id of your direct continuable child, or your direct parent when you are a resident continuable child.”——只有直邻:你的驻留孩子,或你自己是驻留子时的父。
execute 里除了取 exec.agent 和 brandString 之外什么都不做:
const sender = exec.agent
if (!sender) throw new Error('send_message requires a calling agent (exec.agent was undefined)')
const message: ContentBlock[] = [{ type: 'text', text: args.message }]
const messageId = await ctx.subagents.sendMessage(sender, brandString<SessionId>(args.agent_id), message, { signal: exec.signal })
return { messageId }
输出 schema 也只有一个 messageId,render 文案是 "message delivered to agent {agent_id}"——模型侧看到的反馈就是”信投到了”。
为什么工具层要故意这么薄? 因为路由、授权、冷恢复都是有状态的编排逻辑,住在服务里才能被所有调用方共享、被 disposal 路径复用。工具一旦自己记状态(比如缓存”我上次发给谁了”),就会和服务的 Activation 图对不上。薄适配还有一个好处:模型换个委派工具(spawn→fork),控制面语义不变。
本节源码依据(5 处)
精确 live sender:授权在路由之前
sendMessage 的第一行逻辑不是路由,是发信人身份核对:
if (this.ctx.agents.get(sender.id) !== sender) {
throw new SubagentError('message delivery requires the exact live sender agent', 'UNAUTHORIZED')
}
注意比较是 !==——对象同一性,不是 id 相等。为什么要这么严?Agent 在运行中可能被顶替、被 dispose 后同 id 重建(冷恢复);任何”拿着旧引用”的代码路径——比如一个还挂着旧回调的 listener——都不该能冒充这个 id 的 Agent 发信。id 相等只证明”像他”,对象同一才证明”就是他”。这与第 33 章里”发布前失败没有 run”的 fail-loud 一脉相承:身份不对,连路由都不进。
过了身份关,assertAdmitting(sender) 再挡一次——registry 或这条父链已经 drain 时,新投递直接拒绝(DRAINING)。
然后才是路由判定,三个分支:
| 判定 | 条件 | 走向 |
|---|---|---|
| 驻留子→父 | sender 有驻留 Activation 且 activation.parentSession === targetId | sendToParent(...) |
| 非驻留子→父 | sender 不是驻留 Activation,但它的 header 父正是 target | 拒绝 UNAUTHORIZED |
| 父→子(默认) | 其余所有情况 | deliverToChild(..., { delivery: 'steer' }) |
中间那条是最容易被忽略的闸门。消息原文写的是:agent "${sender.id}" is not a resident continuable child and cannot send to parent "${targetId}"。为什么非驻留孩子不能给父发信?因为它的回合根本不归 continuation registry 管——它没有 inbox 接管、没有 wake 通道、没有 announced 状态;让它发信等于往一个没有收件系统的地址投。直邻关系不是”会话记录上的血缘”,是”这条 Activation 图上的邻接”。
为什么路由把”驻留子→父”单独拎出来? 因为方向不同,投递的含义完全不同:孩子给父发信,是”中途汇报”——父可能正在忙,默认 steer 插一步;父给孩子发信,是”下发新指令”——要走 residency 与 cold resume 那套(下一节)。方向编码进了分支,不靠调用方传参猜。
本节源码依据(4 处)
sendToParent:两道闸门,然后才是 sendWaking
驻留子给父发信走 sendToParent。这是个同步函数——没有 await——因为它要抢在任何并发 teardown 之前完成。第一行就是闸门:
if (activation.inbox.closing !== undefined) {
throw new SubagentError(`subagent "${sender.id}" activation is being disposed; the message was not delivered`, 'ACTIVATION_CLOSING')
}
注释坦白了这个闸门的脆弱:“only synchronous re-entrant teardown can open this transaction between exact-agent authorization and this no-await span”——它是个短窗口,但窗口里不设防,信就投进正在拆的房子。第二道闸门是父的存活:
const parent = this.ctx.agents.get(activation.parentSession)
if (parent === undefined) {
throw new SubagentError('direct parent is not live; the message was not delivered', 'PARENT_UNAVAILABLE')
}
为什么父的 id 要单独存一份 activation.parentSession?因为 child 的 header 在 handle dispose 之后就够不着了(见 Activation 接口注释:“a WeakSet is not enumerable, and the child’s own header is only reachable through a handle disposal has already released”)——settlement 投递必须在 child handle 已经 dispose 之后还能解析出父。
过了两道闸门,才真正投递:createAgentMessage(sender, content) 包一层,然后 sendAgentMessage(parent, message)。sendAgentMessage 把底层异常翻译掉:sendWaking 抛错一律转成 PARENT_UNAVAILABLE——调用方只需要认得这一个错误面。
| 闸门 | 条件 | 错误码 |
|---|---|---|
| 自身 disposal | activation.inbox.closing !== undefined | ACTIVATION_CLOSING |
| 父不在线 | ctx.agents.get(parentSession) === undefined | PARENT_UNAVAILABLE |
| sendWaking 失败 | 底层任何异常 | PARENT_UNAVAILABLE(翻译后) |
为什么 sendToParent 要同步、要无 await? 因为它的调用方是孩子回合里的工具执行——那一刻孩子的 turn 正在进行,任何 await 都会让出并发窗口,teardown 可能在中间把 inbox 关掉。同步路径把”核对身份→查 closing→查父→写入”压成一个不可分割的短段。失败的代价是孩子这一轮拿不到 messageId,但绝不会投错。
本节源码依据(3 处)
deliverToChild:ChildLock 临界区里的截断事务
父给孩子发信(包括 queuePrompt/steerPrompt 两个封装)走 deliverToChild,核心是 deliverFollowup 的循环。每个孩子有一把 ChildLock——tails 队列把这个 child 的”投递、释放、销毁”串成一条线:
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) // 吸收拒绝,不传染
...
}
注意”吸收拒绝”那一行:一个失败的临界区不能让排在后面的无关调用者跟着 reject。锁的语义是线性化,不是互斥密码——不同 childId 的操作完全并发,同一 childId 的排成队。
进了临界区,三件事按顺序查:
- 没驻留 →
coldResume:从 session query 观察这条持久化的孩子,authorizeLineage核对父血缘,foldSubagentDescriptor从事件折出 descriptor(mode !== 'continuable'即NOT_RESUMABLE),再materialize重建 Activation——descriptor 供全部重建输入,不再回调 provider。 - inbox.closing 已开 → 这就是”投递 vs 销毁”的截断:信方拿到 disposal promise,等它 settle(成功失败都算),然后退出循环重试——注释说”the send-versus-dispose cutoff needs a delivery to observe the transaction inside the same critical section that opened it”。
- 正常窗口 → 有图就查图片能力(
contentHasImage时assertImageCapable,模型不收图即MODEL_DOES_NOT_SUPPORT_IMAGES,且断言后再看一眼 closing),然后submitAdmitted投递,announced = true。
循环退出条件写得很克制(v8 ignore 标注):只有”输了截断”的投递才重试;重试前再 assertAdmitting + signal.throwIfAborted。
| 情形 | 临界区内动作 | 结果 |
|---|---|---|
| 孩子未驻留 | coldResume(观察→血缘→descriptor→materialize→submit) | 冷投递成功即 messageId |
| closing 已开 | 等 disposal settle 后退出循环 | 重试或被 drain 拒 |
| 正常窗口 + 纯文本 | submitAdmitted + announced=true | messageId |
| 正常窗口 + 图片 | 先 assertImageCapable,断言后复看 closing | 拒收(MODEL_DOES_NOT_SUPPORT_IMAGES)或投递 |
为什么截断要”等 disposal”而不是直接拒收? 因为信可能就是触发 disposal 的那个事件的下游——比如父取消了孩子,同时模型还在跑最后一回合顺手发了条消息。直接拒收是”丢信”,等 disposal 结束再重试是”信最终落在下一个 Activation 上或落在 drain 的拒绝里”。前者模型以为发出去了,后者模型收到明确结果。投递方宁可多等一个 promise,也不吞信。
本节源码依据(6 处)
两种投递:steer 与 queue,收件 inbox 是唯一队列
信最终落在收件方的 SubagentInbox.deliver,逻辑只有三行:
if (this.closingPromise !== undefined) throw ... 'ACTIVATION_CLOSING'
if (delivery === 'steer') this.agent.steer(message)
else this.agent.followup(message)
- steer:
agent.steer(message)——插进收件 Agent 的最近一步。孩子中途汇报,父回合中插一句,回合继续。 - queue:
agent.followup(message)——排一个独立新 turn。人来的新指令、或收尾通知,要单独成一个回合。
sendWaking 是同一条通道的另一端:收件方是驻留 Activation(resident.get(parent.id) 且 handle 匹配)就 inbox.deliver + wake;不是(host-owned 的普通 Agent)就直接 parent.steer / parent.followup。两种宿主共用同一个 delivery 参数。
| steer | queue | |
|---|---|---|
| Agent 面 | agent.steer(message) | agent.followup(message) |
| 语义 | 插入最近一步 | 排独立新 turn |
| 典型发送方 | 孩子中途汇报、忙碌中的父收尾 | 初始 prompt、人来的指令、空闲父的收尾通知 |
| inbox 关闭后 | ACTIVATION_CLOSING(不投) | 同左 |
为什么只有这两态? 因为 turn 的本质只有两种打断:插进行中,或排队新开。编排服务不发明第三种——它把”什么时候用哪种”编码进调用点(孩子汇报默认 steer、初始 prompt 固定 queue、收尾通知看父状态选),而不是把选择留给模型。收件方 Agent inbox 是唯一 turn 队列,这意味着消息系统没有自己的并发模型:所有”消息到了之后会发生什么”都由 Agent loop 决定,编排服务只管投递与唤醒。
本节源码依据(3 处)
账本里的两种消息身份:relay 与 notice
穿过整条通道的两类消息,在 session 账本里是刻意不同的 source kind。
agent-message(孩子自己说的话):createAgentMessage 把内容包成前缀 "Agent ${sender.id} sent a message: ",source 是 { kind: 'agent-message', form: 'relay', senderSessionId }。注释解释了为什么是 relay:“A message another agent addressed to this one”——这是转发来的内容。
subagent-settled(runtime 的收尾旁白):createSettlementMessage 拼一行 summary——"Background subagent {id} finished and will do no further work unless you send it more."——按 stopReason 换五种措辞(completed / aborted / max-tokens / refusal / error,外加异常兜底),然后拼孩子的 closing 文本(一条都没有就补 "It left no closing message.")。source 是 { kind: 'subagent-settled', form: 'notice', summary, senderSessionId }。
为什么要分家?SubagentSettledMessageSource 的注释写得最重:“an Agent message is content the sender 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.”——账本合并时,不能把 runtime 的旁白记成孩子的发言。
| agent-message / relay | subagent-settled / notice | |
|---|---|---|
| 谁说的 | 孩子(模型选的内容) | 编排服务(runtime) |
| form | relay(转发) | notice(不展开行内) |
| 产生点 | createAgentMessage | createSettlementMessage |
| 触发 | 任何 send_message | 仅 notifySettlement |
为什么 model-visible 前缀要用英文模板? 因为这些消息是模型读的——跨语言一致性比本地化重要;账本层面的 source kind 才是给工具/查询机读的。
本节源码依据(4 处)
收尾通知:announced 闸门,以及”父不自动收 transcript”
孩子自然跑完或被拆,notifySettlement 才发收尾通知。四个依次生效的闸门:
announced闸门:if (!activation.announced) return——这个孩子从来没被投递过任何消息(比如 materialize 后 rollback 的),它不欠父一个交代;- 父存活闸门:
ctx.agents.get(parentSession) === undefined就静默不发; - 父关闭中:
closingTeardownFor(parent)命中 →parent.inject(message)(不经过 inbox 语义,直接注入); - 否则:
sendWaking(parent, message, parent.status === 'idle' ? 'queue' : 'steer')——父空闲排新 turn收尾,父忙碌插一步。 - 失败兜底:只
logger.warn(“settlement notice was not delivered to its parent”),不能反过来炸毁 disposal。
这条通知通道正好解释了 return guidance 那句硬话——withContinuableReturnGuidance 拼进孩子初始 prompt 的尾巴:
"Your parent agent id is {id}. Before you finish, send your result to that agent with send_message({ agent_id: {id}, message: "<self-contained result>" })."The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn.”
三个关键事实都在这句里:父不自动收 transcript(孩子必须主动发);发现变了就早发(消息不结束回合);发消息不结束你的 turn。而且这段 guidance 不是无条件注入的——startContinuable 里先问一句:isAdjacentAgentSendMessageTool(ctx.get('tools')?.get('send_message', child))——这个 agent 的工具表里真的有全局 send_message 吗? 有,才把”用 send_message 汇报”教给孩子;没有,教了就是模型幻觉一个不存在的工具。
为什么收尾通知要区分 inject / queue / steer? 因为父的状态决定了”通知以什么代价落账”:父正在拆(closing)就别再走 inbox 语义直接 inject;父闲着正好开个新 turn收尾;父忙就插一步——三种落点都是”通知最终被父看到”,但都不破坏父的 turn 编排。
本节源码依据(4 处)
小结
- send_message 是薄适配:工具层除了取
exec.agent和转交,不做任何路由;控制面全局唯一,多个委派工具共享。 - 身份先于路由:精确 live sender(对象同一)不通过就连路由都不进;直邻关系是 Activation 图上的邻接,不是会话血缘。
- 投递只有两态:steer 插一步、queue 排新 turn;Agent inbox 是唯一 turn 队列,编排服务不建 Task、不建中间包装。
- 截断不吞信:投递与 disposal 共用 ChildLock 临界区;inbox closing 已开就等 disposal 结束再重试,不投进正在拆的房子。
- 账本刻意分家:agent-message/relay 是孩子的话,subagent-settled/notice 是 runtime 旁白——合并账本时不会把旁白记成孩子的发言。
- 父不自动收 transcript:孩子被 guidance 教会主动汇报;收尾通知有 announced 闸门,失败仅 warn。
下一章离开”消息”这条线:第 35 章讲父怎样用一段脚本把一大批孩子编排成 parallel/pipeline——脚本跑在共享 PTC 执行器的受限 Node 进程里,而不是无限生成 Agent。