pi-anti-doom-loop 0.0.8 → 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 +12 -0
- package/extensions/controller.ts +1 -0
- package/extensions/detector.ts +83 -24
- 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,18 @@
|
|
|
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
|
+
|
|
5
17
|
## [0.0.8] — 2026-08-24
|
|
6
18
|
|
|
7
19
|
### Added
|
package/extensions/controller.ts
CHANGED
|
@@ -122,6 +122,7 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
122
122
|
// Within-message duplicate tool-call spam fires first: it aborts (the
|
|
123
123
|
// calls are already emitted, steering cannot retract them), so it must
|
|
124
124
|
// outrank the steer-able text ladder.
|
|
125
|
+
// SAFETY: tool-call arguments arrive as JSON-safe values matching ToolInput.
|
|
125
126
|
const calls = content
|
|
126
127
|
.filter((c) => c.type === "toolCall")
|
|
127
128
|
.map((c) => ({ toolName: c.name ?? "", input: c.arguments as ToolInput }));
|
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
|
|
|
@@ -456,15 +472,20 @@ export function tokenSimilarity(a: string, b: string): number {
|
|
|
456
472
|
* within a single normalized message, or null.
|
|
457
473
|
*
|
|
458
474
|
* Catches growing doom loops where the model self-concatenates the same
|
|
459
|
-
* sentence ("…X
|
|
475
|
+
* sentence ("…X…X…X") — the pattern that evaded cross-message verbatim
|
|
460
476
|
* detection in production (each message differs, so no streak forms).
|
|
461
477
|
* Short segments (< MIN_REPEAT_CHUNK) are ignored so pasted logs with
|
|
462
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.
|
|
463
484
|
*/
|
|
464
485
|
export function repeatedSegment(normalized: string, threshold: number): string | null {
|
|
465
486
|
const segments = normalized
|
|
466
|
-
.split(/(?<=[
|
|
467
|
-
.map((s) => s.trim().replace(/[
|
|
487
|
+
.split(/(?<=[.!?])\s+/)
|
|
488
|
+
.map((s) => s.trim().replace(/[.!?]+$/, ""))
|
|
468
489
|
.filter((s) => s.length >= MIN_REPEAT_CHUNK);
|
|
469
490
|
const counts = new Map<string, number>();
|
|
470
491
|
for (const seg of segments) {
|
|
@@ -472,9 +493,47 @@ export function repeatedSegment(normalized: string, threshold: number): string |
|
|
|
472
493
|
if (n >= threshold) return seg;
|
|
473
494
|
counts.set(seg, n);
|
|
474
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
|
+
}
|
|
475
526
|
return null;
|
|
476
527
|
}
|
|
477
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
|
+
|
|
478
537
|
// --- self-check (runs under `node extensions/detector.ts`, skipped when loaded by pi) ---
|
|
479
538
|
if (import.meta.main) {
|
|
480
539
|
const opts: LoopOptions = {
|
|
@@ -612,7 +671,7 @@ if (import.meta.main) {
|
|
|
612
671
|
const spamHit = d.checkDuplicateCalls(
|
|
613
672
|
Array.from({ length: 3 }, () => ({
|
|
614
673
|
toolName: "bash",
|
|
615
|
-
input: { command: "true" }
|
|
674
|
+
input: { command: "true" } satisfies ToolInput,
|
|
616
675
|
})),
|
|
617
676
|
);
|
|
618
677
|
assert.ok(spamHit.isOk(), "3 identical calls in one message should fire");
|
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(
|