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

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/README.md CHANGED
@@ -25,13 +25,16 @@ turn never ends until the user aborts it. See
25
25
 
26
26
  ## How it works
27
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:
28
+ This plugin is a **community-side fix** that needs no harness patch. It observes
29
+ the public `llm/stream` waterfall (present on **dsh 0.1.2-rc.1 and 0.1.5-alpha.1**)
30
+ and tallies the `StreamChunk` composition of each loop-built model call. The live
31
+ `Agent` is reached from the request's `sessionId` via `ctx.agents.get(...)`,
32
+ so the guard works on the widely-installed 0.1.2-rc.1 as well as current master
33
+ (no dependency on the 0.1.5-alpha.1-only `agent/assistant-stream` seam).
31
34
 
32
35
  - 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`
36
+ call produced output, so it is not a thinking loop;
37
+ - a call that is **only** `reasoning-delta`, and at least `minReasoningChars`
35
38
  long, counts toward `maxThinkingSteps`.
36
39
 
37
40
  When the threshold is crossed (or the reasoning text shows an unambiguous
@@ -66,7 +69,7 @@ interface Config {
66
69
  maxThinkingSteps?: number
67
70
  /** Minimum reasoning text in one step before it counts. Default 2048 chars. */
68
71
  minReasoningChars?: number
69
- /** Longest repeated token / total tokens at which the step is flagged. Default 0.5. */
72
+ /** Repeated-gram coverage of the reasoning text at which the call is flagged. Language-agnostic (handles CJK, no whitespace). Default 0.5. */
70
73
  repeatRatio?: number
71
74
  /** Action on the threshold: 'warn' | 'steer' (default) | 'cancel'. */
72
75
  escalate?: 'warn' | 'steer' | 'cancel'
@@ -82,8 +85,19 @@ interface Config {
82
85
  any real output is ignored.
83
86
  - Per-`Agent` state is kept in a `WeakMap`, so a disposed agent is collected and
84
87
  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.
88
+ - The low-entropy check is a cheap O(n) heuristic (repeated fixed-length gram
89
+ coverage; language-agnostic, so it works for CJK reasoning with no
90
+ whitespace); it runs only once a call is already long, so cost is bound.
91
+
92
+ ## Compatibility
93
+
94
+ - **dsh 0.1.2-rc.1** (the widely-installed npm release): works — `llm/stream`,
95
+ `StreamChunk`, `GenerateOptions.sessionId`, `ctx.agents.get`, and
96
+ `agent.steer/cancel/inject` are all present.
97
+ - **dsh 0.1.5-alpha.1**: works — same `llm/stream` seam. The newer
98
+ `agent/assistant-stream` event is NOT required; the guard does not depend on it.
99
+ - The guard needs `@deepseek-ai/dsh-agent` / `@deepseek-ai/dsh-llm` `>=0.1.2`
100
+ (declared as peer dependencies).
87
101
 
88
102
  ## License
89
103
 
package/lib/index.js CHANGED
@@ -10,28 +10,39 @@
10
10
  * zero `tool-call-delta` — so neither fires, `turn()` never sets `turnEnds`, and
11
11
  * the agent-loop `while (true)` never breaks until the user manually aborts.
12
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`.
13
+ * This plugin is the community-side fix. It observes the public `llm/stream`
14
+ * waterfall (present on dsh **0.1.2-rc.1 and 0.1.5-alpha.1**), tallies the
15
+ * `StreamChunk` composition of each loop-built model call, and detects a call
16
+ * that is reasoning-only AND shows a low-entropy repetition. It then escalates
17
+ * through configured reactions: a `warn` inject, then a `steer`, then a
18
+ * `cancel`.
18
19
  *
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` /
20
+ * Why `llm/stream` and not `agent/assistant-stream`: the latter (scoped emit
21
+ * with `start`/`chunk`/`end` frames) only exists from dsh 0.1.5-alpha.1; the
22
+ * widely-installed 0.1.2-rc.1 has neither it nor `AssistantStreamFrame`, so a
23
+ * plugin pinned to that seam silently no-ops on the common release. `llm/stream`
24
+ * is a Cordis waterfall around every streaming model call in both versions and
25
+ * carries the same `StreamChunk` delta types, and its `GenerateOptions` carries
26
+ * `sessionId`, from which the live Agent is reachable via `ctx.agents.get(...)`.
27
+ *
28
+ * Mechanism notes (verified against packages/core/agent-loop/src/agent.ts,
29
+ * packages/llm/llm/src/index.ts, and packages/core/agent/src/runtime-types.ts on
30
+ * dsh 0.1.5-alpha.1; the `llm/stream` / `StreamChunk` / `ctx.agents.get` trio is
31
+ * present unchanged on 0.1.2-rc.1):
32
+ * - `llm/stream` is a waterfall; a listener wraps `next()` and sees each chunk.
33
+ * - `chunk: StreamChunk` distinguishes `reasoning-delta` / `text-delta` /
24
34
  * `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.
35
+ * - Loop-built requests carry `markAgentLoopRequest`; `isAgentLoopRequest`
36
+ * filters out arbitrary non-agent streaming (tool streams, etc.).
37
+ * - `options.sessionId` `ctx.agents.get(sessionId)` yields the live `Agent`.
38
+ * - `agent.steer(message)`, `agent.inject(message)`, and `agent.cancel(cause)`
39
+ * are public methods; a listener can react but not veto an in-flight step.
29
40
  *
30
41
  * @module @argszero/cordis-plugin-thinking-loop-guard
31
42
  */
32
43
  import { Context } from '@deepseek-ai/cordis';
33
44
  import z from '@deepseek-ai/schemastery';
34
- import { createUserMessage } from '@deepseek-ai/dsh-llm';
45
+ import { createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm';
35
46
  export const Config = z.object({
36
47
  maxThinkingSteps: z.number().min(2).default(3),
37
48
  minReasoningChars: z.number().min(256).default(2048),
@@ -47,23 +58,35 @@ function message(text, form) {
47
58
  source: { ...PLUGIN_SOURCE, form, summary: 'thinking-loop-guard' },
48
59
  });
49
60
  }
50
- /** Low-entropy ratio: longest repeated whitespace token / total tokens. */
61
+ /**
62
+ * Low-entropy ratio: coverage by repeated fixed-length grams.
63
+ *
64
+ * The degenerate loops #5976 describes are CJK ("好。执行。好。执行。"), which have
65
+ * NO whitespace, so a whitespace-token histogram collapses to one token and can
66
+ * never distinguish repetition. This detector is language-agnostic: it slides a
67
+ * fixed-length window and reports the fraction of windows that have appeared
68
+ * before, so a tight repetition ("好。执行。" x N) scores near 1.0 while coherent
69
+ * reasoning (which rarely repeats a 4-char window verbatim) scores near 0.
70
+ *
71
+ * O(n) with a Set — cheap enough even for very long reasoning text (the guard
72
+ * inspects steps well past `minReasoningChars`).
73
+ */
51
74
  function repeatRatio(reasoning) {
52
75
  if (reasoning.length < 16)
53
76
  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);
77
+ const k = Math.min(4, Math.max(2, Math.floor(reasoning.length / 16)));
78
+ let repeated = 0;
79
+ let total = 0;
80
+ const seen = new Set();
81
+ for (let i = 0; i + k <= reasoning.length; i++) {
82
+ const gram = reasoning.slice(i, i + k);
83
+ total++;
84
+ if (seen.has(gram))
85
+ repeated++;
86
+ else
87
+ seen.add(gram);
61
88
  }
62
- let best = 1;
63
- for (const [, count] of counts)
64
- if (count > best)
65
- best = count;
66
- return best / tokens.length;
89
+ return total === 0 ? 0 : repeated / total;
67
90
  }
68
91
  /**
69
92
  * Install the listener. Per-`Agent` state is keyed in a `WeakMap` so a disposed
@@ -79,7 +102,7 @@ export function apply(ctx, config) {
79
102
  function stateFor(agent) {
80
103
  let state = states.get(agent);
81
104
  if (state === undefined) {
82
- state = { attempt: null, thinkingSteps: 0, reacted: false };
105
+ state = { call: null, thinkingSteps: 0, reacted: false };
83
106
  states.set(agent, state);
84
107
  }
85
108
  return state;
@@ -94,7 +117,7 @@ export function apply(ctx, config) {
94
117
  state.reacted = true;
95
118
  switch (escalate) {
96
119
  case 'warn':
97
- agent.inject(message('The agent has produced several long reasoning-only steps with no output. '
120
+ agent.inject(message('The agent has produced several long reasoning-only calls with no output. '
98
121
  + 'If this continues it will be interrupted.', 'notice'));
99
122
  return;
100
123
  case 'steer':
@@ -106,46 +129,61 @@ export function apply(ctx, config) {
106
129
  return;
107
130
  }
108
131
  }
109
- ctx.on('agent/assistant-stream', (payload) => {
110
- const { agent, frame } = payload;
132
+ // Observe every streaming model call through the `llm/stream` waterfall. This
133
+ // is present on both dsh 0.1.2-rc.1 and 0.1.5-alpha.1 and carries the SAME
134
+ // StreamChunk delta types, so no version branching is needed.
135
+ ctx.on('llm/stream', (options, next) => {
136
+ // Only agent-loop-built calls carry the loop identity; skip arbitrary
137
+ // non-agent streaming (tool streams, assistant replay, etc.).
138
+ if (!isAgentLoopRequest(options))
139
+ return next();
140
+ if (options.sessionId === undefined)
141
+ return next();
142
+ const agent = ctx.agents.get(options.sessionId);
143
+ if (agent === undefined)
144
+ return next();
111
145
  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;
146
+ // Reset the per-call candidate at the boundary; if the call yields any
147
+ // text or tool-call delta it is NOT a thinking loop.
148
+ state.call = { reasoning: '', hasOutput: false };
149
+ return (async function* wrapped() {
150
+ let reasoning = '';
151
+ let hasOutput = false;
152
+ let finished = false;
153
+ try {
154
+ for await (const chunk of next()) {
155
+ if (chunk.type === 'reasoning-delta') {
156
+ reasoning += chunk.text;
157
+ }
158
+ else if (chunk.type === 'text-delta' || chunk.type === 'tool-call-delta') {
159
+ hasOutput = true;
160
+ }
161
+ yield chunk;
162
+ }
123
163
  }
124
- if (chunk.type === 'text-delta' || chunk.type === 'tool-call-delta') {
125
- state.attempt.hasOutput = true;
164
+ finally {
165
+ if (!finished && !hasOutput) {
166
+ const call = state.call;
167
+ if (call !== null) {
168
+ call.reasoning = reasoning;
169
+ call.hasOutput = hasOutput;
170
+ }
171
+ state.call = null;
172
+ finished = true;
173
+ if (hasOutput) {
174
+ // Any text or tool output means this is NOT a thinking loop.
175
+ reset(state);
176
+ }
177
+ else if (reasoning.length >= minReasoningChars) {
178
+ // A long reasoning-only call: count it toward the threshold.
179
+ state.thinkingSteps += 1;
180
+ const ratio = repeatRatio(reasoning);
181
+ if (state.thinkingSteps >= maxThinkingSteps || ratio >= repeatRatioThreshold) {
182
+ react(state, agent);
183
+ }
184
+ }
185
+ }
126
186
  }
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
- }
187
+ })();
150
188
  });
151
189
  }
@@ -10,22 +10,33 @@
10
10
  * zero `tool-call-delta` — so neither fires, `turn()` never sets `turnEnds`, and
11
11
  * the agent-loop `while (true)` never breaks until the user manually aborts.
12
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`.
13
+ * This plugin is the community-side fix. It observes the public `llm/stream`
14
+ * waterfall (present on dsh **0.1.2-rc.1 and 0.1.5-alpha.1**), tallies the
15
+ * `StreamChunk` composition of each loop-built model call, and detects a call
16
+ * that is reasoning-only AND shows a low-entropy repetition. It then escalates
17
+ * through configured reactions: a `warn` inject, then a `steer`, then a
18
+ * `cancel`.
18
19
  *
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` /
20
+ * Why `llm/stream` and not `agent/assistant-stream`: the latter (scoped emit
21
+ * with `start`/`chunk`/`end` frames) only exists from dsh 0.1.5-alpha.1; the
22
+ * widely-installed 0.1.2-rc.1 has neither it nor `AssistantStreamFrame`, so a
23
+ * plugin pinned to that seam silently no-ops on the common release. `llm/stream`
24
+ * is a Cordis waterfall around every streaming model call in both versions and
25
+ * carries the same `StreamChunk` delta types, and its `GenerateOptions` carries
26
+ * `sessionId`, from which the live Agent is reachable via `ctx.agents.get(...)`.
27
+ *
28
+ * Mechanism notes (verified against packages/core/agent-loop/src/agent.ts,
29
+ * packages/llm/llm/src/index.ts, and packages/core/agent/src/runtime-types.ts on
30
+ * dsh 0.1.5-alpha.1; the `llm/stream` / `StreamChunk` / `ctx.agents.get` trio is
31
+ * present unchanged on 0.1.2-rc.1):
32
+ * - `llm/stream` is a waterfall; a listener wraps `next()` and sees each chunk.
33
+ * - `chunk: StreamChunk` distinguishes `reasoning-delta` / `text-delta` /
24
34
  * `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.
35
+ * - Loop-built requests carry `markAgentLoopRequest`; `isAgentLoopRequest`
36
+ * filters out arbitrary non-agent streaming (tool streams, etc.).
37
+ * - `options.sessionId` `ctx.agents.get(sessionId)` yields the live `Agent`.
38
+ * - `agent.steer(message)`, `agent.inject(message)`, and `agent.cancel(cause)`
39
+ * are public methods; a listener can react but not veto an in-flight step.
29
40
  *
30
41
  * @module @argszero/cordis-plugin-thinking-loop-guard
31
42
  */
@@ -34,19 +45,19 @@ import z from '@deepseek-ai/schemastery';
34
45
  /** Plugin configuration. */
35
46
  export interface Config {
36
47
  /**
37
- * Consecutive reasoning-only steps (each at least `minReasoningChars` long,
38
- * no text/tool output) before a reaction. Default `3`.
48
+ * Consecutive reasoning-only model calls (each at least `minReasoningChars`
49
+ * long, no text/tool output) before a reaction. Default `3`.
39
50
  */
40
51
  maxThinkingSteps?: number;
41
52
  /**
42
- * Minimum reasoning text within one step before it counts as a thinking step
53
+ * Minimum reasoning text within one call before it counts as a thinking step
43
54
  * at all; a short burst is normal. Default `2048` chars.
44
55
  */
45
56
  minReasoningChars?: number;
46
57
  /**
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
58
+ * Approximate low-entropy repeat detection: the longest repeated substring's
59
+ * coverage of the step's reasoning text. Language-agnostic (handles CJK with
60
+ * no whitespace): a degenerate "好。执行。" loop is near 1.0; coherent
50
61
  * exploration is low. Only consulted once `minReasoningChars` is met.
51
62
  * Default `0.5`.
52
63
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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",
3
+ "description": "Thinking-loop guard for dsh: detects reasoning-only degenerate calls (no text, no tool call) via the llm/stream waterfall and escalates warn -> steer -> cancel to break the loop. Compatible with dsh 0.1.2-rc.1 and 0.1.5-alpha.1.",
4
+ "version": "0.1.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -36,11 +36,11 @@
36
36
  }
37
37
  },
38
38
  "peerDependencies": {
39
- "@deepseek-ai/cordis": "^4.0.2"
39
+ "@deepseek-ai/cordis": "^4.0.2",
40
+ "@deepseek-ai/dsh-agent": ">=0.1.2",
41
+ "@deepseek-ai/dsh-llm": ">=0.1.2"
40
42
  },
41
43
  "dependencies": {
42
- "@deepseek-ai/dsh-agent": "^0.1.5-alpha.1",
43
- "@deepseek-ai/dsh-llm": "^0.1.5-alpha.1",
44
44
  "@deepseek-ai/schemastery": "^3.18.1"
45
45
  },
46
46
  "devDependencies": {