16 KiB
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 扫描调度。
但缺少以下关键查询能力:
- ❌ 无法查询某个 Mail 的所有回复(无
in_reply_to过滤参数) - ❌ 无法查询"所有有新回复的 Mail"(无
find_recently_replied) - ❌ 无法查询"所有 type=request 且无回复的 Mail"(无
find_pending_requests) - ❌ Mail metadata 无法存
deadline_hours(must_havesJSON 可存但无专用字段和查询路径) - ❌ 无 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)。
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))
行为特点:
-
简单字段关联:
in_reply_to只是一个 ID 引用,不是外键约束,没有数据库级的线程聚合。线程聚合通过conversation_id实现。 -
自动推断收件人(A6/A7):回复时
to自动从原邮件的assigned_by(发件者)取值。无论 Agent 传什么to,系统都静默纠正(mail_routes.py:L113-L117)。 -
严格 1 对 1 校验(A8):只有原邮件的
assigned_by(发件者)和assignee(收件者)可以回复(mail_routes.py:L119-L121)。 -
conversation_id 自动继承:回复邮件自动继承原邮件的
conversation_id(mail_routes.py:L160-L166),用于前端线程展示。 -
type 自动设置:有
in_reply_to且未显式指定 type 时,默认inform(防循环)(mail_routes.py:L170-L172)。
查询回复的能力
当前无法直接查询某个 Mail 的所有回复。原因:
list_mail不支持in_reply_to查询参数in_reply_to存在must_havesJSON 中,不是独立的 SQL 列- 当前唯一查询回复的方式:
GET /api/mail/{mail_id}获取详情时附带 comments(但这不是回复 Mail,而是黑板 comment)
daemon 中的回复查询(ticker.py _mail_check_reply):
# 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,模板固化在两个地方:
- Agent Skill / Prompt 指令中:Agent 通过 Skill(如
sanguo-mail)获得发 Mail 的 curl 模板 - 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),走标准调度流程:
_check_timeouts:超时检测(claimed 5min, working 30min)_dispatch_pending:调度 pending 任务_mail_check_reply:幻觉门控(working 超时时检查是否有回复)
关键逻辑
- Mail 自动标 working(
dispatcher.py):dispatch Mail 时系统自动标 working,Agent 不需要手动标 - 幻觉门控(
ticker.py:_mail_check_reply):request 类型邮件 working 超时时,先检查 DB 有无回复邮件,有回复则标 done 而非 failed - 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 过滤。
伪代码:
@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 方法:
- 查询所有 type=request 的 Mail
- 对每个 request,检查是否有 in_reply_to 指向它的回复
- 有回复 → 触发下一步(通知发件者)
- 无回复 + 超时 → 催办
伪代码:
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:
{
"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" |
直接列 |