pi-anti-doom-loop 0.0.1 → 0.0.2

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
@@ -60,11 +60,19 @@ Requires Node 22.6+ (plain `node` runs the TS self-check).
60
60
 
61
61
  ```bash
62
62
  npm install
63
- npm test # detector self-check (pure Node, no deps)
63
+ npm test # node --test: unit + fixture + fuzz + integration + e2e
64
64
  npm run check # npm test + tsc + oxlint --deny-warnings + oxfmt
65
65
  ```
66
66
 
67
- Runtime deps: `better-result` (detector decisions) and `effect` v4 (the release guard).
67
+ ### Test suite (Node built-in runner, no framework)
68
+
69
+ | Suite | File | What it proves |
70
+ | ----------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
71
+ | unit | `tests/unit.test.ts` | detector semantics: repeat/failure/text signals, window eviction, options clamping, helpers |
72
+ | fixture | `tests/fixtures.ts` + `tests/fixture.test.ts` | real doom-loop transcripts (CI-log loops, verbatim repeats) are caught; healthy sessions are not |
73
+ | fuzz | `tests/fuzz.test.ts` | seeded random streams: never throws, no false positives, injected loops always block, canonical stability |
74
+ | integration | `tests/integration.test.ts` | controller + `index.ts` adapter driven through a fake `PiLike`: blocks, escalations, aborts, resets, `/loopcheck` |
75
+ | e2e | `tests/e2e.test.ts` | real subprocesses: detector self-check, version guard, tarball contents (extensions/scripts ship, tests don't) |
68
76
 
69
77
  > `peerDependencies` pins `@earendil-works/pi-coding-agent` at `"*"` on purpose — the
70
78
  > [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
+ }
@@ -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.2",
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 .",