pi-antiloop 1.0.0 → 1.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/src/detect.ts ADDED
@@ -0,0 +1,280 @@
1
+ // antiloop — similarity + detection engine. Lazy-loaded on first message_end.
2
+
3
+ import type { AntiloopConfig, AntiloopState, LoopDetection, TrackedToolCall } 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
+ /**
57
+ * Normalized, size-capped tail of a tool result, prefixed with ok/err so a
58
+ * change between success and failure is always a "different outcome".
59
+ * Stable against PID / timestamp noise at the tail of command output.
60
+ */
61
+ export function resultFingerprint(
62
+ parts: Array<{ type: string; text?: string }>,
63
+ isError: boolean,
64
+ ): string | undefined {
65
+ let text = "";
66
+ for (const p of parts) if (p.type === "text" && typeof p.text === "string") text += p.text;
67
+ const norm = normalizeText(text);
68
+ if (!norm.length) return undefined;
69
+ return `${isError ? "err" : "ok"}|${norm.slice(-400)}`;
70
+ }
71
+
72
+ /** Same outcome = identical fingerprint, or high similarity of the tails. */
73
+ function sameOutcome(a: string, b: string, threshold: number): boolean {
74
+ if (a === b) return true;
75
+ // Fingerprints are already normalized + capped, so short ones can be
76
+ // compared with edit distance directly — similarity() bails under 50 chars
77
+ // and would wrongly veto small outputs with harmless noise (PIDs, times).
78
+ if (a.length < 100 && b.length < 100) {
79
+ const max = Math.max(a.length, b.length);
80
+ const s = max ? 1 - levenshtein(a, b) / max : 1;
81
+ return s >= threshold;
82
+ }
83
+ const s = similarity(a, b);
84
+ return s >= threshold && s > 0;
85
+ }
86
+
87
+ function toolCallsSimilar(
88
+ c1: TrackedToolCall[],
89
+ c2: TrackedToolCall[],
90
+ threshold: number,
91
+ resultThreshold: number,
92
+ ): boolean {
93
+ if (c1.length !== c2.length) return false;
94
+ // Empty call lists carry no repetition evidence — never treat them as a match.
95
+ if (!c1.length) return false;
96
+ for (let i = 0; i < c1.length; i++) {
97
+ if (c1[i].name !== c2[i].name) return false;
98
+ if (similarity(c1[i].args, c2[i].args) < threshold) return false;
99
+ // Result veto: the same command producing a different outcome is
100
+ // progress (a retry that fixed the problem), not a loop. Only applies
101
+ // when both runs actually captured a result.
102
+ const r1 = c1[i].result;
103
+ const r2 = c2[i].result;
104
+ if (r1 && r2 && !sameOutcome(r1, r2, resultThreshold)) {
105
+ return false;
106
+ }
107
+ }
108
+ return true;
109
+ }
110
+
111
+ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopDetection[] {
112
+ const out: LoopDetection[] = [];
113
+ const msgs = state.recentMessages;
114
+ if (msgs.length < 2) return out;
115
+ const start = Math.max(0, msgs.length - config.detectionWindow);
116
+ const win = msgs.slice(start);
117
+ const now = Date.now();
118
+
119
+ if (config.detectTextLoops) {
120
+ const last = win[win.length - 1];
121
+ if (last.content.length >= MIN_CONTENT_LENGTH) {
122
+ for (let i = 0; i < win.length - 1; i++) {
123
+ if (win[i].content.length < MIN_CONTENT_LENGTH) continue;
124
+ const s = similarity(last.content, win[i].content);
125
+ if (s >= config.similarityThreshold) {
126
+ out.push({
127
+ type: "text",
128
+ similarity: s,
129
+ messageIndices: [start + i, msgs.length - 1],
130
+ description: `text similarity ${(s * 100).toFixed(0)}% with msg ${start + i + 1}`,
131
+ timestamp: now,
132
+ });
133
+ }
134
+ }
135
+ }
136
+ if (win.length >= 3) {
137
+ const opens = win.map((m, idx) => ({ o: opening(m.content), idx }))
138
+ .filter((x) => x.o.length >= 20);
139
+ if (opens.length >= 3) {
140
+ const last = opens[opens.length - 1].o;
141
+ let n = 0;
142
+ for (let i = 0; i < opens.length - 1; i++) {
143
+ if (similarity(last, opens[i].o) > 0.9) n++;
144
+ }
145
+ if (n >= 2) {
146
+ out.push({
147
+ type: "structural",
148
+ similarity: 0.9,
149
+ messageIndices: [msgs.length - 1],
150
+ description: `repeated opening (${n + 1} similar starts)`,
151
+ timestamp: now,
152
+ });
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ if (config.detectToolLoops) {
159
+ const last = win[win.length - 1];
160
+ const lastCalls = last.toolCalls;
161
+ if (lastCalls && lastCalls.length) {
162
+ const matched: number[] = [];
163
+ for (let i = 0; i < win.length - 1; i++) {
164
+ const prev = win[i].toolCalls;
165
+ if (prev && toolCallsSimilar(lastCalls, prev, config.toolSimilarityThreshold, config.resultSimilarityThreshold)) {
166
+ matched.push(start + i);
167
+ }
168
+ }
169
+ // A single overlapping command (shared scaffolding in a long bash
170
+ // call) is NOT a loop — the same call set must recur at least
171
+ // minToolRepeatCount times inside the window before we flag it.
172
+ if (matched.length >= config.minToolRepeatCount) {
173
+ out.push({
174
+ type: "tool",
175
+ similarity: 1,
176
+ messageIndices: [...matched, msgs.length - 1],
177
+ description: `repeated ${matched.length + 1}x: ${lastCalls.map((t) => t.name).join(", ")}`,
178
+ timestamp: now,
179
+ });
180
+ }
181
+ }
182
+ }
183
+
184
+ if (config.detectThinkingLoops) {
185
+ const last = win[win.length - 1];
186
+ if (last.thinking && last.thinking.length > 50) {
187
+ for (let i = 0; i < win.length - 1; i++) {
188
+ if (win[i].thinking && win[i].thinking!.length > 50) {
189
+ const s = similarity(last.thinking, win[i].thinking!);
190
+ if (s >= config.similarityThreshold) {
191
+ out.push({
192
+ type: "thinking",
193
+ similarity: s,
194
+ messageIndices: [start + i, msgs.length - 1],
195
+ description: `thinking similarity ${(s * 100).toFixed(0)}%`,
196
+ timestamp: now,
197
+ });
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ return out;
205
+ }
206
+
207
+ export function interventionMessage(level: 1 | 2 | 3, detections: LoopDetection[]): string {
208
+ const det = detections.map((d) => `- ${d.description}`).join("\n");
209
+ if (level === 1) {
210
+ return `[antiloop] ⚠️ loop warning\n${det}\nvary approach — try a different strategy.`;
211
+ }
212
+ if (level === 2) {
213
+ return `[antiloop] 🛑 stuck in loop\n${det}\nstop, change approach, do NOT repeat previous tool calls or reasoning.`;
214
+ }
215
+ return `[antiloop] 🚨 persistent loop\n${det}\nunable to break automatically — provide new instructions.`;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Self-test — runs the REAL engine so it tracks future calibration changes.
220
+ // Includes the regression case that motivated the 0.95 tool threshold:
221
+ // sequential bash operations that share scaffolding (env setup, model path,
222
+ // most flags) are NOT a loop, even when they score 0.8–0.94 similar.
223
+ // ---------------------------------------------------------------------------
224
+
225
+ export function runSelfTest(): string[] {
226
+ const out: string[] = [];
227
+ const pct = (s: number) => `${(s * 100).toFixed(0)}%`;
228
+
229
+ // --- text similarity ---
230
+ const textSame = "I will read the file first to understand the structure before editing anything at all";
231
+ const textNear = "I will read the file first to understand the layout before editing anything at all";
232
+ const textDiff = "The quick brown fox jumps over the lazy dog near the river bank and keeps running";
233
+ const s1 = similarity(textSame, textSame);
234
+ const s2 = similarity(textSame, textNear);
235
+ const s3 = similarity(textSame, textDiff);
236
+ out.push(`text identical → ${pct(s1)} (exp 100%) ${s1 >= 0.99 ? "✅" : "❌"}`);
237
+ out.push(`text near-identical → ${pct(s2)} (exp ≥ 80%) ${s2 >= 0.8 ? "✅" : "❌"}`);
238
+ out.push(`text unrelated → ${pct(s3)} (exp < 50%) ${s3 < 0.5 ? "✅" : "❌"}`);
239
+
240
+ // --- tool calls (default thresholds: 95% args similarity, 2 prior repeats) ---
241
+ const common =
242
+ "cd /home/j/llm && ulimit -l unlimited 2>/dev/null; export ROCBLAS_USE_HIPBLASLT=1 HIP_VISIBLE_DEVICES=1; " +
243
+ "setsid ./kingjones30-boosted/build-unroll/bin/llama-server " +
244
+ "-m /home/j/llm/ling-rocmfp4/Ling-3.0-flash-ROCmFP4-STRIX-MTP-Q4_0-00001-of-00002.gguf " +
245
+ "-dev ROCm0 -ngl 999 -fa on -c 8192 -fit off -np 1 -sm row -ub 2048 " +
246
+ "--spec-type draft-mtp --spec-draft-n-max 2 --spec-draft-n-min 0 --spec-draft-p-min 0.4 " +
247
+ "--reasoning off --jinja --host 127.0.0.1 --port 8093 --no-webui";
248
+ const sweepRun1 = `${common} -b 2048 -ctk q8_0 -ctv turbo4 > /tmp/sweep-turbo4.log 2>&1 & echo $!; sleep 60; grep "model loaded" /tmp/sweep-turbo4.log`;
249
+ const sweepRun2 = `${common} -b 8192 -ctk f16 -ctv f16 > /tmp/sweep-b8192.log 2>&1 & echo $!; sleep 70; grep "model loaded" /tmp/sweep-b8192.log`;
250
+
251
+ const t1 = toolCallsSimilar([{ name: "bash", args: sweepRun1 }], [{ name: "bash", args: sweepRun1 }], 0.95, 0.8);
252
+ const t2 = toolCallsSimilar([{ name: "bash", args: sweepRun1 }], [{ name: "bash", args: sweepRun2 }], 0.95, 0.8);
253
+ const t2old = toolCallsSimilar([{ name: "bash", args: sweepRun1 }], [{ name: "bash", args: sweepRun2 }], 0.8, 0.8);
254
+ const t3 = toolCallsSimilar([{ name: "bash", args: sweepRun1 }], [{ name: "read", args: "{}" }], 0.95, 0.8);
255
+ const t4 = toolCallsSimilar([], [], 0.95, 0.8);
256
+ out.push(`tool identical cmd → ${t1 ? "match" : "no match"} (exp match) ${t1 ? "✅" : "❌"}`);
257
+ out.push(`tool sweep (flags) → ${t2 ? "match" : "no match"} @95% (exp no match) ${!t2 ? "✅" : "❌"}`);
258
+ out.push(`tool sweep (old 80%)→ ${t2old ? "match" : "no match"} @80% (exp match — was the false positive) ${t2old ? "✅" : "❌"}`);
259
+ out.push(`tool different tool → ${t3 ? "match" : "no match"} (exp no match) ${!t3 ? "✅" : "❌"}`);
260
+ out.push(`tool empty lists → ${t4 ? "match" : "no match"} (exp no match) ${!t4 ? "✅" : "❌"}`);
261
+
262
+ // --- result veto: same command, different outcome = progress, not a loop ---
263
+ const rErr = resultFingerprint([{ type: "text", text: "error: invalid argument: ROCm0\nPID 74970" }], true)!;
264
+ const rErr2 = resultFingerprint([{ type: "text", text: "error: invalid argument: ROCm0\nPID 77788" }], true)!;
265
+ const rOk = resultFingerprint([{ type: "text", text: "model loaded\nserver is listening on http://127.0.0.1:8093" }], false)!;
266
+ const sameCmdSameOut = toolCallsSimilar(
267
+ [{ name: "bash", args: sweepRun1, result: rErr }],
268
+ [{ name: "bash", args: sweepRun1, result: rErr2 }],
269
+ 0.95, 0.8,
270
+ );
271
+ const sameCmdDiffOut = toolCallsSimilar(
272
+ [{ name: "bash", args: sweepRun1, result: rErr }],
273
+ [{ name: "bash", args: sweepRun1, result: rOk }],
274
+ 0.95, 0.8,
275
+ );
276
+ out.push(`result same outcome → ${sameCmdSameOut ? "match" : "no match"} (exp match — PID noise ok) ${sameCmdSameOut ? "✅" : "❌"}`);
277
+ out.push(`result diff outcome → ${sameCmdDiffOut ? "match" : "no match"} (exp no match — error→success is progress) ${!sameCmdDiffOut ? "✅" : "❌"}`);
278
+
279
+ return out;
280
+ }