Files
MailUI4Agents/docs/PLUGIN-CONTRACT.md
JianFeeeee e6fd2fafdc feat: agent 邮件寻址能力全面补齐 + .new 别名替换
## 别名替换(让 .new 邮件可寻址)

repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调

notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new

FormatAddress(name,path,session) 空 path 也必须留 @ 与 .

## Agent 侧寻址发现(五个只读端点)

handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做

repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)

repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空

## 共用模块(三插件逐字节相同)

lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
                  renderParticipants/renderContacts/renderThread

lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
  - selfName 参数(兼容旧调用不传的情况)

check-shared-libs.sh 纳入 addressing + discovery

## 插件侧

opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)

dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)

## 测试

repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
  → dsh 用 session_participants 取到地址 → send_mail 给 opencode
  → 地址取自工具返回值(.crisp-planet),未手工拼写
2026-09-03 12:09:12 +08:00

1179 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` | 走 `D-5` 阶梯:退到邮件主题派生别名 | 补全列表里的名字来自人写的主题,而非模型对内容的概括 |
| **C-12** | 列出可用模型 | `list_models` | 不上报模型目录 | 管理员无法在配置页划定模型范围 |
| **C-13** | 指定单轮模型 | `per_turn_model` | 不做降级尝试 | 主力模型故障时该 Agent 整体不可用 |
| **C-14** | 会话内文件读写工具 | `file_tools` | 附件下载后模型看不到 | 附件功能形同虚设 |
> **C-11 可能是「部分具备」。** pi 是实例:它确实会生成会话标题,但生成器在
> **pi-web** 包里,不在 `pi-coding-agent` 内核里。人在 pi-web 界面上开的会话有标题,
> 桥用 SDK 起的会话没有。判定的依据只能是**桥自己起的那条会话**上
> `sessionName` 到底有没有值,不是「这个平台有没有这个功能」。
>
> 具备 `C-11` 的平台还要回答第二个问题:**别名由谁定稿**。别名负有寻址唯一性义务
> (撞名追 `-2`、`manual` 来源永远优先),平台侧没有这个约束。两侧各自命名会分叉,
> 因此必须由服务端定稿、插件把响应里的值**回写**进平台(见 `D-5`、`W-7`)。
### 能力自检脚本
接入前先回答这七个问题。任何一个答不出来,先去读平台文档,不要开始写代码:
```
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
8. 往一条**空闲**会话里注入一轮,用的是哪个方法? → C-1见 9.11
```
> 问题 4 在三次适配里被漏掉过两次,两次都造成「无效模型被判成成功」——
> 详见 `D-3` 与第九节。
>
> 问题 8 是 pi 适配加上的:平台常常有两个注入入口(一个起新轮、一个往正在跑的轮次里
> 排队),而排队那个在会话空闲时**静默什么也不做** —— 不报错、不超时、没有回信。
> 见 9.11。
---
## 二、生命周期与行为约定 / 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 心跳MUST30 秒)
请求体三个字段全部**可选**,语义见 `W-3`
```json
{ "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 具体形态**
> ```json
> {"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 | 由**邮件主题**派生 | 平台没有标题,或标题不可用(见下) |
| 4 | 不回写,保持服务端自动生成的别名 | 以上都没有 |
**不得**用占位标题回写(`"新会话"``"处理邮件"` 之类):那会覆盖掉一个
本来可能更有意义的名字,且之后平台真的生成标题时无从判断该不该覆盖。
> **第 3 级是 pi 适配加上的。** 原先的阶梯假定「平台要么有标题,要么完全没有命名机制」。
> pi 是第三种情况:内核不生成标题(生成器在 pi-web 里),于是 SDK 起的会话
> `sessionName` 恒为 `undefined`。只走前两级的话别名永远是空的 ——
> `name@path.<别名>` 续谈无从下手,`.new` 又是一次性的,那条会话事实上只能收一封信。
>
> **「标题不可用」是真实存在的一类。** pi-web 的标题生成器会泄漏思维链,
> 本机 82 条会话里捞到过这两条真实样本:
>
> ```
> The user is asking me to generate a title for a coding-agent
> 我们只需要生成标题不包含其他内容。标题应反映请求内容测试opencode的源。简短
> ```
>
> 它们语法上是合法标题(`cleanSessionName` 只取首行 + 截 60 字符),
> 派生出的别名却是一句废话。判废逻辑在 `lib/session-snapshot.js` 的
> `isUnusableName()`:命中就降到下一级,**不是**改写成别的。
#### 别名必须由服务端定稿
具备 `C-11` 的平台也要走这一步。插件提议、服务端定稿、插件把响应里的值回写:
```
插件观察到平台的名字
→ POST /sessions/{id}/sync { alias: slug, title: 原文 }
→ 读**响应里**的 alias可能被改写过
→ 与平台当前名字不同 → 写回平台
```
两个原因让单向推送无法收敛:
| 服务端行为 | 后果 |
|---|---|
| 别名撞名时追 `-2``-3``SyncSessionAlias` | 平台侧没有唯一性约束,不会跟着改 |
| `alias_source='manual'`(人手工改过)永远优先 | 服务端**原样返回当前别名**,忽略提议 |
不回写的话:邮箱里显示 `fix-leak-2`、平台界面上显示 `fix-leak`
用户按界面上看到的名字发信会收到「无法送达」。
回写有三条实测约束pi 的形态,其它平台需各自确认):
| # | 约束 | 违反的后果 |
|---|---|---|
| 1 | 只能用平台的改名 API不能自己写会话文件 | 首条 assistant 消息落盘前文件不存在pi 首次落盘用 `openSync(file,"wx")`,抢先创建让它抛 `EEXIST` |
| 2 | 活着的会话管理器从不重读文件 | 它自己后续的 `session_info` 会覆盖外部改名 |
| 3 | 空名字是**清除**语义 | 「没拿到定稿值」绝不能落成一次空写入 |
还要防自激循环:改名通常会触发「名字变了」事件,而那个事件正是同步的触发源。
插件必须记住**上次提交的内容指纹**并在相同时跳过。指纹不能用平台名字本身 ——
名字为空时(上面 pi 的常态)它无法区分「还没提交过」与「提交过、内容没变」。
### 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`](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 注册
```json
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 心跳
```json
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)" }
]
}
```
响应:
```json
{
"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`**
```json
{
"mail_id": "9ea5ff18-...", "session_id": "7b5081e0-...",
"from_name": "admin", "subject": "重构导入路径",
"mail_type": "normal", "role": "to",
"to_workspace": "/home/program/agentmail",
"session_alias": "refactor-imports",
"reply_address": "admin@.refactor-imports",
"self_address": "pi@/home/program/agentmail.refactor-imports"
}
```
| 字段 | 说明 |
|---|---|
| `role` | `to``cc` |
| `to_workspace` | **收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的) |
| `mail_type` | `normal` / `permission_request` / … |
| `session_alias` | 这条会话今后的寻址名 |
| `reply_address` | 「把回信发回这条会话」的现成地址 |
| `self_address` | 对方应当用来称呼自己的地址,供转发/报告时引用 |
> **`reply_address` 应当放进提示词。** 插件会自动转发本轮总结(`B-5`
> 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址
> 的话,`.new` 会被拼进去,于是回信静默开出一条**新**会话,原来的线索里再无下文。
**`permission_decision`**
```json
{
"mail_id": "...", "decision_mail_id": "...",
"decision": "同意", "note": "", "decided_by": "admin",
"session_id": "...", "relay_key": "perm_xyz", "relay_kind": "permission"
}
```
`relay_key` 是当初发起询问时插件传的那个(平台的权限 id。服务端持久化了这个
映射并在此回传 —— 因此插件重启丢了内存映射也能对上。
### W-5 发信
```json
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 非空时必填
}
```
响应:
```json
{ "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 权限询问
```json
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 会话命名回写
```json
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_workspaceN-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=onlinelast_seen 每 30 秒推进B-2.1
[ ] 断网 60 秒再恢复SSE 自动重连,期间的邮件通过 Last-Event-ID 补回D-7.2
[ ] kill 插件:未决权限询问全部 fail closedB-8.2
```
### 7.3 主链路
```
[ ] 发一封到 <name>@<存在的目录>.new
→ 平台里出现新会话,且 cwd == 那个目录B-3.1
→ 模型调了 read_inboxT-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 保持 0B-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>'"
→ 与平台会话数一致,且不含 subagentS-2
→ 没有重复 slugS-3
[ ] 平台侧删掉一条会话后
→ 下次心跳后镜像里也消失W-3 整表替换)
[ ] 断开平台的模型服务(让 list_models 失败)
→ 心跳里省略 models 字段,服务端目录**不变**N-7
```
### 7.8 端到端脚本
上面的检查可以攒成一个脚本。参照 `deploy/remote-agent-demo.py` ——
它用纯标准库跑完了注册 / 心跳 / SSE / 收发信,可以拿来当协议层的对照实现。
---
## 八、平台差异对照 / Platform Matrix
三次真实适配的对照表。接新平台时逐行回答「我这边是什么」。
| 关注点 | opencode | DeepSeek Harness | pi |
|---|---|---|---|
| 插件形态 | `export default async function(input)` | Cordis`export const inject` + `apply(ctx, config)` | **常驻守护进程**,不是插件(见下) |
| 建会话 | `client.session.create({ query: { directory } })` | `ctx.agents.create({ sessionId, meta: { cwd }, agentOptions })` | `createAgentSession({ cwd, sessionManager, … })` |
| 注入一轮 | `client.session.promptAsync({ parts })` | `agent.followup(UserMessage)` | `session.prompt(text)`;忙时 `prompt(text, {streamingBehavior:'followUp'})` |
| 工具定义 | zod schema | `defineTool()` + spec 格式参数 | `defineTool()` + **TypeBox** schema |
| 轮次结束 | `session.idle` 事件 | `agent/status``idle` | `prompt()` 的 promise resolve事件是 `agent_end` |
| 模型失败信号 | `session.error` 事件 | `turn/end``reason.kind === 'error'` | `prompt()` reject **或** 末条 assistant 的 `stopReason==='error'` |
| 权限钩子 | `permission.ask`**同步,不能等** | `approval/request`(异步 waterfall**能等** | `tool_call` 扩展事件(**能 await**,实测) |
| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | `SessionManager.listAll()`**不传参**,传字符串会被当自定义目录) |
| 模型目录 | `client.config.providers()``models` 是**对象** | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`**不是** `getModels()`1221 条里只有 1 条能用) |
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**SDK 会话没有平台标题,见下) |
| 日志可见性 | `console.error` | `console.error``ctx.logger` 不进 journalctl | `console.error` |
> **pi 为什么是守护进程而不是扩展**pi 扩展被加载进**一条已经存在的**会话,
> 那条会话的 cwd 由启动 pi 的人决定。而 `B-3.1` 要求每封邮件的 `to_workspace`
> 成为会话 cwd —— 扩展做不到「按邮件新开一条 cwd 不同的会话」。
> 桥因此用 SDK 起会话,一个进程里并存多条不同 cwd 的会话(实测可行)。
> 代价是它需要自己的 systemd 单元(`deploy/pi-mail-bridge.service`)。
>
> **pi 的 `C-11` 是有条件的**pi 会生成会话标题,但生成器在 **pi-web** 包里
> `sessionNameGenerator.js`),不在 `pi-coding-agent` 内核里。桥用 SDK 起的会话
> 走不到那条路径,`session.sessionName` 一直是 `undefined`。因此 pi 侧的别名走
> `D-5` 阶梯的第二级(邮件主题派生),并把 Gateway 的定稿值**回写**进
> `session.setSessionName()` —— 这一步让 pi-web 界面上显示的名字与邮箱里一致。
>
> **回写只能用 `setSessionName()`**,三条实测约束:首条 assistant 消息落盘前
> 会话文件还不存在pi 首次落盘用 `openSync(file,"wx")`,外部抢先创建会让它抛
> `EEXIST`;活着的 `SessionManager` 从不重读文件,它自己后续的 `session_info`
> 会覆盖外部改名;空名字是**清除**语义,不是「不改」。
---
## 九、踩过的坑 / Pitfalls
按排查成本降序。新接平台前先扫一遍这一节 —— 每一条都是几小时到一天的代价。
### 9.1 `followup()` 的参数形状(花了一下午)
DSH 的 `agent.followup(message)` 要完整的 `UserMessage`
```ts
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 文件锁 | 注入现有实例,不另起 |
| 平台活着但外网访问不了 | 转发层socat / nginx没跟着平台重启 | 见下 |
**转发层的依赖要写两个方向。** 平台监听 loopback、靠一个 socat 单元暴露到 LAN
或公网时,只写 `Requires=` 是不够的:
| 指令 | 管什么 | 少了会怎样 |
|---|---|---|
| `Requires=平台` | 平台**停止**时转发也停 | —— |
| `PartOf=平台` | 平台**重启**时转发也重启 | `restart` 后转发永久消失 |
| 平台侧 `Wants=转发` | 平台**启动**时拉起转发 | 手工 `start` 后转发不起来 |
`Requires` 不含重启语义,`PartOf` 不含启动语义,单元自己的
`WantedBy=multi-user.target` 只在开机时生效 —— 三者缺一,
`systemctl restart <平台>``stop` + `start` 之后就会出现
**「平台进程活着、loopback 通、外网全不通」** 这个很难联想到转发层的现象。
2026-09-02 真实发生过一次,隐形 9 小时:为验证插件的离线补投重启了 dsh
socat 被 `Requires` 带停后再没起来。加 `PartOf` 后又发现 `start` 起不来,
才补上平台侧的 `Wants`
另外给转发单元加 `SuccessExitStatus=143`:被 SIGTERM 停掉是正常路径,
不加会在 `systemctl status` 里留一条红色 `failed`,掩盖真正的故障。
> 用 `Wants` 而不是 `Requires` 引用转发层:转发起不来不该阻止平台本身启动,
> loopback 访问仍然可用(插件走的正是 loopback
### 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'`
### 9.11 pi 的 `followUp()` 在会话空闲时什么也不做(静默丢邮件)
`session.followUp(text)` 只往 `followUpQueue` 里塞消息,而那个队列**只在运行中的
轮次末尾**被 drain`pi-agent-core/agent.js` 的 run 循环,以及 `continue()`)。
会话空闲时(上一轮早已结束)塞进去的消息永远没人取。
表现极具欺骗性:日志打了「续谈成功」,`prompt()` 没报错,收件箱里却只有来信、
没有回复。既没有异常也没有超时。
> **正确做法**:按 `session.isStreaming` 分流 —— 空闲用 `prompt(text)` 直接起一轮,
> 正在跑用 `prompt(text, { streamingBehavior: 'followUp' })` 排到当轮之后
> (缺 `streamingBehavior` 时 pi 会抛 `Agent is already processing.`)。
> 不要用 `steer`:那会打断当前轮,而当前轮正在处理**上一封邮件**。
### 9.12 pi 的全局扩展会 `listen` 固定端口
`~/.pi/agent/extensions/` 下的 `pi-a2a` / `pi-acp` 在加载时就 bind
`127.0.0.1:12010` / `12011`。同机已有 pi 在跑时,任何新起的 pi 进程(包括
`pi --help`)都会 `EADDRINUSE` 并把整条会话拖死。
> 桥用 `noExtensions: true` + 内联 `extensionFactories` 起会话:既避开端口冲突,
> 也不继承那套给人类交互用的扩展TUI 命令、快捷键、状态栏对邮件没有意义)。
> 邮件工具走 `customTools`,权限钩子走内联工厂。
### 9.13 `SessionManager.listAll(dir)` 的参数不是 agentDir
它的字符串参数是**自定义会话目录**,会直接在里面找 `.jsonl`。传
`getAgentDir()``~/.pi/agent`)得到的是空列表 —— 会话在它的 `sessions/`
子目录下按 cwd 分目录存放。**不传参数**才会走默认的逐 cwd 扫描。
同理不要用 `list(cwd)`:桥的进程 cwd 与会话 cwd 无关,按前者过滤会漏掉
所有真正在干活的会话。
---
## 附:文档关系
| 文档 | 内容 |
|---|---|
| **本文** | 插件的规格:能力矩阵、行为约定、降级语义、线协议、不变量、验收清单 |
| [`API.md`](API.md) | 完整 HTTP API含人类侧接口 |
| [`PLAN.md`](PLAN.md) | 分阶段实施记录与决策依据 |
| [`PHASE7-REMAINING.md`](PHASE7-REMAINING.md) | 未完成项与已知取舍 |
参照实现:`plugins/opencode-mail-bridge/``plugins/dsh-mail-bridge/`
`plugins/pi-mail-bridge/``deploy/remote-agent-demo.py`(纯标准库的协议层对照)。