pi-anti-doom-loop 0.0.6 → 0.0.8
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/CHANGELOG.md +17 -1
- package/extensions/controller.ts +27 -12
- package/extensions/detector.ts +78 -19
- package/extensions/index.ts +14 -3
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to **pi-anti-doom-loop**.
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [0.0.8] — 2026-08-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Within-message tool-call spam detection** — `onMessageEnd` now inspects the assistant message's `toolCall` blocks and aborts immediately when `textRepeatThreshold` identical `(tool, args)` calls are batched in ONE message (`LoopDetector.checkDuplicateCalls`). Catches degenerate parallel batches — e.g. a single response emitting 1405 identical `bash "true"` calls (observed in production) — which per-call detection never sees as a streak because every call arrives at once, and which can be aborted before any call executes. Respects `PI_ANTI_LOOP_TOOLS_EXCLUDE`; aborts rather than steers since steering cannot retract emitted calls.
|
|
10
|
+
|
|
11
|
+
## [0.0.7] — 2026-08-13
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **Effect 4.0 RC** — upgraded `effect` from `^4.0.0-beta.103` to `^4.0.0-rc.108`.
|
|
16
|
+
- **Anti-slop lint hardening** — vendored an opinionated Oxlint rule set (`tools/oxlint/anti-slop/`) and enabled it in `oxlint.config.ts`; resolved all 23 findings by introducing a JSON-safe `ToolInput` domain type decoded at the pi boundary, replacing `unknown` parameters, `typeof` checks, `Record<string, unknown>`, and value widening.
|
|
17
|
+
|
|
18
|
+
## [0.0.6] — 2026-08-12
|
|
6
19
|
|
|
7
20
|
### Added
|
|
8
21
|
|
|
@@ -97,7 +110,10 @@ All notable changes to **pi-anti-doom-loop**.
|
|
|
97
110
|
- GitHub Actions release workflow: quality gate → version bump guard → dry-run → publish, triggered by `v*` tags.
|
|
98
111
|
- `pi-package` keyword + `pi` manifest for the pi.dev gallery.
|
|
99
112
|
|
|
113
|
+
[0.0.7]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.6...v0.0.7
|
|
114
|
+
[0.0.6]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.5...v0.0.6
|
|
100
115
|
[0.0.5]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.4...v0.0.5
|
|
101
116
|
[0.0.4]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.3...v0.0.4
|
|
117
|
+
[0.0.3]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.2...v0.0.3
|
|
102
118
|
[0.0.2]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.1...v0.0.2
|
|
103
119
|
[0.0.1]: https://github.com/irfndi/pi-anti-doom-loop/releases/tag/v0.0.1
|
package/extensions/controller.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* a fresh session (new controller) starts over.
|
|
14
14
|
*/
|
|
15
15
|
import { LoopDetector, readOptions } from "./detector.ts";
|
|
16
|
-
import type { LoopOptions } from "./detector.ts";
|
|
16
|
+
import type { LoopOptions, ToolInput } from "./detector.ts";
|
|
17
17
|
|
|
18
18
|
/** Minimal shapes of the pi events the controller consumes (structural). */
|
|
19
19
|
export interface ToolCallEventLite {
|
|
@@ -37,6 +37,18 @@ export interface CommandCtxLite {
|
|
|
37
37
|
ui: { notify(message: string, level: string): void };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** One content block of an assistant message; text blocks carry text, tool
|
|
41
|
+
* calls carry name + arguments (pi's AgentMessage block shape). */
|
|
42
|
+
export interface MessageContentBlock {
|
|
43
|
+
readonly type: string;
|
|
44
|
+
readonly text?: string;
|
|
45
|
+
readonly name?: string;
|
|
46
|
+
readonly arguments?: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The list of content blocks of an assistant message. */
|
|
50
|
+
export type MessageContent = readonly MessageContentBlock[];
|
|
51
|
+
|
|
40
52
|
export interface ToolCallOutcome {
|
|
41
53
|
block: true;
|
|
42
54
|
reason: string;
|
|
@@ -56,11 +68,11 @@ export const RESUME_BUDGET = 1;
|
|
|
56
68
|
|
|
57
69
|
export interface AntiLoopController {
|
|
58
70
|
/** Returns a block decision for a tool call, or null to let it run. */
|
|
59
|
-
onToolCall(toolName: string, input:
|
|
71
|
+
onToolCall(toolName: string, input: ToolInput, toolCallId: string): ToolCallOutcome | null;
|
|
60
72
|
/** Record a finished tool result (blocked calls' results are ignored). */
|
|
61
73
|
onToolResult(toolName: string, toolCallId: string, isError: boolean): void;
|
|
62
74
|
/** Detect assistant-text loops; returns a steer/abort decision or null. */
|
|
63
|
-
onMessageEnd(role: string, content:
|
|
75
|
+
onMessageEnd(role: string, content: MessageContent): TextLoopOutcome | null;
|
|
64
76
|
/** Full reset (session start, user prompt, /loopcheck reset). */
|
|
65
77
|
reset(): void;
|
|
66
78
|
/** Suspend detection until the next reset (escape hatch for intentional repetition). */
|
|
@@ -106,6 +118,16 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
106
118
|
onMessageEnd(role, content) {
|
|
107
119
|
if (suspended) return null;
|
|
108
120
|
if (role !== "assistant") return null;
|
|
121
|
+
|
|
122
|
+
// Within-message duplicate tool-call spam fires first: it aborts (the
|
|
123
|
+
// calls are already emitted, steering cannot retract them), so it must
|
|
124
|
+
// outrank the steer-able text ladder.
|
|
125
|
+
const calls = content
|
|
126
|
+
.filter((c) => c.type === "toolCall")
|
|
127
|
+
.map((c) => ({ toolName: c.name ?? "", input: c.arguments as ToolInput }));
|
|
128
|
+
const batch = detector.checkDuplicateCalls(calls);
|
|
129
|
+
if (batch.isOk()) return { reason: batch.value.reason, action: "abort", resume: false };
|
|
130
|
+
|
|
109
131
|
const text = extractText(content);
|
|
110
132
|
if (!text) return null;
|
|
111
133
|
const hit = detector.checkText(text);
|
|
@@ -164,13 +186,6 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
164
186
|
}
|
|
165
187
|
|
|
166
188
|
/** Join the text content blocks of an assistant message. */
|
|
167
|
-
export function extractText(content:
|
|
168
|
-
|
|
169
|
-
return content
|
|
170
|
-
.map((c) =>
|
|
171
|
-
typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string"
|
|
172
|
-
? c.text
|
|
173
|
-
: "",
|
|
174
|
-
)
|
|
175
|
-
.join(" ");
|
|
189
|
+
export function extractText(content: MessageContent): string {
|
|
190
|
+
return content.map((c) => (c.type === "text" ? (c.text ?? "") : "")).join(" ");
|
|
176
191
|
}
|
package/extensions/detector.ts
CHANGED
|
@@ -77,18 +77,32 @@ export function readOptions(env: Record<string, string | undefined> = process.en
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* A JSON-safe tool-call argument value. Tool inputs arrive from the pi event
|
|
82
|
+
* loop as untyped data; they are decoded into this domain type at the I/O
|
|
83
|
+
* boundary (see index.ts) before the detector fingerprints them.
|
|
84
|
+
*/
|
|
85
|
+
export type ToolInput =
|
|
86
|
+
| null
|
|
87
|
+
| boolean
|
|
88
|
+
| number
|
|
89
|
+
| string
|
|
90
|
+
| ToolInput[]
|
|
91
|
+
| { readonly [key: string]: ToolInput };
|
|
92
|
+
|
|
80
93
|
/** Keys sorted recursively so {a:1,b:2} and {b:2,a:1} share a signature. */
|
|
81
|
-
export function canonical(input:
|
|
82
|
-
if (input === null || typeof input !== "object") return JSON.stringify(input);
|
|
94
|
+
export function canonical(input: ToolInput): string {
|
|
83
95
|
if (Array.isArray(input)) return `[${input.map(canonical).join(",")}]`;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
if (input instanceof Object) {
|
|
97
|
+
return `{${Object.keys(input)
|
|
98
|
+
.sort()
|
|
99
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(input[k])}`)
|
|
100
|
+
.join(",")}}`;
|
|
101
|
+
}
|
|
102
|
+
return JSON.stringify(input) ?? "";
|
|
89
103
|
}
|
|
90
104
|
|
|
91
|
-
export function signature(toolName: string, input:
|
|
105
|
+
export function signature(toolName: string, input: ToolInput): string {
|
|
92
106
|
return `${toolName}:${canonical(input)}`;
|
|
93
107
|
}
|
|
94
108
|
|
|
@@ -118,7 +132,7 @@ export class LoopDetector {
|
|
|
118
132
|
this.opts = opts;
|
|
119
133
|
}
|
|
120
134
|
|
|
121
|
-
check(toolName: string, input:
|
|
135
|
+
check(toolName: string, input: ToolInput): Result<BlockDecision, undefined> {
|
|
122
136
|
if (this.opts.toolExclude.has(toolName)) {
|
|
123
137
|
// record() is a no-op for excluded tools, so nothing enters the window.
|
|
124
138
|
return Result.err(undefined);
|
|
@@ -168,7 +182,7 @@ export class LoopDetector {
|
|
|
168
182
|
});
|
|
169
183
|
}
|
|
170
184
|
|
|
171
|
-
record(toolName: string, input:
|
|
185
|
+
record(toolName: string, input: ToolInput): void {
|
|
172
186
|
if (this.opts.toolExclude.has(toolName)) return;
|
|
173
187
|
this.recentSigs.push({ sig: signature(toolName, input), ts: Date.now() });
|
|
174
188
|
this.evictSigs();
|
|
@@ -260,6 +274,36 @@ export class LoopDetector {
|
|
|
260
274
|
return Result.err(undefined);
|
|
261
275
|
}
|
|
262
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Duplicate identical (tool, args) calls batched inside ONE assistant
|
|
279
|
+
* message (parallel tool-call spam). Degenerate models sometimes emit a
|
|
280
|
+
* single response containing hundreds of the same no-op call; per-call
|
|
281
|
+
* detection never sees it as a streak because every call arrives at once.
|
|
282
|
+
* Fires at textRepeatThreshold duplicates of any one signature. The calls
|
|
283
|
+
* are already emitted when this runs, so the controller aborts instead of
|
|
284
|
+
* steering — a steer cannot retract them.
|
|
285
|
+
*/
|
|
286
|
+
checkDuplicateCalls(
|
|
287
|
+
entries: { toolName: string; input: ToolInput }[],
|
|
288
|
+
): Result<{ reason: string }, undefined> {
|
|
289
|
+
const counts = new Map<string, { n: number; name: string; input: ToolInput }>();
|
|
290
|
+
for (const e of entries) {
|
|
291
|
+
if (this.opts.toolExclude.has(e.toolName)) continue;
|
|
292
|
+
const sig = signature(e.toolName, e.input);
|
|
293
|
+
const cur = counts.get(sig) ?? { n: 0, name: e.toolName, input: e.input };
|
|
294
|
+
cur.n++;
|
|
295
|
+
counts.set(sig, cur);
|
|
296
|
+
if (cur.n >= this.opts.textRepeatThreshold) {
|
|
297
|
+
return Result.ok({
|
|
298
|
+
reason:
|
|
299
|
+
`Assistant message contains ${cur.n} identical "${e.toolName}" calls ` +
|
|
300
|
+
`("${truncate(stringify(e.input), 60)}"). You appear to be in a loop.`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return Result.err(undefined);
|
|
305
|
+
}
|
|
306
|
+
|
|
263
307
|
/** Trail of results that are errors of this same tool (consecutive). */
|
|
264
308
|
private consecutiveFails(toolName: string): number {
|
|
265
309
|
let n = 0;
|
|
@@ -272,7 +316,7 @@ export class LoopDetector {
|
|
|
272
316
|
}
|
|
273
317
|
|
|
274
318
|
/** Error share of all in-window results for a tool. */
|
|
275
|
-
private failRate(toolName: string)
|
|
319
|
+
private failRate(toolName: string) {
|
|
276
320
|
let calls = 0;
|
|
277
321
|
let errors = 0;
|
|
278
322
|
for (const r of this.recentResults) {
|
|
@@ -370,14 +414,9 @@ export const MIN_REPEAT_CHUNK = 16;
|
|
|
370
414
|
/** Jaccard similarity threshold for "near-identical" consecutive texts. */
|
|
371
415
|
export const TEXT_SIMILARITY_THRESHOLD = 0.55;
|
|
372
416
|
|
|
373
|
-
/** Stable string form of
|
|
374
|
-
export function stringify(input:
|
|
375
|
-
|
|
376
|
-
try {
|
|
377
|
-
return JSON.stringify(input);
|
|
378
|
-
} catch {
|
|
379
|
-
return String(input);
|
|
380
|
-
}
|
|
417
|
+
/** Stable string form of a tool input (used for token estimation). */
|
|
418
|
+
export function stringify(input: ToolInput): string {
|
|
419
|
+
return JSON.stringify(input) ?? "";
|
|
381
420
|
}
|
|
382
421
|
|
|
383
422
|
/**
|
|
@@ -568,5 +607,25 @@ if (import.meta.main) {
|
|
|
568
607
|
const two = readOptions({ PI_ANTI_LOOP_REPEATS: "2" });
|
|
569
608
|
assert.equal(two.repeatThreshold, 2, "2 is the minimum accepted");
|
|
570
609
|
|
|
610
|
+
// 13. within-one-message duplicate tool-call spam (degenerate parallel batch)
|
|
611
|
+
d.reset();
|
|
612
|
+
const spamHit = d.checkDuplicateCalls(
|
|
613
|
+
Array.from({ length: 3 }, () => ({
|
|
614
|
+
toolName: "bash",
|
|
615
|
+
input: { command: "true" } as ToolInput,
|
|
616
|
+
})),
|
|
617
|
+
);
|
|
618
|
+
assert.ok(spamHit.isOk(), "3 identical calls in one message should fire");
|
|
619
|
+
if (spamHit.isOk()) assert.match(spamHit.value.reason, /identical "bash" calls/);
|
|
620
|
+
assert.ok(
|
|
621
|
+
d
|
|
622
|
+
.checkDuplicateCalls([
|
|
623
|
+
{ toolName: "read", input: { path: "a.ts" } },
|
|
624
|
+
{ toolName: "read", input: { path: "b.ts" } },
|
|
625
|
+
])
|
|
626
|
+
.isErr(),
|
|
627
|
+
"distinct parallel args are not spam",
|
|
628
|
+
);
|
|
629
|
+
|
|
571
630
|
console.log("detector self-check: all assertions passed");
|
|
572
631
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -31,11 +31,12 @@ import {
|
|
|
31
31
|
type AntiLoopController,
|
|
32
32
|
type CommandCtxLite,
|
|
33
33
|
type CtxLite,
|
|
34
|
+
type MessageContent,
|
|
34
35
|
type MessageEndEventLite,
|
|
35
36
|
type ToolCallEventLite,
|
|
36
37
|
type ToolResultEventLite,
|
|
37
38
|
} from "./controller.ts";
|
|
38
|
-
import { readOptions } from "./detector.ts";
|
|
39
|
+
import { readOptions, type ToolInput } from "./detector.ts";
|
|
39
40
|
|
|
40
41
|
/** The subset of pi's ExtensionAPI this extension uses (structural). */
|
|
41
42
|
export interface PiLike {
|
|
@@ -77,7 +78,13 @@ export default function (pi: PiLike): void {
|
|
|
77
78
|
pi.on("before_agent_start", () => controller.reset());
|
|
78
79
|
|
|
79
80
|
pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
|
|
80
|
-
|
|
81
|
+
// The pi event delivers untyped tool arguments; decode them into the
|
|
82
|
+
// ToolInput domain type at this I/O boundary before the controller sees them.
|
|
83
|
+
const outcome = controller.onToolCall(
|
|
84
|
+
event.toolName,
|
|
85
|
+
event.input as ToolInput,
|
|
86
|
+
event.toolCallId,
|
|
87
|
+
);
|
|
81
88
|
if (outcome === null) return;
|
|
82
89
|
if (outcome.escalate) {
|
|
83
90
|
ctx.ui.notify("Anti-doom-loop: identical call blocked again — aborting turn", "error");
|
|
@@ -94,7 +101,11 @@ export default function (pi: PiLike): void {
|
|
|
94
101
|
// tool calls) never reach tool_call. Steer first, abort as escalation,
|
|
95
102
|
// then a bounded auto-resume so the work continues.
|
|
96
103
|
pi.on("message_end", (event: MessageEndEventLite, ctx: CtxLite) => {
|
|
97
|
-
|
|
104
|
+
// Decode the untyped message content into MessageContent at this boundary.
|
|
105
|
+
const outcome = controller.onMessageEnd(
|
|
106
|
+
event.message.role,
|
|
107
|
+
event.message.content as MessageContent,
|
|
108
|
+
);
|
|
98
109
|
if (outcome === null) return;
|
|
99
110
|
|
|
100
111
|
if (outcome.action === "steer") {
|
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.8",
|
|
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",
|
|
@@ -41,11 +41,12 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"better-result": "^3.0.0",
|
|
44
|
-
"effect": "^4.0.0-
|
|
44
|
+
"effect": "^4.0.0-rc.108"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
48
48
|
"@effect/tsgo": "^0.36.4",
|
|
49
|
+
"@oxlint/plugins": "^1.77.0",
|
|
49
50
|
"@types/node": "^22.0.0",
|
|
50
51
|
"oxfmt": "^0.62.0",
|
|
51
52
|
"oxlint": "^1.77.0",
|