Files
MailUI4Agents/gateway/internal/repo/relayhops.go
JianFeeeee f321380fa3 fix: relay 死循环防护 + DSH 工作区注册修复 + homeagent 工具集补齐 + 三插件 connect_to_server
## 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 已纳入校验)
2026-09-03 15:01:52 +08:00

84 lines
3.3 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"
)
// 连续 relay 跳数限制 —— 防止两个 Agent 靠自动转发互相唤醒到无穷。
//
// # 这是什么问题
//
// 每个插件都在「一轮结束时把模型最后那段话自动发回去」(契约 B-5
// 当收件方也是一个装了同类插件的 Agent 时,这封信唤醒对方 → 对方跑一轮 →
// 对方也自动回一封 → 循环。**双方都没有「决定继续」,因为双方都不在做决定** ——
// 发信是插件代劳的。
//
// 生产上真实发生过:会话 f3d824cedsh 与 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 }