原 PLUGIN-GUIDE 是叙事式的「怎么做 + 踩过的坑」,读者要自己从散文里推断 「我到底必须做什么」。接第三个平台时这不够用 —— 尤其当照着实现的是一个代理。 改为规格式,编号可引用、强度明确标注、每条尽量给出可机械核对的判据。 旧文档的内容全部保留(迁进第八、九节),另补上原先没有的四类: ## 一、能力矩阵(新增) 回答「这个平台能不能接」。七项必需能力(C-1..C-7)加七项可选(C-8..C-14), 每项给出判据。附一个七问自检 —— 任何一问答不出来就先别写代码。 其中 C-4「轮次结束信号必须能区分成功与出错」在两次适配里都被漏掉过, 两次都造成「无效模型被判成成功」,所以单独标了出来。 ## 二、行为约定(重写) 原先散在各节的要求收拢成一个状态机,按事件逐条规定:B-1 启动 / B-2 心跳 / B-3 new_mail / B-4 permission_decision / B-5 轮次结束 / B-6 无法处理时回信 / B-7 启动补拉 / B-8 权限询问 / B-9 关停。 ## 四、降级语义(新增) 平台缺某项能力时的确切退化路径(D-1..D-7)。原文档只说了「可选」, 没说缺了之后该怎么办 —— 于是「不支持权限钩子」很容易被实现成 「提供 request_permission 工具补偿」,而那正是 I-1 反对的模式。 ## 六、不变量与禁止事项(新增) 12 条 MUST NOT,每条附「违反会怎样」。这些是测试全绿、跑起来也不报错, 但行为就是错的那类问题 —— 例如拉取失败时传 [] 而非省略字段会清空服务端目录。 ## 七、验收清单(新增) 八组可勾选项,每条给出具体命令:grep 自查禁止事项、sqlite3 查在线状态与 配额未被消耗、停插件发信再启动看补投日志。 ## 核对过的事实 写完逐项核对了代码,不是凭记忆: - 12 个端点全部在 main.go 里存在且方法一致 - 发信 9 个字段名与 sendMailRequest 的 json tag 一致 - 心跳响应 12 个字段名与 handler 一致 - 九个数字(30s 心跳 / 25MB 附件 / 20 次每小时 / 补投 5 封 / 快照 200 条 / 模型上限 10 / 目录上限 300 / 降级超时 60s / inbox 默认 5)都能在代码里找到出处 - 验收清单里的六条 sqlite 查询都在生产库上跑通 - 38 个编号无重复,18 处交叉引用全部有定义 引用同步:PLAN.md、PHASE7-REMAINING.md、API.md、README.md、 install.sh、check-shared-libs.sh。
46 KiB
AgentMail 插件契约 / Plugin Contract
把一个 Agent 平台接进 AgentMail 需要写一个桥接插件。这份文档是那个插件的 规格说明:必须支持哪些平台能力、每个事件到达时必须做什么、平台缺某项能力时 怎么退化、线协议的确切字段、以及每一条怎么自证做到了。
读者假定为「照着实现的人或代理」,因此:
- 条目用 MUST / SHOULD / MAY 标注强度,编号可引用(
C-1、B-3…) - 每条尽量给出可机械核对的判据,而不是「注意某某」
- 每条附为什么 —— 不知道为什么的约定会在第一次不方便时被绕过
已有两个实现可直接对照:
| 插件 | 平台 | 框架 | 语言 |
|---|---|---|---|
plugins/opencode-mail-bridge/ |
opencode | @opencode-ai/plugin |
JavaScript |
plugins/dsh-mail-bridge/ |
DeepSeek Harness | Cordis | TypeScript |
第八、九节是这两次适配的实现细节与踩坑记录 —— 规范部分(一至七节)与平台无关。
〇、术语与不变量
三维寻址
name@path.session
│ │ └─ 会话位:三态,见下
│ └─ 工作目录(可含 / 与 .)
└─ Agent 名或人类用户名(共用一个命名空间)
按最后一个 . 切分。dsh@/home/a.b/proj.refactor 的 path 是
/home/a.b/proj、session 是 refactor。
会话位三态,语义不可混淆:
| 写法 | 含义 |
|---|---|
dsh@/path |
默认会话(该 Agent + path 下最近活跃的那条,没有则新建) |
dsh@/path.new |
强制新建 |
dsh@/path.refactor |
必须是已存在的别名,不存在返回 404 —— 绝不静默新建 |
为什么三态而非两态:
.refactor静默新建的话,一个拼错的别名会让 Agent 在 一条空会话里干活,而发件人以为在续原来那条。失败必须是可见的。
全局不变量
以下几条贯穿全文,每一节都是它们的推论。违反其中任何一条,插件的行为就是错的, 哪怕单元测试全绿。
| # | 不变量 |
|---|---|
| I-1 | 平台原生信号是唯一真相来源。 不要求模型「记得」调工具去汇报状态 |
| I-2 | 插件代劳的转发不消耗配额。 权限询问与最终总结走 relay 通道 |
| I-3 | 平台命名优先。 会话标题/短名由平台生成,插件只回写,不另造 |
| I-4 | 插件只搬运,不决策。 不改写模型输出,不代替人做权限判断 |
| I-5 | 失败必须可见。 投不进去、模型起不来、目录不存在 —— 都要有人能看到 |
I-1 的由来:早期版本提供了
request_permission工具让模型自己调。模型 可能忘了调(危险操作直接执行),也可能在不需要时乱调(每一步都问人)。而平台 自己的权限钩子拦下的那一次是事实。同理,「模型说完了」由平台的轮次结束信号 决定,不由模型自己声明。
一、能力矩阵 / Capability Matrix
判断一个新平台能不能接,以及接了之后哪些功能可用。
必需能力(缺任何一项无法接入)
| # | 能力 | Capability | 判据 |
|---|---|---|---|
| C-1 | 在会话外注入一轮用户消息 | inject_turn |
存在一个 API,能对指定会话追加一条用户消息并触发模型跑一轮 |
| C-2 | 创建会话并指定工作目录 | create_session_with_cwd |
创建时能传 cwd/directory,且平台按它给会话分组 |
| C-3 | 会话的稳定 id | stable_session_id |
创建后返回一个 id,用它能再次投递到同一会话 |
| C-4 | 轮次结束信号 | turn_end_signal |
有事件/回调告知「这一轮跑完了」,且能区分正常结束与出错 |
| C-5 | 读取会话内的消息 | read_messages |
能取到最后一条 assistant 消息的文本块 |
| C-6 | 注册自定义工具 | register_tools |
能给模型加工具,且工具能拿到当前会话 id |
| C-7 | 插件常驻进程 | long_running |
插件能持有一条 SSE 长连接与一个 30 秒定时器 |
C-1 与 C-3 是硬门槛。 没有它们,「一封邮件 → 一轮工作 → 一封回信」这条 主链路根本无法搭起来 —— 而那是 AgentMail 的全部意义。
C-4 必须能区分成功与出错。 只知道「跑完了」不够:模型调用失败时会话里没有 任何 assistant 消息,如果把它当正常结束,插件会转发一个空字符串回去, 发件人看到一封空邮件而不是错误说明(见
B-6、D-3)。
可选能力(缺则退化,不阻塞接入)
| # | 能力 | Capability | 缺失时的退化 | 影响 |
|---|---|---|---|---|
| C-8 | 权限/审批钩子 | permission_hook |
不转发权限询问 | 危险操作只能靠平台本地 UI 决策 |
| C-9 | 钩子可异步等待 | async_permission |
转出去后立即返回「待决」,决策经 SSE 回来后再补 | 模型会先被拒一次再重试 |
| C-10 | 列出会话 | list_sessions |
不上报平台会话快照 | 写信时只能续谈邮件驱动的会话 |
| C-11 | 模型标题 | model_title |
会话别名退化为随机短名或平台 id | 补全列表里分不清哪条在谈什么 |
| C-12 | 列出可用模型 | list_models |
不上报模型目录 | 管理员无法在配置页划定模型范围 |
| C-13 | 指定单轮模型 | per_turn_model |
不做降级尝试 | 主力模型故障时该 Agent 整体不可用 |
| C-14 | 会话内文件读写工具 | file_tools |
附件下载后模型看不到 | 附件功能形同虚设 |
能力自检脚本
接入前先回答这七个问题。任何一个答不出来,先去读平台文档,不要开始写代码:
1. 我怎么在不通过 UI 的情况下让某个会话跑一轮? → C-1
2. 创建会话时工作目录参数叫什么?平台按它分组吗? → C-2
3. 一轮跑完时我从哪得到通知? → C-4
4. 那个通知能区分「正常结束」和「模型调用失败」吗? → C-4(最容易漏)
5. 怎么取到最后一条 assistant 消息的纯文本? → C-5
6. 注册工具时 execute 能拿到 session id 吗? → C-6
7. 权限钩子是同步还是异步?能不能真的等人? → C-8/C-9
问题 4 在两次适配里都被漏掉过,两次都造成「无效模型被判成成功」—— 详见
D-3与第九节。
二、生命周期与行为约定 / Behaviors
插件是一个状态机。这一节按事件逐个规定「到达时必须做什么」。
B-1 启动
├─▶ 解析密钥 ──▶ POST /agent/register ──▶ 首次心跳(B-2)
│ ├─▶ 读 allowed_models
│ └─▶ pending_mails > 0 ? 补拉(B-7)
├─▶ 建立 SSE 长连(首次不带 Last-Event-ID)
└─▶ 启动 30s 心跳定时器
运行中
├── Gateway 推来 ─────────────┬── 平台侧事件 ──────────────┐
│ │ │
▼ ▼ ▼
new_mail (B-3) 轮次结束 (B-5) 权限询问 (B-8)
permission_decision ├─ 取 text 块 ├─ 转成邮件问人
(B-4) ├─ 模型已自己发过 → 让位 └─ 决策回来 → 回答平台
└─ relay:"summary" 发出 标题生成 → 回写别名 (W-7)
B-9 关停
└─▶ 断 SSE、停定时器、未决询问 fail closed
B-1 启动(MUST)
| # | 要求 | 强度 |
|---|---|---|
| B-1.1 | 按 环境变量 → ~/.agentmail/agent.key 顺序解析密钥;两者皆无时本地生成一把、写入该文件、并把全文打印到日志 |
MUST |
| B-1.2 | POST /agent/register,workspaces 传 [] |
MUST |
| B-1.3 | 立即发一次心跳,不等第一个 30 秒周期 | MUST |
| B-1.4 | 建立 SSE 长连;首次连接不带 Last-Event-ID |
MUST |
| B-1.5 | 启动 30 秒心跳定时器 | MUST |
| B-1.6 | 首个成功心跳的 pending_mails > 0 时补拉存量未读(见 B-7) |
MUST |
B-1.1 为什么是「本地生成 + 登记」而不是服务端签发:密钥全文只从客户端 流向服务器一次。插件生成后打印,管理员在后台登记即可,不需要把密钥从服务器 反向传给客户端 —— 那条路径上任何一处日志都可能把它落盘。
B-1.4 首次不带
Last-Event-ID:带上会收到一批已经处理过的旧事件, 于是插件重启一次就把历史邮件重投一遍。补投走B-7那条明确的路径。
B-2 心跳(MUST,30 秒)
请求体三个字段全部可选,语义见 W-3:
{ "platform_sessions": [...], "models": [...] }
| # | 要求 | 强度 |
|---|---|---|
| B-2.1 | 间隔 30 秒,失败不重试不报错,下一轮补上 | MUST |
| B-2.2 | 从响应读 allowed_models 存入模块级变量 |
MUST |
| B-2.3 | 带上模型目录(拉不到则省略字段,不传空数组) | SHOULD |
| B-2.4 | 带上平台会话快照(同上) | SHOULD |
| B-2.5 | 心跳失败不影响 SSE 与投递 | MUST |
B-2.1 为什么失败不报错:网络抖动很常见,而 Gateway 已经有可见的失败信号 —— 持续连不上时
last_seen会让它显示为离线。插件自己再打一串错误日志只会 淹掉真正的问题。B-2.2 为什么范围随心跳回传而不是插件轮询:管理员在配置页改了范围后最多 一个周期(30 秒)生效,不需要重启插件;也不需要插件多起一个请求。
B-3 收到 new_mail(MUST)
new_mail 到达
│
├─ 1. 去重:mail_id 已在 deliveredMails 里 → 丢弃
├─ 2. 解析 cwd = resolveWorkspaceCwd(to_workspace, 兜底)
├─ 3. 查映射:session_id → 平台会话 id
│ 命中且会话还活着 → 续谈(inject_turn)
│ 否则 → 新建会话(不传占位标题)
├─ 4. 按 modelAttemptOrder 逐个尝试,等异步结论(见 D-3)
├─ 5. 注入提示词:告诉模型「调 read_inbox 读正文」「回信不用你自己发」
└─ 6. 登记 mail_id 到 deliveredMails
| # | 要求 | 强度 |
|---|---|---|
| B-3.1 | cwd 必须取自 to_workspace,不得自己拼临时目录 |
MUST |
| B-3.2 | 新建会话时不传占位标题(会掐掉平台自己的命名机制) | MUST |
| B-3.3 | 维护 mail session_id ↔ 平台 session id 双向映射 |
MUST |
| B-3.4 | 提示词里写明「回信由插件自动发,不必调 send_mail」 | MUST |
| B-3.5 | 提示词里带 mail_id,让模型能自己查这封 |
SHOULD |
| B-3.6 | 投递失败要让人看到(日志 + 见 B-6) |
MUST |
B-3.1 是踩过最贵的坑之一(详见第九节):平台按 cwd 给会话分组,用自己拼的 临时目录会让所有邮件会话既不属于任何项目、彼此也不同组 —— 界面上全落进 「未分组」,而模型在一个空目录里找不到任何要改的代码。
B-3.4 为什么要在提示词里说:不说的话模型会自己调
send_mail回信, 而插件在轮次结束时也会自动转发一次 —— 同一件事两封邮件。生产里真实发生过。 说了之后仍要保留B-5.3的去重兜底:提示词是建议,去重是保证。
B-4 收到 permission_decision(MUST)
| # | 要求 | 强度 |
|---|---|---|
| B-4.1 | 用 relay_key 找到挂起的询问并回答平台 |
MUST |
| B-4.2 | 找不到挂起项(插件重启丢了内存映射)→ 退化为把决策当一封通知投进 session_id 指向的会话 |
MUST |
| B-4.3 | 退化路径不得凭空新开会话 | MUST |
B-4.2 为什么需要退化路径:待决映射在内存里,插件重启就丢了。此时平台侧 那次工具调用早已被拒绝,但人刚刚点了「同意」—— 什么都不做的话人以为自己批准了、 Agent 却毫无反应。把决策作为一条消息投进原会话,模型至少能重试。
B-5 轮次结束 → 自动转发(MUST)
| # | 要求 | 强度 |
|---|---|---|
| B-5.1 | 只取最后一条 assistant 消息里 type === 'text' 的块 |
MUST |
| B-5.2 | 带 relay: "summary" + 平台侧稳定 id 作 relay_key |
MUST |
| B-5.3 | 本轮模型已亲手回过这条线索 → 不转发 | MUST |
| B-5.4 | 文本为空 → 不发空邮件 | MUST |
| B-5.5 | 只对邮件驱动的会话转发(人在平台 UI 里开的会话不转) | MUST |
B-5.1 丢掉 reasoning:思考过程不该出现在邮件里 —— 它对收件人没有意义, 而且经常包含「我先假设…」这类会被误读为结论的内容。
B-5.3 用什么去重:靠进程内的
explicitSends(记录本轮模型主动发过的信), 不是relay_key。后者保证「同一条 assistant 消息不转两次」, 管不了「模型已经自己发过了」—— 那是两条不同的邮件。B-5.5 为什么限定邮件驱动:人在平台 UI 里正常干活时不该往邮箱里灌总结。
B-6 无法处理时必须回信(MUST)
三种「模型一次都没跑起来」的情形,会话里没有任何 assistant 消息,
因此 B-5 什么也不会发 —— 发件人只会看到邮件发出去后再无音讯。
| 情形 | 处理 |
|---|---|
| 划定范围内所有模型都失败 | 发一封列出每个路由与失败原因的邮件 |
| 创建会话失败 | 同上,说明是建会话阶段失败 |
| 工作目录不可用且无兜底 | 同上,说明期望的 path 与实际回退的目录 |
| # | 要求 | 强度 |
|---|---|---|
| B-6.1 | 主题形如 处理失败: <原主题> |
SHOULD |
| B-6.2 | 正文逐条列出尝试过的路由与原因,并指出去哪里调整 | MUST |
| B-6.3 | 带 relay: "summary" + relay_key: "model-failure:<mail_id>" 走免配额通道 |
MUST |
| B-6.4 | 发完仍要 throw/记录,不要静默 | MUST |
B-6.3 为什么免配额:这是插件的故障报告,不是模型的自主发信。让一次配置 错误吃掉用户的往返预算是双重惩罚。
B-7 启动补拉(MUST)
SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一次。
| # | 要求 | 强度 |
|---|---|---|
| B-7.1 | 只在首个成功心跳后补一次,不是每轮 | MUST |
| B-7.2 | 串行投递,一次最多 5 封 | MUST |
| B-7.3 | 与 SSE 共用同一个 deliveredMails 集合去重 |
MUST |
| B-7.4 | 按时间正序投(收件箱是倒序返回的) | MUST |
| B-7.5 | mail_type !== 'normal' 的不补投 |
MUST |
| B-7.6 | 逐封投递前再查一次去重集合 | SHOULD |
B-7.1 为什么只补一次:每轮都补会把「模型正在处理中、尚未标已读」的邮件 重复投递 —— 一封邮件起两轮模型。
B-7.2 为什么串行且限 5 封:每封都要起一轮模型。攒了 80 封时一次性放出去 等于对上游打 80 个并发请求,且最后几封要等前面全部跑完。剩下的留在收件箱里, 下次重启或人工触发时再处理。
B-7.3 为什么必须共用去重集合:心跳与 SSE 建连之间有个窗口,那期间到的邮件 既在
pending_mails里、也会被 SSE 推一次。B-7.4 为什么必须正序:同一会话里的多封邮件倒着塞进去,上下文顺序是乱的。
B-7.5 为什么跳过 permission:原来的工具调用早随进程一起没了, 投过去模型没有可恢复的上下文。
B-8 平台权限询问 → 邮件(若平台支持,MUST)
挂平台的权限/审批钩子,把询问转成一封邮件问人。这是 I-1 最直接的体现:
被平台真正拦下的那一次才是事实,不依赖模型「记得」问人。
平台权限钩子被调用
├─ 1. POST /permission/request(带平台的权限 id 作 relay_key)
├─ 2. 记住 平台权限 id → 挂起项
├─ 3a. 能异步等(C-9)→ await 到 permission_decision 回来,返回人的决策
└─ 3b. 不能等 → 立即返回「询问中」,决策回来后用 SDK 回复那条权限
| # | 要求 | 强度 |
|---|---|---|
| B-8.1 | relay_key 用平台的权限 id;平台不给 id 时用 会话:工具:callId 拼一个 |
MUST |
| B-8.2 | 转发失败 → 让位给平台本地 UI,不要占着钩子 | MUST |
| B-8.3 | 同一次询问重复触发只产生一封邮件(服务端按 relay_key 幂等) |
MUST |
| B-8.4 | 邮件正文带足够上下文(工具名、参数摘要),让人能判断 | SHOULD |
| B-8.5 | 权限询问不消耗配额 | MUST |
B-8.1 为什么必须是平台的 id:服务端会随决策事件把
relay_key回传, 插件重启丢了内存映射也能对上(B-4.2)。自己生成的随机 id 重启后就对不上了。B-8.5 为什么不收费:人不点头 Agent 就动不了,对它收费等于收「求人费」。 这里的
relay_key只用于幂等,不是配额豁免的凭证。
B-9 关停(MUST)
| # | 要求 | 强度 |
|---|---|---|
| B-9.1 | 断开 SSE、清除心跳定时器 | MUST |
| B-9.2 | 所有未决权限询问 fail closed(返回「拒绝」或「不可用」) | MUST |
| B-9.3 | 不发「插件下线」类通知邮件 | SHOULD |
B-9.2 为什么 fail closed:平台侧那些
await永不返回的话,整个会话会挂死。 而且默认放行一个没人批准的危险操作,比让它失败严重得多。
三、工具契约 / Tools
插件给模型注册的工具。除 send_mail 与 read_inbox 外都可选。
| # | 工具 | 强度 | 说明 |
|---|---|---|---|
| T-1 | read_inbox |
MUST | 读收件箱,顺便标记已读 |
| T-2 | send_mail |
MUST | 主动发信(三维地址、抄送、reply_to、附件) |
| T-3 | download_attachment |
SHOULD | attachment_id → 本地文件 |
| T-4 | upload_attachment |
SHOULD | 本地文件 → attachment_id |
| T-5 | forward_mail |
MAY | 转发(走完整三维寻址,它是一条新线索) |
| T-6 | connect_to_server |
MAY | 登记密钥并注册,方便首次接入 |
| T-7 | request_permission |
MUST NOT | 见 I-1:改挂平台权限钩子 |
T-1 read_inbox 的三条硬规则
| # | 规则 | 违反后果 |
|---|---|---|
| T-1.1 | 附件清单必须带 attachment_id |
只说「有附件」模型无从下载 |
| T-1.2 | 抄送人必须显示 | 模型以为是私信,回信时漏掉其他参与方 |
| T-1.3 | 只标本次列出的那些,且 status=all 时不标 |
limit 之外的还没看过;把历史邮件标成已读会让下一轮的新邮件混在里面认不出来 |
默认参数:status='unread'、limit=5。
为什么默认只看未读:默认
all会让模型每轮重读旧邮件,几轮之后上下文里 全是重复内容。这个 bug 在 DSH 插件里真实存在过(默认all且完全没标已读)。
共用实现 lib/inbox-format.js。跨平台必须一致 —— 它不依赖任何平台 SDK。
T-7 为什么禁止 request_permission
模型可能忘了调(危险操作直接执行),也可能在不需要时乱调(每一步都问人)。
平台的权限钩子拦下的那一次是事实。见 I-1、B-8。
四、降级语义 / Degradation
平台缺某项能力时的确切退化路径。原则:降级要可见,不要装作正常。
D-1 无权限钩子(缺 C-8)
| 做什么 | 不做什么 |
|---|---|
| 完全不转发权限询问 | request_permission 工具补偿 |
| 在插件启动日志里声明「本平台不支持权限转发」 |
补偿方案会重新引入
I-1反对的那个模式。危险操作交给平台本地 UI 决策, 是一个诚实的限制;让模型自己判断该不该问人,是一个隐蔽的风险。
D-2 权限钩子不能异步等待(有 C-8 缺 C-9)
钩子被调用
├─ 转成邮件发出去(relay: "permission")
├─ 立即返回「待决」/「询问中」——不阻塞
└─ 人的决策经 permission_decision 事件回来后,用平台 SDK 回复那条权限
| # | 要求 | 强度 |
|---|---|---|
| D-2.1 | 钩子内不得阻塞等待(会挂死整个平台请求) | MUST |
| D-2.2 | 转发失败时让位给本地 UI(return next() 或保持「询问中」) |
MUST |
| D-2.3 | 记住 平台权限 id → 挂起项 的映射 |
MUST |
D-2.2 为什么必须让位:转不出去还占着那个钩子,平台会挂在那儿等一个永远 不会来的回答。让位之后本地 UI 还能接管。
D-3 无法指定单轮模型(缺 C-13)
不做降级尝试,modelAttemptOrder 退化为 [undefined](交给平台自己选)。
有 C-13 时,降级尝试的判定是整个契约里最容易写错的地方:
| # | 要求 | 强度 |
|---|---|---|
| D-3.1 | 必须等异步结论才能判定一次尝试成功或失败 | MUST |
| D-3.2 | 「提交调用返回了」不等于「模型跑起来了」 | MUST |
| D-3.3 | 流式 chunk 的 finish 子类型也可能带错误 |
MUST |
| D-3.4 | 超时(60 秒)按成功处理 | MUST |
| D-3.5 | 换模型重试时换一个新的会话 id,并销毁失败那个 | SHOULD |
D-3.1/D-3.2 —— 两个平台都踩了:
promptAsync()立即返回、ctx.agents.create()根本不校验模型。只包一个 try/catch 的话第一个无效模型 会被判成成功,第二个永远试不到。必须监听平台的错误事件或轮次结束事件。D-3.3 具体形态:
{"chunk": {"type": "finish", "reason": {"kind": "error", "failure": {"code": "NO_ADAPTER"}}}}「收到任意 chunk 就算走通」会让无效 provider 判成成功 —— 实测踩过。判据要落在 chunk 的类型上:
finish看 reason,其余(text-delta、block-start…) 才意味着模型真的在产出。D-3.4 为什么超时算成功:模型可能只是很慢(首 token 前要装载上下文)。 把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉,用户拿到两份回复。
D-3.5 为什么换会话 id:复用同一个 id 会让重试接在一条已经出错的会话后面; 不销毁失败那个 agent 的话,它的轮次结束信号还会触发一次自动转发。
D-4 无法列出会话(缺 C-10)
心跳里省略 platform_sessions(不是传 [],见 W-3)。
后果:写信时的会话补全只能看到邮件驱动的那些。
D-5 无模型标题(缺 C-11)
| 优先级 | 别名来源 |
|---|---|
| 1 | 平台自带的 slug(如 witty-planet) |
| 2 | 由模型标题派生(slugFromTitle) |
| 3 | 不回写,保持服务端自动生成的别名 |
不得用占位标题回写("新会话"、"处理邮件" 之类):那会覆盖掉一个
本来可能更有意义的名字,且之后平台真的生成标题时无从判断该不该覆盖。
D-6 工作目录不存在
| # | 要求 | 强度 |
|---|---|---|
| D-6.1 | 目录已存在才用 | MUST |
| D-6.2 | 不存在时回退到兜底目录,不创建 | MUST |
| D-6.3 | 拒绝相对路径 | MUST |
| D-6.4 | 回退时打日志说明期望值与实际值 | MUST |
D-6.2 为什么不创建:一个笔误
/home/porgram/x不该在磁盘上落下真目录 —— Agent 会在里面一无所获地干活,而那比明确的回退更难排查。D-6.3 为什么拒绝相对路径:相对基准是 harness 进程的启动目录, systemd 下通常是
/。
D-7 Gateway 不可达
| # | 要求 | 强度 |
|---|---|---|
| D-7.1 | 心跳失败静默重试,不打断已有会话 | MUST |
| D-7.2 | SSE 断开后自动重连,重连时带 Last-Event-ID |
MUST |
| D-7.3 | 工具调用失败把错误如实返回给模型 | MUST |
| D-7.4 | 不缓存待发邮件到磁盘 | SHOULD |
D-7.4 为什么不做本地队列:那需要一套持久化 + 重放 + 去重,而收益很小 —— Gateway 通常与插件同机。失败如实告知模型,它可以重试或告诉人。
五、线协议 / Wire Protocol
确切的端点、字段与语义边界。完整 API 见 API.md。
认证:所有 Agent 侧请求带 Authorization: Bearer <agent_key>。
W-1 端点清单
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | /api/v1/agent/register |
注册(注意 agent 是单数) |
| POST | /api/v1/agent/heartbeat |
心跳 + 上报 + 读回模型范围 |
| GET | /api/v1/events/stream |
SSE 长连 |
| GET | /api/v1/mail/inbox |
收件箱 |
| POST | /api/v1/mail/read |
批量标记已读 |
| POST | /api/v1/mail/send |
发信 |
| POST | /api/v1/mail/{id}/forward |
转发 |
| POST | /api/v1/permission/request |
发起权限询问 |
| POST | /api/v1/attachments |
上传附件(multipart,字段名 file) |
| GET | /api/v1/attachments/{id} |
下载附件 |
| POST | /api/v1/sessions/{id}/sync |
回写会话别名/标题 |
| GET | /api/v1/agent/models/allowed |
读模型范围(心跳已回传,此处仅供排查) |
W-2 注册
POST /api/v1/agent/register
{ "name": "mybot", "platform": "myplatform", "workspaces": [] }
| 字段 | 类型 | 说明 |
|---|---|---|
name |
string | Agent 名,与人类用户名共用命名空间 |
platform |
string | 平台标识,仅用于展示 |
workspaces |
对象数组 [{name, path}] |
官方插件都传 [] |
workspaces是对象数组,不是字符串数组。 传字符串会得到400 字段 "workspaces" 类型不对:期望 object,收到 string。 工作目录由每封邮件的to_workspace决定,所以传[]是正确做法。
W-3 心跳
POST /api/v1/agent/heartbeat
{
"platform_sessions": [
{ "platform_id": "ses_abc", "workspace": "/home/program/agentmail",
"slug": "witty-planet", "title": "重构导入路径",
"mail_driven": false, "updated_at": "2026-09-02T11:41:16.744Z" }
],
"models": [
{ "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" }
]
}
响应:
{
"status": "ok",
"pending_mails": 0,
"stats": { "agent_name": "dsh", "default_rounds": 20, "sent_total": 0 },
"platform_sessions_synced": 12,
"models_synced": 9,
"allowed_models": [{ "provider": "llmsproxy", "model": "AUTO" }],
"models_unrestricted": false
}
省略字段与传空数组语义不同 —— 这是最容易搞错的一处:
| 字段 | 省略 | [] |
|---|---|---|
platform_sessions |
保留服务端现有镜像 | 平台侧确实一条会话都没有 → 清空镜像 |
models |
保留服务端现有目录 | 一个模型都拿不到 → 清空目录(配置页变空白) |
因此拉取失败时必须省略,不能传空数组。反过来说,平台真的返回空列表时 应当传
[]—— 平台侧删掉的会话必须从补全候选里消失(session位三态语义要求 指向不存在的会话直接 404)。
allowed_models 按优先级排序;models_unrestricted: true 表示管理员没划定范围,
插件应回退到平台默认模型 —— 与「一个都不许用」不同。
W-4 SSE 事件
GET /api/v1/events/stream
Authorization: Bearer <agent_key>
Last-Event-ID: <上次收到的 id> # 仅重连时带
Agent 侧只会收到两个事件:
new_mail
{
"mail_id": "9ea5ff18-...", "session_id": "7b5081e0-...",
"from_name": "admin", "subject": "重构导入路径",
"mail_type": "normal", "role": "to",
"to_workspace": "/home/program/agentmail"
}
| 字段 | 说明 |
|---|---|
role |
to 或 cc |
to_workspace |
收件方那个地址的 path 位(抄送方拿到的是自己那个地址的) |
mail_type |
normal / permission_request / … |
permission_decision
{
"mail_id": "...", "decision_mail_id": "...",
"decision": "同意", "note": "", "decided_by": "admin",
"session_id": "...", "relay_key": "perm_xyz", "relay_kind": "permission"
}
relay_key 是当初发起询问时插件传的那个(平台的权限 id)。服务端持久化了这个
映射并在此回传 —— 因此插件重启丢了内存映射也能对上。
W-5 发信
POST /api/v1/mail/send
{
"to": "admin", // 或 name@path.session
"cc": "ops@root, sre@root", // 逗号/分号/空格分隔
"subject": "Re: 重构导入路径",
"body": "已完成。", // Markdown
"reply_to": "<原 mail_id>",
"session_alias": "refactor", // 仅新建会话时生效
"attachment_ids": ["..."],
"relay": "summary", // "" | "permission" | "summary"
"relay_key": "msg_abc123" // relay 非空时必填
}
响应:
{ "mail_id": "...", "session_id": "...", "session_alias": "refactor",
"budget_remaining": 18, "budget_max": 20 }
relay 为白名单枚举,只有两个合法值:
relay |
用途 | relay_key 取值 |
|---|---|---|
"permission" |
平台权限询问转邮件 | 平台的权限 id |
"summary" |
轮次结束的最终总结、故障报告 | 平台的消息 id / 事件序号 |
| # | 要求 | 强度 |
|---|---|---|
| W-5.1 | relay_key 必须是平台侧生成的稳定 id,模型伪造不出来 |
MUST |
| W-5.2 | 同一个 relay_key 重复提交返回 {"status":"duplicate_relay"} 而非报错 |
— |
| W-5.3 | 模型自主发信不得带 relay |
MUST |
W-5.1/W-5.3 为什么:免配额通道防滥用靠的是幂等键的唯一约束,不是计数。 让模型能自己填
relay_key等于给它一条无限发信的路。
W-6 权限询问
POST /api/v1/permission/request
{
"question": "允许删除 build/ 目录吗?",
"options": ["同意", "拒绝"], // 省略则默认这两个
"context": "工具 bash,命令 rm -rf build/",
"session_id": "...",
"relay_key": "perm_xyz" // 平台的权限 id,用于幂等
}
权限询问本来就不扣配额(人不点头 Agent 就动不了,收费等于收「求人费」),
relay_key 在这里的作用只是幂等 —— 权限事件会重复触发,插件也会重连重放。
W-7 会话命名回写
POST /api/v1/sessions/{id}/sync
{ "alias": "witty-planet", "title": "重构导入路径" }
| 字段 | 写入 | 说明 |
|---|---|---|
alias |
session_alias |
可寻址的短标识 |
title |
subject |
模型生成的摘要 |
| # | 要求 | 强度 |
|---|---|---|
| W-7.1 | alias 必须去掉寻址分隔符 . @ / |
MUST |
| W-7.2 | 不得回写占位标题 | MUST |
| W-7.3 | 中文可以保留 | — |
W-7.1 为什么:留在别名里会让它自己被解析器切开,填进去的地址指向一个 完全不同的目标。中文不影响解析(按最后一个
.切分),而转拼音后既不好读 也不好打。服务端撞名时自动追加
-2/-3,因此同步永不失败。人工改过的别名 (alias_source = 'manual')不会被平台同步覆盖。
W-8 附件
POST /api/v1/attachments multipart/form-data,字段名 file
GET /api/v1/attachments/{id}
上限 25 MB(AGENTMAIL_MAX_ATTACHMENT_BYTES)。超限返回 413。
下载一律 application/octet-stream + Content-Disposition: attachment + nosniff。
W-9 错误约定
| 状态码 | 含义 | 插件应当 |
|---|---|---|
| 400 | 请求体/字段错误(信息指向具体字段) | 修代码,不要重试 |
| 401 | 密钥无效 | 停止重试,打印明确错误 |
| 403 | 配额用尽 | 把原因如实告诉模型 |
| 404 | 会话别名不存在 | 不要改成 .new 重试 —— 那会静默新建 |
| 413 | 附件超限 | 告知模型文件太大 |
| 429 | 新建会话速率超限(20 次/小时) | 按响应里的秒数退避 |
| 5xx | 服务端故障 | 有限重试 |
404 不得自动降级为
.new:那正是session位三态要防的事 —— 一个拼错的 别名会让 Agent 在一条空会话里干活,而发件人以为在续原来那条。
六、不变量与禁止事项 / Invariants
违反这些的代码可能测试全绿、跑起来也不报错,但行为是错的。
禁止事项
| # | MUST NOT | 为什么 |
|---|---|---|
| N-1 | 提供 request_permission 之类让模型自报状态的工具 |
I-1:模型会忘、会乱调 |
| N-2 | 为不存在的 to_workspace 创建目录 |
笔误会落下真目录,Agent 在里面一无所获 |
| N-3 | 用相对路径作 cwd | 相对基准是进程启动目录,systemd 下是 / |
| N-4 | 新建会话时传占位标题 | 掐掉平台自己的命名机制(I-3) |
| N-5 | 模型自主发信带 relay |
免配额通道防滥用靠幂等键,不是计数 |
| N-6 | 把 reasoning 块转发进邮件 | 思考过程不是结论 |
| N-7 | 拉取失败时传 [] 而非省略字段 |
会清空服务端的镜像/目录(W-3) |
| N-8 | 404 后自动改用 .new 重试 |
静默新建,违反三态语义 |
| N-9 | 未决权限询问在关停时 fail open | 放行一个没人批准的危险操作 |
| N-10 | 修改模型输出的文本内容 | I-4:插件只搬运 |
| N-11 | 首次 SSE 连接带 Last-Event-ID |
会重投历史邮件 |
| N-12 | 让共用模块依赖平台 SDK | 那是它们能跨平台共用的前提 |
共用模块必须逐字节相同
lib/ 下的文件在所有插件里逐字节相同,由 deploy/check-shared-libs.sh 校验,
并纳入 deploy/install.sh 的门禁。
| 文件 | 职责 |
|---|---|
relay-dedup.js |
自动转发去重:本轮模型是否已亲手回过这条线索 |
inbox-format.js |
收件箱渲染 + 已读策略 |
session-snapshot.js |
平台会话快照整理(subagent 过滤、slug 派生、去重) |
workspace.js |
寻址 path 位 → 可用的 cwd |
model-scope.js |
模型目录整理 + 降级顺序 + 失败报告 |
catchup.js |
离线期间积压邮件的补投选择 |
为什么必须逐字节相同而不是「行为一致」:一侧改了另一侧没改,两个平台的行为 会悄悄分叉 —— 同一封邮件在 A 平台标了已读、在 B 平台没标,而两处代码看起来都 「对」。这类分叉没有测试能发现,只能靠 diff。
接新平台时把
lib/与test/整个拷过去,平台专属逻辑写在入口文件里。 TypeScript 插件另需手写.d.ts。
平台会话快照的四条规则
| # | 规则 | 为什么 |
|---|---|---|
| S-1 | 无 slug 的会话不报 |
slug 是填进 session 位的值,没有它这一项点下去只能得到空的 session 段 |
| S-2 | subagent 子会话不报 | 父 agent 内部的工作单元,人往里发邮件毫无意义;且标题就是派活提示词前缀,slug 全撞名 |
| S-3 | slug 撞名只留最近那条 | 服务端只能取一条,上报同名项只会让补全里出现几个点哪个都不确定的候选 |
| S-4 | 按最近活跃排序,截断到 200 条 | 上千个候选对人没有意义 |
S-2 的实测数据:DSH 一次列出 49 条子会话,其中九条标题都叫
You are auditing ONE file。
状态持久性
以下状态目前只在内存,插件重启后丢失。这是已知取舍,不是 bug:
| 状态 | 丢失后果 |
|---|---|
sessionMap / reverseMap |
续谈的邮件会新开一条平台会话 |
mailDrivenSessions |
快照里 mail_driven 全变成 false |
pendingPermissions |
决策回来时走 B-4.2 的退化路径 |
explicitSends |
重启后的第一轮可能重复转发 |
deliveredMails |
由 B-7 的补拉去重兜住 |
要持久化的话应当落在平台的会话元数据里,而不是插件自己的文件 —— 那样才能跟着会话一起被平台清理。
七、验收清单 / Acceptance Checklist
每条都可机械核对。[ ] 全打完之前不要说「接好了」。
7.1 静态检查
[ ] lib/ 与 test/ 下的共用文件与参照插件逐字节相同
./deploy/check-shared-libs.sh
[ ] 共用模块的纯函数测试全绿
cd plugins/<新插件> && npm test
[ ] 入口文件不导出除 default 之外的东西(若平台按导出扫插件)
[ ] grep 自查禁止事项:
grep -rn "request_permission" src/ index.js # 应为空(N-1)
grep -rn "mkdir" src/ index.js # 不得用于 to_workspace(N-2)
grep -rn "'\[\]'" 上报处 # 拉取失败处不得传空数组(N-7)
7.2 连接与生命周期
[ ] 首次启动无密钥时生成一把并打印全文(B-1.1)
[ ] 管理员登记后重启,日志显示「已接入 <URL>,身份 <name>」
[ ] sqlite3 <db> "SELECT status, last_seen FROM agents WHERE agent_name='<name>'"
→ status=online,last_seen 每 30 秒推进(B-2.1)
[ ] 断网 60 秒再恢复:SSE 自动重连,期间的邮件通过 Last-Event-ID 补回(D-7.2)
[ ] kill 插件:未决权限询问全部 fail closed(B-8.2)
7.3 主链路
[ ] 发一封到 <name>@<存在的目录>.new
→ 平台里出现新会话,且 cwd == 那个目录(B-3.1)
→ 模型调了 read_inbox(T-1)
→ 一轮结束后收到自动回信(B-5)
→ 回信不消耗配额:sessions.used_rounds 保持不变(I-2)
[ ] 会话别名回写:sqlite3 "SELECT session_alias, subject FROM sessions ..."
→ 别名是平台生成的短名,subject 是模型生成的标题(W-7)
→ 别名里没有 . @ /(W-7.1)
[ ] 用回写的别名续谈:<name>@<目录>.<别名>
→ 落进同一条平台会话,不新建(B-3.3)
[ ] 发到一个不存在的别名
→ 404,且平台侧没有新建任何会话(N-8)
[ ] 模型主动调 send_mail 回信的那一轮
→ 只有一封邮件,没有额外的自动转发(B-5.3)
7.4 工作目录
[ ] to_workspace 指向存在的目录 → 会话 cwd 是它
[ ] to_workspace 指向不存在的目录 → 回退到兜底目录,且磁盘上没有新目录(D-6.2)
ls <那个不存在的路径> → No such file or directory
[ ] to_workspace 是相对路径 → 拒绝并回退(D-6.3)
[ ] to_workspace 为空 → 用兜底目录
7.5 降级与失败
[ ] 把模型范围设成两个都无效的路由,发一封
→ 收到「处理失败: <原主题>」邮件,正文列出两个路由与原因(B-6)
→ sessions.used_rounds 保持 0(B-6.3 走免配额通道)
→ relayed_mails 里有一条 model-failure:<mail_id>
[ ] 把范围设成「第一个无效 + 第二个有效」,发一封
→ 日志出现「<有效模型> 成功(前 1 个失败)」(D-3.1)
→ 收到正常回信
[ ] 停插件 → 发一封 → 启插件
→ 日志出现「补投 N 封离线期间的邮件」(B-7)
→ 收到回信
[ ] 插件在线时再发一封
→ 只收到一封回信,没有重复投递(B-7.3)
7.6 权限(若平台支持)
[ ] 触发一次平台原生的权限询问
→ 邮箱里出现一封 mail_type=permission_request 的邮件
→ 不消耗配额(I-2)
[ ] 在界面上点「同意」
→ 平台侧那次工具调用继续执行(D-2 / B-4.1)
[ ] 重复触发同一次询问(事件重放)
→ 只产生一封邮件(W-6 幂等)
7.7 上报
[ ] 心跳带模型目录
sqlite3 "SELECT COUNT(*) FROM agent_model_catalog WHERE agent_name='<name>'"
→ 与平台实际可用模型数一致
[ ] 心跳带会话快照
sqlite3 "SELECT COUNT(*) FROM agent_platform_sessions WHERE agent_name='<name>'"
→ 与平台会话数一致,且不含 subagent(S-2)
→ 没有重复 slug(S-3)
[ ] 平台侧删掉一条会话后
→ 下次心跳后镜像里也消失(W-3 整表替换)
[ ] 断开平台的模型服务(让 list_models 失败)
→ 心跳里省略 models 字段,服务端目录**不变**(N-7)
7.8 端到端脚本
上面的检查可以攒成一个脚本。参照 deploy/remote-agent-demo.py ——
它用纯标准库跑完了注册 / 心跳 / SSE / 收发信,可以拿来当协议层的对照实现。
八、平台差异对照 / Platform Matrix
两次真实适配的对照表。接新平台时逐行回答「我这边是什么」。
| 关注点 | opencode | DeepSeek Harness |
|---|---|---|
| 插件形态 | export default async function(input) |
Cordis:export const inject + apply(ctx, config) |
| 建会话 | client.session.create({ query: { directory } }) |
ctx.agents.create({ sessionId, meta: { cwd }, agentOptions }) |
| 注入一轮 | client.session.promptAsync({ parts }) |
agent.followup(UserMessage) |
| 工具定义 | zod schema | defineTool() + spec 格式参数 |
| 轮次结束 | session.idle 事件 |
agent/status → idle |
| 模型失败信号 | session.error 事件 |
turn/end 的 reason.kind === 'error' |
| 权限钩子 | permission.ask(同步,不能等) |
approval/request(异步 waterfall,能等) |
| 会话列表 | client.session.list() |
ctx.sessionQuery.listSessions() |
| 模型目录 | client.config.providers()(models 是对象) |
ctx.llm.listProviders() + listModels() |
| 别名来源 | session.slug(创建时就有) |
模型标题派生 |
| 日志可见性 | console.error |
console.error(ctx.logger 不进 journalctl) |
九、踩过的坑 / Pitfalls
按排查成本降序。新接平台前先扫一遍这一节 —— 每一条都是几小时到一天的代价。
9.1 followup() 的参数形状(花了一下午)
DSH 的 agent.followup(message) 要完整的 UserMessage:
agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
照抄 opencode 的 parts 数组 [{ type: 'text', text }] 不会当场报错 ——
agent-loop 会一路走到 preStep 里读 message.source.kind,然后抛
Cannot read properties of undefined (reading 'kind')。错误落在框架内部,
既不指向调用点也不说是哪个字段,turn 一 start 就 end、模型请求根本不发出去。
教训:「错了不当场报错、只在深处炸一个无关错误」的约定必须用测试钉住。
lib/message.js+test/message.test.mjs就是为此存在的。
9.2 模型失败不是同步抛出的(两个平台都踩)
见 D-3。两次都表现为「第一个无效模型被判成成功,降级从不发生」。
opencode 侧还有个额外复杂度:event 钩子在 deliverMail 之外,需要一张
turnWatchers 表把两者接起来。
9.3 工作目录用了自己拼的临时路径
DSH 插件最初用 ~/.dsh/mail-sessions/mail-<uuid> 作 cwd,结果所有邮件会话在
平台界面上全落进「未分组」(平台按 cwd 分组)。而且 Gateway 当时根本没把地址的
path 位发给插件 —— 补了 SSE payload 的 to_workspace 才修好。
修好前后的会话目录名:--root-.dsh-mail-sessions-mail-<uuid>-- →
--home-program-agentmail--。
9.4 opencode 插件入口只能有 default 一个导出
opencode 用 Object.values(mod) 把每个导出都当插件工厂检查(反编译确认)。
入口多导出一个 Map 就报 Plugin export is not a function,插件静默失效、
邮件全投不进去。
因此所有可测试的逻辑必须放
lib/子模块,入口只export default。
9.5 Cordis 插件必须导出 inject
没有它 ctx.tools / ctx.agents 根本不存在(报
cannot get property "tools" without inject)。
但不要把可选服务写进 inject —— 那是硬依赖,服务没挂载时整个插件不启动。
DSH 的 sessionQuery 用 ctx.get('sessionQuery') 取:会话上报只是补全体验,
不该能把邮件投递整体拘死。
9.6 DSH 的 setup 应当留空
给 ctx.agents.create({ setup }) 挂 preset + installModelSelection 会让整个
turn 崩溃。base bundle 已经注册了 agent-loop / llm / tools,
agentOptions: { provider, model } 就够了。
9.7 DSH 工具必须经 defineTool
直接给 ctx.tools.register 原始对象会报
parameters must be lossless JSON before schema projection。
defineTool 负责把 spec 格式的 parameters 转成 JSON Schema 并在 execute 前
校验,还必须声明 output: { schema, render }。
9.8 部署环境的坑
| 现象 | 原因 | 处理 |
|---|---|---|
读不到 ~/.agentmail/agent.key |
systemd 不注入 HOME |
service 里显式 Environment=HOME=/root |
| 插件像没加载 | opencode 插件懒加载 | ExecStartPost 发一个空请求预热 |
ctx.logger.info 看不到 |
DSH 的 logger 不进 journalctl | 用 console.error |
dsh --patch 不生效 |
--patch 只对 dsh --profile <name> 有效 |
配置写进 profile 的 cordis.patch.yml |
| 第二个实例起不来 | task-board 的 ledger 文件锁 | 注入现有实例,不另起 |
9.9 登记密钥的字段名是 key_token
POST /api/v1/admin/agent-keys 登记自带密钥时字段名是 key_token,不是 key。
传错服务端会静默生成一个随机 token 且全文只回一次。
9.10 node --test test/ 在 node 24 上报 MODULE_NOT_FOUND
它把目录名当模块解析。必须写 glob:node --test 'test/*.test.mjs'。
附:文档关系
| 文档 | 内容 |
|---|---|
| 本文 | 插件的规格:能力矩阵、行为约定、降级语义、线协议、不变量、验收清单 |
API.md |
完整 HTTP API(含人类侧接口) |
PLAN.md |
分阶段实施记录与决策依据 |
PHASE7-REMAINING.md |
未完成项与已知取舍 |
参照实现:plugins/opencode-mail-bridge/、plugins/dsh-mail-bridge/、
deploy/remote-agent-demo.py(纯标准库的协议层对照)。