@monotykamary/pi-retry 0.6.2 → 0.6.4

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.2",
3
+ "version": "0.6.4",
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",
@@ -32,16 +32,9 @@
32
32
  "src/",
33
33
  "README.md"
34
34
  ],
35
- "scripts": {
36
- "test": "vitest run",
37
- "test:watch": "vitest",
38
- "test:coverage": "vitest run --coverage",
39
- "typecheck": "tsc --noEmit",
40
- "lint:dead": "knip --no-gitignore"
41
- },
42
35
  "devDependencies": {
43
- "@earendil-works/pi-agent-core": "^0.79.8",
44
- "@earendil-works/pi-coding-agent": "^0.79.8",
36
+ "@earendil-works/pi-agent-core": "^0.80.7",
37
+ "@earendil-works/pi-coding-agent": "^0.80.7",
45
38
  "@types/node": "25.9.1",
46
39
  "@vitest/coverage-v8": "4.1.7",
47
40
  "knip": "6.14.1",
@@ -55,5 +48,12 @@
55
48
  },
56
49
  "overrides": {
57
50
  "brace-expansion": "5.0.6"
51
+ },
52
+ "scripts": {
53
+ "test": "vitest run",
54
+ "test:watch": "vitest",
55
+ "test:coverage": "vitest run --coverage",
56
+ "typecheck": "tsc --noEmit",
57
+ "lint:dead": "knip --no-gitignore"
58
58
  }
59
- }
59
+ }
package/retry.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  isNonRetryableError,
10
10
  isSilencedError,
11
11
  hasMaxTokensStop,
12
+ isContextOverflowError,
12
13
  isAssistantMessage,
13
14
  getLastAssistantMessage,
14
15
  calculateDelay,
@@ -167,6 +168,8 @@ let _userAborted = false;
167
168
  // automatic retry) race through waitForIdle() and both call prompt([]),
168
169
  // producing "Agent is already processing".
169
170
  let _continueInProgress = false;
171
+ // Session generation that owns the retry mutex and its Escape handler.
172
+ let _continueGeneration: number | null = null;
170
173
 
171
174
  // Timestamp of the last completed triggerInvisibleContinue().
172
175
  // Used by the continue() monkey-patch to avoid double continuation when
@@ -178,6 +181,8 @@ let _lastInvisibleContinueTime = 0;
178
181
  // when it changes — this handles /new and other session switches.
179
182
  let _sessionGeneration = 0;
180
183
 
184
+ let _terminalInputUnsubscribe: (() => void) | null = null;
185
+
181
186
  // Interruptible sleep: polls _userAborted and _sessionGeneration every
182
187
  // 100ms. Returns true if interrupted (abort or session change), false if
183
188
  // the full delay elapsed normally.
@@ -290,6 +295,30 @@ export default function (pi: ExtensionAPI) {
290
295
  return;
291
296
  }
292
297
 
298
+ // Context overflow: defer to compaction. Do NOT retry here.
299
+ //
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.
314
+ if (isContextOverflowError(lastAssistant)) {
315
+ ctx.ui.notify(
316
+ "Context overflow — deferring to compaction (auto-retry after compact).",
317
+ "info",
318
+ );
319
+ return;
320
+ }
321
+
293
322
  // Catch-all: retry ANY error except known permanent failures
294
323
  if (hasRetryableError(lastAssistant)) {
295
324
  const errorMsg = lastAssistant.errorMessage || "Unknown error";
@@ -429,6 +458,17 @@ export default function (pi: ExtensionAPI) {
429
458
  return;
430
459
  }
431
460
 
461
+ // Context overflow: don't retry in place — reducing context is required.
462
+ // Compaction (pi-vcc / /compact) handles it and auto-retries. Retrying
463
+ // without compaction loops forever on a genuinely oversized payload.
464
+ if (isContextOverflowError(lastAssistant)) {
465
+ ctx.ui.notify(
466
+ "Context overflow — use /compact (or /pi-vcc) to reduce context. Compaction auto-retries.",
467
+ "info",
468
+ );
469
+ return;
470
+ }
471
+
432
472
  // Auto-detect error type and trigger appropriate retry
433
473
  if (has400or413Error(lastAssistant)) {
434
474
  ctx.ui.notify("Manually retrying 400/413 error...", "info");
@@ -465,7 +505,7 @@ export default function (pi: ExtensionAPI) {
465
505
  });
466
506
 
467
507
  // Initialize
468
- pi.on("session_start", async () => {
508
+ pi.on("session_start", async (_event, ctx) => {
469
509
  // Bump the generation counter so any in-flight retry loop from a
470
510
  // previous session exits on its next checkpoint (within 100ms during
471
511
  // backoff sleep, or immediately after prompt([]) returns).
@@ -477,11 +517,29 @@ export default function (pi: ExtensionAPI) {
477
517
  stateOther.reset();
478
518
  stateContinuation.reset();
479
519
  // Do NOT reset _continueInProgress here — the in-flight loop's
480
- // finally block handles it conditionally (only if the generation
481
- // hasn't changed since the loop started). Resetting it here would
482
- // break the mutex invariant and could allow a second loop to start.
520
+ // finally block releases its owner token. Resetting it here could allow
521
+ // a second loop to start before the old one has settled.
483
522
  _lastInvisibleContinueTime = 0;
484
523
  _userAborted = false;
524
+
525
+ _terminalInputUnsubscribe?.();
526
+ _terminalInputUnsubscribe = null;
527
+
528
+ if (ctx.mode === "tui") {
529
+ _terminalInputUnsubscribe = ctx.ui.onTerminalInput(data => {
530
+ if (
531
+ data !== "\x1b" ||
532
+ !_continueInProgress ||
533
+ _continueGeneration !== _sessionGeneration
534
+ ) {
535
+ return undefined;
536
+ }
537
+
538
+ _userAborted = true;
539
+ ctx.abort();
540
+ return { consume: true };
541
+ });
542
+ }
485
543
  });
486
544
 
487
545
  // Retry loop driver — the core of pi-retry.
@@ -512,6 +570,7 @@ export default function (pi: ExtensionAPI) {
512
570
  // Capture the current session generation. If /new fires while we're
513
571
  // looping, _sessionGeneration will increment and the loop will exit.
514
572
  const myGeneration = _sessionGeneration;
573
+ _continueGeneration = myGeneration;
515
574
 
516
575
  try {
517
576
  // Wait for the current run to finish (activeRun resolves in
@@ -566,11 +625,10 @@ export default function (pi: ExtensionAPI) {
566
625
  // Error again — loop back for another attempt.
567
626
  }
568
627
  } finally {
569
- // Only reset the mutex if the session hasn't changed since we
570
- // started. If /new fired, a new retry loop may already own the
571
- // mutex — resetting it here would clobber that.
572
- if (_sessionGeneration === myGeneration) {
628
+ // Release the mutex only if this loop still owns it.
629
+ if (_continueGeneration === myGeneration) {
573
630
  _continueInProgress = false;
631
+ _continueGeneration = null;
574
632
  }
575
633
  _lastInvisibleContinueTime = Date.now();
576
634
  }
@@ -64,6 +64,51 @@ const BUILTIN_HANDLED_PATTERNS = [
64
64
  /retry\s*delay/i,
65
65
  ];
66
66
 
67
+ // Context-overflow error patterns. Mirrors pi-core's OVERFLOW_PATTERNS in
68
+ // @earendil-works/pi-ai/dist/utils/overflow.js so that pi-retry defers to
69
+ // compaction exactly when pi-core's _checkCompaction will detect overflow and
70
+ // compact + retry. kept in sync manually — pi-ai is not a direct dependency.
71
+ //
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
74
+ // (pi-retry's loop is uncapped for errors). pi-core instead compacts and
75
+ // retries once via agent.continue(); with static compaction (pi-vcc) that
76
+ // reliably reduces context, so the single retry succeeds.
77
+ const OVERFLOW_ERROR_PATTERNS = [
78
+ /prompt is too long/i,
79
+ /request_too_large/i,
80
+ /input is too long for requested model/i,
81
+ /exceeds the context window/i,
82
+ /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
83
+ /input token count.*exceeds the maximum/i,
84
+ /maximum prompt length is \d+/i,
85
+ /reduce the length of the messages/i,
86
+ /maximum context length is \d+ tokens/i,
87
+ /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
88
+ /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
89
+ /exceeds the limit of \d+/i,
90
+ /exceeds the available context size/i,
91
+ /greater than the context length/i,
92
+ /context window exceeds limit/i,
93
+ /exceeded model token limit/i,
94
+ /too large for model with \d+ maximum context length/i,
95
+ /model_context_window_exceeded/i,
96
+ /prompt too long; exceeded (?:max )?context length/i,
97
+ /context[_ ]length[_ ]exceeded/i,
98
+ /too many tokens/i,
99
+ /token limit exceeded/i,
100
+ /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i,
101
+ ];
102
+
103
+ // Patterns that look like overflow but are actually rate limiting / throttling.
104
+ // Mirrors pi-core's NON_OVERFLOW_PATTERNS. Excluded from overflow detection so
105
+ // throttling errors are still retried (they are not context-size problems).
106
+ const NON_OVERFLOW_PATTERNS = [
107
+ /^(Throttling error|Service unavailable):/i,
108
+ /rate limit/i,
109
+ /too many requests/i,
110
+ ];
111
+
67
112
  // ── Blacklist: errors that are truly permanent and should NOT be retried ──
68
113
 
69
114
  const NON_RETRYABLE_PATTERNS = [
@@ -109,6 +154,24 @@ export function hasConnectionError(message: AgentMessage): boolean {
109
154
  return CONNECTION_ERROR_PATTERNS.some(p => p.test(message.errorMessage!));
110
155
  }
111
156
 
157
+ /**
158
+ * Returns true for an error assistant message whose errorMessage indicates a
159
+ * context-overflow (input exceeded the model's context window).
160
+ *
161
+ * Mirrors pi-core's isContextOverflow Case 1 (error-message patterns). The
162
+ * silent-overflow cases (stopReason "stop"/"length") are not errors and are
163
+ * never seen here — pi-core handles those in _checkCompaction directly.
164
+ *
165
+ * Callers should treat a true result as "defer to compaction, do NOT retry" —
166
+ * see OVERFLOW_ERROR_PATTERNS for rationale.
167
+ */
168
+ export function isContextOverflowError(message: AgentMessage): boolean {
169
+ if (!isAssistantMessage(message)) return false;
170
+ if (message.stopReason !== "error" || !message.errorMessage) return false;
171
+ if (NON_OVERFLOW_PATTERNS.some(p => p.test(message.errorMessage!))) return false;
172
+ return OVERFLOW_ERROR_PATTERNS.some(p => p.test(message.errorMessage!));
173
+ }
174
+
112
175
  // ── Universal retry check ──
113
176
 
114
177
  /**