pi-anti-doom-loop 0.0.9 → 0.0.10
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 +14 -0
- package/extensions/detector.ts +93 -3
- package/extensions/index.ts +22 -7
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to **pi-anti-doom-loop**.
|
|
4
4
|
|
|
5
|
+
## [0.0.10] — 2026-09-23
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Counters reset per user prompt, not per run** — the extension reset on `before_agent_start`, which omp emits on _every_ run start: auto-continue turns (`role:"developer"`), steers/resumes, advisor cards, and queued async-result drains all restart the run, wiping counters (including block-escalation state) between the iterations of exactly the cross-run loops this extension exists to catch. Reported identical `ssh`-polling loops ran 7+ times unblocked while intra-run repeats fired correctly — replaying the incident session on detector-visible input reproduces its 9 exact blocks, with the only discrepancies landing right after post-abort run restarts, where the per-run reset had cleared the window. Reset now keys off `message_end` with `role:"user"` (emitted with the run's input messages on omp and pi alike), matching the documented per-user-prompt contract; steers and auto-continues keep counting, so the steer→abort escalation ladder survives run restarts too.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Near-identical tool-call detection** — when no exact signature repeats, `check()` counts same-tool window entries whose canonical args share ≥ 55% whitespace tokens (token union ≥ 5): sleep-wrapped polls (`gh run list…` → `sleep 150; gh run list…`), flag/grep-pattern/tail drift. Block reason prefix: _"…" was called with near-identical arguments N times…_. Short inputs stay exact-only via the union guard; exact repeats still block first with the exact reason.
|
|
14
|
+
|
|
15
|
+
### Notes
|
|
16
|
+
|
|
17
|
+
- omp strips the harness intent label (`i`) at parse (`extractIntent`), before extension events — this detector never sees it; no handling was added. pi has no intent field at all.
|
|
18
|
+
|
|
5
19
|
## [0.0.9] — 2026-09-07
|
|
6
20
|
|
|
7
21
|
### Added
|
package/extensions/detector.ts
CHANGED
|
@@ -102,6 +102,12 @@ export function canonical(input: ToolInput): string {
|
|
|
102
102
|
return JSON.stringify(input) ?? "";
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Callers pass detector-visible input: hosts strip harness-internal fields
|
|
107
|
+
* (omp removes the intent label `i` at parse via `extractIntent`) before the
|
|
108
|
+
* `tool_call` event, so identity is the executed args exactly as the detector
|
|
109
|
+
* receives them.
|
|
110
|
+
*/
|
|
105
111
|
export function signature(toolName: string, input: ToolInput): string {
|
|
106
112
|
return `${toolName}:${canonical(input)}`;
|
|
107
113
|
}
|
|
@@ -149,7 +155,7 @@ function collectBlockReasons(
|
|
|
149
155
|
*/
|
|
150
156
|
export class LoopDetector {
|
|
151
157
|
readonly opts: LoopOptions;
|
|
152
|
-
private recentSigs: { sig: string; ts: number }[] = [];
|
|
158
|
+
private recentSigs: { sig: string; tool: string; args: string; ts: number }[] = [];
|
|
153
159
|
private recentResults: { tool: string; error: boolean; ts: number }[] = [];
|
|
154
160
|
private blockedBySig = new Map<string, number>();
|
|
155
161
|
private recentTexts: { text: string; ts: number }[] = [];
|
|
@@ -167,7 +173,8 @@ export class LoopDetector {
|
|
|
167
173
|
return Result.err(undefined);
|
|
168
174
|
}
|
|
169
175
|
this.evictSigs();
|
|
170
|
-
const
|
|
176
|
+
const args = canonical(input);
|
|
177
|
+
const sig = `${toolName}:${args}`;
|
|
171
178
|
const repeats = this.recentSigs.filter((s) => s.sig === sig).length;
|
|
172
179
|
const total = repeats + 1; // including this call
|
|
173
180
|
|
|
@@ -183,6 +190,21 @@ export class LoopDetector {
|
|
|
183
190
|
this.opts,
|
|
184
191
|
);
|
|
185
192
|
|
|
193
|
+
// Exact-signature miss: a loop that varies cosmetic fields (the `i` intent
|
|
194
|
+
// label, tail flags, timeouts) never repeats a signature. Production data:
|
|
195
|
+
// 7 of 9 commands repeated ≥3× in a live session had fully distinct args
|
|
196
|
+
// with an identical `command`. Same tool + ≥55% shared arg tokens still
|
|
197
|
+
// counts as a repeat.
|
|
198
|
+
if (reasons.length === 0) {
|
|
199
|
+
const near = this.nearRepeats(toolName, args);
|
|
200
|
+
if (near >= 1) this.wastedTokens += estimateTokens(stringify(input));
|
|
201
|
+
if (near + 1 >= this.opts.repeatThreshold) {
|
|
202
|
+
reasons.push(
|
|
203
|
+
`"${toolName}" was called with near-identical arguments ${near + 1} times in the last ${this.opts.windowSize} tool calls (only minor changes)`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
186
208
|
if (reasons.length === 0) return Result.err(undefined);
|
|
187
209
|
|
|
188
210
|
const blockedCount = (this.blockedBySig.get(sig) ?? 0) + 1;
|
|
@@ -200,10 +222,22 @@ export class LoopDetector {
|
|
|
200
222
|
|
|
201
223
|
record(toolName: string, input: ToolInput): void {
|
|
202
224
|
if (this.opts.toolExclude.has(toolName)) return;
|
|
203
|
-
|
|
225
|
+
const args = canonical(input);
|
|
226
|
+
this.recentSigs.push({ sig: `${toolName}:${args}`, tool: toolName, args, ts: Date.now() });
|
|
204
227
|
this.evictSigs();
|
|
205
228
|
}
|
|
206
229
|
|
|
230
|
+
/** Windowed same-tool entries whose args are ≥55% shared tokens (identical counts). */
|
|
231
|
+
private nearRepeats(toolName: string, args: string): number {
|
|
232
|
+
let n = 0;
|
|
233
|
+
for (const s of this.recentSigs) {
|
|
234
|
+
if (s.tool !== toolName) continue;
|
|
235
|
+
const sim = argSimilarity(args, s.args);
|
|
236
|
+
if (sim !== null && sim >= NEAR_ARGS_SIMILARITY_THRESHOLD) n++;
|
|
237
|
+
}
|
|
238
|
+
return n;
|
|
239
|
+
}
|
|
240
|
+
|
|
207
241
|
recordResult(toolName: string, error: boolean): void {
|
|
208
242
|
this.recentResults.push({ tool: toolName, error, ts: Date.now() });
|
|
209
243
|
this.evictResults();
|
|
@@ -430,6 +464,38 @@ export const MIN_REPEAT_CHUNK = 16;
|
|
|
430
464
|
/** Jaccard similarity threshold for "near-identical" consecutive texts. */
|
|
431
465
|
export const TEXT_SIMILARITY_THRESHOLD = 0.55;
|
|
432
466
|
|
|
467
|
+
/** Shared-whitespace-token threshold for near-identical tool-call arguments. */
|
|
468
|
+
export const NEAR_ARGS_SIMILARITY_THRESHOLD = 0.55;
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Minimum token union before two arg strings are comparable. Short inputs
|
|
472
|
+
* ({"i":"…","op":"view"}) would score 1.0 on one shared token; they rely on
|
|
473
|
+
* exact matching only. Long inputs — shell commands — always clear this.
|
|
474
|
+
*/
|
|
475
|
+
export const NEAR_ARGS_MIN_UNION = 5;
|
|
476
|
+
// ponytail: 0.55 misses ONE shape — short (~11-token) commands that change
|
|
477
|
+
// BOTH ends at once (added wrapper + changed tail glue → ~0.53); single-axis
|
|
478
|
+
// drift and longer commands clear. Upgrade path if that shape shows up:
|
|
479
|
+
// tokenize on punctuation boundaries instead of whitespace.
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Case-insensitive whitespace-token Jaccard over canonical args.
|
|
483
|
+
* Full tokens (no word filtering): a changed field glues into its own token,
|
|
484
|
+
* so only genuinely shared structure counts — same command with a rephrased
|
|
485
|
+
* `i` label scores ~0.8, two different commands score far below the threshold.
|
|
486
|
+
* Returns null when the union is too small to judge.
|
|
487
|
+
*/
|
|
488
|
+
export function argSimilarity(a: string, b: string): number | null {
|
|
489
|
+
const as = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
|
|
490
|
+
const bs = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
|
|
491
|
+
if (as.size === 0 || bs.size === 0) return null;
|
|
492
|
+
let inter = 0;
|
|
493
|
+
for (const t of as) if (bs.has(t)) inter++;
|
|
494
|
+
const union = as.size + bs.size - inter;
|
|
495
|
+
if (union < NEAR_ARGS_MIN_UNION) return null;
|
|
496
|
+
return inter / union;
|
|
497
|
+
}
|
|
498
|
+
|
|
433
499
|
/** Stable string form of a tool input (used for token estimation). */
|
|
434
500
|
export function stringify(input: ToolInput): string {
|
|
435
501
|
return JSON.stringify(input) ?? "";
|
|
@@ -686,5 +752,29 @@ if (import.meta.main) {
|
|
|
686
752
|
"distinct parallel args are not spam",
|
|
687
753
|
);
|
|
688
754
|
|
|
755
|
+
// 14. command-tail/wrapper drift: same poll wrapped or slightly reworded —
|
|
756
|
+
// near-identical token overlap catches what exact identity cannot.
|
|
757
|
+
d.reset();
|
|
758
|
+
const ghTail = (command: string) => ({ command, cwd: "/w/flash", timeout: 30 });
|
|
759
|
+
assert.ok(d.check("bash", ghTail("gh run list --branch main --limit 3 2>&1 | head -6")).isErr());
|
|
760
|
+
d.record("bash", ghTail("gh run list --branch main --limit 3 2>&1 | head -6"));
|
|
761
|
+
assert.ok(
|
|
762
|
+
d
|
|
763
|
+
.check("bash", ghTail("sleep 150; gh run list --branch main --limit 3 2>&1 | head -6"))
|
|
764
|
+
.isErr(),
|
|
765
|
+
);
|
|
766
|
+
d.record("bash", ghTail("sleep 150; gh run list --branch main --limit 3 2>&1 | head -6"));
|
|
767
|
+
const nearHit = d.check(
|
|
768
|
+
"bash",
|
|
769
|
+
ghTail("timeout 30; gh run list --branch main --limit 3 2>&1 | head -6"),
|
|
770
|
+
);
|
|
771
|
+
assert.ok(nearHit.isOk(), "3rd command-tail variant should block");
|
|
772
|
+
if (nearHit.isOk()) {
|
|
773
|
+
assert.match(nearHit.value.reason, /near-identical arguments 3 times/);
|
|
774
|
+
assert.equal(nearHit.value.escalate, false, "first near block does not escalate");
|
|
775
|
+
}
|
|
776
|
+
// genuinely different command with the same tool stays unblocked
|
|
777
|
+
assert.ok(d.check("bash", { command: "cargo test -p pools 2>&1 | tail -5" }).isErr());
|
|
778
|
+
|
|
689
779
|
console.log("detector self-check: all assertions passed");
|
|
690
780
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - identical (tool, args) repeated `PI_ANTI_LOOP_REPEATS` times (default 3)
|
|
9
9
|
* in the last `PI_ANTI_LOOP_WINDOW` calls → block with an instructive reason
|
|
10
|
+
* - near-identical same-tool args (≥ 55% shared whitespace tokens, union ≥ 5)
|
|
11
|
+
* count toward the same threshold — sleep-wrapped polls, flag/pattern/tail
|
|
12
|
+
* drift that exact identity misses by construction
|
|
10
13
|
* - the same tool failing `PI_ANTI_LOOP_FAILS` consecutive times (default 3)
|
|
11
14
|
* → block with a "stop retrying, fix the root cause" reason
|
|
12
15
|
* - the model repeating text: verbatim, near-identical (token similarity),
|
|
@@ -19,8 +22,12 @@
|
|
|
19
22
|
* instructive reason (that is the steer); re-issuing the exact same blocked
|
|
20
23
|
* call aborts the turn.
|
|
21
24
|
*
|
|
22
|
-
* Counters reset on every user prompt
|
|
23
|
-
* in the session is never a false positive.
|
|
25
|
+
* Counters reset on every user prompt (`message_end` with `role:"user"`), so
|
|
26
|
+
* a task legitimately repeated later in the session is never a false positive.
|
|
27
|
+
* Run restarts (auto-continue, steers/resumes, async-result drains) do NOT
|
|
28
|
+
* reset — hosts emit `before_agent_start` on every run start, and resetting
|
|
29
|
+
* there wiped counters between the iterations of cross-run loops. Disable with
|
|
30
|
+
* PI_ANTI_LOOP_DISABLE=1.
|
|
24
31
|
*
|
|
25
32
|
* All logic lives in `controller.ts` (pure, pi-free, unit-tested); this file
|
|
26
33
|
* is a thin adapter wiring it to pi's event loop. The pi API is consumed
|
|
@@ -82,10 +89,6 @@ export default function (pi: PiLike): void {
|
|
|
82
89
|
|
|
83
90
|
pi.on("session_start", () => reset());
|
|
84
91
|
|
|
85
|
-
// Fresh counters per user prompt: only the loop happening *right now* counts.
|
|
86
|
-
// Internal reset keeps session-scoped steers/aborts/resume budget.
|
|
87
|
-
pi.on("before_agent_start", () => controller.reset());
|
|
88
|
-
|
|
89
92
|
pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
|
|
90
93
|
// SAFETY: pi delivers JSON-safe tool arguments, which is exactly the ToolInput domain.
|
|
91
94
|
// Decode them into the ToolInput domain type at this I/O boundary before the controller sees them.
|
|
@@ -108,8 +111,20 @@ export default function (pi: PiLike): void {
|
|
|
108
111
|
|
|
109
112
|
// Text-only doom loops (model re-emits/rephrases the same thing with no
|
|
110
113
|
// tool calls) never reach tool_call. Steer first, abort as escalation,
|
|
111
|
-
// then a bounded auto-resume so
|
|
114
|
+
// then a bounded auto-resume so work continues.
|
|
112
115
|
pi.on("message_end", (event: MessageEndEventLite, ctx: CtxLite) => {
|
|
116
|
+
// Fresh counters per genuine user prompt: only the loop happening *right
|
|
117
|
+
// now* counts (session-scoped steers/aborts/resume budget survive).
|
|
118
|
+
// Deliberately NOT on `before_agent_start`: hosts emit that on every run
|
|
119
|
+
// start — auto-continue turns, steers/resumes, advisor cards, and queued
|
|
120
|
+
// async-result drains — which wiped counters between the iterations of
|
|
121
|
+
// cross-run doom loops, so they never reached the repeat threshold.
|
|
122
|
+
// `role:"user"` input messages are emitted with the run's input messages
|
|
123
|
+
// (agent-core `emitInputMessages`) on omp and pi alike.
|
|
124
|
+
if (event.message.role === "user") {
|
|
125
|
+
controller.reset();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
113
128
|
// SAFETY: pi delivers message content as JSON-safe blocks matching MessageContent.
|
|
114
129
|
// Decode the untyped message content into MessageContent at this boundary.
|
|
115
130
|
const outcome = controller.onMessageEnd(
|
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.10",
|
|
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",
|
|
@@ -37,19 +37,19 @@
|
|
|
37
37
|
"lint": "oxlint --deny-warnings",
|
|
38
38
|
"format": "oxfmt .",
|
|
39
39
|
"format:check": "oxfmt --check .",
|
|
40
|
-
"prepare": "effect-tsgo patch --no-typescript --oxlint"
|
|
40
|
+
"prepare": "effect-tsgo patch --no-typescript --oxlint --skip-missing"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
+
"@effect/tsgo": "^0.36.4",
|
|
43
44
|
"better-result": "^3.0.0",
|
|
44
|
-
"effect": "^4.0.0-rc.108"
|
|
45
|
+
"effect": "^4.0.0-rc.108",
|
|
46
|
+
"oxlint": "1.77.0"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
47
49
|
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
-
"@effect/tsgo": "^0.36.4",
|
|
49
50
|
"@oxlint/plugins": "1.77.0",
|
|
50
51
|
"@types/node": "^22.0.0",
|
|
51
52
|
"oxfmt": "^0.62.0",
|
|
52
|
-
"oxlint": "1.77.0",
|
|
53
53
|
"oxlint-tsgolint": "^7.0.2001",
|
|
54
54
|
"typescript": "^5.6.0"
|
|
55
55
|
},
|