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/src/index.ts CHANGED
@@ -1,975 +1,173 @@
1
1
  /**
2
- * antiloop Extension for pi
3
- *
4
- * Detects reasoning/processing loops in any model and forces a break with
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 { getAgentDir } from "@earendil-works/pi-coding-agent";
8
+ import { loadConfig } from "./config.ts";
9
+ import type { AntiloopState, LoopDetection, Runtime } from "./types.ts";
33
10
 
34
- // ─── Types ──────────────────────────────────────────────────────────────────
11
+ const ICONS = ["", "⚠️", "🛑", "🚨"] as const;
35
12
 
36
- interface AntiloopConfig {
37
- enabled: boolean;
38
- /** Number of similar messages before triggering warning */
39
- warningThreshold: number;
40
- /** Number of similar messages before forcing a break */
41
- forceBreakThreshold: number;
42
- /** Number of similar messages before aborting (0 = disabled) */
43
- abortThreshold: number;
44
- /** Similarity score threshold (0.0-1.0) to consider messages as looping */
45
- similarityThreshold: number;
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
- let config: AntiloopConfig = loadConfig();
452
-
453
- const state: AntiloopState = {
454
- recentMessages: [],
455
- detections: [],
456
- currentLevel: 0,
457
- consecutiveDetections: 0,
458
- inForcedBreak: false,
459
- totalDetections: 0,
460
- lastUserMessageTime: 0,
461
- };
462
-
463
- let pendingIntervention: string | null = null;
464
-
465
- // ─── Core: process detections and determine intervention ────────────────
466
-
467
- function processDetections(detections: LoopDetection[]): void {
468
- if (detections.length === 0) {
469
- // No loops detected, reset consecutive count
470
- if (state.consecutiveDetections > 0) {
471
- state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 1);
472
- }
473
- if (state.currentLevel > 0 && state.consecutiveDetections === 0) {
474
- state.currentLevel = 0;
475
- state.inForcedBreak = false;
476
- }
477
- return;
478
- }
479
-
480
- state.consecutiveDetections++;
481
- state.totalDetections++;
482
-
483
- // Add to history
484
- state.detections.push(...detections);
485
- if (state.detections.length > config.maxHistoryEntries) {
486
- state.detections = state.detections.slice(-config.maxHistoryEntries);
487
- }
488
-
489
- // Determine intervention level
490
- let newLevel = 0;
491
- if (state.consecutiveDetections >= config.abortThreshold && config.abortThreshold > 0) {
492
- newLevel = 3;
493
- } else if (state.consecutiveDetections >= config.forceBreakThreshold) {
494
- newLevel = 2;
495
- } else if (state.consecutiveDetections >= config.warningThreshold) {
496
- newLevel = 1;
497
- }
498
-
499
- // Only escalate, never de-escalate automatically
500
- if (newLevel > state.currentLevel) {
501
- state.currentLevel = newLevel;
502
- }
503
-
504
- // Generate intervention message if needed
505
- if (state.currentLevel > 0) {
506
- pendingIntervention = getInterventionMessage(state.currentLevel, detections, config);
507
- state.inForcedBreak = state.currentLevel >= 2;
508
- }
509
- }
510
-
511
- // ─── Hook 1: Track assistant messages ───────────────────────────────────
512
-
513
- pi.on("message_end", async (event, ctx) => {
514
- if (!config.enabled) return;
515
-
516
- const msg = event.message;
517
- if (msg.role !== "assistant") return;
518
-
519
- // Extract message content
520
- let content = "";
521
- let thinking = "";
522
-
523
- if (typeof msg.content === "string") {
524
- content = msg.content;
525
- } else if (Array.isArray(msg.content)) {
526
- for (const part of msg.content) {
527
- if (part.type === "text") {
528
- content += part.text;
529
- } else if (part.type === "thinking") {
530
- thinking += part.thinking;
531
- }
532
- }
533
- }
534
-
535
- // Extract tool calls
536
- const toolCalls: Array<{ name: string; args: string }> = [];
537
- if (Array.isArray(msg.content)) {
538
- for (const part of msg.content) {
539
- if (part.type === "toolCall") {
540
- toolCalls.push({
541
- name: part.name,
542
- args: JSON.stringify(part.arguments ?? {}),
543
- });
544
- }
545
- }
546
- }
547
-
548
- // Add to recent messages (skip empty or very short messages)
549
- if (content.length >= MIN_CONTENT_LENGTH || toolCalls.length > 0) {
550
- state.recentMessages.push({
551
- content,
552
- thinking: thinking || undefined,
553
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
554
- timestamp: Date.now(),
555
- turnIndex: state.recentMessages.length,
556
- });
557
- }
558
-
559
- // Trim to window size + buffer
560
- if (state.recentMessages.length > config.detectionWindow + 5) {
561
- state.recentMessages = state.recentMessages.slice(-(config.detectionWindow + 5));
562
- }
563
-
564
- // Run detection
565
- const detections = detectLoops(state, config);
566
- processDetections(detections);
567
-
568
- // Notify if configured
569
- if (config.notifyOnDetection && detections.length > 0 && state.currentLevel > 0) {
570
- const levelNames = ["", "warning", "force break", "abort"];
571
- ctx.ui.notify(
572
- `🔄 antiloop: ${levelNames[state.currentLevel]} — ${detections[0].description}`,
573
- state.currentLevel >= 2 ? "error" : "warning"
574
- );
575
- }
576
- });
577
-
578
- // ─── Hook 2: Track user messages (resets loop state) ────────────────────
579
-
580
- pi.on("input", async (event, ctx) => {
581
- if (!config.enabled) return;
582
-
583
- // User input resets the loop detection
584
- state.lastUserMessageTime = Date.now();
585
-
586
- // Don't reset completely, but reduce consecutive count
587
- if (state.consecutiveDetections > 0) {
588
- state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 2);
589
- }
590
- if (state.consecutiveDetections < config.warningThreshold) {
591
- state.currentLevel = 0;
592
- state.inForcedBreak = false;
593
- }
594
-
595
- return { action: "continue" };
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
- }