Files
MailUI4Agents/plugins/pi-mail-bridge/src/turn.mjs
JianFeeeee 453f451fbb fix(permission): 人类的备注必须到达模型 + 决策回执不再被当成新任务
用户报的「很严重的问题」:被拒绝的 agent 看不到授权备注,且看不到他发的回复邮件。
按数据查到了两个**真缺陷**,都在桥的权限回路上(不是猜测,三层证据)。

## 缺陷一:备注在桥内被连丢三处

网关其实一路都带着备注(`CreateDecisionMail(..., req.Note)` 把备注写进决策邮件正文,
SSE payload 里也有 `"note"`),但桥的三个环节只传 decision:
  index.mjs  `pool.routePermission(relayKey, String(data.decision))`
  pool.mjs   `child.send({type:'permission_decision', relayKey, decision})`
  worker.mjs `resolve(String(msg.decision))`
模型最终看到的只有 `用户拒绝了这次 bash 调用`(pi 会话转录逐字可查)。

现场:人类写「我说了让你拉取仓库到program下你听不懂吗」,模型不知道要改什么,
把同一条命令换个写法又问了 —— 会话里连问 **9 次**(22:16–22:26)。

## 缺陷二:决策回执照样被当"新任务"投递 + 等人的邮件被堵在后面

决策是**双通道**送达:SSE `permission_decision`(唤醒停放的 worker)+ 一封普通形状的
邮件("Re: 权限请求 - 拒绝")。以前两条都会起动作 ⇒ 同一件事被处理两次;而这条会话
的新邮件在 worker 停放期间只能排队。实测:人类 22:18:08 发出的更正
「不对,不是让你拉取到agentmail仓库,是让你拉取到program仓库!!」
直到 22:26:30(worker 回合结束)才被模型看到 —— **8 分钟**里它一直在错误的目录上打转。
转录里那封更正确实是模型自己 `read_mail` 读到的(不是没人给它)。

## 改动

- 网关:`CreateDecisionMail` 写 `mail_type='permission_decision'` —— 桥据此区分
  「控制面回执」与「新任务」。
- pi 桥(新增 `lib/denial-reason.js`、`lib/waiting-mails.js`):
  · 备注随决策一路透传到**模型看到的拒绝理由**(工具拦截与通知投递两条路都带);
  · 恢复停放的 worker 时,顺带把「等人期间新到、尚未标记已读」的邮件附进理由,
    模型当场就能改道(这正是那 8 分钟的洞);
  · 决策回执不再起新任务轮次(记进 deliveredMails);若决策事件尚未到达,
    退化为 B-4.3 的通知投递,且没有会话时不凭空新开。

## 判据

- `test/permission-note.test.mjs`:11 条(备注进理由、无备注不得凭空造说明、
  等人期间的邮件要点名 read_inbox、只挑本会话非权限类未交付的、上限、旧回包缺
  session_id 不能丢邮件、接线 8 处形状、判据自检)。
- **扰动验证**:把备注从 `pool.mjs` 的 send 里去掉 → 接线判据 2 条红;恢复 → 11 绿。
- 既有 pi 套件 420/420;server 10 包全绿(新增 1 条 Go 判据验决策邮件的类型与备注正文)。

## 现场证据(可复核)

- 桥日志:9 次 `权限 <key> 决策 同意/拒绝(决策人 jianf)已转交 worker`,全程不含备注;
  「worker 2135211 等待权限决策,让出并发额度(停放 1/5)」
- 会话转录:`{"toolName":"bash","content":[{"text":"用户拒绝了这次 bash 调用"}]}` ×6
2026-09-13 22:47:25 +08:00

250 lines
11 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';
import { clampRelayKey } from '../lib/relay-key.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') {
const lines = [
`你之前发起的权限请求已有结论:${data?.decision ?? '(未给出)'}` +
`(决策人:${data?.decided_by || '用户'})。`,
];
// 人类的备注必须带上:这条路(无挂起 worker 时把决策当通知投进会话)与工具
// 拦截那条是同一个信息,缺失后果一样 —— 模型不知道要改什么。
const note = typeof data?.note === 'string' ? data.note.trim() : '';
if (note) lines.push(`用户的说明:${note}`);
lines.push('请据此继续后续工作。');
return lines.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) {
// clampRelayKey 收尾:会话 id 与 leafId 平常都短,但不能假定——
// 同一个假定在权限询问那边已经坏过一次toolCallId 被拼了思考签名,
// 437 ~ 13601 字节)。超限时才改写,所以合规的键不受影响。
return clampRelayKey(`${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, '-')}--`;
}
/**
* 续谈失败的回报正文。
*
* ## 为什么不直接复用共用库的 `renderFailureReport`
*
* 那句文案说「**划定范围内的模型全部调用失败**」并建议「调整可用模型范围」——
* 那是「新会话逐个试过所有模型」那条路的事实。而续谈这条路是**故意不降级**的:
* 换模型就要换会话,那会丢掉整条上下文,而上下文正是发件人指定这条会话的原因。
* 拿那段文案回过去等于告诉人去调一个在这里无效的旋钮 —— 他会去改配置,
* 然后发现依然失败。真正要做的是看上游错误原文。
*
* ## 为什么必须回这封信
*
* 实测缺口2026-09-12模型侧 402 余额不足):新会话失败会回「处理失败」,
* 而续谈这条路只写日志就 throw —— 发件人**什么都收不到**。
* 邮件驱动的会话没有本地界面可以看,没有这封信就等于
* 「信发出去了,然后再无音讯」。复现条件很普通:往一条**已存在**的会话
* 再发一封信,而模型侧报错。
*
* @param {string} subject 原邮件主题
* @param {string} error 上游错误原文
* @returns {string} Markdown 正文
*/
export function renderResumeFailure(subject, error) {
return [
`本次未能处理「${subject || '(无主题)'}」:这条会话的**续谈**失败了。`,
'',
'上游错误原文:',
'',
'```',
String(error ?? '未知错误'),
'```',
'',
'说明:续谈**不会**换用其它模型 —— 续谈必须用原会话,换模型就等于换会话,',
'会丢掉整条上下文(而上下文正是你指定这条会话的原因)。',
'所以这里只有一个上游错误,不是「范围内的模型都试过了」。',
'',
'可以这样做:',
'- 先看上面的错误原文(常见的:余额不足、密钥失效、上游限流)',
'- 若确实要换模型,请**新建**一条会话(新会话会按范围逐个尝试)',
].join('\n');
}