fix(pi): 移除 additionalProperties: false —— 与 pi 的 _ref 注入冲突
现场:一条会话 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 不被拒」。
This commit is contained in:
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user