pi-anti-doom-loop 0.0.4 → 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 +7 -5
- package/extensions/controller.ts +31 -3
- package/extensions/index.ts +14 -2
- 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
|
@@ -51,15 +51,17 @@ Environment variables, read at session/prompt start:
|
|
|
51
51
|
|
|
52
52
|
## Command
|
|
53
53
|
|
|
54
|
-
- `/loopcheck` — show
|
|
54
|
+
- `/loopcheck` — show thresholds, counters (steers/aborts this session), suspend state
|
|
55
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
|
|
56
58
|
|
|
57
59
|
## How it works
|
|
58
60
|
|
|
59
|
-
Everything hooks into the `tool_call` / `tool_result` events;
|
|
60
|
-
small sliding-window counter (see `extensions/detector.ts`) with
|
|
61
|
-
|
|
62
|
-
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.
|
|
63
65
|
|
|
64
66
|
## Development
|
|
65
67
|
|
package/extensions/controller.ts
CHANGED
|
@@ -63,6 +63,10 @@ export interface AntiLoopController {
|
|
|
63
63
|
onMessageEnd(role: string, content: unknown): TextLoopOutcome | null;
|
|
64
64
|
/** Full reset (session start, user prompt, /loopcheck reset). */
|
|
65
65
|
reset(): void;
|
|
66
|
+
/** Suspend detection until the next reset (escape hatch for intentional repetition). */
|
|
67
|
+
suspend(): void;
|
|
68
|
+
resume(): void;
|
|
69
|
+
isSuspended(): boolean;
|
|
66
70
|
/** Human-readable status with thresholds + counters for /loopcheck. */
|
|
67
71
|
status(): string;
|
|
68
72
|
}
|
|
@@ -72,9 +76,13 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
72
76
|
const blockedIds = new Set<string>();
|
|
73
77
|
let steered = false;
|
|
74
78
|
let resumes = 0;
|
|
79
|
+
let steers = 0;
|
|
80
|
+
let aborts = 0;
|
|
81
|
+
let suspended = false;
|
|
75
82
|
|
|
76
83
|
return {
|
|
77
84
|
onToolCall(toolName, input, toolCallId) {
|
|
85
|
+
if (suspended) return null;
|
|
78
86
|
const decision = detector.check(toolName, input);
|
|
79
87
|
if (decision.isErr()) {
|
|
80
88
|
detector.record(toolName, input);
|
|
@@ -96,6 +104,7 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
96
104
|
},
|
|
97
105
|
|
|
98
106
|
onMessageEnd(role, content) {
|
|
107
|
+
if (suspended) return null;
|
|
99
108
|
if (role !== "assistant") return null;
|
|
100
109
|
const text = extractText(content);
|
|
101
110
|
if (!text) return null;
|
|
@@ -105,12 +114,15 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
105
114
|
const reason = hit.value.reason;
|
|
106
115
|
if (!steered) {
|
|
107
116
|
steered = true;
|
|
117
|
+
steers++;
|
|
108
118
|
return { reason, action: "steer", resume: false };
|
|
109
119
|
}
|
|
110
120
|
if (resumes < RESUME_BUDGET) {
|
|
111
121
|
resumes++;
|
|
122
|
+
aborts++;
|
|
112
123
|
return { reason, action: "abort", resume: true };
|
|
113
124
|
}
|
|
125
|
+
aborts++;
|
|
114
126
|
return { reason, action: "abort", resume: false };
|
|
115
127
|
},
|
|
116
128
|
|
|
@@ -118,15 +130,31 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
118
130
|
detector = new LoopDetector(opts);
|
|
119
131
|
blockedIds.clear();
|
|
120
132
|
steered = false;
|
|
121
|
-
|
|
122
|
-
//
|
|
133
|
+
suspended = false;
|
|
134
|
+
// resumes/steers/aborts are intentionally NOT reset here: they are
|
|
135
|
+
// session-scoped so a stuck model cannot cycle steer→abort forever and
|
|
136
|
+
// /loopcheck can report lifetime counters.
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
suspend() {
|
|
140
|
+
suspended = true;
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
resume() {
|
|
144
|
+
suspended = false;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
isSuspended() {
|
|
148
|
+
return suspended;
|
|
123
149
|
},
|
|
124
150
|
|
|
125
151
|
status() {
|
|
126
152
|
const o = detector.opts;
|
|
153
|
+
const s = suspended ? ", suspended" : "";
|
|
127
154
|
return (
|
|
128
155
|
`anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, ` +
|
|
129
|
-
`fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}. ${detector.summary()}`
|
|
156
|
+
`fails>=${o.failThreshold}, text>=${o.textRepeatThreshold}. ${detector.summary()} ` +
|
|
157
|
+
`steers=${steers} aborts=${aborts}${s}`
|
|
130
158
|
);
|
|
131
159
|
},
|
|
132
160
|
};
|
package/extensions/index.ts
CHANGED
|
@@ -73,7 +73,8 @@ export default function (pi: PiLike): void {
|
|
|
73
73
|
pi.on("session_start", () => reset());
|
|
74
74
|
|
|
75
75
|
// Fresh counters per user prompt: only the loop happening *right now* counts.
|
|
76
|
-
|
|
76
|
+
// Internal reset keeps session-scoped steers/aborts/resume budget.
|
|
77
|
+
pi.on("before_agent_start", () => controller.reset());
|
|
77
78
|
|
|
78
79
|
pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
|
|
79
80
|
const outcome = controller.onToolCall(event.toolName, event.input, event.toolCallId);
|
|
@@ -118,11 +119,22 @@ export default function (pi: PiLike): void {
|
|
|
118
119
|
pi.registerCommand("loopcheck", {
|
|
119
120
|
description: "Anti-doom-loop status; `/loopcheck reset` clears counters",
|
|
120
121
|
handler: async (args: string, ctx: CommandCtxLite) => {
|
|
121
|
-
|
|
122
|
+
const arg = args.trim().toLowerCase();
|
|
123
|
+
if (arg === "reset") {
|
|
122
124
|
reset();
|
|
123
125
|
ctx.ui.notify("Anti-doom-loop: counters reset", "info");
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
128
|
+
if (arg === "suspend") {
|
|
129
|
+
controller.suspend();
|
|
130
|
+
ctx.ui.notify("Anti-doom-loop: suspended until the next prompt", "info");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (arg === "resume") {
|
|
134
|
+
controller.resume();
|
|
135
|
+
ctx.ui.notify("Anti-doom-loop: resumed", "info");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
126
138
|
ctx.ui.notify(controller.status(), "info");
|
|
127
139
|
},
|
|
128
140
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-anti-doom-loop",
|
|
3
|
-
"version": "0.0.
|
|
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",
|