跳过主要内容
V

dsh-loop-detector

viego-qiin/dsh-loop-detector

检测文本循环及同主题搜索重试,并在软提醒无效时硬中断。

安装

dsh plugin --profile web add github:viego-qiin/dsh-loop-detector

README

dsh-loop-detector

Dead-loop / repetition detector with hard interrupt for DeepSeek Harness (dsh).

License: MIT

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:

Problemv1Reality on 0.2.xv2
Never sees streamed textlistened to session/event → assistant/chunkassistant/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 interruptused agent.steer() as the interruptsteer() only queues a message for the next step; it does not abort the current generationagent.cancel(cause, { keepInbox: true }) first, then steer() to guide
Retry budget never runs outreset the counter on turn/endevery steer() opens a new turn, so the counter was cleared as soon as it grewresets 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-stream for text-delta and (optionally) reasoning-delta, with three layered detectors:
    1. Period scan: the tail repeats with a fixed period (default 8–256 chars) ≥4 times
    2. n-gram: the same 64-char block appears ≥3 times inside the last 2048 chars
    3. 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". toolPatterns takes wildcards, so grep, 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 maxRetries the 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).

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 in peerDependencies. For a link:-ed plugin, dsh's profile resolver routes a bare specifier to dsh's own installed copy only when the plugin's package.json lists that name under peerDependencies. Do not move these into dependencies: Node would then look inside the plugin's own node_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

FieldDefaultMeaning
minLen512Start detecting only after this many characters
maxRetries1Interrupt-and-guide attempts; then the turn is cancelled
action'steer''steer' = interrupt + guided retry; 'cancel' = cancel on first hit
cooldownMs1500Cooldown after a hit (residual chunks must not re-trigger)
checkTexttrueDetect plain text output
checkReasoningtrueDetect reasoning output
checkEvery64Run the detector at most once per this many new characters
bufferLimit16384Trailing characters kept per stream (bounded memory)

Tool-call intent-level detection

FieldDefaultMeaning
toolPatterns['web_search']Tools to watch (wildcards); empty array disables the feature
historySize3How many recent calls form the topic keyword set
noveltyThreshold0.25Novel-keyword ratio below this counts as "no new information"
similarityThreshold0.5Jaccard similarity at or above this counts as the same topic
stallLimit2Consecutive stalled calls before it is a loop

Detector tuning (rarely needed)

FieldDefaultMeaning
minPeriod / maxPeriod8 / 256Period scan range
periodRepeats4How many identical periods make a loop
ngramSize / ngramRepeats64 / 3n-gram block size and minimum repeats
ngramWindow / ngramStep2048 / 16n-gram window and sampling stride
anchorSize / anchorWindow512 / 8192Anchor length and window
minDistinctChars2Minimum distinct characters in a repeated unit
minSubstantiveRatio0.3Minimum substantive-character ratio in a repeated unit
legacySessionEventsfalseOld-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 minLen between 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.

相关插件