pi-anti-doom-loop 0.0.7 → 0.0.9
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 +18 -0
- package/extensions/controller.ts +15 -1
- package/extensions/detector.ts +132 -23
- package/extensions/index.ts +13 -3
- package/package.json +3 -3
- package/scripts/guard-publish.ts +22 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to **pi-anti-doom-loop**.
|
|
4
4
|
|
|
5
|
+
## [0.0.9] — 2026-09-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Full anti-slop rule set** — synced the vendored Oxlint plugin with upstream: 5 missing generic rules (`no-module-mocking`, `no-reflect-apply`, `no-reflect-get`, `no-unknown-returns`, `require-safety-comment-for-type-assertion`), the opt-in Effect plugin (`no-service-constructor-imports`, enabled — this repo declares `effect`), and the supporting shared helpers. All 15 generic rules plus the Effect rule run at `error`.
|
|
10
|
+
- **Complexity gate** — `eslint/complexity` with `{ max: 10 }`; anything in the 11+ "refactor now" band fails the build.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Within-message false positive on status lists** — `repeatedSegment` no longer splits on `:` (status updates like "worker dispatched: a. worker dispatched: b. worker dispatched: c." stay whole and distinct). Separator-less concatenation (`S:S:S:`) and truncated repeats are still caught by the new `tandemPrefix` check.
|
|
15
|
+
- **Complexity + slop findings in owned code** — extracted `collectBlockReasons` (`check` CC 11 → 6); replaced `as` casts at I/O boundaries with `SAFETY:` invariants, real `isManifest` / `hasStringVersion` guards, a named `PiHandlerResult` contract, and assertion-free tests.
|
|
16
|
+
|
|
17
|
+
## [0.0.8] — 2026-08-24
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
5
23
|
## [0.0.7] — 2026-08-13
|
|
6
24
|
|
|
7
25
|
### Changed
|
package/extensions/controller.ts
CHANGED
|
@@ -37,10 +37,13 @@ export interface CommandCtxLite {
|
|
|
37
37
|
ui: { notify(message: string, level: string): void };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** One content block of an assistant message;
|
|
40
|
+
/** One content block of an assistant message; text blocks carry text, tool
|
|
41
|
+
* calls carry name + arguments (pi's AgentMessage block shape). */
|
|
41
42
|
export interface MessageContentBlock {
|
|
42
43
|
readonly type: string;
|
|
43
44
|
readonly text?: string;
|
|
45
|
+
readonly name?: string;
|
|
46
|
+
readonly arguments?: unknown;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** The list of content blocks of an assistant message. */
|
|
@@ -115,6 +118,17 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
115
118
|
onMessageEnd(role, content) {
|
|
116
119
|
if (suspended) return null;
|
|
117
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
|
+
// SAFETY: tool-call arguments arrive as JSON-safe values matching ToolInput.
|
|
126
|
+
const calls = content
|
|
127
|
+
.filter((c) => c.type === "toolCall")
|
|
128
|
+
.map((c) => ({ toolName: c.name ?? "", input: c.arguments as ToolInput }));
|
|
129
|
+
const batch = detector.checkDuplicateCalls(calls);
|
|
130
|
+
if (batch.isOk()) return { reason: batch.value.reason, action: "abort", resume: false };
|
|
131
|
+
|
|
118
132
|
const text = extractText(content);
|
|
119
133
|
if (!text) return null;
|
|
120
134
|
const hit = detector.checkText(text);
|
package/extensions/detector.ts
CHANGED
|
@@ -113,6 +113,35 @@ export interface BlockDecision {
|
|
|
113
113
|
escalate: boolean;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** Block reasons for one tool call: identical repeats, failure streak, failure rate. */
|
|
117
|
+
function collectBlockReasons(
|
|
118
|
+
toolName: string,
|
|
119
|
+
total: number,
|
|
120
|
+
consecutiveFails: number,
|
|
121
|
+
rate: { calls: number; errors: number; rate: number },
|
|
122
|
+
opts: LoopOptions,
|
|
123
|
+
): string[] {
|
|
124
|
+
const reasons: string[] = [];
|
|
125
|
+
if (total >= opts.repeatThreshold) {
|
|
126
|
+
reasons.push(
|
|
127
|
+
`"${toolName}" was called with identical arguments ${total} times in the last ${opts.windowSize} tool calls with no change`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (consecutiveFails >= opts.failThreshold) {
|
|
131
|
+
reasons.push(`"${toolName}" failed ${consecutiveFails} consecutive times`);
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
opts.failRateThreshold > 0 &&
|
|
135
|
+
rate.calls >= opts.failRateMinCalls &&
|
|
136
|
+
rate.rate >= opts.failRateThreshold
|
|
137
|
+
) {
|
|
138
|
+
reasons.push(
|
|
139
|
+
`"${toolName}" failed ${rate.errors} of ${rate.calls} calls in the window (${Math.round(rate.rate * 100)}%)`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return reasons;
|
|
143
|
+
}
|
|
144
|
+
|
|
116
145
|
/**
|
|
117
146
|
* Call `check` in `tool_call` (before executing). If it returns a decision,
|
|
118
147
|
* block the call. Call `record` only for calls that were NOT blocked, and
|
|
@@ -141,31 +170,18 @@ export class LoopDetector {
|
|
|
141
170
|
const sig = signature(toolName, input);
|
|
142
171
|
const repeats = this.recentSigs.filter((s) => s.sig === sig).length;
|
|
143
172
|
const total = repeats + 1; // including this call
|
|
144
|
-
const consecutiveFails = this.consecutiveFails(toolName);
|
|
145
|
-
const rate = this.failRate(toolName);
|
|
146
173
|
|
|
147
174
|
// Rough cost accounting (feature B): every redundant repeat of an already
|
|
148
175
|
// present signature burns tokens with no new information.
|
|
149
176
|
if (repeats >= 1) this.wastedTokens += estimateTokens(stringify(input));
|
|
150
177
|
|
|
151
|
-
const reasons
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
reasons.push(`"${toolName}" failed ${consecutiveFails} consecutive times`);
|
|
159
|
-
}
|
|
160
|
-
if (
|
|
161
|
-
this.opts.failRateThreshold > 0 &&
|
|
162
|
-
rate.calls >= this.opts.failRateMinCalls &&
|
|
163
|
-
rate.rate >= this.opts.failRateThreshold
|
|
164
|
-
) {
|
|
165
|
-
reasons.push(
|
|
166
|
-
`"${toolName}" failed ${rate.errors} of ${rate.calls} calls in the window (${Math.round(rate.rate * 100)}%)`,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
178
|
+
const reasons = collectBlockReasons(
|
|
179
|
+
toolName,
|
|
180
|
+
total,
|
|
181
|
+
this.consecutiveFails(toolName),
|
|
182
|
+
this.failRate(toolName),
|
|
183
|
+
this.opts,
|
|
184
|
+
);
|
|
169
185
|
|
|
170
186
|
if (reasons.length === 0) return Result.err(undefined);
|
|
171
187
|
|
|
@@ -274,6 +290,36 @@ export class LoopDetector {
|
|
|
274
290
|
return Result.err(undefined);
|
|
275
291
|
}
|
|
276
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Duplicate identical (tool, args) calls batched inside ONE assistant
|
|
295
|
+
* message (parallel tool-call spam). Degenerate models sometimes emit a
|
|
296
|
+
* single response containing hundreds of the same no-op call; per-call
|
|
297
|
+
* detection never sees it as a streak because every call arrives at once.
|
|
298
|
+
* Fires at textRepeatThreshold duplicates of any one signature. The calls
|
|
299
|
+
* are already emitted when this runs, so the controller aborts instead of
|
|
300
|
+
* steering — a steer cannot retract them.
|
|
301
|
+
*/
|
|
302
|
+
checkDuplicateCalls(
|
|
303
|
+
entries: { toolName: string; input: ToolInput }[],
|
|
304
|
+
): Result<{ reason: string }, undefined> {
|
|
305
|
+
const counts = new Map<string, { n: number; name: string; input: ToolInput }>();
|
|
306
|
+
for (const e of entries) {
|
|
307
|
+
if (this.opts.toolExclude.has(e.toolName)) continue;
|
|
308
|
+
const sig = signature(e.toolName, e.input);
|
|
309
|
+
const cur = counts.get(sig) ?? { n: 0, name: e.toolName, input: e.input };
|
|
310
|
+
cur.n++;
|
|
311
|
+
counts.set(sig, cur);
|
|
312
|
+
if (cur.n >= this.opts.textRepeatThreshold) {
|
|
313
|
+
return Result.ok({
|
|
314
|
+
reason:
|
|
315
|
+
`Assistant message contains ${cur.n} identical "${e.toolName}" calls ` +
|
|
316
|
+
`("${truncate(stringify(e.input), 60)}"). You appear to be in a loop.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return Result.err(undefined);
|
|
321
|
+
}
|
|
322
|
+
|
|
277
323
|
/** Trail of results that are errors of this same tool (consecutive). */
|
|
278
324
|
private consecutiveFails(toolName: string): number {
|
|
279
325
|
let n = 0;
|
|
@@ -426,15 +472,20 @@ export function tokenSimilarity(a: string, b: string): number {
|
|
|
426
472
|
* within a single normalized message, or null.
|
|
427
473
|
*
|
|
428
474
|
* Catches growing doom loops where the model self-concatenates the same
|
|
429
|
-
* sentence ("…X
|
|
475
|
+
* sentence ("…X…X…X") — the pattern that evaded cross-message verbatim
|
|
430
476
|
* detection in production (each message differs, so no streak forms).
|
|
431
477
|
* Short segments (< MIN_REPEAT_CHUNK) are ignored so pasted logs with
|
|
432
478
|
* repeated one-word lines never false-positive.
|
|
479
|
+
*
|
|
480
|
+
* ':' is deliberately NOT a sentence boundary: status lists like
|
|
481
|
+
* "worker dispatched: a. worker dispatched: b. worker dispatched: c."
|
|
482
|
+
* must stay whole so distinct sentences never count as repeats.
|
|
483
|
+
* Separator-less concatenation ("S:S:S:") is caught by tandemPrefix instead.
|
|
433
484
|
*/
|
|
434
485
|
export function repeatedSegment(normalized: string, threshold: number): string | null {
|
|
435
486
|
const segments = normalized
|
|
436
|
-
.split(/(?<=[
|
|
437
|
-
.map((s) => s.trim().replace(/[
|
|
487
|
+
.split(/(?<=[.!?])\s+/)
|
|
488
|
+
.map((s) => s.trim().replace(/[.!?]+$/, ""))
|
|
438
489
|
.filter((s) => s.length >= MIN_REPEAT_CHUNK);
|
|
439
490
|
const counts = new Map<string, number>();
|
|
440
491
|
for (const seg of segments) {
|
|
@@ -442,9 +493,47 @@ export function repeatedSegment(normalized: string, threshold: number): string |
|
|
|
442
493
|
if (n >= threshold) return seg;
|
|
443
494
|
counts.set(seg, n);
|
|
444
495
|
}
|
|
496
|
+
return tandemPrefix(normalized, threshold);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Messages longer than this are pasted logs, not loop utterances — skip. */
|
|
500
|
+
const TANDEM_MAX_LEN = 2000;
|
|
501
|
+
/** Longest repeated block worth scanning (real loop sentences are < 200 chars). */
|
|
502
|
+
const TANDEM_MAX_CHUNK = 500;
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Whole-message consecutive repetition anchored at the start ("S:S:S:",
|
|
506
|
+
* "S: S: S:", regex fragments "X X X"). Returns the stripped block or null.
|
|
507
|
+
* Skips long inputs (pasted logs) and pure-separator blocks.
|
|
508
|
+
*/
|
|
509
|
+
export function tandemPrefix(normalized: string, threshold: number): string | null {
|
|
510
|
+
if (normalized.length < MIN_REPEAT_CHUNK * threshold) return null;
|
|
511
|
+
if (normalized.length > TANDEM_MAX_LEN) return null;
|
|
512
|
+
const padded = normalized.endsWith(" ") ? normalized : `${normalized} `;
|
|
513
|
+
const maxL = Math.min(TANDEM_MAX_CHUNK, Math.floor((padded.length - 1) / (threshold - 1)));
|
|
514
|
+
for (let len = MIN_REPEAT_CHUNK; len <= maxL; len++) {
|
|
515
|
+
const first = stripBlock(padded.slice(0, len));
|
|
516
|
+
if (first.length < MIN_REPEAT_CHUNK) continue;
|
|
517
|
+
let ok = true;
|
|
518
|
+
for (let k = 1; k < threshold; k++) {
|
|
519
|
+
if (stripBlock(padded.slice(k * len, (k + 1) * len)) !== first) {
|
|
520
|
+
ok = false;
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (ok) return first;
|
|
525
|
+
}
|
|
445
526
|
return null;
|
|
446
527
|
}
|
|
447
528
|
|
|
529
|
+
/** Compare blocks ignoring trailing separators so "X:" and "X" unify. */
|
|
530
|
+
function stripBlock(block: string): string {
|
|
531
|
+
return block
|
|
532
|
+
.trim()
|
|
533
|
+
.replace(/[.:!?\s]+$/, "")
|
|
534
|
+
.trim();
|
|
535
|
+
}
|
|
536
|
+
|
|
448
537
|
// --- self-check (runs under `node extensions/detector.ts`, skipped when loaded by pi) ---
|
|
449
538
|
if (import.meta.main) {
|
|
450
539
|
const opts: LoopOptions = {
|
|
@@ -577,5 +666,25 @@ if (import.meta.main) {
|
|
|
577
666
|
const two = readOptions({ PI_ANTI_LOOP_REPEATS: "2" });
|
|
578
667
|
assert.equal(two.repeatThreshold, 2, "2 is the minimum accepted");
|
|
579
668
|
|
|
669
|
+
// 13. within-one-message duplicate tool-call spam (degenerate parallel batch)
|
|
670
|
+
d.reset();
|
|
671
|
+
const spamHit = d.checkDuplicateCalls(
|
|
672
|
+
Array.from({ length: 3 }, () => ({
|
|
673
|
+
toolName: "bash",
|
|
674
|
+
input: { command: "true" } satisfies ToolInput,
|
|
675
|
+
})),
|
|
676
|
+
);
|
|
677
|
+
assert.ok(spamHit.isOk(), "3 identical calls in one message should fire");
|
|
678
|
+
if (spamHit.isOk()) assert.match(spamHit.value.reason, /identical "bash" calls/);
|
|
679
|
+
assert.ok(
|
|
680
|
+
d
|
|
681
|
+
.checkDuplicateCalls([
|
|
682
|
+
{ toolName: "read", input: { path: "a.ts" } },
|
|
683
|
+
{ toolName: "read", input: { path: "b.ts" } },
|
|
684
|
+
])
|
|
685
|
+
.isErr(),
|
|
686
|
+
"distinct parallel args are not spam",
|
|
687
|
+
);
|
|
688
|
+
|
|
580
689
|
console.log("detector self-check: all assertions passed");
|
|
581
690
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -38,9 +38,18 @@ import {
|
|
|
38
38
|
} from "./controller.ts";
|
|
39
39
|
import { readOptions, type ToolInput } from "./detector.ts";
|
|
40
40
|
|
|
41
|
+
/** What an event handler hands back to pi: a tool-call block decision, or nothing.
|
|
42
|
+
* Mirrors pi's own `ExtensionHandler<E, R>` contract (`Promise<R | void> | R | void`)
|
|
43
|
+
* narrowed to this extension's result; handlers stay synchronous here.
|
|
44
|
+
*/
|
|
45
|
+
export type PiHandlerResult = { block: true; reason: string } | undefined;
|
|
46
|
+
|
|
41
47
|
/** The subset of pi's ExtensionAPI this extension uses (structural). */
|
|
42
48
|
export interface PiLike {
|
|
43
|
-
on<E = unknown, C = unknown>(
|
|
49
|
+
on<E = unknown, C = unknown>(
|
|
50
|
+
event: string,
|
|
51
|
+
handler: (event: E, ctx: C) => PiHandlerResult | void,
|
|
52
|
+
): void;
|
|
44
53
|
registerCommand(
|
|
45
54
|
name: string,
|
|
46
55
|
opts: {
|
|
@@ -78,8 +87,8 @@ export default function (pi: PiLike): void {
|
|
|
78
87
|
pi.on("before_agent_start", () => controller.reset());
|
|
79
88
|
|
|
80
89
|
pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
|
|
81
|
-
//
|
|
82
|
-
// ToolInput domain type at this I/O boundary before the controller sees them.
|
|
90
|
+
// SAFETY: pi delivers JSON-safe tool arguments, which is exactly the ToolInput domain.
|
|
91
|
+
// Decode them into the ToolInput domain type at this I/O boundary before the controller sees them.
|
|
83
92
|
const outcome = controller.onToolCall(
|
|
84
93
|
event.toolName,
|
|
85
94
|
event.input as ToolInput,
|
|
@@ -101,6 +110,7 @@ export default function (pi: PiLike): void {
|
|
|
101
110
|
// tool calls) never reach tool_call. Steer first, abort as escalation,
|
|
102
111
|
// then a bounded auto-resume so the work continues.
|
|
103
112
|
pi.on("message_end", (event: MessageEndEventLite, ctx: CtxLite) => {
|
|
113
|
+
// SAFETY: pi delivers message content as JSON-safe blocks matching MessageContent.
|
|
104
114
|
// Decode the untyped message content into MessageContent at this boundary.
|
|
105
115
|
const outcome = controller.onMessageEnd(
|
|
106
116
|
event.message.role,
|
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.9",
|
|
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",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
48
48
|
"@effect/tsgo": "^0.36.4",
|
|
49
|
-
"@oxlint/plugins": "
|
|
49
|
+
"@oxlint/plugins": "1.77.0",
|
|
50
50
|
"@types/node": "^22.0.0",
|
|
51
51
|
"oxfmt": "^0.62.0",
|
|
52
|
-
"oxlint": "
|
|
52
|
+
"oxlint": "1.77.0",
|
|
53
53
|
"oxlint-tsgolint": "^7.0.2001",
|
|
54
54
|
"typescript": "^5.6.0"
|
|
55
55
|
},
|
package/scripts/guard-publish.ts
CHANGED
|
@@ -35,14 +35,31 @@ const readManifest: Effect.Effect<string, GuardError> = Effect.tryPromise({
|
|
|
35
35
|
catch: () => ({ message: "GUARD FAIL: could not read package.json" }),
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
/** True when an unknown JSON value has the manifest shape this guard needs. */
|
|
39
|
+
const isManifest = (value: unknown): value is Manifest => {
|
|
40
|
+
if (typeof value !== "object" || value === null) return false;
|
|
41
|
+
if (!("name" in value) || !("version" in value)) return false;
|
|
42
|
+
return typeof value.name === "string" && typeof value.version === "string";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** True when an unknown JSON value carries a string version field. */
|
|
46
|
+
const hasStringVersion = (value: unknown): value is { version: string } => {
|
|
47
|
+
if (typeof value !== "object" || value === null) return false;
|
|
48
|
+
if (!("version" in value)) return false;
|
|
49
|
+
return typeof value.version === "string";
|
|
50
|
+
};
|
|
51
|
+
|
|
38
52
|
const parseManifest = (raw: string): Effect.Effect<Manifest, GuardError> => {
|
|
39
|
-
let
|
|
53
|
+
let parsed: unknown;
|
|
40
54
|
try {
|
|
41
|
-
|
|
55
|
+
parsed = JSON.parse(raw);
|
|
42
56
|
} catch {
|
|
43
57
|
return Effect.fail({ message: "GUARD FAIL: package.json is not valid JSON" });
|
|
44
58
|
}
|
|
45
|
-
|
|
59
|
+
if (!isManifest(parsed)) {
|
|
60
|
+
return Effect.fail({ message: "GUARD FAIL: package.json has no string name and version" });
|
|
61
|
+
}
|
|
62
|
+
return Effect.succeed(parsed);
|
|
46
63
|
};
|
|
47
64
|
const semverLike = (value: string): boolean => /^\d+\.\d+\.\d+$/.test(value);
|
|
48
65
|
|
|
@@ -77,7 +94,8 @@ const fetchPublished = (name: string): Effect.Effect<string | null> =>
|
|
|
77
94
|
headers: { accept: "application/json" },
|
|
78
95
|
});
|
|
79
96
|
if (!res.ok) return null;
|
|
80
|
-
|
|
97
|
+
const body: unknown = await res.json();
|
|
98
|
+
return hasStringVersion(body) ? body.version : null;
|
|
81
99
|
},
|
|
82
100
|
catch: () => {
|
|
83
101
|
console.warn(
|