dsh-tool-args-unwrap
Fix malformed tool-call arguments (nested arguments wrappers, key aliases) at the dsh llm/stream seam. 修复畸形工具参数的 dsh 插件
- Stars
- 0
- Language
- JavaScript
- Created
- Sep 5, 2026
- Updated
- Sep 5, 2026
Introduction
dsh-tool-args-unwrap
修复模型产出的畸形工具参数,挂在官方
llm/streamwaterfall 上。 Fixes malformed tool-call arguments produced by models, hooked on the officialllm/streamwaterfall.
为什么需要它 · 修复家族 · 安全保证 · 验证数据 · 安装与使用 · 运维与排障 · 已知限制 — English section
为什么需要它
部分模型(尤其小参数/长会话/高频工具调用场景)会产出畸形工具参数,最典型的是多套了一层 {"arguments":{…}} 包装。宿主把这层包装原样持久化进 tool/call 与 assistant/message,下一轮整段回放给模型;模型于是看到"我上一轮就是这么调的"→ 越模仿越像 → 越陷越深(实测包装层数 1→8→13→33→34→38,递归加深)。这就是"agent 自称跳不出来、只有重开会话才好"的真因——重开有效是因为坏示范没了,不是模型状态变了。
这个插件在参数被持久化之前拦截并修正,一处同时修好三件事:执行、持久化、历史回放——坏示范根本不会被写进会话。
修复家族(全部来自真实语料,不是猜测)
以下每类都在 16,535 条真实 tool/call 记录里实测出现过,并逐条对着真实 tool/result 错误文本核对:
| 家族 | 形态 | 实测条数 | 报错 | 插件动作 |
|---|---|---|---|---|
| A | 嵌套包装 {"arguments":{…}}(1–38 层) | 547 | missing required property | unwrap:arguments(无条件解包) |
| B | 包装键别名 {"args":{…}} / {"parameters":{…}} | 2 | 同上 | unwrap:args 等 |
| C | 包装键 + 兄弟键混排 {"arguments":{…},"offset":5} | 1 | 同上 | merge:arguments(合并,冲突则放弃) |
| D | 参数键名别名 skill_name→name、path↔file_path、new_str→new_string、query→queries | ≈13 | 同上 | rename:x→y(需名字相关 + 违例减少) |
明确不修(语料证明它们不是缺陷,改了就是编造):把 JSON 当文本写进文件的 {"content":"{…}"}(75 条,报错全是"需先 read",与参数形状无关)、模型真漏必填(run_code 漏 description、{} 空参数)、兄弟键与内层重名的歧义合并。
安全保证(这是最重要的一节)
判定基准是违例总数严格减少,不是"必填键齐了没":
- 用宿主自己的校验器当裁判:插件优先加载
@deepseek-ai/dsh-tools的validateJsonSchemaValue(宿主校验工具参数的那个函数)——修完后的参数是否真的过校验,由宿主说了算。加载不到时退化为内置保守计数器(只数必填缺失/浅层类型不符/多余键,刻意松:宁可少修不误修)。 - 用本次请求真实下发的 schema(
options.tools)判定,绝不缓存、绝不猜测。 - schema 说某个键是参数就绝不动它:例如
dev_stage_add真有个parameters参数、str_replace_editor真有个path参数,它们不会被解包/改名(语料里实测确认)。 - 除 A 类外,改写必须让违例数严格减少,否则整个回退、原样放行。
- 键名改名额外要求名字相关(规范化后相等 / 前缀 / 后缀 / 单复数归一),且只在"改完真的少报错"时生效——所以
configuration绝不会被塞进description(语料里真实存在这个陷阱,已验证被拒绝)。 - A 类(单键
arguments)无条件解包的理由:arguments在 dsh 全量工具集里从未作为参数名出现(已在 node_modules 全量核验);它必须无条件,否则"无必填参数"的工具会静默吞掉包装——比报错更坏(模型以为调用成功,其实参数全丢)。 - 歧义即放弃:合并时兄弟键与内层重名 → 不改名不合并,让调用带着原本清晰的报错失败(模型会自己改),而不是替你猜哪个值是对的。
- 改不了就原样放行:没有候选能让违例数减少 → 返回
null,块原样透传。
验证数据
- 全量 dry-run(16,535 条真实记录,宿主校验器为裁判):541 条真实
missing required property报错中,520 条完全修复(校验器判定 0 违例);3 条"修了但仍缺"(模型用了语义错键,如 glob 要pattern却写了file_path——名字不相关,按纪律不猜);18 条未动(空{}/ 真漏必填 / 嵌套缺失,属"改了就是编造")。 - 零误修证明:把同一批参数放到声明了该键的旧 schema下,插件完全不动(schema 漂移不是线上危险——线上永远用当次请求自己的 schema)。
- 静默吞包装修复:45 处"曾成功"的调用被触碰,全部是无必填参数的工具被静默吞掉
arguments包装的潜在 bug(语义无损,只是让落盘的参数诚实)。 - 单元测试 16/16:覆盖 A/B/C/D 四类、无条件解包边界、声明键豁免、歧义拒绝、类型不符改名拒绝、JSON 文本不触碰。
- 端到端:经真实
BlockAssembler全链路(适配器 chunk → 插件改写 → 宿主组装 → 校验器),修复后违例清零。
安装与使用
方式一:开发装配(推荐,本插件即以此验证)
把插件目录 link 进 profile,加进 bundles 数组,重启即持久生效:
// <profile>/package.json
{
"dependencies": { "@dsh-external/dsh-tool-args-unwrap": "link:/abs/path/to/dsh-tool-args-unwrap" },
"dsh": { "profile": { "bundles": ["@dsh-external/dsh-tool-args-unwrap"] } }
}
# <plugin>/cordis.patch.yml(仓库已附)
- bundle:
insert:
id: dsh-tool-args-unwrap
# 入口自动指向 lib/index.js
方式二:手动
- 把仓库 clone 到任意目录;
- 在 profile 的
package.json加link:依赖 +bundles数组项(如上); - 重启 dsh。
确认生效:插件目录会写 state.json,看到 listenerRegistered: true 且 streamsSeen 随模型调用递增即在工作;hostValidator: true 表示已接入宿主校验器(最高保真);每次修正会打一条 warn 日志并记入 lastFix。
临时停用:设配置 enabled: false 即可完全停用流改写(不卸载插件)。
运维与排障
state.json:插件每次加载/修正时写。streamsSeen不涨 → 监听器没装上(检查 bundle 是否装配);hostValidator: false→ 用了内置兜底(功能仍正常,只是判定略松)。schemas-snapshot.json:每进程写一次,仅含工具名+参数名+类型+必填名,不含描述文本,用于离线核对。- 每次修正的
warn日志含:provider/model、工具名、动作列表、字符数、累计次数。
已知限制
- 只修"形状畸形",不修"语义缺失":模型真没写
description、真漏了必填 → 插件不猜(也猜不了)。 - 跨 provider 的实机复现未全部完成(网络受限环境);但缺陷形态与修复判定都与 provider 无关——schema 是宿主下发的,校验器是宿主的。
- 更根本的修复应在宿主侧:持久化前对 tool-call 参数做一次 schema 校验 + 规范化(本插件即是一个可插拔的实现参考)。
English
Fixes malformed tool-call arguments produced by models, hooked on the official
llm/streamwaterfall.
Why it's needed
Some models (especially small-parameter ones, long sessions, high-frequency tool-calling) emit malformed tool arguments — most typically an extra {"arguments":{…}} wrapper. The host persists that wrapper verbatim into tool/call and assistant/message, then replays the whole history back to the model next turn; the model sees "that's what I did last time" and imitates its own mistake, recursively deepening (observed 1→8→13→33→34→38 layers). This is the real reason agents claim they "can't escape" and only a session restart helps — restart works because the bad exemplars vanish, not because model state resets. This plugin intercepts and fixes arguments before they are persisted, repairing three things at once: execution, persistence, and history replay — the bad exemplar never gets written into the session.
The defect family (all from real corpus, not guesswork)
Every family below was actually observed in 16,535 real tool/call records and checked against the real tool/result error text one by one:
| Family | Shape | Observed | Error | Plugin action |
|---|---|---|---|---|
| A | Nested wrapper {"arguments":{…}} (1–38 layers) | 547 | missing required property | unwrap:arguments (unconditional) |
| B | Wrapper-key alias {"args":{…}} / {"parameters":{…}} | 2 | same | unwrap:args etc. |
| C | Wrapper + sibling keys {"arguments":{…},"offset":5} | 1 | same | merge:arguments (aborts on ambiguity) |
| D | Param-key alias skill_name→name, path↔file_path, new_str→new_string, query→queries | ≈13 | same | rename:x→y (needs name-relevance + fewer violations) |
Explicitly not fixed (the corpus proves these aren't defects; fixing them would be fabrication): {"content":"{…}"} cases that legitimately write JSON text into a file (75 of them, error was "must read first", unrelated to shape), genuine omissions (run_code missing description, empty {}), and ambiguous merges where a sibling key collides with an inner key.
Safety guarantees (the most important section)
The acceptance criterion is strictly fewer total violations, not "required keys present":
- The host's own validator is the judge: the plugin preferentially loads
validateJsonSchemaValuefrom@deepseek-ai/dsh-tools(the very function the host uses) — whether a fixed argument actually passes is decided by the host itself. If unavailable it falls back to a conservative built-in counter (only missing-required / shallow type mismatch / extra keys), deliberately lenient: better to under-fix than mis-fix. - Judged against the schema the current request actually sent (
options.tools), never cached, never guessed. - A key the schema declares as a parameter is never touched: e.g.
dev_stage_addreally has aparametersparam,str_replace_editorreally has apathparam — they are not unwrapped/renamed (verified against the live toolset). - Except class A, a rewrite must strictly reduce the violation count, otherwise it reverts and passes through unchanged.
- Key renames additionally require name-relevance (normalized equality / prefix / suffix / plural collapse) and only take effect when they genuinely reduce errors — so
configurationis never mapped ontodescription(this exact trap exists in the corpus and is verified rejected). - Why class A (single-key
arguments) is unconditional:argumentshas never been a parameter name anywhere in the dsh toolset (verified across node_modules); it must be unconditional, otherwise tools with no required params would silently swallow the wrapper — worse than an error (the model thinks the call succeeded, but every argument was dropped). - Ambiguity means abort: when a sibling key collides with an inner key during merge, the plugin neither renames nor merges — the call fails with its original clear error (the model fixes it itself) instead of the plugin guessing which value you meant.
- If nothing can reduce violations, pass through unchanged: the block streams through verbatim.
Verification data
- Full-corpus dry-run (16,535 real records, host validator as judge): of 541 real
missing required propertyerrors, 520 fully fixed (validator reports 0 violations); 3 "fixed but still failing" (model used a semantically wrong key, e.g. glob wantspatternbut gotfile_path— not name-related, not guessed per policy); 18 untouched (empty{}/ genuinely missing required / nested gaps — "fixing would be fabrication"). - Zero-mis-fix proof: the same arguments under an older schema that declared that key are left completely untouched (schema drift is not a live danger — live always uses the request's own schema).
- Silent-swallow fixes: 45 "previously successful" calls were touched, all potential bugs where a no-required-param tool silently swallowed an
argumentswrapper (semantically lossless — just makes the persisted arguments honest). - Unit tests 16/16: covering A/B/C/D, unconditional-unwrap boundary, declared-key exemption, ambiguity refusal, type-mismatch rename refusal, and JSON-text untouched.
- End-to-end: through the real
BlockAssemblerchain (adapter chunk → plugin rewrite → host assembly → validator), violations drop to zero after fixing.
Install & use
Recommended (how this plugin was verified): link the plugin dir into the profile and add it to the bundles array — persistent across restarts:
// <profile>/package.json
{
"dependencies": { "@dsh-external/dsh-tool-args-unwrap": "link:/abs/path/to/dsh-tool-args-unwrap" },
"dsh": { "profile": { "bundles": ["@dsh-external/dsh-tool-args-unwrap"] } }
}
# <plugin>/cordis.patch.yml (shipped with this repo)
- bundle:
insert:
id: dsh-tool-args-unwrap
Manual: clone the repo anywhere, add the link: dependency + bundles entry to the profile package.json (as above), restart dsh.
Confirm it's live: the plugin dir writes state.json; listenerRegistered: true with streamsSeen incrementing on each model call means it's working; hostValidator: true means the host validator is attached (highest fidelity); each fix emits one warn log and is recorded in lastFix.
Kill switch: set config enabled: false to fully disable stream rewriting without unloading.
Operations & troubleshooting
state.json is written on load/fix; if streamsSeen doesn't grow, the listener isn't installed (check bundle assembly); hostValidator: false means the built-in fallback is in use (still works, just slightly looser). schemas-snapshot.json is written once per process — tool names + param names + types + required only, no description text — for offline auditing. Each fix's warn log carries provider/model, tool name, action list, char counts, and a running total.
Known limits
Only shape-malformation is fixed, not semantic omission: if the model genuinely omitted description or a required key, the plugin doesn't guess (and can't). Cross-provider live reproduction isn't fully complete (network-restricted environment), but the defect shape and the fix judgement are provider-independent — the schema comes from the host, the validator is the host's. The deeper fix belongs upstream: validate + normalize tool-call arguments before persistence (this plugin is a pluggable reference implementation of exactly that).
Layout
lib/index.js the plugin, all logic
cordis.patch.yml bundle assembly declaration
schemas-snapshot.json live tool-schema snapshot (names/types/required only)
state.json live state (proves it's working)
package.json manifest
LICENSE MIT
License
MIT