dsh-repeat-tool-breaker
Hard break on an agent's repeated identical tool calls. A local, dependency-free
DeepSeek Harness (DSH) plugin
that registers a single synchronous monotonic gate on the public
ctx.tools.guard API. By default the 2nd identical call (same tool name +
canonically-equal arguments, property order ignored) is denied before it
executes; the model then only sees an isError result whose text starts with
REPEAT_TOOL_BLOCKED, quotes the previous successful result, and tells it to
advance or conclude instead of retrying.
The sibling official plugin @deepseek-ai/dsh-repeat-tool-reminder (advisory,
at 3/5/8 repeats) may stay on — this breaker refuses earlier (at 2), so the two
compose: the breaker is the hard gate, the reminder is the soft nudge.
Requirements
- Node.js >= 20 (developed and tested on 22).
- A DSH profile that exposes the
toolsservice. Built and verified against@deepseek-ai/dsh0.1.2-rc.1. - No runtime dependencies —
index.jsimports nothing (nocordis, 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 monotonic 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.
Interface
ctx.tools.guard((execution) => string | undefined)— synchronous:- return
string→ deny (tool does not run; model seesError: <string>), - return
undefined→ leave allowed.
- return
- Tracking state (
WeakMap<Agent, chain>) is per live Agent instance, so one agent's loop never trips another's, and it is reclaimed when the agent goes away. - A new user message (
agent/pre-stepwith ausersource) clears that agent's chain, so a fresh instruction is never treated as a loop. - Reserved tools (
exclude, defaulttodo_write) are transparent: they neither count nor reset other tools' chains. - Read-like tools (
read,read_file,file-read,fs-read,Read, and any name matching/read/iby default) additionally get a same-path cap (maxSamePath, default 3) so a model re-reading one file with varying arguments is still bounded.
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.
A hand-written mount row looks like this (name is the package specifier once the
plugin is installed into the profile, or an absolute path to index.js when
mounting a bare checkout):
- insert:
- id: repeat-tool-breaker
name: dsh-repeat-tool-breaker
config:
denyAfter: 2 # identical (tool + canonical args) call #2 is denied (>=2)
warnAfter: 2 # advisory tier; inert unless 2 <= warnAfter < denyAfter
registerAdvisory: true
exclude: [todo_write] # never count/reset these tools (include/exclude are *-wildcards)
include: [] # non-empty = ONLY these tools are tracked
readTools: [read, Read, read_file, read-file, file-read, fs-read]
matchReadBySubstring: true
pathAliases: [path, filePath, file, target_file]
maxSamePath: 3
previewChars: 400
resultPreviewChars: 800
(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.)
Here name is an absolute POSIX path to this directory's index.js (dev/overlay
loop). When the package is installed into a profile it can instead be the package
specifier dsh-repeat-tool-breaker.
About the advisory tier
The warnAfter notice is deliberately inert unless 2 <= warnAfter < denyAfter:
warnAftermust be at least 2, because a notice only makes sense once a repeat has actually happened;- it must be below
denyAfter, because atdenyAfterthe call is blocked and the deny reason already explains why.
With the default denyAfter: 2 the gate blocks on the very first repeat, so there
is no room for a separate pre-block nudge and nothing is emitted. Set
denyAfter: 3, warnAfter: 2 to get one warning after the first repeat and the
block on the second.
This matters: an advisory keyed only on count === warnAfter would fire on every
ordinary tool call (every fresh call starts a new run at count 1), attaching a
misleading "you repeated this" message to each one.
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
webprofile). Boot a headless test profile 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 ofapplyand remove it after — the shipped file intentionally logs nothing.
Acceptance
The deterministic pure-logic suite covers the important cases with no model or endpoint required:
npm test # or: node test/logic.test.mjs
It verifies, for a stable live Agent object: 1st identical read allowed →
2nd denied (REPEAT_TOOL_BLOCKED, tool named, previous result quoted);
property-order-insensitive keying; a different path/tool write chain allowed,
then its 2nd identical denied; todo_write repeated twice never denied and never
resets an unrelated chain; per-agent isolation; user-message reset re-allows a
same call; and the same-path cap bounds varying-argument re-reads of one file.
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 directnpm publishis not selected under "Allowed actions", the registry answers403 ... 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=trueto 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 guard-logic suite (
npm test) — 29 assertions over a stable liveAgentobject, covering the allow/deny matrix, canonicalization, tool exclusion, per-agent isolation, and the user-message reset. Runs in CI on Node 20 and 22 with no model or endpoint. - Loads and applies on a real DSH boot. Verified against
@deepseek-ai/dsh0.1.2-rc.1 through a--patchoverlay: the loader resolves the module andapplyruns withctx.tools.guardpresent as a function — which is only reachable onceinject: ['tools']defers activation until the realToolRuntimeis live.
Not covered here
- There is no end-to-end, model-driven trajectory in the suite (a model actually
issuing two identical
readcalls and receiving the blocked second one). The deny behaviour is pinned by the deterministic suite instead; see Development loop if you want to drive it manually against a live profile.
Intentional limits — only exact repeats are caught (same tool, same canonical arguments, property order ignored). Two calls differing by one argument character, or achieving the same effect through different tools, are out of scope: the gate is a monotonic safety net, not a semantic deduplicator.
Design notes
- Counting lives in the guard, which runs for every tracked attempt (allowed
and denied) and commits state on allow and deny.
tools/post-executeonly records the rendered result (for a high-quality deny message) and may emit thewarnAfteradvisory throughadditionalContexts; it never increments. That single counting locus is what prevents the guard/post-execute double count the naive design smuggles in when both update the chain. The advisory is additionally gated on being reachable — see About the advisory tier. - Only consecutive repeats are caught. The run resets when a call with a
different signature arrives, so the pattern
A, B, A, B, …never trips the gate. That is intentional (a consecutive-run detector, not a call counter), and it is why the deny message says "in a row". - Fail loud in
apply: no schemasteryConfigexport (keeping index.js dependency-free is deliberate —cordis.resolveConfigpasses config through unchanged when a plugin exports noConfig), but every load-bearing invariant (denyAfter >= 2, non-empty patterns, preview caps) is validated at load and throws rather than silently degrading. - State is in-memory only; a resumed session starts fresh (same tradeoff as the official reminder).
No comments yet. Be the first to write one.