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 +10 -2
- package/extensions/controller.ts +116 -0
- package/extensions/index.ts +45 -47
- package/package.json +2 -2
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 #
|
|
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
|
-
|
|
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
|
+
}
|
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.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
|
|
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 .",
|