dsh-circuit-breaker
A loop guard for 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
duplicateLimittimes inside a sliding per-agent window. - The agent has passed
maxCallsPerAgenttotal 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
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:
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:
- 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
duplicateLimittier 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:
{"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
No comments yet. Be the first to write one.