Files
MailUI4Agents/plugins/pi-mail-bridge/lib/waiting-mails.js
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

49 lines
2.4 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.

import { renderMail } from './inbox-format.js';
/**
* 「等人点头期间新到的邮件」—— 权限恢复时要一并交给模型的那几封。
*
* # 为什么需要它
*
* 一个 worker 在等人类点「同意/拒绝」时是**停放**的(不占并发额度,见 pool.mjs
* 而这条会话的新邮件会被排队等在它后面。于是人类在等待期间发来的更正(实测:
* 22:18:08 发出的「不对不是让你拉取到agentmail仓库是让你拉取到program仓库
* 要等整个回合结束才被看到 —— 22:26:30整整 8 分钟,期间模型一直在错误的目录上打转。
*
* 而那一刻 worker 正好卡在等决策,本来就是"要重新给模型喂上下文"的时刻 ——
* 决策一到就把这些人发的新邮件一并附上,模型当场就能改道。
*
* # 为什么是纯函数
*
* 筛选规则容易写错(漏掉会话过滤就会把别人会话的信塞进来;忘了排除权限邮件就会
* 把决策回执自己当成"新更正"),而这些错误都不会抛异常、只会让提示读起来不对 ——
* 必须有判据钉住。
*/
/** 权限请求/决策是控制面邮件,不是"新到的信"。 */
const PERMISSION_TYPES = new Set(['permission_request', 'permission_decision']);
/**
* @param {object|Array} inbox `/mail/inbox` 的回包(或它的 mails 数组)
* @param {object} opts
* @param {string} [opts.sessionID] 只取这条会话里的(空则不筛)
* @param {Set<string>} [opts.seen] 已经交付过的 mail_id不重复给
* @param {number} [opts.limit] 最多几封(默认 3再多会把工具结果撑爆
* @param {number} [opts.bodyLimit] 每封正文截断长度
* @returns {string[]} 已渲染好的条目,可直接拼进拒绝理由
*/
export function selectWaitingMails(inbox, { sessionID = '', seen = new Set(), limit = 3, bodyLimit = 300 } = {}) {
const list = Array.isArray(inbox) ? inbox : (inbox?.mails ?? []);
const out = [];
for (const m of list) {
if (!m?.mail_id || seen.has(m.mail_id)) continue;
if (PERMISSION_TYPES.has(m.mail_type)) continue;
// 会话过滤只在两边都有值时生效:老回包没有 session_id 时不该把邮件全丢掉
// (宁可多给一封,也不能让人类的更正消失)。
if (sessionID && m.session_id && m.session_id !== sessionID) continue;
out.push(renderMail(m, bodyLimit, ''));
if (out.length >= limit) break;
}
return out;
}