@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-agent-loop",
3
- "version": "0.3.14",
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.14",
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.14",
20
- "@frockbot/plugin-prompt": "0.3.14",
21
- "@frockbot/plugin-tools": "0.3.14",
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 = { kind: "retry" } | { kind: "fail" };
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;
@@ -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: { turnDeadlineMs?: number } = {},
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 LlmEffectNotStartedError)) {
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
- // The durable evidence of a retry is the log itself: a `model/request`,
1088
- // the `model/effect-not-started` just journaled against it, and then a
1089
- // second `model/request`. A Package listening on `agent/request-error`
1090
- // still has the final say in either direction.
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
- attempts < MODEL_REQUEST_ATTEMPTS_V1
1112
+ retry
1099
1113
  ? ({ kind: "retry" } as const)
1100
1114
  : ({ kind: "fail" } as const),
1101
1115
  ),
1102
1116
  );
1103
- if (action.kind !== "retry") throw error;
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 LlmEffectNotStartedError) {
1160
+ if (receivedProviderEvent && error instanceof ModelProviderFailureError) {
1135
1161
  throw new Error(
1136
- "Model provider reported no effect after returning response data",
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
+ }