pi-anti-doom-loop 0.0.3 → 0.0.5
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 +84 -0
- package/README.md +16 -8
- package/extensions/controller.ts +60 -3
- package/extensions/detector.ts +52 -20
- package/extensions/index.ts +57 -8
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **pi-anti-doom-loop**.
|
|
4
|
+
|
|
5
|
+
## [0.0.5] — 2026-08-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`/loopcheck suspend` / `resume`** — escape hatch: pause detection until the next prompt when repetition is intentional (polling, retrying a deploy). No env restart needed.
|
|
10
|
+
- **Session counters** — `steers`/`aborts` appear in `/loopcheck` status; session-scoped (survive per-prompt resets, reset on `/loopcheck reset` or new session).
|
|
11
|
+
- Fixture for the live regex-repeat capture (`parameter\s+(\S+)\s+("` 3× in one message) — regression-covered via the within-message signal.
|
|
12
|
+
- `CHANGELOG.md` ships in the tarball.
|
|
13
|
+
|
|
14
|
+
### Tests
|
|
15
|
+
|
|
16
|
+
- 72 → 75: suspend/resume lifecycle, session counters, regex-repeat fixture, `/loopcheck` subcommands.
|
|
17
|
+
|
|
18
|
+
## [0.0.4] — 2026-08-05
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Near-identical text detection** — token-set Jaccard similarity ≥ 0.55 on consecutive assistant messages counts as a loop streak. Catches rephrasing loops where the model changes one or two words per turn ("inspect the failing test" → "examine the failing assertion") that exact matching missed.
|
|
23
|
+
- **Escalation ladder for text loops: steer → abort → bounded resume** — the first detection steers the agent mid-run (injects guidance, work continues); a persistent loop aborts the run and queues **one** fresh-resume directive (`followUp`) so work continues with a new approach; further looping aborts for real and hands control back. The auto-resume budget is session-scoped so a stuck model cannot cycle forever.
|
|
24
|
+
- Detector now fires on every message while a loop condition holds; the controller turns the 1st into a steer, later ones into aborts.
|
|
25
|
+
- Tests: 65 → 72 (token-similarity unit cases, near-identical streak + reset, steer→abort→resume ladder, budget persistence, adapter wiring, fuzz no-FP + injected-3× properties).
|
|
26
|
+
|
|
27
|
+
## [0.0.3] — 2026-08-05
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **Within-message self-repetition detection** — a sentence repeating 3+ times inside ONE message (growing self-concatenation loops like "…X:…X:…X") now aborts/steers immediately. Liquid Antidoom's loop definition ("a section repeats at least N times").
|
|
32
|
+
- Segmenter ignores chunks < 16 chars so pasted logs with repeated one-word lines never false-positive.
|
|
33
|
+
- Fixtures for the production growing-loop capture (exact + spaced variants).
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Production gap: loops that grow by self-concatenation evaded both verbatim-streak (messages differ) and identical-call (args vary) detection. Now caught at the message itself.
|
|
38
|
+
|
|
39
|
+
### Tests
|
|
40
|
+
|
|
41
|
+
- 51 → 65: `repeatedSegment` unit cases, growing-pattern `checkText` cases, fixture controller paths, fuzz no-FP + injected-3× properties.
|
|
42
|
+
|
|
43
|
+
## [0.0.2] — 2026-08-05
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **Full test suite** — Node's built-in `node:test`, no framework:
|
|
48
|
+
- unit (detector semantics, options clamping, helpers)
|
|
49
|
+
- fixtures (real doom-loop transcripts: CI-log loops, verbatim repeats; healthy sessions not flagged)
|
|
50
|
+
- fuzz (seeded: never throws, no false positives, injected loops always block)
|
|
51
|
+
- integration (controller + adapter driven through a fake `PiLike`)
|
|
52
|
+
- e2e (real subprocesses: detector self-check, version guard, tarball contents)
|
|
53
|
+
- **`controller.ts`** — extracted all event logic into a pure, pi-free, testable module; `index.ts` became a thin structural adapter.
|
|
54
|
+
- `npm test`, `engines >= 22.18`, `files` includes `scripts/`.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- Thresholds clamped to a minimum of 2 (a config of 1 would brick the agent — deepsec finding).
|
|
59
|
+
- GitHub Actions pinned to commit SHAs (NPM_TOKEN in scope — deepsec finding).
|
|
60
|
+
- Release job `concurrency` group (publish race / TOCTOU).
|
|
61
|
+
- `npm publish --dry-run` preflight before the real publish.
|
|
62
|
+
- Version guard requires the git tag `v$version` on the manual-dispatch path.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- Leveraged `better-result` (detector decisions) and `effect` v4 (release guard).
|
|
67
|
+
|
|
68
|
+
## [0.0.1] — 2026-08-05
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- Initial release. Detects and breaks agent doom loops in pi:
|
|
73
|
+
- **identical (tool, args) repeated** 3× in the last 10 calls → block with an instructive reason
|
|
74
|
+
- **same tool failing** 3× consecutively → block with "stop retrying, fix the root cause"
|
|
75
|
+
- escalation: re-issuing a blocked call aborts the turn
|
|
76
|
+
- `/loopcheck` command (status + reset).
|
|
77
|
+
- Counters reset per user prompt (legit repeated tasks are never false positives).
|
|
78
|
+
- GitHub Actions release workflow: quality gate → version bump guard → dry-run → publish, triggered by `v*` tags.
|
|
79
|
+
- `pi-package` keyword + `pi` manifest for the pi.dev gallery.
|
|
80
|
+
|
|
81
|
+
[0.0.5]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.4...v0.0.5
|
|
82
|
+
[0.0.4]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.3...v0.0.4
|
|
83
|
+
[0.0.2]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.1...v0.0.2
|
|
84
|
+
[0.0.1]: https://github.com/irfndi/pi-anti-doom-loop/releases/tag/v0.0.1
|
package/README.md
CHANGED
|
@@ -21,13 +21,19 @@ pi install npm:pi-anti-doom-loop
|
|
|
21
21
|
| Same tool failing consecutively | 3× | A tool errored `3` times in a row — stop retrying it blindly |
|
|
22
22
|
| Same assistant text verbatim | 3× in a row | The model re-emitted identical text `3` times (text-only loops) |
|
|
23
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 |
|
|
24
25
|
|
|
25
26
|
Blocks hand the model an instructive reason ("change your approach, use a
|
|
26
27
|
different tool, or ask the user"). If the model ignores the block and re-issues
|
|
27
|
-
the exact same call, the turn is **aborted
|
|
28
|
-
text loops and within-message self-repetition (no tool calls involved) abort
|
|
29
|
-
the run immediately with a notification.
|
|
28
|
+
the exact same call, the turn is **aborted**.
|
|
30
29
|
|
|
30
|
+
### Escalation (text loops): steer → abort → bounded resume
|
|
31
|
+
|
|
32
|
+
The first text-loop detection **steers** the agent mid-run (injects guidance,
|
|
33
|
+
lets it continue). If it persists, the run is **aborted** and **one** fresh-
|
|
34
|
+
resume directive is queued so work continues with a new approach. If it still
|
|
35
|
+
loops after that, the run aborts for real and control returns to you — the
|
|
36
|
+
auto-resume budget is capped so a truly stuck model can't cycle forever.
|
|
31
37
|
Counters reset on every user prompt, so a task legitimately repeated later in
|
|
32
38
|
the same session is never a false positive.
|
|
33
39
|
|
|
@@ -45,15 +51,17 @@ Environment variables, read at session/prompt start:
|
|
|
45
51
|
|
|
46
52
|
## Command
|
|
47
53
|
|
|
48
|
-
- `/loopcheck` — show
|
|
54
|
+
- `/loopcheck` — show thresholds, counters (steers/aborts this session), suspend state
|
|
49
55
|
- `/loopcheck reset` — clear counters
|
|
56
|
+
- `/loopcheck suspend` — pause detection until the next prompt (escape hatch for intentional repetition)
|
|
57
|
+
- `/loopcheck resume` — re-enable detection early
|
|
50
58
|
|
|
51
59
|
## How it works
|
|
52
60
|
|
|
53
|
-
Everything hooks into the `tool_call` / `tool_result` events;
|
|
54
|
-
small sliding-window counter (see `extensions/detector.ts`) with
|
|
55
|
-
|
|
56
|
-
often.
|
|
61
|
+
Everything hooks into the `tool_call` / `tool_result` / `message_end` events;
|
|
62
|
+
detection is a small sliding-window counter (see `extensions/detector.ts`) with
|
|
63
|
+
per-session counters (steers/aborts) tracked in `extensions/controller.ts`.
|
|
64
|
+
Works with any model — cheap models just trigger it more often.
|
|
57
65
|
|
|
58
66
|
## Development
|
|
59
67
|
|
package/extensions/controller.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* `index.ts` is a thin adapter that wires these methods to pi's event loop;
|
|
5
5
|
* tests drive this controller directly with plain objects. Same behavior,
|
|
6
6
|
* no pi dependency (only `better-result` via the detector).
|
|
7
|
+
*
|
|
8
|
+
* Escalation ladder for message loops:
|
|
9
|
+
* detection #1 → steer (inject guidance, let the agent continue)
|
|
10
|
+
* detection #2 → abort + resume (stop the run, queue one fresh directive)
|
|
11
|
+
* detection #3+ → abort for real (hand back to the user)
|
|
12
|
+
* The resume budget is session-scoped: `reset()` (per user prompt) keeps it,
|
|
13
|
+
* a fresh session (new controller) starts over.
|
|
7
14
|
*/
|
|
8
15
|
import { LoopDetector, readOptions } from "./detector.ts";
|
|
9
16
|
import type { LoopOptions } from "./detector.ts";
|
|
@@ -39,17 +46,27 @@ export interface ToolCallOutcome {
|
|
|
39
46
|
|
|
40
47
|
export interface TextLoopOutcome {
|
|
41
48
|
reason: string;
|
|
49
|
+
action: "steer" | "abort";
|
|
50
|
+
/** When aborting: also queue a single fresh-resume directive (bounded). */
|
|
51
|
+
resume: boolean;
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
/** How many auto-resumes per session before we hand control back for real. */
|
|
55
|
+
export const RESUME_BUDGET = 1;
|
|
56
|
+
|
|
44
57
|
export interface AntiLoopController {
|
|
45
58
|
/** Returns a block decision for a tool call, or null to let it run. */
|
|
46
59
|
onToolCall(toolName: string, input: unknown, toolCallId: string): ToolCallOutcome | null;
|
|
47
60
|
/** Record a finished tool result (blocked calls' results are ignored). */
|
|
48
61
|
onToolResult(toolName: string, toolCallId: string, isError: boolean): void;
|
|
49
|
-
/** Detect
|
|
62
|
+
/** Detect assistant-text loops; returns a steer/abort decision or null. */
|
|
50
63
|
onMessageEnd(role: string, content: unknown): TextLoopOutcome | null;
|
|
51
64
|
/** Full reset (session start, user prompt, /loopcheck reset). */
|
|
52
65
|
reset(): void;
|
|
66
|
+
/** Suspend detection until the next reset (escape hatch for intentional repetition). */
|
|
67
|
+
suspend(): void;
|
|
68
|
+
resume(): void;
|
|
69
|
+
isSuspended(): boolean;
|
|
53
70
|
/** Human-readable status with thresholds + counters for /loopcheck. */
|
|
54
71
|
status(): string;
|
|
55
72
|
}
|
|
@@ -57,9 +74,15 @@ export interface AntiLoopController {
|
|
|
57
74
|
export function createController(opts: LoopOptions = readOptions()): AntiLoopController {
|
|
58
75
|
let detector = new LoopDetector(opts);
|
|
59
76
|
const blockedIds = new Set<string>();
|
|
77
|
+
let steered = false;
|
|
78
|
+
let resumes = 0;
|
|
79
|
+
let steers = 0;
|
|
80
|
+
let aborts = 0;
|
|
81
|
+
let suspended = false;
|
|
60
82
|
|
|
61
83
|
return {
|
|
62
84
|
onToolCall(toolName, input, toolCallId) {
|
|
85
|
+
if (suspended) return null;
|
|
63
86
|
const decision = detector.check(toolName, input);
|
|
64
87
|
if (decision.isErr()) {
|
|
65
88
|
detector.record(toolName, input);
|
|
@@ -81,23 +104,57 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
81
104
|
},
|
|
82
105
|
|
|
83
106
|
onMessageEnd(role, content) {
|
|
107
|
+
if (suspended) return null;
|
|
84
108
|
if (role !== "assistant") return null;
|
|
85
109
|
const text = extractText(content);
|
|
86
110
|
if (!text) return null;
|
|
87
111
|
const hit = detector.checkText(text);
|
|
88
|
-
|
|
112
|
+
if (!hit.isOk()) return null;
|
|
113
|
+
|
|
114
|
+
const reason = hit.value.reason;
|
|
115
|
+
if (!steered) {
|
|
116
|
+
steered = true;
|
|
117
|
+
steers++;
|
|
118
|
+
return { reason, action: "steer", resume: false };
|
|
119
|
+
}
|
|
120
|
+
if (resumes < RESUME_BUDGET) {
|
|
121
|
+
resumes++;
|
|
122
|
+
aborts++;
|
|
123
|
+
return { reason, action: "abort", resume: true };
|
|
124
|
+
}
|
|
125
|
+
aborts++;
|
|
126
|
+
return { reason, action: "abort", resume: false };
|
|
89
127
|
},
|
|
90
128
|
|
|
91
129
|
reset() {
|
|
92
130
|
detector = new LoopDetector(opts);
|
|
93
131
|
blockedIds.clear();
|
|
132
|
+
steered = false;
|
|
133
|
+
suspended = false;
|
|
134
|
+
// resumes/steers/aborts are intentionally NOT reset here: they are
|
|
135
|
+
// session-scoped so a stuck model cannot cycle steer→abort forever and
|
|
136
|
+
// /loopcheck can report lifetime counters.
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
suspend() {
|
|
140
|
+
suspended = true;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
resume() {
|
|
144
|
+
suspended = false;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
isSuspended() {
|
|
148
|
+
return suspended;
|
|
94
149
|
},
|
|
95
150
|
|
|
96
151
|
status() {
|
|
97
152
|
const o = detector.opts;
|
|
153
|
+
const s = suspended ? ", suspended" : "";
|
|
98
154
|
return (
|
|
99
155
|
`anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, ` +
|
|
100
|
-
`fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}. ${detector.summary()}`
|
|
156
|
+
`fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}. ${detector.summary()} ` +
|
|
157
|
+
`steers=${steers} aborts=${aborts}${s}`
|
|
101
158
|
);
|
|
102
159
|
},
|
|
103
160
|
};
|
package/extensions/detector.ts
CHANGED
|
@@ -147,26 +147,29 @@ export class LoopDetector {
|
|
|
147
147
|
// `textRepeatThreshold`+ times inside ONE message (growing loops like
|
|
148
148
|
// "…X:…X:…X"). Liquid.ai's loop definition — a section repeats at least N
|
|
149
149
|
// times. No streak needed: the message itself is the loop.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
`within a single message. You appear to be in a loop — this run is aborted.`,
|
|
158
|
-
});
|
|
159
|
-
}
|
|
150
|
+
const chunk = repeatedSegment(norm, this.opts.textRepeatThreshold);
|
|
151
|
+
if (chunk !== null) {
|
|
152
|
+
return Result.ok({
|
|
153
|
+
reason:
|
|
154
|
+
`Assistant message repeats "${truncate(chunk, 60)}" ${this.opts.textRepeatThreshold}+ times ` +
|
|
155
|
+
`within a single message. You appear to be in a loop.`,
|
|
156
|
+
});
|
|
160
157
|
}
|
|
161
158
|
|
|
162
|
-
|
|
159
|
+
// Cross-message streak: consecutive assistant texts that are identical
|
|
160
|
+
// OR near-identical (token-overlap similarity). Catches loops where the
|
|
161
|
+
// model slightly rephrases each turn ("inspect the failing test" →
|
|
162
|
+
// "examine the failing assertion") so exact matching never fires.
|
|
163
|
+
const similar =
|
|
164
|
+
norm === this.lastText ||
|
|
165
|
+
(this.lastText !== null && tokenSimilarity(norm, this.lastText) >= TEXT_SIMILARITY_THRESHOLD);
|
|
166
|
+
this.textStreak = similar ? this.textStreak + 1 : 1;
|
|
163
167
|
this.lastText = norm;
|
|
164
|
-
if (this.textStreak >= this.opts.textRepeatThreshold
|
|
165
|
-
this.textFired = true;
|
|
168
|
+
if (this.textStreak >= this.opts.textRepeatThreshold) {
|
|
166
169
|
return Result.ok({
|
|
167
170
|
reason:
|
|
168
|
-
`Assistant replied with identical text ${this.textStreak} times in a row
|
|
169
|
-
`You appear to be in a loop
|
|
171
|
+
`Assistant replied with identical or near-identical text ${this.textStreak} times in a row ` +
|
|
172
|
+
`("${truncate(norm, 80)}"). You appear to be in a loop.`,
|
|
170
173
|
});
|
|
171
174
|
}
|
|
172
175
|
return Result.err(undefined);
|
|
@@ -212,6 +215,31 @@ export function truncate(text: string, max: number): string {
|
|
|
212
215
|
/** Minimum length of a segment worth treating as a repeated loop chunk. */
|
|
213
216
|
export const MIN_REPEAT_CHUNK = 16;
|
|
214
217
|
|
|
218
|
+
/** Jaccard similarity threshold for "near-identical" consecutive texts. */
|
|
219
|
+
export const TEXT_SIMILARITY_THRESHOLD = 0.55;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Token-set Jaccard similarity of two texts (case/whitespace-insensitive).
|
|
223
|
+
* Short tokens (< 3 chars: "a", "me", "to") are ignored to reduce noise.
|
|
224
|
+
* Returns 0..1; 1 = identical token sets.
|
|
225
|
+
*/
|
|
226
|
+
export function tokenSimilarity(a: string, b: string): number {
|
|
227
|
+
const tokenize = (s: string) => {
|
|
228
|
+
return new Set(
|
|
229
|
+
normalizeText(s)
|
|
230
|
+
.toLowerCase()
|
|
231
|
+
.split(/\s+/g)
|
|
232
|
+
.filter((w) => w.length >= 3 && /^[a-z0-9_-]+$/.test(w)),
|
|
233
|
+
);
|
|
234
|
+
};
|
|
235
|
+
const as = tokenize(a);
|
|
236
|
+
const bs = tokenize(b);
|
|
237
|
+
if (as.size === 0 || bs.size === 0) return 0;
|
|
238
|
+
let inter = 0;
|
|
239
|
+
for (const t of as) if (bs.has(t)) inter++;
|
|
240
|
+
return inter / (as.size + bs.size - inter);
|
|
241
|
+
}
|
|
242
|
+
|
|
215
243
|
/**
|
|
216
244
|
* Returns the first sentence-ish segment that repeats `threshold` times
|
|
217
245
|
* within a single normalized message, or null.
|
|
@@ -294,18 +322,22 @@ if (import.meta.main) {
|
|
|
294
322
|
for (let i = 0; i < opts.windowSize; i++) d.record("bash", { command: `cmd ${i}` });
|
|
295
323
|
assert.ok(d.check("bash", { command: "cmd 0" }).isErr(), "evicted repeats do not count");
|
|
296
324
|
|
|
297
|
-
// 7. verbatim assistant text loop: fires
|
|
325
|
+
// 7. verbatim assistant text loop: fires at the 3rd identical message and
|
|
326
|
+
// every message after while the streak holds (controller escalates)
|
|
298
327
|
d.reset();
|
|
299
328
|
assert.ok(d.checkText("Now update buildProgram.").isErr(), "1st text passes");
|
|
300
329
|
assert.ok(d.checkText("Now update buildProgram.").isErr(), "2nd text passes");
|
|
301
330
|
const textHit = d.checkText("Now update buildProgram.");
|
|
302
331
|
assert.ok(textHit.isOk(), "3rd identical text should fire");
|
|
303
332
|
if (textHit.isOk()) {
|
|
304
|
-
assert.match(textHit.value.reason, /identical text 3 times/);
|
|
305
|
-
assert.match(textHit.value.reason, /aborted/);
|
|
333
|
+
assert.match(textHit.value.reason, /identical or near-identical text 3 times/);
|
|
306
334
|
}
|
|
307
|
-
assert.ok(
|
|
308
|
-
|
|
335
|
+
assert.ok(
|
|
336
|
+
d.checkText("Now update buildProgram.").isOk(),
|
|
337
|
+
"4th identical text still fires (escalation)",
|
|
338
|
+
);
|
|
339
|
+
d.reset();
|
|
340
|
+
assert.ok(d.checkText("Now update buildProgram.").isErr(), "reset clears the streak");
|
|
309
341
|
// 8. whitespace drift does not hide a verbatim loop
|
|
310
342
|
d.reset();
|
|
311
343
|
d.checkText("Read the region:");
|
package/extensions/index.ts
CHANGED
|
@@ -9,11 +9,15 @@
|
|
|
9
9
|
* in the last `PI_ANTI_LOOP_WINDOW` calls → block with an instructive reason
|
|
10
10
|
* - the same tool failing `PI_ANTI_LOOP_FAILS` consecutive times (default 3)
|
|
11
11
|
* → block with a "stop retrying, fix the root cause" reason
|
|
12
|
-
* - the model
|
|
13
|
-
*
|
|
12
|
+
* - the model repeating text: verbatim, near-identical (token similarity),
|
|
13
|
+
* or a sentence repeated inside ONE message → steer first, abort as
|
|
14
|
+
* escalation, then a bounded auto-resume so work continues
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Escalation (message loops): detection #1 steers the agent mid-run; #2
|
|
17
|
+
* aborts the turn and queues one fresh-resume directive; #3+ aborts for real
|
|
18
|
+
* and hands control back to the user. Tool-call blocks hand the model an
|
|
19
|
+
* instructive reason (that is the steer); re-issuing the exact same blocked
|
|
20
|
+
* call aborts the turn.
|
|
17
21
|
*
|
|
18
22
|
* Counters reset on every user prompt, so a task legitimately repeated later
|
|
19
23
|
* in the session is never a false positive. Disable with PI_ANTI_LOOP_DISABLE=1.
|
|
@@ -43,8 +47,24 @@ export interface PiLike {
|
|
|
43
47
|
handler: (args: string, ctx: CommandCtxLite) => Promise<void> | void;
|
|
44
48
|
},
|
|
45
49
|
): void;
|
|
50
|
+
sendMessage?(
|
|
51
|
+
content: { customType?: string; content?: string; display?: boolean },
|
|
52
|
+
options?: { deliverAs?: "steer" | "followUp" | "nextTurn"; triggerTurn?: boolean },
|
|
53
|
+
): void;
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
/** Injected on the first loop detection — steer the agent back on track. */
|
|
57
|
+
const STEER_TEXT =
|
|
58
|
+
"Anti-doom-loop steering: you are repeating the same action or text without making progress. " +
|
|
59
|
+
"Stop. Re-read the actual error output, pick ONE different action, and execute it. " +
|
|
60
|
+
"If you are stuck, ask the user instead of retrying.";
|
|
61
|
+
|
|
62
|
+
/** Queued once after an abort so the work can continue with a fresh approach. */
|
|
63
|
+
const RESUME_TEXT =
|
|
64
|
+
"Anti-doom-loop: the previous run was aborted because it looped. " +
|
|
65
|
+
"Start over with a genuinely different approach: do not repeat the previous investigation steps. " +
|
|
66
|
+
"Re-read the task, choose one new action, execute it, then report results.";
|
|
67
|
+
|
|
48
68
|
export default function (pi: PiLike): void {
|
|
49
69
|
if (process.env.PI_ANTI_LOOP_DISABLE === "1") return;
|
|
50
70
|
|
|
@@ -53,7 +73,8 @@ export default function (pi: PiLike): void {
|
|
|
53
73
|
pi.on("session_start", () => reset());
|
|
54
74
|
|
|
55
75
|
// Fresh counters per user prompt: only the loop happening *right now* counts.
|
|
56
|
-
|
|
76
|
+
// Internal reset keeps session-scoped steers/aborts/resume budget.
|
|
77
|
+
pi.on("before_agent_start", () => controller.reset());
|
|
57
78
|
|
|
58
79
|
pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
|
|
59
80
|
const outcome = controller.onToolCall(event.toolName, event.input, event.toolCallId);
|
|
@@ -69,23 +90,51 @@ export default function (pi: PiLike): void {
|
|
|
69
90
|
controller.onToolResult(event.toolName, event.toolCallId, event.isError === true);
|
|
70
91
|
});
|
|
71
92
|
|
|
72
|
-
// Text-only doom loops (model re-emits the same
|
|
73
|
-
// never reach tool_call.
|
|
93
|
+
// Text-only doom loops (model re-emits/rephrases the same thing with no
|
|
94
|
+
// tool calls) never reach tool_call. Steer first, abort as escalation,
|
|
95
|
+
// then a bounded auto-resume so the work continues.
|
|
74
96
|
pi.on("message_end", (event: MessageEndEventLite, ctx: CtxLite) => {
|
|
75
97
|
const outcome = controller.onMessageEnd(event.message.role, event.message.content);
|
|
76
98
|
if (outcome === null) return;
|
|
99
|
+
|
|
100
|
+
if (outcome.action === "steer") {
|
|
101
|
+
ctx.ui.notify(`Anti-doom-loop: ${outcome.reason}`, "warning");
|
|
102
|
+
pi.sendMessage?.(
|
|
103
|
+
{ customType: "anti-doom-loop", content: STEER_TEXT, display: true },
|
|
104
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
105
|
+
);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
ctx.ui.notify(`Anti-doom-loop: ${outcome.reason}`, "error");
|
|
78
110
|
ctx.abort();
|
|
111
|
+
if (outcome.resume) {
|
|
112
|
+
pi.sendMessage?.(
|
|
113
|
+
{ customType: "anti-doom-loop", content: RESUME_TEXT, display: true },
|
|
114
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
115
|
+
);
|
|
116
|
+
}
|
|
79
117
|
});
|
|
80
118
|
|
|
81
119
|
pi.registerCommand("loopcheck", {
|
|
82
120
|
description: "Anti-doom-loop status; `/loopcheck reset` clears counters",
|
|
83
121
|
handler: async (args: string, ctx: CommandCtxLite) => {
|
|
84
|
-
|
|
122
|
+
const arg = args.trim().toLowerCase();
|
|
123
|
+
if (arg === "reset") {
|
|
85
124
|
reset();
|
|
86
125
|
ctx.ui.notify("Anti-doom-loop: counters reset", "info");
|
|
87
126
|
return;
|
|
88
127
|
}
|
|
128
|
+
if (arg === "suspend") {
|
|
129
|
+
controller.suspend();
|
|
130
|
+
ctx.ui.notify("Anti-doom-loop: suspended until the next prompt", "info");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (arg === "resume") {
|
|
134
|
+
controller.resume();
|
|
135
|
+
ctx.ui.notify("Anti-doom-loop: resumed", "info");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
89
138
|
ctx.ui.notify(controller.status(), "info");
|
|
90
139
|
},
|
|
91
140
|
});
|
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.5",
|
|
4
4
|
"description": "Detect and break agent doom loops in pi: blocks identical repeated tool calls and blind retries before they burn tokens.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"anti-doom-loop",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"extensions",
|
|
20
|
-
"scripts"
|
|
20
|
+
"scripts",
|
|
21
|
+
"CHANGELOG.md"
|
|
21
22
|
],
|
|
22
23
|
"type": "module",
|
|
23
24
|
"main": "extensions/index.ts",
|