@monotykamary/pi-retry 0.7.1 → 0.8.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 +5 -2
- package/package.json +1 -1
- package/retry.ts +69 -2
- package/src/error-patterns.ts +64 -2
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ This extension automatically detects and retries **all** errors by default, with
|
|
|
27
27
|
| **Quota / session-limit / budget exhaustion** | **Not retried** — notify + stop | "You've hit your limit", `insufficient_quota`, "out of budget", suspended accounts |
|
|
28
28
|
| Connection errors | **Indefinite** with capped backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
|
|
29
29
|
| Max tokens (`stopReason: "length"`) | **Auto-continue** indefinitely with hidden continuation turns | Model hits output token limit mid-generation |
|
|
30
|
+
| 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) |
|
|
30
31
|
|
|
31
32
|
---
|
|
32
33
|
|
|
@@ -140,6 +141,7 @@ const BACKOFF_MULTIPLIER = 2; // Double each time
|
|
|
140
141
|
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`
|
|
141
142
|
6. **Valid provider context** — Hidden retry and continuation messages remain in context so providers never receive a trailing assistant message
|
|
142
143
|
7. **Indefinite continuation** — Max_tokens auto-continues are uncapped; repeated `length` stops keep producing continuation turns until the model terminates normally
|
|
144
|
+
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.
|
|
143
145
|
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
|
|
144
146
|
|
|
145
147
|
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.
|
|
@@ -162,9 +164,10 @@ These are explicitly **not** retried:
|
|
|
162
164
|
|
|
163
165
|
### Non-Retryable (Quota / Session Limit / Budget)
|
|
164
166
|
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:
|
|
165
|
-
- **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)
|
|
167
|
+
- **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`)
|
|
166
168
|
- **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)
|
|
167
|
-
- **
|
|
169
|
+
- **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)
|
|
170
|
+
- **Hard allotments** — OpenRouter `free-models-per-day`, Alibaba "Allocated quota exceeded" (`Throttling.AllocationQuota`), GitHub Copilot "premium request allowance", z.ai GLM Coding Plan "Usage limit reached for 5 hour" / "no resource package"
|
|
168
171
|
- **Budget exhaustion** — "out of budget", "Budget has been exceeded" (LiteLLM-style proxies), max/spending/monthly limits
|
|
169
172
|
- **Suspended accounts** — "Your account … is suspended" (Kimi `exceeded_current_quota_error`)
|
|
170
173
|
|
package/package.json
CHANGED
package/retry.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
isSilencedError,
|
|
12
12
|
hasQuotaExhaustedError,
|
|
13
13
|
hasMaxTokensStop,
|
|
14
|
+
hasEmptyStop,
|
|
14
15
|
isContextOverflowError,
|
|
15
16
|
isAssistantMessage,
|
|
16
17
|
getLastAssistantMessage,
|
|
@@ -105,6 +106,14 @@ const stateOther = new RetryState();
|
|
|
105
106
|
// Max_tokens continuation state (indefinite — no cap needed)
|
|
106
107
|
const stateContinuation = new ContinuationState();
|
|
107
108
|
|
|
109
|
+
// Empty/think-only stop continuation state — BOUNDED by design: the model
|
|
110
|
+
// decided to end its turn with no usable output (zero text, zero tool
|
|
111
|
+
// calls; Anthropic's documented "empty responses with end_turn"/reasoning
|
|
112
|
+
// budget exhaustion). It gets ONE nudge, then we give up rather than
|
|
113
|
+
// burning tokens looping on a model that has decided it is done.
|
|
114
|
+
const stateEmptyStop = new ContinuationState();
|
|
115
|
+
const MAX_EMPTY_CONTINUATIONS = 1;
|
|
116
|
+
|
|
108
117
|
// Abort flag: set when Pi's active signal is aborted or turn_end reports
|
|
109
118
|
// stopReason "aborted", cleared on session_start and fresh user activity.
|
|
110
119
|
// Prevents triggerInvisibleContinue() from starting a hidden retry turn after
|
|
@@ -170,7 +179,7 @@ function removeErrorFromAgentState(): void {
|
|
|
170
179
|
}
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
type HiddenTurnKind = "retry" | "continue";
|
|
182
|
+
type HiddenTurnKind = "retry" | "continue" | "empty";
|
|
174
183
|
|
|
175
184
|
function getHiddenTurnKind(): HiddenTurnKind | null {
|
|
176
185
|
if (!_agent) return null;
|
|
@@ -179,6 +188,7 @@ function getHiddenTurnKind(): HiddenTurnKind | null {
|
|
|
179
188
|
if (lastMsg?.role !== "assistant") return null;
|
|
180
189
|
if (lastMsg.stopReason === "error") return "retry";
|
|
181
190
|
if (lastMsg.stopReason === "length") return "continue";
|
|
191
|
+
if (hasEmptyStop(lastMsg)) return "empty";
|
|
182
192
|
return null;
|
|
183
193
|
}
|
|
184
194
|
|
|
@@ -210,6 +220,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
210
220
|
stateConnection.reset();
|
|
211
221
|
stateOther.reset();
|
|
212
222
|
stateContinuation.endContinuation();
|
|
223
|
+
stateEmptyStop.endContinuation();
|
|
213
224
|
// Signal to any in-flight triggerInvisibleContinue or pending retry
|
|
214
225
|
// that the user has cancelled — do not queue another retry turn.
|
|
215
226
|
_userAborted = true;
|
|
@@ -223,6 +234,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
223
234
|
stateConnection.succeed();
|
|
224
235
|
stateOther.succeed();
|
|
225
236
|
stateContinuation.complete();
|
|
237
|
+
stateEmptyStop.complete();
|
|
226
238
|
// Clear abort flag — this is a fresh successful turn, so any
|
|
227
239
|
// previous abort is stale and shouldn't block future retries.
|
|
228
240
|
_userAborted = false;
|
|
@@ -278,6 +290,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
278
290
|
return;
|
|
279
291
|
}
|
|
280
292
|
|
|
293
|
+
// Empty / think-only stop - the model ended its turn with NO usable
|
|
294
|
+
// output (zero text blocks, zero tool calls; only thinking or nothing).
|
|
295
|
+
// Anthropic documents these as "empty responses with end_turn" - the
|
|
296
|
+
// model decided the turn is complete. Not an error, but also not a
|
|
297
|
+
// usable turn: without this, the agent just goes silent.
|
|
298
|
+
//
|
|
299
|
+
// Remedy (per Anthropic docs and CLIProxyAPI 4886 measurements): one
|
|
300
|
+
// continuation prompt in a NEW user message. Bounded on purpose - a
|
|
301
|
+
// model that returns empty once tends to be done; see
|
|
302
|
+
// MAX_EMPTY_CONTINUATIONS below.
|
|
303
|
+
if (hasEmptyStop(lastAssistant) && !stateEmptyStop.getIsContinuing()) {
|
|
304
|
+
stateEmptyStop.startContinuation();
|
|
305
|
+
ctx.ui.notify(
|
|
306
|
+
`Empty response - nudging once to produce output (continuation ${stateEmptyStop.getCount()})...`,
|
|
307
|
+
"info",
|
|
308
|
+
);
|
|
309
|
+
void triggerInvisibleContinue("empty");
|
|
310
|
+
stateEmptyStop.endContinuation();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
281
314
|
// Context overflow: defer to compaction. Do NOT retry here.
|
|
282
315
|
//
|
|
283
316
|
// Retrying the same oversized context before compaction would produce an
|
|
@@ -382,6 +415,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
382
415
|
status += ` Is continuing: ${stateContinuation.getIsContinuing()}\n`;
|
|
383
416
|
status += ` Trigger: hidden provider-valid AgentSession turn\n\n`;
|
|
384
417
|
|
|
418
|
+
// Empty-stop continuation state
|
|
419
|
+
status += "Empty/Think-only Stop Continuation:\n";
|
|
420
|
+
status += ` Continuations used: ${stateEmptyStop.getCount()}\n`;
|
|
421
|
+
status += ` Is continuing: ${stateEmptyStop.getIsContinuing()}\n`;
|
|
422
|
+
status += ` Cap: ${MAX_EMPTY_CONTINUATIONS} nudge(s), then give up\n\n`;
|
|
423
|
+
|
|
385
424
|
// Config
|
|
386
425
|
status += "Configuration:\n";
|
|
387
426
|
status += ` Base delay: 2000ms\n`;
|
|
@@ -410,6 +449,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
410
449
|
stateConnection.reset();
|
|
411
450
|
stateOther.reset();
|
|
412
451
|
stateContinuation.reset();
|
|
452
|
+
stateEmptyStop.reset();
|
|
413
453
|
_userAborted = false;
|
|
414
454
|
ctx.ui.notify("All retry counters reset", "info");
|
|
415
455
|
return;
|
|
@@ -435,6 +475,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
435
475
|
return;
|
|
436
476
|
}
|
|
437
477
|
|
|
478
|
+
// Empty / think-only stop — nudge once
|
|
479
|
+
if (hasEmptyStop(lastAssistant)) {
|
|
480
|
+
ctx.ui.notify("Empty response — nudging once...", "info");
|
|
481
|
+
void triggerInvisibleContinue("empty");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
438
485
|
// Context overflow: don't retry in place — reducing context is required.
|
|
439
486
|
// Compaction (pi-vcc / /compact) handles it and auto-retries. Retrying
|
|
440
487
|
// without compaction loops forever on a genuinely oversized payload.
|
|
@@ -506,6 +553,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
506
553
|
stateConnection.reset();
|
|
507
554
|
stateOther.reset();
|
|
508
555
|
stateContinuation.reset();
|
|
556
|
+
stateEmptyStop.reset();
|
|
509
557
|
// Do NOT reset _continueInProgress here — the in-flight loop's
|
|
510
558
|
// finally block releases its owner token. Resetting it here could allow
|
|
511
559
|
// a second loop to start before the old one has settled.
|
|
@@ -586,6 +634,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
586
634
|
|
|
587
635
|
let attempt = 0;
|
|
588
636
|
let hiddenTurnKind: HiddenTurnKind | null = initialKind;
|
|
637
|
+
// Empty-stop nudges are bounded: MAX_EMPTY_CONTINUATIONS total
|
|
638
|
+
// continuation requests, then we give up (the model decided it is done).
|
|
639
|
+
let emptyNudges = 0;
|
|
589
640
|
|
|
590
641
|
// Loop until success, abort, or session change.
|
|
591
642
|
while (true) {
|
|
@@ -604,6 +655,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
604
655
|
}
|
|
605
656
|
removeErrorFromAgentState();
|
|
606
657
|
|
|
658
|
+
// Empty-stop cap: a model that produced no usable output and answers
|
|
659
|
+
// the nudge with another empty turn is decided, not stalled. Stop
|
|
660
|
+
// after MAX_EMPTY_CONTINUATIONS rather than looping forever.
|
|
661
|
+
if (hiddenTurnKind === "empty") {
|
|
662
|
+
if (emptyNudges >= MAX_EMPTY_CONTINUATIONS) {
|
|
663
|
+
_notifyFn?.(
|
|
664
|
+
`Empty response after ${emptyNudges} continuation(s) - giving up (model keeps ending the turn with no output).`,
|
|
665
|
+
"warning",
|
|
666
|
+
);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
emptyNudges++;
|
|
670
|
+
}
|
|
671
|
+
|
|
607
672
|
attempt++;
|
|
608
673
|
const delay = calculateDelay(attempt);
|
|
609
674
|
|
|
@@ -629,7 +694,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
629
694
|
: CONTINUATION_CUSTOM_TYPE,
|
|
630
695
|
content: hiddenTurnKind === "retry"
|
|
631
696
|
? "Retry the previous request."
|
|
632
|
-
:
|
|
697
|
+
: hiddenTurnKind === "empty"
|
|
698
|
+
? "Your previous turn contained only thinking and no answer or text. Continue now and produce the actual response, using tools if needed."
|
|
699
|
+
: "Continue exactly where you left off without repeating content.",
|
|
633
700
|
display: false,
|
|
634
701
|
details: undefined,
|
|
635
702
|
},
|
package/src/error-patterns.ts
CHANGED
|
@@ -142,10 +142,19 @@ const SILENCED_PATTERNS = [
|
|
|
142
142
|
// "5-hour limit reached · resets 12pm"
|
|
143
143
|
// - Codex: "You've hit your usage limit. Upgrade to Plus"
|
|
144
144
|
// "You've exceeded your usage limit."
|
|
145
|
+
// + ChatGPT subscription plan caps (surfaced from chatgpt.com backend,
|
|
146
|
+
// observed via codex providers): "You have hit your ChatGPT usage limit
|
|
147
|
+
// (plus plan). Try again in ~5330 min." — plan name in parens varies
|
|
148
|
+
// (go/plus/pro/team); also arrives as HTTP 429 "The usage limit has been
|
|
149
|
+
// reached" with error.type `usage_limit_reached`
|
|
145
150
|
// - OpenAI: code "insufficient_quota" — "You exceeded your current
|
|
146
151
|
// quota, please check your plan and billing details"
|
|
147
152
|
// - Gemini: same sentence in 429 RESOURCE_EXHAUSTED responses; only
|
|
148
153
|
// reaches us after pi's built-in 429 retry gives up
|
|
154
|
+
// + Google AI Pro/Ultra subscription caps: "You have exhausted your
|
|
155
|
+
// capacity on this model. Your quota will reset after 8h44m7s." (Code
|
|
156
|
+
// Assist), "You have reached the quota limit for Claude Sonnet 4.5
|
|
157
|
+
// (Thinking). You can resume using this model at …" (Antigravity)
|
|
149
158
|
// - OpenRouter: "Rate limit exceeded: free-models-per-day. ..."
|
|
150
159
|
// - Alibaba: "Allocated quota exceeded, please increase your quota limit"
|
|
151
160
|
// (Throttling.AllocationQuota — hard cap; RateQuota is
|
|
@@ -154,9 +163,21 @@ const SILENCED_PATTERNS = [
|
|
|
154
163
|
// - LiteLLM: "Budget has been exceeded! Current cost: …, Max budget: …"
|
|
155
164
|
// - Kimi: "Your account {org}<{ak}> is suspended, please check your
|
|
156
165
|
// plan and billing details" (exceeded_current_quota_error)
|
|
166
|
+
// - z.ai GLM: "Usage limit reached for 5 hour. Your limit will reset at
|
|
167
|
+
// …" (5-hour window, matches usage-limit-reached above) and
|
|
168
|
+
// 429 code 1113 "Insufficient balance or no resource package.
|
|
169
|
+
// Please recharge." (Coding Plan quota drained — unlike plain
|
|
170
|
+
// balance errors this needs a window reset or plan change)
|
|
171
|
+
//
|
|
172
|
+
// Deliberately retryable (verified, kept out): DeepSeek 402 "Insufficient
|
|
173
|
+
// Balance" and 429 "Rate Limit Reached" (concurrency), Kimi TPD org limits
|
|
174
|
+
// and "exceeded your current token quota" (balance). See the note on
|
|
175
|
+
// hasQuotaExhaustedError below.
|
|
157
176
|
export const QUOTA_EXHAUSTED_PATTERNS = [
|
|
158
|
-
// Session / usage limits with reset windows (Claude, Codex)
|
|
159
|
-
/hit your (
|
|
177
|
+
// Session / usage limits with reset windows (Claude, Codex, ChatGPT plans)
|
|
178
|
+
/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)
|
|
179
|
+
/hit your limit/i,
|
|
180
|
+
/usage_limit_reached/i, // Codex backend 429 error.type surfaced in the body
|
|
160
181
|
/usage\s*limit\s*(has\s*been\s*)?reached/i,
|
|
161
182
|
/hour\s*limit\s*reached/i, // "5-hour limit reached" — must NOT hit DeepSeek 429 "Rate Limit Reached"
|
|
162
183
|
/limit\s*will\s*reset\s*at/i,
|
|
@@ -175,6 +196,13 @@ export const QUOTA_EXHAUSTED_PATTERNS = [
|
|
|
175
196
|
/budget\s*(has\s*been\s*)?(exceeded|exhausted|limit)/i,
|
|
176
197
|
/max(imum)?\s*budget\s*(exceeded|reached|limit)/i,
|
|
177
198
|
/spending\s*limit/i,
|
|
199
|
+
// Google subscription caps (Gemini Code Assist, Antigravity)
|
|
200
|
+
/exhausted your capacity/i, // "You have exhausted your capacity on this model."
|
|
201
|
+
/quota will reset after/i, // "Your quota will reset after 8h44m7s."
|
|
202
|
+
/reached the quota limit/i, // Antigravity "You have reached the quota limit for Gemini 3 Pro (High)"
|
|
203
|
+
/you can resume using this model/i, // Antigravity resume tail when the lead-in is truncated
|
|
204
|
+
// z.ai / GLM Coding Plan window exhaustion (429 code 1113)
|
|
205
|
+
/no resource package/i, // "Insufficient balance or no resource package. Please recharge."
|
|
178
206
|
// Suspended accounts (Kimi exceeded_current_quota_error suspended form)
|
|
179
207
|
/account\b[^.]*\bis\s*suspended/i,
|
|
180
208
|
// Generic
|
|
@@ -294,5 +322,39 @@ export function hasMaxTokensStop(message: AgentMessage): boolean {
|
|
|
294
322
|
return message.stopReason === "length";
|
|
295
323
|
}
|
|
296
324
|
|
|
325
|
+
// ── Empty / think-only stop (not an error — continuation) ──
|
|
326
|
+
|
|
327
|
+
function getContentBlocks(message: AgentMessage): unknown[] {
|
|
328
|
+
const content = (message as { content?: unknown }).content;
|
|
329
|
+
return Array.isArray(content) ? content : [];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Returns true for an assistant message whose turn ended without any
|
|
334
|
+
* USABLE output: stopReason "stop" with only empty content — zero blocks
|
|
335
|
+
* (Anthropic's documented empty responses with end_turn) or only reasoning
|
|
336
|
+
* blocks (a reasoning model spent its output budget on thinking and never
|
|
337
|
+
* produced text/tool calls — pi#6963 / kimi-cli think-only class).
|
|
338
|
+
*
|
|
339
|
+
* "Usable" = a non-empty text block or a toolCall block. Thinking, images,
|
|
340
|
+
* and blank text do not count. This deliberately mirrors Anthropic's
|
|
341
|
+
* "Empty responses..." do not continue the loop on these, and does NOT
|
|
342
|
+
* flag a legitimately text-only final answer.
|
|
343
|
+
*/
|
|
344
|
+
export function hasEmptyStop(message: AgentMessage): boolean {
|
|
345
|
+
if (!isAssistantMessage(message)) return false;
|
|
346
|
+
if (message.stopReason !== "stop") return false;
|
|
347
|
+
const hasUsable = getContentBlocks(message).some((block) => {
|
|
348
|
+
if (!block || typeof block !== "object") return false;
|
|
349
|
+
const candidate = block as { type?: unknown; text?: unknown };
|
|
350
|
+
if (candidate.type === "toolCall") return true;
|
|
351
|
+
if (candidate.type === "text") {
|
|
352
|
+
return typeof candidate.text === "string" && candidate.text.trim().length > 0;
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
});
|
|
356
|
+
return !hasUsable;
|
|
357
|
+
}
|
|
358
|
+
|
|
297
359
|
// Re-export getLastAssistantMessage for convenience
|
|
298
360
|
export { getLastAssistantMessage } from './retry-logic.js';
|