pi-anti-doom-loop 0.0.7 → 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,6 +2,12 @@
2
2
 
3
3
  All notable changes to **pi-anti-doom-loop**.
4
4
 
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
+
5
11
  ## [0.0.7] — 2026-08-13
6
12
 
7
13
  ### 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,16 @@ 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
+ 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
+
118
131
  const text = extractText(content);
119
132
  if (!text) return null;
120
133
  const hit = detector.checkText(text);
@@ -274,6 +274,36 @@ export class LoopDetector {
274
274
  return Result.err(undefined);
275
275
  }
276
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
+
277
307
  /** Trail of results that are errors of this same tool (consecutive). */
278
308
  private consecutiveFails(toolName: string): number {
279
309
  let n = 0;
@@ -577,5 +607,25 @@ if (import.meta.main) {
577
607
  const two = readOptions({ PI_ANTI_LOOP_REPEATS: "2" });
578
608
  assert.equal(two.repeatThreshold, 2, "2 is the minimum accepted");
579
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
+
580
630
  console.log("detector self-check: all assertions passed");
581
631
  }
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.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",