@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 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
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./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
+ }
@@ -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
+ });