pi-antiloop 1.0.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/LICENSE +21 -0
- package/README.md +253 -0
- package/package.json +35 -0
- package/src/index.ts +975 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Noguerol
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
# Antiloop — Loop Detection and Break for pi
|
|
8
|
+
|
|
9
|
+
**Antiloop watches every assistant message, tool call and thinking block, and forces the model out of reasoning loops before they eat your context and your patience.** Three simultaneous detection strategies (text similarity, tool-call sequences, thinking content) find loops that humans miss — and progressive intervention (warning → force break → abort) tells the model to take a different approach, without you having to babysit it.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Four detection strategies** — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name + argument matching), thinking blocks, and structural opening-phrase patterns
|
|
16
|
+
- **Progressive intervention** — `warning` reminds the model to vary its approach; `force break` injects explicit anti-loop instructions and modifies context; `abort` stops the run entirely
|
|
17
|
+
- **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
|
|
18
|
+
- **Sliding window** — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
|
|
19
|
+
- **Live status bar** — `🔄 antiloop`, `⚠️ antiloop(N)`, `🛑 antiloop(N)`, `🚨 antiloop(N)` reflect the current intervention level
|
|
20
|
+
- **Detection log** — timestamped history with similarity scores, filterable through the native pi menu
|
|
21
|
+
- **Self-test** — `/antiloop test` runs built-in cases to verify the similarity engine is calibrated
|
|
22
|
+
- **User input softens detection** — each new user message decays the consecutive counter so a fresh prompt can resolve the loop without manual reset
|
|
23
|
+
- **Bilingual-friendly** — no language assumptions in the comparison (only whitespace + punctuation normalization)
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Antiloop is a [pi package](https://pi.dev/packages): one extension (`src/index.ts`) declared in `package.json`.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# From GitHub
|
|
31
|
+
pi install git:github.com/noguerol/antiloop
|
|
32
|
+
|
|
33
|
+
# Pin a tag/commit
|
|
34
|
+
pi install git:github.com/noguerol/antiloop@v1.0.0
|
|
35
|
+
|
|
36
|
+
# From npm
|
|
37
|
+
pi install npm:pi-antiloop
|
|
38
|
+
|
|
39
|
+
# Local checkout (development)
|
|
40
|
+
pi install /path/to/antiloop
|
|
41
|
+
|
|
42
|
+
# Try it for one run only
|
|
43
|
+
pi -e git:github.com/noguerol/antiloop
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pi list # show installed packages
|
|
48
|
+
pi remove npm:pi-antiloop
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> **Security:** pi packages run with full system access — extensions execute arbitrary code. Install only packages you trust and review the source.
|
|
52
|
+
|
|
53
|
+
**Requirements:** a working pi installation. Works with every model — including small local ones that get stuck easily. Zero external dependencies.
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
/antiloop # toggle on (enabled by default)
|
|
59
|
+
/antiloop status # check state, configuration, recent detections
|
|
60
|
+
/antiloop config # adjust thresholds to taste
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That's it. Antiloop is on by default. If the model ever starts repeating itself, you'll see a `⚠️ antiloop(N)` warning; if it keeps looping past the force-break threshold, antiloop injects an "abort this pattern now" instruction into the context.
|
|
64
|
+
|
|
65
|
+
## Commands
|
|
66
|
+
|
|
67
|
+
| Command | Description |
|
|
68
|
+
|---------|-------------|
|
|
69
|
+
| `/antiloop` | Toggle on/off |
|
|
70
|
+
| `/antiloop enable` / `/antiloop disable` | Explicit enable/disable |
|
|
71
|
+
| `/antiloop status` | Show current state, configuration, and recent detections |
|
|
72
|
+
| `/antiloop config` | Open interactive configuration menu |
|
|
73
|
+
| `/antiloop log` | Show detection history (last 30, newest first) |
|
|
74
|
+
| `/antiloop reset` | Clear all counters and history |
|
|
75
|
+
| `/antiloop test` | Run a self-test of the similarity engine |
|
|
76
|
+
|
|
77
|
+
### `/antiloop status`
|
|
78
|
+
|
|
79
|
+
Example output:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
State: ✅ ENABLED
|
|
83
|
+
Current level: warning
|
|
84
|
+
Consecutive detections: 2
|
|
85
|
+
Total detections: 5
|
|
86
|
+
Messages tracked: 8
|
|
87
|
+
In forced break: no
|
|
88
|
+
|
|
89
|
+
Configuration:
|
|
90
|
+
Warning threshold: 2 similar messages
|
|
91
|
+
Force break threshold: 3 similar messages
|
|
92
|
+
Abort threshold: disabled
|
|
93
|
+
Similarity threshold: 75%
|
|
94
|
+
Detection window: 10 messages
|
|
95
|
+
|
|
96
|
+
Detection strategies:
|
|
97
|
+
Text loops: ✅
|
|
98
|
+
Tool loops: ✅
|
|
99
|
+
Thinking loops: ✅
|
|
100
|
+
|
|
101
|
+
Recent detections:
|
|
102
|
+
[text] Text similarity 85% with message 3 (2m ago)
|
|
103
|
+
[tool] Same tool calls repeated: read, edit (5m ago)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `/antiloop config`
|
|
107
|
+
|
|
108
|
+
Interactive menu with current values:
|
|
109
|
+
|
|
110
|
+
- **Enable/disable**
|
|
111
|
+
- **Warning threshold** — similar messages before warning (default 2)
|
|
112
|
+
- **Force break threshold** — similar messages before force break (default 3)
|
|
113
|
+
- **Abort threshold** — similar messages before abort (0 = disabled)
|
|
114
|
+
- **Similarity threshold** — `0.5 / 0.6 / 0.7 / 0.75 / 0.8 / 0.9` — how close two messages must be to count as looping
|
|
115
|
+
- **Detection window** — `5 / 10 / 15 / 20` — number of recent messages to analyze
|
|
116
|
+
- **Per-strategy toggles** — text / tool / thinking detection
|
|
117
|
+
- **Notifications** — show detection notifications
|
|
118
|
+
- **Reset state** — clear all counters and history
|
|
119
|
+
|
|
120
|
+
### `/antiloop log`
|
|
121
|
+
|
|
122
|
+
Shows the most recent 30 detections with similarity scores and timestamps, newest first.
|
|
123
|
+
|
|
124
|
+
### `/antiloop test`
|
|
125
|
+
|
|
126
|
+
Runs five built-in cases plus a tool-call equality check to verify the similarity engine is working correctly:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
"Hello world" vs "Hello world"
|
|
130
|
+
Similarity: 100.0% (expected: identical)
|
|
131
|
+
"Hello world" vs "Hello World!"
|
|
132
|
+
Similarity: 95.0% (expected: very similar)
|
|
133
|
+
"I will read the file first" vs "I will read the file first to understand"
|
|
134
|
+
Similarity: 85.0% (expected: similar)
|
|
135
|
+
...
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## How It Works
|
|
139
|
+
|
|
140
|
+
### Detection pipeline
|
|
141
|
+
|
|
142
|
+
After every `message_end` event, antiloop extracts the new assistant content (text, thinking, tool calls) and pushes it onto a sliding window of the last `detectionWindow + 5` messages. Then it runs the active detection strategies against the current window:
|
|
143
|
+
|
|
144
|
+
| Strategy | What it compares | Algorithm |
|
|
145
|
+
|----------|------------------|-----------|
|
|
146
|
+
| Text | Full assistant message text | n-gram Jaccard (≥ 100 chars) or Levenshtein (shorter) |
|
|
147
|
+
| Tool | Tool name + arguments | Sequence match + ≥ 80% args similarity |
|
|
148
|
+
| Thinking | Internal reasoning/thinking blocks | Same as text |
|
|
149
|
+
| Structural | First 10 words of each message | Opening-phrase similarity ≥ 80% across ≥ 3 messages |
|
|
150
|
+
|
|
151
|
+
Each detected pair becomes a `LoopDetection { type, similarity, messageIndices, description }` and the consecutive counter increases.
|
|
152
|
+
|
|
153
|
+
### Intervention levels
|
|
154
|
+
|
|
155
|
+
| Level | Trigger | Behavior |
|
|
156
|
+
|-------|---------|----------|
|
|
157
|
+
| 0 (no loop) | — | Silent — passes the message through |
|
|
158
|
+
| 1 (warning) | `consecutiveDetections >= warningThreshold` | Injects a soft reminder asking the model to vary its approach |
|
|
159
|
+
| 2 (force break) | `consecutiveDetections >= forceBreakThreshold` | Injects mandatory anti-loop instructions + appends a context message to the last assistant message |
|
|
160
|
+
| 3 (abort) | `consecutiveDetections >= abortThreshold` | (Disabled by default) Surfaces an error asking the user for new instructions |
|
|
161
|
+
|
|
162
|
+
The level never de-escalates during an active loop; user input decays the consecutive counter naturally so a fresh prompt can break the cycle.
|
|
163
|
+
|
|
164
|
+
### Similarity scoring
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
For short texts (< 100 chars): Levenshtein distance
|
|
168
|
+
"Hello world" vs "Hello World!" → 95% (1 char edit on 11 chars)
|
|
169
|
+
|
|
170
|
+
For longer texts: Character trigram Jaccard
|
|
171
|
+
"I will read the file first to understand the structure..."
|
|
172
|
+
"I will read the file first to understand the codebase..."
|
|
173
|
+
→ ~85% (many shared 3-grams)
|
|
174
|
+
|
|
175
|
+
For tool calls: sequence + per-call argument similarity ≥ 80%
|
|
176
|
+
[read({path:"/src/x.ts"}), edit({path:"/src/x.ts",...})]
|
|
177
|
+
[read({path:"/src/x.ts"}), edit({path:"/src/x.ts",...})]
|
|
178
|
+
→ matched
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Sliding window
|
|
182
|
+
|
|
183
|
+
Only the last `detectionWindow` messages participate in comparisons, so detection cost stays bounded: O(N × W) where N is the window size and W is the message size. The window is trimmed to `detectionWindow + 5` to keep a small buffer past the analysis range, avoiding edge artifacts.
|
|
184
|
+
|
|
185
|
+
## Configuration
|
|
186
|
+
|
|
187
|
+
Persisted as JSON at `~/.pi/agent/antiloop.json`:
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"enabled": true,
|
|
192
|
+
"warningThreshold": 2,
|
|
193
|
+
"forceBreakThreshold": 3,
|
|
194
|
+
"abortThreshold": 0,
|
|
195
|
+
"similarityThreshold": 0.75,
|
|
196
|
+
"detectToolLoops": true,
|
|
197
|
+
"detectThinkingLoops": true,
|
|
198
|
+
"detectTextLoops": true,
|
|
199
|
+
"notifyOnDetection": true,
|
|
200
|
+
"maxHistoryEntries": 100,
|
|
201
|
+
"detectionWindow": 10
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
| Field | Default | Description |
|
|
206
|
+
|-------|---------|-------------|
|
|
207
|
+
| `enabled` | `true` | Master switch |
|
|
208
|
+
| `warningThreshold` | `2` | Consecutive detections before warning |
|
|
209
|
+
| `forceBreakThreshold` | `3` | Consecutive detections before force break |
|
|
210
|
+
| `abortThreshold` | `0` | Consecutive detections before abort (0 = disabled) |
|
|
211
|
+
| `similarityThreshold` | `0.75` | Minimum similarity (0.0–1.0) to count a pair as looping |
|
|
212
|
+
| `detectTextLoops` | `true` | Detect full-text repetition |
|
|
213
|
+
| `detectToolLoops` | `true` | Detect tool-call sequence + argument repetition |
|
|
214
|
+
| `detectThinkingLoops` | `true` | Detect repeated thinking/reasoning content |
|
|
215
|
+
| `notifyOnDetection` | `true` | Show a notification on every detection |
|
|
216
|
+
| `maxHistoryEntries` | `100` | Max detection history entries |
|
|
217
|
+
| `detectionWindow` | `10` | Number of recent messages to analyze |
|
|
218
|
+
|
|
219
|
+
## Best Practices
|
|
220
|
+
|
|
221
|
+
1. **Start with defaults** — `warning=2 / force-break=3 / similarity=75%` works well for most models.
|
|
222
|
+
2. **Adjust sensitivity to the model** — small/local models loop more, so lower `warningThreshold` and `similarityThreshold` to catch them early. Big cloud models rarely loop, so you can raise them to avoid false positives.
|
|
223
|
+
3. **Per-strategy toggles** — if the model's reasoning legitimately repeats (e.g. it's working through a checklist), disable `thinking` detection and leave text/tool on.
|
|
224
|
+
4. **Watch the log** — `/antiloop log` shows what's actually triggering. If you see false positives, raise `similarityThreshold` instead of disabling the strategy entirely.
|
|
225
|
+
5. **Let user input clear state** — each user message decays the consecutive counter by 2, so a fresh prompt naturally resets without `/antiloop reset`.
|
|
226
|
+
6. **`/antiloop test`** — if you ever change the similarity engine, run the self-test to verify it still produces expected scores.
|
|
227
|
+
|
|
228
|
+
## Architecture
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
antiloop/
|
|
232
|
+
├── package.json # pi package manifest (pi-package)
|
|
233
|
+
├── LICENSE # MIT
|
|
234
|
+
├── README.md
|
|
235
|
+
├── docs/
|
|
236
|
+
│ ├── banner.png # wide README header
|
|
237
|
+
│ └── preview.png # npm pi.dev preview card
|
|
238
|
+
├── screenshot.png # full-res master
|
|
239
|
+
└── src/
|
|
240
|
+
└── index.ts # full extension (≈975 lines)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Single-file extension with zero external dependencies (only pi's bundled `@earendil-works/pi-coding-agent` + Node built-ins):
|
|
244
|
+
|
|
245
|
+
- **Levenshtein + trigram Jaccard** hybrid — small texts use edit distance, large texts use n-gram overlap (each is O(N) in text length)
|
|
246
|
+
- **Sliding window** — only the last `detectionWindow` messages participate, capping memory at O(W × message_size)
|
|
247
|
+
- **Early bail** — short messages and empty tool calls skip similarity computation entirely
|
|
248
|
+
- **TUI integration** — uses `ctx.ui.select` for the config menu and the log viewer; `ctx.ui.notify` for state notifications; `ctx.ui.setStatus` for the persistent status bar
|
|
249
|
+
- **Hooks** — `message_end` (track + detect), `input` (decay), `before_agent_start` (inject intervention), `context` (modify context in force-break mode), `turn_end` (refresh status), `session_start` (load config + reset)
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
[MIT](LICENSE) © Javier Noguerol
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-antiloop",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A pi extension that detects reasoning/processing loops in any model and forces a break with progressive intervention (warning → force break → abort). Text, tool, thinking and structural similarity detection with configurable thresholds and a self-test command.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"loop-detection",
|
|
8
|
+
"antiloop",
|
|
9
|
+
"reasoning",
|
|
10
|
+
"monitoring",
|
|
11
|
+
"debugging"
|
|
12
|
+
],
|
|
13
|
+
"author": "Javier Noguerol <https://github.com/noguerol>",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/noguerol/antiloop"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/noguerol/antiloop",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/noguerol/antiloop/issues"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./src/index.ts"
|
|
26
|
+
],
|
|
27
|
+
"image": "https://raw.githubusercontent.com/noguerol/antiloop/main/docs/preview.png"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,975 @@
|
|
|
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
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
33
|
+
|
|
34
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
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;
|
|
58
|
+
}
|
|
59
|
+
|
|
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
|
+
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
|
+
}
|
|
967
|
+
|
|
968
|
+
// ─── Session lifecycle ──────────────────────────────────────────────────
|
|
969
|
+
|
|
970
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
971
|
+
config = loadConfig();
|
|
972
|
+
resetState();
|
|
973
|
+
updateStatus(ctx);
|
|
974
|
+
});
|
|
975
|
+
}
|