pi-antiloop 1.0.0 → 1.0.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 +3 -4
- package/package.json +5 -6
- package/src/commands.ts +216 -0
- package/src/config.ts +46 -0
- package/src/detect.ts +167 -0
- package/src/index.ts +162 -964
- package/src/types.ts +51 -0
- package/src/ui.ts +23 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-

|
|
4
4
|
|
|
5
5
|
</div>
|
|
6
6
|
|
|
@@ -233,9 +233,8 @@ antiloop/
|
|
|
233
233
|
├── LICENSE # MIT
|
|
234
234
|
├── README.md
|
|
235
235
|
├── docs/
|
|
236
|
-
│ ├── banner.
|
|
237
|
-
│ └── preview.
|
|
238
|
-
├── screenshot.png # full-res master
|
|
236
|
+
│ ├── banner.jpeg # wide README header
|
|
237
|
+
│ └── preview.jpeg # npm pi.dev preview card
|
|
239
238
|
└── src/
|
|
240
239
|
└── index.ts # full extension (≈975 lines)
|
|
241
240
|
```
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antiloop",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, and structural patterns.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
|
-
"loop-detection",
|
|
8
7
|
"antiloop",
|
|
8
|
+
"loop-detection",
|
|
9
9
|
"reasoning",
|
|
10
|
-
"monitoring"
|
|
11
|
-
"debugging"
|
|
10
|
+
"monitoring"
|
|
12
11
|
],
|
|
13
12
|
"author": "Javier Noguerol <https://github.com/noguerol>",
|
|
14
13
|
"license": "MIT",
|
|
@@ -24,7 +23,7 @@
|
|
|
24
23
|
"extensions": [
|
|
25
24
|
"./src/index.ts"
|
|
26
25
|
],
|
|
27
|
-
"image": "https://raw.githubusercontent.com/noguerol/antiloop/main/docs/preview.
|
|
26
|
+
"image": "https://raw.githubusercontent.com/noguerol/antiloop/main/docs/preview.jpeg"
|
|
28
27
|
},
|
|
29
28
|
"files": [
|
|
30
29
|
"src"
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// antiloop — command handlers. Lazy-loaded on /antiloop.
|
|
2
|
+
|
|
3
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { saveConfig } from "./config.ts";
|
|
5
|
+
import type { AntiloopState, Runtime } from "./types.ts";
|
|
6
|
+
import { formatDuration, selectFrom } from "./ui.ts";
|
|
7
|
+
|
|
8
|
+
export async function handleCommand(
|
|
9
|
+
args: string | undefined,
|
|
10
|
+
ctx: ExtensionCommandContext,
|
|
11
|
+
rt: Runtime,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const sub = (args ?? "").trim().toLowerCase();
|
|
14
|
+
switch (sub) {
|
|
15
|
+
case "enable":
|
|
16
|
+
rt.config.enabled = true;
|
|
17
|
+
saveConfig(rt.config);
|
|
18
|
+
ctx.ui.notify("antiloop: ON", "info");
|
|
19
|
+
rt.updateStatus(ctx);
|
|
20
|
+
return;
|
|
21
|
+
case "disable":
|
|
22
|
+
rt.config.enabled = false;
|
|
23
|
+
saveConfig(rt.config);
|
|
24
|
+
ctx.ui.notify("antiloop: OFF", "info");
|
|
25
|
+
rt.updateStatus(ctx);
|
|
26
|
+
return;
|
|
27
|
+
case "status":
|
|
28
|
+
return showStatus(ctx, rt);
|
|
29
|
+
case "config":
|
|
30
|
+
return showConfigMenu(ctx, rt);
|
|
31
|
+
case "log":
|
|
32
|
+
return showLog(ctx, rt);
|
|
33
|
+
case "reset":
|
|
34
|
+
resetState(rt.state);
|
|
35
|
+
rt.pendingIntervention = null;
|
|
36
|
+
ctx.ui.notify("antiloop: reset", "info");
|
|
37
|
+
rt.updateStatus(ctx);
|
|
38
|
+
return;
|
|
39
|
+
case "test":
|
|
40
|
+
return runSelfTest(ctx);
|
|
41
|
+
default:
|
|
42
|
+
rt.config.enabled = !rt.config.enabled;
|
|
43
|
+
saveConfig(rt.config);
|
|
44
|
+
ctx.ui.notify(`antiloop: ${rt.config.enabled ? "ON" : "OFF"}`, "info");
|
|
45
|
+
rt.updateStatus(ctx);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
|
|
51
|
+
const lvl = ["none", "warn", "force", "abort"][rt.state.currentLevel];
|
|
52
|
+
const recent = rt.state.detections.slice(-5);
|
|
53
|
+
const lines = [
|
|
54
|
+
`state: ${rt.config.enabled ? "ON" : "OFF"} · level: ${lvl} · consecutive: ${rt.state.consecutiveDetections}`,
|
|
55
|
+
`total: ${rt.state.totalDetections} · tracked: ${rt.state.recentMessages.length} · forced: ${rt.state.inForcedBreak ? "yes" : "no"}`,
|
|
56
|
+
"",
|
|
57
|
+
"thresholds:",
|
|
58
|
+
` warn: ${rt.config.warningThreshold} force: ${rt.config.forceBreakThreshold} abort: ${rt.config.abortThreshold || "off"}`,
|
|
59
|
+
` similarity: ${(rt.config.similarityThreshold * 100).toFixed(0)}% window: ${rt.config.detectionWindow}`,
|
|
60
|
+
"",
|
|
61
|
+
`detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
|
|
62
|
+
];
|
|
63
|
+
if (recent.length) {
|
|
64
|
+
lines.push("", "recent:");
|
|
65
|
+
for (const d of recent) lines.push(` [${d.type}] ${d.description} · ${formatDuration(Date.now() - d.timestamp)} ago`);
|
|
66
|
+
}
|
|
67
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
|
|
71
|
+
const c = rt.config;
|
|
72
|
+
const picked = await selectFrom(ctx, "antiloop config", [
|
|
73
|
+
{ value: "toggle" as const, label: c.enabled ? "🟢 disable" : "🔴 enable", description: "toggle detection" },
|
|
74
|
+
{ value: "warn" as const, label: `warn threshold: ${c.warningThreshold}` },
|
|
75
|
+
{ value: "force" as const, label: `force threshold: ${c.forceBreakThreshold}` },
|
|
76
|
+
{ value: "abort" as const, label: `abort threshold: ${c.abortThreshold || "off"}`, description: "0 = disabled" },
|
|
77
|
+
{ value: "sim" as const, label: `similarity: ${(c.similarityThreshold * 100).toFixed(0)}%` },
|
|
78
|
+
{ value: "window" as const, label: `window: ${c.detectionWindow} msgs` },
|
|
79
|
+
{ value: "text" as const, label: `text detect: ${yn(c.detectTextLoops)}` },
|
|
80
|
+
{ value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
|
|
81
|
+
{ value: "think" as const, label: `think detect: ${yn(c.detectThinkingLoops)}` },
|
|
82
|
+
{ value: "notify" as const, label: `notify: ${yn(c.notifyOnDetection)}` },
|
|
83
|
+
{ value: "reset" as const, label: "reset state" },
|
|
84
|
+
]);
|
|
85
|
+
if (!picked) return;
|
|
86
|
+
switch (picked) {
|
|
87
|
+
case "toggle":
|
|
88
|
+
c.enabled = !c.enabled;
|
|
89
|
+
saveConfig(c);
|
|
90
|
+
ctx.ui.notify(`antiloop: ${c.enabled ? "ON" : "OFF"}`, "info");
|
|
91
|
+
rt.updateStatus(ctx);
|
|
92
|
+
break;
|
|
93
|
+
case "warn": {
|
|
94
|
+
const v = await selectFrom(ctx, "warn threshold", [
|
|
95
|
+
{ value: 1, label: "1 (sensitive)" },
|
|
96
|
+
{ value: 2, label: "2 (default)" },
|
|
97
|
+
{ value: 3, label: "3" },
|
|
98
|
+
{ value: 5, label: "5 (relaxed)" },
|
|
99
|
+
]);
|
|
100
|
+
if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`warn: ${v}`, "info"); }
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "force": {
|
|
104
|
+
const v = await selectFrom(ctx, "force threshold", [
|
|
105
|
+
{ value: 2, label: "2 (sensitive)" },
|
|
106
|
+
{ value: 3, label: "3 (default)" },
|
|
107
|
+
{ value: 5, label: "5" },
|
|
108
|
+
{ value: 8, label: "8 (relaxed)" },
|
|
109
|
+
]);
|
|
110
|
+
if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`force: ${v}`, "info"); }
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case "abort": {
|
|
114
|
+
const v = await selectFrom(ctx, "abort threshold (0=off)", [
|
|
115
|
+
{ value: 0, label: "off" },
|
|
116
|
+
{ value: 5, label: "5" },
|
|
117
|
+
{ value: 8, label: "8" },
|
|
118
|
+
{ value: 10, label: "10" },
|
|
119
|
+
{ value: 15, label: "15" },
|
|
120
|
+
]);
|
|
121
|
+
if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`abort: ${v || "off"}`, "info"); }
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case "sim": {
|
|
125
|
+
const v = await selectFrom(ctx, "similarity", [
|
|
126
|
+
{ value: 0.5, label: "50% (sensitive)" },
|
|
127
|
+
{ value: 0.6, label: "60%" },
|
|
128
|
+
{ value: 0.7, label: "70%" },
|
|
129
|
+
{ value: 0.75, label: "75% (default)" },
|
|
130
|
+
{ value: 0.8, label: "80%" },
|
|
131
|
+
{ value: 0.9, label: "90% (relaxed)" },
|
|
132
|
+
]);
|
|
133
|
+
if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`similarity: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "window": {
|
|
137
|
+
const v = await selectFrom(ctx, "window", [
|
|
138
|
+
{ value: 5, label: "5" },
|
|
139
|
+
{ value: 10, label: "10 (default)" },
|
|
140
|
+
{ value: 15, label: "15" },
|
|
141
|
+
{ value: 20, label: "20" },
|
|
142
|
+
]);
|
|
143
|
+
if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`window: ${v}`, "info"); }
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "text":
|
|
147
|
+
c.detectTextLoops = !c.detectTextLoops; saveConfig(c);
|
|
148
|
+
ctx.ui.notify(`text: ${yn(c.detectTextLoops)}`, "info"); break;
|
|
149
|
+
case "tool":
|
|
150
|
+
c.detectToolLoops = !c.detectToolLoops; saveConfig(c);
|
|
151
|
+
ctx.ui.notify(`tool: ${yn(c.detectToolLoops)}`, "info"); break;
|
|
152
|
+
case "think":
|
|
153
|
+
c.detectThinkingLoops = !c.detectThinkingLoops; saveConfig(c);
|
|
154
|
+
ctx.ui.notify(`think: ${yn(c.detectThinkingLoops)}`, "info"); break;
|
|
155
|
+
case "notify":
|
|
156
|
+
c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
|
|
157
|
+
ctx.ui.notify(`notify: ${yn(c.notifyOnDetection)}`, "info"); break;
|
|
158
|
+
case "reset":
|
|
159
|
+
resetState(rt.state);
|
|
160
|
+
rt.pendingIntervention = null;
|
|
161
|
+
ctx.ui.notify("reset", "info");
|
|
162
|
+
rt.updateStatus(ctx);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
|
|
168
|
+
if (!rt.state.detections.length) {
|
|
169
|
+
ctx.ui.notify("no detections this session", "info");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const items = rt.state.detections.slice(-30).reverse().map((d) => ({
|
|
173
|
+
value: "" as const,
|
|
174
|
+
label: `[${d.type}] ${d.description}`,
|
|
175
|
+
description: `${(d.similarity * 100).toFixed(0)}% · ${formatDuration(Date.now() - d.timestamp)} ago`,
|
|
176
|
+
}));
|
|
177
|
+
await selectFrom(ctx, `detections (${rt.state.detections.length} total)`, items);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resetState(state: AntiloopState): void {
|
|
181
|
+
state.recentMessages = [];
|
|
182
|
+
state.detections = [];
|
|
183
|
+
state.currentLevel = 0;
|
|
184
|
+
state.consecutiveDetections = 0;
|
|
185
|
+
state.inForcedBreak = false;
|
|
186
|
+
state.totalDetections = 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function runSelfTest(ctx: ExtensionCommandContext): Promise<void> {
|
|
190
|
+
// Inline similarity + normalize (mirrors detect.ts). Keeps test self-contained
|
|
191
|
+
// without re-importing private helpers.
|
|
192
|
+
const norm = (t: string) => t.toLowerCase().replace(/\s+/g, " ").replace(/[^\w\s]/g, "").trim();
|
|
193
|
+
const cases: Array<{ a: string; b: string; expect: string }> = [
|
|
194
|
+
{ a: "Hello world", b: "Hello world", expect: "1.00" },
|
|
195
|
+
{ a: "Hello world", b: "Hello World!", expect: "high" },
|
|
196
|
+
{ a: "The quick brown fox", b: "The quick brown fox jumps over the lazy dog", expect: "high" },
|
|
197
|
+
{ a: "Hello world", b: "Goodbye universe", expect: "low" },
|
|
198
|
+
{ a: "I will read the file first", b: "I will read the file first to understand", expect: "high" },
|
|
199
|
+
];
|
|
200
|
+
const out: string[] = [];
|
|
201
|
+
for (const c of cases) {
|
|
202
|
+
// Re-implement minimal Levenshtein similarity for the self-test
|
|
203
|
+
const a = norm(c.a), b = norm(c.b);
|
|
204
|
+
const max = Math.max(a.length, b.length);
|
|
205
|
+
let diff = 0;
|
|
206
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) diff++;
|
|
207
|
+
diff += Math.abs(a.length - b.length);
|
|
208
|
+
const s = max ? 1 - diff / max : 1;
|
|
209
|
+
out.push(`"${c.a}" vs "${c.b}" → ${(s * 100).toFixed(0)}% (exp: ${c.expect})`);
|
|
210
|
+
}
|
|
211
|
+
ctx.ui.notify(`antiloop self-test\n${out.join("\n")}`, "info");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function yn(b: boolean): string {
|
|
215
|
+
return b ? "on" : "off";
|
|
216
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// antiloop — config load/save. Lightweight: only file I/O at session_start.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { AntiloopConfig } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export const CONFIG_FILE = "antiloop.json";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_CONFIG: AntiloopConfig = {
|
|
11
|
+
enabled: true,
|
|
12
|
+
warningThreshold: 2,
|
|
13
|
+
forceBreakThreshold: 3,
|
|
14
|
+
abortThreshold: 0,
|
|
15
|
+
similarityThreshold: 0.75,
|
|
16
|
+
detectToolLoops: true,
|
|
17
|
+
detectThinkingLoops: true,
|
|
18
|
+
detectTextLoops: true,
|
|
19
|
+
notifyOnDetection: true,
|
|
20
|
+
maxHistoryEntries: 100,
|
|
21
|
+
detectionWindow: 10,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function getConfigPath(): string {
|
|
25
|
+
return join(getAgentDir(), CONFIG_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadConfig(): AntiloopConfig {
|
|
29
|
+
const p = getConfigPath();
|
|
30
|
+
if (existsSync(p)) {
|
|
31
|
+
try {
|
|
32
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(p, "utf-8")) };
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error(`[antiloop] config load error: ${e}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ...DEFAULT_CONFIG };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveConfig(config: AntiloopConfig): void {
|
|
41
|
+
try {
|
|
42
|
+
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8");
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error(`[antiloop] config save error: ${e}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// antiloop — similarity + detection engine. Lazy-loaded on first message_end.
|
|
2
|
+
|
|
3
|
+
import type { AntiloopConfig, AntiloopState, LoopDetection } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const MIN_CONTENT_LENGTH = 50;
|
|
6
|
+
|
|
7
|
+
function normalizeText(t: string): string {
|
|
8
|
+
return t.toLowerCase().replace(/\s+/g, " ").replace(/[^\w\s]/g, "").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function levenshtein(a: string, b: string): number {
|
|
12
|
+
if (!a.length) return b.length;
|
|
13
|
+
if (!b.length) return a.length;
|
|
14
|
+
const m: number[][] = [];
|
|
15
|
+
for (let i = 0; i <= b.length; i++) m[i] = [i];
|
|
16
|
+
for (let j = 0; j <= a.length; j++) m[0][j] = j;
|
|
17
|
+
for (let i = 1; i <= b.length; i++) {
|
|
18
|
+
for (let j = 1; j <= a.length; j++) {
|
|
19
|
+
m[i][j] = b.charAt(i - 1) === a.charAt(j - 1)
|
|
20
|
+
? m[i - 1][j - 1]
|
|
21
|
+
: Math.min(m[i - 1][j - 1] + 1, m[i][j - 1] + 1, m[i - 1][j] + 1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return m[b.length][a.length];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ngrams(text: string, n: number): Set<string> {
|
|
28
|
+
const out = new Set<string>();
|
|
29
|
+
for (let i = 0; i <= text.length - n; i++) out.add(text.substring(i, i + n));
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function opening(text: string, n = 10): string {
|
|
34
|
+
return normalizeText(text.split(/\s+/).slice(0, n).join(" "));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function similarity(a: string, b: string): number {
|
|
38
|
+
if (a.length < MIN_CONTENT_LENGTH || b.length < MIN_CONTENT_LENGTH) return 0;
|
|
39
|
+
if (a === b) return 1;
|
|
40
|
+
const na = normalizeText(a);
|
|
41
|
+
const nb = normalizeText(b);
|
|
42
|
+
if (na.length < 20 || nb.length < 20) return 0;
|
|
43
|
+
if (na === nb) return 1;
|
|
44
|
+
if (na.length < 100 && nb.length < 100) {
|
|
45
|
+
const max = Math.max(na.length, nb.length);
|
|
46
|
+
return 1 - levenshtein(na, nb) / max;
|
|
47
|
+
}
|
|
48
|
+
const ga = ngrams(na, 3);
|
|
49
|
+
const gb = ngrams(nb, 3);
|
|
50
|
+
let inter = 0;
|
|
51
|
+
for (const x of ga) if (gb.has(x)) inter++;
|
|
52
|
+
const uni = ga.size + gb.size - inter;
|
|
53
|
+
return inter / uni;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toolCallsSimilar(
|
|
57
|
+
c1: Array<{ name: string; args: string }>,
|
|
58
|
+
c2: Array<{ name: string; args: string }>,
|
|
59
|
+
): boolean {
|
|
60
|
+
if (c1.length !== c2.length) return false;
|
|
61
|
+
if (!c1.length) return true;
|
|
62
|
+
for (let i = 0; i < c1.length; i++) {
|
|
63
|
+
if (c1[i].name !== c2[i].name) return false;
|
|
64
|
+
if (similarity(c1[i].args, c2[i].args) < 0.8) return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopDetection[] {
|
|
70
|
+
const out: LoopDetection[] = [];
|
|
71
|
+
const msgs = state.recentMessages;
|
|
72
|
+
if (msgs.length < 2) return out;
|
|
73
|
+
const start = Math.max(0, msgs.length - config.detectionWindow);
|
|
74
|
+
const win = msgs.slice(start);
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
|
|
77
|
+
if (config.detectTextLoops) {
|
|
78
|
+
const last = win[win.length - 1];
|
|
79
|
+
if (last.content.length >= MIN_CONTENT_LENGTH) {
|
|
80
|
+
for (let i = 0; i < win.length - 1; i++) {
|
|
81
|
+
if (win[i].content.length < MIN_CONTENT_LENGTH) continue;
|
|
82
|
+
const s = similarity(last.content, win[i].content);
|
|
83
|
+
if (s >= config.similarityThreshold) {
|
|
84
|
+
out.push({
|
|
85
|
+
type: "text",
|
|
86
|
+
similarity: s,
|
|
87
|
+
messageIndices: [start + i, msgs.length - 1],
|
|
88
|
+
description: `text similarity ${(s * 100).toFixed(0)}% with msg ${start + i + 1}`,
|
|
89
|
+
timestamp: now,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (win.length >= 3) {
|
|
95
|
+
const opens = win.map((m, idx) => ({ o: opening(m.content), idx }))
|
|
96
|
+
.filter((x) => x.o.length >= 20);
|
|
97
|
+
if (opens.length >= 3) {
|
|
98
|
+
const last = opens[opens.length - 1].o;
|
|
99
|
+
let n = 0;
|
|
100
|
+
for (let i = 0; i < opens.length - 1; i++) {
|
|
101
|
+
if (similarity(last, opens[i].o) > 0.8) n++;
|
|
102
|
+
}
|
|
103
|
+
if (n >= 2) {
|
|
104
|
+
out.push({
|
|
105
|
+
type: "structural",
|
|
106
|
+
similarity: 0.9,
|
|
107
|
+
messageIndices: [msgs.length - 1],
|
|
108
|
+
description: `repeated opening (${n + 1} similar starts)`,
|
|
109
|
+
timestamp: now,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (config.detectToolLoops) {
|
|
117
|
+
const last = win[win.length - 1];
|
|
118
|
+
const lastCalls = last.toolCalls;
|
|
119
|
+
if (lastCalls && lastCalls.length) {
|
|
120
|
+
for (let i = 0; i < win.length - 1; i++) {
|
|
121
|
+
const prev = win[i].toolCalls;
|
|
122
|
+
if (prev && toolCallsSimilar(lastCalls, prev)) {
|
|
123
|
+
out.push({
|
|
124
|
+
type: "tool",
|
|
125
|
+
similarity: 1,
|
|
126
|
+
messageIndices: [start + i, msgs.length - 1],
|
|
127
|
+
description: `repeated: ${lastCalls.map((t) => t.name).join(", ")}`,
|
|
128
|
+
timestamp: now,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (config.detectThinkingLoops) {
|
|
136
|
+
const last = win[win.length - 1];
|
|
137
|
+
if (last.thinking && last.thinking.length > 50) {
|
|
138
|
+
for (let i = 0; i < win.length - 1; i++) {
|
|
139
|
+
if (win[i].thinking && win[i].thinking!.length > 50) {
|
|
140
|
+
const s = similarity(last.thinking, win[i].thinking!);
|
|
141
|
+
if (s >= config.similarityThreshold) {
|
|
142
|
+
out.push({
|
|
143
|
+
type: "thinking",
|
|
144
|
+
similarity: s,
|
|
145
|
+
messageIndices: [start + i, msgs.length - 1],
|
|
146
|
+
description: `thinking similarity ${(s * 100).toFixed(0)}%`,
|
|
147
|
+
timestamp: now,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function interventionMessage(level: 1 | 2 | 3, detections: LoopDetection[]): string {
|
|
159
|
+
const det = detections.map((d) => `- ${d.description}`).join("\n");
|
|
160
|
+
if (level === 1) {
|
|
161
|
+
return `[antiloop] ⚠️ loop warning\n${det}\nvary approach — try a different strategy.`;
|
|
162
|
+
}
|
|
163
|
+
if (level === 2) {
|
|
164
|
+
return `[antiloop] 🛑 stuck in loop\n${det}\nstop, change approach, do NOT repeat previous tool calls or reasoning.`;
|
|
165
|
+
}
|
|
166
|
+
return `[antiloop] 🚨 persistent loop\n${det}\nunable to break automatically — provide new instructions.`;
|
|
167
|
+
}
|