pi-antiloop 1.0.0 → 1.1.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 +72 -31
- package/package.json +5 -6
- package/src/commands.ts +230 -0
- package/src/config.ts +49 -0
- package/src/detect.ts +280 -0
- package/src/index.ts +188 -964
- package/src/types.ts +84 -0
- package/src/ui.ts +23 -0
package/README.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-

|
|
4
4
|
|
|
5
5
|
</div>
|
|
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
|
|
@@ -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
|
|
|
@@ -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,6 +223,9 @@ 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,
|
|
@@ -208,7 +241,10 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
|
|
|
208
241
|
| `warningThreshold` | `2` | Consecutive detections before warning |
|
|
209
242
|
| `forceBreakThreshold` | `3` | Consecutive detections before force break |
|
|
210
243
|
| `abortThreshold` | `0` | Consecutive detections before abort (0 = disabled) |
|
|
211
|
-
| `similarityThreshold` | `0.75` | Minimum similarity (0.0–1.0) to count a pair as looping |
|
|
244
|
+
| `similarityThreshold` | `0.75` | Minimum similarity (0.0–1.0) to count a text/thinking pair as looping |
|
|
245
|
+
| `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) |
|
|
246
|
+
| `minToolRepeatCount` | `2` | Prior occurrences of a near-identical call set required before a tool loop is flagged (2 = same call seen 3×) |
|
|
247
|
+
| `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
248
|
| `detectTextLoops` | `true` | Detect full-text repetition |
|
|
213
249
|
| `detectToolLoops` | `true` | Detect tool-call sequence + argument repetition |
|
|
214
250
|
| `detectThinkingLoops` | `true` | Detect repeated thinking/reasoning content |
|
|
@@ -218,12 +254,13 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
|
|
|
218
254
|
|
|
219
255
|
## Best Practices
|
|
220
256
|
|
|
221
|
-
1. **Start with defaults** — `warning=2 / force-break=3 / similarity=75%` works well for most models.
|
|
257
|
+
1. **Start with defaults** — `warning=2 / force-break=3 / similarity=75% / tool-sim=95%` works well for most models.
|
|
222
258
|
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.
|
|
259
|
+
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.
|
|
260
|
+
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.
|
|
261
|
+
5. **Watch the log** — `/antiloop log` shows what's actually triggering. If you see false positives, raise `similarityThreshold` instead of disabling the strategy entirely.
|
|
262
|
+
6. **Let user input clear state** — each user message decays the consecutive counter by 2, so a fresh prompt naturally resets without `/antiloop reset`.
|
|
263
|
+
7. **`/antiloop test`** — runs the real detection engine (text + tool-call regression cases) to verify calibration after any change.
|
|
227
264
|
|
|
228
265
|
## Architecture
|
|
229
266
|
|
|
@@ -233,20 +270,24 @@ antiloop/
|
|
|
233
270
|
├── LICENSE # MIT
|
|
234
271
|
├── README.md
|
|
235
272
|
├── docs/
|
|
236
|
-
│ ├── banner.
|
|
237
|
-
│ └── preview.
|
|
238
|
-
├── screenshot.png # full-res master
|
|
273
|
+
│ ├── banner.jpeg # wide README header
|
|
274
|
+
│ └── preview.jpeg # npm pi.dev preview card
|
|
239
275
|
└── src/
|
|
240
|
-
|
|
276
|
+
├── index.ts # hooks + intervention pipeline
|
|
277
|
+
├── detect.ts # similarity engine, detection strategies, self-test
|
|
278
|
+
├── commands.ts # /antiloop command handlers + config menu
|
|
279
|
+
├── config.ts # config load/save
|
|
280
|
+
├── types.ts # shared types
|
|
281
|
+
└── ui.ts # UI helpers (select, duration)
|
|
241
282
|
```
|
|
242
283
|
|
|
243
|
-
|
|
284
|
+
Modular extension with zero external dependencies (only pi's bundled `@earendil-works/pi-coding-agent` + Node built-ins):
|
|
244
285
|
|
|
245
286
|
- **Levenshtein + trigram Jaccard** hybrid — small texts use edit distance, large texts use n-gram overlap (each is O(N) in text length)
|
|
246
287
|
- **Sliding window** — only the last `detectionWindow` messages participate, capping memory at O(W × message_size)
|
|
247
288
|
- **Early bail** — short messages and empty tool calls skip similarity computation entirely
|
|
248
289
|
- **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 status bar
|
|
249
|
-
- **Hooks** — `message_end` (track + detect), `input` (decay), `before_agent_start` (inject intervention), `context` (modify context in force-break mode), `
|
|
290
|
+
- **Hooks** — `message_end` (track messages + tool call ids), `turn_end` (attach result fingerprints + detect), `input` (decay), `before_agent_start` (inject intervention), `context` (modify context in force-break mode), `session_start` (load config + reset)
|
|
250
291
|
|
|
251
292
|
## License
|
|
252
293
|
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-antiloop",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.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
|
-
"loop-detection",
|
|
8
7
|
"antiloop",
|
|
8
|
+
"loop-detection",
|
|
9
9
|
"reasoning",
|
|
10
|
-
"monitoring"
|
|
11
|
-
"debugging"
|
|
10
|
+
"monitoring"
|
|
12
11
|
],
|
|
13
12
|
"author": "Javier Noguerol <https://github.com/noguerol>",
|
|
14
13
|
"license": "MIT",
|
|
@@ -24,7 +23,7 @@
|
|
|
24
23
|
"extensions": [
|
|
25
24
|
"./src/index.ts"
|
|
26
25
|
],
|
|
27
|
-
"image": "https://raw.githubusercontent.com/noguerol/antiloop/main/docs/preview.
|
|
26
|
+
"image": "https://raw.githubusercontent.com/noguerol/antiloop/main/docs/preview.jpeg"
|
|
28
27
|
},
|
|
29
28
|
"files": [
|
|
30
29
|
"src"
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// antiloop — command handlers. Lazy-loaded on /antiloop.
|
|
2
|
+
|
|
3
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { saveConfig } from "./config.ts";
|
|
5
|
+
import type { AntiloopState, Runtime } from "./types.ts";
|
|
6
|
+
import { formatDuration, selectFrom } from "./ui.ts";
|
|
7
|
+
|
|
8
|
+
export async function handleCommand(
|
|
9
|
+
args: string | undefined,
|
|
10
|
+
ctx: ExtensionCommandContext,
|
|
11
|
+
rt: Runtime,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const sub = (args ?? "").trim().toLowerCase();
|
|
14
|
+
switch (sub) {
|
|
15
|
+
case "enable":
|
|
16
|
+
rt.config.enabled = true;
|
|
17
|
+
saveConfig(rt.config);
|
|
18
|
+
ctx.ui.notify("antiloop: ON", "info");
|
|
19
|
+
rt.updateStatus(ctx);
|
|
20
|
+
return;
|
|
21
|
+
case "disable":
|
|
22
|
+
rt.config.enabled = false;
|
|
23
|
+
saveConfig(rt.config);
|
|
24
|
+
ctx.ui.notify("antiloop: OFF", "info");
|
|
25
|
+
rt.updateStatus(ctx);
|
|
26
|
+
return;
|
|
27
|
+
case "status":
|
|
28
|
+
return showStatus(ctx, rt);
|
|
29
|
+
case "config":
|
|
30
|
+
return showConfigMenu(ctx, rt);
|
|
31
|
+
case "log":
|
|
32
|
+
return showLog(ctx, rt);
|
|
33
|
+
case "reset":
|
|
34
|
+
resetState(rt.state);
|
|
35
|
+
rt.pendingIntervention = null;
|
|
36
|
+
ctx.ui.notify("antiloop: reset", "info");
|
|
37
|
+
rt.updateStatus(ctx);
|
|
38
|
+
return;
|
|
39
|
+
case "test":
|
|
40
|
+
return runSelfTest(ctx);
|
|
41
|
+
default:
|
|
42
|
+
rt.config.enabled = !rt.config.enabled;
|
|
43
|
+
saveConfig(rt.config);
|
|
44
|
+
ctx.ui.notify(`antiloop: ${rt.config.enabled ? "ON" : "OFF"}`, "info");
|
|
45
|
+
rt.updateStatus(ctx);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
|
|
51
|
+
const lvl = ["none", "warn", "force", "abort"][rt.state.currentLevel];
|
|
52
|
+
const recent = rt.state.detections.slice(-5);
|
|
53
|
+
const lines = [
|
|
54
|
+
`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"}`,
|
|
56
|
+
"",
|
|
57
|
+
"thresholds:",
|
|
58
|
+
` warn: ${rt.config.warningThreshold} force: ${rt.config.forceBreakThreshold} abort: ${rt.config.abortThreshold || "off"}`,
|
|
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)`,
|
|
62
|
+
"",
|
|
63
|
+
`detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
|
|
64
|
+
];
|
|
65
|
+
if (recent.length) {
|
|
66
|
+
lines.push("", "recent:");
|
|
67
|
+
for (const d of recent) lines.push(` [${d.type}] ${d.description} · ${formatDuration(Date.now() - d.timestamp)} ago`);
|
|
68
|
+
}
|
|
69
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
|
|
73
|
+
const c = rt.config;
|
|
74
|
+
const picked = await selectFrom(ctx, "antiloop config", [
|
|
75
|
+
{ value: "toggle" as const, label: c.enabled ? "🟢 disable" : "🔴 enable", description: "toggle detection" },
|
|
76
|
+
{ value: "warn" as const, label: `warn threshold: ${c.warningThreshold}` },
|
|
77
|
+
{ value: "force" as const, label: `force threshold: ${c.forceBreakThreshold}` },
|
|
78
|
+
{ value: "abort" as const, label: `abort threshold: ${c.abortThreshold || "off"}`, description: "0 = disabled" },
|
|
79
|
+
{ value: "sim" as const, label: `similarity: ${(c.similarityThreshold * 100).toFixed(0)}%` },
|
|
80
|
+
{ value: "toolSim" as const, label: `tool similarity: ${(c.toolSimilarityThreshold * 100).toFixed(0)}%`, description: "args must match this closely to count as the same call" },
|
|
81
|
+
{ value: "toolRepeat" as const, label: `tool repeat: ${c.minToolRepeatCount}+ prior`, description: "recurrences before a tool loop flags" },
|
|
82
|
+
{ value: "resultSim" as const, label: `result similarity: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "same cmd + different outcome vetoes the loop" },
|
|
83
|
+
{ value: "window" as const, label: `window: ${c.detectionWindow} msgs` },
|
|
84
|
+
{ value: "text" as const, label: `text detect: ${yn(c.detectTextLoops)}` },
|
|
85
|
+
{ value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
|
|
86
|
+
{ value: "think" as const, label: `think detect: ${yn(c.detectThinkingLoops)}` },
|
|
87
|
+
{ value: "notify" as const, label: `notify: ${yn(c.notifyOnDetection)}` },
|
|
88
|
+
{ value: "reset" as const, label: "reset state" },
|
|
89
|
+
]);
|
|
90
|
+
if (!picked) return;
|
|
91
|
+
switch (picked) {
|
|
92
|
+
case "toggle":
|
|
93
|
+
c.enabled = !c.enabled;
|
|
94
|
+
saveConfig(c);
|
|
95
|
+
ctx.ui.notify(`antiloop: ${c.enabled ? "ON" : "OFF"}`, "info");
|
|
96
|
+
rt.updateStatus(ctx);
|
|
97
|
+
break;
|
|
98
|
+
case "warn": {
|
|
99
|
+
const v = await selectFrom(ctx, "warn threshold", [
|
|
100
|
+
{ value: 1, label: "1 (sensitive)" },
|
|
101
|
+
{ value: 2, label: "2 (default)" },
|
|
102
|
+
{ value: 3, label: "3" },
|
|
103
|
+
{ value: 5, label: "5 (relaxed)" },
|
|
104
|
+
]);
|
|
105
|
+
if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`warn: ${v}`, "info"); }
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "force": {
|
|
109
|
+
const v = await selectFrom(ctx, "force threshold", [
|
|
110
|
+
{ value: 2, label: "2 (sensitive)" },
|
|
111
|
+
{ value: 3, label: "3 (default)" },
|
|
112
|
+
{ value: 5, label: "5" },
|
|
113
|
+
{ value: 8, label: "8 (relaxed)" },
|
|
114
|
+
]);
|
|
115
|
+
if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`force: ${v}`, "info"); }
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "abort": {
|
|
119
|
+
const v = await selectFrom(ctx, "abort threshold (0=off)", [
|
|
120
|
+
{ value: 0, label: "off" },
|
|
121
|
+
{ value: 5, label: "5" },
|
|
122
|
+
{ value: 8, label: "8" },
|
|
123
|
+
{ value: 10, label: "10" },
|
|
124
|
+
{ value: 15, label: "15" },
|
|
125
|
+
]);
|
|
126
|
+
if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`abort: ${v || "off"}`, "info"); }
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case "sim": {
|
|
130
|
+
const v = await selectFrom(ctx, "similarity", [
|
|
131
|
+
{ value: 0.5, label: "50% (sensitive)" },
|
|
132
|
+
{ value: 0.6, label: "60%" },
|
|
133
|
+
{ value: 0.7, label: "70%" },
|
|
134
|
+
{ value: 0.75, label: "75% (default)" },
|
|
135
|
+
{ value: 0.8, label: "80%" },
|
|
136
|
+
{ value: 0.9, label: "90% (relaxed)" },
|
|
137
|
+
]);
|
|
138
|
+
if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`similarity: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "toolSim": {
|
|
142
|
+
const v = await selectFrom(ctx, "tool similarity (args)", [
|
|
143
|
+
{ value: 0.99, label: "99% (strict)" },
|
|
144
|
+
{ value: 0.95, label: "95% (default)" },
|
|
145
|
+
{ value: 0.9, label: "90%" },
|
|
146
|
+
{ value: 0.8, label: "80% (sensitive)" },
|
|
147
|
+
]);
|
|
148
|
+
if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`tool similarity: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "toolRepeat": {
|
|
152
|
+
const v = await selectFrom(ctx, "tool repeat (prior occurrences)", [
|
|
153
|
+
{ value: 1, label: "1 (sensitive)" },
|
|
154
|
+
{ value: 2, label: "2 (default)" },
|
|
155
|
+
{ value: 3, label: "3 (relaxed)" },
|
|
156
|
+
]);
|
|
157
|
+
if (v !== undefined) { c.minToolRepeatCount = v; saveConfig(c); ctx.ui.notify(`tool repeat: ${v}+ prior`, "info"); }
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "resultSim": {
|
|
161
|
+
const v = await selectFrom(ctx, "result similarity (outcome veto)", [
|
|
162
|
+
{ value: 0.95, label: "95% (strict — only near-identical results count as same outcome)" },
|
|
163
|
+
{ value: 0.8, label: "80% (default)" },
|
|
164
|
+
{ value: 0.6, label: "60% (relaxed — tolerates more output noise)" },
|
|
165
|
+
]);
|
|
166
|
+
if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`result similarity: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case "window": {
|
|
170
|
+
const v = await selectFrom(ctx, "window", [
|
|
171
|
+
{ value: 5, label: "5" },
|
|
172
|
+
{ value: 10, label: "10 (default)" },
|
|
173
|
+
{ value: 15, label: "15" },
|
|
174
|
+
{ value: 20, label: "20" },
|
|
175
|
+
]);
|
|
176
|
+
if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`window: ${v}`, "info"); }
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "text":
|
|
180
|
+
c.detectTextLoops = !c.detectTextLoops; saveConfig(c);
|
|
181
|
+
ctx.ui.notify(`text: ${yn(c.detectTextLoops)}`, "info"); break;
|
|
182
|
+
case "tool":
|
|
183
|
+
c.detectToolLoops = !c.detectToolLoops; saveConfig(c);
|
|
184
|
+
ctx.ui.notify(`tool: ${yn(c.detectToolLoops)}`, "info"); break;
|
|
185
|
+
case "think":
|
|
186
|
+
c.detectThinkingLoops = !c.detectThinkingLoops; saveConfig(c);
|
|
187
|
+
ctx.ui.notify(`think: ${yn(c.detectThinkingLoops)}`, "info"); break;
|
|
188
|
+
case "notify":
|
|
189
|
+
c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
|
|
190
|
+
ctx.ui.notify(`notify: ${yn(c.notifyOnDetection)}`, "info"); break;
|
|
191
|
+
case "reset":
|
|
192
|
+
resetState(rt.state);
|
|
193
|
+
rt.pendingIntervention = null;
|
|
194
|
+
ctx.ui.notify("reset", "info");
|
|
195
|
+
rt.updateStatus(ctx);
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
|
|
201
|
+
if (!rt.state.detections.length) {
|
|
202
|
+
ctx.ui.notify("no detections this session", "info");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const items = rt.state.detections.slice(-30).reverse().map((d) => ({
|
|
206
|
+
value: "" as const,
|
|
207
|
+
label: `[${d.type}] ${d.description}`,
|
|
208
|
+
description: `${(d.similarity * 100).toFixed(0)}% · ${formatDuration(Date.now() - d.timestamp)} ago`,
|
|
209
|
+
}));
|
|
210
|
+
await selectFrom(ctx, `detections (${rt.state.detections.length} total)`, items);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resetState(state: AntiloopState): void {
|
|
214
|
+
state.recentMessages = [];
|
|
215
|
+
state.detections = [];
|
|
216
|
+
state.currentLevel = 0;
|
|
217
|
+
state.consecutiveDetections = 0;
|
|
218
|
+
state.inForcedBreak = false;
|
|
219
|
+
state.totalDetections = 0;
|
|
220
|
+
state.lastDetectedTurnIndex = -1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function runSelfTest(ctx: ExtensionCommandContext): Promise<void> {
|
|
224
|
+
const { runSelfTest } = await import("./detect.ts");
|
|
225
|
+
ctx.ui.notify(`antiloop self-test\n${runSelfTest().join("\n")}`, "info");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function yn(b: boolean): string {
|
|
229
|
+
return b ? "on" : "off";
|
|
230
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// antiloop — config load/save. Lightweight: only file I/O at session_start.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { AntiloopConfig } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export const CONFIG_FILE = "antiloop.json";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_CONFIG: AntiloopConfig = {
|
|
11
|
+
enabled: true,
|
|
12
|
+
warningThreshold: 2,
|
|
13
|
+
forceBreakThreshold: 3,
|
|
14
|
+
abortThreshold: 0,
|
|
15
|
+
similarityThreshold: 0.75,
|
|
16
|
+
toolSimilarityThreshold: 0.95,
|
|
17
|
+
minToolRepeatCount: 2,
|
|
18
|
+
resultSimilarityThreshold: 0.8,
|
|
19
|
+
detectToolLoops: true,
|
|
20
|
+
detectThinkingLoops: true,
|
|
21
|
+
detectTextLoops: true,
|
|
22
|
+
notifyOnDetection: true,
|
|
23
|
+
maxHistoryEntries: 100,
|
|
24
|
+
detectionWindow: 10,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function getConfigPath(): string {
|
|
28
|
+
return join(getAgentDir(), CONFIG_FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadConfig(): AntiloopConfig {
|
|
32
|
+
const p = getConfigPath();
|
|
33
|
+
if (existsSync(p)) {
|
|
34
|
+
try {
|
|
35
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(p, "utf-8")) };
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error(`[antiloop] config load error: ${e}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { ...DEFAULT_CONFIG };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function saveConfig(config: AntiloopConfig): void {
|
|
44
|
+
try {
|
|
45
|
+
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8");
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.error(`[antiloop] config save error: ${e}`);
|
|
48
|
+
}
|
|
49
|
+
}
|