DSH plugin runaway tool-call args: guard for thinking loops
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:
- 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).
- The threshold is lower than it looks: another user on Windows plus
dsh webwith the samedeepseek-v4.1-flash-expires-on-0910model 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). - Argument runaway: after the parent successfully called
subagent, the next step began ajob_outputcall, 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 amax-tokensturn end, with no usable content in the assembled assistant message (#6059). - 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: atool-call-deltasequence accumulating runaway arguments forjob_outputuntilblock-end. - There is a clear counter-example to the obvious fixes: raising
maxTokensis 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:
- 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 whenturnEndsis non-null (completed / max-tokens / blocked / aborted / error) and the inbox is empty, while theStepEndReasonreturned bystep()only takes two forms,completedandmax-tokens(agent.ts:50). When the model keeps emitting thinking deltas with neither text nor a tool call,stepEndremains incomplete andturnEndsis never set, so the loop never ends. - The existing guards are tool-call centric:
guard/timeout-policywrapstools/executeand only arms a deadline when a tool is called and declarestimeoutMs;guard/repeat-tool-reminderhangs offtools/post-executeand detects chains of identical consecutive tool calls. With zero tool calls neither fires. - The usable mounting surface is the stream event:
agent/assistant-streamcarriesframe.chunk: StreamChunkper frame, andStreamChunkexplicitly distinguishes the three kindsreasoning-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", andagent.cancel(cause)hard-stops. - The argument-runaway seam is
llm/stream:packages/llm/llm/src/index.ts:72definesllm/streamas a Cordis waterfall, so a plugin can wrap the iterator; theStreamChunktype (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 byindex, never byid, because upstream may reuse the same id for different calls. - The real incident overturns byte budgets: the recorded stream shows one call with
index: 0, a stable id, and the namejob_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. - 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 dispatchesagent/request-error, never reaching the later.filter(block => block.type === 'tool-call')step; and the stream validator explicitly permits anerrororabortedfinish to carry open block indexes, so the protocol is legal. - The persisted event names do not overlap across generations:
assistant/chunk(session format v1, roughly ≤0.1.2-rc.1) andassistant/attempt(v2, roughly ≥0.1.5) have no intersection, so anything reading persisted events can only cover one generation;llm/streamis the seam present in both. One implementation-level trap on the side: earlier versions countedargumentsDelta.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:
- Reasoning-only spins:
@argszero/cordis-plugin-thinking-loop-guardsubscribes to the stream event and tallies chunk composition frame by frame. As soon as a step emitstext-deltaortool-call-deltait 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 throughwarn(inject a next pre-step hint) tosteer(send "stop spinning, act now") tocancel('thinking-loop')(hard stop). The defaults are deliberately conservative (maxThinkingSteps: 3,minReasoningChars: 2048,repeatRatio: 0.5,escalate: 'steer') to avoid harming normal long reasoning. - Argument runaways:
@argszero/cordis-plugin-llm-tool-call-guardwrapsllm/streamand accumulates each tool-call block'sargumentsDeltaas UTF-8 bytes (viaTextEncoder) per index, then stops consuming upstream on breach and emits a terminalerrorfinish carrying a stable routing code. Three complementary gates cover three shapes: single-block bytesmaxArgsBytes(TOOL_CALL_ARGUMENTS_TOO_LARGE), whole-request aggregatemaxTotalArgsBytes(..._TOTAL_TOO_LARGE, for "too many calls" rather than "one call too large"), and per-block fragmentsmaxArgsFragments(..._TOO_MANY_FRAGMENTS, which covers the real 4,074-fragment incident above):
- 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
- 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/firedper step. It reads locally and uploads nothing:
node node_modules/@argszero/cordis-plugin-thinking-loop-guard/tools/analyze-session.mjs \
<your session.jsonl> --similarity 0.6 --threshold 3 --min-chars 512
- 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.patchtuple, so>=0.1.2-rc.1 <0.2.0admits only0.1.2-rc.1. Covering both the0.1.2and0.1.5lines requires>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0; to self-check,npm view <pkg>@<range> versionreturning nothing means the range is dead. - 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).
- Do not treat this as an ordinary output cap: for other causes of
Output token limit reachedand 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:
- 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.
- 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.
- Bucket by index, not by id: the same id may be reused for different calls.
- 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.
maxTokensis not a solution: a smallmaxTokenscan still be eaten whole by one runaway call, with no per-call signal at all.- A plugin can only react, not veto retrospectively: by contract it cannot veto a step that already happened, but
steer/cancelplus an error finish are enough to stop it.

Sources: Discussion #5976, Discussion #6059, cordis-plugin-thinking-loop-guard, cordis-plugin-llm-tool-call-guard.
FAQ
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).
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).
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, 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
- deepseek-harness Discussion #5976: thinking degeneration loop under very long context plus max reasoning effort, with zero-output turns and no automatic circuit breaker· deepseek-ai (GitHub Discussions)
- deepseek-harness Discussion #6059: Runaway tool-call arguments consume the full output budget before validation· deepseek-ai (GitHub Discussions)
- cordis-plugin-thinking-loop-guard: repeated-gram coverage detection for reasoning-only spins (ships the offline analyze-session.mjs tool)· GitHub (argszero)
- cordis-plugin-llm-tool-call-guard: byte, fragment, and aggregate budgets per call index at the llm/stream seam· GitHub (argszero)