@monotykamary/pi-retry 0.2.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 +305 -0
- package/package.json +59 -0
- package/retry.ts +327 -0
- package/src/error-patterns.ts +156 -0
- package/src/index.ts +10 -0
- package/src/retry-logic.ts +141 -0
- package/vitest.config.ts +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 🔄 pi-retry
|
|
4
|
+
|
|
5
|
+
**Automatic retry for every error in [pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
6
|
+
|
|
7
|
+
_400/413, connection errors, credit errors, stream exhaustion — retry them all._
|
|
8
|
+
|
|
9
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Overview
|
|
19
|
+
|
|
20
|
+
This extension automatically detects and retries **all** errors by default, with only a tiny blacklist of known permanent failures (invalid API key, missing model, etc.).
|
|
21
|
+
|
|
22
|
+
| Error Type | Retry Behavior | Use Case |
|
|
23
|
+
|------------|----------------|----------|
|
|
24
|
+
| **Any retryable error** (catch-all) | **Indefinite** with capped backoff | Everything else — provider hiccups, stream exhaustion, credit issues, unknown errors |
|
|
25
|
+
| HTTP 400/413 | **Indefinite** with capped backoff, NO compaction | Transient context overflow that might resolve |
|
|
26
|
+
| Credit / payment errors | **Indefinite** with capped backoff | "Not Enough Credits", insufficient balance, 402 |
|
|
27
|
+
| Connection errors | **Indefinite** with capped backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
|
|
28
|
+
| Max tokens (`stopReason: "length"`) | **Auto-continue** indefinitely (invisible — no prompt pollution) | Model hits output token limit mid-generation |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## The Problem
|
|
33
|
+
|
|
34
|
+
By default, pi has built-in retry for some errors (rate limits, 5xx, overloaded), but:
|
|
35
|
+
|
|
36
|
+
1. **400/413 errors** are treated as context overflow → triggers compaction but NO retry
|
|
37
|
+
2. **Connection errors** sometimes get only limited retries before giving up
|
|
38
|
+
3. **Credit errors** ("Not Enough Credits") are never retried
|
|
39
|
+
4. **Stream exhaustion** ("Max outbound streams") and other provider-specific errors are never retried
|
|
40
|
+
5. **Any unknown error** from a new provider is silently ignored
|
|
41
|
+
|
|
42
|
+
## The Solution
|
|
43
|
+
|
|
44
|
+
This extension provides **automatic** infinite retry with sensible exponential backoff (2s → 4s → 8s → ... → 60s max).
|
|
45
|
+
|
|
46
|
+
**Philosophy: retry EVERYTHING by default.** The only things we skip are a tiny blacklist of known permanent failures (invalid API key, model not found, unsupported model, etc.).
|
|
47
|
+
|
|
48
|
+
**Features:**
|
|
49
|
+
- **Catch-all retry** — Any `stopReason: "error"` is retried, regardless of error message
|
|
50
|
+
- Automatic detection of 400/413, connection, credit, and stream exhaustion errors
|
|
51
|
+
- **Auto-continuation** when the model hits its max output tokens (`stopReason: "length"`) — indefinite, no cap, **invisible** to the LLM
|
|
52
|
+
- **Indefinite retry** — Keeps retrying until success
|
|
53
|
+
- Exponential backoff with cap: max 60s between retries
|
|
54
|
+
- **ALL triggers are invisible** — custom messages with `display: false`, stripped by context handler (no TUI clutter, no conversation pollution)
|
|
55
|
+
- Manual controls via unified `/retry` command
|
|
56
|
+
- Non-retryable errors are explicitly logged so you know why we didn't retry
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
### Option 1: Install via pi package (Recommended)
|
|
63
|
+
|
|
64
|
+
Install directly from GitHub as a pi package:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pi install https://github.com/monotykamary/pi-retry
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or add to your `settings.json`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"packages": [
|
|
75
|
+
"https://github.com/monotykamary/pi-retry"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Option 2: Global Installation
|
|
81
|
+
|
|
82
|
+
Copy the extension to pi's global extensions directory:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
cp retry.ts ~/.pi/agent/extensions/
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Option 3: Project-Local Installation
|
|
89
|
+
|
|
90
|
+
Copy to your project's `.pi/extensions/` directory:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
mkdir -p .pi/extensions
|
|
94
|
+
cp retry.ts .pi/extensions/
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Option 4: Quick Test
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pi -e ./retry.ts
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Usage
|
|
106
|
+
|
|
107
|
+
Once loaded, the extension **automatically** detects and retries all errors.
|
|
108
|
+
|
|
109
|
+
### Manual Controls
|
|
110
|
+
|
|
111
|
+
| Command | Description |
|
|
112
|
+
|---------|-------------|
|
|
113
|
+
| `/retry` | Manually trigger immediate retry (auto-detects: 400/413, credit, connection, max_tokens, or any other error) |
|
|
114
|
+
| `/retry status` | Show current retry diagnostics for all error types + continuation state |
|
|
115
|
+
| `/retry reset` | Reset all retry counters and state |
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Configuration
|
|
120
|
+
|
|
121
|
+
Edit the constants at the top of `retry.ts`:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
const BASE_DELAY_MS = 2000; // Start with 2 seconds
|
|
125
|
+
const MAX_DELAY_MS = 60000; // Cap at 60 seconds
|
|
126
|
+
const BACKOFF_MULTIPLIER = 2; // Double each time
|
|
127
|
+
// Continuation is now invisible — no CONTINUATION_PROMPT needed
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## How It Works
|
|
133
|
+
|
|
134
|
+
1. **Listen to `agent_end` event** — Fires after each agent turn completes
|
|
135
|
+
2. **Check for any error** — Examine the last assistant message for `stopReason === "error"`
|
|
136
|
+
3. **Blacklist check** — Skip known permanent failures (invalid API key, model not found, etc.)
|
|
137
|
+
4. **Categorize for messaging** — Classify into 400/413, credit, connection, or other for nice UI notifications
|
|
138
|
+
5. **Retry or continue (both invisible)** — Wait (exponential backoff for errors), then trigger a new turn via `pi.sendMessage()` with `customType`, `display: false`, and `triggerTurn: true`
|
|
139
|
+
6. **Context cleanup** — The `context` event strips all custom-type triggers before the LLM sees them (insurance against custom `convertToLlm` overrides)
|
|
140
|
+
7. **Indefinite continuation** — Max_tokens auto-continues are uncapped; each continuation produces valid output and the model naturally terminates when done
|
|
141
|
+
|
|
142
|
+
The pi's built-in `transform-messages` already strips aborted/errored assistant messages from the LLM context, so the model never sees the failed attempts.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Detected Error Patterns
|
|
147
|
+
|
|
148
|
+
### Catch-All (Any Error)
|
|
149
|
+
- **Any** assistant message with `stopReason === "error"` is retried by default
|
|
150
|
+
- Unknown provider errors, stream errors, unexpected failures — all handled automatically
|
|
151
|
+
- Only skipped if it matches a known permanent failure (invalid API key, missing model, etc.)
|
|
152
|
+
|
|
153
|
+
### Non-Retryable (Permanent Failures)
|
|
154
|
+
These are explicitly **not** retried:
|
|
155
|
+
- Invalid API key / invalid authentication
|
|
156
|
+
- API key not found / missing / revoked
|
|
157
|
+
- Model not found / unknown model / no such model / model does not exist
|
|
158
|
+
- Unsupported model
|
|
159
|
+
|
|
160
|
+
### Max Tokens (stopReason: "length")
|
|
161
|
+
- The model hit its `max_tokens` / output token limit
|
|
162
|
+
- The model's response was truncated mid-generation
|
|
163
|
+
- Auto-continuation sends an invisible custom message — no visible "Continue" prompt in the conversation
|
|
164
|
+
|
|
165
|
+
### 400/413 Errors
|
|
166
|
+
- HTTP 400 Bad Request
|
|
167
|
+
- HTTP 413 Payload Too Large
|
|
168
|
+
- "bad request" messages
|
|
169
|
+
- "payload too large" messages
|
|
170
|
+
|
|
171
|
+
### Credit / Payment Errors
|
|
172
|
+
- "Not Enough Credits"
|
|
173
|
+
- "insufficient credits"
|
|
174
|
+
- "insufficient balance"
|
|
175
|
+
- "out of credits"
|
|
176
|
+
- "Payment Required"
|
|
177
|
+
- HTTP 402 status code
|
|
178
|
+
|
|
179
|
+
### Connection Errors
|
|
180
|
+
- Connection / network errors
|
|
181
|
+
- Fetch failures
|
|
182
|
+
- Socket hang up / socket errors
|
|
183
|
+
- `ECONNRESET`, `ECONNREFUSED`, `ETIMEDOUT`, `ENOTFOUND`
|
|
184
|
+
- DNS lookup failures
|
|
185
|
+
- "Request ended without sending any chunks"
|
|
186
|
+
- Upstream connect errors
|
|
187
|
+
- TLS handshake errors
|
|
188
|
+
- Timeouts awaiting response
|
|
189
|
+
- Stream exhaustion ("Max outbound streams is 100, 100 open")
|
|
190
|
+
- Stream limit errors
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
### Running Tests
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# Run all tests
|
|
200
|
+
npm test
|
|
201
|
+
|
|
202
|
+
# Run tests in watch mode
|
|
203
|
+
npm run test:watch
|
|
204
|
+
|
|
205
|
+
# Run tests with coverage
|
|
206
|
+
npm run test:coverage
|
|
207
|
+
|
|
208
|
+
# Type check
|
|
209
|
+
npm run typecheck
|
|
210
|
+
|
|
211
|
+
# Dead code detection
|
|
212
|
+
npm run lint:dead
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Project Structure
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
.
|
|
219
|
+
├── retry.ts # Main unified extension
|
|
220
|
+
├── src/ # Shared utilities (testable, DRY)
|
|
221
|
+
│ ├── error-patterns.ts # Error pattern matching, custom types, hasMaxTokensStop
|
|
222
|
+
│ ├── retry-logic.ts # Retry utilities (calculateDelay, RetryState, ContinuationState, etc.)
|
|
223
|
+
│ └── index.ts # Barrel exports
|
|
224
|
+
├── __tests__/ # Unit tests
|
|
225
|
+
│ └── unit/
|
|
226
|
+
│ ├── error-patterns.test.ts
|
|
227
|
+
│ └── retry-logic.test.ts
|
|
228
|
+
├── vitest.config.ts # Test configuration
|
|
229
|
+
└── knip.json # Dead code detection config
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Code Quality
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
# Run all quality checks
|
|
236
|
+
npm test # 99 unit tests
|
|
237
|
+
npm run typecheck # TypeScript type checking
|
|
238
|
+
npm run lint:dead # Dead code detection with knip
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Troubleshooting
|
|
244
|
+
|
|
245
|
+
### Extension not working?
|
|
246
|
+
|
|
247
|
+
Check that it's loaded in the startup header:
|
|
248
|
+
```
|
|
249
|
+
Loaded extensions: retry.ts
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Retry not triggering?
|
|
253
|
+
|
|
254
|
+
Use the status command to diagnose:
|
|
255
|
+
```
|
|
256
|
+
/retry status
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Want to see what's happening?
|
|
260
|
+
|
|
261
|
+
The extensions send notifications on retry attempts. Look at the footer status line for retry status updates. Non-retryable errors are logged as errors so you know why we stopped.
|
|
262
|
+
|
|
263
|
+
### Too many retries?
|
|
264
|
+
|
|
265
|
+
Use `/retry reset` to clear the counters, or press `Ctrl+C` to abort the session.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Comparison with @georgebashi/pi-retry
|
|
270
|
+
|
|
271
|
+
The npm package `@georgebashi/pi-retry` handles "aborted" streaming errors but explicitly excludes "connection error" (assuming pi's built-in retry handles it). This extension:
|
|
272
|
+
|
|
273
|
+
1. **Handles ALL errors** via a catch-all — no more playing whack-a-mole with new error patterns
|
|
274
|
+
2. **Handles connection errors** that pi might not retry sufficiently
|
|
275
|
+
3. **Handles 400/413 errors** without compaction
|
|
276
|
+
4. **Handles credit errors** and stream exhaustion
|
|
277
|
+
|
|
278
|
+
They can work together for maximum coverage:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
pi install npm:@georgebashi/pi-retry
|
|
282
|
+
# Plus install this extension
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Limitations
|
|
288
|
+
|
|
289
|
+
- Extensions cannot override pi's internal `isRetryableError()` check — they run *after* pi decides not to auto-retry
|
|
290
|
+
- Error messages remain in the session history (but are invisible to the LLM)
|
|
291
|
+
- May hit the same error repeatedly if the issue is persistent (use `Ctrl+C` to abort)
|
|
292
|
+
- **Warning**: Retrying 400/413 without reducing context may fail repeatedly if the payload is genuinely too large
|
|
293
|
+
- Non-retryable errors (invalid API key, missing model) are logged but not retried — you'll need to fix the underlying issue
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Related
|
|
298
|
+
|
|
299
|
+
- [Pi Coding Agent Extensions Docs](https://github.com/badlogic/pi/tree/main/packages/coding-agent/docs/extensions.md)
|
|
300
|
+
- [@georgebashi/pi-retry](https://github.com/georgebashi/pi-retry) — Handles "aborted" streaming errors
|
|
301
|
+
- [Issue #252: Connection error with no retry](https://github.com/badlogic/pi-mono/issues/252)
|
|
302
|
+
|
|
303
|
+
## License
|
|
304
|
+
|
|
305
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@monotykamary/pi-retry",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Extension suite for pi coding agent that handles 400/413 errors and connection errors with automatic retry",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Tom X Nguyen",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/monotykamary/pi-retry.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/monotykamary/pi-retry#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/monotykamary/pi-retry/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"pi-coding-agent",
|
|
20
|
+
"extension",
|
|
21
|
+
"retry",
|
|
22
|
+
"error-handling",
|
|
23
|
+
"http-400",
|
|
24
|
+
"http-413",
|
|
25
|
+
"connection-error",
|
|
26
|
+
"network-error",
|
|
27
|
+
"infinite-retry",
|
|
28
|
+
"cli"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"*.ts",
|
|
32
|
+
"src/",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest",
|
|
38
|
+
"test:coverage": "vitest run --coverage",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"lint:dead": "knip --no-gitignore"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-agent-core": "0.75.4",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "0.75.4",
|
|
45
|
+
"@types/node": "25.9.1",
|
|
46
|
+
"@vitest/coverage-v8": "4.1.7",
|
|
47
|
+
"knip": "6.14.1",
|
|
48
|
+
"typescript": "6.0.3",
|
|
49
|
+
"vitest": "4.1.7"
|
|
50
|
+
},
|
|
51
|
+
"pi": {
|
|
52
|
+
"extensions": [
|
|
53
|
+
"./retry.ts"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"overrides": {
|
|
57
|
+
"brace-expansion": "5.0.6"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/retry.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
RETRY_TRIGGER_CUSTOM_TYPE,
|
|
4
|
+
CONTINUATION_CUSTOM_TYPE,
|
|
5
|
+
has400or413Error,
|
|
6
|
+
hasCreditError,
|
|
7
|
+
hasConnectionError,
|
|
8
|
+
hasRetryableError,
|
|
9
|
+
isNonRetryableError,
|
|
10
|
+
hasMaxTokensStop,
|
|
11
|
+
isAssistantMessage,
|
|
12
|
+
getLastAssistantMessage,
|
|
13
|
+
calculateDelay,
|
|
14
|
+
formatDuration,
|
|
15
|
+
getErrorCategory,
|
|
16
|
+
RetryState,
|
|
17
|
+
ContinuationState,
|
|
18
|
+
} from "./src/index.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Unified retry extension — retries EVERY error by default.
|
|
22
|
+
*
|
|
23
|
+
* Philosophy: any assistant message with stopReason === "error" is retried
|
|
24
|
+
* indefinitely with exponential backoff, except a tiny blacklist of known
|
|
25
|
+
* permanent failures (invalid API key, model not found, etc.).
|
|
26
|
+
*
|
|
27
|
+
* Specific categories (400/413, credit, connection, stream exhaustion, etc.)
|
|
28
|
+
* are tracked for diagnostics but all share the same retry mechanism.
|
|
29
|
+
*
|
|
30
|
+
* Features:
|
|
31
|
+
* - Automatic detection and retry for ALL errors (catch-all)
|
|
32
|
+
* - Indefinite retry with exponential backoff (capped at 60s)
|
|
33
|
+
* - Auto-continuation when model hits max output tokens (stopReason "length")
|
|
34
|
+
* - ALL triggers are invisible — custom messages with display:false, stripped by context handler
|
|
35
|
+
* - Unified manual controls via /retry command
|
|
36
|
+
*
|
|
37
|
+
* Silent continue trick (ported from pi-invisible-continue):
|
|
38
|
+
* - sendMessage() with customType + display:false + triggerTurn:true
|
|
39
|
+
* - pi's default convertToLlm filters custom-role messages → LLM never sees them
|
|
40
|
+
* - context event handler strips them as insurance against custom convertToLlm overrides
|
|
41
|
+
* - No user-visible "Continue" message pollution in the conversation
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
// Per-category retry state (for diagnostics / messaging)
|
|
45
|
+
const state400 = new RetryState();
|
|
46
|
+
const stateCredit = new RetryState();
|
|
47
|
+
const stateConnection = new RetryState();
|
|
48
|
+
const stateOther = new RetryState();
|
|
49
|
+
|
|
50
|
+
// Max_tokens continuation state (indefinite — no cap needed)
|
|
51
|
+
const stateContinuation = new ContinuationState();
|
|
52
|
+
|
|
53
|
+
// Sleep helper
|
|
54
|
+
function sleep(ms: number): Promise<void> {
|
|
55
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default function (pi: ExtensionAPI) {
|
|
59
|
+
|
|
60
|
+
// Reset retry counters on successful completion (not max_tokens, not error)
|
|
61
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
62
|
+
const msg = event.message as any;
|
|
63
|
+
if (msg.role === "assistant" && msg.stopReason !== "error") {
|
|
64
|
+
if (msg.stopReason === "aborted") {
|
|
65
|
+
// User cancelled — reset retry state so it doesn't leak into other
|
|
66
|
+
// branches of the session tree.
|
|
67
|
+
state400.reset();
|
|
68
|
+
stateCredit.reset();
|
|
69
|
+
stateConnection.reset();
|
|
70
|
+
stateOther.reset();
|
|
71
|
+
// Do NOT reset continuation state — a user abort of a continuation
|
|
72
|
+
// turn is different from aborting an error retry.
|
|
73
|
+
stateContinuation.endContinuation();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (msg.stopReason !== "length") {
|
|
77
|
+
// Normal completion — reset everything including continuation count
|
|
78
|
+
state400.succeed();
|
|
79
|
+
stateCredit.succeed();
|
|
80
|
+
stateConnection.succeed();
|
|
81
|
+
stateOther.succeed();
|
|
82
|
+
stateContinuation.complete();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Handle errors and max_tokens on agent_end
|
|
88
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
89
|
+
const entries = ctx.sessionManager.getEntries();
|
|
90
|
+
const lastAssistant = getLastAssistantMessage(entries);
|
|
91
|
+
|
|
92
|
+
if (!lastAssistant || !isAssistantMessage(lastAssistant)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check for max_tokens stop — auto-continue (silent, invisible to LLM)
|
|
97
|
+
if (hasMaxTokensStop(lastAssistant) && !stateContinuation.getIsContinuing()) {
|
|
98
|
+
stateContinuation.startContinuation();
|
|
99
|
+
ctx.ui.notify(
|
|
100
|
+
`Max tokens reached — auto-continuing (continuation ${stateContinuation.getCount()})...`,
|
|
101
|
+
"info",
|
|
102
|
+
);
|
|
103
|
+
triggerContinuation(pi);
|
|
104
|
+
stateContinuation.endContinuation();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Catch-all: retry ANY error except known permanent failures
|
|
109
|
+
if (hasRetryableError(lastAssistant)) {
|
|
110
|
+
const errorMsg = lastAssistant.errorMessage || "Unknown error";
|
|
111
|
+
const category = getErrorCategory(errorMsg);
|
|
112
|
+
|
|
113
|
+
// Pick the right state tracker for diagnostics
|
|
114
|
+
let state: RetryState;
|
|
115
|
+
let label: string;
|
|
116
|
+
if (category === "400-413") {
|
|
117
|
+
state = state400;
|
|
118
|
+
label = "400/413";
|
|
119
|
+
} else if (category === "credit") {
|
|
120
|
+
state = stateCredit;
|
|
121
|
+
label = "Credit";
|
|
122
|
+
} else if (category === "connection") {
|
|
123
|
+
state = stateConnection;
|
|
124
|
+
label = "Connection";
|
|
125
|
+
} else {
|
|
126
|
+
state = stateOther;
|
|
127
|
+
label = category === "builtin" ? "Server" : "Other";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (state.getIsRetrying()) return;
|
|
131
|
+
|
|
132
|
+
state.startRetry(errorMsg);
|
|
133
|
+
const delay = calculateDelay(state.getAttempt());
|
|
134
|
+
|
|
135
|
+
await sleep(delay);
|
|
136
|
+
triggerRetry(pi);
|
|
137
|
+
state.endRetry();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Log non-retryable errors so the user knows why we didn't retry
|
|
142
|
+
if (isNonRetryableError(lastAssistant)) {
|
|
143
|
+
const errorMsg = lastAssistant.errorMessage || "Unknown error";
|
|
144
|
+
ctx.ui.notify(`Non-retryable error (not retried): ${errorMsg.substring(0, 100)}`, "error");
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
// Strip hidden retry/continuation markers from context before each LLM call.
|
|
151
|
+
// This is insurance — convertToLlm already filters custom roles, but a
|
|
152
|
+
// custom convertToLlm override could leak them. Clean proactively.
|
|
153
|
+
pi.on("context", async (event) => {
|
|
154
|
+
const cleaned = event.messages.filter(
|
|
155
|
+
(msg: any) =>
|
|
156
|
+
!(
|
|
157
|
+
msg.role === "custom" &&
|
|
158
|
+
(msg.customType === RETRY_TRIGGER_CUSTOM_TYPE ||
|
|
159
|
+
msg.customType === CONTINUATION_CUSTOM_TYPE)
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
if (cleaned.length !== event.messages.length) {
|
|
163
|
+
return { messages: cleaned };
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Unified /retry command with subcommands
|
|
168
|
+
pi.registerCommand("retry", {
|
|
169
|
+
description: "Unified retry controls: /retry (manual trigger), /retry status (diagnostics), /retry reset (clear state)",
|
|
170
|
+
handler: async (args, ctx) => {
|
|
171
|
+
const subcommand = args[0]?.toLowerCase();
|
|
172
|
+
|
|
173
|
+
// /retry status - Show diagnostics
|
|
174
|
+
if (subcommand === "status") {
|
|
175
|
+
const entries = ctx.sessionManager.getEntries();
|
|
176
|
+
const lastAssistant = getLastAssistantMessage(entries);
|
|
177
|
+
|
|
178
|
+
let status = "=== Retry Status ===\n\n";
|
|
179
|
+
|
|
180
|
+
// 400/413 state
|
|
181
|
+
status += "400/413 Errors:\n";
|
|
182
|
+
status += ` Current attempt: ${state400.getAttempt()}\n`;
|
|
183
|
+
status += ` Is retrying: ${state400.getIsRetrying()}\n`;
|
|
184
|
+
status += ` Last error: ${state400.getLastErrorMessage().substring(0, 100) || "None"}\n\n`;
|
|
185
|
+
|
|
186
|
+
// Credit state
|
|
187
|
+
status += "Credit Errors:\n";
|
|
188
|
+
status += ` Current attempt: ${stateCredit.getAttempt()}\n`;
|
|
189
|
+
status += ` Is retrying: ${stateCredit.getIsRetrying()}\n`;
|
|
190
|
+
status += ` Last error: ${stateCredit.getLastErrorMessage().substring(0, 100) || "None"}\n\n`;
|
|
191
|
+
|
|
192
|
+
// Connection state
|
|
193
|
+
status += "Connection Errors:\n";
|
|
194
|
+
status += ` Current attempt: ${stateConnection.getAttempt()}\n`;
|
|
195
|
+
status += ` Is retrying: ${stateConnection.getIsRetrying()}\n`;
|
|
196
|
+
status += ` Last error: ${stateConnection.getLastErrorMessage().substring(0, 100) || "None"}\n\n`;
|
|
197
|
+
|
|
198
|
+
// Other / catch-all state
|
|
199
|
+
status += "Other Errors (catch-all):\n";
|
|
200
|
+
status += ` Current attempt: ${stateOther.getAttempt()}\n`;
|
|
201
|
+
status += ` Is retrying: ${stateOther.getIsRetrying()}\n`;
|
|
202
|
+
status += ` Last error: ${stateOther.getLastErrorMessage().substring(0, 100) || "None"}\n\n`;
|
|
203
|
+
|
|
204
|
+
// Continuation state
|
|
205
|
+
status += "Max Tokens Continuation:\n";
|
|
206
|
+
status += ` Continuations used: ${stateContinuation.getCount()}\n`;
|
|
207
|
+
status += ` Is continuing: ${stateContinuation.getIsContinuing()}\n`;
|
|
208
|
+
status += ` Trigger: invisible (custom message, LLM never sees a prompt)\n\n`;
|
|
209
|
+
|
|
210
|
+
// Config
|
|
211
|
+
status += "Configuration:\n";
|
|
212
|
+
status += ` Base delay: 2000ms\n`;
|
|
213
|
+
status += ` Max delay: 60000ms\n`;
|
|
214
|
+
status += ` Backoff multiplier: 2\n`;
|
|
215
|
+
status += ` Continuation: invisible custom message\n\n`;
|
|
216
|
+
|
|
217
|
+
// Last assistant info
|
|
218
|
+
if (lastAssistant && isAssistantMessage(lastAssistant)) {
|
|
219
|
+
status += "Last Assistant Message:\n";
|
|
220
|
+
status += ` Stop reason: ${lastAssistant.stopReason}\n`;
|
|
221
|
+
status += ` Error message: ${lastAssistant.errorMessage?.substring(0, 100) || "None"}\n`;
|
|
222
|
+
if (lastAssistant.errorMessage) {
|
|
223
|
+
status += ` Error category: ${getErrorCategory(lastAssistant.errorMessage)}`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
ctx.ui.notify(status, "info");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// /retry reset - Reset all state
|
|
232
|
+
if (subcommand === "reset") {
|
|
233
|
+
state400.reset();
|
|
234
|
+
stateCredit.reset();
|
|
235
|
+
stateConnection.reset();
|
|
236
|
+
stateOther.reset();
|
|
237
|
+
stateContinuation.reset();
|
|
238
|
+
ctx.ui.notify("All retry counters reset", "info");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// /retry (no args) - Manual trigger with auto-detection
|
|
243
|
+
const entries = ctx.sessionManager.getEntries();
|
|
244
|
+
const lastAssistant = getLastAssistantMessage(entries);
|
|
245
|
+
|
|
246
|
+
if (!lastAssistant || !isAssistantMessage(lastAssistant)) {
|
|
247
|
+
ctx.ui.notify("No assistant message found to retry", "warning");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Auto-detect: max_tokens continuation takes priority
|
|
252
|
+
if (hasMaxTokensStop(lastAssistant)) {
|
|
253
|
+
ctx.ui.notify("Manually continuing after max_tokens...", "info");
|
|
254
|
+
triggerContinuation(pi);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Auto-detect error type and trigger appropriate retry
|
|
259
|
+
if (has400or413Error(lastAssistant)) {
|
|
260
|
+
ctx.ui.notify("Manually retrying 400/413 error...", "info");
|
|
261
|
+
state400.reset();
|
|
262
|
+
triggerRetry(pi);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (hasCreditError(lastAssistant)) {
|
|
267
|
+
ctx.ui.notify("Manually retrying credit error...", "info");
|
|
268
|
+
stateCredit.reset();
|
|
269
|
+
triggerRetry(pi);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (hasConnectionError(lastAssistant)) {
|
|
274
|
+
ctx.ui.notify("Manually retrying connection error...", "info");
|
|
275
|
+
stateConnection.reset();
|
|
276
|
+
triggerRetry(pi);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Catch-all: any other retryable error
|
|
281
|
+
if (hasRetryableError(lastAssistant)) {
|
|
282
|
+
ctx.ui.notify("Manually retrying error...", "info");
|
|
283
|
+
stateOther.reset();
|
|
284
|
+
triggerRetry(pi);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// No error detected - show status instead
|
|
289
|
+
ctx.ui.notify("No retryable error detected. Use '/retry status' for diagnostics.", "warning");
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Initialize
|
|
294
|
+
pi.on("session_start", async () => {
|
|
295
|
+
state400.reset();
|
|
296
|
+
stateCredit.reset();
|
|
297
|
+
stateConnection.reset();
|
|
298
|
+
stateOther.reset();
|
|
299
|
+
stateContinuation.reset();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Helper: send the hidden retry trigger
|
|
303
|
+
function triggerRetry(pi: ExtensionAPI) {
|
|
304
|
+
pi.sendMessage(
|
|
305
|
+
{
|
|
306
|
+
customType: RETRY_TRIGGER_CUSTOM_TYPE,
|
|
307
|
+
content: "",
|
|
308
|
+
display: false,
|
|
309
|
+
details: {},
|
|
310
|
+
},
|
|
311
|
+
{ triggerTurn: true },
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Helper: send the hidden continuation trigger (silent — LLM never sees a prompt)
|
|
316
|
+
function triggerContinuation(pi: ExtensionAPI) {
|
|
317
|
+
pi.sendMessage(
|
|
318
|
+
{
|
|
319
|
+
customType: CONTINUATION_CUSTOM_TYPE,
|
|
320
|
+
content: "",
|
|
321
|
+
display: false,
|
|
322
|
+
details: {},
|
|
323
|
+
},
|
|
324
|
+
{ triggerTurn: true },
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error pattern matching utilities for retry extensions
|
|
3
|
+
*
|
|
4
|
+
* Philosophy: retry EVERY error by default. The only things we skip are a
|
|
5
|
+
* tiny blacklist of known permanent failures (e.g. invalid API key, model
|
|
6
|
+
* does not exist). Everything else — 400s, connection issues, credit errors,
|
|
7
|
+
* stream exhaustion, provider hiccups, unknown errors — is retried.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
11
|
+
|
|
12
|
+
// Custom message types for invisible triggers.
|
|
13
|
+
// These are sent with role="custom" and display=false, so pi's default
|
|
14
|
+
// convertToLlm filters them out. The context event handler also strips
|
|
15
|
+
// them as insurance.
|
|
16
|
+
|
|
17
|
+
/** Custom type used for the invisible error-retry trigger. */
|
|
18
|
+
export const RETRY_TRIGGER_CUSTOM_TYPE = "__retry_trigger";
|
|
19
|
+
|
|
20
|
+
/** Custom type used for the invisible max_tokens continuation trigger. */
|
|
21
|
+
export const CONTINUATION_CUSTOM_TYPE = "__retry_continuation";
|
|
22
|
+
|
|
23
|
+
// ── Specific pattern groups (used for categorisation / messaging) ──
|
|
24
|
+
|
|
25
|
+
const ERROR_400_413_PATTERNS = [
|
|
26
|
+
/\b4(00|13)\b.*status code/i,
|
|
27
|
+
/bad request/i,
|
|
28
|
+
/payload too large/i,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const CREDIT_ERROR_PATTERNS = [
|
|
32
|
+
/not enough credits/i,
|
|
33
|
+
/insufficient credits/i,
|
|
34
|
+
/insufficient balance/i,
|
|
35
|
+
/out of credits/i,
|
|
36
|
+
/payment required/i,
|
|
37
|
+
/\b402\b.*status code/i,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export const CONNECTION_ERROR_PATTERNS = [
|
|
41
|
+
/connection\s*error/i,
|
|
42
|
+
/network\s*error/i,
|
|
43
|
+
/fetch\s*failed/i,
|
|
44
|
+
/socket\s*(hang\s*up|error|timeout)/i,
|
|
45
|
+
/econnreset/i,
|
|
46
|
+
/econnrefused/i,
|
|
47
|
+
/etimedout/i,
|
|
48
|
+
/enotfound/i,
|
|
49
|
+
/dns\s*lookup\s*failed/i,
|
|
50
|
+
/request\s*ended\s*without\s*sending\s*any\s*chunks/i,
|
|
51
|
+
/upstream\s*connect/i,
|
|
52
|
+
/other\s*side\s*closed/i,
|
|
53
|
+
/reset\s*before\s*headers/i,
|
|
54
|
+
/broken\s*pipe/i,
|
|
55
|
+
/unexpected\s*end\s*of\s*file/i,
|
|
56
|
+
/tls\s*handshake\s*(error|timeout)/i,
|
|
57
|
+
/ssl\s*connection\s*error/i,
|
|
58
|
+
/timeout\s*(awaiting|waiting\s*for)\s*response/i,
|
|
59
|
+
/request\s*timeout/i,
|
|
60
|
+
// Stream exhaustion (e.g. "Max outbound streams is 100, 100 open")
|
|
61
|
+
/max outbound streams/i,
|
|
62
|
+
/streams?\s*(exhausted|limit)/i,
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// Patterns handled by pi's built-in retry — used for categorisation only
|
|
66
|
+
const BUILTIN_HANDLED_PATTERNS = [
|
|
67
|
+
/overloaded/i,
|
|
68
|
+
/rate\s*limit/i,
|
|
69
|
+
/too\s*many\s*requests/i,
|
|
70
|
+
/429/i,
|
|
71
|
+
/5\d{2}/,
|
|
72
|
+
/service\s*unavailable/i,
|
|
73
|
+
/server\s*error/i,
|
|
74
|
+
/internal\s*error/i,
|
|
75
|
+
/retry\s*delay/i,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// ── Blacklist: errors that are truly permanent and should NOT be retried ──
|
|
79
|
+
|
|
80
|
+
const NON_RETRYABLE_PATTERNS = [
|
|
81
|
+
/invalid\s*api\s*key/i,
|
|
82
|
+
/invalid\s*authentication/i,
|
|
83
|
+
/api\s*key\s*(not\s*found|missing|revoked)/i,
|
|
84
|
+
/model\s*not\s*found/i,
|
|
85
|
+
/unknown\s*model/i,
|
|
86
|
+
/no\s*such\s*model/i,
|
|
87
|
+
/model\s*does\s*not\s*exist/i,
|
|
88
|
+
/unsupported\s*model/i,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// ── Type guard ──
|
|
92
|
+
|
|
93
|
+
export function isAssistantMessage(message: AgentMessage): message is Extract<AgentMessage, { role: "assistant" }> {
|
|
94
|
+
return message.role === "assistant";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Specific category checks (for diagnostics / messaging) ──
|
|
98
|
+
|
|
99
|
+
export function has400or413Error(message: AgentMessage): boolean {
|
|
100
|
+
if (!isAssistantMessage(message)) return false;
|
|
101
|
+
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
102
|
+
return ERROR_400_413_PATTERNS.some(p => p.test(message.errorMessage!));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function hasCreditError(message: AgentMessage): boolean {
|
|
106
|
+
if (!isAssistantMessage(message)) return false;
|
|
107
|
+
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
108
|
+
return CREDIT_ERROR_PATTERNS.some(p => p.test(message.errorMessage!));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function hasConnectionError(message: AgentMessage): boolean {
|
|
112
|
+
if (!isAssistantMessage(message)) return false;
|
|
113
|
+
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
114
|
+
return CONNECTION_ERROR_PATTERNS.some(p => p.test(message.errorMessage!));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Universal retry check ──
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns true for ANY assistant message with stopReason === "error"
|
|
121
|
+
* except a tiny blacklist of known permanent failures.
|
|
122
|
+
*/
|
|
123
|
+
export function hasRetryableError(message: AgentMessage): boolean {
|
|
124
|
+
if (!isAssistantMessage(message)) return false;
|
|
125
|
+
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
126
|
+
return !NON_RETRYABLE_PATTERNS.some(p => p.test(message.errorMessage));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Returns true only for known permanent failures (invalid API key, missing model, etc.)
|
|
131
|
+
*/
|
|
132
|
+
export function isNonRetryableError(message: AgentMessage): boolean {
|
|
133
|
+
if (!isAssistantMessage(message)) return false;
|
|
134
|
+
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
135
|
+
return NON_RETRYABLE_PATTERNS.some(p => p.test(message.errorMessage));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Categorisation (for UI messages) ──
|
|
139
|
+
|
|
140
|
+
export function getErrorCategory(errorMessage: string): '400-413' | 'credit' | 'connection' | 'builtin' | 'other' {
|
|
141
|
+
if (ERROR_400_413_PATTERNS.some(p => p.test(errorMessage))) return '400-413';
|
|
142
|
+
if (CREDIT_ERROR_PATTERNS.some(p => p.test(errorMessage))) return 'credit';
|
|
143
|
+
if (CONNECTION_ERROR_PATTERNS.some(p => p.test(errorMessage))) return 'connection';
|
|
144
|
+
if (BUILTIN_HANDLED_PATTERNS.some(p => p.test(errorMessage))) return 'builtin';
|
|
145
|
+
return 'other';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Max tokens (not an error — continuation) ──
|
|
149
|
+
|
|
150
|
+
export function hasMaxTokensStop(message: AgentMessage): boolean {
|
|
151
|
+
if (!isAssistantMessage(message)) return false;
|
|
152
|
+
return message.stopReason === "length";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Re-export getLastAssistantMessage for convenience
|
|
156
|
+
export { getLastAssistantMessage } from './retry-logic.js';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for pi-retry extensions
|
|
3
|
+
*
|
|
4
|
+
* This module provides testable pure functions for:
|
|
5
|
+
* - Error pattern matching (400/413, connection errors, max_tokens)
|
|
6
|
+
* - Retry logic (exponential backoff, state management)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export * from './error-patterns.js';
|
|
10
|
+
export * from './retry-logic.js';
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry logic utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configuration for exponential backoff
|
|
9
|
+
*/
|
|
10
|
+
export interface BackoffConfig {
|
|
11
|
+
baseDelayMs: number;
|
|
12
|
+
maxDelayMs: number;
|
|
13
|
+
multiplier: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default backoff configuration
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_BACKOFF_CONFIG: BackoffConfig = {
|
|
20
|
+
baseDelayMs: 2000,
|
|
21
|
+
maxDelayMs: 60000,
|
|
22
|
+
multiplier: 2,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Calculate delay with exponential backoff and cap
|
|
27
|
+
*/
|
|
28
|
+
export function calculateDelay(attempt: number, config: BackoffConfig = DEFAULT_BACKOFF_CONFIG): number {
|
|
29
|
+
const delay = config.baseDelayMs * Math.pow(config.multiplier, attempt - 1);
|
|
30
|
+
return Math.min(delay, config.maxDelayMs);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Format duration for display
|
|
35
|
+
*/
|
|
36
|
+
export function formatDuration(ms: number): string {
|
|
37
|
+
if (ms < 1000) return `${ms}ms`;
|
|
38
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
39
|
+
const minutes = Math.floor(ms / 60000);
|
|
40
|
+
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
|
41
|
+
return `${minutes}m ${seconds}s`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the last assistant message from session entries
|
|
46
|
+
*/
|
|
47
|
+
export function getLastAssistantMessage(entries: unknown[]): AgentMessage | undefined {
|
|
48
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
49
|
+
const entry = entries[i] as { type?: string; message?: AgentMessage };
|
|
50
|
+
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
51
|
+
return entry.message;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Retry state manager for tracking attempts
|
|
59
|
+
*/
|
|
60
|
+
export class RetryState {
|
|
61
|
+
private attempt = 0;
|
|
62
|
+
private isRetrying = false;
|
|
63
|
+
private lastErrorMessage = "";
|
|
64
|
+
|
|
65
|
+
getAttempt(): number {
|
|
66
|
+
return this.attempt;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getIsRetrying(): boolean {
|
|
70
|
+
return this.isRetrying;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getLastErrorMessage(): string {
|
|
74
|
+
return this.lastErrorMessage;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
startRetry(errorMessage: string): void {
|
|
78
|
+
this.isRetrying = true;
|
|
79
|
+
this.attempt++;
|
|
80
|
+
this.lastErrorMessage = errorMessage;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
endRetry(): void {
|
|
84
|
+
this.isRetrying = false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
reset(): void {
|
|
88
|
+
this.attempt = 0;
|
|
89
|
+
this.isRetrying = false;
|
|
90
|
+
this.lastErrorMessage = "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
succeed(): void {
|
|
94
|
+
this.attempt = 0;
|
|
95
|
+
this.isRetrying = false;
|
|
96
|
+
this.lastErrorMessage = "";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* State manager for tracking max_tokens continuations.
|
|
102
|
+
*
|
|
103
|
+
* Unlike RetryState (which caps nothing but counts retries), continuations are
|
|
104
|
+
* also uncapped — each one produces valid output and the model naturally
|
|
105
|
+
* terminates when done, so there is no reason to impose a limit.
|
|
106
|
+
*/
|
|
107
|
+
export class ContinuationState {
|
|
108
|
+
private count = 0;
|
|
109
|
+
private isContinuing = false;
|
|
110
|
+
|
|
111
|
+
getCount(): number {
|
|
112
|
+
return this.count;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getIsContinuing(): boolean {
|
|
116
|
+
return this.isContinuing;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
startContinuation(): void {
|
|
120
|
+
this.isContinuing = true;
|
|
121
|
+
this.count++;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
endContinuation(): void {
|
|
125
|
+
this.isContinuing = false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Called when a turn completes without hitting max_tokens.
|
|
130
|
+
* Resets the counter since the model finished normally.
|
|
131
|
+
*/
|
|
132
|
+
complete(): void {
|
|
133
|
+
this.count = 0;
|
|
134
|
+
this.isContinuing = false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
reset(): void {
|
|
138
|
+
this.count = 0;
|
|
139
|
+
this.isContinuing = false;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
include: ['__tests__/**/*.test.ts'],
|
|
8
|
+
exclude: ['node_modules', 'dist', '.idea', '.git', '.cache'],
|
|
9
|
+
coverage: {
|
|
10
|
+
provider: 'v8',
|
|
11
|
+
reporter: ['text', 'json', 'html'],
|
|
12
|
+
exclude: ['node_modules/', '**/*.d.ts', '**/*.test.ts'],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|