snailium/dsh-repeat-tool-breaker

dsh-repeat-tool-breaker

Hard break on repeated identical tool calls in DeepSeek Harness (DSH): a synchronous monotonic ctx.tools.guard gate that denies the 2nd identical call per agent. Dependency-free.

Uncategorizedagentcordisdeepseek-harness

dsh-repeat-tool-breaker

⚠️ Supported dsh: ^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0

0.8.x covers the dsh 0.1.7 line and dsh 0.2.0 (from rc.1, up to but not including 0.2.1). On dsh 0.1.5, install dsh-repeat-tool-breaker@0.7. The same range is declared as a peer dependency on @deepseek-ai/dsh, so a resolver can see it; it is marked optional because dsh is provided by the host at runtime. Note the 0.2.0-rc.1 lower bound rather than 0.2.0: semver admits a prerelease only when a comparator in the same set names one, so >=0.2.0 would reject every 0.2.0 rc. 0.1.7 replaced the settings API, so one build cannot register a settings page on both generations — the two are served by two lines rather than by a version probe.

On dsh 0.1.5, 0.8.x still detects repeats, still blocks shell HTTP and still delivers its advisories (verified on 0.1.5-rc.2: the advisory persists with this release's message source). The one thing that is lost is the settings page, because the Host no longer registers a namespace there and the card needs 0.1.7's client services. Use @0.7 on 0.1.5 if you want the settings UI.

Escalating repeat detection for an agent's tool calls. A local DeepSeek Harness (DSH) plugin that registers one synchronous gate on the public ctx.tools.guard API. A measure repeated inside the agent's sliding window is not stopped at the first threshold: it escalates — a light warning at 7, a written-summary demand at 11, and the gate at 12 (16 for the coarser host: measure), where the operator is offered a turn-scoped exemption (onLimit: ask, the default) or the call is denied outright. When the gate denies, the call never executes and the model gets an isError result starting with REPEAT_TOOL_BLOCKED that quotes the previous result and says what to do instead.

Since 0.5.0 a second track watches the same fingerprints for consecutive failures rather than occurrences: an advisory at 3, and the same gate at 5. Both tracks share one gate, one measure set and one exemption policy. Numbers in this README belong to the occurrence track unless they are labelled failWarnAt or failLimit.

In one paragraph, the v1 → v2 → 0.5.0 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 (exact:, cmd:, net:, host:, site:, sink:, family:, verb:) over a per-agent window of the last 16 calls, so a repeat has to change the actual resource — not its spelling — to pass. 0.4.0 adds escalation: the same measure warns, demands a summary, and only then gates — retuned in 0.4.2 to 7 / 11 / 12, with host: at 16 — and the gate asks the operator rather than blocking silently. 0.5.0 adds the failure track: the same fingerprints are counted for consecutive failures too — advisory at 3, gate at 5 — because repeating a call that works is fixation, while repeating one that fails is not learning.

The three loops, and what catches each

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

A fixed strategy is a fourth failure mode that none of those loops catches: a high-volume but non-repetitive run against one target, where every query string differs so net: never collides. On the reference deployment one agent made 30 bash/curl calls against a single host in one turn and never converged. The host: measure (new in 0.4.0, cap 16) is what makes it visible; see How a call is fingerprinted.

The failure track (0.5.0) is a different axis from all of these: it fires on a fingerprint that keeps failing, even when the number of attempts is far too low to reach the occurrence stages — a model guessing at an endpoint that does not exist, getting a 404, and rephrasing the request. See The failure track.

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 escalating gate that asks at 12.

The occurrence track: three stages

The occurrence track counts repeats. Every measure — one fingerprint identity such as exact:<tool>:…, net:<host><path>?<query>, or host:<host> — has the same three-stage structure. A stage fires once per crossing, on exact equality: the count including the current call must equal the threshold, so the window sliding does not re-announce it. The failure track below has its own two stages.

RepeatsSettingStageWhat happens
7warnAt1 — light warningThe model is told it is repeating and should consider whether a different route would get there faster. Nothing is blocked and nothing is demanded.
11summarizeAt2 — summary demandThe model must write down what it established, what it assumed without verifying, what failed and why, and at least two approaches it has not tried — plus an instruction to use a larger per-batch amount so there are fewer batches. Nothing is blocked.
12the limits entry3 — the gateonLimit: ask (default) offers the operator a turn-scoped exemption; onLimit: deny blocks outright. An unattended ask degrades to a denial.

The occurrence cap is 12 for the action-identity measures (exact, cmd, net, sink) and 16 for the coarser host: measure — the whole window. See Tuning. The failure track's gate is failLimit, a separate setting, at 5.

limits is the occurrence stage-3 threshold. There is deliberately no separate gateAt: a second gate number would be the same value written twice, and two knobs that must agree will eventually disagree.

Stages 1 and 2 are advisory. A guard can only return a denial, so the guard computes the advisory while it still sees the counts and tools/post-execute attaches it as an additionalContexts entry — the channel @deepseek-ai/dsh-repeat-tool-reminder uses — stamped source.kind: 'plugin'. It composes with a downstream block rather than replacing it. (An unlabeled context would render as a user prompt in derived history, which is why the source is mandatory.)

Each stage is an ordinary, independent setting, and no setting is validated against another:

  • 0, a negative number, or null disables a stage silently — that is the documented off-switch, not a value to reject;
  • a limits entry at or below a stage means "no escalation for this measure": exact: 5 under warnAt: 7 gates at 5 with no warning at all;
  • inverted stages are legal too — the stronger message simply fires first.

When several measures cross a stage on the same call, the strongest stage wins and ties go to the highest count.

A stage above a cap can never speak

The gate fires before the advisory, so a summarizeAt at or above every cap is dead code, and no threshold above the window (16) can fire at all. Nothing validates one setting against another — a limits entry below a stage remains a deliberate way to skip an advisory — but the shipped defaults keep summarizeAt strictly below every cap, which is why the stages and the caps move together. The measurement behind the 0.4.2 numbers is in docs/issue-b-thresholds.md.

onLimit — what happens at the gate

ValueBehaviour
ask (default)The operator is offered a turn-scoped exemption, once per measure per turn. Approving exempts exactly the fingerprints that hit, stops counting them for the rest of the turn, and lets later identical calls ride along; declining stops the asking for those measures and denies them until the next human message.
denyNever ask. The cap-th call is denied outright with REPEAT_TOOL_BLOCKED.

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, the value the registry falls back to when no answerer is registered — so a headless profile degrades to deny on its own and nothing stalls. Exemptions are per fingerprint: an exemption for host:api.weather.gc.ca says nothing about exact: or about a different host.

Asking used to be local-only (localHosts: ask). Since 0.4.0 it is what onLimit does for every measure; see Local addresses.

onLimit governs both tracks. A failure hit and a repeat hit go through the same exemption prompt and the same refusal set, so approving exempts exactly the fingerprints that hit whichever track flagged them. A second gate would have needed a second ask, a second refusal set and a second way to get stuck.

The failure track: two stages

New in 0.5.0. A second escalation track watches the same fingerprints for consecutive failures instead of occurrences. It exists because failure is a much stronger signal than repetition: repeating a call that works is fixation, repeating one that fails is not learning. Its thresholds therefore sit well below the occurrence ones (7 / 11 / 12).

Consecutive failuresSettingStageWhat happens
3failWarnAt1 — failure advisoryThe model is told the target has failed three times in a row, with the failure reason quoted, and is pointed at the error, at whether the target exists, and at the cause. Nothing is blocked.
5failLimit2 — the gateA call carrying that fingerprint is blocked, through the same onLimit policy as the occurrence gate — ask by default (fail-closed), deny for a hard break.

The motivating case is a model guessing at an endpoint that does not exist: it keeps getting a 404 and rephrasing the request. The failure is the signal, and it is available long before repetition counting would notice.

What counts as a failure

A failure is not result.isError. That flag is true only when the call failed — a thrown error, an unknown tool, a sandbox denial, an abort — and it is false for the two shapes that matter most: a non-zero exit code and an HTTP error status. Measured over 40 recorded sessions, the thrown case covered 40 of 6765 bash results (0.6%), so counting only it would make the track blind.

lib/failure.js reads the structured result.value each tool declares in its output.schema, structured first:

  • a non-zero exitCode (bash);
  • statusCode >= 400 (web fetch);
  • timedOut;
  • a non-null signal;
  • a sandbox denial.

A status code is definitive in both directions: a 200 is a success whatever words the page contains, so the text is never consulted for a fetch that reported one. That matters — a fetched document mentioning "HTTP Error 400" is not a failure.

The text fallback then covers what the structured value cannot, and it is restricted to shell tools on purpose. A shell's exitCode: 0 proves nothing: curl … | python3 … | head exits with head's status, and a script that catches its own HTTP error exits 0 too. So for a shell the text is the remaining evidence:

  • [exit code: N], (HTTP nnn), [timed out after Nms], [killed by signal: …], [sandbox: file access denied — the harness's own markers;
  • Traceback (most recent call last) and a line-anchored Python exception (SyntaxError:, urllib.error.URLError:, …) — a script that crashed while the pipeline still exited 0;
  • HTTP Error nnn — urllib's message, printed by a script that caught it and carried on;
  • curl: (n) — curl's own diagnostic.

One more case needs the command, not just the output, so classifyFailure takes it: when a shell command asked curl for the status (-w "%{http_code}" or %{response_code}), a leading 4xx/5xx in the output is the status by construction. That is how a model checks an endpoint by hand — curl exits 0 for a 404 unless it was given --fail, and the output is a bare number that nothing else would recognise. The anchor is deliberate: the same output often carries a byte count (153226 /tmp/…) whose digits contain something like 532, and a plain wc -c number without the write-out request is not a status.

Every other tool answers through its structured value, so its prose is never guessed at: that is what keeps a fetched page, or a read of a Python file containing the word ValueError:, from being read as a failure.

Two things are deliberately not failures: aborted — a cancellation is external to the model's choice — and a background job that started (kind: 'background', whose exit code belongs to a later call).

Only the fingerprint that hit is blocked

A call is blocked only when it carries a fingerprint that has already failed failLimit times in a row. A call that does not carry it — reading the error log, grepping the code, trying a different endpoint — is allowed. Blocking the recovery action is how a guard turns a stuck model into a wedged one.

A success clears that fingerprint's streak

A success of the failing fingerprint clears its streak. A success of a different fingerprint does not: a model that fails a build, reads a file, and fails the build again has failed the build twice, and the read is not progress on the build.

The two tracks share one measure set

A fingerprint whose limits cap is null is disabled for both tracks. This is load-bearing rather than tidy: measured over the corpus, the longest failure streaks sat on exactly those disabled measures (9 on family:http-fetch, 8 on verb:curl, 7 on verb:export), so counting them would have reintroduced the 0.4.0 bug through a new channel. The failure track also skips exempted fingerprints, exactly as the occurrence track does.

The failure gate can only fire on a later call

ctx.tools.guard is synchronous and runs before execution, while the outcome is known only in tools/post-execute. The failure gate therefore cannot stop the call that produces the fifth failure: after 5 failures, the 6th call carrying that fingerprint is blocked.

The plugin never counts its own denial

REPEAT_TOOL_BLOCKED is not the model's failure. A denial this plugin issued is excluded from the streak — otherwise the guard would feed itself: deny a call, the streak grows, and the next call is denied one step earlier. A declined ask is excluded too, because that call never ran.

Naming the measure, and the off-switches

When several fingerprints cross the failure stage together, the advisory names the most actionable one, using the same measureRank tie-break as the occurrence stages — a target-scoped measure such as host:, never a truncated exact: command line.

failWarnAt follows the same on/off rule as warnAt: a positive integer enables it, and 0, a negative number or null disables it silently. failLimit: null disables the gate while keeping the advisory. The two are independent of each other and of the occurrence settings; as everywhere else in this plugin, nothing is validated against anything.

Measured support (tools/failure-run-measurement.mjs, over 107 recorded sessions, counting only enabled measures): 3.6% of calls fail; 9 sessions reach a 3-failure streak and 5 reach 5, while zero known-good runs reach 3. The clearest real case was a session hitting host:api.github.invalid — a reserved, permanently nonexistent host — 8 times in a row.

Requirements

  • Node.js >= 20 (developed and tested on 22).
  • dsh ^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0 (session format 4), declared as an optional peer dependency on @deepseek-ai/dsh — optional because the host provides dsh, and a non-optional peer would make npm/pnpm install a second copy into the consumer's tree. The dsh 0.1.7 line and dsh 0.2.0 share one build: the Host-half packages this plugin calls (dsh-tools, dsh-web, dsh-web-fetch-http, dsh-settings, dsh-api-settings-controller) are byte-identical across the two. The compat suite targets dsh 0.2.x only — it passes on 0.2.0-rc.2, and 0.1.7 is historical: it was verified when the settings split landed, run-compat.sh now refuses a non-target install unless COMPAT_ALLOW_ANY_VERSION=1 is set, and its default prefix is named for the target (.rtb-compat-020) so a stale 0.1.7 tree cannot be measured by mistake. 0.1.7 removed the imperative settings API and rejects the message-source shape that 0.1.5 requires, so one build cannot serve both generations: use 0.7.x on dsh 0.1.5.
  • A DSH profile that exposes the tools service. The settings page additionally needs the web surface and a profile that mounts @deepseek-ai/dsh-client-ui-plugin-manager.
  • One runtime dependency: @deepseek-ai/schemastery (^3.18.4) — the exported Config schema is built with it, and 0.1.7 renders the settings form from that schema (which is why .volatile() is required and why 3.18.2 is too old). The browser half additionally requires react and @deepseek-ai/dsh-client-ui-primitives, both client module-table seeds the platform provides rather than dependencies of this package.

Install

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 the gate from the official reminder, which only injects a softer "you repeated X" message after the call already executed. Since 0.4.0 the breaker also has its own two advisory stages, delivered on the same tools/post-execute channel (see The occurrence track), and since 0.5.0 the failure advisory rides it too.

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
host:<host>normalized host of each URL, with no path and no query (new in 0.4.0)a fixed strategy: many distinct requests against one target, which net: cannot see because every query string differs. Local hosts are excluded by default (includeLocal)
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: siteOf() collapses to two labels, so it merges unrelated services (api.weather.gc.ca → gc.ca); the host: measure is the discriminating one
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 target-scoped fingerprint cannot tell them apart from a web crawl. localHosts decides whether local traffic is fingerprinted at all:

ValueBehaviour
deny (default)local calls are counted and blocked like any other host
allowlocal traffic is never fingerprinted: no net:, site:, `host: