pi-anti-doom-loop 0.0.5 → 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 +19 -0
- package/README.md +28 -16
- package/extensions/controller.ts +5 -2
- package/extensions/detector.ts +224 -29
- package/package.json +5 -2
- package/scripts/guard-publish.ts +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to **pi-anti-doom-loop**.
|
|
4
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
|
+
|
|
5
24
|
## [0.0.5] — 2026-08-05
|
|
6
25
|
|
|
7
26
|
### Added
|
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
|
|
19
|
-
|
|
|
20
|
-
| Same `(tool, args)` repeated
|
|
21
|
-
| Same tool failing consecutively
|
|
22
|
-
| Same assistant text verbatim
|
|
23
|
-
| Same sentence inside ONE message
|
|
24
|
-
| Near-identical text (rephrasing)
|
|
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
|
|
45
|
-
|
|
|
46
|
-
| `PI_ANTI_LOOP_REPEATS`
|
|
47
|
-
| `PI_ANTI_LOOP_FAILS`
|
|
48
|
-
| `PI_ANTI_LOOP_TEXT_REPEATS`
|
|
49
|
-
| `PI_ANTI_LOOP_WINDOW`
|
|
50
|
-
| `
|
|
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.
|
|
80
|
+
Requires Node 22.18+ (plain `node` runs the TS self-check).
|
|
69
81
|
|
|
70
82
|
```bash
|
|
71
83
|
npm install
|
package/extensions/controller.ts
CHANGED
|
@@ -151,10 +151,13 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
151
151
|
status() {
|
|
152
152
|
const o = detector.opts;
|
|
153
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(",")}]` : "";
|
|
154
157
|
return (
|
|
155
158
|
`anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, ` +
|
|
156
|
-
`fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}
|
|
157
|
-
|
|
159
|
+
`fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}${rate}${time}${excl}. ` +
|
|
160
|
+
`${detector.diagnostics()} steers=${steers} aborts=${aborts}${s}`
|
|
158
161
|
);
|
|
159
162
|
},
|
|
160
163
|
};
|
package/extensions/detector.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
130
|
+
const consecutiveFails = this.consecutiveFails(toolName);
|
|
131
|
+
const rate = this.failRate(toolName);
|
|
96
132
|
|
|
97
|
-
|
|
98
|
-
|
|
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 (
|
|
107
|
-
reasons.push(`"${toolName}" failed ${
|
|
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.
|
|
123
|
-
|
|
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
|
-
|
|
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).
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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.
|
|
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
|
|
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.
|
|
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("
|
|
352
|
-
d.checkText("
|
|
353
|
-
d.checkText("
|
|
354
|
-
assert.ok(d.checkText("A").
|
|
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
|
-
//
|
|
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",
|
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.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",
|
|
@@ -36,7 +36,8 @@
|
|
|
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",
|
|
@@ -44,9 +45,11 @@
|
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@effect/tsgo": "^0.36.4",
|
|
47
49
|
"@types/node": "^22.0.0",
|
|
48
50
|
"oxfmt": "^0.62.0",
|
|
49
51
|
"oxlint": "^1.77.0",
|
|
52
|
+
"oxlint-tsgolint": "^7.0.2001",
|
|
50
53
|
"typescript": "^5.6.0"
|
|
51
54
|
},
|
|
52
55
|
"peerDependencies": {
|
package/scripts/guard-publish.ts
CHANGED
|
@@ -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: () =>
|
|
67
|
-
}).pipe(Effect.
|
|
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
|
|
86
|
+
return { message: "registry unreachable" };
|
|
87
87
|
},
|
|
88
|
-
}).pipe(Effect.
|
|
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
|
}
|