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 +6 -0
- package/extensions/controller.ts +14 -1
- package/extensions/detector.ts +50 -0
- package/package.json +1 -1
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
|
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,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);
|
package/extensions/detector.ts
CHANGED
|
@@ -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