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

Agent 之间的消息:send_message 的直邻路由

子 Agent 中途想给父回话,靠的是全局 send_message 工具——它只是薄适配,真正的路由全在 ctx.subagents.sendMessage:先核对精确 live sender,再按"驻留子→父"与"父→子"两个直邻方向分流;投递目标只有 steer(最近一步)与 queue(新 turn)两种,收件方 Agent inbox 是唯一 turn 队列。投递与 disposal 共用 ChildLock 临界区,错过截断就重试;收尾通知是 runtime 自己的话(subagent-settled),不是孩子的发言。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 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 与它背后的路由、投递和收尾通知。

send_message 路由全图:左侧驻留子 Agent 调 send_message 工具(薄适配);中央 SubagentContinuationManager.sendMessage 先精确 live sender 校验(UNAUTHORIZED),再按 parentSession 命中分两路(sendToParent / deliverToChild);deliverFollowup 在 ChildLock 临界区内看 inbox.closing——disposal 截断即等待重试,未驻留则 coldResume;右侧收件方 inbox 只有 steer(agent.steer)与 queue(agent.followup)两种投递,投递后 wake;底部红色条是 notifySettlement:announced 闸门 → 父 closing inject / idle queue / 忙 steer,失败仅 warn。

一个全局控制 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 === targetIdsendToParent(...)
非驻留子→父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——调用方只需要认得这一个错误面。

闸门条件错误码
自身 disposalactivation.inbox.closing !== undefinedACTIVATION_CLOSING
父不在线ctx.agents.get(parentSession) === undefinedPARENT_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 的排成队。

进了临界区,三件事按顺序查:

  1. 没驻留 → coldResume:从 session query 观察这条持久化的孩子,authorizeLineage 核对父血缘,foldSubagentDescriptor 从事件折出 descriptor(mode !== 'continuable' 即 NOT_RESUMABLE),再 materialize 重建 Activation——descriptor 供全部重建输入,不再回调 provider。
  2. 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”。
  3. 正常窗口 → 有图就查图片能力(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=truemessageId
正常窗口 + 图片先 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 参数。

steerqueue
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 / relaysubagent-settled / notice
谁说的孩子(模型选的内容)编排服务(runtime)
formrelay(转发)notice(不展开行内)
产生点createAgentMessagecreateSettlementMessage
触发任何 send_message仅 notifySettlement

为什么 model-visible 前缀要用英文模板? 因为这些消息是模型读的——跨语言一致性比本地化重要;账本层面的 source kind 才是给工具/查询机读的。

本节源码依据(4 处)

收尾通知:announced 闸门,以及”父不自动收 transcript”

孩子自然跑完或被拆,notifySettlement 才发收尾通知。四个依次生效的闸门:

  1. announced 闸门:if (!activation.announced) return——这个孩子从来没被投递过任何消息(比如 materialize 后 rollback 的),它不欠父一个交代;
  2. 父存活闸门:ctx.agents.get(parentSession) === undefined 就静默不发;
  3. 父关闭中:closingTeardownFor(parent) 命中 → parent.inject(message)(不经过 inbox 语义,直接注入);
  4. 否则:sendWaking(parent, message, parent.status === 'idle' ? 'queue' : 'steer')——父空闲排新 turn收尾,父忙碌插一步。
  5. 失败兜底:只 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。