pi-anti-doom-loop 0.0.1 → 0.0.3

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,17 +15,18 @@ 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) |
23
24
 
24
25
  Blocks hand the model an instructive reason ("change your approach, use a
25
26
  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.
27
+ the exact same call, the turn is **aborted** and you are notified. Verbatim
28
+ text loops and within-message self-repetition (no tool calls involved) abort
29
+ the run immediately with a notification.
29
30
 
30
31
  Counters reset on every user prompt, so a task legitimately repeated later in
31
32
  the same session is never a false positive.
@@ -60,11 +61,19 @@ Requires Node 22.6+ (plain `node` runs the TS self-check).
60
61
 
61
62
  ```bash
62
63
  npm install
63
- npm test # detector self-check (pure Node, no deps)
64
+ npm test # node --test: unit + fixture + fuzz + integration + e2e
64
65
  npm run check # npm test + tsc + oxlint --deny-warnings + oxfmt
65
66
  ```
66
67
 
67
- Runtime deps: `better-result` (detector decisions) and `effect` v4 (the release guard).
68
+ ### Test suite (Node built-in runner, no framework)
69
+
70
+ | Suite | File | What it proves |
71
+ | ----------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
72
+ | unit | `tests/unit.test.ts` | detector semantics: repeat/failure/text signals, window eviction, options clamping, helpers |
73
+ | fixture | `tests/fixtures.ts` + `tests/fixture.test.ts` | real doom-loop transcripts (CI-log loops, verbatim repeats) are caught; healthy sessions are not |
74
+ | fuzz | `tests/fuzz.test.ts` | seeded random streams: never throws, no false positives, injected loops always block, canonical stability |
75
+ | integration | `tests/integration.test.ts` | controller + `index.ts` adapter driven through a fake `PiLike`: blocks, escalations, aborts, resets, `/loopcheck` |
76
+ | e2e | `tests/e2e.test.ts` | real subprocesses: detector self-check, version guard, tarball contents (extensions/scripts ship, tests don't) |
68
77
 
69
78
  > `peerDependencies` pins `@earendil-works/pi-coding-agent` at `"*"` on purpose — the
70
79
  > [pi packages docs](https://pi.dev/docs/latest/packages) require an unbounded range for
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Controller — the extension's event logic as a pure, pi-free module.
3
+ *
4
+ * `index.ts` is a thin adapter that wires these methods to pi's event loop;
5
+ * tests drive this controller directly with plain objects. Same behavior,
6
+ * no pi dependency (only `better-result` via the detector).
7
+ */
8
+ import { LoopDetector, readOptions } from "./detector.ts";
9
+ import type { LoopOptions } from "./detector.ts";
10
+
11
+ /** Minimal shapes of the pi events the controller consumes (structural). */
12
+ export interface ToolCallEventLite {
13
+ toolName: string;
14
+ toolCallId: string;
15
+ input: unknown;
16
+ }
17
+ export interface ToolResultEventLite {
18
+ toolName: string;
19
+ toolCallId: string;
20
+ isError: boolean;
21
+ }
22
+ export interface MessageEndEventLite {
23
+ message: { role: string; content?: unknown };
24
+ }
25
+ export interface CtxLite {
26
+ ui: { notify(message: string, level: string): void };
27
+ abort(): void;
28
+ }
29
+ export interface CommandCtxLite {
30
+ ui: { notify(message: string, level: string): void };
31
+ }
32
+
33
+ export interface ToolCallOutcome {
34
+ block: true;
35
+ reason: string;
36
+ /** True when this exact call was blocked before — caller should abort the turn. */
37
+ escalate: boolean;
38
+ }
39
+
40
+ export interface TextLoopOutcome {
41
+ reason: string;
42
+ }
43
+
44
+ export interface AntiLoopController {
45
+ /** Returns a block decision for a tool call, or null to let it run. */
46
+ onToolCall(toolName: string, input: unknown, toolCallId: string): ToolCallOutcome | null;
47
+ /** Record a finished tool result (blocked calls' results are ignored). */
48
+ onToolResult(toolName: string, toolCallId: string, isError: boolean): void;
49
+ /** Detect verbatim assistant-text loops; returns an abort reason or null. */
50
+ onMessageEnd(role: string, content: unknown): TextLoopOutcome | null;
51
+ /** Full reset (session start, user prompt, /loopcheck reset). */
52
+ reset(): void;
53
+ /** Human-readable status with thresholds + counters for /loopcheck. */
54
+ status(): string;
55
+ }
56
+
57
+ export function createController(opts: LoopOptions = readOptions()): AntiLoopController {
58
+ let detector = new LoopDetector(opts);
59
+ const blockedIds = new Set<string>();
60
+
61
+ return {
62
+ onToolCall(toolName, input, toolCallId) {
63
+ const decision = detector.check(toolName, input);
64
+ if (decision.isErr()) {
65
+ detector.record(toolName, input);
66
+ return null;
67
+ }
68
+ blockedIds.add(toolCallId);
69
+ const block = decision.value;
70
+ return { block: true, reason: block.reason, escalate: block.escalate };
71
+ },
72
+
73
+ onToolResult(toolName, toolCallId, isError) {
74
+ // Blocked calls never ran, so their (error) result must not count as a
75
+ // consecutive failure.
76
+ if (blockedIds.has(toolCallId)) {
77
+ blockedIds.delete(toolCallId);
78
+ return;
79
+ }
80
+ detector.recordResult(toolName, isError);
81
+ },
82
+
83
+ onMessageEnd(role, content) {
84
+ if (role !== "assistant") return null;
85
+ const text = extractText(content);
86
+ if (!text) return null;
87
+ const hit = detector.checkText(text);
88
+ return hit.isOk() ? { reason: hit.value.reason } : null;
89
+ },
90
+
91
+ reset() {
92
+ detector = new LoopDetector(opts);
93
+ blockedIds.clear();
94
+ },
95
+
96
+ status() {
97
+ const o = detector.opts;
98
+ return (
99
+ `anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, ` +
100
+ `fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}. ${detector.summary()}`
101
+ );
102
+ },
103
+ };
104
+ }
105
+
106
+ /** Join the text content blocks of an assistant message. */
107
+ export function extractText(content: unknown): string {
108
+ if (!Array.isArray(content)) return "";
109
+ return content
110
+ .map((c) =>
111
+ typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string"
112
+ ? c.text
113
+ : "",
114
+ )
115
+ .join(" ");
116
+ }
@@ -142,6 +142,23 @@ 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
+
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
+ if (!this.textFired) {
151
+ const chunk = repeatedSegment(norm, this.opts.textRepeatThreshold);
152
+ if (chunk !== null) {
153
+ this.textFired = true;
154
+ return Result.ok({
155
+ reason:
156
+ `Assistant message repeats "${truncate(chunk, 60)}" ${this.opts.textRepeatThreshold}+ times ` +
157
+ `within a single message. You appear to be in a loop — this run is aborted.`,
158
+ });
159
+ }
160
+ }
161
+
145
162
  this.textStreak = norm === this.lastText ? this.textStreak + 1 : 1;
146
163
  this.lastText = norm;
147
164
  if (this.textStreak >= this.opts.textRepeatThreshold && !this.textFired) {
@@ -192,6 +209,33 @@ export function truncate(text: string, max: number): string {
192
209
  return text.length <= max ? text : `${text.slice(0, max)}…`;
193
210
  }
194
211
 
212
+ /** Minimum length of a segment worth treating as a repeated loop chunk. */
213
+ export const MIN_REPEAT_CHUNK = 16;
214
+
215
+ /**
216
+ * Returns the first sentence-ish segment that repeats `threshold` times
217
+ * within a single normalized message, or null.
218
+ *
219
+ * Catches growing doom loops where the model self-concatenates the same
220
+ * sentence ("…X:…X:…X") — the pattern that evaded cross-message verbatim
221
+ * detection in production (each message differs, so no streak forms).
222
+ * Short segments (< MIN_REPEAT_CHUNK) are ignored so pasted logs with
223
+ * repeated one-word lines never false-positive.
224
+ */
225
+ export function repeatedSegment(normalized: string, threshold: number): string | null {
226
+ const segments = normalized
227
+ .split(/(?<=[.:!?])\s*/)
228
+ .map((s) => s.trim().replace(/[.:!?]+$/, ""))
229
+ .filter((s) => s.length >= MIN_REPEAT_CHUNK);
230
+ const counts = new Map<string, number>();
231
+ for (const seg of segments) {
232
+ const n = (counts.get(seg) ?? 0) + 1;
233
+ if (n >= threshold) return seg;
234
+ counts.set(seg, n);
235
+ }
236
+ return null;
237
+ }
238
+
195
239
  // --- self-check (runs under `node extensions/detector.ts`, skipped when loaded by pi) ---
196
240
  if (import.meta.main) {
197
241
  const opts: LoopOptions = {
@@ -9,90 +9,88 @@
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
14
  *
13
15
  * Blocking hands control back to the model once. If the model re-issues the
14
16
  * exact same blocked call, the turn is aborted (escalation).
15
17
  *
16
18
  * Counters reset on every user prompt, so a task legitimately repeated later
17
19
  * in the session is never a false positive. Disable with PI_ANTI_LOOP_DISABLE=1.
20
+ *
21
+ * All logic lives in `controller.ts` (pure, pi-free, unit-tested); this file
22
+ * is a thin adapter wiring it to pi's event loop. The pi API is consumed
23
+ * structurally so the wiring stays testable and import-light.
18
24
  */
19
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
- import { LoopDetector, readOptions } from "./detector";
25
+ import {
26
+ createController,
27
+ type AntiLoopController,
28
+ type CommandCtxLite,
29
+ type CtxLite,
30
+ type MessageEndEventLite,
31
+ type ToolCallEventLite,
32
+ type ToolResultEventLite,
33
+ } from "./controller.ts";
34
+ import { readOptions } from "./detector.ts";
21
35
 
22
- export default function (pi: ExtensionAPI): void {
36
+ /** The subset of pi's ExtensionAPI this extension uses (structural). */
37
+ export interface PiLike {
38
+ on<E = unknown, C = unknown>(event: string, handler: (event: E, ctx: C) => unknown): void;
39
+ registerCommand(
40
+ name: string,
41
+ opts: {
42
+ description?: string;
43
+ handler: (args: string, ctx: CommandCtxLite) => Promise<void> | void;
44
+ },
45
+ ): void;
46
+ }
47
+
48
+ export default function (pi: PiLike): void {
23
49
  if (process.env.PI_ANTI_LOOP_DISABLE === "1") return;
24
50
 
25
- let detector = new LoopDetector(readOptions());
26
- let configTxt = settingsTxt(detector);
27
- const blockedIds = new Set<string>();
51
+ let controller: AntiLoopController = createController(readOptions());
28
52
 
29
53
  pi.on("session_start", () => reset());
30
54
 
31
55
  // Fresh counters per user prompt: only the loop happening *right now* counts.
32
56
  pi.on("before_agent_start", () => reset());
33
57
 
34
- pi.on("tool_call", (event, ctx) => {
35
- const decision = detector.check(event.toolName, event.input);
36
- if (decision.isErr()) {
37
- detector.record(event.toolName, event.input);
38
- return;
39
- }
40
- const block = decision.value;
41
- blockedIds.add(event.toolCallId);
42
- if (block.escalate) {
58
+ pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
59
+ const outcome = controller.onToolCall(event.toolName, event.input, event.toolCallId);
60
+ if (outcome === null) return;
61
+ if (outcome.escalate) {
43
62
  ctx.ui.notify("Anti-doom-loop: identical call blocked again — aborting turn", "error");
44
63
  ctx.abort();
45
64
  }
46
- return { block: true, reason: block.reason };
65
+ return { block: true, reason: outcome.reason };
47
66
  });
48
67
 
49
- // Blocked calls never ran, so their (error) result must not count as a failure.
50
- pi.on("tool_result", (event) => {
51
- if (blockedIds.has(event.toolCallId)) {
52
- blockedIds.delete(event.toolCallId);
53
- return;
54
- }
55
- detector.recordResult(event.toolName, event.isError === true);
68
+ pi.on("tool_result", (event: ToolResultEventLite) => {
69
+ controller.onToolResult(event.toolName, event.toolCallId, event.isError === true);
56
70
  });
57
71
 
58
72
  // Text-only doom loops (model re-emits the same sentence with no tool calls)
59
73
  // never reach tool_call. Detect verbatim assistant repeats and abort the run.
60
- pi.on("message_end", (event, ctx) => {
61
- const message = event.message;
62
- if (message.role !== "assistant") return;
63
- const content = Array.isArray(message.content) ? message.content : [];
64
- const text = content
65
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
66
- .map((c) => c.text)
67
- .join(" ");
68
- if (!text) return;
69
- const hit = detector.checkText(text);
70
- if (hit.isOk()) {
71
- ctx.ui.notify(`Anti-doom-loop: ${hit.value.reason}`, "error");
72
- ctx.abort();
73
- }
74
+ pi.on("message_end", (event: MessageEndEventLite, ctx: CtxLite) => {
75
+ const outcome = controller.onMessageEnd(event.message.role, event.message.content);
76
+ if (outcome === null) return;
77
+ ctx.ui.notify(`Anti-doom-loop: ${outcome.reason}`, "error");
78
+ ctx.abort();
74
79
  });
75
80
 
76
81
  pi.registerCommand("loopcheck", {
77
82
  description: "Anti-doom-loop status; `/loopcheck reset` clears counters",
78
- handler: async (args, ctx) => {
83
+ handler: async (args: string, ctx: CommandCtxLite) => {
79
84
  if (args.trim().toLowerCase() === "reset") {
80
85
  reset();
81
86
  ctx.ui.notify("Anti-doom-loop: counters reset", "info");
82
87
  return;
83
88
  }
84
- ctx.ui.notify(`${configTxt}. ${detector.summary()}`, "info");
89
+ ctx.ui.notify(controller.status(), "info");
85
90
  },
86
91
  });
87
92
 
88
93
  function reset(): void {
89
- detector = new LoopDetector(readOptions());
90
- configTxt = settingsTxt(detector);
91
- blockedIds.clear();
94
+ controller = createController(readOptions());
92
95
  }
93
96
  }
94
-
95
- function settingsTxt(d: LoopDetector): string {
96
- const o = d.opts;
97
- return `anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, fails>=${o.failThreshold}`;
98
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anti-doom-loop",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
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",
@@ -31,7 +31,7 @@
31
31
  "scripts": {
32
32
  "check": "npm run test && tsc --noEmit && oxlint --deny-warnings && oxfmt --check .",
33
33
  "prepublishOnly": "npm run check",
34
- "test": "node extensions/detector.ts",
34
+ "test": "node --test tests/*.test.ts",
35
35
  "guard": "node scripts/guard-publish.ts",
36
36
  "lint": "oxlint --deny-warnings",
37
37
  "format": "oxfmt .",