Bundle
dsh-circuit-breaker
Stop runaway agent loops in DeepSeek Harness: denies repeated identical tool calls and enforces a per-agent call cap, outside the model where instructions cannot reach.
- Source
- pricklywiggles
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 6 days ago
Readme
# dsh-circuit-breaker
A loop guard for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
It denies a tool call once the agent has already made that exact call several
times, and caps how many calls one agent can make. The check runs in code,
outside the model.
## Why this exists
I run Qwen3.8-27B locally as the agent behind a DeepSeek Harness box. Two of
its subagents went into degenerate repetition on the same afternoon. Here is
what the session transcripts showed.
| | Agent A | Agent B |
|---|---|---|
| Runtime | 33 min | 68 min, until I killed it |
| Tool calls | 626 | 1,227 |
| Searches | 1,200, of which 74 distinct | 1,043 |
| Worst repeat | one query 555 times | one query 1,000 times |
| Failed calls | none | none |
Every call succeeded. Search returned results each time. Nothing errored, so
nothing alerted, and the only reason I caught either one was that I happened to
look. Both started repeating around step 11 to 28, so this is not context
exhaustion, and neither recovered on its own.
The part that convinced me to write this: Agent B had *read* a briefing telling
it "Budget: 12 web_search calls" and "never re-issue a query you have already
run". It then made 1,043 searches. Telling the model to stop does not work,
because a model in this state has stopped following instructions in any useful
sense. The repetition happens below the level where instructions apply.
So the guard has to be code that never asks the model's opinion.
## Why this happens, and why it is not only Qwen
Qwen3.8 was my trigger, and the vendor treats this as a known failure. The
model card's own non-thinking preset sets `presence_penalty: 1.5` to hold back
repetition, and calls out language mixing as the cost of pushing it higher. The
architecture gives it a reason. Qwen3.8 runs three Gated DeltaNet linear-attention
layers for every full-attention one, and the linear layers compress history into
a small recurrent state rather than attending over every past token. When that
state drifts, the model can lock onto its own recent output. llama.cpp shipped a
real arithmetic bug in exactly that path (the `key_gdiff` fix, PR #19324, merged
February 2026) whose symptom was looping and degraded output that got worse deeper
into the context. My build postdates the fix, so it was not my cause, but it shows
the shape of the problem. In this model family a numerical slip in the engine
surfaces as a loop.
The deeper reason is not specific to Qwen at all. Repetition is a self-reinforcing
attractor. Each time a sequence repeats, the probability of repeating it again
goes up, and the published work on this finds the state holds through added
sampling randomness and through changed prompts. That is the mechanism behind the
part that surprised me most. Telling a looping agent that it is looping sends it
straight back into the loop. The instruction lands in a context already dominated
by the pattern.
Two things make agent loops worse than the chat-repetition most people have seen.
The repeated unit is a whole tool call, not a word, so llama.cpp's anti-repetition
penalty never catches it. That penalty scans the last `--repeat-last-n` tokens,
64 by default, and two copies of a tool call are thousands of tokens apart. And
an agent that loops keeps taking real actions, so it burns time and a model slot
while every prompt-level guardrail you wrote sails past it.
None of that is unique to Qwen. Any local model driven as an agent can land in
the same attractor, and the mainstream hosted models are not immune either. This
guard works on the pattern of tool calls, not on anything about the model, so it
does the same job whatever you run behind it. Qwen is just what made me write it.
## What it does
The plugin registers a guard through `ctx.tools.guard()`, which DSH runs before
every tool execution. Returning a string denies the call and hands that string
back to the model. Denials are monotonic in DSH, so nothing downstream can
re-allow a call the guard refused.
It denies on either of two conditions:
- The same tool has already run with the same significant arguments
`duplicateLimit` times inside a sliding per-agent window.
- The agent has passed `maxCallsPerAgent` total calls, which catches loops
that vary their arguments enough to slip past duplicate detection.
The denial text explains what happened and tells the model to stop or change
approach, so a working model can recover, and the whole exchange lands in the
transcript where a human can read it later.
Here is a real one. I asked an agent to run `echo cbprobe4` ten times. The
sixth call was denied. It tried `bash -c` and `sh -c` variants, then stopped and
said:
> Every step executed the unchanged `echo cbprobe4`. If you needed all 10 to be
> byte-identical tool calls, that isn't possible in this session because of the
> breaker.
That is the behavior I want. It stopped, and it told the user why.
## Install
```sh
dsh plugin --profile web add github:pricklywiggles/dsh-circuit-breaker
```
Restart the profile afterwards.
The package ships plain ESM with no build step. That matters more than it
sounds. DSH's own docs warn that a GitHub-installed plugin needing a build also
needs its users to add an `allowBuilds` entry to their `pnpm-workspace.yaml`,
which grants that package permission to execute code at install time. This one
asks for nothing.
Pin a commit if you want to know exactly what you are running:
```sh
dsh plugin --profile web add github:pricklywiggles/dsh-circuit-breaker#<sha>
```
## Configuration
Every setting has a default that works. To change one, target the plugin's
row id in a cordis patch layer, either your profile's `cordis.patch.yml` or
`$DSH_HOME/cordis.patch.yml`:
```yaml
- id: circuit-breaker
config:
duplicateLimit: 6
maxCallsPerAgent: 300
incidentLog: /workspace/.circuit-breaker-incidents.jsonl
```
A `circuit-breaker:` section in `settings.yaml` does not work, and I tested
it. That namespace reaches plugins that read settings for themselves, not a
bundle plugin's config. The patch-layer override is the path that does.
| Key | Default | What it does |
|---|---|---|
| `enabled` | `true` | Master switch |
| `duplicateLimit` | `6` | Deny after this many identical calls in the window |
| `window` | `200` | How many recent calls to remember per agent |
| `maxCallsPerAgent` | `300` | Lifetime call cap per agent object; once hit, that agent is stopped for good. Sized for unattended subagents, which get a fresh cap per run. `0` disables it |
| `exempt` | `todo_write`, `ask_user_question`, `exit_plan_mode` | Tools the guard ignores |
| `only` | `[]` | If set, guard only these tools. Overrides `exempt` |
| `ignoreArgs` | `description`, `explanation`, `reason`, `thought`, `purpose` | Argument names excluded when comparing two calls |
| `denyMessage` | see source | Denial text. Supports `{tool}`, `{count}`, `{limit}` |
| `incidentLog` | `""` (off) | Append-only JSONL recording the first denial of each kind per agent, so a supervisor can notice a tripped agent. See below |
### Picking a duplicateLimit
The default sits far above normal behavior on purpose. Re-reading a file or
re-listing a directory a few times is ordinary work and should not be punished.
Running the same search 555 times is not ordinary. At 6, a real loop dies in
seconds and healthy agents never notice the plugin is installed.
Lower it if you want tighter control and can live with the occasional false
positive. Raise it if your agents legitimately poll something.
## Things I got wrong building this
**The first version never fired.** The guard was invoked on all ten calls of my
test and denied none of them. DSH's `bash` tool takes a free-text `description`
argument next to `command`, and the model rewrites it every time: "Run probe
step 1", "step 2", and so on. Ten byte-identical commands produced ten distinct
comparison keys.
`ignoreArgs` exists because of that. It strips annotation-only arguments before
comparing. If your tools take a similar field, add it to the list, or the
breaker will sit there doing nothing. No unit test of mine would have caught
this, because I wrote the fixtures myself and my fixtures did not lie about
their own arguments.
**Denied calls are not counted.** A call that never ran must not push its own
count higher or evict a real entry from the window. Getting that wrong makes
the breaker latch permanently once it trips.
## How it works
State is a bounded ring of recent call keys per agent, not a running tally. No
turn-boundary detection is needed, memory cannot grow without limit, and a
legitimate repeat from earlier ages out rather than counting toward a future
denial.
Keys sort object properties before serializing, so argument order never changes
the result. Arguments that will not serialize are allowed through. The guard
cannot judge them, and failing open beats blocking real work.
Counters live in a `WeakMap` keyed by the agent object. A subagent's state
disappears with the subagent, and agents in a parallel batch never interfere
with each other.
The bundled patch mounts the guard on the host plane, so it covers background
subagents. Those are the ones that run unattended long enough to loop, which is
how both of mine survived for half an hour. Register it through an agent's own
context instead if you want it scoped to one agent.
## A denial is not a kill
The guard denies calls; it cannot terminate an agent, because DSH's guard API
has no abort hook. That splits outcomes in two:
- **A model that can still read** sees the denial, stops, and reports. Its
completion reaches whoever launched it through the normal channel. The
`duplicateLimit` tier fires early (six repeats) precisely because that is
the window where a model is most likely to still be reachable.
- **A model in true degenerate repetition ignores the denial** the same way it
ignored its own briefing. The research on repetition attractors matches what
I saw: the repeated pattern in context self-reinforces, and it persists
through added randomness and through changed prompts. Such an agent keeps
emitting the same call and collecting denials forever. It is now harmless,
every call denied before execution, but it never completes, so nothing is
ever returned to its parent, and on a single-slot model server it still
competes for inference until something external stops it.
`incidentLog` exists for that second case. Set it to a writable path in the
patch layer above. On the first denial of each kind per agent, the plugin
appends one JSON line:
```json
{"time":"2026-09-01T23:10:07Z","kind":"cap","tool":"bash","count":300,"limit":300,"agent":"agent-007"}
```
`agent` is the agent's uuid. Verified live on dsh 0.1.1-rc.2: it is the
same id that `list_agents` reports and `interrupt_agent` accepts, and it also
names the agent's session directory, so an incident maps straight onto the
tools a supervisor already has. If a future dsh changes the agent object's
shape and no id field is recognized, the entry carries `agentKeys` instead so
you can map it yourself. Writes are append-only and fail open: a bad path
never affects the guard.
The supervision pattern this enables, used by the research skills on the box
this was built for: a parent that has launched subagents reads the incident
file on each of its turns. A tripped child that has not delivered within a
few minutes gets `interrupt_agent` and its work re-dispatched once, with the
replacement told that the repeated line of investigation is exhausted. A
second trip on the same work item means the item itself is probably
unsatisfiable (in my loops, a hallucinated premise), so stop retrying and
record the gap.
## Limits
This bounds the damage. It does not fix the model. A loop still means the
agent failed at its task. What changes is that the failure is fast and
visible rather than slow and silent.
A loop that varies its arguments every single time will get past duplicate
detection, and only the call cap will stop it. DSH guards are synchronous, so
the guard path does no I/O beyond the optional one-line incident append and
keeps no state across processes.
## License
MIT
Install
dsh plugin --profile web add github:pricklywiggles/dsh-circuit-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-circuit-breaker from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.