Files
MailUI4Agents/server/internal/repo/agentloop.go
JianFeeeee a8d6811cd7 style(server): gofmt 注释规范 + init.sql 补 parent_session_id + 新增 ratelimit 判据
## 主体是 gofmt

`server/` 下 20 个文件的改动里,**142/147 行是注释与缩进** ——
Go 1.19+ 的 gofmt 把块注释里的 `//   x` 规范成 `//\tx`。
功能零变化。

## 一处实质:init.sql 补 parent_session_id

`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id
 TEXT REFERENCES sessions(session_id)`

远端已在 `db/migrate.go` 的 `sqliteAddColumns` 结构迁移通道里加了同一列;
init.sql 是**全新库**的建库脚本,缺这列会让新库少一个字段。

- `IF NOT EXISTS` ⇒ 与结构迁移通道幂等,两者并存无害
- 默认空 = 根节点;**不据历史数据猜父子**(猜错会让线索拓扑失真,比没有更糟)

## 新增 handler/ratelimit_test.go(162 行)

速率限制的判据。此前这一块没有独立判据。

## 验证(本机实跑,非推断)

    go build ./...                              通过
    go vet ./internal/{handler,repo,db,sse}/   通过
    go test -count=1(handler/repo/db/sse/blob)
      handler  41.3s  ok
      repo    101.9s  ok
      db       1.1s  ok
      sse      0.01s ok
      blob     0.08s ok

`-count=1` 是刻意的:第一遍跑出来全是 `(cached)`,那种绿灯不算数。
2026-10-05 09:26:59 +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 }