## 别名替换(让 .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),未手工拼写
1179 lines
56 KiB
Markdown
1179 lines
56 KiB
Markdown
# 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 心跳(MUST,30 秒)
|
||
|
||
请求体三个字段全部**可选**,语义见 `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_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 | 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`(纯标准库的协议层对照)。
|