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 包全绿
This commit is contained in:
2026-09-04 23:52:52 +08:00
parent 375578cf9b
commit 784192d8c4
22 changed files with 1315 additions and 92 deletions

View File

@ -50,6 +50,14 @@ type Mail struct {
// 收得到信的账号 —— 回给它的信投不出去。此时应当填主收件方自己的名字,
// 让模型把结果回报到同一条线索上。
ReplyToName string
// ParentMailID 非空表示这封是**回信**(回的那封的 mail_id
//
// 插件靠它区分「有人派了新活」与「我上一封信的回复到了」—— 两者对模型而言
// 是完全不同的处境,而在此之前 payload 里没有任何信号能分开它们。
//
// 后果在生产上兜现过pi 转发给 dshdsh 回确认pi 把那封确认当成新任务
// 又回一封,两边互相客套 6 轮直到撞上 hop 上限。
ParentMailID string
}
// Recipients 把一封邮件推给主收件人、所有抄送方,并刷新发件方的会话列表。
@ -105,6 +113,14 @@ func Recipients(ctx context.Context, m Mail) {
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(),
@ -134,6 +150,16 @@ func Recipients(ctx context.Context, m Mail) {
// **只发给归属方**:其余参与方拿到它只会去自己磁盘上找一个
// 不存在的会话文件,然后按 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