pi-antiloop 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -31
- package/package.json +2 -2
- package/src/commands.ts +53 -22
- package/src/config.ts +5 -0
- package/src/detect.ts +127 -14
- package/src/index.ts +187 -28
- package/src/types.ts +47 -1
package/README.md
CHANGED
|
@@ -6,17 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
# Antiloop — Loop Detection and Break for pi
|
|
8
8
|
|
|
9
|
-
**Antiloop watches every assistant message, tool call and thinking block, and forces the model out of reasoning loops before they eat your context and your patience.**
|
|
9
|
+
**Antiloop watches every assistant message, tool call and thinking block, and forces the model out of reasoning loops before they eat your context and your patience.** Four simultaneous detection strategies (text similarity, tool-call sequences, thinking content, structural openings) find loops that humans miss — and progressive intervention (warning → force break → abort) tells the model to take a different approach, without you having to babysit it.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
- **Four detection strategies** — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name +
|
|
15
|
+
- **Four detection strategies** — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name + near-identical arguments + same outcome — result-aware, so retries that make progress don't false-positive), thinking blocks, and structural opening-phrase patterns
|
|
16
16
|
- **Progressive intervention** — `warning` reminds the model to vary its approach; `force break` injects explicit anti-loop instructions and modifies context; `abort` stops the run entirely
|
|
17
17
|
- **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
|
|
18
18
|
- **Sliding window** — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
|
|
19
|
-
- **Live
|
|
19
|
+
- **Live footer indicator** — `🔄 antiloop(on|off)` in the footer, per spec, with the current level (`⚠️/🛑/🚨`) and consecutive count; an interactive TUI footer adds a keyboard toggle (`esc+a` by default, configurable/off) and preserves the built-in footer's pwd/branch/context/model info
|
|
20
20
|
- **Detection log** — timestamped history with similarity scores, filterable through the native pi menu
|
|
21
21
|
- **Self-test** — `/antiloop test` runs built-in cases to verify the similarity engine is calibrated
|
|
22
22
|
- **User input softens detection** — each new user message decays the consecutive counter so a fresh prompt can resolve the loop without manual reset
|
|
@@ -100,7 +100,7 @@ Detection strategies:
|
|
|
100
100
|
|
|
101
101
|
Recent detections:
|
|
102
102
|
[text] Text similarity 85% with message 3 (2m ago)
|
|
103
|
-
[tool]
|
|
103
|
+
[tool] repeated 3x: bash (5m ago)
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
### `/antiloop config`
|
|
@@ -112,6 +112,9 @@ Interactive menu with current values:
|
|
|
112
112
|
- **Force break threshold** — similar messages before force break (default 3)
|
|
113
113
|
- **Abort threshold** — similar messages before abort (0 = disabled)
|
|
114
114
|
- **Similarity threshold** — `0.5 / 0.6 / 0.7 / 0.75 / 0.8 / 0.9` — how close two messages must be to count as looping
|
|
115
|
+
- **Tool similarity** — `0.99 / 0.95 / 0.9 / 0.8` — how close tool-call *arguments* must be to count as the same call (95% default: only near-identical repeats loop)
|
|
116
|
+
- **Tool repeat** — `1 / 2 / 3` — prior occurrences of the same call set required before a tool loop flags
|
|
117
|
+
- **Result similarity** — `0.95 / 0.8 / 0.6` — how similar captured results must be to count as the *same outcome* (veto when a repeated command starts succeeding/differing)
|
|
115
118
|
- **Detection window** — `5 / 10 / 15 / 20` — number of recent messages to analyze
|
|
116
119
|
- **Per-strategy toggles** — text / tool / thinking detection
|
|
117
120
|
- **Notifications** — show detection notifications
|
|
@@ -123,30 +126,33 @@ Shows the most recent 30 detections with similarity scores and timestamps, newes
|
|
|
123
126
|
|
|
124
127
|
### `/antiloop test`
|
|
125
128
|
|
|
126
|
-
Runs
|
|
129
|
+
Runs the real detection engine (not a copy) — text similarity plus tool-call regression cases:
|
|
127
130
|
|
|
128
131
|
```
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
132
|
+
text identical → 100% (exp 100%) ✅
|
|
133
|
+
text near-identical → 91% (exp ≥ 80%) ✅
|
|
134
|
+
text unrelated → 19% (exp < 50%) ✅
|
|
135
|
+
tool identical cmd → match (exp match) ✅
|
|
136
|
+
tool sweep (flags) → no match @95% (exp no match) ✅ ← regression: 0.8–0.94 overlap is NOT a loop
|
|
137
|
+
tool sweep (old 80%)→ match @80% (exp match — was the false positive) ✅
|
|
138
|
+
tool different tool → no match (exp no match) ✅
|
|
139
|
+
tool empty lists → no match (exp no match) ✅
|
|
140
|
+
result same outcome → match (exp match — PID noise ok) ✅
|
|
141
|
+
result diff outcome → no match (exp no match — error→success is progress) ✅
|
|
136
142
|
```
|
|
137
143
|
|
|
138
144
|
## How It Works
|
|
139
145
|
|
|
140
146
|
### Detection pipeline
|
|
141
147
|
|
|
142
|
-
After every `message_end` event, antiloop extracts the new
|
|
148
|
+
After every assistant `message_end` event, antiloop extracts the new content (text, thinking, tool calls — including their ids) and pushes it onto a sliding window of the last `detectionWindow + 5` messages. Detection itself runs at `turn_end`, once the tool results are known: results are fingerprinted and attached to the tracked calls, then the active detection strategies run against the window:
|
|
143
149
|
|
|
144
150
|
| Strategy | What it compares | Algorithm |
|
|
145
151
|
|----------|------------------|-----------|
|
|
146
152
|
| Text | Full assistant message text | n-gram Jaccard (≥ 100 chars) or Levenshtein (shorter) |
|
|
147
|
-
| Tool | Tool name + arguments | Sequence match + ≥
|
|
153
|
+
| Tool | Tool name + arguments (+ captured result) | Sequence match + near-identical args (≥ `toolSimilarityThreshold`, default 95%) *and* ≥ `minToolRepeatCount` prior recurrences. **Result veto:** if both runs captured a result and the outcomes differ, it's progress, not a loop |
|
|
148
154
|
| Thinking | Internal reasoning/thinking blocks | Same as text |
|
|
149
|
-
| Structural | First 10 words of each message | Opening-phrase similarity ≥
|
|
155
|
+
| Structural | First 10 words of each message | Opening-phrase similarity ≥ 90% across ≥ 3 messages |
|
|
150
156
|
|
|
151
157
|
Each detected pair becomes a `LoopDetection { type, similarity, messageIndices, description }` and the consecutive counter increases.
|
|
152
158
|
|
|
@@ -155,7 +161,7 @@ Each detected pair becomes a `LoopDetection { type, similarity, messageIndices,
|
|
|
155
161
|
| Level | Trigger | Behavior |
|
|
156
162
|
|-------|---------|----------|
|
|
157
163
|
| 0 (no loop) | — | Silent — passes the message through |
|
|
158
|
-
| 1 (warning) | `consecutiveDetections >= warningThreshold` |
|
|
164
|
+
| 1 (warning) | `consecutiveDetections >= warningThreshold` | Notifies the user (`⚠️`) — no message is injected into the conversation, so the model's generation is never interrupted by the warning itself |
|
|
159
165
|
| 2 (force break) | `consecutiveDetections >= forceBreakThreshold` | Injects mandatory anti-loop instructions + appends a context message to the last assistant message |
|
|
160
166
|
| 3 (abort) | `consecutiveDetections >= abortThreshold` | (Disabled by default) Surfaces an error asking the user for new instructions |
|
|
161
167
|
|
|
@@ -172,10 +178,34 @@ For longer texts: Character trigram Jaccard
|
|
|
172
178
|
"I will read the file first to understand the codebase..."
|
|
173
179
|
→ ~85% (many shared 3-grams)
|
|
174
180
|
|
|
175
|
-
For tool calls: sequence + per-call argument similarity ≥
|
|
176
|
-
[
|
|
177
|
-
[
|
|
178
|
-
|
|
181
|
+
For tool calls: sequence + per-call argument similarity ≥ 95% (default)
|
|
182
|
+
[bash("setsid ./llama-server -m … -b 2048 -ctk q8_0 …")]
|
|
183
|
+
[bash("setsid ./llama-server -m … -b 2048 -ctk q8_0 …")] → matched (identical)
|
|
184
|
+
|
|
185
|
+
…but a parameter sweep is NOT a loop, even at 80–94% similarity:
|
|
186
|
+
[bash("… -b 2048 -ctk q8_0 -ctv turbo4 > /tmp/sweep-turbo4.log …")]
|
|
187
|
+
[bash("… -b 8192 -ctk f16 -ctv f16 > /tmp/sweep-b8192.log …")] → not matched
|
|
188
|
+
|
|
189
|
+
Long bash commands share scaffolding (env setup, model path, most flags),
|
|
190
|
+
so 80% overlap is normal for *different* sequential operations. Only
|
|
191
|
+
near-identical repeats — the same call set seen `minToolRepeatCount` times
|
|
192
|
+
inside the window — count as a tool loop.
|
|
193
|
+
|
|
194
|
+
Result veto (tool loops): detection runs at `turn_end`, where the tool
|
|
195
|
+
results are known. Each captured result becomes a normalized tail fingerprint
|
|
196
|
+
("err|" / "ok|" prefix + last 400 chars, so PID/timestamp noise is tolerated).
|
|
197
|
+
If the same command produced a *different* outcome, the pair is progress:
|
|
198
|
+
|
|
199
|
+
[bash("...")] → err|error: invalid argument: ROCm0 (attempt 1)
|
|
200
|
+
[bash("...")] → ok|model loaded / listening on :8093 (attempt 2)
|
|
201
|
+
→ NOT a loop — the retry fixed the problem
|
|
202
|
+
|
|
203
|
+
[bash("...")] → err|failed to create context … (attempt 1)
|
|
204
|
+
[bash("...")] → err|failed to create context … (attempt 2)
|
|
205
|
+
→ loop signal (same command, same outcome, repeated)
|
|
206
|
+
|
|
207
|
+
Results only veto; they never trigger on their own, and calls without a
|
|
208
|
+
captured result fall back to argument matching alone.
|
|
179
209
|
```
|
|
180
210
|
|
|
181
211
|
### Sliding window
|
|
@@ -193,12 +223,17 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
|
|
|
193
223
|
"forceBreakThreshold": 3,
|
|
194
224
|
"abortThreshold": 0,
|
|
195
225
|
"similarityThreshold": 0.75,
|
|
226
|
+
"toolSimilarityThreshold": 0.95,
|
|
227
|
+
"minToolRepeatCount": 2,
|
|
228
|
+
"resultSimilarityThreshold": 0.8,
|
|
196
229
|
"detectToolLoops": true,
|
|
197
230
|
"detectThinkingLoops": true,
|
|
198
231
|
"detectTextLoops": true,
|
|
199
232
|
"notifyOnDetection": true,
|
|
200
233
|
"maxHistoryEntries": 100,
|
|
201
|
-
"detectionWindow": 10
|
|
234
|
+
"detectionWindow": 10,
|
|
235
|
+
"interactiveFooter": true,
|
|
236
|
+
"toggleShortcut": "esc+a"
|
|
202
237
|
}
|
|
203
238
|
```
|
|
204
239
|
|
|
@@ -208,22 +243,28 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
|
|
|
208
243
|
| `warningThreshold` | `2` | Consecutive detections before warning |
|
|
209
244
|
| `forceBreakThreshold` | `3` | Consecutive detections before force break |
|
|
210
245
|
| `abortThreshold` | `0` | Consecutive detections before abort (0 = disabled) |
|
|
211
|
-
| `similarityThreshold` | `0.75` | Minimum similarity (0.0–1.0) to count a pair as looping |
|
|
246
|
+
| `similarityThreshold` | `0.75` | Minimum similarity (0.0–1.0) to count a text/thinking pair as looping |
|
|
247
|
+
| `toolSimilarityThreshold` | `0.95` | How close tool-call arguments must be (0.0–1.0) to count as the *same* call — see [tool loops](#how-it-works) |
|
|
248
|
+
| `minToolRepeatCount` | `2` | Prior occurrences of a near-identical call set required before a tool loop is flagged (2 = same call seen 3×) |
|
|
249
|
+
| `resultSimilarityThreshold` | `0.8` | Minimum similarity between captured result tails to still count as the *same outcome*; below this, a repeated command is treated as progress, not a loop |
|
|
212
250
|
| `detectTextLoops` | `true` | Detect full-text repetition |
|
|
213
251
|
| `detectToolLoops` | `true` | Detect tool-call sequence + argument repetition |
|
|
214
252
|
| `detectThinkingLoops` | `true` | Detect repeated thinking/reasoning content |
|
|
215
253
|
| `notifyOnDetection` | `true` | Show a notification on every detection |
|
|
216
254
|
| `maxHistoryEntries` | `100` | Max detection history entries |
|
|
217
255
|
| `detectionWindow` | `10` | Number of recent messages to analyze |
|
|
256
|
+
| `interactiveFooter` | `true` | TUI footer replaces the built-in one with an antiloop indicator + toggle shortcut (set `false` to keep the built-in footer and only the `setStatus` line) |
|
|
257
|
+
| `toggleShortcut` | `esc+a` | Key sequence that toggles antiloop from the footer (`esc+a` or `off`). The input is never consumed, so typing is unaffected |
|
|
218
258
|
|
|
219
259
|
## Best Practices
|
|
220
260
|
|
|
221
|
-
1. **Start with defaults** — `warning=2 / force-break=3 / similarity=75%` works well for most models.
|
|
261
|
+
1. **Start with defaults** — `warning=2 / force-break=3 / similarity=75% / tool-sim=95%` works well for most models.
|
|
222
262
|
2. **Adjust sensitivity to the model** — small/local models loop more, so lower `warningThreshold` and `similarityThreshold` to catch them early. Big cloud models rarely loop, so you can raise them to avoid false positives.
|
|
223
|
-
3. **
|
|
224
|
-
4. **
|
|
225
|
-
5. **
|
|
226
|
-
6.
|
|
263
|
+
3. **Tool loops are strict on purpose** — a long bash command with env setup + flags scores 80–94% similar to the *next, different* command. Antiloop only flags tool calls that are near-identical (≥ 95%) *and* repeated ≥ `minToolRepeatCount` times, *and* — when results are captured — produced the same outcome. If you still see false positives on sequential operations, raise `toolSimilarityThreshold` (or `minToolRepeatCount`, or `resultSimilarityThreshold`) via `/antiloop config` — don't disable the detector.
|
|
264
|
+
4. **Per-strategy toggles** — if the model's reasoning legitimately repeats (e.g. it's working through a checklist), disable `thinking` detection and leave text/tool on.
|
|
265
|
+
5. **Watch the log** — `/antiloop log` shows what's actually triggering. If you see false positives, raise `similarityThreshold` instead of disabling the strategy entirely.
|
|
266
|
+
6. **Let user input clear state** — each user message decays the consecutive counter by 2, so a fresh prompt naturally resets without `/antiloop reset`.
|
|
267
|
+
7. **`/antiloop test`** — runs the real detection engine (text + tool-call regression cases) to verify calibration after any change.
|
|
227
268
|
|
|
228
269
|
## Architecture
|
|
229
270
|
|
|
@@ -236,16 +277,21 @@ antiloop/
|
|
|
236
277
|
│ ├── banner.jpeg # wide README header
|
|
237
278
|
│ └── preview.jpeg # npm pi.dev preview card
|
|
238
279
|
└── src/
|
|
239
|
-
|
|
280
|
+
├── index.ts # hooks + intervention pipeline
|
|
281
|
+
├── detect.ts # similarity engine, detection strategies, self-test
|
|
282
|
+
├── commands.ts # /antiloop command handlers + config menu
|
|
283
|
+
├── config.ts # config load/save
|
|
284
|
+
├── types.ts # shared types
|
|
285
|
+
└── ui.ts # UI helpers (select, duration)
|
|
240
286
|
```
|
|
241
287
|
|
|
242
|
-
|
|
288
|
+
Modular extension with zero external dependencies (only pi's bundled `@earendil-works/pi-coding-agent` + Node built-ins):
|
|
243
289
|
|
|
244
290
|
- **Levenshtein + trigram Jaccard** hybrid — small texts use edit distance, large texts use n-gram overlap (each is O(N) in text length)
|
|
245
291
|
- **Sliding window** — only the last `detectionWindow` messages participate, capping memory at O(W × message_size)
|
|
246
292
|
- **Early bail** — short messages and empty tool calls skip similarity computation entirely
|
|
247
|
-
- **TUI integration** — uses `ctx.ui.select` for the config menu and the log viewer; `ctx.ui.notify` for state notifications; `ctx.ui.setStatus` for the persistent
|
|
248
|
-
- **Hooks** — `message_end` (track + detect), `input` (decay), `before_agent_start` (inject intervention), `context` (modify context in force-break mode), `
|
|
293
|
+
- **TUI integration** — uses `ctx.ui.select` for the config menu and the log viewer; `ctx.ui.notify` for state notifications; `ctx.ui.setStatus` + a custom `ctx.ui.setFooter` component for the persistent footer indicator, live level info, and the `esc+a` keyboard toggle (`ctx.ui.onTerminalInput`, never consumes input)
|
|
294
|
+
- **Hooks** — `message_end` (track messages + tool call ids), `turn_end` (attach result fingerprints + detect), `input` (decay), `before_agent_start` (inject intervention — force/abort only), `context` (modify context in force-break mode), `session_start` (load config + install footer + reset), `session_shutdown` (restore built-in footer)
|
|
249
295
|
|
|
250
296
|
## License
|
|
251
297
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antiloop",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, and structural patterns.",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, and structural patterns. Tool-loop detection is result-aware: only near-identical repeated calls with the same outcome count, so sequential bash operations and retries that make progress don't false-positive.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"antiloop",
|
package/src/commands.ts
CHANGED
|
@@ -57,8 +57,11 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
|
|
|
57
57
|
"thresholds:",
|
|
58
58
|
` warn: ${rt.config.warningThreshold} force: ${rt.config.forceBreakThreshold} abort: ${rt.config.abortThreshold || "off"}`,
|
|
59
59
|
` similarity: ${(rt.config.similarityThreshold * 100).toFixed(0)}% window: ${rt.config.detectionWindow}`,
|
|
60
|
+
` tool sim: ${(rt.config.toolSimilarityThreshold * 100).toFixed(0)}% tool repeat: ${rt.config.minToolRepeatCount}+ prior`,
|
|
61
|
+
` result sim: ${(rt.config.resultSimilarityThreshold * 100).toFixed(0)}% (same cmd + diff outcome = no loop)`,
|
|
60
62
|
"",
|
|
61
63
|
`detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
|
|
64
|
+
`footer: interactive ${yn(rt.config.interactiveFooter)} · toggle: ${rt.config.toggleShortcut}`,
|
|
62
65
|
];
|
|
63
66
|
if (recent.length) {
|
|
64
67
|
lines.push("", "recent:");
|
|
@@ -75,11 +78,16 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
75
78
|
{ value: "force" as const, label: `force threshold: ${c.forceBreakThreshold}` },
|
|
76
79
|
{ value: "abort" as const, label: `abort threshold: ${c.abortThreshold || "off"}`, description: "0 = disabled" },
|
|
77
80
|
{ value: "sim" as const, label: `similarity: ${(c.similarityThreshold * 100).toFixed(0)}%` },
|
|
81
|
+
{ value: "toolSim" as const, label: `tool similarity: ${(c.toolSimilarityThreshold * 100).toFixed(0)}%`, description: "args must match this closely to count as the same call" },
|
|
82
|
+
{ value: "toolRepeat" as const, label: `tool repeat: ${c.minToolRepeatCount}+ prior`, description: "recurrences before a tool loop flags" },
|
|
83
|
+
{ value: "resultSim" as const, label: `result similarity: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "same cmd + different outcome vetoes the loop" },
|
|
78
84
|
{ value: "window" as const, label: `window: ${c.detectionWindow} msgs` },
|
|
79
85
|
{ value: "text" as const, label: `text detect: ${yn(c.detectTextLoops)}` },
|
|
80
86
|
{ value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
|
|
81
87
|
{ value: "think" as const, label: `think detect: ${yn(c.detectThinkingLoops)}` },
|
|
82
88
|
{ value: "notify" as const, label: `notify: ${yn(c.notifyOnDetection)}` },
|
|
89
|
+
{ value: "footer" as const, label: `interactive footer: ${yn(c.interactiveFooter)}`, description: "TUI footer with toggle shortcut" },
|
|
90
|
+
{ value: "shortcut" as const, label: `toggle shortcut: ${c.toggleShortcut}`, description: "esc+a or off" },
|
|
83
91
|
{ value: "reset" as const, label: "reset state" },
|
|
84
92
|
]);
|
|
85
93
|
if (!picked) return;
|
|
@@ -133,6 +141,34 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
133
141
|
if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`similarity: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
134
142
|
break;
|
|
135
143
|
}
|
|
144
|
+
case "toolSim": {
|
|
145
|
+
const v = await selectFrom(ctx, "tool similarity (args)", [
|
|
146
|
+
{ value: 0.99, label: "99% (strict)" },
|
|
147
|
+
{ value: 0.95, label: "95% (default)" },
|
|
148
|
+
{ value: 0.9, label: "90%" },
|
|
149
|
+
{ value: 0.8, label: "80% (sensitive)" },
|
|
150
|
+
]);
|
|
151
|
+
if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`tool similarity: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case "toolRepeat": {
|
|
155
|
+
const v = await selectFrom(ctx, "tool repeat (prior occurrences)", [
|
|
156
|
+
{ value: 1, label: "1 (sensitive)" },
|
|
157
|
+
{ value: 2, label: "2 (default)" },
|
|
158
|
+
{ value: 3, label: "3 (relaxed)" },
|
|
159
|
+
]);
|
|
160
|
+
if (v !== undefined) { c.minToolRepeatCount = v; saveConfig(c); ctx.ui.notify(`tool repeat: ${v}+ prior`, "info"); }
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
case "resultSim": {
|
|
164
|
+
const v = await selectFrom(ctx, "result similarity (outcome veto)", [
|
|
165
|
+
{ value: 0.95, label: "95% (strict — only near-identical results count as same outcome)" },
|
|
166
|
+
{ value: 0.8, label: "80% (default)" },
|
|
167
|
+
{ value: 0.6, label: "60% (relaxed — tolerates more output noise)" },
|
|
168
|
+
]);
|
|
169
|
+
if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`result similarity: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
136
172
|
case "window": {
|
|
137
173
|
const v = await selectFrom(ctx, "window", [
|
|
138
174
|
{ value: 5, label: "5" },
|
|
@@ -155,6 +191,20 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
155
191
|
case "notify":
|
|
156
192
|
c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
|
|
157
193
|
ctx.ui.notify(`notify: ${yn(c.notifyOnDetection)}`, "info"); break;
|
|
194
|
+
case "footer":
|
|
195
|
+
c.interactiveFooter = !c.interactiveFooter; saveConfig(c);
|
|
196
|
+
ctx.ui.notify(`interactive footer: ${yn(c.interactiveFooter)}`, "info");
|
|
197
|
+
rt.refreshFooter?.(ctx);
|
|
198
|
+
rt.updateStatus(ctx);
|
|
199
|
+
break;
|
|
200
|
+
case "shortcut": {
|
|
201
|
+
const v = await selectFrom(ctx, "toggle shortcut", [
|
|
202
|
+
{ value: "esc+a" as const, label: "esc+a (default)", description: "press ESC then a to toggle" },
|
|
203
|
+
{ value: "off" as const, label: "off", description: "disable keyboard toggle" },
|
|
204
|
+
]);
|
|
205
|
+
if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`toggle shortcut: ${v}`, "info"); }
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
158
208
|
case "reset":
|
|
159
209
|
resetState(rt.state);
|
|
160
210
|
rt.pendingIntervention = null;
|
|
@@ -184,31 +234,12 @@ export function resetState(state: AntiloopState): void {
|
|
|
184
234
|
state.consecutiveDetections = 0;
|
|
185
235
|
state.inForcedBreak = false;
|
|
186
236
|
state.totalDetections = 0;
|
|
237
|
+
state.lastDetectedTurnIndex = -1;
|
|
187
238
|
}
|
|
188
239
|
|
|
189
240
|
async function runSelfTest(ctx: ExtensionCommandContext): Promise<void> {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const norm = (t: string) => t.toLowerCase().replace(/\s+/g, " ").replace(/[^\w\s]/g, "").trim();
|
|
193
|
-
const cases: Array<{ a: string; b: string; expect: string }> = [
|
|
194
|
-
{ a: "Hello world", b: "Hello world", expect: "1.00" },
|
|
195
|
-
{ a: "Hello world", b: "Hello World!", expect: "high" },
|
|
196
|
-
{ a: "The quick brown fox", b: "The quick brown fox jumps over the lazy dog", expect: "high" },
|
|
197
|
-
{ a: "Hello world", b: "Goodbye universe", expect: "low" },
|
|
198
|
-
{ a: "I will read the file first", b: "I will read the file first to understand", expect: "high" },
|
|
199
|
-
];
|
|
200
|
-
const out: string[] = [];
|
|
201
|
-
for (const c of cases) {
|
|
202
|
-
// Re-implement minimal Levenshtein similarity for the self-test
|
|
203
|
-
const a = norm(c.a), b = norm(c.b);
|
|
204
|
-
const max = Math.max(a.length, b.length);
|
|
205
|
-
let diff = 0;
|
|
206
|
-
for (let i = 0; i < Math.min(a.length, b.length); i++) if (a[i] !== b[i]) diff++;
|
|
207
|
-
diff += Math.abs(a.length - b.length);
|
|
208
|
-
const s = max ? 1 - diff / max : 1;
|
|
209
|
-
out.push(`"${c.a}" vs "${c.b}" → ${(s * 100).toFixed(0)}% (exp: ${c.expect})`);
|
|
210
|
-
}
|
|
211
|
-
ctx.ui.notify(`antiloop self-test\n${out.join("\n")}`, "info");
|
|
241
|
+
const { runSelfTest } = await import("./detect.ts");
|
|
242
|
+
ctx.ui.notify(`antiloop self-test\n${runSelfTest().join("\n")}`, "info");
|
|
212
243
|
}
|
|
213
244
|
|
|
214
245
|
function yn(b: boolean): string {
|
package/src/config.ts
CHANGED
|
@@ -13,12 +13,17 @@ export const DEFAULT_CONFIG: AntiloopConfig = {
|
|
|
13
13
|
forceBreakThreshold: 3,
|
|
14
14
|
abortThreshold: 0,
|
|
15
15
|
similarityThreshold: 0.75,
|
|
16
|
+
toolSimilarityThreshold: 0.95,
|
|
17
|
+
minToolRepeatCount: 2,
|
|
18
|
+
resultSimilarityThreshold: 0.8,
|
|
16
19
|
detectToolLoops: true,
|
|
17
20
|
detectThinkingLoops: true,
|
|
18
21
|
detectTextLoops: true,
|
|
19
22
|
notifyOnDetection: true,
|
|
20
23
|
maxHistoryEntries: 100,
|
|
21
24
|
detectionWindow: 10,
|
|
25
|
+
interactiveFooter: true,
|
|
26
|
+
toggleShortcut: "esc+a",
|
|
22
27
|
};
|
|
23
28
|
|
|
24
29
|
export function getConfigPath(): string {
|
package/src/detect.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// antiloop — similarity + detection engine. Lazy-loaded on first message_end.
|
|
2
2
|
|
|
3
|
-
import type { AntiloopConfig, AntiloopState, LoopDetection } from "./types.ts";
|
|
3
|
+
import type { AntiloopConfig, AntiloopState, LoopDetection, TrackedToolCall } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
const MIN_CONTENT_LENGTH = 50;
|
|
6
6
|
|
|
@@ -53,15 +53,57 @@ function similarity(a: string, b: string): number {
|
|
|
53
53
|
return inter / uni;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Normalized, size-capped tail of a tool result, prefixed with ok/err so a
|
|
58
|
+
* change between success and failure is always a "different outcome".
|
|
59
|
+
* Stable against PID / timestamp noise at the tail of command output.
|
|
60
|
+
*/
|
|
61
|
+
export function resultFingerprint(
|
|
62
|
+
parts: Array<{ type: string; text?: string }>,
|
|
63
|
+
isError: boolean,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
let text = "";
|
|
66
|
+
for (const p of parts) if (p.type === "text" && typeof p.text === "string") text += p.text;
|
|
67
|
+
const norm = normalizeText(text);
|
|
68
|
+
if (!norm.length) return undefined;
|
|
69
|
+
return `${isError ? "err" : "ok"}|${norm.slice(-400)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Same outcome = identical fingerprint, or high similarity of the tails. */
|
|
73
|
+
function sameOutcome(a: string, b: string, threshold: number): boolean {
|
|
74
|
+
if (a === b) return true;
|
|
75
|
+
// Fingerprints are already normalized + capped, so short ones can be
|
|
76
|
+
// compared with edit distance directly — similarity() bails under 50 chars
|
|
77
|
+
// and would wrongly veto small outputs with harmless noise (PIDs, times).
|
|
78
|
+
if (a.length < 100 && b.length < 100) {
|
|
79
|
+
const max = Math.max(a.length, b.length);
|
|
80
|
+
const s = max ? 1 - levenshtein(a, b) / max : 1;
|
|
81
|
+
return s >= threshold;
|
|
82
|
+
}
|
|
83
|
+
const s = similarity(a, b);
|
|
84
|
+
return s >= threshold && s > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
56
87
|
function toolCallsSimilar(
|
|
57
|
-
c1:
|
|
58
|
-
c2:
|
|
88
|
+
c1: TrackedToolCall[],
|
|
89
|
+
c2: TrackedToolCall[],
|
|
90
|
+
threshold: number,
|
|
91
|
+
resultThreshold: number,
|
|
59
92
|
): boolean {
|
|
60
93
|
if (c1.length !== c2.length) return false;
|
|
61
|
-
|
|
94
|
+
// Empty call lists carry no repetition evidence — never treat them as a match.
|
|
95
|
+
if (!c1.length) return false;
|
|
62
96
|
for (let i = 0; i < c1.length; i++) {
|
|
63
97
|
if (c1[i].name !== c2[i].name) return false;
|
|
64
|
-
if (similarity(c1[i].args, c2[i].args) <
|
|
98
|
+
if (similarity(c1[i].args, c2[i].args) < threshold) return false;
|
|
99
|
+
// Result veto: the same command producing a different outcome is
|
|
100
|
+
// progress (a retry that fixed the problem), not a loop. Only applies
|
|
101
|
+
// when both runs actually captured a result.
|
|
102
|
+
const r1 = c1[i].result;
|
|
103
|
+
const r2 = c2[i].result;
|
|
104
|
+
if (r1 && r2 && !sameOutcome(r1, r2, resultThreshold)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
65
107
|
}
|
|
66
108
|
return true;
|
|
67
109
|
}
|
|
@@ -98,7 +140,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
|
|
|
98
140
|
const last = opens[opens.length - 1].o;
|
|
99
141
|
let n = 0;
|
|
100
142
|
for (let i = 0; i < opens.length - 1; i++) {
|
|
101
|
-
if (similarity(last, opens[i].o) > 0.
|
|
143
|
+
if (similarity(last, opens[i].o) > 0.9) n++;
|
|
102
144
|
}
|
|
103
145
|
if (n >= 2) {
|
|
104
146
|
out.push({
|
|
@@ -117,18 +159,25 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
|
|
|
117
159
|
const last = win[win.length - 1];
|
|
118
160
|
const lastCalls = last.toolCalls;
|
|
119
161
|
if (lastCalls && lastCalls.length) {
|
|
162
|
+
const matched: number[] = [];
|
|
120
163
|
for (let i = 0; i < win.length - 1; i++) {
|
|
121
164
|
const prev = win[i].toolCalls;
|
|
122
|
-
if (prev && toolCallsSimilar(lastCalls, prev)) {
|
|
123
|
-
|
|
124
|
-
type: "tool",
|
|
125
|
-
similarity: 1,
|
|
126
|
-
messageIndices: [start + i, msgs.length - 1],
|
|
127
|
-
description: `repeated: ${lastCalls.map((t) => t.name).join(", ")}`,
|
|
128
|
-
timestamp: now,
|
|
129
|
-
});
|
|
165
|
+
if (prev && toolCallsSimilar(lastCalls, prev, config.toolSimilarityThreshold, config.resultSimilarityThreshold)) {
|
|
166
|
+
matched.push(start + i);
|
|
130
167
|
}
|
|
131
168
|
}
|
|
169
|
+
// A single overlapping command (shared scaffolding in a long bash
|
|
170
|
+
// call) is NOT a loop — the same call set must recur at least
|
|
171
|
+
// minToolRepeatCount times inside the window before we flag it.
|
|
172
|
+
if (matched.length >= config.minToolRepeatCount) {
|
|
173
|
+
out.push({
|
|
174
|
+
type: "tool",
|
|
175
|
+
similarity: 1,
|
|
176
|
+
messageIndices: [...matched, msgs.length - 1],
|
|
177
|
+
description: `repeated ${matched.length + 1}x: ${lastCalls.map((t) => t.name).join(", ")}`,
|
|
178
|
+
timestamp: now,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
132
181
|
}
|
|
133
182
|
}
|
|
134
183
|
|
|
@@ -165,3 +214,67 @@ export function interventionMessage(level: 1 | 2 | 3, detections: LoopDetection[
|
|
|
165
214
|
}
|
|
166
215
|
return `[antiloop] 🚨 persistent loop\n${det}\nunable to break automatically — provide new instructions.`;
|
|
167
216
|
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Self-test — runs the REAL engine so it tracks future calibration changes.
|
|
220
|
+
// Includes the regression case that motivated the 0.95 tool threshold:
|
|
221
|
+
// sequential bash operations that share scaffolding (env setup, model path,
|
|
222
|
+
// most flags) are NOT a loop, even when they score 0.8–0.94 similar.
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
export function runSelfTest(): string[] {
|
|
226
|
+
const out: string[] = [];
|
|
227
|
+
const pct = (s: number) => `${(s * 100).toFixed(0)}%`;
|
|
228
|
+
|
|
229
|
+
// --- text similarity ---
|
|
230
|
+
const textSame = "I will read the file first to understand the structure before editing anything at all";
|
|
231
|
+
const textNear = "I will read the file first to understand the layout before editing anything at all";
|
|
232
|
+
const textDiff = "The quick brown fox jumps over the lazy dog near the river bank and keeps running";
|
|
233
|
+
const s1 = similarity(textSame, textSame);
|
|
234
|
+
const s2 = similarity(textSame, textNear);
|
|
235
|
+
const s3 = similarity(textSame, textDiff);
|
|
236
|
+
out.push(`text identical → ${pct(s1)} (exp 100%) ${s1 >= 0.99 ? "✅" : "❌"}`);
|
|
237
|
+
out.push(`text near-identical → ${pct(s2)} (exp ≥ 80%) ${s2 >= 0.8 ? "✅" : "❌"}`);
|
|
238
|
+
out.push(`text unrelated → ${pct(s3)} (exp < 50%) ${s3 < 0.5 ? "✅" : "❌"}`);
|
|
239
|
+
|
|
240
|
+
// --- tool calls (default thresholds: 95% args similarity, 2 prior repeats) ---
|
|
241
|
+
const common =
|
|
242
|
+
"cd /home/j/llm && ulimit -l unlimited 2>/dev/null; export ROCBLAS_USE_HIPBLASLT=1 HIP_VISIBLE_DEVICES=1; " +
|
|
243
|
+
"setsid ./kingjones30-boosted/build-unroll/bin/llama-server " +
|
|
244
|
+
"-m /home/j/llm/ling-rocmfp4/Ling-3.0-flash-ROCmFP4-STRIX-MTP-Q4_0-00001-of-00002.gguf " +
|
|
245
|
+
"-dev ROCm0 -ngl 999 -fa on -c 8192 -fit off -np 1 -sm row -ub 2048 " +
|
|
246
|
+
"--spec-type draft-mtp --spec-draft-n-max 2 --spec-draft-n-min 0 --spec-draft-p-min 0.4 " +
|
|
247
|
+
"--reasoning off --jinja --host 127.0.0.1 --port 8093 --no-webui";
|
|
248
|
+
const sweepRun1 = `${common} -b 2048 -ctk q8_0 -ctv turbo4 > /tmp/sweep-turbo4.log 2>&1 & echo $!; sleep 60; grep "model loaded" /tmp/sweep-turbo4.log`;
|
|
249
|
+
const sweepRun2 = `${common} -b 8192 -ctk f16 -ctv f16 > /tmp/sweep-b8192.log 2>&1 & echo $!; sleep 70; grep "model loaded" /tmp/sweep-b8192.log`;
|
|
250
|
+
|
|
251
|
+
const t1 = toolCallsSimilar([{ name: "bash", args: sweepRun1 }], [{ name: "bash", args: sweepRun1 }], 0.95, 0.8);
|
|
252
|
+
const t2 = toolCallsSimilar([{ name: "bash", args: sweepRun1 }], [{ name: "bash", args: sweepRun2 }], 0.95, 0.8);
|
|
253
|
+
const t2old = toolCallsSimilar([{ name: "bash", args: sweepRun1 }], [{ name: "bash", args: sweepRun2 }], 0.8, 0.8);
|
|
254
|
+
const t3 = toolCallsSimilar([{ name: "bash", args: sweepRun1 }], [{ name: "read", args: "{}" }], 0.95, 0.8);
|
|
255
|
+
const t4 = toolCallsSimilar([], [], 0.95, 0.8);
|
|
256
|
+
out.push(`tool identical cmd → ${t1 ? "match" : "no match"} (exp match) ${t1 ? "✅" : "❌"}`);
|
|
257
|
+
out.push(`tool sweep (flags) → ${t2 ? "match" : "no match"} @95% (exp no match) ${!t2 ? "✅" : "❌"}`);
|
|
258
|
+
out.push(`tool sweep (old 80%)→ ${t2old ? "match" : "no match"} @80% (exp match — was the false positive) ${t2old ? "✅" : "❌"}`);
|
|
259
|
+
out.push(`tool different tool → ${t3 ? "match" : "no match"} (exp no match) ${!t3 ? "✅" : "❌"}`);
|
|
260
|
+
out.push(`tool empty lists → ${t4 ? "match" : "no match"} (exp no match) ${!t4 ? "✅" : "❌"}`);
|
|
261
|
+
|
|
262
|
+
// --- result veto: same command, different outcome = progress, not a loop ---
|
|
263
|
+
const rErr = resultFingerprint([{ type: "text", text: "error: invalid argument: ROCm0\nPID 74970" }], true)!;
|
|
264
|
+
const rErr2 = resultFingerprint([{ type: "text", text: "error: invalid argument: ROCm0\nPID 77788" }], true)!;
|
|
265
|
+
const rOk = resultFingerprint([{ type: "text", text: "model loaded\nserver is listening on http://127.0.0.1:8093" }], false)!;
|
|
266
|
+
const sameCmdSameOut = toolCallsSimilar(
|
|
267
|
+
[{ name: "bash", args: sweepRun1, result: rErr }],
|
|
268
|
+
[{ name: "bash", args: sweepRun1, result: rErr2 }],
|
|
269
|
+
0.95, 0.8,
|
|
270
|
+
);
|
|
271
|
+
const sameCmdDiffOut = toolCallsSimilar(
|
|
272
|
+
[{ name: "bash", args: sweepRun1, result: rErr }],
|
|
273
|
+
[{ name: "bash", args: sweepRun1, result: rOk }],
|
|
274
|
+
0.95, 0.8,
|
|
275
|
+
);
|
|
276
|
+
out.push(`result same outcome → ${sameCmdSameOut ? "match" : "no match"} (exp match — PID noise ok) ${sameCmdSameOut ? "✅" : "❌"}`);
|
|
277
|
+
out.push(`result diff outcome → ${sameCmdDiffOut ? "match" : "no match"} (exp no match — error→success is progress) ${!sameCmdDiffOut ? "✅" : "❌"}`);
|
|
278
|
+
|
|
279
|
+
return out;
|
|
280
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* antiloop — detect reasoning loops and intervene.
|
|
3
|
-
* Hooks: message_end, input, before_agent_start, context, turn_end, session_start.
|
|
3
|
+
* Hooks: message_end, input, before_agent_start, context, turn_end, session_start, session_shutdown.
|
|
4
4
|
* Commands: /antiloop [enable|disable|status|config|log|reset|test]
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { loadConfig, saveConfig } from "./config.ts";
|
|
10
|
+
import type { AntiloopState, LoopDetection, Runtime, TrackedToolCall } from "./types.ts";
|
|
10
11
|
|
|
11
12
|
const ICONS = ["", "⚠️", "🛑", "🚨"] as const;
|
|
13
|
+
const LEVEL_NAMES = ["", "warning", "force", "abort"] as const;
|
|
12
14
|
|
|
13
15
|
function newState(): AntiloopState {
|
|
14
16
|
return {
|
|
@@ -19,25 +21,54 @@ function newState(): AntiloopState {
|
|
|
19
21
|
inForcedBreak: false,
|
|
20
22
|
totalDetections: 0,
|
|
21
23
|
lastUserMessageTime: 0,
|
|
24
|
+
lastDetectedTurnIndex: -1,
|
|
22
25
|
};
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
/** Compact pwd: ~-relative when inside $HOME, with trailing separator trimmed. */
|
|
29
|
+
function formatCwd(cwd: string): string {
|
|
30
|
+
const home = process.env.HOME;
|
|
31
|
+
if (home && cwd.startsWith(home)) {
|
|
32
|
+
const rel = cwd.slice(home.length).replace(/^[/\\]+/, "");
|
|
33
|
+
return rel ? `~/${rel}` : "~";
|
|
34
|
+
}
|
|
35
|
+
return cwd;
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
export default function antiloopExtension(pi: ExtensionAPI) {
|
|
26
39
|
const config = loadConfig();
|
|
27
40
|
let state = newState();
|
|
28
41
|
|
|
42
|
+
/** TUI handle for forcing footer re-renders (set by the footer factory). */
|
|
43
|
+
let activeTui: { requestRender(force?: boolean): void } | undefined;
|
|
44
|
+
|
|
45
|
+
/** Status text shown in the footer: "(emoji_antiloop)(on/off)" per spec. */
|
|
46
|
+
function antiloopStatusText(): string {
|
|
47
|
+
if (!config.enabled) return "🔄 antiloop(off)";
|
|
48
|
+
if (state.currentLevel === 0) return "🔄 antiloop(on)";
|
|
49
|
+
return `${ICONS[state.currentLevel]} antiloop(on)×${state.consecutiveDetections}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
29
52
|
function updateStatus(ctx: ExtensionContext): void {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
53
|
+
// Always set a status line so the footer shows on/off either way.
|
|
54
|
+
ctx.ui.setStatus("antiloop", antiloopStatusText());
|
|
55
|
+
activeTui?.requestRender();
|
|
33
56
|
}
|
|
34
57
|
|
|
35
|
-
const rt: Runtime = { config, state, pendingIntervention: null, updateStatus };
|
|
58
|
+
const rt: Runtime = { config, state, pendingIntervention: null, updateStatus, refreshFooter: installFooter };
|
|
36
59
|
const setPending = (v: string | null) => { rt.pendingIntervention = v; };
|
|
37
60
|
|
|
61
|
+
/** Toggle enable/disable, persisting config and refreshing the footer. */
|
|
62
|
+
function toggleEnabled(ctx: ExtensionContext): void {
|
|
63
|
+
config.enabled = !config.enabled;
|
|
64
|
+
saveConfig(config);
|
|
65
|
+
ctx.ui.notify(`antiloop: ${config.enabled ? "ON" : "OFF"}`, "info");
|
|
66
|
+
updateStatus(ctx);
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
function processDetections(
|
|
39
70
|
detections: LoopDetection[],
|
|
40
|
-
interventionMessage: (level:
|
|
71
|
+
interventionMessage: (level: 2 | 3, d: LoopDetection[]) => string,
|
|
41
72
|
): void {
|
|
42
73
|
if (!detections.length) {
|
|
43
74
|
if (state.consecutiveDetections > 0) state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 1);
|
|
@@ -58,19 +89,114 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
58
89
|
else if (state.consecutiveDetections >= config.warningThreshold) next = 1;
|
|
59
90
|
if (next > state.currentLevel) state.currentLevel = next;
|
|
60
91
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
92
|
+
// Warning (level 1) is informational only: notify the user but DO NOT
|
|
93
|
+
// inject any message into the conversation. Injecting at warning level
|
|
94
|
+
// made the model respond to the warning, which could stall generation
|
|
95
|
+
// even though hard-kill turns remained. Only force (2) / abort (3) inject.
|
|
96
|
+
if (state.currentLevel >= 2) {
|
|
97
|
+
setPending(interventionMessage(state.currentLevel as 2 | 3, detections));
|
|
98
|
+
state.inForcedBreak = true;
|
|
99
|
+
} else if (state.currentLevel === 1) {
|
|
100
|
+
state.inForcedBreak = false;
|
|
64
101
|
}
|
|
65
102
|
}
|
|
66
103
|
|
|
67
|
-
|
|
104
|
+
// ------------------------------------------------------------------
|
|
105
|
+
// Interactive footer (TUI). Replaces the built-in footer with a line
|
|
106
|
+
// per spec — "🔄 antiloop(on|off)" — plus live detection info, a
|
|
107
|
+
// keyboard toggle (esc+a by default, configurable/off), and the
|
|
108
|
+
// built-in footer's useful data (pwd, branch, ctx %, model) preserved.
|
|
109
|
+
// ------------------------------------------------------------------
|
|
110
|
+
function installFooter(ctx: ExtensionContext): void {
|
|
111
|
+
if (!config.interactiveFooter || ctx.mode !== "tui") {
|
|
112
|
+
ctx.ui.setFooter(undefined);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
116
|
+
activeTui = tui;
|
|
117
|
+
|
|
118
|
+
// Keyboard toggle from raw terminal input: escape followed by `a`.
|
|
119
|
+
// We never consume the input, so typing is unaffected — ESC alone
|
|
120
|
+
// passes through, and an accidental toggle is easily reversed.
|
|
121
|
+
let pendingEsc = false;
|
|
122
|
+
const shortcut = config.toggleShortcut;
|
|
123
|
+
const unsubInput =
|
|
124
|
+
shortcut === "off"
|
|
125
|
+
? undefined
|
|
126
|
+
: ctx.ui.onTerminalInput?.((data: string) => {
|
|
127
|
+
if (config.toggleShortcut === "off") return undefined;
|
|
128
|
+
if (data === "\x1b") {
|
|
129
|
+
pendingEsc = true;
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
if (pendingEsc && data === "a") {
|
|
133
|
+
pendingEsc = false;
|
|
134
|
+
toggleEnabled(ctx);
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
pendingEsc = false;
|
|
138
|
+
return undefined;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
dispose() {
|
|
143
|
+
unsubInput?.();
|
|
144
|
+
if (activeTui === tui) activeTui = undefined;
|
|
145
|
+
},
|
|
146
|
+
invalidate() {},
|
|
147
|
+
render(width: number): string[] {
|
|
148
|
+
const lines: string[] = [];
|
|
149
|
+
|
|
150
|
+
// Line 1: the spec indicator + toggle hint.
|
|
151
|
+
const status = antiloopStatusText();
|
|
152
|
+
const colored = config.enabled ? theme.fg("accent", status) : theme.fg("dim", status);
|
|
153
|
+
const hint =
|
|
154
|
+
config.toggleShortcut !== "off"
|
|
155
|
+
? theme.fg("dim", ` [${config.toggleShortcut}] toggle`)
|
|
156
|
+
: theme.fg("dim", " [/antiloop] toggle");
|
|
157
|
+
lines.push(truncateToWidth(colored + hint, width));
|
|
158
|
+
|
|
159
|
+
// Line 2 (only while detecting): level + consecutive + last reason.
|
|
160
|
+
if (state.currentLevel > 0) {
|
|
161
|
+
const last = state.detections[state.detections.length - 1];
|
|
162
|
+
const desc = last ? ` · ${last.description}` : "";
|
|
163
|
+
lines.push(
|
|
164
|
+
truncateToWidth(
|
|
165
|
+
theme.fg("warning", `${LEVEL_NAMES[state.currentLevel]} ×${state.consecutiveDetections}${desc}`),
|
|
166
|
+
width,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Line 3: built-in footer data preserved (dim).
|
|
172
|
+
let info = formatCwd(ctx.cwd);
|
|
173
|
+
const branch = footerData.getGitBranch();
|
|
174
|
+
if (branch) info += ` (${branch})`;
|
|
175
|
+
const usage = ctx.getContextUsage();
|
|
176
|
+
const cw = usage?.contextWindow ?? ctx.model?.contextWindow;
|
|
177
|
+
if (cw && usage && usage.percent !== null) info += ` · ctx ${Math.round(usage.percent)}%`;
|
|
178
|
+
if (ctx.model) info += ` · ${ctx.model.id}`;
|
|
179
|
+
lines.push(truncateToWidth(theme.fg("dim", info), width));
|
|
180
|
+
|
|
181
|
+
// Line 4 (only when other extensions set statuses): keep them visible.
|
|
182
|
+
const others = Array.from(footerData.getExtensionStatuses().entries())
|
|
183
|
+
.filter(([k]) => k !== "antiloop")
|
|
184
|
+
.map(([, v]) => v);
|
|
185
|
+
if (others.length) {
|
|
186
|
+
lines.push(truncateToWidth(theme.fg("dim", others.join(" ")), width));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return lines;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
pi.on("message_end", async (event) => {
|
|
68
196
|
if (!config.enabled) return;
|
|
69
197
|
const msg = event.message;
|
|
70
198
|
if (msg.role !== "assistant") return;
|
|
71
199
|
|
|
72
|
-
const { detectLoops, interventionMessage } = await import("./detect.ts");
|
|
73
|
-
|
|
74
200
|
let content = "";
|
|
75
201
|
let thinking = "";
|
|
76
202
|
if (typeof msg.content === "string") content = msg.content;
|
|
@@ -81,10 +207,11 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
81
207
|
}
|
|
82
208
|
}
|
|
83
209
|
|
|
84
|
-
|
|
210
|
+
// Track the call with its id so turn_end can attach the execution result.
|
|
211
|
+
const toolCalls: TrackedToolCall[] = [];
|
|
85
212
|
if (Array.isArray(msg.content)) {
|
|
86
213
|
for (const p of msg.content) {
|
|
87
|
-
if (p.type === "toolCall") toolCalls.push({ name: p.name, args: JSON.stringify(p.arguments ?? {}) });
|
|
214
|
+
if (p.type === "toolCall") toolCalls.push({ name: p.name, args: JSON.stringify(p.arguments ?? {}), id: p.id });
|
|
88
215
|
}
|
|
89
216
|
}
|
|
90
217
|
|
|
@@ -100,14 +227,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
100
227
|
if (state.recentMessages.length > config.detectionWindow + 5) {
|
|
101
228
|
state.recentMessages = state.recentMessages.slice(-(config.detectionWindow + 5));
|
|
102
229
|
}
|
|
103
|
-
|
|
104
|
-
const detections = detectLoops(state, config);
|
|
105
|
-
processDetections(detections, interventionMessage);
|
|
106
|
-
|
|
107
|
-
if (config.notifyOnDetection && detections.length && state.currentLevel > 0) {
|
|
108
|
-
const lvl = ["", "warning", "force", "abort"][state.currentLevel];
|
|
109
|
-
ctx.ui.notify(`antiloop: ${lvl} — ${detections[0].description}`, state.currentLevel >= 2 ? "error" : "warning");
|
|
110
|
-
}
|
|
111
230
|
});
|
|
112
231
|
|
|
113
232
|
pi.on("input", async () => {
|
|
@@ -145,8 +264,42 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
145
264
|
return { messages: msgs };
|
|
146
265
|
});
|
|
147
266
|
|
|
148
|
-
pi.on("turn_end", async (
|
|
149
|
-
if (config.enabled)
|
|
267
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
268
|
+
if (!config.enabled) return;
|
|
269
|
+
const { detectLoops, interventionMessage, resultFingerprint } = await import("./detect.ts");
|
|
270
|
+
|
|
271
|
+
const last = state.recentMessages[state.recentMessages.length - 1];
|
|
272
|
+
// A turn whose assistant message wasn't tracked (short text, no tools)
|
|
273
|
+
// must not re-run detection on the previous message — skip it.
|
|
274
|
+
if (!last || last.turnIndex === state.lastDetectedTurnIndex) {
|
|
275
|
+
updateStatus(ctx);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
state.lastDetectedTurnIndex = last.turnIndex;
|
|
279
|
+
|
|
280
|
+
// Attach execution results to the message that just finished its turn.
|
|
281
|
+
// Detection runs here (not at message_end) because the results — the
|
|
282
|
+
// signal that distinguishes "stuck loop" from "making progress" — only
|
|
283
|
+
// exist after the tools have executed.
|
|
284
|
+
if (last.toolCalls?.length && event.toolResults.length) {
|
|
285
|
+
const byId = new Map<string, string | undefined>();
|
|
286
|
+
for (const tr of event.toolResults) byId.set(tr.toolCallId, resultFingerprint(tr.content, tr.isError));
|
|
287
|
+
for (const tc of last.toolCalls) {
|
|
288
|
+
if (tc.id && tc.result === undefined) {
|
|
289
|
+
const r = byId.get(tc.id);
|
|
290
|
+
if (r !== undefined) tc.result = r;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const detections = detectLoops(state, config);
|
|
296
|
+
processDetections(detections, interventionMessage);
|
|
297
|
+
|
|
298
|
+
if (config.notifyOnDetection && detections.length && state.currentLevel > 0) {
|
|
299
|
+
const lvl = ["", "warning", "force", "abort"][state.currentLevel];
|
|
300
|
+
ctx.ui.notify(`antiloop: ${lvl} — ${detections[0].description}`, state.currentLevel >= 2 ? "error" : "warning");
|
|
301
|
+
}
|
|
302
|
+
updateStatus(ctx);
|
|
150
303
|
});
|
|
151
304
|
|
|
152
305
|
pi.on("session_start", async (_e, ctx) => {
|
|
@@ -154,9 +307,17 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
154
307
|
Object.assign(config, loadConfig());
|
|
155
308
|
state = newState();
|
|
156
309
|
rt.pendingIntervention = null;
|
|
310
|
+
installFooter(ctx);
|
|
157
311
|
updateStatus(ctx);
|
|
158
312
|
});
|
|
159
313
|
|
|
314
|
+
pi.on("session_shutdown", async (_e, ctx) => {
|
|
315
|
+
// Restore the built-in footer on shutdown (in case another extension
|
|
316
|
+
// installs its own footer later, or the TUI is torn down).
|
|
317
|
+
ctx.ui.setFooter(undefined);
|
|
318
|
+
activeTui = undefined;
|
|
319
|
+
});
|
|
320
|
+
|
|
160
321
|
pi.registerCommand("antiloop", {
|
|
161
322
|
description: "antiloop: detect & break reasoning loops",
|
|
162
323
|
getArgumentCompletions: (prefix: string) => {
|
|
@@ -169,5 +330,3 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
169
330
|
},
|
|
170
331
|
});
|
|
171
332
|
}
|
|
172
|
-
|
|
173
|
-
|
package/src/types.ts
CHANGED
|
@@ -7,12 +7,41 @@ export interface AntiloopConfig {
|
|
|
7
7
|
forceBreakThreshold: number;
|
|
8
8
|
abortThreshold: number;
|
|
9
9
|
similarityThreshold: number;
|
|
10
|
+
/**
|
|
11
|
+
* How close tool-call arguments must be (0..1) to count as the SAME call.
|
|
12
|
+
* High by default: long bash commands share scaffolding (env setup, flags,
|
|
13
|
+
* paths) even when they are different operations — a parameter sweep or a
|
|
14
|
+
* retry after a fix is NOT a loop. Only near-identical repeats qualify.
|
|
15
|
+
*/
|
|
16
|
+
toolSimilarityThreshold: number;
|
|
17
|
+
/**
|
|
18
|
+
* How many PRIOR occurrences of a near-identical tool-call set must exist
|
|
19
|
+
* in the window before a tool loop is flagged. 2 = the same call seen 3x.
|
|
20
|
+
*/
|
|
21
|
+
minToolRepeatCount: number;
|
|
22
|
+
/**
|
|
23
|
+
* Result-aware veto: when both runs have a captured result, the normalized
|
|
24
|
+
* result tails must be at least this similar for the pair to count as a
|
|
25
|
+
* loop. Same command + different outcome = progress, not a loop.
|
|
26
|
+
*/
|
|
27
|
+
resultSimilarityThreshold: number;
|
|
10
28
|
detectToolLoops: boolean;
|
|
11
29
|
detectThinkingLoops: boolean;
|
|
12
30
|
detectTextLoops: boolean;
|
|
13
31
|
notifyOnDetection: boolean;
|
|
14
32
|
maxHistoryEntries: number;
|
|
15
33
|
detectionWindow: number;
|
|
34
|
+
/**
|
|
35
|
+
* Show the antiloop indicator as a custom interactive footer in TUI mode
|
|
36
|
+
* (replaces the built-in footer). When false, the indicator is still shown
|
|
37
|
+
* as a status line in the built-in footer via ctx.ui.setStatus.
|
|
38
|
+
*/
|
|
39
|
+
interactiveFooter: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Key sequence that toggles antiloop from the footer (raw terminal input).
|
|
42
|
+
* Format: "esc+a" (escape followed by `a`) or "off" to disable.
|
|
43
|
+
*/
|
|
44
|
+
toggleShortcut: string;
|
|
16
45
|
}
|
|
17
46
|
|
|
18
47
|
export type LoopKind = "text" | "tool" | "thinking" | "structural";
|
|
@@ -25,10 +54,23 @@ export interface LoopDetection {
|
|
|
25
54
|
timestamp: number;
|
|
26
55
|
}
|
|
27
56
|
|
|
57
|
+
export interface TrackedToolCall {
|
|
58
|
+
name: string;
|
|
59
|
+
args: string;
|
|
60
|
+
/** toolCallId — used to attach the execution result at turn_end. */
|
|
61
|
+
id?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Normalized tail of the tool result ("err|" / "ok|" prefix + last chars).
|
|
64
|
+
* Only set when the turn completed and a result was captured. When both
|
|
65
|
+
* sides of a comparison have one, a mismatch vetoes the loop.
|
|
66
|
+
*/
|
|
67
|
+
result?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
28
70
|
export interface TrackedMessage {
|
|
29
71
|
content: string;
|
|
30
72
|
thinking?: string;
|
|
31
|
-
toolCalls?:
|
|
73
|
+
toolCalls?: TrackedToolCall[];
|
|
32
74
|
timestamp: number;
|
|
33
75
|
turnIndex: number;
|
|
34
76
|
}
|
|
@@ -41,6 +83,8 @@ export interface AntiloopState {
|
|
|
41
83
|
inForcedBreak: boolean;
|
|
42
84
|
totalDetections: number;
|
|
43
85
|
lastUserMessageTime: number;
|
|
86
|
+
/** turnIndex of the last tracked message detection already ran on. */
|
|
87
|
+
lastDetectedTurnIndex: number;
|
|
44
88
|
}
|
|
45
89
|
|
|
46
90
|
export interface Runtime {
|
|
@@ -48,4 +92,6 @@ export interface Runtime {
|
|
|
48
92
|
state: AntiloopState;
|
|
49
93
|
pendingIntervention: string | null;
|
|
50
94
|
updateStatus(ctx: ExtensionContext): void;
|
|
95
|
+
/** Re-install the interactive footer (after config changes). */
|
|
96
|
+
refreshFooter?(ctx: ExtensionContext): void;
|
|
51
97
|
}
|