@monotykamary/pi-retry 0.3.12 → 0.4.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/retry.ts +185 -76
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.3.12",
3
+ "version": "0.4.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
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Agent } from "@earendil-works/pi-agent-core";
3
+ import { AgentSession } from "@earendil-works/pi-coding-agent";
3
4
  import {
4
5
  has400or413Error,
5
6
  hasCreditError,
@@ -39,15 +40,26 @@ import {
39
40
  * - agent.prompt([]) starts a fresh agent loop with an empty prompt array
40
41
  * - No message injected into context — LLM sees the exact same message list
41
42
  * - No convertToLlm involvement, no filter needed, no session artifact
43
+ *
44
+ * Retry loop design:
45
+ * - The agent_end handler detects retryable errors but does NOT sleep.
46
+ * It fires triggerInvisibleContinue() immediately, keeping processEvents
47
+ * unblocked so the agent can finish its run and become idle.
48
+ * - triggerInvisibleContinue() owns the retry loop: it waits for idle,
49
+ * removes error assistant messages from agent state, calls prompt([])
50
+ * and checks the result. On error it sleeps (outside processEvents)
51
+ * and retries. On success or user abort the loop exits.
52
+ * - The continue() monkey-patch cooperates: while _continueInProgress is
53
+ * true, the session's continue() spins. After the loop finishes, it
54
+ * calls _origContinue which checks the now-updated agent state. For
55
+ * stopReason "error" it no longer falls back to prompt([]) (the loop
56
+ * already handled it). For toolUse/length (compaction mid-task) it
57
+ * still falls back to prompt([]).
42
58
  */
43
59
 
44
60
  // Capture the live Agent instance when AgentSession subscribes to it.
45
61
  // subscribe() is called during AgentSession construction — fires on both
46
62
  // fresh sessions and session resumes.
47
- //
48
- // We also monkey-patch continue() so the session's loop can never race
49
- // our retry. Without this, observing isStreaming is a heuristic that
50
- // misses the narrow window between our check and the session's call.
51
63
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
64
  let _agent: Agent | null = null;
53
65
 
@@ -59,18 +71,9 @@ Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
59
71
 
60
72
  // Monkey-patch continue() so the session's built-in retry loop cooperates
61
73
  // with our _continueInProgress mutex AND can convert the "Cannot continue
62
- // from assistant" error into a prompt([]) call when the agent was mid-task.
63
- //
64
- // When continue() throws "Cannot continue from message role: assistant":
65
- // - stopReason "stop" → agent finished cleanly, don't continue
66
- // - stopReason "aborted" → user cancelled, don't continue
67
- // - stopReason "error" → pi-retry IS the error handler, fall back to prompt([])
68
- // so the retry actually happens (rather than swallowing and stalling)
69
- // - stopReason "toolUse" or "length" → mid-task, fall back to prompt([])
70
- //
71
- // This ensures the agent loop actually continues after compaction instead
72
- // of swallowing the error and letting the while-loop die.
73
- const _origContinue = Agent.prototype.continue as (this: Agent) => Promise<unknown>;
74
+ // from assistant" error into a prompt([]) call when the agent was mid-task
75
+ // (compaction, toolUse, length — but NOT error, which the loop handles).
76
+ const _origContinue = Agent.prototype.continue as (this: Agent) => Promise<void>;
74
77
  Agent.prototype.continue = function (this: Agent) {
75
78
  const self = this;
76
79
  return (async () => {
@@ -84,15 +87,22 @@ Agent.prototype.continue = function (this: Agent) {
84
87
  const msg = e?.message ?? '';
85
88
  if (msg.includes('Cannot continue from message role') ||
86
89
  msg.includes('Cannot continue from an assistant message')) {
87
- // Check stopReason — only continue if the agent was mid-task
88
90
  const lastMsg = self.state.messages[self.state.messages.length - 1];
89
- if (lastMsg?.role === 'assistant' &&
90
- lastMsg.stopReason !== 'stop' &&
91
- lastMsg.stopReason !== 'aborted') {
92
- // Agent was mid-task (toolUse, length, or error) — fall back to prompt([])
93
- // Guard: if triggerInvisibleContinue() just completed (within 500ms),
94
- // the agent already continued — skip to avoid double continuation.
95
- if (!_continueInProgress && Date.now() - _lastInvisibleContinueTime > 500) {
91
+ if (lastMsg?.role === 'assistant') {
92
+ // stopReason "error": pi-retry's loop is the error handler.
93
+ // It will have already retried or the user aborted — don't
94
+ // start a second retry path via prompt([]).
95
+ if (lastMsg.stopReason === 'error') {
96
+ return;
97
+ }
98
+ // stopReason "stop" / "aborted": agent finished or user cancelled.
99
+ // Don't continue.
100
+ if (lastMsg.stopReason === 'stop' || lastMsg.stopReason === 'aborted') {
101
+ return;
102
+ }
103
+ // stopReason "toolUse" or "length": agent was mid-task (e.g.
104
+ // compaction broke the message ordering). Fall back to prompt([]).
105
+ if (!_continueInProgress) {
96
106
  _continueInProgress = true;
97
107
  try {
98
108
  await self.prompt([]);
@@ -103,7 +113,6 @@ Agent.prototype.continue = function (this: Agent) {
103
113
  }
104
114
  }
105
115
  }
106
- // For stop/aborted: return void, the session loop exits naturally
107
116
  return;
108
117
  }
109
118
  if (msg.includes('Agent is already processing')) {
@@ -114,6 +123,27 @@ Agent.prototype.continue = function (this: Agent) {
114
123
  })();
115
124
  };
116
125
 
126
+ // Monkey-patch AgentSession._prepareRetry to suppress the built-in retry
127
+ // when pi-retry's loop is driving. Without this, both the built-in retry
128
+ // and pi-retry race to handle the same error: the built-in retry counts
129
+ // 3 failed attempts and shows "Retry failed after 3 attempts: ...",
130
+ // while pi-retry is still looping indefinitely in the background.
131
+ //
132
+ // When _continueInProgress is true (pi-retry is running), _prepareRetry
133
+ // returns false immediately, so _handlePostAgentRun falls through to
134
+ // the compaction check and the while loop in _runAgentPrompt exits
135
+ // cleanly. No auto_retry_start/end events, no "Retry failed" message.
136
+ //
137
+ // When _continueInProgress is false (pi-retry is not active), the
138
+ // built-in retry works normally as a fallback.
139
+ const _origPrepareRetry = (AgentSession.prototype as any)._prepareRetry;
140
+ (AgentSession.prototype as any)._prepareRetry = function(this: any, message: any) {
141
+ if (_continueInProgress) {
142
+ return Promise.resolve(false);
143
+ }
144
+ return _origPrepareRetry.call(this, message);
145
+ };
146
+
117
147
  // Per-category retry state (for diagnostics / messaging)
118
148
  const state400 = new RetryState();
119
149
  const stateCredit = new RetryState();
@@ -139,11 +169,33 @@ let _continueInProgress = false;
139
169
  // triggerInvisibleContinue just ran and the session's continue() unblocks.
140
170
  let _lastInvisibleContinueTime = 0;
141
171
 
142
- // Sleep helper
172
+ // Sleep helper (non-abortable — used inside the retry loop outside
173
+ // processEvents where no abort signal is available)
143
174
  function sleep(ms: number): Promise<void> {
144
175
  return new Promise(resolve => setTimeout(resolve, ms));
145
176
  }
146
177
 
178
+ // Remove the error assistant message at the end of agent state, if present.
179
+ // Same technique used by the built-in retry in _prepareRetry — the error
180
+ // message stays in the session journal for history but is removed from the
181
+ // agent's live transcript so the LLM receives a clean context on retry.
182
+ function removeErrorFromAgentState(): void {
183
+ if (!_agent) return;
184
+ const messages = _agent.state.messages;
185
+ const lastMsg = messages[messages.length - 1];
186
+ if (lastMsg?.role === 'assistant' && lastMsg.stopReason === 'error') {
187
+ _agent.state.messages = messages.slice(0, -1);
188
+ }
189
+ }
190
+
191
+ // Check if the agent's last message indicates a retryable error.
192
+ function lastMessageIsRetryableError(): boolean {
193
+ if (!_agent) return false;
194
+ const messages = _agent.state.messages;
195
+ const lastMsg = messages[messages.length - 1];
196
+ return lastMsg?.role === 'assistant' && lastMsg.stopReason === 'error';
197
+ }
198
+
147
199
  export default function (pi: ExtensionAPI) {
148
200
 
149
201
  // Reset retry counters on successful completion (not max_tokens, not error)
@@ -157,8 +209,6 @@ export default function (pi: ExtensionAPI) {
157
209
  stateCredit.reset();
158
210
  stateConnection.reset();
159
211
  stateOther.reset();
160
- // Do NOT reset continuation state — a user abort of a continuation
161
- // turn is different from aborting an error retry.
162
212
  stateContinuation.endContinuation();
163
213
  // Signal to any in-flight triggerInvisibleContinue or pending retry
164
214
  // that the user has cancelled — don't drive a new prompt([]).
@@ -179,11 +229,20 @@ export default function (pi: ExtensionAPI) {
179
229
  }
180
230
  });
181
231
 
182
- // Handle errors and max_tokens on agent_end
232
+ // Handle errors and max_tokens on agent_end.
233
+ //
234
+ // IMPORTANT: this handler must return quickly and NOT await sleep().
235
+ // The handler is invoked inside processEvents(), which blocks finishRun()
236
+ // until all listeners settle. A sleep here freezes the entire agent —
237
+ // no UI updates, no abort handling, no event processing.
238
+ //
239
+ // Instead, the handler detects errors and kicks off
240
+ // triggerInvisibleContinue(), which owns the retry loop with backoff
241
+ // sleeps that happen AFTER processEvents returns (outside the agent run).
183
242
  pi.on("agent_end", async (event, ctx) => {
184
243
  const entries = ctx.sessionManager.getEntries();
185
244
  const lastAssistant = getLastAssistantMessage(entries);
186
-
245
+
187
246
  if (!lastAssistant || !isAssistantMessage(lastAssistant)) {
188
247
  return;
189
248
  }
@@ -191,6 +250,10 @@ export default function (pi: ExtensionAPI) {
191
250
  // Guard: if the user aborted, don't drive any new prompt([])
192
251
  if (_userAborted) return;
193
252
 
253
+ // If the retry loop is already driving, don't interfere — it will
254
+ // see the new error on its next loop iteration.
255
+ if (_continueInProgress) return;
256
+
194
257
  // Check for max_tokens stop — auto-continue (invisible to LLM)
195
258
  if (hasMaxTokensStop(lastAssistant) && !stateContinuation.getIsContinuing()) {
196
259
  stateContinuation.startContinuation();
@@ -198,7 +261,6 @@ export default function (pi: ExtensionAPI) {
198
261
  `Max tokens reached — auto-continuing (continuation ${stateContinuation.getCount()})...`,
199
262
  "info",
200
263
  );
201
- // Must NOT await — see triggerInvisibleContinue() for explanation
202
264
  void triggerInvisibleContinue();
203
265
  stateContinuation.endContinuation();
204
266
  return;
@@ -228,22 +290,12 @@ export default function (pi: ExtensionAPI) {
228
290
 
229
291
  if (state.getIsRetrying()) return;
230
292
 
293
+ // Record the error for diagnostics but do NOT sleep here.
294
+ // The retry loop in triggerInvisibleContinue handles backoff.
231
295
  state.startRetry(errorMsg);
232
- const delay = calculateDelay(state.getAttempt());
233
-
234
- await sleep(delay);
235
-
236
- // Re-check: user may have aborted during the backoff sleep.
237
- // turn_end with stopReason "aborted" sets _userAborted, but this
238
- // handler was already past the initial check.
239
- if (_userAborted) {
240
- state.endRetry();
241
- return;
242
- }
296
+ state.endRetry();
243
297
 
244
- // Must NOT await — see triggerInvisibleContinue() for explanation
245
298
  void triggerInvisibleContinue();
246
- state.endRetry();
247
299
  return;
248
300
  }
249
301
 
@@ -267,9 +319,9 @@ export default function (pi: ExtensionAPI) {
267
319
  if (subcommand === "status") {
268
320
  const entries = ctx.sessionManager.getEntries();
269
321
  const lastAssistant = getLastAssistantMessage(entries);
270
-
322
+
271
323
  let status = "=== Retry Status ===\n\n";
272
-
324
+
273
325
  // 400/413 state
274
326
  status += "400/413 Errors:\n";
275
327
  status += ` Current attempt: ${state400.getAttempt()}\n`;
@@ -293,20 +345,20 @@ export default function (pi: ExtensionAPI) {
293
345
  status += ` Current attempt: ${stateOther.getAttempt()}\n`;
294
346
  status += ` Is retrying: ${stateOther.getIsRetrying()}\n`;
295
347
  status += ` Last error: ${stateOther.getLastErrorMessage().substring(0, 100) || "None"}\n\n`;
296
-
348
+
297
349
  // Continuation state
298
350
  status += "Max Tokens Continuation:\n";
299
351
  status += ` Continuations used: ${stateContinuation.getCount()}\n`;
300
352
  status += ` Is continuing: ${stateContinuation.getIsContinuing()}\n`;
301
353
  status += ` Trigger: invisible (agent.prompt([]), LLM never sees a prompt)\n\n`;
302
-
354
+
303
355
  // Config
304
356
  status += "Configuration:\n";
305
357
  status += ` Base delay: 2000ms\n`;
306
358
  status += ` Max delay: 60000ms\n`;
307
359
  status += ` Backoff multiplier: 2\n`;
308
- status += ` Continuation: invisible (agent.prompt([]))\n\n`;
309
-
360
+ status += ` Retry loop: infinite (triggerInvisibleContinue loops until success or abort)\n\n`;
361
+
310
362
  // Last assistant info
311
363
  if (lastAssistant && isAssistantMessage(lastAssistant)) {
312
364
  status += "Last Assistant Message:\n";
@@ -316,7 +368,7 @@ export default function (pi: ExtensionAPI) {
316
368
  status += ` Error category: ${getErrorCategory(lastAssistant.errorMessage)}`;
317
369
  }
318
370
  }
319
-
371
+
320
372
  ctx.ui.notify(status, "info");
321
373
  return;
322
374
  }
@@ -336,7 +388,7 @@ export default function (pi: ExtensionAPI) {
336
388
  // /retry (no args) - Manual trigger with auto-detection
337
389
  const entries = ctx.sessionManager.getEntries();
338
390
  const lastAssistant = getLastAssistantMessage(entries);
339
-
391
+
340
392
  if (!lastAssistant || !isAssistantMessage(lastAssistant)) {
341
393
  ctx.ui.notify("No assistant message found to retry", "warning");
342
394
  return;
@@ -400,24 +452,28 @@ export default function (pi: ExtensionAPI) {
400
452
  _userAborted = false;
401
453
  });
402
454
 
403
- // Resume the agent loop invisiblyno message injected into context.
404
- // The LLM sees the exact same message list it had before.
455
+ // Retry loop driverthe core of pi-retry.
456
+ //
457
+ // Unlike the original one-shot design, this function loops. After each
458
+ // prompt([]) call it checks the result:
459
+ // - Success (stopReason !== "error"): loop exits, agent is done.
460
+ // - Error (stopReason === "error"): sleep with backoff, then retry.
461
+ // - User abort (stopReason "aborted"): loop exits immediately.
405
462
  //
406
- // The continue() monkey-patch at the top of this file ensures the
407
- // session's built-in retry loop can never race us. While
408
- // _continueInProgress is true, the session's continue() waits.
409
- // When we finish, it wakes, finds the transcript already updated,
410
- // gracefully no-ops, and the session loop exits.
463
+ // The backoff sleep happens AFTER prompt([]) returns and processEvents
464
+ // has settled, so it does NOT block the agent. The agent is idle during
465
+ // the sleep and can respond to user input (e.g. Escape to abort).
466
+ //
467
+ // Before each retry, the error assistant message is removed from
468
+ // agent.state.messages so the LLM receives a clean context (same
469
+ // technique as the built-in retry's _prepareRetry).
411
470
  async function triggerInvisibleContinue() {
412
471
  if (!_agent) return;
413
472
 
414
- // Guard 0: if the user aborted, don't drive a new prompt([]).
415
- // This catches aborts that happened between agent_end firing and
416
- // this async function actually executing (e.g. during backoff sleep
417
- // or while waitForIdle was pending).
473
+ // Guard: if the user aborted, don't drive a new prompt([]).
418
474
  if (_userAborted) return;
419
475
 
420
- // Guard 1: mutex — if a previous continue is still in-flight, skip
476
+ // Guard: mutex — if a previous continue is still in-flight, skip
421
477
  if (_continueInProgress) return;
422
478
  _continueInProgress = true;
423
479
 
@@ -430,22 +486,75 @@ export default function (pi: ExtensionAPI) {
430
486
  // we were waiting for the agent to become idle.
431
487
  if (_userAborted) return;
432
488
 
433
- try {
434
- // Await so _continueInProgress stays true for the full retry.
435
- // The session's continue() is blocked (monkey-patch) and the
436
- // session's _runAgentPrompt stays alive, keeping the UI
437
- // "Working…" until the agent is actually done.
438
- await _agent.prompt([]);
439
- } catch {
440
- // Ignore if prompt throws, something else is driving.
441
- // The session will handle it or report the error.
489
+ let attempt = 0;
490
+
491
+ // Loop until success or abort.
492
+ while (true) {
493
+ if (_userAborted) return;
494
+
495
+ // Remove the error assistant message from agent state so
496
+ // prompt([]) sends a clean context to the LLM.
497
+ removeErrorFromAgentState();
498
+
499
+ attempt++;
500
+ const delay = calculateDelay(attempt);
501
+
502
+ // Notify the user about the upcoming retry attempt.
503
+ _notifyRetryAttempt(attempt, delay);
504
+
505
+ // Sleep with backoff BEFORE the retry attempt.
506
+ // This matches the built-in retry's UX: "Retrying (attempt N) in Xs..."
507
+ // The sleep is safe: we are outside processEvents, the agent is
508
+ // idle, and the user can press Escape to abort.
509
+ await sleep(delay);
510
+
511
+ // Re-check after sleep — user may have aborted during backoff.
512
+ if (_userAborted) return;
513
+
514
+ try {
515
+ await _agent.prompt([]);
516
+ } catch {
517
+ // "Agent is already processing" or other transient error —
518
+ // the session or another driver is handling it.
519
+ return;
520
+ }
521
+
522
+ // prompt([]) completed. Check the result.
523
+ if (!lastMessageIsRetryableError()) {
524
+ // Success or non-error terminal state — exit the loop.
525
+ return;
526
+ }
527
+
528
+ // Error again — loop back for another attempt.
442
529
  }
443
530
  } finally {
444
531
  _continueInProgress = false;
445
- // Record completion time so the continue() monkey-patch can
446
- // detect that an invisible continue just ran and avoid firing
447
- // a duplicate prompt([]) (RC7: double continuation guard).
448
532
  _lastInvisibleContinueTime = Date.now();
449
533
  }
450
534
  }
535
+
536
+ // Notify the user about a retry attempt via the extension API.
537
+ // ctx.ui.notify is only available inside event handlers, not inside
538
+ // triggerInvisibleContinue. We capture a fresh reference from the
539
+ // most recent handler invocation so it's always current.
540
+ let _notifyFn: ((message: string, level: "info" | "warning" | "error") => void) | null = null;
541
+
542
+ // Refresh on every handler that carries a ctx — stale references
543
+ // break after session switches (the old ctx becomes invalid).
544
+ pi.on("agent_end", async (_event, ctx) => {
545
+ _notifyFn = (message, level) => ctx.ui.notify(message, level);
546
+ });
547
+
548
+ pi.on("turn_end", async (_event, ctx) => {
549
+ if (!_notifyFn) {
550
+ _notifyFn = (message, level) => ctx.ui.notify(message, level);
551
+ }
552
+ });
553
+
554
+ function _notifyRetryAttempt(attempt: number, delayMs: number) {
555
+ if (_notifyFn) {
556
+ const duration = formatDuration(delayMs);
557
+ _notifyFn(`Retry attempt ${attempt} (backoff ${duration})...`, "info");
558
+ }
559
+ }
451
560
  }