# 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"` | 直接列 |
