pi-antiloop 1.4.0 → 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
 
@@ -106,35 +106,35 @@ Recent detections:
106
106
 
107
107
  ### `/antiloop config`
108
108
 
109
- Menú interactivo agrupado, con el valor actual en cada opción:
109
+ Grouped interactive menu showing the current value in each option:
110
110
 
111
111
  **🎛️ General**
112
- - **🟢/🔴 activar/desactivar** — enciende o apaga la detección
113
- - **⏳ ventana** — `5 / 10 / 15 / 20` — cuántos mensajes recientes se analizan (por defecto 10)
114
- - **🔔 avisos** — on/off — avisa cuando detecta un bucle
115
- - **📊 pie interactivo** — on/off — experimental: reemplaza el pie de pantalla de pi y captura el teclado para el atajo (si da problemas de rendimiento, déjalo en off)
116
- - **⌨️ atajo** — `esc+a` o desactivadopulsación para activar/apagar sin teclear nada
117
-
118
- **🎯 Detección**
119
- - **⚠️ umbral de aviso** — `1 / 2 / 3 / 5` — repeticiones antes de avisar (por defecto 2)
120
- - **🛑 umbral de corte** — `2 / 3 / 5 / 8` — repeticiones antes de forzar un cambio de rumbo (por defecto 3)
121
- - **🚨 umbral de aborto** — `desactivado / 5 / 8 / 10 / 15` — repeticiones antes de abortar (0 = desactivado)
122
- - **📏 parecido mínimo** — `50 / 60 / 70 / 75 / 80 / 90%` — qué tan parecidos deben ser dos mensajes para contar como bucle (por defecto 75%)
123
- - **🔧 parecido de llamadas** — `99 / 95 / 90 / 80%` — qué tan idénticas deben ser las llamadas para contar como la misma (por defecto 95%: solo repeticiones casi idénticas son bucle)
124
- - **🔁 repeticiones de llamada** — `1 / 2 / 3` — cuántas veces se repite la misma llamada antes de marcarla (por defecto 2)
125
- - **🧾 parecido de resultados** — `95 / 80 / 60%` — qué tan parecidos deben ser los resultados para contar como el *mismo resultado*; si un comando repetido empieza a dar otro resultado, es progreso y no bucle (por defecto 80%)
126
-
127
- **📋 Lotes de tareas** — el trabajo en lote (punched_log, plan_manager, …) es N tareas de un tipo, no un bucle
128
- - **📋 lotes de tareas** — on/off — reconoce ese trabajo en lote y se queda en silencio
129
- - **📋 llamadas mínimas** — `2 / 3 / 4 / 5` — llamadas de la misma herramienta antes de reconocer un lote (por defecto 3)
130
- - **📋 gemelos** — `99 / 95 / 90%` — llamadas más parecidas que esto cuentan como la *misma tarea* repetida; un gemelo invalida el lote y vuelve a la detección normal (por defecto 99%)
131
-
132
- **🔍 Detectores**
133
- - **📝 texto** — on/off — detecta mensajes de texto repetidos
134
- - **🔧 herramientas** — on/off — detecta llamadas a herramientas repetidas
135
- - **🧠 pensamiento** — on/off — detecta bloques de razonamiento repetidos
136
-
137
- **🧹 reiniciar estado** — borra contadores e historial
112
+ - **🟢/🔴 enable/disable** — turn detection on or off
113
+ - **⏳ window** — `5 / 10 / 15 / 20` — how many recent messages are analyzed (default 10)
114
+ - **🔔 notifications** — on/off — show a warning when a loop is detected
115
+ - **📊 interactive footer** — on/off — experimental: replaces pi's footer and captures keystrokes for the toggle shortcut (leave it off if it misbehaves)
116
+ - **⌨️ shortcut** — `esc+a` or offkey press to toggle without typing a command
117
+
118
+ **🎯 Detection**
119
+ - **⚠️ warn threshold** — `1 / 2 / 3 / 5` — repetitions before antiloop warns you (default 2)
120
+ - **🛑 force break threshold** — `2 / 3 / 5 / 8` — repetitions before forcing a change of approach (default 3)
121
+ - **🚨 abort threshold** — `off / 5 / 8 / 10 / 15` — repetitions before aborting (0 = disabled)
122
+ - **📏 text similarity** — `50 / 60 / 70 / 75 / 80 / 90%` — how similar two messages must be to count as a loop (default 75%)
123
+ - **🔧 call similarity** — `99 / 95 / 90 / 80%` — how identical tool-call *arguments* must be to count as the same call (default 95%: only near-identical repeats loop)
124
+ - **🔁 call repeats** — `1 / 2 / 3` — how many times the same call must repeat before it flags (default 2)
125
+ - **🧾 result similarity** — `95 / 80 / 60%` — how similar captured results must be to count as the *same outcome*; a repeated command that starts producing a different result is progress, not a loop (default 80%)
126
+
127
+ **📋 Task streams** — batch work (punched_log, plan_manager, …) is N tasks of one type, not a loop
128
+ - **📋 task streams** — on/off — recognize that batch work and stay silent
129
+ - **📋 stream min calls** — `2 / 3 / 4 / 5` — same-tool calls required before a batch is recognized (default 3)
130
+ - **📋 twin threshold** — `99 / 95 / 90%` — calls more similar than this count as the *same task* repeated; one twin invalidates the batch and normal detection resumes (default 99%)
131
+
132
+ **🔍 Detectors**
133
+ - **📝 text** — on/off — detect repeated text messages
134
+ - **🔧 tools** — on/off — detect repeated tool calls
135
+ - **🧠 thinking** — on/off — detect repeated internal reasoning
136
+
137
+ **🧹 reset state** — clear all counters and history
138
138
 
139
139
  ### `/antiloop log`
140
140
 
@@ -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.0",
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)`,
@@ -78,29 +77,30 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
78
77
  const c = rt.config;
79
78
  const picked = await selectFrom(ctx, "⚙️ antiloop config", [
80
79
  // ── 🎛️ General ──────────────────────────────────────────────
81
- { value: "toggle" as const, label: c.enabled ? "🟢 desactivar" : "🔴 activar", description: "enciende o apaga la detección" },
82
- { value: "window" as const, label: `⏳ ventana: ${c.detectionWindow} msgs`, description: "cuántos mensajes recientes se analizan" },
83
- { value: "notify" as const, label: `🔔 avisos: ${yn(c.notifyOnDetection)}`, description: "avisa cuando detecta un bucle" },
84
- { value: "footer" as const, label: `📊 pie interactivo: ${yn(c.interactiveFooter)}`, description: "experimental: reemplaza el pie de pi y captura el teclado (si da problemas, déjalo en off)" },
85
- { value: "shortcut" as const, label: `⌨️ atajo: ${c.toggleShortcut}`, description: "esc+a o desactivado" },
86
- // ── 🎯 Detección ────────────────────────────────────────────
87
- { value: "warn" as const, label: `⚠️ umbral aviso: ${c.warningThreshold}`, description: "repeticiones antes de avisar" },
88
- { value: "force" as const, label: `🛑 umbral corte: ${c.forceBreakThreshold}`, description: "repeticiones antes de forzar un cambio de rumbo" },
89
- { value: "abort" as const, label: `🚨 umbral abortar: ${c.abortThreshold || "off"}`, description: "repeticiones antes de abortar (0 = desactivado)" },
90
- { value: "sim" as const, label: `📏 parecido mínimo: ${(c.similarityThreshold * 100).toFixed(0)}%`, description: "qué tan parecidos deben ser dos mensajes para contar como bucle" },
91
- { value: "toolSim" as const, label: `🔧 parecido de llamadas: ${(c.toolSimilarityThreshold * 100).toFixed(0)}%`, description: "qué tan idénticas deben ser las llamadas para contar como la misma" },
92
- { value: "toolRepeat" as const, label: `🔁 repeticiones de llamada: ${c.minToolRepeatCount}+`, description: "cuántas veces se repite la misma llamada antes de marcarla" },
93
- { value: "resultSim" as const, label: `🧾 parecido de resultados: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "mismo comando + resultado distinto = progreso, no bucle" },
80
+ { value: "toggle" as const, label: c.enabled ? "🟢 disable" : "🔴 enable", description: "turn detection on or off" },
81
+ { value: "window" as const, label: `⏳ window: ${c.detectionWindow} msgs`, description: "how many recent messages are analyzed" },
82
+ { value: "notify" as const, label: `🔔 notifications: ${yn(c.notifyOnDetection)}`, description: "show a warning when a loop is detected" },
83
+ { value: "footer" as const, label: `📊 interactive footer: ${yn(c.interactiveFooter)}`, description: "experimental: replaces pi's footer and captures keystrokes (leave off if it misbehaves)" },
84
+ { value: "shortcut" as const, label: `⌨️ shortcut: ${c.toggleShortcut}`, description: "keys to toggle on/off without typing a command" },
85
+ // ── 🎯 Detection ────────────────────────────────────────────
86
+ { value: "warn" as const, label: `⚠️ warn after: ${c.warningThreshold}`, description: "repetitions before antiloop warns you" },
87
+ { value: "force" as const, label: `🛑 force break after: ${c.forceBreakThreshold}`, description: "repetitions before forcing a change of approach" },
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
+ { 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
+ { 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
+ { value: "toolRepeat" as const, label: `🔁 call repeats: ${c.minToolRepeatCount}+`, description: "how many times the same call must repeat before it flags" },
93
+ { value: "resultSim" as const, label: `🧾 result similarity: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "same command + different result = progress, not a loop" },
94
94
  // ── 📋 Task streams ─────────────────────────────────────────
95
- { value: "streams" as const, label: `📋 lotes de tareas: ${yn(c.detectTaskStreams)}`, description: "trabajo en lote (punched_log / plan_manager / …) no es un bucle" },
96
- { value: "streamMin" as const, label: `📋 llamadas mínimas: ${c.taskStreamMinCalls}`, description: "llamadas de la misma herramienta antes de reconocer un lote" },
97
- { value: "streamTwin" as const, label: `📋 gemelos: ${(c.taskStreamTwinThreshold * 100).toFixed(0)}%`, description: "llamadas más parecidas que esto = la misma tarea repetida, no un lote" },
98
- // ── 🔍 Detectores ───────────────────────────────────────────
99
- { value: "text" as const, label: `📝 texto: ${yn(c.detectTextLoops)}`, description: "detecta mensajes de texto repetidos" },
100
- { value: "tool" as const, label: `🔧 herramientas: ${yn(c.detectToolLoops)}`, description: "detecta llamadas repetidas a herramientas" },
101
- { value: "think" as const, label: `🧠 pensamiento: ${yn(c.detectThinkingLoops)}`, description: "detecta razonamiento interno repetido" },
95
+ { value: "streams" as const, label: `📋 task streams: ${yn(c.detectTaskStreams)}`, description: "batch work (punched_log / plan_manager / …) is not a loop" },
96
+ { value: "streamMin" as const, label: `📋 stream min calls: ${c.taskStreamMinCalls}`, description: "calls of the same tool before a batch is recognized" },
97
+ { value: "streamTwin" as const, label: `📋 twin threshold: ${(c.taskStreamTwinThreshold * 100).toFixed(0)}%`, description: "calls more similar than this = the same task repeated, not a batch" },
98
+ // ── 🔍 Detectors ────────────────────────────────────────────
99
+ { value: "text" as const, label: `📝 text: ${yn(c.detectTextLoops)}`, description: "detect repeated text messages" },
100
+ { value: "tool" as const, label: `🔧 tools: ${yn(c.detectToolLoops)}`, description: "detect repeated tool calls" },
101
+ { value: "think" as const, label: `🧠 thinking: ${yn(c.detectThinkingLoops)}`, description: "detect repeated internal reasoning" },
102
102
  // ── 🧹 ──────────────────────────────────────────────────────
103
- { value: "reset" as const, label: "🧹 reiniciar estado", description: "borra contadores e historial" },
103
+ { value: "reset" as const, label: "🧹 reset state", description: "clear counters and history" },
104
104
  ]);
105
105
  if (!picked) return;
106
106
  switch (picked) {
@@ -111,138 +111,146 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
111
111
  rt.updateStatus(ctx);
112
112
  break;
113
113
  case "window": {
114
- const v = await selectFrom(ctx, "⏳ ventana (mensajes a analizar)", [
114
+ const v = await selectFrom(ctx, "⏳ window (messages to analyze)", [
115
115
  { value: 5, label: "5" },
116
- { value: 10, label: "10 (por defecto)" },
116
+ { value: 10, label: "🎯 10 (default)" },
117
117
  { value: 15, label: "15" },
118
118
  { value: 20, label: "20" },
119
119
  ]);
120
- if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`ventana: ${v}`, "info"); }
120
+ if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`window: ${v}`, "info"); }
121
121
  break;
122
122
  }
123
123
  case "notify":
124
124
  c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
125
- ctx.ui.notify(`avisos: ${yn(c.notifyOnDetection)}`, "info"); break;
125
+ ctx.ui.notify(`notifications: ${yn(c.notifyOnDetection)}`, "info"); break;
126
126
  case "footer":
127
127
  c.interactiveFooter = !c.interactiveFooter; saveConfig(c);
128
- ctx.ui.notify(`pie interactivo: ${yn(c.interactiveFooter)}`, "info");
128
+ ctx.ui.notify(`interactive footer: ${yn(c.interactiveFooter)}`, "info");
129
129
  rt.refreshFooter?.(ctx);
130
130
  rt.updateStatus(ctx);
131
131
  break;
132
132
  case "shortcut": {
133
- const v = await selectFrom(ctx, "⌨️ atajo para activar/apagar", [
134
- { value: "esc+a" as const, label: "⌨️ esc+a (por defecto)", description: "pulsa ESC y luego a" },
135
- { value: "off" as const, label: "🚫 desactivado" },
133
+ const v = await selectFrom(ctx, "⌨️ toggle shortcut", [
134
+ { value: "esc+a" as const, label: "⌨️ esc+a (default)", description: "press ESC then a" },
135
+ { value: "off" as const, label: "🚫 off" },
136
136
  ]);
137
- if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`atajo: ${v}`, "info"); }
137
+ if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`shortcut: ${v}`, "info"); }
138
138
  break;
139
139
  }
140
140
  case "warn": {
141
- const v = await selectFrom(ctx, "⚠️ umbral de aviso", [
142
- { value: 1, label: "⚡ 1 (sensible)" },
143
- { value: 2, label: "🎯 2 (por defecto)" },
141
+ const v = await selectFrom(ctx, "⚠️ warn threshold", [
142
+ { value: 1, label: "⚡ 1 (sensitive)" },
143
+ { value: 2, label: "🎯 2 (default)" },
144
144
  { value: 3, label: "3" },
145
- { value: 5, label: "🐢 5 (relajado)" },
145
+ { value: 5, label: "🐢 5 (relaxed)" },
146
146
  ]);
147
- if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`aviso: ${v}`, "info"); }
147
+ if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`warn: ${v}`, "info"); }
148
148
  break;
149
149
  }
150
150
  case "force": {
151
- const v = await selectFrom(ctx, "🛑 umbral de corte", [
152
- { value: 2, label: "⚡ 2 (sensible)" },
153
- { value: 3, label: "🎯 3 (por defecto)" },
151
+ const v = await selectFrom(ctx, "🛑 force break threshold", [
152
+ { value: 2, label: "⚡ 2 (sensitive)" },
153
+ { value: 3, label: "🎯 3 (default)" },
154
154
  { value: 5, label: "5" },
155
- { value: 8, label: "🐢 8 (relajado)" },
155
+ { value: 8, label: "🐢 8 (relaxed)" },
156
156
  ]);
157
- if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`corte: ${v}`, "info"); }
157
+ if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`force break: ${v}`, "info"); }
158
158
  break;
159
159
  }
160
160
  case "abort": {
161
- const v = await selectFrom(ctx, "🚨 umbral de aborto (0 = desactivado)", [
162
- { value: 0, label: "🚫 desactivado" },
161
+ const v = await selectFrom(ctx, "🚨 abort threshold (0 = disabled)", [
162
+ { value: 0, label: "🚫 off" },
163
163
  { value: 5, label: "5" },
164
164
  { value: 8, label: "8" },
165
165
  { value: 10, label: "10" },
166
166
  { value: 15, label: "15" },
167
167
  ]);
168
- if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`aborto: ${v || "desactivado"}`, "info"); }
168
+ if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`abort: ${v || "off"}`, "info"); }
169
169
  break;
170
170
  }
171
171
  case "sim": {
172
- const v = await selectFrom(ctx, "📏 parecido mínimo entre mensajes", [
173
- { value: 0.5, label: "⚡ 50% (sensible)" },
172
+ const v = await selectFrom(ctx, "📏 text similarity", [
173
+ { value: 0.5, label: "⚡ 50% (sensitive)" },
174
174
  { value: 0.6, label: "60%" },
175
175
  { value: 0.7, label: "70%" },
176
- { value: 0.75, label: "🎯 75% (por defecto)" },
176
+ { value: 0.75, label: "🎯 75% (default)" },
177
177
  { value: 0.8, label: "80%" },
178
- { value: 0.9, label: "🐢 90% (relajado)" },
178
+ { value: 0.9, label: "🐢 90% (relaxed)" },
179
179
  ]);
180
- if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`parecido: ${(v * 100).toFixed(0)}%`, "info"); }
180
+ if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`similarity: ${(v * 100).toFixed(0)}%`, "info"); }
181
181
  break;
182
182
  }
183
183
  case "toolSim": {
184
- const v = await selectFrom(ctx, "🔧 parecido de las llamadas (argumentos)", [
185
- { value: 0.99, label: "⚡ 99% (estricto)" },
186
- { value: 0.95, label: "🎯 95% (por defecto)" },
184
+ const v = await selectFrom(ctx, "🔧 call similarity (arguments)", [
185
+ { value: 0.99, label: "⚡ 99% (strict)" },
186
+ { value: 0.95, label: "🎯 95% (default)" },
187
187
  { value: 0.9, label: "90%" },
188
- { value: 0.8, label: "🐢 80% (sensible)" },
188
+ { value: 0.8, label: "🐢 80% (sensitive)" },
189
189
  ]);
190
- if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`parecido de llamadas: ${(v * 100).toFixed(0)}%`, "info"); }
190
+ if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`call similarity: ${(v * 100).toFixed(0)}%`, "info"); }
191
191
  break;
192
192
  }
193
193
  case "toolRepeat": {
194
- const v = await selectFrom(ctx, "🔁 repeticiones de la misma llamada", [
195
- { value: 1, label: "⚡ 1 (sensible)" },
196
- { value: 2, label: "🎯 2 (por defecto)" },
197
- { value: 3, label: "🐢 3 (relajado)" },
194
+ const v = await selectFrom(ctx, "🔁 call repeats", [
195
+ { value: 1, label: "⚡ 1 (sensitive)" },
196
+ { value: 2, label: "🎯 2 (default)" },
197
+ { value: 3, label: "🐢 3 (relaxed)" },
198
198
  ]);
199
- if (v !== undefined) { c.minToolRepeatCount = v; saveConfig(c); ctx.ui.notify(`repeticiones: ${v}+`, "info"); }
199
+ if (v !== undefined) { c.minToolRepeatCount = v; saveConfig(c); ctx.ui.notify(`call repeats: ${v}+`, "info"); }
200
200
  break;
201
201
  }
202
202
  case "resultSim": {
203
- const v = await selectFrom(ctx, "🧾 parecido de resultados (veto de progreso)", [
204
- { value: 0.95, label: "⚡ 95% (estrictosolo resultados casi idénticos cuentan como igual)" },
205
- { value: 0.8, label: "🎯 80% (por defecto)" },
206
- { value: 0.6, label: "🐢 60% (relajadotolera más ruido de salida)" },
203
+ const v = await selectFrom(ctx, "🧾 result similarity (progress veto)", [
204
+ { value: 0.95, label: "⚡ 95% (strictonly near-identical results count as the same)" },
205
+ { value: 0.8, label: "🎯 80% (default)" },
206
+ { value: 0.6, label: "🐢 60% (relaxedtolerates more output noise)" },
207
207
  ]);
208
- if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`parecido de resultados: ${(v * 100).toFixed(0)}%`, "info"); }
208
+ if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`result similarity: ${(v * 100).toFixed(0)}%`, "info"); }
209
209
  break;
210
210
  }
211
211
  case "streams":
212
212
  c.detectTaskStreams = !c.detectTaskStreams; saveConfig(c);
213
- ctx.ui.notify(`lotes de tareas: ${yn(c.detectTaskStreams)}`, "info"); break;
213
+ ctx.ui.notify(`task streams: ${yn(c.detectTaskStreams)}`, "info"); break;
214
214
  case "streamMin": {
215
- const v = await selectFrom(ctx, "📋 llamadas mínimas para reconocer un lote", [
216
- { value: 2, label: "⚡ 2 (sensible)" },
217
- { value: 3, label: "🎯 3 (por defecto)" },
215
+ const v = await selectFrom(ctx, "📋 stream min calls", [
216
+ { value: 2, label: "⚡ 2 (sensitive)" },
217
+ { value: 3, label: "🎯 3 (default)" },
218
218
  { value: 4, label: "4" },
219
- { value: 5, label: "🐢 5 (conservador)" },
219
+ { value: 5, label: "🐢 5 (conservative)" },
220
220
  ]);
221
- if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`llamadas mínimas: ${v}`, "info"); }
221
+ if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`stream min calls: ${v}`, "info"); }
222
222
  break;
223
223
  }
224
224
  case "streamTwin": {
225
- const v = await selectFrom(ctx, "📋 umbral de gemelos (argumentos)", [
226
- { value: 0.99, label: "🎯 99% (por defecto cualquier diferencia real = tarea distinta)" },
227
- { value: 0.95, label: "95% (argumentos casi idénticos cuentan como la misma tarea)" },
228
- { value: 0.9, label: "⚡ 90% (detección de bucles más agresiva)" },
225
+ const v = await selectFrom(ctx, "📋 twin threshold (arguments)", [
226
+ { value: 0.99, label: "🎯 99% (defaultany real difference = distinct task)" },
227
+ { value: 0.95, label: "95% (near-identical arguments count as the same task)" },
228
+ { value: 0.9, label: "⚡ 90% (more aggressive loop detection)" },
229
229
  ]);
230
- if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`gemelos: ${(v * 100).toFixed(0)}%`, "info"); }
230
+ if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`twin threshold: ${(v * 100).toFixed(0)}%`, "info"); }
231
+ break;
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"); }
231
240
  break;
232
241
  }
233
242
  case "text":
234
243
  c.detectTextLoops = !c.detectTextLoops; saveConfig(c);
235
- ctx.ui.notify(`texto: ${yn(c.detectTextLoops)}`, "info"); break;
244
+ ctx.ui.notify(`text: ${yn(c.detectTextLoops)}`, "info"); break;
236
245
  case "tool":
237
246
  c.detectToolLoops = !c.detectToolLoops; saveConfig(c);
238
- ctx.ui.notify(`herramientas: ${yn(c.detectToolLoops)}`, "info"); break;
247
+ ctx.ui.notify(`tools: ${yn(c.detectToolLoops)}`, "info"); break;
239
248
  case "think":
240
249
  c.detectThinkingLoops = !c.detectThinkingLoops; saveConfig(c);
241
- ctx.ui.notify(`pensamiento: ${yn(c.detectThinkingLoops)}`, "info"); break;
250
+ ctx.ui.notify(`thinking: ${yn(c.detectThinkingLoops)}`, "info"); break;
242
251
  case "reset":
243
252
  resetState(rt.state);
244
- rt.pendingIntervention = null;
245
- ctx.ui.notify("🧹 estado reiniciado", "info");
253
+ ctx.ui.notify("🧹 state reset", "info");
246
254
  rt.updateStatus(ctx);
247
255
  break;
248
256
  }
@@ -250,7 +258,7 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
250
258
 
251
259
  async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
252
260
  if (!rt.state.detections.length) {
253
- ctx.ui.notify("no hay detecciones en esta sesión", "info");
261
+ ctx.ui.notify("no detections this session", "info");
254
262
  return;
255
263
  }
256
264
  const items = rt.state.detections.slice(-30).reverse().map((d) => ({
@@ -258,7 +266,7 @@ async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void>
258
266
  label: `[${d.type}] ${d.description}`,
259
267
  description: `${(d.similarity * 100).toFixed(0)}% · ${formatDuration(Date.now() - d.timestamp)} ago`,
260
268
  }));
261
- await selectFrom(ctx, `🕵️ detecciones (${rt.state.detections.length} en total)`, items);
269
+ await selectFrom(ctx, `🕵️ detections (${rt.state.detections.length} total)`, items);
262
270
  }
263
271
 
264
272
  export function resetState(state: AntiloopState): void {
@@ -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;