pi-better-btw-plus 1.0.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.
- package/LICENSE +23 -0
- package/README.md +251 -0
- package/README.zh-CN.md +252 -0
- package/banner.png +0 -0
- package/config.json +23 -0
- package/package.json +69 -0
- package/prompts/btw-focus-anchor.md +1 -0
- package/prompts/btw-framing.md +8 -0
- package/prompts/lane-failed-note.md +1 -0
- package/prompts/lane-preamble.md +1 -0
- package/prompts/lane-reminder-base.md +1 -0
- package/prompts/lane-reminder-escalated.md +1 -0
- package/srcs/clipboard-read.ts +339 -0
- package/srcs/config.ts +341 -0
- package/srcs/file-activity-tracker.ts +21 -0
- package/srcs/fork-surgery.ts +106 -0
- package/srcs/index.ts +381 -0
- package/srcs/model-switch.ts +60 -0
- package/srcs/prompt-pack.ts +145 -0
- package/srcs/retry.ts +360 -0
- package/srcs/shortcuts.ts +4 -0
- package/srcs/side-chat-export.ts +302 -0
- package/srcs/side-chat-messages.ts +479 -0
- package/srcs/side-chat-mouse.ts +108 -0
- package/srcs/side-chat-overlay.ts +1513 -0
- package/srcs/tool-wrapper.ts +225 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* config.json `promptPack` manifest (#13): each value is a path relative to
|
|
7
|
+
* the extension dir, or an absolute path. All keys are optional — an absent
|
|
8
|
+
* or unreadable key falls back to the bundled `prompts/` default.
|
|
9
|
+
*/
|
|
10
|
+
export interface PromptPackManifest {
|
|
11
|
+
framing?: string;
|
|
12
|
+
focusAnchor?: string;
|
|
13
|
+
laneReminders?: {
|
|
14
|
+
base?: string;
|
|
15
|
+
escalated?: string;
|
|
16
|
+
failedNote?: string;
|
|
17
|
+
preamble?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Fully resolved prompt texts after manifest resolution + file reads. */
|
|
22
|
+
export interface PromptPack {
|
|
23
|
+
framing: string;
|
|
24
|
+
focusAnchor: string;
|
|
25
|
+
laneReminders: {
|
|
26
|
+
base: string;
|
|
27
|
+
escalated: string;
|
|
28
|
+
failedNote: string;
|
|
29
|
+
preamble: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bundled defaults, shipped git-tracked in `prompts/` (#14 drafts). */
|
|
34
|
+
const BUNDLED_PATHS = {
|
|
35
|
+
framing: "prompts/btw-framing.md",
|
|
36
|
+
focusAnchor: "prompts/btw-focus-anchor.md",
|
|
37
|
+
laneReminders: {
|
|
38
|
+
base: "prompts/lane-reminder-base.md",
|
|
39
|
+
escalated: "prompts/lane-reminder-escalated.md",
|
|
40
|
+
failedNote: "prompts/lane-failed-note.md",
|
|
41
|
+
preamble: "prompts/lane-preamble.md",
|
|
42
|
+
},
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Package root: base for the bundle `config.json` and the `prompts/`
|
|
47
|
+
* defaults. This module lives in `srcs/`, so the extension dir is the
|
|
48
|
+
* parent directory of this file.
|
|
49
|
+
*/
|
|
50
|
+
export function getExtensionDir(): string {
|
|
51
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load the prompt pack fresh — no caching, every fork re-reads the manifest
|
|
56
|
+
* files so edits apply on the next fork. Per-key fallback (#13):
|
|
57
|
+
*
|
|
58
|
+
* - configured path missing/unreadable → bundled default + notify warning;
|
|
59
|
+
* - bundled default unreadable (packaging error) → empty text + notify.
|
|
60
|
+
*/
|
|
61
|
+
export function loadPromptPack(
|
|
62
|
+
manifest: PromptPackManifest | undefined,
|
|
63
|
+
options: { extensionDir: string; notify: (message: string) => void },
|
|
64
|
+
): PromptPack {
|
|
65
|
+
const read = (
|
|
66
|
+
key: string,
|
|
67
|
+
configured: string | undefined,
|
|
68
|
+
bundled: string,
|
|
69
|
+
): string => {
|
|
70
|
+
if (configured && configured.trim()) {
|
|
71
|
+
try {
|
|
72
|
+
return readFileSync(
|
|
73
|
+
resolvePath(options.extensionDir, configured),
|
|
74
|
+
"utf-8",
|
|
75
|
+
).trim();
|
|
76
|
+
} catch {
|
|
77
|
+
options.notify(
|
|
78
|
+
`promptPack ${key}: cannot read "${configured}" — falling back to the bundled default`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return readFileSync(
|
|
84
|
+
resolvePath(options.extensionDir, bundled),
|
|
85
|
+
"utf-8",
|
|
86
|
+
).trim();
|
|
87
|
+
} catch {
|
|
88
|
+
options.notify(
|
|
89
|
+
`promptPack ${key}: bundled default "${bundled}" unreadable — using empty prompt text`,
|
|
90
|
+
);
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const laneReminders = manifest?.laneReminders ?? {};
|
|
96
|
+
return {
|
|
97
|
+
framing: read("framing", manifest?.framing, BUNDLED_PATHS.framing),
|
|
98
|
+
focusAnchor: read(
|
|
99
|
+
"focusAnchor",
|
|
100
|
+
manifest?.focusAnchor,
|
|
101
|
+
BUNDLED_PATHS.focusAnchor,
|
|
102
|
+
),
|
|
103
|
+
laneReminders: {
|
|
104
|
+
base: read(
|
|
105
|
+
"laneReminders.base",
|
|
106
|
+
laneReminders.base,
|
|
107
|
+
BUNDLED_PATHS.laneReminders.base,
|
|
108
|
+
),
|
|
109
|
+
escalated: read(
|
|
110
|
+
"laneReminders.escalated",
|
|
111
|
+
laneReminders.escalated,
|
|
112
|
+
BUNDLED_PATHS.laneReminders.escalated,
|
|
113
|
+
),
|
|
114
|
+
failedNote: read(
|
|
115
|
+
"laneReminders.failedNote",
|
|
116
|
+
laneReminders.failedNote,
|
|
117
|
+
BUNDLED_PATHS.laneReminders.failedNote,
|
|
118
|
+
),
|
|
119
|
+
preamble: read(
|
|
120
|
+
"laneReminders.preamble",
|
|
121
|
+
laneReminders.preamble,
|
|
122
|
+
BUNDLED_PATHS.laneReminders.preamble,
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Template substitution (#13): replaces `{{name}}` with the given value.
|
|
130
|
+
* Undefined variables are left as-is (typos stay visible, never swallowed).
|
|
131
|
+
* Framing vars: `{{cwd}}` `{{model}}`; lane reminder vars: `{{tool}}` `{{count}}`.
|
|
132
|
+
*/
|
|
133
|
+
export function substituteTemplate(
|
|
134
|
+
template: string,
|
|
135
|
+
vars: Record<string, string | number | undefined>,
|
|
136
|
+
): string {
|
|
137
|
+
return template.replace(/\{\{(\w+)\}\}/g, (match, name: string) => {
|
|
138
|
+
const value = vars[name];
|
|
139
|
+
return value === undefined ? match : String(value);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolvePath(extensionDir: string, path: string): string {
|
|
144
|
+
return isAbsolute(path) ? path : join(extensionDir, path);
|
|
145
|
+
}
|
package/srcs/retry.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-level retry engine (issue #6, D9 seam): the classifier and the
|
|
3
|
+
* injectable retry loop the fork's turn cycle wraps around `agent.prompt()`.
|
|
4
|
+
*
|
|
5
|
+
* The engine mirrors pi's turn retry semantics verbatim — classification
|
|
6
|
+
* patterns are transcribed from `@earendil-works/pi-ai@0.85.1` (see
|
|
7
|
+
* `dist/utils/retry.js` and `dist/utils/overflow.js`, themselves the pieces
|
|
8
|
+
* `AgentSession._isRetryableError` composes). The fork targets pi 0.85.1 but
|
|
9
|
+
* the repo's devDependencies pin pi-ai 0.84.2, which does not export those
|
|
10
|
+
* helpers, so the tables are copied here (with their source annotations)
|
|
11
|
+
* rather than imported — "照抄语义,不发明私有协议".
|
|
12
|
+
*
|
|
13
|
+
* The loop is fully injected so tests drive it with a fake agent (scripted
|
|
14
|
+
* attempt results) and a fake clock (recorded delays); the engine itself has
|
|
15
|
+
* no overlay / TUI / pi-runtime dependency.
|
|
16
|
+
*/
|
|
17
|
+
/** Assistant-message-shaped failure the classifier accepts (pi's message shape). */
|
|
18
|
+
export interface RetryableFailure {
|
|
19
|
+
stopReason?: string;
|
|
20
|
+
errorMessage?: string;
|
|
21
|
+
usage?: { input?: number; output?: number; cacheRead?: number };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Classifier (pi-ai 0.85.1 semantics)
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/** Non-retryable provider limit / billing exhaustion patterns (pi retry.js). */
|
|
29
|
+
function buildProviderErrorPattern(patterns: string[]): RegExp {
|
|
30
|
+
return new RegExp(patterns.join("|"), "i");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
|
|
34
|
+
// OpenCode Go/free-tier limits returned as 429 JSON error types by OpenCode's
|
|
35
|
+
// Zen API. These are subscription/account limits, not transient throttles.
|
|
36
|
+
"GoUsageLimitError",
|
|
37
|
+
"FreeUsageLimitError",
|
|
38
|
+
// OpenCode Go subscription-limit text asks users to enable available-balance
|
|
39
|
+
// usage after rolling/weekly/monthly limits are reached.
|
|
40
|
+
"Monthly usage limit reached",
|
|
41
|
+
"available balance",
|
|
42
|
+
// Generic quota/budget/billing exhaustion. `insufficient_quota` is OpenAI's
|
|
43
|
+
// quota/billing error code; the other strings cover common gateway wording.
|
|
44
|
+
"insufficient_quota",
|
|
45
|
+
"out of budget",
|
|
46
|
+
"quota exceeded",
|
|
47
|
+
"billing",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
|
51
|
+
// Generic provider load, HTTP status, and server-side transient failures.
|
|
52
|
+
"overloaded",
|
|
53
|
+
"rate.?limit",
|
|
54
|
+
"too many requests",
|
|
55
|
+
"429",
|
|
56
|
+
"500",
|
|
57
|
+
"502",
|
|
58
|
+
"503",
|
|
59
|
+
"504",
|
|
60
|
+
"524",
|
|
61
|
+
"service.?unavailable",
|
|
62
|
+
"server.?error",
|
|
63
|
+
"internal.?error",
|
|
64
|
+
// Wrapper/provider text for transient upstream failures, including OpenRouter
|
|
65
|
+
// "Provider returned error" responses (#2264).
|
|
66
|
+
"provider.?returned.?error",
|
|
67
|
+
"exceeded request buffer limit while retrying upstream",
|
|
68
|
+
// Network, proxy, and fetch transport failures. This includes OpenAI Codex
|
|
69
|
+
// raw-fetch failures such as "upstream connect", "connection refused", and
|
|
70
|
+
// "reset before headers" (#733), plus OpenRouter connection drops (#3317).
|
|
71
|
+
"network.?error",
|
|
72
|
+
"connection.?error",
|
|
73
|
+
"connection.?refused",
|
|
74
|
+
"connection.?lost",
|
|
75
|
+
"other side closed",
|
|
76
|
+
"fetch failed",
|
|
77
|
+
"getaddrinfo",
|
|
78
|
+
"ENOTFOUND",
|
|
79
|
+
"EAI_AGAIN",
|
|
80
|
+
"upstream.?connect",
|
|
81
|
+
"reset before headers",
|
|
82
|
+
"socket hang up",
|
|
83
|
+
"socket connection was closed",
|
|
84
|
+
"timed? out",
|
|
85
|
+
"timeout",
|
|
86
|
+
"terminated",
|
|
87
|
+
// WebSocket transports can report close/error text instead of HTTP/fetch text.
|
|
88
|
+
"websocket.?closed",
|
|
89
|
+
"websocket.?error",
|
|
90
|
+
// Premature stream endings from SDKs and transports. Anthropic can throw
|
|
91
|
+
// "stream ended without ..." and "Anthropic stream ended before message_stop"
|
|
92
|
+
// (#4433); Bedrock/Smithy can throw an HTTP/2 no-response error (#3594).
|
|
93
|
+
"ended without",
|
|
94
|
+
"stream ended before message_stop",
|
|
95
|
+
"stream ended before a terminal response event",
|
|
96
|
+
"http2 request did not get a response",
|
|
97
|
+
// Provider-requested retry delay cap failures should flow through the outer
|
|
98
|
+
// retry policy so callers can surface/abort the backoff (#1123).
|
|
99
|
+
"retry delay",
|
|
100
|
+
// Explicit retry guidance emitted mid-stream by OpenAI Responses and Bedrock
|
|
101
|
+
// stream exceptions (#6019).
|
|
102
|
+
"you can retry your request",
|
|
103
|
+
"try your request again",
|
|
104
|
+
"please retry your request",
|
|
105
|
+
// gRPC based providers (e.g. NVIDIA NIM)
|
|
106
|
+
"ResourceExhausted",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Context-overflow error patterns (pi overflow.js). The engine reimplements
|
|
111
|
+
* the fork's retry decision on top of these, so overflow never retries (it is
|
|
112
|
+
* handled by compaction in the main session and shown as a final error here).
|
|
113
|
+
*/
|
|
114
|
+
const OVERFLOW_PATTERNS = [
|
|
115
|
+
/prompt is too long/i, // Anthropic token overflow
|
|
116
|
+
/request_too_large/i, // Anthropic request byte-size overflow (HTTP 413)
|
|
117
|
+
/input is too long for requested model/i, // Amazon Bedrock
|
|
118
|
+
/exceeds the context window/i, // OpenAI (Completions & Responses API)
|
|
119
|
+
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies (LiteLLM)
|
|
120
|
+
/input token count.*exceeds the maximum/i, // Google (Gemini)
|
|
121
|
+
/maximum prompt length is \d+/i, // xAI (Grok)
|
|
122
|
+
/reduce the length of the messages/i, // Groq
|
|
123
|
+
/maximum context length is \d+ tokens/i, // OpenRouter (most backends)
|
|
124
|
+
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside
|
|
125
|
+
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI
|
|
126
|
+
/exceeds the limit of \d+/i, // GitHub Copilot
|
|
127
|
+
/exceeds the available context size/i, // llama.cpp server
|
|
128
|
+
/greater than the context length/i, // LM Studio
|
|
129
|
+
/context window exceeds limit/i, // MiniMax
|
|
130
|
+
/exceeded model token limit/i, // Kimi For Coding
|
|
131
|
+
/too large for model with \d+ maximum context length/i, // Mistral
|
|
132
|
+
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4 server
|
|
133
|
+
/model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
|
|
134
|
+
/prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error
|
|
135
|
+
/range of input length should be/i, // DashScope / Qwen Token Plan
|
|
136
|
+
/context[_ ]length[_ ]exceeded/i, // Generic fallback
|
|
137
|
+
/too many tokens/i, // Generic fallback
|
|
138
|
+
/token limit exceeded/i, // Generic fallback
|
|
139
|
+
/^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Non-overflow patterns excluded from overflow detection (pi overflow.js):
|
|
144
|
+
* throttling/rate-limit text that would otherwise match an overflow pattern
|
|
145
|
+
* (e.g. Bedrock "ThrottlingException: Too many tokens, please wait...").
|
|
146
|
+
*/
|
|
147
|
+
const NON_OVERFLOW_PATTERNS = [
|
|
148
|
+
/^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors
|
|
149
|
+
/rate limit/i, // Generic rate limiting
|
|
150
|
+
/too many requests/i, // Generic HTTP 429 style
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/** Anything the classifier can be handed: a message-shaped failure, an Error, or text. */
|
|
154
|
+
export type RetryableInput = RetryableFailure | Error | string | null | undefined;
|
|
155
|
+
|
|
156
|
+
/** Normalize loose error shapes to the assistant-message shape pi classifies. */
|
|
157
|
+
function toRetryableFailure(error: RetryableInput): RetryableFailure | null {
|
|
158
|
+
if (error == null) return null;
|
|
159
|
+
if (typeof error === "string") return { stopReason: "error", errorMessage: error };
|
|
160
|
+
if (error instanceof Error) return { stopReason: "error", errorMessage: error.message };
|
|
161
|
+
if (typeof error === "object") return error;
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** isContextOverflow mirror (pi-ai 0.85.1 dist/utils/overflow.js). */
|
|
166
|
+
function isContextOverflow(message: RetryableFailure, contextWindow?: number): boolean {
|
|
167
|
+
// Case 1: error-message patterns.
|
|
168
|
+
if (message.stopReason === "error" && message.errorMessage) {
|
|
169
|
+
const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) =>
|
|
170
|
+
p.test(message.errorMessage as string),
|
|
171
|
+
);
|
|
172
|
+
if (
|
|
173
|
+
!isNonOverflow &&
|
|
174
|
+
OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage as string))
|
|
175
|
+
) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Case 2: silent overflow (z.ai style) — successful but usage exceeds context.
|
|
180
|
+
if (contextWindow && message.stopReason === "stop") {
|
|
181
|
+
const inputTokens = (message.usage?.input ?? 0) + (message.usage?.cacheRead ?? 0);
|
|
182
|
+
if (inputTokens > contextWindow) return true;
|
|
183
|
+
}
|
|
184
|
+
// Case 3: length-stop overflow (Xiaomi MiMo style) — the server truncates
|
|
185
|
+
// oversized input to fit the context window, leaving no room for output.
|
|
186
|
+
if (contextWindow && message.stopReason === "length" && message.usage?.output === 0) {
|
|
187
|
+
const inputTokens = (message.usage?.input ?? 0) + (message.usage?.cacheRead ?? 0);
|
|
188
|
+
if (inputTokens >= contextWindow * 0.99) return true;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** isRetryableAssistantError mirror (pi-ai 0.85.1 dist/utils/retry.js). */
|
|
194
|
+
function isRetryableAssistantError(message: RetryableFailure): boolean {
|
|
195
|
+
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
196
|
+
if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(message.errorMessage)) return false;
|
|
197
|
+
return RETRYABLE_PROVIDER_ERROR_PATTERN.test(message.errorMessage);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Classify a failed turn as retryable, mirroring pi's `_isRetryableError`
|
|
202
|
+
* semantics: transient provider errors (overloaded / rate limit / 5xx /
|
|
203
|
+
* transport) retry; context overflow, aborts, non-error stops, and
|
|
204
|
+
* quota/billing exhaustion never do. When the caller knows the model's
|
|
205
|
+
* context window it can be passed to also catch silent overflow (pi's
|
|
206
|
+
* `isContextOverflow` cases 2/3); the fork's wiring binds it via the
|
|
207
|
+
* injected `classify` when it is available.
|
|
208
|
+
*/
|
|
209
|
+
export function classifyRetryable(
|
|
210
|
+
error: RetryableInput,
|
|
211
|
+
contextWindow?: number,
|
|
212
|
+
): boolean {
|
|
213
|
+
const failure = toRetryableFailure(error);
|
|
214
|
+
if (!failure) return false;
|
|
215
|
+
if (isContextOverflow(failure, contextWindow)) return false;
|
|
216
|
+
return isRetryableAssistantError(failure);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// Retry loop
|
|
221
|
+
// =============================================================================
|
|
222
|
+
|
|
223
|
+
/** The `settings.retry` budget/backoff block, same shape pi reads. */
|
|
224
|
+
export interface RetryPolicy {
|
|
225
|
+
enabled: boolean;
|
|
226
|
+
maxRetries: number;
|
|
227
|
+
baseDelayMs: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Status payload for `onAttempt`, emitted before each backoff wait. */
|
|
231
|
+
export interface RetryAttemptInfo {
|
|
232
|
+
/** Retry number, 1-based (the n-th retry after the original failed attempt). */
|
|
233
|
+
attempt: number;
|
|
234
|
+
/** Total retry budget (`policy.maxRetries`). */
|
|
235
|
+
maxAttempts: number;
|
|
236
|
+
/** Backoff for this retry: `baseDelayMs * 2^(attempt-1)`. */
|
|
237
|
+
delayMs: number;
|
|
238
|
+
/** Error text of the failed result. */
|
|
239
|
+
errorMessage: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface RunWithRetryOptions {
|
|
243
|
+
/** Runs one turn attempt and resolves with its result (the fake agent in tests). */
|
|
244
|
+
attempt: () => Promise<unknown>;
|
|
245
|
+
/**
|
|
246
|
+
* Retryability decision for a result; defaults to `classifyRetryable`.
|
|
247
|
+
* Injected so tests can classify arbitrary shapes and the wiring can bind
|
|
248
|
+
* the model's context window.
|
|
249
|
+
*/
|
|
250
|
+
classify?: (result: unknown) => boolean;
|
|
251
|
+
/**
|
|
252
|
+
* Backoff sleep; injected for a fake clock. Defaults to {@link sleep}.
|
|
253
|
+
* Must settle (resolve or reject) when `signal` aborts — the engine treats
|
|
254
|
+
* an aborted signal after the wait as "stop retrying, return the last error".
|
|
255
|
+
*/
|
|
256
|
+
delay?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
257
|
+
/** Cancellation (Esc): interrupts the backoff wait. */
|
|
258
|
+
signal?: AbortSignal;
|
|
259
|
+
/** Called before each backoff wait (e.g. to render "Retrying (attempt n)…"). */
|
|
260
|
+
onAttempt?: (info: RetryAttemptInfo) => void | Promise<void>;
|
|
261
|
+
/** Retry budget + backoff base (`settings.retry` shape). */
|
|
262
|
+
policy: RetryPolicy;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Abortable setTimeout sleep, mirroring pi's retry sleep behavior. */
|
|
266
|
+
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
if (signal?.aborted) {
|
|
269
|
+
reject(abortError());
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const onAbort = () => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
signal?.removeEventListener("abort", onAbort);
|
|
275
|
+
reject(abortError());
|
|
276
|
+
};
|
|
277
|
+
const timer = setTimeout(() => {
|
|
278
|
+
signal?.removeEventListener("abort", onAbort);
|
|
279
|
+
resolve();
|
|
280
|
+
}, ms);
|
|
281
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function abortError(): Error {
|
|
286
|
+
const error = new Error("Aborted");
|
|
287
|
+
error.name = "AbortError";
|
|
288
|
+
return error;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function extractErrorMessage(result: unknown): string {
|
|
292
|
+
if (
|
|
293
|
+
typeof result === "object" &&
|
|
294
|
+
result !== null &&
|
|
295
|
+
"errorMessage" in result
|
|
296
|
+
) {
|
|
297
|
+
const errorMessage = (result as { errorMessage?: unknown }).errorMessage;
|
|
298
|
+
if (typeof errorMessage === "string" && errorMessage.length > 0) {
|
|
299
|
+
return errorMessage;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (typeof result === "string" && result.length > 0) return result;
|
|
303
|
+
if (result instanceof Error && result.message.length > 0) return result.message;
|
|
304
|
+
return "Unknown error";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Run one turn attempt with bounded retry, mirroring pi's turn retry loop
|
|
309
|
+
* (`retryAssistantCall` + `_prepareRetry`):
|
|
310
|
+
*
|
|
311
|
+
* - A non-retryable result (success, abort, overflow, quota, …) returns as-is;
|
|
312
|
+
* - a retryable failure retries up to `policy.maxRetries` times with
|
|
313
|
+
* `baseDelayMs * 2^(n-1)` backoff, emitting `onAttempt` before each wait;
|
|
314
|
+
* - budget exhaustion returns the final error result unchanged;
|
|
315
|
+
* - an aborted `signal` interrupts the backoff wait — an abort that lands
|
|
316
|
+
* before a wait is scheduled skips it entirely — and returns the last
|
|
317
|
+
* error result (so "Esc cancel" and "budget exhausted" are isomorphic —
|
|
318
|
+
* the wiring shows the final error either way);
|
|
319
|
+
* - `policy.enabled === false` runs a single attempt with zero overhead
|
|
320
|
+
* (no classify / delay / onAttempt).
|
|
321
|
+
*
|
|
322
|
+
* A thrown rejection from `attempt` propagates to the caller (pi's stream
|
|
323
|
+
* encodes failures as messages instead of throwing; the fork's wiring decides
|
|
324
|
+
* how to surface its own throws).
|
|
325
|
+
*/
|
|
326
|
+
export async function runWithRetry(options: RunWithRetryOptions): Promise<unknown> {
|
|
327
|
+
const { attempt, signal, onAttempt } = options;
|
|
328
|
+
const policy = options.policy;
|
|
329
|
+
const classify =
|
|
330
|
+
options.classify ?? ((result: unknown) => classifyRetryable(result as RetryableInput));
|
|
331
|
+
const delay = options.delay ?? sleep;
|
|
332
|
+
|
|
333
|
+
const maxAttempts = policy.enabled ? (policy.maxRetries ?? 0) : 0;
|
|
334
|
+
const baseDelayMs = policy.baseDelayMs ?? 0;
|
|
335
|
+
|
|
336
|
+
let result: unknown = await attempt();
|
|
337
|
+
let retryCount = 0;
|
|
338
|
+
while (retryCount < maxAttempts && classify(result)) {
|
|
339
|
+
// Esc may have fired between the failed attempt and this decision point:
|
|
340
|
+
// skip scheduling a wait that would be interrupted immediately.
|
|
341
|
+
if (signal?.aborted) return result;
|
|
342
|
+
retryCount++;
|
|
343
|
+
const delayMs = baseDelayMs * 2 ** (retryCount - 1);
|
|
344
|
+
await onAttempt?.({
|
|
345
|
+
attempt: retryCount,
|
|
346
|
+
maxAttempts,
|
|
347
|
+
delayMs,
|
|
348
|
+
errorMessage: extractErrorMessage(result),
|
|
349
|
+
});
|
|
350
|
+
try {
|
|
351
|
+
await delay(delayMs, signal);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (signal?.aborted) return result;
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
if (signal?.aborted) return result;
|
|
357
|
+
result = await attempt();
|
|
358
|
+
}
|
|
359
|
+
return result;
|
|
360
|
+
}
|