## relay 死循环防护(两道防线) ### 主防线:免配额只给发往人类的 relay(handler/mail.go) 原设计:relay 走免配额通道(harness 搬运不该算模型自主发信)。 问题:收件方是另一个同样会自动转发的 Agent 时,整个回路里没有任何 一处在计数——生产上跑出过 41 封(会话 f3d824ce),间隔从 15 分钟 缩到 5 秒,且用了 37 封才烧掉 4/20 预算。 改为:repo.IsHumanUser(to.Name) 判定。Agent→Agent 的 relay 照样扣预算。 顺带修次序问题:原来是「先占幂等键再扣预算」,预算耗尽时幂等键 已被占用,加了额度也无法重发。现在预算失败会 ReleaseRelay 还回去。 ### 兜底:hop_limit 列接通(repo/relayhops.go) schema 里早有 hop_limit INT DEFAULT 5,从未有代码读它。 CountTrailingRelayHops 从最新邮件往前扫,遇到第一封非 relay 邮件即停(中间有一封自主发信或人类插话就归零)。 5 测试:空会话 / 只数 relay / 自主发信打断归零 / 达到上限 / 按会话独立 ## DSH 工作区注册修复 问题:上一轮加的 workspaceRegistry.create(cwd) 用了兜底值 cwd(来自 resolveWorkspaceCwd,可能是 ~/.dsh/mail-sessions/mail-<uuid>), 而不是会话 header 里的真实 cwd。两者不一致时 attachSession 拒绝, 且 create 已先执行,每封邮件都往注册表里塞一条空的垃圾 workspace。 修复:读 handle.agent.session.header.cwd —— create 路径下是 meta.cwd, resume 路径下是持久化 header 里那个。 ## homeagent 插件:11 工具齐平 opencode tools.go 新增:read_mail / forward_mail / suggest_address / list_contacts / session_participants / read_thread / connect_to_server + handleConnectToServer(注册到 Gateway 前先用候选坐标试注册, 成功才写回 p.gwURL/p.key,失败不破坏原配置) 关键修:Plugin.name(插件名,homed 注册用)与 Plugin.agentName (AgentMail 身份,Gateway 密钥绑定用)是两个命名空间。 它们混淆会导致 403:「该密钥已绑定到 Agent 'homeagent',不能用于 注册 'homeagent-mail-bridge'」。现已分开,并在 systemd drop-in 里显式设 AGENTMAIL_AGENT_NAME=homeagent。 ## 三插件补齐 connect_to_server 之前只有 opencode 有。后果:Gateway 换地址或密钥需要重新登记时, opencode 里的模型能自己修好,其他平台只能干等环境变量被人改。 DSH 版:从 GatewayClient 内部调 register(),成功后写回 client.baseURL 与 client.agentKey 当场生效。 pi 版:新导出 KEY_FILE / saveLocalKey(从 gateway.mjs),connect 工具直接用。 # 测试 relayhops_test.go 5 例 opencode 172 / dsh 188 / pi 214 全绿 check-shared-libs.sh 三方同源(rename-proposal 已纳入校验)
84 lines
3.3 KiB
Go
84 lines
3.3 KiB
Go
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 }
|