pi-anti-doom-loop 0.0.4 → 0.0.6

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 ADDED
@@ -0,0 +1,103 @@
1
+ # Changelog
2
+
3
+ All notable changes to **pi-anti-doom-loop**.
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Added
8
+
9
+ - **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"_.
10
+ - **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.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **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.
15
+
16
+ ### Changed
17
+
18
+ - **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.
19
+
20
+ ### Tests
21
+
22
+ - 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.
23
+
24
+ ## [0.0.5] — 2026-08-05
25
+
26
+ ### Added
27
+
28
+ - **`/loopcheck suspend` / `resume`** — escape hatch: pause detection until the next prompt when repetition is intentional (polling, retrying a deploy). No env restart needed.
29
+ - **Session counters** — `steers`/`aborts` appear in `/loopcheck` status; session-scoped (survive per-prompt resets, reset on `/loopcheck reset` or new session).
30
+ - Fixture for the live regex-repeat capture (`parameter\s+(\S+)\s+("` 3× in one message) — regression-covered via the within-message signal.
31
+ - `CHANGELOG.md` ships in the tarball.
32
+
33
+ ### Tests
34
+
35
+ - 72 → 75: suspend/resume lifecycle, session counters, regex-repeat fixture, `/loopcheck` subcommands.
36
+
37
+ ## [0.0.4] — 2026-08-05
38
+
39
+ ### Added
40
+
41
+ - **Near-identical text detection** — token-set Jaccard similarity ≥ 0.55 on consecutive assistant messages counts as a loop streak. Catches rephrasing loops where the model changes one or two words per turn ("inspect the failing test" → "examine the failing assertion") that exact matching missed.
42
+ - **Escalation ladder for text loops: steer → abort → bounded resume** — the first detection steers the agent mid-run (injects guidance, work continues); a persistent loop aborts the run and queues **one** fresh-resume directive (`followUp`) so work continues with a new approach; further looping aborts for real and hands control back. The auto-resume budget is session-scoped so a stuck model cannot cycle forever.
43
+ - Detector now fires on every message while a loop condition holds; the controller turns the 1st into a steer, later ones into aborts.
44
+ - Tests: 65 → 72 (token-similarity unit cases, near-identical streak + reset, steer→abort→resume ladder, budget persistence, adapter wiring, fuzz no-FP + injected-3× properties).
45
+
46
+ ## [0.0.3] — 2026-08-05
47
+
48
+ ### Added
49
+
50
+ - **Within-message self-repetition detection** — a sentence repeating 3+ times inside ONE message (growing self-concatenation loops like "…X:…X:…X") now aborts/steers immediately. Liquid Antidoom's loop definition ("a section repeats at least N times").
51
+ - Segmenter ignores chunks < 16 chars so pasted logs with repeated one-word lines never false-positive.
52
+ - Fixtures for the production growing-loop capture (exact + spaced variants).
53
+
54
+ ### Fixed
55
+
56
+ - Production gap: loops that grow by self-concatenation evaded both verbatim-streak (messages differ) and identical-call (args vary) detection. Now caught at the message itself.
57
+
58
+ ### Tests
59
+
60
+ - 51 → 65: `repeatedSegment` unit cases, growing-pattern `checkText` cases, fixture controller paths, fuzz no-FP + injected-3× properties.
61
+
62
+ ## [0.0.2] — 2026-08-05
63
+
64
+ ### Added
65
+
66
+ - **Full test suite** — Node's built-in `node:test`, no framework:
67
+ - unit (detector semantics, options clamping, helpers)
68
+ - fixtures (real doom-loop transcripts: CI-log loops, verbatim repeats; healthy sessions not flagged)
69
+ - fuzz (seeded: never throws, no false positives, injected loops always block)
70
+ - integration (controller + adapter driven through a fake `PiLike`)
71
+ - e2e (real subprocesses: detector self-check, version guard, tarball contents)
72
+ - **`controller.ts`** — extracted all event logic into a pure, pi-free, testable module; `index.ts` became a thin structural adapter.
73
+ - `npm test`, `engines >= 22.18`, `files` includes `scripts/`.
74
+
75
+ ### Fixed
76
+
77
+ - Thresholds clamped to a minimum of 2 (a config of 1 would brick the agent — deepsec finding).
78
+ - GitHub Actions pinned to commit SHAs (NPM_TOKEN in scope — deepsec finding).
79
+ - Release job `concurrency` group (publish race / TOCTOU).
80
+ - `npm publish --dry-run` preflight before the real publish.
81
+ - Version guard requires the git tag `v$version` on the manual-dispatch path.
82
+
83
+ ### Changed
84
+
85
+ - Leveraged `better-result` (detector decisions) and `effect` v4 (release guard).
86
+
87
+ ## [0.0.1] — 2026-08-05
88
+
89
+ ### Added
90
+
91
+ - Initial release. Detects and breaks agent doom loops in pi:
92
+ - **identical (tool, args) repeated** 3× in the last 10 calls → block with an instructive reason
93
+ - **same tool failing** 3× consecutively → block with "stop retrying, fix the root cause"
94
+ - escalation: re-issuing a blocked call aborts the turn
95
+ - `/loopcheck` command (status + reset).
96
+ - Counters reset per user prompt (legit repeated tasks are never false positives).
97
+ - GitHub Actions release workflow: quality gate → version bump guard → dry-run → publish, triggered by `v*` tags.
98
+ - `pi-package` keyword + `pi` manifest for the pi.dev gallery.
99
+
100
+ [0.0.5]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.4...v0.0.5
101
+ [0.0.4]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.3...v0.0.4
102
+ [0.0.2]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.1...v0.0.2
103
+ [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,29 +42,42 @@ 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 current thresholds and counters
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
61
+ - `/loopcheck suspend` — pause detection until the next prompt (escape hatch for intentional repetition)
62
+ - `/loopcheck resume` — re-enable detection early
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.
56
70
 
57
71
  ## How it works
58
72
 
59
- Everything hooks into the `tool_call` / `tool_result` events; detection is a
60
- small sliding-window counter (see `extensions/detector.ts`) with no state kept
61
- between user prompts. Works with any model — cheap models just trigger it more
62
- often.
73
+ Everything hooks into the `tool_call` / `tool_result` / `message_end` events;
74
+ detection is a small sliding-window counter (see `extensions/detector.ts`) with
75
+ per-session counters (steers/aborts) tracked in `extensions/controller.ts`.
76
+ Works with any model — cheap models just trigger it more often.
63
77
 
64
78
  ## Development
65
79
 
66
- Requires Node 22.6+ (plain `node` runs the TS self-check).
80
+ Requires Node 22.18+ (plain `node` runs the TS self-check).
67
81
 
68
82
  ```bash
69
83
  npm install
@@ -63,6 +63,10 @@ export interface AntiLoopController {
63
63
  onMessageEnd(role: string, content: unknown): TextLoopOutcome | null;
64
64
  /** Full reset (session start, user prompt, /loopcheck reset). */
65
65
  reset(): void;
66
+ /** Suspend detection until the next reset (escape hatch for intentional repetition). */
67
+ suspend(): void;
68
+ resume(): void;
69
+ isSuspended(): boolean;
66
70
  /** Human-readable status with thresholds + counters for /loopcheck. */
67
71
  status(): string;
68
72
  }
@@ -72,9 +76,13 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
72
76
  const blockedIds = new Set<string>();
73
77
  let steered = false;
74
78
  let resumes = 0;
79
+ let steers = 0;
80
+ let aborts = 0;
81
+ let suspended = false;
75
82
 
76
83
  return {
77
84
  onToolCall(toolName, input, toolCallId) {
85
+ if (suspended) return null;
78
86
  const decision = detector.check(toolName, input);
79
87
  if (decision.isErr()) {
80
88
  detector.record(toolName, input);
@@ -96,6 +104,7 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
96
104
  },
97
105
 
98
106
  onMessageEnd(role, content) {
107
+ if (suspended) return null;
99
108
  if (role !== "assistant") return null;
100
109
  const text = extractText(content);
101
110
  if (!text) return null;
@@ -105,12 +114,15 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
105
114
  const reason = hit.value.reason;
106
115
  if (!steered) {
107
116
  steered = true;
117
+ steers++;
108
118
  return { reason, action: "steer", resume: false };
109
119
  }
110
120
  if (resumes < RESUME_BUDGET) {
111
121
  resumes++;
122
+ aborts++;
112
123
  return { reason, action: "abort", resume: true };
113
124
  }
125
+ aborts++;
114
126
  return { reason, action: "abort", resume: false };
115
127
  },
116
128
 
@@ -118,15 +130,34 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
118
130
  detector = new LoopDetector(opts);
119
131
  blockedIds.clear();
120
132
  steered = false;
121
- // resumes is intentionally NOT reset here: the auto-resume budget is
122
- // session-scoped so a stuck model cannot cycle steer→abort forever.
133
+ suspended = false;
134
+ // resumes/steers/aborts are intentionally NOT reset here: they are
135
+ // session-scoped so a stuck model cannot cycle steer→abort forever and
136
+ // /loopcheck can report lifetime counters.
137
+ },
138
+
139
+ suspend() {
140
+ suspended = true;
141
+ },
142
+
143
+ resume() {
144
+ suspended = false;
145
+ },
146
+
147
+ isSuspended() {
148
+ return suspended;
123
149
  },
124
150
 
125
151
  status() {
126
152
  const o = detector.opts;
153
+ const s = suspended ? ", suspended" : "";
154
+ const rate = o.failRateThreshold > 0 ? `, failRate>=${o.failRateThreshold}` : "";
155
+ const time = o.timeWindowMs > 0 ? `, window ${o.timeWindowMs}ms` : "";
156
+ const excl = o.toolExclude.size ? `, exclude[${[...o.toolExclude].join(",")}]` : "";
127
157
  return (
128
158
  `anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, ` +
129
- `fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}. ${detector.summary()}`
159
+ `fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}${rate}${time}${excl}. ` +
160
+ `${detector.diagnostics()} steers=${steers} aborts=${aborts}${s}`
130
161
  );
131
162
  },
132
163
  };
@@ -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,11 +57,23 @@ 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
 
@@ -77,25 +106,33 @@ export interface BlockDecision {
77
106
  */
78
107
  export class LoopDetector {
79
108
  readonly opts: LoopOptions;
80
- private recentSigs: string[] = [];
81
- private recentResults: { tool: string; error: boolean }[] = [];
109
+ private recentSigs: { sig: string; ts: number }[] = [];
110
+ private recentResults: { tool: string; error: boolean; ts: number }[] = [];
82
111
  private blockedBySig = new Map<string, number>();
112
+ private recentTexts: { text: string; ts: number }[] = [];
83
113
  private lastText: string | null = null;
84
114
  private textStreak = 0;
85
- private textFired = false;
115
+ private wastedTokens = 0;
86
116
 
87
117
  constructor(opts: LoopOptions = DEFAULT_OPTIONS) {
88
118
  this.opts = opts;
89
119
  }
90
120
 
91
121
  check(toolName: string, input: unknown): Result<BlockDecision, undefined> {
122
+ if (this.opts.toolExclude.has(toolName)) {
123
+ // record() is a no-op for excluded tools, so nothing enters the window.
124
+ return Result.err(undefined);
125
+ }
126
+ this.evictSigs();
92
127
  const sig = signature(toolName, input);
93
- const repeats = this.recentSigs.filter((s) => s === sig).length;
128
+ const repeats = this.recentSigs.filter((s) => s.sig === sig).length;
94
129
  const total = repeats + 1; // including this call
95
- const fails = this.consecutiveFails(toolName);
130
+ const consecutiveFails = this.consecutiveFails(toolName);
131
+ const rate = this.failRate(toolName);
96
132
 
97
- if (total < this.opts.repeatThreshold && fails < this.opts.failThreshold)
98
- return Result.err(undefined);
133
+ // Rough cost accounting (feature B): every redundant repeat of an already
134
+ // present signature burns tokens with no new information.
135
+ if (repeats >= 1) this.wastedTokens += estimateTokens(stringify(input));
99
136
 
100
137
  const reasons: string[] = [];
101
138
  if (total >= this.opts.repeatThreshold) {
@@ -103,36 +140,49 @@ export class LoopDetector {
103
140
  `"${toolName}" was called with identical arguments ${total} times in the last ${this.opts.windowSize} tool calls with no change`,
104
141
  );
105
142
  }
106
- if (fails >= this.opts.failThreshold) {
107
- reasons.push(`"${toolName}" failed ${fails} consecutive times`);
143
+ if (consecutiveFails >= this.opts.failThreshold) {
144
+ reasons.push(`"${toolName}" failed ${consecutiveFails} consecutive times`);
108
145
  }
146
+ if (
147
+ this.opts.failRateThreshold > 0 &&
148
+ rate.calls >= this.opts.failRateMinCalls &&
149
+ rate.rate >= this.opts.failRateThreshold
150
+ ) {
151
+ reasons.push(
152
+ `"${toolName}" failed ${rate.errors} of ${rate.calls} calls in the window (${Math.round(rate.rate * 100)}%)`,
153
+ );
154
+ }
155
+
156
+ if (reasons.length === 0) return Result.err(undefined);
109
157
 
110
158
  const blockedCount = (this.blockedBySig.get(sig) ?? 0) + 1;
111
159
  this.blockedBySig.set(sig, blockedCount);
160
+ const cost = this.wastedTokens > 0 ? ` ~${this.wastedTokens} tokens burned on repeats.` : "";
112
161
 
113
162
  return Result.ok({
114
163
  reason:
115
164
  reasons.join("; ") +
165
+ cost +
116
166
  `. BLOCKED by anti-doom-loop — you appear to be looping. Change your approach, use a different tool, or ask the user.`,
117
167
  escalate: blockedCount > 1,
118
168
  });
119
169
  }
120
170
 
121
171
  record(toolName: string, input: unknown): void {
122
- this.recentSigs.push(signature(toolName, input));
123
- if (this.recentSigs.length > this.opts.windowSize) this.recentSigs.shift();
172
+ if (this.opts.toolExclude.has(toolName)) return;
173
+ this.recentSigs.push({ sig: signature(toolName, input), ts: Date.now() });
174
+ this.evictSigs();
124
175
  }
125
176
 
126
177
  recordResult(toolName: string, error: boolean): void {
127
- this.recentResults.push({ tool: toolName, error });
128
- if (this.recentResults.length > this.opts.windowSize) this.recentResults.shift();
178
+ this.recentResults.push({ tool: toolName, error, ts: Date.now() });
179
+ this.evictResults();
129
180
  }
130
181
 
131
182
  /**
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
- * $
183
+ * Consecutive verbatim/near-identical assistant text (whitespace-normalized).
184
+ * The controller turns the first detection into a steer, later ones into
185
+ * aborts. $
136
186
  * Detects the text-only loop shape (model re-emits the same sentence
137
187
  * forever, e.g. goal-function loops) that identical-tool-call detection
138
188
  * never sees. Liquid.ai's Antidoom mines loops as 'a section repeats at
@@ -156,6 +206,24 @@ export class LoopDetector {
156
206
  });
157
207
  }
158
208
 
209
+ // Cross-message window repeat (exact): the same text reappearing
210
+ // textRepeatThreshold times within the recent-text window. Catches text
211
+ // CYCLES that never form a 3-consecutive streak and are too short for
212
+ // repeatedSegment. Mirrors the tool-signature window in check().
213
+ const exactCount = this.recentTexts.filter((t) => t.text === norm).length + 1;
214
+ this.recentTexts.push({ text: norm, ts: Date.now() });
215
+ this.evictTexts();
216
+ if (this.recentTexts.filter((t) => t.text === norm).length >= 2) {
217
+ this.wastedTokens += estimateTokens(norm);
218
+ }
219
+ if (exactCount >= this.opts.textRepeatThreshold) {
220
+ return Result.ok({
221
+ reason:
222
+ `Assistant sent identical text ${exactCount} times within the last ${this.opts.windowSize} messages ` +
223
+ `("${truncate(norm, 80)}"). You appear to be in a loop.`,
224
+ });
225
+ }
226
+
159
227
  // Cross-message streak: consecutive assistant texts that are identical
160
228
  // OR near-identical (token-overlap similarity). Catches loops where the
161
229
  // model slightly rephrases each turn ("inspect the failing test" →
@@ -172,6 +240,23 @@ export class LoopDetector {
172
240
  `("${truncate(norm, 80)}"). You appear to be in a loop.`,
173
241
  });
174
242
  }
243
+
244
+ // Cross-message window repeat (near-identical): a rotating set of
245
+ // rephrased commands ("Run the test." / "Run tests now." / "Let me run
246
+ // the test.") that is never identical and never consecutive, so both the
247
+ // exact window check and the streak above miss it. Similar, non-identical
248
+ // texts accumulating to textRepeatThreshold within the window fire here.
249
+ const similarCount =
250
+ this.recentTexts.filter(
251
+ (t) => t.text !== norm && tokenSimilarity(norm, t.text) >= TEXT_SIMILARITY_THRESHOLD,
252
+ ).length + 1;
253
+ if (similarCount >= this.opts.textRepeatThreshold) {
254
+ return Result.ok({
255
+ reason:
256
+ `Assistant sent near-identical text ${similarCount} times within the last ${this.opts.windowSize} messages ` +
257
+ `("${truncate(norm, 80)}"). You appear to be in a loop.`,
258
+ });
259
+ }
175
260
  return Result.err(undefined);
176
261
  }
177
262
 
@@ -186,13 +271,80 @@ export class LoopDetector {
186
271
  return n;
187
272
  }
188
273
 
274
+ /** Error share of all in-window results for a tool. */
275
+ private failRate(toolName: string): { calls: number; errors: number; rate: number } {
276
+ let calls = 0;
277
+ let errors = 0;
278
+ for (const r of this.recentResults) {
279
+ if (r.tool !== toolName) continue;
280
+ calls++;
281
+ if (r.error) errors++;
282
+ }
283
+ return { calls, errors, rate: calls ? errors / calls : 0 };
284
+ }
285
+
286
+ private evictSigs(): void {
287
+ if (this.opts.timeWindowMs > 0) {
288
+ const cutoff = Date.now() - this.opts.timeWindowMs;
289
+ this.recentSigs = this.recentSigs.filter((s) => s.ts >= cutoff);
290
+ }
291
+ while (this.recentSigs.length > this.opts.windowSize) this.recentSigs.shift();
292
+ }
293
+
294
+ private evictResults(): void {
295
+ if (this.opts.timeWindowMs > 0) {
296
+ const cutoff = Date.now() - this.opts.timeWindowMs;
297
+ this.recentResults = this.recentResults.filter((r) => r.ts >= cutoff);
298
+ }
299
+ while (this.recentResults.length > this.opts.windowSize) this.recentResults.shift();
300
+ }
301
+
302
+ private evictTexts(): void {
303
+ if (this.opts.timeWindowMs > 0) {
304
+ const cutoff = Date.now() - this.opts.timeWindowMs;
305
+ this.recentTexts = this.recentTexts.filter((t) => t.ts >= cutoff);
306
+ }
307
+ while (this.recentTexts.length > this.opts.windowSize) this.recentTexts.shift();
308
+ }
309
+
310
+ /** Estimated tokens burned on redundant repeats (feature B). */
311
+ wastedTokensCount(): number {
312
+ return this.wastedTokens;
313
+ }
314
+
315
+ /** Human-readable window introspection for /loopcheck (feature F). */
316
+ diagnostics(): string {
317
+ const sigCounts = new Map<string, number>();
318
+ for (const s of this.recentSigs) sigCounts.set(s.sig, (sigCounts.get(s.sig) ?? 0) + 1);
319
+ const topSigs = [...sigCounts.entries()]
320
+ .sort((a, b) => b[1] - a[1])
321
+ .slice(0, 3)
322
+ .map(([s, n]) => `${truncate(s, 40)} x${n}`)
323
+ .join(", ");
324
+
325
+ const textCounts = new Map<string, number>();
326
+ for (const t of this.recentTexts) textCounts.set(t.text, (textCounts.get(t.text) ?? 0) + 1);
327
+ const topTexts = [...textCounts.entries()]
328
+ .sort((a, b) => b[1] - a[1])
329
+ .slice(0, 3)
330
+ .map(([s, n]) => `"${truncate(s, 24)}" x${n}`)
331
+ .join(", ");
332
+
333
+ return (
334
+ `calls[${this.recentSigs.length}] ${topSigs || "none"}; ` +
335
+ `texts[${this.recentTexts.length}] ${topTexts || "none"}; ` +
336
+ `wastedTokens=${this.wastedTokens}`
337
+ );
338
+ }
339
+
189
340
  reset(): void {
190
341
  this.recentSigs = [];
191
342
  this.recentResults = [];
192
343
  this.blockedBySig.clear();
344
+ this.recentTexts = [];
193
345
  this.lastText = null;
194
346
  this.textStreak = 0;
195
- this.textFired = false;
347
+ this.wastedTokens = 0;
196
348
  }
197
349
 
198
350
  summary(): string {
@@ -218,6 +370,26 @@ export const MIN_REPEAT_CHUNK = 16;
218
370
  /** Jaccard similarity threshold for "near-identical" consecutive texts. */
219
371
  export const TEXT_SIMILARITY_THRESHOLD = 0.55;
220
372
 
373
+ /** Stable string form of an arbitrary tool input (used for token estimation). */
374
+ export function stringify(input: unknown): string {
375
+ if (typeof input === "string") return input;
376
+ try {
377
+ return JSON.stringify(input);
378
+ } catch {
379
+ return String(input);
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Rough token estimate for cost accounting (feature B): ~4 chars per token,
385
+ * like the widely-used wc/4 rule. Purposely crude — it only needs to be
386
+ * monotonic so the same work always reports the same order of magnitude.
387
+ */
388
+ export function estimateTokens(text: string): number {
389
+ if (!text) return 0;
390
+ return Math.max(1, Math.ceil(text.length / 4));
391
+ }
392
+
221
393
  /**
222
394
  * Token-set Jaccard similarity of two texts (case/whitespace-insensitive).
223
395
  * Short tokens (< 3 chars: "a", "me", "to") are ignored to reduce noise.
@@ -271,6 +443,10 @@ if (import.meta.main) {
271
443
  failThreshold: 3,
272
444
  windowSize: 10,
273
445
  textRepeatThreshold: 3,
446
+ timeWindowMs: 0,
447
+ failRateThreshold: 0,
448
+ failRateMinCalls: 3,
449
+ toolExclude: new Set(),
274
450
  };
275
451
  const d: LoopDetector = new LoopDetector(opts);
276
452
 
@@ -330,7 +506,7 @@ if (import.meta.main) {
330
506
  const textHit = d.checkText("Now update buildProgram.");
331
507
  assert.ok(textHit.isOk(), "3rd identical text should fire");
332
508
  if (textHit.isOk()) {
333
- assert.match(textHit.value.reason, /identical or near-identical text 3 times/);
509
+ assert.match(textHit.value.reason, /identical text 3 times within the last/);
334
510
  }
335
511
  assert.ok(
336
512
  d.checkText("Now update buildProgram.").isOk(),
@@ -345,23 +521,42 @@ if (import.meta.main) {
345
521
  const wsHit = d.checkText(" Read the region: ");
346
522
  assert.ok(wsHit.isOk(), "whitespace-normalized repeats should fire");
347
523
 
348
- // 9. a different message breaks the streak (need 3 consecutive AFTER the break to fire)
524
+ // 9. window semantics: < textRepeatThreshold recurrences in the window do
525
+ // NOT fire, even with other messages interleaved; the 3rd recurrence does.
349
526
  d.reset();
350
527
  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");
528
+ d.checkText("B"); // different message
529
+ d.checkText("A"); // 2nd A in window
530
+ assert.ok(d.checkText("C").isErr(), "2 As in the window must not fire");
531
+ assert.ok(d.checkText("A").isOk(), "3rd A in the window fires");
357
532
 
358
533
  // 10. empty/blank text is ignored as a loop signal
359
534
  d.reset();
360
535
  d.checkText("");
361
536
  d.checkText(" ");
362
537
  assert.ok(d.checkText("").isErr(), "blank text must not fire");
363
-
364
- // 11. threshold 1 is clamped away (would brick the agent)
538
+ // 11. text CYCLES: a small set of short near-identical commands rotating
539
+ // ("Let me run. GO." / "Run. GO." / "GO.") never forms a consecutive
540
+ // streak, but the same text reappears >= threshold within the window.
541
+ d.reset();
542
+ const cycle = [
543
+ "Let me run. GO.",
544
+ "Run. GO.",
545
+ "GO.",
546
+ "Run. GO.",
547
+ "GO.",
548
+ "Let me run. GO.",
549
+ "Run. GO.",
550
+ "GO.",
551
+ "Run. GO.",
552
+ "GO.",
553
+ "Let me run. GO.",
554
+ ];
555
+ let fired = false;
556
+ for (const m of cycle) if (d.checkText(m).isOk()) fired = true;
557
+ assert.ok(fired, "rotating near-identical cycle must fire");
558
+
559
+ // 12. threshold 1 is clamped away (would brick the agent)
365
560
  const clamped = readOptions({
366
561
  PI_ANTI_LOOP_REPEATS: "1",
367
562
  PI_ANTI_LOOP_FAILS: "0",
@@ -73,7 +73,8 @@ export default function (pi: PiLike): void {
73
73
  pi.on("session_start", () => reset());
74
74
 
75
75
  // Fresh counters per user prompt: only the loop happening *right now* counts.
76
- pi.on("before_agent_start", () => reset());
76
+ // Internal reset keeps session-scoped steers/aborts/resume budget.
77
+ pi.on("before_agent_start", () => controller.reset());
77
78
 
78
79
  pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
79
80
  const outcome = controller.onToolCall(event.toolName, event.input, event.toolCallId);
@@ -118,11 +119,22 @@ export default function (pi: PiLike): void {
118
119
  pi.registerCommand("loopcheck", {
119
120
  description: "Anti-doom-loop status; `/loopcheck reset` clears counters",
120
121
  handler: async (args: string, ctx: CommandCtxLite) => {
121
- if (args.trim().toLowerCase() === "reset") {
122
+ const arg = args.trim().toLowerCase();
123
+ if (arg === "reset") {
122
124
  reset();
123
125
  ctx.ui.notify("Anti-doom-loop: counters reset", "info");
124
126
  return;
125
127
  }
128
+ if (arg === "suspend") {
129
+ controller.suspend();
130
+ ctx.ui.notify("Anti-doom-loop: suspended until the next prompt", "info");
131
+ return;
132
+ }
133
+ if (arg === "resume") {
134
+ controller.resume();
135
+ ctx.ui.notify("Anti-doom-loop: resumed", "info");
136
+ return;
137
+ }
126
138
  ctx.ui.notify(controller.status(), "info");
127
139
  },
128
140
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anti-doom-loop",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
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",
@@ -17,7 +17,8 @@
17
17
  },
18
18
  "files": [
19
19
  "extensions",
20
- "scripts"
20
+ "scripts",
21
+ "CHANGELOG.md"
21
22
  ],
22
23
  "type": "module",
23
24
  "main": "extensions/index.ts",
@@ -35,7 +36,8 @@
35
36
  "guard": "node scripts/guard-publish.ts",
36
37
  "lint": "oxlint --deny-warnings",
37
38
  "format": "oxfmt .",
38
- "format:check": "oxfmt --check ."
39
+ "format:check": "oxfmt --check .",
40
+ "prepare": "effect-tsgo patch --no-typescript --oxlint"
39
41
  },
40
42
  "dependencies": {
41
43
  "better-result": "^3.0.0",
@@ -43,9 +45,11 @@
43
45
  },
44
46
  "devDependencies": {
45
47
  "@earendil-works/pi-coding-agent": "*",
48
+ "@effect/tsgo": "^0.36.4",
46
49
  "@types/node": "^22.0.0",
47
50
  "oxfmt": "^0.62.0",
48
51
  "oxlint": "^1.77.0",
52
+ "oxlint-tsgolint": "^7.0.2001",
49
53
  "typescript": "^5.6.0"
50
54
  },
51
55
  "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
  }