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 +19 -10
- package/extensions/controller.ts +116 -0
- package/extensions/detector.ts +44 -0
- package/extensions/index.ts +45 -47
- package/package.json +2 -2
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
|
|
19
|
-
|
|
|
20
|
-
| Same `(tool, args)` repeated
|
|
21
|
-
| Same tool failing consecutively
|
|
22
|
-
| Same assistant text verbatim
|
|
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.
|
|
27
|
-
text
|
|
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 #
|
|
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
|
-
|
|
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
|
+
}
|
package/extensions/detector.ts
CHANGED
|
@@ -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 = {
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
36
|
-
if (
|
|
37
|
-
|
|
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:
|
|
65
|
+
return { block: true, reason: outcome.reason };
|
|
47
66
|
});
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
89
|
+
ctx.ui.notify(controller.status(), "info");
|
|
85
90
|
},
|
|
86
91
|
});
|
|
87
92
|
|
|
88
93
|
function reset(): void {
|
|
89
|
-
|
|
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.
|
|
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
|
|
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 .",
|