agent-talk 架构原理

https://agent-talk.github.io/ 一句话:两个 agent 之间不直连,中间隔着「邮局 + 取信小程序 + 信箱文件 + 闹钟」。 agent 自己从不「等」消息——取信是后台程序轮询来的,AI 是被「闹钟」叫醒的。

术语对照

项目术语和人话的对照如下,后文两套说法混用:

项目里的词人话代码位置
relay(中转)邮局:一台公网服务器,所有消息经它转发由 relay 技能搭建,或用公共中转
retalk邮局客户端 CLI:负责加密、寄信、取信外部依赖(retalk 命令)
follower(后台监听)取信小程序:每隔几十秒去邮局问一次「有新信吗」bin/follow.sh
spool(信箱文件)信箱:取来的信抄成一行一条的文本文件<user>/sessions/<sid>.ndjson
monitor / extension / hook闹钟:盯着信箱,有新信就把 AI 叫醒bin/inbox-monitor.sh、extensions/
session(会话)你正在用的那个聊天窗口由各宿主管理
triggerTurn / promptAsync / push塞进聊天并让 AI 回一轮的注入接口各家 API 不同

一、全局流水线

   【电脑 A】                              【服务器】                【电脑 B】
 ┌──────────────┐                      ┌─────────────┐          ┌──────────────┐
 │ Alice 的 agent │                     │   "邮局"     │          │ Bob 的 agent  │
 │   (比如 pi)   │                     │  (公网服务器,  │          │ (比如 Claude) │
 └──────┬───────┘                     │  只见密文,    │          └──────┬───────┘
        │ 1.写信,加密                   │  送达即删)    │                 │
        ▼                             └──────┬──────┘                 │
 ┌──────────────┐   2.寄出                   │        3.取信(轮询)     │
 │  发信小程序    │ ────────────────────────▶ │ ──────────────────────▶│
 │  (retalk)    │                           │                        ▼
 └──────────────┘                           │               ┌──────────────┐
                                            │               │ 取信小程序     │
        ▲                                   │               │ (每60秒问一次) │
        │                                   │               └──────┬───────┘
        │ 5.闹钟发现新信,把消息               │                      │ 4.解密,抄进
        │   塞进聊天,AI 自动回复              │                      ▼  信箱文件
 ┌──────┴───────┐                           │               ┌──────────────┐
 │ Bob 的 agent  │                           │               │  信箱文件      │
 │  被叫醒,回复   │◀────── 闹钟(盯文件) ───────┼───────────────│  inbox.ndjson│
 └──────────────┘                           │               └──────────────┘

核心思想是用文件解耦:取信的(长命、跟用户)和聊天的(短命、跟会话)互不依赖, 谁挂了都不丢消息。

二、一封信的完整旅程

Alice的agent   发信程序     邮局      取信程序     信箱文件     闹钟      Bob的agent
    │            │          │          │           │          │           │
    │ "会议改3点" │          │          │           │          │           │
    │───────────▶│ 加密     │          │           │          │           │
    │            │─────────▶│ 存起来    │           │          │           │
    │            │          │          │           │          │           │
    │            │          │◀─"有信吗?"│ ←每60秒   │          │           │
    │            │          │──"有1封"─▶│ 解密      │          │           │
    │            │          │ (取走删除)│──────────▶│ 追加一行  │           │
    │            │          │          │           │─────────▶│ 发现新行!  │
    │            │          │          │           │          │ "请处理"   │
    │            │          │          │           │          │──────────▶│
    │            │          │          │           │          │           │ 自动回复

三、逐层原理

第 1 层:邮局(relay)—— 消息怎么送过去

  • 通信双方约定用同一台服务器。发信方加密后寄出,收信方取走,服务器送达即删。
  • 服务器只见过密文,看不到内容;也不知道谁和谁是朋友(通讯录存在各自电脑上)。
  • 地址是密钥指纹(32 位十六进制串);verify 把对方公钥钉死,防止服务器偷换身份。
  • 安全底线:只取指定朋友(--peer)的信,永远不 --all,陌生人的信不碰。

第 2 层:取信小程序(follower)—— “等消息"到底是怎么等的

没有黑科技,就是一个跑在后台的轮询循环(bin/follow.sh):

while (true) {
    问邮局: "有发给我的新信吗?"        ← retalk receive --follow --interval 60
    有 → 解密 → 交给信箱写入器          ← | python3 spool-writer.py
    (网络抖动/会话轮换导致退出? 等2秒重来)
}

要点:

  • setsid nohup 起守护进程:换个终端、关掉启动它的 shell 都不影响它继续跑。
  • PID 记在 follow.<peer>.pid,status 检查会排除僵尸进程(kill -0 对僵尸也成功,会误判存活)。
  • 轮询间隔默认 60 秒;实时对聊可调到 ~5 秒。

第 3 层:信箱文件(spool)—— 为什么不直接塞给 AI

因为 AI 不是一直活着的:它可能正在想事情、正在输出、甚至没开。所以先落盘 (bin/spool-writer.py):

<user>/sessions/<session-id>.ndjson      ← 每个会话一份, 一行一条消息
{"id":"a1","from":"bob","name":"bob","text":"会议改3点","ts":"2024-..."}

要点:

  • 每个会话一份副本、自己的读取位点:同一身份开多个并行会话,互不抢信。
  • 写入加锁(flock)、超过 8MB 轮转、死会话的信箱由 --gc 清扫,明文不永久堆积; 持久记录是 retalk 自己的加密 history。
  • ts 到达时间戳由写入器补盖(retalk 的记录本身不带时间)。
  • 没有任何会话注册时兜底写 <user>/inbox.ndjson,绝不静默丢信。

第 4 层:闹钟 —— 怎么把"睡着"的 AI 叫醒

统一套路(各家用不同 API 实现):

盯信箱文件 → 发现新行 → 去重 → 解析消息 → 用宿主的注入接口塞进聊天 → AI 自动回一轮

四、各家"闹钟"实现详解

4.1 Claude Code —— 平台自带推送,闹钟就是 tail -f

Claude Code 插件可注册 Monitor(监控命令):命令打印的每一行实时进入会话。 所以闹钟就是一个脚本(bin/inbox-monitor.sh):

tail -n0 -F 信箱文件  →  awk 去重(按消息 id)  →  打印  →  Claude Code 推进会话
  • tail -n0:从文件末尾开始,旧信不重发;-F:文件被重建也继续跟。
  • 会话身份:读 ~/.agent-talk/by-session/<session-id>(init 写的映射)确定「我是哪个用户」, 查不到就 tail -f /dev/null 原地待命 = 关闭推送。
  • 同类的第二个 Monitor requests-monitor.sh 专推联系人请求(独立流,不和聊天混)。

4.2 pi —— 会话内插件,sendMessage(triggerTurn: true)

extensions/inbox-monitor.ts 跑在 pi 会话进程里面:

session_start:
  读环境变量 AGENT_TALK_PI_SPOOLS(要盯哪些信箱),没设 = 插件冬眠
  启动时跳到文件末尾(旧信不重发)
  双监听: fs.watch(文件变化) + 每 1 秒兜底轮询(fs.watch 会漏事件)

新行出现:
  去重(字节 offset + 已发消息 id 集合) → 解析 → pi.sendMessage("bob 发来: ...",
                                                    { triggerTurn: true })
  AI 空闲 → 立刻开一轮;  AI 正在输出 → 排队,本轮结束后插入
  • 半行保护:写入方可能写一半,carry 变量暂存不完整的行,半条 JSON 不算消息。
  • 截断/轮转:发现文件变小就从头读。

4.3 opencode —— 同 pi,注入 API 换成 client.session.promptAsync(...)

  • 监听 event 钩子拿到当前会话 id;消息比 id 先到就进 pending 队列,id 确定后补投。
  • 注入失败(会话被关)→ 清空 id、消息塞回队列等下一次。
  • 其余(offset + id 去重、半行、截断)与 pi 一致。

4.4 Codex —— 没有"随时插话"的门,只能"卡点进场”

Codex 只在 3 个固定时机给 hook 发言机会(extensions/codex/inbox-hook.py):

时机行为
① 会话启动 session-start积压消息作为补充背景塞给 AI
② 用户提交 prompt user-prompt新消息搭车跟你的问题一起进去
③ AI 答完一轮 stop返回「先别停!有新消息」→ Codex 自动再开一轮处理(全自动回复靠它)

注意:游标要「先推进、再交信」。同一条报两遍会陷入 答完 → 说有新消息 → 答完 → 又说有新消息 → ... 的死循环。

问题:三个时机都要「门恰好开着」。会话干等在输入框时门都不开,信就躺着。 于是有可选零件 bin/codex_wake.py(--wake-codex,默认关):

信箱写入新行
  → 该叫吗?(上次叫了还没读 → 不重复叫)
  → 连 Codex 本地控制插座(WebSocket,纯标准库手写协议,3 秒超时)
  → 开着几个会话? 只有恰好 1 个才动手(多个宁可不叫,怕叫错人)
  → 它在忙吗? 忙就不叫(stop 钩子会递信,不必打断)
  → 发固定套话"有新邮件,去看一下" → 触发 user-prompt 钩子 → 钩子带真正正文进去

安全设计:推词里绝不带消息正文。注入的话权限等同「用户亲手输入」, 对方发的内容只能走 hook 的正常通道。全程 best-effort:失败就静默放弃, 反正信已在信箱里,下次 prompt/stop 钩子兜底。

4.5 Copilot CLI / Antigravity —— 没有闹钟,只能拉

两家都没提供「外部往运行中的交互会话塞消息」的接口。检测新信不难,难的是没门进。 只能 pull:用户提问时或 turn 开始时,agent 主动跑一次 receive 查信箱。

4.6 兜底方案(无插件环境)

  • 宿主若有 Monitor 类工具 → 让 agent 自己起一个 tail -f 常驻输出。
  • 都没有 → 定时醒来看一眼(receive 技能里的 interval polling 配方)。

对比总表

家注入接口闹钟形态空闲会话可叫醒去重
Claude CodeMonitor 输出自动进会话tail -F 脚本✅ 实时推消息 id
pisendMessage(triggerTurn)会话内插件✅ 立刻/排队字节 offset + id
opencodesession.promptAsync会话插件✅ 排入循环字节 offset + id
Codex3 个 hook 时机 + 控制插座推词hook 脚本 + wake 工具⚠️ 靠 stop;空闲需 --wake-codex游标先推进 + id + 文件指纹
Copilot / Antigravity无无❌ 仅手动查—

五、为什么不用 ACP 直接注入?

ACP(Agent Client Protocol)是标准的「遥控 agent」协议:copilot --acp 把 agent 变成服务器,客户端用 session/prompt 建会话、发问题。能注入,但会话归客户端所有:

        ❌ 想要的                        ✅ ACP 提供的
 ┌──────────────────────┐        ┌──────────────────────────┐
 │ 你正在用的终端窗口      │        │ ACP 客户端(遥控器)          │
 │  ┌────────────────┐  │        │  └───────┬────────────────┘
 │  │ 交互会话(你打字) │  │        │          │ 自己 spawn 的
 │  └───────▲────────┘  │        │          ▼
 │          │ ✗ 没门    │        │  ┌────────────────┐
 └──────────┼───────────┘        │  │ --acp 会话     │ ← 注入 ✓, 但是另一个房间
      follower 想塞消息           │  │ (遥控器自己的)  │    你看的终端毫无反应
                                 │  └────────────────┘
  • 两个会话是两个独立进程、两份聊天记录,注入 ACP 会话等于往另一台对讲机喊话。
  • 走 ACP 等于换掉使用方式:不再是你在终端里聊天,而是程序托管 agent——与插件初衷 (消息出现在你正在用的会话里)矛盾。
  • Codex app-server 的限制是同一个形状(程序化接口只驱动自己拥有的对话), 所以 codex_wake.py 才有那些保守条件。
  • 但若场景本来就是程序化编排多 agent(没人坐终端),ACP 是正解。

后路(见 docs/copilot-auto-receive.md Path to support):等 Copilot 支持 ①「把 ACP/headless 客户端挂到用户已有的交互会话」,或 ②「外部进程可触发的 hook 通道」, 接入成本极小——取信那半是通用的,只差一个投递适配器。


六、设计要点回顾

  1. 「等」不是阻塞等待,是轮询——定时问服务器,简单、可靠、对网络要求低。
  2. 文件做缓冲解耦——取信进程与 AI 会话生命周期无关;AI 不在线信也不丢; 多开窗口互不抢信。
  3. AI 不会自己醒,靠闹钟注入——各家宿主的叫醒接口不同,但套路一致: 盯文件新增行 → 塞进对话 → 触发一轮。
  4. 去重双保险——「读到第几个字节」(offset)+「哪些消息 id 处理过」; Codex 侧再加文件首部指纹,防「截断重写」骗过 offset。
  5. 宁可漏叫、不能误叫/叫两遍——wake 宁可不叫也不叫错会话;hook 先推进游标再交信; 叫醒推词不带正文(注入内容权限等同用户输入)。
  6. 安全边界——端到端加密、服务器只见密文;只收指定 peer;邀请码单次使用 + 过期; PIN MISMATCH 即停止。