dsh-loop-rescue
DRAFT — DeepSeek Harness guard that breaks an agent out of a tool-call loop and escalates to a stronger model for one concrete next action. Window-based detection with a progress epoch, so it catches cycles the stock single-slot repeat guard resets away.
- Stars
- 0
- Language
- JavaScript
- Created
- Aug 22, 2026
- Updated
- Aug 22, 2026
Introduction
dsh-loop-rescue
Status: draft. Written and verified against DeepSeek Harness
0.1.0-rc.7over a single day, on one machine, driving a local Qwen3.8-27B. The detection logic is covered by tests and was validated by replaying a real transcript, but it has not been run anywhere else. Treat the defaults as a starting point, not settled numbers. Feedback and counter-examples welcome.
Breaks a stuck agent out of a tool-call loop, and buys it one concrete way forward from a stronger model.
Why
The stock guard, @deepseek-ai/dsh-repeat-tool-reminder, keeps a single-slot
chain:
const count = chain !== void 0 && chain.key === key ? chain.count + 1 : 1
Any different tracked call resets it to 1. On 2026-08-22 a dsh session running Qwen3.8-27B fell into this cycle:
grep X, grep X, grep X, grep X, grep X, read Y, (repeat)
93 identical greps and 21 identical reads — 111 of the session's 192 tool
calls, 7 minutes — all to re-learn a one-line answer it already had. The
period-6 read reset the chain every time, so the stock guard re-fired its
threshold-3 and threshold-5 reminders forever and never escalated past them.
The agent escaped on its own, at call 170. mcp__claude__ask was available the
whole time and was called zero times.
Replaying that transcript through this plugin: first denial at call 62, escalation at call 65 — six minutes and fifty-three seconds earlier, after 3 wasted repeats instead of 93.
How it differs
- Window, not chain. Occurrences of a signature within the last
windowtracked calls. A cycle of any period is caught. - Progress epoch. Repeats only count while nothing has actually changed. A
successful mutating call (
edit/write/…) or a genuine user message bumps the epoch and voids every earlier observation. This is what stops a legitimatecargo clippy× 4 around a pair of edits from tripping it — verified against the real transcript's productive tail: 0 false denials across 22 calls containing 9 cargo invocations. - Denies.
tools/pre-executereturnsdeny, so the call never runs and the guidance lands in the tool result — where the model is already looking. Reminders demonstrably do not work on a 27B; a refusal does. - Escalates. Past
rescueAtit shells out toask-claude(Claude/opus) with the task, the looping call and the output the agent already has, and returns one concrete next action. - Ignores cosmetic arguments. Found during live testing: told to run one
command six times, the model numbered each call's
description—"…(run 1)","…(run 2)", … Six byte-identical commands, six distinct signatures, detector blind.ignoredArgsstrips non-semantic top-level fields before fingerprinting. The stock guard still has this hole.
The askCommand contract
Escalation shells out to an ordinary executable. The contract is deliberately tiny, so this is not tied to any one vendor:
- invoked as
askCommand "<the question>"— one argv, no stdin; - prints advice to stdout and exits 0 on success;
- any non-zero exit (or empty stdout) is treated as "unavailable" and the plugin falls back to its free local denial.
examples/ask-claude.sh is a working reference implementation using the Claude
Code CLI with every file/web/task tool disabled, so the responder answers from
the text of the question alone and cannot read the calling machine's code. Point
askCommand at anything that satisfies the contract — a different CLI, a curl
wrapper around an API, a local model.
The question it receives contains the task, the looping call, that call's output, and a list of recent calls. It contains no file contents beyond what the looping tool already printed — but it is still your code's text leaving the machine, so point it somewhere you are comfortable with.
Cost control
An escalation is the only thing here that spends money.
- Advice is bought once per signature and cached for the epoch; every later attempt at the same call reuses it free.
maxRescues(default 3) caps a session;cooldownMs(default 45s) spaces them.- A failed ask refunds the budget and falls back to the free local denial.
mcp__claude__askis excluded by default, so the escalation tool can never be blocked by the thing that escalates.
Config
- id: loop-rescue
name: dsh-loop-rescue
config:
window: 12 # detection window, in tracked calls
nudgeAt: 3 # repeats -> free local denial
rescueAt: 5 # repeats -> denial carrying Claude's guidance
stallAt: 40 # redundant calls in one epoch -> rescue regardless of signature (0 disables)
maxRescues: 3 # Claude escalations per agent per session
cooldownMs: 45000
include: [] # tool-name patterns to watch ('*' wildcard); empty = all
exclude: [todo_write, ask_user, 'mcp__claude__*']
excludeCommands: [] # regexes over a bash `command`; the escape hatch for deliberate polling
ignoredArgs: [description, explanation, reasoning, thought, comment] # stripped before fingerprinting
mutatingTools: [edit, write, multi_edit, str_replace_editor, apply_patch, create_file, notebook_edit]
askCommand: ~/.local/bin/ask-claude
askModel: opus
askTimeoutMs: 90000
logFile: ~/.llmbox/loop-rescue.jsonl # one JSON line per trip; '' disables
Every value is validated at load and throws on nonsense rather than silently falling back to a default.
Deliberate polling
A bash call that repeats on purpose — waiting on a container, a health check —
looks exactly like a loop. Two things save it: excludeCommands, and the fact
that a denial only blocks that one signature, so sleep 10 && docker ps
still gets through after docker ps is refused.
When repetition is the point
If a task genuinely requires repeating one command — polling a container, a
health check, a deliberate benchmark — this guard fights it, and escalating will
have Claude tell the agent to carry on. Use excludeCommands. Note also that a
denial blocks only that one signature, so sleep 10 && docker ps still gets
through after docker ps is refused.
Install
dsh plugin --profile <name> add /path/to/dsh-loop-rescue
then add it to that profile's cordis.patch.yml:
- insert:
- id: loop-rescue
name: dsh-loop-rescue
config: {}
Not published to npm — package.json is marked private while this is a draft.
Tests
node --test 'test/*.test.mjs'
14 tests. The harness points askCommand at an unreachable path by default: no test can
reach the real ask-claude and spend Opus tokens without explicitly asking for
a stub.
Observability
Trips append to ~/.llmbox/loop-rescue.jsonl (kind: deny, rescue,
escalation-failed). Escalations also land in ~/.llmbox/ask-claude.jsonl, so
they show up wherever that is already tracked.