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 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 `⚠️ antiloop(N)` warning; if it keeps looping past the force-break threshold, antiloop injects an "abort this pattern now" instruction into the context.
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 + 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)
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.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
@@ -12,6 +12,7 @@ export const DEFAULT_CONFIG: AntiloopConfig = {
12
12
  warningThreshold: 2,
13
13
  forceBreakThreshold: 3,
14
14
  abortThreshold: 0,
15
+ ignoredSteerLimit: 2,
15
16
  similarityThreshold: 0.75,
16
17
  toolSimilarityThreshold: 0.95,
17
18
  minToolRepeatCount: 2,
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
- export function interventionMessage(level: 1 | 2 | 3, detections: LoopDetection[]): string {
291
- const det = detections.map((d) => `- ${d.description}`).join("\n");
292
- if (level === 1) {
293
- return `[antiloop] ⚠️ loop warning\n${det}\nvary approach try a different strategy.`;
294
- }
295
- if (level === 2) {
296
- return `[antiloop] 🛑 stuck in loop\n${det}\nstop, change approach, do NOT repeat previous tool calls or reasoning.`;
297
- }
298
- return `[antiloop] 🚨 persistent loop\n${det}\nunable to break automatically — provide new instructions.`;
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, before_agent_start, context, turn_end, session_start, session_shutdown.
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, pendingIntervention: null, updateStatus, refreshFooter: installFooter };
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
- function processDetections(
69
- detections: LoopDetection[],
70
- interventionMessage: (level: 2 | 3, d: LoopDetection[]) => string,
71
- ): void {
72
- if (!detections.length) {
73
- if (state.consecutiveDetections > 0) state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 1);
74
- if (state.currentLevel > 0 && state.consecutiveDetections === 0) {
75
- state.currentLevel = 0;
76
- state.inForcedBreak = false;
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
- state.consecutiveDetections++;
81
- state.totalDetections++;
82
- state.detections.push(...detections);
83
- if (state.detections.length > config.maxHistoryEntries) state.detections = state.detections.slice(-config.maxHistoryEntries);
84
-
85
- let next: 0 | 1 | 2 | 3 = 0;
86
- if (state.consecutiveDetections >= config.abortThreshold && config.abortThreshold > 0) next = 3;
87
- else if (state.consecutiveDetections >= config.forceBreakThreshold) next = 2;
88
- else if (state.consecutiveDetections >= config.warningThreshold) next = 1;
89
- if (next > state.currentLevel) state.currentLevel = next;
90
-
91
- // Warning (level 1) is informational only: notify the user but DO NOT
92
- // inject any message into the conversation. Injecting at warning level
93
- // made the model respond to the warning, which could stall generation
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) state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 2);
229
- if (state.consecutiveDetections < config.warningThreshold) {
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, interventionMessage, resultFingerprint } = await import("./detect.ts");
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
- processDetections(detections, interventionMessage);
317
+ const prevLevel = state.currentLevel;
292
318
 
293
- if (config.notifyOnDetection && detections.length && state.currentLevel > 0) {
294
- const lvl = ["", "warning", "force", "abort"][state.currentLevel];
295
- ctx.ui.notify(`antiloop: ${lvl} — ${detections[0].description}`, state.currentLevel >= 2 ? "error" : "warning");
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;