pi-antiloop 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -31
- package/package.json +5 -6
- package/src/commands.ts +230 -0
- package/src/config.ts +49 -0
- package/src/detect.ts +280 -0
- package/src/index.ts +188 -964
- package/src/types.ts +84 -0
- package/src/ui.ts +23 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// antiloop — shared types. Zero runtime cost.
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export interface AntiloopConfig {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
warningThreshold: number;
|
|
7
|
+
forceBreakThreshold: number;
|
|
8
|
+
abortThreshold: number;
|
|
9
|
+
similarityThreshold: number;
|
|
10
|
+
/**
|
|
11
|
+
* How close tool-call arguments must be (0..1) to count as the SAME call.
|
|
12
|
+
* High by default: long bash commands share scaffolding (env setup, flags,
|
|
13
|
+
* paths) even when they are different operations — a parameter sweep or a
|
|
14
|
+
* retry after a fix is NOT a loop. Only near-identical repeats qualify.
|
|
15
|
+
*/
|
|
16
|
+
toolSimilarityThreshold: number;
|
|
17
|
+
/**
|
|
18
|
+
* How many PRIOR occurrences of a near-identical tool-call set must exist
|
|
19
|
+
* in the window before a tool loop is flagged. 2 = the same call seen 3x.
|
|
20
|
+
*/
|
|
21
|
+
minToolRepeatCount: number;
|
|
22
|
+
/**
|
|
23
|
+
* Result-aware veto: when both runs have a captured result, the normalized
|
|
24
|
+
* result tails must be at least this similar for the pair to count as a
|
|
25
|
+
* loop. Same command + different outcome = progress, not a loop.
|
|
26
|
+
*/
|
|
27
|
+
resultSimilarityThreshold: number;
|
|
28
|
+
detectToolLoops: boolean;
|
|
29
|
+
detectThinkingLoops: boolean;
|
|
30
|
+
detectTextLoops: boolean;
|
|
31
|
+
notifyOnDetection: boolean;
|
|
32
|
+
maxHistoryEntries: number;
|
|
33
|
+
detectionWindow: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type LoopKind = "text" | "tool" | "thinking" | "structural";
|
|
37
|
+
|
|
38
|
+
export interface LoopDetection {
|
|
39
|
+
type: LoopKind;
|
|
40
|
+
similarity: number;
|
|
41
|
+
messageIndices: number[];
|
|
42
|
+
description: string;
|
|
43
|
+
timestamp: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TrackedToolCall {
|
|
47
|
+
name: string;
|
|
48
|
+
args: string;
|
|
49
|
+
/** toolCallId — used to attach the execution result at turn_end. */
|
|
50
|
+
id?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Normalized tail of the tool result ("err|" / "ok|" prefix + last chars).
|
|
53
|
+
* Only set when the turn completed and a result was captured. When both
|
|
54
|
+
* sides of a comparison have one, a mismatch vetoes the loop.
|
|
55
|
+
*/
|
|
56
|
+
result?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TrackedMessage {
|
|
60
|
+
content: string;
|
|
61
|
+
thinking?: string;
|
|
62
|
+
toolCalls?: TrackedToolCall[];
|
|
63
|
+
timestamp: number;
|
|
64
|
+
turnIndex: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AntiloopState {
|
|
68
|
+
recentMessages: TrackedMessage[];
|
|
69
|
+
detections: LoopDetection[];
|
|
70
|
+
currentLevel: 0 | 1 | 2 | 3;
|
|
71
|
+
consecutiveDetections: number;
|
|
72
|
+
inForcedBreak: boolean;
|
|
73
|
+
totalDetections: number;
|
|
74
|
+
lastUserMessageTime: number;
|
|
75
|
+
/** turnIndex of the last tracked message detection already ran on. */
|
|
76
|
+
lastDetectedTurnIndex: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Runtime {
|
|
80
|
+
config: AntiloopConfig;
|
|
81
|
+
state: AntiloopState;
|
|
82
|
+
pendingIntervention: string | null;
|
|
83
|
+
updateStatus(ctx: ExtensionContext): void;
|
|
84
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// antiloop — UI helpers. Lazy-loaded with command handler.
|
|
2
|
+
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export function formatDuration(ms: number): string {
|
|
6
|
+
if (ms < 1000) return `${ms}ms`;
|
|
7
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
8
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
|
9
|
+
return `${Math.round(ms / 3_600_000)}h`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Labeled select: returns the matched `value` from `items`, or undefined if cancelled.
|
|
13
|
+
export async function selectFrom<T>(
|
|
14
|
+
ctx: ExtensionContext,
|
|
15
|
+
title: string,
|
|
16
|
+
items: Array<{ value: T; label: string; description?: string }>,
|
|
17
|
+
): Promise<T | undefined> {
|
|
18
|
+
const strings = items.map((it) => (it.description ? `${it.label} — ${it.description}` : it.label));
|
|
19
|
+
const picked = await ctx.ui.select(title, strings);
|
|
20
|
+
if (picked === undefined) return undefined;
|
|
21
|
+
const idx = strings.indexOf(picked);
|
|
22
|
+
return idx >= 0 ? items[idx].value : undefined;
|
|
23
|
+
}
|