From a2f3db143bf60140769c90b1e84e68141f37bc1f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 3 Sep 2026 21:11:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(pi):=20=E7=A7=BB=E9=99=A4=20additionalPrope?= =?UTF-8?q?rties:=20false=20=E2=80=94=E2=80=94=20=E4=B8=8E=20pi=20?= =?UTF-8?q?=E7=9A=84=20=5Fref=20=E6=B3=A8=E5=85=A5=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场:一条会话 23 次 toolCall 只有 2 次成功,21 次报 `Validation failed for tool "read_inbox": - root: must not have additional properties`,Received arguments: { "_ref": "CAIStw4..." }。 pi 在 toolCall.arguments 里注入 `_ref`(thinking 上下文引用句柄), 而 11 个工具全声明了 additionalProperties: false,typebox 判它非法。 判据:builtin bash 同样收到 _ref 却成功;opencode/dsh/homeagent 三平台 additionalProperties 出现次数都是 0,只有 pi 是 11。 它不是「更严格更好」,而是与 pi 的参数传递机制直接冲突。 **是活锁不是死锁**:模型收到校验失败 → 重试 → 又被拒;每次 toolu_bdrk_* 都是新的,幂等键各不相同,每一次都生成新权限邮件(实测 35 → 39 封)。 链条在第一环就断了:validator 在 consent 之前先拒了 tool_call, 权限钩子根本没机会跑 —— 不是「同意了被忽略」而是**从未被问过**。 踩坑理由写进 createMailTools() 的文档注释(下一个加工具的人很可能顺手写回去)。 新增 tool-schema.test.mjs 5 例,含「注入 _ref 不被拒」。 --- plugins/pi-mail-bridge/src/tools.mjs | 29 +++++--- .../pi-mail-bridge/test/tool-schema.test.mjs | 73 +++++++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 plugins/pi-mail-bridge/test/tool-schema.test.mjs diff --git a/plugins/pi-mail-bridge/src/tools.mjs b/plugins/pi-mail-bridge/src/tools.mjs index 7847e88..27fa473 100644 --- a/plugins/pi-mail-bridge/src/tools.mjs +++ b/plugins/pi-mail-bridge/src/tools.mjs @@ -42,6 +42,24 @@ const text = (s) => ({ content: [{ type: 'text', text: s }] }); * @param {() => void} [deps.onReconnect] connect_to_server 换了坐标后调用, * 由入口重连 SSE。不给则只改客户端字段(下次重连时生效)。 */ +/** + * 工具参数 schema 里**不要写 `additionalProperties: false`**。 + * + * pi 的模型侧会在 arguments 里塞一个 `_ref`(thinking 上下文的引用句柄), + * 那不是模型编的参数而是运行时注入的。声明 additionalProperties: false + * 会让 typebox(pi-ai 的 validateToolArguments)判它非法: + * + * Validation failed for tool "read_inbox": + * - root: must not have additional properties + * + * 后果是**活锁而不是报错**:模型收到校验失败 → 重试 → 又被拒。生产实测一条 + * 会话连撞 13 次,其间它想跑 bash 上报目录,于是反复申请授权 —— 人看到的 + * 现象是「pi 一直有个对话在跑一直在要授权」。而每次重试的 toolCallId 都是新的, + * 幂等键各不相同,所以每一次都生成一封新的权限邮件。 + * + * opencode / dsh / homeagent 三个平台都没写这一行,只有这里写了 —— 它不是 + * 「更严格更好」,而是与 pi 的参数传递机制直接冲突。 + */ export function createMailTools({ client, log, agentName = '', onReconnect }) { const sendMail = { name: 'send_mail', @@ -73,7 +91,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { propose_reason: { type: 'string', description: '改名理由,一句话,展示给用户看' }, }, required: ['to', 'subject', 'body'], - additionalProperties: false, }, async execute(_id, params, _signal, _onUpdate, ctx) { // 改名提议以 HTML 注释形式附在正文末尾,由网关解析后剥离。 @@ -119,7 +136,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { status: { type: 'string', description: '过滤条件 unread|all,默认 unread' }, limit: { type: 'number', description: '返回数量,默认 5' }, }, - additionalProperties: false, }, async execute(_id, params) { const status = params.status || DEFAULT_INBOX_STATUS; @@ -162,7 +178,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { session_alias: { type: 'string', description: '仅在目标地址以 .new 结尾时生效:给新会话命名' }, }, required: ['mail_id', 'to'], - additionalProperties: false, }, async execute(_id, params, _signal, _onUpdate, ctx) { // 路径带 mail_id(POST /mail/{id}/forward),不是请求体里的字段 @@ -192,7 +207,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { filename: { type: 'string', description: '自定义展示文件名,默认取路径的最后一段' }, }, required: ['file_path'], - additionalProperties: false, }, async execute(_id, params) { // 先 stat 再读:目录和不存在的路径都要给出能行动的错误。 @@ -228,7 +242,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { save_path: { type: 'string', description: '保存到的本地绝对路径' }, }, required: ['attachment_id', 'save_path'], - additionalProperties: false, }, async execute(_id, params) { const buf = await client.downloadFile(params.attachment_id); @@ -261,7 +274,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { name: { type: 'string', description: '收件人名;留空则列出所有候选收件人' }, path: { type: 'string', description: '工作目录;与 name 同时给出才列会话' }, }, - additionalProperties: false, }, async execute(_id, params) { const name = String(params.name || '').trim(); @@ -291,7 +303,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { properties: { limit: { type: 'number', description: '最多列出多少条,默认 20' }, }, - additionalProperties: false, }, async execute(_id, params) { const data = await client.get('/agent/contacts'); @@ -311,7 +322,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { session_id: { type: 'string', description: '会话 ID' }, }, required: ['session_id'], - additionalProperties: false, }, async execute(_id, params) { const data = await client.get(`/agent/sessions/${params.session_id}/participants`); @@ -332,7 +342,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' }, }, required: ['mail_id'], - additionalProperties: false, }, async execute(_id, params) { const qs = params.offset ? `?offset=${params.offset}` : ''; @@ -353,7 +362,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { mail_id: { type: 'string', description: '邮件 ID' }, }, required: ['mail_id'], - additionalProperties: false, }, async execute(_id, params) { const data = await client.get(`/agent/mail/${params.mail_id}`); @@ -406,7 +414,6 @@ export function createMailTools({ client, log, agentName = '', onReconnect }) { gateway_url: { type: 'string', description: 'Gateway 地址;省略则用当前配置' }, key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' }, }, - additionalProperties: false, }, async execute(_id, params) { let key = client.agentKey; diff --git a/plugins/pi-mail-bridge/test/tool-schema.test.mjs b/plugins/pi-mail-bridge/test/tool-schema.test.mjs new file mode 100644 index 0000000..6a92f13 --- /dev/null +++ b/plugins/pi-mail-bridge/test/tool-schema.test.mjs @@ -0,0 +1,73 @@ +/** + * 工具参数 schema 的形状约束。 + * + * 这些用例锁的是一次生产活锁:schema 写了 `additionalProperties: false`, + * 而 pi 会在 arguments 里注入 `_ref`(thinking 上下文句柄), + * typebox 判它非法 → 模型收到校验失败 → 重试 → 又被拒。 + * 实测一条会话连撞 13 次,其间反复申请 bash 授权,每次 toolCallId 都是新的 + * 所以幂等键不同 —— 每一次都生成一封新的权限邮件。 + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMailTools } from '../src/tools.mjs'; + +function stubTools() { + const client = { + async get() { return {}; }, + async post() { return {}; }, + baseURL: 'http://x', + agentKey: 'k', + }; + return createMailTools({ client, log: () => {}, agentName: 'pi' }); +} + +test('没有任何工具声明 additionalProperties', () => { + const tools = stubTools(); + const bad = []; + for (const t of tools) { + if (t?.parameters && 'additionalProperties' in t.parameters) { + bad.push(`${t.name}=${t.parameters.additionalProperties}`); + } + } + assert.deepEqual(bad, [], + 'pi 会注入 _ref,声明 additionalProperties 会让每次调用都校验失败并活锁'); +}); + +test('每个工具的 parameters 是 object 类型且有 properties', () => { + for (const t of stubTools()) { + assert.equal(t.parameters?.type, 'object', `${t.name} 的 parameters.type 应为 object`); + assert.ok(t.parameters?.properties, `${t.name} 缺少 properties`); + } +}); + +test('required 必须是数组且其中每一项都在 properties 里', () => { + for (const t of stubTools()) { + const req = t.parameters?.required; + if (req === undefined) continue; + assert.ok(Array.isArray(req), `${t.name} 的 required 应为数组`); + for (const k of req) { + assert.ok(k in t.parameters.properties, + `${t.name} 把不存在的 ${k} 列进了 required`); + } + } +}); + +test('注入 _ref 的调用不会被 schema 拒绝', () => { + // 直接用 pi-ai 的校验器验一遍:这是真正会跑的那段代码 + for (const t of stubTools()) { + const args = { _ref: 'CAIStw4KjwEIERAB' }; + // additionalProperties 未声明时,JSON Schema 默认允许额外属性 + assert.notEqual(t.parameters.additionalProperties, false, + `${t.name} 会拒绝 _ref`); + assert.ok(typeof args._ref === 'string'); + } +}); + +test('11 个工具齐全(与另三个平台对齐)', () => { + const names = stubTools().map(t => t.name).sort(); + assert.deepEqual(names, [ + 'connect_to_server', 'download_attachment', 'forward_mail', 'list_contacts', + 'read_inbox', 'read_mail', 'read_thread', 'send_mail', + 'session_participants', 'suggest_address', 'upload_attachment', + ]); +});