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 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
@@ -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 sig = signature(toolName, input);
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
- this.recentSigs.push({ sig: signature(toolName, input), ts: Date.now() });
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
  }
@@ -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, so a task legitimately repeated later
23
- * in the session is never a false positive. Disable with PI_ANTI_LOOP_DISABLE=1.
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 the work continues.
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.9",
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
  },