dsh-loop-detector
viego-qiin/dsh-loop-detector
检测文本循环及同主题搜索重试,并在软提醒无效时硬中断。
安装
dsh plugin --profile web add github:viego-qiin/dsh-loop-detectorREADME
dsh-loop-detector
Dead-loop / repetition detector with hard interrupt for DeepSeek Harness (dsh).
Local LLMs (especially quantized / RL-tuned reasoning models) sometimes get stuck:
repeating the same sentence forever, or re-running web_search with reworded queries
for the same topic without gaining any new information. This plugin watches the
agent's stream in real time, detects both kinds of loops, and interrupts them
hard — not a soft reminder the model can ignore.
中文文档见 README.zh.md.
v2.0.0 targets dsh 0.2.x. If you installed v1 on dsh 0.2.x and it never fired, that is expected — see the next section.
Why v1 does not work on dsh 0.2.x
v1 was written against an older dsh. Three things broke, each fixed in v2:
| Problem | v1 | Reality on 0.2.x | v2 |
|---|---|---|---|
| Never sees streamed text | listened to session/event → assistant/chunk | assistant/chunk is no longer a live v2 session event (only the legacy-format migration packages read it) | listens to agent/assistant-stream |
| Interrupt does not interrupt | used agent.steer() as the interrupt | steer() only queues a message for the next step; it does not abort the current generation | agent.cancel(cause, { keepInbox: true }) first, then steer() to guide |
| Retry budget never runs out | reset the counter on turn/end | every steer() opens a new turn, so the counter was cleared as soon as it grew | resets on genuine user input (plugin-injected messages are tracked by id and excluded) |
v1's intent detector also tokenized on "non-alphanumeric", which collapses a whole Chinese sentence into a single token — so Chinese reworded-search loops were never detected. v2 uses "stop-character splitting + CJK bigrams + Jaccard similarity".
Features
- Text-level repetition detection — watches
agent/assistant-streamfortext-deltaand (optionally)reasoning-delta, with three layered detectors:- Period scan: the tail repeats with a fixed period (default 8–256 chars) ≥4 times
- n-gram: the same 64-char block appears ≥3 times inside the last 2048 chars
- Large anchor: the trailing 512 chars reappear earlier and extend backwards (catches "the whole plan printed twice")
- Noise rejection: a repeated unit needs ≥2 distinct characters and ≥30% substantive
characters (letters/digits/CJK), so markdown rules (
--------,|:---|:---|) are not mistaken for a loop. - Tool-call intent-level detection — watches
tools/post-execute, extracts keywords from each call's arguments, and compares them against the union of the last 3 calls. A low novelty ratio or a high Jaccard similarity, twice in a row, means "same task, reworded".toolPatternstakes wildcards, sogrep, shell commands, etc. can participate. - Hard interrupt: the current generation is actually aborted, then a guidance message
is handed to the model for one retry; after
maxRetriesthe whole turn is cancelled.
dsh ships @deepseek-ai/dsh-repeat-tool-reminder, but it is advisory only (injected
as context). In practice local models (e.g. Qwen / Ornith 35B MoE with reasoning off)
ignore it and keep looping — which is exactly why this plugin exists.
Quick start
Prerequisite: a running dsh profile (dsh 0.2.x, Node ≥ 22.18 — the bundled runtime qualifies).
Option 1: link it as a local dependency
cd $env:USERPROFILE\.dsh\profiles\desktop
pnpm add link:"D:\path\to\dsh-loop-detector"
Dependency-resolution contract: every runtime dependency of this plugin (
@deepseek-ai/cordis,dsh-agent,dsh-llm,dsh-tools,schemastery) is declared inpeerDependencies. For alink:-ed plugin, dsh's profile resolver routes a bare specifier to dsh's own installed copy only when the plugin'spackage.jsonlists that name underpeerDependencies. Do not move these intodependencies: Node would then look inside the plugin's ownnode_modules, and a miss fails the whole plugin load.
Option 2: copy it in
Copy-Item -Recurse -Force "D:\path\to\dsh-loop-detector" `
"$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-loop-detector"
Register it as a bundle (skip this and the GUI will not list it)
The plugin ships dsh.bundle.patch (pointing at this repo's
cordis.patch.yml), which is its self-mount patch. So all you need is to
add the package name to the profile's dsh.profile.bundles:
// $env:USERPROFILE\.dsh\profiles\desktop\package.json
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
// …existing entries…
"dsh-loop-detector" // ← add this line
]
}
}
Restart dsh: the plugin now shows up under Settings → Plugins → Installed, where the toggle
can enable or disable it. (pnpm add link: alone makes the plugin work, but it will not
appear on the plugins page — that list is exactly this bundles array.)
To override defaults, put an id-targeted override at the end of the profile's
cordis.patch.yml; it survives plugin upgrades:
- id: loop-detector
config:
minLen: 1024
checkReasoning: false
To disable temporarily, use the GUI toggle, or:
- id: loop-detector
disabled: true
Then let a model loop on purpose. The log shows:
[loop-detector] session xxx 正文本命中: periodic repeat: period=8 x4|片段 "abcdefgh"
[loop-detector] session xxx 检测到死循环(重试 1/1): periodic repeat: period=8 x4;已打断并引导重试
[loop-detector] session xxx 意图级重复: repeated web_search intent: novelty=0.29 similarity=0.71
Configuration
Interrupt behaviour
| Field | Default | Meaning |
|---|---|---|
minLen | 512 | Start detecting only after this many characters |
maxRetries | 1 | Interrupt-and-guide attempts; then the turn is cancelled |
action | 'steer' | 'steer' = interrupt + guided retry; 'cancel' = cancel on first hit |
cooldownMs | 1500 | Cooldown after a hit (residual chunks must not re-trigger) |
checkText | true | Detect plain text output |
checkReasoning | true | Detect reasoning output |
checkEvery | 64 | Run the detector at most once per this many new characters |
bufferLimit | 16384 | Trailing characters kept per stream (bounded memory) |
Tool-call intent-level detection
| Field | Default | Meaning |
|---|---|---|
toolPatterns | ['web_search'] | Tools to watch (wildcards); empty array disables the feature |
historySize | 3 | How many recent calls form the topic keyword set |
noveltyThreshold | 0.25 | Novel-keyword ratio below this counts as "no new information" |
similarityThreshold | 0.5 | Jaccard similarity at or above this counts as the same topic |
stallLimit | 2 | Consecutive stalled calls before it is a loop |
Detector tuning (rarely needed)
| Field | Default | Meaning |
|---|---|---|
minPeriod / maxPeriod | 8 / 256 | Period scan range |
periodRepeats | 4 | How many identical periods make a loop |
ngramSize / ngramRepeats | 64 / 3 | n-gram block size and minimum repeats |
ngramWindow / ngramStep | 2048 / 16 | n-gram window and sampling stride |
anchorSize / anchorWindow | 512 / 8192 | Anchor length and window |
minDistinctChars | 2 | Minimum distinct characters in a repeated unit |
minSubstantiveRatio | 0.3 | Minimum substantive-character ratio in a repeated unit |
legacySessionEvents | false | Old-dsh compatibility channel (session/event → assistant/chunk) |
Invalid configuration (unknown action, minLen < 1, ratios outside [0,1]) fails loudly
at mount time instead of silently falling back.
How it works
Text level (src/detector.ts):
1) period scan tail repeats a period of [minPeriod..maxPeriod] for periodRepeats times
2) n-gram the same ngramSize-char block appears >= ngramRepeats times in ngramWindow
3) anchor trailing anchorSize chars reappear earlier and extend >= anchorSize back
every layer additionally requires a "meaningful" unit: >= minDistinctChars distinct chars
and substantive-character ratio >= minSubstantiveRatio
Tool-intent level (src/intent.ts):
keywords = latin words + CJK bigrams split on stop characters
topic = union of the last historySize calls' keywords
novelty = novel keywords / current keywords
similarity = |intersection| / |union| (Jaccard)
novelty < noveltyThreshold OR similarity >= similarityThreshold -> one "stall"
stallLimit consecutive stalls -> intent-level loop -> interrupt
CJK bigrams exist because whitespace tokenization is meaningless for Chinese; the extra similarity criterion exists because rewording in Chinese naturally introduces new bigrams.
Development and tests
npm test # node --test runs .ts natively (Node >= 22.18 type stripping)
39 tests cover all three detectors (hits and non-hits: markdown rules, ordinary prose,
short text), Chinese/English keyword extraction and intent decisions, configuration
validation, and — using a real cordis Context and the real dsh-agent scoped dispatcher —
the wiring of agent/assistant-stream / agent/pre-step / tools/post-execute plus the
"interrupt + guide, then cancel" behaviour.
Running the tests requires @deepseek-ai/cordis, @deepseek-ai/dsh-agent and
@deepseek-ai/dsh-llm to be resolvable. A bare node --test never goes through dsh's
profile resolver, so this repo may contain a git-ignored node_modules junction pointing at
dsh's installation node_modules — it exists only for local tests. At runtime dsh uses
its own resolver and does not depend on it.
New-Item -ItemType Junction -Path .\node_modules `
-Target "$env:LOCALAPPDATA\Programs\dsh\resources\app\dsh\node_modules" # your dsh install dir
Limitations
- Detection is heuristic; tune the thresholds for your model. For small local models try
minLenbetween 256 and 512. - Intent-level detection compares argument strings, not meaning. A very broad
toolPatterns(e.g.'*') may false-positive on legitimately varied tool use. - Interrupts are in-process: counters restart from zero after a dsh restart or session resume.
- Subagents are counted independently (keyed per agent); parent and child loops are never merged.
License
MIT — see LICENSE.