pi-antiloop 1.4.1 → 1.5.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 +3 -2
- package/package.json +2 -2
- package/src/commands.ts +14 -4
- package/src/config.ts +1 -0
- package/src/detect.ts +33 -11
- package/src/index.ts +147 -68
- package/src/types.ts +16 -1
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ pi remove npm:pi-antiloop
|
|
|
61
61
|
/antiloop config # adjust thresholds to taste
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
That's it. Antiloop is on by default. If the model ever starts repeating itself, you'll see a
|
|
64
|
+
That's it. Antiloop is on by default. If the model ever starts repeating itself, you'll see a `⚠️` warning; if it keeps looping past the force-break threshold, antiloop steers a break message into the *running* agent before its next tool call — and if the model ignores it and keeps repeating verbatim, antiloop aborts the run. Loops always terminate.
|
|
65
65
|
|
|
66
66
|
## Commands
|
|
67
67
|
|
|
@@ -353,7 +353,8 @@ Modular extension with zero external dependencies (only pi's bundled `@earendil-
|
|
|
353
353
|
- **Sliding window** — only the last `detectionWindow` messages participate, capping memory at O(W × message_size)
|
|
354
354
|
- **Early bail** — short messages and empty tool calls skip similarity computation entirely
|
|
355
355
|
- **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)
|
|
356
|
-
- **Hooks** — `message_end` (track messages + tool call ids), `turn_end` (attach result fingerprints
|
|
356
|
+
- **Hooks** — `message_end` (track messages + tool call ids), `turn_end` (attach result fingerprints, detect, and intervene: steer the force break / abort the run), `input` (decay on real user messages only), `session_start` (load config + install footer + reset), `session_shutdown` (restore built-in footer)
|
|
357
|
+
- **Intervention runs on the turn loop, not on user prompts** — escalation is decided at `turn_end`, the break is steered into the running agent before its next LLM call, and the guaranteed hard stop aborts the run (`ctx.abort`, fire-and-forget — never awaited, so the hook can't deadlock). No custom-role messages are injected into the conversation at any level (steering a real user message + aborting are the only levers; custom-role injections were removed because a model can stall on an unexpected injected message)
|
|
357
358
|
|
|
358
359
|
## License
|
|
359
360
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antiloop",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Antiloop: detect reasoning loops and force a break (warn \u2192 force \u2192 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. Task-stream recognition: when an extension (punched, plan, \u2026) makes the model call the SAME tool many times with DIFFERENT content \u2014 N distinct tasks of one type, e.g. appending lines or adding plan tasks \u2014 antiloop stays silent.",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Antiloop: detect reasoning loops and force a break (warn \u2192 force \u2192 abort) across text, tool, thinking, and structural patterns. The force break is delivered mid-run: a real break message is steered into the running agent right before its next LLM call, and if the model ignores it and keeps repeating verbatim, antiloop aborts the run \u2014 an autonomous tool loop always terminates. 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. Task-stream recognition: when an extension (punched, plan, \u2026) makes the model call the SAME tool many times with DIFFERENT content \u2014 N distinct tasks of one type, e.g. appending lines or adding plan tasks \u2014 antiloop stays silent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"antiloop",
|
package/src/commands.ts
CHANGED
|
@@ -32,7 +32,6 @@ export async function handleCommand(
|
|
|
32
32
|
return showLog(ctx, rt);
|
|
33
33
|
case "reset":
|
|
34
34
|
resetState(rt.state);
|
|
35
|
-
rt.pendingIntervention = null;
|
|
36
35
|
ctx.ui.notify("antiloop: reset", "info");
|
|
37
36
|
rt.updateStatus(ctx);
|
|
38
37
|
return;
|
|
@@ -52,10 +51,10 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
|
|
|
52
51
|
const recent = rt.state.detections.slice(-5);
|
|
53
52
|
const lines = [
|
|
54
53
|
`state: ${rt.config.enabled ? "ON" : "OFF"} · level: ${lvl} · consecutive: ${rt.state.consecutiveDetections}`,
|
|
55
|
-
`total: ${rt.state.totalDetections} · tracked: ${rt.state.recentMessages.length} · forced: ${rt.state.inForcedBreak ? "yes" : "no"}`,
|
|
54
|
+
`total: ${rt.state.totalDetections} · tracked: ${rt.state.recentMessages.length} · forced: ${rt.state.inForcedBreak ? "yes" : "no"} · steer: ${rt.state.steerDelivered ? `sent (${rt.state.ignoredSteerCount} ignored)` : "armed"}`,
|
|
56
55
|
"",
|
|
57
56
|
"thresholds:",
|
|
58
|
-
` warn: ${rt.config.warningThreshold} force: ${rt.config.forceBreakThreshold} abort: ${rt.config.abortThreshold || "off"}`,
|
|
57
|
+
` warn: ${rt.config.warningThreshold} force: ${rt.config.forceBreakThreshold} abort: ${rt.config.abortThreshold || "off"} stop-after-ignored-break: ${rt.config.ignoredSteerLimit}`,
|
|
59
58
|
` similarity: ${(rt.config.similarityThreshold * 100).toFixed(0)}% window: ${rt.config.detectionWindow}`,
|
|
60
59
|
` tool sim: ${(rt.config.toolSimilarityThreshold * 100).toFixed(0)}% tool repeat: ${rt.config.minToolRepeatCount}+ prior`,
|
|
61
60
|
` result sim: ${(rt.config.resultSimilarityThreshold * 100).toFixed(0)}% (same cmd + diff outcome = no loop)`,
|
|
@@ -87,6 +86,7 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
87
86
|
{ value: "warn" as const, label: `⚠️ warn after: ${c.warningThreshold}`, description: "repetitions before antiloop warns you" },
|
|
88
87
|
{ value: "force" as const, label: `🛑 force break after: ${c.forceBreakThreshold}`, description: "repetitions before forcing a change of approach" },
|
|
89
88
|
{ value: "abort" as const, label: `🚨 abort after: ${c.abortThreshold || "off"}`, description: "repetitions before aborting (0 = disabled)" },
|
|
89
|
+
{ value: "ignoredBreak" as const, label: `🛑 stop after ignored break: ${c.ignoredSteerLimit}`, description: "identical repeats allowed after the force break before antiloop stops the run" },
|
|
90
90
|
{ value: "sim" as const, label: `📏 text similarity: ${(c.similarityThreshold * 100).toFixed(0)}%`, description: "how similar two messages must be to count as a loop" },
|
|
91
91
|
{ value: "toolSim" as const, label: `🔧 call similarity: ${(c.toolSimilarityThreshold * 100).toFixed(0)}%`, description: "how identical tool calls must be to count as the same call" },
|
|
92
92
|
{ value: "toolRepeat" as const, label: `🔁 call repeats: ${c.minToolRepeatCount}+`, description: "how many times the same call must repeat before it flags" },
|
|
@@ -230,6 +230,15 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
230
230
|
if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`twin threshold: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
231
231
|
break;
|
|
232
232
|
}
|
|
233
|
+
case "ignoredBreak": {
|
|
234
|
+
const v = await selectFrom(ctx, "🛑 stop after ignored break (identical repeats after the force break)", [
|
|
235
|
+
{ value: 1, label: "⚡ 1 (sensitive — one identical repeat after the break stops the run)" },
|
|
236
|
+
{ value: 2, label: "🎯 2 (default)" },
|
|
237
|
+
{ value: 3, label: "🐢 3 (lenient)" },
|
|
238
|
+
]);
|
|
239
|
+
if (v !== undefined) { c.ignoredSteerLimit = v; saveConfig(c); ctx.ui.notify(`stop after ignored break: ${v}`, "info"); }
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
233
242
|
case "text":
|
|
234
243
|
c.detectTextLoops = !c.detectTextLoops; saveConfig(c);
|
|
235
244
|
ctx.ui.notify(`text: ${yn(c.detectTextLoops)}`, "info"); break;
|
|
@@ -241,7 +250,6 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
241
250
|
ctx.ui.notify(`thinking: ${yn(c.detectThinkingLoops)}`, "info"); break;
|
|
242
251
|
case "reset":
|
|
243
252
|
resetState(rt.state);
|
|
244
|
-
rt.pendingIntervention = null;
|
|
245
253
|
ctx.ui.notify("🧹 state reset", "info");
|
|
246
254
|
rt.updateStatus(ctx);
|
|
247
255
|
break;
|
|
@@ -270,6 +278,8 @@ export function resetState(state: AntiloopState): void {
|
|
|
270
278
|
state.inForcedBreak = false;
|
|
271
279
|
state.totalDetections = 0;
|
|
272
280
|
state.lastDetectedTurnIndex = -1;
|
|
281
|
+
state.steerDelivered = false;
|
|
282
|
+
state.ignoredSteerCount = 0;
|
|
273
283
|
}
|
|
274
284
|
|
|
275
285
|
async function runSelfTest(ctx: ExtensionCommandContext): Promise<void> {
|
package/src/config.ts
CHANGED
package/src/detect.ts
CHANGED
|
@@ -287,15 +287,23 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
|
|
|
287
287
|
return out;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
290
|
+
/** Escalation level for a consecutive-detection count (mirror of the configured
|
|
291
|
+
* ladder). Abort (3) only when abortThreshold is enabled (> 0). */
|
|
292
|
+
export function nextLevel(consecutiveDetections: number, config: AntiloopConfig): 0 | 1 | 2 | 3 {
|
|
293
|
+
if (config.abortThreshold > 0 && consecutiveDetections >= config.abortThreshold) return 3;
|
|
294
|
+
if (consecutiveDetections >= config.forceBreakThreshold) return 2;
|
|
295
|
+
if (consecutiveDetections >= config.warningThreshold) return 1;
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** True when detections prove the model repeated a message/tool call essentially
|
|
300
|
+
* verbatim (≥98% text similarity or an identical tool-loop). Weaker signals
|
|
301
|
+
* (thinking echoes, structural repeated openings at 90%) do NOT count — a model
|
|
302
|
+
* that only *thinks* in circles but varies its actual output is still making an
|
|
303
|
+
* attempt and must not be hard-stopped. Used post-force-break: only verbatim
|
|
304
|
+
* repeats prove the model ignored the break instruction. */
|
|
305
|
+
export function isVerbatimRepeat(detections: LoopDetection[]): boolean {
|
|
306
|
+
return detections.some((d) => d.type !== "thinking" && d.similarity >= 0.98);
|
|
299
307
|
}
|
|
300
308
|
|
|
301
309
|
// ---------------------------------------------------------------------------
|
|
@@ -369,7 +377,7 @@ export function runSelfTest(): string[] {
|
|
|
369
377
|
const noteArgs = (x: string) =>
|
|
370
378
|
JSON.stringify({ type: "note", title: `task ${x}`, body: "append this line to the project memory document so context is preserved" });
|
|
371
379
|
const tcfg: AntiloopConfig = {
|
|
372
|
-
enabled: true, warningThreshold: 2, forceBreakThreshold: 3, abortThreshold: 0,
|
|
380
|
+
enabled: true, warningThreshold: 2, forceBreakThreshold: 3, abortThreshold: 0, ignoredSteerLimit: 2,
|
|
373
381
|
similarityThreshold: 0.75, toolSimilarityThreshold: 0.95, minToolRepeatCount: 2,
|
|
374
382
|
resultSimilarityThreshold: 0.8, detectToolLoops: true, detectThinkingLoops: true,
|
|
375
383
|
detectTextLoops: true, notifyOnDetection: true, maxHistoryEntries: 100,
|
|
@@ -379,7 +387,8 @@ export function runSelfTest(): string[] {
|
|
|
379
387
|
const asState = (recentMessages: TrackedMessage[]): AntiloopState =>
|
|
380
388
|
({ recentMessages, detections: [], activeTaskStreams: [], currentLevel: 0,
|
|
381
389
|
consecutiveDetections: 0, inForcedBreak: false, totalDetections: 0,
|
|
382
|
-
lastUserMessageTime: 0, lastDetectedTurnIndex: -1
|
|
390
|
+
lastUserMessageTime: 0, lastDetectedTurnIndex: -1,
|
|
391
|
+
steerDelivered: false, ignoredSteerCount: 0 });
|
|
383
392
|
const NARR = "Now I will append the next decision entry to the project memory document so we keep the context.";
|
|
384
393
|
|
|
385
394
|
// 1) punched_log batch: 3 DIFFERENT appends (args 98.9% similar, NOT twins)
|
|
@@ -421,5 +430,18 @@ export function runSelfTest(): string[] {
|
|
|
421
430
|
const twoStreams = detectTaskStreams(twoMsgs, tcfg);
|
|
422
431
|
out.push(`stream needs ≥3 calls → ${twoStreams.size === 0 ? "no stream" : "stream"} (exp no stream at 2 calls) ${twoStreams.size === 0 ? "✅" : "❌"}`);
|
|
423
432
|
|
|
433
|
+
// --- v1.5: escalation ladder + post-steer verbatim-repeat gating ---
|
|
434
|
+
const lvl = (n: number) => nextLevel(n, tcfg);
|
|
435
|
+
out.push(`ladder 0→0 1→0 2→1 3→2 4→2 → ${[0, 1, 2, 3, 4].map(lvl).join(",")} (exp 0,0,1,2,2) ${[0, 1, 2, 3, 4].map(lvl).join(",") === "0,0,1,2,2" ? "✅" : "❌"}`);
|
|
436
|
+
const abortCfg: AntiloopConfig = { ...tcfg, abortThreshold: 5 };
|
|
437
|
+
out.push(`ladder abort@5 → 5→3 → ${nextLevel(5, abortCfg)} (exp 3) ${nextLevel(5, abortCfg) === 3 ? "✅" : "❌"}`);
|
|
438
|
+
const dl = (type: LoopDetection["type"], sim: number): LoopDetection[] =>
|
|
439
|
+
[{ type, similarity: sim, messageIndices: [0, 1], description: `${type} ${sim}`, timestamp: Date.now() }];
|
|
440
|
+
out.push(`verbatim text 1.00 → ${isVerbatimRepeat(dl("text", 1)) ? "yes" : "no"} (exp yes) ${isVerbatimRepeat(dl("text", 1)) ? "✅" : "❌"}`);
|
|
441
|
+
out.push(`verbatim text 0.97 → ${isVerbatimRepeat(dl("text", 0.97)) ? "yes" : "no"} (exp no — changed output = attempt) ${!isVerbatimRepeat(dl("text", 0.97)) ? "✅" : "❌"}`);
|
|
442
|
+
out.push(`verbatim tool-loop → ${isVerbatimRepeat(dl("tool", 1)) ? "yes" : "no"} (exp yes) ${isVerbatimRepeat(dl("tool", 1)) ? "✅" : "❌"}`);
|
|
443
|
+
out.push(`thinking-only 1.00 → ${isVerbatimRepeat(dl("thinking", 1)) ? "yes" : "no"} (exp no — output varies) ${!isVerbatimRepeat(dl("thinking", 1)) ? "✅" : "❌"}`);
|
|
444
|
+
out.push(`structural 0.90 → ${isVerbatimRepeat(dl("structural", 0.9)) ? "yes" : "no"} (exp no) ${!isVerbatimRepeat(dl("structural", 0.9)) ? "✅" : "❌"}`);
|
|
445
|
+
|
|
424
446
|
return out;
|
|
425
447
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* antiloop — detect reasoning loops and intervene.
|
|
3
|
-
* Hooks: message_end, input,
|
|
3
|
+
* Hooks: message_end, input, turn_end, session_start, session_shutdown.
|
|
4
4
|
* Commands: /antiloop [enable|disable|status|config|log|reset|test]
|
|
5
|
+
*
|
|
6
|
+
* Intervention model (v1.5):
|
|
7
|
+
* warning (level 1) — informational only (notify). Never injects: an injected
|
|
8
|
+
* message at warning level made models stall on the unexpected message.
|
|
9
|
+
* force (level 2) — a REAL user message is steered into the running agent
|
|
10
|
+
* (pi.sendUserMessage, deliverAs "steer"); pi delivers it right after the
|
|
11
|
+
* current tool results, immediately before the next LLM call, so it lands at
|
|
12
|
+
* the exact spot where the model anchors — even a deterministic model stuck
|
|
13
|
+
* on an identical context tail must respond to it. One steer per episode.
|
|
14
|
+
* If the model ignores the steer and repeats the same message verbatim
|
|
15
|
+
* (≥98% similar / identical tool loop) ignoredSteerLimit times, antiloop
|
|
16
|
+
* hard-stops the run (ctx.abort).
|
|
17
|
+
* abort (level 3, opt-in via abortThreshold) — stops the run outright.
|
|
5
18
|
*/
|
|
6
19
|
|
|
7
20
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -23,6 +36,8 @@ function newState(): AntiloopState {
|
|
|
23
36
|
totalDetections: 0,
|
|
24
37
|
lastUserMessageTime: 0,
|
|
25
38
|
lastDetectedTurnIndex: -1,
|
|
39
|
+
steerDelivered: false,
|
|
40
|
+
ignoredSteerCount: 0,
|
|
26
41
|
};
|
|
27
42
|
}
|
|
28
43
|
|
|
@@ -54,8 +69,7 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
54
69
|
activeTui?.requestRender();
|
|
55
70
|
}
|
|
56
71
|
|
|
57
|
-
const rt: Runtime = { config, state,
|
|
58
|
-
const setPending = (v: string | null) => { rt.pendingIntervention = v; };
|
|
72
|
+
const rt: Runtime = { config, state, updateStatus, refreshFooter: installFooter };
|
|
59
73
|
|
|
60
74
|
/** Toggle enable/disable, persisting config and refreshing the footer. */
|
|
61
75
|
function toggleEnabled(ctx: ExtensionContext): void {
|
|
@@ -65,38 +79,65 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
65
79
|
updateStatus(ctx);
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
/** Set the escalation level; keeps the derived inForcedBreak flag in sync. */
|
|
83
|
+
function applyLevel(level: 0 | 1 | 2 | 3): void {
|
|
84
|
+
state.currentLevel = level;
|
|
85
|
+
state.inForcedBreak = level >= 2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** level from the current consecutive count (lazy import wrapper). */
|
|
89
|
+
async function levelForConsecutive(): Promise<0 | 1 | 2 | 3> {
|
|
90
|
+
const { nextLevel } = await import("./detect.ts");
|
|
91
|
+
return nextLevel(state.consecutiveDetections, config);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Force break: steer a real user message into the running agent. pi delivers
|
|
96
|
+
* "steer" messages right after the current tool results and BEFORE the next
|
|
97
|
+
* LLM call — the model must respond to it, which breaks the identical-context
|
|
98
|
+
* anchoring that verbatim loops feed on. (before_agent_start injection was
|
|
99
|
+
* abandoned: it only fires on the NEXT user prompt, never mid-run — the exact
|
|
100
|
+
* reason antiloop previously failed to cut autonomous tool loops.)
|
|
101
|
+
*/
|
|
102
|
+
function deliverForceBreak(ctx: ExtensionContext, detections: LoopDetection[]): void {
|
|
103
|
+
const detail = detections[0]?.description ?? "repeated message/tool calls";
|
|
104
|
+
try {
|
|
105
|
+
pi.sendUserMessage(
|
|
106
|
+
`[antiloop] 🛑 Force break — loop detected (${detail}; ${state.consecutiveDetections} consecutive).\n` +
|
|
107
|
+
"Stop repeating previous text, reasoning and tool calls, and change approach now.\n" +
|
|
108
|
+
"If you cannot make progress with a different approach, do NOT call more tools — " +
|
|
109
|
+
"reply to the user briefly: what you tried, what is blocking you, and what you need.",
|
|
110
|
+
{ deliverAs: "steer" },
|
|
111
|
+
);
|
|
112
|
+
state.steerDelivered = true;
|
|
113
|
+
if (config.notifyOnDetection) {
|
|
114
|
+
ctx.ui.notify(`antiloop: force break — ${detail} (break message sent to the model)`, "error");
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Steer could not be queued (edge: run ended between detection and queue).
|
|
118
|
+
// Stay armed so the next steerable turn delivers the break.
|
|
119
|
+
state.steerDelivered = false;
|
|
120
|
+
if (config.notifyOnDetection) {
|
|
121
|
+
ctx.ui.notify(
|
|
122
|
+
`antiloop: force break — ${detail} (could not inject: ${err instanceof Error ? err.message : String(err)})`,
|
|
123
|
+
"error",
|
|
124
|
+
);
|
|
77
125
|
}
|
|
78
|
-
return;
|
|
79
126
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// even though hard-kill turns remained. Only force (2) / abort (3) inject.
|
|
95
|
-
if (state.currentLevel >= 2) {
|
|
96
|
-
setPending(interventionMessage(state.currentLevel as 2 | 3, detections));
|
|
97
|
-
state.inForcedBreak = true;
|
|
98
|
-
} else if (state.currentLevel === 1) {
|
|
99
|
-
state.inForcedBreak = false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Hard stop: abort the current agent run (fire-and-forget — never awaited, so
|
|
131
|
+
* the turn_end handler cannot deadlock on waitForIdle) and tell the user.
|
|
132
|
+
*/
|
|
133
|
+
function hardStop(ctx: ExtensionContext, text: string): void {
|
|
134
|
+
try {
|
|
135
|
+
ctx.abort();
|
|
136
|
+
} catch {
|
|
137
|
+
// abort must never throw out of a hook.
|
|
138
|
+
}
|
|
139
|
+
if (config.notifyOnDetection) {
|
|
140
|
+
ctx.ui.notify(text, "error");
|
|
100
141
|
}
|
|
101
142
|
}
|
|
102
143
|
|
|
@@ -222,44 +263,29 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
222
263
|
}
|
|
223
264
|
});
|
|
224
265
|
|
|
225
|
-
pi.on("input", async () => {
|
|
266
|
+
pi.on("input", async (event) => {
|
|
226
267
|
if (!config.enabled) return;
|
|
268
|
+
// Messages queued by extensions (including antiloop's own force-break
|
|
269
|
+
// steer) are NOT user input: they must not cool down the escalation or
|
|
270
|
+
// re-arm the steer — that would defeat the force break.
|
|
271
|
+
const source = (event as { source?: string } | undefined)?.source;
|
|
272
|
+
if (source === "extension") return { action: "continue" };
|
|
273
|
+
|
|
227
274
|
state.lastUserMessageTime = Date.now();
|
|
228
|
-
if (state.consecutiveDetections > 0)
|
|
229
|
-
|
|
230
|
-
state.currentLevel = 0;
|
|
231
|
-
state.inForcedBreak = false;
|
|
275
|
+
if (state.consecutiveDetections > 0) {
|
|
276
|
+
state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 2);
|
|
232
277
|
}
|
|
278
|
+
// A real user message is a new chance: mirror the level off the cooled-down
|
|
279
|
+
// counter and re-arm the force break so a fresh loop gets a fresh steer.
|
|
280
|
+
applyLevel(await levelForConsecutive());
|
|
281
|
+
state.steerDelivered = false;
|
|
282
|
+
state.ignoredSteerCount = 0;
|
|
233
283
|
return { action: "continue" };
|
|
234
284
|
});
|
|
235
285
|
|
|
236
|
-
pi.on("before_agent_start", async () => {
|
|
237
|
-
if (!config.enabled || !rt.pendingIntervention) return;
|
|
238
|
-
const msg = rt.pendingIntervention;
|
|
239
|
-
rt.pendingIntervention = null;
|
|
240
|
-
return {
|
|
241
|
-
message: { customType: "antiloop-intervention", content: msg, display: true },
|
|
242
|
-
};
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
pi.on("context", async (event) => {
|
|
246
|
-
if (!config.enabled || state.currentLevel < 2) return;
|
|
247
|
-
const msgs = [...event.messages];
|
|
248
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
249
|
-
if (msgs[i].role === "assistant") {
|
|
250
|
-
const m = msgs[i] as { content: string | Array<{ type: string; text?: string }> };
|
|
251
|
-
const inject = "\n\n[antiloop] break out of loop — try a different approach.";
|
|
252
|
-
if (typeof m.content === "string") m.content += inject;
|
|
253
|
-
else if (Array.isArray(m.content)) m.content.push({ type: "text", text: inject });
|
|
254
|
-
break;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return { messages: msgs };
|
|
258
|
-
});
|
|
259
|
-
|
|
260
286
|
pi.on("turn_end", async (event, ctx) => {
|
|
261
287
|
if (!config.enabled) return;
|
|
262
|
-
const { detectLoops, detectTaskStreams,
|
|
288
|
+
const { detectLoops, detectTaskStreams, isVerbatimRepeat, nextLevel, resultFingerprint } = await import("./detect.ts");
|
|
263
289
|
|
|
264
290
|
const last = state.recentMessages[state.recentMessages.length - 1];
|
|
265
291
|
if (!last || last.turnIndex === state.lastDetectedTurnIndex) {
|
|
@@ -288,12 +314,66 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
288
314
|
).map(([tool, count]) => ({ tool, count }));
|
|
289
315
|
|
|
290
316
|
const detections = detectLoops(state, config);
|
|
291
|
-
|
|
317
|
+
const prevLevel = state.currentLevel;
|
|
292
318
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
319
|
+
// ---- clean turn: cool down ----------------------------------------
|
|
320
|
+
if (!detections.length) {
|
|
321
|
+
if (state.consecutiveDetections > 0) state.consecutiveDetections--;
|
|
322
|
+
applyLevel(nextLevel(state.consecutiveDetections, config));
|
|
323
|
+
if (state.currentLevel === 0) {
|
|
324
|
+
state.steerDelivered = false;
|
|
325
|
+
state.ignoredSteerCount = 0;
|
|
326
|
+
}
|
|
327
|
+
updateStatus(ctx);
|
|
328
|
+
return;
|
|
296
329
|
}
|
|
330
|
+
|
|
331
|
+
// ---- detection: escalate ------------------------------------------
|
|
332
|
+
state.consecutiveDetections++;
|
|
333
|
+
state.totalDetections++;
|
|
334
|
+
state.detections.push(...detections);
|
|
335
|
+
if (state.detections.length > config.maxHistoryEntries) {
|
|
336
|
+
state.detections = state.detections.slice(-config.maxHistoryEntries);
|
|
337
|
+
}
|
|
338
|
+
applyLevel(nextLevel(state.consecutiveDetections, config));
|
|
339
|
+
const level = state.currentLevel;
|
|
340
|
+
|
|
341
|
+
// The steer is only picked up when the run keeps going after this turn.
|
|
342
|
+
// On error/aborted stop reasons the agent loop returns immediately and the
|
|
343
|
+
// queued message would go stale — stay armed and steer on a later turn.
|
|
344
|
+
const stopReason = (event as { message?: { stopReason?: string } } | undefined)?.message?.stopReason;
|
|
345
|
+
const steerable = stopReason !== "error" && stopReason !== "aborted" && ctx.signal !== undefined;
|
|
346
|
+
|
|
347
|
+
if (level === 1 && prevLevel < 1) {
|
|
348
|
+
// Warning: informational only (never injects — a warning must not stall).
|
|
349
|
+
if (config.notifyOnDetection) {
|
|
350
|
+
ctx.ui.notify(`antiloop: warning — ${detections[0].description}`, "warning");
|
|
351
|
+
}
|
|
352
|
+
} else if (level === 2) {
|
|
353
|
+
if (!state.steerDelivered) {
|
|
354
|
+
// First force-break turn of the episode: steer a break message
|
|
355
|
+
// before the next LLM call.
|
|
356
|
+
if (steerable) {
|
|
357
|
+
deliverForceBreak(ctx, detections);
|
|
358
|
+
} else if (prevLevel < 2 && config.notifyOnDetection) {
|
|
359
|
+
ctx.ui.notify(`antiloop: force break — ${detections[0].description}`, "error");
|
|
360
|
+
}
|
|
361
|
+
} else if (isVerbatimRepeat(detections)) {
|
|
362
|
+
// The model repeated the same message/call after being told to stop:
|
|
363
|
+
// count it; once the ignore limit is hit the run is cut for good.
|
|
364
|
+
state.ignoredSteerCount++;
|
|
365
|
+
if (state.ignoredSteerCount >= config.ignoredSteerLimit) {
|
|
366
|
+
hardStop(
|
|
367
|
+
ctx,
|
|
368
|
+
`antiloop: abort — the model repeated the same message ${state.ignoredSteerCount}× after the force break — run stopped; provide new instructions`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
} else if (level === 3) {
|
|
373
|
+
// abortThreshold configured and reached: stop the run outright.
|
|
374
|
+
hardStop(ctx, `antiloop: abort — ${detections[0].description} — run stopped; provide new instructions`);
|
|
375
|
+
}
|
|
376
|
+
|
|
297
377
|
updateStatus(ctx);
|
|
298
378
|
});
|
|
299
379
|
|
|
@@ -301,7 +381,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
301
381
|
// re-read config and reset state for a fresh session
|
|
302
382
|
Object.assign(config, loadConfig());
|
|
303
383
|
state = newState();
|
|
304
|
-
rt.pendingIntervention = null;
|
|
305
384
|
installFooter(ctx);
|
|
306
385
|
updateStatus(ctx);
|
|
307
386
|
});
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,15 @@ export interface AntiloopConfig {
|
|
|
7
7
|
forceBreakThreshold: number;
|
|
8
8
|
abortThreshold: number;
|
|
9
9
|
similarityThreshold: number;
|
|
10
|
+
/**
|
|
11
|
+
* How many ESSENTIALLY IDENTICAL repeats (≥98% similar text / identical tool
|
|
12
|
+
* loops) the model may produce AFTER the force-break message before antiloop
|
|
13
|
+
* hard-stops the run (ctx.abort). Guards the force break: if the model ignores
|
|
14
|
+
* the break instruction and keeps repeating verbatim, the run is cut instead of
|
|
15
|
+
* burning context forever. Only verbatim repeats count — a model that changes
|
|
16
|
+
* its output (even while still similar) gets room to escape on its own.
|
|
17
|
+
*/
|
|
18
|
+
ignoredSteerLimit: number;
|
|
10
19
|
/**
|
|
11
20
|
* How close tool-call arguments must be (0..1) to count as the SAME call.
|
|
12
21
|
* High by default: long bash commands share scaffolding (env setup, flags,
|
|
@@ -107,12 +116,18 @@ export interface AntiloopState {
|
|
|
107
116
|
lastUserMessageTime: number;
|
|
108
117
|
/** turnIndex of the last tracked message detection already ran on. */
|
|
109
118
|
lastDetectedTurnIndex: number;
|
|
119
|
+
/** True once the force-break user message was steered into the current episode.
|
|
120
|
+
* One steer per episode: repeated steering would spam the conversation. Cleared
|
|
121
|
+
* when the episode decays (currentLevel back to 0) or on real user input. */
|
|
122
|
+
steerDelivered: boolean;
|
|
123
|
+
/** Verbatim repeats counted AFTER the steer. When this reaches
|
|
124
|
+
* config.ignoredSteerLimit the run is hard-stopped (aborted). */
|
|
125
|
+
ignoredSteerCount: number;
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
export interface Runtime {
|
|
113
129
|
config: AntiloopConfig;
|
|
114
130
|
state: AntiloopState;
|
|
115
|
-
pendingIntervention: string | null;
|
|
116
131
|
updateStatus(ctx: ExtensionContext): void;
|
|
117
132
|
/** Re-install the interactive footer (after config changes). */
|
|
118
133
|
refreshFooter?(ctx: ExtensionContext): void;
|