第二部分 · RLM
第 7 章:代理间直接通信
一条只有 steer 的投递通道,一个核心家庭的可达域
结果回来了,但不是返回值
第 6 章结束时留了一个悬念:子代理的答案「只经 agent_message 回复或文件到达」。现在到了兑现这句话的时候。
这是 Prime Agent 区别于几乎所有既有 Agent 系统的场景:运行中的 agent 直接互发消息、互相编排,不经过用户。父派子,子回父,父追问子,兄弟互通——而用户可以只是看着。官方 README 把它列为一级特性:"Agents communicate directly: running agents can exchange messages and orchestrate one another without routing everything through the user."
实现它的有两个技能:agent_message(能投递的)与 agent_observe(只能看的)。本章走完前者的完整投递链路,再看后者的克制设计。
调用面
模型在 kernel 里这样写:
# 回复父代理(子代理的典型动作)
await agent_message.send(message, receiver_role="parent")
# 给某个子代理追加任务
await agent_message.send(
"Check the newly added regression test.",
receiver_role="child",
receiver_name="api-reviewer",
)
# 看看家里都有谁
roster = await agent_message.list_agents()
Python 技能端做的事情少得刻意:参数形状校验(receiver_role ∈ {parent, sibling, child};parent 必须省略名字,sibling/child 必须提供),然后一次 host request,最后把回执 display() 给模型看。发件人身份字段在 Python 端根本不存在——技能文档明说:from 完全由 TS daemon 从发送方会话状态推导。模型生成的代码无法伪造自己是谁。
投递链路:一条五层兜底的路由
host handler 收到请求后(createAgentMessageHostHandlers,core/agent-messages.ts),先分流:
- 广播:
target === "all"→ 取家族花名册,对每个成员Promise.allSettled逐个投递,返回回执数组——失败不连坐,每个收件人一份独立的receipt。 - 定向:按
receiver_role在花名册里过滤「关系 + 名字/id」,要求恰好一个匹配。零匹配报错,多匹配报歧义。
定向分支有一个精彩的竞态处理:目标是 child 时,先 awaitPendingChildPublication(selector)——如果那个子代理刚刚被 rlm() 准入、运行时还没创建完,就等它的 publication Promise 落定。没有这一步,「spawn 后立刻发消息」会稳定撞上「查无此人」。第 6 章的准入即返回在这里付出了它的第一笔利息。
然后进入 daemon 的路由主入口 sendAgentSessionMessage()(modes/daemon/daemon-mode.ts,约 L5347)。目标的解析是一条逐层兜底的梯子:
解析到目标之后,还有四道关卡:
- 自投检查——不能发给自己。
- 家族可达性——
assertAgentFamilyReachable(from, target),只允许核心家庭(下一节)。 - 容量与限流——预占目标会话的排队槽位(每个会话至多 20 条待发,
DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION);按「发送者→目标」键走令牌桶(容量 3,每 1000ms 回填 1),超限抛 "rate limit exceeded; retry after Nms"。消息正文限 16,384 字符。 - 目标锁——
withAgentMessageTargetLock(target)串行化对同一目标的并发投递,锁内复查目标仍然活着。
export const DEFAULT_AGENT_MESSAGE_MAX_CHARS = 16_384; export const DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION = 20; export const DEFAULT_AGENT_MESSAGE_RATE_LIMIT_CAPACITY = 3; export const DEFAULT_AGENT_MESSAGE_RATE_LIMIT_REFILL_MS = 1000; ... export const AGENT_FAMILY_REACH_ERROR = "Agent reach is limited to parent, siblings, and children";
接收端:delivered 还是 queued
acceptAgentSessionMessage()(daemon-mode.ts 约 L5503)根据目标的忙碌程度二选一:
const shouldQueue = this.agentMessageAcceptingTargets.has(targetState.activeSessionId) || this.agentMessagePreparingTargets.has(targetState.activeSessionId) || session.isStreaming || session.isCompacting || session.isRetrying || session.isBashRunning || session.unfinishedActionCount > 0; const streamingBehavior = "steer";
- queued:消息进入目标的输入队列(第 2 章讲过的会话动作系统),作为预备 turn 等待执行。发送方不等待投递完成——注释写明:等待会和「发送者自己也在 turn 中」互相死锁。回执
deliveryStatus: "queued"。 - delivered:目标空闲,
acceptAgentMessagePrompt()直达(跳过模板展开与输入 handler),立刻开启一个 turn。回执deliveryStatus: "delivered"。
无论哪条路,消息最终的形态相同:目标转录里追加一条 role: "custom"、customType: "agent_message" 的条目,正文是统一渲染的文本——
[from parent] Agent-to-agent message received. Source: agent_message From: <发送者格式化> To: <接收者格式化> Message id: agentmsg_xxx <消息正文>
——忙碌目标则在活跃 run 中途以 steering 方式注入。注意那个写死的常量:streamingBehavior = "steer"。所有 agent 消息恒为 steer。协议里曾经有过 auto/follow_up 等投递模式的痕迹,如今 deliveryMode 字段永远是 "steer",旧枚举的注释写着 "Legacy daemon wire input accepted and ignored for compatibility"。简化到只剩一种模式,换来的是发送方永不阻塞、接收方语义唯一。
回执类型 AgentSessionMessageReceipt 的字段叫 deliveryStatus 而不是 status——源码注释解释:kernel 宿主桥的信封保留了 status 键({status: "ok"|"error"})。host request 的结果会被展开进信封,字段名不避开就会撞车。跨层协议设计里,这类「信封保留字」是最常见的暗坑。
家族可达域:一个纯函数画出的边界
agent 之间不是全连接的。可达性由 agentFamilyRelationship(current, target) 判定,返回值只有三种:"parent" | "sibling" | "child"。判定依据是持久化的 parent 边(sessionPath/sessionId 双标识),是一个纯函数——不查运行时树,不做图遍历。
这个选择的好处要到分布式场景才显现:supervisor、发送方 worker、目标 worker 可以在三个不同进程里独立重放同一个判定,不需要共享任何运行时状态。祖孙、堂表之间想通信?文档的回答是 "relay through an intermediate child"——经中间节点中继。还有一个容易被忽略的细节:root 会话互为 sibling——这让两个毫无血缘的顶层会话也能互发消息(prime-agent send 命令的基础),家族模型在顶层退化成了「同代人」模型。
身份与可达性的验证不止做一次。消息若跨 worker,会经 supervisor 中转——supervisor 对 agent 来源的消息再验一次家族可达性,并且目标若是已停止的 saved 会话,会先 createOrReuseWorker 把它唤醒。sender 身份与家族边界在每一跳都被重新验证,不信任任何中间声明。
agent_observe:克制的只读面
agent_observe 是通信的「望远镜」版本:三个 host handler——list / get / recent。
list_agents()返回家族成员的详细状态:是否在流式输出、是否在压缩、有无连接的客户端、消息计数、最新消息摘要……get_agent(target)取单个会话的快照,必要时唤醒目标。recent_messages(target, limit=8, max_chars=800)返回目标转录尾部若干条的有界预览——limit 限 1–50、字符限 80–2000,全部由宿主强校验。
它的 SKILL.md 里有一句值得抄进设计文档的话:"It cannot prompt, steer, clear, kill, rename, or otherwise mutate another session."观察与干预被拆成两个技能、两套权限语义——编排之前先看("Prefer status and recent previews for orchestration"),需要动手时才用 agent_message 或父的 rlm.delete_subagent 权力。
全景图
实践应用
- 身份由系统推导,不由调用方声明。模型生成的代码永远不应该有机会填写「我是谁」。发件人身份从会话状态推导,伪造问题在 API 形状层面就不存在。
- 路由要有兜底梯子,而不是假设在场。目标可能休眠、可能钝化、可能不在本机——每一层兜底对应一种「不在场」。消息系统顺便变成了唤醒系统。
- 投递模式宁少勿多。三种模式收缩成一种(steer),换来发送方永不阻塞、接收方语义唯一、协议面缩小。遗留枚举保留解析但忽略——向后兼容的体面姿势。
- 把观察与干预拆成两个工具。只读面的存在让模型可以先侦察后行动,也让权限审计变得简单:observe 永远不会改变系统状态。
- 可达域用纯函数定义。把「谁和谁能说话」写成无状态判定,它就能在任意一跳独立执行——分布式校验的前提是无共享。
总结
agent 间通信在 Prime 里是一条窄而硬的通道:消息限 16K、限流、限家族可达域;投递语义只剩 steer;身份不可伪造;每一跳重验。它承接了第 6 章「结果永不作为返回值」的设计——子代理的答案、追问、终态通知,全部以这条通道流动。窄不是缺陷:正因为通道窄且可预测,「派生 → 结束回合 → 被消息唤醒」这个编排范式才敢把并发、断线、压缩都押在上面。
第二部分到此结束:模型住在 kernel 里,以代码行动,经宿主桥触达权威,用 rlm() 递归,用消息互通。下一部分换一个轴——时间:这个系统如何记住经验,并谨慎地改进自己。