diff --git a/docs/research/mail-reply-research.md b/docs/research/mail-reply-research.md new file mode 100644 index 0000000..0bfb56c --- /dev/null +++ b/docs/research/mail-reply-research.md @@ -0,0 +1,383 @@ +# Mail API 回复能力调研报告 + +**调研人**: 庞统(subagent) +**日期**: 2026-06-06 +**任务来源**: 主公委派,验证 Mail API 回复驱动可行性 + +--- + +## 调研结论 + +### Mail API 当前能力是否满足回复驱动设计 + +**部分满足**。Mail API 已具备 `in_reply_to` 关联机制、`conversation_id` 线程聚合、`type` 区分 request/inform、完整的安全防御(A1-A10),以及 daemon ticker 对 `_mail` 虚拟项目的 30s 扫描调度。 + +**但缺少以下关键查询能力**: +1. ❌ 无法查询某个 Mail 的所有回复(无 `in_reply_to` 过滤参数) +2. ❌ 无法查询"所有有新回复的 Mail"(无 `find_recently_replied`) +3. ❌ 无法查询"所有 type=request 且无回复的 Mail"(无 `find_pending_requests`) +4. ❌ Mail metadata 无法存 `deadline_hours`(`must_haves` JSON 可存但无专用字段和查询路径) +5. ❌ 无 Mail 模板功能(模板固化在 Agent 的 Skill/Prompt 中) + +### 缺失能力清单 + +| 缺失项 | 影响级别 | 实现难度 | +|--------|---------|---------| +| `in_reply_to` 查询过滤 | 高 | 低(加一个查询参数) | +| `find_pending_requests` 查询 | 高 | 低(组合查询) | +| `deadline_hours` 存储 | 中 | 极低(must_haves JSON 已支持) | +| `find_recently_replied` 查询 | 中 | 中(需子查询) | +| Mail 模板系统 | 低 | 不需要(设计决策:模板在 Prompt 层) | + +--- + +## Mail API 现有能力 + +### 1. 发送 Mail(`POST /api/mail`) + +**源码**: `mail_routes.py:L93-L194`(`send_mail` 函数) + +支持字段: +| 字段 | 必填 | 说明 | 源码行 | +|------|------|------|--------| +| `from` | ✅ | 发件 Agent ID(A1/A9 校验) | L98 | +| `to` | 非回复时必填 | 收件 Agent ID(A2/A4 校验) | L100 | +| `title` | ❌ | 标题(fallback 空) | L178 | +| `text` / `description` | ✅ | 正文(A10 校验) | L121 | +| `type` | ❌ | inform/request(默认:非回复→request,回复→inform) | L170-L172 | +| `in_reply_to` | ❌ | 回复的 Mail ID(A5 存在性校验) | L105 | +| `conversation_id` | ❌ | 会话 ID(自动继承/生成) | L160-L166 | +| `id` | ❌ | 自定义 ID(默认 mail-timestamp) | L175 | + +### 2. 列表查询(`GET /api/mail`) + +**源码**: `mail_routes.py:L79-L91` + +支持过滤参数: +| 参数 | 说明 | 实现方式 | +|------|------|---------| +| `status` | Task 状态过滤 | SQL WHERE(直接传 Blackboard.list_tasks) | +| `from_agent` | 发件人过滤 | SQL WHERE(assigned_by) | +| `to_agent` | 收件人过滤 | SQL WHERE(assignee) | +| `unread` | 未读过滤 | **内存过滤**(解析 must_haves JSON) | +| `limit` | 数量限制(默认 50,最大 200) | Python 切片 | + +**不支持**的过滤: +- ❌ `in_reply_to` 过滤(无法查询某个 Mail 的所有回复) +- ❌ `type` 过滤(must_haves JSON 内字段,无法 SQL WHERE) +- ❌ `conversation_id` 过滤(同上) +- ❌ 时间范围过滤(`since`/`until`) + +### 3. 详情查询(`GET /api/mail/{mail_id}`) + +**源码**: `mail_routes.py:L104-L121` + +返回 Mail 全部字段 + `comments`(对话线程)。Comments 来自黑板 comments 表。 + +### 4. 更新(`PATCH /api/mail/{mail_id}`) + +**源码**: `mail_routes.py:L208-L224` + +- `is_read` 标记已读 +- `mark_executed` 标记已执行(status→done) + +### 5. 批量删除(`DELETE /api/mail`) + +按 title 前缀匹配,status→cancelled。 + +### 6. 摘要(`GET /api/mail/summary`) + +返回 total、unread、by_type 统计。 + +### 7. Agent 列表(`GET /api/mail/agents/list`) + +返回参与过 Mail 的所有 Agent ID。 + +--- + +## 回复机制详解 + +### in_reply_to 的实际行为 + +**存储方式**: `in_reply_to` 存储在 Task 的 `must_haves` JSON 字段中(`mail_routes.py:L182`)。 + +```python +meta = { + "type": mail_type, + "performative": performative, + "is_read": False, + "conversation_id": conversation_id, + "in_reply_to": in_reply_to, # ← 存在这里 + "from": from_agent, +} +task = Task(..., must_haves=json.dumps(meta)) +``` + +**行为特点**: + +1. **简单字段关联**:`in_reply_to` 只是一个 ID 引用,不是外键约束,没有数据库级的线程聚合。线程聚合通过 `conversation_id` 实现。 + +2. **自动推断收件人**(A6/A7):回复时 `to` 自动从原邮件的 `assigned_by`(发件者)取值。无论 Agent 传什么 `to`,系统都静默纠正(`mail_routes.py:L113-L117`)。 + +3. **严格 1 对 1 校验**(A8):只有原邮件的 `assigned_by`(发件者)和 `assignee`(收件者)可以回复(`mail_routes.py:L119-L121`)。 + +4. **conversation_id 自动继承**:回复邮件自动继承原邮件的 `conversation_id`(`mail_routes.py:L160-L166`),用于前端线程展示。 + +5. **type 自动设置**:有 `in_reply_to` 且未显式指定 type 时,默认 `inform`(防循环)(`mail_routes.py:L170-L172`)。 + +### 查询回复的能力 + +**当前无法直接查询某个 Mail 的所有回复**。原因: + +1. `list_mail` 不支持 `in_reply_to` 查询参数 +2. `in_reply_to` 存在 `must_haves` JSON 中,不是独立的 SQL 列 +3. 当前唯一查询回复的方式:`GET /api/mail/{mail_id}` 获取详情时附带 comments(但这不是回复 Mail,而是黑板 comment) + +**daemon 中的回复查询**(`ticker.py` `_mail_check_reply`): +```python +# L890 左右 +row = conn.execute( + "SELECT id FROM tasks WHERE id != ? AND must_haves LIKE ? LIMIT 1", + (original_task_id, f'%{original_task_id}%'), +).fetchone() +``` +这是通过 `LIKE '%{task_id}%'` 在 `must_haves` JSON 中模糊匹配实现的——**脆弱但能用**。`in_reply_to` 的值恰好包含在 `must_haves` JSON 中,所以 `LIKE` 能匹配到。 + +--- + +## 查询能力 + +### 支持的查询 + +| 查询场景 | 支持方式 | 限制 | +|---------|---------|------| +| 按 status 查询 | SQL WHERE | ✅ 直接支持 | +| 按 from_agent 查询 | SQL WHERE(assigned_by) | ✅ 直接支持 | +| 按 to_agent 查询 | SQL WHERE(assignee) | ✅ 直接支持 | +| 按 unread 过滤 | 内存过滤(解析 JSON) | ⚠️ 性能差(全量加载再过滤) | +| 按 title 模糊查询 | 无 | ❌ | +| 按 time range 查询 | 无 | ❌ | +| 单封 Mail 详情 | GET /api/mail/{id} | ✅ 含 comments | +| 摘要统计 | GET /api/mail/summary | ✅ | + +### 不支持的查询 + +| 查询场景 | 设计需要 | 当前状态 | 影响 | +|---------|---------|---------|------| +| 查询某 Mail 的所有回复 | daemon ticker 检查回复 | ❌ 无 `in_reply_to` 过滤参数 | **高** — daemon 靠 LIKE 模糊匹配,脆弱 | +| 查询所有 type=request 的 Mail | `find_pending_requests` | ❌ type 在 JSON 中 | **高** — 无法高效找未回复的 request | +| 查询所有有新回复的 Mail | `find_recently_replied` | ❌ 无此查询 | **中** — daemon 无法发现已回复的 Mail | +| 查询某 conversation 的所有 Mail | 线程展示 | ❌ conversation_id 在 JSON 中 | **中** — 前端无法获取完整线程 | +| 按 created_at/updated_at 范围查询 | 时间窗口 | ❌ 无时间参数 | **低** — 可通过 limit+倒序近似 | + +### 时间字段 + +- ✅ `created_at`:tasks 表有,自动填充 `datetime('now')` +- ✅ `updated_at`:tasks 表有,状态变更时自动更新 +- ⚠️ 两个字段都是 Task 模型自带,Mail 复用了它们 + +### Metadata 能力 + +Mail 的所有扩展 metadata 都存在 `must_haves` JSON 字段中: + +| 字段 | 当前值 | 可扩展性 | +|------|--------|---------| +| `type` | inform/request | ✅ 可存任意值 | +| `performative` | 同 type | ✅ | +| `is_read` | true/false | ✅ | +| `conversation_id` | conv-timestamp | ✅ | +| `in_reply_to` | mail_id 或 null | ✅ | +| `deadline_hours` | **未存** | ✅ 可直接加到 JSON 中 | +| `priority` | **未存** | ✅ 可直接加 | +| `template_id` | **未存** | ✅ 可直接加 | + +**结论**:`must_haves` JSON 完全可以存 `deadline_hours` 等任意 metadata,**无需改表结构**。问题是查询时必须解析 JSON,无法直接 SQL WHERE。 + +--- + +## Mail 模板能力 + +### 当前状态 + +Mail API **没有模板功能**。API 只负责 CRUD,模板固化在两个地方: + +1. **Agent Skill / Prompt 指令中**:Agent 通过 Skill(如 `sanguo-mail`)获得发 Mail 的 curl 模板 +2. **Spawner Mail Prompt 模板中**(`spawner.py`): + - `MAIL_INFORM_TEMPLATE`:纯通知模板 + - `MAIL_REQUEST_TEMPLATE`:需回复模板 + +### 设计决策 + +**这是正确的设计**。模板属于业务层(Prompt),不属于 API 层。API 只负责消息的发送、存储、查询。模板放在 API 层会导致: +- API 与业务逻辑耦合 +- 模板变更需要改后端代码 +- Agent 自主决策能力受限 + +--- + +## Daemon 中的 Mail 扫描逻辑 + +### 现有扫描 + +Daemon ticker 在 `_tick_project` 中会扫描 `_mail` 虚拟项目(`ticker.py:L243-L252`),走标准调度流程: +1. `_check_timeouts`:超时检测(claimed 5min, working 30min) +2. `_dispatch_pending`:调度 pending 任务 +3. `_mail_check_reply`:幻觉门控(working 超时时检查是否有回复) + +### 关键逻辑 + +1. **Mail 自动标 working**(`dispatcher.py`):dispatch Mail 时系统自动标 working,Agent 不需要手动标 +2. **幻觉门控**(`ticker.py:_mail_check_reply`):request 类型邮件 working 超时时,先检查 DB 有无回复邮件,有回复则标 done 而非 failed +3. **Mail 不走 review 流程**(`ticker.py:_dispatch_reviews`):`if project_id == "_mail": return []` + +### 缺失的扫描逻辑 + +| 需要的扫描 | 当前状态 | 用途 | +|-----------|---------|------| +| `find_pending_requests`(type=request 且无回复) | ❌ 无 | daemon 主动催办未回复的 request | +| `find_recently_replied`(有新回复的 Mail) | ❌ 无 | daemon 发现回复后驱动下一步 | +| `check_stale_flows`(超时检测 + 分级处置) | ❌ 无 | §15.4 设计的三级超时处置 | + +--- + +## 缺失能力清单 + +| # | 缺失项 | 设计需要 | 实现建议 | 影响范围 | +|---|--------|---------|---------|---------| +| 1 | `in_reply_to` 查询过滤 | daemon 检查某 Mail 是否有回复 | **改 API**:`GET /api/mail?in_reply_to={id}` — list_mail 加参数,用 `must_haves LIKE` 或子查询 | `mail_routes.py` | +| 2 | `type` 查询过滤 | daemon 查找 type=request 的 Mail | **改 API**:`GET /api/mail?type=request` — 同上,must_haves LIKE 匹配 | `mail_routes.py` | +| 3 | `conversation_id` 查询过滤 | 前端线程展示 | **改 API**:`GET /api/mail?conversation_id={cid}` | `mail_routes.py` | +| 4 | `find_pending_requests` | daemon 催办无回复的 request | **改 daemon**:ticker 中加一个扫描逻辑,组合 type=request + 无 in_reply_to 回复 | `ticker.py` | +| 5 | `find_recently_replied` | daemon 发现新回复驱动下一步 | **改 daemon**:扫描最近 updated_at 变化且 has_reply 的 Mail | `ticker.py` | +| 6 | `deadline_hours` 存储查询 | §15.4 三级超时 | **不需改 API**:直接在 send_mail 的 must_haves JSON 中加字段;daemon 读 must_haves 解析即可 | 调用方 + `ticker.py` | +| 7 | Mail 模板系统 | §15.5 模板 | **不需实现**:模板在 Prompt 层是正确设计 | 无 | + +--- + +## 建议改动 + +### 优先级 P0(回复驱动必需) + +#### 改动 1:`mail_routes.py` list_mail 增加 `in_reply_to` 和 `type` 过滤 + +**位置**: `list_mail` 函数(L79-L91) + +**改什么**: 增加两个可选查询参数 `in_reply_to` 和 `type`,用 `must_haves LIKE` 过滤。 + +**伪代码**: +```python +@router.get("") +async def list_mail( + status, from_agent, to_agent, unread, limit, + in_reply_to: Optional[str] = None, # 新增 + mail_type: Optional[str] = None, # 新增(避免与 Python type 冲突) + conversation_id: Optional[str] = None, # 新增 +): + # ... 现有逻辑 ... + for t in tasks: + meta = _mail_meta(t) + if in_reply_to and meta.get("in_reply_to") != in_reply_to: + continue + if mail_type and meta.get("type") != mail_type: + continue + if conversation_id and meta.get("conversation_id") != conversation_id: + continue + # ... 其余逻辑 ... +``` + +**替代方案(更高效)**: 加一个 DB migration,把 `in_reply_to`、`type`、`conversation_id` 从 must_haves JSON 提取为独立的 SQL 列。这样可以用索引加速查询。但这会改变 tasks 表结构,影响所有项目。 + +**推荐**: 先用内存过滤(快速实现),后续按需优化为独立列。 + +#### 改动 2:`ticker.py` 增加 Mail 回复扫描逻辑 + +**位置**: `_tick_project` 中 `_mail` 虚拟项目处理之后 + +**改什么**: 新增一个 `_check_mail_replies` 方法: +1. 查询所有 type=request 的 Mail +2. 对每个 request,检查是否有 in_reply_to 指向它的回复 +3. 有回复 → 触发下一步(通知发件者) +4. 无回复 + 超时 → 催办 + +**伪代码**: +```python +async def _check_mail_replies(self, db_path: Path): + """扫描 Mail 回复状态,驱动回复驱动流程""" + conn = get_connection(db_path) + try: + # 查所有 working 状态的 Mail + rows = conn.execute( + "SELECT id, must_haves, created_at FROM tasks WHERE task_type='mail' AND status='working'" + ).fetchall() + for row in rows: + meta = json.loads(row["must_haves"]) if row["must_haves"] else {} + mail_type = meta.get("type", "inform") + if mail_type != "request": + continue # inform 不需要回复 + + # 检查是否有回复 + reply = conn.execute( + "SELECT id FROM tasks WHERE task_type='mail' AND id != ? AND must_haves LIKE ? LIMIT 1", + (row["id"], f'%"in_reply_to": "{row["id"]}"%'), + ).fetchone() + + if reply: + # 有回复 → 可以标 done(如果还没标) + pass + else: + # 无回复 → 检查超时 + deadline_hours = meta.get("deadline_hours", 4) + elapsed = (datetime.utcnow() - datetime.fromisoformat(row["created_at"])).total_seconds() / 3600 + if elapsed > deadline_hours: + # 超时 → 催办 + pass + finally: + conn.close() +``` + +### 优先级 P1(增强) + +#### 改动 3:`deadline_hours` 支持 + +**不需改 API**。在发送 Mail 时,调用方在 `must_haves` JSON 中加入 `deadline_hours`: + +```json +{ + "from": "pangtong-fujunshi", + "to": "simayi-challenger", + "type": "request", + "deadline_hours": 2, + "text": "请审查..." +} +``` + +API 已支持——`must_haves` 是自由 JSON,`deadline_hours` 会被存入 DB。daemon ticker 读取时解析 JSON 即可。 + +### 优先级 P2(远期) + +#### 改动 4:独立列优化 + +将 `in_reply_to`、`type`、`conversation_id` 从 `must_haves` JSON 提取为 tasks 表的独立列,建索引加速查询。需要 DB migration。 + +--- + +## 附录:Mail 数据模型映射 + +Mail 基于 Task 模型,字段映射关系: + +| Mail 语义 | Task 字段 | 存储位置 | +|-----------|----------|---------| +| `id` | `task.id` | 主键 | +| `title` | `task.title` | 直接列 | +| `text` / `description` | `task.description` | 直接列 | +| `from` | `task.assigned_by` | 直接列 | +| `to` | `task.assignee` | 直接列 | +| `status` | `task.status` | 直接列(pending→claimed→working→done/failed) | +| `type` | `must_haves.type` | JSON | +| `is_read` | `must_haves.is_read` | JSON | +| `in_reply_to` | `must_haves.in_reply_to` | JSON | +| `conversation_id` | `must_haves.conversation_id` | JSON | +| `performative` | `must_haves.performative` | JSON | +| `created_at` | `task.created_at` | 直接列 | +| `updated_at` | `task.updated_at` | 直接列 | +| `task_type` | `task.task_type` = `"mail"` | 直接列 |