Files
MailUI4Agents/plugins/opencode-mail-bridge/test/relay-policy.test.mjs
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

132 lines
5.3 KiB
JavaScript
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.

/**
* 自动转发适用范围的判定lib/relay-policy.js
*
* 三个函数是同一件事的三个出口,必须一起看:
* - autoRelayDecision 插件该不该替模型把结论发出去
* - replyInstruction 提示词里怎么跟模型说这件事
* - inboundHeadline 进来的这封是新活、是回复、还是补投
*
* 分开写必然分叉,而分叉的代价是模型被骗:以为插件会替它回信,于是把话说完
* 就停手,那封信却永远不会发出去。所以这里逐条钉住它们的一致性。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
addrName,
autoRelayDecision,
replyInstruction,
inboundHeadline,
} from '../lib/relay-policy.js';
// ─── addrName ───
test('addrName 取三维地址的名字段', () => {
assert.equal(addrName('pi@/home/program/agentmail.某别名'), 'pi');
assert.equal(addrName('jianf'), 'jianf');
assert.equal(addrName(' dsh@/x '), 'dsh');
assert.equal(addrName(''), '');
assert.equal(addrName(undefined), '');
});
// ─── autoRelayDecision ───
test('人类来信 → 自动转发', () => {
const d = autoRelayDecision({ fromHuman: true, replyTo: 'jianf' });
assert.equal(d.relay, true);
});
test('Agent 来信 → 不自动转发', () => {
const d = autoRelayDecision({ fromHuman: false, replyTo: 'dsh' });
assert.equal(d.relay, false,
'Agent 间通信必须由模型主动 send_mail —— 两边都自动回会无休止互相唤醒');
assert.match(d.reason, /dsh/, '日志要说清是谁');
assert.match(d.reason, /Agent/);
});
test('不知道回给谁 → 不转发,且理由与「对方是 Agent」区分得开', () => {
const d = autoRelayDecision({ fromHuman: true, replyTo: '' });
assert.equal(d.relay, false);
assert.match(d.reason, /不知道回给谁/,
'「本轮没有回信」有三种原因,日志里必须能分辨');
});
test('replyTo 带三维地址时也能认出 Agent 名', () => {
const d = autoRelayDecision({ fromHuman: false, replyTo: 'opencode@/home/x.别名' });
assert.equal(d.relay, false);
assert.match(d.reason, /opencode/);
});
test('缺省参数不抛错(畸形事件不该弄死投递)', () => {
assert.equal(autoRelayDecision().relay, false);
assert.equal(autoRelayDecision({}).relay, false);
});
// ─── replyInstruction 与 autoRelayDecision 的一致性 ───
test('人类来信的提示词承诺「插件会替你发」,且这与决策一致', () => {
const lines = replyInstruction({ fromHuman: true });
const text = lines.join('\n');
assert.match(text, /回信不用你自己发/);
assert.equal(autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }).relay, true,
'承诺了就必须真的做');
});
test('Agent 来信的提示词必须明说「插件不会替你回信」', () => {
const text = replyInstruction({ fromHuman: false }).join('\n');
assert.match(text, /不会替你回信/);
assert.match(text, /send_mail/, '必须给出唯一可行的做法');
assert.doesNotMatch(text, /回信不用你自己发/,
'这句话在 Agent → Agent 时是假的 —— 说了它模型就会把话说完然后停手');
});
test('Agent 来信的提示词要劝阻纯客套', () => {
const text = replyInstruction({ fromHuman: false }).join('\n');
assert.match(text, /收到|确认/, '要点名那种没有信息量的回复');
assert.match(text, /互相客套|无休止/, '要说清后果,否则模型不知道为什么被劝阻');
});
test('Agent 来信时把回信地址带进提示词(有就带)', () => {
const withAddr = replyInstruction({ fromHuman: false, replyAddress: 'dsh@/x.别名' }).join('\n');
assert.match(withAddr, /dsh@\/x\.别名/,
'要它自己发信却不给地址,它会拼一个 .new 出来 —— 那会静默开新会话');
const without = replyInstruction({ fromHuman: false }).join('\n');
assert.doesNotMatch(without, /(回信地址:)/, '没有地址时不该留一个空括号');
});
// ─── inboundHeadline ───
test('回复到了 → 明说「这不是新任务」', () => {
const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: false });
assert.match(h, /回复/);
assert.match(h, /不是新任务/,
'把回复当新任务处理正是互相客套的起点');
});
test('回复的标题优先于续谈/补投标记', () => {
const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: true, reused: true, catchup: true });
assert.match(h, /回复/, 'in_reply_to 是最强信号');
});
test('Agent 来信在标题里就标出来', () => {
assert.match(inboundHeadline({ fromHuman: false }), /Agent/);
assert.doesNotMatch(inboundHeadline({ fromHuman: true }), /Agent/,
'人类来信不该带这个括号 —— 那是噪音');
});
test('补投要说明,否则模型按「刚到的」语气回', () => {
const h = inboundHeadline({ fromHuman: true, catchup: true });
assert.match(h, /积压|补投/);
});
test('续谈与新会话的措辞不同', () => {
assert.match(inboundHeadline({ fromHuman: true, reused: true }), /本会话/);
assert.match(inboundHeadline({ fromHuman: true, reused: false }), /你收到/);
});
test('缺省参数不抛错', () => {
assert.equal(typeof inboundHeadline(), 'string');
assert.equal(typeof inboundHeadline({}), 'string');
});