## 设计规则:Agent 之间不自动转发
自动转发存在的理由是「人不该等模型记得调 send_mail」—— 收件方是人时这是
纯收益。**收件方是另一个 Agent 时这个理由不成立,而且有害**:双方的插件都
会自动回一封,于是两个模型都以为「我只要把话说完就行」,实际在持续互相唤醒。
生产实测 pi 与 dsh 客套 6 轮直到撞上连续 relay 跳数上限。
规则现在写死在共用模块 `lib/relay-policy.js`(三平台逐字节相同):
- `autoRelayDecision` — 插件该不该替模型开口
- `replyInstruction` — 提示词怎么跟模型说(人类 vs Agent 各一套措辞)
- `inboundHeadline` — 进来的是新活、回复、还是补投
`from_human` 缺失时保守按 Agent 处理:宁可让模型多调一次 send_mail,
也不能承诺一个不会发生的自动回信让发件方白等。
## Gateway 侧:`in_reply_to` + `from_human`
- `notify.Mail` 新增 `ParentMailID`(非空 = 这是对收件方某封信的回复)
- `notify.Mail` 新增 `FromHuman`(走 `repo.IsHumanUser`)
- SSE payload 里叫 `in_reply_to` / `from_human`
- 四个调用点全部传入:handler/mail(转发后产出的邮件,parentMailID 从
resolveTarget 取)、handler/me(同理)、handler/forward(传空串,
因为对收件方而言那封原邮件不在它的线索里)、scheduler/calendar(传空串)
- `ListInbox` 的 SELECT 加 `EXISTS (SELECT 1 FROM users u WHERE u.username = m.from_name)`
→ `models.Mail.FromHuman`,让补拉路径也有这个信号
## 提示词分流
三种处境各一套标题:
- 新活(人类):「你收到一封新邮件」+ 「回信不用你自己发:…」
- 新活(Agent):「你收到一封新邮件(对方是一个 Agent)」+ 「插件不会替你
回信。需要回复时你必须自己调 send_mail…请先判断是否真的需要回复」
- 回复到了:「你上一封信的回复到了。**这不是新任务**。」
- 补投:在标题里说明「离线期间积压」
## homeagent 特殊处理
Go 插件不能直接 `import('../lib/relay-policy.js')`,因此新增 `relay_policy.go`
(Go 对应物)+ `relay_policy_test.go`(11 例,逐条对齐 Node 侧判据)。
`sseLoop` / `catchUp` 两条路径都接上。
## `mailEvent` 命名类型
homeagent 的 SSE 事件解析 / handleNewMail / handlePermissionDecision 三处
原来各写一遍匿名 struct(字段列表几乎相同),加 `from_human` / `in_reply_to`
时漏改一处 → 编译报错但错误信息是两串几乎相同的字段列表,极难定位。
提成 `mailEvent` 命名类型:一处改、三处跟着走。
## 测试
- `lib/relay-policy.test.mjs`(Node)16 例:含「replyInstruction 与
autoRelayDecision 不得互相矛盾」「Agent 来信的标题要点名且回复要明确反对」
- `relay_policy_test.go`(Go)11 例:逐条对齐 Node 侧
- `turn.test.mjs` +3 例:from_human 缺失时按 Agent 处理 / Agent 来信时改口 /
回复到了说「不是新任务」;删掉两条旧的「必定自动转发」断言
- 共用脚本 `check-shared-libs.sh` +1 个文件(relay-policy)
- pi 288 / dsh 241 / opencode 217 / homeagent 14 / gateway 8 包全绿
210 lines
9.5 KiB
Go
210 lines
9.5 KiB
Go
// Package notify 是「一封邮件落库之后要通知谁、推什么」的**唯一实现**。
|
||
//
|
||
// # 为什么单独成包
|
||
//
|
||
// 在此之前有两份几乎相同的推送代码:`handler.notifyRecipients`(人发信、
|
||
// Agent 发信、转发都走它)和 `scheduler` 里日历提醒自己拼的那一份。
|
||
//
|
||
// 两份代码的代价在生产上兑现过一次,而且症状离原因很远:给 `new_mail` 加
|
||
// `platform_session_id` 字段时只改了 handler 那份,调度器那份仍是旧的。
|
||
// 于是日历提醒投进一条**接管会话**时,插件收不到 `platform_session_id`,
|
||
// 把它当成新会话另开了一条平台会话;那条新会话的名字随后经命名同步回写,
|
||
// **把接管会话的别名冲掉了** —— 人在补全里选中的「项目定位」变成了
|
||
// 「日程提醒:…」,同一条会话因此在候选列表里出现两次,而另一条真实会话
|
||
// 被按别名字符串去重吃掉了。
|
||
//
|
||
// 链条上每一环都不报错。根因只是「同一件事写了两遍」。
|
||
//
|
||
// 因此这个包对外只暴露一个入口:新增字段时不存在「另一处忘了改」的可能。
|
||
package notify
|
||
|
||
import (
|
||
"context"
|
||
|
||
"github.com/agentmail/gateway/internal/models"
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
"github.com/agentmail/gateway/internal/sse"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// Mail 描述一封刚落库的邮件需要推给谁。
|
||
type Mail struct {
|
||
SessionID uuid.UUID
|
||
MailID uuid.UUID
|
||
// From 是发件方名字。人类用户名与 Agent 名共享命名空间,这里不区分。
|
||
From string
|
||
// To 是主收件方地址(三维寻址已解析)。
|
||
To models.Address
|
||
// CC 是抄送方地址列表。
|
||
CC []models.Address
|
||
// Subject 是邮件主题。
|
||
Subject string
|
||
// MailType 默认 "normal";权限请求等特殊类型由调用方指定。
|
||
MailType string
|
||
// Origin 标记这封信的来源,供插件与 UI 区分「定时提醒」与「有人在找它」。
|
||
// 空串表示普通邮件。
|
||
Origin string
|
||
// ReplyToName 是「把回信发回这条会话」时该写的收件人名。
|
||
//
|
||
// 默认取 From。日历提醒必须覆盖它:发件人是 `calendar`,而那不是一个
|
||
// 收得到信的账号 —— 回给它的信投不出去。此时应当填主收件方自己的名字,
|
||
// 让模型把结果回报到同一条线索上。
|
||
ReplyToName string
|
||
// ParentMailID 非空表示这封是**回信**(回的那封的 mail_id)。
|
||
//
|
||
// 插件靠它区分「有人派了新活」与「我上一封信的回复到了」—— 两者对模型而言
|
||
// 是完全不同的处境,而在此之前 payload 里没有任何信号能分开它们。
|
||
//
|
||
// 后果在生产上兜现过:pi 转发给 dsh,dsh 回确认,pi 把那封确认当成新任务
|
||
// 又回一封,两边互相客套 6 轮直到撞上 hop 上限。
|
||
ParentMailID string
|
||
}
|
||
|
||
// Recipients 把一封邮件推给主收件人、所有抄送方,并刷新发件方的会话列表。
|
||
//
|
||
// # 每个收件方拿到的是**自己那个地址**
|
||
//
|
||
// 三维地址 `name@path.session` 的 path 就是工作目录,插件靠它建会话。
|
||
// 抄送给 `opencode@/a` 与主发给 `dsh@/b` 是两个不同的工作区,共用一份
|
||
// payload 会让抄送方在别人的目录里开会话。`reply_address` / `self_address`
|
||
// 同理,且 session 位已经把 `.new` 换成真实别名 —— `.new` 建完会话就失效了,
|
||
// 把原文那个 `x@/p.new` 送给参与方只会让它下一次又建一条新会话。
|
||
//
|
||
// # 抄送方必须单独推
|
||
//
|
||
// 漏掉的后果很隐蔽:邮件的 cc_list 里有他们、他们**查**收件箱能看到这封信,
|
||
// 但没有任何事件推给他们 —— 插件不会唤起会话,Agent 直到下一次补拉
|
||
// (重启时)才发现。对「知情方」而言等于没通知。
|
||
func Recipients(ctx context.Context, m Mail) {
|
||
// 别名此时应已由会话解析路径保证存在(`.new` 与默认会话都过
|
||
// EnsureSessionAlias)。仍可能为空的情形:命名写入失败(已吐日志)。
|
||
// 此时退回省略 session 位,而不是把 "new" 写进去 —— 后者会让参与方
|
||
// 反复建新会话。
|
||
alias := repo.SessionAliasOf(ctx, m.SessionID)
|
||
|
||
// 这条会话是否接管了一条平台侧已存在的会话(人在 TUI/GUI 里开的那种),
|
||
// 以及那条平台会话属于哪个 Agent。插件据此决定 resume 还是新建;
|
||
// 空串就是过去的行为。
|
||
//
|
||
// **owner 必须参与分发判据**:platform_id 是会话级的一个值,而一封邮件
|
||
// 可以有多个参与方。无差别下发会让抄送方拿一个属于别的平台的会话 id
|
||
// 去自己磁盘上找文件,找不到就抛「平台侧会话已删」—— 邮件静默消失。
|
||
// 生产实测过:pi 的会话 `01a05a5e-…` 被推给了抄送方 dsh。
|
||
platformID, platformOwner := repo.PlatformSessionFor(ctx, m.SessionID)
|
||
|
||
mailType := m.MailType
|
||
if mailType == "" {
|
||
mailType = "normal"
|
||
}
|
||
replyTo := m.ReplyToName
|
||
if replyTo == "" {
|
||
replyTo = m.From
|
||
}
|
||
|
||
// platformFor 只把 platform_session_id 给归属方。
|
||
//
|
||
// owner 为空(镜像里没这条、sessions.from_agent 也空)时一律不下发:
|
||
// 宁可退回「当普通会话处理」(插件新建一条,人在界面上看不到),
|
||
// 也不能让一个抽不到归属的 id 把邮件弄丢。
|
||
platformFor := func(forName string) string {
|
||
if platformID == "" || platformOwner == "" || forName != platformOwner {
|
||
return ""
|
||
}
|
||
return platformID
|
||
}
|
||
|
||
// 发件方是人还是 Agent。
|
||
//
|
||
// 插件靠它选提示词里那句关键的话:**「插件会自动把你本轮结论发回去」只对
|
||
// 人类发件方成立**。发给另一个 Agent 时,那边的插件也会自动回一封,
|
||
// 于是两个模型都以为「我只要把话说完就行」,实际上彼此持续唤醒 ——
|
||
// 生产实测 pi 与 dsh 互相客套 6 轮直到撞上 hop 上限。
|
||
fromHuman, _ := repo.IsHumanUser(ctx, m.From)
|
||
|
||
payload := func(role, workspace, forName string) map[string]interface{} {
|
||
p := map[string]interface{}{
|
||
"mail_id": m.MailID.String(),
|
||
"session_id": m.SessionID.String(),
|
||
"from_name": m.From,
|
||
"subject": m.Subject,
|
||
"mail_type": mailType,
|
||
"role": role, // to / cc
|
||
// to_workspace 是收件方地址的 path 位,即希望它在哪个工作目录干活。
|
||
// 不带这一项的后果:插件只能自己拼一个临时目录,于是每封邮件都落在
|
||
// 不同的空目录里,DSH / opencode 按 cwd 分组时全进「未分组」。
|
||
"to_workspace": workspace,
|
||
// session_alias 是这条会话今后的寻址名。没有它的话,收到 `.new`
|
||
// 邮件的一方只持有一个 send_mail 不接受的 session_id。
|
||
"session_alias": alias,
|
||
// reply_address 是「把回信发回这条会话」的现成地址。
|
||
// 插件不必自己拼(拼错了就是静默开新会话)。
|
||
"reply_address": models.FormatAddress(replyTo, "", alias),
|
||
// self_address 是对方应当用来称呼自己的地址,供转发/报告时引用。
|
||
"self_address": models.FormatAddress(forName, workspace, alias),
|
||
// platform_session_id 非空时,这封邮件要投进**平台侧已经存在的
|
||
// 那条会话**(TUI 与邮箱是同一个 Agent 的两个入口)。
|
||
//
|
||
// 插件必须 resume 而不是新建:新建会让人在 TUI 里看不到这封邮件
|
||
// 带来的对话,而那正是接管这条会话的目的。
|
||
//
|
||
// **只发给归属方**:其余参与方拿到它只会去自己磁盘上找一个
|
||
// 不存在的会话文件,然后按 N-8 报错丢掉这封邮件。
|
||
"platform_session_id": platformFor(forName),
|
||
// in_reply_to 非空 = 这封是对收件方某封信的**回复**,不是新派的活。
|
||
//
|
||
// 插件据此换一套提示词:把它当新任务会让模型又“处理”一遍并再回一封,
|
||
// 于是两个 Agent 互相客套直到撞上 hop 上限(生产实测 6 轮)。
|
||
"in_reply_to": m.ParentMailID,
|
||
// from_human 区分「人在找你」与「另一个 Agent 在找你」。
|
||
//
|
||
// 插件据此不再对 Agent → Agent 的信说「回信不用你自己发」:
|
||
// 那句话在那种情形下是假的,而它让模型以为自己只需要「把话说完」。
|
||
"from_human": fromHuman,
|
||
}
|
||
if m.Origin != "" {
|
||
p["origin"] = m.Origin
|
||
}
|
||
return p
|
||
}
|
||
|
||
update := map[string]interface{}{
|
||
"session_id": m.SessionID.String(),
|
||
"status": "active",
|
||
}
|
||
|
||
// 参与方去重:收件人 + 所有抄送 + 发件方自己(刷新他的发件箱)
|
||
seen := map[string]bool{}
|
||
|
||
sse.Default.SendToRecipient(m.To.Name, "new_mail", payload("to", m.To.Path, m.To.Name))
|
||
sse.Default.SendToRecipient(m.To.Name, "session_update", update)
|
||
seen[m.To.Name] = true
|
||
|
||
for _, c := range m.CC {
|
||
if seen[c.Name] {
|
||
continue
|
||
}
|
||
seen[c.Name] = true
|
||
sse.Default.SendToRecipient(c.Name, "new_mail", payload("cc", c.Path, c.Name))
|
||
sse.Default.SendToRecipient(c.Name, "session_update", update)
|
||
}
|
||
|
||
if !seen[m.From] {
|
||
sse.Default.SendToRecipient(m.From, "session_update", update)
|
||
}
|
||
}
|
||
|
||
// SessionActive 只刷新某一方的会话列表,不推 new_mail。
|
||
//
|
||
// 用于「日历事件的创建者该知道提醒发出去了」这类场景:他不是收件方,
|
||
// 不该收到一封信的通知,但需要看到那条会话活跃起来 —— 否则
|
||
// 「我设的提醒到底触发了没有」只能去翻 journalctl。
|
||
func SessionActive(name string, sessionID uuid.UUID) {
|
||
if name == "" {
|
||
return
|
||
}
|
||
sse.Default.SendToRecipient(name, "session_update", map[string]interface{}{
|
||
"session_id": sessionID.String(),
|
||
"status": "active",
|
||
})
|
||
}
|