@ethanwong-hk/dsh-thinking-guard 1.0.2

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 Ethan Wong
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,130 @@
1
+ # dsh-thinking-guard
2
+
3
+ 纯思考空转熔断器。挂在 `agent/assistant-stream` 事件上,对「只思考、零产出」的退化回合实施三重熔断。
4
+
5
+ ## 安装
6
+
7
+ **方式一:从 npm 安装(推荐)**
8
+
9
+ ```sh
10
+ dsh plugin add @ethanwong-hk/dsh-thinking-guard
11
+ ```
12
+
13
+ **方式二:从 GitHub 安装**
14
+
15
+ ```sh
16
+ dsh plugin add github:ethanwong-hk/dsh-thinking-guard
17
+ ```
18
+
19
+ **方式三:手工挂载**
20
+
21
+ 把本仓库放到 `~/.dsh/plugins/dsh-thinking-guard/`,然后在 `~/.dsh/cordis.patch.yml` 中加入 `cordis.patch.yml` 里的 `insert` 片段(见下方「配置」)。
22
+
23
+ 安装后**重启 DSH** 生效。验证是否加载:
24
+
25
+ ```sh
26
+ grep -n "thinking-guard" ~/.dsh/cordis.patch.yml
27
+ ```
28
+
29
+ ## 为什么需要它
30
+
31
+ `dsh-agent-loop` 的 `step()` 只在**流结束后**才做终止判定(`lib/index.js:1115-1119`)。流不结束,`turn/end` 就永不写入。
32
+
33
+ `dsh-llm-deepseek` 的 `idleWatchdog`(`lib/index.js:1627`)测的是**连接活性**而非任务进展 —— 它在每个 SSE 事件上 `pulse()` 重置 5 分钟计时器(`:1816` 经 `parseSse` 喂食)。模型持续吐 `reasoning-delta` 时,看门狗每 chunk 重置,**永不触发**。
34
+
35
+ 结果是:模型以比 5 分钟更密的节奏输出 thinking token 时,回合可以无限持续,只能由用户手动中止。默认 `max_tokens = 256000`(`DEFAULT_MAX_TOKENS`)量级过大,不构成实际兜底。
36
+
37
+ ## 三重熔断
38
+
39
+ | 熔断 | 默认值 | 触发条件 |
40
+ |---|---|---|
41
+ | `thinking-only-timeout` | 45000 ms | 零 text、零 tool-call,且距**最后一次进展**已超时 |
42
+ | `thinking-volume` | 80000 字符 | 单次尝试 reasoning 总量超限,**不依赖**是否有 text 产出 |
43
+ | `degenerate-loop` | 3 次重复 | 尾部窗口命中:精确重复单元 / 句子级模板重复 / 稀疏复读 / N-gram 高密度 |
44
+
45
+ 命中即 `agent.cancel({ kind:'thinking-guard', reason, detail })`,并尽力用 `agent.followup()` 注入一条可见说明。
46
+
47
+ 「停滞时长」锚定**最后一次进展**(text 或 tool-call 到达的时刻),而非 attempt 起点 —— 避免慢启动的正常回合被误判。
48
+
49
+ ## 配置
50
+
51
+ 在 `~/.dsh/cordis.patch.yml` 的 `thinking-guard` 条目 `config` 下调整,重启 DSH 生效:
52
+
53
+ ```yaml
54
+ - insert:
55
+ - id: thinking-guard
56
+ name: "dsh-thinking-guard"
57
+ config:
58
+ enabled: true
59
+ # 只思考、零 text/tool-call 的持续时长上限(毫秒)
60
+ thinkingOnlyMs: 45000
61
+ # 单次尝试 reasoning 字符总量上限
62
+ maxThinkingChars: 80000
63
+ # 退化循环:重复次数阈值
64
+ repeatThreshold: 3
65
+ # 熔断后自动注入「继续当前任务」指令
66
+ autoContinue: true
67
+ notify: true
68
+ verbose: false
69
+ ```
70
+
71
+ 环境变量可临时覆盖(无需改配置):
72
+
73
+ | 变量 | 作用 |
74
+ |---|---|
75
+ | `DSH_THINKING_GUARD_DISABLED=1` | 停用 |
76
+ | `DSH_THINKING_ONLY_MS` | 纯思考停滞阈值 |
77
+ | `DSH_MAX_THINKING_CHARS` | 思考容量阈值 |
78
+ | `DSH_THINKING_REPEAT_THRESHOLD` | 重复次数阈值 |
79
+ | `DSH_THINKING_GUARD_VERBOSE=1` | 打日志 |
80
+
81
+ ## 误报调优
82
+
83
+ | 现象 | 调整 |
84
+ |---|---|
85
+ | 正常长文本被误熔断 | 调高 `sentenceRepeatRatio`(默认 0.6)、`gramDensity`(默认 0.22) |
86
+ | 退化循环漏检 | 调低 `repeatThreshold`(默认 8) |
87
+ | 慢模型被误判停滞 | 调高 `thinkingOnlyMs` |
88
+
89
+ ## 验证
90
+
91
+ ```bash
92
+ node ~/.dsh/plugins/dsh-thinking-guard/test/guard.test.mjs
93
+ ```
94
+
95
+ 11 个场景覆盖:纯思考超时、退化复读、容量上限、有产出仍限容量、文本复读、稀疏复读(回归用例)、正常回合放行、工具调用放行、正常结束放行、长正常文本放行。
96
+
97
+ ## 已修缺陷:熔断器自身的静默失效
98
+
99
+ `formatNotice` 是**模块级函数**,却在 `sparse-repeat` 分支引用了 `apply()` 内的局部 `cfg`(`cfg.sparseWindow`)。该分支一旦命中即抛 `ReferenceError: cfg is not defined`。
100
+
101
+ 危害不是「少一条提示」,而是**熔断完全失效**:
102
+
103
+ 1. `trip()` 中 `formatNotice(...)` 位于 `agent.cancel(...)` **之前**,异常直接中断 `trip()`,`cancel` 永不执行;
104
+ 2. `st.fired = true` 已在异常前置位,本 attempt 的后续检测被 `if (st.fired) return` 全部跳过。
105
+
106
+ 线上日志实证:`dsh-2026-09-26.log` 记录 472 次、`dsh-2026-09-27.log` 记录 39 次 `agent/assistant-stream listener threw: ReferenceError: cfg is not defined` —— 全部来自本插件唯一的事件监听器。修复前,该档熔断在所有命中回合上都是空转的。
107
+
108
+ 修复两处:
109
+
110
+ ```js
111
+ // 1) 根因:显式传参,消除模块级作用域泄漏
112
+ function formatNotice(reason, detail, st, cfg) { ... }
113
+
114
+ // 2) 防御:cancel 前置,且通知文本生成失败时降级而非中断
115
+ console.warn(...);
116
+ try { agent.cancel({ ... }); } catch (error) { ... }
117
+ let text;
118
+ try { text = formatNotice(reason, detail, st, cfg); }
119
+ catch (error) { text = `...(说明文本生成失败:${error?.message})`; }
120
+ ```
121
+
122
+ 原则:**熔断这一关键动作不得依赖通知环节的成功**。回归用例见 `test/guard.test.mjs` 场景 11。
123
+
124
+ ## 语义边界
125
+
126
+ `thinkingOnlyMs` **只在零 text、零 tool-call 时生效**。已产出文本的回合不会被它熔断 —— 那类回合至少有可见产出,不属于「纯思考空转」。`maxThinkingChars` 与退化检测对已产出文本的回合同样生效,用以兜住「先吐一小段文本再无限思考」。
127
+
128
+ ## 依赖
129
+
130
+ 无。仅用 harness 的 `agent/assistant-stream` / `agent/disposed` 事件与 `agent.cancel` / `agent.followup` 接口。消息工厂按候选路径显式解析 `@deepseek-ai/dsh-llm`,解析失败时降级为仅日志,不影响熔断本身。
@@ -0,0 +1,23 @@
1
+ # dsh-thinking-guard bundle patch
2
+ #
3
+ # 以本插件包为单位挂载时使用(插入 profile 的 layer stack)。
4
+ # 若你已手工在 ~/.dsh/cordis.patch.yml 的 DSH_THINKING_GUARD 段里配置过,
5
+ # 二者等价,保留其一即可。
6
+ #
7
+ # 注意:这里的 config 会覆盖插件内 DEFAULTS,请与 lib/index.js 的
8
+ # DEFAULTS 保持同步,否则会出现「配置文件写的值 ≠ 实际生效值」。
9
+ - insert:
10
+ - id: thinking-guard
11
+ name: 'dsh-thinking-guard'
12
+ config:
13
+ enabled: true
14
+ # 只思考、零 text/tool-call 的持续时长上限(毫秒)
15
+ thinkingOnlyMs: 45000
16
+ # 单次尝试 reasoning 字符总量上限
17
+ maxThinkingChars: 80000
18
+ # 退化循环:短语连续重复次数阈值
19
+ repeatThreshold: 3
20
+ # 熔断后自动注入「继续当前任务」指令
21
+ autoContinue: true
22
+ notify: true
23
+ verbose: false
package/lib/index.js ADDED
@@ -0,0 +1,435 @@
1
+ // dsh-thinking-guard —— 纯思考空转熔断器
2
+ //
3
+ // 机理(已实证):
4
+ // dsh-agent-loop/lib/index.js 的 step() 只在流结束后才做终止判定
5
+ // (1115-1119 行:max-tokens / 零工具调用 / concludesTurn)。
6
+ // dsh-llm-deepseek 的 idleWatchdog 只测「连接是否还有数据到达」
7
+ // (1627 行 idleWatchdog + 1659 行 onActivity 经 parseSse 喂食),
8
+ // 模型持续吐 reasoning-delta 时它每 chunk 重置,5 分钟阈值永不触发。
9
+ // 结果是「只思考、零 text、零 tool-call」的退化回合没有任何熔断点,
10
+ // 只能由用户手动中止。
11
+ //
12
+ // 本插件挂 agent/assistant-stream 事件,做的是进展型检测(而非活性检测),
13
+ // 三重熔断:
14
+ // 1. 纯思考时长 thinkingOnlyMs — 持续仅 reasoning 且零 text/tool-call
15
+ // 2. 思考容量 maxThinkingChars — 单次尝试 reasoning 字符总量
16
+ // 3. 退化循环 detectDegenerate — 尾部文本出现高密度重复短语
17
+ // 命中即调用 agent.cancel({ kind:'thinking-guard', ... }) 中止当前活动,
18
+ // 并尽力向会话注入一条可见说明。
19
+
20
+ import { homedir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ export const name = 'dsh-thinking-guard';
24
+
25
+ // 只需事件总线,不注入其它服务。
26
+ export const inject = [];
27
+
28
+ const DEFAULTS = {
29
+ enabled: true,
30
+ // 只思考、无任何 text/tool-call 产出的持续时长上限(毫秒)
31
+ thinkingOnlyMs: 45000,
32
+ // 单次尝试 reasoning 字符总量上限
33
+ maxThinkingChars: 80000,
34
+ // 熔断后是否自动注入「继续当前任务」指令
35
+ autoContinue: true,
36
+ // 退化循环:尾部窗口内同一短语连续重复次数阈值
37
+ repeatThreshold: 3,
38
+ // 退化循环:重复单元最大长度
39
+ repeatUnitMax: 40,
40
+ // 退化循环:N-gram 密度检测
41
+ gramWindow: 600,
42
+ gramSize: 6,
43
+ gramDensity: 0.16,
44
+ gramMinDistinct: 40,
45
+ // 退化循环:句子级模板重复(对「固定模板 + 小变化」的自我催促有效)
46
+ sentenceRepeatRatio: 0.6,
47
+ sentenceMinPeak: 8,
48
+ sentenceMinParts: 10,
49
+ // 退化循环:稀疏复读(跨大窗口的短句高次重复,不要求连续)
50
+ // 命中场景:thinking 里每隔几百字插一句「好。执行。」这类自我催促
51
+ sparseWindow: 4000,
52
+ sparseMaxUnit: 12,
53
+ sparseMinCount: 5,
54
+ sparseMinRatio: 0.15,
55
+ sparseMinTotal: 6,
56
+ // 每累积多少字符做一次退化检查
57
+ checkEveryChars: 1024,
58
+ // 是否向会话注入说明
59
+ notify: true,
60
+ verbose: false,
61
+ };
62
+
63
+ let createUserMessage = null;
64
+
65
+ // harness 包不在用户插件的解析路径上,按候选顺序显式解析。
66
+ // 路径由 homedir() / DSH_HOME 推导,不硬编码具体用户名——
67
+ // 否则安装路径会随包分发,泄露维护者的本机目录结构。
68
+ const LLM_CANDIDATES = [
69
+ process.env.DSH_HOME
70
+ ? join(process.env.DSH_HOME, 'profiles/node_modules/@deepseek-ai/dsh-llm/lib/index.js')
71
+ : null,
72
+ join(homedir(), '.dsh/profiles/node_modules/@deepseek-ai/dsh-llm/lib/index.js'),
73
+ '/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh-llm/lib/index.js',
74
+ ].filter(Boolean);
75
+
76
+ async function resolveMessageFactory() {
77
+ for (const candidate of LLM_CANDIDATES) {
78
+ try {
79
+ const mod = await import(candidate);
80
+ if (typeof mod.createUserMessage === 'function') return mod.createUserMessage;
81
+ } catch {
82
+ // 试下一个候选
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function mergeConfig(raw) {
89
+ const cfg = { ...DEFAULTS, ...(raw ?? {}) };
90
+ // 环境变量可覆盖,便于不改配置文件调参
91
+ const env = (k) => process.env[k];
92
+ if (env('DSH_THINKING_GUARD_DISABLED') === '1') cfg.enabled = false;
93
+ const numeric = [
94
+ ['DSH_THINKING_ONLY_MS', 'thinkingOnlyMs'],
95
+ ['DSH_MAX_THINKING_CHARS', 'maxThinkingChars'],
96
+ ['DSH_THINKING_REPEAT_THRESHOLD', 'repeatThreshold'],
97
+ ];
98
+ for (const [envKey, cfgKey] of numeric) {
99
+ const value = Number(env(envKey));
100
+ if (Number.isFinite(value) && value > 0) cfg[cfgKey] = value;
101
+ }
102
+ if (env('DSH_THINKING_GUARD_VERBOSE') === '1') cfg.verbose = true;
103
+ return cfg;
104
+ }
105
+
106
+ /**
107
+ * 退化循环检测:三档互补指标,任一命中即判定退化。
108
+ * 1) 尾部精确重复单元 —— 抓严格复读
109
+ * 2) 句子级模板重复 —— 抓「固定模板 + 小变化」的自我催促
110
+ * 3) N-gram 密度 —— 抓低信息密度的原地打转
111
+ */
112
+ export function detectDegenerate(text, cfg) {
113
+ const window = (text ?? '').slice(-cfg.gramWindow);
114
+ if (window.trim().length < 120) return null;
115
+
116
+ // 1) 尾部精确重复单元:从短到长找第一个满足阈值的最小单元
117
+ for (let unit = 4; unit <= cfg.repeatUnitMax; unit += 1) {
118
+ let count = 1;
119
+ let cursor = window.length - unit;
120
+ while (cursor - unit >= 0 && window.slice(cursor - unit, cursor) === window.slice(cursor, cursor + unit)) {
121
+ count += 1;
122
+ cursor -= unit;
123
+ }
124
+ if (count >= cfg.repeatThreshold) {
125
+ return { kind: 'repeat-unit', unit: window.slice(window.length - unit), count };
126
+ }
127
+ }
128
+
129
+ // 2) 句子级模板重复:数字归一化后统计句模占比
130
+ const normalized = window.replace(/\d+/g, '#').replace(/[ \t]+/g, '');
131
+ const parts = normalized
132
+ .split(/(?<=[。!?!?.\n])/)
133
+ .map((part) => part.trim())
134
+ .filter((part) => part.length >= 4);
135
+ if (parts.length >= cfg.sentenceMinParts) {
136
+ const freq = new Map();
137
+ for (const part of parts) freq.set(part, (freq.get(part) ?? 0) + 1);
138
+ let peak = 0;
139
+ let peakPart = '';
140
+ for (const [part, count] of freq) {
141
+ if (count > peak) {
142
+ peak = count;
143
+ peakPart = part;
144
+ }
145
+ }
146
+ const ratio = peak / parts.length;
147
+ if (peak >= cfg.sentenceMinPeak && ratio >= cfg.sentenceRepeatRatio) {
148
+ return {
149
+ kind: 'sentence-repeat',
150
+ sample: peakPart.slice(0, 40),
151
+ count: peak,
152
+ ratio: Number(ratio.toFixed(3)),
153
+ };
154
+ }
155
+ }
156
+
157
+ // 3) 稀疏复读:大窗口内短句高次重复(不要求连续)
158
+ // 覆盖「实质分析与自我催促交替出现」的形态——这类复读在 600 字窗口里
159
+ // 密度不够,前三档全部漏检。
160
+ const bigWindow = (text ?? '').slice(-cfg.sparseWindow);
161
+ const shortParts = bigWindow
162
+ .split(/(?<=[。!?!?.\n])/)
163
+ .map((part) => part.trim())
164
+ .filter((part) => part.length >= 2 && part.length <= cfg.sparseMaxUnit);
165
+ if (shortParts.length >= cfg.sparseMinTotal) {
166
+ const shortFreq = new Map();
167
+ for (const part of shortParts) shortFreq.set(part, (shortFreq.get(part) ?? 0) + 1);
168
+ let peak = 0;
169
+ let peakPart = '';
170
+ for (const [part, count] of shortFreq) {
171
+ if (count > peak) {
172
+ peak = count;
173
+ peakPart = part;
174
+ }
175
+ }
176
+ const share = peak / shortParts.length;
177
+ if (peak >= cfg.sparseMinCount && share >= cfg.sparseMinRatio) {
178
+ return {
179
+ kind: 'sparse-repeat',
180
+ sample: peakPart.slice(0, 24),
181
+ count: peak,
182
+ share: Number(share.toFixed(4)),
183
+ };
184
+ }
185
+ }
186
+
187
+ // 4) N-gram 密度:最高频 gram 占比过高说明文本在原地打转
188
+ const grams = new Map();
189
+ for (let i = 0; i + cfg.gramSize <= window.length; i += 1) {
190
+ const gram = window.slice(i, i + cfg.gramSize);
191
+ grams.set(gram, (grams.get(gram) ?? 0) + 1);
192
+ }
193
+ if (grams.size >= cfg.gramMinDistinct) {
194
+ let peak = 0;
195
+ for (const count of grams.values()) if (count > peak) peak = count;
196
+ const density = peak / grams.size;
197
+ if (density >= cfg.gramDensity) return { kind: 'gram-density', density: Number(density.toFixed(3)), peak };
198
+ }
199
+
200
+ return null;
201
+ }
202
+
203
+ function formatNotice(reason, detail, st, cfg) {
204
+ const head = '[thinking-guard] 已中止当前活动:检测到纯思考空转。';
205
+ const tail = '请停止自我催促式思考,直接输出结论或调用工具推进任务。';
206
+ const stats = `(本回合 reasoning ${st.reasoningChars} 字符,text ${st.textChars} 字符,工具调用 ${st.toolCalls} 次)`;
207
+ switch (reason) {
208
+ case 'thinking-only-timeout':
209
+ return `${head}\n原因:连续 ${Math.round(detail.heldMs / 1000)} 秒只产出思考内容,没有任何回复文本或工具调用。${stats}\n${tail}`;
210
+ case 'thinking-volume':
211
+ return `${head}\n原因:单次尝试思考内容已达 ${detail.chars} 字符,仍无回复文本或工具调用。${stats}\n${tail}`;
212
+ case 'degenerate-loop': {
213
+ let why;
214
+ if (detail.kind === 'repeat-unit') why = `短语「${detail.unit}」连续重复 ${detail.count} 次`;
215
+ else if (detail.kind === 'sentence-repeat') why = `句模「${detail.sample}」重复 ${detail.count} 次(占比 ${detail.ratio})`;
216
+ else if (detail.kind === 'sparse-repeat') why = `短句「${detail.sample}」在最近 ${cfg.sparseWindow} 字内出现 ${detail.count} 次(稀疏复读)`;
217
+ else why = `6-gram 密度 ${detail.density}`;
218
+ return `${head}\n原因:思考内容陷入重复循环(${why})。${stats}\n${tail}`;
219
+ }
220
+ default:
221
+ return `${head}${stats}\n${tail}`;
222
+ }
223
+ }
224
+
225
+ export function apply(ctx, config) {
226
+ const cfg = mergeConfig(config);
227
+ const states = new Map();
228
+
229
+ // 异步准备消息工厂;失败只是失去可见提示,不影响熔断本身。
230
+ resolveMessageFactory().then((factory) => {
231
+ createUserMessage = factory;
232
+ if (cfg.verbose) {
233
+ console.log(`[thinking-guard] 消息工厂 ${factory ? '已就绪' : '不可用(降级为仅日志)'}`);
234
+ }
235
+ });
236
+
237
+ const log = (...args) => {
238
+ if (cfg.verbose) console.log('[thinking-guard]', ...args);
239
+ };
240
+
241
+ function notify(agent, text) {
242
+ if (!cfg.notify) return;
243
+ try {
244
+ if (createUserMessage === null) return;
245
+ const message = createUserMessage({
246
+ content: [{ type: 'text', text }],
247
+ source: {
248
+ kind: 'plugin',
249
+ plugin: 'thinking-guard',
250
+ form: 'notice',
251
+ summary: '纯思考空转熔断',
252
+ },
253
+ });
254
+ agent.followup(message);
255
+ log('已注入熔断说明');
256
+ } catch (error) {
257
+ log('注入说明失败:', error?.message ?? error);
258
+ }
259
+ }
260
+
261
+ // 熔断后自动继续当前任务:注入一条"继续"指令,要求立即以工具调用恢复。
262
+ function notifyContinue(agent, reason) {
263
+ try {
264
+ if (createUserMessage === null) return;
265
+ const message = createUserMessage({
266
+ content: [{
267
+ type: 'text',
268
+ text: '继续。上一回合因思考空转被熔断中止(' + reason + ')。'
269
+ + '现在立即恢复原有任务,不要重述背景、不要写长篇思考:'
270
+ + '本回合第一个动作必须是工具调用;先做一件可验证的小事(读文件、跑命令、查状态),再根据结果继续。',
271
+ }],
272
+ source: {
273
+ kind: 'plugin',
274
+ plugin: 'thinking-guard',
275
+ form: 'notice',
276
+ summary: '自动继续当前任务',
277
+ },
278
+ });
279
+ agent.followup(message);
280
+ log('已注入自动继续指令');
281
+ } catch (error) {
282
+ log('注入自动继续指令失败:', error?.message ?? error);
283
+ }
284
+ }
285
+
286
+ function trip(agent, st, reason, detail) {
287
+ if (st.fired) return;
288
+ st.fired = true;
289
+ console.warn(`[thinking-guard] 熔断 ${reason} session=${st.sessionId} turn=${st.turn} step=${st.step} detail=${JSON.stringify(detail)}`);
290
+ // 关键动作优先:cancel 必须先于通知文本生成。
291
+ // 曾出现过的故障:formatNotice 抛 ReferenceError 后 cancel 永不执行,
292
+ // 熔断静默失效(且 st.fired 已置位,本 attempt 后续检测全部跳过)。
293
+ try {
294
+ // keepInbox 默认 false:清掉待处理输入后中止当前活动,避免残留
295
+ agent.cancel({ kind: 'thinking-guard', reason, detail, sessionId: st.sessionId });
296
+ } catch (error) {
297
+ console.warn('[thinking-guard] cancel 失败:', error?.message ?? error);
298
+ }
299
+ // 通知属尽力而为:生成失败也要降级为一条最小说明,不能影响已完成的熔断。
300
+ let text;
301
+ try {
302
+ text = formatNotice(reason, detail, st, cfg);
303
+ } catch (error) {
304
+ text = `[thinking-guard] 已中止当前活动:检测到纯思考空转。\n原因:${reason}`
305
+ + `(说明文本生成失败:${error?.message ?? error})`;
306
+ console.warn('[thinking-guard] 通知文本生成失败,已降级:', error?.message ?? error);
307
+ }
308
+ notify(agent, text);
309
+ if (cfg.autoContinue !== false) notifyContinue(agent, reason);
310
+ }
311
+
312
+ function evaluate(agent, st) {
313
+ if (st.fired) return;
314
+ const noProgress = st.textChars === 0 && st.toolCalls === 0;
315
+
316
+ // 绝对容量上限:不依赖是否已有产出。模型先吐一小段文本再无限思考,
317
+ // 同样会耗尽资源,因此这条必须独立生效。
318
+ if (st.reasoningChars >= cfg.maxThinkingChars) {
319
+ trip(agent, st, 'thinking-volume', { chars: st.reasoningChars });
320
+ return;
321
+ }
322
+
323
+ if (noProgress && st.reasoningChars > 0) {
324
+ // 停滞时长锚定「最后一次进展」,而非 attempt 起点:
325
+ // 已产出 text/tool-call 的回合不会因早期延迟被误判。
326
+ const heldMs = Date.now() - st.lastProgressAt;
327
+ if (heldMs >= cfg.thinkingOnlyMs) {
328
+ trip(agent, st, 'thinking-only-timeout', { heldMs });
329
+ return;
330
+ }
331
+ }
332
+
333
+ // 有进展的回合同样要防退化循环:空转既可以是纯思考,也可以是
334
+ // 反复输出同段落而不推进任务。
335
+ if (st.sinceCheck >= cfg.checkEveryChars) {
336
+ st.sinceCheck = 0;
337
+ const hit = detectDegenerate(st.tail, cfg);
338
+ if (hit !== null) trip(agent, st, 'degenerate-loop', hit);
339
+ }
340
+ }
341
+
342
+ function onStream(payload) {
343
+ if (!cfg.enabled) return;
344
+ const agent = payload?.agent;
345
+ const frame = payload?.frame;
346
+ if (agent === undefined || frame === undefined) return;
347
+
348
+ const sessionId = agent.session?.id;
349
+ if (typeof sessionId !== 'string') return;
350
+
351
+ if (frame.type === 'start') {
352
+ states.set(sessionId, {
353
+ sessionId,
354
+ attemptId: frame.attemptId,
355
+ turn: frame.turn,
356
+ step: frame.step,
357
+ startedAt: Date.now(),
358
+ lastProgressAt: Date.now(),
359
+ reasoningChars: 0,
360
+ textChars: 0,
361
+ toolCalls: 0,
362
+ tail: '',
363
+ sinceCheck: 0,
364
+ fired: false,
365
+ });
366
+ return;
367
+ }
368
+
369
+ if (frame.type === 'end') {
370
+ states.delete(sessionId);
371
+ return;
372
+ }
373
+
374
+ if (frame.type !== 'chunk') return;
375
+
376
+ const st = states.get(sessionId);
377
+ if (st === undefined || st.fired) return;
378
+
379
+ const chunk = frame.chunk;
380
+ if (chunk === undefined || typeof chunk.type !== 'string') return;
381
+
382
+ switch (chunk.type) {
383
+ case 'reasoning-delta': {
384
+ const text = typeof chunk.text === 'string' ? chunk.text : '';
385
+ if (text === '') return;
386
+ st.reasoningChars += text.length;
387
+ st.sinceCheck += text.length;
388
+ // 尾部缓冲按窗口两倍截断,避免无限增长
389
+ st.tail = (st.tail + text).slice(-cfg.gramWindow * 2);
390
+ break;
391
+ }
392
+ case 'text-delta': {
393
+ const text = typeof chunk.text === 'string' ? chunk.text : '';
394
+ if (text.trim() !== '') {
395
+ st.textChars += text.length;
396
+ st.lastProgressAt = Date.now();
397
+ // 文本同样进入退化窗口:反复输出同段文本也是空转
398
+ st.sinceCheck += text.length;
399
+ st.tail = (st.tail + text).slice(-cfg.gramWindow * 2);
400
+ break; // 落到 evaluate:文本退化同样要检查
401
+ }
402
+ return;
403
+ }
404
+ case 'tool-call-delta':
405
+ st.toolCalls += 1;
406
+ st.lastProgressAt = Date.now();
407
+ return;
408
+ case 'block-start':
409
+ // 出现 text / tool-call 块即视为已产生进展
410
+ if (chunk.blockType === 'tool-call') {
411
+ st.toolCalls += 1;
412
+ st.lastProgressAt = Date.now();
413
+ } else if (chunk.blockType === 'text') {
414
+ st.lastProgressAt = Date.now();
415
+ }
416
+ return;
417
+ default:
418
+ return;
419
+ }
420
+
421
+ evaluate(agent, st);
422
+ }
423
+
424
+ // global:会话以编程方式创建的 agent 同样纳入熔断
425
+ ctx.on('agent/assistant-stream', onStream, { global: true });
426
+
427
+ ctx.on('agent/disposed', ({ agent }) => {
428
+ const sessionId = agent?.session?.id;
429
+ if (typeof sessionId === 'string') states.delete(sessionId);
430
+ }, { global: true });
431
+
432
+ ctx.effect(() => () => states.clear(), 'thinking-guard.state');
433
+
434
+ log(`已启用:thinkingOnlyMs=${cfg.thinkingOnlyMs} maxThinkingChars=${cfg.maxThinkingChars} repeatThreshold=${cfg.repeatThreshold}`);
435
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@ethanwong-hk/dsh-thinking-guard",
3
+ "version": "1.0.2",
4
+ "description": "Circuit breaker for pure-thinking idle loops in DSH agent turns: triple fusing on timeout, reasoning volume, and degenerate repetition.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "lib",
13
+ "test",
14
+ "README.md",
15
+ "cordis.patch.yml"
16
+ ],
17
+ "scripts": {
18
+ "test": "node test/guard.test.mjs"
19
+ },
20
+ "author": "Ethan Wong <202729672+ethanwong-hk@users.noreply.github.com>",
21
+ "license": "MIT",
22
+ "keywords": [
23
+ "dsh",
24
+ "dsh-plugin",
25
+ "thinking-guard",
26
+ "circuit-breaker",
27
+ "idle-loop",
28
+ "reasoning-stall",
29
+ "agent-reliability"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/ethanwong-hk/dsh-thinking-guard.git"
34
+ },
35
+ "homepage": "https://github.com/ethanwong-hk/dsh-thinking-guard#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/ethanwong-hk/dsh-thinking-guard/issues"
38
+ },
39
+ "dsh": {
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ }
43
+ },
44
+ "peerDependencies": {
45
+ "@deepseek-ai/cordis": "^4.0.1"
46
+ },
47
+ "engines": {
48
+ "node": ">=18",
49
+ "dsh": ">=0.1.0"
50
+ }
51
+ }
@@ -0,0 +1,90 @@
1
+ // 相对本文件解析,避免写死安装位置(否则随包分发会泄露维护者的本机目录)
2
+ const mod = await import(new URL('../lib/index.js', import.meta.url).href);
3
+ function h(config) {
4
+ const handlers = new Map();
5
+ mod.apply({ on: (e, f) => handlers.set(e, f), effect: (f) => f() }, config);
6
+ const events = []; let rev = 0, idx = 0;
7
+ const agent = { session: { id: 's' }, cancel: (c) => events.push({ type: 'cancel', cause: c }), followup: () => {} };
8
+ return {
9
+ start: () => handlers.get('agent/assistant-stream')({ agent, frame: { type:'start', attemptId:'a', revision:++rev, turn:1, step:1 } }),
10
+ chunk: (c) => handlers.get('agent/assistant-stream')({ agent, frame: { type:'chunk', attemptId:'a', revision:++rev, index:idx++, time:Date.now(), chunk:c } }),
11
+ tripped: () => events.find((e) => e.type === 'cancel'),
12
+ };
13
+ }
14
+ let pass=0, fail=0;
15
+ const check=(l,g,w)=>{const ok=g===w; ok?pass++:fail++; console.log(`[${ok?'PASS':'FAIL'}] ${l} 熔断=${g} 期望=${w}`);};
16
+
17
+ // 1 纯思考超时
18
+ { const t=h({thinkingOnlyMs:60,checkEveryChars:1e9,notify:false}); t.start();
19
+ await new Promise(r=>setTimeout(r,80));
20
+ for(let i=0;i<5;i++) t.chunk({type:'reasoning-delta',index:0,text:`思考片段 ${i},仍未形成结论。`});
21
+ check('场景1 纯思考超时',!!t.tripped(),true); }
22
+
23
+ // 2 YG 样本复读
24
+ { const t=h({thinkingOnlyMs:1e9,checkEveryChars:1,notify:false}); t.start();
25
+ const s='好。执行。好。(输出工具调用)'.repeat(12);
26
+ for(let i=0;i<s.length;i+=20) t.chunk({type:'reasoning-delta',index:0,text:s.slice(i,i+20)});
27
+ check('场景2 退化循环复读',!!t.tripped(),true); }
28
+
29
+ // 3 容量上限
30
+ { const t=h({thinkingOnlyMs:1e9,maxThinkingChars:300,checkEveryChars:1e9,notify:false}); t.start();
31
+ for(let i=0;i<20;i++) t.chunk({type:'reasoning-delta',index:0,text:'一段没有结论的思考内容,持续消耗资源而无任何产出。'});
32
+ check('场景3 思考容量上限',!!t.tripped(),true); }
33
+
34
+ // 4 正常回合(每轮内容不同,逼近真实)
35
+ { const t=h({thinkingOnlyMs:60,maxThinkingChars:300,checkEveryChars:1,notify:false}); t.start();
36
+ for(let i=0;i<12;i++){
37
+ t.chunk({type:'reasoning-delta',index:0,text:`第 ${i} 步需要先确认目标服务的版本信息。`});
38
+ t.chunk({type:'text-delta',index:1,text:`正在处理第 ${i} 项检查,结果已记录到证据文件。`});
39
+ }
40
+ check('场景4 正常回合放行',!!t.tripped(),false); }
41
+
42
+ // 5 工具调用
43
+ { const t=h({thinkingOnlyMs:60,maxThinkingChars:300,checkEveryChars:1,notify:false}); t.start();
44
+ for(let i=0;i<12;i++){ t.chunk({type:'reasoning-delta',index:0,text:'准备调用工具。'}); t.chunk({type:'tool-call-delta',index:1,id:'c',name:'bash',argumentsDelta:'{"command":' }); }
45
+ check('场景5 工具调用放行',!!t.tripped(),false); }
46
+
47
+ // 6 有产出后短思考
48
+ { const t=h({thinkingOnlyMs:1e9,maxThinkingChars:1e9,checkEveryChars:1,notify:false}); t.start();
49
+ t.chunk({type:'text-delta',index:1,text:'已输出一段正式回复。'});
50
+ await new Promise(r=>setTimeout(r,80));
51
+ for(let i=0;i<5;i++) t.chunk({type:'reasoning-delta',index:0,text:'继续思考但没有新产出。'});
52
+ check('场景6 有产出后短思考放行',!!t.tripped(),false); }
53
+
54
+ // 7 正常结束(各句实质不同)
55
+ { const t=h({thinkingOnlyMs:1e9,maxThinkingChars:1e9,checkEveryChars:1,notify:false}); t.start();
56
+ const lines=['先确认服务端口与协议。','检查证书链的颁发者字段。','比对中国件响应头顺序。','核对错误页模板哈希。','记录归属判定依据。','标注仍需复核的条目。','换一条路径扩展侦察。','汇总证据链后收束。','整理未完成事项清单。','输出最终结论与建议。'];
57
+ for(const line of lines) t.chunk({type:'text-delta',index:1,text:line});
58
+ t.chunk({type:'finish',reason:{kind:'stop'}});
59
+ check('场景7 正常结束放行',!!t.tripped(),false); }
60
+
61
+ // 8 有产出仍限容量
62
+ { const t=h({thinkingOnlyMs:1e9,maxThinkingChars:400,checkEveryChars:1e9,notify:false}); t.start();
63
+ t.chunk({type:'text-delta',index:1,text:'先输出一小段文本。'});
64
+ for(let i=0;i<20;i++) t.chunk({type:'reasoning-delta',index:0,text:'随后陷入无限思考,继续消耗而不产出结论。'});
65
+ check('场景8 有产出仍限容量',!!t.tripped(),true); }
66
+
67
+ // 9 文本复读
68
+ { const t=h({thinkingOnlyMs:1e9,maxThinkingChars:1e9,checkEveryChars:1,notify:false}); t.start();
69
+ const s='正在重新整理思路,稍后输出。'.repeat(20);
70
+ for(let i=0;i<s.length;i+=25) t.chunk({type:'text-delta',index:1,text:s.slice(i,i+25)});
71
+ check('场景9 文本复读熔断',!!t.tripped(),true); }
72
+
73
+ // 10 长正常技术文本
74
+ { const t=h({thinkingOnlyMs:1e9,maxThinkingChars:1e9,checkEveryChars:1,notify:false}); t.start();
75
+ const para=['先确认目标服务的监听端口与证书链。','再比对中国件指纹,包括响应头顺序与错误页哈希。','随后核对静态资源路径的命名习惯,判断归属。','综合以上证据后记录确认方法,并标注待复核项。','如果证据不足则扩展侦察角度,换一条路径重新验证。'];
76
+ for(let r=0;r<4;r++) for(const p of para) t.chunk({type:'text-delta',index:1,text:p});
77
+ check('场景10 长正常文本放行',!!t.tripped(),false); }
78
+
79
+ // 11 稀疏复读(跨大窗口短句高次重复,前三档在 600 字窗口内密度不足会漏检)
80
+ // 回归用例:此档曾因 formatNotice 引用模块级 cfg 抛 ReferenceError,
81
+ // 导致 cancel 永不执行、熔断静默失效(线上日志累计 500+ 次)。
82
+ { const t=h({thinkingOnlyMs:1e9,maxThinkingChars:1e9,checkEveryChars:1,notify:false}); t.start();
83
+ const parts=[]; for(let i=0;i<12;i++) parts.push('好。执行。', `第 ${i} 段实质不同的分析内容,用于拉开窗口距离。`);
84
+ const s=parts.join('');
85
+ let threw=null;
86
+ for(let i=0;i<s.length;i+=40){ try{ t.chunk({type:'reasoning-delta',index:0,text:s.slice(i,i+40)}); }catch(e){ threw=e; break; } }
87
+ check('场景11 稀疏复读熔断且不抛异常', threw===null && !!t.tripped(), true); }
88
+
89
+ console.log(`\n合计:${pass} 通过 / ${fail} 失败`);
90
+ process.exit(fail===0?0:1);