Bundle
dsh-repeat-tool-breaker
Hard break on repeated identical tool calls: a synchronous ctx.tools.guard monotonic gate that denies the 2nd identical (tool name + canonical arguments) call per agent, with an optional same-path read guard and advisory first-repeat warning.
- Source
- snailium
- License
- MIT
- Updated
- Updated yesterday
Readme
# dsh-repeat-tool-breaker
[](https://github.com/snailium/dsh-repeat-tool-breaker/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/dsh-repeat-tool-breaker)
[](LICENSE)
[](https://nodejs.org)
Hard break on an agent's repeated **identical** tool calls. A local, dependency-free
[DeepSeek Harness](https://github.com/deepseek-ai/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 `tools` service. Built and verified against
**`@deepseek-ai/dsh` 0.1.2-rc.1**.
- No runtime dependencies — `index.js` imports nothing (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.
```bash
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`):
```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](#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](#configuration) for the full snippet), then boot with the
overlay:
```bash
git clone https://github.com/snailium/dsh-repeat-tool-breaker.git
```
```bash
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
```bash
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 sees `Error: <string>`),
- return `undefined` → leave allowed.
- 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-step` with a `user` source) clears that
agent's chain, so a fresh instruction is never treated as a loop.
- Reserved tools (`exclude`, default `todo_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/i` by 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):
```yaml
- 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`**:
- `warnAfter` must be at least 2, because a notice only makes sense once a repeat
has actually happened;
- it must be below `denyAfter`, because at `denyAfter` the 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:
```bash
# 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 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
The deterministic pure-logic suite covers the important cases with no model or
endpoint required:
```bash
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.
```bash
# 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 guard-logic suite** (`npm test`) — 29 assertions over a stable
live `Agent` object, 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/dsh` 0.1.2-rc.1 through a `--patch` overlay: the loader resolves
the module and `apply` runs with `ctx.tools.guard` present as a function — which
is only reachable once `inject: ['tools']` defers activation until the real
`ToolRuntime` is live.
**Not covered here**
- There is no end-to-end, model-driven trajectory in the suite (a model actually
issuing two identical `read` calls and receiving the blocked second one). The
deny behaviour is pinned by the deterministic suite instead; see
[Development loop](#development-loop-dependency-free) 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-execute` only
records the rendered result (for a high-quality deny message) and may emit the
`warnAfter` advisory through `additionalContexts`; 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](#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 schemastery `Config` export (keeping index.js
dependency-free is deliberate — `cordis.resolveConfig` passes config through
unchanged when a plugin exports no `Config`), 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).
Install
dsh plugin --profile web add github:snailium/dsh-repeat-tool-breaker
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-repeat-tool-breaker from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.