feat(mcp)★★: 自动转发通道 —— relay/relay_key,并修掉一个从未生效的幂等文案

用户 2026-10-04:「不只是 Agent 主动回,还有自动转发 Agent 的最后一条消息」。

## 为什么 MCP 侧需要显式上报,而插件侧不用

  插件(pi / dsh / homeagent)有 `session.idle` 钩子:
    宿主进程在自己会话 idle 时读到最后一条回复 → 自动 send_mail(relay=…)
  MCP 宿主(Codex / Claude Desktop)**没有这个概念**:
    不知道「这一轮处理完了」,也不会主动调 send_mail。
    ⇒ 宿主必须显式上报,服务端才可能自动转发。

  所以新增 `send_mail(relay="summary", relay_key="<宿主消息 id>")`:
  relay=summary 免配额(服务端既有逻辑),relay_key 提供幂等。

## 顺带修掉一个从未生效的幂等文案(既有缺陷)

    MCP 侧读:str(payload, "duplicate_relay")
    服务端返:{"status": "duplicate_relay", "detail": "该上游消息已转发过…"}

  **字段名对不上 ⇒ 那段逻辑从来没生效过。**

  后果对 MCP 接入端尤其致命:它的自动转发正是靠 relay_key 幂等兜底,
  而宿主重试时看到的是「已发送」(无 mail_id、无任何异常),
  于是继续重试 —— 每一轮都在消耗一次往返。

  现在文案是:
    该上游消息(relay_key: xxx)已转发过,本次调用未产生新邮件 —— 不需要重试。

## 判据(3 格,2 个变异都转红)

- send_mail 暴露 relay / relay_key(去掉透传 → 转红)
- relay 非空而 relay_key 为空必须被拒
- 同一 relay_key 第二次转发报重复,且**只有一封真邮件**

## 判据自己错了两次

1. `TestRelayRequiresKey` 红着但**原因不对**:没建收件人,400 发生在
   「收件人不存在」那一步,掩盖了 parseRelay 的校验。
2. 幂等那格被我自己 `Skip` 了 —— `.relay-idem` 会话不存在,
   于是「幂等没被击穿」的假绿。改为 `.new` 并**禁止 Skip**:
   幂等是自动转发不重投的唯一保证,它绿着才敢让宿主上报。
   (判据不可达 = 描述的状态不存在。)

## 双向对齐判据立刻证明了价值

加了 relay/relay_key 却没登记,它当场红:

    ★ send_mail 的参数 "relay" 既不在插件版、也没登记为 MCP 额外

若它不存在,加了新参数就会静默通过,而「这个能力是有意加的还是漏登记的」
正是以后没人能回答的问题。relay 登记为「额外」而非「缺失」——
插件侧走 session.idle,不需要这条参数。

14 包全绿。
This commit is contained in:
2026-10-04 15:03:33 +08:00
parent d84c198975
commit a6b7994a45
4 changed files with 170 additions and 7 deletions

View File

@ -120,6 +120,11 @@ func (t *Tools) SendMail() Tool {
"propose_alias": strProp("可选。建议把当前会话改名成这个别名 —— 这只是建议," +
"别名是人的寻址入口,实际改名由用户在界面上确认。不可含 . / @ 空白,不可为 new"),
"propose_reason": strProp("改名理由,一句话,展示给用户看"),
"relay": strProp("免配额转发标记。\"\" | \"permission\" | \"summary\"。\n" +
" 本轮处理完时把最终结果转给人类请填 summary —— 它不扣配额。\n" +
" 人类回信后本轮已完成,不填才是正常发信(要扣配额)。"),
"relay_key": strProp("relay 非空时必填:上游那条消息的稳定标识(宿主侧的消息 id)。\n" +
" 同一 id 只能转发一次(服务端有唯一约束),用于重试时不重复投递。"),
}, "to"),
},
run: func(ctx context.Context, args map[string]any) (string, error) {
@ -151,6 +156,22 @@ func (t *Tools) SendMail() Tool {
if ids := argStrList(args, "attachment_ids"); len(ids) > 0 {
body["attachment_ids"] = ids
}
// relay / relay_key:MCP 接入端的「本轮总结自动转给人类」通道(2026-10-04)。
//
// ★ 为什么 MCP 侧需要它而插件侧不用:
// 插件(pi/dsh/…)有 session.idle 钩子,能在宿主侧读到最后一条回复
// 并自动转发;MCP 宿主(Codex/Claude Desktop)**没有这个概念**,
// 不知道「这一轮处理完了」,也不会主动调 send_mail。
// ⇒ 宿主必须显式上报,服务端才可能自动转发。
//
// relay_key 由宿主生成(模型伪造不出),服务端有唯一约束,
// 所以重试不会重复投递 —— 与各桥的 relay 幂等语义一致。
if v := argStr(args, "relay"); v != "" {
body["relay"] = v
}
if v := argStr(args, "relay_key"); v != "" {
body["relay_key"] = v
}
code, payload, raw := invoke(ctx, handler.SendMail, newRequest(ctx, http.MethodPost, "/api/v1/mail/send", body))
if code < 200 || code >= 300 {
return render("send_mail", code, payload, raw)