@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 +1 -1
- package/retry.ts +75 -59
- package/src/error-patterns.ts +0 -11
package/package.json
CHANGED
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 —
|
|
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
|
-
*
|
|
38
|
-
* -
|
|
39
|
-
* -
|
|
40
|
-
* -
|
|
41
|
-
* - No
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
}
|
package/src/error-patterns.ts
CHANGED
|
@@ -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 = [
|