fix(dsh-mail-bridge): ESM 下 defineTool 静默降级导致工具 schema 非法
现象:dsh 经 llmsproxy AUTO 走到 gozen 时 400:
Invalid schema for function 'connect_to_server':
schema must be a JSON Schema of 'type: "object"', got 'type: null'.
根因:插件 package.json 是 "type": "module"(ESM),而源码用裸
require.resolve / require 载入 @deepseek-ai/dsh-tools。ESM 里 require
是 undefined,require.resolve 抛 ReferenceError,被 catch { return opts; }
**静默吞掉** —— defineTool 恒等返回,工具的 parameters 以**未编译的裸映射**
注册:
{ gateway_url: {type:'string'}, key_token: {type:'string'} } // ❌
而不是合法形态:
{ type:'object', properties:{ gateway_url:…, key_token:… } } // ✅
后果波及全部 11 个 mail-bridge 工具。严格的上游直接 400(实测 OpenCode Go),
宽松的(Claude 系)不校验 —— 所以只在特定 AUTO 档位暴露,表现为「某个模型
突然不能用了」。
修法:
1. 用 createRequire(import.meta.url) 取得合法的 require(ESM 标准做法)。
2. 兜底也必须产出**合法** schema —— 新增 parametersToJsonSchema,在
dsh-tools 不可用时自己编译:required:true 提升到根级 required 数组
(JSON Schema 不允许属性自带 required),type:object 必补。
绝不再静默把裸映射发出去 —— 那比直接报错更难查。
实测 11 个 mail-bridge 工具全部产出合法 schema,包括真机被拒的那个
connect_to_server。测试 test/tool-schema.test.mjs 覆盖:裸映射编译、
required 提升、数组 items、空 spec、被上游拒过的实际工具。
This commit is contained in:
@ -16,6 +16,7 @@ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
import {
|
import {
|
||||||
explicitSends,
|
explicitSends,
|
||||||
noteExplicitSend,
|
noteExplicitSend,
|
||||||
@ -282,14 +283,61 @@ function takeDenial(agentId: string, callId: unknown): string | undefined {
|
|||||||
// ─── 运行时导入 DSH 内部函数 ───
|
// ─── 运行时导入 DSH 内部函数 ───
|
||||||
|
|
||||||
let _defineTool: any;
|
let _defineTool: any;
|
||||||
|
// 插件是 ESM(package.json "type": "module"),ESM 里**没有** require。
|
||||||
|
//
|
||||||
|
// 这里原本写的是裸 `require.resolve(...)` + `require(...)`,于是 try 块必抛
|
||||||
|
// `ReferenceError: require is not defined`,被 `catch { return opts; }` 静默吞掉 ——
|
||||||
|
// defineTool 变成恒等函数,工具的 parameters 以**未编译的裸映射**注册:
|
||||||
|
//
|
||||||
|
// parameters: { gateway_url: {type:'string'}, key_token: {type:'string'} }
|
||||||
|
//
|
||||||
|
// 而合法形态必须是:
|
||||||
|
//
|
||||||
|
// parameters: { type:'object', properties:{ gateway_url:…, key_token:… } }
|
||||||
|
//
|
||||||
|
// 后果:11 个 mail-bridge 工具的 JSON Schema 全部非法。严格的上游直接 400
|
||||||
|
// (实测 OpenCode Go:`Invalid schema for function 'connect_to_server': schema
|
||||||
|
// must be a JSON Schema of 'type: "object"', got 'type: null'`),而宽松的
|
||||||
|
// 上游(Claude 系)不校验 —— 所以这个 bug 只在特定 AUTO 档位上暴露,
|
||||||
|
// 表现为“某个模型突然不能用了”。
|
||||||
|
const _require = createRequire(import.meta.url);
|
||||||
function defineTool(opts: any): any {
|
function defineTool(opts: any): any {
|
||||||
if (!_defineTool) {
|
if (!_defineTool) {
|
||||||
try {
|
try {
|
||||||
const dshToolsPath = require.resolve('@deepseek-ai/dsh-tools');
|
_defineTool = _require('@deepseek-ai/dsh-tools').defineTool;
|
||||||
_defineTool = require(dshToolsPath).defineTool;
|
} catch {
|
||||||
} catch { return opts; }
|
_defineTool = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return _defineTool(opts);
|
if (_defineTool) return _defineTool(opts);
|
||||||
|
// 兜底也必须产出**合法** schema:宁可自己拼,也绝不能把裸映射发出去
|
||||||
|
// (这正是上面那个 bug 的教训 —— 静默降级出一个非法产物,比直接报错更难查)。
|
||||||
|
return { ...opts, parameters: parametersToJsonSchema(opts.parameters) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 defineTool 的参数 spec 转成合法 JSON Schema。
|
||||||
|
*
|
||||||
|
* 只作 dsh-tools 不可用时的兜底,形态与 `parameterSchemaSpecToJsonSchema` 对齐:
|
||||||
|
* 属性上的 `required: true` 被提升到根级 `required` 数组(JSON Schema 不允许
|
||||||
|
* 属性自己带 `required`)。
|
||||||
|
*
|
||||||
|
* 注意:仅处理顶层属性,不做嵌套属性映射的递归 —— 那是兜底的兜底,真走到
|
||||||
|
* 这里说明依赖解析出了问题,应该先修依赖。
|
||||||
|
*/
|
||||||
|
export function parametersToJsonSchema(spec: any): any {
|
||||||
|
const properties: Record<string, any> = {};
|
||||||
|
const required: string[] = [];
|
||||||
|
for (const [key, value] of Object.entries(spec ?? {})) {
|
||||||
|
const { required: isRequired, ...rest } = (value ?? {}) as any;
|
||||||
|
properties[key] = rest;
|
||||||
|
if (isRequired) required.push(key);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
properties,
|
||||||
|
...(required.length > 0 ? { required } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Cordis 插件入口 ───
|
// ─── Cordis 插件入口 ───
|
||||||
|
|||||||
69
plugins/dsh-mail-bridge/test/tool-schema.test.mjs
Normal file
69
plugins/dsh-mail-bridge/test/tool-schema.test.mjs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// 回归:mail-bridge 的工具 schema 必须是合法 JSON Schema。
|
||||||
|
//
|
||||||
|
// 曾经这里用裸 `require(...)` 载入 @deepseek-ai/dsh-tools。插件是 ESM,
|
||||||
|
// ESM 里没有 require → 抛 ReferenceError → 被 `catch { return opts; }` 静默吞掉,
|
||||||
|
// 于是 defineTool 恒等返回,工具的 parameters 以**未编译的裸映射**注册:
|
||||||
|
//
|
||||||
|
// { gateway_url: {type:'string'}, key_token: {type:'string'} }
|
||||||
|
//
|
||||||
|
// 而不是:
|
||||||
|
//
|
||||||
|
// { type:'object', properties:{ gateway_url:…, key_token:… } }
|
||||||
|
//
|
||||||
|
// 严格的上游直接 400(实测 OpenCode Go):
|
||||||
|
// Invalid schema for function 'connect_to_server':
|
||||||
|
// schema must be a JSON Schema of 'type: "object"', got 'type: null'.
|
||||||
|
// 宽松的上游(Claude 系)不校验,所以只在特定 AUTO 档位暴露。
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parametersToJsonSchema } from '../dist/index.js';
|
||||||
|
|
||||||
|
test('裸参数映射被编译成 type:object + properties', () => {
|
||||||
|
const got = parametersToJsonSchema({
|
||||||
|
gateway_url: { type: 'string', description: 'Gateway 地址' },
|
||||||
|
key_token: { type: 'string', description: '密钥' },
|
||||||
|
});
|
||||||
|
assert.equal(got.type, 'object', '缺 type:object 会被严格上游 400');
|
||||||
|
assert.deepEqual(Object.keys(got.properties), ['gateway_url', 'key_token']);
|
||||||
|
// 属性必须原样保留
|
||||||
|
assert.equal(got.properties.gateway_url.type, 'string');
|
||||||
|
// 没有任何 required 时不应凭空产出空数组
|
||||||
|
assert.equal('required' in got, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('required:true 被提升到根级 required 数组,并从属性上移除', () => {
|
||||||
|
const got = parametersToJsonSchema({
|
||||||
|
to: { type: 'string', required: true, description: '收件人' },
|
||||||
|
cc: { type: 'string', description: '抄送' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(got.required, ['to']);
|
||||||
|
// JSON Schema 的属性里不允许出现 required
|
||||||
|
assert.equal('required' in got.properties.to, false);
|
||||||
|
assert.equal(got.properties.to.type, 'string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数组属性与其 items 原样保留', () => {
|
||||||
|
const got = parametersToJsonSchema({
|
||||||
|
attachment_ids: { type: 'array', items: { type: 'string' }, description: '附件' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(got.properties.attachment_ids.items, { type: 'string' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空 / 缺失 spec 产出合法的空对象 schema', () => {
|
||||||
|
for (const spec of [undefined, null, {}]) {
|
||||||
|
const got = parametersToJsonSchema(spec);
|
||||||
|
assert.equal(got.type, 'object');
|
||||||
|
assert.deepEqual(got.properties, {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('编译结果本身通过「必须有 type:object」的断言(上游的实际要求)', () => {
|
||||||
|
// 真机上被拒的那个工具
|
||||||
|
const connect = parametersToJsonSchema({
|
||||||
|
gateway_url: { type: 'string', description: 'Gateway 地址,如 https://mail.example.com;省略则用当前配置' },
|
||||||
|
key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用本地密钥(不存在时自动生成)' },
|
||||||
|
});
|
||||||
|
assert.equal(connect.type, 'object');
|
||||||
|
// 模拟上游校验:type 必须存在且为 object
|
||||||
|
assert.ok(connect.type === 'object', `got type: ${JSON.stringify(connect.type ?? null)}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user