Files
MailUI4Agents/README.md
JianFeeeee 9e5c557cdf feat: 跨主机 Agent 验证 + 离线邮件补投 + 400 指向具体字段
7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。

## 为什么不做注册中心

它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。

同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。

## 验证方式:一个纯标准库脚本

`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。

「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。

## 缺陷一:SSE 只推连上之后的事件,没人补拉积压

写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。

新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:

- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
  邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
  并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
  那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文

端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。

## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段

脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。

新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:

    {"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
    {"error": "JSON 语法错误(第 8 字节处)"}
    {"error": "请求体为空"}

刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。

## 验证

- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
2026-09-02 22:47:31 +08:00

259 lines
12 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)
> 以「邮件交互」为统一范式的多 Agent 调度系统
人给 Agent 发邮件派活Agent 之间互相发邮件协作,需要人拍板时发一封「权限请求」邮件等回复。
所有交互都是邮件,所以协作过程天然可读、可追溯、可归档。
## 核心概念:三维寻址
收件人地址形如 `name@path.session`
| 地址 | 含义 |
|------|------|
| `pi@root` | 投递到 `pi``root` 工作区的**默认会话**(从未通信过则建立) |
| `pi@root.new` | **强制新建**一个会话 |
| `pi@root.fix-leak` | 投递到别名为 `fix-leak` 的**已有会话**;不存在则报「无法送达」,不会静默新建 |
| `jianf@.new` | 人类用户也是 name 位的一等公民path 可空) |
`path` 内可以含 `/``.`,解析时按**最后一个 `.`** 切分 session 位。
会话别名负责寻址,因此全局唯一。默认由 Agent 平台自己的命名机制提供 —— opencode 等平台
本就会由模型为会话生成摘要标题和 slugAgentMail 直接复用,不另造一套。
## 快速开始
### 开发
```bash
# 后端:默认用内置 SQLite无需任何外部依赖
cd gateway
ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server
# → http://localhost:8180
# 前端(另开一个终端)
cd web
npm install
npm run dev # → http://localhost:5173自动代理到 8180
```
### 部署
```bash
sudo ./deploy/install.sh
```
该脚本装依赖 → 跑类型检查与测试 → 构建前端 → 嵌入后端 → 构建二进制 → 装成 systemd 服务。
产物是**一个二进制加一个 .db 文件**:前端经 `go:embed` 打进二进制,数据库默认是 SQLite。
首次运行会在 `/etc/agentmail/gateway.env` 生成随机管理员密码。
### 构建期依赖
npm 只在构建期用到Node ≥ 18`npm run build`)与 Go ≥ 1.22。
`web/dist` 会被复制进 `gateway/internal/static/static/` 再由 `go:embed` 编入二进制,
**部署机上不需要 node**
`web/node_modules``web/dist``gateway/internal/static/static/` 的内容与编译出的二进制
都是构建产物,不进版本库(见 `.gitignore`)。
新克隆**可以直接 `go build` / `go test`**`static/` 目录里留了一个占位 index.html
`go:embed` 要求目标目录存在,否则编译失败——只改后端的人不该被迫先装 node
此时打开网页看到的是「前端未构建」提示页API 照常可用。要真正的界面就跑
`deploy/install.sh`,或手工:
```bash
cd web && npm ci && npm run build
rm -rf ../gateway/internal/static/static && cp -r dist ../gateway/internal/static/static
cd ../gateway && go build ./cmd/server
```
### 数据库
默认 SQLite落在 `$AGENTMAIL_DATA_DIR/agentmail.db`(默认 `./data/`)。想接外部库就设
`DATABASE_URL`
```bash
DATABASE_URL=postgres://user:pass@host:5432/agentmail # 外部 PostgreSQL
DATABASE_URL=sqlite:///var/lib/agentmail/mail.db # 指定 SQLite 路径
DATABASE_URL= # 留空 = 内置 SQLite
```
两种方言共用一份 repo 层 SQL差异集中在 `internal/db`(占位符、`NOW()`、JSON 包含判断、
唯一冲突识别。SQLite 开了 WAL读写不互斥。
### 接入 Agent
以 opencode 为例:
```bash
# 在 opencode 配置的 plugin 列表里加上本地路径
"plugin": ["file:///path/to/agentmail/plugins/opencode-mail-bridge"]
```
插件提供六个工具(`send_mail` / `read_inbox` / `forward_mail` / `upload_attachment` /
`download_attachment` / `connect_to_server`),并通过 SSE 监听新邮件:
收到邮件时自动在 opencode 侧开会话处理,回信落回同一邮件会话。
两类消息由插件**自动**转发,不需要模型自己调工具,也不消耗发信配额:
- **平台原生的权限询问**opencode 拦下一个危险操作时(`permission.ask`
插件把它转成邮件问人,人在网页上点「同意/一直同意/拒绝」,插件再回复 opencode 让它继续。
这是 harness 的职责 —— 让模型自己调一个 `request_permission` 工具的话,
它可能忘了调,而真正被拦下的那次询问反而没人看见。
- **本轮的最终总结**:一轮跑完(`session.idle`)时把最后那段话作为回信发回去。
模型已经把话说完了,插件只是搬运。
配额约束的是**模型的自主发信**,不是 harness 的转发 —— 否则配额用尽时 Agent 连交代都做不了。
`read_inbox` 读完会自动把本次列出的邮件标为已读,所以下次拉收件箱只会看到新来的。
**首次接入**:插件启动时在 `~/.agentmail/agent.key` 生成一把密钥并打印到日志,
管理员在 Web 后台「用户管理 → Agent 密钥」把它登记上去即可(密钥全文只从客户端往
服务器走一次)。也可以反过来:先在后台签发,再把密钥填进 `AGENTMAIL_AGENT_KEY`
密钥分两类,权限边界不同:
| | 签发方 | 用途 | 不能做什么 |
|---|---|---|---|
| Agent 密钥 | 管理员 | 注册、收发邮件、订阅 SSE | 读不了人类邮箱 |
| 用户密钥 | 用户自助 | 第三方客户端访问自己的邮箱 | 注册不了 Agent |
三种生命周期:`permanent`(长期)/ `one_time`(首次使用后失效)/ `timed`(限时)。
环境变量见 `deploy/install.sh` 生成的 `/etc/agentmail/opencode.env`
## 项目结构
```
agentmail/
├── docs/
│ ├── PLAN.md # 分阶段实施计划
│ ├── MVP-SPEC.md # MVP 技术规格书
│ └── PLUGIN-GUIDE.md # Agent 平台插件适配指南
├── gateway/ # 后端Go单二进制
│ ├── cmd/server/ # 入口与路由表
│ └── internal/
│ ├── db/ # 连接 + 方言适配 + 内嵌迁移
│ ├── models/ # 三维地址解析、领域模型
│ ├── repo/ # 数据访问
│ ├── handler/ # HTTP 处理
│ ├── middleware/ # Agent / 用户双认证
│ ├── sse/ # 事件推送(按收件人分流)
│ └── static/ # go:embed 的前端产物
├── plugins/ # 各平台桥接插件lib/ 下的纯函数模块逐字节共用)
│ ├── opencode-mail-bridge/ # opencode
│ └── dsh-mail-bridge/ # DeepSeek HarnessCordis
├── web/ # 前端React + Vite + Tailwind
└── deploy/ # systemd 单元 + 安装脚本
└── remote-agent-demo.py # 最小跟主机 Agent纯标准库验证协议层能力
```
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端 | Go + chi |
| 数据库 | SQLite默认零依赖/ PostgreSQL可选 |
| 前端 | React 18 + TypeScript + Vite + TailwindCSS |
| 通信 | HTTP REST + SSE |
| 认证 | 人类 bcrypt + Cookie密钥认证Agent / 用户两类Bearer |
| 附件 | 内容寻址磁盘存储sha256元数据入库 |
| 对话树 | parent_mail_id 递归 CTE按方向分块加载 |
| 部署 | 单二进制 + SQLite 文件systemd 托管 |
## 验证
```bash
cd gateway && go build ./... && go test ./... # 后端
cd web && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试)
```
## WebAPI
WebUI 调用的就是这套公开 API没有「仅前端可用」的私有通道 —— 第三方客户端拿一把用户密钥
即可获得与网页完全相同的能力:
```bash
# 在网页「账号 → 客户端连接密钥」创建密钥,然后
curl {host}/api/v1/me/mail/inbox -H "Authorization: Bearer $TOKEN"
```
`src/api/` 本身就是可复用的客户端 SDK基地址与凭证集中在 `src/api/config.ts`
同一份构建产物可通过 `window.__AGENTMAIL_API_BASE__` 指向不同后端。
完整接口见 [WebAPI 文档](docs/API.md)。
## 工作列表卡片视图
中间栏可在**列表**与**卡片**之间切换(右上角图标,偏好存 localStorage
分工:列表答「跟谁在聊」,卡片答「在聊什么、进展如何」——
卡片显示会话主题、最新一封的发件人与摘要、往返预算徽标。
预算徽标在「不限」时不显示一个对每张卡片都成立的「0/0」是纯噪声
剩 1 个来回转橙、用尽转红 —— 那是需要人介入的时刻。
## 窄屏适配
小于 768px 时布局从三栏切成**页面覆盖**:列表铺满整屏,点开邮件后详情页从右侧滑入盖在它上面,
返回时滑出。底层列表始终挂载 —— 滚动位置与选中态因此天然保留,退出动画也才有东西可播
(直接卸载再渲染另一个组件的话,没有任何一帧能让旧页面往右滑出去)。
左侧导航竖条在窄屏退化为抽屉,日常切换交给底部导航(拇指够得到),
并留了 `env(safe-area-inset-bottom)` 避开 iPhone 手势条。
窄屏专属控件(返回按钮、抽屉入口)用 `useIsNarrow()` 条件渲染而不是 `md:hidden` ——
后者只是视觉隐藏,宽屏用户按 Tab 会聚焦到一个看不见的按钮上。
动画尊重 `prefers-reduced-motion`
## 往返预算
配额的语义是「这件事值得多少个来回」—— 那是**任务**的属性,不是 Agent 的属性。
所以预算落在会话上:
- 新建邮件时填「往返预算」,留空则用**收件 Agent 的默认值**
- 对话页头部点预算徽标即可改上限,或把已用次数归零 ——
人看着往来内容才知道这件事还值不值得再来几个回合
- 管理员页按 Agent 配默认值(默认 20跑测试的小工具与重构整个模块的 Agent
合理来回数差一个量级
没有「Agent 终身额度」这一层。那种额度跑满后要管理员手工重置才能再干活,
而 Agent 是长期在线的 —— 它是把一次性资源的模型套在长期服务上。
Agent 用 `.new` 开一串新会话绕过预算,靠**新建会话速率限制**堵
1 小时 20 条,超出 429过一个窗口自动恢复人类不受此限
插件自动转发的权限询问与最终总结**不占预算**:配额约束的是模型的自主发信,
不是 harness 的搬运。
## 会话别名
会话别名是寻址的第三维(`name@path.别名`)。默认**复用 Agent 平台自己的命名机制** ——
opencode 创建会话时就有 slug首轮对话后模型会生成摘要标题平台叫什么本侧就叫什么
不另造一套。
Agent 干完活可以在正文里**提议**改成更贴切的名字,但改不改由人点头:
别名是人的寻址入口Agent 中途改掉会让人刚记住的地址立刻失效。
## 对话树
邮件的 `parent_mail_id` 天然编码了树结构(回复指向来信,转发指向原件),
所以对话树直接用递归查询在 `mails` 上展开,不额外维护一张树表 —— 那会变成第二份真相。
树可以跨会话:转发把线索引到新会话,却仍属同一条线索。邮件详情页点「对话树」查看,
首屏只加载当前屏幕附近的节点,上滑逐步补齐更早的往来。
## 附件
支持给邮件附加文件。上传与发信是两步:先 `POST /me/attachments``attachment_id`
再在发信时放进 `attachment_ids`
内容按 sha256 内容寻址存磁盘(数据库只存元数据),同内容重复上传不占额外空间;
下载一律强制 `octet-stream` + `attachment`,绝不按声明的 MIME 内联渲染。
单个默认上限 25MB未随邮件发出的附件 24 小时后自动清理。
## 文档
- [WebAPI](docs/API.md) — 接口清单、认证方式、错误约定
- [插件适配指南](docs/PLUGIN-GUIDE.md) — 接一个新 Agent 平台要实现什么,以及踩过的坑
- [实施计划](docs/PLAN.md) — 分阶段任务与验收标准
- [MVP 技术规格书](docs/MVP-SPEC.md) — 数据模型与接口细节