@monotykamary/pi-retry 0.7.2 → 0.8.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
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",
@@ -38,9 +38,9 @@
38
38
  "@earendil-works/pi-tui": "*"
39
39
  },
40
40
  "devDependencies": {
41
- "@earendil-works/pi-agent-core": "^0.84.2",
42
- "@earendil-works/pi-coding-agent": "^0.84.2",
43
- "@earendil-works/pi-tui": "^0.84.2",
41
+ "@earendil-works/pi-agent-core": "^0.84.3",
42
+ "@earendil-works/pi-coding-agent": "^0.84.3",
43
+ "@earendil-works/pi-tui": "^0.84.3",
44
44
  "@types/node": "25.9.1",
45
45
  "@vitest/coverage-v8": "4.1.7",
46
46
  "knip": "6.14.1",
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
  },
@@ -322,5 +322,39 @@ export function hasMaxTokensStop(message: AgentMessage): boolean {
322
322
  return message.stopReason === "length";
323
323
  }
324
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
+
325
359
  // Re-export getLastAssistantMessage for convenience
326
360
  export { getLastAssistantMessage } from './retry-logic.js';