@monotykamary/pi-retry 0.6.5 → 0.6.7

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.
Files changed (3) hide show
  1. package/README.md +9 -8
  2. package/package.json +1 -1
  3. package/retry.ts +56 -44
package/README.md CHANGED
@@ -25,7 +25,7 @@ This extension automatically detects and retries **all** errors by default, with
25
25
  | HTTP 400/413 | **Indefinite** with capped backoff, NO compaction | Transient context overflow that might resolve |
26
26
  | Credit / payment errors | **Indefinite** with capped backoff | "Not Enough Credits", insufficient balance, 402 |
27
27
  | Connection errors | **Indefinite** with capped backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
28
- | Max tokens (`stopReason: "length"`) | **Auto-continue** indefinitely (invisible no prompt pollution) | Model hits output token limit mid-generation |
28
+ | Max tokens (`stopReason: "length"`) | **Auto-continue** indefinitely with hidden continuation turns | Model hits output token limit mid-generation |
29
29
 
30
30
  ---
31
31
 
@@ -48,10 +48,10 @@ This extension provides **automatic** infinite retry with sensible exponential b
48
48
  **Features:**
49
49
  - **Catch-all retry** — Any `stopReason: "error"` is retried, regardless of error message
50
50
  - Automatic detection of 400/413, connection, credit, and stream exhaustion errors
51
- - **Auto-continuation** when the model hits its max output tokens (`stopReason: "length"`) — indefinite, no cap, **invisible** to the LLM
51
+ - **Auto-continuation** when the model hits its max output tokens (`stopReason: "length"`) — indefinite, no cap, hidden from the TUI
52
52
  - **Indefinite retry** — Keeps retrying until success
53
53
  - Exponential backoff with cap: max 60s between retries
54
- - **ALL triggers are invisible** — custom messages with `display: false`, stripped by context handler (no TUI clutter, no conversation pollution)
54
+ - **Hidden triggers** — provider-valid custom messages use `display: false`, so retries do not add TUI clutter
55
55
  - Manual controls via unified `/retry` command
56
56
  - Non-retryable errors are explicitly logged so you know why we didn't retry
57
57
 
@@ -124,7 +124,7 @@ Edit the constants at the top of `retry.ts`:
124
124
  const BASE_DELAY_MS = 2000; // Start with 2 seconds
125
125
  const MAX_DELAY_MS = 60000; // Cap at 60 seconds
126
126
  const BACKOFF_MULTIPLIER = 2; // Double each time
127
- // Continuation is now invisible no CONTINUATION_PROMPT needed
127
+ // Continuations use a hidden provider-valid custom message
128
128
  ```
129
129
 
130
130
  ---
@@ -135,9 +135,10 @@ const BACKOFF_MULTIPLIER = 2; // Double each time
135
135
  2. **Check for any error** — Examine the last assistant message for `stopReason === "error"`
136
136
  3. **Blacklist check** — Skip known permanent failures (invalid API key, model not found, etc.)
137
137
  4. **Categorize for messaging** — Classify into 400/413, credit, connection, or other for nice UI notifications
138
- 5. **Retry or continue (both invisible)** — Wait (exponential backoff for errors), then trigger a new turn via `pi.sendMessage()` with `customType`, `display: false`, and `triggerTurn: true`
139
- 6. **Context cleanup** — The `context` event strips all custom-type triggers before the LLM sees them (insurance against custom `convertToLlm` overrides)
140
- 7. **Indefinite continuation** — Max_tokens auto-continues are uncapped; each continuation produces valid output and the model naturally terminates when done
138
+ 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`
139
+ 6. **Valid provider context** — Hidden retry and continuation messages remain in context so providers never receive a trailing assistant message
140
+ 7. **Indefinite continuation** — Max_tokens auto-continues are uncapped; repeated `length` stops keep producing continuation turns until the model terminates normally
141
+ 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
141
142
 
142
143
  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.
143
144
 
@@ -160,7 +161,7 @@ These are explicitly **not** retried:
160
161
  ### Max Tokens (stopReason: "length")
161
162
  - The model hit its `max_tokens` / output token limit
162
163
  - The model's response was truncated mid-generation
163
- - Auto-continuation sends an invisible custom message no visible "Continue" prompt in the conversation
164
+ - Auto-continuation sends a provider-valid custom message hidden from the TUI
164
165
 
165
166
  ### 400/413 Errors
166
167
  - HTTP 400 Bad Request
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
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
@@ -21,6 +21,10 @@ import {
21
21
  CONTINUATION_CUSTOM_TYPE,
22
22
  } from "./src/index.js";
23
23
 
24
+ const RETRY_STARTED_EVENT = "pi-retry:started";
25
+ const RETRY_COMPLETED_EVENT = "pi-retry:completed";
26
+ const RETRY_CANCELLED_EVENT = "pi-retry:cancelled";
27
+
24
28
  /**
25
29
  * Unified retry extension — retries EVERY error by default.
26
30
  *
@@ -35,12 +39,12 @@ import {
35
39
  * - Automatic detection and retry for ALL errors (catch-all)
36
40
  * - Indefinite retry with exponential backoff (capped at 60s)
37
41
  * - Auto-continuation when model hits max output tokens (stopReason "length")
38
- * - ALL triggers are invisible hidden AgentSession turns are filtered before the LLM call
42
+ * - Retry triggers are hidden in the TUI and serialized as provider-valid user turns
39
43
  * - Unified manual controls via /retry command
40
44
  *
41
45
  * Continuation mechanism:
42
46
  * - A hidden custom message starts or joins a canonical AgentSession turn
43
- * - A context hook removes that marker before provider serialization
47
+ * - The message remains in context as a provider-valid user turn
44
48
  * - AgentSession remains authoritative for busy state and queued messages
45
49
  *
46
50
  * Retry loop design:
@@ -111,6 +115,7 @@ let _continueInProgress = false;
111
115
  let _continueGeneration: number | null = null;
112
116
  let _continueInputGeneration: number | null = null;
113
117
  let _inputGeneration = 0;
118
+ let _retryLifecycleId = 0;
114
119
 
115
120
  // Session generation counter: incremented on every session_start.
116
121
  // The retry loop captures the current generation when it starts and exits
@@ -161,33 +166,26 @@ function removeErrorFromAgentState(): void {
161
166
  }
162
167
  }
163
168
 
164
- // Check if the agent's last message indicates a retryable error.
165
- function lastMessageIsRetryableError(): boolean {
166
- if (!_agent) return false;
169
+ type HiddenTurnKind = "retry" | "continue";
170
+
171
+ function getHiddenTurnKind(): HiddenTurnKind | null {
172
+ if (!_agent) return null;
167
173
  const messages = _agent.state.messages;
168
174
  const lastMsg = messages[messages.length - 1];
169
- return lastMsg?.role === 'assistant' && lastMsg.stopReason === 'error';
175
+ if (lastMsg?.role !== "assistant") return null;
176
+ if (lastMsg.stopReason === "error") return "retry";
177
+ if (lastMsg.stopReason === "length") return "continue";
178
+ return null;
179
+ }
180
+
181
+ function lastMessageIsRetryableError(): boolean {
182
+ return getHiddenTurnKind() === "retry";
170
183
  }
171
184
 
172
185
  export default function (pi: ExtensionAPI) {
173
186
 
174
- const markRealPromptStart = () => {
187
+ pi.on("input", () => {
175
188
  _inputGeneration++;
176
- };
177
- pi.on("input", (event) => {
178
- if (event.source === "interactive" || event.source === "rpc") {
179
- markRealPromptStart();
180
- }
181
- });
182
- pi.on("before_agent_start", markRealPromptStart);
183
-
184
- pi.on("context", (event) => {
185
- const messages = event.messages.filter((message: any) => !(
186
- message.role === "custom" &&
187
- (message.customType === RETRY_TRIGGER_CUSTOM_TYPE ||
188
- message.customType === CONTINUATION_CUSTOM_TYPE)
189
- ));
190
- if (messages.length !== event.messages.length) return { messages };
191
189
  });
192
190
 
193
191
  // Reset retry counters on successful completion (not max_tokens, not error)
@@ -249,14 +247,14 @@ export default function (pi: ExtensionAPI) {
249
247
  _continueInputGeneration === _inputGeneration
250
248
  ) return;
251
249
 
252
- // Check for max_tokens stop — auto-continue (invisible to LLM)
250
+ // Check for max_tokens stop — auto-continue with a hidden TUI message
253
251
  if (hasMaxTokensStop(lastAssistant) && !stateContinuation.getIsContinuing()) {
254
252
  stateContinuation.startContinuation();
255
253
  ctx.ui.notify(
256
254
  `Max tokens reached — auto-continuing (continuation ${stateContinuation.getCount()})...`,
257
255
  "info",
258
256
  );
259
- void triggerInvisibleContinue();
257
+ void triggerInvisibleContinue("continue");
260
258
  stateContinuation.endContinuation();
261
259
  return;
262
260
  }
@@ -303,7 +301,7 @@ export default function (pi: ExtensionAPI) {
303
301
  state.startRetry(errorMsg);
304
302
  state.endRetry();
305
303
 
306
- void triggerInvisibleContinue();
304
+ void triggerInvisibleContinue("retry");
307
305
  return;
308
306
  }
309
307
 
@@ -358,7 +356,7 @@ export default function (pi: ExtensionAPI) {
358
356
  status += "Max Tokens Continuation:\n";
359
357
  status += ` Continuations used: ${stateContinuation.getCount()}\n`;
360
358
  status += ` Is continuing: ${stateContinuation.getIsContinuing()}\n`;
361
- status += ` Trigger: hidden AgentSession turn (filtered before provider call)\n\n`;
359
+ status += ` Trigger: hidden provider-valid AgentSession turn\n\n`;
362
360
 
363
361
  // Config
364
362
  status += "Configuration:\n";
@@ -409,7 +407,7 @@ export default function (pi: ExtensionAPI) {
409
407
  // Auto-detect: max_tokens continuation takes priority
410
408
  if (hasMaxTokensStop(lastAssistant)) {
411
409
  ctx.ui.notify("Manually continuing after max_tokens...", "info");
412
- void triggerInvisibleContinue();
410
+ void triggerInvisibleContinue("continue");
413
411
  return;
414
412
  }
415
413
 
@@ -428,21 +426,21 @@ export default function (pi: ExtensionAPI) {
428
426
  if (has400or413Error(lastAssistant)) {
429
427
  ctx.ui.notify("Manually retrying 400/413 error...", "info");
430
428
  state400.reset();
431
- void triggerInvisibleContinue();
429
+ void triggerInvisibleContinue("retry");
432
430
  return;
433
431
  }
434
432
 
435
433
  if (hasCreditError(lastAssistant)) {
436
434
  ctx.ui.notify("Manually retrying credit error...", "info");
437
435
  stateCredit.reset();
438
- void triggerInvisibleContinue();
436
+ void triggerInvisibleContinue("retry");
439
437
  return;
440
438
  }
441
439
 
442
440
  if (hasConnectionError(lastAssistant)) {
443
441
  ctx.ui.notify("Manually retrying connection error...", "info");
444
442
  stateConnection.reset();
445
- void triggerInvisibleContinue();
443
+ void triggerInvisibleContinue("retry");
446
444
  return;
447
445
  }
448
446
 
@@ -450,7 +448,7 @@ export default function (pi: ExtensionAPI) {
450
448
  if (hasRetryableError(lastAssistant)) {
451
449
  ctx.ui.notify("Manually retrying error...", "info");
452
450
  stateOther.reset();
453
- void triggerInvisibleContinue();
451
+ void triggerInvisibleContinue("retry");
454
452
  return;
455
453
  }
456
454
 
@@ -500,9 +498,10 @@ export default function (pi: ExtensionAPI) {
500
498
  //
501
499
  // Unlike the original one-shot design, this function loops. After each
502
500
  // hidden AgentSession turn it checks the result:
503
- // - Success (stopReason !== "error"): loop exits, agent is done.
504
- // - Error (stopReason === "error"): sleep with backoff, then retry.
505
- // - User abort (stopReason "aborted"): loop exits immediately.
501
+ // - Success: loop exits when the stop reason is neither error nor length.
502
+ // - Error: sleep with backoff, then retry the request.
503
+ // - Length: sleep with backoff, then continue the response.
504
+ // - User abort: loop exits immediately.
506
505
  //
507
506
  // The backoff sleep happens AFTER the hidden turn settles and processEvents
508
507
  // has settled, so it does NOT block the agent. The agent is idle during
@@ -511,7 +510,7 @@ export default function (pi: ExtensionAPI) {
511
510
  // Before each retry, the error assistant message is removed from
512
511
  // agent.state.messages so the LLM receives a clean context (same
513
512
  // technique as the built-in retry's _prepareRetry).
514
- async function triggerInvisibleContinue() {
513
+ async function triggerInvisibleContinue(initialKind: HiddenTurnKind) {
515
514
  if (!_agent) return;
516
515
 
517
516
  // Guard: if the user aborted, do not queue another retry turn.
@@ -520,6 +519,9 @@ export default function (pi: ExtensionAPI) {
520
519
  // Guard: mutex — if a previous continue is still in-flight, skip
521
520
  if (_continueInProgress) return;
522
521
  _continueInProgress = true;
522
+ const retryLifecycleId = ++_retryLifecycleId;
523
+ let didRetryComplete = false;
524
+ pi.events.emit(RETRY_STARTED_EVENT, { retryId: retryLifecycleId });
523
525
 
524
526
  // Capture the current session generation. If /new fires while we're
525
527
  // looping, _sessionGeneration will increment and the loop will exit.
@@ -542,6 +544,7 @@ export default function (pi: ExtensionAPI) {
542
544
  ) return;
543
545
 
544
546
  let attempt = 0;
547
+ let hiddenTurnKind: HiddenTurnKind | null = initialKind;
545
548
 
546
549
  // Loop until success, abort, or session change.
547
550
  while (true) {
@@ -552,8 +555,12 @@ export default function (pi: ExtensionAPI) {
552
555
  ) return;
553
556
 
554
557
  // Preserve the trigger kind before removing a trailing error from
555
- // live state. The error remains in the session journal for history.
556
- const isErrorRetry = lastMessageIsRetryableError();
558
+ // live state. Length-stopped output stays in context so the model can
559
+ // continue from it; error messages remain only in the session journal.
560
+ if (!hiddenTurnKind) {
561
+ didRetryComplete = true;
562
+ return;
563
+ }
557
564
  removeErrorFromAgentState();
558
565
 
559
566
  attempt++;
@@ -576,10 +583,12 @@ export default function (pi: ExtensionAPI) {
576
583
  try {
577
584
  pi.sendMessage(
578
585
  {
579
- customType: isErrorRetry
586
+ customType: hiddenTurnKind === "retry"
580
587
  ? RETRY_TRIGGER_CUSTOM_TYPE
581
588
  : CONTINUATION_CUSTOM_TYPE,
582
- content: [],
589
+ content: hiddenTurnKind === "retry"
590
+ ? "Retry the previous request."
591
+ : "Continue exactly where you left off without repeating content.",
583
592
  display: false,
584
593
  details: undefined,
585
594
  },
@@ -603,17 +612,20 @@ export default function (pi: ExtensionAPI) {
603
612
  _inputGeneration !== myInputGeneration
604
613
  ) return;
605
614
 
606
- // The hidden AgentSession turn completed. Check the result.
607
- if (!lastMessageIsRetryableError()) {
608
- // Success or non-error terminal state — exit the loop.
615
+ // The hidden AgentSession turn completed. Both errors and output
616
+ // length stops need another turn; all other terminal states are done.
617
+ hiddenTurnKind = getHiddenTurnKind();
618
+ if (!hiddenTurnKind) {
619
+ didRetryComplete = true;
609
620
  return;
610
621
  }
611
-
612
- // Error again — loop back for another attempt.
613
622
  }
614
623
  } finally {
615
624
  // Release the mutex only if this loop still owns it.
616
625
  if (_continueGeneration === myGeneration) {
626
+ pi.events.emit(didRetryComplete ? RETRY_COMPLETED_EVENT : RETRY_CANCELLED_EVENT, {
627
+ retryId: retryLifecycleId,
628
+ });
617
629
  _continueInProgress = false;
618
630
  _continueGeneration = null;
619
631
  _continueInputGeneration = null;