DSH plugin runaway tool-call args: guard for thinking loops

TroubleshootingPublished 2026-09-12Author: DeepSeek Plugin Market
DeepSeek HarnessDSH pluginthinking looprunaway tool-call argsOutput token limit reached
On long DeepSeek Harness runs a turn can spin on thinking with zero output, or a tool argument grows until the budget is gone. Detect it by repeated phrases.

On long DeepSeek Harness runs there are two ways a run can eat its entire output budget: a turn that spins on thinking with zero tool calls and zero text, and a tool-call argument that keeps being appended without bound. Both can end up showing you nothing but Output token limit reached, yet neither is context overflow, and neither is solved by raising maxTokens or adding a wall-clock timeout. Detection has to rely on repeated phrases and per-call fragment and byte accounting, and community guard plugins can already warn, steer, and cancel at configurable thresholds.

DSH plugin: two shapes of runaway

One error string hides two different runaway shapes, and you have to tell them apart before choosing a detector. What the community recorded:

  1. Reasoning-only spin: turns with zero tool calls and zero reply text, all of it thinking deltas, whose content falls into a degenerate loop that keeps generating self-prompting phrases like "好。执行。好。(输出工具调用)". The model even tried to self-correct ("嗯,我要停止循环,直接输出工具调用") and then resumed looping. The two recorded episodes lasted about 2 minutes 15 seconds and roughly 67,000 events, then about 16 seconds and roughly 2,000 events, all of them thinking deltas, with a manual abort the only way out (#5976).
  2. The threshold is lower than it looks: another user on Windows plus dsh web with the same deepseek-v4.1-flash-expires-on-0910 model hit it on an ordinary, slightly longer task. The UI only shows "thinking", and unless you open the thinking block it looks no different from sincere reasoning, so it is easily mistaken for the model simply being slow (#5976).
  3. Argument runaway: after the parent successfully called subagent, the next step began a job_output call, which is already the wrong control for a continuable subagent id. The streamed JSON opened with the correct id and then repeated its suffix thousands of times: {"job_id":"a72944e6-...717af96a717af96a..."}. The session recorded 4,096 output tokens, stopReason: "length", and a max-tokens turn end, with no usable content in the assembled assistant message (#6059).
  4. The front end downgrades the information: the web UI only showed Output token limit reached, which initially made it look like an ordinary response-length problem. The real cause is visible in the durable stream: a tool-call-delta sequence accumulating runaway arguments for job_output until block-end.
  5. There is a clear counter-example to the obvious fixes: raising maxTokens is not a fix, it only makes the failure slower and more expensive; and context length is not the main driver either, since the spin reproduced after compacting down to about 322K tokens (#5976).

DeepSeek Harness mechanism: reasoning-only spins and fragment-count runaways

Both chains can be pinned to specific source and both land on seams that a plugin can mount. Layer by layer:

  1. The turn loop does not end just because nothing was produced: the agent-loop turn loop (packages/core/agent-loop/src/agent.ts:274) only breaks when turnEnds is non-null (completed / max-tokens / blocked / aborted / error) and the inbox is empty, while the StepEndReason returned by step() only takes two forms, completed and max-tokens (agent.ts:50). When the model keeps emitting thinking deltas with neither text nor a tool call, stepEnd remains incomplete and turnEnds is never set, so the loop never ends.
  2. The existing guards are tool-call centric: guard/timeout-policy wraps tools/execute and only arms a deadline when a tool is called and declares timeoutMs; guard/repeat-tool-reminder hangs off tools/post-execute and detects chains of identical consecutive tool calls. With zero tool calls neither fires.
  3. The usable mounting surface is the stream event: agent/assistant-stream carries frame.chunk: StreamChunk per frame, and StreamChunk explicitly distinguishes the three kinds reasoning-delta / text-delta / tool-call-delta, so a plugin can tally each kind's share per (agent, turn, step). The reaction capabilities are public too: agent.steer(input) injects "you are repeating yourself, stop and act", and agent.cancel(cause) hard-stops.
  4. The argument-runaway seam is llm/stream: packages/llm/llm/src/index.ts:72 defines llm/stream as a Cordis waterfall, so a plugin can wrap the iterator; the StreamChunk type (llm/llm/src/types.ts:388-394) includes { type: 'tool-call-delta'; index; id; name?; argumentsDelta: string }, which is exactly the field to accumulate per call. You must bucket by index, never by id, because upstream may reuse the same id for different calls.
  5. The real incident overturns byte budgets: the recorded stream shows one call with index: 0, a stable id, and the name job_output, producing 4,074 fragments totalling only 4,658 ASCII bytes over about 115 seconds (histogram 3,492×1, 581×2, 1×4 bytes), with the JSON string never closed and upstream finally emitting { type: 'finish', reason: { kind: 'max-tokens' } }. That makes it a fragment-count runaway rather than a large-argument runaway: a 24 KiB default byte cap never triggers, a 4,096-byte setting only catches it at fragment 3,583, while a 1,024-fragment cap catches it at 1,173 bytes.
  6. Why emitting an error finish really blocks execution: the agent loop branches on finish reason before filtering tool-call blocks out of the assistant content. When finish.kind === 'error' || 'aborted', it settles the attempt and dispatches agent/request-error, never reaching the later .filter(block => block.type === 'tool-call') step; and the stream validator explicitly permits an error or aborted finish to carry open block indexes, so the protocol is legal.
  7. The persisted event names do not overlap across generations: assistant/chunk (session format v1, roughly ≤0.1.2-rc.1) and assistant/attempt (v2, roughly ≥0.1.5) have no intersection, so anything reading persisted events can only cover one generation; llm/stream is the seam present in both. One implementation-level trap on the side: earlier versions counted argumentsDelta.length, which is UTF-16 code units rather than the documented UTF-8 bytes, and therefore undercounts non-ASCII arguments.

DSH plugin guarding: repeated phrases and fragment thresholds

Each gap has a plugin-shaped backstop with configurable thresholds, and the split is clear: the plugin detects and cuts, the core decides not to execute and raises the structured error. The approach:

  1. Reasoning-only spins: @argszero/cordis-plugin-thinking-loop-guard subscribes to the stream event and tallies chunk composition frame by frame. As soon as a step emits text-delta or tool-call-delta it counts as productive and the counter resets; only a step that stays reasoning-only with more than a minimum length counts toward the consecutive spin count, and the reaction escalates through warn (inject a next pre-step hint) to steer (send "stop spinning, act now") to cancel('thinking-loop') (hard stop). The defaults are deliberately conservative (maxThinkingSteps: 3, minReasoningChars: 2048, repeatRatio: 0.5, escalate: 'steer') to avoid harming normal long reasoning.
  2. Argument runaways: @argszero/cordis-plugin-llm-tool-call-guard wraps llm/stream and accumulates each tool-call block's argumentsDelta as UTF-8 bytes (via TextEncoder) per index, then stops consuming upstream on breach and emits a terminal error finish carrying a stable routing code. Three complementary gates cover three shapes: single-block bytes maxArgsBytes (TOOL_CALL_ARGUMENTS_TOO_LARGE), whole-request aggregate maxTotalArgsBytes (..._TOTAL_TOO_LARGE, for "too many calls" rather than "one call too large"), and per-block fragments maxArgsFragments (..._TOO_MANY_FRAGMENTS, which covers the real 4,074-fragment incident above):
yaml
- set:
    - id: llm-tool-call-guard
      config:
        maxArgsBytes: 8192        # per-block byte cap
        maxArgsFragments: 1024    # per-block fragment cap; 0 disables
        maxTotalArgsBytes: 32768  # whole-request aggregate cap; 0 disables
        fail: true                # emit an error finish on breach, do not execute
  1. Calibrate thresholds on your own session files: the thinking-loop plugin ships an offline replay tool that runs the same detector the plugin uses at runtime against a session jsonl, printing reasonChars / textChars / verdict / fired per step. It reads locally and uploads nothing:
sh
node node_modules/@argszero/cordis-plugin-thinking-loop-guard/tools/analyze-session.mjs \
  <your session.jsonl> --similarity 0.6 --threshold 3 --min-chars 512
  1. Install plugins through the market: add and update guard plugins like these from DSH Plugin Hub under Settings, Plugin Market, which is safer than hand-editing mount lines into the profile because a failure rolls the manifest back. Watch the peer-range trap: in semver each comparator only admits prereleases sharing its own major.minor.patch tuple, so >=0.1.2-rc.1 <0.2.0 admits only 0.1.2-rc.1. Covering both the 0.1.2 and 0.1.5 lines requires >=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0; to self-check, npm view <pkg>@<range> version returning nothing means the range is dead.
  2. One local mitigation: switch delegation to one-shot, foreground-returning subagents. Independent calls issued in a single assistant response still run concurrently, but their results return directly with no id polling, which sidesteps the trigger for this local model (#6059).
  3. Do not treat this as an ordinary output cap: for other causes of Output token limit reached and how to handle them, see output limit troubleshooting.

DSH plugin troubleshooting notes

Duration is not the signal — normal long reasoning also goes a long time with no text or tool output, so a pure wall-clock threshold will always misfire; identify runaway by content features instead. Six points to keep in mind when a DeepSeek Harness plugin turn burns its output budget:

  1. Duration is not a feature: normal long reasoning also produced no text or tool output for a long time, so a pure wall-clock threshold will always misfire.
  2. Repeated phrases are the dependable signal: for CJK text without spaces ("好。执行。"), use language-agnostic repeated-gram coverage; the earlier whitespace-tokenisation approach could not detect it.
  3. Bucket by index, not by id: the same id may be reused for different calls.
  4. Bytes and fragments are complementary signals: the byte gate rejects genuinely large payloads, the fragment gate rejects fragment storms, and missing either leaves a blind spot.
  5. maxTokens is not a solution: a small maxTokens can still be eaten whole by one runaway call, with no per-call signal at all.
  6. A plugin can only react, not veto retrospectively: by contract it cannot veto a step that already happened, but steer / cancel plus an error finish are enough to stop it.
DSH Plugin Hub plugin market: install guard plugins, check versions and updates

Sources: Discussion #5976, Discussion #6059, cordis-plugin-thinking-loop-guard, cordis-plugin-llm-tool-call-guard.

FAQ

Why does a DeepSeek Harness turn sit on "thinking" forever with zero tool calls and no text, until I abort it by hand?

A DeepSeek Harness turn loop only breaks once a turn-end reason is set and the inbox is empty, and a step's end reason has only two forms, completed and max-tokens, so a zero-output turn keeps spinning. When the model keeps emitting thinking deltas without ever producing text or a tool call, stepEnd stays "incomplete" and turnEnds is never set, so the loop never exits. That is the mechanism behind a reasoning-only spin, and context length is not necessarily the cause: one user compacted a session from roughly 515K tokens down to about 322K tokens and the new turns still spun the same way (source: Discussion #5976).

Why do the existing guard plugins in a DeepSeek Harness install not stop a spin that makes zero tool calls?

In DeepSeek Harness the existing guard plugins are all tool-call centric, so none of them covers the zero-tool-call reasoning-only spin. guard/timeout-policy only arms a deadline when a tool is actually called and declares timeoutMs, and guard/repeat-tool-reminder detects chains of identical consecutive tool calls; with zero tool calls the former never fires and the latter has no chain to inspect. That is not a miss but a coverage gap: neither one was designed for the reasoning-only failure surface (source: Discussion #5976).

Why does a runaway tool argument in DeepSeek Harness end up showing only "Output token limit reached"?

In DeepSeek Harness a runaway tool-call-delta stream is allowed to keep appending until the model has exhausted its entire output allowance, so the front end can only show the generic output-limit message. The turn then ends with stopReason: "length" and a max-tokens turn end, and the assembled assistant message has no usable content. In the real incident one job_output call accumulated 4,074 fragments over about 115 seconds totalling only 4,658 bytes, which is a fragment-count runaway that a 24 KiB byte budget can never catch (source: Discussion #6059).

In DeepSeek Harness, can I fix a drained output budget by raising maxTokens or adding a time-based timeout?

In DeepSeek Harness, neither raising maxTokens nor adding a time-based timeout fixes a drained output budget. Raising maxTokens only makes the failure slower and more expensive, and a pure wall-clock threshold will misfire on normal long reasoning, which also produces no text or tool output for a long time, so duration is not a valid feature. Reliable detection has to use content features (repeated phrases via repeated-gram coverage) together with per-call fragment and byte accounting, which is exactly what the two community guard plugins do (source: Discussion #6059).

Related Terms

reasoning-only step
A step that emits only reasoning-delta chunks, with neither text-delta nor tool-call-delta. It satisfies neither of the two step end reasons, so the turn loop never exits on its own.https://github.com/deepseek-ai/deepseek-harness/discussions/5976
repeated-gram coverage
A language-agnostic repetition metric: the share of repeated fragments in the reasoning text. A CJK spin such as "好。执行。" with no spaces measures roughly 0.99 degeneracy, while coherent long reasoning measures about 0.0, at O(n) cost.https://github.com/argszero/cordis-plugin-thinking-loop-guard
fragment-count runaway
A runaway shape where one tool call's arguments are chopped into a very large number of small fragments and appended continuously while the total byte count stays small. It slips past byte-based budgets and can only be caught with a per-call fragment cap.https://github.com/deepseek-ai/deepseek-harness/discussions/6059

Sources