@monotykamary/pi-retry 0.2.0 → 0.3.1

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.2.0",
3
+ "version": "0.3.1",
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,7 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Agent } from "@earendil-works/pi-agent-core";
2
3
  import {
3
- RETRY_TRIGGER_CUSTOM_TYPE,
4
- CONTINUATION_CUSTOM_TYPE,
5
4
  has400or413Error,
6
5
  hasCreditError,
7
6
  hasConnectionError,
@@ -31,16 +30,27 @@ import {
31
30
  * - Automatic detection and retry for ALL errors (catch-all)
32
31
  * - Indefinite retry with exponential backoff (capped at 60s)
33
32
  * - Auto-continuation when model hits max output tokens (stopReason "length")
34
- * - ALL triggers are invisible — custom messages with display:false, stripped by context handler
33
+ * - ALL triggers are invisible — agent.prompt([]) resumes the loop with no new message
35
34
  * - Unified manual controls via /retry command
36
35
  *
37
- * Silent continue trick (ported from pi-invisible-continue):
38
- * - sendMessage() with customType + display:false + triggerTurn:true
39
- * - pi's default convertToLlm filters custom-role messages LLM never sees them
40
- * - context event handler strips them as insurance against custom convertToLlm overrides
41
- * - No user-visible "Continue" message pollution in the conversation
36
+ * Invisibility mechanism:
37
+ * - Agent.prototype.subscribe monkey-patch captures the Agent instance
38
+ * - agent.prompt([]) starts a fresh agent loop with an empty prompt array
39
+ * - No message injected into context LLM sees the exact same message list
40
+ * - No convertToLlm involvement, no filter needed, no session artifact
42
41
  */
43
42
 
43
+ // Capture the live Agent instance when AgentSession subscribes to it.
44
+ // subscribe() is called during AgentSession construction — fires on both
45
+ // fresh sessions and session resumes.
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ let _agent: Agent | null = null;
48
+ const _origSubscribe = Agent.prototype.subscribe as (...args: any[]) => any;
49
+ Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
50
+ _agent = this;
51
+ return _origSubscribe.apply(this, args);
52
+ };
53
+
44
54
  // Per-category retry state (for diagnostics / messaging)
45
55
  const state400 = new RetryState();
46
56
  const stateCredit = new RetryState();
@@ -50,6 +60,12 @@ const stateOther = new RetryState();
50
60
  // Max_tokens continuation state (indefinite — no cap needed)
51
61
  const stateContinuation = new ContinuationState();
52
62
 
63
+ // Mutex: only one triggerInvisibleContinue may be in-flight at a time.
64
+ // Without this, concurrent agent_end events (or a manual /retry during an
65
+ // automatic retry) race through waitForIdle() and both call prompt([]),
66
+ // producing "Agent is already processing".
67
+ let _continueInProgress = false;
68
+
53
69
  // Sleep helper
54
70
  function sleep(ms: number): Promise<void> {
55
71
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -93,14 +109,15 @@ export default function (pi: ExtensionAPI) {
93
109
  return;
94
110
  }
95
111
 
96
- // Check for max_tokens stop — auto-continue (silent, invisible to LLM)
112
+ // Check for max_tokens stop — auto-continue (invisible to LLM)
97
113
  if (hasMaxTokensStop(lastAssistant) && !stateContinuation.getIsContinuing()) {
98
114
  stateContinuation.startContinuation();
99
115
  ctx.ui.notify(
100
116
  `Max tokens reached — auto-continuing (continuation ${stateContinuation.getCount()})...`,
101
117
  "info",
102
118
  );
103
- triggerContinuation(pi);
119
+ // Must NOT await — see triggerInvisibleContinue() for explanation
120
+ void triggerInvisibleContinue();
104
121
  stateContinuation.endContinuation();
105
122
  return;
106
123
  }
@@ -133,7 +150,8 @@ export default function (pi: ExtensionAPI) {
133
150
  const delay = calculateDelay(state.getAttempt());
134
151
 
135
152
  await sleep(delay);
136
- triggerRetry(pi);
153
+ // Must NOT await — see triggerInvisibleContinue() for explanation
154
+ void triggerInvisibleContinue();
137
155
  state.endRetry();
138
156
  return;
139
157
  }
@@ -147,23 +165,6 @@ export default function (pi: ExtensionAPI) {
147
165
 
148
166
 
149
167
 
150
- // Strip hidden retry/continuation markers from context before each LLM call.
151
- // This is insurance — convertToLlm already filters custom roles, but a
152
- // custom convertToLlm override could leak them. Clean proactively.
153
- pi.on("context", async (event) => {
154
- const cleaned = event.messages.filter(
155
- (msg: any) =>
156
- !(
157
- msg.role === "custom" &&
158
- (msg.customType === RETRY_TRIGGER_CUSTOM_TYPE ||
159
- msg.customType === CONTINUATION_CUSTOM_TYPE)
160
- ),
161
- );
162
- if (cleaned.length !== event.messages.length) {
163
- return { messages: cleaned };
164
- }
165
- });
166
-
167
168
  // Unified /retry command with subcommands
168
169
  pi.registerCommand("retry", {
169
170
  description: "Unified retry controls: /retry (manual trigger), /retry status (diagnostics), /retry reset (clear state)",
@@ -205,14 +206,14 @@ export default function (pi: ExtensionAPI) {
205
206
  status += "Max Tokens Continuation:\n";
206
207
  status += ` Continuations used: ${stateContinuation.getCount()}\n`;
207
208
  status += ` Is continuing: ${stateContinuation.getIsContinuing()}\n`;
208
- status += ` Trigger: invisible (custom message, LLM never sees a prompt)\n\n`;
209
+ status += ` Trigger: invisible (agent.prompt([]), LLM never sees a prompt)\n\n`;
209
210
 
210
211
  // Config
211
212
  status += "Configuration:\n";
212
213
  status += ` Base delay: 2000ms\n`;
213
214
  status += ` Max delay: 60000ms\n`;
214
215
  status += ` Backoff multiplier: 2\n`;
215
- status += ` Continuation: invisible custom message\n\n`;
216
+ status += ` Continuation: invisible (agent.prompt([]))\n\n`;
216
217
 
217
218
  // Last assistant info
218
219
  if (lastAssistant && isAssistantMessage(lastAssistant)) {
@@ -251,7 +252,7 @@ export default function (pi: ExtensionAPI) {
251
252
  // Auto-detect: max_tokens continuation takes priority
252
253
  if (hasMaxTokensStop(lastAssistant)) {
253
254
  ctx.ui.notify("Manually continuing after max_tokens...", "info");
254
- triggerContinuation(pi);
255
+ void triggerInvisibleContinue();
255
256
  return;
256
257
  }
257
258
 
@@ -259,21 +260,21 @@ export default function (pi: ExtensionAPI) {
259
260
  if (has400or413Error(lastAssistant)) {
260
261
  ctx.ui.notify("Manually retrying 400/413 error...", "info");
261
262
  state400.reset();
262
- triggerRetry(pi);
263
+ void triggerInvisibleContinue();
263
264
  return;
264
265
  }
265
266
 
266
267
  if (hasCreditError(lastAssistant)) {
267
268
  ctx.ui.notify("Manually retrying credit error...", "info");
268
269
  stateCredit.reset();
269
- triggerRetry(pi);
270
+ void triggerInvisibleContinue();
270
271
  return;
271
272
  }
272
273
 
273
274
  if (hasConnectionError(lastAssistant)) {
274
275
  ctx.ui.notify("Manually retrying connection error...", "info");
275
276
  stateConnection.reset();
276
- triggerRetry(pi);
277
+ void triggerInvisibleContinue();
277
278
  return;
278
279
  }
279
280
 
@@ -281,7 +282,7 @@ export default function (pi: ExtensionAPI) {
281
282
  if (hasRetryableError(lastAssistant)) {
282
283
  ctx.ui.notify("Manually retrying error...", "info");
283
284
  stateOther.reset();
284
- triggerRetry(pi);
285
+ void triggerInvisibleContinue();
285
286
  return;
286
287
  }
287
288
 
@@ -297,31 +298,46 @@ export default function (pi: ExtensionAPI) {
297
298
  stateConnection.reset();
298
299
  stateOther.reset();
299
300
  stateContinuation.reset();
301
+ _continueInProgress = false;
300
302
  });
301
303
 
302
- // Helper: send the hidden retry trigger
303
- function triggerRetry(pi: ExtensionAPI) {
304
- pi.sendMessage(
305
- {
306
- customType: RETRY_TRIGGER_CUSTOM_TYPE,
307
- content: "",
308
- display: false,
309
- details: {},
310
- },
311
- { triggerTurn: true },
312
- );
313
- }
314
-
315
- // Helper: send the hidden continuation trigger (silent — LLM never sees a prompt)
316
- function triggerContinuation(pi: ExtensionAPI) {
317
- pi.sendMessage(
318
- {
319
- customType: CONTINUATION_CUSTOM_TYPE,
320
- content: "",
321
- display: false,
322
- details: {},
323
- },
324
- { triggerTurn: true },
325
- );
304
+ // Resume the agent loop invisibly — no message injected into context.
305
+ // The LLM sees the exact same message list it had before.
306
+ //
307
+ // IMPORTANT: We must wait for the agent to become idle before calling
308
+ // prompt(). The agent_end event fires while the active run is still
309
+ // set (it's cleared in the finally block, after all listeners settle).
310
+ // Calling prompt() inside an agent_end listener would otherwise throw
311
+ // "Agent is already processing a prompt" because activeRun is truthy.
312
+ //
313
+ // GUARDS (three layers):
314
+ // 1. _continueInProgress mutex — prevents concurrent calls from racing
315
+ // 2. isStreaming pre-flight — detects user-initiated runs before prompt()
316
+ // 3. try/catch — final safety net, swallows the error gracefully
317
+ async function triggerInvisibleContinue() {
318
+ if (!_agent) return;
319
+
320
+ // Guard 1: mutex — if a previous continue is still in-flight, skip
321
+ if (_continueInProgress) return;
322
+ _continueInProgress = true;
323
+
324
+ try {
325
+ await _agent.waitForIdle();
326
+
327
+ // Guard 2: pre-flight — the user may have sent a message while we
328
+ // waited for idle. agent.state.isStreaming is authoritative (read
329
+ // directly from the activeRun field, no TOCTOU beyond the next line).
330
+ if (_agent.state.isStreaming) return;
331
+
332
+ try {
333
+ _agent.prompt([]);
334
+ } catch {
335
+ // Guard 3: catch the "already processing" error as a last resort.
336
+ // This handles the remaining microtask TOCTOU gap between the
337
+ // isStreaming check and prompt() acquiring the lock.
338
+ }
339
+ } finally {
340
+ _continueInProgress = false;
341
+ }
326
342
  }
327
343
  }
@@ -9,17 +9,6 @@
9
9
 
10
10
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
11
11
 
12
- // Custom message types for invisible triggers.
13
- // These are sent with role="custom" and display=false, so pi's default
14
- // convertToLlm filters them out. The context event handler also strips
15
- // them as insurance.
16
-
17
- /** Custom type used for the invisible error-retry trigger. */
18
- export const RETRY_TRIGGER_CUSTOM_TYPE = "__retry_trigger";
19
-
20
- /** Custom type used for the invisible max_tokens continuation trigger. */
21
- export const CONTINUATION_CUSTOM_TYPE = "__retry_continuation";
22
-
23
12
  // ── Specific pattern groups (used for categorisation / messaging) ──
24
13
 
25
14
  const ERROR_400_413_PATTERNS = [