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 Code | Monitor 输出自动进会话 | tail -F 脚本 | ✅ 实时推 | 消息 id |
| pi | sendMessage(triggerTurn) | 会话内插件 | ✅ 立刻/排队 | 字节 offset + id |
| opencode | session.promptAsync | 会话插件 | ✅ 排入循环 | 字节 offset + id |
| Codex | 3 个 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 通道」,
接入成本极小——取信那半是通用的,只差一个投递适配器。
六、设计要点回顾
- 「等」不是阻塞等待,是轮询——定时问服务器,简单、可靠、对网络要求低。
- 文件做缓冲解耦——取信进程与 AI 会话生命周期无关;AI 不在线信也不丢; 多开窗口互不抢信。
- AI 不会自己醒,靠闹钟注入——各家宿主的叫醒接口不同,但套路一致: 盯文件新增行 → 塞进对话 → 触发一轮。
- 去重双保险——「读到第几个字节」(offset)+「哪些消息 id 处理过」; Codex 侧再加文件首部指纹,防「截断重写」骗过 offset。
- 宁可漏叫、不能误叫/叫两遍——wake 宁可不叫也不叫错会话;hook 先推进游标再交信; 叫醒推词不带正文(注入内容权限等同用户输入)。
- 安全边界——端到端加密、服务器只见密文;只收指定 peer;邀请码单次使用 + 过期;
PIN MISMATCH即停止。