DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

IRwDONE /

IRwDONE/dsh-tool-call-leak-fix

Verified

Fixes DSH's silent failure where a tool call never runs and its markup (bare invoke/parameter tags, antml:-prefixed, or a stray 'court') leaks into the reply as text: blocks damaged calls, self-heals in-turn, and after a fork voids the damaged output on the model-visible surface. Zero deps.

★ 1 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@4ce67c1f

dsh-tool-call-leak-fix

简体中文 | English

一个 DSH(DeepSeek Harness)宿主插件,治一种静默失败:工具调用没有执行,调用标记(invoke / parameter,antml: 前缀或赤裸形式)却泄漏成正文,而 turn 像"已完成"一样正常关闭。

它做四件事:拦下已损坏的调用 → turn 将静默关闭时在同一 turn 内纠正 → fork 时给继承来的历史打简报 → 把历史里损坏的输出在模型可见面上作废(最后一条零模型调用)。

零依赖,只需要 DSH 本身(Host 半边,桌面端与 CLI 用同一批核心接缝)。

状态:可用。M2(拦截)、M4(自愈)、M7(fork 简报)、M9(上下文修复)均已现场验证;做不到的部分(如 M5 改写历史)见「已知边界」。


0. 你会先看到的,是这个(贴一段真实形态)

下面这段是真实故障的节选(渲染上尽量贴近 DSH 里看到的样子):

已完成,用时 1分55秒
思考
  My last call AGAIN used unprefixed tags (`<parameter name="code">`, `</parameter>`,
  `</invoke>`). I need to use `antml:` prefix.
  <antml:invoke name="run_code"> <antml:parameter name="code">const st = await
  (await import("node:fs/promises")).stat("ui.html").catch(() => null); return
  { uiBytes: st ? st.size : "MISSING" };</antml:parameter> <antml:parameter
  name="description">Check ui.html size</antml:parameter> </antml:invoke>
(然后 turn 就结束了:没有任何工具被执行,界面看起来却像"已完成"。)

说明:上面这段故意保留了真实的连续闭合序列(参数闭合紧跟调用闭合)—— 因为它正是故障本体,去掉就看不出问题了。本仓其余文档仍遵守"不写字面连续闭合标签"的约定(见 §1 的记号说明)。

注意三件事:① 思考是正常的,它甚至知道"自己本该用 antml: 前缀";② 那一次调用没有执行,整段变成了正文;③ 对话不会报错,turn 照常关闭 —— 这就是"静默失败"。

然后你会发现这些办法都不管用:

你试的办法 实际结果
在对话里让 DSH 自己修 它道歉、继续解释,然后又写坏一次;重试还会往历史里再塞一个坏样本,越滚越坏(社区把这种连锁叫 chain reaction)
fork 出去继续 父会话里那几段坏输出跟着被继承,模型照着模仿,继续失败
compact 一下 / 等一段时间 好一阵子,之后又复现同样的泄漏
装上这个插件 fork 之后,历史里那些损坏输出会在模型可见面上被作废(换成一条说明),后续对话不再出现这类泄漏型失败

这不是本插件臆想的问题:同类现象在 Claude Code 上也有公开记录 —— 例如 anthropics/claude-code#64108 描述的 "tool calls emitted as literal text (stray court + raw <invoke>)",并指出 "tags are bare — the antml: namespace prefix is dropped"、以及"API 已示意 tool-use 但正文没有 tool_use 块"。 本插件把这一类失败(调用标记泄漏为正文、开场标记缺失、结束序列增殖、参数被截断、伪造工具结果)统一接住。


1. 问题

当模型要写一个非常大的工具调用参数时——尤其当内容本身就充满以 </ 开头的闭合标签(HTML / CSS / XML / JSX)——它可能在生成过程中丢掉参数的边界。已观测到三种形态:

  • V1 结束序列增殖:调用结尾的结束标记在尾部重复;
  • V2 开场标记缺失:只剩参数开场标记,没有外层调用开场标记;
  • V3 泄漏为正文:调用信封混进 assistant 的正文,没有被识别为真正的工具调用。

另外,泄漏出来的标记不止一种方言:既可能是裸标签(<invoke / <parameter),也可能是带命名空间前缀的(如 <antml:invoke> / <antml:parameter>)。后者曾在一次真实会话里让检测器整体漏判——因为 <antml:parameter 里根本没有 <parameter 这个子串。这就是缺陷 D-10,已修(见 §7.4)。

还有更危险的一种:伪造的工具结果 —— 正文里出现"结果形状的标记"(tool_result 之类),而该步根本没有调用过任何工具:模型自称拿到了结果(甚至带上具体版本号),那个结果是编的。这是比静默 no-op 更糟的假事实,对应缺陷 D-11,已修(见 §7.4)。

还有一种最"干净"的:裸信封 —— 正文就是完整、配平、没有任何散文的调用信封(该步 toolCallCount === 0)。它躲过了全部既有规则(没有散文 → 散文规则不响;结构配平 → 截断/不配对也不响),于是 damaged=false、M4 不触发、turn 照样静默关闭。对应缺陷 D-13,已修(见 §7.4)。

三种都静默失败:不执行、不报错,turn 正常关闭,流程上像是做完了。

还有第四种相关形态 V4:harness 的容错 JSON 解析把非法参数降级成 {},工具于是带着空参数执行。

更麻烦的是:损坏输出会留在对话里,模型开始模仿自己那份坏格式,于是一次坏步骤很容易自我复制;而 compact 不会删掉这个样本,只会压缩它周围的文本。

一个真实的(节选)例子

记号说明:INVOKE_END = 由 < + /invoke + > 组成的 token。这里刻意这样书写——这个守卫存在的意义,就是不写连续的字面闭合标签。

一次参数体很长、闭合标签很密的调用,尾部变成了这样(节选;原文里外层结束序列重复了四组):

<parameter name="code">…一段很长的 JS…</parameter>
<parameter name="description">Read ui.html size and profile job output</parameter>
INVOKE_END × 4   ← 外层结束序列在此重复

那一步一张工具卡都没产生。agent 循环看到零个工具调用,直接返回 {kind:'completed'},turn 就此关闭。任何地方都没有报错。

用户的体感是:模型像是在思考中途"停工",输出质量持续下滑,而且后续每一轮都在重复同一种坏格式。

2. 装上之后的变化

以前 现在
坏的调用不执行、也不报错,看起来像成功了。 调用在分发前被拦下并给出可操作原因:「参数疑似边界丢失,本次未执行;请拆成 ≤100 行分块重发」——静默 no-op 变成显式失败。
信封泄漏成正文,这一步什么都没做,turn 却正常关闭。 turn 不关闭,并注入一条纠正,让模型在同一个 turn 内重发。
写入类工具自报成功、其实什么都没改。 结果后面补一句:「工具自报未产生任何改动,请回读确认」。
坏样本留在历史里,持续被模仿。 没解决。 在这版 harness 上插件改不了历史(见「已知边界」)。它缩短故障窗口,不洗白过去。绕法:新开会话,或用一句话让模型忽略那段损坏内容。

3. 它做什么(人话)

三个守卫,平时都不出声:

  1. 拦截(M2)——工具调用分发前扫描已解析的参数。若某个字符串参数的边界已损坏(尾部结束序列增殖,或结构中途被截断)且体量够大(≥2000 字符或 ≥20 行),就拒绝这次调用并说明该怎么改。它同时兜住 {} 那种情况:声明了必填参数的工具却收到空参数。
  2. 纠偏(M3)——写入类工具跑完后,检查是否有机器可读的空转证据(noop: true、status: "noop"、written: 0 且确实带了内容),有就补一句短提示。它不做自由文本猜测。
  3. 自愈(M4)——观察已提交的 assistant 消息。若某一步的输出损坏且该步没有任何工具调用,就在 turn 即将关闭的那一刻注入一条纠正,让 turn 保持打开、模型重发调用。§1 里那个例子,正是靠它。 它是两档有界的:第一次给"上一步没执行 + 该怎么改";如果紧接着那一步还是写坏(真实会话里确实发生过),再给一次更硬更短的指令——"这一步只发一个调用、参数不超过 40 行、不要解释"。上限由 maxCorrectionsPerTurn 控制(本 bundle 设为 2),到顶即停,不会循环。
  4. fork 简报(M7)——fork 出来的会话会继承父会话的历史,而出故障之后 fork 正是最常用的动作。那段历史改不了(冻结请求,D-8),但插件能在 fork 的第一个模型步之前放一条代码生成的简报:把历史里"没有对应工具卡的孤立调用标记"明确标为损坏输出、未执行,请模型忽略、不要模仿,需要时用小块重发。每个会话只放一次;非 fork 会话零开销。
  5. 上下文修复(M9)——上面四条都是"当场纠正",但已经进入历史的坏样本仍会被逐字回放、被模型模仿(真实现场:纠正一次后模型又吐了 9168 字符的坏正文)。改写历史做不到(D-8/M5),压缩服务在这台 harness 上也没挂载(实测,见契约 R18)—— 于是改用会话自己的 surface 替换:把损坏的那一步在模型可见面上遮蔽,并换成一条静态说明("此处原为损坏输出、已作废,请勿模仿")。日志里的原始事件仍然保留,只是不再进入模型视野。零模型调用。 因为 surfaceOp.replace 的语义在真实语料里没有任何样例,所以不拿真实消息试:先用自建探针节点自检一次,通过才动手;自检失败整个会话故障关闭;每次替换后再验证一次,不通过就立刻停手并记 repair.unverified。fork 继承来的损坏步骤也会在每会话第一次 turn 结束时扫一遍并一并遮蔽。

刻意没做:完全不注入提示词(曾经实现过 system-prompt 规则块,后按设计决策移除——提示词层约束力不保证,且会分散 attention);也不做"强制小块写入工具"。

4. 安装(DSH 用户)

用 Harness 自带的插件管理器安装,不要手改 profile。三种方式任选:

方式 A · 在插件管理器里搜包名(最直观)

打开插件市场 / 插件管理器,搜索 dsh-tool-call-leak-fix(或完整包名 @irwdone/dsh-tool-call-leak-fix),点安装。装好后在已装列表里能看到版本号。

方式 B · 一条命令

npm 包名(不依赖仓库是否公开):

@irwdone/dsh-tool-call-leak-fix

或直接从 GitHub 装(仓库需为公开):

https://github.com/IRwDONE/dsh-tool-call-leak-fix

包身份就在仓库根(package.json 在根、patch 为 plugin/cordis.patch.yml),所以 git spec 直接可装。

方式 C · 克隆后按本地路径安装(想改代码、跟着开发时用)

git clone https://github.com/IRwDONE/dsh-tool-call-leak-fix.git

然后 install_bundle 的 target 填克隆目录本身(即仓库根,含 package.json):

<你的克隆路径>\dsh-tool-call-leak-fix

⚠️ 不要填到 plugin/ 子目录 —— 那里只放实现代码(index.js / src/ / locale/ / cordis.patch.yml),包身份在仓库根。

安装之后

1. install_bundle 返回 application: "applied" -> 已生效
   若提示需要重启,就重启 Harness。
2. 确认该行已挂载(**判活要用对地方**):
   - ✅ 插件管理器列表;或 Harness 的 Config 检查(会出现 `include:tool-call-leak-fix`);
   - ✅ 最硬的判据:它是否**真的注入了纠正**。
   - ❌ **不要去 grep profile 的 `~/.dsh/profiles/<profile>/cordis.patch.yml`**:
     bundle **自带 patch**(本包的 `plugin/cordis.patch.yml`,由 `package.json` 的
     `dsh.bundle.patch` 声明),所以那一行**本来就不在 profile 的 patch 里**。
     实测:有真实会话因为 grep 了 profile patch 而误判"插件没被激活",整个排查都被带偏。
3. 随时停用:插件管理器里禁用该 bundle;
   或把该行 config 的 enabled 设为 false(此时不注册任何监听器)。

不需要:构建步骤、任何运行时依赖、前端半边。这个 bundle 是纯 ESM JavaScript,只用 Node 内置模块(node:fs、node:os、node:path)。

5. 配置

该行的 config 会被校验并归一化;非法值回退默认而不是抛错。数值阈值必须是整数且 ≥1(只有 maxCorrectionsPerTurn 允许 0)。

键 默认 含义
enabled true 为 false 时不注册任何监听器,等同关闭插件
maxLines 100 单个字符串叶子的行数上限(PAYLOAD_TOO_LONG 预警)
maxChars 20000 单个字符串叶子的字符数上限
closingTagCount 8 </ 前缀出现多少次算「密集」——只作证据,永不导致拦截
endSequenceRepeats 2 尾部连续结束序列达到几个即判损坏
maxCorrectionsPerTurn 1 每个 turn 最多自愈几次(防循环)。本 bundle 的 patch 把它设为 2:让"第一次纠正后又写坏"的情况获得一次更硬的二次纠正
lang zh zh / en,决定注入文案与拦截原因的语言
noopCheck true M3 空转检查的总开关
debugLog false 诊断日志开关;false 时不打开任何文件(零开销)
debugLogPath '' 空串 ⇒ 由系统临时目录推导(绝不硬编码本机路径)
contextGuard false 内部保留、未接线——见「已知边界」
contextGuardNote '' 内部保留、未接线
forkBrief true R16(M7):fork 会话在其首步之前注入一次"会话简报"(把继承来的损坏产物标为无效)。置 false 关闭
forkBriefNote '' R16:简报文本;空串 = 内置中英文文本
repairContext true R19(M9):把损坏步骤(损坏 且 该步没有任何工具调用)在模型可见 surface 上遮蔽并换成一条说明;零模型调用。先自检、失败即对整个会话关闭。设 false 可完全停用

6. 已验证 / 未验证

验证刻意分两层:「代码是否符合契约」由自动化探针证明;「插件是否真的改变了真实对话」只在亲眼观测到的地方才算数。

结论 状态 证据
M2 真的拦住了损坏调用 ✅ 现场观测 一次真实的、22 行以上且结构截断的参数在分发前被拒,并确认目标文件未被创建
M4 让 turn 不关闭并继续 ✅ 现场观测 诊断日志完整链条:damaged:true, silentFailure:true → turn-stopping 命中记录 → agent.inject() 成功 → agent/inbox/spliced → 同一 turn 号内新开一步
M3 只认显式证据 ✅ 探针 独立探针;早先的自由文本启发式在真实会话误报 5 次后已删除
检测器契约(形状、裁定、纯函数纪律) ✅ 探针 独立 480 条检测器断言 + 102 条接线断言,加作者测试台 187 条,全绿
M1 预防提示词 ➖ 按设计移除 提示词层约束力不保证,且会分散 attention
M5 改写历史 ❌ 本版做不到 见「已知边界」D-8
自愈能让模型真正重做丢失的工作 ⚠️ 真实故障中观测到:没有 在一个真实用户会话里 M4 完整走通(检测 → 注入 → turn 未关闭、多跑一步),但那一步又写坏了(还把 23 KB 内容当正文吐出),随后限次用尽、turn 正常关闭。这正是二次升级的由来;而二次升级本身尚未在真实故障中被观测到

怎么验的:由独立作者编写的探针(verify/probe.mjs、verify/probe-index.mjs,不复用作者断言),外加一套带前后哈希守护的复现脚本(verify/run-all.sh)。

7. 技术细节

7.1 决定设计的两条 harness 事实

  1. DSH 里不存在文本协议的调用解析器。 本版走 provider 原生 OpenAI 风格 tool_calls(经 pi-ai)。所以当模型把信封当正文吐出时,没有任何东西会把它变成调用:消息里零个 tool-call 块,agent 循环直接返回 {kind:'completed'}。结论:那个场景下 tools/pre-execute 根本不会被触达,检测只能挂在 session/event 上。
  2. llm/stream 收到的请求是冻结的。 dsh-agent-loop 组装完请求后把整个 envelope Object.freeze(并 deepFreeze 每条消息),才交给 dsh-llm 的 waterfall。所以监听器无法改写模型可见历史——这正是 M5 停用的直接原因。

7.2 接缝与纪律

机制 接缝 纪律
M2 tools/pre-execute waterfall (exec, next) 要么拒绝,要么 next(),绝不接管别人的决策
M3 tools/post-execute waterfall (exec, result, next) 先 await next(),然后只做加法(追加 additionalContexts)
M4 观察 session/event → assistant/message 只观察,绝不干扰已提交的事件
M4 纠正 agent/turn-stopping(serial)+ agent.inject() inject 把消息放进 next-step 且不唤醒驱动,这正是 turn 不关闭的原因

7.3 文档与证据

  • docs/INTERFACE.md——冻结的检测器契约与全部裁定(R1–R19),包含那些被证据推翻后修正过的。
  • docs/CONTRACTS.md——实现前的契约调研,每条结论都带文件路径与行号。
  • docs/DESIGN.md——故障分类、触发条件、机制设计与实测后的修正。
  • docs/PROMPTS.md——预防/纠正/拒绝三段文案。插件已不注入提示词,文本留作手动选用。
  • verify/REPORT.md——独立验证报告,含查出的缺陷与修复。

7.4 验证期间查出的缺陷(这部分才是有用的)

其中两个只能靠读源码或对抗性测试发现,跑正常路径永远跑不出来。

编号 严重度 是什么
D-0 高 注入消息用了已退休的 source.kind = 'plugin',被 v4 会话格式硬拒绝(可能让会话不可读)。已改为"生产者自有"的 kind
D-1 中上 截断判据方向写反:既漏报真截断,又误拦合法文档——包括插件自己的 README。已修,并补了能区分判据方向的用例
D-8 高(已接受) M5 改不了冻结的请求 ⇒ 属能力缺失而非 bug。已移除接线、保留实现
D-10 高 检测器只认裸标签,认不出真实故障里的带命名空间前缀形态(<antml:invoke> / <antml:parameter>)→ damaged=false → M4 不触发、turn 静默关闭。已修(R14:结构分析跑在"去前缀的影子副本"上;不含冒号的文本返回同一字符串,零漂移)。语料级验收(不变量,不是快照):把全部真实会话的每条文本同时跑修复前 / 修复后的检测器,差异恒为 1 条——正是用户实际撞到的那次 fork。绝对总数会随 append-only 日志持续增长(连验证过程本身都会写进去),故不作基线
D-11 高 伪造的工具结果:某步 toolCallCount === 0,正文却是 tool_result 形状的块包着一个"版本号"(真实会话实测)——模型报了一个从未获得的结果,属假事实。已修(R15:assistant 正文出现结果形状标记即报 MARKUP_LEAKED_AS_TEXT;复用既有 code,不新增;不依赖 meta、也不要求自然语言句子;当该步确有工具调用时 silentFailure=false,注入被 R1 挡住)
D-12 高 M7 的 fork 判定读了不存在的 API:初版读 agent.session.events 去找 type === 'session',但 Session 既没有 events、也没有这个事件(fork 标记在 session.header 上)→ 函数恒返回 null → M7 静默 no-op。独立验证员用三条互证(接口声明 / 实现 / 运行时事件目录)抓出来。已修(改读 session.header)。与本项目的 D-8/M5 同型:读一个"看起来合理、其实不存在"的接口,然后静默什么都不做
D-13 高 裸信封漏判:某步 toolCallCount === 0,正文是完整、配平、无散文的调用信封(真实会话实测 325 字符),躲过全部既有规则(散文规则要求自然语言句子;结构配平 → 截断/不配对也不响)→ damaged=false → M4 不触发、turn 静默关闭。已修(R17:规范化后 trimStart() 若以调用开场标记起头即报 MARKUP_LEAKED_AS_TEXT,不要求散文、不要求损坏;代码块引用与散文讲解均不误报)。附带把反例 ok-single-end-sequence.txt 改为引用形式——它的原内容本就是裸信封——断言改为只增不减
D-5 / D-6 / D-7 / D-9 低 契约漂移、默认值里硬编码本机路径、发布件里留着诊断开关、以及自相矛盾的文档段落。全部修复

8. 已知边界

  1. 治不了已经被带坏的对话:改写历史需要可写接缝(M5,D-8)。绕法:新开会话,或一句话让模型忽略那段。
  2. 不阻止第一次失败:提示词层预防已按设计移除,第一次照样写坏,只是不再静默。
  3. 有界扫描:最多 200 个字符串叶子、深度 24;更深或更靠后的会漏检(有意为之,宁漏勿扰)。
  4. 配置会被校验但没有 UI 表单:用手写的 Standard Schema v1(以保持零依赖),管理器报 unsupported——校验生效,但不渲染表单。
  5. 自愈触发时多一次模型请求,由 maxCorrectionsPerTurn 限次。
  6. 绝不碰真实工具调用:只有没有任何 tool-call 块的消息,才会被判定自愈或改写。

9. 仓库结构

package.json        包身份在【仓库根】—— 所以 install_bundle 直接填仓库 URL 即可
plugin/             实现(不含 package.json)
  index.js          Host 接线:M2/M3/M4/M7/M9,每个监听器都 try/catch + effect 作用域注册
  src/detect.js     纯检测核心——不 import 任何 DSH 包,可在普通 node 下直接跑
  cordis.patch.yml  挂载行(`- insert:` 列表)
  locale/*.json     插件管理器显示名与描述(中/英)
docs/               设计、冻结契约(R1–R19)、契约调研、提示词文案
tests/              193 条断言:V1/V2/V3 的 fixture + 反例 + 性质测试
verify/             独立验证:独立探针 + 复现脚本 + 报告

包根 = 仓库根(package.json 在根,patch 指向 plugin/cordis.patch.yml);plugin/ 之后是全部实现,docs / tests / verify 是证据。

10. 许可

MIT —— 见 LICENSE。

11. 反馈

欢迎 issue 与复现,尤其是真实的静默失败 + 原始记录:那正是 M4 有效性唯一还没被证实的场景。

—/ 5

No ratings yet

Verified DSH bundle

Commit 4ce67c1f0da4

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout