Files
MailUI4Agents/server/internal/repo/agentloop.go
dsh 974bf2a62f fix(server): ★ relay 邮件不计入 Agent 互发上限 + 该闸放行 relay 发送 —— 修「退信把通道自己锁死」的会话死锁
症状(生产会话 0094eee5 `harmony-push-真机验证`):
homeagent 的 3 封失败退信(relay:"summary")被 CountTrailingAgentPingPong
计入「Agent 互发」计数,与 5 封模型主动往返合计 8 ⇒ 触发上限。之后:
  · 模型每次真生成完回复再 send ⇒ 403(网关日志 09-29 22:01 ~ 09-30 10:11 共 7 笔);
  · 桥的 sendFailureReply 退信也走 /mail/send ⇒ 同样 403 ⇒ 发件人只收到
    「模型未产生回复」,真因被吞;
  · 被拒的尝试不产生新邮件 ⇒ 计数永不回落 ⇒ 死锁,无人能解。
发件方收到的错误与「模型没说话」在桥侧合并成同一文案(plugin.go:1022),
把内核侧限流误报成模型故障 —— 两个 Agent 各自查错了方向一整天。

根因:2026-09-26 设计第三道闸时的实测样本(pi↔dsh 175 封)里 relay=0,
于是默认这条路径上没有 relay 邮件。但插件代劳的退信同样满足
「Agent→Agent + 无人类」,被并进同一个闸。relay 本有自己的更严闸
(maxRelayHops=5),两类回路挤在同一个计数里是设计疏漏。

修法(两处,缺一不可):
  1. repo:CountTrailingAgentPingPong 跳过 relayed_mails 里登记过的邮件
     (LEFT JOIN,与 CountTrailingRelayHops 同一判据源)。
  2. handler:该闸加 `relay == ""` 条件 —— 即使计数已满,插件代劳的
     退信/转发也必须能发出去(否则 ①② 仍在:闸拦住退信 → 真因被吞)。

红绿(生产同款数据形状):
  3 主动 + 3 relay + 2 主动 ⇒ 缺陷版数出 8(FAIL,与生产实测一字不差),
  修复后 5(PASS)。人类参与打断连续性的语义不变(回归 TestAgentPingPongResetsOnHuman)。

自证边界:单测证明的是计数器与闸门的判据;「死锁会话已解锁」要在部署后
用真实会话验证(见下一笔提交)。403 文案同时删掉了「或说明为何这轮必须继续」
—— 模型的任何说明本身也要走 send,被同一道闸拦着,这条恢复路径不存在。
2026-09-30 17:08:49 +08:00

107 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"context"
"github.com/agentmail/gateway/internal/db"
"github.com/google/uuid"
)
/*
防的是**无人决策的 Agent↔Agent 往返**,与 relay 无关。
# 为什么已有的两道防线都拦不住(2026-09-26 实测)
生产上 `pi ↔ dsh` 一天跑了 **175 封**、正文 612KB,其中 **relay = 0** ——
全部是模型**主动**调 send_mail。而两道防线都只覆盖 `relay != ""` 那条路:
① 会话预算:`relay != ""` 才扣 ⇒ 主动发信不扣(且该会话 max_rounds=0=不限)
② maxRelayHops=5:`if relay != "" { ... }` ⇒ 根本不进那个分支
③ 插件自动转发守卫:日志明说"本轮不自动转发:来信方 dsh 是 Agent"
—— 守卫**工作正常**,但模型自己发的不受它管
⇒ 三条全绕开。没有任何一层在数"两个 Agent 在没有人类参与的情况下连续往返了几次"。
# 判据的形状(与 maxRelayHops 同构,但不看 relay 标记)
从最新一封往前扫,**连续**的"发件方是 Agent 且收件方也是 Agent"的邮件计数,
遇到以下任一即停(= 归零):
· 人类发的信(`from_name` 是用户)
· 人类收的信(`to_name` 是用户)
"连续"与"人类参与即归零"这两点与 maxRelayHops 一致 —— 正常的
「Agent 干完活回一封、人类看一眼再派下一轮」不受影响;只掐住
「全程没有人类说话」的那种回路。
# 为什么阈值取 8 而不是 5
maxRelayHops=5 管的是**插件自动转发**:那种回路每封都没有新信息,5 跳足够。
而这里是模型**主动**发信 —— 两个 Agent 协同一件事时,来回十几次是正常的
(本轮真实案例:`mc` 插件修复本身就有 6 封有效往返)。
8 是"明显超出正常协同、但还没到烧掉一天 175 封"的位置。触发时的处理是
**拒绝并说清怎么办**(人类插一句、或模型说明为何必须继续),不是静默限流。
*/
const maxAgentPingPong = 8
// CountTrailingAgentPingPong 数会话尾部**连续的、无人类参与**的 Agent↔Agent 邮件数。
//
// 返回值即「若本次再发一封,它会是第几跳」的前一个数。
//
// ★ relay 邮件**不计数**(2026-09-30 修,dsh):
//
// 本函数原样数了插件代劳的邮件,与设计意图(注释第 15~16 行「relay = 0」
// 的实测场景)相悖 —— 那次实测里回路全由**模型主动 send_mail** 构成,
// 所以设计时默认「这条路径上没有 relay 邮件」。但插件代劳的邮件
// (失败退信 sendFailureReply、权限询问转发、总结转发)也满足
// 「发件方 Agent + 收件方 Agent + 无人类」,一旦被计入,就会出现:
//
// ① 退信计入 8 连 ⇒ 上限被**插件自己**触发;
// ② 闸门在 send 入口、先于 relay 分支 ⇒ 插件代劳的退信同样被 403;
// ③ 退信发不出去 ⇒ 发件人只看到「模型未产生回复」,不知道真因;
// ④ 计数只增不减(每一次被拒的尝试都不产生新邮件,计数不动,
// 但每次模型真生成完回复再被拒,桥又发一封退信也进不来)⇒
// 会话进入**永久死锁**:模型想回信 → 403 → 退信也 403 →
// 发件人重发 → 模型再回 → 再 403,没有任何一方能解。
//
// relay 邮件有自己的计数器(CountTrailingRelayHops,阈值 5,更严),
// 把两类计数混在同一个闸里正是本缺陷的根因。
func CountTrailingAgentPingPong(ctx context.Context, sessionID uuid.UUID) (int, error) {
rows, err := db.DB.QueryContext(ctx, `
SELECT
EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name) AS from_human,
EXISTS (SELECT 1 FROM users u WHERE u.username = m.to_name) AS to_human,
CASE WHEN r.mail_id IS NULL THEN 0 ELSE 1 END AS is_relay
FROM mails m
LEFT JOIN relayed_mails r ON r.mail_id = m.mail_id
WHERE m.session_id = $1
ORDER BY m.created_at DESC, m.mail_id DESC`, sessionID)
if err != nil {
return 0, err
}
defer rows.Close()
n := 0
for rows.Next() {
var fromHuman, toHuman, isRelay bool
if err := rows.Scan(&fromHuman, &toHuman, &isRelay); err != nil {
return 0, err
}
// 人类参与(发或收)即打断连续性 —— 与 maxRelayHops 同一个"连续"语义。
if fromHuman || toHuman {
break
}
// ★ relay(插件代劳)不计入:它有自己的更严阈值(maxRelayHops=5),
// 且绝不能反过来把模型主动回信的通道锁死(详见函数头注释 ①~④)。
if isRelay {
continue
}
n++
}
return n, rows.Err()
}
// MaxAgentPingPong 供 handler 与测试共用同一个阈值。
func MaxAgentPingPong() int { return maxAgentPingPong }