pi-antiloop 1.0.0 → 1.0.1
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 +3 -4
- package/package.json +5 -6
- package/src/commands.ts +216 -0
- package/src/config.ts +46 -0
- package/src/detect.ts +167 -0
- package/src/index.ts +162 -964
- package/src/types.ts +51 -0
- package/src/ui.ts +23 -0
package/src/index.ts
CHANGED
|
@@ -1,975 +1,173 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* antiloop
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* explicit instructions to take a different approach.
|
|
6
|
-
*
|
|
7
|
-
* Detection strategies:
|
|
8
|
-
* 1. Text repetition — similar assistant messages across turns
|
|
9
|
-
* 2. Tool call loops — same tool called with same/similar arguments
|
|
10
|
-
* 3. Thinking loops — similar thinking/reasoning content
|
|
11
|
-
* 4. Structural patterns — similar opening phrases, sentence structures
|
|
12
|
-
*
|
|
13
|
-
* Intervention levels:
|
|
14
|
-
* 1. Warning — inject a gentle reminder to vary approach
|
|
15
|
-
* 2. Force break — inject explicit instruction to stop looping
|
|
16
|
-
* 3. Abort — stop the agent entirely (configurable)
|
|
17
|
-
*
|
|
18
|
-
* Commands:
|
|
19
|
-
* /antiloop - Toggle on/off
|
|
20
|
-
* /antiloop enable - Enable antiloop
|
|
21
|
-
* /antiloop disable - Disable antiloop
|
|
22
|
-
* /antiloop status - Show current state and detection stats
|
|
23
|
-
* /antiloop config - Open interactive config menu
|
|
24
|
-
* /antiloop log - Show loop detection history
|
|
25
|
-
* /antiloop reset - Reset all counters and history
|
|
26
|
-
* /antiloop test - Run a self-test with sample patterns
|
|
2
|
+
* antiloop — detect reasoning loops and intervene.
|
|
3
|
+
* Hooks: message_end, input, before_agent_start, context, turn_end, session_start.
|
|
4
|
+
* Commands: /antiloop [enable|disable|status|config|log|reset|test]
|
|
27
5
|
*/
|
|
28
6
|
|
|
29
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
30
|
-
import { join } from "node:path";
|
|
31
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
32
|
-
import {
|
|
8
|
+
import { loadConfig } from "./config.ts";
|
|
9
|
+
import type { AntiloopState, LoopDetection, Runtime } from "./types.ts";
|
|
33
10
|
|
|
34
|
-
|
|
11
|
+
const ICONS = ["", "⚠️", "🛑", "🚨"] as const;
|
|
35
12
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** Enable detection of tool call loops */
|
|
47
|
-
detectToolLoops: boolean;
|
|
48
|
-
/** Enable detection of thinking/reasoning loops */
|
|
49
|
-
detectThinkingLoops: boolean;
|
|
50
|
-
/** Enable detection of text pattern loops */
|
|
51
|
-
detectTextLoops: boolean;
|
|
52
|
-
/** Show notifications when loops are detected */
|
|
53
|
-
notifyOnDetection: boolean;
|
|
54
|
-
/** Maximum history entries to keep */
|
|
55
|
-
maxHistoryEntries: number;
|
|
56
|
-
/** Window size for pattern detection (number of recent messages to analyze) */
|
|
57
|
-
detectionWindow: number;
|
|
13
|
+
function newState(): AntiloopState {
|
|
14
|
+
return {
|
|
15
|
+
recentMessages: [],
|
|
16
|
+
detections: [],
|
|
17
|
+
currentLevel: 0,
|
|
18
|
+
consecutiveDetections: 0,
|
|
19
|
+
inForcedBreak: false,
|
|
20
|
+
totalDetections: 0,
|
|
21
|
+
lastUserMessageTime: 0,
|
|
22
|
+
};
|
|
58
23
|
}
|
|
59
24
|
|
|
60
|
-
interface LoopDetection {
|
|
61
|
-
type: "text" | "tool" | "thinking" | "structural";
|
|
62
|
-
similarity: number;
|
|
63
|
-
messageIndices: number[];
|
|
64
|
-
description: string;
|
|
65
|
-
timestamp: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface AntiloopState {
|
|
69
|
-
/** Recent assistant message contents for comparison */
|
|
70
|
-
recentMessages: Array<{
|
|
71
|
-
content: string;
|
|
72
|
-
thinking?: string;
|
|
73
|
-
toolCalls?: Array<{ name: string; args: string }>;
|
|
74
|
-
timestamp: number;
|
|
75
|
-
turnIndex: number;
|
|
76
|
-
}>;
|
|
77
|
-
/** Detection history */
|
|
78
|
-
detections: LoopDetection[];
|
|
79
|
-
/** Current intervention level (0=none, 1=warning, 2=force, 3=abort) */
|
|
80
|
-
currentLevel: number;
|
|
81
|
-
/** Number of consecutive loop detections */
|
|
82
|
-
consecutiveDetections: number;
|
|
83
|
-
/** Whether we're currently in a forced break */
|
|
84
|
-
inForcedBreak: boolean;
|
|
85
|
-
/** Total detections this session */
|
|
86
|
-
totalDetections: number;
|
|
87
|
-
/** Last user message timestamp (resets loop tracking) */
|
|
88
|
-
lastUserMessageTime: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
const CONFIG_FILE = "antiloop.json";
|
|
94
|
-
|
|
95
|
-
const DEFAULT_CONFIG: AntiloopConfig = {
|
|
96
|
-
enabled: true,
|
|
97
|
-
warningThreshold: 2,
|
|
98
|
-
forceBreakThreshold: 3,
|
|
99
|
-
abortThreshold: 0, // disabled by default
|
|
100
|
-
similarityThreshold: 0.75,
|
|
101
|
-
detectToolLoops: true,
|
|
102
|
-
detectThinkingLoops: true,
|
|
103
|
-
detectTextLoops: true,
|
|
104
|
-
notifyOnDetection: true,
|
|
105
|
-
maxHistoryEntries: 100,
|
|
106
|
-
detectionWindow: 10,
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
110
|
-
|
|
111
|
-
function getConfigPath(): string {
|
|
112
|
-
return join(getAgentDir(), CONFIG_FILE);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function loadConfig(): AntiloopConfig {
|
|
116
|
-
const configPath = getConfigPath();
|
|
117
|
-
if (existsSync(configPath)) {
|
|
118
|
-
try {
|
|
119
|
-
return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(configPath, "utf-8")) };
|
|
120
|
-
} catch (err) {
|
|
121
|
-
console.error(`[antiloop] Config load error: ${err}`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return { ...DEFAULT_CONFIG };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function saveConfig(config: AntiloopConfig): void {
|
|
128
|
-
try {
|
|
129
|
-
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2), "utf-8");
|
|
130
|
-
} catch (err) {
|
|
131
|
-
console.error(`[antiloop] Config save error: ${err}`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function formatDuration(ms: number): string {
|
|
136
|
-
if (ms < 1000) return `${ms}ms`;
|
|
137
|
-
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
138
|
-
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
|
139
|
-
return `${Math.round(ms / 3_600_000)}h`;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Select helper: presents labeled strings to ctx.ui.select(), returns the
|
|
144
|
-
* matched value from the items array. Returns undefined if cancelled.
|
|
145
|
-
*/
|
|
146
|
-
function selectFrom<T>(
|
|
147
|
-
ctx: ExtensionContext,
|
|
148
|
-
title: string,
|
|
149
|
-
items: Array<{ value: T; label: string; description?: string }>
|
|
150
|
-
): Promise<T | undefined> {
|
|
151
|
-
const strings = items.map((it) =>
|
|
152
|
-
it.description ? `${it.label} — ${it.description}` : it.label
|
|
153
|
-
);
|
|
154
|
-
return ctx.ui.select(title, strings).then((picked) => {
|
|
155
|
-
if (picked === undefined) return undefined;
|
|
156
|
-
const idx = strings.indexOf(picked);
|
|
157
|
-
return idx >= 0 ? items[idx].value : undefined;
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ─── Similarity Detection Engine ────────────────────────────────────────────
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Normalize text for comparison: lowercase, collapse whitespace, remove punctuation
|
|
165
|
-
*/
|
|
166
|
-
function normalizeText(text: string): string {
|
|
167
|
-
return text
|
|
168
|
-
.toLowerCase()
|
|
169
|
-
.replace(/\s+/g, " ")
|
|
170
|
-
.replace(/[^\w\s]/g, "")
|
|
171
|
-
.trim();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Calculate Levenshtein distance between two strings
|
|
176
|
-
*/
|
|
177
|
-
function levenshteinDistance(a: string, b: string): number {
|
|
178
|
-
if (a.length === 0) return b.length;
|
|
179
|
-
if (b.length === 0) return a.length;
|
|
180
|
-
|
|
181
|
-
const matrix: number[][] = [];
|
|
182
|
-
|
|
183
|
-
for (let i = 0; i <= b.length; i++) {
|
|
184
|
-
matrix[i] = [i];
|
|
185
|
-
}
|
|
186
|
-
for (let j = 0; j <= a.length; j++) {
|
|
187
|
-
matrix[0][j] = j;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
for (let i = 1; i <= b.length; i++) {
|
|
191
|
-
for (let j = 1; j <= a.length; j++) {
|
|
192
|
-
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
193
|
-
matrix[i][j] = matrix[i - 1][j - 1];
|
|
194
|
-
} else {
|
|
195
|
-
matrix[i][j] = Math.min(
|
|
196
|
-
matrix[i - 1][j - 1] + 1, // substitution
|
|
197
|
-
matrix[i][j - 1] + 1, // insertion
|
|
198
|
-
matrix[i - 1][j] + 1 // deletion
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return matrix[b.length][a.length];
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Calculate similarity score between two strings (0.0 to 1.0)
|
|
209
|
-
* Uses a combination of:
|
|
210
|
-
* - Levenshtein distance (for short texts)
|
|
211
|
-
* - N-gram Jaccard similarity (for longer texts)
|
|
212
|
-
* - Opening phrase matching (for structural detection)
|
|
213
|
-
*/
|
|
214
|
-
/** Minimum content length to be considered for comparison */
|
|
215
|
-
const MIN_CONTENT_LENGTH = 50;
|
|
216
|
-
|
|
217
|
-
function calculateSimilarity(a: string, b: string): number {
|
|
218
|
-
// Reject empty or very short strings
|
|
219
|
-
if (a.length < MIN_CONTENT_LENGTH || b.length < MIN_CONTENT_LENGTH) return 0.0;
|
|
220
|
-
if (a === b) return 1.0;
|
|
221
|
-
|
|
222
|
-
const normA = normalizeText(a);
|
|
223
|
-
const normB = normalizeText(b);
|
|
224
|
-
|
|
225
|
-
// After normalization, check again
|
|
226
|
-
if (normA.length < 20 || normB.length < 20) return 0.0;
|
|
227
|
-
if (normA === normB) return 1.0;
|
|
228
|
-
|
|
229
|
-
// For very short texts, use Levenshtein
|
|
230
|
-
if (normA.length < 100 && normB.length < 100) {
|
|
231
|
-
const maxLen = Math.max(normA.length, normB.length);
|
|
232
|
-
const distance = levenshteinDistance(normA, normB);
|
|
233
|
-
return 1.0 - (distance / maxLen);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// For longer texts, use n-gram Jaccard similarity
|
|
237
|
-
const ngramsA = getNgrams(normA, 3);
|
|
238
|
-
const ngramsB = getNgrams(normB, 3);
|
|
239
|
-
|
|
240
|
-
const intersection = new Set([...ngramsA].filter(x => ngramsB.has(x)));
|
|
241
|
-
const union = new Set([...ngramsA, ...ngramsB]);
|
|
242
|
-
|
|
243
|
-
return intersection.size / union.size;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Extract character n-grams from text
|
|
248
|
-
*/
|
|
249
|
-
function getNgrams(text: string, n: number): Set<string> {
|
|
250
|
-
const ngrams = new Set<string>();
|
|
251
|
-
for (let i = 0; i <= text.length - n; i++) {
|
|
252
|
-
ngrams.add(text.substring(i, i + n));
|
|
253
|
-
}
|
|
254
|
-
return ngrams;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Extract opening phrase (first N words) for structural comparison
|
|
259
|
-
*/
|
|
260
|
-
function getOpeningPhrase(text: string, wordCount: number = 10): string {
|
|
261
|
-
const words = text.split(/\s+/).slice(0, wordCount).join(" ");
|
|
262
|
-
return normalizeText(words);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Detect if two tool call sequences are similar
|
|
267
|
-
*/
|
|
268
|
-
function areToolCallsSimilar(
|
|
269
|
-
calls1: Array<{ name: string; args: string }>,
|
|
270
|
-
calls2: Array<{ name: string; args: string }>
|
|
271
|
-
): boolean {
|
|
272
|
-
if (calls1.length !== calls2.length) return false;
|
|
273
|
-
if (calls1.length === 0) return true;
|
|
274
|
-
|
|
275
|
-
// Check if tools are called in the same order with similar args
|
|
276
|
-
for (let i = 0; i < calls1.length; i++) {
|
|
277
|
-
if (calls1[i].name !== calls2[i].name) return false;
|
|
278
|
-
const argSimilarity = calculateSimilarity(calls1[i].args, calls2[i].args);
|
|
279
|
-
if (argSimilarity < 0.8) return false;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
return true;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Main loop detection function
|
|
287
|
-
* Analyzes recent messages and returns detected loops
|
|
288
|
-
*/
|
|
289
|
-
function detectLoops(
|
|
290
|
-
state: AntiloopState,
|
|
291
|
-
config: AntiloopConfig
|
|
292
|
-
): LoopDetection[] {
|
|
293
|
-
const detections: LoopDetection[] = [];
|
|
294
|
-
const messages = state.recentMessages;
|
|
295
|
-
|
|
296
|
-
if (messages.length < 2) return detections;
|
|
297
|
-
|
|
298
|
-
// Only analyze within the detection window
|
|
299
|
-
const windowStart = Math.max(0, messages.length - config.detectionWindow);
|
|
300
|
-
const window = messages.slice(windowStart);
|
|
301
|
-
|
|
302
|
-
// Strategy 1: Text repetition detection
|
|
303
|
-
if (config.detectTextLoops) {
|
|
304
|
-
const lastMsg = window[window.length - 1];
|
|
305
|
-
|
|
306
|
-
// Skip if current message is too short
|
|
307
|
-
if (lastMsg.content.length >= MIN_CONTENT_LENGTH) {
|
|
308
|
-
for (let i = 0; i < window.length - 1; i++) {
|
|
309
|
-
// Skip comparison with messages that are too short
|
|
310
|
-
if (window[i].content.length < MIN_CONTENT_LENGTH) continue;
|
|
311
|
-
|
|
312
|
-
const similarity = calculateSimilarity(lastMsg.content, window[i].content);
|
|
313
|
-
|
|
314
|
-
if (similarity >= config.similarityThreshold) {
|
|
315
|
-
detections.push({
|
|
316
|
-
type: "text",
|
|
317
|
-
similarity,
|
|
318
|
-
messageIndices: [windowStart + i, messages.length - 1],
|
|
319
|
-
description: `Text similarity ${(similarity * 100).toFixed(0)}% with message ${windowStart + i + 1}`,
|
|
320
|
-
timestamp: Date.now(),
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Structural pattern detection (opening phrases)
|
|
327
|
-
if (window.length >= 3) {
|
|
328
|
-
// Only check messages with sufficient content
|
|
329
|
-
const validOpenings = window
|
|
330
|
-
.map((m, idx) => ({ opening: getOpeningPhrase(m.content), idx }))
|
|
331
|
-
.filter(o => o.opening.length >= 20);
|
|
332
|
-
|
|
333
|
-
if (validOpenings.length >= 3) {
|
|
334
|
-
const lastOpening = validOpenings[validOpenings.length - 1].opening;
|
|
335
|
-
let matchCount = 0;
|
|
336
|
-
for (let i = 0; i < validOpenings.length - 1; i++) {
|
|
337
|
-
if (calculateSimilarity(lastOpening, validOpenings[i].opening) > 0.8) {
|
|
338
|
-
matchCount++;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
if (matchCount >= 2) {
|
|
342
|
-
detections.push({
|
|
343
|
-
type: "structural",
|
|
344
|
-
similarity: 0.9,
|
|
345
|
-
messageIndices: [messages.length - 1],
|
|
346
|
-
description: `Repeated opening pattern detected (${matchCount + 1} similar starts)`,
|
|
347
|
-
timestamp: Date.now(),
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Strategy 2: Tool call loop detection
|
|
355
|
-
if (config.detectToolLoops) {
|
|
356
|
-
const lastMsg = window[window.length - 1];
|
|
357
|
-
if (lastMsg.toolCalls && lastMsg.toolCalls.length > 0) {
|
|
358
|
-
for (let i = 0; i < window.length - 1; i++) {
|
|
359
|
-
if (window[i].toolCalls && areToolCallsSimilar(lastMsg.toolCalls, window[i].toolCalls)) {
|
|
360
|
-
detections.push({
|
|
361
|
-
type: "tool",
|
|
362
|
-
similarity: 1.0,
|
|
363
|
-
messageIndices: [windowStart + i, messages.length - 1],
|
|
364
|
-
description: `Same tool calls repeated: ${lastMsg.toolCalls.map(t => t.name).join(", ")}`,
|
|
365
|
-
timestamp: Date.now(),
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Strategy 3: Thinking loop detection
|
|
373
|
-
if (config.detectThinkingLoops) {
|
|
374
|
-
const lastMsg = window[window.length - 1];
|
|
375
|
-
if (lastMsg.thinking && lastMsg.thinking.length > 50) {
|
|
376
|
-
for (let i = 0; i < window.length - 1; i++) {
|
|
377
|
-
if (window[i].thinking && window[i].thinking.length > 50) {
|
|
378
|
-
const similarity = calculateSimilarity(lastMsg.thinking, window[i].thinking);
|
|
379
|
-
if (similarity >= config.similarityThreshold) {
|
|
380
|
-
detections.push({
|
|
381
|
-
type: "thinking",
|
|
382
|
-
similarity,
|
|
383
|
-
messageIndices: [windowStart + i, messages.length - 1],
|
|
384
|
-
description: `Thinking content similarity ${(similarity * 100).toFixed(0)}%`,
|
|
385
|
-
timestamp: Date.now(),
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
return detections;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Generate intervention message based on detection level
|
|
398
|
-
*/
|
|
399
|
-
function getInterventionMessage(
|
|
400
|
-
level: number,
|
|
401
|
-
detections: LoopDetection[],
|
|
402
|
-
config: AntiloopConfig
|
|
403
|
-
): string {
|
|
404
|
-
const detectionSummary = detections
|
|
405
|
-
.map(d => `- ${d.description}`)
|
|
406
|
-
.join("\n");
|
|
407
|
-
|
|
408
|
-
switch (level) {
|
|
409
|
-
case 1: // Warning
|
|
410
|
-
return [
|
|
411
|
-
"[antiloop] ⚠️ LOOP WARNING: I notice I may be repeating myself.",
|
|
412
|
-
"Detected patterns:",
|
|
413
|
-
detectionSummary,
|
|
414
|
-
"",
|
|
415
|
-
"Please vary my approach and try a different strategy.",
|
|
416
|
-
"Consider: alternative algorithms, different file locations, new angles of analysis.",
|
|
417
|
-
].join("\n");
|
|
418
|
-
|
|
419
|
-
case 2: // Force break
|
|
420
|
-
return [
|
|
421
|
-
"[antiloop] 🛑 LOOP DETECTED: I am stuck in a reasoning loop.",
|
|
422
|
-
"Detected patterns:",
|
|
423
|
-
detectionSummary,
|
|
424
|
-
"",
|
|
425
|
-
"MANDATORY: I must immediately stop my current approach and try something completely different.",
|
|
426
|
-
"Required actions:",
|
|
427
|
-
"1. Stop the current line of reasoning entirely",
|
|
428
|
-
"2. Consider what assumptions I've been making",
|
|
429
|
-
"3. Try an alternative approach or ask the user for guidance",
|
|
430
|
-
"4. Do NOT repeat any previous tool calls or reasoning patterns",
|
|
431
|
-
].join("\n");
|
|
432
|
-
|
|
433
|
-
case 3: // Abort
|
|
434
|
-
return [
|
|
435
|
-
"[antiloop] 🚨 LOOP ABORT: Persistent loop detected despite interventions.",
|
|
436
|
-
"Detected patterns:",
|
|
437
|
-
detectionSummary,
|
|
438
|
-
"",
|
|
439
|
-
"The agent is unable to break out of this loop automatically.",
|
|
440
|
-
"User intervention required. Please provide new instructions or context.",
|
|
441
|
-
].join("\n");
|
|
442
|
-
|
|
443
|
-
default:
|
|
444
|
-
return "";
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// ─── Extension ──────────────────────────────────────────────────────────────
|
|
449
|
-
|
|
450
25
|
export default function antiloopExtension(pi: ExtensionAPI) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
// ─── Hook 3: Inject intervention before agent starts ────────────────────
|
|
599
|
-
|
|
600
|
-
pi.on("before_agent_start", async (event, ctx) => {
|
|
601
|
-
if (!config.enabled) return;
|
|
602
|
-
if (!pendingIntervention) return;
|
|
603
|
-
|
|
604
|
-
const msg = pendingIntervention;
|
|
605
|
-
pendingIntervention = null;
|
|
606
|
-
|
|
607
|
-
return {
|
|
608
|
-
message: {
|
|
609
|
-
customType: "antiloop-intervention",
|
|
610
|
-
content: msg,
|
|
611
|
-
display: true,
|
|
612
|
-
},
|
|
613
|
-
};
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
// ─── Hook 4: Context modification for persistent anti-loop instructions ──
|
|
617
|
-
|
|
618
|
-
pi.on("context", async (event, ctx) => {
|
|
619
|
-
if (!config.enabled) return;
|
|
620
|
-
if (state.currentLevel < 2) return;
|
|
621
|
-
|
|
622
|
-
// When in force-break mode, add anti-loop instructions to context
|
|
623
|
-
const messages = [...event.messages];
|
|
624
|
-
|
|
625
|
-
// Find the last assistant message and append instructions
|
|
626
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
627
|
-
if (messages[i].role === "assistant") {
|
|
628
|
-
const msg = messages[i] as any;
|
|
629
|
-
if (typeof msg.content === "string") {
|
|
630
|
-
msg.content += "\n\n[antiloop] I must break out of this loop. Trying a completely different approach.";
|
|
631
|
-
} else if (Array.isArray(msg.content)) {
|
|
632
|
-
msg.content.push({
|
|
633
|
-
type: "text",
|
|
634
|
-
text: "\n\n[antiloop] I must break out of this loop. Trying a completely different approach.",
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
break;
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
return { messages };
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
// ─── Hook 5: Track turns for timing ────────────────────────────────────
|
|
645
|
-
|
|
646
|
-
pi.on("turn_end", async (event, ctx) => {
|
|
647
|
-
if (!config.enabled) return;
|
|
648
|
-
|
|
649
|
-
// Update status bar
|
|
650
|
-
updateStatus(ctx);
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
// ─── Status bar ─────────────────────────────────────────────────────────
|
|
654
|
-
|
|
655
|
-
function updateStatus(ctx: ExtensionContext) {
|
|
656
|
-
if (!config.enabled) {
|
|
657
|
-
ctx.ui.setStatus("antiloop", undefined);
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
if (state.currentLevel === 0) {
|
|
662
|
-
ctx.ui.setStatus("antiloop", "🔄 antiloop");
|
|
663
|
-
} else {
|
|
664
|
-
const levelIcons = ["", "⚠️", "🛑", "🚨"];
|
|
665
|
-
ctx.ui.setStatus(
|
|
666
|
-
"antiloop",
|
|
667
|
-
`${levelIcons[state.currentLevel]} antiloop(${state.consecutiveDetections})`
|
|
668
|
-
);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
673
|
-
// COMMANDS
|
|
674
|
-
// ═══════════════════════════════════════════════════════════════════════
|
|
675
|
-
|
|
676
|
-
// ─── /antiloop [subcommand] ──────────────────────────────────────────────
|
|
677
|
-
|
|
678
|
-
pi.registerCommand("antiloop", {
|
|
679
|
-
description: "Antiloop: detect and break reasoning loops",
|
|
680
|
-
getArgumentCompletions: (prefix) => {
|
|
681
|
-
const subs = ["enable", "disable", "status", "config", "log", "reset", "test"];
|
|
682
|
-
return subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
683
|
-
},
|
|
684
|
-
handler: async (args, ctx) => {
|
|
685
|
-
const sub = args?.trim().toLowerCase() ?? "";
|
|
686
|
-
switch (sub) {
|
|
687
|
-
case "enable":
|
|
688
|
-
config.enabled = true;
|
|
689
|
-
saveConfig(config);
|
|
690
|
-
ctx.ui.notify("🔄 antiloop: ENABLED", "info");
|
|
691
|
-
updateStatus(ctx);
|
|
692
|
-
break;
|
|
693
|
-
case "disable":
|
|
694
|
-
config.enabled = false;
|
|
695
|
-
saveConfig(config);
|
|
696
|
-
ctx.ui.notify("🔄 antiloop: DISABLED", "info");
|
|
697
|
-
updateStatus(ctx);
|
|
698
|
-
break;
|
|
699
|
-
case "status":
|
|
700
|
-
await showStatus(ctx);
|
|
701
|
-
break;
|
|
702
|
-
case "config":
|
|
703
|
-
await showConfigMenu(pi, ctx);
|
|
704
|
-
break;
|
|
705
|
-
case "log":
|
|
706
|
-
await showDetectionLog(ctx);
|
|
707
|
-
break;
|
|
708
|
-
case "reset":
|
|
709
|
-
resetState();
|
|
710
|
-
ctx.ui.notify("🔄 antiloop: All counters and history reset", "info");
|
|
711
|
-
updateStatus(ctx);
|
|
712
|
-
break;
|
|
713
|
-
case "test":
|
|
714
|
-
await runSelfTest(ctx);
|
|
715
|
-
break;
|
|
716
|
-
default:
|
|
717
|
-
config.enabled = !config.enabled;
|
|
718
|
-
saveConfig(config);
|
|
719
|
-
ctx.ui.notify(`🔄 antiloop: ${config.enabled ? "ENABLED" : "DISABLED"}`, "info");
|
|
720
|
-
updateStatus(ctx);
|
|
721
|
-
break;
|
|
722
|
-
}
|
|
723
|
-
},
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
// ─── /antiloop status ────────────────────────────────────────────────────
|
|
727
|
-
|
|
728
|
-
async function showStatus(ctx: ExtensionContext) {
|
|
729
|
-
const levelNames = ["none", "warning", "force break", "abort"];
|
|
730
|
-
const recentDetections = state.detections.slice(-5);
|
|
731
|
-
|
|
732
|
-
const lines = [
|
|
733
|
-
`State: ${config.enabled ? "✅ ENABLED" : "❌ DISABLED"}`,
|
|
734
|
-
`Current level: ${levelNames[state.currentLevel]}`,
|
|
735
|
-
`Consecutive detections: ${state.consecutiveDetections}`,
|
|
736
|
-
`Total detections: ${state.totalDetections}`,
|
|
737
|
-
`Messages tracked: ${state.recentMessages.length}`,
|
|
738
|
-
`In forced break: ${state.inForcedBreak ? "yes" : "no"}`,
|
|
739
|
-
"",
|
|
740
|
-
"Configuration:",
|
|
741
|
-
` Warning threshold: ${config.warningThreshold} similar messages`,
|
|
742
|
-
` Force break threshold: ${config.forceBreakThreshold} similar messages`,
|
|
743
|
-
` Abort threshold: ${config.abortThreshold > 0 ? config.abortThreshold : "disabled"}`,
|
|
744
|
-
` Similarity threshold: ${(config.similarityThreshold * 100).toFixed(0)}%`,
|
|
745
|
-
` Detection window: ${config.detectionWindow} messages`,
|
|
746
|
-
"",
|
|
747
|
-
"Detection strategies:",
|
|
748
|
-
` Text loops: ${config.detectTextLoops ? "✅" : "❌"}`,
|
|
749
|
-
` Tool loops: ${config.detectToolLoops ? "✅" : "❌"}`,
|
|
750
|
-
` Thinking loops: ${config.detectThinkingLoops ? "✅" : "❌"}`,
|
|
751
|
-
];
|
|
752
|
-
|
|
753
|
-
if (recentDetections.length > 0) {
|
|
754
|
-
lines.push("", "Recent detections:");
|
|
755
|
-
for (const d of recentDetections) {
|
|
756
|
-
lines.push(` [${d.type}] ${d.description} (${formatDuration(Date.now() - d.timestamp)} ago)`);
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// ─── /antiloop config ────────────────────────────────────────────────────
|
|
764
|
-
|
|
765
|
-
async function showConfigMenu(pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
766
|
-
const enabledLabel = config.enabled ? "🟢 Disable antiloop" : "🔴 Enable antiloop";
|
|
767
|
-
|
|
768
|
-
const action = await selectFrom(ctx, "🔄 Antiloop Config", [
|
|
769
|
-
{ value: "toggle", label: enabledLabel, description: `Currently: ${config.enabled ? "enabled" : "disabled"}` },
|
|
770
|
-
{ value: "warning", label: `⚠️ Warning threshold: ${config.warningThreshold}`, description: "Similar messages before warning" },
|
|
771
|
-
{ value: "force", label: `🛑 Force break threshold: ${config.forceBreakThreshold}`, description: "Similar messages before force break" },
|
|
772
|
-
{ value: "abort", label: `🚨 Abort threshold: ${config.abortThreshold > 0 ? config.abortThreshold : "disabled"}`, description: "Similar messages before abort (0=disabled)" },
|
|
773
|
-
{ value: "similarity", label: `📊 Similarity: ${(config.similarityThreshold * 100).toFixed(0)}%`, description: "How similar messages must be to count as looping" },
|
|
774
|
-
{ value: "window", label: `🪟 Detection window: ${config.detectionWindow}`, description: "Number of recent messages to analyze" },
|
|
775
|
-
{ value: "text", label: `📝 Text detection: ${config.detectTextLoops ? "on" : "off"}`, description: "Detect text repetition loops" },
|
|
776
|
-
{ value: "tool", label: `🔧 Tool detection: ${config.detectToolLoops ? "on" : "off"}`, description: "Detect tool call loops" },
|
|
777
|
-
{ value: "thinking", label: `🧠 Thinking detection: ${config.detectThinkingLoops ? "on" : "off"}`, description: "Detect thinking/reasoning loops" },
|
|
778
|
-
{ value: "notify", label: `🔔 Notifications: ${config.notifyOnDetection ? "on" : "off"}`, description: "Show notifications on detection" },
|
|
779
|
-
{ value: "reset", label: "🔃 Reset state", description: "Clear all counters and history" },
|
|
780
|
-
]);
|
|
781
|
-
|
|
782
|
-
if (!action) return;
|
|
783
|
-
|
|
784
|
-
switch (action) {
|
|
785
|
-
case "toggle":
|
|
786
|
-
config.enabled = !config.enabled;
|
|
787
|
-
saveConfig(config);
|
|
788
|
-
ctx.ui.notify(`antiloop: ${config.enabled ? "ENABLED" : "DISABLED"}`, "info");
|
|
789
|
-
updateStatus(ctx);
|
|
790
|
-
break;
|
|
791
|
-
|
|
792
|
-
case "warning": {
|
|
793
|
-
const picked = await selectFrom(ctx, "Warning threshold", [
|
|
794
|
-
{ value: 1, label: "1 (very sensitive)" },
|
|
795
|
-
{ value: 2, label: "2 (default)" },
|
|
796
|
-
{ value: 3, label: "3" },
|
|
797
|
-
{ value: 5, label: "5 (less sensitive)" },
|
|
798
|
-
]);
|
|
799
|
-
if (picked !== undefined) {
|
|
800
|
-
config.warningThreshold = picked;
|
|
801
|
-
saveConfig(config);
|
|
802
|
-
ctx.ui.notify(`Warning threshold set to ${picked}`, "info");
|
|
803
|
-
}
|
|
804
|
-
break;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
case "force": {
|
|
808
|
-
const picked = await selectFrom(ctx, "Force break threshold", [
|
|
809
|
-
{ value: 2, label: "2 (very sensitive)" },
|
|
810
|
-
{ value: 3, label: "3 (default)" },
|
|
811
|
-
{ value: 5, label: "5" },
|
|
812
|
-
{ value: 8, label: "8 (less sensitive)" },
|
|
813
|
-
]);
|
|
814
|
-
if (picked !== undefined) {
|
|
815
|
-
config.forceBreakThreshold = picked;
|
|
816
|
-
saveConfig(config);
|
|
817
|
-
ctx.ui.notify(`Force break threshold set to ${picked}`, "info");
|
|
818
|
-
}
|
|
819
|
-
break;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
case "abort": {
|
|
823
|
-
const picked = await selectFrom(ctx, "Abort threshold (0=disabled)", [
|
|
824
|
-
{ value: 0, label: "0 (disabled)" },
|
|
825
|
-
{ value: 5, label: "5" },
|
|
826
|
-
{ value: 8, label: "8" },
|
|
827
|
-
{ value: 10, label: "10" },
|
|
828
|
-
{ value: 15, label: "15" },
|
|
829
|
-
]);
|
|
830
|
-
if (picked !== undefined) {
|
|
831
|
-
config.abortThreshold = picked;
|
|
832
|
-
saveConfig(config);
|
|
833
|
-
ctx.ui.notify(`Abort threshold set to ${picked > 0 ? picked : "disabled"}`, "info");
|
|
834
|
-
}
|
|
835
|
-
break;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
case "similarity": {
|
|
839
|
-
const picked = await selectFrom(ctx, "Similarity threshold", [
|
|
840
|
-
{ value: 0.5, label: "50% (very sensitive)" },
|
|
841
|
-
{ value: 0.6, label: "60%" },
|
|
842
|
-
{ value: 0.7, label: "70%" },
|
|
843
|
-
{ value: 0.75, label: "75% (default)" },
|
|
844
|
-
{ value: 0.8, label: "80%" },
|
|
845
|
-
{ value: 0.9, label: "90% (less sensitive)" },
|
|
846
|
-
]);
|
|
847
|
-
if (picked !== undefined) {
|
|
848
|
-
config.similarityThreshold = picked;
|
|
849
|
-
saveConfig(config);
|
|
850
|
-
ctx.ui.notify(`Similarity threshold set to ${(picked * 100).toFixed(0)}%`, "info");
|
|
851
|
-
}
|
|
852
|
-
break;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
case "window": {
|
|
856
|
-
const picked = await selectFrom(ctx, "Detection window", [
|
|
857
|
-
{ value: 5, label: "5 messages" },
|
|
858
|
-
{ value: 10, label: "10 messages (default)" },
|
|
859
|
-
{ value: 15, label: "15 messages" },
|
|
860
|
-
{ value: 20, label: "20 messages" },
|
|
861
|
-
]);
|
|
862
|
-
if (picked !== undefined) {
|
|
863
|
-
config.detectionWindow = picked;
|
|
864
|
-
saveConfig(config);
|
|
865
|
-
ctx.ui.notify(`Detection window set to ${picked} messages`, "info");
|
|
866
|
-
}
|
|
867
|
-
break;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
case "text":
|
|
871
|
-
config.detectTextLoops = !config.detectTextLoops;
|
|
872
|
-
saveConfig(config);
|
|
873
|
-
ctx.ui.notify(`Text detection: ${config.detectTextLoops ? "ON" : "OFF"}`, "info");
|
|
874
|
-
break;
|
|
875
|
-
|
|
876
|
-
case "tool":
|
|
877
|
-
config.detectToolLoops = !config.detectToolLoops;
|
|
878
|
-
saveConfig(config);
|
|
879
|
-
ctx.ui.notify(`Tool detection: ${config.detectToolLoops ? "ON" : "OFF"}`, "info");
|
|
880
|
-
break;
|
|
881
|
-
|
|
882
|
-
case "thinking":
|
|
883
|
-
config.detectThinkingLoops = !config.detectThinkingLoops;
|
|
884
|
-
saveConfig(config);
|
|
885
|
-
ctx.ui.notify(`Thinking detection: ${config.detectThinkingLoops ? "ON" : "OFF"}`, "info");
|
|
886
|
-
break;
|
|
887
|
-
|
|
888
|
-
case "notify":
|
|
889
|
-
config.notifyOnDetection = !config.notifyOnDetection;
|
|
890
|
-
saveConfig(config);
|
|
891
|
-
ctx.ui.notify(`Notifications: ${config.notifyOnDetection ? "ON" : "OFF"}`, "info");
|
|
892
|
-
break;
|
|
893
|
-
|
|
894
|
-
case "reset":
|
|
895
|
-
resetState();
|
|
896
|
-
ctx.ui.notify("State reset", "info");
|
|
897
|
-
updateStatus(ctx);
|
|
898
|
-
break;
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// ─── /antiloop log ───────────────────────────────────────────────────────
|
|
903
|
-
|
|
904
|
-
async function showDetectionLog(ctx: ExtensionContext) {
|
|
905
|
-
if (state.detections.length === 0) {
|
|
906
|
-
ctx.ui.notify("No loop detections recorded this session", "info");
|
|
907
|
-
return;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
const items = state.detections.slice(-30).reverse().map((d) => ({
|
|
911
|
-
value: "",
|
|
912
|
-
label: `[${d.type}] ${d.description}`,
|
|
913
|
-
description: `${(d.similarity * 100).toFixed(0)}% similar · ${formatDuration(Date.now() - d.timestamp)} ago`,
|
|
914
|
-
}));
|
|
915
|
-
|
|
916
|
-
await selectFrom(ctx, `🔄 Detection log (${state.detections.length} total)`, items);
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// ─── /antiloop reset ─────────────────────────────────────────────────────
|
|
920
|
-
|
|
921
|
-
function resetState(): void {
|
|
922
|
-
state.recentMessages = [];
|
|
923
|
-
state.detections = [];
|
|
924
|
-
state.currentLevel = 0;
|
|
925
|
-
state.consecutiveDetections = 0;
|
|
926
|
-
state.inForcedBreak = false;
|
|
927
|
-
state.totalDetections = 0;
|
|
928
|
-
pendingIntervention = null;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// ─── /antiloop test ──────────────────────────────────────────────────────
|
|
932
|
-
|
|
933
|
-
async function runSelfTest(ctx: ExtensionContext) {
|
|
934
|
-
ctx.ui.notify("🧪 Running antiloop self-test...", "info");
|
|
935
|
-
|
|
936
|
-
const testCases: Array<{ a: string; b: string; expected: string }> = [
|
|
937
|
-
{ a: "Hello world", b: "Hello world", expected: "identical" },
|
|
938
|
-
{ a: "Hello world", b: "Hello World!", expected: "very similar" },
|
|
939
|
-
{ a: "The quick brown fox", b: "The quick brown fox jumps over the lazy dog", expected: "similar" },
|
|
940
|
-
{ a: "Hello world", b: "Goodbye universe", expected: "different" },
|
|
941
|
-
{ a: "I will read the file first", b: "I will read the file first to understand", expected: "similar" },
|
|
942
|
-
];
|
|
943
|
-
|
|
944
|
-
const results: string[] = [];
|
|
945
|
-
for (const tc of testCases) {
|
|
946
|
-
const similarity = calculateSimilarity(tc.a, tc.b);
|
|
947
|
-
const normalized = normalizeText(tc.a);
|
|
948
|
-
const normalizedB = normalizeText(tc.b);
|
|
949
|
-
results.push(
|
|
950
|
-
`"${tc.a}" vs "${tc.b}"\n Similarity: ${(similarity * 100).toFixed(1)}% (expected: ${tc.expected})`
|
|
951
|
-
);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// Test tool call detection
|
|
955
|
-
const toolCalls1 = [{ name: "read", args: '{"path":"/test"}' }];
|
|
956
|
-
const toolCalls2 = [{ name: "read", args: '{"path":"/test"}' }];
|
|
957
|
-
const toolCalls3 = [{ name: "write", args: '{"path":"/other"}' }];
|
|
958
|
-
|
|
959
|
-
results.push(
|
|
960
|
-
`\nTool call tests:`,
|
|
961
|
-
` Same calls: ${areToolCallsSimilar(toolCalls1, toolCalls2)} (expected: true)`,
|
|
962
|
-
` Different calls: ${areToolCallsSimilar(toolCalls1, toolCalls3)} (expected: false)`,
|
|
963
|
-
);
|
|
964
|
-
|
|
965
|
-
ctx.ui.notify(`🧪 Self-test results:\n${results.join("\n")}`, "info");
|
|
966
|
-
}
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
let state = newState();
|
|
28
|
+
|
|
29
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
30
|
+
if (!config.enabled) ctx.ui.setStatus("antiloop", undefined);
|
|
31
|
+
else if (state.currentLevel === 0) ctx.ui.setStatus("antiloop", "🔄 antiloop");
|
|
32
|
+
else ctx.ui.setStatus("antiloop", `${ICONS[state.currentLevel]} antiloop(${state.consecutiveDetections})`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rt: Runtime = { config, state, pendingIntervention: null, updateStatus };
|
|
36
|
+
const setPending = (v: string | null) => { rt.pendingIntervention = v; };
|
|
37
|
+
|
|
38
|
+
function processDetections(
|
|
39
|
+
detections: LoopDetection[],
|
|
40
|
+
interventionMessage: (level: 1 | 2 | 3, d: LoopDetection[]) => string,
|
|
41
|
+
): void {
|
|
42
|
+
if (!detections.length) {
|
|
43
|
+
if (state.consecutiveDetections > 0) state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 1);
|
|
44
|
+
if (state.currentLevel > 0 && state.consecutiveDetections === 0) {
|
|
45
|
+
state.currentLevel = 0;
|
|
46
|
+
state.inForcedBreak = false;
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
state.consecutiveDetections++;
|
|
51
|
+
state.totalDetections++;
|
|
52
|
+
state.detections.push(...detections);
|
|
53
|
+
if (state.detections.length > config.maxHistoryEntries) state.detections = state.detections.slice(-config.maxHistoryEntries);
|
|
54
|
+
|
|
55
|
+
let next: 0 | 1 | 2 | 3 = 0;
|
|
56
|
+
if (state.consecutiveDetections >= config.abortThreshold && config.abortThreshold > 0) next = 3;
|
|
57
|
+
else if (state.consecutiveDetections >= config.forceBreakThreshold) next = 2;
|
|
58
|
+
else if (state.consecutiveDetections >= config.warningThreshold) next = 1;
|
|
59
|
+
if (next > state.currentLevel) state.currentLevel = next;
|
|
60
|
+
|
|
61
|
+
if (state.currentLevel > 0) {
|
|
62
|
+
setPending(interventionMessage(state.currentLevel as 1 | 2 | 3, detections));
|
|
63
|
+
state.inForcedBreak = state.currentLevel >= 2;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
pi.on("message_end", async (event, ctx) => {
|
|
68
|
+
if (!config.enabled) return;
|
|
69
|
+
const msg = event.message;
|
|
70
|
+
if (msg.role !== "assistant") return;
|
|
71
|
+
|
|
72
|
+
const { detectLoops, interventionMessage } = await import("./detect.ts");
|
|
73
|
+
|
|
74
|
+
let content = "";
|
|
75
|
+
let thinking = "";
|
|
76
|
+
if (typeof msg.content === "string") content = msg.content;
|
|
77
|
+
else if (Array.isArray(msg.content)) {
|
|
78
|
+
for (const p of msg.content) {
|
|
79
|
+
if (p.type === "text") content += p.text;
|
|
80
|
+
else if (p.type === "thinking") thinking += p.thinking;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const toolCalls: Array<{ name: string; args: string }> = [];
|
|
85
|
+
if (Array.isArray(msg.content)) {
|
|
86
|
+
for (const p of msg.content) {
|
|
87
|
+
if (p.type === "toolCall") toolCalls.push({ name: p.name, args: JSON.stringify(p.arguments ?? {}) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (content.length >= 50 || toolCalls.length > 0) {
|
|
92
|
+
state.recentMessages.push({
|
|
93
|
+
content,
|
|
94
|
+
thinking: thinking || undefined,
|
|
95
|
+
toolCalls: toolCalls.length ? toolCalls : undefined,
|
|
96
|
+
timestamp: Date.now(),
|
|
97
|
+
turnIndex: state.recentMessages.length,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (state.recentMessages.length > config.detectionWindow + 5) {
|
|
101
|
+
state.recentMessages = state.recentMessages.slice(-(config.detectionWindow + 5));
|
|
102
|
+
}
|
|
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
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.on("input", async () => {
|
|
114
|
+
if (!config.enabled) return;
|
|
115
|
+
state.lastUserMessageTime = Date.now();
|
|
116
|
+
if (state.consecutiveDetections > 0) state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 2);
|
|
117
|
+
if (state.consecutiveDetections < config.warningThreshold) {
|
|
118
|
+
state.currentLevel = 0;
|
|
119
|
+
state.inForcedBreak = false;
|
|
120
|
+
}
|
|
121
|
+
return { action: "continue" };
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
pi.on("before_agent_start", async () => {
|
|
125
|
+
if (!config.enabled || !rt.pendingIntervention) return;
|
|
126
|
+
const msg = rt.pendingIntervention;
|
|
127
|
+
rt.pendingIntervention = null;
|
|
128
|
+
return {
|
|
129
|
+
message: { customType: "antiloop-intervention", content: msg, display: true },
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
pi.on("context", async (event) => {
|
|
134
|
+
if (!config.enabled || state.currentLevel < 2) return;
|
|
135
|
+
const msgs = [...event.messages];
|
|
136
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
137
|
+
if (msgs[i].role === "assistant") {
|
|
138
|
+
const m = msgs[i] as { content: string | Array<{ type: string; text?: string }> };
|
|
139
|
+
const inject = "\n\n[antiloop] break out of loop — try a different approach.";
|
|
140
|
+
if (typeof m.content === "string") m.content += inject;
|
|
141
|
+
else if (Array.isArray(m.content)) m.content.push({ type: "text", text: inject });
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { messages: msgs };
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
pi.on("turn_end", async (_e, ctx) => {
|
|
149
|
+
if (config.enabled) updateStatus(ctx);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
153
|
+
// re-read config and reset state for a fresh session
|
|
154
|
+
Object.assign(config, loadConfig());
|
|
155
|
+
state = newState();
|
|
156
|
+
rt.pendingIntervention = null;
|
|
157
|
+
updateStatus(ctx);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
pi.registerCommand("antiloop", {
|
|
161
|
+
description: "antiloop: detect & break reasoning loops",
|
|
162
|
+
getArgumentCompletions: (prefix: string) => {
|
|
163
|
+
const subs = ["enable", "disable", "status", "config", "log", "reset", "test"];
|
|
164
|
+
return subs.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
|
|
165
|
+
},
|
|
166
|
+
handler: async (args, ctx) => {
|
|
167
|
+
const { handleCommand } = await import("./commands.ts");
|
|
168
|
+
await handleCommand(args, ctx, rt);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
967
172
|
|
|
968
|
-
// ─── Session lifecycle ──────────────────────────────────────────────────
|
|
969
173
|
|
|
970
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
971
|
-
config = loadConfig();
|
|
972
|
-
resetState();
|
|
973
|
-
updateStatus(ctx);
|
|
974
|
-
});
|
|
975
|
-
}
|