@monotykamary/pi-retry 0.3.11 → 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 +211 -63
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.3.11",
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();
@@ -123,6 +153,11 @@ const stateOther = new RetryState();
123
153
  // Max_tokens continuation state (indefinite — no cap needed)
124
154
  const stateContinuation = new ContinuationState();
125
155
 
156
+ // Abort flag: set when turn_end reports stopReason "aborted", cleared on
157
+ // session_start and on fresh user activity. Prevents triggerInvisibleContinue()
158
+ // from driving a new prompt([]) after the user explicitly cancelled.
159
+ let _userAborted = false;
160
+
126
161
  // Mutex: only one triggerInvisibleContinue may be in-flight at a time.
127
162
  // Without this, concurrent agent_end events (or a manual /retry during an
128
163
  // automatic retry) race through waitForIdle() and both call prompt([]),
@@ -134,11 +169,33 @@ let _continueInProgress = false;
134
169
  // triggerInvisibleContinue just ran and the session's continue() unblocks.
135
170
  let _lastInvisibleContinueTime = 0;
136
171
 
137
- // Sleep helper
172
+ // Sleep helper (non-abortable — used inside the retry loop outside
173
+ // processEvents where no abort signal is available)
138
174
  function sleep(ms: number): Promise<void> {
139
175
  return new Promise(resolve => setTimeout(resolve, ms));
140
176
  }
141
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
+
142
199
  export default function (pi: ExtensionAPI) {
143
200
 
144
201
  // Reset retry counters on successful completion (not max_tokens, not error)
@@ -152,9 +209,10 @@ export default function (pi: ExtensionAPI) {
152
209
  stateCredit.reset();
153
210
  stateConnection.reset();
154
211
  stateOther.reset();
155
- // Do NOT reset continuation state — a user abort of a continuation
156
- // turn is different from aborting an error retry.
157
212
  stateContinuation.endContinuation();
213
+ // Signal to any in-flight triggerInvisibleContinue or pending retry
214
+ // that the user has cancelled — don't drive a new prompt([]).
215
+ _userAborted = true;
158
216
  return;
159
217
  }
160
218
  if (msg.stopReason !== "length") {
@@ -164,19 +222,38 @@ export default function (pi: ExtensionAPI) {
164
222
  stateConnection.succeed();
165
223
  stateOther.succeed();
166
224
  stateContinuation.complete();
225
+ // Clear abort flag — this is a fresh successful turn, so any
226
+ // previous abort is stale and shouldn't block future retries.
227
+ _userAborted = false;
167
228
  }
168
229
  }
169
230
  });
170
231
 
171
- // 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).
172
242
  pi.on("agent_end", async (event, ctx) => {
173
243
  const entries = ctx.sessionManager.getEntries();
174
244
  const lastAssistant = getLastAssistantMessage(entries);
175
-
245
+
176
246
  if (!lastAssistant || !isAssistantMessage(lastAssistant)) {
177
247
  return;
178
248
  }
179
249
 
250
+ // Guard: if the user aborted, don't drive any new prompt([])
251
+ if (_userAborted) return;
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
+
180
257
  // Check for max_tokens stop — auto-continue (invisible to LLM)
181
258
  if (hasMaxTokensStop(lastAssistant) && !stateContinuation.getIsContinuing()) {
182
259
  stateContinuation.startContinuation();
@@ -184,7 +261,6 @@ export default function (pi: ExtensionAPI) {
184
261
  `Max tokens reached — auto-continuing (continuation ${stateContinuation.getCount()})...`,
185
262
  "info",
186
263
  );
187
- // Must NOT await — see triggerInvisibleContinue() for explanation
188
264
  void triggerInvisibleContinue();
189
265
  stateContinuation.endContinuation();
190
266
  return;
@@ -214,13 +290,12 @@ export default function (pi: ExtensionAPI) {
214
290
 
215
291
  if (state.getIsRetrying()) return;
216
292
 
293
+ // Record the error for diagnostics but do NOT sleep here.
294
+ // The retry loop in triggerInvisibleContinue handles backoff.
217
295
  state.startRetry(errorMsg);
218
- const delay = calculateDelay(state.getAttempt());
296
+ state.endRetry();
219
297
 
220
- await sleep(delay);
221
- // Must NOT await — see triggerInvisibleContinue() for explanation
222
298
  void triggerInvisibleContinue();
223
- state.endRetry();
224
299
  return;
225
300
  }
226
301
 
@@ -244,9 +319,9 @@ export default function (pi: ExtensionAPI) {
244
319
  if (subcommand === "status") {
245
320
  const entries = ctx.sessionManager.getEntries();
246
321
  const lastAssistant = getLastAssistantMessage(entries);
247
-
322
+
248
323
  let status = "=== Retry Status ===\n\n";
249
-
324
+
250
325
  // 400/413 state
251
326
  status += "400/413 Errors:\n";
252
327
  status += ` Current attempt: ${state400.getAttempt()}\n`;
@@ -270,20 +345,20 @@ export default function (pi: ExtensionAPI) {
270
345
  status += ` Current attempt: ${stateOther.getAttempt()}\n`;
271
346
  status += ` Is retrying: ${stateOther.getIsRetrying()}\n`;
272
347
  status += ` Last error: ${stateOther.getLastErrorMessage().substring(0, 100) || "None"}\n\n`;
273
-
348
+
274
349
  // Continuation state
275
350
  status += "Max Tokens Continuation:\n";
276
351
  status += ` Continuations used: ${stateContinuation.getCount()}\n`;
277
352
  status += ` Is continuing: ${stateContinuation.getIsContinuing()}\n`;
278
353
  status += ` Trigger: invisible (agent.prompt([]), LLM never sees a prompt)\n\n`;
279
-
354
+
280
355
  // Config
281
356
  status += "Configuration:\n";
282
357
  status += ` Base delay: 2000ms\n`;
283
358
  status += ` Max delay: 60000ms\n`;
284
359
  status += ` Backoff multiplier: 2\n`;
285
- status += ` Continuation: invisible (agent.prompt([]))\n\n`;
286
-
360
+ status += ` Retry loop: infinite (triggerInvisibleContinue loops until success or abort)\n\n`;
361
+
287
362
  // Last assistant info
288
363
  if (lastAssistant && isAssistantMessage(lastAssistant)) {
289
364
  status += "Last Assistant Message:\n";
@@ -293,7 +368,7 @@ export default function (pi: ExtensionAPI) {
293
368
  status += ` Error category: ${getErrorCategory(lastAssistant.errorMessage)}`;
294
369
  }
295
370
  }
296
-
371
+
297
372
  ctx.ui.notify(status, "info");
298
373
  return;
299
374
  }
@@ -305,6 +380,7 @@ export default function (pi: ExtensionAPI) {
305
380
  stateConnection.reset();
306
381
  stateOther.reset();
307
382
  stateContinuation.reset();
383
+ _userAborted = false;
308
384
  ctx.ui.notify("All retry counters reset", "info");
309
385
  return;
310
386
  }
@@ -312,12 +388,16 @@ export default function (pi: ExtensionAPI) {
312
388
  // /retry (no args) - Manual trigger with auto-detection
313
389
  const entries = ctx.sessionManager.getEntries();
314
390
  const lastAssistant = getLastAssistantMessage(entries);
315
-
391
+
316
392
  if (!lastAssistant || !isAssistantMessage(lastAssistant)) {
317
393
  ctx.ui.notify("No assistant message found to retry", "warning");
318
394
  return;
319
395
  }
320
396
 
397
+ // Manual /retry overrides any previous abort — the user is
398
+ // explicitly requesting a retry, so clear the abort flag.
399
+ _userAborted = false;
400
+
321
401
  // Auto-detect: max_tokens continuation takes priority
322
402
  if (hasMaxTokensStop(lastAssistant)) {
323
403
  ctx.ui.notify("Manually continuing after max_tokens...", "info");
@@ -369,20 +449,31 @@ export default function (pi: ExtensionAPI) {
369
449
  stateContinuation.reset();
370
450
  _continueInProgress = false;
371
451
  _lastInvisibleContinueTime = 0;
452
+ _userAborted = false;
372
453
  });
373
454
 
374
- // Resume the agent loop invisiblyno message injected into context.
375
- // The LLM sees the exact same message list it had before.
455
+ // Retry loop driverthe core of pi-retry.
376
456
  //
377
- // The continue() monkey-patch at the top of this file ensures the
378
- // session's built-in retry loop can never race us. While
379
- // _continueInProgress is true, the session's continue() waits.
380
- // When we finish, it wakes, finds the transcript already updated,
381
- // gracefully no-ops, and the session loop exits.
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.
462
+ //
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).
382
470
  async function triggerInvisibleContinue() {
383
471
  if (!_agent) return;
384
472
 
385
- // Guard 1: mutex if a previous continue is still in-flight, skip
473
+ // Guard: if the user aborted, don't drive a new prompt([]).
474
+ if (_userAborted) return;
475
+
476
+ // Guard: mutex — if a previous continue is still in-flight, skip
386
477
  if (_continueInProgress) return;
387
478
  _continueInProgress = true;
388
479
 
@@ -391,22 +482,79 @@ export default function (pi: ExtensionAPI) {
391
482
  // finishRun() after agent_end listeners return).
392
483
  await _agent.waitForIdle();
393
484
 
394
- try {
395
- // Await so _continueInProgress stays true for the full retry.
396
- // The session's continue() is blocked (monkey-patch) and the
397
- // session's _runAgentPrompt stays alive, keeping the UI
398
- // "Working…" until the agent is actually done.
399
- await _agent.prompt([]);
400
- } catch {
401
- // Ignore — if prompt throws, something else is driving.
402
- // The session will handle it or report the error.
485
+ // Re-check after waitForIdle: the user may have aborted while
486
+ // we were waiting for the agent to become idle.
487
+ if (_userAborted) return;
488
+
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.
403
529
  }
404
530
  } finally {
405
531
  _continueInProgress = false;
406
- // Record completion time so the continue() monkey-patch can
407
- // detect that an invisible continue just ran and avoid firing
408
- // a duplicate prompt([]) (RC7: double continuation guard).
409
532
  _lastInvisibleContinueTime = Date.now();
410
533
  }
411
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
+ }
412
560
  }