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 +29 -0
- package/README.md +28 -16
- package/extensions/controller.ts +19 -14
- package/extensions/detector.ts +243 -39
- package/extensions/index.ts +14 -3
- package/package.json +7 -3
- package/scripts/guard-publish.ts +8 -8
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
|
|
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
|
@@ -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:
|
|
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:
|
|
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}
|
|
157
|
-
|
|
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:
|
|
165
|
-
|
|
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
|
}
|
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,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:
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
144
|
+
const consecutiveFails = this.consecutiveFails(toolName);
|
|
145
|
+
const rate = this.failRate(toolName);
|
|
96
146
|
|
|
97
|
-
|
|
98
|
-
|
|
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 (
|
|
107
|
-
reasons.push(`"${toolName}" failed ${
|
|
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:
|
|
122
|
-
this.
|
|
123
|
-
|
|
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
|
-
|
|
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).
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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.
|
|
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
|
|
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.
|
|
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("
|
|
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");
|
|
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
|
-
//
|
|
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",
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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": {
|
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
|
}
|