DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

pricklywiggles /

pricklywiggles/dsh-circuit-breaker

Verified

Loop guard for DeepSeek Harness: denies repeated identical tool calls and caps per-agent calls, outside the model where instructions cannot reach

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: master@8aa4ffeb

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 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

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 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:

{"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

—/ 5

No ratings yet

Verified DSH bundle

Commit 8aa4ffeb0e3d

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout