pi-anti-doom-loop 0.0.2 → 0.0.4

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 CHANGED
@@ -15,18 +15,25 @@ pi install npm:pi-anti-doom-loop
15
15
 
16
16
  ## What it detects
17
17
 
18
- | Signal | Default | Blocked when |
19
- | ------------------------------- | ----------------------- | --------------------------------------------------------------- |
20
- | Same `(tool, args)` repeated | 3× in the last 10 calls | The pattern has repeated `3` times with no change |
21
- | Same tool failing consecutively | 3× | A tool errored `3` times in a row — stop retrying it blindly |
22
- | Same assistant text verbatim | 3× in a row | The model re-emitted identical text `3` times (text-only loops) |
18
+ | Signal | Default | Blocked when |
19
+ | -------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------- |
20
+ | Same `(tool, args)` repeated | 3× in the last 10 calls | The pattern has repeated `3` times with no change |
21
+ | Same tool failing consecutively | 3× | A tool errored `3` times in a row — stop retrying it blindly |
22
+ | Same assistant text verbatim | 3× in a row | The model re-emitted identical text `3` times (text-only loops) |
23
+ | Same sentence inside ONE message | 3× | A sentence repeats `3`+ times within a single message (growing self-concatenation loops) |
24
+ | Near-identical text (rephrasing) | 3× in a row | Consecutive messages share ≥55% tokens — the model is rephrasing the same step |
23
25
 
24
26
  Blocks hand the model an instructive reason ("change your approach, use a
25
27
  different tool, or ask the user"). If the model ignores the block and re-issues
26
- the exact same call, the turn is **aborted** and you are notified. A verbatim
27
- text loop (no tool calls involved) aborts the run immediately with a
28
- notification.
28
+ the exact same call, the turn is **aborted**.
29
29
 
30
+ ### Escalation (text loops): steer → abort → bounded resume
31
+
32
+ The first text-loop detection **steers** the agent mid-run (injects guidance,
33
+ lets it continue). If it persists, the run is **aborted** and **one** fresh-
34
+ resume directive is queued so work continues with a new approach. If it still
35
+ loops after that, the run aborts for real and control returns to you — the
36
+ auto-resume budget is capped so a truly stuck model can't cycle forever.
30
37
  Counters reset on every user prompt, so a task legitimately repeated later in
31
38
  the same session is never a false positive.
32
39
 
@@ -4,6 +4,13 @@
4
4
  * `index.ts` is a thin adapter that wires these methods to pi's event loop;
5
5
  * tests drive this controller directly with plain objects. Same behavior,
6
6
  * no pi dependency (only `better-result` via the detector).
7
+ *
8
+ * Escalation ladder for message loops:
9
+ * detection #1 → steer (inject guidance, let the agent continue)
10
+ * detection #2 → abort + resume (stop the run, queue one fresh directive)
11
+ * detection #3+ → abort for real (hand back to the user)
12
+ * The resume budget is session-scoped: `reset()` (per user prompt) keeps it,
13
+ * a fresh session (new controller) starts over.
7
14
  */
8
15
  import { LoopDetector, readOptions } from "./detector.ts";
9
16
  import type { LoopOptions } from "./detector.ts";
@@ -39,14 +46,20 @@ export interface ToolCallOutcome {
39
46
 
40
47
  export interface TextLoopOutcome {
41
48
  reason: string;
49
+ action: "steer" | "abort";
50
+ /** When aborting: also queue a single fresh-resume directive (bounded). */
51
+ resume: boolean;
42
52
  }
43
53
 
54
+ /** How many auto-resumes per session before we hand control back for real. */
55
+ export const RESUME_BUDGET = 1;
56
+
44
57
  export interface AntiLoopController {
45
58
  /** Returns a block decision for a tool call, or null to let it run. */
46
59
  onToolCall(toolName: string, input: unknown, toolCallId: string): ToolCallOutcome | null;
47
60
  /** Record a finished tool result (blocked calls' results are ignored). */
48
61
  onToolResult(toolName: string, toolCallId: string, isError: boolean): void;
49
- /** Detect verbatim assistant-text loops; returns an abort reason or null. */
62
+ /** Detect assistant-text loops; returns a steer/abort decision or null. */
50
63
  onMessageEnd(role: string, content: unknown): TextLoopOutcome | null;
51
64
  /** Full reset (session start, user prompt, /loopcheck reset). */
52
65
  reset(): void;
@@ -57,6 +70,8 @@ export interface AntiLoopController {
57
70
  export function createController(opts: LoopOptions = readOptions()): AntiLoopController {
58
71
  let detector = new LoopDetector(opts);
59
72
  const blockedIds = new Set<string>();
73
+ let steered = false;
74
+ let resumes = 0;
60
75
 
61
76
  return {
62
77
  onToolCall(toolName, input, toolCallId) {
@@ -85,12 +100,26 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
85
100
  const text = extractText(content);
86
101
  if (!text) return null;
87
102
  const hit = detector.checkText(text);
88
- return hit.isOk() ? { reason: hit.value.reason } : null;
103
+ if (!hit.isOk()) return null;
104
+
105
+ const reason = hit.value.reason;
106
+ if (!steered) {
107
+ steered = true;
108
+ return { reason, action: "steer", resume: false };
109
+ }
110
+ if (resumes < RESUME_BUDGET) {
111
+ resumes++;
112
+ return { reason, action: "abort", resume: true };
113
+ }
114
+ return { reason, action: "abort", resume: false };
89
115
  },
90
116
 
91
117
  reset() {
92
118
  detector = new LoopDetector(opts);
93
119
  blockedIds.clear();
120
+ steered = false;
121
+ // resumes is intentionally NOT reset here: the auto-resume budget is
122
+ // session-scoped so a stuck model cannot cycle steer→abort forever.
94
123
  },
95
124
 
96
125
  status() {
@@ -142,14 +142,34 @@ export class LoopDetector {
142
142
  checkText(text: string): Result<{ reason: string }, undefined> {
143
143
  const norm = normalizeText(text);
144
144
  if (!norm) return Result.err(undefined); // blank text is not a loop signal
145
- this.textStreak = norm === this.lastText ? this.textStreak + 1 : 1;
145
+
146
+ // Within-message self-repetition: the model pastes the same sentence
147
+ // `textRepeatThreshold`+ times inside ONE message (growing loops like
148
+ // "…X:…X:…X"). Liquid.ai's loop definition — a section repeats at least N
149
+ // times. No streak needed: the message itself is the loop.
150
+ const chunk = repeatedSegment(norm, this.opts.textRepeatThreshold);
151
+ if (chunk !== null) {
152
+ return Result.ok({
153
+ reason:
154
+ `Assistant message repeats "${truncate(chunk, 60)}" ${this.opts.textRepeatThreshold}+ times ` +
155
+ `within a single message. You appear to be in a loop.`,
156
+ });
157
+ }
158
+
159
+ // Cross-message streak: consecutive assistant texts that are identical
160
+ // OR near-identical (token-overlap similarity). Catches loops where the
161
+ // model slightly rephrases each turn ("inspect the failing test" →
162
+ // "examine the failing assertion") so exact matching never fires.
163
+ const similar =
164
+ norm === this.lastText ||
165
+ (this.lastText !== null && tokenSimilarity(norm, this.lastText) >= TEXT_SIMILARITY_THRESHOLD);
166
+ this.textStreak = similar ? this.textStreak + 1 : 1;
146
167
  this.lastText = norm;
147
- if (this.textStreak >= this.opts.textRepeatThreshold && !this.textFired) {
148
- this.textFired = true;
168
+ if (this.textStreak >= this.opts.textRepeatThreshold) {
149
169
  return Result.ok({
150
170
  reason:
151
- `Assistant replied with identical text ${this.textStreak} times in a row ("${truncate(norm, 80)}"). ` +
152
- `You appear to be in a loop — this run is aborted.`,
171
+ `Assistant replied with identical or near-identical text ${this.textStreak} times in a row ` +
172
+ `("${truncate(norm, 80)}"). You appear to be in a loop.`,
153
173
  });
154
174
  }
155
175
  return Result.err(undefined);
@@ -192,6 +212,58 @@ export function truncate(text: string, max: number): string {
192
212
  return text.length <= max ? text : `${text.slice(0, max)}…`;
193
213
  }
194
214
 
215
+ /** Minimum length of a segment worth treating as a repeated loop chunk. */
216
+ export const MIN_REPEAT_CHUNK = 16;
217
+
218
+ /** Jaccard similarity threshold for "near-identical" consecutive texts. */
219
+ export const TEXT_SIMILARITY_THRESHOLD = 0.55;
220
+
221
+ /**
222
+ * Token-set Jaccard similarity of two texts (case/whitespace-insensitive).
223
+ * Short tokens (< 3 chars: "a", "me", "to") are ignored to reduce noise.
224
+ * Returns 0..1; 1 = identical token sets.
225
+ */
226
+ export function tokenSimilarity(a: string, b: string): number {
227
+ const tokenize = (s: string) => {
228
+ return new Set(
229
+ normalizeText(s)
230
+ .toLowerCase()
231
+ .split(/\s+/g)
232
+ .filter((w) => w.length >= 3 && /^[a-z0-9_-]+$/.test(w)),
233
+ );
234
+ };
235
+ const as = tokenize(a);
236
+ const bs = tokenize(b);
237
+ if (as.size === 0 || bs.size === 0) return 0;
238
+ let inter = 0;
239
+ for (const t of as) if (bs.has(t)) inter++;
240
+ return inter / (as.size + bs.size - inter);
241
+ }
242
+
243
+ /**
244
+ * Returns the first sentence-ish segment that repeats `threshold` times
245
+ * within a single normalized message, or null.
246
+ *
247
+ * Catches growing doom loops where the model self-concatenates the same
248
+ * sentence ("…X:…X:…X") — the pattern that evaded cross-message verbatim
249
+ * detection in production (each message differs, so no streak forms).
250
+ * Short segments (< MIN_REPEAT_CHUNK) are ignored so pasted logs with
251
+ * repeated one-word lines never false-positive.
252
+ */
253
+ export function repeatedSegment(normalized: string, threshold: number): string | null {
254
+ const segments = normalized
255
+ .split(/(?<=[.:!?])\s*/)
256
+ .map((s) => s.trim().replace(/[.:!?]+$/, ""))
257
+ .filter((s) => s.length >= MIN_REPEAT_CHUNK);
258
+ const counts = new Map<string, number>();
259
+ for (const seg of segments) {
260
+ const n = (counts.get(seg) ?? 0) + 1;
261
+ if (n >= threshold) return seg;
262
+ counts.set(seg, n);
263
+ }
264
+ return null;
265
+ }
266
+
195
267
  // --- self-check (runs under `node extensions/detector.ts`, skipped when loaded by pi) ---
196
268
  if (import.meta.main) {
197
269
  const opts: LoopOptions = {
@@ -250,18 +322,22 @@ if (import.meta.main) {
250
322
  for (let i = 0; i < opts.windowSize; i++) d.record("bash", { command: `cmd ${i}` });
251
323
  assert.ok(d.check("bash", { command: "cmd 0" }).isErr(), "evicted repeats do not count");
252
324
 
253
- // 7. verbatim assistant text loop: fires once at the 3rd identical message
325
+ // 7. verbatim assistant text loop: fires at the 3rd identical message and
326
+ // every message after while the streak holds (controller escalates)
254
327
  d.reset();
255
328
  assert.ok(d.checkText("Now update buildProgram.").isErr(), "1st text passes");
256
329
  assert.ok(d.checkText("Now update buildProgram.").isErr(), "2nd text passes");
257
330
  const textHit = d.checkText("Now update buildProgram.");
258
331
  assert.ok(textHit.isOk(), "3rd identical text should fire");
259
332
  if (textHit.isOk()) {
260
- assert.match(textHit.value.reason, /identical text 3 times/);
261
- assert.match(textHit.value.reason, /aborted/);
333
+ assert.match(textHit.value.reason, /identical or near-identical text 3 times/);
262
334
  }
263
- assert.ok(d.checkText("Now update buildProgram.").isErr(), "fires only once per run");
264
-
335
+ assert.ok(
336
+ d.checkText("Now update buildProgram.").isOk(),
337
+ "4th identical text still fires (escalation)",
338
+ );
339
+ d.reset();
340
+ assert.ok(d.checkText("Now update buildProgram.").isErr(), "reset clears the streak");
265
341
  // 8. whitespace drift does not hide a verbatim loop
266
342
  d.reset();
267
343
  d.checkText("Read the region:");
@@ -9,11 +9,15 @@
9
9
  * in the last `PI_ANTI_LOOP_WINDOW` calls → block with an instructive reason
10
10
  * - the same tool failing `PI_ANTI_LOOP_FAILS` consecutive times (default 3)
11
11
  * → block with a "stop retrying, fix the root cause" reason
12
- * - the model re-emitting the same assistant text verbatim
13
- * `PI_ANTI_LOOP_TEXT_REPEATS` times (default 3) → abort the run
12
+ * - the model repeating text: verbatim, near-identical (token similarity),
13
+ * or a sentence repeated inside ONE message steer first, abort as
14
+ * escalation, then a bounded auto-resume so work continues
14
15
  *
15
- * Blocking hands control back to the model once. If the model re-issues the
16
- * exact same blocked call, the turn is aborted (escalation).
16
+ * Escalation (message loops): detection #1 steers the agent mid-run; #2
17
+ * aborts the turn and queues one fresh-resume directive; #3+ aborts for real
18
+ * and hands control back to the user. Tool-call blocks hand the model an
19
+ * instructive reason (that is the steer); re-issuing the exact same blocked
20
+ * call aborts the turn.
17
21
  *
18
22
  * Counters reset on every user prompt, so a task legitimately repeated later
19
23
  * in the session is never a false positive. Disable with PI_ANTI_LOOP_DISABLE=1.
@@ -43,8 +47,24 @@ export interface PiLike {
43
47
  handler: (args: string, ctx: CommandCtxLite) => Promise<void> | void;
44
48
  },
45
49
  ): void;
50
+ sendMessage?(
51
+ content: { customType?: string; content?: string; display?: boolean },
52
+ options?: { deliverAs?: "steer" | "followUp" | "nextTurn"; triggerTurn?: boolean },
53
+ ): void;
46
54
  }
47
55
 
56
+ /** Injected on the first loop detection — steer the agent back on track. */
57
+ const STEER_TEXT =
58
+ "Anti-doom-loop steering: you are repeating the same action or text without making progress. " +
59
+ "Stop. Re-read the actual error output, pick ONE different action, and execute it. " +
60
+ "If you are stuck, ask the user instead of retrying.";
61
+
62
+ /** Queued once after an abort so the work can continue with a fresh approach. */
63
+ const RESUME_TEXT =
64
+ "Anti-doom-loop: the previous run was aborted because it looped. " +
65
+ "Start over with a genuinely different approach: do not repeat the previous investigation steps. " +
66
+ "Re-read the task, choose one new action, execute it, then report results.";
67
+
48
68
  export default function (pi: PiLike): void {
49
69
  if (process.env.PI_ANTI_LOOP_DISABLE === "1") return;
50
70
 
@@ -69,13 +89,30 @@ export default function (pi: PiLike): void {
69
89
  controller.onToolResult(event.toolName, event.toolCallId, event.isError === true);
70
90
  });
71
91
 
72
- // Text-only doom loops (model re-emits the same sentence with no tool calls)
73
- // never reach tool_call. Detect verbatim assistant repeats and abort the run.
92
+ // Text-only doom loops (model re-emits/rephrases the same thing with no
93
+ // tool calls) never reach tool_call. Steer first, abort as escalation,
94
+ // then a bounded auto-resume so the work continues.
74
95
  pi.on("message_end", (event: MessageEndEventLite, ctx: CtxLite) => {
75
96
  const outcome = controller.onMessageEnd(event.message.role, event.message.content);
76
97
  if (outcome === null) return;
98
+
99
+ if (outcome.action === "steer") {
100
+ ctx.ui.notify(`Anti-doom-loop: ${outcome.reason}`, "warning");
101
+ pi.sendMessage?.(
102
+ { customType: "anti-doom-loop", content: STEER_TEXT, display: true },
103
+ { deliverAs: "steer", triggerTurn: true },
104
+ );
105
+ return;
106
+ }
107
+
77
108
  ctx.ui.notify(`Anti-doom-loop: ${outcome.reason}`, "error");
78
109
  ctx.abort();
110
+ if (outcome.resume) {
111
+ pi.sendMessage?.(
112
+ { customType: "anti-doom-loop", content: RESUME_TEXT, display: true },
113
+ { deliverAs: "followUp", triggerTurn: true },
114
+ );
115
+ }
79
116
  });
80
117
 
81
118
  pi.registerCommand("loopcheck", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anti-doom-loop",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Detect and break agent doom loops in pi: blocks identical repeated tool calls and blind retries before they burn tokens.",
5
5
  "keywords": [
6
6
  "anti-doom-loop",