@frockbot/kernel-agent-loop 0.3.14 → 0.3.15
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 +5 -5
- package/src/agent.ts +2 -1
- package/src/deadlines.test.ts +129 -1
- package/src/index.ts +50 -16
- package/src/retry-policy.test.ts +67 -0
- package/src/retry-policy.ts +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-agent-loop",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
15
|
+
"@frockbot/kernel-contracts": "0.3.15",
|
|
16
16
|
"cordis": "4.0.0-rc.8"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@frockbot/plugin-models": "0.3.
|
|
20
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
21
|
-
"@frockbot/plugin-tools": "0.3.
|
|
19
|
+
"@frockbot/plugin-models": "0.3.15",
|
|
20
|
+
"@frockbot/plugin-prompt": "0.3.15",
|
|
21
|
+
"@frockbot/plugin-tools": "0.3.15",
|
|
22
22
|
"@types/bun": "1.4.0",
|
|
23
23
|
"@types/node": "26.2.0",
|
|
24
24
|
"typescript": "^7.0.2"
|
package/src/agent.ts
CHANGED
|
@@ -61,7 +61,8 @@ export interface AgentSendV1 {
|
|
|
61
61
|
export type PreStepDecision =
|
|
62
62
|
{ kind: "enter"; inputs: AgentInput[] } | { kind: "reject"; reason: string };
|
|
63
63
|
|
|
64
|
-
export type RequestErrorAction =
|
|
64
|
+
export type RequestErrorAction =
|
|
65
|
+
{ kind: "retry" } | { kind: "fallback" } | { kind: "fail" };
|
|
65
66
|
|
|
66
67
|
export interface Agent extends LoopAgentRuntimeV1 {
|
|
67
68
|
readonly id: string;
|
package/src/deadlines.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
6
6
|
import {
|
|
7
7
|
LlmEffectNotStartedError,
|
|
8
8
|
type LlmProvider,
|
|
9
|
+
ModelProviderFailureError,
|
|
9
10
|
SessionStore,
|
|
10
11
|
} from "@frockbot/kernel-contracts";
|
|
11
12
|
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
@@ -24,7 +25,14 @@ afterEach(async () => {
|
|
|
24
25
|
|
|
25
26
|
async function mount(
|
|
26
27
|
provider: LlmProvider,
|
|
27
|
-
config: {
|
|
28
|
+
config: {
|
|
29
|
+
turnDeadlineMs?: number;
|
|
30
|
+
retry?: {
|
|
31
|
+
now?: () => number;
|
|
32
|
+
random?: () => number;
|
|
33
|
+
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
} = {},
|
|
28
36
|
): Promise<Context> {
|
|
29
37
|
const root = new Context();
|
|
30
38
|
roots.push(root);
|
|
@@ -243,6 +251,11 @@ describe("a model request the provider says never started", () => {
|
|
|
243
251
|
(event) => event.type === "model/effect-not-started",
|
|
244
252
|
),
|
|
245
253
|
).toHaveLength(1);
|
|
254
|
+
expect(
|
|
255
|
+
handle.agent.session.events.filter(
|
|
256
|
+
(event) => event.type === "model/retry",
|
|
257
|
+
),
|
|
258
|
+
).toHaveLength(1);
|
|
246
259
|
});
|
|
247
260
|
|
|
248
261
|
test("is not retried a second time", async () => {
|
|
@@ -306,6 +319,121 @@ describe("a model request the provider says never started", () => {
|
|
|
306
319
|
});
|
|
307
320
|
});
|
|
308
321
|
|
|
322
|
+
describe("classified model retry policy", () => {
|
|
323
|
+
test("backs off with injected time and random before a transient retry", async () => {
|
|
324
|
+
let attempts = 0;
|
|
325
|
+
let now = 10_000;
|
|
326
|
+
const sleeps: number[] = [];
|
|
327
|
+
const provider: LlmProvider = {
|
|
328
|
+
id: "transient-once",
|
|
329
|
+
async *stream() {
|
|
330
|
+
attempts += 1;
|
|
331
|
+
if (attempts === 1) {
|
|
332
|
+
throw new ModelProviderFailureError({
|
|
333
|
+
classification: "transient",
|
|
334
|
+
reason: "service unavailable",
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
yield { type: "text-delta", text: "Recovered." } as const;
|
|
338
|
+
yield { type: "finish", reason: "completed" } as const;
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
const root = await mount(provider, {
|
|
342
|
+
retry: {
|
|
343
|
+
now: () => now,
|
|
344
|
+
random: () => 1,
|
|
345
|
+
sleep: (milliseconds) => {
|
|
346
|
+
sleeps.push(milliseconds);
|
|
347
|
+
now += milliseconds;
|
|
348
|
+
return Promise.resolve();
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
const handle = await root.agents.create({
|
|
353
|
+
botId: "transient-bot",
|
|
354
|
+
sessionId: "transient",
|
|
355
|
+
provider: provider.id,
|
|
356
|
+
model: "test-model",
|
|
357
|
+
admitEffect: allowEffect,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
handle.agent.send("Try the provider.");
|
|
361
|
+
await handle.agent.whenIdle();
|
|
362
|
+
|
|
363
|
+
expect(attempts).toBe(2);
|
|
364
|
+
expect(sleeps).toEqual([500]);
|
|
365
|
+
expect(
|
|
366
|
+
handle.agent.session.events.find((event) => event.type === "model/retry"),
|
|
367
|
+
).toMatchObject({
|
|
368
|
+
attempt: 2,
|
|
369
|
+
classification: "transient",
|
|
370
|
+
delayMs: 500,
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("does not retry a permanent failure", async () => {
|
|
375
|
+
let attempts = 0;
|
|
376
|
+
const provider: LlmProvider = {
|
|
377
|
+
id: "permanent",
|
|
378
|
+
async *stream() {
|
|
379
|
+
attempts += 1;
|
|
380
|
+
throw new ModelProviderFailureError({
|
|
381
|
+
classification: "permanent",
|
|
382
|
+
reason: "credentials were refused",
|
|
383
|
+
});
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
const root = await mount(provider);
|
|
387
|
+
const handle = await root.agents.create({
|
|
388
|
+
botId: "permanent-bot",
|
|
389
|
+
sessionId: "permanent",
|
|
390
|
+
provider: provider.id,
|
|
391
|
+
model: "test-model",
|
|
392
|
+
admitEffect: allowEffect,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
handle.agent.send("Try the provider.");
|
|
396
|
+
await handle.agent.whenIdle();
|
|
397
|
+
|
|
398
|
+
expect(attempts).toBe(1);
|
|
399
|
+
expect(
|
|
400
|
+
handle.agent.session.events.filter(
|
|
401
|
+
(event) => event.type === "model/retry",
|
|
402
|
+
),
|
|
403
|
+
).toHaveLength(0);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("does not schedule a retry beyond the remaining Turn deadline", async () => {
|
|
407
|
+
let attempts = 0;
|
|
408
|
+
const provider: LlmProvider = {
|
|
409
|
+
id: "deadline-bound",
|
|
410
|
+
async *stream() {
|
|
411
|
+
attempts += 1;
|
|
412
|
+
throw new ModelProviderFailureError({
|
|
413
|
+
classification: "transient",
|
|
414
|
+
reason: "service unavailable",
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
const root = await mount(provider, {
|
|
419
|
+
turnDeadlineMs: 400,
|
|
420
|
+
retry: { now: () => 0, random: () => 1 },
|
|
421
|
+
});
|
|
422
|
+
const handle = await root.agents.create({
|
|
423
|
+
botId: "bounded-bot",
|
|
424
|
+
sessionId: "bounded",
|
|
425
|
+
provider: provider.id,
|
|
426
|
+
model: "test-model",
|
|
427
|
+
admitEffect: allowEffect,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
handle.agent.send("Try the provider.");
|
|
431
|
+
await handle.agent.whenIdle();
|
|
432
|
+
|
|
433
|
+
expect(attempts).toBe(1);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
309
437
|
// Named so a reader who greps for the reason string finds where it is set.
|
|
310
438
|
test("the deadline reason tells the person what to do about it", () => {
|
|
311
439
|
expect(TURN_DEADLINE_REASON_V1).toContain("Try sending it again");
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type LlmStreamEvent,
|
|
16
16
|
type LoopStepContinuationV1,
|
|
17
17
|
type NormalizedModelRequest,
|
|
18
|
+
ModelProviderFailureError,
|
|
18
19
|
type Session,
|
|
19
20
|
type SessionEvent,
|
|
20
21
|
type StepOutcome,
|
|
@@ -29,6 +30,13 @@ import {
|
|
|
29
30
|
validateToolOccurrenceJournal,
|
|
30
31
|
} from "@frockbot/kernel-contracts";
|
|
31
32
|
import { type Context, Service } from "cordis";
|
|
33
|
+
import {
|
|
34
|
+
defaultModelRetrySleepV1,
|
|
35
|
+
type ModelRetryPolicyRuntimeV1,
|
|
36
|
+
nextModelRetryV1,
|
|
37
|
+
} from "./retry-policy.js";
|
|
38
|
+
|
|
39
|
+
export * from "./retry-policy.js";
|
|
32
40
|
|
|
33
41
|
declare module "cordis" {
|
|
34
42
|
interface Events {
|
|
@@ -47,6 +55,8 @@ export interface AgentLoopConfig {
|
|
|
47
55
|
* {@link TURN_DEADLINE_MS_V1}; named by a caller only to test it.
|
|
48
56
|
*/
|
|
49
57
|
turnDeadlineMs?: number;
|
|
58
|
+
/** Deterministic retry seams; production uses wall time, Math.random and timers. */
|
|
59
|
+
retry?: Partial<ModelRetryPolicyRuntimeV1>;
|
|
50
60
|
/** The Composition generation this mounted root was pinned to at admission. */
|
|
51
61
|
composition: CompositionPinV1;
|
|
52
62
|
}
|
|
@@ -120,13 +130,7 @@ class StepLimitReachedError extends Error {
|
|
|
120
130
|
*/
|
|
121
131
|
export { TURN_DEADLINE_MS_V1 };
|
|
122
132
|
|
|
123
|
-
/**
|
|
124
|
-
* How many times one step will send its model request.
|
|
125
|
-
*
|
|
126
|
-
* Two: the first attempt and one retry. It applies only to a failure the
|
|
127
|
-
* provider classified as never having started, so no retry can duplicate a
|
|
128
|
-
* call that may already have run.
|
|
129
|
-
*/
|
|
133
|
+
/** Legacy ceiling for unknown failures: the first attempt plus one retry. */
|
|
130
134
|
export const MODEL_REQUEST_ATTEMPTS_V1 = 2;
|
|
131
135
|
|
|
132
136
|
/** What a `turn/end` records when the Turn ran out of wall clock. */
|
|
@@ -213,6 +217,8 @@ class LoopAgent implements Agent {
|
|
|
213
217
|
#turnDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
214
218
|
#turnDeadlineReached = false;
|
|
215
219
|
#turnDeadlineMs: number;
|
|
220
|
+
#turnDeadlineAt = 0;
|
|
221
|
+
#retry: ModelRetryPolicyRuntimeV1;
|
|
216
222
|
|
|
217
223
|
constructor(
|
|
218
224
|
ctx: Context,
|
|
@@ -221,6 +227,7 @@ class LoopAgent implements Agent {
|
|
|
221
227
|
maxSteps: number,
|
|
222
228
|
composition: CompositionPinV1,
|
|
223
229
|
turnDeadlineMs: number,
|
|
230
|
+
retry: ModelRetryPolicyRuntimeV1,
|
|
224
231
|
) {
|
|
225
232
|
this.#ctx = ctx;
|
|
226
233
|
this.#composition = composition;
|
|
@@ -235,6 +242,7 @@ class LoopAgent implements Agent {
|
|
|
235
242
|
this.#subagentRole = options.subagentRole;
|
|
236
243
|
this.#maxSteps = maxSteps;
|
|
237
244
|
this.#turnDeadlineMs = turnDeadlineMs;
|
|
245
|
+
this.#retry = retry;
|
|
238
246
|
}
|
|
239
247
|
|
|
240
248
|
get status(): AgentStatus {
|
|
@@ -305,6 +313,7 @@ class LoopAgent implements Agent {
|
|
|
305
313
|
#armTurnDeadline(): void {
|
|
306
314
|
this.#disarmTurnDeadline();
|
|
307
315
|
this.#turnDeadlineReached = false;
|
|
316
|
+
this.#turnDeadlineAt = this.#retry.now() + this.#turnDeadlineMs;
|
|
308
317
|
this.#turnDeadlineTimer = setTimeout(() => {
|
|
309
318
|
this.#turnDeadlineReached = true;
|
|
310
319
|
this.#controller?.abort(new Error(TURN_DEADLINE_REASON_V1));
|
|
@@ -1060,7 +1069,7 @@ class LoopAgent implements Agent {
|
|
|
1060
1069
|
reason,
|
|
1061
1070
|
);
|
|
1062
1071
|
}
|
|
1063
|
-
if (!(error instanceof
|
|
1072
|
+
if (!(error instanceof ModelProviderFailureError)) {
|
|
1064
1073
|
const reason = `Model response outcome is uncertain: ${modelFailureMessage(error)}`;
|
|
1065
1074
|
this.session.append({
|
|
1066
1075
|
type: "model/reconciliation-required",
|
|
@@ -1084,10 +1093,15 @@ class LoopAgent implements Agent {
|
|
|
1084
1093
|
});
|
|
1085
1094
|
await this.session.flush();
|
|
1086
1095
|
await this.#notifyModelOutcome(request.requestId, "not-started");
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1096
|
+
const retry = nextModelRetryV1({
|
|
1097
|
+
failure: error,
|
|
1098
|
+
attempt: attempts,
|
|
1099
|
+
deadlineAt: this.#turnDeadlineAt,
|
|
1100
|
+
runtime: this.#retry,
|
|
1101
|
+
});
|
|
1102
|
+
// A Package can refuse a planned retry, or replace a permanent failure
|
|
1103
|
+
// with a provider-owned fallback. It cannot turn a permanent failure
|
|
1104
|
+
// into another attempt against the same model.
|
|
1091
1105
|
const action = await this.#ctx.waterfall(
|
|
1092
1106
|
"agent/request-error",
|
|
1093
1107
|
this,
|
|
@@ -1095,13 +1109,25 @@ class LoopAgent implements Agent {
|
|
|
1095
1109
|
signal,
|
|
1096
1110
|
() =>
|
|
1097
1111
|
Promise.resolve(
|
|
1098
|
-
|
|
1112
|
+
retry
|
|
1099
1113
|
? ({ kind: "retry" } as const)
|
|
1100
1114
|
: ({ kind: "fail" } as const),
|
|
1101
1115
|
),
|
|
1102
1116
|
);
|
|
1103
|
-
if (action.kind
|
|
1117
|
+
if (action.kind === "fail") throw error;
|
|
1118
|
+
if (action.kind === "retry" && !retry) throw error;
|
|
1119
|
+
const delayMs = action.kind === "fallback" ? 0 : retry!.delayMs;
|
|
1120
|
+
this.session.append({
|
|
1121
|
+
type: "model/retry",
|
|
1122
|
+
turn,
|
|
1123
|
+
step,
|
|
1124
|
+
attempt: attempts + 1,
|
|
1125
|
+
classification: error.classification,
|
|
1126
|
+
delayMs,
|
|
1127
|
+
});
|
|
1128
|
+
await this.session.flush();
|
|
1104
1129
|
this.#ctx.emit("agent/error", this, error);
|
|
1130
|
+
await this.#retry.sleep(delayMs, signal);
|
|
1105
1131
|
}
|
|
1106
1132
|
}
|
|
1107
1133
|
}
|
|
@@ -1131,9 +1157,10 @@ class LoopAgent implements Agent {
|
|
|
1131
1157
|
);
|
|
1132
1158
|
}
|
|
1133
1159
|
} catch (error) {
|
|
1134
|
-
if (receivedProviderEvent && error instanceof
|
|
1160
|
+
if (receivedProviderEvent && error instanceof ModelProviderFailureError) {
|
|
1135
1161
|
throw new Error(
|
|
1136
|
-
|
|
1162
|
+
error.message ||
|
|
1163
|
+
"Model provider reported a retryable failure after returning response data",
|
|
1137
1164
|
);
|
|
1138
1165
|
}
|
|
1139
1166
|
throw error;
|
|
@@ -1495,6 +1522,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|
|
1495
1522
|
private maxSteps: number;
|
|
1496
1523
|
private turnDeadlineMs: number;
|
|
1497
1524
|
private composition: CompositionPinV1;
|
|
1525
|
+
private retry: ModelRetryPolicyRuntimeV1;
|
|
1498
1526
|
private handles = new Set<AgentHandle>();
|
|
1499
1527
|
|
|
1500
1528
|
constructor(ctx: Context, config: AgentLoopConfig) {
|
|
@@ -1502,6 +1530,11 @@ export class AgentLoop extends Service implements AgentFactory {
|
|
|
1502
1530
|
this.composition = config.composition;
|
|
1503
1531
|
this.maxSteps = config.maxSteps ?? 20;
|
|
1504
1532
|
this.turnDeadlineMs = config.turnDeadlineMs ?? TURN_DEADLINE_MS_V1;
|
|
1533
|
+
this.retry = {
|
|
1534
|
+
now: config.retry?.now ?? Date.now,
|
|
1535
|
+
random: config.retry?.random ?? Math.random,
|
|
1536
|
+
sleep: config.retry?.sleep ?? defaultModelRetrySleepV1,
|
|
1537
|
+
};
|
|
1505
1538
|
if (!Number.isInteger(this.maxSteps) || this.maxSteps <= 0) {
|
|
1506
1539
|
throw new Error("agent-loop maxSteps must be a positive integer");
|
|
1507
1540
|
}
|
|
@@ -1519,6 +1552,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|
|
1519
1552
|
this.maxSteps,
|
|
1520
1553
|
this.composition,
|
|
1521
1554
|
this.turnDeadlineMs,
|
|
1555
|
+
this.retry,
|
|
1522
1556
|
);
|
|
1523
1557
|
const unregister = this.ctx.agents.register(agent);
|
|
1524
1558
|
let disposed = false;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ModelProviderFailureError } from "@frockbot/kernel-contracts";
|
|
3
|
+
import {
|
|
4
|
+
MODEL_RETRY_BACKOFF_CAP_MS_V1,
|
|
5
|
+
modelRetryDelayV1,
|
|
6
|
+
nextModelRetryV1,
|
|
7
|
+
} from "./retry-policy.js";
|
|
8
|
+
|
|
9
|
+
describe("model retry backoff", () => {
|
|
10
|
+
test("uses deterministic equal-jitter exponential delays up to the cap", () => {
|
|
11
|
+
expect(
|
|
12
|
+
[1, 2, 3, 4, 5, 6].map((retry) =>
|
|
13
|
+
modelRetryDelayV1({ retry, random: 1 }),
|
|
14
|
+
),
|
|
15
|
+
).toEqual([500, 1_000, 2_000, 4_000, 8_000, 8_000]);
|
|
16
|
+
expect(modelRetryDelayV1({ retry: 1, random: 0 })).toBe(250);
|
|
17
|
+
expect(MODEL_RETRY_BACKOFF_CAP_MS_V1).toBe(8_000);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("honours Retry-After even when it is longer than the backoff cap", () => {
|
|
21
|
+
expect(
|
|
22
|
+
modelRetryDelayV1({ retry: 6, random: 0, retryAfterMs: 12_000 }),
|
|
23
|
+
).toBe(12_000);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("allows unknown once, permanent never, and transient while time remains", () => {
|
|
27
|
+
let now = 1_000;
|
|
28
|
+
const runtime = { now: () => now, random: () => 1 };
|
|
29
|
+
const failure = (classification: "transient" | "permanent" | "unknown") =>
|
|
30
|
+
new ModelProviderFailureError({ classification, reason: "test" });
|
|
31
|
+
|
|
32
|
+
expect(
|
|
33
|
+
nextModelRetryV1({
|
|
34
|
+
failure: failure("unknown"),
|
|
35
|
+
attempt: 1,
|
|
36
|
+
deadlineAt: 2_000,
|
|
37
|
+
runtime,
|
|
38
|
+
}),
|
|
39
|
+
).toEqual({ attempt: 2, delayMs: 500 });
|
|
40
|
+
expect(
|
|
41
|
+
nextModelRetryV1({
|
|
42
|
+
failure: failure("unknown"),
|
|
43
|
+
attempt: 2,
|
|
44
|
+
deadlineAt: 10_000,
|
|
45
|
+
runtime,
|
|
46
|
+
}),
|
|
47
|
+
).toBeUndefined();
|
|
48
|
+
expect(
|
|
49
|
+
nextModelRetryV1({
|
|
50
|
+
failure: failure("permanent"),
|
|
51
|
+
attempt: 1,
|
|
52
|
+
deadlineAt: 10_000,
|
|
53
|
+
runtime,
|
|
54
|
+
}),
|
|
55
|
+
).toBeUndefined();
|
|
56
|
+
|
|
57
|
+
now = 1_600;
|
|
58
|
+
expect(
|
|
59
|
+
nextModelRetryV1({
|
|
60
|
+
failure: failure("transient"),
|
|
61
|
+
attempt: 1,
|
|
62
|
+
deadlineAt: 2_000,
|
|
63
|
+
runtime,
|
|
64
|
+
}),
|
|
65
|
+
).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ModelProviderFailureClassV1,
|
|
3
|
+
ModelProviderFailureError,
|
|
4
|
+
} from "@frockbot/kernel-contracts";
|
|
5
|
+
|
|
6
|
+
export const MODEL_RETRY_BACKOFF_BASE_MS_V1 = 500;
|
|
7
|
+
export const MODEL_RETRY_BACKOFF_CAP_MS_V1 = 8_000;
|
|
8
|
+
|
|
9
|
+
export interface ModelRetryPolicyRuntimeV1 {
|
|
10
|
+
now(): number;
|
|
11
|
+
random(): number;
|
|
12
|
+
sleep(milliseconds: number, signal: AbortSignal): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function defaultModelRetrySleepV1(
|
|
16
|
+
milliseconds: number,
|
|
17
|
+
signal: AbortSignal,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
if (milliseconds === 0) return Promise.resolve();
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const timer = setTimeout(done, milliseconds);
|
|
22
|
+
function done(): void {
|
|
23
|
+
signal.removeEventListener("abort", aborted);
|
|
24
|
+
resolve();
|
|
25
|
+
}
|
|
26
|
+
function aborted(): void {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
reject(signal.reason);
|
|
29
|
+
}
|
|
30
|
+
if (signal.aborted) aborted();
|
|
31
|
+
else signal.addEventListener("abort", aborted, { once: true });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Equal-jitter exponential delay; Retry-After is a floor, not silently capped. */
|
|
36
|
+
export function modelRetryDelayV1(input: {
|
|
37
|
+
retry: number;
|
|
38
|
+
random: number;
|
|
39
|
+
retryAfterMs?: number;
|
|
40
|
+
}): number {
|
|
41
|
+
const ceiling = Math.min(
|
|
42
|
+
MODEL_RETRY_BACKOFF_CAP_MS_V1,
|
|
43
|
+
MODEL_RETRY_BACKOFF_BASE_MS_V1 * 2 ** Math.max(0, input.retry - 1),
|
|
44
|
+
);
|
|
45
|
+
const boundedRandom = Math.min(1, Math.max(0, input.random));
|
|
46
|
+
const jittered = Math.round(ceiling / 2 + (ceiling / 2) * boundedRandom);
|
|
47
|
+
return Math.max(jittered, input.retryAfterMs ?? 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function modelFailureMayRetryV1(input: {
|
|
51
|
+
classification: ModelProviderFailureClassV1;
|
|
52
|
+
attempt: number;
|
|
53
|
+
}): boolean {
|
|
54
|
+
if (input.classification === "transient") return true;
|
|
55
|
+
return input.classification === "unknown" && input.attempt === 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Undefined means the Turn has too little wall clock left for another try. */
|
|
59
|
+
export function nextModelRetryV1(input: {
|
|
60
|
+
failure: ModelProviderFailureError;
|
|
61
|
+
attempt: number;
|
|
62
|
+
deadlineAt: number;
|
|
63
|
+
runtime: Pick<ModelRetryPolicyRuntimeV1, "now" | "random">;
|
|
64
|
+
}): { attempt: number; delayMs: number } | undefined {
|
|
65
|
+
if (
|
|
66
|
+
!modelFailureMayRetryV1({
|
|
67
|
+
classification: input.failure.classification,
|
|
68
|
+
attempt: input.attempt,
|
|
69
|
+
})
|
|
70
|
+
) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const delayMs = modelRetryDelayV1({
|
|
74
|
+
retry: input.attempt,
|
|
75
|
+
random: input.runtime.random(),
|
|
76
|
+
...(input.failure.retryAfterMs === undefined
|
|
77
|
+
? {}
|
|
78
|
+
: { retryAfterMs: input.failure.retryAfterMs }),
|
|
79
|
+
});
|
|
80
|
+
return delayMs < input.deadlineAt - input.runtime.now()
|
|
81
|
+
? { attempt: input.attempt + 1, delayMs }
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|