DSH plugin 工具参数被拒?Invalid schema、oneOf 丢类型与必填误判排查
如果你给 DeepSeek Harness 写了一个工具插件,装完却让每一轮对话都报 Invalid schema for function 'xxx',那基本可以先怀疑 parameters 的根形状——它必须是 { type: "object", properties: {...} },而不是裸属性表。 同一句「工具参数被拒」其实包含三种互不相同的拒法:注册期 parameters 根形状不合法(模型 API 直接拒掉每一个请求)、type: "json" 在 schema 投影里丢掉线格式导致 oneOf 匹配零分支、以及 Codex 会把声明但非必填的顶层属性 materialize 成值而被误判为「可选被当必填」。三者定位与修法完全不同,混为一谈只会浪费时间。
DSH plugin 的三种拒法:Invalid schema for function、oneOf matched 0、可选被当必填
先按报错原文分流,三句话对应三条完全不同的链路。 分别看:
- 注册期根形状不合法——
Invalid schema for function 'vision_query':插件dsh-image-vision注册vision_query工具时,parameters传的是裸属性表({ images: {...}, prompt: {...} }),没有 JSON Schema 要求的 object 根。模型函数调用 API 要求参数 schema 根必须是type: "object",于是 DeepSeek API 拒掉每一个请求,报schema must be a JSON Schema of 'type: "object"', got 'type: null'。表现不是「这个工具不能用」,而是整个会话每一轮都失败——用户当时的描述就是「把 dsh 搞崩了」「整个不能工作了」(#297)。同类还有Invalid schema for function 'cmd'(#447),同一根因。 - 对象参数被字符串化——
must match exactly one oneOf branch (matched 0):cordis_define的plugin参数是判别式oneOf(两支均为 object:kind:"new"+idPrefix/kind:"existing"+pluginId)。从 Web GUI 调用时,{"kind":"new","idPrefix":"hello"}这种完全合法的载荷却稳定报matched 0;cordis_inspect_query的input(也是对象类型)同样失配。用户环境是@deepseek-ai/[email protected](npm)+ Node v26.4.0 + Windows + Chrome(#1122)。 - 可选属性被实体化——
write/bash被 escalation 校验卡住:GPT-5.6-sol 走openai-codex-responses时,每一次write/bash调用都必须提交sandbox_permissions与justification才能通过校验,否则直接失败。观察到的实际载荷是{"sandbox_permissions": "workspace-write", "justification": ""},而用户根本没要求提权(#1149)。 - 三种拒法的判别速查:报错里出现
Invalid schema for function→ 看插件parameters根形状;出现oneOf ... matched 0/must match exactly one→ 看该字段是否被投影成了无类型字段或被字符串化;出现「某个可选字段缺了就失败」→ 看该模型路径是否 materialize 声明属性,以及宿主是否把它当成控制型字段。把这三类混在一起猜,是最常见的排查弯路。
DeepSeek Harness 机制:parameters 根形状未校验、type:"json" 投影丢类型、Codex materialize 声明属性
三条机制彼此独立,唯一共同点只是「工具参数在到达校验器之前已经变形」。 逐层拆解:
- 宿主侧不校验
parameters(第一类的根因):ctx.tools.register()只校验了output.schema,对parameters从不校验、原样透传给模型 API。后果是任何一个第三方插件注册了不合法的工具 schema,都会让整个会话的每一轮请求全部 400,而且报错既不点名出错的工具、也不说明原因——表面现象就是「装了个插件,整个跑不动了」(#297)。 type: "json"在投影里变成纯注解(第二类的一半):dsh-tools的parameterSchemaSpecToJsonSchema把type: "json"当作 annotation-only,发给模型 API 的字面内容就是"input": { "description": "Optional query input; ..." }——处处没有type。没有类型信息,API 层就把嵌套对象以 JSON 字符串发出;本地复现验证器也确认:JSON.parse后的结果能通过isPlainJsonRecord,原始字符串不行(#1122)。- 下游没有任何补偿(第二类的另一半):
dsh-cordis-host-runner的validateInput会拿args.input逐个方法比对inputSchema(全都要求type: "object"),而从传输层到校验层之间没有任何环节会重新 parse 一个被字符串化的值。两半合起来,就是「精确查询全挂、目录模式正常」——因为在已装包的dsh-tool-*里扫一遍,这个input是唯一一个输入侧type: "json"参数(#1122)。 oneOf校验器本身是对的(第二类的关键反证):cordis_define.plugin声明的是两个 object 分支的oneOf,投影完整保留了它,线格式上带着type: "object"(直接调用导出的投影函数可以核验),并不像type: "json"那样丢类型。所以matched 0不是oneOf语义问题,而是 Web 路径把错误的运行时类型交给了校验器——matched 0只是下游症状。校验器统计命中分支的逻辑本身很简单:恰好 1 支 → 通过,0 支 →matched 0,2 支以上 → 重叠非法(#1122)。- Codex 会 materialize 已声明的顶层可选属性(第三类的根因):一组绕过宿主的直接探针把这点钉死了。向本机 CC Switch 的 Anthropic Messages 入口发一个 schema:
required只有required_value,另有optional_text(string)与optional_mode(string +enum: ["alpha","beta"]),prompt 明确要求「只填 required_value,不要编造可选值」。返回结果仍是{"required_value":"ok","optional_text":"","optional_mode":"alpha"}——optional string 被填成空串、optional enum 被填成第一个枚举值。这与宿主内观察到的{"sandbox_permissions":"workspace-write","justification":""}完全对应(#1149)。 - nullable 对照实验证明「未提供」是可表达的:把两个可选字段改成
oneOf: [{type:"string"},{type:"null"}](enum 同理加一支 null)后,同一条路由返回{"required_value":"ok","optional_text":null,"optional_mode":null}。也就是说,Codex 路径能表达「未提供」,只是需要用 nullable 表达;而 DeepSeek Harness 当时的 escalation 参数只接受 string,所以仍需一侧做归一化(#1149)。 - 归因修正:
strict: null不是必要条件:上游pi#8105把问题定位到openai-codex-responses的strict: null,但复现链路DSH plugin pi-ai anthropic-messages → CC Switch 3.19.2 → Codex Responses(apiFormat=openai_responses→ Codex OAuth →chatgpt.com/backend-api/codex/responses→ gpt-5.6-sol)没有经过 pi-ai 的 codex 适配器,却复现了同样的 optional materialization。更准确的表述是:问题在 Codex Responses 工具调用与 Anthropic optional-property 语义之间的兼容,strict: null可能是某条路径上的触发或放大因素,但不是该行为成立的必要条件(#1149)。 - 跨路径反证:同一模型同一条账号也能正常省略可选参数:在 stock
@deepseek-ai/[email protected]上走 pi2dsh 内置的 OpenAI-Codex 路由、对一个干净工作区执行真实write,从会话日志读回的提交参数恰好只有["file_path","content"],sandbox_permissions与justification都没有被 materialize;校验通过、文件落盘、回合completed收尾。这说明故障不在「模型不会省略可选参数」,而在那条特定线路如何呈现 schema(#1149)。
DeepSeek Harness 按 JSON Schema 修根形状与三种后续规避
修法分三档:插件侧把根形状写对、宿主侧在注册期就响亮失败、调用侧为已知缺陷选择合适的绕行。 具体如下:
- 插件侧:
parameters必须是 object 根(第一类最直接的修法):
ctx.tools.register({
name: 'vision_query',
parameters: {
type: 'object',
additionalProperties: false,
required: ['images'],
properties: {
images: { type: 'array', items: { type: 'string' } },
prompt: { type: 'string' },
},
},
})
同时要检查属性级的 required 位置——把 required: true 写在属性对象里是无效的 JSON Schema,required 只能是对象层级的字符串数组(image-vision 插件当时就踩了这个坑,修复版本升到 0.1.1)。不写 type: "object" 的裸属性表一定会让每一次请求都被拒。
2. 宿主侧:注册期校验根契约,响亮失败:register() 现在会在注册时校验 parameters 的根契约(必须是 object 根、type: "object"),违规立即抛 JsonSchemaError,在插件加载时就点名出问题,而不是污染之后所有请求。只校验根契约是有意为之:MCP 服务器经 SDK 的 zod 转换会输出 $schema / definitions 等关键字,属于强制子集之外的合法 JSON Schema 词汇,做完整子集校验会误伤它们。装上这一版后,坏插件会在加载阶段直接失败,而不是让整个会话崩掉。
3. 应急绕过:重置 profile 补丁文件:如果已经被坏插件卡住、连界面都进不去,把 profile 的 cordis.patch.yml 改回空数组再重启即可——重置之所以有效,是因为它让注册了坏 schema 的插件不再被加载:
# ~/.dsh/profiles/web/cordis.patch.yml 内容改为:
[]
这只是绕过表象,根因仍在插件 schema;确认是哪个插件后应升级到修好的版本,或用 DSH Plugin Hub 先卸载它。
4. type: "json" 参数:先加防御性再解析,长期改投影:第三类的一半(cordis_inspect_query.input)可以在 execute 里做防御性 JSON.parse——解析失败就自然落到原有校验错误,畸形字符串仍会被正常报出:
async execute(args, exec) {
- const data = await ctx.cordisInspect.query(args.platform, args.provider, args.method, args.input, requireAgent(exec), exec.signal);
+ let input = args.input;
+ if (typeof input === "string") {
+ try { input = JSON.parse(input); } catch { /* fall through */ }
+ }
+ const data = await ctx.cordisInspect.query(args.platform, args.provider, args.method, input, requireAgent(exec), exec.signal);
return {
更干净的长期方案是给 type: "json" 参数在投影阶段补上显式线格式类型,让 API 层从一开始就不必猜。已装包里这个 input 是唯一一个输入侧 type: "json" 参数,所以影响面可控。
5. oneOf 被字符串化:在 defineTool 层归一化(社区可用解):有人把修复放在 dsh-tools 的 defineTool 包装里,新增一个递归 _coerceArgs,把「以 { 或 [ 开头的字符串」递归解析回对象/数组,再交给 validate(在 dsh-tools/lib/index.js 与 lib/types/schema.js 两处替换 execute 包装):
async execute(args, exec) {
function _coerceArgs(val) {
if (typeof val === "string") {
if (val.charCodeAt(0) === 123 || val.charCodeAt(0) === 91) {
try { return JSON.parse(val); } catch { return val; }
}
return val;
}
if (Array.isArray(val)) return val.map(_coerceArgs);
if (val !== null && typeof val === "object") {
const out = {};
for (const k of Object.keys(val)) out[k] = _coerceArgs(val[k]);
return out;
}
return val;
}
const coerced = _coerceArgs(args);
const violations = validate(coerced);
if (violations.length > 0) throw new ToolArgsError(violations);
return userExecute(coerced, exec);
}
_coerceArgs 会新建对象而非改动被冻结的 args。实机验证环境为 [email protected] + Windows + Chrome,cordis_define 恢复正常、其他工具不受影响。但要注意这个解法的副作用:一个合法字符串参数本来就可能故意包含 JSON 文本,无条件递归解析会在校验前悄悄改变它的类型;更稳妥的做法是让归一化由 schema 引导——只在声明为 object/array/JSON 值或命中某个 oneOf 分支时才对字符串做解析,并补一条覆盖 cordis_define.plugin 与 cordis_inspect_query.input 两条路径的 Web 回归测试。
6. 可选参数被 materialize:换 Code Mode 或做 nullable 归一化:#1149 的可用绕过是把该会话的工具呈现方式从原生 function calling 切到 PTC / Code Mode——模型只看到外层 run_code,其 code / description 都是真正必填,嵌套的 write / bash 在内部按 canonical schema 派发,因此不会出现可选字段被补值的问题,文件创建与写入恢复可用。隔离证据也表明文件系统后端、沙箱策略、canonical 工具运行时与 write 执行器本身都是好的,故障只限于原生 function 工具的 schema/调用路径。
7. 可选参数的正解:允许 null 并在校验前归一化:最小兼容修复是让 sandbox_permissions 与 justification 接受显式 null,并在进入 escalation 校验前把 null 归一化为「未提供」;涉及 packages/shell/tool-bash、packages/shell/tool-pwsh、packages/fs/tool-fs 与 packages/fs/tool-fs/src/sandbox。也可以把「已知目标等于或窄于当前有效模式」的成对字段视为冗余 metadata 走 no-op(例如 danger-full-access + 请求 workspace-write),但真正变宽的请求必须保持现有流程(read-only → workspace-write、read-only → danger-full-access、workspace-write → danger-full-access),不得削弱 strictly-wider 校验、非空 justification、approval 与 never 下的 fail-closed 行为——这套语义在本站 sandbox 提权校验排查 里有更细的展开。
8. 更稳的长期方向是「denial-bound」:不要仅凭模型提供了 sandbox_permissions 就认定这是一次合法提权重试——模型输出是请求,不是授权。可以考虑:普通调用先发生真实 sandbox denial → denial 返回一次性 escalation_token → 只有携带该 token 的精确重试才能请求更宽模式 → 没有有效 token 的冗余 escalation 参数不进审批路径 → 或者干脆把普通工具与 escalation retry 拆成两个独立工具。eligibility 应绑定到会话、工具、归一化后的命令/操作、workdir 或目标路径与当前有效模式,并且短时效、一次性使用,避免并行或无关调用继承它。想自己写这类工具插件,可先看 DSH plugin 开发入门,把 parameters 根形状与 required 分层写对是从一开始就该守住的约定。
DSH plugin 排查注意事项
报错分流优先于猜测——三句报错原文对应三条完全不同的链路,混在一起猜是最常见的排查弯路。 七条要点:
- 报错分流优先于猜测:
Invalid schema for function看parameters根形状;oneOf matched 0看字段是否被字符串化;「可选字段缺了就失败」看模型路径是否 materialize 声明属性。 - 裸属性表必炸:无论自研的 DSH插件 还是第三方 DeepSeek插件,
parameters不写type: "object"都会让每一轮请求被拒,表现为「整个不能工作了」,而不是单个工具不可用。 required只在对象层级:属性内部的required: true是无效 JSON Schema,必须写成对象级的字符串数组。- 只校验根契约是有意的:完整子集校验会误伤 MCP 服务器经 zod 转换产生的合法关键字。
- 谨慎做递归解析:无条件的「看起来像 JSON 就 parse」会改变合法字符串参数的类型,应让归一化由 schema 引导。
- 降级方案不等于根修:PTC / Code Mode 与重置
cordis.patch.yml都只是绕过,escalation 相关语义不应为绕过而放松。 - 同现象未必同根因:
cordis_define.plugin与cordis_inspect_query.input是两条独立链路,未经核验不要合并归因。

来源:Discussion #297、Discussion #1122、Discussion #1149、earendil-works/pi#8105。
常见问题
DeepSeek Harness 的函数调用 API 要求工具参数 schema 的根必须是 type: "object",所以坏 schema 会在每一次请求上被拒,而不是在加载插件时被拦下。如果插件的 parameters 传的是裸属性表,根上就没有 type,API 会拒掉**每一个**请求并回 Invalid schema for function 'xxx': schema must be a JSON Schema of 'type: "object"', got 'type: null'。早期宿主在 ctx.tools.register() 里只校验 output.schema、不校验 parameters,坏 schema 被原样透传下去,表现为整个会话每一轮都 400(来源:Discussion #297)。
不一定,这类 matched 0 在 DSH plugin 里多半是对象参数被字符串化造成的,而不是参数本身写错。以 cordis_define 为例,它的 plugin 参数确实是两个对象分支的 oneOf(kind:"new" + idPrefix / kind:"existing" + pluginId),{"kind":"new","idPrefix":"hello"} 本该命中第一支。真正的原因是 Web 路径把嵌套对象当成了**字符串**交下来,两个对象分支自然都不匹配,matched 0 只是下游症状(来源:Discussion #1122)。
两者不是一回事,但在 DeepSeek Harness 里同属参数 schema 在投影或校验阶段变形。cordis_inspect_query.input 声明为 type: "json",而 schema 投影把 type: "json" 当成纯注解,发给模型 API 的字面内容只有 "input": { "description": "Optional query input; ..." }——**完全没有 type**。模型无从判断形状,API 层就把嵌套对象以 JSON 字符串发出;下游 dsh-cordis-host-runner 的 validateInput 又按各方法 inputSchema(都要求 type:"object")校验,中间没有任何环节把它 JSON.parse 回来。已装包里这个 input 是唯一一个输入侧 type:"json" 参数,这正好解释了「精确查询全挂、目录模式正常」(来源:Discussion #1122)。
准确说不是 DeepSeek Harness 把 optional 标成 required,而是 Codex 路径会主动 materialize 已声明的可选属性。canonical tool schema 只把真正必填的字段放进 required,pi-ai / Anthropic Messages 路径也完整保留该数组。真正发生的是 GPT/Codex 会为 schema 中**已声明但不在 required 里**的顶层属性主动 materialize 类型合法的默认值——optional string 变成 ""、optional enum 变成第一个枚举值——而 DeepSeek Harness 又把 escalation 字段的「存在」解释为一次真实的提权请求,于是普通 write / bash 调用被 escalation 校验卡住。换成 PTC / Code Mode 后文件写入立即可用,因为模型只看到外层 run_code(真正必填的只有 code / description),嵌套调用在内部按 canonical schema 派发(来源:Discussion #1149)。
相关术语
- parameters root shape(parameters 根形状)
- parameters 根形状是工具参数 schema 的顶层契约。函数调用 API 要求根必须是 `type: "object"`,插件若传裸属性表就会让每一轮请求都被拒。只校验根契约是有意为之:MCP 服务器经 SDK 的 zod 转换会带 `$schema` 等关键字,完整子集校验会误伤它们。— https://github.com/deepseek-ai/deepseek-harness/discussions/297
- schema 投影(schema projection)
- schema projection 是把工具内部的参数规格转换成发给模型 API 的 JSON Schema 的过程。`type: "json"` 目前在此步被当成纯注解,线格式上不带 `type`,因此模型无法判断形状、嵌套对象容易以字符串形态往返。— https://github.com/deepseek-ai/deepseek-harness/discussions/1122
- materialize(属性实体化)
- materialize 是 Codex 路径在工具调用时为 schema 中已声明却不在 `required` 里的顶层可选属性自动生成类型合法默认值的行为(string→""、enum→首个枚举值),也是「可选被当必填」现象的直接触发因素。— https://github.com/deepseek-ai/deepseek-harness/discussions/1149
来源
- deepseek-harness Discussion #297:安装自研图片识别插件把 DSH 搞崩(Invalid schema for function 'vision_query')· deepseek-ai(GitHub Discussions)
- deepseek-harness Discussion #1122:cordis_define 的 plugin 对象参数总是 oneOf 校验失败(matched 0)· deepseek-ai(GitHub Discussions)
- deepseek-harness Discussion #1149:GPT-5.6-sol 把可选工具参数当成必填(write 失败)· deepseek-ai(GitHub Discussions)
- 上游依赖追踪:earendil-works/pi issue #8105· GitHub(earendil-works)