From 289f37f7fb08ffd4bd4e8be20033d9af5389799f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 3 Sep 2026 08:09:54 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20PLUGIN-GUIDE=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E4=B8=BA=20PLUGIN-CONTRACT=EF=BC=88=E5=8F=AF=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E7=9A=84=E6=8F=92=E4=BB=B6=E8=A7=84=E6=A0=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原 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。 --- README.md | 5 +- deploy/check-shared-libs.sh | 2 +- deploy/install.sh | 2 +- docs/API.md | 2 +- docs/PHASE7-REMAINING.md | 4 +- docs/PLAN.md | 5 +- docs/PLUGIN-CONTRACT.md | 1028 +++++++++++++++++++++++++++++++++++ docs/PLUGIN-GUIDE.md | 536 ------------------ 8 files changed, 1039 insertions(+), 545 deletions(-) create mode 100644 docs/PLUGIN-CONTRACT.md delete mode 100644 docs/PLUGIN-GUIDE.md diff --git a/README.md b/README.md index 9f5ec1b..60d7488 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ agentmail/ ├── docs/ │ ├── PLAN.md # 分阶段实施计划 │ ├── MVP-SPEC.md # MVP 技术规格书 -│ └── PLUGIN-GUIDE.md # Agent 平台插件适配指南 +│ └── PLUGIN-CONTRACT.md # Agent 平台插件契约(规格 + 验收清单) ├── gateway/ # 后端(Go,单二进制) │ ├── cmd/server/ # 入口与路由表 │ └── internal/ @@ -254,6 +254,7 @@ Agent 干完活可以在正文里**提议**改成更贴切的名字,但改不 ## 文档 - [WebAPI](docs/API.md) — 接口清单、认证方式、错误约定 -- [插件适配指南](docs/PLUGIN-GUIDE.md) — 接一个新 Agent 平台要实现什么,以及踩过的坑 +- [插件契约](docs/PLUGIN-CONTRACT.md) — 接一个新 Agent 平台的规格:能力矩阵、行为约定、 + 降级语义、线协议、不变量、验收清单,以及两次适配踩过的坑 - [实施计划](docs/PLAN.md) — 分阶段任务与验收标准 - [MVP 技术规格书](docs/MVP-SPEC.md) — 数据模型与接口细节 diff --git a/deploy/check-shared-libs.sh b/deploy/check-shared-libs.sh index 0284d9e..7dde1d3 100755 --- a/deploy/check-shared-libs.sh +++ b/deploy/check-shared-libs.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 共用模块必须逐字节相同 —— 见 docs/PLUGIN-GUIDE.md §8。 +# 共用模块必须逐字节相同 —— 见 docs/PLUGIN-CONTRACT.md 第六节。 # # 一侧改了另一侧没改,两个平台的行为就会悄悄分叉:同一封邮件在 opencode 那边 # 标了已读、在 DSH 那边没标,而两处代码看起来都"对"。 diff --git a/deploy/install.sh b/deploy/install.sh index dff9fe1..ac53c33 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -19,7 +19,7 @@ echo "==> 构建前端" ( cd "$REPO/web" && npm run typecheck && npm test && npm run build ) echo "==> 校验插件共用模块同源" -# lib/ 下的纯函数模块在两个插件里逐字节相同(见 docs/PLUGIN-GUIDE.md §8)。 +# lib/ 下的纯函数模块在两个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。 # 一侧改了另一侧没改,两个平台的行为就会悄悄分叉。 "$REPO/deploy/check-shared-libs.sh" diff --git a/docs/API.md b/docs/API.md index 3e59dd3..2738788 100644 --- a/docs/API.md +++ b/docs/API.md @@ -349,7 +349,7 @@ Gateway 只看得见邮件驱动的那部分,人直接在平台界面上开的 空数组的语义是「平台侧确实一条会话都没有」,会把镜像抹掉 - 单次上限 200 条,按最近活跃排序后截断 -字段要求见 [插件适配指南](PLUGIN-GUIDE.md#四平台会话快照上报)(含 subagent 过滤、 +字段要求见 [插件契约](PLUGIN-CONTRACT.md#五线协议--wire-protocol)的 `W-3`(含 subagent 过滤、 slug 去重等规则)。 心跳还可带 `models`(平台当前看得见的模型目录): diff --git a/docs/PHASE7-REMAINING.md b/docs/PHASE7-REMAINING.md index ef0930b..93efef4 100644 --- a/docs/PHASE7-REMAINING.md +++ b/docs/PHASE7-REMAINING.md @@ -1,6 +1,6 @@ # Phase 7 剩余项与已知生产缺陷追踪 -7.7 DSH 插件已完成(见 `docs/PLUGIN-GUIDE.md` 与 PLAN.md §7.7)。 +7.7 DSH 插件已完成(见 `docs/PLUGIN-CONTRACT.md` 与 PLAN.md §7.7)。 ## 7.8 跨主机 Agent —— 协议层面已支持 @@ -12,7 +12,7 @@ 公网 URL 加一把密钥。注册中心要解决的「被叫方在哪」在这个架构里不存在 —— 被叫方自己会打进来。 -同一个理由让平台会话同步走插件上报(见 PLUGIN-GUIDE §4): +同一个理由让平台会话同步走插件上报(见 PLUGIN-CONTRACT 的 `W-3`): Gateway 不外呼,就不需要知道任何 Agent 的地址。 ### 已验证可用(2026-09-02,从 192.168.2.106 打到公网) diff --git a/docs/PLAN.md b/docs/PLAN.md index efa4652..dbb9fa1 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1026,7 +1026,7 @@ execute: { ### 7.7 DeepSeek Harness 插件(已完成) `plugins/dsh-mail-bridge/`,Cordis 插件框架 + TypeScript。 -适配方法与踩坑记录已固化为 **[`docs/PLUGIN-GUIDE.md`](PLUGIN-GUIDE.md)**, +适配方法与踩坑记录已固化为 **[`docs/PLUGIN-CONTRACT.md`](PLUGIN-CONTRACT.md)**, 后续接入新平台按那份清单走。 - [x] Cordis 插件骨架:`export const inject` + `export const name` + `apply(ctx, config)` @@ -1302,7 +1302,8 @@ MVP 计划(Phase 1-6)已全部落地并在 systemd 部署态实测通过。 - [x] 工作列表卡片视图(中间栏,与列表视图切换) - [x] DeepSeek Harness 插件(`dsh-mail-bridge`) - [x] 平台会话快照同步:工作区下的历史会话可在写信时选中 -- [x] 插件适配方法固化为 `docs/PLUGIN-GUIDE.md` +- [x] 插件适配方法固化为 `docs/PLUGIN-CONTRACT.md`(能力矩阵 / 行为约定 / + 降级语义 / 线协议 / 不变量 / 验收清单) - [ ] 跨主机 Agent 发现(Gateway + Registry 拆分) ### 7.10 窄屏适配(已完成) diff --git a/docs/PLUGIN-CONTRACT.md b/docs/PLUGIN-CONTRACT.md new file mode 100644 index 0000000..bfa43c1 --- /dev/null +++ b/docs/PLUGIN-CONTRACT.md @@ -0,0 +1,1028 @@ +# 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`: + +```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:"` 走免配额通道 | 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 | 不回写,保持服务端自动生成的别名 | + +**不得**用占位标题回写(`"新会话"`、`"处理邮件"` 之类):那会覆盖掉一个 +本来可能更有意义的名字,且之后平台真的生成标题时无从判断该不该覆盖。 + +### 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 `。 + +### 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 +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" +} +``` + +| 字段 | 说明 | +|---|---| +| `role` | `to` 或 `cc` | +| `to_workspace` | **收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的) | +| `mail_type` | `normal` / `permission_request` / … | + +**`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) +[ ] 管理员登记后重启,日志显示「已接入 ,身份 」 +[ ] sqlite3 "SELECT status, last_seen FROM agents WHERE agent_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 主链路 + +``` +[ ] 发一封到 @<存在的目录>.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) + +[ ] 用回写的别名续谈:@<目录>.<别名> + → 落进同一条平台会话,不新建(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: + +[ ] 把范围设成「第一个无效 + 第二个有效」,发一封 + → 日志出现「<有效模型> 成功(前 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=''" + → 与平台实际可用模型数一致 + +[ ] 心跳带会话快照 + sqlite3 "SELECT COUNT(*) FROM agent_platform_sessions WHERE agent_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`: + +```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-` 作 cwd,结果所有邮件会话在 +平台界面上全落进「未分组」(平台按 cwd 分组)。而且 Gateway 当时根本没把地址的 +path 位发给插件 —— 补了 SSE payload 的 `to_workspace` 才修好。 + +修好前后的会话目录名:`--root-.dsh-mail-sessions-mail---` → +`--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 ` 有效 | 配置写进 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`](API.md) | 完整 HTTP API(含人类侧接口) | +| [`PLAN.md`](PLAN.md) | 分阶段实施记录与决策依据 | +| [`PHASE7-REMAINING.md`](PHASE7-REMAINING.md) | 未完成项与已知取舍 | + +参照实现:`plugins/opencode-mail-bridge/`、`plugins/dsh-mail-bridge/`、 +`deploy/remote-agent-demo.py`(纯标准库的协议层对照)。 diff --git a/docs/PLUGIN-GUIDE.md b/docs/PLUGIN-GUIDE.md deleted file mode 100644 index 9daa211..0000000 --- a/docs/PLUGIN-GUIDE.md +++ /dev/null @@ -1,536 +0,0 @@ -# Agent 平台插件适配指南 - -把一个新的 Agent 平台接进 AgentMail 需要写一个**桥接插件**。这份文档描述插件的 -职责边界、必须实现的六件事,以及两次真实适配(opencode、DeepSeek Harness)里 -踩过的坑。 - -现有实现可直接对照: - -| 插件 | 平台 | 框架 | 语言 | -|---|---|---|---| -| `plugins/opencode-mail-bridge/` | opencode | `@opencode-ai/plugin` | JavaScript | -| `plugins/dsh-mail-bridge/` | DeepSeek Harness | Cordis | TypeScript | - ---- - -## 一、插件的职责 - -插件是**平台与 Gateway 之间的翻译层**,只做搬运,不做决策。 - -``` - SSE (new_mail / permission_decision) - AgentMail ─────────────────────────────────────────▶ 插件 ──▶ 平台会话 - Gateway ◀───────────────────────────────────────── - HTTP (register / heartbeat / mail.send / …) -``` - -三条设计原则贯穿全文,先说清楚,后面每一节都是它们的推论: - -### 原则一:平台原生信号才是真相来源,不要求模型「记得」调工具 - -模型可能忘了调,也可能在不需要时乱调。真正被平台拦下的那次权限询问、 -模型真正说完的那段话,都是平台自己知道的事实。 - -**推论**:不提供 `request_permission` 工具(改为挂 `permission.ask` / `approval/request` 钩子), -不要求模型主动调 `send_mail` 回信(改为在「一轮结束」的平台信号上自动转发)。 - -### 原则二:插件代劳的转发不消耗配额 - -配额约束的是模型的自主发信。插件把平台原生的权限询问与最终总结搬到邮件里, -对它收费会导致配额用尽时 Agent 连交代都做不了。 - -**推论**:这两类转发带 `relay` + `relay_key`,走服务端的免配额通道。 - -### 原则三:平台命名优先,不另造一套 - -各平台本来就会由模型为会话生成摘要标题与短标识。平台那边叫什么, -AgentMail 这边的 `session_alias` 就叫什么。 - -**推论**:创建会话时**不要**传占位标题(那会掐掉平台自己的命名机制), -标题生成后通过 `POST /sessions/{id}/sync` 回写。 - ---- - -## 二、必须实现的六件事 - -### 1. 注册与心跳 - -``` -POST /api/v1/agent/register { name, platform, workspaces: [] } -POST /api/v1/agent/heartbeat { platform_sessions?: [...] } -``` - -认证用 `Authorization: Bearer `。密钥来源按优先级: - -1. 环境变量(systemd 部署走这条) -2. `~/.agentmail/agent.key` —— 首次启动时**本地生成**并打印到日志 - -**为什么是登记式而不是服务端签发**:密钥全文只从客户端流向服务器一次。 -插件生成后打印出来,管理员在后台「Agent 密钥」页登记即可, -不需要把密钥从服务器反向传给客户端。 - -登记接口的字段名是 **`key_token`**(不是 `key`)。传错服务端会静默生成一个 -随机 token 且全文只回一次 —— 这个坑踩过。 - -**心跳不能省。** Gateway 靠 `last_seen` 判在线,不发心跳的 Agent 会被当成离线 -(DSH 插件最初就漏了心跳,靠注册那一次撑着)。间隔 30 秒。 - -### 2. SSE 订阅 - -``` -GET /api/v1/events/stream -``` - -关心两个事件: - -| 事件 | 处理 | -|---|---| -| `new_mail` | 投递到平台会话(新建或续谈) | -| `permission_decision` | 回答之前挂起的权限询问 | - -`new_mail` 的 payload: - -```json -{ - "mail_id": "...", "session_id": "...", "from_name": "admin", - "subject": "...", "mail_type": "normal", "role": "to", - "to_workspace": "/home/program/agentmail" -} -``` - -`to_workspace` 是**收件方那个地址的 path 位**(抄送方拿到的是自己那个地址的), -见下一节。 - -服务端支持 `Last-Event-ID` 补投:断线重连时带上它,能取回断线期间的事件。 -首次连接不传该头(否则会收到一批已处理过的旧事件)。 - -### 3. 工作目录:必须用寻址里的 path 位 - -三维地址 `name@path.session` 的 `path` 就是「希望它在哪个工作目录干活」。 - -```js -// 正确 -const { cwd, grouped } = resolveWorkspaceCwd(data.to_workspace, sessionId); - -// 错误:每封邮件一个新的临时目录 -const cwd = join(homedir(), '.dsh', 'mail-sessions', sessionId); -``` - -**这是踩过最贵的坑之一。** 平台按 cwd 给会话分组,用自己拼的临时目录会让所有 -邮件会话既不属于任何项目、彼此也不同组 —— 界面上全落进「未分组」。 - -`lib/workspace.js` 是共用实现,三条规则: - -- 目录**已存在**才用,不存在时回退到兜底目录而**不创建** - (一个笔误 `/home/porgram/x` 不该在磁盘上落下真目录,Agent 会在里面一无所获地干活) -- 拒绝相对路径(cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 `/`) -- `path` 为空(地址写成 `dsh` 而不带 `@/path`)时用兜底目录 - -### 4. 六个工具 - -| 工具 | 说明 | -|---|---| -| `send_mail` | 主动发信。三维地址、抄送、`reply_to`、附件 | -| `read_inbox` | 读收件箱,**顺便标记已读** | -| `forward_mail` | 转发(可选,opencode 有 / DSH 暂无) | -| `upload_attachment` | 本地文件 → `attachment_id` | -| `download_attachment` | `attachment_id` → 本地文件 | -| `connect_to_server` | 登记密钥并注册(可选,方便首次接入) | - -**不提供 `request_permission`** —— 见原则一。 - -`read_inbox` 的渲染与已读策略放在共用的 `lib/inbox-format.js`, -它与平台 SDK 无关,各平台必须一致。三条规则各对应一次错误行为: - -- **附件必须带 `attachment_id`**:只说「有附件」模型就无从下载 -- **抄送人要显示**:不显示的话模型以为这是私信,回信时漏掉其他参与方 -- **只标本次列出的那些**,且 `status=all` 时不标 - (`limit` 之外的还没看过;把历史邮件标成已读会让下一轮的新邮件混在里面认不出来) - -### 5. 自动转发最终总结 - -在平台的「一轮结束」信号上,取最后一条 assistant 消息的**文本块**发回去。 - -| 平台 | 信号 | -|---|---| -| opencode | `session.idle` 事件 | -| DSH | `agent/status` → `idle` | - -三个必须处理的细节: - -- **只取 `type === 'text'` 的块。** reasoning 是思考过程,不该出现在邮件里。 -- **让位于模型的主动发信。** 模型自己调过 `send_mail` 回这条线索时不再自动转发, - 否则同一件事发两封(生产里真实发生过:311 字节 + 342 字节各一封, - 其中只有一封带附件)。共用实现在 `lib/relay-dedup.js`。 -- **带 `relay: 'summary'` + `relay_key`** 走免配额通道。`relay_key` 要是一个 - 平台侧的稳定 id(消息 id / 事件序号),模型伪造不出来 —— 它保证同一条消息 - 不被转两次。 - -去重靠 `explicitSends`(进程内记录本轮模型主动发过的信)**而不是** `relay_key`: -后者保证「同一条消息不转两次」,管不了「模型已经自己发过了」。 - -### 6. 权限询问转邮件 - -挂平台的权限钩子,把询问转成一封邮件问人。 - -| 平台 | 钩子 | 能否等人 | -|---|---|---| -| opencode | `permission.ask(input, output)` | **不能** —— 同步钩子,卡住会挂死整个请求 | -| DSH | `approval/request` waterfall | **能** —— 返回 `Promise` | - -opencode 那边只能「转出去 + 立即返回 `ask`」,人类决策通过 SSE 回来后再用 SDK -回复那条 permission;DSH 这边可以真的 `await` 到人回答。 - -两个共同点: - -- **转不出去就让位**(`return next()` 或保持 `ask`),别让平台挂在那儿等一个 - 永远不会来的回答 —— 本地 UI 还能接管 -- **拆插件时未决询问一律 fail closed**,否则平台侧那些 `await` 永不返回 - -`relay_key` 用平台的权限 id(DSH 不发 id,用 `会话:工具:callId` 拼)。 -服务端会随决策事件把它回传,因此插件重启丢了内存映射也能续上。 - ---- - -## 三、会话命名回写 - -``` -POST /api/v1/sessions/{id}/sync { alias?, title? } -``` - -- **`alias`** 是可寻址的短标识,写入 `session_alias` -- **`title`** 是模型生成的摘要,写入 `subject` - -| 平台 | alias 来源 | -|---|---| -| opencode | `session.slug`(创建时就有,如 `witty-planet`) | -| DSH | 由模型标题派生(`slugFromTitle`) | - -派生 slug 时**必须去掉寻址分隔符**(`.` `@` `/`)—— 留在别名里会让它自己被 -解析器切开,填进去的地址指向一个完全不同的目标。中文可以保留: -三维地址按最后一个 `.` 切分,中文不影响解析,而转拼音后既不好读也不好打。 - -服务端撞名时自动追加 `-2`/`-3`,因此同步永不失败。人工改过的别名 -(`alias_source = 'manual'`)不会被平台同步覆盖。 - ---- - -## 四、平台会话快照上报 - -写信时想续谈某条会话,得先知道那个工作区下有哪些会话可续。Gateway 只看得见 -邮件驱动的那部分 —— 人直接在平台界面上开的会话它一无所知。 - -插件在心跳里带上快照: - -```json -{ - "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" } - ] -} -``` - -**为什么是插件上报而不是 Gateway 反向拉取**:当前架构是单向的(Agent 持密钥 -主动连 Gateway,Gateway 从不外呼)。反向拉取需要 Gateway 保存各平台的地址与 -凭证,那是另一套信任模型。代价是插件没运行时同步不了 —— 但插件没运行时邮件 -本来也投不进去。 - -共用实现 `lib/session-snapshot.js`。四条规则: - -- **无 `slug` 的会话不报**:slug 是填进 session 位的值,没有它这一项在补全里 - 点下去只能得到一个空的 session 段 -- **subagent 子会话不报**:它们是父 agent 内部的工作单元,人往里发邮件毫无意义。 - 实测 DSH 一次列出 49 条子会话,标题就是派活的提示词前缀 - (九条都叫 `You are auditing ONE file`),slug 全撞名 -- **slug 撞名只留最近那条**:服务端只能取其中一条,上报同名项只会让补全里出现 - 几个点哪个都不确定的候选 -- **按最近活跃排序并截断到 200 条**:上千个候选对人没有意义 - -**`platform_sessions` 省略与传空数组语义不同。** 拉不到列表时**省略该字段** -(保留服务端现有镜像);传空数组的语义是「平台侧确实一条会话都没有」, -会把镜像抹掉。 - ---- - -## 五、模型范围与降级尝试 - -管理员在配置页为每个平台划定「邮件场景下可用的模型」。插件按顺序逐个尝试, -全部失败才回一封说明失败原因的邮件。 - -### 上报目录 - -随心跳带 `models`(与会话快照同一个请求): - -```json -{ "models": [ - { "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" } -] } -``` - -**为什么随心跳而不是只在注册时报一次**:模型清单会在运行中变(换 provider -配置、上游上下线、换 API key)。只在注册时报的话目录会静静变陈,管理员在配置页 -选中一个平台其实调不到的模型,失败要到真发邮件时才暴露。 - -与 `platform_sessions` 同一约定:拉不到目录时**省略该字段**(保留服务端现有目录), -传空数组会把配置页清成空白。 - -| 平台 | 目录来源 | -|---|---| -| opencode | `client.config.providers()` → `providers[].models` 是**对象**,键是 model id | -| DSH | `ctx.llm.listProviders()` 再逐个 `listModels(provider)` | - -### 读取生效范围 - -心跳响应回传 `allowed_models`(按优先级)与 `models_unrestricted`。 -插件存在模块级变量里,下次投递时用 —— 管理员改了范围后最多一个心跳周期 -(30 秒)生效,不需要重启。 - -也有 `GET /agent/models/allowed`,但那是给没有心跳循环的第三方客户端与排查用的。 - -### 尝试顺序 - -共用实现 `lib/model-scope.js` 的 `modelAttemptOrder(allowed, envDefault)`: - -| 情形 | 返回 | -|---|---| -| 管理员划定了范围 | 按 rank 顺序的路由列表 | -| 没划定,但配了 `AGENTMAIL_REPLY_*` | 环境变量那一个 | -| 都没有 | `[undefined]` —— 交给平台自己选 | - -**范围优先于环境变量**:范围是运行时可改的策略,环境变量是部署时的兜底。 -反过来的话管理员在配置页改了却不生效,得去改 service 文件重启。 - -**范围为空时返回 `[undefined]` 而不是 `[]`**:返回空数组会让调用方一次都不试, -而「管理员没配」的正确含义是不限定,不是「一个都不许用」。 - -### 判定一次尝试是否成功 —— 这里最容易错 - -**两个平台的模型失败都不是同步抛出的。** 只包一个 try/catch 的话第二个模型 -永远不会被试到: - -| 平台 | 提交调用 | 失败从哪来 | -|---|---|---| -| opencode | `promptAsync()` 立即返回 | `session.error` 事件 | -| DSH | `ctx.agents.create()` 不校验模型 | `turn/end` 的 `reason.kind === 'error'` | - -DSH 侧还有一个陷阱:**`assistant/chunk` 本身不能当成功信号**,它的 `finish` -子类型也带错误 —— - -```json -{"chunk": {"type": "finish", "reason": {"kind": "error", "failure": {"code": "NO_ADAPTER"}}}} -``` - -实测踩过一次「无效 provider 却判成功」正是因为把任意 chunk 当成了走通。 -判据要落在 chunk 的类型上:`finish` 看 reason,其余(`block-start`、 -`text-delta`、`tool-call-delta`…)才意味着模型真的在产出。 - -**超时按成功处理**:模型可能只是很慢(首 token 前要装载上下文), -把慢当成失败会在换模型的同时把已经在跑的那一轮丢掉。窗口取 60 秒。 - -DSH 侧换模型要**换会话 id**(`<原 id>-r1`)并 `dispose()` 失败那个 agent: -复用同一个 id 会让重试接在一条已经出错的会话后面,而不 dispose 的话 -`agent/status` 还会为那个死会话触发一次自动转发。 - -### 全部失败时必须发信 - -模型一次都没跑起来时会话里没有任何 assistant 消息,自动转发因此什么也不会发 -—— 发件人只会看到邮件发出去后再无音讯。 - -`renderFailureReport(failures, subject)` 生成正文(逐条列出路由与原因, -并指出去哪里调整)。这封信带 `relay: 'summary'` 走免配额通道: -它是插件的故障报告,不是模型的自主发信。 - -## 六、平台差异对照 - -| 关注点 | 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` | -| 权限钩子 | `permission.ask`(同步,不能等) | `approval/request`(异步 waterfall,能等) | -| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | -| 模型目录 | `client.config.providers()` | `ctx.llm.listProviders()` + `listModels()` | -| 模型失败信号 | `session.error` 事件 | `turn/end` 的 `reason.kind==='error'` | -| 别名来源 | `session.slug` | 模型标题派生 | -| 日志可见性 | `console.error` | `console.error`(`ctx.logger` 不进 journalctl) | - ---- - -## 七、踩过的坑 - -按「排查成本」降序。新接平台时先扫一遍这一节。 - -### `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` 就是为此存在的。 - -### opencode 插件入口只能有 `default` 一个导出 - -opencode 用 `Object.values(mod)` 把**每个导出**都当插件工厂检查 -(反编译确认)。入口多导出一个 Map 就报 `Plugin export is not a function`, -插件静默失效、邮件全投不进去。 - -**因此所有可测试的逻辑必须放 `lib/` 子模块**,入口只 `export default`。 -`test/auto-relay.test.mjs` 里有一条断言钉住这一点。 - -### Cordis 插件必须导出 `inject` - -没有它 `ctx.tools` / `ctx.agents` 根本不存在(报 -`cannot get property "tools" without inject`)。 - -但**不要把可选服务写进 `inject`** —— 那是硬依赖,服务没挂载时整个插件不启动。 -DSH 的 `sessionQuery` 用 `ctx.get('sessionQuery')` 取:会话上报只是补全体验, -不该能把邮件投递整体拘死。 - -### DSH 工具必须经 `defineTool` - -直接给 `ctx.tools.register` 原始对象会报 -`parameters must be lossless JSON before schema projection`。 -`defineTool`(来自 `@deepseek-ai/dsh-tools`,不在 npm,运行时从 DSH 的 -node_modules 解析)负责把 spec 格式的 `parameters` 转成 JSON Schema 并在 -`execute` 前校验。还必须声明 `output: { schema, render }`。 - -### `ctx.logger` 不进 journalctl - -DSH 的 `ctx.logger.info` 在 systemd 下看不到,`console.error` 能看到。 -排查阶段用后者。 - -### 同一时间只能跑一个 DSH 实例 - -`@linxin666/dsh-client-ui-task-board` 有 ledger 文件锁 -(`task-board ledger is already owned by process ...`)。因此邮件桥接是 -**注入现有 `dsh.service`**,而不是另起一个实例。 - -### systemd 不注入 `HOME` - -插件要读 `~/.agentmail/agent.key`,`HOME` 缺失时会落到 `/`。 -service 文件里显式 `Environment=HOME=/root`。 - -opencode 还有个额外问题:**插件是懒加载的**,进程起来了插件还没加载 —— -用 `ExecStartPost` 发一个空请求预热。 - -### `dsh --patch` 对 `dsh web` 无效 - -`--patch` 只在 `dsh --profile ` 形式下有效,`dsh web` 不认这个选项。 -插件配置要写进 profile 的 `cordis.patch.yml`。 - ---- - -### `workspaces` 是对象数组 - -注册体的 `workspaces` 要 `[{name, path}]`。传字符串数组会 400。 -两个官方插件都传 `[]`(工作目录由每封邮件的 `to_workspace` 决定), -所以照抄它们不会踩;自己从 API 文档写起就会。 - -### 启动时要主动拉一次收件箱 - -SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一次 —— -心跳响应的 `pending_mails` 是唯一线索。不补拉的后果: -那封邮件永远躺在收件箱里,发件人以为 Agent 收到了。 - -共用实现 `lib/catchup.js` 的 `selectCatchup(mails, seen, max)`。四条约束: -只在**首个**心跳后补(每轮都补会重复投递正在处理中的邮件)、串行且一次最多 5 封 -(每封都要起一轮模型)、与 SSE 共用一个 `deliveredMails` 集合去重 -(建连窗口期的邮件两条路都会到)、按时间**正序**投(收件箱是倒序返回的)。 - -## 八、新平台适配清单 - -``` -[ ] 1. 认证与连接 - [ ] 密钥:环境变量 → ~/.agentmail/agent.key(本地生成并打印) - [ ] POST /agent/register - [ ] 心跳 30s(别漏!Gateway 靠 last_seen 判在线) - [ ] SSE 订阅,支持断线重连 - -[ ] 2. 会话投递 - [ ] cwd 取 data.to_workspace(复用 lib/workspace.js) - [ ] 新建会话不传占位标题 - [ ] 维护 mailSessionID ↔ 平台 sessionID 双向映射 - [ ] 续谈:映射命中且会话还活着 → followup,否则新建 - -[ ] 3. 工具(复用 lib/inbox-format.js) - [ ] send_mail / read_inbox / upload_attachment / download_attachment - [ ] read_inbox 顺便标记已读(只标本次列出的,status=all 时不标) - [ ] 不提供 request_permission - -[ ] 4. 自动转发(复用 lib/relay-dedup.js) - [ ] 找到平台的「一轮结束」信号 - [ ] 只取 text 块,丢掉 reasoning - [ ] relay: 'summary' + 稳定的 relay_key - [ ] 模型主动发过就让位 - -[ ] 5. 权限询问 - [ ] 挂平台的权限钩子 - [ ] 转不出去就让位给本地 UI - [ ] 拆插件时未决询问 fail closed - -[ ] 6. 模型范围(复用 lib/model-scope.js) - [ ] 心跳带 models(拉不到就省略,别传空数组) - [ ] 心跳响应读回 allowed_models - [ ] 按 modelAttemptOrder 逐个尝试 - [ ] **等异步结论**再判成功/失败(失败不是同步抛的!) - [ ] 全部失败 → renderFailureReport + relay:'summary' 发信 - -[ ] 6.5 启动补拉:心跳 pending_mails > 0 时先处理存量未读 - -[ ] 7. 命名与快照 - [ ] alias/title 回写 POST /sessions/{id}/sync - [ ] slug 去掉 . @ / 等寻址分隔符 - [ ] 心跳带 platform_sessions(复用 lib/session-snapshot.js) - [ ] 过滤 subagent、slug 去重 - -[ ] 8. 工程 - [ ] 可测逻辑放 lib/,入口保持最小 - [ ] 纯函数测试纳入 deploy/install.sh 的门禁 - [ ] 端到端:发一封 → 会话建在正确 cwd → 自动回信 → 别名可续谈 -``` - ---- - -## 九、共用模块 - -`lib/` 下的文件在两个插件里**逐字节相同**,接新平台时直接拷。 -它们只依赖 node 内置模块,不碰任何平台 SDK。 - -| 文件 | 职责 | -|---|---| -| `relay-dedup.js` | 自动转发去重:本轮模型是否已亲手回过这条线索 | -| `inbox-format.js` | 收件箱渲染 + 已读策略 | -| `session-snapshot.js` | 平台会话快照整理(含 subagent 过滤、slug 派生) | -| `workspace.js` | 寻址 path 位 → 可用的 cwd | -| `model-scope.js` | 模型目录整理 + 降级顺序 + 失败报告 | -| `catchup.js` | 离线期间积压邮件的补投选择 | -| `message.js` | DSH 的消息构造与会话日志读取(DSH 专用) | - -`test/` 下对应的测试文件同样逐字节共用。 - -TypeScript 插件另需 `.d.ts`(`lib/` 是 JS,`tsc` 需要类型声明)。 - -**改动共用模块时两侧一起改。** 一侧改了另一侧没改,两个平台的行为就会悄悄分叉: -同一封邮件在 opencode 那边标了已读、在 DSH 那边没标,而两处代码看起来都「对」。 - -`deploy/install.sh` 会跑同源校验,也可以单独执行: - -```bash -./deploy/check-shared-libs.sh -``` - -接新平台时把 `lib/` 与 `test/` 整个拷过去,平台专属逻辑写在入口文件里。 -共用模块只依赖 node 内置模块,不碰任何平台 SDK —— 这是它们能共用的前提, -新增共用函数时也要守住。