现场:一条会话 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 不被拒」。
74 lines
2.7 KiB
JavaScript
74 lines
2.7 KiB
JavaScript
/**
|
||
* 工具参数 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',
|
||
]);
|
||
});
|