@argszero/cordis-plugin-thinking-loop-guard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 argszero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @argszero/cordis-plugin-thinking-loop-guard
2
+
3
+ Thinking-loop guard for the DeepSeek Harness (`dsh`). Detects an agent that
4
+ degrades into a **pure-thinking loop** — a step that emits only `reasoning-delta`
5
+ chunks, with **zero `text-delta` and zero `tool-call-delta`** — and reacts to break
6
+ the loop before it burns tokens until a human manually aborts the turn.
7
+
8
+ ## The gap it closes
9
+
10
+ The in-tree `guard/` family watches two failure modes, both **tool-call-centric**:
11
+
12
+ | Guard | Hook | Catches |
13
+ |---|---|---|
14
+ | `guard/timeout-policy` | `tools/execute` | a tool call that exceeds a declared `timeoutMs` |
15
+ | `guard/repeat-tool-reminder` | `tools/post-execute` | the model repeating the **same tool call** chain |
16
+
17
+ Neither fires when the model emits **no tool call at all** — the exact shape of a
18
+ thinking loop. When a model (observed with
19
+ `deepseek-v4.1-flash-expires-on-0910` under `max`/`high` reasoning effort and a
20
+ long context) keeps emitting low-entropy reasoning ("好。执行。好。", repeated
21
+ "Let me / Wait / Actually / Hmm"), the agent-loop `turn()` `while (true)` never
22
+ sets `turnEnds` (`StepEndReason` only carries `completed` / `max-tokens`), so the
23
+ turn never ends until the user aborts it. See
24
+ [deepseek-ai/deepseek-harness discussion #5976](https://github.com/deepseek-ai/deepseek-harness/discussions/5976).
25
+
26
+ ## How it works
27
+
28
+ This plugin is a **community-side fix** that needs no harness patch. It subscribes
29
+ to the public `agent/assistant-stream` event (scoped emit) and tallies the
30
+ `StreamChunk` composition of each attempt:
31
+
32
+ - a chunk of type `text-delta` or `tool-call-delta` **resets** the counter — this
33
+ step produced output, so it is not a thinking loop;
34
+ - a step that is **only** `reasoning-delta`, and at least `minReasoningChars`
35
+ long, counts toward `maxThinkingSteps`.
36
+
37
+ When the threshold is crossed (or the reasoning text shows an unambiguous
38
+ low-entropy repetition, i.e. `repeatRatio`), it escalates:
39
+
40
+ 1. `escalate: 'warn'` → `agent.inject(...)` a notice into the next pre-step;
41
+ 2. `escalate: 'steer'` (default) → `agent.steer(...)` a "stop deliberating and act"
42
+ steering message into the next step boundary;
43
+ 3. `escalate: 'cancel'` → `agent.cancel(cause)` hard-aborts the active turn.
44
+
45
+ `agent.steer()`, `agent.inject()`, and `agent.cancel()` are all public methods on
46
+ the live `Agent`. A listener can react but cannot veto/rewrite an in-flight step;
47
+ steer and cancel are sufficient to break the loop.
48
+
49
+ ## Install
50
+
51
+ Load it as an `@deepseek-ai/cordis` plugin in your `dsh` profile, or mount the
52
+ bundle patch:
53
+
54
+ ```yaml
55
+ # cordis.patch.yml (already packaged in this plugin)
56
+ - insert:
57
+ - id: thinking-loop-guard
58
+ name: '@argszero/cordis-plugin-thinking-loop-guard'
59
+ ```
60
+
61
+ ## Configuration
62
+
63
+ ```ts
64
+ interface Config {
65
+ /** Consecutive reasoning-only steps (each ≥ minReasoningChars, no output) before reacting. Default 3. */
66
+ maxThinkingSteps?: number
67
+ /** Minimum reasoning text in one step before it counts. Default 2048 chars. */
68
+ minReasoningChars?: number
69
+ /** Longest repeated token / total tokens at which the step is flagged. Default 0.5. */
70
+ repeatRatio?: number
71
+ /** Action on the threshold: 'warn' | 'steer' (default) | 'cancel'. */
72
+ escalate?: 'warn' | 'steer' | 'cancel'
73
+ /** Cancel cause when escalate is 'cancel'. Default 'thinking-loop'. */
74
+ cancelCause?: string
75
+ }
76
+ ```
77
+
78
+ ## Notes
79
+
80
+ - The detector is deliberately **conservative**: a step must be long
81
+ (`minReasoningChars`) and reasoning-only; a short thinking burst or a step with
82
+ any real output is ignored.
83
+ - Per-`Agent` state is kept in a `WeakMap`, so a disposed agent is collected and
84
+ its counters dropped.
85
+ - The low-entropy check is a cheap heuristic (longest repeated whitespace token
86
+ over total tokens); it runs only once a step is already long, so cost is bound.
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,16 @@
1
+ # The @argszero/cordis-plugin-thinking-loop-guard bundle patch: the guard needs no
2
+ # deployment-specific config to mount (its defaults detect reasoning-only steps
3
+ # and steer on the second strike). Tune via a profile layer if desired:
4
+ #
5
+ # - set:
6
+ # - id: thinking-loop-guard
7
+ # config:
8
+ # maxThinkingSteps: 3
9
+ # minReasoningChars: 2048
10
+ # repeatRatio: 0.5
11
+ # escalate: steer
12
+ # cancelCause: thinking-loop
13
+
14
+ - insert:
15
+ - id: thinking-loop-guard
16
+ name: '@argszero/cordis-plugin-thinking-loop-guard'
package/lib/index.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Thinking-loop guard for the dsh harness.
3
+ *
4
+ * Closes a guard-layer gap the in-tree `guard/` family does not cover.
5
+ * `guard/timeout-policy` is a per-tool `tools/execute` deadline and
6
+ * `guard/repeat-tool-reminder` is a same-tool-call chain detector — only a tool
7
+ * *call* arms either. When a model degrades into a pure-thinking loop
8
+ * (`deepseek-v4.1-flash-expires-on-0910` under `max`/`high` reasoning effort and
9
+ * a long context), it emits only `reasoning-delta` chunks — zero `text-delta`,
10
+ * zero `tool-call-delta` — so neither fires, `turn()` never sets `turnEnds`, and
11
+ * the agent-loop `while (true)` never breaks until the user manually aborts.
12
+ *
13
+ * This plugin is the community-side fix. It listens to the public
14
+ * `agent/assistant-stream` event, tallies the `StreamChunk` composition per
15
+ * `(agent, turn, step)`, and detects a step that is reasoning-only AND shows a
16
+ * low-entropy repetition. It then escalates through configured reactions:
17
+ * a `warn` inject, then a `steer`, then a `cancel`.
18
+ *
19
+ * Mechanism notes (verified against packages/core/agent-loop/src/agent.ts and
20
+ * packages/core/agent/src/runtime-types.ts on dsh 0.1.5-alpha.1):
21
+ * - `agent/assistant-stream` is scoped emit; `frame` is one
22
+ * `AssistantStreamFrame` (`start` | `chunk` | `end`).
23
+ * - `frame.chunk: StreamChunk` distinguishes `reasoning-delta` / `text-delta` /
24
+ * `tool-call-delta` (packages/llm/llm/src/types.ts).
25
+ * - `payload.agent.steer(message)`, `payload.agent.inject(message)`, and
26
+ * `payload.agent.cancel(cause)` are public methods.
27
+ * - A listener can react but cannot veto/rewrite an in-flight step; steer and
28
+ * cancel are sufficient to break the loop.
29
+ *
30
+ * @module @argszero/cordis-plugin-thinking-loop-guard
31
+ */
32
+ import { Context } from '@deepseek-ai/cordis';
33
+ import z from '@deepseek-ai/schemastery';
34
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
35
+ export const Config = z.object({
36
+ maxThinkingSteps: z.number().min(2).default(3),
37
+ minReasoningChars: z.number().min(256).default(2048),
38
+ repeatRatio: z.number().min(0).max(1).default(0.5),
39
+ escalate: z.union(['warn', 'steer', 'cancel']).default('steer'),
40
+ cancelCause: z.string().default('thinking-loop'),
41
+ });
42
+ export const name = 'thinking-loop-guard';
43
+ const PLUGIN_SOURCE = { kind: 'plugin', plugin: 'thinking-loop-guard' };
44
+ function message(text, form) {
45
+ return createUserMessage({
46
+ content: [{ type: 'text', text }],
47
+ source: { ...PLUGIN_SOURCE, form, summary: 'thinking-loop-guard' },
48
+ });
49
+ }
50
+ /** Low-entropy ratio: longest repeated whitespace token / total tokens. */
51
+ function repeatRatio(reasoning) {
52
+ if (reasoning.length < 16)
53
+ return 0;
54
+ const tokens = reasoning.split(/\s+/u).filter((t) => t.length > 0);
55
+ if (tokens.length < 8)
56
+ return 0;
57
+ const counts = new Map();
58
+ for (const tok of tokens) {
59
+ const key = tok.toLowerCase();
60
+ counts.set(key, (counts.get(key) ?? 0) + 1);
61
+ }
62
+ let best = 1;
63
+ for (const [, count] of counts)
64
+ if (count > best)
65
+ best = count;
66
+ return best / tokens.length;
67
+ }
68
+ /**
69
+ * Install the listener. Per-`Agent` state is keyed in a `WeakMap` so a disposed
70
+ * agent is collected; counters are scoped to one agent lifecycle.
71
+ */
72
+ export function apply(ctx, config) {
73
+ const maxThinkingSteps = config.maxThinkingSteps;
74
+ const minReasoningChars = config.minReasoningChars;
75
+ const repeatRatioThreshold = config.repeatRatio;
76
+ const escalate = config.escalate;
77
+ const cancelCause = config.cancelCause;
78
+ const states = new WeakMap();
79
+ function stateFor(agent) {
80
+ let state = states.get(agent);
81
+ if (state === undefined) {
82
+ state = { attempt: null, thinkingSteps: 0, reacted: false };
83
+ states.set(agent, state);
84
+ }
85
+ return state;
86
+ }
87
+ function reset(state) {
88
+ state.thinkingSteps = 0;
89
+ state.reacted = false;
90
+ }
91
+ function react(state, agent) {
92
+ if (state.reacted)
93
+ return;
94
+ state.reacted = true;
95
+ switch (escalate) {
96
+ case 'warn':
97
+ agent.inject(message('The agent has produced several long reasoning-only steps with no output. '
98
+ + 'If this continues it will be interrupted.', 'notice'));
99
+ return;
100
+ case 'steer':
101
+ agent.steer(message('You are repeating the same reasoning without acting. Stop deliberating and '
102
+ + 'either call a tool or produce a concise answer now.', 'notice'));
103
+ return;
104
+ case 'cancel':
105
+ agent.cancel(cancelCause);
106
+ return;
107
+ }
108
+ }
109
+ ctx.on('agent/assistant-stream', (payload) => {
110
+ const { agent, frame } = payload;
111
+ const state = stateFor(agent);
112
+ if (frame.type === 'start') {
113
+ state.attempt = { reasoning: '', hasOutput: false };
114
+ return;
115
+ }
116
+ if (frame.type === 'chunk') {
117
+ if (state.attempt === null)
118
+ return;
119
+ const chunk = frame.chunk;
120
+ if (chunk.type === 'reasoning-delta') {
121
+ state.attempt.reasoning += chunk.text;
122
+ return;
123
+ }
124
+ if (chunk.type === 'text-delta' || chunk.type === 'tool-call-delta') {
125
+ state.attempt.hasOutput = true;
126
+ }
127
+ return;
128
+ }
129
+ if (frame.type === 'end') {
130
+ if (state.attempt === null)
131
+ return;
132
+ const attempt = state.attempt;
133
+ state.attempt = null;
134
+ if (attempt.hasOutput) {
135
+ // Any text or tool output means this is NOT a thinking loop.
136
+ reset(state);
137
+ return;
138
+ }
139
+ if (attempt.reasoning.length < minReasoningChars) {
140
+ // Too short to be a degenerate run; do not count it.
141
+ return;
142
+ }
143
+ // A long reasoning-only step: count it toward the threshold.
144
+ state.thinkingSteps += 1;
145
+ const ratio = repeatRatio(attempt.reasoning);
146
+ if (state.thinkingSteps >= maxThinkingSteps || ratio >= repeatRatioThreshold) {
147
+ react(state, agent);
148
+ }
149
+ }
150
+ });
151
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Thinking-loop guard for the dsh harness.
3
+ *
4
+ * Closes a guard-layer gap the in-tree `guard/` family does not cover.
5
+ * `guard/timeout-policy` is a per-tool `tools/execute` deadline and
6
+ * `guard/repeat-tool-reminder` is a same-tool-call chain detector — only a tool
7
+ * *call* arms either. When a model degrades into a pure-thinking loop
8
+ * (`deepseek-v4.1-flash-expires-on-0910` under `max`/`high` reasoning effort and
9
+ * a long context), it emits only `reasoning-delta` chunks — zero `text-delta`,
10
+ * zero `tool-call-delta` — so neither fires, `turn()` never sets `turnEnds`, and
11
+ * the agent-loop `while (true)` never breaks until the user manually aborts.
12
+ *
13
+ * This plugin is the community-side fix. It listens to the public
14
+ * `agent/assistant-stream` event, tallies the `StreamChunk` composition per
15
+ * `(agent, turn, step)`, and detects a step that is reasoning-only AND shows a
16
+ * low-entropy repetition. It then escalates through configured reactions:
17
+ * a `warn` inject, then a `steer`, then a `cancel`.
18
+ *
19
+ * Mechanism notes (verified against packages/core/agent-loop/src/agent.ts and
20
+ * packages/core/agent/src/runtime-types.ts on dsh 0.1.5-alpha.1):
21
+ * - `agent/assistant-stream` is scoped emit; `frame` is one
22
+ * `AssistantStreamFrame` (`start` | `chunk` | `end`).
23
+ * - `frame.chunk: StreamChunk` distinguishes `reasoning-delta` / `text-delta` /
24
+ * `tool-call-delta` (packages/llm/llm/src/types.ts).
25
+ * - `payload.agent.steer(message)`, `payload.agent.inject(message)`, and
26
+ * `payload.agent.cancel(cause)` are public methods.
27
+ * - A listener can react but cannot veto/rewrite an in-flight step; steer and
28
+ * cancel are sufficient to break the loop.
29
+ *
30
+ * @module @argszero/cordis-plugin-thinking-loop-guard
31
+ */
32
+ import { Context } from '@deepseek-ai/cordis';
33
+ import z from '@deepseek-ai/schemastery';
34
+ /** Plugin configuration. */
35
+ export interface Config {
36
+ /**
37
+ * Consecutive reasoning-only steps (each at least `minReasoningChars` long,
38
+ * no text/tool output) before a reaction. Default `3`.
39
+ */
40
+ maxThinkingSteps?: number;
41
+ /**
42
+ * Minimum reasoning text within one step before it counts as a thinking step
43
+ * at all; a short burst is normal. Default `2048` chars.
44
+ */
45
+ minReasoningChars?: number;
46
+ /**
47
+ * Approximate low-entropy repeat detection: the longest single repeated
48
+ * whitespace-delimited token, as a fraction of total tokens in the step's
49
+ * reasoning text. A degenerate "好。执行。" loop is near 1.0; coherent
50
+ * exploration is low. Only consulted once `minReasoningChars` is met.
51
+ * Default `0.5`.
52
+ */
53
+ repeatRatio?: number;
54
+ /**
55
+ * Reaction to fire when the threshold is crossed. `warn` injects a notice,
56
+ * `steer` sends a steering message, `cancel` hard-aborts the turn. Default
57
+ * `steer`.
58
+ */
59
+ escalate?: 'warn' | 'steer' | 'cancel';
60
+ /** Cancel cause used when `escalate` is `cancel`. Default `'thinking-loop'`. */
61
+ cancelCause?: string;
62
+ }
63
+ /** Resolved config: every field carries its validated default. */
64
+ type ResolvedConfig = Required<Config>;
65
+ export declare const Config: z<Config>;
66
+ export declare const name = "thinking-loop-guard";
67
+ /**
68
+ * Install the listener. Per-`Agent` state is keyed in a `WeakMap` so a disposed
69
+ * agent is collected; counters are scoped to one agent lifecycle.
70
+ */
71
+ export declare function apply(ctx: Context, config: ResolvedConfig): void;
72
+ export {};
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@argszero/cordis-plugin-thinking-loop-guard",
3
+ "description": "Thinking-loop guard for dsh: detects reasoning-only degenerate steps (no text, no tool call) via the agent/assistant-stream event and escalates warn -> steer -> cancel to break the loop.",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./src/*": "./src/*",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib/index.js",
18
+ "lib/types/**/*.d.ts",
19
+ "cordis.patch.yml",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "license": "MIT",
24
+ "keywords": [
25
+ "cordis",
26
+ "deepseek-harness",
27
+ "dsh",
28
+ "plugin",
29
+ "guard",
30
+ "thinking-loop",
31
+ "reasoning"
32
+ ],
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ }
37
+ },
38
+ "peerDependencies": {
39
+ "@deepseek-ai/cordis": "^4.0.2"
40
+ },
41
+ "dependencies": {
42
+ "@deepseek-ai/dsh-agent": "^0.1.5-alpha.1",
43
+ "@deepseek-ai/dsh-llm": "^0.1.5-alpha.1",
44
+ "@deepseek-ai/schemastery": "^3.18.1"
45
+ },
46
+ "devDependencies": {
47
+ "@deepseek-ai/cordis": "^4.0.2",
48
+ "typescript": "^5.5.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc",
52
+ "prepublishOnly": "tsc"
53
+ }
54
+ }