Files
MailUI4Agents/plugins/pi-mail-bridge/src/turn.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

198 lines
8.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.

/**
* pi 侧的纯逻辑:提示词、轮次结论判定、消息文本提取、回信主题。
*
* 单独一个文件而不是塞进 index.mjs这几件事每一件都对应过一次真实的错误行为
* 而它们都不需要 pi SDK —— 因此可以直接用 node --test 钉住,不必起模型。
*
* 与 lib/ 的区别lib/ 下的文件三个平台**逐字节相同**deploy/check-shared-libs.sh
* 校验),这里的东西是 pi 专属的消息形状、stopReason 语义),不参与那个约束。
*/
import { replyInstruction, inboundHeadline } from '../lib/relay-policy.js';
/** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 叠加。 */
export function stripRe(subject) {
return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, '');
}
/** 自动转发时的回信主题。 */
export function replySubject(subject) {
const base = stripRe(subject).trim();
return base ? `Re: ${base}` : '本轮工作总结';
}
/**
* 取最后一条 assistant 消息里的纯文本。
*
* pi 的消息形状:`{ role, content: [{ type: 'text'|'thinking'|'toolCall', ... }] }`。
*
* **只取 `type === 'text'`**B-5.1thinking 块是思考过程,转进邮件对收件人
* 没有意义,而且经常包含「我先假设…」这类会被误读为结论的话。
*
* 从后往前找第一条**有文本**的 assistant 消息,而不是「最后一条 assistant 消息」:
* 一轮的收尾常常是纯工具调用消息content 里只有 toolCall
* 取到它会得到空字符串,于是 B-5.4 判成「无话可说」而漏掉真正的结论。
*
* @param {any[]} messages `session.messages` 或 `agent_end` 事件里的 messages
* @returns {string} 纯文本,找不到时为空串
*/
export function lastAssistantText(messages) {
const list = Array.isArray(messages) ? messages : [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (m?.role !== 'assistant') continue;
const blocks = Array.isArray(m.content) ? m.content : [];
const text = blocks
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim();
if (text) return text;
}
return '';
}
/**
* 判定这一轮到底跑起来了没有C-4 / D-3
*
* 「submit 返回了」不等于「模型跑了」—— 这是两次适配都踩过的坑(契约 9.2)。
* pi 侧有三条互不重叠的失败信号,必须全查:
*
* 1. `prompt()` 直接 reject。凭证缺失就是这条实测无 API key 的 provider
* 抛 `No API key found for amazon-bedrock.`,一个事件都不发。
* 2. 最后一条 assistant 消息 `stopReason === 'error'`,原因在 `errorMessage`。
* 模型请求发出去了但上游报错走这条。
* 3. 一条 assistant 消息都没有。既没抛也没报错却什么都没产出,
* 当成功处理会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件。
*
* `stopReason: 'aborted'` **算失败**但要区别对待:那是有人主动打断
* Esc / dispose不是模型故障因此不该触发换模型重试。
*
* @param {{error?: any, messages?: any[]}} input
* @returns {{ok: boolean, error: string, aborted: boolean}}
*/
export function classifyTurnOutcome({ error, messages } = {}) {
if (error) {
return { ok: false, error: describeError(error), aborted: false };
}
const list = Array.isArray(messages) ? messages : [];
let lastAssistant = null;
for (let i = list.length - 1; i >= 0; i--) {
if (list[i]?.role === 'assistant') { lastAssistant = list[i]; break; }
}
if (!lastAssistant) {
return { ok: false, error: '模型没有产出任何回复(一条 assistant 消息都没有)', aborted: false };
}
const stop = lastAssistant.stopReason;
if (stop === 'error') {
return {
ok: false,
error: describeError(lastAssistant.errorMessage) || '模型报错但未给出原因',
aborted: false,
};
}
if (stop === 'aborted') {
return { ok: false, error: '本轮被中断aborted', aborted: true };
}
// 'stop' 正常收尾;'length' 是被 max tokens 截断 —— 内容不完整但**是模型的产出**
// 判成失败会让一封「说了一半」的回信变成「换个模型重试」,那更糟。
// 'toolUse' 出现在这里说明轮次在等工具,正常流程下 agent_end 时不会是它。
return { ok: true, error: '', aborted: false };
}
/** 把各种形态的错误拼成一行可读文本。 */
export function describeError(err) {
if (!err) return '';
if (typeof err === 'string') return err.split('\n')[0].trim();
const parts = [err.code, err.message ?? String(err)].filter(Boolean);
return parts.join(': ').split('\n')[0].trim() || '未知错误';
}
/**
* 投递一封邮件时给模型的提示词。
*
* 三条硬要求B-3.4 / B-3.5
* - 写明「回信由插件自动发」。不说的话模型会自己调 send_mail
* 而插件在轮次结束时也会转发一次 —— 同一件事两封邮件(生产里真实发生过)。
* - 带上 mail_id让模型能自己定位这一封。
* - 让它先调 read_inbox事件里只有主题正文和附件清单都在收件箱里。
*
* @param {{agentName: string, data: any, kind: string, reused: boolean}} input
* @returns {string}
*/
export function buildMailPrompt({ agentName, data, kind, reused }) {
if (kind === 'permission') {
return [
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
`(决策人:${data?.decided_by || '用户'})。`,
`请据此继续后续工作。`,
].join('\n');
}
// 发件方是人还是 Agent以及这封是不是回信 —— 两个信号都来自服务端。
// 旧版一律说「你收到一封新邮件」+「回信不用你自己发」,于是 Agent 之间
// 两边都以为插件会代它开口,把对方的一句「已收到」当成待办再处理一遍。
//
// `from_human` 缺失时保守当作「不是人」:宁可让模型多调一次 send_mail
// 也不能对它承诺一个不会发生的自动回信 —— 后者让发件方白等。
const fromHuman = data?.from_human === true;
const lines = [
inboundHeadline({
inReplyTo: data?.in_reply_to,
fromHuman,
catchup: data?.catchup,
reused,
}),
'',
`发件人:${data?.from_name || 'unknown'}`,
`主题:${data?.subject || '(无主题)'}`,
`邮件 ID${data?.mail_id || 'unknown'}`,
];
if (data?.in_reply_to) {
lines.push(`回的是你那封:${data.in_reply_to}`);
}
if (!reused) lines.push(`身份:你是 ${agentName}`);
// 服务端算好的回信地址(`new_mail` 的 reply_address。带上它是因为模型
// **确实会**自己发信 —— 尤其是要抄送第三方、或分多封交代不同的事时。
// 让它自己拼三维地址的话,`.new` 会被拼进去,于是回信静默开出一条新会话,
// 原来的线索里再无下文。
if (data?.reply_address) {
lines.push(`回信地址:${data.reply_address}`);
}
lines.push(
'',
'请先调用 read_inbox 读取完整正文(附带附件清单,如有附件可用 download_attachment 取回),',
'然后处理其中的请求。',
...replyInstruction({ fromHuman, replyAddress: data?.reply_address }),
);
return lines.join('\n');
}
/**
* 自动转发的幂等键W-6 / B-5.2)。
*
* 用 pi 侧的会话 id + 会话树叶子条目 id两者都由 pi 生成且落盘,
* 插件重启后重放同一轮也会得到同一个键。用「消息条数」之类的派生量不行 ——
* 压缩compaction会改变条数于是同一轮结论换了个键被当成新消息再转一次。
*
* @param {string} piSessionId
* @param {string} leafId
* @returns {string}
*/
export function relayKeyFor(piSessionId, leafId) {
return `${piSessionId || 'unknown'}:${leafId || 'noleaf'}`;
}
/**
* pi 会话文件名里的 cwd 编码(`/home/x` → `--home-x--`)。
*
* 只用于日志与排查提示,不参与任何决策 —— 真正的路径一律用 SDK 给的
* `session.sessionFile`。自己拼路径去读会话文件是错的:编码规则属于 pi。
*
* @param {string} cwd
* @returns {string}
*/
export function sessionDirLabel(cwd) {
return `--${String(cwd ?? '').replace(/\//g, '-')}--`;
}