Bundle
dsh-loop-brake
DeepSeek Harness plugin: a circuit breaker that denies the Nth identical tool call in a row, by exact hash of tool name and canonical arguments
- Source
- jwilson411
- License
- MIT
- Updated
- Updated yesterday
Readme
# dsh-loop-brake
A [DeepSeek Harness][dsh] function plugin: a **circuit breaker for identical
tool calls**.
An agent gets stuck. It calls `search` with `{"q":"weather"}`, reads the result,
decides it needs to call `search` with `{"q":"weather"}`, and does. Nothing is
wrong with any single call, so nothing stops it, and the loop runs until a
budget, a turn limit, or a human notices.
This plugin notices on the third one.
```
LoopBrakeError: loop brake: tool "search" called 3 times in a row with identical
arguments (call 9f2c1a7b3e04…, limit 3); the call was denied before it ran.
Change the arguments, call a different tool, or stop.
```
The tool body does not run. The error carries `code: 'LOOP_BRAKE'`, the count,
the limit, and twelve hex characters of the call hash, so the denial is legible
in a log without the arguments themselves being copied into one.
## What it is not
**This is not an LLM similarity judge.** It is not embeddings, not a fuzzy match,
not a semantic "are these two calls basically the same" question put to a model,
not a workflow engine, and not a second agent loop watching the first one.
There is no model call anywhere in this package and no threshold to tune.
**Exact hash match only.** Two calls are the same call when
```
sha256(toolName + "\n" + canonicalJson(args))
```
produces the same digest, and are different calls otherwise. Canonicalization
sorts object keys at every nesting level and nothing else — so `{"a":1,"b":2}`
and `{"b":2,"a":1}` are one call, and `{"q":"weather"}` and `{"q":"weather "}`
are two.
That boundary is worth stating plainly, because it decides what this catches:
- An agent repeating a call **byte for byte** — caught, on the Nth try.
- An agent looping while a timestamp, a nonce, a page cursor, or a retry counter
changes in its arguments — **not caught**. Every call hashes differently, and
this plugin has no opinion about whether they mean the same thing.
- An agent alternating between two calls forever — **not caught**. The counter
is a consecutive-repeat streak, so `A, B, A, B` never reaches three of
anything.
A brake that fired on "these look similar" would need a model to decide what
similar means, which is a model call in front of every tool call in the
composition, with a false-positive rate and a bill. The exact-match version
costs microseconds, cannot hallucinate, and is explainable to whoever reads the
denial at three in the morning. It catches the single most common shape of a
stuck agent and it is honest about the rest.
## Install
```sh
dsh plugin --profile default add github:jwilson411/dsh-loop-brake
```
The installer reads `dsh.bundle.patch` from the package manifest and appends
this package to the profile's ordered bundle list. Its `cordis.patch.yml`
carries one insert row, `id: loop-brake`, with no config — the defaults.
Pin the tools package at **`0.1.1-rc.2`**; that is the release candidate this
plugin is developed and tested against.
## Configure
One knob, in the plugin's row in the composed patch:
```yaml
- id: loop-brake
config:
maxRepeats: 5
```
| Key | Default | Meaning |
| --- | --- | --- |
| `maxRepeats` | `3` | Consecutive identical calls a session may make before the next one is denied. Counting includes the denied call, so at `3` two identical calls run and the third is refused. |
Must be an integer of at least 1. An unusable value is rejected when the plugin
applies, not at the first call: a brake with different teeth than the one a
profile asked for is discovered, if ever, only when a loop is already burning
tokens. `1` denies every repeat immediately, including the first call of any
streak — a debugging setting rather than a deployment one.
The environment fallback `DSH_LOOP_BRAKE_MAX_REPEATS` is read only when the
config key is absent. The patch row is the deployment's stated intent, so it
wins over an ambient variable.
An id-targeted patch replaces the row's whole `config` rather than merging into
it, so an override must restate every field it means to keep.
## Counting
Counts are **per session**, and per session the plugin keeps one current hash
and one count — a streak, not a histogram.
- Call `A`, `A` → the streak is at 2.
- Call `B` → the `A` streak is gone; `B` is at 1.
- Call `A` → back to 1, not 3.
Two sessions never share a count: two agents legitimately making the same call
are not one agent looping. A session is the explicit `sessionId` a caller
passes, else the calling agent's id from the execution input, else `default`.
Memory does not grow with session length — one slot per session, not one entry
per distinct call ever made.
## The error
```js
error.code // 'LOOP_BRAKE'
error.toolName // 'search'
error.count // 3 — which consecutive repeat this was, counting this call
error.maxRepeats // 3
error.hashPrefix // '9f2c1a7b3e04' — the first 12 hex characters, and no more
error.sessionId // the session whose streak tripped
error.plugin // 'dsh-loop-brake'
```
`LoopBrakeError` is thrown, not returned. A brake a caller can drive past by
ignoring a return value is not a brake, and the failure this exists to stop is
precisely the case where nobody is reading return values carefully.
The arguments are never put in the error. They are the thing most likely to hold
a path, a query, or a credential the caller passed in, and the error message is
the thing most likely to be logged, shown, or handed back to a model.
## How it attaches
`apply` patches the injected tool registry in two places and returns the
`LoopBrake` instance, so a host that wants to inspect or reset it can.
- **`ctx.tools.register`** is patched, so every definition registered for the
lifetime of this fiber carries a braked `execute`. This is the mandatory path
and the only one this plugin assumes exists.
- **`ctx.tools.execute`** is patched when the runtime exposes it, which catches
what the register patch cannot: a tool registered *before* this plugin
applied.
One host call passing through both seams is counted **once**, keyed by its call
id. A pair of wrappers that both counted would bite at half the configured
limit.
Both patches are undone on dispose, so stopping or reloading the plugin leaves
the registry exactly as it was found — and forgets every streak, which is the
right default. A brake that remembered across a reload would deny the first call
of a fresh run for something the previous run did.
**No tool is registered.** This plugin adds nothing to the model's tool surface.
A brake the model can call is a brake it can be talked into arguing with, and
there is nothing a status tool could report that the denial does not already
say.
## Using the library directly
The counting half is exported on its own for a host that owns its call sites:
```js
import { LoopBrake, decorateTool, wrapExecute } from 'dsh-loop-brake/loop-brake'
const brake = new LoopBrake({ maxRepeats: 3 })
// Wrap one execute…
const guarded = wrapExecute(execute, brake, { tool: 'search', sessionId: 's1' })
// …or copy a whole definition with its execute braked.
const braked = decorateTool(definition, brake)
```
`decorateTool` returns a copy; the original definition is left alone. Also
exported: `callHash`, `canonicalJson`, `hashPrefix`, `sessionKey`,
`normalizeMaxRepeats`, `resolveMaxRepeats`, `LoopBrakeError`,
`InvalidMaxRepeatsError`.
## Dependencies
None at runtime. The shipped source imports `node:crypto` and nothing else — it
opens no socket, reads no file, and spawns no process, all of which the test
suite asserts rather than promises. `@deepseek-ai/cordis` and
`@deepseek-ai/dsh-tools` are peer dependencies, provided by the harness.
Node `>=22.14.0`.
## Tests
```sh
npm install
npm test
```
No network, no credentials, no model weights. CI runs the same suite on Node
22.x and 24.x.
## License
MIT. Copyright (c) 2026 jwilson411.
[dsh]: https://www.npmjs.com/package/@deepseek-ai/dsh-tools
Install
dsh plugin --profile web add github:jwilson411/dsh-loop-brake
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-loop-brake from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.