第二部分 · 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 收到请求后(createAgentMessageHostHandlerscore/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)。目标的解析是一条逐层兜底的梯子:

图 1:目标解析的五层梯子。每一层都是一种「目标此刻不在场」的应对:不在内存就唤醒,不在运行就从注册表找,不在本 worker 就上交 supervisor——消息不只是通信,还是唤醒源。

解析到目标之后,还有四道关卡:

  1. 自投检查——不能发给自己。
  2. 家族可达性——assertAgentFamilyReachable(from, target),只允许核心家庭(下一节)。
  3. 容量与限流——预占目标会话的排队槽位(每个会话至多 20 条待发,DEFAULT_AGENT_MESSAGE_MAX_PENDING_PER_SESSION);按「发送者→目标」键走令牌桶(容量 3,每 1000ms 回填 1),超限抛 "rate limit exceeded; retry after Nms"。消息正文限 16,384 字符。
  4. 目标锁——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"。简化到只剩一种模式,换来的是发送方永不阻塞、接收方语义唯一。

深入一点:回执字段为什么叫 deliveryStatus

回执类型 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 权力。

全景图

图 2:一个典型的代理家族与消息面。实线是可达的消息边(parent/child/sibling),虚线是只读观察;祖孙之间没有直达边。右下的 root 会话互为 sibling——家族模型在顶层的退化形态。

实践应用

  1. 身份由系统推导,不由调用方声明。模型生成的代码永远不应该有机会填写「我是谁」。发件人身份从会话状态推导,伪造问题在 API 形状层面就不存在。
  2. 路由要有兜底梯子,而不是假设在场。目标可能休眠、可能钝化、可能不在本机——每一层兜底对应一种「不在场」。消息系统顺便变成了唤醒系统。
  3. 投递模式宁少勿多。三种模式收缩成一种(steer),换来发送方永不阻塞、接收方语义唯一、协议面缩小。遗留枚举保留解析但忽略——向后兼容的体面姿势。
  4. 把观察与干预拆成两个工具。只读面的存在让模型可以先侦察后行动,也让权限审计变得简单:observe 永远不会改变系统状态。
  5. 可达域用纯函数定义。把「谁和谁能说话」写成无状态判定,它就能在任意一跳独立执行——分布式校验的前提是无共享。

总结

agent 间通信在 Prime 里是一条窄而硬的通道:消息限 16K、限流、限家族可达域;投递语义只剩 steer;身份不可伪造;每一跳重验。它承接了第 6 章「结果永不作为返回值」的设计——子代理的答案、追问、终态通知,全部以这条通道流动。窄不是缺陷:正因为通道窄且可预测,「派生 → 结束回合 → 被消息唤醒」这个编排范式才敢把并发、断线、压缩都押在上面。

第二部分到此结束:模型住在 kernel 里,以代码行动,经宿主桥触达权威,用 rlm() 递归,用消息互通。下一部分换一个轴——时间:这个系统如何记住经验,并谨慎地改进自己。