dsh-plugin-loop-guard 0.0.0-stage → 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/README.md +40 -2
- package/lib/index.js +835 -0
- package/package.json +34 -4
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-plugin-loop-guard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**思维链循环守卫**:检测 agent 的推理块陷入原地打转(同一批短语反复几百次、不出正文不调工具),
|
|
4
|
+
检出后**截断本轮流**(默认 `cut`),避免白烧 token。
|
|
5
|
+
|
|
6
|
+
## 判据(三条,全部带标定)
|
|
7
|
+
|
|
8
|
+
| 判据 | 逻辑 | 来源 |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| 周期 | 滑动窗口 zstd 压缩比(重复文本压缩率异常高) | 参考项目思路 + 本机标定 |
|
|
11
|
+
| `reasoning-flat` | 窗口内字符唯一率 < 0.30(字符级坍缩) | 本机真值集标定 |
|
|
12
|
+
| `reasoning-stub` | 最近 20 行里「含语言字符的短行(≤4 字)」占比 ≥ 30% | 本机真值集标定 |
|
|
13
|
+
|
|
14
|
+
**两条护栏**(都踩过坑):
|
|
15
|
+
|
|
16
|
+
- `reasoning-flat`:窗口内有代码围栏 ⇒ 整窗跳过(否则误伤 edit 的 old/new 代码对比)
|
|
17
|
+
- `reasoning-stub`:只认**含语言字符**的短行(否则源码里的 `}` / `);` 被算成碎片)
|
|
18
|
+
|
|
19
|
+
## 模式
|
|
20
|
+
|
|
21
|
+
| 模式 | 行为 |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `cut`(默认) | 截断本轮流(实测:往流里注入提示拦不住生成中的那一步)|
|
|
24
|
+
| `inject` | 向推理流注入收尾提示 |
|
|
25
|
+
| `break` | 中断 |
|
|
26
|
+
|
|
27
|
+
## 配置
|
|
28
|
+
|
|
29
|
+
| 环境变量 | 默认 | 说明 |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `LOOP_GUARD_LOG` | `~/.dsh/logs/loop-guard.log` | 日志路径(测试用独立文件,避免污染生产日志)|
|
|
32
|
+
|
|
33
|
+
## 注意
|
|
34
|
+
|
|
35
|
+
- 判据是**启发式**的:可能误伤(长推理 / 大量重复代码);默认 cut 只截断不报错
|
|
36
|
+
- 阈值标定依赖真实循环样本,换模型/换任务分布后建议重新标定
|
|
37
|
+
- DSH **0.2.2-rc.1+** · 零运行时依赖 · host-only
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
// dsh-plugin-loop-guard -- 思维链/正文循环守卫(host 侧)
|
|
2
|
+
//
|
|
3
|
+
// ★ 2026-10-15 重写:判据与收尾语义【吸收】自 @mrweicodes/dsh-loop-guard v1.0.9(MIT)。
|
|
4
|
+
// 换掉了原来的「zstd 压缩比」判据 —— 参考项目用它自己的实测数据证明:低熵比率
|
|
5
|
+
// 随【长度】上升(rho(长度,比率)=0.606),是「长度检测器」而非循环检测器,
|
|
6
|
+
// 在 152 个有产出的调用上误报 60%。新判据全部是【逐字周期】与【短语池】。
|
|
7
|
+
//
|
|
8
|
+
// 六条判据:
|
|
9
|
+
// ① reasoning-cycle 推理尾部是精确周期(周期<=512,跨度>=512) ┐ 流内
|
|
10
|
+
// ② reasoning-lines 推理的重复行(质量>=2048 / 覆盖率>=0.6 / 集中度>=4)├ 流内
|
|
11
|
+
// ③ repeating-cycle 可见输出尾部精确周期(512 / 256) │ 流内
|
|
12
|
+
// ④ text-lines 可见输出的短语池(2048 / 0.6 / 4) ┘ 流内
|
|
13
|
+
// ⑤ identical-chunks 连续相同的 text-delta(60 次)
|
|
14
|
+
// ⑥ 跨调用 reasoning-only / repeated-material(containment>=0.8,累计 3 次)
|
|
15
|
+
//
|
|
16
|
+
// ★ 收尾语义(这次别再犯):
|
|
17
|
+
// 检出后【关闭所有打开的块】→ 再 yield {type:'finish', reason:{kind:'stop'}} → return。
|
|
18
|
+
// · 不能「只 return」:块还开着,流"简单地结束"
|
|
19
|
+
// · 不能发 error finish:agent-loop 会 settle 成 assistant/attempt(无 message),
|
|
20
|
+
// 客户端渲染不出来,且 turn() 提前 throw,排队的 steer 永远送不到 —— 参考项目为此崩过 UI
|
|
21
|
+
// · 只能「关块 + stop」:走正常路径,消息能落盘、客户端能渲染、turn 能继续
|
|
22
|
+
//
|
|
23
|
+
// 保留自研骨架:单例守卫 / WeakSet 防重入 / 跳 purpose 内部请求 / 独立日志。
|
|
24
|
+
|
|
25
|
+
import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
26
|
+
import { dirname, join as joinPath } from 'node:path';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import { randomUUID } from 'node:crypto';
|
|
29
|
+
import { zstdCompressSync } from 'node:zlib';
|
|
30
|
+
|
|
31
|
+
export const name = 'loop-guard';
|
|
32
|
+
// ★ Cordis 的 context proxy 对【未声明】的服务读取会抛错
|
|
33
|
+
// (cannot get property "agents" without inject)—— 参考项目 v0.1.1 正是因此
|
|
34
|
+
// 让 apply() 在激活时抛错、整个部署的会话都跑不起来。所以 agents 必须声明。
|
|
35
|
+
export const inject = ['llm', 'agents'];
|
|
36
|
+
|
|
37
|
+
// 默认写 ~/.dsh/logs;测试可用 LOOP_GUARD_LOG 覆盖(见下方 logFile)
|
|
38
|
+
const LOG = process.env.LOOP_GUARD_LOG || joinPath(os.homedir(), '.dsh', 'logs', 'loop-guard.log');
|
|
39
|
+
// 可被环境变量覆盖:测试用它写独立文件,避免污染生产日志。
|
|
40
|
+
let logFile = process.env.LOOP_GUARD_LOG || LOG;
|
|
41
|
+
|
|
42
|
+
/** 固定长度 gram 的窗口大小(跨调用比较用;参考项目标定值)。 */
|
|
43
|
+
export const GRAM_SIZE = 4;
|
|
44
|
+
/** 周期扫描的步长:每新积累这么多字符扫一次。 */
|
|
45
|
+
const CYCLE_CHECK_STRIDE = 32;
|
|
46
|
+
/** 短于此长度的行不计入短语池(分子分母都不计)。 */
|
|
47
|
+
const LINE_MIN_CHARS = 2;
|
|
48
|
+
|
|
49
|
+
export const DEFAULTS = {
|
|
50
|
+
// 'break' = 检出后关块+stop 收尾(默认);'watch' = 只记录;'inject' = 只往流里塞提示(对照,实测无效)
|
|
51
|
+
mode: 'break',
|
|
52
|
+
// ⑥ 跨调用
|
|
53
|
+
maxThinkingSteps: 3,
|
|
54
|
+
minReasoningChars: 2048,
|
|
55
|
+
similarityThreshold: 0.8,
|
|
56
|
+
escalate: 'steer',
|
|
57
|
+
maxFires: 4,
|
|
58
|
+
// ⑤ 连续相同 delta
|
|
59
|
+
maxRepeatedText: 60,
|
|
60
|
+
// ③ 可见输出周期
|
|
61
|
+
maxRepeatedCycleChars: 512,
|
|
62
|
+
minRepeatedCycleChars: 256,
|
|
63
|
+
// ④ 可见输出短语池
|
|
64
|
+
maxRepeatedTextLineChars: 2048,
|
|
65
|
+
minRepeatedTextLineCoverage: 0.6,
|
|
66
|
+
minRepeatedTextLineConcentration: 4,
|
|
67
|
+
// ★① 坍缩检测(2026-10-15 新增,真值集标定)——
|
|
68
|
+
// 捕获「停止产生新内容」这一形态:短句/协议碎片池轮换,字符级看熵不低(尾部压缩比 23%),
|
|
69
|
+
// 但唯一 4-gram 率断崖(真循环 0.08~0.12 vs 正常 0.70)。固定窗口 ⇒ 不随长度漂移。
|
|
70
|
+
// 窗口 300:真值集上比 700 早 300 字触发(684/524/524 vs 988/796/764),误伤同为 0。
|
|
71
|
+
// 不能再小 —— 压到 150 时池子周期(约 75~100 字)装不下 2~3 个,B 组真循环命中从 14 崩到 1。
|
|
72
|
+
reasoningGramWindow: 300, // 尾部窗口(字符)
|
|
73
|
+
minReasoningGramUniq: 0.30, // 唯一 4-gram 率上限(低于它判坍缩);<=0 关闭
|
|
74
|
+
// ★② 短行空转(2026-10-16 新增)—— 坍缩检测抓不到「短链」:
|
|
75
|
+
// 稀释型样本(短循环与正常内容交替)尾部唯一率只到 0.45,但它【85% 的行都 ≤4 字符】
|
|
76
|
+
// 而正常推理最大只有 25%。窗口小、几乎零成本,与坍缩判据互补。
|
|
77
|
+
stubRows: 20, // 看最近这么多行
|
|
78
|
+
stubLineMaxLen: 4, // 多短算「碎片行」
|
|
79
|
+
minStubRatio: 0.30, // 碎片行占比下限;<=0 关闭
|
|
80
|
+
// ② 推理周期
|
|
81
|
+
maxRepeatedReasoningCycleChars: 512,
|
|
82
|
+
// ★ 2026-10-15 由 512 收紧到 384 —— 用本机真机样本标定(95 条纯推理步 + 98 条有产出步):
|
|
83
|
+
// 触发 61→81 条(多抓 20 条),平均触发位置 3332→2530 字(提前约 800 字);
|
|
84
|
+
// 未触发者的【尾部 600 字压缩比】中位由 16.5% 升到 52.4% ⇒ 剩下的确实是不重复的正常推理;
|
|
85
|
+
// 有产出步 7→11,但那 11 条尾部压缩比 10~14%(与真循环同区间)⇒ 属「循环后又走出来」,不是误伤。
|
|
86
|
+
// 再降到 256 只多抓 1 条 ⇒ 不划算。
|
|
87
|
+
minRepeatedReasoningCycleChars: 384,
|
|
88
|
+
// ② 推理短语池
|
|
89
|
+
maxRepeatedReasoningLineChars: 2048,
|
|
90
|
+
minRepeatedReasoningLineCoverage: 0.6,
|
|
91
|
+
minRepeatedReasoningLineConcentration: 4,
|
|
92
|
+
// 收尾时是否 steer 一句纠正提示
|
|
93
|
+
breakCorrection: true,
|
|
94
|
+
// 收尾兜底:同一回合最多 steer 几次(吸收 empty-turn-guard 时由 8 提高到 20 ——
|
|
95
|
+
// 合并后 loop-guard 会在源头 steer,空回合本就大幅减少,配额不该被截断白耗)
|
|
96
|
+
maxEmptyTurnSteers: 20,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** 记日志:任何失败都不影响流。 */
|
|
100
|
+
export function log(line) {
|
|
101
|
+
try {
|
|
102
|
+
if (!existsSync(dirname(logFile))) mkdirSync(dirname(logFile), { recursive: true });
|
|
103
|
+
appendFileSync(logFile, '[' + new Date().toISOString() + '] ' + line + '\n', 'utf8');
|
|
104
|
+
} catch (e) { /* 忽略 */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 合并配置:只认 DEFAULTS 里的键;兼容旧的 mode:'cut'。 */
|
|
108
|
+
export function mergeConfig(given) {
|
|
109
|
+
const out = Object.assign({}, DEFAULTS);
|
|
110
|
+
if (given && typeof given === 'object') {
|
|
111
|
+
for (const k of Object.keys(DEFAULTS)) {
|
|
112
|
+
if (given[k] !== undefined) out[k] = given[k];
|
|
113
|
+
}
|
|
114
|
+
if (given.mode === 'cut') out.mode = 'break'; // 旧默认名,语义即「关块+stop」
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 压缩比:★ 已【不再作为判据】,只留作日志里的对照诊断。 */
|
|
120
|
+
export function compressRatio(text) {
|
|
121
|
+
try {
|
|
122
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
123
|
+
if (!bytes) return 1;
|
|
124
|
+
return zstdCompressSync(Buffer.from(text, 'utf8')).length / bytes;
|
|
125
|
+
} catch (e) { return 1; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ------------------------------------------------------------------ */
|
|
129
|
+
/* 一、逐字周期(精确,不是比率) */
|
|
130
|
+
/* ------------------------------------------------------------------ */
|
|
131
|
+
|
|
132
|
+
/** text 末尾连续相同字符的长度(用于「周期必须含至少两个不同字符」的护栏)。 */
|
|
133
|
+
function trailingRunLength(text) {
|
|
134
|
+
const len = text.length;
|
|
135
|
+
if (len === 0) return 0;
|
|
136
|
+
const last = text[len - 1];
|
|
137
|
+
let run = 1;
|
|
138
|
+
while (run < len && text[len - 1 - run] === last) run++;
|
|
139
|
+
return run;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 尾部是否是一个精确周期的重复:返回达标跨度(>= minSpan),否则 0。
|
|
144
|
+
* 精确性天然避开了「表格/日志/CSS/JSON 也重复」的误伤:那些样本的周期判据恒为 0,
|
|
145
|
+
* 而覆盖率类度量只能把它们和真循环拉开 ~0.06。
|
|
146
|
+
*/
|
|
147
|
+
export function trailingCycle(text, maxPeriod, minSpan) {
|
|
148
|
+
if (maxPeriod < 1) return 0;
|
|
149
|
+
const len = text.length;
|
|
150
|
+
const tailRun = trailingRunLength(text);
|
|
151
|
+
for (let period = 1; period <= maxPeriod; period++) {
|
|
152
|
+
if (tailRun >= period) continue; // 单字符周期不算循环(那是分隔线)
|
|
153
|
+
const need = Math.max(minSpan, 2 * period) - period;
|
|
154
|
+
const limit = Math.min(len - period, need);
|
|
155
|
+
let matched = 0;
|
|
156
|
+
while (matched < limit && text[len - period - 1 - matched] === text[len - 1 - matched]) matched++;
|
|
157
|
+
if (matched >= need) return matched + period;
|
|
158
|
+
}
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 同一条周期,但把尾巴走到底:用于报告「究竟重复了多少字」。 */
|
|
163
|
+
export function periodicTailSpan(text, maxPeriod, minSpan) {
|
|
164
|
+
if (maxPeriod < 1) return 0;
|
|
165
|
+
const len = text.length;
|
|
166
|
+
const tailRun = trailingRunLength(text);
|
|
167
|
+
for (let period = 1; period <= maxPeriod; period++) {
|
|
168
|
+
if (tailRun >= period) continue;
|
|
169
|
+
const need = Math.max(minSpan, 2 * period) - period;
|
|
170
|
+
const limit = len - period;
|
|
171
|
+
let matched = 0;
|
|
172
|
+
while (matched < limit && text[len - period - 1 - matched] === text[len - 1 - matched]) matched++;
|
|
173
|
+
if (matched >= need) return matched + period;
|
|
174
|
+
}
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 末尾连续「相同(trim 后)」delta 的个数。 */
|
|
179
|
+
export function countRepeatedText(texts) {
|
|
180
|
+
const last = texts[texts.length - 1];
|
|
181
|
+
if (last === undefined) return 0;
|
|
182
|
+
const normalized = last.trim();
|
|
183
|
+
let run = 0;
|
|
184
|
+
for (let i = texts.length - 1; i >= 0; i--) {
|
|
185
|
+
if (texts[i].trim() !== normalized) break;
|
|
186
|
+
run++;
|
|
187
|
+
}
|
|
188
|
+
return run;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------ */
|
|
192
|
+
/* 二、短语池(换个顺序重复同一批句子) */
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 计数「重复行」的质量 / 覆盖率 / 集中度。
|
|
197
|
+
* 集中度是关键护栏:引用一段代码两次(改前/改后)会让覆盖率逼近 1,
|
|
198
|
+
* 但那种调用的「每行平均出现次数」只有 1.4~2.5,而真短语池是 6.4~84。
|
|
199
|
+
*/
|
|
200
|
+
export class PhrasePool {
|
|
201
|
+
constructor() {
|
|
202
|
+
this.counts = new Map();
|
|
203
|
+
this.total = 0;
|
|
204
|
+
this.repeat = 0;
|
|
205
|
+
this.instances = 0;
|
|
206
|
+
this.partial = '';
|
|
207
|
+
}
|
|
208
|
+
get repeatedChars() { return this.repeat; }
|
|
209
|
+
push(text) {
|
|
210
|
+
this.partial += text;
|
|
211
|
+
let idx;
|
|
212
|
+
while ((idx = this.partial.indexOf('\n')) >= 0) {
|
|
213
|
+
this.addLine(this.partial.slice(0, idx).trim());
|
|
214
|
+
this.partial = this.partial.slice(idx + 1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
addLine(line) {
|
|
218
|
+
if (line.length < LINE_MIN_CHARS) return;
|
|
219
|
+
const prev = this.counts.get(line) || 0;
|
|
220
|
+
this.counts.set(line, prev + 1);
|
|
221
|
+
this.total += line.length;
|
|
222
|
+
this.instances += 1;
|
|
223
|
+
if (prev === 1) this.repeat += line.length * 2;
|
|
224
|
+
else if (prev > 1) this.repeat += line.length;
|
|
225
|
+
}
|
|
226
|
+
tripped(minRepeat, minCoverage, minConcentration) {
|
|
227
|
+
if (this.repeat < minRepeat) return false;
|
|
228
|
+
if (this.total === 0) return false;
|
|
229
|
+
if (this.repeat / this.total < minCoverage) return false;
|
|
230
|
+
if (this.counts.size === 0) return false;
|
|
231
|
+
return this.instances / this.counts.size >= minConcentration;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ------------------------------------------------------------------ */
|
|
236
|
+
/* 三、gram 与含容度(跨调用比较) */
|
|
237
|
+
/* ------------------------------------------------------------------ */
|
|
238
|
+
|
|
239
|
+
/** 固定长度窗口下的【唯一率】:不同 4-gram / 总 4-gram。
|
|
240
|
+
* 与「重复占比」不同 —— 它测的是「还有没有新东西出现」,固定窗口下不随长度漂移。
|
|
241
|
+
* 真值集:正常推理中位 0.70,真循环 0.08~0.12。 */
|
|
242
|
+
export function gramUniqText(text) {
|
|
243
|
+
const seen = new Set();
|
|
244
|
+
let total = 0;
|
|
245
|
+
for (let i = 0; i + GRAM_SIZE <= text.length; i++) { total++; seen.add(text.slice(i, i + GRAM_SIZE)); }
|
|
246
|
+
return total === 0 ? 1 : seen.size / total;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
/** 一段文本里所有【不同】的定长窗口。CJK 无空格,所以用定长而不是分词。 */
|
|
251
|
+
export function grams(text, size) {
|
|
252
|
+
const k = size === undefined ? GRAM_SIZE : size;
|
|
253
|
+
const out = new Set();
|
|
254
|
+
for (let i = 0; i + k <= text.length; i++) out.add(text.slice(i, i + k));
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 较小的 gram 集有多大比例出现在较大的里。 */
|
|
259
|
+
export function containment(a, b) {
|
|
260
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
261
|
+
const small = a.size <= b.size ? a : b;
|
|
262
|
+
const large = a.size <= b.size ? b : a;
|
|
263
|
+
let shared = 0;
|
|
264
|
+
for (const gram of small) if (large.has(gram)) shared++;
|
|
265
|
+
return shared / small.size;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ------------------------------------------------------------------ */
|
|
269
|
+
/* 四、跨调用:卡住的调用连续出现几次 */
|
|
270
|
+
/* ------------------------------------------------------------------ */
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 逐次调用地累积「卡住」次数,达标后交一次「开火」。
|
|
274
|
+
* 两个结构性判据:本次调用没有任何产出(reasoning-only),
|
|
275
|
+
* 或者它把上一步的推理材料又复述了一遍(repeated-material)。
|
|
276
|
+
*/
|
|
277
|
+
export class LoopDetector {
|
|
278
|
+
constructor(config) {
|
|
279
|
+
this.config = config;
|
|
280
|
+
this.stalled = 0;
|
|
281
|
+
this.fires = 0;
|
|
282
|
+
this.previous = undefined;
|
|
283
|
+
this.lastReason = undefined;
|
|
284
|
+
}
|
|
285
|
+
get threshold() { return this.config.maxThinkingSteps; }
|
|
286
|
+
get fired() { return this.fires; }
|
|
287
|
+
observe(step) {
|
|
288
|
+
if (step.reasoning.length < this.config.minReasoningChars) return undefined;
|
|
289
|
+
const current = grams(step.reasoning);
|
|
290
|
+
const repeated = this.previous !== undefined
|
|
291
|
+
&& this.config.similarityThreshold > 0
|
|
292
|
+
&& containment(this.previous, current) >= this.config.similarityThreshold;
|
|
293
|
+
this.previous = current;
|
|
294
|
+
let reason;
|
|
295
|
+
if (!step.hasOutput) reason = 'reasoning-only';
|
|
296
|
+
else if (repeated) reason = 'repeated-material';
|
|
297
|
+
else reason = undefined;
|
|
298
|
+
if (reason === undefined) { this.stalled = 0; this.lastReason = undefined; return undefined; }
|
|
299
|
+
this.stalled += 1;
|
|
300
|
+
this.lastReason = reason;
|
|
301
|
+
return reason;
|
|
302
|
+
}
|
|
303
|
+
takeFire() {
|
|
304
|
+
if (this.stalled < this.config.maxThinkingSteps) return undefined;
|
|
305
|
+
if (this.fires >= this.config.maxFires) return undefined;
|
|
306
|
+
this.fires += 1;
|
|
307
|
+
this.stalled = 0;
|
|
308
|
+
const reason = this.lastReason || 'reasoning-only';
|
|
309
|
+
this.lastReason = undefined;
|
|
310
|
+
return reason;
|
|
311
|
+
}
|
|
312
|
+
reset() { this.stalled = 0; this.previous = undefined; this.lastReason = undefined; }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* ------------------------------------------------------------------ */
|
|
316
|
+
/* 五、流内:推理循环 */
|
|
317
|
+
/* ------------------------------------------------------------------ */
|
|
318
|
+
|
|
319
|
+
export class ReasoningLoopBreaker {
|
|
320
|
+
constructor(config) {
|
|
321
|
+
this.config = config;
|
|
322
|
+
this.broken = false;
|
|
323
|
+
this.chars = 0;
|
|
324
|
+
this.reasoning = '';
|
|
325
|
+
this.lastCycleCheck = 0;
|
|
326
|
+
this.lastFlatCheck = 0;
|
|
327
|
+
this.trippedRule = undefined;
|
|
328
|
+
this.pool = new PhrasePool();
|
|
329
|
+
this.repeated = 0;
|
|
330
|
+
}
|
|
331
|
+
get tripped() { return this.broken; }
|
|
332
|
+
get trippedBy() { return this.trippedRule; }
|
|
333
|
+
get repeatedChars() { return this.repeated; }
|
|
334
|
+
get emittedChars() { return this.chars; }
|
|
335
|
+
push(text) {
|
|
336
|
+
if (this.broken) return false;
|
|
337
|
+
this.chars += text.length;
|
|
338
|
+
this.reasoning += text;
|
|
339
|
+
// ★① 坍缩检测(放在最前:真值集上它比周期/短语池都早 800~1000 字)
|
|
340
|
+
if (this.checkFlat()) {
|
|
341
|
+
this.broken = true;
|
|
342
|
+
this.trippedRule = 'reasoning-flat';
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
if (this.checkStub()) {
|
|
346
|
+
this.broken = true;
|
|
347
|
+
this.trippedRule = 'reasoning-stub';
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
if (this.pushLines(text)) {
|
|
351
|
+
this.broken = true;
|
|
352
|
+
this.trippedRule = 'reasoning-lines';
|
|
353
|
+
this.repeated = this.pool.repeatedChars;
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
if (this.config.maxRepeatedReasoningCycleChars <= 0) return false;
|
|
357
|
+
if (this.reasoning.length - this.lastCycleCheck < CYCLE_CHECK_STRIDE) return false;
|
|
358
|
+
this.lastCycleCheck = this.reasoning.length;
|
|
359
|
+
const span = trailingCycle(this.reasoning, this.config.maxRepeatedReasoningCycleChars, this.config.minRepeatedReasoningCycleChars);
|
|
360
|
+
if (span === 0) return false;
|
|
361
|
+
this.broken = true;
|
|
362
|
+
this.trippedRule = 'reasoning-cycle';
|
|
363
|
+
this.repeated = periodicTailSpan(this.reasoning, this.config.maxRepeatedReasoningCycleChars, this.config.minRepeatedReasoningCycleChars) || span;
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* 短链检测:最近 N 行里「碎片行(很短的行)」的占比是否过高。
|
|
368
|
+
* 坍缩判据看的是字符级唯一率,抓不到「做。/好。/写。」这类小池子;
|
|
369
|
+
* 但它们每行都极短,占比会断崖 —— 真值集实测:稀释型 0.85,正常最大 0.25。
|
|
370
|
+
*/
|
|
371
|
+
checkStub() {
|
|
372
|
+
const cfg = this.config;
|
|
373
|
+
if (!(cfg.minStubRatio > 0) || !(cfg.stubRows > 0)) return false;
|
|
374
|
+
const tail = this.reasoning.slice(-2000); // 够装下 stubRows 行
|
|
375
|
+
const lines = tail.split('\n').map(function (s) { return s.trim(); }).filter(Boolean).slice(-cfg.stubRows);
|
|
376
|
+
if (lines.length < 8) return false;
|
|
377
|
+
// ★ 护栏:只认【含语言字符】的短行。
|
|
378
|
+
// 否则源码里的 `}` / `);` / `},` 会把它顶上去 —— 实测误伤了「正常源码 4000 字」
|
|
379
|
+
// 和「引用代码两次」两个回归样本(参考项目也踩过:它为此设了 LINE_MIN_CHARS)。
|
|
380
|
+
let short = 0;
|
|
381
|
+
for (const l of lines) {
|
|
382
|
+
if (l.length <= cfg.stubLineMaxLen && /[\u4e00-\u9fff a-zA-Z0-9]/.test(l)) short++;
|
|
383
|
+
}
|
|
384
|
+
const ratio = short / lines.length;
|
|
385
|
+
if (ratio < cfg.minStubRatio) return false;
|
|
386
|
+
this.repeated = short;
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** 尾部窗口的唯一 4-gram 率是否塌到阈值以下(停止产生新信息)。 */
|
|
391
|
+
checkFlat() {
|
|
392
|
+
const cfg = this.config;
|
|
393
|
+
if (!(cfg.minReasoningGramUniq > 0) || !(cfg.reasoningGramWindow > 0)) return false;
|
|
394
|
+
if (this.reasoning.length < cfg.reasoningGramWindow) return false; // 窗口没填满不判
|
|
395
|
+
if (this.reasoning.length - this.lastFlatCheck < CYCLE_CHECK_STRIDE) return false;
|
|
396
|
+
this.lastFlatCheck = this.reasoning.length;
|
|
397
|
+
const raw = this.reasoning.slice(-cfg.reasoningGramWindow);
|
|
398
|
+
// ★ 护栏(真值集标定):窗口里只要出现代码围栏,就不判坍缩。
|
|
399
|
+
// 真循环的三个窗口围栏数都是 0;而被误伤的那条是「edit 的 old/new 代码对比」
|
|
400
|
+
// (围栏 8 个、唯一率 0.267)—— 同样塌陷,但那是合法内容。
|
|
401
|
+
// 不做半吊子剔除:围栏配对在窗口边界会错位,宁可整窗跳过。
|
|
402
|
+
if (raw.indexOf('```') >= 0) return false;
|
|
403
|
+
const u = gramUniqText(raw);
|
|
404
|
+
if (u >= cfg.minReasoningGramUniq) return false;
|
|
405
|
+
this.repeated = Math.round((1 - u) * cfg.reasoningGramWindow);
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
pushLines(text) {
|
|
410
|
+
if (this.config.maxRepeatedReasoningLineChars <= 0) return false;
|
|
411
|
+
this.pool.push(text);
|
|
412
|
+
return this.pool.tripped(
|
|
413
|
+
this.config.maxRepeatedReasoningLineChars,
|
|
414
|
+
this.config.minRepeatedReasoningLineCoverage,
|
|
415
|
+
this.config.minRepeatedReasoningLineConcentration,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* ------------------------------------------------------------------ */
|
|
421
|
+
/* 六、流内:可见输出循环 */
|
|
422
|
+
/* ------------------------------------------------------------------ */
|
|
423
|
+
|
|
424
|
+
export class TextRepetitionDetector {
|
|
425
|
+
constructor(config) {
|
|
426
|
+
this.config = config;
|
|
427
|
+
this.texts = [];
|
|
428
|
+
this.broken = false;
|
|
429
|
+
this.visible = '';
|
|
430
|
+
this.lastCycleCheck = 0;
|
|
431
|
+
this.reason = undefined;
|
|
432
|
+
this.repeated = 0;
|
|
433
|
+
this.chars = 0;
|
|
434
|
+
this.pool = new PhrasePool();
|
|
435
|
+
}
|
|
436
|
+
get tripped() { return this.broken; }
|
|
437
|
+
get trippedBy() { return this.reason; }
|
|
438
|
+
get repeatedChars() { return this.repeated; }
|
|
439
|
+
get emittedChars() { return this.chars; }
|
|
440
|
+
push(text) {
|
|
441
|
+
if (this.broken) return false;
|
|
442
|
+
this.chars += text.length;
|
|
443
|
+
if (this.config.maxRepeatedText <= 0 && this.config.maxRepeatedCycleChars <= 0
|
|
444
|
+
&& this.config.maxRepeatedTextLineChars <= 0) return false;
|
|
445
|
+
this.texts.push(text);
|
|
446
|
+
if (this.config.maxRepeatedText > 0
|
|
447
|
+
&& this.texts.length >= this.config.maxRepeatedText
|
|
448
|
+
&& countRepeatedText(this.texts) >= this.config.maxRepeatedText) {
|
|
449
|
+
this.broken = true;
|
|
450
|
+
this.reason = 'identical-chunks';
|
|
451
|
+
this.repeated = countRepeatedText(this.texts) * text.length;
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
if (this.config.maxRepeatedTextLineChars > 0) {
|
|
455
|
+
this.pool.push(text);
|
|
456
|
+
if (this.pool.tripped(
|
|
457
|
+
this.config.maxRepeatedTextLineChars,
|
|
458
|
+
this.config.minRepeatedTextLineCoverage,
|
|
459
|
+
this.config.minRepeatedTextLineConcentration,
|
|
460
|
+
)) {
|
|
461
|
+
this.broken = true;
|
|
462
|
+
this.reason = 'text-lines';
|
|
463
|
+
this.repeated = this.pool.repeatedChars;
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (this.config.maxRepeatedCycleChars <= 0) return false;
|
|
468
|
+
this.visible += text;
|
|
469
|
+
if (this.visible.length - this.lastCycleCheck < CYCLE_CHECK_STRIDE) return false;
|
|
470
|
+
this.lastCycleCheck = this.visible.length;
|
|
471
|
+
const span = trailingCycle(this.visible, this.config.maxRepeatedCycleChars, this.config.minRepeatedCycleChars);
|
|
472
|
+
if (span === 0) return false;
|
|
473
|
+
this.broken = true;
|
|
474
|
+
this.reason = 'repeating-cycle';
|
|
475
|
+
this.repeated = periodicTailSpan(this.visible, this.config.maxRepeatedCycleChars, this.config.minRepeatedCycleChars) || span;
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/* ------------------------------------------------------------------ */
|
|
481
|
+
/* 七、收尾与反应 */
|
|
482
|
+
/* ------------------------------------------------------------------ */
|
|
483
|
+
|
|
484
|
+
// ★ 这里的值必须是【能被「你正在 X」接住】的动词短语 ——
|
|
485
|
+
// 2026-10-15 首次真机触发时写成了整句('推理在重复同一段内容'),
|
|
486
|
+
// 拼出来是「你正在推理在重复同一段内容」,语法不通。
|
|
487
|
+
const RULE_ZH = {
|
|
488
|
+
'reasoning-flat': '原地打转、不再产生新内容',
|
|
489
|
+
'reasoning-stub': '用一串极短的碎片句在原地打转(如「做。好。写。」)',
|
|
490
|
+
'reasoning-cycle': '重复同一段推理内容',
|
|
491
|
+
'reasoning-lines': '反复使用同一批推理句子',
|
|
492
|
+
'repeating-cycle': '重复同一段正文内容',
|
|
493
|
+
'text-lines': '反复使用同一批正文句子',
|
|
494
|
+
'identical-chunks': '重复输出相同片段',
|
|
495
|
+
'reasoning-only': '空转推理、不产出结果',
|
|
496
|
+
'repeated-material': '复述上一步的推理材料',
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/** 给模型的纠正提示:只说「停止重复、继续原任务」,不说「给结论收尾」(那会让它偏离)。 */
|
|
500
|
+
export function noticeFor(rule, repeated) {
|
|
501
|
+
const what = RULE_ZH[rule] || '重复输出';
|
|
502
|
+
const amount = (typeof repeated === 'number' && repeated > 0) ? ('(约 ' + repeated + ' 字)') : '';
|
|
503
|
+
return '你正在' + what + amount + '。停止重复,继续推进当前任务:直接执行下一步,不要再复述。';
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 组装一条模型可见的 notice 用户消息。
|
|
508
|
+
* ★ 形状必须齐:dsh-llm 的 MessageBase 要求 id 与 source 都【必填】
|
|
509
|
+
* (官方 createUserMessage 就是补 id=randomUUID())。缺了会写入一条残缺的会话消息。
|
|
510
|
+
* source.kind 用插件自己的 id —— MessageSourceMap 是 merge-extensible,
|
|
511
|
+
* 消费方对未知 kind 走 fallthrough(官方注释原话)。
|
|
512
|
+
*/
|
|
513
|
+
export function userMessage(text, summary) {
|
|
514
|
+
const s0 = (typeof summary === 'string' && summary.length > 0) ? summary : '循环守卫';
|
|
515
|
+
const s = s0.length > 120 ? s0.slice(0, 119) + '…' : s0;
|
|
516
|
+
return {
|
|
517
|
+
id: randomUUID(),
|
|
518
|
+
role: 'user',
|
|
519
|
+
content: [{ type: 'text', text: text }],
|
|
520
|
+
source: { kind: 'loop-guard', form: 'notice', summary: s },
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* 从流内部结束一次调用:关掉打开的块,再发一个 stop finish。
|
|
526
|
+
* 返回要补发的 chunk 数组;无法安全截断时返回 null(宁可多跑几字,也不提交半个工具调用)。
|
|
527
|
+
*/
|
|
528
|
+
export function breakStream(agent, repeated, rule, openBlocks, config) {
|
|
529
|
+
if (openBlocks.some(function (b) { return b.blockType !== 'text' && b.blockType !== 'reasoning'; })) {
|
|
530
|
+
log('⚠️ 检出 ' + rule + ' 但存在非 text/reasoning 的打开块 ⇒ 放弃截断(避免提交半个工具调用)');
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
// steer 先入队,再发终止块 —— 顺序不能反(参考项目实测:反了 steer 送不到)
|
|
534
|
+
if (config.breakCorrection && agent && typeof agent.steer === 'function') {
|
|
535
|
+
try {
|
|
536
|
+
agent.steer(userMessage(noticeFor(rule, repeated), '循环守卫 · ' + rule));
|
|
537
|
+
} catch (e) {
|
|
538
|
+
log('⚠️ steer 失败(不影响截断): ' + ((e && e.message) || e));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const out = [];
|
|
542
|
+
for (const b of openBlocks) {
|
|
543
|
+
out.push({ type: 'block-end', index: b.index, block: { type: b.blockType, text: b.text } });
|
|
544
|
+
}
|
|
545
|
+
out.push({ type: 'finish', reason: { kind: 'stop' } });
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** 跨调用判定的反应:warn=inject / steer / cancel。 */
|
|
550
|
+
export function react(agent, reason, config) {
|
|
551
|
+
if (!agent) { log('★ 跨调用判定命中(' + reason + ')但拿不到 agent ⇒ 只记录'); return; }
|
|
552
|
+
const msg = userMessage(noticeFor(reason, 0), '循环守卫 · ' + reason);
|
|
553
|
+
try {
|
|
554
|
+
if (config.escalate === 'cancel' && typeof agent.cancel === 'function') {
|
|
555
|
+
// AgentCancelCause 是【封闭 union】(user|parent|hook|disposed) —— 只能借 hook 带原因
|
|
556
|
+
agent.cancel({ kind: 'hook', reason: 'loop-guard:' + reason });
|
|
557
|
+
log('★ 跨调用判定命中(' + reason + ')⇒ cancel');
|
|
558
|
+
} else if (config.escalate === 'warn' && typeof agent.inject === 'function') {
|
|
559
|
+
agent.inject(msg);
|
|
560
|
+
log('★ 跨调用判定命中(' + reason + ')⇒ inject 提示');
|
|
561
|
+
} else if (typeof agent.steer === 'function') {
|
|
562
|
+
agent.steer(msg);
|
|
563
|
+
log('★ 跨调用判定命中(' + reason + ')⇒ steer 提示');
|
|
564
|
+
}
|
|
565
|
+
} catch (e) {
|
|
566
|
+
log('⚠️ 反应失败(' + config.escalate + '): ' + ((e && e.message) || e));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/* ------------------------------------------------------------------ */
|
|
571
|
+
/* 七·补、收尾兜底(吸收自 dsh-plugin-empty-turn-guard) */
|
|
572
|
+
/* ------------------------------------------------------------------ */
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 回合以「只有思考、没有正文也没有工具调用」收尾时,把回合拽回来再走一步。
|
|
576
|
+
*
|
|
577
|
+
* 【为什么并进 loop-guard】实测「纯推理空转」的回合里,绝大多数空收尾就是循环步
|
|
578
|
+
* 被截断产生的(截断步没有 tool-call ⇒ agent-loop 判定完成 ⇒ 回合结束)。
|
|
579
|
+
* 两条防线本就是一个链条,拆成两个插件只会各记各的账、各发各的 steer。
|
|
580
|
+
*
|
|
581
|
+
* 【平台语义】agent/turn-stopping 是 serial 事件:监听器没有 next、不能改结果,
|
|
582
|
+
* 唯一手段是在里面调 agent.steer —— 平台收尾前会重读收件箱,有引导就再走一步。
|
|
583
|
+
*/
|
|
584
|
+
const EMPTY_TURN_TEXT = '你上一次只输出了思考,没有给用户任何可见的回复就结束了回合。'
|
|
585
|
+
+ '如果你的推理在重复,请立即停止重复;然后把当前的进展、结论或下一步用正文讲清楚再收尾。';
|
|
586
|
+
|
|
587
|
+
/** 消息里有没有某类块。 */
|
|
588
|
+
export function hasBlock(message, blockType) {
|
|
589
|
+
const content = (message && message.content) || [];
|
|
590
|
+
return Array.isArray(content) && content.some(function (b) { return b && b.type === blockType; });
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** 拼接消息里某类块的文本(块间用换行)。 */
|
|
594
|
+
export function joinBlocks(message, blockType) {
|
|
595
|
+
const content = (message && message.content) || [];
|
|
596
|
+
if (!Array.isArray(content)) return '';
|
|
597
|
+
const parts = [];
|
|
598
|
+
for (const b of content) {
|
|
599
|
+
if (b && b.type === blockType && typeof b.text === 'string') parts.push(b.text);
|
|
600
|
+
}
|
|
601
|
+
return parts.join('\n');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** 取某回合【最后一步】的 assistant 消息;session 读不到时返回 null(绝不阻断回合)。 */
|
|
605
|
+
export function readLastStepAssistantMessage(session, turn) {
|
|
606
|
+
let events = [];
|
|
607
|
+
try {
|
|
608
|
+
if (session && typeof session.snapshotEvents === 'function') {
|
|
609
|
+
const got = session.snapshotEvents();
|
|
610
|
+
if (Array.isArray(got)) events = got;
|
|
611
|
+
}
|
|
612
|
+
} catch (e) { return null; }
|
|
613
|
+
let lastStep = -1, lastMessage = null;
|
|
614
|
+
for (const e of events) {
|
|
615
|
+
if (!e || e.type !== 'assistant/message') continue;
|
|
616
|
+
if (!e.data || e.data.turn !== turn) continue;
|
|
617
|
+
const step = e.data.step;
|
|
618
|
+
const message = e.data.message;
|
|
619
|
+
if (step === undefined || !message) continue;
|
|
620
|
+
if (step < lastStep) continue;
|
|
621
|
+
lastStep = step; lastMessage = message;
|
|
622
|
+
}
|
|
623
|
+
return lastMessage === null ? null : { step: lastStep, message: lastMessage };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** 结构判定:命中返回一句说明,否则 null(不做文本匹配)。 */
|
|
627
|
+
export function detectEmptyTurnEnd(session, turn) {
|
|
628
|
+
const last = readLastStepAssistantMessage(session, turn);
|
|
629
|
+
if (last === null) return null;
|
|
630
|
+
if (hasBlock(last.message, 'tool-call')) return null;
|
|
631
|
+
if (joinBlocks(last.message, 'text').trim() !== '') return null;
|
|
632
|
+
if (joinBlocks(last.message, 'reasoning').trim() === '') return null;
|
|
633
|
+
return '回合在第 ' + last.step + ' 步只有思考、没有正文就收尾';
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** 收尾兜底:判定 + steer(同一 agent 同一回合有配额)。 */
|
|
637
|
+
function considerEmptyTurn(payload, budgets, config) {
|
|
638
|
+
if (payload === null || typeof payload !== 'object') return;
|
|
639
|
+
const agent = payload.agent;
|
|
640
|
+
if (agent === null || typeof agent !== 'object') return;
|
|
641
|
+
const turn = payload.turn === undefined ? 0 : payload.turn;
|
|
642
|
+
const hit = detectEmptyTurnEnd(agent.session, turn);
|
|
643
|
+
if (hit === null) return;
|
|
644
|
+
if (typeof agent.steer !== 'function') { log(' 收尾命中但 agent.steer 不可用,放行'); return; }
|
|
645
|
+
const prev = budgets.get(agent);
|
|
646
|
+
const count = (prev !== undefined && prev.turn === turn) ? prev.count + 1 : 1;
|
|
647
|
+
budgets.set(agent, { turn: turn, count: count });
|
|
648
|
+
if (count > config.maxEmptyTurnSteers) {
|
|
649
|
+
log(' ★ 收尾兜底已达上限(' + config.maxEmptyTurnSteers + ' 次/回合),放行:turn=' + turn + ' ' + hit);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
agent.steer(userMessage(EMPTY_TURN_TEXT, '循环守卫 · 空回合兜底'));
|
|
654
|
+
log(' ★ 收尾兜底命中:turn=' + turn + ' ' + hit + ',已 steer(第 ' + count + ' 次)');
|
|
655
|
+
} catch (e) {
|
|
656
|
+
log(' ⚠️ 收尾兜底 steer 失败: ' + ((e && e.message) || e));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/* ------------------------------------------------------------------ */
|
|
661
|
+
/* 八、挂载 */
|
|
662
|
+
/* ------------------------------------------------------------------ */
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* 挂载守卫。
|
|
666
|
+
* @param ctx - Cordis 上下文(需要 llm 服务;agents 服务做能力探测,拿不到就只截断不 steer)。
|
|
667
|
+
* @param given - 局部配置。
|
|
668
|
+
*/
|
|
669
|
+
export function apply(ctx, given) {
|
|
670
|
+
const cfg = mergeConfig(given);
|
|
671
|
+
const G = globalThis.__dshLoopGuard || (globalThis.__dshLoopGuard = { installed: false });
|
|
672
|
+
if (G.installed) {
|
|
673
|
+
log('已有实例在运行,跳过重复挂载(防止多层包装 / 重复注入)');
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
G.installed = true;
|
|
677
|
+
const seen = new WeakSet();
|
|
678
|
+
const detectors = new Map();
|
|
679
|
+
|
|
680
|
+
function detectorFor(agent, options) {
|
|
681
|
+
const key = agent || ('session:' + (options && options.sessionId));
|
|
682
|
+
let d = detectors.get(key);
|
|
683
|
+
if (d === undefined) { d = new LoopDetector(cfg); detectors.set(key, d); }
|
|
684
|
+
return d;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
let agentProbeWarned = false;
|
|
688
|
+
function agentFor(options) {
|
|
689
|
+
try {
|
|
690
|
+
if (ctx && ctx.agents && typeof ctx.agents.get === 'function' && options.sessionId !== undefined) {
|
|
691
|
+
return ctx.agents.get(options.sessionId);
|
|
692
|
+
}
|
|
693
|
+
} catch (e) {
|
|
694
|
+
// 只提示一次,别每个流刷一行
|
|
695
|
+
if (!agentProbeWarned) {
|
|
696
|
+
agentProbeWarned = true;
|
|
697
|
+
log('⚠️ 读不到 ctx.agents(inject 未声明该服务?): ' + ((e && e.message) || e) + ' —— 截断照常,但不通知模型');
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
ctx.on('llm/stream', async function* (options, next) {
|
|
705
|
+
// 跳内部请求(摘要/压缩)与已处理过的同一个 options
|
|
706
|
+
if (!options || typeof options !== 'object' || seen.has(options) || options.purpose !== undefined) {
|
|
707
|
+
yield* next();
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
seen.add(options);
|
|
711
|
+
const t0 = Date.now();
|
|
712
|
+
const agent = agentFor(options);
|
|
713
|
+
const detector = detectorFor(agent, options);
|
|
714
|
+
const textBreaker = new TextRepetitionDetector(cfg);
|
|
715
|
+
const reasonBreaker = new ReasoningLoopBreaker(cfg);
|
|
716
|
+
const open = new Map();
|
|
717
|
+
let reasoning = '';
|
|
718
|
+
let hasOutput = false;
|
|
719
|
+
let chunks = 0, reasonChars = 0, textChars = 0, firstAt = null, lastReasonAt = null;
|
|
720
|
+
const types = {};
|
|
721
|
+
let diagBuf = '', lowRatio = null; // 诊断:保留压缩比作对照
|
|
722
|
+
let broke = false, brokeRule = null, brokeAt = 0;
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
for await (const chunk of next()) {
|
|
726
|
+
yield chunk;
|
|
727
|
+
if (!chunk || typeof chunk.type !== 'string') continue;
|
|
728
|
+
chunks++;
|
|
729
|
+
if (firstAt === null) firstAt = Date.now();
|
|
730
|
+
types[chunk.type] = (types[chunk.type] || 0) + 1;
|
|
731
|
+
|
|
732
|
+
if (chunk.type === 'block-start') {
|
|
733
|
+
open.set(chunk.index, { blockType: chunk.blockType, text: '' });
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (chunk.type === 'block-end') { open.delete(chunk.index); continue; }
|
|
737
|
+
if (chunk.type === 'tool-call-delta') { hasOutput = true; continue; }
|
|
738
|
+
if (typeof chunk.text !== 'string') continue;
|
|
739
|
+
|
|
740
|
+
if (chunk.type === 'text-delta') {
|
|
741
|
+
textChars += chunk.text.length;
|
|
742
|
+
hasOutput = true;
|
|
743
|
+
const b = open.get(chunk.index);
|
|
744
|
+
if (b !== undefined) b.text += chunk.text;
|
|
745
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
746
|
+
reasonChars += chunk.text.length;
|
|
747
|
+
lastReasonAt = Date.now();
|
|
748
|
+
reasoning += chunk.text;
|
|
749
|
+
const b2 = open.get(chunk.index);
|
|
750
|
+
if (b2 !== undefined) b2.text += chunk.text;
|
|
751
|
+
diagBuf += chunk.text;
|
|
752
|
+
if (diagBuf.length > 4800) diagBuf = diagBuf.slice(-2400);
|
|
753
|
+
if (diagBuf.length >= 1600) {
|
|
754
|
+
const r = compressRatio(diagBuf.slice(-2400));
|
|
755
|
+
if (lowRatio === null || r < lowRatio) lowRatio = r;
|
|
756
|
+
}
|
|
757
|
+
} else {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (cfg.mode === 'watch') continue;
|
|
762
|
+
let hit = null;
|
|
763
|
+
if (chunk.type === 'text-delta') { if (textBreaker.push(chunk.text)) hit = textBreaker; }
|
|
764
|
+
else { if (reasonBreaker.push(chunk.text)) hit = reasonBreaker; }
|
|
765
|
+
if (hit === null) continue;
|
|
766
|
+
|
|
767
|
+
brokeRule = hit.trippedBy;
|
|
768
|
+
brokeAt = hit.repeatedChars;
|
|
769
|
+
if (cfg.mode === 'inject') {
|
|
770
|
+
broke = true;
|
|
771
|
+
log('★ 检出循环 [' + brokeRule + '] 重复 ' + brokeAt + ' 字 / 本次 ' + hit.emittedChars + ' 字 ⇒ 注入提示(对照)');
|
|
772
|
+
yield { type: 'reasoning-delta', index: chunk.index, text: '\n[循环守卫] ' + noticeFor(brokeRule, brokeAt) + '\n' };
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
const blocks = [];
|
|
776
|
+
for (const entry of open) blocks.push({ index: entry[0], blockType: entry[1].blockType, text: entry[1].text });
|
|
777
|
+
const tail = breakStream(agent, brokeAt, brokeRule, blocks, cfg);
|
|
778
|
+
if (tail === null) continue;
|
|
779
|
+
broke = true;
|
|
780
|
+
log('★ 检出循环 [' + brokeRule + '] 重复 ' + brokeAt + ' 字 / 本次 ' + hit.emittedChars + ' 字'
|
|
781
|
+
+ '(已转发 ' + chunks + ' 个 chunk)⇒ 关块+stop 收尾'
|
|
782
|
+
+ (agent ? '' : '(无 agent,未 steer)'));
|
|
783
|
+
for (const c of tail) yield c;
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
} catch (e) {
|
|
787
|
+
log('⚠️ 上游流抛错(原样上抛): ' + ((e && e.message) || e));
|
|
788
|
+
throw e;
|
|
789
|
+
} finally {
|
|
790
|
+
const total = Date.now() - t0;
|
|
791
|
+
const ttfb = firstAt ? (firstAt - t0) : -1;
|
|
792
|
+
const span = (lastReasonAt && firstAt) ? (lastReasonAt - firstAt) : 0;
|
|
793
|
+
const top = Object.keys(types).sort(function (a, b) { return types[b] - types[a]; })
|
|
794
|
+
.slice(0, 6).map(function (k) { return k + '=' + types[k]; }).join(' ');
|
|
795
|
+
log('stream' + (broke ? '【已截断 ' + brokeRule + '】' : '') + ' chunks=' + chunks + ' 耗时=' + total + 'ms 首块=' + ttfb + 'ms'
|
|
796
|
+
+ ' reasoning=' + reasonChars + '字 text=' + textChars + '字 思维链跨度=' + span + 'ms'
|
|
797
|
+
+ ' | 模式 ' + cfg.mode
|
|
798
|
+
+ (lowRatio !== null ? (' 最低压缩比 ' + (lowRatio * 100).toFixed(1) + '%(仅供对照)') : ''));
|
|
799
|
+
log(' 类型: ' + (top || '(无)'));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// 流自然结束:跨调用判定
|
|
803
|
+
if (!broke) {
|
|
804
|
+
const stalled = detector.observe({ hasOutput: hasOutput, reasoning: reasoning });
|
|
805
|
+
if (stalled !== undefined) log(' 卡住的调用(' + stalled + ')累计 ' + detector.stalled + '/' + detector.threshold);
|
|
806
|
+
const fire = detector.takeFire();
|
|
807
|
+
if (fire !== undefined) react(agent, fire, cfg);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// 收尾兜底(吸收 empty-turn-guard):把「只有思考、没有正文」的回合拽回来。
|
|
812
|
+
const budgets = new WeakMap();
|
|
813
|
+
try {
|
|
814
|
+
ctx.on('agent/turn-stopping', async function (payload) {
|
|
815
|
+
try { considerEmptyTurn(payload, budgets, cfg); }
|
|
816
|
+
catch (e) { log(' ⚠️ 收尾兜底异常(已放行): ' + ((e && e.message) || e)); }
|
|
817
|
+
});
|
|
818
|
+
} catch (e) {
|
|
819
|
+
log(' ⚠️ 注册 agent/turn-stopping 失败(收尾兜底不可用): ' + ((e && e.message) || e));
|
|
820
|
+
}
|
|
821
|
+
log('已挂载 loop-guard · 模式=' + cfg.mode + ' 跨调用阈值=' + cfg.maxThinkingSteps + ' 次'
|
|
822
|
+
+ ' 推理周期=' + cfg.maxRepeatedReasoningCycleChars + '/' + cfg.minRepeatedReasoningCycleChars
|
|
823
|
+
+ ' 推理短语池=' + cfg.maxRepeatedReasoningLineChars + '/' + cfg.minRepeatedReasoningLineCoverage + '/' + cfg.minRepeatedReasoningLineConcentration
|
|
824
|
+
+ ' 正文周期=' + cfg.maxRepeatedCycleChars + '/' + cfg.minRepeatedCycleChars
|
|
825
|
+
+ ' 正文短语池=' + cfg.maxRepeatedTextLineChars + '/' + cfg.minRepeatedTextLineCoverage + '/' + cfg.minRepeatedTextLineConcentration
|
|
826
|
+
+ ' agent通知=' + cfg.escalate);
|
|
827
|
+
try { if (ctx.logger && ctx.logger.info) ctx.logger.info('[loop-guard] 已挂载(模式 ' + cfg.mode + ')'); } catch (e) { /* 忽略 */ }
|
|
828
|
+
if (ctx && typeof ctx.effect === 'function') {
|
|
829
|
+
ctx.effect(function () { return function () { G.installed = false; log('已卸载,单例标记已交还'); }; });
|
|
830
|
+
}
|
|
831
|
+
} catch (e) {
|
|
832
|
+
G.installed = false;
|
|
833
|
+
log('挂载失败: ' + ((e && e.message) || e));
|
|
834
|
+
}
|
|
835
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-loop-guard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "思维链循环守卫:滑动窗口压缩比 + 坍缩/短链判据,检出后截断本轮流(默认 cut)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"lib/index.js"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Fanlr1/dsh-plugins.git",
|
|
20
|
+
"directory": "packages/loop-guard"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/Fanlr1/dsh-plugins/tree/main/packages/loop-guard#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/Fanlr1/dsh-plugins/issues"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"dsh",
|
|
28
|
+
"dsh-plugin",
|
|
29
|
+
"agent",
|
|
30
|
+
"reasoning",
|
|
31
|
+
"loop-guard"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
}
|
|
36
|
+
}
|