用户报的「很严重的问题」:被拒绝的 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
41 lines
1.8 KiB
JavaScript
41 lines
1.8 KiB
JavaScript
/**
|
||
* 把「人类对这次权限请求的结论」渲染成模型能读懂的一段话。
|
||
*
|
||
* # 为什么单独一个模块
|
||
*
|
||
* 三条路径都要给模型同一句话:工具拦截(bash/write…)、无挂起 worker 时的通知投递、
|
||
* 以及主动询问(kind=question)。而 2026-09-13 的线上缺陷正是这句话里**没有人类的备注**:
|
||
*
|
||
* - 用户原话:「被拒绝的 agent 好像看不到我给授权的备注」
|
||
* - 现场证据(pi 会话转录):模型收到的工具结果只有
|
||
* `用户拒绝了这次 bash 调用`;而人类那封决策邮件里写着
|
||
* `拒绝\n\n备注: 我说了让你拉取仓库到program下你听不懂吗`
|
||
* - 后果:模型不知道要改什么,把同一条命令换个写法又问了一遍(实测连问 9 次)。
|
||
*
|
||
* 备注在 `index.mjs → pool.mjs → worker.mjs` 三处被逐个丢掉,所以判据必须落在
|
||
* **模型最终看到的那段文本**上(这个函数的返回值),而不是"某个字段有没有传"。
|
||
*/
|
||
|
||
/** 模型最终看到的那句"被拒了",附上人类说明与等人期间新到的邮件。 */
|
||
export function renderDecisionReason({ toolName, decision, note = '', waiting = [] }) {
|
||
const base = decision === 'shutdown'
|
||
? '用户未及决策(桥已关停)'
|
||
: `用户拒绝了这次 ${toolName} 调用`;
|
||
|
||
const parts = [base];
|
||
|
||
const text = String(note ?? '').trim();
|
||
if (text) parts.push(`用户的说明:${text}`);
|
||
|
||
const fresh = Array.isArray(waiting) ? waiting.filter(Boolean) : [];
|
||
if (fresh.length) {
|
||
parts.push([
|
||
`【等人审批期间新到的邮件 ${fresh.length} 封,尚未标记已读】`,
|
||
...fresh,
|
||
'请先用 read_inbox 读这些邮件,再决定下一步 —— 里面可能是对当前做法的更正。',
|
||
].join('\n'));
|
||
}
|
||
|
||
return parts.join('\n\n');
|
||
}
|