@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 +2 -0
- package/package.json +4 -4
- package/retry.ts +69 -2
- package/src/error-patterns.ts +34 -0
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.
|
|
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.
|
|
42
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
43
|
-
"@earendil-works/pi-tui": "^0.84.
|
|
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
|
-
:
|
|
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
|
@@ -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';
|