@mehdisadeghi/pi-retry 0.11.2 → 0.12.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 +34 -48
- package/package.json +4 -4
- package/retry.ts +16 -87
- package/src/child-retry.ts +2 -48
- package/src/config.ts +2 -18
- package/src/error-patterns.ts +15 -133
package/README.md
CHANGED
|
@@ -25,15 +25,17 @@ Run `bun run test:host` for the offline real-host load, native codemode/nested-c
|
|
|
25
25
|
|
|
26
26
|
## Overview
|
|
27
27
|
|
|
28
|
-
This extension automatically detects and retries **all** errors
|
|
28
|
+
This extension automatically detects and retries **all** errors, forever, until you abort. There is no error blacklist and no failure cap: invalid API keys, missing models, quota/budget exhaustion and context overflow are retried like a socket hiccup.
|
|
29
29
|
|
|
30
30
|
| Error Type | Retry Behavior | Use Case |
|
|
31
31
|
|------------|----------------|----------|
|
|
32
|
-
| **Any
|
|
33
|
-
| HTTP 400/413 | **
|
|
34
|
-
| Credit / payment errors | **
|
|
35
|
-
| **Quota / session-limit / budget exhaustion** | **
|
|
36
|
-
|
|
|
32
|
+
| **Any error** (catch-all) | **Uncapped** exponential retry | Everything, including unknown errors |
|
|
33
|
+
| HTTP 400/413 | **Uncapped** with exponential backoff | Transient context overflow that might resolve |
|
|
34
|
+
| Credit / payment errors | **Uncapped** with exponential backoff | "Not Enough Credits", insufficient balance, 402 — top up and the retry loop auto-resumes |
|
|
35
|
+
| **Quota / session-limit / budget exhaustion** | **Retried** — labelled in `/retry status` | "You've hit your limit", `insufficient_quota`, "out of budget", suspended accounts |
|
|
36
|
+
| Invalid API key / unknown model | **Retried** — no exception | The key or model may be fixed mid-loop |
|
|
37
|
+
| Context overflow | **Retried** alongside SDK compaction | Oversized payload; either path can recover the turn |
|
|
38
|
+
| Connection errors | **Uncapped** with exponential backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
|
|
37
39
|
| Max tokens (`stopReason: "length"`) | **Auto-continue** indefinitely with hidden continuation turns | Model hits output token limit mid-generation |
|
|
38
40
|
| Empty / think-only stop (`stopReason: "stop"` with no text or tool calls) | **Nudge once** with a hidden continuation, then give up | Model ends its turn with no usable output (Anthropic empty responses with end_turn, thinking-only turns) |
|
|
39
41
|
|
|
@@ -51,20 +53,18 @@ By default, pi has built-in retry for some errors (rate limits, 5xx, overloaded)
|
|
|
51
53
|
|
|
52
54
|
## The Solution
|
|
53
55
|
|
|
54
|
-
This extension provides automatic retry for all errors with configurable exponential backoff
|
|
56
|
+
This extension provides automatic retry for all errors with configurable exponential backoff (2s → 4s → 8s → ... → 60s by default) and keeps retrying at the maximum delay forever.
|
|
55
57
|
|
|
56
|
-
**Philosophy: retry EVERYTHING
|
|
58
|
+
**Philosophy: retry EVERYTHING, forever.** Nothing is excluded — not an invalid API key, not a missing model, not an exhausted quota. Only a user abort (ESC), fresh input, or a session change stops the loop.
|
|
57
59
|
|
|
58
60
|
**Features:**
|
|
59
61
|
- **Catch-all retry** — Any `stopReason: "error"` is retried, regardless of error message
|
|
60
62
|
- Automatic detection of 400/413, connection, credit, and stream exhaustion errors
|
|
61
63
|
- **Auto-continuation** when the model hits its max output tokens (`stopReason: "length"`) — indefinite, no cap, hidden from the TUI
|
|
62
|
-
- **
|
|
63
|
-
-
|
|
64
|
-
- Exponential backoff with configurable base delay, cap, multiplier, and maximum-delay failure count
|
|
64
|
+
- **No cutoff** — Keeps retrying at the maximum backoff until success, abort, or session change, with a notification explaining why
|
|
65
|
+
- Exponential backoff with configurable base delay, cap, and multiplier
|
|
65
66
|
- **Hidden triggers** — provider-valid custom messages use `display: false`, so retries do not add TUI clutter
|
|
66
67
|
- Manual controls via unified `/retry` command
|
|
67
|
-
- Non-retryable errors are explicitly logged so you know why we didn't retry
|
|
68
68
|
|
|
69
69
|
---
|
|
70
70
|
|
|
@@ -139,20 +139,18 @@ The extension reads a `piRetry` object from Pi's settings files:
|
|
|
139
139
|
"piRetry": {
|
|
140
140
|
"baseDelayMs": 10000,
|
|
141
141
|
"maxDelayMs": 3600000,
|
|
142
|
-
"multiplier": 2
|
|
143
|
-
"maxRetriesAtMaxDelay": 3
|
|
142
|
+
"multiplier": 2
|
|
144
143
|
}
|
|
145
144
|
}
|
|
146
145
|
```
|
|
147
146
|
|
|
148
|
-
The example above waits 10 seconds before the first retry, doubles each delay, caps the delay at one hour, and
|
|
147
|
+
The example above waits 10 seconds before the first retry, doubles each delay, caps the delay at one hour, and keeps retrying at that cap until you abort. Supported values are:
|
|
149
148
|
|
|
150
149
|
| Setting | Default | Description |
|
|
151
150
|
|---------|---------|-------------|
|
|
152
151
|
| `baseDelayMs` | `2000` | Delay before the first retry, in milliseconds |
|
|
153
152
|
| `maxDelayMs` | `60000` | Maximum delay between retries, in milliseconds |
|
|
154
153
|
| `multiplier` | `2` | Exponential backoff multiplier; must be at least `1` |
|
|
155
|
-
| `maxRetriesAtMaxDelay` | `3` | Failed ordinary retries allowed after the delay reaches `maxDelayMs` |
|
|
156
154
|
| `messageFreeRetries` | `false` | Retry errors without a hidden retry message (see below) |
|
|
157
155
|
|
|
158
156
|
`piRetry` is separate from Pi's built-in `retry` object so the two retry policies do not share ambiguous settings. The extension disables Pi's native retry scheduler while it is loaded, while preserving Pi's compaction handling, so only one retry loop owns the backoff schedule.
|
|
@@ -184,12 +182,11 @@ the raw session history.
|
|
|
184
182
|
|
|
185
183
|
1. **Listen to `agent_end` event** — Fires after each agent turn completes
|
|
186
184
|
2. **Check for any error** — Examine the last assistant message for `stopReason === "error"`
|
|
187
|
-
3. **
|
|
188
|
-
4. **
|
|
189
|
-
5. **
|
|
190
|
-
6. **
|
|
191
|
-
7. **
|
|
192
|
-
8. **Empty-stop recovery** — A `stop` turn with no usable output (only thinking, or nothing at all) gets exactly one hidden "nudge" continuation, then gives up. Matches Anthropic's documented "empty responses with `end_turn`" remedy (continuation prompt in a new user message) — retrying an empty response in place doesn't help because the model has already decided it's done.
|
|
185
|
+
3. **Categorize for messaging** — Classify into 400/413, credit, connection, quota, or other for `/retry status` diagnostics (labelling only; it never gates retrying)
|
|
186
|
+
4. **Retry or continue with hidden turns** — Wait with exponential backoff, then trigger a provider-valid custom user turn via `pi.sendMessage()` with `display: false` and `triggerTurn: true` (with `messageFreeRetries`, error retries omit the failed response and continue instead)
|
|
187
|
+
5. **Valid provider context** — Hidden retry and continuation messages remain in context so providers never receive a trailing assistant message
|
|
188
|
+
6. **Indefinite continuation** — Max_tokens auto-continues are uncapped; repeated `length` stops keep producing continuation turns until the model terminates normally
|
|
189
|
+
7. **Empty-stop recovery** — A `stop` turn with no usable output (only thinking, or nothing at all) gets exactly one hidden "nudge" continuation, then gives up. Matches Anthropic's documented "empty responses with `end_turn`" remedy (continuation prompt in a new user message) — retrying an empty response in place doesn't help because the model has already decided it's done.
|
|
193
190
|
8. **Lifecycle exposure** — Emits `pi-retry:started`, `pi-retry:completed`, and `pi-retry:cancelled` on Pi's shared extension event bus with a matching `retryId`, allowing status integrations to suppress intermediate completion signals
|
|
194
191
|
|
|
195
192
|
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.
|
|
@@ -199,27 +196,17 @@ The pi's built-in `transform-messages` already strips aborted/errored assistant
|
|
|
199
196
|
## Detected Error Patterns
|
|
200
197
|
|
|
201
198
|
### Catch-All (Any Error)
|
|
202
|
-
- **Any** assistant message with `stopReason === "error"` is retried
|
|
199
|
+
- **Any** assistant message with `stopReason === "error"` is retried — nothing is excluded
|
|
203
200
|
- Unknown provider errors, stream errors, unexpected failures — all handled automatically
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
-
|
|
209
|
-
-
|
|
210
|
-
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
### Non-Retryable (Quota / Session Limit / Budget)
|
|
214
|
-
Exhausted quotas, session limits, and budgets are auto-detected and stop the retry loop (with an explanatory notification), because retrying is pointless until you act or the reset window passes:
|
|
215
|
-
- **Usage / session limits with reset windows** — "You've hit your limit · resets …" and "5-hour limit reached" (Claude Code), "You've hit your usage limit" / "You've exceeded your usage limit" (Codex), "You have hit your ChatGPT usage limit (plus plan)" (ChatGPT subscription caps via the Codex backend, also `usage_limit_reached`)
|
|
216
|
-
- **Plan / billing quotas** — OpenAI `insufficient_quota`, "You exceeded your current quota, please check your plan and billing details" (OpenAI, Gemini — reached only after pi's built-in 429 retry gives up)
|
|
217
|
-
- **Google subscription caps** — "You have exhausted your capacity on this model. Your quota will reset after …" (Gemini Code Assist), "You have reached the quota limit for …" / "You can resume using this model at …" (Antigravity)
|
|
218
|
-
- **Hard allotments** — OpenRouter `free-models-per-day`, Alibaba Coding Plan window quotas "hour/week/month allocated quota exceeded" and free-quota exhaustion "free allocated quota exceeded" (the bare "Allocated quota exceeded" is TPM rate limiting and stays retryable), GitHub Copilot "premium request allowance", z.ai GLM Coding Plan "Usage limit reached for 5 hour" / "no resource package"
|
|
219
|
-
- **Budget exhaustion** — "out of budget", "Budget has been exceeded" (LiteLLM-style proxies), max/spending/monthly limits
|
|
220
|
-
- **Suspended accounts** — "Your account … is suspended" (Kimi `exceeded_current_quota_error`)
|
|
221
|
-
|
|
222
|
-
Deliberate distinction: plain pay-as-you-go **balance** errors stay retryable — DeepSeek 402 "Insufficient Balance", OpenRouter 402 "Insufficient credits", Kimi "exceeded your current token quota". A mid-session top-up lets the retry loop auto-resume, whereas session limits and budgets do not self-resolve for hours.
|
|
201
|
+
|
|
202
|
+
### Retried Like Everything Else
|
|
203
|
+
These used to be treated as permanent. They are now retried too, because the cause can disappear mid-loop (a key gets refreshed, a quota window rolls over, the context gets compacted):
|
|
204
|
+
- Invalid API key / invalid authentication / revoked or missing key
|
|
205
|
+
- Model not found / unknown model / unsupported model
|
|
206
|
+
- **Quota, session-limit and budget exhaustion** — "You've hit your limit · resets …" (Claude Code, Codex, ChatGPT plans), `insufficient_quota` (OpenAI), "You have exhausted your capacity on this model" (Gemini Code Assist), `free-models-per-day` (OpenRouter), "out of budget" (LiteLLM-style proxies), suspended accounts (Kimi)
|
|
207
|
+
- **Context overflow** — "prompt is too long", "exceeds the context window", and friends
|
|
208
|
+
|
|
209
|
+
These messages are still labelled `quota` in `/retry status`, so an endless retry loop tells you whether you are waiting on billing or on the network.
|
|
223
210
|
|
|
224
211
|
### Max Tokens (stopReason: "length")
|
|
225
212
|
- The model hit its `max_tokens` / output token limit
|
|
@@ -324,7 +311,7 @@ Use the status command to diagnose:
|
|
|
324
311
|
|
|
325
312
|
### Want to see what's happening?
|
|
326
313
|
|
|
327
|
-
The extensions send notifications on retry attempts. Look at the footer status line for retry status updates
|
|
314
|
+
The extensions send notifications on retry attempts. Look at the footer status line for retry status updates, and `/retry status` for the per-category attempt counters and the last error text.
|
|
328
315
|
|
|
329
316
|
### Too many retries?
|
|
330
317
|
|
|
@@ -354,9 +341,9 @@ pi install npm:@georgebashi/pi-retry
|
|
|
354
341
|
|
|
355
342
|
- Pi's native retry scheduler is disabled while this extension is loaded so native and extension retries cannot interleave; Pi's compaction check still runs normally
|
|
356
343
|
- Error messages remain in the session history (but are invisible to the LLM)
|
|
357
|
-
-
|
|
358
|
-
-
|
|
359
|
-
-
|
|
344
|
+
- Every error class is retried, so a permanent-looking failure (invalid API key, exhausted quota, genuinely oversized context) keeps firing at the maximum backoff until the cause is fixed
|
|
345
|
+
- Escape aborts the loop; fresh input or `/new` also stops it
|
|
346
|
+
- **Warning**: Retrying 400/413 without reducing context may fail repeatedly if the payload is genuinely too large — run `/compact`
|
|
360
347
|
|
|
361
348
|
---
|
|
362
349
|
|
|
@@ -390,8 +377,7 @@ no built-in marker or identity check for any specific subagent tool.
|
|
|
390
377
|
]
|
|
391
378
|
},
|
|
392
379
|
"baseDelayMs": 1000,
|
|
393
|
-
"maxDelayMs": 10000
|
|
394
|
-
"maxRetriesAtMaxDelay": 2
|
|
380
|
+
"maxDelayMs": 10000
|
|
395
381
|
}
|
|
396
382
|
}
|
|
397
383
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehdisadeghi/pi-retry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Extension suite for pi coding agent that handles 400/413 errors and connection errors with automatic retry",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@earendil-works/pi-agent-core": "*",
|
|
45
|
+
"@earendil-works/pi-ai": "*",
|
|
45
46
|
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
-
"@earendil-works/pi-tui": "*"
|
|
47
|
-
"@earendil-works/pi-ai": "*"
|
|
47
|
+
"@earendil-works/pi-tui": "*"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@earendil-works/pi-ai": "1.3.0",
|
|
51
50
|
"@earendil-works/pi-agent-core": "1.3.0",
|
|
51
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
52
52
|
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
53
53
|
"@earendil-works/pi-tui": "1.3.0",
|
|
54
54
|
"@types/node": "25.9.1",
|
package/retry.ts
CHANGED
|
@@ -6,18 +6,13 @@ import {
|
|
|
6
6
|
hasCreditError,
|
|
7
7
|
hasConnectionError,
|
|
8
8
|
hasRetryableError,
|
|
9
|
-
isNonRetryableError,
|
|
10
|
-
isSilencedError,
|
|
11
|
-
hasQuotaExhaustedError,
|
|
12
9
|
hasMaxTokensStop,
|
|
13
10
|
hasEmptyStop,
|
|
14
|
-
isContextOverflowError,
|
|
15
11
|
isAssistantMessage,
|
|
16
12
|
getLastAssistantMessage,
|
|
17
13
|
calculateDelay,
|
|
18
14
|
DEFAULT_RETRY_CONFIG,
|
|
19
15
|
loadPiRetrySettings,
|
|
20
|
-
formatDuration,
|
|
21
16
|
formatRetryCountdown,
|
|
22
17
|
getErrorCategory,
|
|
23
18
|
RETRY_STATUS_KEY,
|
|
@@ -49,10 +44,9 @@ const sdkHooksSupported = installRetrySdkHooks();
|
|
|
49
44
|
* Unified retry extension — retries EVERY error by default.
|
|
50
45
|
*
|
|
51
46
|
* Philosophy: any assistant message with stopReason === "error" is retried
|
|
52
|
-
* with exponential backoff
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* found, quota/session-limit/budget exhaustion, suspended accounts, etc.).
|
|
47
|
+
* with exponential backoff and retried again forever, with no error blacklist
|
|
48
|
+
* and no failure cap. Only a user abort (ESC) or a session change stops the
|
|
49
|
+
* loop; the delay stays pinned at the configured maxDelayMs meanwhile.
|
|
56
50
|
*
|
|
57
51
|
* Specific categories (400/413, credit, connection, stream exhaustion, etc.)
|
|
58
52
|
* are tracked for diagnostics but all share the same retry mechanism.
|
|
@@ -243,6 +237,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
243
237
|
let _notifyFn: ((message: string, level: "info" | "warning" | "error") => void) | null = null;
|
|
244
238
|
let _setStatusFn: ((text: string | undefined) => void) | null = null;
|
|
245
239
|
let retryConfig = { ...DEFAULT_RETRY_CONFIG };
|
|
240
|
+
// SAFETY: the extension API object is only ever used as a stable WeakMap
|
|
241
|
+
// / identity key for per-session ownership; pi's own type does not say so.
|
|
246
242
|
const owner = pi as unknown as object;
|
|
247
243
|
// Policy source of each inline controller, so a session reclassified between
|
|
248
244
|
// session_start and before_agent_start never keeps the other policy.
|
|
@@ -446,7 +442,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
446
442
|
// Instead, the handler detects errors and kicks off
|
|
447
443
|
// triggerInvisibleContinue(), which owns the retry loop with backoff
|
|
448
444
|
// sleeps that happen AFTER processEvents returns (outside the agent run).
|
|
449
|
-
pi.on("agent_end", async (
|
|
445
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
450
446
|
const binding = getRetrySession(ctx.sessionManager);
|
|
451
447
|
if (binding && binding.owner !== owner) return;
|
|
452
448
|
if (binding?.isChild) {
|
|
@@ -512,39 +508,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
512
508
|
return;
|
|
513
509
|
}
|
|
514
510
|
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
// its normal compaction and retry path with the reduced context.
|
|
520
|
-
if (isContextOverflowError(lastAssistant)) {
|
|
521
|
-
ctx.ui.notify(
|
|
522
|
-
"Context overflow — deferring to compaction (auto-retry after compact).",
|
|
523
|
-
"info",
|
|
524
|
-
);
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Catch-all: retry ANY error except known permanent failures
|
|
511
|
+
// Catch-all: retry EVERY error. Invalid API keys, missing models,
|
|
512
|
+
// quota/budget exhaustion and context overflow are retried too — pi-core
|
|
513
|
+
// may compact in parallel, and a hidden turn on the reduced context is
|
|
514
|
+
// exactly what recovers the turn.
|
|
529
515
|
if (hasRetryableError(lastAssistant)) {
|
|
530
516
|
const errorMsg = lastAssistant.errorMessage || "Unknown error";
|
|
531
517
|
const category = getErrorCategory(errorMsg);
|
|
532
518
|
|
|
533
519
|
// Pick the right state tracker for diagnostics
|
|
534
520
|
let state: RetryState;
|
|
535
|
-
let label: string;
|
|
536
521
|
if (category === "400-413") {
|
|
537
522
|
state = state400;
|
|
538
|
-
label = "400/413";
|
|
539
523
|
} else if (category === "credit") {
|
|
540
524
|
state = stateCredit;
|
|
541
|
-
label = "Credit";
|
|
542
525
|
} else if (category === "connection") {
|
|
543
526
|
state = stateConnection;
|
|
544
|
-
label = "Connection";
|
|
545
527
|
} else {
|
|
546
528
|
state = stateOther;
|
|
547
|
-
label = category === "builtin" ? "Server" : "Other";
|
|
548
529
|
}
|
|
549
530
|
|
|
550
531
|
if (state.getIsRetrying()) return;
|
|
@@ -557,18 +538,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
557
538
|
triggerForContext("retry", ctx);
|
|
558
539
|
return;
|
|
559
540
|
}
|
|
560
|
-
|
|
561
|
-
// Log non-retryable errors so the user knows why we didn't retry
|
|
562
|
-
// (silenced errors are neither retried nor shown)
|
|
563
|
-
if (isNonRetryableError(lastAssistant) && !isSilencedError(lastAssistant)) {
|
|
564
|
-
const errorMsg = lastAssistant.errorMessage || "Unknown error";
|
|
565
|
-
ctx.ui.notify(
|
|
566
|
-
hasQuotaExhaustedError(lastAssistant)
|
|
567
|
-
? `Quota/limit exhausted — not retrying (fix plan/billing or wait for the reset window, then /retry): ${errorMsg.substring(0, 100)}`
|
|
568
|
-
: `Non-retryable error (not retried): ${errorMsg.substring(0, 100)}`,
|
|
569
|
-
"error",
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
541
|
});
|
|
573
542
|
|
|
574
543
|
|
|
@@ -652,8 +621,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
652
621
|
status += ` Base delay: ${retryConfig.baseDelayMs}ms\n`;
|
|
653
622
|
status += ` Max delay: ${retryConfig.maxDelayMs}ms\n`;
|
|
654
623
|
status += ` Backoff multiplier: ${retryConfig.multiplier}\n`;
|
|
655
|
-
status +=
|
|
656
|
-
status += " Retry loop: until success, abort, or the max-delay failure limit\n\n";
|
|
624
|
+
status += " Retry loop: until success, abort, or session change (uncapped)\n\n";
|
|
657
625
|
|
|
658
626
|
// Last assistant info
|
|
659
627
|
if (lastAssistant && isAssistantMessage(lastAssistant)) {
|
|
@@ -709,29 +677,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
709
677
|
return;
|
|
710
678
|
}
|
|
711
679
|
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
// without compaction loops forever on a genuinely oversized payload.
|
|
715
|
-
if (isContextOverflowError(lastAssistant)) {
|
|
716
|
-
ctx.ui.notify(
|
|
717
|
-
"Context overflow — use /compact (or /pi-vcc) to reduce context. Compaction auto-retries.",
|
|
718
|
-
"info",
|
|
719
|
-
);
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// Non-retryable errors (permanent failures + quota/budget
|
|
724
|
-
// exhaustion): report clearly instead of the generic fallback.
|
|
725
|
-
if (isNonRetryableError(lastAssistant)) {
|
|
726
|
-
const errorMsg = lastAssistant.errorMessage || "Unknown error";
|
|
727
|
-
ctx.ui.notify(
|
|
728
|
-
hasQuotaExhaustedError(lastAssistant)
|
|
729
|
-
? `Quota/limit exhausted — resolve the plan/billing issue or wait for the reset window first: ${errorMsg.substring(0, 100)}`
|
|
730
|
-
: `Non-retryable error (fix the underlying issue first, then /retry): ${errorMsg.substring(0, 100)}`,
|
|
731
|
-
"warning",
|
|
732
|
-
);
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
680
|
+
// Every error is retryable, including quota/budget exhaustion and
|
|
681
|
+
// context overflow — no gate here, the same as the automatic path.
|
|
735
682
|
|
|
736
683
|
// Auto-detect error type and trigger appropriate retry
|
|
737
684
|
if (has400or413Error(lastAssistant)) {
|
|
@@ -910,9 +857,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
910
857
|
// and answers the nudge with another empty turn is decided, not stalled.
|
|
911
858
|
// Stop after MAX_EMPTY_CONTINUATIONS rather than looping forever.
|
|
912
859
|
let emptyNudges = 0;
|
|
913
|
-
// Only ordinary retries count toward the maximum-delay cutoff; token
|
|
914
|
-
// continuations intentionally remain uncapped.
|
|
915
|
-
let maxDelayRetries = 0;
|
|
916
860
|
|
|
917
861
|
// Loop until success, abort, or session change.
|
|
918
862
|
while (true) {
|
|
@@ -949,10 +893,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
949
893
|
? ++retryAttempt
|
|
950
894
|
: Math.max(1, retryAttempt + 1);
|
|
951
895
|
const delay = calculateDelay(delayAttempt, retryConfig);
|
|
952
|
-
const isMaxDelay = delay >= retryConfig.maxDelayMs;
|
|
953
|
-
if (hiddenTurnKind === "retry" && isMaxDelay) {
|
|
954
|
-
maxDelayRetries++;
|
|
955
|
-
}
|
|
956
896
|
|
|
957
897
|
// Interruptible sleep with backoff BEFORE the retry attempt. The
|
|
958
898
|
// helper emits the initial frame and replaces it every 100ms instead
|
|
@@ -1005,25 +945,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1005
945
|
|
|
1006
946
|
// The hidden AgentSession turn completed. Both errors and output
|
|
1007
947
|
// length stops need another turn; all other terminal states are done.
|
|
948
|
+
// Loop until success, abort, or session change. No failure cap: an
|
|
949
|
+
// error that never resolves keeps retrying at the maximum backoff
|
|
950
|
+
// until the user aborts with ESC or replaces the session.
|
|
1008
951
|
hiddenTurnKind = getHiddenTurnKind(myAgent);
|
|
1009
952
|
if (!hiddenTurnKind) {
|
|
1010
953
|
didRetryComplete = true;
|
|
1011
954
|
return;
|
|
1012
955
|
}
|
|
1013
|
-
|
|
1014
|
-
// Stop ordinary retries after the configured number of failures at
|
|
1015
|
-
// the cap. The failed capped turns have already been delivered; this
|
|
1016
|
-
// check prevents scheduling one more capped request.
|
|
1017
|
-
if (
|
|
1018
|
-
hiddenTurnKind === "retry" &&
|
|
1019
|
-
maxDelayRetries >= retryConfig.maxRetriesAtMaxDelay
|
|
1020
|
-
) {
|
|
1021
|
-
notifySafely(
|
|
1022
|
-
`Retry failed ${retryConfig.maxRetriesAtMaxDelay} times at the maximum backoff (${formatDuration(retryConfig.maxDelayMs)}); giving up.`,
|
|
1023
|
-
"warning",
|
|
1024
|
-
);
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
956
|
}
|
|
1028
957
|
} finally {
|
|
1029
958
|
// Always remove the mutable countdown row when the loop completes,
|
package/src/child-retry.ts
CHANGED
|
@@ -11,12 +11,8 @@ import {
|
|
|
11
11
|
getLastAssistantMessage,
|
|
12
12
|
hasEmptyStop,
|
|
13
13
|
hasMaxTokensStop,
|
|
14
|
-
hasQuotaExhaustedError,
|
|
15
14
|
hasRetryableError,
|
|
16
15
|
isAssistantMessage,
|
|
17
|
-
isContextOverflowError,
|
|
18
|
-
isNonRetryableError,
|
|
19
|
-
isSilencedError,
|
|
20
16
|
RETRY_TRIGGER_CUSTOM_TYPE,
|
|
21
17
|
} from "./error-patterns.js";
|
|
22
18
|
import {
|
|
@@ -74,8 +70,6 @@ export class ChildRetryController {
|
|
|
74
70
|
private readonly continuationState = new ContinuationState();
|
|
75
71
|
/** Empty-stop continuation counter, bounded to one nudge. */
|
|
76
72
|
private emptyNudges = 0;
|
|
77
|
-
/** Number of ordinary attempts whose delay reached the configured cap. */
|
|
78
|
-
private maxDelayRetries = 0;
|
|
79
73
|
/** Number of ordinary retry turns scheduled from this original prompt. */
|
|
80
74
|
private retryAttempt = 0;
|
|
81
75
|
/** Prevents duplicate scheduling if an event and manual command race. */
|
|
@@ -132,15 +126,6 @@ export class ChildRetryController {
|
|
|
132
126
|
if (!lastAssistant || !isAssistantMessage(lastAssistant)) return;
|
|
133
127
|
if (this.driving) return;
|
|
134
128
|
|
|
135
|
-
// Compaction owns context overflow. The child controller must not queue a
|
|
136
|
-
// same-context retry that races the SDK's compaction recovery.
|
|
137
|
-
if (isContextOverflowError(lastAssistant)) {
|
|
138
|
-
this.notify(ctx, "Context overflow - deferring to compaction.", "info");
|
|
139
|
-
this.resetCounters();
|
|
140
|
-
this.finishLifecycle(false);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
129
|
if (hasMaxTokensStop(lastAssistant)) {
|
|
145
130
|
if (this.continuationState.getIsContinuing()) return;
|
|
146
131
|
this.continuationState.startContinuation();
|
|
@@ -170,24 +155,8 @@ export class ChildRetryController {
|
|
|
170
155
|
return;
|
|
171
156
|
}
|
|
172
157
|
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
if (isNonRetryableError(lastAssistant)) {
|
|
176
|
-
const errorMsg = lastAssistant.errorMessage || "Unknown error";
|
|
177
|
-
if (!isSilencedError(lastAssistant)) {
|
|
178
|
-
this.notify(
|
|
179
|
-
ctx,
|
|
180
|
-
hasQuotaExhaustedError(lastAssistant)
|
|
181
|
-
? `Quota/limit exhausted - not retrying: ${errorMsg.substring(0, 100)}`
|
|
182
|
-
: `Non-retryable error (not retried): ${errorMsg.substring(0, 100)}`,
|
|
183
|
-
"warning",
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
this.resetCounters();
|
|
187
|
-
this.finishLifecycle(false);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
158
|
+
// Every error is retryable — invalid API keys, missing models,
|
|
159
|
+
// quota/budget exhaustion and context overflow included.
|
|
191
160
|
if (!hasRetryableError(lastAssistant)) return;
|
|
192
161
|
|
|
193
162
|
const errorMsg = lastAssistant.errorMessage || "Unknown error";
|
|
@@ -291,7 +260,6 @@ export class ChildRetryController {
|
|
|
291
260
|
for (const state of Object.values(this.states)) state.reset();
|
|
292
261
|
this.continuationState.reset();
|
|
293
262
|
this.emptyNudges = 0;
|
|
294
|
-
this.maxDelayRetries = 0;
|
|
295
263
|
this.retryAttempt = 0;
|
|
296
264
|
this.finishLifecycle(false);
|
|
297
265
|
}
|
|
@@ -359,21 +327,8 @@ export class ChildRetryController {
|
|
|
359
327
|
if (kind === "retry") {
|
|
360
328
|
this.retryAttempt++;
|
|
361
329
|
}
|
|
362
|
-
if (kind === "retry" && this.maxDelayRetries >= this.config.maxRetriesAtMaxDelay) {
|
|
363
|
-
this.notify(
|
|
364
|
-
ctx,
|
|
365
|
-
`Retry failed ${this.config.maxRetriesAtMaxDelay} times at the maximum backoff; giving up.`,
|
|
366
|
-
"warning",
|
|
367
|
-
);
|
|
368
|
-
this.resetCounters();
|
|
369
|
-
this.finishLifecycle(false);
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
330
|
const attempt = kind === "retry" ? this.retryAttempt : this.retryAttempt + 1;
|
|
373
331
|
const delay = calculateDelay(attempt, this.config);
|
|
374
|
-
if (kind === "retry" && delay >= this.config.maxDelayMs) {
|
|
375
|
-
this.maxDelayRetries++;
|
|
376
|
-
}
|
|
377
332
|
|
|
378
333
|
// An automatic error retry can continue the active run without a message;
|
|
379
334
|
// a manual retry starts from idle, where only a queued message can.
|
|
@@ -550,7 +505,6 @@ export class ChildRetryController {
|
|
|
550
505
|
for (const state of Object.values(this.states)) state.succeed();
|
|
551
506
|
this.continuationState.complete();
|
|
552
507
|
this.emptyNudges = 0;
|
|
553
|
-
this.maxDelayRetries = 0;
|
|
554
508
|
this.retryAttempt = 0;
|
|
555
509
|
}
|
|
556
510
|
|
package/src/config.ts
CHANGED
|
@@ -14,8 +14,6 @@ import {
|
|
|
14
14
|
* Complete retry configuration used by the extension.
|
|
15
15
|
*/
|
|
16
16
|
export interface PiRetryConfig extends BackoffConfig {
|
|
17
|
-
/** Number of failed retries allowed after the delay reaches maxDelayMs. */
|
|
18
|
-
maxRetriesAtMaxDelay: number;
|
|
19
17
|
/**
|
|
20
18
|
* Retry errors by omitting the failed response and continuing the same
|
|
21
19
|
* request, instead of queueing a hidden retry message. Opt-in.
|
|
@@ -35,8 +33,6 @@ export interface PiRetrySubagentsOverride {
|
|
|
35
33
|
maxDelayMs?: number;
|
|
36
34
|
/** Child retry exponential multiplier. */
|
|
37
35
|
multiplier?: number;
|
|
38
|
-
/** Failed child retries allowed at the maximum delay. */
|
|
39
|
-
maxRetriesAtMaxDelay?: number;
|
|
40
36
|
/** Child override for message-free error retries. */
|
|
41
37
|
messageFreeRetries?: boolean;
|
|
42
38
|
/** Optional policy-selection rules for effective system prompts. */
|
|
@@ -80,7 +76,6 @@ export interface PiRetrySettings {
|
|
|
80
76
|
*/
|
|
81
77
|
export const DEFAULT_RETRY_CONFIG: PiRetryConfig = {
|
|
82
78
|
...DEFAULT_BACKOFF_CONFIG,
|
|
83
|
-
maxRetriesAtMaxDelay: 3,
|
|
84
79
|
messageFreeRetries: false,
|
|
85
80
|
};
|
|
86
81
|
|
|
@@ -338,12 +333,6 @@ function resolveMainRetryConfig(
|
|
|
338
333
|
DEFAULT_RETRY_CONFIG.multiplier,
|
|
339
334
|
value => value >= 1,
|
|
340
335
|
),
|
|
341
|
-
maxRetriesAtMaxDelay: readNumberSetting(
|
|
342
|
-
mergedSettings,
|
|
343
|
-
"maxRetriesAtMaxDelay",
|
|
344
|
-
DEFAULT_RETRY_CONFIG.maxRetriesAtMaxDelay,
|
|
345
|
-
value => Number.isInteger(value) && value >= 1,
|
|
346
|
-
),
|
|
347
336
|
messageFreeRetries: readBooleanSetting(
|
|
348
337
|
mergedSettings,
|
|
349
338
|
"messageFreeRetries",
|
|
@@ -417,13 +406,6 @@ export function resolvePiRetrySettings(
|
|
|
417
406
|
value => value >= 1,
|
|
418
407
|
`${PI_RETRY_SETTINGS_KEY}.subagents.multiplier`,
|
|
419
408
|
),
|
|
420
|
-
maxRetriesAtMaxDelay: readNumberSetting(
|
|
421
|
-
mergedSubagents,
|
|
422
|
-
"maxRetriesAtMaxDelay",
|
|
423
|
-
main.maxRetriesAtMaxDelay,
|
|
424
|
-
value => Number.isInteger(value) && value >= 1,
|
|
425
|
-
`${PI_RETRY_SETTINGS_KEY}.subagents.maxRetriesAtMaxDelay`,
|
|
426
|
-
),
|
|
427
409
|
messageFreeRetries: readBooleanSetting(
|
|
428
410
|
mergedSubagents,
|
|
429
411
|
"messageFreeRetries",
|
|
@@ -494,6 +476,8 @@ export function loadPiRetryConfig(
|
|
|
494
476
|
* @returns Decoded JSON contents, or an empty object when unavailable.
|
|
495
477
|
*/
|
|
496
478
|
function readSettingsFile(filePath: string): unknown {
|
|
479
|
+
// SAFETY: contents come from an arbitrary user settings file, so the honest
|
|
480
|
+
// type is unknown; every caller validates through isSettingsObject first.
|
|
497
481
|
try {
|
|
498
482
|
return JSON.parse(readFileSync(filePath, "utf8")) as unknown;
|
|
499
483
|
} catch (error) {
|
package/src/error-patterns.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Error pattern matching utilities for retry extensions
|
|
3
3
|
*
|
|
4
|
-
* Philosophy: retry EVERY error
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Philosophy: retry EVERY error. There is no blacklist and no cap — invalid
|
|
5
|
+
* API keys, missing models, quota/budget exhaustion, context overflow,
|
|
6
|
+
* 400s, connection issues, stream exhaustion, unknown errors are all retried
|
|
7
|
+
* with backoff until the user aborts. The pattern groups below exist only to
|
|
8
|
+
* label errors in `/retry status`; none of them gate retrying.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
@@ -67,72 +68,9 @@ const BUILTIN_HANDLED_PATTERNS = [
|
|
|
67
68
|
/retry\s*delay/i,
|
|
68
69
|
];
|
|
69
70
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// compact + retry. kept in sync manually — pi-ai is not a direct dependency.
|
|
74
|
-
//
|
|
75
|
-
// Why these are NOT retried by pi-retry: a hidden retry turn would re-send
|
|
76
|
-
// the same oversized context, so it overflows again → infinite loop
|
|
77
|
-
// (pi-retry's loop is uncapped for errors). pi-core instead compacts and
|
|
78
|
-
// retries once via agent.continue(); with static compaction (pi-vcc) that
|
|
79
|
-
// reliably reduces context, so the single retry succeeds.
|
|
80
|
-
const OVERFLOW_ERROR_PATTERNS = [
|
|
81
|
-
/prompt is too long/i,
|
|
82
|
-
/request_too_large/i,
|
|
83
|
-
/input is too long for requested model/i,
|
|
84
|
-
/exceeds the context window/i,
|
|
85
|
-
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
|
86
|
-
/input token count.*exceeds the maximum/i,
|
|
87
|
-
/maximum prompt length is \d+/i,
|
|
88
|
-
/reduce the length of the messages/i,
|
|
89
|
-
/maximum context length is \d+ tokens/i,
|
|
90
|
-
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
|
|
91
|
-
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
|
|
92
|
-
/exceeds the limit of \d+/i,
|
|
93
|
-
/exceeds the available context size/i,
|
|
94
|
-
/greater than the context length/i,
|
|
95
|
-
/context window exceeds limit/i,
|
|
96
|
-
/exceeded model token limit/i,
|
|
97
|
-
/too large for model with \d+ maximum context length/i,
|
|
98
|
-
/model_context_window_exceeded/i,
|
|
99
|
-
/prompt too long; exceeded (?:max )?context length/i,
|
|
100
|
-
/context[_ ]length[_ ]exceeded/i,
|
|
101
|
-
/too many tokens/i,
|
|
102
|
-
/token limit exceeded/i,
|
|
103
|
-
/^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i,
|
|
104
|
-
];
|
|
105
|
-
|
|
106
|
-
// Patterns that look like overflow but are actually rate limiting / throttling.
|
|
107
|
-
// Mirrors pi-core's NON_OVERFLOW_PATTERNS. Excluded from overflow detection so
|
|
108
|
-
// throttling errors are still retried (they are not context-size problems).
|
|
109
|
-
const NON_OVERFLOW_PATTERNS = [
|
|
110
|
-
/^(Throttling error|Service unavailable):/i,
|
|
111
|
-
/rate limit/i,
|
|
112
|
-
/too many requests/i,
|
|
113
|
-
];
|
|
114
|
-
|
|
115
|
-
// ── Blacklist: errors that are truly permanent and should NOT be retried ──
|
|
116
|
-
|
|
117
|
-
const NON_RETRYABLE_PATTERNS = [
|
|
118
|
-
/invalid\s*api\s*key/i,
|
|
119
|
-
/invalid\s*authentication/i,
|
|
120
|
-
/api\s*key\s*(not\s*found|missing|revoked)/i,
|
|
121
|
-
/model\s*not\s*found/i,
|
|
122
|
-
/unknown\s*model/i,
|
|
123
|
-
/no\s*such\s*model/i,
|
|
124
|
-
/model\s*does\s*not\s*exist/i,
|
|
125
|
-
/unsupported\s*model/i,
|
|
126
|
-
/cannot continue from message role/i,
|
|
127
|
-
];
|
|
128
|
-
|
|
129
|
-
// Errors that are non-retryable AND should be silently ignored (no notification)
|
|
130
|
-
const SILENCED_PATTERNS = [/cannot continue from message role/i];
|
|
131
|
-
|
|
132
|
-
// Quota / session-limit / budget exhaustion — hard stops:
|
|
133
|
-
// retrying is pointless until the user upgrades, tops up a budget, or waits
|
|
134
|
-
// out a reset window measured in hours/days. Distinct from per-minute rate
|
|
135
|
-
// limits and pay-as-you-go balance errors, which stay retryable.
|
|
71
|
+
// Quota / session-limit / budget exhaustion — labelled in `/retry status` so
|
|
72
|
+
// a user staring at an endless retry loop can see the cause is billing, not
|
|
73
|
+
// the network. Retried like every other error.
|
|
136
74
|
//
|
|
137
75
|
// Evidence (real provider messages):
|
|
138
76
|
// - Claude Code: "You've hit your limit · resets 4pm (Asia/Kuala_Lumpur)"
|
|
@@ -174,7 +112,7 @@ const SILENCED_PATTERNS = [/cannot continue from message role/i];
|
|
|
174
112
|
// Balance" and 429 "Rate Limit Reached" (concurrency), Kimi TPD org limits
|
|
175
113
|
// and "exceeded your current token quota" (balance), Alibaba "Allocated quota
|
|
176
114
|
// exceeded" (429 Throttling.AllocationQuota — TPM rate limiting, recovers in
|
|
177
|
-
// minutes).
|
|
115
|
+
// minutes). Retried like every other error.
|
|
178
116
|
export const QUOTA_EXHAUSTED_PATTERNS = [
|
|
179
117
|
// Session / usage limits with reset windows (Claude, Codex, ChatGPT plans)
|
|
180
118
|
/hit your (?:[a-z]+ )?usage limit/i, // "…hit your usage limit", "…hit your ChatGPT usage limit (plus plan)" — the optional word is the provider name; "hit your rate limit" intentionally NOT matched (burst limit stays retryable)
|
|
@@ -246,74 +184,18 @@ export function hasConnectionError(message: AgentMessage): boolean {
|
|
|
246
184
|
return CONNECTION_ERROR_PATTERNS.some((p) => p.test(message.errorMessage!));
|
|
247
185
|
}
|
|
248
186
|
|
|
249
|
-
/**
|
|
250
|
-
* Returns true for an error assistant message whose errorMessage indicates a
|
|
251
|
-
* context-overflow (input exceeded the model's context window).
|
|
252
|
-
*
|
|
253
|
-
* Mirrors pi-core's isContextOverflow Case 1 (error-message patterns). The
|
|
254
|
-
* silent-overflow cases (stopReason "stop"/"length") are not errors and are
|
|
255
|
-
* never seen here — pi-core handles those in _checkCompaction directly.
|
|
256
|
-
*
|
|
257
|
-
* Callers should treat a true result as "defer to compaction, do NOT retry" —
|
|
258
|
-
* see OVERFLOW_ERROR_PATTERNS for rationale.
|
|
259
|
-
*/
|
|
260
|
-
export function isContextOverflowError(message: AgentMessage): boolean {
|
|
261
|
-
if (!isAssistantMessage(message)) return false;
|
|
262
|
-
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
263
|
-
if (NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!)))
|
|
264
|
-
return false;
|
|
265
|
-
return OVERFLOW_ERROR_PATTERNS.some((p) => p.test(message.errorMessage!));
|
|
266
|
-
}
|
|
267
|
-
|
|
268
187
|
// ── Universal retry check ──
|
|
269
188
|
|
|
270
189
|
/**
|
|
271
|
-
* Returns true for ANY assistant message with stopReason === "error"
|
|
272
|
-
* except a tiny blacklist of known permanent failures.
|
|
273
|
-
*/
|
|
274
|
-
export function hasRetryableError(message: AgentMessage): boolean {
|
|
275
|
-
if (!isAssistantMessage(message)) return false;
|
|
276
|
-
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
277
|
-
return !isNonRetryableError(message);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Returns true only for known permanent failures (invalid API key, missing model, etc.)
|
|
282
|
-
*/
|
|
283
|
-
export function isNonRetryableError(message: AgentMessage): boolean {
|
|
284
|
-
if (!isAssistantMessage(message)) return false;
|
|
285
|
-
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
286
|
-
return (
|
|
287
|
-
NON_RETRYABLE_PATTERNS.some((p) => p.test(message.errorMessage!)) ||
|
|
288
|
-
QUOTA_EXHAUSTED_PATTERNS.some((p) => p.test(message.errorMessage!))
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Returns true for quota / session-limit / budget exhaustion errors where
|
|
294
|
-
* retrying is pointless until the user acts (upgrade, top up a budget) or a
|
|
295
|
-
* long reset window passes (hours/days). Treated as non-retryable.
|
|
190
|
+
* Returns true for ANY assistant message with stopReason === "error".
|
|
296
191
|
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* so a mid-session top-up auto-resumes.
|
|
192
|
+
* Nothing is excluded: invalid API keys, missing models, quota/budget
|
|
193
|
+
* exhaustion and context overflow are retried like every other error. The
|
|
194
|
+
* caller is only allowed to stop on a user abort or a session change.
|
|
301
195
|
*/
|
|
302
|
-
export function
|
|
303
|
-
if (!isAssistantMessage(message)) return false;
|
|
304
|
-
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
305
|
-
return QUOTA_EXHAUSTED_PATTERNS.some((p) => p.test(message.errorMessage!));
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Returns true for errors that are non-retryable and should be silently
|
|
310
|
-
* ignored (no UI notification). These are provider-level refusals that
|
|
311
|
-
* the user cannot act on and that would only add noise.
|
|
312
|
-
*/
|
|
313
|
-
export function isSilencedError(message: AgentMessage): boolean {
|
|
196
|
+
export function hasRetryableError(message: AgentMessage): boolean {
|
|
314
197
|
if (!isAssistantMessage(message)) return false;
|
|
315
|
-
|
|
316
|
-
return SILENCED_PATTERNS.some((p) => p.test(message.errorMessage!));
|
|
198
|
+
return message.stopReason === "error" && Boolean(message.errorMessage);
|
|
317
199
|
}
|
|
318
200
|
|
|
319
201
|
// ── Categorisation (for UI messages) ──
|