## 主体是 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)`,那种绿灯不算数。
107 lines
4.7 KiB
Go
107 lines
4.7 KiB
Go
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 }
|