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 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
@@ -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; only text blocks carry text. */
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);
@@ -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: string[] = [];
152
- if (total >= this.opts.repeatThreshold) {
153
- reasons.push(
154
- `"${toolName}" was called with identical arguments ${total} times in the last ${this.opts.windowSize} tool calls with no change`,
155
- );
156
- }
157
- if (consecutiveFails >= this.opts.failThreshold) {
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:…X:…X") — the pattern that evaded cross-message verbatim
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(/(?<=[.:!?])\s*/)
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
  }
@@ -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>(event: string, handler: (event: E, ctx: C) => unknown): void;
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
- // 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.
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.7",
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": "^1.77.0",
49
+ "@oxlint/plugins": "1.77.0",
50
50
  "@types/node": "^22.0.0",
51
51
  "oxfmt": "^0.62.0",
52
- "oxlint": "^1.77.0",
52
+ "oxlint": "1.77.0",
53
53
  "oxlint-tsgolint": "^7.0.2001",
54
54
  "typescript": "^5.6.0"
55
55
  },
@@ -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 manifest: Manifest;
53
+ let parsed: unknown;
40
54
  try {
41
- manifest = JSON.parse(raw) as Manifest;
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
- return Effect.succeed(manifest);
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
- return ((await res.json()) as { version?: string }).version ?? null;
97
+ const body: unknown = await res.json();
98
+ return hasStringVersion(body) ? body.version : null;
81
99
  },
82
100
  catch: () => {
83
101
  console.warn(