DeepSeek Harness Plugin Hub

发布与管理完整 Harness Profiles,发现适合你的插件。

探索

插件目录环境预设文档中心动态

社区

发布插件联系我们报告问题

相关链接

Plugin Hub GitHubDeepSeek Harness 官方项目系统状态隐私说明
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

独立、非官方社区项目,与 DeepSeek 官方无隶属、授权或背书关系。

Repeat Tool Breaker — DeepSeek Harness 插件(DSH Plugin)
← Plugins
R

dsh-repeat-tool-breaker

Repeat Tool Breaker

对重复工具调用进行强制中断:一个同步的 ctx.tools.guard 门控机制,在每个代理的滑动窗口内统计语义指纹(net/site/sink/cmd/exact),忽略 description 和 timeoutMs 等诱饵参数。

插件会安装到这里;不确定时保持 web。

npx -y @deepseek-ai/dsh plugin --profile web add github:snailium/dsh-repeat-tool-breaker#5b0c3b017b62e13fb4cb05d97c033f16b4f0ad36
README兼容性版本

兼容性与来源证明

Repeat Tool Breaker 以 dsh-repeat-tool-breaker 发布,当前版本为 0.3.3。Plugin Hub 会校验它的 manifest,并保存精确安装来源,便于复现安装结果。

DSH 兼容范围
*
运行环境
any
发布来源
github
Registry 更新时间
2026/9/16

版本

0.3.3stable
2026/9/16
0.3.2stable
2026/9/15
0.2.3stable
2026/9/15
查看其余 2 个版本收起版本
0.2.0stable
2026/9/15
0.1.3stable
2026/9/13

相关插件

继续浏览 security-access 分类下经过校验的插件。

Doctor@linxin666/dsh-doctorDSH 配置档案的事务性救援模式,配备受监督的启动器、隔离的恢复容器、确定性修复、健康监控以及本地 Web 恢复控制台Pocketdsh-pocket把 DeepSeek Harness 装进你的口袋:一个包、一个设置页,手机扫码即同步访问电脑上的 DSH(局域网 + 公网,实时同屏)。DSCODE@toddzheng024/dscode-bundle完整的 DeepSeek 编码代理,支持持久化 shell、Ultra 协作和自动权限审查。Auto Reviewdsh-auto-review针对 DeepSeek Harness 审批请求的第二模型 AI 自动审查:只读审查子代理在审批应答链上决定允许或拒绝,并采用故障关闭回退机制和完整的会话日志审计。
最新版
0.3.3
DSH
*
HMR
重启进程
Tree shaking
未声明可安全裁剪
解包体积
未提供
文件数
未提供
Surface
any
许可证
MIT
发布源
github
GitHub
★ 0
周下载
0
最近提交
2026/9/16
查看源码 ↗
README Badge

点击下方 Badge 复制 Markdown,粘贴到 README 即可。

这是你的 Plugin?认领权益 · 优先安全扫描

验证 package.json 声明的 GitHub 仓库,即可管理这个公开页面。认领后,Hub 会优先安排当前版本的安全扫描,并在通过后公开展示结果。

认领这个 Plugin →
报告问题
DeepSeek Harness Plugin Hub
ProfilesPlugins分类动态文档登录管理 Profiles
ProfilesPlugins分类动态文档登录

README

dsh-repeat-tool-breaker

Hard break on an agent's repeated tool calls. A local, dependency-free DeepSeek Harness (DSH) plugin that registers one synchronous gate on the public ctx.tools.guard API: when a call repeats an action already seen inside the agent's sliding window, the call is denied before it executes and the model gets an isError result starting with REPEAT_TOOL_BLOCKED that quotes the previous result and says what to do instead.

In one paragraph, the v1 → v2 story:

v1 lost because it counted byte-identical consecutive calls while the model varied a presentation field (description: '1st'|'2nd'|'3rd', churning timeoutMs) and ping-ponged between host spellings (open-data.canada.ca ↔ open.canada.ca) with a different --max-time each time — every call looked new, so the counter never advanced. v2 wins by deleting decoy arguments before any fingerprint is built, then counting semantic fingerprints (net:, site:, sink:, cmd:, exact:, family:, verb:) over a per-agent window of the last 12 calls, so a repeat has to change the actual resource — not its spelling — to pass.

The three loops, and what catches each

LoopCaught by
Athe same read/write/bash arguments again, verbatimexact: (5)
Bdescription: '1st'/'2nd'/'3rd', command unchangeddecoy arguments are stripped before fingerprinting, so the calls become byte-identical → exact: (5)
Ccurl --max-time 60 open-data.canada.ca ↔ curl --max-time 30 open.canada.canet: (5) after host-alias folding, plus sink: (5) and cmd: (5) after volatile-flag stripping

The sibling official plugin @deepseek-ai/dsh-repeat-tool-reminder (advisory, at 3/5/8 repeats) may stay on — the two compose, with the reminder as the soft nudge and this breaker as the hard gate at 5.

Requirements

  • Node.js >= 20 (developed and tested on 22).
  • A DSH profile that exposes the tools service. Built and verified against @deepseek-ai/dsh 0.1.2-rc.1.
  • No runtime dependencies — the plugin imports only its own lib/ modules (no cordis, no schemastery), so it can be mounted straight from a path.

Install

Option A — list it as a profile bundle (recommended)

The package declares "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }, so it is a first-class profile bundle: no hand-written mount row is needed.

dsh plugin --profile <name> add dsh-repeat-tool-breaker

Then add it to the profile's ordered bundle list ($DSH_HOME/profiles/<name>/package.json):

"dsh": {
  "profile": {
    "bundles": [
      "@deepseek-ai/dsh-base",
      "@deepseek-ai/dsh-web-app",
      "dsh-repeat-tool-breaker"
    ]
  }
}

The bundle's patch layer mounts the plugin with no config:, so the fail-loud DEFAULTS really are the defaults. To tune it, reconfigure the row by id from the profile's own cordis.patch.yml — remember a patch replaces the targeted row's whole config instead of merging into it, so restate every field you want (see Configuration).

Naming a bundle-less package in dsh.profile.bundles is a hard boot error (declares no dsh.bundle in its package.json), which is why the manifest above is required for this path.

Option B — mount from a path (dev loop, no install)

Clone this repo and add an insert entry to a profile (see Configuration for the full snippet), then boot with the overlay:

git clone https://github.com/snailium/dsh-repeat-tool-breaker.git
dsh --profile <name> --patch /path/to/overlay.yml --dump-config   # resolve check, does not boot
dsh --profile <name> --patch /path/to/overlay.yml "reply ok"      # real apply run

Here name must be an absolute path to this checkout's index.js, because the package is not resolvable from the profile directory.

Option C — install from npm, mount by hand

dsh plugin --profile <name> add dsh-repeat-tool-breaker

dsh plugin add forwards to the profile's package manager, so the plugin becomes a normal profile dependency and its name resolves to the package specifier dsh-repeat-tool-breaker from a hand-written insert row. The files/exports entries in package.json control what ships.

How it stops a loop

Tool dispatch on the DeepSeek Harness runs:

tool/call
  → tools/pre-execute      (allow / deny / ask)
  → tools/guard()          ← THIS plugin's gate
  → tools/execute          (the real tool body)
  → tools/post-execute
  → tools/result

Returning a string from a guard is a final, monotonic denial: it cannot be re-allowed by listener ordering, and — critically — the tool body never runs. That is what distinguishes a hard break from the official reminder, which only injects a softer "you repeated X" message after the call already executed.

The guard is deliberately synchronous: no await, no DNS, no disk reads.

How a call is fingerprinted

Each call contributes a set of fingerprints. Any one of them reaching its cap denies the call, so dodging one (a new host spelling) still collides on another (a new sink: or cmd:).

FingerprintBuilt fromCatches
exact:<tool>:<json>tool name + arguments with decoy fields deleted, keys deep-sortedA, B
cmd:<verb>:<command>verb + command with volatile flags (--max-time, -s, --retry, timeout N, -sSL clusters…) removedC, B
net:<host><path>?<query>http(s) URL with the scheme defaulted, www. and default ports dropped, host aliases folded, the fragment discarded, a trailing slash trimmed, and the query kept (sorted, tracking parameters removed) — the query is what makes ?page=2 a different resourceC, and it must NOT fire on pagination
site:<last-2-labels>registrable-ish site of each URL (IP literals stand alone) — note this merges api.github.com into github.comnot capped by default; see Local addresses
sink:<path>-o/--output/-O/>/>>/tee target of a shell command — except generic destinations (/dev/null, -, …), which say nothing about which resource was fetchedC
family:http-fetchevery curl / wget / http / httpie / URL-taking tool callnothing by default — a volume budget no setting of which avoided false positives
verb:<cmd>the first non-wrapper command word (sudo, timeout 30, FOO=1 are transparent)tool-swapping within one verb

Local addresses

localhost, loopback, RFC1918 and link-local hosts are what a development loop talks to — a dev server, a local inference endpoint, a container — and a site: budget cannot tell them apart from a web crawl. localHosts decides:

ValueBehaviour
ask (default)the first local call that would be blocked asks the operator instead — once per turn
denynever ask: local calls are counted and blocked like any other host, and the denial names this knob
allowlocal traffic is never fingerprinted

ask is the default because it is fail-closed. Every unattended outcome of an approval is a denial — rejected (the session policy is never), cancelled (the turn was aborted), and unavailable, which is what the registry falls back to when no answerer is registered — so a headless profile degrades to deny on its own. Nothing has to be configured per profile:

  • a profile with a UI gets the prompt;
  • a profile without one keeps blocking local calls, and the only difference from deny is that the model's first blocked local call of a turn is told "requires approval" instead of REPEAT_TOOL_BLOCKED (from the second one on the breaker's own message applies again).

Set deny where even that is unwanted, or allow to stop counting local traffic entirely:

- id: repeat-tool-breaker
  config:
    localHosts: deny

What an approval buys: the local target fingerprints (net, site, sink, family, verb) stop blocking for the rest of that turn. What it does not buy: exact and cmd are untouched, because a byte-identical repeat is a loop whether or not it points at localhost — and a call that mentions even one public URL is not a local call at all. Declining an ask stops the asking and behaves like deny until the next human message.

An ask is only ever made when local traffic is the only reason the call would be denied, so a real repeat is a straight denial rather than a prompt.

Counting rules

  • State is a per-agent sliding window (window, default 12 calls) held in a WeakMap keyed by the live Agent object — one agent's loop never trips another's, and subagents get their own budget.
  • A call is denied when a fingerprint already appears limit - 1 times in the window, i.e. when the current call would be the limit-th occurrence. The first occurrence of anything is therefore always allowed.
  • The guard commits on both outcomes, but what it commits differs, and that difference is load-bearing:
    • an allowed call commits every fingerprint it carries — the action really happened, so it owns its share of the budget;
    • a denied call commits only the fingerprints that hit their cap. The action never ran, so it must not spend budget on a resource it never touched. A measured run showed the cost of getting this wrong: a denied curl https://example.org poisoned net:example.org/, after which the model could not fetch that URL through any tool for the rest of the turn. The hitting fingerprints are already at their cap, so re-attempting the blocked call stays blocked either way.
  • A real user message (agent/pre-step with source kind: 'user') clears that agent's window. Plugin notices and tool results do not — otherwise the breaker's own denial would reset the budget it is enforcing.
  • Excluded tools (exclude, default todo_write; *-wildcards supported) are fully transparent: they neither count nor reset.

Configuration

Mount via a profile bundle (Option A above — no config: in the bundle layer, defaults apply), a --patch overlay, or a profile's cordis.patch.yml. The plugin exports an object form ({ name, inject: ['tools'], apply }); inject: ['tools'] defers apply until the real ToolRuntime service is live, at which point ctx.tools.guard is the genuine method.

- insert:
    - id: repeat-tool-breaker
      name: dsh-repeat-tool-breaker
      config:
        window: 12                  # recent calls per agent that participate
        localHosts: ask             # ask | deny | allow — see "Local addresses"
        previewChars: 400           # truncation for quoted fingerprints
        resultPreviewChars: 800     # truncation for the quoted previous result
        exclude: [todo_write]       # never counted, never resets (*-wildcards ok)
        include: []                 # non-empty = ONLY these names/patterns count
        ignoreArgs:                 # merged over the defaults
          '*': [description, timeoutMs, run_in_background, justification, reason, title, comment]
          bash: [description, timeoutMs, run_in_background, justification]
        hostAliases:                # merged over the defaults
          open-data.canada.ca: open.canada.ca
        limits:                     # merged over the defaults; null = uncapped
          exact: 5
          cmd: 5
          net: 5
          sink: 5
          site: null                # volume budgets: off by default, see "Tuning"
          'family:http-fetch': null
          'verb:curl': null
          'verb:wget': null

Merge semantics, which matter when retuning:

  • ignoreArgs, hostAliases and limits merge one level deep over the defaults, so you can add one host alias or retune one cap without restating the table.
  • Scalars replace; the arrays exclude and include replace outright, so a two-entry exclude: list is the whole list, not an addition to the default todo_write entry.
  • A patch replaces the targeted row's whole config, so config keys are not inherited from the bundle layer.

Every value is validated fail-loud in apply: window >= 4, every limit either null or a finite number >= 2 (a cap below 2 would deny the first call), and preview caps >= 1. Config keys removed in 0.2.0 (denyAfter, warnAfter, registerAdvisory, maxSamePath, readTools, matchReadBySubstring) throw with a pointer at their replacement rather than being ignored, so an upgraded profile cannot silently lose its tuning.

(The - insert: list is required to add a new plugin; a flat - id: entry is a reconfig of an already-present id and fails with "entry not found" for a plugin that isn't yet in the composed tree.)

Tuning, and how these numbers were chosen

The table mixes precise caps with broad ones, and the difference matters:

  • precise, resource-scoped, cap 5: exact, cmd, net, sink. These fire only when the same action actually happens again, and they are what catches loops. Every lower value was tried against real work and each produced a false positive. A cap of 2 leaves no room for the most common non-loop repeat: the first attempt fails for a reason that has nothing to do with looping — a precondition the harness enforces, a DNS failure — and the correct response is 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, and a call that keeps failing is still stopped on its fifth attempt.

  • 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 (see File operations).

  • 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:

    SettingWhat it blocked
    family:http-fetch: 4a task asking for the status code of four different URLs (blocked from the second)
    site: 3ordinary development calls that merely mentioned a loopback URL
    site: 3a session paginating a GitHub commit list — api.github.com and github.com share one budget, so it tripped after three fetches
- 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 ship at 6, not 4, and a denied call commits only the fingerprints that hit. Both were changed after live runs: the first because a four-URL batch was blocked, the second 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 <name> --patch ./cordis.patch.yml --dump-config | grep repeat-tool-breaker

# 2) real apply run on a SAFE profile
dsh --profile <name> --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

30 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) and 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 the documented shape of the shipped defaults (T14/T14b: volume is not a loop signal) and pagination (T14c: ?page=N is a new resource, re-fetching one page is a loop).

Three 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 localHosts matrix is asserted end to end (T17–T22): a mixed call is never askable, an approval exempts targets but not exact, and a refusal stops the asking until the next human turn;
  • 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:

ScenarioResult
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; 2nd blocked before execution, hits net:open.canada.ca/ 2/2 and sink:/tmp/od.html 2/2
echo hello-repeat twice, description '1st' / '2nd', timeoutMs 60000 / 10001st executed; 2nd blocked, hits the identical cleaned command
four different URLs, one curl eachall four allowed and returned 200

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 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 issue the same bash call four times in a row, and run-compat.sh asserts the trajectory: attempts 1..cap-1 executed, the rest denied with REPEAT_TOOL_BLOCKED.

DSH_PREFIX=/tmp/dsh-compat
mkdir -p "$DSH_PREFIX" && cd "$DSH_PREFIX" && npm init -y
npm install --no-audit --no-fund @deepseek-ai/dsh@<version>

cd <this repo>
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. Reference output on @deepseek-ai/dsh 0.1.5-rc.2:

=== 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)

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) — 30 tests covering the full fingerprint matrix, decoy stripping, host folding, sink extraction, window arithmetic, per-agent isolation, the user-message reset, 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 — 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.
  • 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. It contains no <tool_call>-shaped markup.
  • State is in-memory only; a resumed session starts fresh (same tradeoff as the official reminder).

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 resource-scoped caps do that: exact, cmd, net, sink. If you do want a crawl budget, set one: