Back to home@shy19805

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
GitHub repo

Introduction

dsh-tool-args-unwrap

修复模型产出的畸形工具参数,挂在官方 llm/stream waterfall 上。 Fixes malformed tool-call arguments produced by models, hooked on the official llm/stream waterfall.

为什么需要它 · 修复家族 · 安全保证 · 验证数据 · 安装与使用 · 运维与排障 · 已知限制English section


为什么需要它

部分模型(尤其小参数/长会话/高频工具调用场景)会产出畸形工具参数,最典型的是多套了一层 {"arguments":{…}} 包装。宿主把这层包装原样持久化tool/callassistant/message,下一轮整段回放给模型;模型于是看到"我上一轮就是这么调的"→ 越模仿越像 → 越陷越深(实测包装层数 1→8→13→33→34→38,递归加深)。这就是"agent 自称跳不出来、只有重开会话才好"的真因——重开有效是因为坏示范没了,不是模型状态变了。

这个插件在参数被持久化之前拦截并修正,一处同时修好三件事:执行、持久化、历史回放——坏示范根本不会被写进会话。

修复家族(全部来自真实语料,不是猜测)

以下每类都在 16,535 条真实 tool/call 记录里实测出现过,并逐条对着真实 tool/result 错误文本核对:

家族形态实测条数报错插件动作
A嵌套包装 {"arguments":{…}}(1–38 层)547missing required propertyunwrap:arguments(无条件解包)
B包装键别名 {"args":{…}} / {"parameters":{…}}2同上unwrap:args
C包装键 + 兄弟键混排 {"arguments":{…},"offset":5}1同上merge:arguments(合并,冲突则放弃)
D参数键名别名 skill_name→namepath↔file_pathnew_str→new_stringquery→queries≈13同上rename:x→y(需名字相关 + 违例减少)

明确不修(语料证明它们不是缺陷,改了就是编造):把 JSON 当文本写进文件的 {"content":"{…}"}(75 条,报错全是"需先 read",与参数形状无关)、模型真漏必填(run_codedescription{} 空参数)、兄弟键与内层重名的歧义合并。

安全保证(这是最重要的一节)

判定基准是违例总数严格减少,不是"必填键齐了没":

  1. 用宿主自己的校验器当裁判:插件优先加载 @deepseek-ai/dsh-toolsvalidateJsonSchemaValue(宿主校验工具参数的那个函数)——修完后的参数是否真的过校验,由宿主说了算。加载不到时退化为内置保守计数器(只数必填缺失/浅层类型不符/多余键,刻意:宁可少修不误修)。
  2. 用本次请求真实下发的 schemaoptions.tools)判定,绝不缓存、绝不猜测。
  3. schema 说某个键是参数就绝不动它:例如 dev_stage_add 真有个 parameters 参数、str_replace_editor 真有个 path 参数,它们不会被解包/改名(语料里实测确认)。
  4. 除 A 类外,改写必须让违例数严格减少,否则整个回退、原样放行。
  5. 键名改名额外要求名字相关(规范化后相等 / 前缀 / 后缀 / 单复数归一),且只在"改完真的少报错"时生效——所以 configuration 绝不会被塞进 description(语料里真实存在这个陷阱,已验证被拒绝)。
  6. A 类(单键 arguments)无条件解包的理由:arguments 在 dsh 全量工具集里从未作为参数名出现(已在 node_modules 全量核验);它必须无条件,否则"无必填参数"的工具会静默吞掉包装——比报错更坏(模型以为调用成功,其实参数全丢)。
  7. 歧义即放弃:合并时兄弟键与内层重名 → 不改名不合并,让调用带着原本清晰的报错失败(模型会自己改),而不是替你猜哪个值是对的。
  8. 改不了就原样放行:没有候选能让违例数减少 → 返回 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

方式二:手动

  1. 把仓库 clone 到任意目录;
  2. 在 profile 的 package.jsonlink: 依赖 + bundles 数组项(如上);
  3. 重启 dsh。

确认生效:插件目录会写 state.json,看到 listenerRegistered: truestreamsSeen 随模型调用递增即在工作;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/stream waterfall.

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:

FamilyShapeObservedErrorPlugin action
ANested wrapper {"arguments":{…}} (1–38 layers)547missing required propertyunwrap:arguments (unconditional)
BWrapper-key alias {"args":{…}} / {"parameters":{…}}2sameunwrap:args etc.
CWrapper + sibling keys {"arguments":{…},"offset":5}1samemerge:arguments (aborts on ambiguity)
DParam-key alias skill_name→name, path↔file_path, new_str→new_string, query→queries≈13samerename: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":

  1. The host's own validator is the judge: the plugin preferentially loads validateJsonSchemaValue from @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.
  2. Judged against the schema the current request actually sent (options.tools), never cached, never guessed.
  3. A key the schema declares as a parameter is never touched: e.g. dev_stage_add really has a parameters param, str_replace_editor really has a path param — they are not unwrapped/renamed (verified against the live toolset).
  4. Except class A, a rewrite must strictly reduce the violation count, otherwise it reverts and passes through unchanged.
  5. Key renames additionally require name-relevance (normalized equality / prefix / suffix / plural collapse) and only take effect when they genuinely reduce errors — so configuration is never mapped onto description (this exact trap exists in the corpus and is verified rejected).
  6. Why class A (single-key arguments) is unconditional: arguments has 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).
  7. 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.
  8. 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 property errors, 520 fully fixed (validator reports 0 violations); 3 "fixed but still failing" (model used a semantically wrong key, e.g. glob wants pattern but got file_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 arguments wrapper (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 BlockAssembler chain (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