@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 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 by default, with only a tiny blacklist of known permanent failures (invalid API key, missing model, etc.).
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 retryable error** (catch-all) | **Capped exponential retry** | Everything else — provider hiccups, stream exhaustion, credit issues, unknown errors |
33
- | HTTP 400/413 | **Capped** with exponential backoff, NO compaction | Transient context overflow that might resolve |
34
- | Credit / payment errors | **Capped** with exponential backoff | "Not Enough Credits", insufficient balance, 402 — top up and the retry loop auto-resumes |
35
- | **Quota / session-limit / budget exhaustion** | **Not retried** — notify + stop | "You've hit your limit", `insufficient_quota`, "out of budget", suspended accounts |
36
- | Connection errors | **Capped** with exponential backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
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 and a maximum-delay failure limit (2s → 4s → 8s → ... → 60s by default).
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 by default.** The only things we skip are a tiny blacklist of known permanent failures (invalid API key, model not found, unsupported model, etc.).
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
- - **Retry cutoff** — Keeps retrying until success, abort, or the configured number of failures at the maximum delay
63
- - **Auto-stop on quota/budget exhaustion** — Session limits, plan quotas, and budget caps ("You've hit your limit", "out of budget", `insufficient_quota`, suspended accounts) are detected and **not** retried, with a notification explaining why
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 stops after three failed retries at that cap. Supported values are:
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. **Blacklist check** — Skip known permanent failures (invalid API key, model not found, quota/session-limit/budget exhaustion, suspended accounts, etc.)
188
- 4. **Categorize for messaging** — Classify into 400/413, credit, connection, or other for nice UI notifications
189
- 5. **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)
190
- 6. **Valid provider context** — Hidden retry and continuation messages remain in context so providers never receive a trailing assistant message
191
- 7. **Indefinite continuation** — Max_tokens auto-continues are uncapped; repeated `length` stops keep producing continuation turns until the model terminates normally
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 by default
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
- - Only skipped if it matches a known permanent failure (invalid API key, missing model, etc.)
205
-
206
- ### Non-Retryable (Permanent Failures)
207
- These are explicitly **not** retried:
208
- - Invalid API key / invalid authentication
209
- - API key not found / missing / revoked
210
- - Model not found / unknown model / no such model / model does not exist
211
- - Unsupported model
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. Non-retryable errors are logged as errors so you know why we stopped.
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
- - May hit the same error repeatedly if the issue is persistent (use `Ctrl+C` to abort)
358
- - **Warning**: Retrying 400/413 without reducing context may fail repeatedly if the payload is genuinely too large
359
- - Non-retryable errors (invalid API key, missing model, quota/session-limit/budget exhaustion) are logged but not retried — you'll need to fix the underlying issue, then use `/retry`
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.11.2",
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 capped by settings, then stops after the configured
53
- * number of failures at the maximum delay, except a small blacklist of known
54
- * permanent failures and hard-stop conditions (invalid API key, model not
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 (event, ctx) => {
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
- // Context overflow: defer to compaction. Do NOT retry here.
516
- //
517
- // Retrying the same oversized context before compaction would produce an
518
- // uncapped overflow loop. Leave _continueInProgress false so Pi can run
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 += ` Max-delay failures: ${retryConfig.maxRetriesAtMaxDelay}\n`;
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
- // Context overflow: don't retry in place — reducing context is required.
713
- // Compaction (pi-vcc / /compact) handles it and auto-retries. Retrying
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,
@@ -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
- // Re-evaluate permanent and quota errors for every attempt, including a
174
- // provider that changes its error category after a transient failure.
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) {
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Error pattern matching utilities for retry extensions
3
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.
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
- // Context-overflow error patterns. Mirrors pi-core's OVERFLOW_PATTERNS in
71
- // @earendil-works/pi-ai/dist/utils/overflow.js so that pi-retry defers to
72
- // compaction exactly when pi-core's _checkCompaction will detect overflow and
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). See the note on hasQuotaExhaustedError below.
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
- * Deliberately NOT matched: per-minute rate limits (429s) and pay-as-you-go
298
- * balance errors (DeepSeek "Insufficient Balance", OpenRouter "Insufficient
299
- * credits", Kimi "exceeded your current token quota") — those stay retryable
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 hasQuotaExhaustedError(message: AgentMessage): boolean {
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
- if (message.stopReason !== "error" || !message.errorMessage) return false;
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) ──