pi-anti-doom-loop 0.0.1
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/LICENSE +21 -0
- package/README.md +95 -0
- package/extensions/detector.ts +301 -0
- package/extensions/index.ts +98 -0
- package/package.json +62 -0
- package/scripts/guard-publish.ts +145 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 irfandi m (join.mantap@gmail.com) / github.com/irfndi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# pi-anti-doom-loop
|
|
2
|
+
|
|
3
|
+
Stop agent doom loops in [pi](https://pi.dev/) before they burn tokens.
|
|
4
|
+
|
|
5
|
+
Cheap models sometimes get stuck repeating the same cheap tool call — `grep`
|
|
6
|
+
the same file, re-run the same failing command — with no progress. Each
|
|
7
|
+
iteration is so cheap nobody notices until the bill mounts. This extension
|
|
8
|
+
watches every tool call and blocks the loop at the source.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:pi-anti-doom-loop
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What it detects
|
|
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
|
+
|
|
24
|
+
Blocks hand the model an instructive reason ("change your approach, use a
|
|
25
|
+
different tool, or ask the user"). If the model ignores the block and re-issues
|
|
26
|
+
the exact same call, the turn is **aborted** and you are notified. A verbatim
|
|
27
|
+
text loop (no tool calls involved) aborts the run immediately with a
|
|
28
|
+
notification.
|
|
29
|
+
|
|
30
|
+
Counters reset on every user prompt, so a task legitimately repeated later in
|
|
31
|
+
the same session is never a false positive.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
Environment variables, read at session/prompt start:
|
|
36
|
+
|
|
37
|
+
| Variable | Default | Meaning |
|
|
38
|
+
| --------------------------- | ------- | -------------------------------------------------- |
|
|
39
|
+
| `PI_ANTI_LOOP_REPEATS` | `3` | Identical-call block threshold |
|
|
40
|
+
| `PI_ANTI_LOOP_FAILS` | `3` | Consecutive-failure block threshold |
|
|
41
|
+
| `PI_ANTI_LOOP_TEXT_REPEATS` | `3` | Consecutive identical assistant texts before abort |
|
|
42
|
+
| `PI_ANTI_LOOP_WINDOW` | `10` | How many recent calls/results are inspected |
|
|
43
|
+
| `PI_ANTI_LOOP_DISABLE` | — | Set to `1` to disable the extension entirely |
|
|
44
|
+
|
|
45
|
+
## Command
|
|
46
|
+
|
|
47
|
+
- `/loopcheck` — show current thresholds and counters
|
|
48
|
+
- `/loopcheck reset` — clear counters
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
Everything hooks into the `tool_call` / `tool_result` events; detection is a
|
|
53
|
+
small sliding-window counter (see `extensions/detector.ts`) with no state kept
|
|
54
|
+
between user prompts. Works with any model — cheap models just trigger it more
|
|
55
|
+
often.
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
Requires Node 22.6+ (plain `node` runs the TS self-check).
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm install
|
|
63
|
+
npm test # detector self-check (pure Node, no deps)
|
|
64
|
+
npm run check # npm test + tsc + oxlint --deny-warnings + oxfmt
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Runtime deps: `better-result` (detector decisions) and `effect` v4 (the release guard).
|
|
68
|
+
|
|
69
|
+
> `peerDependencies` pins `@earendil-works/pi-coding-agent` at `"*"` on purpose — the
|
|
70
|
+
> [pi packages docs](https://pi.dev/docs/latest/packages) require an unbounded range for
|
|
71
|
+
> pi-core packages (pi provides them at runtime). The extension loads `.ts` directly via
|
|
72
|
+
> jiti, so no build step ships; `prepublishOnly` runs the full quality gate before publish.
|
|
73
|
+
|
|
74
|
+
> When a call is blocked, escalation still works without recording it in the window:
|
|
75
|
+
> re-issuing the identical call increments a per-signature block counter and aborts the
|
|
76
|
+
> turn on the second block. Thresholds are clamped to a minimum of 2 so a bad config
|
|
77
|
+
> can never brick the agent.
|
|
78
|
+
|
|
79
|
+
## Releasing
|
|
80
|
+
|
|
81
|
+
Publishing is handled by the GitHub Actions workflow [`.github/workflows/release.yml`](.github/workflows/release.yml), guarded against version drift:
|
|
82
|
+
|
|
83
|
+
1. Add an npm **Automation** token as the `NPM_TOKEN` repo secret (Settings → Secrets and variables → Actions, or `gh secret set NPM_TOKEN`).
|
|
84
|
+
2. Bump `version` in `package.json`, commit, then tag and push:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git tag v0.0.1
|
|
88
|
+
git push origin v0.0.1
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
CI runs the quality gate, then the **version bump guard** (`npm run guard`): it blocks publishing if the version is already on npm or the tag doesn't match `package.json`. After the first publish, the [pi.dev gallery](https://pi.dev/packages) picks the package up automatically via the `pi-package` keyword.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure loop-detection logic — no pi imports, so it is runnable/testable
|
|
3
|
+
* standalone (`node extensions/detector.ts` self-check at the bottom).
|
|
4
|
+
*
|
|
5
|
+
* Detects two "doom loop" signatures, both scoped to a sliding window so a
|
|
6
|
+
* genuinely repeated task spread over time never trips it:
|
|
7
|
+
*
|
|
8
|
+
* 1. The same tool called with identical arguments `repeatThreshold` times
|
|
9
|
+
* (the classic "grep the same file 10 times" loop).
|
|
10
|
+
* 2. The same tool failing `failThreshold` consecutive times (blind retry
|
|
11
|
+
* of a flaky/ungrounded operation).
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { Result } from "better-result";
|
|
15
|
+
|
|
16
|
+
export interface LoopOptions {
|
|
17
|
+
/** Identical (tool, args) occurrences that trigger a block. */
|
|
18
|
+
repeatThreshold: number;
|
|
19
|
+
/** Consecutive same-tool errors that trigger a block. */
|
|
20
|
+
failThreshold: number;
|
|
21
|
+
/** How many recent calls/results are inspected for repetition. */
|
|
22
|
+
windowSize: number;
|
|
23
|
+
/** Consecutive verbatim assistant messages that trigger an abort. */
|
|
24
|
+
textRepeatThreshold: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_OPTIONS: LoopOptions = {
|
|
28
|
+
repeatThreshold: 3,
|
|
29
|
+
failThreshold: 3,
|
|
30
|
+
windowSize: 10,
|
|
31
|
+
textRepeatThreshold: 3,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function readOptions(env: Record<string, string | undefined> = process.env): LoopOptions {
|
|
35
|
+
// ponytail: min 2 — a threshold of 1 would block every tool call / abort on
|
|
36
|
+
// the first message, bricking the agent (deepsec finding).
|
|
37
|
+
const num = (key: string, fallback: number): number => {
|
|
38
|
+
const raw = env[key];
|
|
39
|
+
if (!raw) return fallback;
|
|
40
|
+
const n = Number(raw);
|
|
41
|
+
return Number.isFinite(n) && n >= 2 ? n : fallback;
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
repeatThreshold: num("PI_ANTI_LOOP_REPEATS", DEFAULT_OPTIONS.repeatThreshold),
|
|
45
|
+
failThreshold: num("PI_ANTI_LOOP_FAILS", DEFAULT_OPTIONS.failThreshold),
|
|
46
|
+
windowSize: num("PI_ANTI_LOOP_WINDOW", DEFAULT_OPTIONS.windowSize),
|
|
47
|
+
textRepeatThreshold: num("PI_ANTI_LOOP_TEXT_REPEATS", DEFAULT_OPTIONS.textRepeatThreshold),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Keys sorted recursively so {a:1,b:2} and {b:2,a:1} share a signature. */
|
|
52
|
+
export function canonical(input: unknown): string {
|
|
53
|
+
if (input === null || typeof input !== "object") return JSON.stringify(input);
|
|
54
|
+
if (Array.isArray(input)) return `[${input.map(canonical).join(",")}]`;
|
|
55
|
+
const obj = input as Record<string, unknown>;
|
|
56
|
+
return `{${Object.keys(obj)
|
|
57
|
+
.sort()
|
|
58
|
+
.map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`)
|
|
59
|
+
.join(",")}}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function signature(toolName: string, input: unknown): string {
|
|
63
|
+
return `${toolName}:${canonical(input)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface BlockDecision {
|
|
67
|
+
/** Reason shown to the LLM as the (blocked) tool result. */
|
|
68
|
+
reason: string;
|
|
69
|
+
/** True when this exact call was already blocked before — caller should abort the turn. */
|
|
70
|
+
escalate: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Call `check` in `tool_call` (before executing). If it returns a decision,
|
|
75
|
+
* block the call. Call `record` only for calls that were NOT blocked, and
|
|
76
|
+
* `recordResult` in `tool_result` for executed calls.
|
|
77
|
+
*/
|
|
78
|
+
export class LoopDetector {
|
|
79
|
+
readonly opts: LoopOptions;
|
|
80
|
+
private recentSigs: string[] = [];
|
|
81
|
+
private recentResults: { tool: string; error: boolean }[] = [];
|
|
82
|
+
private blockedBySig = new Map<string, number>();
|
|
83
|
+
private lastText: string | null = null;
|
|
84
|
+
private textStreak = 0;
|
|
85
|
+
private textFired = false;
|
|
86
|
+
|
|
87
|
+
constructor(opts: LoopOptions = DEFAULT_OPTIONS) {
|
|
88
|
+
this.opts = opts;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
check(toolName: string, input: unknown): Result<BlockDecision, undefined> {
|
|
92
|
+
const sig = signature(toolName, input);
|
|
93
|
+
const repeats = this.recentSigs.filter((s) => s === sig).length;
|
|
94
|
+
const total = repeats + 1; // including this call
|
|
95
|
+
const fails = this.consecutiveFails(toolName);
|
|
96
|
+
|
|
97
|
+
if (total < this.opts.repeatThreshold && fails < this.opts.failThreshold)
|
|
98
|
+
return Result.err(undefined);
|
|
99
|
+
|
|
100
|
+
const reasons: string[] = [];
|
|
101
|
+
if (total >= this.opts.repeatThreshold) {
|
|
102
|
+
reasons.push(
|
|
103
|
+
`"${toolName}" was called with identical arguments ${total} times in the last ${this.opts.windowSize} tool calls with no change`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (fails >= this.opts.failThreshold) {
|
|
107
|
+
reasons.push(`"${toolName}" failed ${fails} consecutive times`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const blockedCount = (this.blockedBySig.get(sig) ?? 0) + 1;
|
|
111
|
+
this.blockedBySig.set(sig, blockedCount);
|
|
112
|
+
|
|
113
|
+
return Result.ok({
|
|
114
|
+
reason:
|
|
115
|
+
reasons.join("; ") +
|
|
116
|
+
`. BLOCKED by anti-doom-loop — you appear to be looping. Change your approach, use a different tool, or ask the user.`,
|
|
117
|
+
escalate: blockedCount > 1,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
record(toolName: string, input: unknown): void {
|
|
122
|
+
this.recentSigs.push(signature(toolName, input));
|
|
123
|
+
if (this.recentSigs.length > this.opts.windowSize) this.recentSigs.shift();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
recordResult(toolName: string, error: boolean): void {
|
|
127
|
+
this.recentResults.push({ tool: toolName, error });
|
|
128
|
+
if (this.recentResults.length > this.opts.windowSize) this.recentResults.shift();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
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
|
+
* $
|
|
136
|
+
* Detects the text-only loop shape (model re-emits the same sentence
|
|
137
|
+
* forever, e.g. goal-function loops) that identical-tool-call detection
|
|
138
|
+
* never sees. Liquid.ai's Antidoom mines loops as 'a section repeats at
|
|
139
|
+
* least four times'; we use 3 and no length floor because at runtime each
|
|
140
|
+
* repetition already burns tokens.
|
|
141
|
+
*/
|
|
142
|
+
checkText(text: string): Result<{ reason: string }, undefined> {
|
|
143
|
+
const norm = normalizeText(text);
|
|
144
|
+
if (!norm) return Result.err(undefined); // blank text is not a loop signal
|
|
145
|
+
this.textStreak = norm === this.lastText ? this.textStreak + 1 : 1;
|
|
146
|
+
this.lastText = norm;
|
|
147
|
+
if (this.textStreak >= this.opts.textRepeatThreshold && !this.textFired) {
|
|
148
|
+
this.textFired = true;
|
|
149
|
+
return Result.ok({
|
|
150
|
+
reason:
|
|
151
|
+
`Assistant replied with identical text ${this.textStreak} times in a row ("${truncate(norm, 80)}"). ` +
|
|
152
|
+
`You appear to be in a loop — this run is aborted.`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return Result.err(undefined);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Trail of results that are errors of this same tool (consecutive). */
|
|
159
|
+
private consecutiveFails(toolName: string): number {
|
|
160
|
+
let n = 0;
|
|
161
|
+
for (let i = this.recentResults.length - 1; i >= 0; i--) {
|
|
162
|
+
const r = this.recentResults[i];
|
|
163
|
+
if (!r.error || r.tool !== toolName) break;
|
|
164
|
+
n++;
|
|
165
|
+
}
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
reset(): void {
|
|
170
|
+
this.recentSigs = [];
|
|
171
|
+
this.recentResults = [];
|
|
172
|
+
this.blockedBySig.clear();
|
|
173
|
+
this.lastText = null;
|
|
174
|
+
this.textStreak = 0;
|
|
175
|
+
this.textFired = false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
summary(): string {
|
|
179
|
+
const blocked = [...this.blockedBySig.values()].reduce((a, b) => a + b, 0);
|
|
180
|
+
const text = this.textStreak > 1 ? `, text streak ${this.textStreak}` : "";
|
|
181
|
+
return `window: ${this.recentSigs.length}/${this.opts.windowSize} calls, ${this.recentResults.length} results, blocked ${blocked} time(s)${text}`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Collapse runs of whitespace so formatting drift never hides a verbatim loop. */
|
|
186
|
+
export function normalizeText(text: string): string {
|
|
187
|
+
return text.trim().replace(/\s+/g, " ");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** First `max` chars of a single-line string, with an ellipsis. */
|
|
191
|
+
export function truncate(text: string, max: number): string {
|
|
192
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- self-check (runs under `node extensions/detector.ts`, skipped when loaded by pi) ---
|
|
196
|
+
if (import.meta.main) {
|
|
197
|
+
const opts: LoopOptions = {
|
|
198
|
+
repeatThreshold: 3,
|
|
199
|
+
failThreshold: 3,
|
|
200
|
+
windowSize: 10,
|
|
201
|
+
textRepeatThreshold: 3,
|
|
202
|
+
};
|
|
203
|
+
const d: LoopDetector = new LoopDetector(opts);
|
|
204
|
+
|
|
205
|
+
// 1. identical calls: 2 pass, 3rd is blocked; retry of a blocked call escalates
|
|
206
|
+
assert.ok(d.check("bash", { command: "grep foo bar.ts" }).isErr(), "1st call passes");
|
|
207
|
+
d.record("bash", { command: "grep foo bar.ts" });
|
|
208
|
+
assert.ok(d.check("bash", { command: "grep foo bar.ts" }).isErr(), "2nd call passes");
|
|
209
|
+
d.record("bash", { command: "grep foo bar.ts" });
|
|
210
|
+
const hit = d.check("bash", { command: "grep foo bar.ts" });
|
|
211
|
+
assert.ok(hit.isOk(), "3rd identical call should block");
|
|
212
|
+
if (hit.isOk()) {
|
|
213
|
+
assert.equal(hit.value.escalate, false, "first block does not escalate");
|
|
214
|
+
assert.match(hit.value.reason, /identical arguments 3 times/);
|
|
215
|
+
}
|
|
216
|
+
// LLM ignores the block and retries the exact same call: escalate
|
|
217
|
+
const hit2 = d.check("bash", { command: "grep foo bar.ts" });
|
|
218
|
+
assert.ok(hit2.isOk() && hit2.value.escalate, "retry of a blocked call should escalate");
|
|
219
|
+
|
|
220
|
+
// 2. different arguments are not a loop
|
|
221
|
+
d.reset();
|
|
222
|
+
assert.ok(d.check("read", { path: "a.ts" }).isErr());
|
|
223
|
+
d.record("read", { path: "a.ts" });
|
|
224
|
+
assert.ok(d.check("read", { path: "b.ts" }).isErr());
|
|
225
|
+
d.record("read", { path: "b.ts" });
|
|
226
|
+
assert.ok(d.check("read", { path: "a.ts" }).isErr(), "arg order/counter: only same args count");
|
|
227
|
+
|
|
228
|
+
// 3. key order does not matter
|
|
229
|
+
assert.equal(signature("read", { a: 1, b: 2 }), signature("read", { b: 2, a: 1 }));
|
|
230
|
+
|
|
231
|
+
// 4. consecutive same-tool failures trigger a block
|
|
232
|
+
d.reset();
|
|
233
|
+
d.recordResult("bash", true);
|
|
234
|
+
d.recordResult("bash", true);
|
|
235
|
+
d.recordResult("bash", true);
|
|
236
|
+
const failHit = d.check("bash", { command: "npm test" });
|
|
237
|
+
assert.ok(failHit.isOk(), "3 consecutive failures should block");
|
|
238
|
+
if (failHit.isOk()) assert.match(failHit.value.reason, /failed 3 consecutive times/);
|
|
239
|
+
|
|
240
|
+
// 5. an interleaved success breaks the failure streak
|
|
241
|
+
d.reset();
|
|
242
|
+
d.recordResult("bash", true);
|
|
243
|
+
d.recordResult("bash", false);
|
|
244
|
+
d.recordResult("bash", true);
|
|
245
|
+
d.recordResult("bash", true);
|
|
246
|
+
assert.ok(d.check("bash", { command: "npm test" }).isErr(), "success breaks the streak");
|
|
247
|
+
|
|
248
|
+
// 6. window eviction: stale repeats no longer count
|
|
249
|
+
d.reset();
|
|
250
|
+
for (let i = 0; i < opts.windowSize; i++) d.record("bash", { command: `cmd ${i}` });
|
|
251
|
+
assert.ok(d.check("bash", { command: "cmd 0" }).isErr(), "evicted repeats do not count");
|
|
252
|
+
|
|
253
|
+
// 7. verbatim assistant text loop: fires once at the 3rd identical message
|
|
254
|
+
d.reset();
|
|
255
|
+
assert.ok(d.checkText("Now update buildProgram.").isErr(), "1st text passes");
|
|
256
|
+
assert.ok(d.checkText("Now update buildProgram.").isErr(), "2nd text passes");
|
|
257
|
+
const textHit = d.checkText("Now update buildProgram.");
|
|
258
|
+
assert.ok(textHit.isOk(), "3rd identical text should fire");
|
|
259
|
+
if (textHit.isOk()) {
|
|
260
|
+
assert.match(textHit.value.reason, /identical text 3 times/);
|
|
261
|
+
assert.match(textHit.value.reason, /aborted/);
|
|
262
|
+
}
|
|
263
|
+
assert.ok(d.checkText("Now update buildProgram.").isErr(), "fires only once per run");
|
|
264
|
+
|
|
265
|
+
// 8. whitespace drift does not hide a verbatim loop
|
|
266
|
+
d.reset();
|
|
267
|
+
d.checkText("Read the region:");
|
|
268
|
+
d.checkText("Read the region:");
|
|
269
|
+
const wsHit = d.checkText(" Read the region: ");
|
|
270
|
+
assert.ok(wsHit.isOk(), "whitespace-normalized repeats should fire");
|
|
271
|
+
|
|
272
|
+
// 9. a different message breaks the streak (need 3 consecutive AFTER the break to fire)
|
|
273
|
+
d.reset();
|
|
274
|
+
d.checkText("A");
|
|
275
|
+
d.checkText("A");
|
|
276
|
+
d.checkText("B"); // breaks the streak
|
|
277
|
+
d.checkText("A");
|
|
278
|
+
assert.ok(d.checkText("A").isErr(), "only 2 consecutive As after the break must not fire");
|
|
279
|
+
const again = d.checkText("A");
|
|
280
|
+
assert.ok(again.isOk(), "3 consecutive As after the break fire");
|
|
281
|
+
|
|
282
|
+
// 10. empty/blank text is ignored as a loop signal
|
|
283
|
+
d.reset();
|
|
284
|
+
d.checkText("");
|
|
285
|
+
d.checkText(" ");
|
|
286
|
+
assert.ok(d.checkText("").isErr(), "blank text must not fire");
|
|
287
|
+
|
|
288
|
+
// 11. threshold 1 is clamped away (would brick the agent)
|
|
289
|
+
const clamped = readOptions({
|
|
290
|
+
PI_ANTI_LOOP_REPEATS: "1",
|
|
291
|
+
PI_ANTI_LOOP_FAILS: "0",
|
|
292
|
+
PI_ANTI_LOOP_TEXT_REPEATS: "-2",
|
|
293
|
+
});
|
|
294
|
+
assert.equal(clamped.repeatThreshold, 3, "1 falls back to default");
|
|
295
|
+
assert.equal(clamped.failThreshold, 3, "0 falls back to default");
|
|
296
|
+
assert.equal(clamped.textRepeatThreshold, 3, "negative falls back to default");
|
|
297
|
+
const two = readOptions({ PI_ANTI_LOOP_REPEATS: "2" });
|
|
298
|
+
assert.equal(two.repeatThreshold, 2, "2 is the minimum accepted");
|
|
299
|
+
|
|
300
|
+
console.log("detector self-check: all assertions passed");
|
|
301
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* anti-doom-loop — pi extension that detects and breaks agent doom loops.
|
|
3
|
+
*
|
|
4
|
+
* Cheap models sometimes repeat the same cheap tool call (grep, read, ls)
|
|
5
|
+
* without progress, silently burning tokens. This extension watches every
|
|
6
|
+
* tool call and blocks loops before they cost anything:
|
|
7
|
+
*
|
|
8
|
+
* - identical (tool, args) repeated `PI_ANTI_LOOP_REPEATS` times (default 3)
|
|
9
|
+
* in the last `PI_ANTI_LOOP_WINDOW` calls → block with an instructive reason
|
|
10
|
+
* - the same tool failing `PI_ANTI_LOOP_FAILS` consecutive times (default 3)
|
|
11
|
+
* → block with a "stop retrying, fix the root cause" reason
|
|
12
|
+
*
|
|
13
|
+
* Blocking hands control back to the model once. If the model re-issues the
|
|
14
|
+
* exact same blocked call, the turn is aborted (escalation).
|
|
15
|
+
*
|
|
16
|
+
* Counters reset on every user prompt, so a task legitimately repeated later
|
|
17
|
+
* in the session is never a false positive. Disable with PI_ANTI_LOOP_DISABLE=1.
|
|
18
|
+
*/
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { LoopDetector, readOptions } from "./detector";
|
|
21
|
+
|
|
22
|
+
export default function (pi: ExtensionAPI): void {
|
|
23
|
+
if (process.env.PI_ANTI_LOOP_DISABLE === "1") return;
|
|
24
|
+
|
|
25
|
+
let detector = new LoopDetector(readOptions());
|
|
26
|
+
let configTxt = settingsTxt(detector);
|
|
27
|
+
const blockedIds = new Set<string>();
|
|
28
|
+
|
|
29
|
+
pi.on("session_start", () => reset());
|
|
30
|
+
|
|
31
|
+
// Fresh counters per user prompt: only the loop happening *right now* counts.
|
|
32
|
+
pi.on("before_agent_start", () => reset());
|
|
33
|
+
|
|
34
|
+
pi.on("tool_call", (event, ctx) => {
|
|
35
|
+
const decision = detector.check(event.toolName, event.input);
|
|
36
|
+
if (decision.isErr()) {
|
|
37
|
+
detector.record(event.toolName, event.input);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const block = decision.value;
|
|
41
|
+
blockedIds.add(event.toolCallId);
|
|
42
|
+
if (block.escalate) {
|
|
43
|
+
ctx.ui.notify("Anti-doom-loop: identical call blocked again — aborting turn", "error");
|
|
44
|
+
ctx.abort();
|
|
45
|
+
}
|
|
46
|
+
return { block: true, reason: block.reason };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Blocked calls never ran, so their (error) result must not count as a failure.
|
|
50
|
+
pi.on("tool_result", (event) => {
|
|
51
|
+
if (blockedIds.has(event.toolCallId)) {
|
|
52
|
+
blockedIds.delete(event.toolCallId);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
detector.recordResult(event.toolName, event.isError === true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Text-only doom loops (model re-emits the same sentence with no tool calls)
|
|
59
|
+
// never reach tool_call. Detect verbatim assistant repeats and abort the run.
|
|
60
|
+
pi.on("message_end", (event, ctx) => {
|
|
61
|
+
const message = event.message;
|
|
62
|
+
if (message.role !== "assistant") return;
|
|
63
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
64
|
+
const text = content
|
|
65
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
66
|
+
.map((c) => c.text)
|
|
67
|
+
.join(" ");
|
|
68
|
+
if (!text) return;
|
|
69
|
+
const hit = detector.checkText(text);
|
|
70
|
+
if (hit.isOk()) {
|
|
71
|
+
ctx.ui.notify(`Anti-doom-loop: ${hit.value.reason}`, "error");
|
|
72
|
+
ctx.abort();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pi.registerCommand("loopcheck", {
|
|
77
|
+
description: "Anti-doom-loop status; `/loopcheck reset` clears counters",
|
|
78
|
+
handler: async (args, ctx) => {
|
|
79
|
+
if (args.trim().toLowerCase() === "reset") {
|
|
80
|
+
reset();
|
|
81
|
+
ctx.ui.notify("Anti-doom-loop: counters reset", "info");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
ctx.ui.notify(`${configTxt}. ${detector.summary()}`, "info");
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function reset(): void {
|
|
89
|
+
detector = new LoopDetector(readOptions());
|
|
90
|
+
configTxt = settingsTxt(detector);
|
|
91
|
+
blockedIds.clear();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function settingsTxt(d: LoopDetector): string {
|
|
96
|
+
const o = d.opts;
|
|
97
|
+
return `anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, fails>=${o.failThreshold}`;
|
|
98
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-anti-doom-loop",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Detect and break agent doom loops in pi: blocks identical repeated tool calls and blind retries before they burn tokens.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"anti-doom-loop",
|
|
7
|
+
"loop-detection",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-package"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/irfndi/pi-anti-doom-loop",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "irfandi <join.mantap@gmail.com> (https://github.com/irfndi)",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/irfndi/pi-anti-doom-loop.git"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"extensions",
|
|
20
|
+
"scripts"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "extensions/index.ts",
|
|
24
|
+
"types": "extensions/index.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./extensions/index.ts"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"check": "npm run test && tsc --noEmit && oxlint --deny-warnings && oxfmt --check .",
|
|
33
|
+
"prepublishOnly": "npm run check",
|
|
34
|
+
"test": "node extensions/detector.ts",
|
|
35
|
+
"guard": "node scripts/guard-publish.ts",
|
|
36
|
+
"lint": "oxlint --deny-warnings",
|
|
37
|
+
"format": "oxfmt .",
|
|
38
|
+
"format:check": "oxfmt --check ."
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"better-result": "^3.0.0",
|
|
42
|
+
"effect": "^4.0.0-beta.103"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"oxfmt": "^0.62.0",
|
|
48
|
+
"oxlint": "^1.77.0",
|
|
49
|
+
"typescript": "^5.6.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22.18"
|
|
56
|
+
},
|
|
57
|
+
"pi": {
|
|
58
|
+
"extensions": [
|
|
59
|
+
"./extensions"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version bump guard — the consistency gate before `npm publish`.
|
|
3
|
+
*
|
|
4
|
+
* Fails (exit 1) unless BOTH hold:
|
|
5
|
+
* 1. The package.json version is NOT already the latest published on npm
|
|
6
|
+
* (npm never overwrites a published version; re-releasing the same
|
|
7
|
+
* version is a no-op at best and a dishonest release at worst).
|
|
8
|
+
* 2. If an expected version is passed (e.g. a tag like v0.0.1), it matches
|
|
9
|
+
* the package.json version — so tags and manifests can't drift.
|
|
10
|
+
*
|
|
11
|
+
* Implemented with Effect v4: each fallible step (read, parse, fetch, guard
|
|
12
|
+
* rules) lives in the typed error channel, so failures short-circuit with a
|
|
13
|
+
* clean GUARD FAIL message and exit code 1.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* node scripts/guard-publish.ts # keep published version < local
|
|
17
|
+
* node scripts/guard-publish.ts v0.0.1 # also require local == 0.0.1
|
|
18
|
+
*/
|
|
19
|
+
import { Effect } from "effect";
|
|
20
|
+
import { readFile } from "node:fs/promises";
|
|
21
|
+
|
|
22
|
+
interface GuardError {
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Manifest {
|
|
27
|
+
name: string;
|
|
28
|
+
version: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fail = (message: string): Effect.Effect<never, GuardError> => Effect.fail({ message });
|
|
32
|
+
|
|
33
|
+
const readManifest: Effect.Effect<string, GuardError> = Effect.tryPromise({
|
|
34
|
+
try: () => readFile(new URL("../package.json", import.meta.url), "utf8"),
|
|
35
|
+
catch: () => ({ message: "GUARD FAIL: could not read package.json" }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const parseManifest = (raw: string): Effect.Effect<Manifest, GuardError> => {
|
|
39
|
+
let manifest: Manifest;
|
|
40
|
+
try {
|
|
41
|
+
manifest = JSON.parse(raw) as Manifest;
|
|
42
|
+
} catch {
|
|
43
|
+
return Effect.fail({ message: "GUARD FAIL: package.json is not valid JSON" });
|
|
44
|
+
}
|
|
45
|
+
return Effect.succeed(manifest);
|
|
46
|
+
};
|
|
47
|
+
const semverLike = (value: string): boolean => /^\d+\.\d+\.\d+$/.test(value);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether a git tag `v<version>` exists in the current checkout. On the
|
|
51
|
+
* manual-dispatch path no tag is pushed, so this closes the tag/sync gap.
|
|
52
|
+
*/
|
|
53
|
+
const tagExists = (version: string): Effect.Effect<boolean> =>
|
|
54
|
+
Effect.tryPromise({
|
|
55
|
+
try: async () => {
|
|
56
|
+
const { execFile } = await import("node:child_process");
|
|
57
|
+
const { promisify } = await import("node:util");
|
|
58
|
+
const run = promisify(execFile);
|
|
59
|
+
try {
|
|
60
|
+
const { stdout } = await run("git", ["tag", "-l", `v${version}`], { cwd: process.cwd() });
|
|
61
|
+
return stdout.trim().length > 0;
|
|
62
|
+
} catch {
|
|
63
|
+
return false; // no git repo / git missing → treat as no tag
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
catch: () => new Error("git tag check failed"),
|
|
67
|
+
}).pipe(Effect.catch(() => Effect.succeed(false)));
|
|
68
|
+
/**
|
|
69
|
+
* Latest published version on npm, or null when the package was never
|
|
70
|
+
* published or the registry is unreachable (warn-only on network failure —
|
|
71
|
+
* `npm publish` remains the real gate).
|
|
72
|
+
*/
|
|
73
|
+
const fetchPublished = (name: string): Effect.Effect<string | null> =>
|
|
74
|
+
Effect.tryPromise({
|
|
75
|
+
try: async () => {
|
|
76
|
+
const res = await fetch(`https://registry.npmjs.org/${name}/latest`, {
|
|
77
|
+
headers: { accept: "application/json" },
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) return null;
|
|
80
|
+
return ((await res.json()) as { version?: string }).version ?? null;
|
|
81
|
+
},
|
|
82
|
+
catch: () => {
|
|
83
|
+
console.warn(
|
|
84
|
+
"GUARD WARN: could not reach the npm registry; skipping published-version check.",
|
|
85
|
+
);
|
|
86
|
+
return new Error("registry unreachable");
|
|
87
|
+
},
|
|
88
|
+
}).pipe(Effect.catch(() => Effect.succeed(null)));
|
|
89
|
+
|
|
90
|
+
const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
|
|
91
|
+
const raw = yield* readManifest;
|
|
92
|
+
const pkg = yield* parseManifest(raw);
|
|
93
|
+
const local = pkg.version;
|
|
94
|
+
const expected = (process.argv[2] ?? "").replace(/^v/, ""); // tolerate "v0.0.1"
|
|
95
|
+
|
|
96
|
+
if (!semverLike(local)) {
|
|
97
|
+
yield* fail(`GUARD FAIL: package.json version "${local}" is not a semver X.Y.Z.`);
|
|
98
|
+
}
|
|
99
|
+
if (expected && expected !== local) {
|
|
100
|
+
yield* fail(
|
|
101
|
+
`GUARD FAIL: expected "${expected}" (tag/input) does not match package.json version "${local}". ` +
|
|
102
|
+
`Bump package.json to ${expected}, or tag ${local}.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const published = yield* fetchPublished(pkg.name);
|
|
107
|
+
if (published === local) {
|
|
108
|
+
yield* fail(
|
|
109
|
+
`GUARD FAIL: ${pkg.name}@${local} is already published on npm. ` +
|
|
110
|
+
`Bump the version in package.json to cut a new release.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// On the manual-dispatch path no tag is pushed, so require the version to
|
|
115
|
+
// exist as a git tag — keeps the tag/manifest-sync invariant on both entry
|
|
116
|
+
// points (clawpatch finding).
|
|
117
|
+
if (expected && (yield* tagExists(expected)) === false) {
|
|
118
|
+
yield* fail(
|
|
119
|
+
`GUARD FAIL: expected version "${expected}" has no matching git tag v${expected}. Tag it first.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
`GUARD OK: publishing ${pkg.name}@${local}` +
|
|
125
|
+
(published ? ` (latest on npm: ${published})` : " (first publish — not on npm yet)")
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
interface Outcome {
|
|
130
|
+
ok: boolean;
|
|
131
|
+
message: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const outcome: Outcome = await Effect.runPromise(
|
|
135
|
+
Effect.match(program, {
|
|
136
|
+
onFailure: (error: GuardError): Outcome => ({ ok: false, message: error.message }),
|
|
137
|
+
onSuccess: (message: string): Outcome => ({ ok: true, message }),
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (!outcome.ok) {
|
|
142
|
+
console.error(outcome.message);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
console.log(outcome.message);
|