Back to home@sam-midlight

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

  1. Window, not chain. Occurrences of a signature within the last window tracked calls. A cycle of any period is caught.
  2. 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 legitimate cargo 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.
  3. Denies. tools/pre-execute returns deny, 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.
  4. Escalates. Past rescueAt it shells out to ask-claude (Claude/opus) with the task, the looping call and the output the agent already has, and returns one concrete next action.
  5. 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. ignoredArgs strips 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__ask is 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.