用户报的「很严重的问题」:被拒绝的 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
49 lines
2.4 KiB
JavaScript
49 lines
2.4 KiB
JavaScript
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;
|
||
}
|