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 邮件数。 // // 返回值即「若本次再发一封,它会是第几跳」的前一个数。 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 FROM mails m 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 bool if err := rows.Scan(&fromHuman, &toHuman); err != nil { return 0, err } // 人类参与(发或收)即打断连续性 —— 与 maxRelayHops 同一个"连续"语义。 if fromHuman || toHuman { break } n++ } return n, rows.Err() } // MaxAgentPingPong 供 handler 与测试共用同一个阈值。 func MaxAgentPingPong() int { return maxAgentPingPong }