pi-anti-doom-loop 0.0.6 → 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 +11 -1
- package/extensions/controller.ts +14 -12
- package/extensions/detector.ts +28 -19
- package/extensions/index.ts +14 -3
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to **pi-anti-doom-loop**.
|
|
4
4
|
|
|
5
|
-
## [
|
|
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
|
|
6
13
|
|
|
7
14
|
### Added
|
|
8
15
|
|
|
@@ -97,7 +104,10 @@ All notable changes to **pi-anti-doom-loop**.
|
|
|
97
104
|
- GitHub Actions release workflow: quality gate → version bump guard → dry-run → publish, triggered by `v*` tags.
|
|
98
105
|
- `pi-package` keyword + `pi` manifest for the pi.dev gallery.
|
|
99
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
|
|
100
109
|
[0.0.5]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.4...v0.0.5
|
|
101
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
|
|
102
112
|
[0.0.2]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.1...v0.0.2
|
|
103
113
|
[0.0.1]: https://github.com/irfndi/pi-anti-doom-loop/releases/tag/v0.0.1
|
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). */
|
|
@@ -164,13 +173,6 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
|
|
|
164
173
|
}
|
|
165
174
|
|
|
166
175
|
/** Join the text content blocks of an assistant message. */
|
|
167
|
-
export function extractText(content:
|
|
168
|
-
|
|
169
|
-
return content
|
|
170
|
-
.map((c) =>
|
|
171
|
-
typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string"
|
|
172
|
-
? c.text
|
|
173
|
-
: "",
|
|
174
|
-
)
|
|
175
|
-
.join(" ");
|
|
176
|
+
export function extractText(content: MessageContent): string {
|
|
177
|
+
return content.map((c) => (c.type === "text" ? (c.text ?? "") : "")).join(" ");
|
|
176
178
|
}
|
package/extensions/detector.ts
CHANGED
|
@@ -77,18 +77,32 @@ export function readOptions(env: Record<string, string | undefined> = process.en
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
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
|
+
|
|
80
93
|
/** Keys sorted recursively so {a:1,b:2} and {b:2,a:1} share a signature. */
|
|
81
|
-
export function canonical(input:
|
|
82
|
-
if (input === null || typeof input !== "object") return JSON.stringify(input);
|
|
94
|
+
export function canonical(input: ToolInput): string {
|
|
83
95
|
if (Array.isArray(input)) return `[${input.map(canonical).join(",")}]`;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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) ?? "";
|
|
89
103
|
}
|
|
90
104
|
|
|
91
|
-
export function signature(toolName: string, input:
|
|
105
|
+
export function signature(toolName: string, input: ToolInput): string {
|
|
92
106
|
return `${toolName}:${canonical(input)}`;
|
|
93
107
|
}
|
|
94
108
|
|
|
@@ -118,7 +132,7 @@ export class LoopDetector {
|
|
|
118
132
|
this.opts = opts;
|
|
119
133
|
}
|
|
120
134
|
|
|
121
|
-
check(toolName: string, input:
|
|
135
|
+
check(toolName: string, input: ToolInput): Result<BlockDecision, undefined> {
|
|
122
136
|
if (this.opts.toolExclude.has(toolName)) {
|
|
123
137
|
// record() is a no-op for excluded tools, so nothing enters the window.
|
|
124
138
|
return Result.err(undefined);
|
|
@@ -168,7 +182,7 @@ export class LoopDetector {
|
|
|
168
182
|
});
|
|
169
183
|
}
|
|
170
184
|
|
|
171
|
-
record(toolName: string, input:
|
|
185
|
+
record(toolName: string, input: ToolInput): void {
|
|
172
186
|
if (this.opts.toolExclude.has(toolName)) return;
|
|
173
187
|
this.recentSigs.push({ sig: signature(toolName, input), ts: Date.now() });
|
|
174
188
|
this.evictSigs();
|
|
@@ -272,7 +286,7 @@ export class LoopDetector {
|
|
|
272
286
|
}
|
|
273
287
|
|
|
274
288
|
/** Error share of all in-window results for a tool. */
|
|
275
|
-
private failRate(toolName: string)
|
|
289
|
+
private failRate(toolName: string) {
|
|
276
290
|
let calls = 0;
|
|
277
291
|
let errors = 0;
|
|
278
292
|
for (const r of this.recentResults) {
|
|
@@ -370,14 +384,9 @@ export const MIN_REPEAT_CHUNK = 16;
|
|
|
370
384
|
/** Jaccard similarity threshold for "near-identical" consecutive texts. */
|
|
371
385
|
export const TEXT_SIMILARITY_THRESHOLD = 0.55;
|
|
372
386
|
|
|
373
|
-
/** Stable string form of
|
|
374
|
-
export function stringify(input:
|
|
375
|
-
|
|
376
|
-
try {
|
|
377
|
-
return JSON.stringify(input);
|
|
378
|
-
} catch {
|
|
379
|
-
return String(input);
|
|
380
|
-
}
|
|
387
|
+
/** Stable string form of a tool input (used for token estimation). */
|
|
388
|
+
export function stringify(input: ToolInput): string {
|
|
389
|
+
return JSON.stringify(input) ?? "";
|
|
381
390
|
}
|
|
382
391
|
|
|
383
392
|
/**
|
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",
|
|
@@ -41,11 +41,12 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"better-result": "^3.0.0",
|
|
44
|
-
"effect": "^4.0.0-
|
|
44
|
+
"effect": "^4.0.0-rc.108"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
48
48
|
"@effect/tsgo": "^0.36.4",
|
|
49
|
+
"@oxlint/plugins": "^1.77.0",
|
|
49
50
|
"@types/node": "^22.0.0",
|
|
50
51
|
"oxfmt": "^0.62.0",
|
|
51
52
|
"oxlint": "^1.77.0",
|