jeo-code 0.7.27 → 0.7.29
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/CHANGELOG.md +26 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/config-schema.ts +1 -0
- package/src/agent/engine.ts +43 -17
- package/src/agent/loop-guards.ts +7 -2
- package/src/agent/loop.ts +3 -0
- package/src/agent/state.ts +84 -5
- package/src/agent/task-tool.ts +3 -0
- package/src/ai/model-catalog.ts +23 -0
- package/src/ai/model-discovery.ts +6 -2
- package/src/ai/model-manager.ts +33 -12
- package/src/ai/providers/anthropic.ts +56 -17
- package/src/ai/providers/antigravity.ts +34 -22
- package/src/ai/providers/gemini.ts +81 -27
- package/src/ai/providers/kimi.ts +44 -5
- package/src/ai/providers/openai-responses.ts +36 -2
- package/src/ai/types.ts +10 -0
- package/src/auth/callback-server.ts +19 -5
- package/src/auth/flows/AGENTS.md +7 -6
- package/src/auth/flows/antigravity.ts +3 -0
- package/src/auth/flows/google-project.ts +8 -2
- package/src/auth/flows/google.ts +15 -1
- package/src/auth/flows/index.ts +10 -0
- package/src/auth/flows/kimi.ts +263 -0
- package/src/auth/flows/openai.ts +103 -3
- package/src/auth/oauth.ts +9 -0
- package/src/auth/refresh.ts +6 -1
- package/src/auth/storage.ts +5 -5
- package/src/commands/launch.ts +6 -2
- package/src/commands/team.ts +3 -0
- package/src/tui/components/welcome.ts +98 -39
- package/src/util/provider-error.ts +1 -1
- package/src/util/retry.ts +91 -33
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
2
4
|
import { renderForgeMark, FORGE_MARK_ART_GRAND } from "./ascii-art";
|
|
3
5
|
import { truncate, isTTY } from "../terminal";
|
|
4
|
-
import { detectColorLevel, ColorLevel } from "./color";
|
|
6
|
+
import { detectColorLevel, ColorLevel, visibleWidth } from "./color";
|
|
5
7
|
|
|
6
8
|
export interface WelcomeData {
|
|
7
9
|
version: string;
|
|
@@ -23,12 +25,8 @@ export interface WelcomeData {
|
|
|
23
25
|
color?: boolean; // default true
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
function getVisibleWidth(s: string): number {
|
|
27
|
-
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
28
|
function padLine(line: string, width: number, align: "left" | "center" | "right" = "left"): string {
|
|
31
|
-
const vis =
|
|
29
|
+
const vis = visibleWidth(line);
|
|
32
30
|
if (width <= 0 || vis >= width) return line;
|
|
33
31
|
const total = width - vis;
|
|
34
32
|
if (align === "right") return " ".repeat(total) + line;
|
|
@@ -39,12 +37,60 @@ function padLine(line: string, width: number, align: "left" | "center" | "right"
|
|
|
39
37
|
return line + " ".repeat(total);
|
|
40
38
|
}
|
|
41
39
|
|
|
40
|
+
function shortenPath(p: string | undefined, maxWidth: number, unicode: boolean): string {
|
|
41
|
+
if (!p) return "-";
|
|
42
|
+
let s = p;
|
|
43
|
+
const home = os.homedir();
|
|
44
|
+
if (home && (s === home || s.startsWith(home + path.sep))) s = "~" + s.slice(home.length);
|
|
45
|
+
if (visibleWidth(s) <= maxWidth) return s;
|
|
46
|
+
const ell = unicode ? "…" : "...";
|
|
47
|
+
return truncate(`${ell}${s.slice(-Math.max(1, maxWidth - visibleWidth(ell)))}`, maxWidth);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function metaRows(d: WelcomeData, width: number, unicode: boolean, useColor: boolean): string[] {
|
|
51
|
+
const labelWidth = 8;
|
|
52
|
+
const valueWidth = Math.max(1, width - labelWidth - 3);
|
|
53
|
+
const key = useColor ? chalk.dim : (s: string) => s;
|
|
54
|
+
const rows: string[] = [];
|
|
55
|
+
const push = (label: string, value: string): void => {
|
|
56
|
+
const lhs = key(padLine(label, labelWidth, "right"));
|
|
57
|
+
const rhs = truncate(value || "-", valueWidth);
|
|
58
|
+
rows.push(padLine(`${lhs} │ ${rhs}`, width));
|
|
59
|
+
};
|
|
60
|
+
push("version", `v${d.version}`);
|
|
61
|
+
push("folder", shortenPath(d.cwd, valueWidth, unicode));
|
|
62
|
+
push("model", d.model);
|
|
63
|
+
if (d.provider) push("provider", d.provider);
|
|
64
|
+
if (d.thinking) push("thinking", d.thinking);
|
|
65
|
+
if (d.sessionId) push("session", d.sessionId.slice(0, 8));
|
|
66
|
+
|
|
67
|
+
const files = d.contextFiles ?? [];
|
|
68
|
+
if (files.length > 0 && width >= 28) {
|
|
69
|
+
const shown = files.slice(0, width >= 34 ? 3 : 2).map(f => path.basename(f));
|
|
70
|
+
push("files", shown.join(", ") + (files.length > shown.length ? ` +${files.length - shown.length}` : ""));
|
|
71
|
+
}
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function centerColumn(lines: string[], width: number): string[] {
|
|
76
|
+
return lines.map(raw => padLine(truncate(raw, width), width, "center"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function zipColumns(left: string[], right: string[], leftWidth: number, gap: string, rightWidth: number): string[] {
|
|
80
|
+
const n = Math.max(left.length, right.length);
|
|
81
|
+
const out: string[] = [];
|
|
82
|
+
for (let i = 0; i < n; i++) {
|
|
83
|
+
out.push(`${padLine(left[i] ?? "", leftWidth)}${gap}${padLine(right[i] ?? "", rightWidth)}`);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
42
88
|
/**
|
|
43
89
|
* The gjc-style hero welcome box ("JEO forge"): one outer box with the version
|
|
44
|
-
* embedded in the top border
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
90
|
+
* embedded in the top border. Narrow terminals keep a single centered forge
|
|
91
|
+
* column; wide terminals split into a left hero mark and a right workspace table
|
|
92
|
+
* (folder, version, model, context files) so the launch capture uses the extra
|
|
93
|
+
* resolution instead of leaving a dead right side.
|
|
48
94
|
*/
|
|
49
95
|
export function renderWelcome(d: WelcomeData): string[] {
|
|
50
96
|
const cols = d.cols ?? 80;
|
|
@@ -80,11 +126,11 @@ export function renderWelcome(d: WelcomeData): string[] {
|
|
|
80
126
|
const dashStr = g.h.repeat(titleDashes);
|
|
81
127
|
const titleHead = `${dashStr}${titleLabel}`;
|
|
82
128
|
let topBorderLine: string;
|
|
83
|
-
if (titleHead
|
|
84
|
-
const clipped = titleHead
|
|
85
|
-
topBorderLine = lit(g.tl + clipped + g.h.repeat(Math.max(0, inner - clipped
|
|
129
|
+
if (visibleWidth(titleHead) + 2 > inner) {
|
|
130
|
+
const clipped = truncate(titleHead, inner);
|
|
131
|
+
topBorderLine = lit(g.tl + clipped + g.h.repeat(Math.max(0, inner - visibleWidth(clipped))) + g.tr);
|
|
86
132
|
} else {
|
|
87
|
-
const fill = g.h.repeat(inner - titleHead
|
|
133
|
+
const fill = g.h.repeat(inner - visibleWidth(titleHead));
|
|
88
134
|
topBorderLine = useColor
|
|
89
135
|
? lit(g.tl + dashStr) + chalk.bold(lit(titleLabel)) + lit(fill) + lit(g.tr)
|
|
90
136
|
: g.tl + titleHead + fill + g.tr;
|
|
@@ -93,44 +139,57 @@ export function renderWelcome(d: WelcomeData): string[] {
|
|
|
93
139
|
const bottomBorderPlain = g.bl + g.h.repeat(inner) + g.br;
|
|
94
140
|
const bottomBorderLine = shadow(bottomBorderPlain);
|
|
95
141
|
|
|
96
|
-
// Grand symbol when the box is wide enough; compact forge mark otherwise.
|
|
142
|
+
// Grand symbol when the box is wide enough; compact forge mark otherwise. In a
|
|
143
|
+
// split layout, art is constrained to the left pane so the right table remains stable.
|
|
97
144
|
const colorLevel = useColor ? detectColorLevel(process.env, isTTY()) : ColorLevel.None;
|
|
98
|
-
const
|
|
145
|
+
const wideMeta = inner >= 86;
|
|
146
|
+
const gap = wideMeta ? " " : "";
|
|
147
|
+
const rightWidth = wideMeta ? Math.min(38, Math.max(28, Math.floor(inner * 0.34))) : 0;
|
|
148
|
+
const leftWidth = wideMeta ? Math.max(32, inner - rightWidth - visibleWidth(gap)) : inner;
|
|
149
|
+
const grand = leftWidth >= grandWidth;
|
|
99
150
|
const artLines = renderForgeMark({
|
|
100
151
|
color: useColor,
|
|
101
152
|
phase: d.phase ?? 0,
|
|
102
153
|
unicode,
|
|
103
154
|
colorLevel,
|
|
104
155
|
grand,
|
|
105
|
-
cols:
|
|
156
|
+
cols: leftWidth,
|
|
106
157
|
});
|
|
107
158
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
159
|
+
const pill = (icon: string, text: string, paint: (s: string) => string): string => {
|
|
160
|
+
const p = truncate(`[ ${icon} ${text} ]`, leftWidth);
|
|
161
|
+
return useColor ? paint(p) : p;
|
|
162
|
+
};
|
|
163
|
+
const leftContent: string[] = [];
|
|
164
|
+
leftContent.push("");
|
|
165
|
+
leftContent.push(useColor ? chalk.bold.cyan("Jeo forge") : "Jeo forge");
|
|
166
|
+
leftContent.push(useColor ? chalk.dim("evolve · act · prove") : "evolve · act · prove");
|
|
167
|
+
leftContent.push("");
|
|
168
|
+
leftContent.push(...artLines);
|
|
169
|
+
leftContent.push("");
|
|
170
|
+
leftContent.push(pill(unicode ? "◆" : "*", d.model, chalk.cyan));
|
|
171
|
+
if (d.provider) leftContent.push(pill(unicode ? "◇" : "o", d.provider, chalk.blue));
|
|
172
|
+
leftContent.push("");
|
|
173
|
+
|
|
174
|
+
let content: string[];
|
|
175
|
+
if (wideMeta) {
|
|
176
|
+
const tableTitle = useColor ? chalk.bold("workspace") : "workspace";
|
|
177
|
+
const rightContent = [
|
|
178
|
+
"",
|
|
179
|
+
padLine(tableTitle, rightWidth, "center"),
|
|
180
|
+
(useColor ? chalk.dim : (s: string) => s)(padLine("forge context", rightWidth, "center")),
|
|
181
|
+
"",
|
|
182
|
+
...metaRows(d, rightWidth, unicode, useColor),
|
|
183
|
+
"",
|
|
184
|
+
];
|
|
185
|
+
content = zipColumns(centerColumn(leftContent, leftWidth), rightContent, leftWidth, gap, rightWidth);
|
|
186
|
+
} else {
|
|
187
|
+
content = centerColumn(leftContent, inner);
|
|
125
188
|
}
|
|
126
|
-
content.push("");
|
|
127
189
|
|
|
128
190
|
const leftBorder = lit(g.v);
|
|
129
191
|
const rightBorder = shadow(g.v);
|
|
130
|
-
const finalContentLines = content.map(raw =>
|
|
131
|
-
const line = padLine(truncate(raw, inner), inner, "center");
|
|
132
|
-
return leftBorder + line + rightBorder;
|
|
133
|
-
});
|
|
192
|
+
const finalContentLines = content.map(raw => leftBorder + padLine(truncate(raw, inner), inner) + rightBorder);
|
|
134
193
|
|
|
135
194
|
return [topBorderLine, ...finalContentLines, bottomBorderLine];
|
|
136
195
|
}
|
|
@@ -61,7 +61,7 @@ export function friendlyProviderError(err: unknown): string {
|
|
|
61
61
|
return `${provider} rejected the request: the conversation no longer fits the model's context window. Run /compact, drop large attachments, or start a fresh session.`;
|
|
62
62
|
}
|
|
63
63
|
if (isRefusalError(err)) {
|
|
64
|
-
return `${provider} declined to answer (safety refusal — no content returned)
|
|
64
|
+
return `${provider} declined to answer (safety refusal — no content returned). Usually a content classifier tripped on recently read file/search content: /retry, /compact or /new to drop the triggering context, or switch model with /model. If this persists on a Claude subscription (OAuth) login, Anthropic restricts third-party OAuth clients — set ANTHROPIC_API_KEY or use another provider.`;
|
|
65
65
|
}
|
|
66
66
|
if (status === 404) {
|
|
67
67
|
return `${provider} does not recognize the requested model (HTTP 404). The id may be retired, gated, or mistyped — pick another with /model.`;
|
package/src/util/retry.ts
CHANGED
|
@@ -7,8 +7,14 @@ export interface RetryOptions {
|
|
|
7
7
|
random?: () => number; // injectable RNG for jitter (default Math.random); equal-jitter in [0.5x, 1x]
|
|
8
8
|
/** Notified before each backoff wait; `delayMs` is the wait actually applied. */
|
|
9
9
|
onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
|
|
10
|
+
/** Aborts an in-progress backoff wait (e.g. Ctrl-C / turn cancel). Already-aborted or
|
|
11
|
+
* aborted-mid-wait rejects immediately instead of completing the sleep — the escape hatch
|
|
12
|
+
* for a long, honored provider `Retry-After` (see `rateLimitMaxServerDelayMs`: those are no
|
|
13
|
+
* longer capped by default). Does NOT cancel the in-flight `fn()` call itself; thread the
|
|
14
|
+
* same signal into the request (e.g. `fetch`) for that. */
|
|
15
|
+
signal?: AbortSignal;
|
|
10
16
|
/** Minimum backoff (ms) applied specifically to rate-limit (429) errors. The floor
|
|
11
|
-
* ESCALATES per attempt (floor × 2^(attempt-1), capped at
|
|
17
|
+
* ESCALATES per attempt (floor × 2^(attempt-1), capped at RATE_LIMIT_FLOOR_CAP_MS) because a
|
|
12
18
|
* rate-limit window rarely clears in <1s and often needs tens of seconds — a flat
|
|
13
19
|
* sub-second retry cadence just burns the budget; default 0 (no floor) preserves
|
|
14
20
|
* generic behavior. */
|
|
@@ -17,9 +23,13 @@ export interface RetryOptions {
|
|
|
17
23
|
* When higher than `retries`, rate-limit errors get extra attempts so a transient
|
|
18
24
|
* per-minute window can reset; non-rate-limit errors still use `retries`. */
|
|
19
25
|
rateLimitRetries?: number;
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
26
|
+
/** Opt-in ceiling on a 429's server-directed retry delay: set it and a delay beyond it fails
|
|
27
|
+
* fast with the original error instead of sleeping. Unset (the default) honors ANY
|
|
28
|
+
* server-directed delay in full, however long — gjc parity: a generic rate limit is retried
|
|
29
|
+
* forever, not treated as fatal past some arbitrary budget. Usage/quota-limit errors
|
|
30
|
+
* (`isUsageLimitError`) are unaffected either way: `defaultRetryable` already classifies
|
|
31
|
+
* them non-retryable before this wait is ever computed. Pair with `signal` so an unbounded
|
|
32
|
+
* wait stays user-cancellable. */
|
|
23
33
|
rateLimitMaxServerDelayMs?: number;
|
|
24
34
|
}
|
|
25
35
|
|
|
@@ -102,37 +112,45 @@ export function defaultRetryable(err: unknown): boolean {
|
|
|
102
112
|
return false;
|
|
103
113
|
}
|
|
104
114
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
//
|
|
115
|
+
// Synthetic per-minute floor for a 429 that carries NO server `Retry-After` (see
|
|
116
|
+
// `rateLimitMinDelayMs`) — caps the escalating floor so a SILENT rate limit still waits a
|
|
117
|
+
// realistic window instead of ~8s. Does NOT cap a genuine server-directed Retry-After: a
|
|
118
|
+
// provider that names its own wait is authoritative and is honored in full (gjc parity);
|
|
119
|
+
// `rateLimitMaxServerDelayMs` is the opt-in ceiling for a caller that truly cannot wait that long.
|
|
120
|
+
const RATE_LIMIT_FLOOR_CAP_MS = 30_000;
|
|
108
121
|
|
|
109
122
|
// Run fn; on a retryable error, back off and retry up to `retries` attempts. Backoff is
|
|
110
|
-
// exponential (baseDelay * 2^(attempt-1), capped at maxDelay) with equal jitter (the wait lands
|
|
111
|
-
// [0.5x, 1x] of that cap)
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
123
|
+
// exponential (baseDelay * 2^(attempt-1), capped at maxDelay) with equal jitter (the wait lands
|
|
124
|
+
// in [0.5x, 1x] of that cap). A server-directed `retryAfterMs` (Retry-After) wins over the
|
|
125
|
+
// jitter and is honored IN FULL — no cap — unless `rateLimitMaxServerDelayMs` is explicitly set
|
|
126
|
+
// and exceeded, in which case the original error is re-thrown immediately instead of sleeping.
|
|
127
|
+
// `opts.signal` can abort an in-progress wait, so an unbounded honored delay stays cancellable.
|
|
115
128
|
export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T> {
|
|
116
129
|
const retries = opts?.retries ?? 3;
|
|
117
130
|
const baseDelayMs = opts?.baseDelayMs ?? 250;
|
|
118
131
|
const maxDelayMs = opts?.maxDelayMs ?? 4000;
|
|
119
132
|
const isRetryable = opts?.isRetryable ?? defaultRetryable;
|
|
120
|
-
const sleep = opts?.sleep ?? ((ms: number) =>
|
|
133
|
+
const sleep = opts?.sleep ?? ((ms: number) => {
|
|
134
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
135
|
+
setTimeout(resolve, ms);
|
|
136
|
+
return promise;
|
|
137
|
+
});
|
|
121
138
|
const random = opts?.random ?? Math.random;
|
|
122
139
|
const onRetry = opts?.onRetry;
|
|
123
140
|
const rateLimitMinDelayMs = opts?.rateLimitMinDelayMs ?? 0;
|
|
124
141
|
const rateLimitRetries = opts?.rateLimitRetries;
|
|
125
142
|
const rateLimitMaxServerDelayMs = opts?.rateLimitMaxServerDelayMs;
|
|
143
|
+
const signal = opts?.signal;
|
|
126
144
|
|
|
127
145
|
let attempt = 1;
|
|
128
146
|
while (true) {
|
|
129
147
|
try {
|
|
130
148
|
return await fn();
|
|
131
149
|
} catch (err) {
|
|
132
|
-
// Rate-limit (429) errors may use a higher attempt cap so a transient
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
150
|
+
// Rate-limit (429) errors may use a higher attempt cap so a transient per-minute window
|
|
151
|
+
// can reset before we surface the failure. `rateLimitMaxServerDelayMs` is opt-in
|
|
152
|
+
// (unset by default): only when explicitly configured and the server's own wait exceeds
|
|
153
|
+
// it do we fail fast instead of sleeping through a window the caller declared too long.
|
|
136
154
|
const rateLimited = isRateLimitError(err);
|
|
137
155
|
const serverDelay = retryAfterOf(err);
|
|
138
156
|
if (
|
|
@@ -151,15 +169,15 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
|
|
|
151
169
|
const capped = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs);
|
|
152
170
|
// Equal jitter: half fixed + half random → [0.5x, 1x] of the capped backoff.
|
|
153
171
|
const jittered = capped / 2 + random() * (capped / 2);
|
|
154
|
-
// Server `Retry-After` wins (
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
const base =
|
|
172
|
+
// Server `Retry-After` wins, honored IN FULL (no cap — gjc parity: even a multi-hour
|
|
173
|
+
// provider-directed window is retried, never silently compressed). For rate limits,
|
|
174
|
+
// apply the floor in BOTH cases so a 0/near-0 Retry-After (or sub-second jitter) doesn't
|
|
175
|
+
// burn the 429 budget back-to-back with no real pause. The floor escalates per attempt
|
|
176
|
+
// (×2 each retry, capped at RATE_LIMIT_FLOOR_CAP_MS) so a SYNTHETIC (server-silent) wait
|
|
177
|
+
// spans a realistic rate-limit window (~a minute) instead of ~8s.
|
|
178
|
+
const base = serverDelay !== undefined ? serverDelay : jittered;
|
|
161
179
|
const floor = rateLimited
|
|
162
|
-
? Math.min(rateLimitMinDelayMs * Math.pow(2, attempt - 1),
|
|
180
|
+
? Math.min(rateLimitMinDelayMs * Math.pow(2, attempt - 1), RATE_LIMIT_FLOOR_CAP_MS)
|
|
163
181
|
: 0;
|
|
164
182
|
const delay = Math.max(base, floor);
|
|
165
183
|
|
|
@@ -167,12 +185,49 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
|
|
|
167
185
|
onRetry(attempt, err, delay);
|
|
168
186
|
}
|
|
169
187
|
|
|
170
|
-
await sleep
|
|
188
|
+
await abortableSleep(sleep, delay, signal);
|
|
171
189
|
attempt++;
|
|
172
190
|
}
|
|
173
191
|
}
|
|
174
192
|
}
|
|
175
193
|
|
|
194
|
+
/** Race `sleep(ms)` against `signal`; an already-aborted or mid-wait-aborted signal rejects
|
|
195
|
+
* immediately with its abort reason instead of completing the wait. Keeps a long, honored
|
|
196
|
+
* server-directed retry delay (see `withRetry`) user-cancellable even though it is no longer
|
|
197
|
+
* capped. A no-op passthrough when no signal is supplied (existing callers/tests unaffected). */
|
|
198
|
+
function abortableSleep(sleep: (ms: number) => Promise<void>, ms: number, signal?: AbortSignal): Promise<void> {
|
|
199
|
+
if (!signal) return sleep(ms);
|
|
200
|
+
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("aborted"));
|
|
201
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
202
|
+
const onAbort = () => reject(signal.reason ?? new Error("aborted"));
|
|
203
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
204
|
+
sleep(ms).then(
|
|
205
|
+
value => { signal.removeEventListener("abort", onAbort); resolve(value); },
|
|
206
|
+
err => { signal.removeEventListener("abort", onAbort); reject(err); },
|
|
207
|
+
);
|
|
208
|
+
return promise;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Abortable wait for the ENGINE's post-ladder refusal backoff: resolves EARLY (never
|
|
212
|
+
* rejects) when `signal` aborts, so the agent loop's top-of-loop cancellation check
|
|
213
|
+
* owns the exit path. Distinct from the rejecting `abortableSleep` inside `withRetry`,
|
|
214
|
+
* whose callers need the abort reason propagated as an error. */
|
|
215
|
+
export function waitAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
216
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
217
|
+
if (signal?.aborted) {
|
|
218
|
+
resolve();
|
|
219
|
+
return promise;
|
|
220
|
+
}
|
|
221
|
+
const finish = () => {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
signal?.removeEventListener("abort", finish);
|
|
224
|
+
resolve();
|
|
225
|
+
};
|
|
226
|
+
const timer = setTimeout(finish, ms);
|
|
227
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
228
|
+
return promise;
|
|
229
|
+
}
|
|
230
|
+
|
|
176
231
|
// Extract a non-negative `retryAfterMs` from an error, if it carries one (e.g. ProviderHttpError).
|
|
177
232
|
function retryAfterOf(err: unknown): number | undefined {
|
|
178
233
|
if (typeof err === "object" && err !== null) {
|
|
@@ -205,12 +260,14 @@ function errorMessageOf(err: unknown): string {
|
|
|
205
260
|
|
|
206
261
|
/**
|
|
207
262
|
* Persistent usage/quota-limit detection (gjc parity: `isUsageLimitError`). These are
|
|
208
|
-
* subscription/window limits ("usage limit reached", "quota exceeded"
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
263
|
+
* subscription/window limits ("usage limit reached", "quota exceeded", model/message
|
|
264
|
+
* window limits, credit exhaustion, Anthropic ACCOUNT-level exhaustion) that need a
|
|
265
|
+
* model or credential switch — retrying within seconds is pure waste, unlike a
|
|
266
|
+
* per-minute 429. Bare "resource exhausted" (gRPC capacity wording) stays transient;
|
|
267
|
+
* only the qualified "Resource has been exhausted (… quota/limit)" form is persistent
|
|
268
|
+
* (gjc fixture parity: rate-limit-utils.test.ts).
|
|
212
269
|
*/
|
|
213
|
-
const USAGE_LIMIT_PATTERN = /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?exceeded|exceeded your/i;
|
|
270
|
+
const USAGE_LIMIT_PATTERN = /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|model.?limit|message.?limit|limit for this model|quota.?exceeded|out_of_credits|request would exceed your account.?s rate limit|resource has been exhausted[^\n]*(?:quota|limit)|exceeded your/i;
|
|
214
271
|
export function isUsageLimitError(err: unknown): boolean {
|
|
215
272
|
return USAGE_LIMIT_PATTERN.test(errorMessageOf(err));
|
|
216
273
|
}
|
|
@@ -221,8 +278,9 @@ export function isUsageLimitError(err: unknown): boolean {
|
|
|
221
278
|
* block reasons). Lives here (not provider-error.ts) so `defaultRetryable` can
|
|
222
279
|
* fail fast on it: a refusal is DETERMINISTIC for the same conversation content —
|
|
223
280
|
* transport-level resends of an identical payload just burn billed calls. The
|
|
224
|
-
* engine's
|
|
225
|
-
*
|
|
281
|
+
* engine's refusal ladder (resend → context reset → guidance strip, then unbounded
|
|
282
|
+
* capped backoff — gjc parity: a refusal is never terminal) is the correct recovery
|
|
283
|
+
* layer because it MUTATES the context between attempts. */
|
|
226
284
|
export function isRefusalError(err: unknown): boolean {
|
|
227
285
|
return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(errorMessageOf(err));
|
|
228
286
|
}
|