Files
MailUI4Agents/plugins/homeagent-mail-bridge/relay_policy_test.go
JianFeeeee 784192d8c4 Agent→Agent 不自动转发 + 提示词区分新活/回复/补投
## 设计规则: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 包全绿
2026-09-04 23:52:52 +08:00

123 lines
4.1 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 main
// relay_policy.go 的单元测试。
//
// 这三个函数是 Node 侧 lib/relay-policy.js 的 Go 对应物,**语义必须一致** ——
// 一个平台自动回信、另一个不回,同一条 Agent 链的行为就取决于对方是谁,
// 那是无法排查的。所以这里的判据与那边的 relay-policy.test.mjs 逐条对齐。
import (
"strings"
"testing"
)
func TestAutoRelay_HumanSenderRelays(t *testing.T) {
d := autoRelayDecision(true, "jianf")
if !d.relay {
t.Fatalf("人类来信必须自动转发got reason=%q", d.reason)
}
}
func TestAutoRelay_AgentSenderDoesNot(t *testing.T) {
d := autoRelayDecision(false, "dsh")
if d.relay {
t.Fatal("Agent 间通信必须由模型主动 send_mail —— 两边都自动回会无休止互相唤醒")
}
if !strings.Contains(d.reason, "dsh") {
t.Errorf("日志要说清是谁got %q", d.reason)
}
if !strings.Contains(d.reason, "Agent") {
t.Errorf("理由要点明这是 Agent 来信got %q", d.reason)
}
}
// 「本轮没有回信」有三种原因,日志里必须能分辨。
func TestAutoRelay_NoRecipientHasDistinctReason(t *testing.T) {
d := autoRelayDecision(true, "")
if d.relay {
t.Fatal("不知道回给谁时不该发")
}
if !strings.Contains(d.reason, "不知道回给谁") {
t.Errorf("理由必须与「对方是 Agent」区分得开got %q", d.reason)
}
}
func TestReplyInstruction_HumanPromisesAutoRelay(t *testing.T) {
s := replyInstruction(true, "")
if !strings.Contains(s, "回信不用你自己发") {
t.Errorf("人类来信要告诉模型插件会代劳got %q", s)
}
// 承诺了就必须真的做
if !autoRelayDecision(true, "jianf").relay {
t.Error("提示词与决策不一致 —— 承诺了自动转发却不转")
}
}
func TestReplyInstruction_AgentSaysPluginWontReply(t *testing.T) {
s := replyInstruction(false, "")
if strings.Contains(s, "回信不用你自己发") {
t.Error("这句话在 Agent → Agent 时是假的 —— 说了它模型就会把话说完然后停手")
}
if !strings.Contains(s, "不会替你回信") {
t.Errorf("必须明说插件不代劳got %q", s)
}
if !strings.Contains(s, "send_mail") {
t.Errorf("必须给出唯一可行的做法got %q", s)
}
}
func TestReplyInstruction_AgentDiscouragesEmptyCourtesy(t *testing.T) {
s := replyInstruction(false, "")
if !strings.Contains(s, "收到") || !strings.Contains(s, "确认") {
t.Errorf("要点名那种没有信息量的回复got %q", s)
}
if !strings.Contains(s, "互相客套") {
t.Errorf("要说清后果否则模型不知道为什么被劝阻got %q", s)
}
}
func TestReplyInstruction_CarriesReplyAddress(t *testing.T) {
with := replyInstruction(false, "dsh@/x.别名")
if !strings.Contains(with, "dsh@/x.别名") {
t.Error("要它自己发信却不给地址,它会拼一个 .new 出来 —— 那会静默开新会话")
}
without := replyInstruction(false, "")
if strings.Contains(without, "(回信地址:)") {
t.Error("没有地址时不该留一个空括号")
}
}
func TestInboundHeadline_ReplyIsNotNewWork(t *testing.T) {
h := inboundHeadline("m-1", false, false)
if !strings.Contains(h, "回复") {
t.Errorf("要说清这是回复got %q", h)
}
if !strings.Contains(h, "不是新任务") {
t.Errorf("把回复当新任务处理正是互相客套的起点got %q", h)
}
}
// in_reply_to 是最强信号:它同时出现时压过补投标记。
func TestInboundHeadline_ReplyBeatsCatchup(t *testing.T) {
h := inboundHeadline("m-1", true, true)
if !strings.Contains(h, "回复") {
t.Errorf("in_reply_to 优先got %q", h)
}
}
func TestInboundHeadline_MarksAgentSender(t *testing.T) {
if !strings.Contains(inboundHeadline("", false, false), "Agent") {
t.Error("Agent 来信要在标题里标出来")
}
if strings.Contains(inboundHeadline("", true, false), "Agent") {
t.Error("人类来信不该带这个括号 —— 那是噪音")
}
}
func TestInboundHeadline_CatchupExplained(t *testing.T) {
h := inboundHeadline("", true, true)
if !strings.Contains(h, "积压") {
t.Errorf("补投要说明否则模型按「刚到的」语气回got %q", h)
}
}