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/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
- turnIndex: state.recentMessages.length,
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
- state.consecutiveDetections++;
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. */