pi-antiloop 1.2.0 → 1.3.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 +50 -0
- package/package.json +2 -2
- package/src/commands.ts +30 -0
- package/src/config.ts +3 -0
- package/src/detect.ts +149 -5
- package/src/index.ts +21 -4
- package/src/types.ts +22 -0
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
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
|
+
- **Task-stream recognition (batch work)** — when another extension (e.g. `punched` appending lines to pi.md, or `plan` adding tasks) makes the model call the *same* tool many times with *different* content, antiloop recognizes it as N distinct tasks of one type and stays silent — no warning, no force break. A genuine loop (the *same* call repeated verbatim) is still caught
|
|
16
17
|
- **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
18
|
- **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
|
|
18
19
|
- **Sliding window** — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
|
|
@@ -115,6 +116,9 @@ Interactive menu with current values:
|
|
|
115
116
|
- **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
117
|
- **Tool repeat** — `1 / 2 / 3` — prior occurrences of the same call set required before a tool loop flags
|
|
117
118
|
- **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)
|
|
119
|
+
- **Task streams** — on/off — recognize homogeneous batch work (same extension tool, distinct content) as N tasks of one type, not a loop
|
|
120
|
+
- **Stream min calls** — `2 / 3 / 4 / 5` — same-tool calls required inside the window before batch recognition kicks in (default 3)
|
|
121
|
+
- **Stream twin threshold** — `99% / 95% / 90%` — arguments this similar count as the *same task*; a twin invalidates the stream and re-enables normal detection (default 99%)
|
|
118
122
|
- **Detection window** — `5 / 10 / 15 / 20` — number of recent messages to analyze
|
|
119
123
|
- **Per-strategy toggles** — text / tool / thinking detection
|
|
120
124
|
- **Notifications** — show detection notifications
|
|
@@ -139,6 +143,11 @@ tool different tool → no match (exp no match) ✅
|
|
|
139
143
|
tool empty lists → no match (exp no match) ✅
|
|
140
144
|
result same outcome → match (exp match — PID noise ok) ✅
|
|
141
145
|
result diff outcome → no match (exp no match — error→success is progress) ✅
|
|
146
|
+
batch stream detected → punched_log×3 (exp punched_log×3) ✅ ← task streams: N tasks of one type
|
|
147
|
+
batch no detections → silent (exp silent — 98.9% args would match without gate) ✅
|
|
148
|
+
loop still detected → tool (exp tool — identical repeats are NOT a stream) ✅
|
|
149
|
+
loop survives batch gate→ tool (exp tool — bash repeats are real) ✅
|
|
150
|
+
stream needs ≥3 calls → no stream (exp no stream at 2 calls) ✅
|
|
142
151
|
```
|
|
143
152
|
|
|
144
153
|
## How It Works
|
|
@@ -153,6 +162,7 @@ After every assistant `message_end` event, antiloop extracts the new content (te
|
|
|
153
162
|
| 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 |
|
|
154
163
|
| Thinking | Internal reasoning/thinking blocks | Same as text |
|
|
155
164
|
| Structural | First 10 words of each message | Opening-phrase similarity ≥ 90% across ≥ 3 messages |
|
|
165
|
+
| Task stream | Same tool, many calls | When a tool appears ≥ `taskStreamMinCalls` times (default 3) in the window and *no two* calls are near-identical (`taskStreamTwinThreshold`, default 99%), the tool is an active batch: N different tasks of one type (e.g. `punched_log` appends, `plan_manager` task adds). Those calls are exempt from tool-loop detection, and text/thinking/structural patterns that only involve those batch messages are suppressed too. If even one call pair is a twin (the same task repeated), the tool is *not* a stream and detection proceeds normally |
|
|
156
166
|
|
|
157
167
|
Each detected pair becomes a `LoopDetection { type, similarity, messageIndices, description }` and the consecutive counter increases.
|
|
158
168
|
|
|
@@ -206,6 +216,40 @@ If the same command produced a *different* outcome, the pair is progress:
|
|
|
206
216
|
|
|
207
217
|
Results only veto; they never trigger on their own, and calls without a
|
|
208
218
|
captured result fall back to argument matching alone.
|
|
219
|
+
|
|
220
|
+
### Task streams: N tasks of one type ≠ a loop
|
|
221
|
+
|
|
222
|
+
Extensions push homogeneous work into the model's hands. `punched` appends
|
|
223
|
+
lines to pi.md one at a time; `plan` adds tasks one at a time; `obsidian_*`
|
|
224
|
+
writes/edits notes file by file. Each call is a *different* task — "add line 1",
|
|
225
|
+
"add line 2", "add line 3" — of the *same type*, and the narration around it
|
|
226
|
+
sounds similar ("now appending the next entry…"). That is not a reasoning
|
|
227
|
+
loop; flagging it would interrupt legitimate multi-step work.
|
|
228
|
+
|
|
229
|
+
Antiloop recognizes this as a **task stream**: if the same tool name appears at
|
|
230
|
+
least `taskStreamMinCalls` times (default 3) inside the detection window and
|
|
231
|
+
no two calls are "twins" (arguments ≥ `taskStreamTwinThreshold` similar,
|
|
232
|
+
default 99% — any real content difference counts as a distinct task), the
|
|
233
|
+
tool is treated as an active batch:
|
|
234
|
+
|
|
235
|
+
- tool-loop detection skips messages whose calls are all stream tools;
|
|
236
|
+
- text / thinking / structural detections that only involve those batch
|
|
237
|
+
messages are suppressed (identical narration while adding N entries is
|
|
238
|
+
expected, not looping);
|
|
239
|
+
- the footer shows it: `🔄 antiloop(on) · batch: punched_log×4`.
|
|
240
|
+
|
|
241
|
+
Crucially, the exemption requires **no twins**: the moment the same call
|
|
242
|
+
repeats verbatim (or near-verbatim), the stream is invalid and normal loop
|
|
243
|
+
detection takes over — so a model stuck re-logging the *same* line still gets
|
|
244
|
+
caught. The check is name-agnostic: any extension tool used as a batch is
|
|
245
|
+
covered, no allow-list needed. If a genuine loop coexists with a batch (e.g.
|
|
246
|
+
the same `bash` command re-run while appending different notes), the loop is
|
|
247
|
+
still flagged.
|
|
248
|
+
|
|
249
|
+
Tunables: `detectTaskStreams` (master switch), `taskStreamMinCalls` (batch
|
|
250
|
+
size needed before recognition), `taskStreamTwinThreshold` (how similar args
|
|
251
|
+
must be to count as *the same task* — lower it to treat near-duplicate
|
|
252
|
+
entries as loops again).
|
|
209
253
|
```
|
|
210
254
|
|
|
211
255
|
### Sliding window
|
|
@@ -226,6 +270,9 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
|
|
|
226
270
|
"toolSimilarityThreshold": 0.95,
|
|
227
271
|
"minToolRepeatCount": 2,
|
|
228
272
|
"resultSimilarityThreshold": 0.8,
|
|
273
|
+
"detectTaskStreams": true,
|
|
274
|
+
"taskStreamMinCalls": 3,
|
|
275
|
+
"taskStreamTwinThreshold": 0.99,
|
|
229
276
|
"detectToolLoops": true,
|
|
230
277
|
"detectThinkingLoops": true,
|
|
231
278
|
"detectTextLoops": true,
|
|
@@ -247,6 +294,9 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
|
|
|
247
294
|
| `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
295
|
| `minToolRepeatCount` | `2` | Prior occurrences of a near-identical call set required before a tool loop is flagged (2 = same call seen 3×) |
|
|
249
296
|
| `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 |
|
|
297
|
+
| `detectTaskStreams` | `true` | Recognize homogeneous batch work (same tool called with distinct content — e.g. punched/plan/obsidian extensions) and stay silent; see [task streams](#task-streams-n-tasks-of-one-type--a-loop) |
|
|
298
|
+
| `taskStreamMinCalls` | `3` | Same-tool calls required inside the window before a task stream is recognized |
|
|
299
|
+
| `taskStreamTwinThreshold` | `0.99` | Arguments this similar (or identical) count as *the same task* — a twin invalidates the stream and re-enables normal loop detection |
|
|
250
300
|
| `detectTextLoops` | `true` | Detect full-text repetition |
|
|
251
301
|
| `detectToolLoops` | `true` | Detect tool-call sequence + argument repetition |
|
|
252
302
|
| `detectThinkingLoops` | `true` | Detect repeated thinking/reasoning content |
|
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 → 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.",
|
|
3
|
+
"version": "1.3.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. Task-stream recognition: when an extension (punched, plan, …) makes the model call the SAME tool many times with DIFFERENT content — N distinct tasks of one type, e.g. appending lines or adding plan tasks — antiloop stays silent.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"antiloop",
|
package/src/commands.ts
CHANGED
|
@@ -59,10 +59,14 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
|
|
|
59
59
|
` similarity: ${(rt.config.similarityThreshold * 100).toFixed(0)}% window: ${rt.config.detectionWindow}`,
|
|
60
60
|
` tool sim: ${(rt.config.toolSimilarityThreshold * 100).toFixed(0)}% tool repeat: ${rt.config.minToolRepeatCount}+ prior`,
|
|
61
61
|
` result sim: ${(rt.config.resultSimilarityThreshold * 100).toFixed(0)}% (same cmd + diff outcome = no loop)`,
|
|
62
|
+
` task streams: ${yn(rt.config.detectTaskStreams)} (min ${rt.config.taskStreamMinCalls} calls, twins ≥ ${(rt.config.taskStreamTwinThreshold * 100).toFixed(0)}%)`,
|
|
62
63
|
"",
|
|
63
64
|
`detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
|
|
64
65
|
`footer: interactive ${yn(rt.config.interactiveFooter)} · toggle: ${rt.config.toggleShortcut}`,
|
|
65
66
|
];
|
|
67
|
+
if (rt.state.activeTaskStreams.length) {
|
|
68
|
+
lines.push("", `active batch: ${rt.state.activeTaskStreams.map((x) => `${x.tool}×${x.count}`).join(", ")}`);
|
|
69
|
+
}
|
|
66
70
|
if (recent.length) {
|
|
67
71
|
lines.push("", "recent:");
|
|
68
72
|
for (const d of recent) lines.push(` [${d.type}] ${d.description} · ${formatDuration(Date.now() - d.timestamp)} ago`);
|
|
@@ -81,6 +85,9 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
81
85
|
{ 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
86
|
{ value: "toolRepeat" as const, label: `tool repeat: ${c.minToolRepeatCount}+ prior`, description: "recurrences before a tool loop flags" },
|
|
83
87
|
{ value: "resultSim" as const, label: `result similarity: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "same cmd + different outcome vetoes the loop" },
|
|
88
|
+
{ value: "streams" as const, label: `task streams: ${yn(c.detectTaskStreams)}`, description: "batch work via extensions (punched_log / plan_manager / …) is not a loop" },
|
|
89
|
+
{ value: "streamMin" as const, label: `stream min calls: ${c.taskStreamMinCalls}`, description: "same tool calls required before batch recognition kicks in" },
|
|
90
|
+
{ value: "streamTwin" as const, label: `stream twin threshold: ${(c.taskStreamTwinThreshold * 100).toFixed(0)}%`, description: "args this similar = same task repeated → not a stream" },
|
|
84
91
|
{ value: "window" as const, label: `window: ${c.detectionWindow} msgs` },
|
|
85
92
|
{ value: "text" as const, label: `text detect: ${yn(c.detectTextLoops)}` },
|
|
86
93
|
{ value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
|
|
@@ -169,6 +176,28 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
|
|
|
169
176
|
if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`result similarity: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
170
177
|
break;
|
|
171
178
|
}
|
|
179
|
+
case "streams":
|
|
180
|
+
c.detectTaskStreams = !c.detectTaskStreams; saveConfig(c);
|
|
181
|
+
ctx.ui.notify(`task streams: ${yn(c.detectTaskStreams)}`, "info"); break;
|
|
182
|
+
case "streamMin": {
|
|
183
|
+
const v = await selectFrom(ctx, "stream min calls", [
|
|
184
|
+
{ value: 2, label: "2 (sensitive)" },
|
|
185
|
+
{ value: 3, label: "3 (default)" },
|
|
186
|
+
{ value: 4, label: "4" },
|
|
187
|
+
{ value: 5, label: "5 (conservative)" },
|
|
188
|
+
]);
|
|
189
|
+
if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`stream min calls: ${v}`, "info"); }
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case "streamTwin": {
|
|
193
|
+
const v = await selectFrom(ctx, "stream twin threshold (args)", [
|
|
194
|
+
{ value: 0.99, label: "99% (default — any real difference = distinct task)" },
|
|
195
|
+
{ value: 0.95, label: "95% (near-identical args count as same task)" },
|
|
196
|
+
{ value: 0.9, label: "90% (aggressive loop detection)" },
|
|
197
|
+
]);
|
|
198
|
+
if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`stream twin threshold: ${(v * 100).toFixed(0)}%`, "info"); }
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
172
201
|
case "window": {
|
|
173
202
|
const v = await selectFrom(ctx, "window", [
|
|
174
203
|
{ value: 5, label: "5" },
|
|
@@ -230,6 +259,7 @@ async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void>
|
|
|
230
259
|
export function resetState(state: AntiloopState): void {
|
|
231
260
|
state.recentMessages = [];
|
|
232
261
|
state.detections = [];
|
|
262
|
+
state.activeTaskStreams = [];
|
|
233
263
|
state.currentLevel = 0;
|
|
234
264
|
state.consecutiveDetections = 0;
|
|
235
265
|
state.inForcedBreak = false;
|
package/src/config.ts
CHANGED
|
@@ -16,6 +16,9 @@ export const DEFAULT_CONFIG: AntiloopConfig = {
|
|
|
16
16
|
toolSimilarityThreshold: 0.95,
|
|
17
17
|
minToolRepeatCount: 2,
|
|
18
18
|
resultSimilarityThreshold: 0.8,
|
|
19
|
+
detectTaskStreams: true,
|
|
20
|
+
taskStreamMinCalls: 3,
|
|
21
|
+
taskStreamTwinThreshold: 0.99,
|
|
19
22
|
detectToolLoops: true,
|
|
20
23
|
detectThinkingLoops: true,
|
|
21
24
|
detectTextLoops: true,
|
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, TrackedToolCall } from "./types.ts";
|
|
3
|
+
import type { AntiloopConfig, AntiloopState, LoopDetection, TrackedMessage, TrackedToolCall } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
const MIN_CONTENT_LENGTH = 50;
|
|
6
6
|
|
|
@@ -108,6 +108,71 @@ function toolCallsSimilar(
|
|
|
108
108
|
return true;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Task-stream recognition (batch work).
|
|
113
|
+
//
|
|
114
|
+
// Extensions like `punched` (append lines to pi.md) or `plan` (add tasks) make
|
|
115
|
+
// the model call the SAME tool many times in a row with DIFFERENT content:
|
|
116
|
+
// N distinct tasks of the same type — "adding line 1…N to a doc", "adding task
|
|
117
|
+
// 1…N to the plan". That is not a reasoning loop, and antiloop must not fire.
|
|
118
|
+
//
|
|
119
|
+
// A tool is an active task stream when, inside the detection window, it was
|
|
120
|
+
// called at least taskStreamMinCalls times and NO two calls are "twins"
|
|
121
|
+
// (args ≥ taskStreamTwinThreshold similar). Twins = the same task repeated;
|
|
122
|
+
// a stream with twins is indistinguishable from a loop and stays detected.
|
|
123
|
+
// The check is name-agnostic: any extension tool used as a batch is covered.
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/** Near-identity of two argument payloads — unlike similarity() it works on
|
|
127
|
+
* short args (JSON templates like {"action":"add",...} are < 50 chars, where
|
|
128
|
+
* similarity() bails to 0).
|
|
129
|
+
*/
|
|
130
|
+
function argsTwin(a: string, b: string, threshold: number): boolean {
|
|
131
|
+
const na = normalizeText(a);
|
|
132
|
+
const nb = normalizeText(b);
|
|
133
|
+
if (!na.length || !nb.length) return na === nb;
|
|
134
|
+
if (na === nb) return true;
|
|
135
|
+
if (na.length < 100 && nb.length < 100) {
|
|
136
|
+
const max = Math.max(na.length, nb.length);
|
|
137
|
+
return max > 0 && 1 - levenshtein(na, nb) / max >= threshold;
|
|
138
|
+
}
|
|
139
|
+
return similarity(a, b) >= threshold;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Map of tool name → call count for tools currently used as a homogeneous
|
|
144
|
+
* task stream in the given window. Empty map = no batch work recognized.
|
|
145
|
+
*/
|
|
146
|
+
export function detectTaskStreams(
|
|
147
|
+
win: TrackedMessage[],
|
|
148
|
+
config: AntiloopConfig,
|
|
149
|
+
): Map<string, number> {
|
|
150
|
+
const streams = new Map<string, number>();
|
|
151
|
+
if (!config.detectTaskStreams || win.length < config.taskStreamMinCalls) return streams;
|
|
152
|
+
|
|
153
|
+
const callsByName = new Map<string, string[]>();
|
|
154
|
+
for (const m of win) {
|
|
155
|
+
for (const tc of m.toolCalls ?? []) {
|
|
156
|
+
const arr = callsByName.get(tc.name) ?? [];
|
|
157
|
+
arr.push(tc.args);
|
|
158
|
+
callsByName.set(tc.name, arr);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const [name, argsList] of callsByName) {
|
|
163
|
+
if (argsList.length < config.taskStreamMinCalls) continue;
|
|
164
|
+
let twins = 0;
|
|
165
|
+
for (let i = 0; i < argsList.length; i++) {
|
|
166
|
+
for (let j = i + 1; j < argsList.length; j++) {
|
|
167
|
+
if (argsTwin(argsList[i], argsList[j], config.taskStreamTwinThreshold)) twins++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Every call is a distinct task → batch work, exempt from loop detection.
|
|
171
|
+
if (twins === 0) streams.set(name, argsList.length);
|
|
172
|
+
}
|
|
173
|
+
return streams;
|
|
174
|
+
}
|
|
175
|
+
|
|
111
176
|
export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopDetection[] {
|
|
112
177
|
const out: LoopDetection[] = [];
|
|
113
178
|
const msgs = state.recentMessages;
|
|
@@ -116,13 +181,28 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
|
|
|
116
181
|
const win = msgs.slice(start);
|
|
117
182
|
const now = Date.now();
|
|
118
183
|
|
|
184
|
+
// Task-stream gate: if the window is a homogeneous batch (same extension
|
|
185
|
+
// tool called with DISTINCT content ≥ taskStreamMinCalls times), that tool
|
|
186
|
+
// is exempt from tool-loop detection, and text/thinking/structural
|
|
187
|
+
// detections that only involve batch messages are suppressed too — the
|
|
188
|
+
// model is doing N different tasks of the same type, not looping.
|
|
189
|
+
const streams = detectTaskStreams(win, config);
|
|
190
|
+
const batchAt = new Set<number>();
|
|
191
|
+
win.forEach((m, idx) => {
|
|
192
|
+
const calls = m.toolCalls;
|
|
193
|
+
if (calls && calls.length && calls.every((c) => (streams.get(c.name) ?? 0) >= config.taskStreamMinCalls)) {
|
|
194
|
+
batchAt.add(start + idx);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
const allBatch = (idxs: number[]): boolean => idxs.every((i) => batchAt.has(i));
|
|
198
|
+
|
|
119
199
|
if (config.detectTextLoops) {
|
|
120
200
|
const last = win[win.length - 1];
|
|
121
201
|
if (last.content.length >= MIN_CONTENT_LENGTH) {
|
|
122
202
|
for (let i = 0; i < win.length - 1; i++) {
|
|
123
203
|
if (win[i].content.length < MIN_CONTENT_LENGTH) continue;
|
|
124
204
|
const s = similarity(last.content, win[i].content);
|
|
125
|
-
if (s >= config.similarityThreshold) {
|
|
205
|
+
if (s >= config.similarityThreshold && !allBatch([start + i, msgs.length - 1])) {
|
|
126
206
|
out.push({
|
|
127
207
|
type: "text",
|
|
128
208
|
similarity: s,
|
|
@@ -142,7 +222,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
|
|
|
142
222
|
for (let i = 0; i < opens.length - 1; i++) {
|
|
143
223
|
if (similarity(last, opens[i].o) > 0.9) n++;
|
|
144
224
|
}
|
|
145
|
-
if (n >= 2) {
|
|
225
|
+
if (n >= 2 && !allBatch([msgs.length - 1])) {
|
|
146
226
|
out.push({
|
|
147
227
|
type: "structural",
|
|
148
228
|
similarity: 0.9,
|
|
@@ -158,7 +238,9 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
|
|
|
158
238
|
if (config.detectToolLoops) {
|
|
159
239
|
const last = win[win.length - 1];
|
|
160
240
|
const lastCalls = last.toolCalls;
|
|
161
|
-
|
|
241
|
+
// A message whose calls are all task-stream tools is batch work — skip
|
|
242
|
+
// it entirely (the stream gate already proved the calls are distinct).
|
|
243
|
+
if (lastCalls && lastCalls.length && !batchAt.has(msgs.length - 1)) {
|
|
162
244
|
const matched: number[] = [];
|
|
163
245
|
for (let i = 0; i < win.length - 1; i++) {
|
|
164
246
|
const prev = win[i].toolCalls;
|
|
@@ -187,7 +269,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
|
|
|
187
269
|
for (let i = 0; i < win.length - 1; i++) {
|
|
188
270
|
if (win[i].thinking && win[i].thinking!.length > 50) {
|
|
189
271
|
const s = similarity(last.thinking, win[i].thinking!);
|
|
190
|
-
if (s >= config.similarityThreshold) {
|
|
272
|
+
if (s >= config.similarityThreshold && !allBatch([start + i, msgs.length - 1])) {
|
|
191
273
|
out.push({
|
|
192
274
|
type: "thinking",
|
|
193
275
|
similarity: s,
|
|
@@ -276,5 +358,67 @@ export function runSelfTest(): string[] {
|
|
|
276
358
|
out.push(`result same outcome → ${sameCmdSameOut ? "match" : "no match"} (exp match — PID noise ok) ${sameCmdSameOut ? "✅" : "❌"}`);
|
|
277
359
|
out.push(`result diff outcome → ${sameCmdDiffOut ? "match" : "no match"} (exp no match — error→success is progress) ${!sameCmdDiffOut ? "✅" : "❌"}`);
|
|
278
360
|
|
|
361
|
+
// --- task streams: extension batch work is NOT a loop ---
|
|
362
|
+
// punched_log / plan_manager / obsidian_* style tools: the model calls the
|
|
363
|
+
// SAME tool N times with DIFFERENT content ("add line 1…N", "add task 1…N").
|
|
364
|
+
// Near-identical template args (98.9% similar here) WOULD match the tool
|
|
365
|
+
// detector; the task-stream gate must suppress the whole window instead.
|
|
366
|
+
const mk = (content: string, toolCalls?: TrackedToolCall[]): TrackedMessage =>
|
|
367
|
+
({ content, toolCalls, timestamp: Date.now(), turnIndex: 0 });
|
|
368
|
+
const noteArgs = (x: string) =>
|
|
369
|
+
JSON.stringify({ type: "note", title: `task ${x}`, body: "append this line to the project memory document so context is preserved" });
|
|
370
|
+
const tcfg: AntiloopConfig = {
|
|
371
|
+
enabled: true, warningThreshold: 2, forceBreakThreshold: 3, abortThreshold: 0,
|
|
372
|
+
similarityThreshold: 0.75, toolSimilarityThreshold: 0.95, minToolRepeatCount: 2,
|
|
373
|
+
resultSimilarityThreshold: 0.8, detectToolLoops: true, detectThinkingLoops: true,
|
|
374
|
+
detectTextLoops: true, notifyOnDetection: true, maxHistoryEntries: 100,
|
|
375
|
+
detectionWindow: 10, interactiveFooter: true, toggleShortcut: "esc+a",
|
|
376
|
+
detectTaskStreams: true, taskStreamMinCalls: 3, taskStreamTwinThreshold: 0.99,
|
|
377
|
+
};
|
|
378
|
+
const asState = (recentMessages: TrackedMessage[]): AntiloopState =>
|
|
379
|
+
({ recentMessages, detections: [], activeTaskStreams: [], currentLevel: 0,
|
|
380
|
+
consecutiveDetections: 0, inForcedBreak: false, totalDetections: 0,
|
|
381
|
+
lastUserMessageTime: 0, lastDetectedTurnIndex: -1 });
|
|
382
|
+
const NARR = "Now I will append the next decision entry to the project memory document so we keep the context.";
|
|
383
|
+
|
|
384
|
+
// 1) punched_log batch: 3 DIFFERENT appends (args 98.9% similar, NOT twins)
|
|
385
|
+
// → stream recognized, tool + text + structural all suppressed.
|
|
386
|
+
const batchMsgs = [
|
|
387
|
+
mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
|
|
388
|
+
mk(NARR, [{ name: "punched_log", args: noteArgs("2") }]),
|
|
389
|
+
mk(NARR, [{ name: "punched_log", args: noteArgs("3") }]),
|
|
390
|
+
];
|
|
391
|
+
const batchWin = batchMsgs.slice(-tcfg.detectionWindow);
|
|
392
|
+
const batchStreams = detectTaskStreams(batchWin, tcfg);
|
|
393
|
+
const batchDet = detectLoops(asState(batchMsgs), tcfg);
|
|
394
|
+
out.push(`batch stream detected → ${batchStreams.get("punched_log") === 3 ? `punched_log×${batchStreams.get("punched_log")}` : "no"} (exp punched_log×3) ${batchStreams.get("punched_log") === 3 ? "✅" : "❌"}`);
|
|
395
|
+
out.push(`batch no detections → ${batchDet.length === 0 ? "silent" : `${batchDet.map((d) => d.type).join(",")}`} (exp silent — 98.9% args would match without gate) ${batchDet.length === 0 ? "✅" : "❌"}`);
|
|
396
|
+
|
|
397
|
+
// 2) genuine loop: SAME call repeated verbatim → twins → stream inactive,
|
|
398
|
+
// tool detection must still fire.
|
|
399
|
+
const loopMsgs = [
|
|
400
|
+
mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
|
|
401
|
+
mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
|
|
402
|
+
mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
|
|
403
|
+
];
|
|
404
|
+
const loopDet = detectLoops(asState(loopMsgs), tcfg);
|
|
405
|
+
out.push(`loop still detected → ${loopDet.some((d) => d.type === "tool") ? "tool" : "no"} (exp tool — identical repeats are NOT a stream) ${loopDet.some((d) => d.type === "tool") ? "✅" : "❌"}`);
|
|
406
|
+
|
|
407
|
+
// 3) coexistence: batch tool + a genuinely repeated bash command in the
|
|
408
|
+
// same messages → the bash loop must still be flagged.
|
|
409
|
+
const loopCmd = "cd /tmp && sleep 1 && echo retrying the same build step again and again forever";
|
|
410
|
+
const mixedMsgs = [
|
|
411
|
+
mk(NARR, [{ name: "punched_log", args: noteArgs("1") }, { name: "bash", args: loopCmd }]),
|
|
412
|
+
mk(NARR, [{ name: "punched_log", args: noteArgs("2") }, { name: "bash", args: loopCmd }]),
|
|
413
|
+
mk(NARR, [{ name: "punched_log", args: noteArgs("3") }, { name: "bash", args: loopCmd }]),
|
|
414
|
+
];
|
|
415
|
+
const mixedDet = detectLoops(asState(mixedMsgs), tcfg);
|
|
416
|
+
out.push(`loop survives batch gate→ ${mixedDet.some((d) => d.type === "tool") ? "tool" : "no"} (exp tool — bash repeats are real) ${mixedDet.some((d) => d.type === "tool") ? "✅" : "❌"}`);
|
|
417
|
+
|
|
418
|
+
// 4) below min calls: 2 distinct appends → no stream (could be coincidence).
|
|
419
|
+
const twoMsgs = [mk("a", [{ name: "punched_log", args: noteArgs("1") }]), mk("b", [{ name: "punched_log", args: noteArgs("2") }])];
|
|
420
|
+
const twoStreams = detectTaskStreams(twoMsgs, tcfg);
|
|
421
|
+
out.push(`stream needs ≥3 calls → ${twoStreams.size === 0 ? "no stream" : "stream"} (exp no stream at 2 calls) ${twoStreams.size === 0 ? "✅" : "❌"}`);
|
|
422
|
+
|
|
279
423
|
return out;
|
|
280
424
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ function newState(): AntiloopState {
|
|
|
16
16
|
return {
|
|
17
17
|
recentMessages: [],
|
|
18
18
|
detections: [],
|
|
19
|
+
activeTaskStreams: [],
|
|
19
20
|
currentLevel: 0,
|
|
20
21
|
consecutiveDetections: 0,
|
|
21
22
|
inForcedBreak: false,
|
|
@@ -42,11 +43,19 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
42
43
|
/** TUI handle for forcing footer re-renders (set by the footer factory). */
|
|
43
44
|
let activeTui: { requestRender(force?: boolean): void } | undefined;
|
|
44
45
|
|
|
45
|
-
/** Status text shown in the footer: "(emoji_antiloop)(on/off)" per spec
|
|
46
|
+
/** Status text shown in the footer: "(emoji_antiloop)(on/off)" per spec,
|
|
47
|
+
* plus active batch streams so a quiet antiloop is explainable. */
|
|
46
48
|
function antiloopStatusText(): string {
|
|
47
49
|
if (!config.enabled) return "🔄 antiloop(off)";
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
const base =
|
|
51
|
+
state.currentLevel === 0
|
|
52
|
+
? "🔄 antiloop(on)"
|
|
53
|
+
: `${ICONS[state.currentLevel]} antiloop(on)×${state.consecutiveDetections}`;
|
|
54
|
+
if (state.activeTaskStreams.length) {
|
|
55
|
+
const s = state.activeTaskStreams.map((x) => `${x.tool}×${x.count}`).join(", ");
|
|
56
|
+
return `${base} · batch: ${s}`;
|
|
57
|
+
}
|
|
58
|
+
return base;
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
function updateStatus(ctx: ExtensionContext): void {
|
|
@@ -266,7 +275,7 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
266
275
|
|
|
267
276
|
pi.on("turn_end", async (event, ctx) => {
|
|
268
277
|
if (!config.enabled) return;
|
|
269
|
-
const { detectLoops, interventionMessage, resultFingerprint } = await import("./detect.ts");
|
|
278
|
+
const { detectLoops, detectTaskStreams, interventionMessage, resultFingerprint } = await import("./detect.ts");
|
|
270
279
|
|
|
271
280
|
const last = state.recentMessages[state.recentMessages.length - 1];
|
|
272
281
|
// A turn whose assistant message wasn't tracked (short text, no tools)
|
|
@@ -292,6 +301,14 @@ export default function antiloopExtension(pi: ExtensionAPI) {
|
|
|
292
301
|
}
|
|
293
302
|
}
|
|
294
303
|
|
|
304
|
+
// Recomputed batch streams (punched_log / plan_manager / … used as a
|
|
305
|
+
// homogeneous task stream) — shown in the footer so the user sees why
|
|
306
|
+
// antiloop stays quiet while the model performs N tasks of one type.
|
|
307
|
+
const winStart = Math.max(0, state.recentMessages.length - config.detectionWindow);
|
|
308
|
+
state.activeTaskStreams = Array.from(
|
|
309
|
+
detectTaskStreams(state.recentMessages.slice(winStart), config).entries(),
|
|
310
|
+
).map(([tool, count]) => ({ tool, count }));
|
|
311
|
+
|
|
295
312
|
const detections = detectLoops(state, config);
|
|
296
313
|
processDetections(detections, interventionMessage);
|
|
297
314
|
|
package/src/types.ts
CHANGED
|
@@ -25,6 +25,19 @@ export interface AntiloopConfig {
|
|
|
25
25
|
* loop. Same command + different outcome = progress, not a loop.
|
|
26
26
|
*/
|
|
27
27
|
resultSimilarityThreshold: number;
|
|
28
|
+
/**
|
|
29
|
+
* Task-stream recognition (batch work). Extensions like punched (append
|
|
30
|
+
* lines to pi.md) or plan (add tasks) make the model call the SAME tool
|
|
31
|
+
* many times with DIFFERENT content — N distinct tasks of the same type,
|
|
32
|
+
* not a loop. When the same tool appears at least taskStreamMinCalls times
|
|
33
|
+
* in the window and no two calls are "twins" (args ≥ taskStreamTwinThreshold
|
|
34
|
+
* similar), antiloop treats that tool as an active task stream and does not
|
|
35
|
+
* flag repetitions of it, nor text/thinking/structural patterns that only
|
|
36
|
+
* involve those batch messages.
|
|
37
|
+
*/
|
|
38
|
+
detectTaskStreams: boolean;
|
|
39
|
+
taskStreamMinCalls: number;
|
|
40
|
+
taskStreamTwinThreshold: number;
|
|
28
41
|
detectToolLoops: boolean;
|
|
29
42
|
detectThinkingLoops: boolean;
|
|
30
43
|
detectTextLoops: boolean;
|
|
@@ -75,9 +88,18 @@ export interface TrackedMessage {
|
|
|
75
88
|
turnIndex: number;
|
|
76
89
|
}
|
|
77
90
|
|
|
91
|
+
export interface TaskStreamInfo {
|
|
92
|
+
tool: string;
|
|
93
|
+
count: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
78
96
|
export interface AntiloopState {
|
|
79
97
|
recentMessages: TrackedMessage[];
|
|
80
98
|
detections: LoopDetection[];
|
|
99
|
+
/** Tools currently being used as a homogeneous batch (N tasks, same type).
|
|
100
|
+
* Recomputed at turn_end; shown in the footer/status so a quiet antiloop is
|
|
101
|
+
* explainable. */
|
|
102
|
+
activeTaskStreams: TaskStreamInfo[];
|
|
81
103
|
currentLevel: 0 | 1 | 2 | 3;
|
|
82
104
|
consecutiveDetections: number;
|
|
83
105
|
inForcedBreak: boolean;
|