@monotykamary/pi-retry 0.6.4 → 0.6.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
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
@@ -17,6 +17,8 @@ import {
17
17
  getErrorCategory,
18
18
  RetryState,
19
19
  ContinuationState,
20
+ RETRY_TRIGGER_CUSTOM_TYPE,
21
+ CONTINUATION_CUSTOM_TYPE,
20
22
  } from "./src/index.js";
21
23
 
22
24
  /**
@@ -33,29 +35,22 @@ import {
33
35
  * - Automatic detection and retry for ALL errors (catch-all)
34
36
  * - Indefinite retry with exponential backoff (capped at 60s)
35
37
  * - Auto-continuation when model hits max output tokens (stopReason "length")
36
- * - ALL triggers are invisible — agent.prompt([]) resumes the loop with no new message
38
+ * - ALL triggers are invisible — hidden AgentSession turns are filtered before the LLM call
37
39
  * - Unified manual controls via /retry command
38
40
  *
39
- * Invisibility mechanism:
40
- * - Agent.prototype.subscribe monkey-patch captures the Agent instance
41
- * - agent.prompt([]) starts a fresh agent loop with an empty prompt array
42
- * - No message injected into context LLM sees the exact same message list
43
- * - No convertToLlm involvement, no filter needed, no session artifact
41
+ * Continuation mechanism:
42
+ * - A hidden custom message starts or joins a canonical AgentSession turn
43
+ * - A context hook removes that marker before provider serialization
44
+ * - AgentSession remains authoritative for busy state and queued messages
44
45
  *
45
46
  * Retry loop design:
46
47
  * - The agent_end handler detects retryable errors but does NOT sleep.
47
48
  * It fires triggerInvisibleContinue() immediately, keeping processEvents
48
49
  * unblocked so the agent can finish its run and become idle.
49
50
  * - triggerInvisibleContinue() owns the retry loop: it waits for idle,
50
- * removes error assistant messages from agent state, calls prompt([])
51
- * and checks the result. On error it sleeps (outside processEvents)
52
- * and retries. On success or user abort the loop exits.
53
- * - The continue() monkey-patch cooperates: while _continueInProgress is
54
- * true, the session's continue() spins. After the loop finishes, it
55
- * calls _origContinue which checks the now-updated agent state. For
56
- * stopReason "error" it no longer falls back to prompt([]) (the loop
57
- * already handled it). For toolUse/length (compaction mid-task) it
58
- * still falls back to prompt([]).
51
+ * removes error assistant messages from live state, queues a hidden
52
+ * AgentSession turn, and checks the result. On error it sleeps outside
53
+ * processEvents and retries. On success or user abort the loop exits.
59
54
  */
60
55
 
61
56
  // Capture the live Agent instance when AgentSession subscribes to it.
@@ -70,64 +65,6 @@ Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
70
65
  return _origSubscribe.apply(this, args);
71
66
  };
72
67
 
73
- // Monkey-patch continue() so the session's built-in retry loop cooperates
74
- // with our _continueInProgress mutex AND can convert the "Cannot continue
75
- // from assistant" error into a prompt([]) call when the agent was mid-task
76
- // (compaction, toolUse, length — but NOT error, which the loop handles).
77
- //
78
- // Note (pi 0.79+): Agent.continue() now drains queued steering/follow-up
79
- // messages before throwing, so this throw path only fires when there are
80
- // genuinely no queued messages — the prompt([]) fallback is still correct.
81
- const _origContinue = Agent.prototype.continue as (this: Agent) => Promise<void>;
82
- Agent.prototype.continue = function (this: Agent) {
83
- const self = this;
84
- return (async () => {
85
- // Wait while pi-retry is driving the agent so we don't double-dip.
86
- while (_continueInProgress) {
87
- await new Promise(r => setTimeout(r, 10));
88
- }
89
- try {
90
- return await _origContinue.call(self);
91
- } catch (e: any) {
92
- const msg = e?.message ?? '';
93
- if (msg.includes('Cannot continue from message role') ||
94
- msg.includes('Cannot continue from an assistant message')) {
95
- const lastMsg = self.state.messages[self.state.messages.length - 1];
96
- if (lastMsg?.role === 'assistant') {
97
- // stopReason "error": pi-retry's loop is the error handler.
98
- // It will have already retried or the user aborted — don't
99
- // start a second retry path via prompt([]).
100
- if (lastMsg.stopReason === 'error') {
101
- return;
102
- }
103
- // stopReason "stop" / "aborted": agent finished or user cancelled.
104
- // Don't continue.
105
- if (lastMsg.stopReason === 'stop' || lastMsg.stopReason === 'aborted') {
106
- return;
107
- }
108
- // stopReason "toolUse" or "length": agent was mid-task (e.g.
109
- // compaction broke the message ordering). Fall back to prompt([]).
110
- if (!_continueInProgress) {
111
- _continueInProgress = true;
112
- try {
113
- await self.prompt([]);
114
- } catch {
115
- // Agent already processing or other transient error
116
- } finally {
117
- _continueInProgress = false;
118
- }
119
- }
120
- }
121
- return;
122
- }
123
- if (msg.includes('Agent is already processing')) {
124
- return;
125
- }
126
- throw e;
127
- }
128
- })();
129
- };
130
-
131
68
  // Monkey-patch AgentSession._prepareRetry to suppress the built-in retry
132
69
  // when pi-retry's loop is driving. Without this, both the built-in retry
133
70
  // and pi-retry race to handle the same error: the built-in retry counts
@@ -143,7 +80,10 @@ Agent.prototype.continue = function (this: Agent) {
143
80
  // built-in retry works normally as a fallback.
144
81
  const _origPrepareRetry = (AgentSession.prototype as any)._prepareRetry;
145
82
  (AgentSession.prototype as any)._prepareRetry = function(this: any, message: any) {
146
- if (_continueInProgress) {
83
+ if (
84
+ _continueInProgress &&
85
+ _continueInputGeneration === _inputGeneration
86
+ ) {
147
87
  return Promise.resolve(false);
148
88
  }
149
89
  return _origPrepareRetry.call(this, message);
@@ -160,21 +100,17 @@ const stateContinuation = new ContinuationState();
160
100
 
161
101
  // Abort flag: set when turn_end reports stopReason "aborted", cleared on
162
102
  // session_start and on fresh user activity. Prevents triggerInvisibleContinue()
163
- // from driving a new prompt([]) after the user explicitly cancelled.
103
+ // from starting a hidden retry turn after the user explicitly cancelled.
164
104
  let _userAborted = false;
165
105
 
166
106
  // Mutex: only one triggerInvisibleContinue may be in-flight at a time.
167
107
  // Without this, concurrent agent_end events (or a manual /retry during an
168
- // automatic retry) race through waitForIdle() and both call prompt([]),
169
- // producing "Agent is already processing".
108
+ // automatic retry) could queue duplicate turns for the same failure.
170
109
  let _continueInProgress = false;
171
110
  // Session generation that owns the retry mutex and its Escape handler.
172
111
  let _continueGeneration: number | null = null;
173
-
174
- // Timestamp of the last completed triggerInvisibleContinue().
175
- // Used by the continue() monkey-patch to avoid double continuation when
176
- // triggerInvisibleContinue just ran and the session's continue() unblocks.
177
- let _lastInvisibleContinueTime = 0;
112
+ let _continueInputGeneration: number | null = null;
113
+ let _inputGeneration = 0;
178
114
 
179
115
  // Session generation counter: incremented on every session_start.
180
116
  // The retry loop captures the current generation when it starts and exits
@@ -186,14 +122,22 @@ let _terminalInputUnsubscribe: (() => void) | null = null;
186
122
  // Interruptible sleep: polls _userAborted and _sessionGeneration every
187
123
  // 100ms. Returns true if interrupted (abort or session change), false if
188
124
  // the full delay elapsed normally.
189
- function interruptibleSleep(ms: number, generation: number): Promise<boolean> {
125
+ function interruptibleSleep(
126
+ ms: number,
127
+ generation: number,
128
+ inputGeneration: number,
129
+ ): Promise<boolean> {
190
130
  if (ms <= 0) return Promise.resolve(false);
191
131
  return new Promise(resolve => {
192
132
  const checkInterval = 100;
193
133
  let elapsed = 0;
194
134
  const timer = setInterval(() => {
195
135
  elapsed += checkInterval;
196
- if (_userAborted || _sessionGeneration !== generation) {
136
+ if (
137
+ _userAborted ||
138
+ _sessionGeneration !== generation ||
139
+ _inputGeneration !== inputGeneration
140
+ ) {
197
141
  clearInterval(timer);
198
142
  resolve(true);
199
143
  } else if (elapsed >= ms) {
@@ -227,6 +171,25 @@ function lastMessageIsRetryableError(): boolean {
227
171
 
228
172
  export default function (pi: ExtensionAPI) {
229
173
 
174
+ const markRealPromptStart = () => {
175
+ _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
+ });
192
+
230
193
  // Reset retry counters on successful completion (not max_tokens, not error)
231
194
  pi.on("turn_end", async (event, ctx) => {
232
195
  const msg = event.message as any;
@@ -240,7 +203,7 @@ export default function (pi: ExtensionAPI) {
240
203
  stateOther.reset();
241
204
  stateContinuation.endContinuation();
242
205
  // Signal to any in-flight triggerInvisibleContinue or pending retry
243
- // that the user has cancelled — don't drive a new prompt([]).
206
+ // that the user has cancelled — do not queue another retry turn.
244
207
  _userAborted = true;
245
208
  return;
246
209
  }
@@ -276,12 +239,15 @@ export default function (pi: ExtensionAPI) {
276
239
  return;
277
240
  }
278
241
 
279
- // Guard: if the user aborted, don't drive any new prompt([])
242
+ // Guard: if the user aborted, do not queue another retry turn.
280
243
  if (_userAborted) return;
281
244
 
282
245
  // If the retry loop is already driving, don't interfere — it will
283
246
  // see the new error on its next loop iteration.
284
- if (_continueInProgress) return;
247
+ if (
248
+ _continueInProgress &&
249
+ _continueInputGeneration === _inputGeneration
250
+ ) return;
285
251
 
286
252
  // Check for max_tokens stop — auto-continue (invisible to LLM)
287
253
  if (hasMaxTokensStop(lastAssistant) && !stateContinuation.getIsContinuing()) {
@@ -297,20 +263,9 @@ export default function (pi: ExtensionAPI) {
297
263
 
298
264
  // Context overflow: defer to compaction. Do NOT retry here.
299
265
  //
300
- // triggerInvisibleContinue() calls agent.prompt([]) directly on the core
301
- // Agent, bypassing AgentSession._handlePostAgentRun _checkCompaction, so
302
- // a pi-retry retry loop gets NO compaction. Retrying an overflow would
303
- // re-send the same oversized context → overflow again → infinite loop
304
- // (pi-retry's error loop is uncapped). Meanwhile pi-retry's _continueInProgress
305
- // mutex would block pi-core's own compaction-retry (agent.continue()), so
306
- // pi-core never gets to compact either.
307
- //
308
- // Instead, return without firing triggerInvisibleContinue. pi-core's
309
- // _checkCompaction (which runs in _handlePostAgentRun regardless of
310
- // extensions) detects the same overflow, compacts (statically via pi-vcc
311
- // when installed), and retries once via agent.continue() with the reduced
312
- // context. _continueInProgress stays false, so pi-core's continue is
313
- // unblocked.
266
+ // Retrying the same oversized context before compaction would produce an
267
+ // uncapped overflow loop. Leave _continueInProgress false so Pi can run
268
+ // its normal compaction and retry path with the reduced context.
314
269
  if (isContextOverflowError(lastAssistant)) {
315
270
  ctx.ui.notify(
316
271
  "Context overflow — deferring to compaction (auto-retry after compact).",
@@ -403,7 +358,7 @@ export default function (pi: ExtensionAPI) {
403
358
  status += "Max Tokens Continuation:\n";
404
359
  status += ` Continuations used: ${stateContinuation.getCount()}\n`;
405
360
  status += ` Is continuing: ${stateContinuation.getIsContinuing()}\n`;
406
- status += ` Trigger: invisible (agent.prompt([]), LLM never sees a prompt)\n\n`;
361
+ status += ` Trigger: hidden AgentSession turn (filtered before provider call)\n\n`;
407
362
 
408
363
  // Config
409
364
  status += "Configuration:\n";
@@ -508,7 +463,7 @@ export default function (pi: ExtensionAPI) {
508
463
  pi.on("session_start", async (_event, ctx) => {
509
464
  // Bump the generation counter so any in-flight retry loop from a
510
465
  // previous session exits on its next checkpoint (within 100ms during
511
- // backoff sleep, or immediately after prompt([]) returns).
466
+ // backoff sleep, or immediately after a hidden retry turn settles).
512
467
  _sessionGeneration++;
513
468
 
514
469
  state400.reset();
@@ -519,7 +474,6 @@ export default function (pi: ExtensionAPI) {
519
474
  // Do NOT reset _continueInProgress here — the in-flight loop's
520
475
  // finally block releases its owner token. Resetting it here could allow
521
476
  // a second loop to start before the old one has settled.
522
- _lastInvisibleContinueTime = 0;
523
477
  _userAborted = false;
524
478
 
525
479
  _terminalInputUnsubscribe?.();
@@ -545,12 +499,12 @@ export default function (pi: ExtensionAPI) {
545
499
  // Retry loop driver — the core of pi-retry.
546
500
  //
547
501
  // Unlike the original one-shot design, this function loops. After each
548
- // prompt([]) call it checks the result:
502
+ // hidden AgentSession turn it checks the result:
549
503
  // - Success (stopReason !== "error"): loop exits, agent is done.
550
504
  // - Error (stopReason === "error"): sleep with backoff, then retry.
551
505
  // - User abort (stopReason "aborted"): loop exits immediately.
552
506
  //
553
- // The backoff sleep happens AFTER prompt([]) returns and processEvents
507
+ // The backoff sleep happens AFTER the hidden turn settles and processEvents
554
508
  // has settled, so it does NOT block the agent. The agent is idle during
555
509
  // the sleep and can respond to user input (e.g. Escape to abort).
556
510
  //
@@ -560,7 +514,7 @@ export default function (pi: ExtensionAPI) {
560
514
  async function triggerInvisibleContinue() {
561
515
  if (!_agent) return;
562
516
 
563
- // Guard: if the user aborted, don't drive a new prompt([]).
517
+ // Guard: if the user aborted, do not queue another retry turn.
564
518
  if (_userAborted) return;
565
519
 
566
520
  // Guard: mutex — if a previous continue is still in-flight, skip
@@ -570,7 +524,9 @@ export default function (pi: ExtensionAPI) {
570
524
  // Capture the current session generation. If /new fires while we're
571
525
  // looping, _sessionGeneration will increment and the loop will exit.
572
526
  const myGeneration = _sessionGeneration;
527
+ const myInputGeneration = _inputGeneration;
573
528
  _continueGeneration = myGeneration;
529
+ _continueInputGeneration = myInputGeneration;
574
530
 
575
531
  try {
576
532
  // Wait for the current run to finish (activeRun resolves in
@@ -579,16 +535,25 @@ export default function (pi: ExtensionAPI) {
579
535
 
580
536
  // Re-check after waitForIdle: the user may have aborted or the
581
537
  // session may have changed while we were waiting.
582
- if (_userAborted || _sessionGeneration !== myGeneration) return;
538
+ if (
539
+ _userAborted ||
540
+ _sessionGeneration !== myGeneration ||
541
+ _inputGeneration !== myInputGeneration
542
+ ) return;
583
543
 
584
544
  let attempt = 0;
585
545
 
586
546
  // Loop until success, abort, or session change.
587
547
  while (true) {
588
- if (_userAborted || _sessionGeneration !== myGeneration) return;
589
-
590
- // Remove the error assistant message from agent state so
591
- // prompt([]) sends a clean context to the LLM.
548
+ if (
549
+ _userAborted ||
550
+ _sessionGeneration !== myGeneration ||
551
+ _inputGeneration !== myInputGeneration
552
+ ) return;
553
+
554
+ // 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();
592
557
  removeErrorFromAgentState();
593
558
 
594
559
  attempt++;
@@ -601,22 +566,44 @@ export default function (pi: ExtensionAPI) {
601
566
  // Polls _userAborted and _sessionGeneration every 100ms so ESC
602
567
  // and /new take effect within 100ms instead of waiting for the
603
568
  // full backoff (up to 60s).
604
- const interrupted = await interruptibleSleep(delay, myGeneration);
605
- if (interrupted) return;
569
+ const interrupted = await interruptibleSleep(
570
+ delay,
571
+ myGeneration,
572
+ myInputGeneration,
573
+ );
574
+ if (interrupted || _inputGeneration !== myInputGeneration) return;
606
575
 
607
576
  try {
608
- await _agent.prompt([]);
577
+ pi.sendMessage(
578
+ {
579
+ customType: isErrorRetry
580
+ ? RETRY_TRIGGER_CUSTOM_TYPE
581
+ : CONTINUATION_CUSTOM_TYPE,
582
+ content: [],
583
+ display: false,
584
+ details: undefined,
585
+ },
586
+ { triggerTurn: true, deliverAs: "followUp" },
587
+ );
588
+
589
+ // sendMessage is fire-and-forget, but AgentSession publishes the
590
+ // low-level run synchronously before returning. Waiting on Agent
591
+ // keeps this retry loop intact without bypassing session state.
592
+ await Promise.resolve();
593
+ await _agent.waitForIdle();
609
594
  } catch {
610
- // "Agent is already processing" or other transient error —
611
- // the session or another driver is handling it.
612
595
  return;
613
596
  }
614
597
 
615
598
  // Re-check after prompt: the user may have hit ESC during the
616
599
  // prompt, or /new may have fired — don't keep retrying.
617
- if (_userAborted || _sessionGeneration !== myGeneration) return;
600
+ if (
601
+ _userAborted ||
602
+ _sessionGeneration !== myGeneration ||
603
+ _inputGeneration !== myInputGeneration
604
+ ) return;
618
605
 
619
- // prompt([]) completed. Check the result.
606
+ // The hidden AgentSession turn completed. Check the result.
620
607
  if (!lastMessageIsRetryableError()) {
621
608
  // Success or non-error terminal state — exit the loop.
622
609
  return;
@@ -629,8 +616,8 @@ export default function (pi: ExtensionAPI) {
629
616
  if (_continueGeneration === myGeneration) {
630
617
  _continueInProgress = false;
631
618
  _continueGeneration = null;
619
+ _continueInputGeneration = null;
632
620
  }
633
- _lastInvisibleContinueTime = Date.now();
634
621
  }
635
622
  }
636
623
 
@@ -9,6 +9,9 @@
9
9
 
10
10
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
11
11
 
12
+ export const RETRY_TRIGGER_CUSTOM_TYPE = "pi-retry:retry";
13
+ export const CONTINUATION_CUSTOM_TYPE = "pi-retry:continue";
14
+
12
15
  // ── Specific pattern groups (used for categorisation / messaging) ──
13
16
 
14
17
  const ERROR_400_413_PATTERNS = [
@@ -69,8 +72,8 @@ const BUILTIN_HANDLED_PATTERNS = [
69
72
  // compaction exactly when pi-core's _checkCompaction will detect overflow and
70
73
  // compact + retry. kept in sync manually — pi-ai is not a direct dependency.
71
74
  //
72
- // Why these are NOT retried by pi-retry: retrying an overflow via prompt([])
73
- // re-sends the same oversized context, so it overflows again → infinite loop
75
+ // Why these are NOT retried by pi-retry: a hidden retry turn would re-send
76
+ // the same oversized context, so it overflows again → infinite loop
74
77
  // (pi-retry's loop is uncapped for errors). pi-core instead compacts and
75
78
  // retries once via agent.continue(); with static compaction (pi-vcc) that
76
79
  // reliably reduces context, so the single retry succeeds.