dsh-loop-rescue
English · 简体中文
A loop-rescue plugin for DSH (DeepSeek Harness). When the model falls into repetition (Now. / OK. / Emit. / Producing. rotations, the same paragraph over and over), it cuts the stream from the inside, prunes the loop out of what gets written to the session log, and resumes automatically.
The difference from a plain cut-off: the interrupted message that goes into the next turn's context contains no loop, so the model never re-reads its own repetition and carries on repeating.
Requires DSH 0.1.7 or newer. Pure JS, no build step.
Quick start
Use this or @mrweicodes/dsh-loop-guard, not both: with both on, loops get cut twice.
- Install: sidebar Plugins → Add plugin, and give it
https://github.com/citydirector/dsh-loop-rescue
Switch it on.
- The defaults work as they are. To watch before it acts, set
mode to observe (see Configuration).
From the command line:
dsh plugin --profile web remove @mrweicodes/dsh-loop-guard
dsh plugin --profile web add -w github:citydirector/dsh-loop-rescue#v0.1.0
Restart dsh web after a command-line install. No pnpm build scripts need approving.
Configuration
The defaults work as they are. To change them, write to the profile's cordis.patch.yml:
- set:
- id: loop-rescue
config:
mode: rescue # rescue: cut + prune + resume | cut: cut + prune | observe: log only
watch: both # both | reasoning | text
marker: true # leave a "〔N chars of repeated reasoning removed〕" line where it pruned
maxConsecutiveRescues: 3 # cap on back-to-back rescues; one normal call resets it
stallCalls: 3 # nudge after this many reasoning-only calls with no output; 0 = off
stallMinReasoningChars: 1024
retryTemperature: 1.0 # temperature for the resume request; unset = unchanged
retryReasoningEffort: low # reasoning effort for the resume request; unset = unchanged
retryRequests: 1 # how many requests after a resume use the two settings above
language: auto # auto | zh | en
detector: # detection thresholds; rarely need changing
poolWindow: 30
poolMinCoverage: 0.75
cycleMinChars: 600
Consider running mode: observe for a day or two first, check that every loop-rescue: ... loop detected line in the log is a real loop, then switch to rescue.
How it works
When the agent loop assembles an assistant/message, each block's final content comes from the block-end payload, not from the deltas streamed before it. The plugin wraps the model call in the llm/stream waterfall:
- It runs a streaming detector on every reasoning and text block.
- On a hit it closes every open block: the looping block is replaced by its content pruned back to where the loop started (optionally with a one-line marker), and the stream ends with
stop.
- The message written to the log holds only the useful content before the loop. The raw stream is still kept in full in the event's
stream record, where the trajectory view can show it.
- Once the turn has really ended (
whenIdle()), it sends a resume message with a correction via followup().
- The first N requests after a resume can use a different set of sampling parameters.
- After the cap on back-to-back rescues it stops resuming and leaves a note.
Why not edit the session afterwards: an assistant/message cannot carry sourceEventSeqs, so the session surface's replace operation cannot swap one assistant message for another. Pruning at block-end is the only way that keeps message roles and tool-call pairing intact.
Detection rules
| Rule | Catches | Prunes from |
|---|
Phrase pool pool | ≥75% of the last 30 sentences are a handful of recurring short phrases (in any order) | Back to where that batch of phrases first appeared |
Verbatim cycle cycle | The tail is the same text repeated verbatim, period 16–4000 chars, ≥3 repeats, ≥600 chars in total | Keeps the first copy, prunes from the second |
Guarding against false positives:
- A Chinese full stop always ends a sentence; an English period only when followed by whitespace.
- Symbol-only lines (
|---|, }) are not counted.
- List items, table rows and quote lines in the text are not counted.
- The inside of ``` code blocks in the text is skipped entirely.
- The phrase pool needs at least two different phrases rotating.
- It never cuts while an open block holds a tool call: closing a half-written tool call would make the host run it with incomplete arguments.
Offline scan
Replay past sessions through the same detector the plugin uses, to see where it would cut:
node tools/scan-session.mjs <session file or directory> # hits only
node tools/scan-session.mjs <session file or directory> --all # hits and misses
node tools/scan-session.mjs <session file> --json
Reads plain jsonl and zstd-compressed session files (zstd needs Node 22.15+). The script only depends on src/detector.js and runs on plain node.
Cleaning loops already in the history
The plugin only stops new loops; loops already committed to the history keep being imitated by the model. What DSH sends the model is a frozen projection of the session log, which a plugin cannot change, so the session files have to be cleaned offline with DSH fully closed. This works for old sessions that never had the plugin too.
The recommended tool is dsh-session-manager (dsm loops, a single-file exe on Windows): the same detection rules, plus upgrading, archiving and repairing broken sessions.
This repository also ships a lightweight script (Node 22.15+, v4 sessions only):
node tools/clean-session.mjs # list sessions with loops, pick by number; Enter = newest
node tools/clean-session.mjs --dry-run # show what would change
node tools/clean-session.mjs --undo # restore from the backup
- On Windows you can also double-click
tools\clean-session.cmd, or drop a session folder onto it; if it cannot find the data directory, pass --data <data directory>.
- Only matching
assistant/message events change: text and reasoning are cut back to where the loop started, data.stream is cut to match, and the message's source.replayState is removed. Every other line stays byte-for-byte the same.
- It backs up to
<data directory>\sessions-loop-rescue-backup\ first, and refuses while DSH is running (--force skips that check; not recommended).
- Versions before 2026-10-17 read only the first frame of a compressed log and wrote the result as a single frame, which DSH treats as corrupt and hides. That is fixed;
dsm repair brings hidden sessions back.
Known limitations
- Loop text already streamed into the chat is replaced by the pruned version once the message is committed, but exactly how it redraws depends on the DSH front-end version.
- Loops that span steps (every step is "say a line → call the same tool") are out of scope; that is the job of the built-in
guard/repeat-tool-reminder.
retryTemperature needs an adapter that supports temperature. DSH has no generic field for Ollama's repetition penalty and similar parameters, so they cannot be set from here.
Development
npm install
npm test
Related projects
License
MIT