@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 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
- - **Hard allotments** — OpenRouter `free-models-per-day`, Alibaba "Allocated quota exceeded" (`Throttling.AllocationQuota`), GitHub Copilot "premium request allowance"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
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
- : "Continue exactly where you left off without repeating content.",
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
  },
@@ -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 (usage )?limit/i,
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';