pi-antiloop 1.1.0 → 1.2.0
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/README.md +9 -5
- package/package.json +1 -1
- package/src/commands.ts +17 -0
- package/src/config.ts +2 -0
- package/src/index.ts +145 -12
- package/src/types.ts +13 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
- **Progressive intervention** — `warning` reminds the model to vary its approach; `force break` injects explicit anti-loop instructions and modifies context; `abort` stops the run entirely
|
|
17
17
|
- **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
|
|
18
18
|
- **Sliding window** — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
|
|
19
|
-
- **Live
|
|
19
|
+
- **Live footer indicator** — `🔄 antiloop(on|off)` in the footer, per spec, with the current level (`⚠️/🛑/🚨`) and consecutive count; an interactive TUI footer adds a keyboard toggle (`esc+a` by default, configurable/off) and preserves the built-in footer's pwd/branch/context/model info
|
|
20
20
|
- **Detection log** — timestamped history with similarity scores, filterable through the native pi menu
|
|
21
21
|
- **Self-test** — `/antiloop test` runs built-in cases to verify the similarity engine is calibrated
|
|
22
22
|
- **User input softens detection** — each new user message decays the consecutive counter so a fresh prompt can resolve the loop without manual reset
|
|
@@ -161,7 +161,7 @@ Each detected pair becomes a `LoopDetection { type, similarity, messageIndices,
|
|
|
161
161
|
| Level | Trigger | Behavior |
|
|
162
162
|
|-------|---------|----------|
|
|
163
163
|
| 0 (no loop) | — | Silent — passes the message through |
|
|
164
|
-
| 1 (warning) | `consecutiveDetections >= warningThreshold` |
|
|
164
|
+
| 1 (warning) | `consecutiveDetections >= warningThreshold` | Notifies the user (`⚠️`) — no message is injected into the conversation, so the model's generation is never interrupted by the warning itself |
|
|
165
165
|
| 2 (force break) | `consecutiveDetections >= forceBreakThreshold` | Injects mandatory anti-loop instructions + appends a context message to the last assistant message |
|
|
166
166
|
| 3 (abort) | `consecutiveDetections >= abortThreshold` | (Disabled by default) Surfaces an error asking the user for new instructions |
|
|
167
167
|
|
|
@@ -231,7 +231,9 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
|
|
|
231
231
|
"detectTextLoops": true,
|
|
232
232
|
"notifyOnDetection": true,
|
|
233
233
|
"maxHistoryEntries": 100,
|
|
234
|
-
"detectionWindow": 10
|
|
234
|
+
"detectionWindow": 10,
|
|
235
|
+
"interactiveFooter": true,
|
|
236
|
+
"toggleShortcut": "esc+a"
|
|
235
237
|
}
|
|
236
238
|
```
|
|
237
239
|
|
|
@@ -251,6 +253,8 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
|
|
|
251
253
|
| `notifyOnDetection` | `true` | Show a notification on every detection |
|
|
252
254
|
| `maxHistoryEntries` | `100` | Max detection history entries |
|
|
253
255
|
| `detectionWindow` | `10` | Number of recent messages to analyze |
|
|
256
|
+
| `interactiveFooter` | `true` | TUI footer replaces the built-in one with an antiloop indicator + toggle shortcut (set `false` to keep the built-in footer and only the `setStatus` line) |
|
|
257
|
+
| `toggleShortcut` | `esc+a` | Key sequence that toggles antiloop from the footer (`esc+a` or `off`). The input is never consumed, so typing is unaffected |
|
|
254
258
|
|
|
255
259
|
## Best Practices
|
|
256
260
|
|
|
@@ -286,8 +290,8 @@ Modular extension with zero external dependencies (only pi's bundled `@earendil-
|
|
|
286
290
|
- **Levenshtein + trigram Jaccard** hybrid — small texts use edit distance, large texts use n-gram overlap (each is O(N) in text length)
|
|
287
291
|
- **Sliding window** — only the last `detectionWindow` messages participate, capping memory at O(W × message_size)
|
|
288
292
|
- **Early bail** — short messages and empty tool calls skip similarity computation entirely
|
|
289
|
-
- **TUI integration** — uses `ctx.ui.select` for the config menu and the log viewer; `ctx.ui.notify` for state notifications; `ctx.ui.setStatus` for the persistent
|
|
290
|
-
- **Hooks** — `message_end` (track messages + tool call ids), `turn_end` (attach result fingerprints + detect), `input` (decay), `before_agent_start` (inject intervention), `context` (modify context in force-break mode), `session_start` (load config + reset)
|
|
293
|
+
- **TUI integration** — uses `ctx.ui.select` for the config menu and the log viewer; `ctx.ui.notify` for state notifications; `ctx.ui.setStatus` + a custom `ctx.ui.setFooter` component for the persistent footer indicator, live level info, and the `esc+a` keyboard toggle (`ctx.ui.onTerminalInput`, never consumes input)
|
|
294
|
+
- **Hooks** — `message_end` (track messages + tool call ids), `turn_end` (attach result fingerprints + detect), `input` (decay), `before_agent_start` (inject intervention — force/abort only), `context` (modify context in force-break mode), `session_start` (load config + install footer + reset), `session_shutdown` (restore built-in footer)
|
|
291
295
|
|
|
292
296
|
## License
|
|
293
297
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antiloop",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, and structural patterns. Tool-loop detection is result-aware: only near-identical repeated calls with the same outcome count, so sequential bash operations and retries that make progress don't false-positive.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/commands.ts
CHANGED
|
@@ -61,6 +61,7 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
|
|
|
61
61
|
` result sim: ${(rt.config.resultSimilarityThreshold * 100).toFixed(0)}% (same cmd + diff outcome = no loop)`,
|
|
62
62
|
"",
|
|
63
63
|
`detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
|
|
64
|
+
`footer: interactive ${yn(rt.config.interactiveFooter)} · toggle: ${rt.config.toggleShortcut}`,
|
|
64
65
|
];
|
|
65
66
|
if (recent.length) {
|
|
66
67
|
lines.push("", "recent:");
|
|
@@ -85,6 +86,8 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
85
86
|
{ value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
|
|
86
87
|
{ value: "think" as const, label: `think detect: ${yn(c.detectThinkingLoops)}` },
|
|
87
88
|
{ value: "notify" as const, label: `notify: ${yn(c.notifyOnDetection)}` },
|
|
89
|
+
{ value: "footer" as const, label: `interactive footer: ${yn(c.interactiveFooter)}`, description: "TUI footer with toggle shortcut" },
|
|
90
|
+
{ value: "shortcut" as const, label: `toggle shortcut: ${c.toggleShortcut}`, description: "esc+a or off" },
|
|
88
91
|
{ value: "reset" as const, label: "reset state" },
|
|
89
92
|
]);
|
|
90
93
|
if (!picked) return;
|
|
@@ -188,6 +191,20 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
188
191
|
case "notify":
|
|
189
192
|
c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
|
|
190
193
|
ctx.ui.notify(`notify: ${yn(c.notifyOnDetection)}`, "info"); break;
|
|
194
|
+
case "footer":
|
|
195
|
+
c.interactiveFooter = !c.interactiveFooter; saveConfig(c);
|
|
196
|
+
ctx.ui.notify(`interactive footer: ${yn(c.interactiveFooter)}`, "info");
|
|
197
|
+
rt.refreshFooter?.(ctx);
|
|
198
|
+
rt.updateStatus(ctx);
|
|
199
|
+
break;
|
|
200
|
+
case "shortcut": {
|
|
201
|
+
const v = await selectFrom(ctx, "toggle shortcut", [
|
|
202
|
+
{ value: "esc+a" as const, label: "esc+a (default)", description: "press ESC then a to toggle" },
|
|
203
|
+
{ value: "off" as const, label: "off", description: "disable keyboard toggle" },
|
|
204
|
+
]);
|
|
205
|
+
if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`toggle shortcut: ${v}`, "info"); }
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
191
208
|
case "reset":
|
|
192
209
|
resetState(rt.state);
|
|
193
210
|
rt.pendingIntervention = null;
|
package/src/config.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* antiloop — detect reasoning loops and intervene.
|
|
3
|
-
* Hooks: message_end, input, before_agent_start, context, turn_end, session_start.
|
|
3
|
+
* Hooks: message_end, input, before_agent_start, context, turn_end, session_start, session_shutdown.
|
|
4
4
|
* Commands: /antiloop [enable|disable|status|config|log|reset|test]
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { loadConfig, saveConfig } from "./config.ts";
|
|
9
10
|
import type { AntiloopState, LoopDetection, Runtime, TrackedToolCall } from "./types.ts";
|
|
10
11
|
|
|
11
12
|
const ICONS = ["", "⚠️", "🛑", "🚨"] as const;
|
|
13
|
+
const LEVEL_NAMES = ["", "warning", "force", "abort"] as const;
|
|
12
14
|
|
|
13
15
|
function newState(): AntiloopState {
|
|
14
16
|
return {
|
|
@@ -23,22 +25,50 @@ function newState(): AntiloopState {
|
|
|
23
25
|
};
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/** Compact pwd: ~-relative when inside $HOME, with trailing separator trimmed. */
|
|
29
|
+
function formatCwd(cwd: string): string {
|
|
30
|
+
const home = process.env.HOME;
|
|
31
|
+
if (home && cwd.startsWith(home)) {
|
|
32
|
+
const rel = cwd.slice(home.length).replace(/^[/\\]+/, "");
|
|
33
|
+
return rel ? `~/${rel}` : "~";
|
|
34
|
+
}
|
|
35
|
+
return cwd;
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
export default function antiloopExtension(pi: ExtensionAPI) {
|
|
27
39
|
const config = loadConfig();
|
|
28
40
|
let state = newState();
|
|
29
41
|
|
|
42
|
+
/** TUI handle for forcing footer re-renders (set by the footer factory). */
|
|
43
|
+
let activeTui: { requestRender(force?: boolean): void } | undefined;
|
|
44
|
+
|
|
45
|
+
/** Status text shown in the footer: "(emoji_antiloop)(on/off)" per spec. */
|
|
46
|
+
function antiloopStatusText(): string {
|
|
47
|
+
if (!config.enabled) return "🔄 antiloop(off)";
|
|
48
|
+
if (state.currentLevel === 0) return "🔄 antiloop(on)";
|
|
49
|
+
return `${ICONS[state.currentLevel]} antiloop(on)×${state.consecutiveDetections}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
function updateStatus(ctx: ExtensionContext): void {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
53
|
+
// Always set a status line so the footer shows on/off either way.
|
|
54
|
+
ctx.ui.setStatus("antiloop", antiloopStatusText());
|
|
55
|
+
activeTui?.requestRender();
|
|
34
56
|
}
|
|
35
57
|
|
|
36
|
-
const rt: Runtime = { config, state, pendingIntervention: null, updateStatus };
|
|
58
|
+
const rt: Runtime = { config, state, pendingIntervention: null, updateStatus, refreshFooter: installFooter };
|
|
37
59
|
const setPending = (v: string | null) => { rt.pendingIntervention = v; };
|
|
38
60
|
|
|
61
|
+
/** Toggle enable/disable, persisting config and refreshing the footer. */
|
|
62
|
+
function toggleEnabled(ctx: ExtensionContext): void {
|
|
63
|
+
config.enabled = !config.enabled;
|
|
64
|
+
saveConfig(config);
|
|
65
|
+
ctx.ui.notify(`antiloop: ${config.enabled ? "ON" : "OFF"}`, "info");
|
|
66
|
+
updateStatus(ctx);
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
function processDetections(
|
|
40
70
|
detections: LoopDetection[],
|
|
41
|
-
interventionMessage: (level:
|
|
71
|
+
interventionMessage: (level: 2 | 3, d: LoopDetection[]) => string,
|
|
42
72
|
): void {
|
|
43
73
|
if (!detections.length) {
|
|
44
74
|
if (state.consecutiveDetections > 0) state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 1);
|
|
@@ -59,12 +89,109 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
59
89
|
else if (state.consecutiveDetections >= config.warningThreshold) next = 1;
|
|
60
90
|
if (next > state.currentLevel) state.currentLevel = next;
|
|
61
91
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
92
|
+
// Warning (level 1) is informational only: notify the user but DO NOT
|
|
93
|
+
// inject any message into the conversation. Injecting at warning level
|
|
94
|
+
// made the model respond to the warning, which could stall generation
|
|
95
|
+
// even though hard-kill turns remained. Only force (2) / abort (3) inject.
|
|
96
|
+
if (state.currentLevel >= 2) {
|
|
97
|
+
setPending(interventionMessage(state.currentLevel as 2 | 3, detections));
|
|
98
|
+
state.inForcedBreak = true;
|
|
99
|
+
} else if (state.currentLevel === 1) {
|
|
100
|
+
state.inForcedBreak = false;
|
|
65
101
|
}
|
|
66
102
|
}
|
|
67
103
|
|
|
104
|
+
// ------------------------------------------------------------------
|
|
105
|
+
// Interactive footer (TUI). Replaces the built-in footer with a line
|
|
106
|
+
// per spec — "🔄 antiloop(on|off)" — plus live detection info, a
|
|
107
|
+
// keyboard toggle (esc+a by default, configurable/off), and the
|
|
108
|
+
// built-in footer's useful data (pwd, branch, ctx %, model) preserved.
|
|
109
|
+
// ------------------------------------------------------------------
|
|
110
|
+
function installFooter(ctx: ExtensionContext): void {
|
|
111
|
+
if (!config.interactiveFooter || ctx.mode !== "tui") {
|
|
112
|
+
ctx.ui.setFooter(undefined);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
116
|
+
activeTui = tui;
|
|
117
|
+
|
|
118
|
+
// Keyboard toggle from raw terminal input: escape followed by `a`.
|
|
119
|
+
// We never consume the input, so typing is unaffected — ESC alone
|
|
120
|
+
// passes through, and an accidental toggle is easily reversed.
|
|
121
|
+
let pendingEsc = false;
|
|
122
|
+
const shortcut = config.toggleShortcut;
|
|
123
|
+
const unsubInput =
|
|
124
|
+
shortcut === "off"
|
|
125
|
+
? undefined
|
|
126
|
+
: ctx.ui.onTerminalInput?.((data: string) => {
|
|
127
|
+
if (config.toggleShortcut === "off") return undefined;
|
|
128
|
+
if (data === "\x1b") {
|
|
129
|
+
pendingEsc = true;
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
if (pendingEsc && data === "a") {
|
|
133
|
+
pendingEsc = false;
|
|
134
|
+
toggleEnabled(ctx);
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
pendingEsc = false;
|
|
138
|
+
return undefined;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
dispose() {
|
|
143
|
+
unsubInput?.();
|
|
144
|
+
if (activeTui === tui) activeTui = undefined;
|
|
145
|
+
},
|
|
146
|
+
invalidate() {},
|
|
147
|
+
render(width: number): string[] {
|
|
148
|
+
const lines: string[] = [];
|
|
149
|
+
|
|
150
|
+
// Line 1: the spec indicator + toggle hint.
|
|
151
|
+
const status = antiloopStatusText();
|
|
152
|
+
const colored = config.enabled ? theme.fg("accent", status) : theme.fg("dim", status);
|
|
153
|
+
const hint =
|
|
154
|
+
config.toggleShortcut !== "off"
|
|
155
|
+
? theme.fg("dim", ` [${config.toggleShortcut}] toggle`)
|
|
156
|
+
: theme.fg("dim", " [/antiloop] toggle");
|
|
157
|
+
lines.push(truncateToWidth(colored + hint, width));
|
|
158
|
+
|
|
159
|
+
// Line 2 (only while detecting): level + consecutive + last reason.
|
|
160
|
+
if (state.currentLevel > 0) {
|
|
161
|
+
const last = state.detections[state.detections.length - 1];
|
|
162
|
+
const desc = last ? ` · ${last.description}` : "";
|
|
163
|
+
lines.push(
|
|
164
|
+
truncateToWidth(
|
|
165
|
+
theme.fg("warning", `${LEVEL_NAMES[state.currentLevel]} ×${state.consecutiveDetections}${desc}`),
|
|
166
|
+
width,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Line 3: built-in footer data preserved (dim).
|
|
172
|
+
let info = formatCwd(ctx.cwd);
|
|
173
|
+
const branch = footerData.getGitBranch();
|
|
174
|
+
if (branch) info += ` (${branch})`;
|
|
175
|
+
const usage = ctx.getContextUsage();
|
|
176
|
+
const cw = usage?.contextWindow ?? ctx.model?.contextWindow;
|
|
177
|
+
if (cw && usage && usage.percent !== null) info += ` · ctx ${Math.round(usage.percent)}%`;
|
|
178
|
+
if (ctx.model) info += ` · ${ctx.model.id}`;
|
|
179
|
+
lines.push(truncateToWidth(theme.fg("dim", info), width));
|
|
180
|
+
|
|
181
|
+
// Line 4 (only when other extensions set statuses): keep them visible.
|
|
182
|
+
const others = Array.from(footerData.getExtensionStatuses().entries())
|
|
183
|
+
.filter(([k]) => k !== "antiloop")
|
|
184
|
+
.map(([, v]) => v);
|
|
185
|
+
if (others.length) {
|
|
186
|
+
lines.push(truncateToWidth(theme.fg("dim", others.join(" ")), width));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return lines;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
68
195
|
pi.on("message_end", async (event) => {
|
|
69
196
|
if (!config.enabled) return;
|
|
70
197
|
const msg = event.message;
|
|
@@ -180,9 +307,17 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
180
307
|
Object.assign(config, loadConfig());
|
|
181
308
|
state = newState();
|
|
182
309
|
rt.pendingIntervention = null;
|
|
310
|
+
installFooter(ctx);
|
|
183
311
|
updateStatus(ctx);
|
|
184
312
|
});
|
|
185
313
|
|
|
314
|
+
pi.on("session_shutdown", async (_e, ctx) => {
|
|
315
|
+
// Restore the built-in footer on shutdown (in case another extension
|
|
316
|
+
// installs its own footer later, or the TUI is torn down).
|
|
317
|
+
ctx.ui.setFooter(undefined);
|
|
318
|
+
activeTui = undefined;
|
|
319
|
+
});
|
|
320
|
+
|
|
186
321
|
pi.registerCommand("antiloop", {
|
|
187
322
|
description: "antiloop: detect & break reasoning loops",
|
|
188
323
|
getArgumentCompletions: (prefix: string) => {
|
|
@@ -195,5 +330,3 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
195
330
|
},
|
|
196
331
|
});
|
|
197
332
|
}
|
|
198
|
-
|
|
199
|
-
|
package/src/types.ts
CHANGED
|
@@ -31,6 +31,17 @@ export interface AntiloopConfig {
|
|
|
31
31
|
notifyOnDetection: boolean;
|
|
32
32
|
maxHistoryEntries: number;
|
|
33
33
|
detectionWindow: number;
|
|
34
|
+
/**
|
|
35
|
+
* Show the antiloop indicator as a custom interactive footer in TUI mode
|
|
36
|
+
* (replaces the built-in footer). When false, the indicator is still shown
|
|
37
|
+
* as a status line in the built-in footer via ctx.ui.setStatus.
|
|
38
|
+
*/
|
|
39
|
+
interactiveFooter: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Key sequence that toggles antiloop from the footer (raw terminal input).
|
|
42
|
+
* Format: "esc+a" (escape followed by `a`) or "off" to disable.
|
|
43
|
+
*/
|
|
44
|
+
toggleShortcut: string;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
export type LoopKind = "text" | "tool" | "thinking" | "structural";
|
|
@@ -81,4 +92,6 @@ export interface Runtime {
|
|
|
81
92
|
state: AntiloopState;
|
|
82
93
|
pendingIntervention: string | null;
|
|
83
94
|
updateStatus(ctx: ExtensionContext): void;
|
|
95
|
+
/** Re-install the interactive footer (after config changes). */
|
|
96
|
+
refreshFooter?(ctx: ExtensionContext): void;
|
|
84
97
|
}
|