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 CHANGED
@@ -2,7 +2,20 @@
2
2
 
3
3
  All notable changes to **pi-anti-doom-loop**.
4
4
 
5
- ## [Unreleased]
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
@@ -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: unknown, toolCallId: string): ToolCallOutcome | null;
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: unknown): TextLoopOutcome | null;
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: unknown): string {
168
- if (!Array.isArray(content)) return "";
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
  }
@@ -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: unknown): string {
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
- const obj = input as Record<string, unknown>;
85
- return `{${Object.keys(obj)
86
- .sort()
87
- .map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`)
88
- .join(",")}}`;
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: unknown): string {
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: unknown): Result<BlockDecision, undefined> {
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: unknown): void {
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): { calls: number; errors: number; rate: number } {
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 an arbitrary tool input (used for token estimation). */
374
- export function stringify(input: unknown): string {
375
- if (typeof input === "string") return input;
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
  }
@@ -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
- const outcome = controller.onToolCall(event.toolName, event.input, event.toolCallId);
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
- const outcome = controller.onMessageEnd(event.message.role, event.message.content);
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.6",
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-beta.103"
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",