Back to home@jwilson411

dsh-loop-brake

DeepSeek Harness plugin: identical tool+args circuit breaker (LOOP_BRAKE)

Stars
0
Language
JavaScript
Created
Aug 31, 2026
Updated
Aug 31, 2026
GitHub repo

Introduction

dsh-loop-brake

A DeepSeek Harness 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

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:

- id: loop-brake
  config:
    maxRepeats: 5
KeyDefaultMeaning
maxRepeats3Consecutive 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

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:

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

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.