Bundle
@dsh-external/dsh-tool-args-unwrap
Fixes malformed tool-call arguments (nested {"arguments":{…}} wrappers, wrapper-key aliases, param-key aliases) at the llm/stream seam — repairs execution, persistence and history replay in one place. 修复模型产出的畸形工具参数。
- Source
- shy19805
- License
- MIT
- Updated
- Updated 12 hours ago
Readme
# dsh-tool-args-unwrap
> 修复模型产出的**畸形工具参数**,挂在官方 `llm/stream` waterfall 上。
> Fixes **malformed tool-call arguments** produced by models, hooked on the official `llm/stream` waterfall.
**[为什么需要它](#为什么需要它) · [修复家族](#修复家族-全部来自真实语料不是猜测) · [安全保证](#安全保证-这是最重要的一节) · [验证数据](#验证数据) · [安装与使用](#安装与使用) · [运维与排障](#运维与排障) · [已知限制](#已知限制) — [English section](#english)**
---
## 为什么需要它
部分模型(尤其小参数/长会话/高频工具调用场景)会产出畸形工具参数,最典型的是多套了一层 `{"arguments":{…}}` 包装。宿主把这层包装**原样持久化**进 `tool/call` 与 `assistant/message`,下一轮整段回放给模型;模型于是看到"我上一轮就是这么调的"→ **越模仿越像 → 越陷越深**(实测包装层数 1→8→13→33→34→38,递归加深)。这就是"agent 自称跳不出来、只有重开会话才好"的真因——重开有效是因为坏示范没了,不是模型状态变了。
这个插件在**参数被持久化之前**拦截并修正,一处同时修好三件事:**执行、持久化、历史回放**——坏示范根本不会被写进会话。
### 老会话需要重开吗?/ Do old sessions need to be recreated?
**不需要(v0.3.0 起)。** 插件在两个方向同时闭环:
- **输出侧**:模型新产出的畸形参数在持久化前被修正——不再产生新的坏示范;
- **输入侧(v0.3.0 引入,v0.3.1 重做)**:老会话历史里的存量畸形 `tool/call` 在请求发往模型**之前**被就地修正(内存内克隆替换,持久化数据一个字节不动)。模型从此看不到坏示范 → 停止模仿 → 下一轮就自愈。
输入侧与输出侧共用同一套判定(宿主校验器 + 违例严格减少 + 名字相关性),历史里的合法调用一个字节都不会变;WeakMap 记忆化保证同一条历史只修一次,不给每轮请求添开销。
> **v0.3.1 关键修正**:llm/stream 的请求对象被 agent-loop `deepFreeze`(含 messages),v0.3.0 在监听器里赋值 `options.messages` 会抛 `Cannot assign to read only property 'messages'` 并**杀死整轮对话**(只在有污染历史的老会话触发——宝可梦天国会话实测)。v0.3.1 改为包装 `llm.stream` 服务方法(clone-on-write:清洗结果放进浅克隆交给原方法,调用方的冻结对象零改动,与宿主 adapterStream 处理冻结请求的模式一致),清洗环节异常完全隔离——任何失败都降级为放行原始请求,绝不影响对话。
**No (since v0.3.0).** The plugin closes the loop in both directions: the **output side** fixes freshly produced malformed arguments before persistence (no new bad exemplars), and the **input side** (v0.3.0, redesigned in v0.3.1 as a `llm.stream` service wrapper with clone-on-write) sanitizes the replayed history before the request reaches the model. Both sides share the same acceptance rules (host validator + strict violation reduction + name relevance); valid history records are never touched, sanitizer failures degrade to pass-through, and a WeakMap memoizes each history block so repeated requests cost nothing.
## 修复家族(全部来自真实语料,不是猜测)
以下每类都在 **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`、`{}` 空参数)、兄弟键与内层重名的歧义合并。
## 安全保证(这是最重要的一节)
判定基准是**违例总数严格减少**,不是"必填键齐了没":
1. **用宿主自己的校验器当裁判**:插件优先加载 `@deepseek-ai/dsh-tools` 的 `validateJsonSchemaValue`(宿主校验工具参数的那个函数)——修完后的参数是否真的过校验,由宿主说了算。加载不到时退化为内置保守计数器(只数必填缺失/浅层类型不符/多余键,刻意**松**:宁可少修不误修)。
2. **用本次请求真实下发的 schema**(`options.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` 数组,重启即持久生效:
```jsonc
// <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"] } }
}
```
```yaml
# <plugin>/cordis.patch.yml(仓库已附)
- bundle:
insert:
id: dsh-tool-args-unwrap
# 入口自动指向 lib/index.js
```
### 方式二:手动
1. 把仓库 clone 到任意目录;
2. 在 profile 的 `package.json` 加 `link:` 依赖 + `bundles` 数组项(如上);
3. 重启 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 校验 + 规范化(本插件即是一个可插拔的实现参考)。
---
<a id="english"></a>
## 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.
**No (since v0.3.0).** See the bilingual section above ("老会话需要重开吗?"). Both sides share the same acceptance rules; valid history records are never touched.
### 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":
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:
```jsonc
// <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"] } }
}
```
```yaml
# <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
Install
dsh plugin --profile web add github:shy19805/dsh-tool-args-unwrap
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-external-dsh-tool-args-unwrap from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.