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 +30 -29
- package/package.json +2 -2
- package/src/commands.ts +94 -84
- 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
|
|
|
@@ -106,35 +106,35 @@ Recent detections:
|
|
|
106
106
|
|
|
107
107
|
### `/antiloop config`
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
Grouped interactive menu showing the current value in each option:
|
|
110
110
|
|
|
111
111
|
**🎛️ General**
|
|
112
|
-
- **🟢/🔴
|
|
113
|
-
- **⏳
|
|
114
|
-
- **🔔
|
|
115
|
-
- **📊
|
|
116
|
-
- **⌨️
|
|
117
|
-
|
|
118
|
-
**🎯
|
|
119
|
-
- **⚠️
|
|
120
|
-
- **🛑
|
|
121
|
-
- **🚨
|
|
122
|
-
- **📏
|
|
123
|
-
- **🔧
|
|
124
|
-
- **🔁
|
|
125
|
-
- **🧾
|
|
126
|
-
|
|
127
|
-
**📋
|
|
128
|
-
- **📋
|
|
129
|
-
- **📋
|
|
130
|
-
- **📋
|
|
131
|
-
|
|
132
|
-
**🔍
|
|
133
|
-
- **📝
|
|
134
|
-
- **🔧
|
|
135
|
-
- **🧠
|
|
136
|
-
|
|
137
|
-
**🧹
|
|
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 off — key 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
|
|
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)`,
|
|
@@ -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 ? "🟢
|
|
82
|
-
{ value: "window" as const, label: `⏳
|
|
83
|
-
{ value: "notify" as const, label: `🔔
|
|
84
|
-
{ value: "footer" as const, label: `📊
|
|
85
|
-
{ value: "shortcut" as const, label: `⌨️
|
|
86
|
-
// ── 🎯
|
|
87
|
-
{ value: "warn" as const, label: `⚠️
|
|
88
|
-
{ value: "force" as const, label: `🛑
|
|
89
|
-
{ value: "abort" as const, label: `🚨
|
|
90
|
-
{ value: "
|
|
91
|
-
{ value: "
|
|
92
|
-
{ value: "
|
|
93
|
-
{ value: "
|
|
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: `📋
|
|
96
|
-
{ value: "streamMin" as const, label: `📋
|
|
97
|
-
{ value: "streamTwin" as const, label: `📋
|
|
98
|
-
// ── 🔍
|
|
99
|
-
{ value: "text" as const, label: `📝
|
|
100
|
-
{ value: "tool" as const, label: `🔧
|
|
101
|
-
{ value: "think" as const, label: `🧠
|
|
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: "🧹
|
|
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, "⏳
|
|
114
|
+
const v = await selectFrom(ctx, "⏳ window (messages to analyze)", [
|
|
115
115
|
{ value: 5, label: "5" },
|
|
116
|
-
{ value: 10, label: "10 (
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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, "⌨️
|
|
134
|
-
{ value: "esc+a" as const, label: "⌨️ esc+a (
|
|
135
|
-
{ value: "off" as const, label: "🚫
|
|
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(`
|
|
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, "⚠️
|
|
142
|
-
{ value: 1, label: "⚡ 1 (
|
|
143
|
-
{ value: 2, label: "🎯 2 (
|
|
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 (
|
|
145
|
+
{ value: 5, label: "🐢 5 (relaxed)" },
|
|
146
146
|
]);
|
|
147
|
-
if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
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, "🛑
|
|
152
|
-
{ value: 2, label: "⚡ 2 (
|
|
153
|
-
{ value: 3, label: "🎯 3 (
|
|
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 (
|
|
155
|
+
{ value: 8, label: "🐢 8 (relaxed)" },
|
|
156
156
|
]);
|
|
157
|
-
if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
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, "🚨
|
|
162
|
-
{ value: 0, label: "🚫
|
|
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(`
|
|
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, "📏
|
|
173
|
-
{ value: 0.5, label: "⚡ 50% (
|
|
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% (
|
|
176
|
+
{ value: 0.75, label: "🎯 75% (default)" },
|
|
177
177
|
{ value: 0.8, label: "80%" },
|
|
178
|
-
{ value: 0.9, label: "🐢 90% (
|
|
178
|
+
{ value: 0.9, label: "🐢 90% (relaxed)" },
|
|
179
179
|
]);
|
|
180
|
-
if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
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, "🔧
|
|
185
|
-
{ value: 0.99, label: "⚡ 99% (
|
|
186
|
-
{ value: 0.95, label: "🎯 95% (
|
|
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% (
|
|
188
|
+
{ value: 0.8, label: "🐢 80% (sensitive)" },
|
|
189
189
|
]);
|
|
190
|
-
if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
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, "🔁
|
|
195
|
-
{ value: 1, label: "⚡ 1 (
|
|
196
|
-
{ value: 2, label: "🎯 2 (
|
|
197
|
-
{ value: 3, label: "🐢 3 (
|
|
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(`
|
|
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, "🧾
|
|
204
|
-
{ value: 0.95, label: "⚡ 95% (
|
|
205
|
-
{ value: 0.8, label: "🎯 80% (
|
|
206
|
-
{ value: 0.6, label: "🐢 60% (
|
|
203
|
+
const v = await selectFrom(ctx, "🧾 result similarity (progress veto)", [
|
|
204
|
+
{ value: 0.95, label: "⚡ 95% (strict — only near-identical results count as the same)" },
|
|
205
|
+
{ value: 0.8, label: "🎯 80% (default)" },
|
|
206
|
+
{ value: 0.6, label: "🐢 60% (relaxed — tolerates more output noise)" },
|
|
207
207
|
]);
|
|
208
|
-
if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`
|
|
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(`
|
|
213
|
+
ctx.ui.notify(`task streams: ${yn(c.detectTaskStreams)}`, "info"); break;
|
|
214
214
|
case "streamMin": {
|
|
215
|
-
const v = await selectFrom(ctx, "📋
|
|
216
|
-
{ value: 2, label: "⚡ 2 (
|
|
217
|
-
{ value: 3, label: "🎯 3 (
|
|
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 (
|
|
219
|
+
{ value: 5, label: "🐢 5 (conservative)" },
|
|
220
220
|
]);
|
|
221
|
-
if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`
|
|
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, "📋
|
|
226
|
-
{ value: 0.99, label: "🎯 99% (
|
|
227
|
-
{ value: 0.95, label: "95% (
|
|
228
|
-
{ value: 0.9, label: "⚡ 90% (
|
|
225
|
+
const v = await selectFrom(ctx, "📋 twin threshold (arguments)", [
|
|
226
|
+
{ value: 0.99, label: "🎯 99% (default — any 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(`
|
|
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(`
|
|
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(`
|
|
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(`
|
|
250
|
+
ctx.ui.notify(`thinking: ${yn(c.detectThinkingLoops)}`, "info"); break;
|
|
242
251
|
case "reset":
|
|
243
252
|
resetState(rt.state);
|
|
244
|
-
|
|
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
|
|
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, `🕵️
|
|
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
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;
|