@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 +22 -8
- package/lib/index.js +106 -68
- package/lib/types/index.d.ts +31 -20
- package/package.json +5 -5
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
|
|
29
|
-
|
|
30
|
-
`StreamChunk` composition of each
|
|
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
|
-
|
|
34
|
-
- a
|
|
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
|
-
/**
|
|
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 (
|
|
86
|
-
|
|
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
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
* low-entropy repetition. It then escalates
|
|
17
|
-
* a `warn` inject, then a `steer`, then a
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
* - `
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
28
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
for (
|
|
59
|
-
const
|
|
60
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
* low-entropy repetition. It then escalates
|
|
17
|
-
* a `warn` inject, then a `steer`, then a
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
* - `
|
|
26
|
-
*
|
|
27
|
-
* -
|
|
28
|
-
*
|
|
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
|
|
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
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
|
4
|
-
"version": "0.1.
|
|
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": {
|