package repo import ( "context" "github.com/agentmail/gateway/internal/db" "github.com/google/uuid" ) // 连续 relay 跳数限制 —— 防止两个 Agent 靠自动转发互相唤醒到无穷。 // // # 这是什么问题 // // 每个插件都在「一轮结束时把模型最后那段话自动发回去」(契约 B-5)。 // 当收件方也是一个装了同类插件的 Agent 时,这封信唤醒对方 → 对方跑一轮 → // 对方也自动回一封 → 循环。**双方都没有「决定继续」,因为双方都不在做决定** —— // 发信是插件代劳的。 // // 生产上真实发生过:会话 f3d824ce(dsh 与 opencode 联调 llmsproxy)共 41 封, // 最后一封人类意图的邮件之后,**每一封都是 relay:summary**, // 间隔从 15 分钟一路缩到 5 秒,内容已无新增信息。 // // # 为什么 relay_key 拦不住 // // 它是幂等键,职责是「同一条上游消息不重复转发」,这一点它做对了。 // 但每一轮都是**真正不同**的新消息:opencode 侧是 assistant message id // (msg_0655bbf6…、msg_0656cc7fc…),dsh 侧是事件计数(…:12220、…:13347)。 // 每次 ClaimRelay 都合法通过。 // // # 为什么需要两道防线 // // 主防线是「免配额只给发往人类的 relay」(见 handler.SendMail): // Agent→Agent 的自动转发转而消耗会话预算,max_rounds 会截断它。 // // 但那还不够:预算给得大(比如 200)时,两个 Agent 仍能烧掉 200 个来回; // 而故障报告这类**必须**走 relay 的邮件也需要受约束。因此这里再加一道 // 与预算无关的硬上限:一条会话里**连续**的 relay 邮件不得超过 maxRelayHops。 // // 「连续」是关键:只要中间有一封自主发信(模型真的决定说什么)或人类插话, // 计数就归零。这让正常的「模型回一封、插件补一封总结」不受影响, // 只掐住「全程无人决策」的那种回路。 // // hop_limit 列早就在 schema 里(DEFAULT 5)却从没有人读它 —— 它显然 // 就是为这件事准备的。这里把它接上,取同一个默认值。 const maxRelayHops = 5 // CountTrailingRelayHops 数会话尾部**连续**的 relay 邮件数。 // // 从最新一封往前扫,遇到第一封非 relay 邮件即停。返回值即「若本次再发一封 // relay,它会是第几跳」的前一个数。 // // 判据用 relayed_mails 的存在性而不是 mails 上的某个标记: // relay 身份本来就记在那张表里,在 mails 上再存一份等于给同一事实留两个答案。 func CountTrailingRelayHops(ctx context.Context, sessionID uuid.UUID) (int, error) { rows, err := db.DB.QueryContext(ctx, ` SELECT 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() hops := 0 for rows.Next() { var isRelay int if err := rows.Scan(&isRelay); err != nil { return hops, err } if isRelay == 0 { // 遇到一封自主发信/人类邮件:链条到此为止 break } hops++ } return hops, rows.Err() } // MaxRelayHops 暴露上限供错误文案与测试使用。 func MaxRelayHops() int { return maxRelayHops }