pi-anti-doom-loop 0.0.5 → 0.0.7

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,32 @@
2
2
 
3
3
  All notable changes to **pi-anti-doom-loop**.
4
4
 
5
+ ## [0.0.7] — 2026-08-13
6
+
7
+ ### Changed
8
+
9
+ - **Effect 4.0 RC** — upgraded `effect` from `^4.0.0-beta.103` to `^4.0.0-rc.108`.
10
+ - **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.
11
+
12
+ ## [0.0.6] — 2026-08-12
13
+
14
+ ### Added
15
+
16
+ - **Near-identical text cycle detection** — `checkText` now fires when near-identical assistant texts (token similarity ≥ 55%) accumulate to the text-repeat threshold within the sliding window, even when they are not identical and not consecutive. Catches a rotating set of rephrased commands ("Run the test." / "Run tests now." / "Let me run the test.") that never repeat verbatim. New block reason prefix: _"Assistant sent near-identical text N times within the last M messages"_.
17
+ - **Token-cost awareness** — the detector estimates tokens burned on redundant repeats (~4 chars/token) and reports `~N tokens burned on repeats.` in tool-call block reasons; the cumulative wasted-token count appears in `/loopcheck` status.
18
+ - **Time-windowed eviction** — optional `PI_ANTI_LOOP_TIME_WINDOW` (elapsed-time window in ms; default `0` = disabled, count-only) so slow chronic loops spread over a long session are caught.
19
+ - **Failure-rate window** — optional `PI_ANTI_LOOP_FAIL_RATE` (0..1, default `0` = disabled) blocks a tool when its error share of in-window calls reaches the threshold, with `PI_ANTI_LOOP_FAIL_RATE_MIN` (default `3`) as the minimum-calls gate. Catches flaky retries interleaved with successes that never form a consecutive streak.
20
+ - **Per-tool allowlist** — `PI_ANTI_LOOP_TOOLS_EXCLUDE` (comma-separated tool names) disables detection for those tools entirely: they never block and never enter the window.
21
+ - **Richer `/loopcheck` diagnostics** — status now shows the current window contents (most-repeated recent calls and texts) and the wasted-token count, plus the fail-rate/time-window/exclude config when enabled.
22
+
23
+ ### Changed
24
+
25
+ - **Same assistant text verbatim** now uses window semantics: it blocks on `3× within the last N messages` rather than strictly "in a row", consistent with the sliding-window repeat threshold.
26
+
27
+ ### Tests
28
+
29
+ - Detection and regression coverage for the near-identical text cycle, token-cost reporting, time-windowed eviction, failure-rate window, per-tool exclusion, and the richer `/loopcheck` status.
30
+
5
31
  ## [0.0.5] — 2026-08-05
6
32
 
7
33
  ### Added
@@ -78,7 +104,10 @@ All notable changes to **pi-anti-doom-loop**.
78
104
  - GitHub Actions release workflow: quality gate → version bump guard → dry-run → publish, triggered by `v*` tags.
79
105
  - `pi-package` keyword + `pi` manifest for the pi.dev gallery.
80
106
 
107
+ [0.0.7]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.6...v0.0.7
108
+ [0.0.6]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.5...v0.0.6
81
109
  [0.0.5]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.4...v0.0.5
82
110
  [0.0.4]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.3...v0.0.4
111
+ [0.0.3]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.2...v0.0.3
83
112
  [0.0.2]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.1...v0.0.2
84
113
  [0.0.1]: https://github.com/irfndi/pi-anti-doom-loop/releases/tag/v0.0.1
package/README.md CHANGED
@@ -15,13 +15,14 @@ pi install npm:pi-anti-doom-loop
15
15
 
16
16
  ## What it detects
17
17
 
18
- | Signal | Default | Blocked when |
19
- | -------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------- |
20
- | Same `(tool, args)` repeated | 3× in the last 10 calls | The pattern has repeated `3` times with no change |
21
- | Same tool failing consecutively | 3× | A tool errored `3` times in a row — stop retrying it blindly |
22
- | Same assistant text verbatim | 3× in a row | The model re-emitted identical text `3` times (text-only loops) |
23
- | Same sentence inside ONE message | 3× | A sentence repeats `3`+ times within a single message (growing self-concatenation loops) |
24
- | Near-identical text (rephrasing) | 3× in a row | Consecutive messages share ≥55% tokens — the model is rephrasing the same step |
18
+ | Signal | Default | Blocked when |
19
+ | ------------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
20
+ | Same `(tool, args)` repeated | 3× in the last 10 calls | The pattern has repeated `3` times with no change |
21
+ | Same tool failing consecutively | 3× | A tool errored `3` times in a row — stop retrying it blindly |
22
+ | Same assistant text verbatim | 3× within the last N messages | The model re-emitted identical text `3` times inside the sliding window |
23
+ | Same sentence inside ONE message | 3× | A sentence repeats `3`+ times within a single message (growing self-concatenation loops) |
24
+ | Near-identical text (rephrasing) | 3× in a row | Consecutive messages share ≥55% tokens — the model is rephrasing the same step |
25
+ | Near-identical text cycle (rotating rephrased commands) | 3× within the last N messages | Near-identical assistant texts (≥55% token similarity) accumulate to the repeat threshold in the window, even when not identical and not consecutive |
25
26
 
26
27
  Blocks hand the model an instructive reason ("change your approach, use a
27
28
  different tool, or ask the user"). If the model ignores the block and re-issues
@@ -41,21 +42,32 @@ the same session is never a false positive.
41
42
 
42
43
  Environment variables, read at session/prompt start:
43
44
 
44
- | Variable | Default | Meaning |
45
- | --------------------------- | ------- | -------------------------------------------------- |
46
- | `PI_ANTI_LOOP_REPEATS` | `3` | Identical-call block threshold |
47
- | `PI_ANTI_LOOP_FAILS` | `3` | Consecutive-failure block threshold |
48
- | `PI_ANTI_LOOP_TEXT_REPEATS` | `3` | Consecutive identical assistant texts before abort |
49
- | `PI_ANTI_LOOP_WINDOW` | `10` | How many recent calls/results are inspected |
50
- | `PI_ANTI_LOOP_DISABLE` | | Set to `1` to disable the extension entirely |
45
+ | Variable | Default | Meaning |
46
+ | ---------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
47
+ | `PI_ANTI_LOOP_REPEATS` | `3` | Identical-call block threshold |
48
+ | `PI_ANTI_LOOP_FAILS` | `3` | Consecutive-failure block threshold |
49
+ | `PI_ANTI_LOOP_TEXT_REPEATS` | `3` | Window/cycle repeat threshold for identical and near-identical assistant texts |
50
+ | `PI_ANTI_LOOP_WINDOW` | `10` | How many recent calls/results are inspected |
51
+ | `PI_ANTI_LOOP_TIME_WINDOW` | `0` | Elapsed-time window in ms (`0` = disabled, count-only): evicts window entries older than this so slow chronic loops over a long session are caught |
52
+ | `PI_ANTI_LOOP_FAIL_RATE` | `0` | Fail-rate block threshold `0..1` (`0` = disabled): block when a tool's error share of its in-window calls reaches this |
53
+ | `PI_ANTI_LOOP_FAIL_RATE_MIN` | `3` | Minimum calls before the fail-rate window can block |
54
+ | `PI_ANTI_LOOP_TOOLS_EXCLUDE` | — | Comma-separated tool names to disable detection for entirely (never block, never enter the window) |
55
+ | `PI_ANTI_LOOP_DISABLE` | — | Set to `1` to disable the extension entirely |
51
56
 
52
57
  ## Command
53
58
 
54
- - `/loopcheck` — show thresholds, counters (steers/aborts this session), suspend state
59
+ - `/loopcheck` — show thresholds, counters (steers/aborts this session), suspend state, the current window contents (most-repeated recent calls and texts), wasted-token count, and the fail-rate/time-window/exclude config when enabled
55
60
  - `/loopcheck reset` — clear counters
56
61
  - `/loopcheck suspend` — pause detection until the next prompt (escape hatch for intentional repetition)
57
62
  - `/loopcheck resume` — re-enable detection early
58
63
 
64
+ ## Token-cost awareness
65
+
66
+ The detector estimates tokens burned on redundant repeats (~4 chars/token) and
67
+ reports `"~N tokens burned on repeats."` in tool-call block reasons. The
68
+ cumulative wasted-token count also appears in `/loopcheck` status, so you can
69
+ see how much a loop actually cost before it was stopped.
70
+
59
71
  ## How it works
60
72
 
61
73
  Everything hooks into the `tool_call` / `tool_result` / `message_end` events;
@@ -65,7 +77,7 @@ Works with any model — cheap models just trigger it more often.
65
77
 
66
78
  ## Development
67
79
 
68
- Requires Node 22.6+ (plain `node` runs the TS self-check).
80
+ Requires Node 22.18+ (plain `node` runs the TS self-check).
69
81
 
70
82
  ```bash
71
83
  npm install
@@ -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,15 @@ 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. */
41
+ export interface MessageContentBlock {
42
+ readonly type: string;
43
+ readonly text?: string;
44
+ }
45
+
46
+ /** The list of content blocks of an assistant message. */
47
+ export type MessageContent = readonly MessageContentBlock[];
48
+
40
49
  export interface ToolCallOutcome {
41
50
  block: true;
42
51
  reason: string;
@@ -56,11 +65,11 @@ export const RESUME_BUDGET = 1;
56
65
 
57
66
  export interface AntiLoopController {
58
67
  /** Returns a block decision for a tool call, or null to let it run. */
59
- onToolCall(toolName: string, input: unknown, toolCallId: string): ToolCallOutcome | null;
68
+ onToolCall(toolName: string, input: ToolInput, toolCallId: string): ToolCallOutcome | null;
60
69
  /** Record a finished tool result (blocked calls' results are ignored). */
61
70
  onToolResult(toolName: string, toolCallId: string, isError: boolean): void;
62
71
  /** Detect assistant-text loops; returns a steer/abort decision or null. */
63
- onMessageEnd(role: string, content: unknown): TextLoopOutcome | null;
72
+ onMessageEnd(role: string, content: MessageContent): TextLoopOutcome | null;
64
73
  /** Full reset (session start, user prompt, /loopcheck reset). */
65
74
  reset(): void;
66
75
  /** Suspend detection until the next reset (escape hatch for intentional repetition). */
@@ -151,23 +160,19 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
151
160
  status() {
152
161
  const o = detector.opts;
153
162
  const s = suspended ? ", suspended" : "";
163
+ const rate = o.failRateThreshold > 0 ? `, failRate>=${o.failRateThreshold}` : "";
164
+ const time = o.timeWindowMs > 0 ? `, window ${o.timeWindowMs}ms` : "";
165
+ const excl = o.toolExclude.size ? `, exclude[${[...o.toolExclude].join(",")}]` : "";
154
166
  return (
155
167
  `anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, ` +
156
- `fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}. ${detector.summary()} ` +
157
- `steers=${steers} aborts=${aborts}${s}`
168
+ `fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}${rate}${time}${excl}. ` +
169
+ `${detector.diagnostics()} steers=${steers} aborts=${aborts}${s}`
158
170
  );
159
171
  },
160
172
  };
161
173
  }
162
174
 
163
175
  /** Join the text content blocks of an assistant message. */
164
- export function extractText(content: unknown): string {
165
- if (!Array.isArray(content)) return "";
166
- return content
167
- .map((c) =>
168
- typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string"
169
- ? c.text
170
- : "",
171
- )
172
- .join(" ");
176
+ export function extractText(content: MessageContent): string {
177
+ return content.map((c) => (c.type === "text" ? (c.text ?? "") : "")).join(" ");
173
178
  }
@@ -20,8 +20,21 @@ export interface LoopOptions {
20
20
  failThreshold: number;
21
21
  /** How many recent calls/results are inspected for repetition. */
22
22
  windowSize: number;
23
- /** Consecutive verbatim assistant messages that trigger an abort. */
23
+ /** Verbatim/near-identical assistant-message repeats that trigger a block. */
24
24
  textRepeatThreshold: number;
25
+ /** Evict window entries older than this many ms (0 = count-only window). */
26
+ timeWindowMs: number;
27
+ /**
28
+ * Failure-rate threshold (0..1). When a tool's error share of its calls in
29
+ * the window is >= this (and it has >= failRateMinCalls calls), block. This
30
+ * catches flaky/ungrounded retries that are interleaved with successes and
31
+ * never form a consecutive streak. 0 = disabled.
32
+ */
33
+ failRateThreshold: number;
34
+ /** Minimum calls before the failure-rate signal applies. */
35
+ failRateMinCalls: number;
36
+ /** Tool names to skip detection for entirely (intentional repetition). */
37
+ toolExclude: Set<string>;
25
38
  }
26
39
 
27
40
  export const DEFAULT_OPTIONS: LoopOptions = {
@@ -29,6 +42,10 @@ export const DEFAULT_OPTIONS: LoopOptions = {
29
42
  failThreshold: 3,
30
43
  windowSize: 10,
31
44
  textRepeatThreshold: 3,
45
+ timeWindowMs: 0,
46
+ failRateThreshold: 0,
47
+ failRateMinCalls: 3,
48
+ toolExclude: new Set(),
32
49
  };
33
50
 
34
51
  export function readOptions(env: Record<string, string | undefined> = process.env): LoopOptions {
@@ -40,26 +57,52 @@ export function readOptions(env: Record<string, string | undefined> = process.en
40
57
  const n = Number(raw);
41
58
  return Number.isFinite(n) && n >= 2 ? n : fallback;
42
59
  };
60
+ const timeWindow = Number(env["PI_ANTI_LOOP_TIME_WINDOW"] ?? "0");
61
+ const failRate = Number(env["PI_ANTI_LOOP_FAIL_RATE"] ?? "0");
62
+ const toolExclude = new Set(
63
+ (env["PI_ANTI_LOOP_TOOLS_EXCLUDE"] ?? "")
64
+ .split(",")
65
+ .map((s) => s.trim())
66
+ .filter(Boolean),
67
+ );
43
68
  return {
44
69
  repeatThreshold: num("PI_ANTI_LOOP_REPEATS", DEFAULT_OPTIONS.repeatThreshold),
45
70
  failThreshold: num("PI_ANTI_LOOP_FAILS", DEFAULT_OPTIONS.failThreshold),
46
71
  windowSize: num("PI_ANTI_LOOP_WINDOW", DEFAULT_OPTIONS.windowSize),
47
72
  textRepeatThreshold: num("PI_ANTI_LOOP_TEXT_REPEATS", DEFAULT_OPTIONS.textRepeatThreshold),
73
+ timeWindowMs: Number.isFinite(timeWindow) && timeWindow >= 0 ? timeWindow : 0,
74
+ failRateThreshold: Number.isFinite(failRate) ? Math.min(1, Math.max(0, failRate)) : 0,
75
+ failRateMinCalls: num("PI_ANTI_LOOP_FAIL_RATE_MIN", DEFAULT_OPTIONS.failRateMinCalls),
76
+ toolExclude,
48
77
  };
49
78
  }
50
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
+
51
93
  /** Keys sorted recursively so {a:1,b:2} and {b:2,a:1} share a signature. */
52
- export function canonical(input: unknown): string {
53
- if (input === null || typeof input !== "object") return JSON.stringify(input);
94
+ export function canonical(input: ToolInput): string {
54
95
  if (Array.isArray(input)) return `[${input.map(canonical).join(",")}]`;
55
- const obj = input as Record<string, unknown>;
56
- return `{${Object.keys(obj)
57
- .sort()
58
- .map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`)
59
- .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) ?? "";
60
103
  }
61
104
 
62
- export function signature(toolName: string, input: unknown): string {
105
+ export function signature(toolName: string, input: ToolInput): string {
63
106
  return `${toolName}:${canonical(input)}`;
64
107
  }
65
108
 
@@ -77,25 +120,33 @@ export interface BlockDecision {
77
120
  */
78
121
  export class LoopDetector {
79
122
  readonly opts: LoopOptions;
80
- private recentSigs: string[] = [];
81
- private recentResults: { tool: string; error: boolean }[] = [];
123
+ private recentSigs: { sig: string; ts: number }[] = [];
124
+ private recentResults: { tool: string; error: boolean; ts: number }[] = [];
82
125
  private blockedBySig = new Map<string, number>();
126
+ private recentTexts: { text: string; ts: number }[] = [];
83
127
  private lastText: string | null = null;
84
128
  private textStreak = 0;
85
- private textFired = false;
129
+ private wastedTokens = 0;
86
130
 
87
131
  constructor(opts: LoopOptions = DEFAULT_OPTIONS) {
88
132
  this.opts = opts;
89
133
  }
90
134
 
91
- check(toolName: string, input: unknown): Result<BlockDecision, undefined> {
135
+ check(toolName: string, input: ToolInput): Result<BlockDecision, undefined> {
136
+ if (this.opts.toolExclude.has(toolName)) {
137
+ // record() is a no-op for excluded tools, so nothing enters the window.
138
+ return Result.err(undefined);
139
+ }
140
+ this.evictSigs();
92
141
  const sig = signature(toolName, input);
93
- const repeats = this.recentSigs.filter((s) => s === sig).length;
142
+ const repeats = this.recentSigs.filter((s) => s.sig === sig).length;
94
143
  const total = repeats + 1; // including this call
95
- const fails = this.consecutiveFails(toolName);
144
+ const consecutiveFails = this.consecutiveFails(toolName);
145
+ const rate = this.failRate(toolName);
96
146
 
97
- if (total < this.opts.repeatThreshold && fails < this.opts.failThreshold)
98
- return Result.err(undefined);
147
+ // Rough cost accounting (feature B): every redundant repeat of an already
148
+ // present signature burns tokens with no new information.
149
+ if (repeats >= 1) this.wastedTokens += estimateTokens(stringify(input));
99
150
 
100
151
  const reasons: string[] = [];
101
152
  if (total >= this.opts.repeatThreshold) {
@@ -103,36 +154,49 @@ export class LoopDetector {
103
154
  `"${toolName}" was called with identical arguments ${total} times in the last ${this.opts.windowSize} tool calls with no change`,
104
155
  );
105
156
  }
106
- if (fails >= this.opts.failThreshold) {
107
- reasons.push(`"${toolName}" failed ${fails} consecutive times`);
157
+ if (consecutiveFails >= this.opts.failThreshold) {
158
+ reasons.push(`"${toolName}" failed ${consecutiveFails} consecutive times`);
108
159
  }
160
+ if (
161
+ this.opts.failRateThreshold > 0 &&
162
+ rate.calls >= this.opts.failRateMinCalls &&
163
+ rate.rate >= this.opts.failRateThreshold
164
+ ) {
165
+ reasons.push(
166
+ `"${toolName}" failed ${rate.errors} of ${rate.calls} calls in the window (${Math.round(rate.rate * 100)}%)`,
167
+ );
168
+ }
169
+
170
+ if (reasons.length === 0) return Result.err(undefined);
109
171
 
110
172
  const blockedCount = (this.blockedBySig.get(sig) ?? 0) + 1;
111
173
  this.blockedBySig.set(sig, blockedCount);
174
+ const cost = this.wastedTokens > 0 ? ` ~${this.wastedTokens} tokens burned on repeats.` : "";
112
175
 
113
176
  return Result.ok({
114
177
  reason:
115
178
  reasons.join("; ") +
179
+ cost +
116
180
  `. BLOCKED by anti-doom-loop — you appear to be looping. Change your approach, use a different tool, or ask the user.`,
117
181
  escalate: blockedCount > 1,
118
182
  });
119
183
  }
120
184
 
121
- record(toolName: string, input: unknown): void {
122
- this.recentSigs.push(signature(toolName, input));
123
- if (this.recentSigs.length > this.opts.windowSize) this.recentSigs.shift();
185
+ record(toolName: string, input: ToolInput): void {
186
+ if (this.opts.toolExclude.has(toolName)) return;
187
+ this.recentSigs.push({ sig: signature(toolName, input), ts: Date.now() });
188
+ this.evictSigs();
124
189
  }
125
190
 
126
191
  recordResult(toolName: string, error: boolean): void {
127
- this.recentResults.push({ tool: toolName, error });
128
- if (this.recentResults.length > this.opts.windowSize) this.recentResults.shift();
192
+ this.recentResults.push({ tool: toolName, error, ts: Date.now() });
193
+ this.evictResults();
129
194
  }
130
195
 
131
196
  /**
132
- * Consecutive verbatim assistant text (whitespace-normalized). Fires once
133
- * per run when the streak reaches textRepeatThreshold, then stays silent
134
- * until reset — message_end cannot return a block, so the caller aborts.
135
- * $
197
+ * Consecutive verbatim/near-identical assistant text (whitespace-normalized).
198
+ * The controller turns the first detection into a steer, later ones into
199
+ * aborts. $
136
200
  * Detects the text-only loop shape (model re-emits the same sentence
137
201
  * forever, e.g. goal-function loops) that identical-tool-call detection
138
202
  * never sees. Liquid.ai's Antidoom mines loops as 'a section repeats at
@@ -156,6 +220,24 @@ export class LoopDetector {
156
220
  });
157
221
  }
158
222
 
223
+ // Cross-message window repeat (exact): the same text reappearing
224
+ // textRepeatThreshold times within the recent-text window. Catches text
225
+ // CYCLES that never form a 3-consecutive streak and are too short for
226
+ // repeatedSegment. Mirrors the tool-signature window in check().
227
+ const exactCount = this.recentTexts.filter((t) => t.text === norm).length + 1;
228
+ this.recentTexts.push({ text: norm, ts: Date.now() });
229
+ this.evictTexts();
230
+ if (this.recentTexts.filter((t) => t.text === norm).length >= 2) {
231
+ this.wastedTokens += estimateTokens(norm);
232
+ }
233
+ if (exactCount >= this.opts.textRepeatThreshold) {
234
+ return Result.ok({
235
+ reason:
236
+ `Assistant sent identical text ${exactCount} times within the last ${this.opts.windowSize} messages ` +
237
+ `("${truncate(norm, 80)}"). You appear to be in a loop.`,
238
+ });
239
+ }
240
+
159
241
  // Cross-message streak: consecutive assistant texts that are identical
160
242
  // OR near-identical (token-overlap similarity). Catches loops where the
161
243
  // model slightly rephrases each turn ("inspect the failing test" →
@@ -172,6 +254,23 @@ export class LoopDetector {
172
254
  `("${truncate(norm, 80)}"). You appear to be in a loop.`,
173
255
  });
174
256
  }
257
+
258
+ // Cross-message window repeat (near-identical): a rotating set of
259
+ // rephrased commands ("Run the test." / "Run tests now." / "Let me run
260
+ // the test.") that is never identical and never consecutive, so both the
261
+ // exact window check and the streak above miss it. Similar, non-identical
262
+ // texts accumulating to textRepeatThreshold within the window fire here.
263
+ const similarCount =
264
+ this.recentTexts.filter(
265
+ (t) => t.text !== norm && tokenSimilarity(norm, t.text) >= TEXT_SIMILARITY_THRESHOLD,
266
+ ).length + 1;
267
+ if (similarCount >= this.opts.textRepeatThreshold) {
268
+ return Result.ok({
269
+ reason:
270
+ `Assistant sent near-identical text ${similarCount} times within the last ${this.opts.windowSize} messages ` +
271
+ `("${truncate(norm, 80)}"). You appear to be in a loop.`,
272
+ });
273
+ }
175
274
  return Result.err(undefined);
176
275
  }
177
276
 
@@ -186,13 +285,80 @@ export class LoopDetector {
186
285
  return n;
187
286
  }
188
287
 
288
+ /** Error share of all in-window results for a tool. */
289
+ private failRate(toolName: string) {
290
+ let calls = 0;
291
+ let errors = 0;
292
+ for (const r of this.recentResults) {
293
+ if (r.tool !== toolName) continue;
294
+ calls++;
295
+ if (r.error) errors++;
296
+ }
297
+ return { calls, errors, rate: calls ? errors / calls : 0 };
298
+ }
299
+
300
+ private evictSigs(): void {
301
+ if (this.opts.timeWindowMs > 0) {
302
+ const cutoff = Date.now() - this.opts.timeWindowMs;
303
+ this.recentSigs = this.recentSigs.filter((s) => s.ts >= cutoff);
304
+ }
305
+ while (this.recentSigs.length > this.opts.windowSize) this.recentSigs.shift();
306
+ }
307
+
308
+ private evictResults(): void {
309
+ if (this.opts.timeWindowMs > 0) {
310
+ const cutoff = Date.now() - this.opts.timeWindowMs;
311
+ this.recentResults = this.recentResults.filter((r) => r.ts >= cutoff);
312
+ }
313
+ while (this.recentResults.length > this.opts.windowSize) this.recentResults.shift();
314
+ }
315
+
316
+ private evictTexts(): void {
317
+ if (this.opts.timeWindowMs > 0) {
318
+ const cutoff = Date.now() - this.opts.timeWindowMs;
319
+ this.recentTexts = this.recentTexts.filter((t) => t.ts >= cutoff);
320
+ }
321
+ while (this.recentTexts.length > this.opts.windowSize) this.recentTexts.shift();
322
+ }
323
+
324
+ /** Estimated tokens burned on redundant repeats (feature B). */
325
+ wastedTokensCount(): number {
326
+ return this.wastedTokens;
327
+ }
328
+
329
+ /** Human-readable window introspection for /loopcheck (feature F). */
330
+ diagnostics(): string {
331
+ const sigCounts = new Map<string, number>();
332
+ for (const s of this.recentSigs) sigCounts.set(s.sig, (sigCounts.get(s.sig) ?? 0) + 1);
333
+ const topSigs = [...sigCounts.entries()]
334
+ .sort((a, b) => b[1] - a[1])
335
+ .slice(0, 3)
336
+ .map(([s, n]) => `${truncate(s, 40)} x${n}`)
337
+ .join(", ");
338
+
339
+ const textCounts = new Map<string, number>();
340
+ for (const t of this.recentTexts) textCounts.set(t.text, (textCounts.get(t.text) ?? 0) + 1);
341
+ const topTexts = [...textCounts.entries()]
342
+ .sort((a, b) => b[1] - a[1])
343
+ .slice(0, 3)
344
+ .map(([s, n]) => `"${truncate(s, 24)}" x${n}`)
345
+ .join(", ");
346
+
347
+ return (
348
+ `calls[${this.recentSigs.length}] ${topSigs || "none"}; ` +
349
+ `texts[${this.recentTexts.length}] ${topTexts || "none"}; ` +
350
+ `wastedTokens=${this.wastedTokens}`
351
+ );
352
+ }
353
+
189
354
  reset(): void {
190
355
  this.recentSigs = [];
191
356
  this.recentResults = [];
192
357
  this.blockedBySig.clear();
358
+ this.recentTexts = [];
193
359
  this.lastText = null;
194
360
  this.textStreak = 0;
195
- this.textFired = false;
361
+ this.wastedTokens = 0;
196
362
  }
197
363
 
198
364
  summary(): string {
@@ -218,6 +384,21 @@ export const MIN_REPEAT_CHUNK = 16;
218
384
  /** Jaccard similarity threshold for "near-identical" consecutive texts. */
219
385
  export const TEXT_SIMILARITY_THRESHOLD = 0.55;
220
386
 
387
+ /** Stable string form of a tool input (used for token estimation). */
388
+ export function stringify(input: ToolInput): string {
389
+ return JSON.stringify(input) ?? "";
390
+ }
391
+
392
+ /**
393
+ * Rough token estimate for cost accounting (feature B): ~4 chars per token,
394
+ * like the widely-used wc/4 rule. Purposely crude — it only needs to be
395
+ * monotonic so the same work always reports the same order of magnitude.
396
+ */
397
+ export function estimateTokens(text: string): number {
398
+ if (!text) return 0;
399
+ return Math.max(1, Math.ceil(text.length / 4));
400
+ }
401
+
221
402
  /**
222
403
  * Token-set Jaccard similarity of two texts (case/whitespace-insensitive).
223
404
  * Short tokens (< 3 chars: "a", "me", "to") are ignored to reduce noise.
@@ -271,6 +452,10 @@ if (import.meta.main) {
271
452
  failThreshold: 3,
272
453
  windowSize: 10,
273
454
  textRepeatThreshold: 3,
455
+ timeWindowMs: 0,
456
+ failRateThreshold: 0,
457
+ failRateMinCalls: 3,
458
+ toolExclude: new Set(),
274
459
  };
275
460
  const d: LoopDetector = new LoopDetector(opts);
276
461
 
@@ -330,7 +515,7 @@ if (import.meta.main) {
330
515
  const textHit = d.checkText("Now update buildProgram.");
331
516
  assert.ok(textHit.isOk(), "3rd identical text should fire");
332
517
  if (textHit.isOk()) {
333
- assert.match(textHit.value.reason, /identical or near-identical text 3 times/);
518
+ assert.match(textHit.value.reason, /identical text 3 times within the last/);
334
519
  }
335
520
  assert.ok(
336
521
  d.checkText("Now update buildProgram.").isOk(),
@@ -345,23 +530,42 @@ if (import.meta.main) {
345
530
  const wsHit = d.checkText(" Read the region: ");
346
531
  assert.ok(wsHit.isOk(), "whitespace-normalized repeats should fire");
347
532
 
348
- // 9. a different message breaks the streak (need 3 consecutive AFTER the break to fire)
533
+ // 9. window semantics: < textRepeatThreshold recurrences in the window do
534
+ // NOT fire, even with other messages interleaved; the 3rd recurrence does.
349
535
  d.reset();
350
536
  d.checkText("A");
351
- d.checkText("A");
352
- d.checkText("B"); // breaks the streak
353
- d.checkText("A");
354
- assert.ok(d.checkText("A").isErr(), "only 2 consecutive As after the break must not fire");
355
- const again = d.checkText("A");
356
- assert.ok(again.isOk(), "3 consecutive As after the break fire");
537
+ d.checkText("B"); // different message
538
+ d.checkText("A"); // 2nd A in window
539
+ assert.ok(d.checkText("C").isErr(), "2 As in the window must not fire");
540
+ assert.ok(d.checkText("A").isOk(), "3rd A in the window fires");
357
541
 
358
542
  // 10. empty/blank text is ignored as a loop signal
359
543
  d.reset();
360
544
  d.checkText("");
361
545
  d.checkText(" ");
362
546
  assert.ok(d.checkText("").isErr(), "blank text must not fire");
363
-
364
- // 11. threshold 1 is clamped away (would brick the agent)
547
+ // 11. text CYCLES: a small set of short near-identical commands rotating
548
+ // ("Let me run. GO." / "Run. GO." / "GO.") never forms a consecutive
549
+ // streak, but the same text reappears >= threshold within the window.
550
+ d.reset();
551
+ const cycle = [
552
+ "Let me run. GO.",
553
+ "Run. GO.",
554
+ "GO.",
555
+ "Run. GO.",
556
+ "GO.",
557
+ "Let me run. GO.",
558
+ "Run. GO.",
559
+ "GO.",
560
+ "Run. GO.",
561
+ "GO.",
562
+ "Let me run. GO.",
563
+ ];
564
+ let fired = false;
565
+ for (const m of cycle) if (d.checkText(m).isOk()) fired = true;
566
+ assert.ok(fired, "rotating near-identical cycle must fire");
567
+
568
+ // 12. threshold 1 is clamped away (would brick the agent)
365
569
  const clamped = readOptions({
366
570
  PI_ANTI_LOOP_REPEATS: "1",
367
571
  PI_ANTI_LOOP_FAILS: "0",
@@ -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.5",
3
+ "version": "0.0.7",
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",
@@ -36,17 +36,21 @@
36
36
  "guard": "node scripts/guard-publish.ts",
37
37
  "lint": "oxlint --deny-warnings",
38
38
  "format": "oxfmt .",
39
- "format:check": "oxfmt --check ."
39
+ "format:check": "oxfmt --check .",
40
+ "prepare": "effect-tsgo patch --no-typescript --oxlint"
40
41
  },
41
42
  "dependencies": {
42
43
  "better-result": "^3.0.0",
43
- "effect": "^4.0.0-beta.103"
44
+ "effect": "^4.0.0-rc.108"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@earendil-works/pi-coding-agent": "*",
48
+ "@effect/tsgo": "^0.36.4",
49
+ "@oxlint/plugins": "^1.77.0",
47
50
  "@types/node": "^22.0.0",
48
51
  "oxfmt": "^0.62.0",
49
52
  "oxlint": "^1.77.0",
53
+ "oxlint-tsgolint": "^7.0.2001",
50
54
  "typescript": "^5.6.0"
51
55
  },
52
56
  "peerDependencies": {
@@ -63,8 +63,8 @@ const tagExists = (version: string): Effect.Effect<boolean> =>
63
63
  return false; // no git repo / git missing → treat as no tag
64
64
  }
65
65
  },
66
- catch: () => new Error("git tag check failed"),
67
- }).pipe(Effect.catch(() => Effect.succeed(false)));
66
+ catch: (): GuardError => ({ message: "git tag check failed" }),
67
+ }).pipe(Effect.orElseSucceed(() => false));
68
68
  /**
69
69
  * Latest published version on npm, or null when the package was never
70
70
  * published or the registry is unreachable (warn-only on network failure —
@@ -83,9 +83,9 @@ const fetchPublished = (name: string): Effect.Effect<string | null> =>
83
83
  console.warn(
84
84
  "GUARD WARN: could not reach the npm registry; skipping published-version check.",
85
85
  );
86
- return new Error("registry unreachable");
86
+ return { message: "registry unreachable" };
87
87
  },
88
- }).pipe(Effect.catch(() => Effect.succeed(null)));
88
+ }).pipe(Effect.orElseSucceed(() => null));
89
89
 
90
90
  const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
91
91
  const raw = yield* readManifest;
@@ -94,10 +94,10 @@ const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
94
94
  const expected = (process.argv[2] ?? "").replace(/^v/, ""); // tolerate "v0.0.1"
95
95
 
96
96
  if (!semverLike(local)) {
97
- yield* fail(`GUARD FAIL: package.json version "${local}" is not a semver X.Y.Z.`);
97
+ return yield* fail(`GUARD FAIL: package.json version "${local}" is not a semver X.Y.Z.`);
98
98
  }
99
99
  if (expected && expected !== local) {
100
- yield* fail(
100
+ return yield* fail(
101
101
  `GUARD FAIL: expected "${expected}" (tag/input) does not match package.json version "${local}". ` +
102
102
  `Bump package.json to ${expected}, or tag ${local}.`,
103
103
  );
@@ -105,7 +105,7 @@ const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
105
105
 
106
106
  const published = yield* fetchPublished(pkg.name);
107
107
  if (published === local) {
108
- yield* fail(
108
+ return yield* fail(
109
109
  `GUARD FAIL: ${pkg.name}@${local} is already published on npm. ` +
110
110
  `Bump the version in package.json to cut a new release.`,
111
111
  );
@@ -115,7 +115,7 @@ const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
115
115
  // exist as a git tag — keeps the tag/manifest-sync invariant on both entry
116
116
  // points (clawpatch finding).
117
117
  if (expected && (yield* tagExists(expected)) === false) {
118
- yield* fail(
118
+ return yield* fail(
119
119
  `GUARD FAIL: expected version "${expected}" has no matching git tag v${expected}. Tag it first.`,
120
120
  );
121
121
  }