← 返回列表
未验证
针对 agent 工具调用的升级式重复检测。一个本地、无依赖的
尚未跑自动兼容性验证,可查看页面内的依赖与入口分析。 · 最近上游提交 2026/9/24 · 已提供中文文档
DeepSeek Harness(DSH)中对重复相同工具调用的硬中断:一个同步单调的 ctx.tools.guard 门控,对每个 agent 拒绝第 2 次相同调用。无依赖。
综合分
30
GitHub 分
30
用户评分
—
★ Stars
0
周下载量
—
安装插件(需先安装 dsh CLI 引擎:npm install -g @deepseek-ai/dsh)
dsh plugin --profile web add snailium/dsh-repeat-tool-breaker该插件未发布到 npm,走 GitHub 源安装(pnpm 若拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑)
信任档位:已验证本站已于 1 天前真实安装成功
- 是什么
- dsh 原生插件 · chat
- 装得上吗
- 本站已真实安装成功(非静态推断)
- 安全吗
- 本站尚未对该插件做风险分级(暂未覆盖,不等同于无风险)
- 还在维护吗
- 活跃:最近一次提交在 1 天前
档位由下列信号合成:本站实装验证(真实安装,当前最高到 L4)· 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。下方各区块是它的证据明细。 验证判据与等级说明 →
🟢实装验证通过· 2026/9/24
由本站实装验证器在真实 dsh 环境安装成功,非静态推断。
数据截至 2026/9/24(元数据每日更新 · 实装验证按队列轮转,单条结论的验证时间见上方)
用户评分
还没有人投票,来当第一个
订阅周报,不错过优质插件更新
每周一封 · 高评分插件 + 新用户活动
README
由 DeepSeek 最新模型翻译生成dsh-repeat-tool-breaker
CI
npm
License: MIT
Node
针对 agent 工具调用的升级式重复检测。一个本地、无依赖的
DeepSeek Harness(DSH)插件,
在公开的 ctx.tools.guard API 上注册一个同步门控。在 agent 的滑动窗口内重复的
度量不会在第一个阈值处被阻止:它会升级——在 7 次时给出轻度警告,在 11 次时要求
书面总结,在 12 次时触发门控(对于更粗粒度的 host: 度量则为 16 次),此时会向
操作者提供一次回合作用域的豁免
(onLimit: ask,默认值),或者直接拒绝该调用。当门控拒绝时,该调用永远不会执行,
模型会收到一个以 REPEAT_TOOL_BLOCKED 开头的 isError 结果,其中引用了上一次的
结果并说明应该改做什么。
自 0.5.0 起,第二条轨道针对相同的指纹监视连续失败而非出现次数:在 3 次时给出
建议,在 5 次时触发同一个门控。两条轨道共享一个门控、一套度量集和一套豁免策略。
本 README 中的数字属于出现次数轨道,除非它们被标记为 failWarnAt
或 failLimit。
用一段话概括 v1 → v2 → 0.5.0 的历程:
v1 失败了,因为它统计的是逐字节完全相同的连续调用,而模型会改变一个展示
字段(description: '1st'|'2nd'|'3rd',不断变动 timeoutMs),并在主机拼写之间
来回切换(open-data.canada.ca ↔ open.canada.ca),每次使用不同的 --max-time
——每次调用看起来都是新的,所以计数器从未递增。
v2 胜出,它在构建任何指纹之前先删除诱饵参数,然后在每个 agent 最近 16 次
调用的窗口上统计语义指纹(exact:、cmd:、net:、host:、site:、
sink:、family:、verb:),因此重复必须改变实际资源——而不是它的拼写——才能
通过。
0.4.0 增加了升级机制:同一个度量先警告、再要求总结,然后才门控——在 0.4.2 中
重新调整为 7 / 11 / 12,host: 为 16——并且门控会询问操作者,而不是静默阻止。
0.5.0 增加了失败轨道:相同的指纹也会针对连续失败进行计数——在 3 次时给出
建议,在 5 次时门控——因为重复一个有效的调用是固着,而重复一个失败的调用则不是
学习。
三种循环,以及各自被什么捕获
| | 循环 | 被谁捕获 |
|---|---|---|
| A | 相同的 read/write/bash 参数再次出现,逐字相同 | exact:(12) |
| B | description: '1st'/'2nd'/'3rd',command 不变 | 诱饵参数在指纹计算之前被剥离,因此这些调用变得逐字节相同 → exact: (12) |
| C | curl --max-time 60 open-data.canada.ca ↔ curl --max-time 30 open.canada.ca | 主机别名折叠后为 net: (12),再加上易变标志剥离后的 sink: (12) 和 cmd: (12) |
固定策略是那些循环都捕捉不到的第四种失败模式:针对一个目标的高频但非重复运行,其中每个查询字符串都不同,因此 net: 永远不会碰撞。在参考部署中,一个 agent 在一个回合内对单个主机发起了 30 次 bash/curl 调用,却始终没有收敛。host: 度量(0.4.0 新增,上限 16)正是让它可见的原因;参见调用如何被指纹化。
失败轨道(0.5.0)与上述所有机制处于不同的维度:它针对持续失败的指纹触发,即使尝试次数远低于达到出现阶段所需的次数——模型猜测一个不存在的端点,得到 404,然后改写请求。参见失败轨道。
同类的官方插件 @deepseek-ai/dsh-repeat-tool-reminder(建议性,在 3/5/8 次重复时触发)可以保持开启——两者可以组合使用,提醒作为软性提示,而这个断路器作为在 12 次时询问的升级门禁。
出现轨道:三个阶段
出现轨道统计重复次数。每个度量——一个指纹身份,例如 exact::…、net:? 或 host:——都具有相同的三阶段结构。一个阶段在每次跨越时触发一次,基于精确相等:包括当前调用在内的计数必须等于阈值,因此窗口滑动不会重新宣告它。下面的失败轨道有自己的两个阶段。
| 重复次数 | 设置 | 阶段 | 会发生什么 |
|---|---|---|---|
| 7 | warnAt | 1 — 轻度警告 | 模型被告知它正在重复,并应考虑是否有不同的路径能更快达到目的。不会阻止任何操作,也不会要求任何东西。 |
| 11 | summarizeAt | 2 — 要求总结 | 模型必须写下它确立了哪些事实、哪些假设未经核实、哪些失败了以及原因,以及至少两种它尚未尝试的方法——外加一条使用更大的每批数量以减少批次的指令。不会阻止任何操作。 |
| 12 | limits 条目 | 3 — 门禁 | onLimit: ask(默认)为操作员提供一个回合范围的豁免;onLimit: deny 直接阻止。无人值守的询问会降级为拒绝。 |
出现上限对于动作身份度量(exact、cmd、net、sink)为 12,对于更粗粒度的 host: 度量为 16——即整个窗口。参见调优。失败轨道的门禁是 failLimit,一个单独的设置,值为 5。
limits 就是出现阶段 3 的阈值。这里刻意没有单独的
gateAt:第二个门控数字会变成同一个值写两遍,而两个必须一致的旋钮最终总会不一致。
阶段 1 和阶段 2 是建议性的。守卫只能返回拒绝,因此守卫在仍能看到计数时计算建议,并由 tools/post-execute 将其作为 additionalContexts 条目附加——即 @deepseek-ai/dsh-repeat-tool-reminder 所使用的通道——并标记为 source.kind: 'plugin'。它与下游的阻断组合,而不是替代它。(未标记的上下文在派生历史中会渲染为用户提示,这就是 source 为必填项的原因。)
每个阶段都是普通的、独立的设置,并且没有任何设置会针对另一个设置进行校验:
- 0、负数或 null 会静默地禁用某个阶段——这是文档中说明的关闭开关,而不是要拒绝的值;
- limits 条目等于或低于某个阶段意味着“此度量不升级”:warnAt: 7 下的 exact: 5 会在 5 处门控,且完全不发出警告;
- 阶段倒置也是合法的——更强的消息只是会先触发。
当多个度量在同一次调用中跨过某个阶段时,最强的阶段胜出,平局则归于计数最高者。
高于上限的阶段永远无法发声
门控在建议之前触发,因此 summarizeAt 等于或高于所有上限就是死代码,并且任何高于窗口(16)的阈值都根本无法触发。没有任何机制会针对另一个设置校验某个设置——limits 条目低于某个阶段仍然是故意跳过建议的一种方式——但随附的默认值会让 summarizeAt 严格低于所有上限,这就是阶段和上限一起变动的原因。0.4.2 数字背后的测量见
docs/issue-b-thresholds.md。
onLimit——门控处会发生什么
| 值 | 行为 |
|---|---|
| ask(默认) | 操作员会获得一个回合作用域的豁免,每个度量每回合一次。批准会恰好豁免命中的指纹,在剩余回合中停止对它们计数,并让后续相同的调用一并通过;拒绝会停止对这些度量的询问,并拒绝它们,直到下一条人类消息。 |
| deny | 从不询问。达到上限的那次调用会直接以 REPEAT_TOOL_BLOCKED 被拒绝。 |
ask 是默认值,因为它是故障关闭的。批准的所有无人值守结果都是拒绝——rejected(会话策略为 never)、cancelled(回合被中止),以及 unavailable(当没有注册应答者时注册表回退到的值)——因此无头配置会自行降级为 deny,不会有任何东西停滞。豁免是按指纹的:对 host:api.weather.gc.ca 的豁免对 exact: 或不同的主机没有任何意义。
询问过去仅限本地(localHosts: ask)。自 0.4.0 起,这就是 onLimit 对每个度量所做的事情;参见本地地址。
onLimit 管辖两条轨道。失败命中和重复命中都经过相同的豁免提示和相同的拒绝集,因此批准会恰好豁免
命中并标记它们的那个轨道的指纹。第二道闸门本需要第二次询问、第二组拒绝集,以及第二种卡住的方式。
失败轨道:两个阶段
0.5.0 新增。第二条升级轨道监视相同的指纹,但关注的是连续失败而非出现次数。它之所以存在,是因为失败是比重复强得多的信号:重复一个有效的调用是固着,重复一个失败的调用则不是学习。因此它的阈值远低于出现次数阈值(7 / 11 / 12)。
| 连续失败次数 | 设置 | 阶段 | 会发生什么 |
|---|---|---|---|
| 3 | failWarnAt | 1 — 失败提示 | 模型被告知目标已连续失败三次,并引用失败原因,同时被指向错误、目标是否存在以及原因。不会阻止任何操作。 |
| 5 | failLimit | 2 — 闸门 | 携带该指纹的调用会被阻止,通过与出现次数闸门相同的 onLimit 策略——默认 ask(失败关闭),deny 用于硬中断。 |
触发这一设计的典型案例是:模型猜测一个不存在的端点,不断收到 404 并反复改写请求。失败就是信号,而且它远在重复计数注意到之前就已可用。
什么算作失败
失败不是 result.isError。该标志仅在调用失败时为真——抛出错误、未知工具、沙箱拒绝、中止——而对于最重要的两种形态,它是 false:非零退出码和 HTTP 错误状态。在 40 个录制会话中测量,抛出错误的情况覆盖了 6765 个 bash 结果中的 40 个(0.6%),因此只计算它会让该轨道失明。
lib/failure.js 读取每个工具在其 output.schema 中声明的结构化 result.value,结构化优先:
- 非零 exitCode(bash);
- statusCode >= 400(web fetch);
- timedOut;
- 非空 signal;
- 沙箱拒绝。
状态码在两个方向上都是决定性的:200 就是成功,无论页面包含什么文字,因此对于报告了状态码的 fetch,永远不会去查文本。这很重要——一份提到“HTTP Error 400”的已获取文档并不是失败。
文本回退随后覆盖结构化值无法覆盖的情况,并且它被有意限制在shell 工具上。shell 的 exitCode: 0 什么也证明不了:curl … | python3 … | head 以 head 的状态退出,而一个捕获自身 HTTP 错误的脚本也会以 0 退出。因此对于 shell,文本是剩余的证据:
- [exit code: N]、(HTTP nnn)、[timed out after Nms]、[killed by signal: …]、[sandbox: file access denied —— harness 自身的标记;
- Traceback (most recent call last) 以及行首锚定的 Python 异常(SyntaxError:、urllib.error.URLError:、……)——脚本崩溃了,而管道仍以 0 退出;
- HTTP Error nnn —— urllib 的消息,由捕获它并继续执行的脚本打印;
- curl: (n) —— curl 自身的诊断信息。
还有一个案例需要的是命令,而不仅仅是输出,因此 classifyFailure 会接收它:
当某个 shell 命令向 curl 请求状态码(-w "%{http_code}" 或
%{response_code})时,输出开头的 4xx/5xx 按构造就是状态码。
模型手动检查端点时就是这样做的——除非给了 --fail,否则 curl 对 404 也会以 0 退出,
而输出是一个裸数字,其他任何东西都无法识别它。这个锚点是刻意为之的:同一输出
常常还带有字节数(153226 /tmp/…),其中的数字包含类似 532 的内容,
而没有 write-out 请求的普通 wc -c 数字并不是状态码。
其他所有工具都通过其结构化值来给出答案,因此绝不会去猜测它的文本:
这正是防止把抓取到的页面,或对包含 ValueError: 一词的 Python 文件的 read,
误读为失败的原因。
有两件事被刻意不视为失败:aborted——取消是模型选择之外的外部因素——
以及已启动的后台任务(kind: 'background',其退出码属于后续调用)。
只有命中的指纹会被阻止
只有当一次调用携带某个已经连续失败 failLimit 次的指纹时,它才会被阻止。
不携带该指纹的调用——读取错误日志、grep 代码、尝试不同的端点——是允许的。
阻止恢复操作,正是把卡住的模型变成被锁死的模型的方式。
一次成功会清除该指纹的连续失败记录
失败指纹的一次成功会清除其连续失败记录。不同指纹的一次成功则不会:
一个模型构建失败、读取一个文件、然后再次构建失败,那么它已经构建失败两次,
而那次读取并不是构建上的进展。
两条轨道共享同一组度量
limits 上限为 null 的指纹对两条轨道都禁用。这是承重设计,而非为了整洁:
在语料库上测量时,最长的连续失败恰好落在那些被禁用的度量上
(family:http-fetch 上为 9,verb:curl 上为 8,verb:export 上为 7),
因此统计它们会通过一个新渠道重新引入 0.4.0 的 bug。失败轨道也会跳过被豁免的
指纹,与出现轨道完全一样。
失败门控只能在后续调用上触发
ctx.tools.guard 是同步的,并且在执行之前运行,而结果只有在
tools/post-execute 中才可知。因此失败门控无法阻止产生第五次失败的那次调用:
在 5 次失败之后,携带该指纹的第 6 次调用才会被阻止。
插件从不统计自己的拒绝
REPEAT_TOOL_BLOCKED 不是模型的失败。本插件发出的拒绝会被排除在连续失败记录之外——
否则守卫会自我喂养:拒绝一次调用,连续失败记录增长,下一次调用就会提前一步被拒绝。
被拒绝的询问也会被排除,因为那次调用从未运行。
命名度量,以及关闭开关
当多个指纹同时跨过失败阶段时,该建议会列出
最具可操作性的一种,使用与 occurrence 阶段相同的 measureRank 平局决胜规则——一个目标作用域的度量,例如 host:,而绝不是被截断的 exact: 命令行。
failWarnAt 遵循与 warnAt 相同的开/关规则:正整数启用它,而 0、负数或 null 会静默禁用它。failLimit: null 会禁用门控,同时保留建议性提示。这两者彼此独立,也与 occurrence 设置相互独立;正如本插件中其他所有地方一样,任何内容都不会针对任何东西进行校验。
实测支持(tools/failure-run-measurement.mjs,覆盖 107 个已记录会话,仅统计已启用的度量):3.6% 的调用失败;有 9 个会话达到 3 次连续失败,5 个达到 5 次,而零个已知良好运行达到 3 次。最清晰的真实案例是一个会话连续 8 次命中 host:api.github.invalid——一个保留的、永久不存在的主机。
要求
- Node.js >= 20(在 22 上开发和测试)。
- 一个暴露 tools 服务的 DSH profile。基于 @deepseek-ai/dsh 0.1.2-rc.1 构建并验证。
- 无运行时依赖——该插件只导入自己的 lib/ 模块(没有 cordis,没有 schemastery),因此可以直接从路径挂载。
安装
选项 A——将其列为 profile bundle(推荐)
该包声明了 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },因此它是一等 profile bundle:无需手写挂载行。
dsh plugin --profile add dsh-repeat-tool-breaker
然后将其添加到 profile 的有序 bundle 列表中
($DSH_HOME/profiles//package.json):
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-repeat-tool-breaker"
]
}
}
该 bundle 的 patch 层以无 config: 的方式挂载插件,因此 fail-loud 的 DEFAULTS 确实就是默认值。要调整它,请从profile 自身的 cordis.patch.yml 中按 id 重新配置该行——请记住,patch 会替换目标行的整个 config,而不是合并进去,因此请重新声明你想要的每个字段(参见配置)。
在 dsh.profile.bundles 中指定一个没有 bundle 的包是一个硬启动错误(declares no dsh.bundle in its package.json),这就是为什么此路径需要上面的清单。
选项 B——从路径挂载(开发循环,无需安装)
克隆此仓库并向 profile 添加一个 insert 条目(完整片段参见配置),然后使用 overlay 启动:
git clone https://github.com/snailium/dsh-repeat-tool-breaker.git
dsh --profile --patch /path/to/overlay.yml --dump-config # resolve check, does not boot
dsh --profile --patch /path/to/overlay.yml "reply ok" # real apply run
这里 name 必须是此 checkout 的 index.js 的绝对路径,因为该包无法从 profile 目录解析。
选项 C——从 npm 安装,手动挂载
markdown
dsh plugin --profile add dsh-repeat-tool-breaker
dsh plugin add 会转发到该 profile 的包管理器,因此该插件会成为
一个普通的 profile 依赖项,其 name 会从手写的 insert 行解析为包说明符
dsh-repeat-tool-breaker。package.json 中的 files/exports
条目控制发布内容。
它如何阻止循环
DeepSeek Harness 上的工具调度流程如下:
tool/call
→ tools/pre-execute (allow / deny / ask)
→ tools/guard() ← 本插件的闸门
→ tools/execute (真正的工具主体)
→ tools/post-execute
→ tools/result
从 guard 返回 string 是一种最终的、单调的拒绝:它无法
通过监听器顺序被重新允许,而且——关键在于——工具主体永远不会运行。
这正是该闸门与官方提醒的区别所在,后者只会在调用已经执行之后
注入一条更温和的“你重复了 X”消息。自 0.4.0 起,
该断路器还拥有自己的两个建议阶段,通过同一个
tools/post-execute 通道传递(参见出现轨迹),
并且自 0.5.0 起,失败建议也搭载该通道。
该 guard 被刻意设计为同步的:没有 await,没有 DNS,没有磁盘读取。
调用如何被指纹化
每次调用都会贡献一组指纹。其中任何一个达到其上限
都会拒绝该调用,因此规避其中一个(例如换一个新的主机拼写)仍会在另一个上
发生碰撞(例如新的 sink: 或 cmd:)。
| 指纹 | 构建来源 | 捕获对象 |
|---|---|---|
| exact:: | 工具名称 + 删除诱饵字段、键深度排序后的参数 | A、B |
| cmd:: | 动词 + 移除易变标志(--max-time、-s、--retry、timeout N、-sSL 组合……)后的命令 | C、B |
| net:? | http(s) URL,其中 scheme 被默认化,www. 和默认端口被去除,主机别名被折叠,片段被丢弃,末尾斜杠被裁剪,并且查询被保留(排序、移除跟踪参数)——查询正是让 ?page=2 成为不同资源的原因 | C,且它绝不能对分页触发 |
| host: | 每个 URL 的规范化主机,不含路径和查询(0.4.0 新增) | 一种固定策略:针对同一目标发出许多不同请求,而 net: 无法看到这一点,因为每个查询字符串都不同。本地主机默认被排除(includeLocal) |
| site: | 每个 URL 的可注册站点(IP 字面量单独存在)——注意这会将 api.github.com 合并为 github.com | 默认不设上限:siteOf() 折叠为两个标签,因此它会合并不相关的服务(api.weather.gc.ca → gc.ca);host: 度量才是有区分力的那个 |
| sink: | shell 命令的 -o/--output/-O/>/>>/tee 目标——通用目标(/dev/null、-……)除外,因为它们无法说明哪个资源被获取 | C |
| family:http-fetch | 每次 curl / wget / http / httpie / 接受 URL 的工具调用 | 默认无任何内容——一个无论怎样设置都无法避免误报的容量预算 |
| verb: | 第一个非包装命令词(sudo、timeout 30、FOO=1 是透明的) | 同一动词内的工具替换 |
本地地址
localhost、回环地址、RFC1918 和链路本地主机是开发循环所通信的对象——开发服务器、本地推理端点、容器——而目标作用域指纹无法将它们与网络爬取区分开来。localHosts 决定本地流量是否被指纹化:
| 值 | 行为 |
|---|---|
| deny(默认) | 本地调用像任何其他主机一样被计数和阻止 |
| allow | 本地流量从不被指纹化:不发出 net:、site:、host:、sink:、family: 或 verb:,因此只有 exact: 和 cmd: 仍能标识该操作 |
ask 值已在 0.4.0 中移除。询问不再是仅限本地的问题——它是门控对每项措施所做的——因此它移到了 onLimit。仍带有 localHosts: ask 的配置会在加载时大声失败,并给出迁移提示 use onLimit: ask ,与 0.2.0 处理已移除键的方式相同;唯一接受的值是 deny 和 allow。
本地主机默认也被排除在 host: 措施之外(includeLocal: false)。针对 localhost 的开发循环是典型的合法场景,而文档中记录的 site:127.0.0.1 误报正是来自这一类。localHosts: allow 仍然优先——它完全不发出目标指纹。
- id: repeat-tool-breaker
config:
localHosts: deny # deny | allow
includeLocal: false # whether local hosts feed the host: measure
因为 exact 和 cmd 标识的是操作而非目标,allow 不会放宽它们:字节完全相同的重复就是循环,无论它是否指向 localhost;而一个即使只提到一个公共 URL 的调用也根本不是本地调用。
计数规则
- 状态是按代理的滑动窗口(window,默认 16 次调用),保存在以活动 Agent 对象为键的 WeakMap 中——一个代理的循环永远不会触发另一个代理的循环,子代理有自己的预算。
- 当某个指纹在窗口中已经出现 limit - 1 次时,即当前调用将成为第 limit 次出现时,该调用被拒绝。因此任何事物的第一次出现总是被允许。
- 两个建议阶段在提交之前计算,因此它们报告的计数包含当前调用,并且每个阶段仅在其计数等于其阈值时触发。
- 守卫在两种结果下都会提交,但提交的内容不同,而这种差异至关重要:
- 被允许的调用提交它携带的每个指纹——该操作确实发生了,因此它拥有自己那份预算;
- 被拒绝的调用只提交达到其上限的指纹。该
操作从未运行,因此它绝不能把预算花在一个它从未触碰过的资源上。
一次实测运行显示了搞错这一点的代价:一次被拒绝的
curl https://example.org 污染了 net:example.org/,此后模型
在本轮剩余时间里无法通过任何工具获取该 URL。命中的
指纹已经达到上限,所以重新尝试被阻止的调用无论如何仍会被阻止。
- 已批准的豁免停止计数,而不仅仅是阻止:被豁免的
指纹在提交时被丢弃,因此它们不会被递增,并且在本轮剩余时间里
无法再次升级。豁免是按指纹进行的,对任何其他措施
不作任何说明。它适用于两条轨道:被豁免的
指纹也不计入失败连续次数。
- 失败轨道保留其自己的按指纹 failStreak,与
窗口分开(参见失败轨道)。一条真实的用户
消息会连同窗口、豁免和拒绝一起清除它。
- 一条真实的用户消息(agent/pre-step,来源为 kind: 'user')会清除该
代理的窗口——以及随之而来的豁免和拒绝。插件通知和
工具结果不会——否则断路器自身的拒绝会重置它正在执行的
预算。
- 被排除的工具(exclude,默认 todo_write;支持 通配符)是
完全透明的:它们既不计数也不重置。
配置
通过 profile bundle 挂载(上面的选项 A——bundle 层中没有 config:,
应用默认值)、--patch 覆盖层,或 profile 的 cordis.patch.yml。
该插件导出一个对象形式({ name, inject: ['tools'], apply });
inject: ['tools'] 会推迟 apply,直到真正的 ToolRuntime 服务上线,
此时 ctx.tools.guard 就是真正的方法。
- insert:
- id: repeat-tool-breaker
name: dsh-repeat-tool-breaker
config:
window: 16 # 每个代理参与的近期调用
onLimit: ask # ask | deny — 在任一关口发生什么
localHosts: deny # deny | allow — 参见“本地地址”
warnAt: 7 # 出现阶段 1;0 / 负数 / null 禁用它
summarizeAt: 11 # 出现阶段 2;0 / 负数 / null 禁用它
failWarnAt: 3 # 失败阶段 1;与 warnAt 相同的关闭开关
failLimit: 5 # 失败关口;null 禁用它,保留建议
includeLocal: false # 本地主机是否供给 host: 措施
previewChars: 400 # 引用的指纹的截断长度
resultPreviewChars: 800 # 引用的上一个结果的截断长度
exclude: [todo_write] # 从不计数,从不重置(支持 通配符)
include: [] # 非空 = 仅这些名称/模式计数
ignoreArgs: # 合并到默认值之上
'': [description, timeoutMs, run_in_background, justification, reason, title, comment]
bash: [description, timeoutMs, run_in_background, justification]
hostAliases: # 在默认值之上合并
open-data.canada.ca: open.canada.ca
limits: # 在默认值之上合并;null 会同时禁用两条轨道
limits 就是 occurrence 第三阶段阈值——不存在 gateAt。
exact: 12
cmd: 12
net: 12
sink: 12
host: 16 # 0.4.0 新增:一个目标,多个不同请求
site: null # 容量预算:默认关闭,见“调优”
'family:http-fetch': null
'verb:curl': null
'verb:wget': null
合并语义,在重新调优时很重要:
- ignoreArgs、hostAliases 和 limits 会在默认值之上进行一层深合并,因此你可以添加一个主机别名或重新调整一个上限,而无需重述整张表。
- 标量会替换;数组 exclude 和 include 会整体替换,因此一个包含两项的 exclude: 列表就是完整列表,而不是对默认 todo_write 条目的追加。
- 补丁会替换目标行的整个 config,因此 config 键不会从 bundle 层继承。
每个值都会在 apply 中以 fail-loud 方式校验:window >= 4;onLimit 为 ask/deny 之一;localHosts 为 deny/allow 之一;includeLocal 为布尔值;每个 limit 要么为 null,要么为有限数字 >= 2(低于 2 的上限会拒绝第一次调用);failLimit 要么为 null,要么为有限数字 >= 2;previewChars/resultPreviewChars >= 1;以及一个启用的阶段——warnAt、summarizeAt 或 failWarnAt——为正整数。不会将任何设置与另一个设置进行对照检查——颠倒的阶段只会以另一种顺序触发,低于某个阶段的 limits 条目意味着“此度量不升级”,而两个失败设置彼此独立,也与 occurrence 设置独立;这些都是表达意图的合法方式。0.2.0 中移除的配置键(denyAfter、warnAfter、registerAdvisory、maxSamePath、readTools、matchReadBySubstring)会抛出错误并指向其替代项,而不是被忽略,并且 localHosts: ask(0.4.0 中移除)会抛出错误并给出 onLimit: ask 迁移提示——因此升级后的 profile 不会静默丢失其调优。
(- insert: 列表是添加新插件所必需的;扁平的 - id: 条目是对已存在 id 的重新配置,对于尚未出现在组合树中的插件会失败并提示“entry not found”。)
调优,以及这些数字是如何选定的
本小节中的每个数字都属于 occurrence 轨道。失败轨道的 3 / 5 在
失败轨道中单独论证。
该表混合了精确上限和宽泛上限,这一区别很重要:
- 精确、按操作限定、上限 12:exact、cmd、net、sink。这些会触发
只有在同一个动作实际上再次发生时,它们才会出现,而它们正是用来捕获循环的。每个更低的值都针对真实工作做过尝试,且每个都产生了误报。上限为 2 时,就没有空间容纳最常见的非循环重复:第一次尝试失败的原因与循环毫无关系——可能是测试框架强制实施的前置条件,也可能是 DNS 故障——而正确的应对方式是
to retry the same call; at 2 that retry is what gets blocked, and the only way
forward is to cosmetically change the call, which is exactly what this plugin
exists to stop. At 3 the retry fits, but dense legitimate work still tripped,
because sink: is path-only by design — its whole job is to catch one
destination rewritten with ever-changing content — so a shell cycle that writes
the same file several times while iterating looked exactly like a loop. At 5 an
ordinary edit/test cycle fits. 0.4.0 moved the cap to 9 because two advisory
stages now sit underneath the gate — 3 warned, 6 demanded a summary, 9 gated —
so the hard break moved later* instead of firing at the first threshold. 0.4.2
retunes the stages and the cap together to 7 / 11 / 12: measurement over 109
recorded sessions showed the old stages firing on runs that succeeded (two
known-good runs peaked at 4 and 6 repeats, both at or above the old
warnAt: 3) and 10% of real sessions reaching a peak of 13, above the old cap
of 9.
- the new host: measure is active, and it is not a volume budget: it counts
one normalized host, with no path and no query, so it fires when an agent keeps
going back to the same target with genuinely different requests. That is a
failure mode repetition counting cannot see — net: keeps the query by design,
so every page of one API is a different resource, and site: merges unrelated
services. It is reconciled with the disabled volume budgets by the mechanism
around it: its two lower stages are advisory, and its gate is operator-gated
(and fail-closed when unattended), rather than an automatic volume cap. Its cap
is 16 — the whole window — rather than the 12 the action-identity measures
use, because host: is the coarser measure: it discards the path, so
installing many packages from one mirror and re-fetching one broken URL look
identical to it, and it accounted for 25 of the 48 (session, fingerprint)
pairs that reached a cap under the old defaults. The measurement is in
docs/issue-b-thresholds.md.
- not counter-based at all: file operations. There is no readpath or
writepath limit. A file action is identified by its position through
exact: — the same file at the same offset, or the same replacement string, is
the same action and is denied; a different offset or a different region is a
different action and is never blocked. 0.2.0 shipped path-only counters for
these and they both had to be removed after blocking ordinary work on the
reference deployment.
- volume budgets, off by default: site, family:http-fetch, verb:curl,
verb:wget. These counted how MUCH one site or one verb was used. They are all
null now, because a volume budget cannot tell a crawl from a session that is
simply making progress, and every value tried produced a false positive on a
real one:
| Setting | What it blocked |
|---|---|
| family:http-fetch: 4 | a task asking for the status code of four different URLs (blocked from the second) |
| site: 3 | ordinary development calls that merely mentioned a loopback URL |
| site: 3 | a session paginating a GitHub commit list — api.github.com and github.com share one budget, so it tripped after three fetches |
The last one is the clearest argument: the agent's own comment in that session
was # Fetch page 2 of openvino commits using a script file to avoid repeat
detection — a volume cap that pushes an agent to work around the breaker
instead of changing approach is worse than no cap at all.
Repetition is what this plugin detects, and the action-scoped caps do that:
exact, cmd, net, and sink (one destination rewritten with changing
content). host: extends it to a target that is revisited with changing paths
and queries. If you do want a crawl budget, set one:
- id: repeat-tool-breaker
config:
limits:
site: 30 # at most 30 fetches per site per window
'family:http-fetch': 60
Deliberate deviations from the v2 specification
All of these came out of running the plugin against a live model on the reference
deployment.
1. No path-only counter for file tools at all. The spec folded reads and
writes of one path into a single sink: counter, which denies the second half
of the ordinary pair read foo.ts → write foo.ts. 0.2.0 replaced it with
separate readpath/writepath counters and 0.2.2 removed both, because a
path-only counter cannot see POSITION: it blocked re-reading a file that was
being edited, and blocked the third iteration on a single document. File
actions are identified by exact: alone, which is position-aware by
construction. sink: still means what §3.6 defined it as: where a shell
command writes its bytes.
2. pathAliases is gone (0.2.4). The spec's list (path, filePath,
file, target_file) had to gain file_path, the key dsh's own file tools
actually use — but that key existed only to feed the path-only readpath/
writepath fingerprints, which 0.2.2 removed. 0.2.4 deletes the inert key and
its firstPathArg helper, so the documented configuration is exactly what the
code reads. A config that still lists pathAliases is accepted and ignored.
3. Generic sinks are not fingerprints. curl -s -o /dev/null -w '%{http_code}'
is the idiomatic way to ask for a status code, and treating /dev/null as
action identity made four different URLs collide on sink:/dev/null starting
with the second.
4. The volume caps no longer ship at all, and a denied call commits only the
fingerprints that hit. The first started as a deviation from the spec's 4
(it shipped 6) and 0.3.2 turned it off entirely: no value could tell a crawl
from progress, and every one tried produced a false positive on a live session
— see Tuning. The second was changed
because a denied curl was charging net: for a URL it never fetched, locking
the model out of that URL entirely.
Development loop (dependency-free)
cordis.patch.yml in this repo is a ready-made overlay — point its name: at the
absolute path of this checkout, then:
1) prove the overlay + module resolve (prints the composed tree; does NOT boot)
dsh --profile --patch ./cordis.patch.yml --dump-config | grep repeat-tool-breaker
2) real apply run on a SAFE profile
dsh --profile --patch ./cordis.patch.yml "reply ok"
Two things worth knowing:
- Never point this at a profile that serves a live UI (in the reference
deployment that is the web profile). Boot a headless test profile, or an
isolated DSH_HOME, instead.
- Step 1 does not import the module, so a syntax or resolution error only surfaces
in step 2. To confirm the gate really is wired in step 2, add a temporary
console.log(typeof ctx.tools.guard) at the top of apply and remove it after
— the shipped file intentionally logs nothing.
Acceptance
npm test # node --test test/breaker.test.js
58 tests, no model or endpoint required. The suite mirrors the v2 spec's table
(T1 ping-pong, T2/T3 description decoys, T4 unrelated calls, T5 curl↔wget, T6
exclusion, T7 per-agent isolation, T8 volatile flags, T9 normalizer units, T10
read paths, T11 denied calls still spend budget), adds the plugin-level wiring
(T12: the guard denies, quotes the previous result, survives a plugin notice,
resets on a human turn; T12c: the fail-loud config contract), and documents the
shipped defaults (T14: the 0.4.2 table; T14b/T14c: volume is not a loop signal,
?page=N stays a new resource while host: is the convergence measure that
accumulates across pages).
The 0.4.0 occurrence escalation has its own tests:
- T24 — the three occurrence stages fire at warnAt, summarizeAt and the cap
on one measure, with the assertions derived from the defaults rather than
hard-coded (7, 11 and 12), and once per crossing rather than on every later
call;
- T25 — host: accumulates on a public host across distinct paths and queries,
while local hosts are excluded from it by default;
- T26 — an exemption covers only the measures that hit;
- T27 — a disabled stage is never delivered;
- T20–T23 — the gate end to end: an approved ask stops counting that measure
for the turn, any measure can be asked about (not only local targets), a
declined ask denies without re-prompting, and a human turn clears the exemption
and the window;
- T14d–T14f — the silent stage off-switch, no cross-setting validation, and
the localHosts: ask migration error.
The 0.5.0 failure track has its own tests, all asserting that isError is not
the failure test:
- T30 — consecutive failures of one fingerprint warn at failWarnAt, quoting
the failure reason and never the exact: command line, while the occurrence
stage at 7 has not fired;
- T31 — the gate blocks the failing target but not the recovery call (a grep)
and not a different target;
- T32 — a success of the failing fingerprint clears the streak, while a success
of a different fingerprint does not;
- T33 — the plugin's own denial is never counted as a failure, so feeding the
gate its own denials does not grow the streak;
- T34 — a null-capped measure is invisible to the failure track;
- T35 — failLimit: null drops the gate and keeps the advisory, which still
fires exactly once;
- T36 — failWarnAt: 0 disables the advisory but not the gate;
- T37 — failLimit validation is fail-loud and failWarnAt follows the same
silent off-switch as warnAt.
Assertions worth singling out, because they are the ones that would have caught
v1 — or that caught v2's own defaults:
- every fingerprint of a description: '1st'/'2nd'/'3rd' call is asserted to
contain neither the decoy text nor the timeoutMs value;
- the deny path is asserted to be reached for host-spelling ping-pong whose
exact: fingerprints differ;
- one failed attempt is asserted to leave room for the identical retry (T2b),
while a call that keeps failing is still blocked;
- the local-address matrix and the gate are asserted end to end (T17–T23):
localHosts: allow emits no local target fingerprint, a mixed call (one local
plus one public URL) is not a local call, an approval exempts exactly the
measures that hit and no others, a refusal stops the asking until the next human
turn, and the gate is not local-only;
- four different URLs writing to /dev/null are asserted to all be allowed, and
a denied call is asserted not to spend net: budget on the URL it never
fetched.
Verified on a real model
Beyond the unit suite, the plugin was driven end-to-end through the official
dsh-container harness (ghcr.io/snailium/dsh-container/dsh) against a local
Qwen3.8-27B on llama.cpp, in a throwaway DSH_HOME:
| Scenario | Result |
|---|---|
| curl -s -o /tmp/od.html https://open-data.canada.ca/ (description: '1st') then the same fetch of https://open.canada.ca/ ('2nd') | 1st executed; the 2nd collided on net:open.canada.ca/ and sink:/tmp/od.html — it was denied then, under the cap in force at the time |
| echo hello-repeat twice, description '1st' / '2nd', timeoutMs 60000 / 1000 | 1st executed; the 2nd collided on the identical cleaned command |
| four different URLs, one curl each | all four allowed and returned 200 |
These runs predate 0.4.0, so the counts reflect the cap in force at the time (2,
then 5) and there is no host: measure yet. What they establish is the
collision: the alias spelling, the churning --max-time and the decoy
description do not make a new action. Under the 0.4.2 defaults the same calls
still collide, and the break lands at 12 with the two advisory stages before it.
Real-pipeline check (no model needed)
test/pipeline.e2e.mjs drives the genuine ToolRuntime with a stub bash body,
which proves the denied call's body is never entered — offline and deterministically.
It covers both tracks: the occurrence gate, and (since 0.5.0) a failure scenario
where the same command fails every call, so the stub returns a structured
exitCode the failure classifier can read. It needs the dsh packages resolvable,
so it is not part of CI:
DSH_NODE_MODULES=/path/to/dsh/node_modules/@deepseek-ai npm run test:pipeline
Full-boot compatibility check (any dsh version)
test/compat/ boots a real dsh of the version under test with this plugin
mounted as a profile bundle, and drives it with a scripted mock model — no GPU,
no real endpoint. mock-llm.py speaks enough of the OpenAI streaming protocol to
make the agent repeat a scripted bash call, and run-compat.sh reads the
shipped cap out of the plugin's own DEFAULTS (so a retune does not break the
harness) and asserts the resulting tool-result trajectory. It also covers a
local-address loop with no answerer, where the fail-closed onLimit: ask must
deny rather than stall, and pagination of one endpoint, which must never block.
Since 0.5.0 it also drives the failure track — the same command failing every
turn, stopped after failLimit consecutive failures, which lands well before the
occurrence cap — and the two occurrence scenarios neutralise their exit status
(|| true) so that they test the track they name.
DSH_PREFIX=/tmp/dsh-compat
mkdir -p "$DSH_PREFIX" && cd "$DSH_PREFIX" && npm init -y
npm install --no-audit --no-fund @deepseek-ai/dsh@
cd
DSH_PREFIX=$DSH_PREFIX ./test/compat/run-compat.sh
It checks what unit tests cannot: that the loader accepts the dsh.bundle
manifest, that the bundle's patch layer mounts the row, that apply() runs with
inject: ['tools'] satisfied, and that a denial reaches the model as an
isError tool result. Archived reference output, recorded on @deepseek-ai/dsh
0.1.5-rc.2 with a shipped cap of 3:
=== dsh under test ===
0.1.5-rc.2
=== bundle mounts? ===
ok
=== cap for action identity: 3 (mock issues 4 identical calls) ===
=== real headless run ===
compat run complete
=== trajectory ===
attempt 1: isError=False | compat-check
attempt 2: isError=False | compat-check
attempt 3: isError=True | Error: REPEAT_TOOL_BLOCKED: ...
attempt 4: isError=True | Error: REPEAT_TOOL_BLOCKED: ...
COMPAT: PASS (2 executed, 2 denied, cap=3)
The attempt counts follow the cap the script derives from DEFAULTS, so the
0.4.2 defaults move the whole trajectory (cap 12, host 16) without any change
to the assertion shape.
Releasing
Publishing runs through .github/workflows/publish.yml, which is
workflow_dispatch-only — nothing is published as a side effect of a push or a
release, and the job refuses to republish a version that already exists.
1. bump the version and update CHANGELOG.md, commit, push
2. trigger the release
gh workflow run publish.yml -f dry-run=false
Authentication uses npm Trusted Publishing (OIDC): the workflow needs
id-token: write (already set) and a matching trusted-publisher connection on the
npm package page — repository snailium/dsh-repeat-tool-breaker, workflow
filename publish.yml, environment empty. No long-lived token is required, and
provenance is generated automatically.
Two things that will save you time:
- Allow the right action. A trusted-publisher connection created after
2026-09-03 defaults to allowing only npm stage publish. If direct
npm publish is not selected under "Allowed actions", the registry answers
403 ... OIDC permission denied for this action. Connections cannot be edited:
delete and recreate.
- Debugging a 403. Run gh workflow run publish.yml -f dry-run=true -f debug-oidc=true
to print the OIDC claims npm authorises against (repository,
job_workflow_ref, aud, …) and compare them with the connection's fields.
The npm CLI must be >= 11.5.1 and Node >= 22.14.0 for OIDC; the workflow upgrades
the npm CLI explicitly because Node 22 bundles an older one.
Scope and verification status
Verified
- Deterministic suite (npm test) — 58 tests covering the full fingerprint
matrix, decoy stripping, host folding, sink extraction, the host: measure, the
three occurrence stages, the two failure stages, window arithmetic, per-agent
isolation, the user-message reset, the gate's ask/deny outcomes, and the
fail-loud config contract. Runs in CI on Node 20 and 22 with no model or
endpoint.
- Loads and applies on a real DSH boot, including as a profile bundle (the
dsh.bundle layer mounts the row by package specifier). Verified on
@deepseek-ai/dsh 0.1.2-rc.1 (the reference deployment) and
0.1.5-rc.2 (via test/compat/, which boots the real CLI and asserts the
denial in the trajectory).
- API surface is unchanged between those two versions: ToolGuard,
guard(), the tools/pre-execute / tools/post-execute signatures,
ToolExecutionInput/ToolExecution, the decision unions and the
agent/pre-step payload all diff clean, and the tool names the plugin keys on
(bash/pwsh, read/write/edit, web_fetch/web_search) are stable.
- Driven by a real model in the dsh-container harness (under the pre-0.4.0
defaults) — the three scenarios in
Verified on a real model, plus the real
ToolRuntime pipeline driven in-process with a stub tool body (which proves the
denied call's body is never entered).
Not covered here
- The live-model runs are manual, not part of CI: they need a local inference
backend and the dsh-container image. npm test is the CI gate.
Intentional limits
- The breaker is a safety net, not a semantic deduplicator. Two genuinely
different commands that happen to write the same non-generic file collide on
sink:, and that is by design — the denial message tells the model to work from
what it has.
- Fingerprints are computed from the arguments, never from the tool's output, so
a loop that varies only the working directory (cd a && curl X vs
cd b && curl X) still collides on net: but not on cmd:.
Design notes
- Counting lives in the guard and nowhere else. That single locus is what
prevents the guard/post-execute double count, the reset-your-own-budget hole,
and the "denied call charges a resource it never fetched" hole.
- Windows, not consecutive runs. v1's run counter reset as soon as a different
signature arrived, which is exactly the A, B, A, B pattern class C exploited.
The failure track is the one place a consecutive run is the signal, and it is
scoped to one fingerprint rather than to every call in a row.
- Two tracks, one gate. The occurrence and failure tracks are computed
separately but merged into a single hit list before the exemption and refusal
logic runs, so there is one ask, one refusal set and one way to get stuck. The
failure hit is checked first because it is the more actionable of the two.
- The failure track reuses the occurrence measure set, including the
null-cap off-switch, so a measure disabled for repetition cannot leak back in
through the failure channel.
- Fail loud in apply: no schemastery Config export (keeping the plugin
dependency-free is deliberate — cordis.resolveConfig passes config through
unchanged when a plugin exports no Config), but every load-bearing invariant
is validated at load and throws rather than silently degrading.
- The denial text is the model's only new information, so it names the
fingerprints that hit with their counts, states explicitly that changing the
description / timeoutMs / --max-time / host spelling is not a new action,
and quotes the previous result inline (or says the previous result is already
in the history). It contains no -shaped markup.
- Escalation is advisory first. Every advisory stage — occurrence 1 and 2,
and the failure track's stage 1 — rides tools/post-execute
additionalContexts stamped source.kind: 'plugin', because a guard can only
return a denial — and an unlabeled context would render as a user prompt in
derived history. At most one message per track is delivered on a call, and the
failure one goes first when both fire. The gate itself is split across two
hooks: tools/pre-execute
can ask but cannot deny, and ctx.tools.guard can deny but cannot ask. A
rejected ask never reaches the guard, so "the guard saw this execution" is
exactly the approval signal.
- State is in-memory only; a resumed session starts fresh (same tradeoff as
the official reminder).