384 lines
16 KiB
Markdown
384 lines
16 KiB
Markdown
# 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"` | 直接列 |
|