pi-antiloop 1.5.1 → 1.7.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 +209 -12
- package/package.json +2 -2
- package/src/commands.ts +55 -1
- package/src/config.ts +16 -0
- package/src/detect.ts +617 -8
- package/src/index.ts +152 -5
- package/src/types.ts +71 -1
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* antiloop — detect reasoning loops and intervene.
|
|
3
|
-
* Hooks: message_end, input, turn_end, session_start, session_shutdown.
|
|
3
|
+
* Hooks: message_end, tool_call, input, turn_end, session_start, session_shutdown.
|
|
4
4
|
* Commands: /antiloop [enable|disable|status|config|log|reset|test]
|
|
5
5
|
*
|
|
6
6
|
* Intervention model (v1.5):
|
|
@@ -15,9 +15,26 @@
|
|
|
15
15
|
* (≥98% similar / identical tool loop) ignoredSteerLimit times, antiloop
|
|
16
16
|
* hard-stops the run (ctx.abort).
|
|
17
17
|
* abort (level 3, opt-in via abortThreshold) — stops the run outright.
|
|
18
|
+
*
|
|
19
|
+
* v1.6 adds the intra-message degenerate detector: a model whose decoder
|
|
20
|
+
* anchors on a token repeats it hundreds of times INSIDE one message / tool
|
|
21
|
+
* call (the real 46 KB bash "noguerol ×5145" meltdown). That needs no peer
|
|
22
|
+
* message, so it is caught at message_end — BEFORE the tool calls execute —
|
|
23
|
+
* and degenerate bash commands are additionally blocked in the tool_call
|
|
24
|
+
* hook (blockDegenerateBash). One degenerate turn counts degenerateTurnWeight
|
|
25
|
+
* (2) consecutive points: warning on first sight, force-break steer on the
|
|
26
|
+
* second consecutive meltdown, hard stop shortly after if it keeps repeating.
|
|
27
|
+
*
|
|
28
|
+
* v1.7 adds the intra-message BLOCK detector for the narration-loop class:
|
|
29
|
+
* ONE message that replays whole sentences/phrases ("Let me start by checking
|
|
30
|
+
* the environment…" ×5), which every cross-message detector misses because
|
|
31
|
+
* there is no peer message and the degenerate scan only watches single words.
|
|
32
|
+
* Same message_end timing and strong turn weight; the bash gate refuses a
|
|
33
|
+
* command that is itself a replayed block.
|
|
18
34
|
*/
|
|
19
35
|
|
|
20
36
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
21
38
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
22
39
|
import { loadConfig, saveConfig } from "./config.ts";
|
|
23
40
|
import type { AntiloopState, LoopDetection, Runtime, TrackedToolCall } from "./types.ts";
|
|
@@ -38,6 +55,7 @@ function newState(): AntiloopState {
|
|
|
38
55
|
lastDetectedTurnIndex: -1,
|
|
39
56
|
steerDelivered: false,
|
|
40
57
|
ignoredSteerCount: 0,
|
|
58
|
+
turnSeq: 0,
|
|
41
59
|
};
|
|
42
60
|
}
|
|
43
61
|
|
|
@@ -235,7 +253,7 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
235
253
|
});
|
|
236
254
|
}
|
|
237
255
|
|
|
238
|
-
pi.on("message_end", async (event) => {
|
|
256
|
+
pi.on("message_end", async (event, ctx) => {
|
|
239
257
|
if (!config.enabled) return;
|
|
240
258
|
const msg = event.message;
|
|
241
259
|
if (msg.role !== "assistant") return;
|
|
@@ -263,12 +281,105 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
263
281
|
thinking: thinking || undefined,
|
|
264
282
|
toolCalls: toolCalls.length ? toolCalls : undefined,
|
|
265
283
|
timestamp: Date.now(),
|
|
266
|
-
|
|
284
|
+
// Monotonic: the array is trimmed below (detectionWindow + 5), so
|
|
285
|
+
// recentMessages.length would repeat after the first trim and the
|
|
286
|
+
// turn_end dedupe guard (turnIndex === lastDetectedTurnIndex) would
|
|
287
|
+
// skip every later message — antiloop going blind mid-session.
|
|
288
|
+
turnIndex: state.turnSeq++,
|
|
267
289
|
});
|
|
268
290
|
}
|
|
269
291
|
if (state.recentMessages.length > config.detectionWindow + 5) {
|
|
270
292
|
state.recentMessages = state.recentMessages.slice(-(config.detectionWindow + 5));
|
|
271
293
|
}
|
|
294
|
+
|
|
295
|
+
// v1.6/v1.7 — intra-message repetition is handled HERE, at message_end,
|
|
296
|
+
// because turn_end only fires after tool execution (too late to stop the
|
|
297
|
+
// 46 KB "noguerol ×5000" bash from running) and an aborted generation may
|
|
298
|
+
// never reach turn_end at all. The signal is self-contained (one
|
|
299
|
+
// pathological payload, no peer message) so the full escalation ladder runs
|
|
300
|
+
// right now; the turn-guard makes the later turn_end pass skip this message
|
|
301
|
+
// and no detection is double-counted. Two flavors: degenerate (one word
|
|
302
|
+
// ×hundreds) and block (whole sentences/phrases replayed — the narration
|
|
303
|
+
// loop class where every cross-message detector is blind).
|
|
304
|
+
if (!config.detectDegenerate && !config.detectBlockRepeats) return;
|
|
305
|
+
const {
|
|
306
|
+
scanMessageDegenerate,
|
|
307
|
+
degenerateDescription,
|
|
308
|
+
scanMessageBlock,
|
|
309
|
+
blockRepeatDescription,
|
|
310
|
+
nextLevel,
|
|
311
|
+
isVerbatimRepeat,
|
|
312
|
+
} = await import("./detect.ts");
|
|
313
|
+
let det: LoopDetection | undefined;
|
|
314
|
+
if (config.detectDegenerate) {
|
|
315
|
+
const degHit = scanMessageDegenerate(content, toolCalls, config);
|
|
316
|
+
if (degHit) {
|
|
317
|
+
det = {
|
|
318
|
+
type: "degenerate",
|
|
319
|
+
similarity: 1,
|
|
320
|
+
messageIndices: [state.recentMessages.length - 1],
|
|
321
|
+
description: degenerateDescription(degHit),
|
|
322
|
+
timestamp: Date.now(),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!det && config.detectBlockRepeats) {
|
|
327
|
+
const blkHit = scanMessageBlock(content, toolCalls, config);
|
|
328
|
+
if (blkHit) {
|
|
329
|
+
det = {
|
|
330
|
+
type: "block",
|
|
331
|
+
similarity: 0.99,
|
|
332
|
+
messageIndices: [state.recentMessages.length - 1],
|
|
333
|
+
description: blockRepeatDescription(blkHit),
|
|
334
|
+
timestamp: Date.now(),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (!det) return;
|
|
339
|
+
const tracked = state.recentMessages[state.recentMessages.length - 1];
|
|
340
|
+
if (tracked) state.lastDetectedTurnIndex = tracked.turnIndex;
|
|
341
|
+
const prevLevel = state.currentLevel;
|
|
342
|
+
state.consecutiveDetections += Math.max(1, config.degenerateTurnWeight);
|
|
343
|
+
state.totalDetections++;
|
|
344
|
+
state.detections.push(det);
|
|
345
|
+
if (state.detections.length > config.maxHistoryEntries) {
|
|
346
|
+
state.detections = state.detections.slice(-config.maxHistoryEntries);
|
|
347
|
+
}
|
|
348
|
+
applyLevel(nextLevel(state.consecutiveDetections, config));
|
|
349
|
+
const level = state.currentLevel;
|
|
350
|
+
|
|
351
|
+
if (level === 1 && prevLevel < 1) {
|
|
352
|
+
// Warning: informational only (never injects — a warning must not stall).
|
|
353
|
+
if (config.notifyOnDetection) {
|
|
354
|
+
ctx.ui.notify(`antiloop: warning — ${det.description}`, "warning");
|
|
355
|
+
}
|
|
356
|
+
} else if (level === 2) {
|
|
357
|
+
if (!state.steerDelivered) {
|
|
358
|
+
// Steer the break message before the next LLM call. The degenerate bash
|
|
359
|
+
// itself is blocked by the tool_call gate, so the model sees the block
|
|
360
|
+
// reason + the steer together and can still change approach.
|
|
361
|
+
if (ctx.signal !== undefined) {
|
|
362
|
+
deliverForceBreak(ctx, [det]);
|
|
363
|
+
} else if (prevLevel < 2 && config.notifyOnDetection) {
|
|
364
|
+
ctx.ui.notify(`antiloop: force break — ${det.description}`, "error");
|
|
365
|
+
}
|
|
366
|
+
} else if (isVerbatimRepeat([det])) {
|
|
367
|
+
// The model produced the same intra-message repetition AGAIN after the
|
|
368
|
+
// break message: count it; once the ignore limit is hit, cut the run.
|
|
369
|
+
state.ignoredSteerCount++;
|
|
370
|
+
if (state.ignoredSteerCount >= config.ignoredSteerLimit) {
|
|
371
|
+
hardStop(
|
|
372
|
+
ctx,
|
|
373
|
+
`antiloop: abort — repetitive output repeated ${state.ignoredSteerCount}× after the force break — run stopped; provide new instructions`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} else if (level === 3) {
|
|
378
|
+
// abortThreshold configured and reached: stop the run BEFORE the degenerate
|
|
379
|
+
// tool calls can execute (ctx.abort kills the pending tool batch).
|
|
380
|
+
hardStop(ctx, `antiloop: abort — ${det.description} — run stopped; provide new instructions`);
|
|
381
|
+
}
|
|
382
|
+
updateStatus(ctx);
|
|
272
383
|
});
|
|
273
384
|
|
|
274
385
|
pi.on("input", async (event) => {
|
|
@@ -291,9 +402,43 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
291
402
|
return { action: "continue" };
|
|
292
403
|
});
|
|
293
404
|
|
|
405
|
+
/**
|
|
406
|
+
* v1.6 — degenerate bash gate. A meltdown message whose command repeats one
|
|
407
|
+
* word hundreds of times (the 46 KB "noguerol ×5145" SSH-wordlist brute
|
|
408
|
+
* force) must NEVER execute: it is pure context burn at best, and a real
|
|
409
|
+
* brute-force / destructive repetition at worst. message_end already
|
|
410
|
+
* escalated it; here we block the actual call before it runs. The block
|
|
411
|
+
* reason is fed back to the model as the tool error, so the next LLM call
|
|
412
|
+
* sees why the command was refused and can change approach.
|
|
413
|
+
*/
|
|
414
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
415
|
+
if (!config.enabled || !config.blockDegenerateBash) return;
|
|
416
|
+
if (!isToolCallEventType("bash", event)) return;
|
|
417
|
+
const command = event.input.command ?? "";
|
|
418
|
+
const { findDegenerateRepetition, findRepetitiveBlock, degenerateDescription, blockRepeatDescription } = await import("./detect.ts");
|
|
419
|
+
if (config.detectDegenerate) {
|
|
420
|
+
const hit = findDegenerateRepetition(command, config);
|
|
421
|
+
if (hit) {
|
|
422
|
+
return {
|
|
423
|
+
block: true,
|
|
424
|
+
reason: `[antiloop] blocked: ${degenerateDescription({ ...hit, where: "bash command" })} — this is stuck generation, not a real command. Do NOT retry it: stop and take one small, concrete step instead.`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (config.detectBlockRepeats) {
|
|
429
|
+
const blk = findRepetitiveBlock(command, config);
|
|
430
|
+
if (blk) {
|
|
431
|
+
return {
|
|
432
|
+
block: true,
|
|
433
|
+
reason: `[antiloop] blocked: ${blockRepeatDescription({ ...blk, where: "bash command" })} — this is stuck generation, not a real command. Do NOT retry it: stop and take one small, concrete step instead.`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
294
439
|
pi.on("turn_end", async (event, ctx) => {
|
|
295
440
|
if (!config.enabled) return;
|
|
296
|
-
const { detectLoops, detectTaskStreams, isVerbatimRepeat, nextLevel, resultFingerprint } = await import("./detect.ts");
|
|
441
|
+
const { detectLoops, detectTaskStreams, detectionTurnWeight, isVerbatimRepeat, nextLevel, resultFingerprint } = await import("./detect.ts");
|
|
297
442
|
|
|
298
443
|
const last = state.recentMessages[state.recentMessages.length - 1];
|
|
299
444
|
if (!last || last.turnIndex === state.lastDetectedTurnIndex) {
|
|
@@ -337,7 +482,9 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
337
482
|
}
|
|
338
483
|
|
|
339
484
|
// ---- detection: escalate ------------------------------------------
|
|
340
|
-
|
|
485
|
+
// Degenerate turns count degenerateTurnWeight (default 2) points so a
|
|
486
|
+
// single intra-message meltdown already reaches the warning level.
|
|
487
|
+
state.consecutiveDetections += detectionTurnWeight(detections, config);
|
|
341
488
|
state.totalDetections++;
|
|
342
489
|
state.detections.push(...detections);
|
|
343
490
|
if (state.detections.length > config.maxHistoryEntries) {
|
package/src/types.ts
CHANGED
|
@@ -16,6 +16,68 @@ export interface AntiloopConfig {
|
|
|
16
16
|
* its output (even while still similar) gets room to escape on its own.
|
|
17
17
|
*/
|
|
18
18
|
ignoredSteerLimit: number;
|
|
19
|
+
/**
|
|
20
|
+
* Intra-message degenerate repetition (the "noguerol \u00d75145" meltdown class): a model
|
|
21
|
+
* stuck emitting the same token hundreds of times INSIDE one message / tool call.
|
|
22
|
+
* Unlike the other detectors it needs no peer message: one pathological payload is
|
|
23
|
+
* already conclusive. Fires on the first occurrence; each degenerate turn is weighted
|
|
24
|
+
* (degenerateTurnWeight, default 2) so a single meltdown reaches the warning level.
|
|
25
|
+
*/
|
|
26
|
+
detectDegenerate: boolean;
|
|
27
|
+
/** Minimum normalized tokens in a payload before it is scanned (shorter = not conclusive). */
|
|
28
|
+
degenerateMinTokens: number;
|
|
29
|
+
/** Longest run of ONE identical word that flags a payload as degenerate. */
|
|
30
|
+
degenerateMaxRun: number;
|
|
31
|
+
/** Total occurrences of one word (anywhere, interleaved) that flags when combined with share. */
|
|
32
|
+
degenerateMaxFreq: number;
|
|
33
|
+
/** Word frequency share (freq/total) needed together with degenerateMaxFreq. */
|
|
34
|
+
degenerateMaxShare: number;
|
|
35
|
+
/** How many consecutive-detection points ONE degenerate turn adds (2 = warn on first sight). */
|
|
36
|
+
degenerateTurnWeight: number;
|
|
37
|
+
/** Block a bash tool call whose command shows degenerate OR block repetition BEFORE it executes. */
|
|
38
|
+
blockDegenerateBash: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Intra-message BLOCK repetition (v1.7): the "narration loop" class where ONE
|
|
41
|
+
* assistant message keeps replaying the same sentences/phrases (the real S0
|
|
42
|
+
* coding session: ~5 near-verbatim cycles of "Let me start by checking the
|
|
43
|
+
* environment…" inside a SINGLE generation, which every cross-message detector
|
|
44
|
+
* misses because it produced only one message and the degenerate detector only
|
|
45
|
+
* watches for ONE word repeated hundreds of times). A sliding window of
|
|
46
|
+
* blockNgram-word n-grams over the normalized payload is built; when at least
|
|
47
|
+
* blockRepeatShare of those positions recur, one n-gram appears at least
|
|
48
|
+
* blockMinRepeats times and the payload has at least blockMinTokens words, the
|
|
49
|
+
* message is a replay. Fires at message_end (before the message's tools run)
|
|
50
|
+
* with the same strong turn weight as the degenerate detector.
|
|
51
|
+
*/
|
|
52
|
+
detectBlockRepeats: boolean;
|
|
53
|
+
/** Minimum normalized words in a payload before it is scanned for block repetition. */
|
|
54
|
+
blockMinTokens: number;
|
|
55
|
+
/** Word window of the n-grams whose recurrence is measured (default 5). */
|
|
56
|
+
blockNgram: number;
|
|
57
|
+
/** The MOST repeated n-gram must occur at least this many times to flag. */
|
|
58
|
+
blockMinRepeats: number;
|
|
59
|
+
/** Share of n-gram positions that must recur for the payload to count as a replay. */
|
|
60
|
+
blockRepeatShare: number;
|
|
61
|
+
/**
|
|
62
|
+
* No-progress outcome runs (v1.6.1): a model that keeps re-running the SAME
|
|
63
|
+
* experiment with cosmetic mutations (labels/permutations) while the outcome
|
|
64
|
+
* stays the SAME FAILURE — the real NFS session where ~90 near-identical
|
|
65
|
+
* ssh exportfs/mount tests (test A … test QQQ) all failed rc=32. The
|
|
66
|
+
* tool-loop detector can't see it: args mutate every turn so the same call
|
|
67
|
+
* never recurs (labels differ), and results only VETO tool loops today.
|
|
68
|
+
* Signal: >= outcomeMinRepeats PRIOR attempts inside the window whose args
|
|
69
|
+
* are >= outcomeArgSimilarity similar AND whose captured result is the same
|
|
70
|
+
* outcome (>= resultSimilarityThreshold), all after the last real user input.
|
|
71
|
+
*/
|
|
72
|
+
detectOutcomeLoops: boolean;
|
|
73
|
+
outcomeMinRepeats: number;
|
|
74
|
+
outcomeArgSimilarity: number;
|
|
75
|
+
/** Same-FAILURE gate for the outcome detector: minimum similarity between the
|
|
76
|
+
* digit-stripped error signatures of two failing attempts to count as the
|
|
77
|
+
* SAME failure. Looser than the veto threshold on purpose: the signature
|
|
78
|
+
* repeats across attempts of the same wall even when legitimately varying
|
|
79
|
+
* words (mount targets) sit inside it. */
|
|
80
|
+
outcomeSigThreshold: number;
|
|
19
81
|
/**
|
|
20
82
|
* How close tool-call arguments must be (0..1) to count as the SAME call.
|
|
21
83
|
* High by default: long bash commands share scaffolding (env setup, flags,
|
|
@@ -66,7 +128,7 @@ export interface AntiloopConfig {
|
|
|
66
128
|
toggleShortcut: string;
|
|
67
129
|
}
|
|
68
130
|
|
|
69
|
-
export type LoopKind = "text" | "tool" | "thinking" | "structural";
|
|
131
|
+
export type LoopKind = "text" | "tool" | "thinking" | "structural" | "degenerate" | "block" | "outcome";
|
|
70
132
|
|
|
71
133
|
export interface LoopDetection {
|
|
72
134
|
type: LoopKind;
|
|
@@ -94,6 +156,10 @@ export interface TrackedMessage {
|
|
|
94
156
|
thinking?: string;
|
|
95
157
|
toolCalls?: TrackedToolCall[];
|
|
96
158
|
timestamp: number;
|
|
159
|
+
/** Monotonic push sequence (state.turnSeq++), NOT the window index: the
|
|
160
|
+
* recent-messages array is trimmed to detectionWindow+5, so an array-length
|
|
161
|
+
* based index would collide after trimming and make the turn_end dedupe
|
|
162
|
+
* guard skip every later message (antiloop going blind mid-session). */
|
|
97
163
|
turnIndex: number;
|
|
98
164
|
}
|
|
99
165
|
|
|
@@ -116,6 +182,10 @@ export interface AntiloopState {
|
|
|
116
182
|
lastUserMessageTime: number;
|
|
117
183
|
/** turnIndex of the last tracked message detection already ran on. */
|
|
118
184
|
lastDetectedTurnIndex: number;
|
|
185
|
+
/** Monotonic sequence for the next tracked message's turnIndex (see
|
|
186
|
+
* TrackedMessage.turnIndex). Persists across trims; reset on /reset and at
|
|
187
|
+
* session start. */
|
|
188
|
+
turnSeq: number;
|
|
119
189
|
/** True once the force-break user message was steered into the current episode.
|
|
120
190
|
* One steer per episode: repeated steering would spam the conversation. Cleared
|
|
121
191
|
* when the episode decays (currentLevel back to 0) or on real user input. */
|