@frockbot/kernel-agent-loop 0.3.4 → 0.3.6

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.4",
3
+ "version": "0.3.6",
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.4",
15
+ "@frockbot/kernel-contracts": "0.3.6",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
19
- "@frockbot/plugin-models": "0.3.4",
20
- "@frockbot/plugin-prompt": "0.3.4",
21
- "@frockbot/plugin-tools": "0.3.4",
19
+ "@frockbot/plugin-models": "0.3.6",
20
+ "@frockbot/plugin-prompt": "0.3.6",
21
+ "@frockbot/plugin-tools": "0.3.6",
22
22
  "@types/bun": "1.4.0",
23
23
  "@types/node": "26.2.0",
24
24
  "typescript": "^7.0.2"
@@ -0,0 +1,246 @@
1
+ // Two Turns that never ended: one hung for seventeen minutes with nothing on
2
+ // screen because nothing bounded a Turn's wall clock, and a provider that
3
+ // rejected a request before it started took the whole Turn down rather than
4
+ // being tried once more.
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import {
7
+ LlmEffectNotStartedError,
8
+ type LlmProvider,
9
+ SessionStore,
10
+ } from "@frockbot/kernel-contracts";
11
+ import { LlmRegistry } from "@frockbot/plugin-models";
12
+ import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
13
+ import { ToolRegistry } from "@frockbot/plugin-tools";
14
+ import { AgentRegistry } from "./agent.js";
15
+ import { Context, type Plugin } from "cordis";
16
+ import { AgentLoop, TURN_DEADLINE_REASON_V1 } from "./index.js";
17
+
18
+ const roots: Context[] = [];
19
+ const allowEffect = () => Promise.resolve(true);
20
+
21
+ afterEach(async () => {
22
+ await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
23
+ });
24
+
25
+ async function mount(
26
+ provider: LlmProvider,
27
+ config: { turnDeadlineMs?: number } = {},
28
+ ): Promise<Context> {
29
+ const root = new Context();
30
+ roots.push(root);
31
+ await root.plugin(SessionStore, {});
32
+ await root.plugin(SystemPromptRegistry);
33
+ await root.plugin(LlmRegistry);
34
+ await root.plugin(ToolRegistry);
35
+ await root.plugin(AgentRegistry);
36
+ const promptPlugin: Plugin.Function = (ctx) =>
37
+ ctx.systemPrompt.register({ id: "identity", render: () => "Be useful." });
38
+ promptPlugin.inject = ["systemPrompt"];
39
+ const providerPlugin: Plugin.Function = (ctx) => ctx.llm.register(provider);
40
+ providerPlugin.inject = ["llm"];
41
+ await root.plugin(promptPlugin);
42
+ await root.plugin(providerPlugin);
43
+ await root.plugin(AgentLoop, {
44
+ maxSteps: 4,
45
+ composition: {
46
+ generationId: "generation-1",
47
+ artifactSetHash: "a".repeat(64),
48
+ },
49
+ ...config,
50
+ });
51
+ return root;
52
+ }
53
+
54
+ describe("a Turn that runs out of wall clock", () => {
55
+ test("ends as interrupted, saying why, instead of hanging", async () => {
56
+ const provider: LlmProvider = {
57
+ id: "silent",
58
+ // A provider that accepted the request and will never answer. This is
59
+ // the seventeen-minute Turn, reproduced.
60
+ // eslint-disable-next-line require-yield
61
+ async *stream(_request, signal) {
62
+ await new Promise((_resolve, reject) => {
63
+ signal.addEventListener("abort", () => reject(signal.reason), {
64
+ once: true,
65
+ });
66
+ });
67
+ },
68
+ };
69
+ const root = await mount(provider, { turnDeadlineMs: 25 });
70
+ const handle = await root.agents.create({
71
+ botId: "deadline-bot",
72
+ sessionId: "deadline",
73
+ provider: "silent",
74
+ model: "test-model",
75
+ admitEffect: allowEffect,
76
+ });
77
+
78
+ handle.agent.send("Take your time.");
79
+ await handle.agent.whenIdle();
80
+
81
+ // The model effect is unsettled, so the Turn is owed a reconciliation
82
+ // rather than a clean end — but it *ended*, which is the whole point, and
83
+ // ADR 0028 settles it from there.
84
+ const journal = handle.agent.session.events;
85
+ expect(
86
+ journal.some((event) => event.type === "model/reconciliation-required"),
87
+ ).toBe(true);
88
+ expect(handle.agent.status).toBe("idle");
89
+ });
90
+
91
+ test("is reported as the deadline, never as a Stop the person did not press", async () => {
92
+ const provider: LlmProvider = {
93
+ id: "never-reached",
94
+ // eslint-disable-next-line require-yield
95
+ async *stream() {
96
+ throw new Error("the Turn should never have got this far");
97
+ },
98
+ };
99
+ const root = await mount(provider, { turnDeadlineMs: 25 });
100
+ // Stalling before the first model request leaves no uncertain effect, so
101
+ // the Turn settles on its own terms and the reason it carries is the one
102
+ // under test. The deadline aborts the same controller Stop does; the
103
+ // person must not be told they stopped it.
104
+ let stalled: (() => void) | undefined;
105
+ root.on("agent/pre-step", async (_agent, _inputs, _turn, _step, next) => {
106
+ await new Promise<void>((_resolve, reject) => {
107
+ stalled = () => reject(new Error("stalled"));
108
+ });
109
+ return next();
110
+ });
111
+ // The deadline aborts the loop's controller, which the stalled hook does
112
+ // not itself watch; releasing it here stands in for whatever slow thing a
113
+ // real Turn was waiting on noticing that nobody is waiting any more.
114
+ setTimeout(() => stalled?.(), 60);
115
+ const handle = await root.agents.create({
116
+ botId: "deadline-reason-bot",
117
+ sessionId: "deadline-reason",
118
+ provider: "never-reached",
119
+ model: "test-model",
120
+ admitEffect: allowEffect,
121
+ });
122
+
123
+ handle.agent.send("Stall before the model.");
124
+ await handle.agent.whenIdle();
125
+
126
+ const end = handle.agent.session.events.findLast(
127
+ (event) => event.type === "turn/end",
128
+ );
129
+ if (end?.type !== "turn/end") throw new Error("the Turn never ended");
130
+ expect(end.outcome).toBe("interrupted");
131
+ expect(end.reason).toBe(TURN_DEADLINE_REASON_V1);
132
+ });
133
+ });
134
+
135
+ describe("a model request the provider says never started", () => {
136
+ test("is tried once more, and the Turn succeeds on the retry", async () => {
137
+ let attempts = 0;
138
+ const provider: LlmProvider = {
139
+ id: "flaky-binding",
140
+ async *stream() {
141
+ attempts += 1;
142
+ if (attempts === 1) {
143
+ throw new LlmEffectNotStartedError(
144
+ "model binding was not resolvable",
145
+ );
146
+ }
147
+ yield { type: "text-delta", text: "Second time lucky." } as const;
148
+ yield { type: "finish", reason: "completed" } as const;
149
+ },
150
+ };
151
+ const root = await mount(provider);
152
+ const handle = await root.agents.create({
153
+ botId: "retry-bot",
154
+ sessionId: "retry",
155
+ provider: "flaky-binding",
156
+ model: "test-model",
157
+ admitEffect: allowEffect,
158
+ });
159
+
160
+ handle.agent.send("Say hello.");
161
+ await handle.agent.whenIdle();
162
+
163
+ expect(attempts).toBe(2);
164
+ expect(handle.agent.session.events.at(-1)).toMatchObject({
165
+ type: "turn/end",
166
+ outcome: "completed",
167
+ });
168
+ // The retry is not a hidden event type: the durable log already shows the
169
+ // attempt that did not start and the one that replaced it.
170
+ expect(
171
+ handle.agent.session.events.filter(
172
+ (event) => event.type === "model/request",
173
+ ),
174
+ ).toHaveLength(2);
175
+ expect(
176
+ handle.agent.session.events.filter(
177
+ (event) => event.type === "model/effect-not-started",
178
+ ),
179
+ ).toHaveLength(1);
180
+ });
181
+
182
+ test("is not retried a second time", async () => {
183
+ let attempts = 0;
184
+ const provider: LlmProvider = {
185
+ id: "always-rejects",
186
+ async *stream() {
187
+ attempts += 1;
188
+ throw new LlmEffectNotStartedError("invalid api key");
189
+ },
190
+ };
191
+ const root = await mount(provider);
192
+ const handle = await root.agents.create({
193
+ botId: "no-retry-bot",
194
+ sessionId: "no-retry",
195
+ provider: "always-rejects",
196
+ model: "test-model",
197
+ admitEffect: allowEffect,
198
+ });
199
+
200
+ handle.agent.send("Say hello.");
201
+ await handle.agent.whenIdle();
202
+
203
+ expect(attempts).toBe(2);
204
+ expect(handle.agent.session.events.at(-1)).toMatchObject({
205
+ type: "turn/end",
206
+ outcome: "model-error",
207
+ reason: "invalid api key",
208
+ });
209
+ });
210
+
211
+ test("an uncertain failure is never retried", async () => {
212
+ let attempts = 0;
213
+ const provider: LlmProvider = {
214
+ id: "uncertain",
215
+ async *stream() {
216
+ attempts += 1;
217
+ // Not classified as unstarted: the call may well have run, so trying
218
+ // again would be a silent duplicate.
219
+ throw new Error("connection reset mid-stream");
220
+ },
221
+ };
222
+ const root = await mount(provider);
223
+ const handle = await root.agents.create({
224
+ botId: "uncertain-bot",
225
+ sessionId: "uncertain",
226
+ provider: "uncertain",
227
+ model: "test-model",
228
+ admitEffect: allowEffect,
229
+ });
230
+
231
+ handle.agent.send("Say hello.");
232
+ await handle.agent.whenIdle();
233
+
234
+ expect(attempts).toBe(1);
235
+ expect(
236
+ handle.agent.session.events.some(
237
+ (event) => event.type === "model/reconciliation-required",
238
+ ),
239
+ ).toBe(true);
240
+ });
241
+ });
242
+
243
+ // Named so a reader who greps for the reason string finds where it is set.
244
+ test("the deadline reason tells the person what to do about it", () => {
245
+ expect(TURN_DEADLINE_REASON_V1).toContain("Try sending it again");
246
+ });
package/src/index.test.ts CHANGED
@@ -2970,4 +2970,93 @@ describe("AgentLoop", () => {
2970
2970
  expect(end).toMatchObject({ type: "turn/end", outcome: "completed" });
2971
2971
  expect(end && Object.hasOwn(end, "reason")).toBe(false);
2972
2972
  });
2973
+
2974
+ test("reaching the step limit is reported as stopping, not as a model error", async () => {
2975
+ const provider: LlmProvider = {
2976
+ id: "never-stops",
2977
+ async *stream() {
2978
+ yield {
2979
+ type: "tool-call",
2980
+ call: {
2981
+ id: `call-${crypto.randomUUID()}`,
2982
+ name: "loop_tool",
2983
+ input: {},
2984
+ },
2985
+ };
2986
+ yield { type: "finish", reason: "tool-calls" };
2987
+ },
2988
+ };
2989
+ const errors: unknown[] = [];
2990
+ const root = await mountRuntime(provider, {
2991
+ name: "loop_tool",
2992
+ description: "Never ends the Turn.",
2993
+ inputSchema: { type: "object" },
2994
+ execute: () => Promise.resolve({ content: "again", isError: false }),
2995
+ });
2996
+ root.on("agent/error", (_agent, error) => {
2997
+ errors.push(error);
2998
+ });
2999
+ const handle = await root.agents.create({
3000
+ ...allowEffectOptions,
3001
+ botId: "step-limit-bot",
3002
+ sessionId: "step-limit",
3003
+ provider: "never-stops",
3004
+ model: "test-model",
3005
+ });
3006
+
3007
+ handle.agent.send("keep going");
3008
+ await handle.agent.whenIdle();
3009
+
3010
+ expect(handle.agent.session.events.at(-1)).toMatchObject({
3011
+ type: "turn/end",
3012
+ outcome: "interrupted",
3013
+ reason: "stopped after 4 steps",
3014
+ });
3015
+ // Nothing about the model failed, so nothing is reported as if it had.
3016
+ expect(errors).toEqual([]);
3017
+ });
3018
+
3019
+ test("a Turn whose first flush fails ends once instead of spinning", async () => {
3020
+ let streams = 0;
3021
+ const provider: LlmProvider = {
3022
+ id: "persist-fails",
3023
+ async *stream() {
3024
+ streams += 1;
3025
+ yield { type: "finish", reason: "completed" };
3026
+ },
3027
+ };
3028
+ let writes = 0;
3029
+ const root = await mountRuntime(
3030
+ provider,
3031
+ undefined,
3032
+ // Storage that is simply gone: every durable write rejects.
3033
+ () => {
3034
+ writes += 1;
3035
+ return Promise.reject(new Error("durable storage is unavailable"));
3036
+ },
3037
+ );
3038
+ const handle = await root.agents.create({
3039
+ ...allowEffectOptions,
3040
+ botId: "persist-bot",
3041
+ sessionId: "persist-fails",
3042
+ provider: "persist-fails",
3043
+ model: "test-model",
3044
+ });
3045
+
3046
+ handle.agent.send("say something");
3047
+ // The failure reaches the caller, exactly once: the input was claimed
3048
+ // before the flush, so nothing hands it back to be started again.
3049
+ await expect(handle.agent.whenIdle()).rejects.toThrow(
3050
+ "durable storage is unavailable",
3051
+ );
3052
+ const attempts = writes;
3053
+ await new Promise((resolve) => setTimeout(resolve, 5));
3054
+ expect(writes).toBe(attempts);
3055
+ expect(streams).toBe(0);
3056
+ expect(
3057
+ handle.agent.session.events.filter(
3058
+ (event) => event.type === "turn/start",
3059
+ ),
3060
+ ).toHaveLength(1);
3061
+ });
2973
3062
  });
package/src/index.ts CHANGED
@@ -41,6 +41,11 @@ declare module "cordis" {
41
41
 
42
42
  export interface AgentLoopConfig {
43
43
  maxSteps?: number;
44
+ /**
45
+ * The wall clock one Turn is allowed, in milliseconds. Defaults to
46
+ * {@link TURN_DEADLINE_MS_V1}; named by a caller only to test it.
47
+ */
48
+ turnDeadlineMs?: number;
44
49
  /** The Composition generation this mounted root was pinned to at admission. */
45
50
  composition: CompositionPinV1;
46
51
  }
@@ -88,6 +93,44 @@ class ToolEffectReconciliationRequiredError extends Error {
88
93
  }
89
94
  }
90
95
 
96
+ /**
97
+ * The Turn used every step it was allowed.
98
+ *
99
+ * Not a model error: nothing failed, and everything the Turn did in those
100
+ * steps is durable. It is reported as what it is — a Turn that stopped after
101
+ * so many steps — so the person is told the Bot ran out of room rather than
102
+ * that their model broke.
103
+ */
104
+ class StepLimitReachedError extends Error {
105
+ constructor(readonly steps: number) {
106
+ super(`stopped after ${steps} steps`);
107
+ this.name = "StepLimitReachedError";
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The longest a single Turn may run before the loop stops waiting for it.
113
+ *
114
+ * Nothing bounded a Turn's wall clock before this: one hung for seventeen
115
+ * minutes with an animated avatar and nothing else, and would have hung until
116
+ * the isolate died. Fifteen minutes is well past any Turn a person is watching
117
+ * and well inside the point at which they have concluded the product is broken.
118
+ */
119
+ export const TURN_DEADLINE_MS_V1 = 15 * 60 * 1000;
120
+
121
+ /**
122
+ * How many times one step will send its model request.
123
+ *
124
+ * Two: the first attempt and one retry. It applies only to a failure the
125
+ * provider classified as never having started, so no retry can duplicate a
126
+ * call that may already have run.
127
+ */
128
+ export const MODEL_REQUEST_ATTEMPTS_V1 = 2;
129
+
130
+ /** What a `turn/end` records when the Turn ran out of wall clock. */
131
+ export const TURN_DEADLINE_REASON_V1 =
132
+ "This Turn ran for 15 minutes without finishing and was stopped. Try sending it again.";
133
+
91
134
  /** Durable Stop won the final effect-admission transaction. */
92
135
  class EffectAdmissionFencedError extends Error {
93
136
  constructor(readonly effectId: string) {
@@ -158,6 +201,16 @@ class LoopAgent implements Agent {
158
201
  #cancelDetail: string | undefined;
159
202
  #disposeRequested = false;
160
203
  #resumeRequested = false;
204
+ /**
205
+ * The Turn's wall clock, rearmed for each Turn a wake runs.
206
+ *
207
+ * It aborts the same controller Stop uses, so nothing in the step loop has
208
+ * to learn about a second signal; the flag beside it is what tells the
209
+ * settlement that the abort was a deadline rather than a person.
210
+ */
211
+ #turnDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
212
+ #turnDeadlineReached = false;
213
+ #turnDeadlineMs: number;
161
214
 
162
215
  constructor(
163
216
  ctx: Context,
@@ -165,6 +218,7 @@ class LoopAgent implements Agent {
165
218
  options: EffectAdmittingAgentOptions,
166
219
  maxSteps: number,
167
220
  composition: CompositionPinV1,
221
+ turnDeadlineMs: number,
168
222
  ) {
169
223
  this.#ctx = ctx;
170
224
  this.#composition = composition;
@@ -178,6 +232,7 @@ class LoopAgent implements Agent {
178
232
  this.#turnType = options.turnType ?? "chat";
179
233
  this.#subagentRole = options.subagentRole;
180
234
  this.#maxSteps = maxSteps;
235
+ this.#turnDeadlineMs = turnDeadlineMs;
181
236
  }
182
237
 
183
238
  get status(): AgentStatus {
@@ -240,6 +295,27 @@ class LoopAgent implements Agent {
240
295
  this.#controller?.abort(new Error(`agent cancelled by ${reason}`));
241
296
  }
242
297
 
298
+ /**
299
+ * Start this Turn's clock. Any previous Turn's is cleared first, so a wake
300
+ * that runs three queued Turns gives each of them the full allowance rather
301
+ * than sharing one.
302
+ */
303
+ #armTurnDeadline(): void {
304
+ this.#disarmTurnDeadline();
305
+ this.#turnDeadlineReached = false;
306
+ this.#turnDeadlineTimer = setTimeout(() => {
307
+ this.#turnDeadlineReached = true;
308
+ this.#controller?.abort(new Error(TURN_DEADLINE_REASON_V1));
309
+ }, this.#turnDeadlineMs);
310
+ }
311
+
312
+ #disarmTurnDeadline(): void {
313
+ if (this.#turnDeadlineTimer !== undefined) {
314
+ clearTimeout(this.#turnDeadlineTimer);
315
+ this.#turnDeadlineTimer = undefined;
316
+ }
317
+ }
318
+
243
319
  async whenIdle(): Promise<void> {
244
320
  let activity: Promise<void>;
245
321
  do {
@@ -272,11 +348,23 @@ class LoopAgent implements Agent {
272
348
  }
273
349
  this.#controller = new AbortController();
274
350
  this.#setStatus("running");
275
- const activity = this.#drive(this.#controller.signal).finally(() => {
276
- this.#controller = undefined;
277
- if (!this.#disposeRequested) this.#setStatus("idle");
278
- if (!this.#disposeRequested && this.#inbox.length > 0) this.#wake();
279
- });
351
+ let failed = false;
352
+ const activity = this.#drive(this.#controller.signal)
353
+ .catch((error: unknown) => {
354
+ // A Turn that could not even journal its own start throws out of
355
+ // `#drive`. Re-waking on the inbox it left behind would append another
356
+ // `turn/start`, fail the same way, and spin — so the failure ends the
357
+ // waking and reaches whoever is awaiting this Turn.
358
+ failed = true;
359
+ throw error;
360
+ })
361
+ .finally(() => {
362
+ this.#controller = undefined;
363
+ if (!this.#disposeRequested) this.#setStatus("idle");
364
+ if (!this.#disposeRequested && !failed && this.#inbox.length > 0) {
365
+ this.#wake();
366
+ }
367
+ });
280
368
  this.#activity = activity;
281
369
  }
282
370
 
@@ -357,6 +445,7 @@ class LoopAgent implements Agent {
357
445
  let turnOutcome: StepOutcome = "interrupted";
358
446
  let turnReason: string | undefined;
359
447
  let reconciliationRequired = false;
448
+ this.#armTurnDeadline();
360
449
  try {
361
450
  if (latestAssistant) {
362
451
  await this.#notifyModelOutcome(latestAssistant.requestId, "completed");
@@ -580,7 +669,7 @@ class LoopAgent implements Agent {
580
669
  }
581
670
  }
582
671
  }
583
- throw new Error(`agent exceeded ${this.#maxSteps} steps`);
672
+ throw new StepLimitReachedError(this.#maxSteps);
584
673
  } catch (error) {
585
674
  if (
586
675
  error instanceof ModelEffectReconciliationRequiredError ||
@@ -590,18 +679,36 @@ class LoopAgent implements Agent {
590
679
  ) {
591
680
  reconciliationRequired = true;
592
681
  this.#ctx.emit("agent/error", this, error);
682
+ } else if (this.#turnDeadlineReached) {
683
+ // Ahead of the cancellation branch on purpose: the deadline aborts the
684
+ // same controller Stop does, and a Turn the clock ended must not be
685
+ // reported to the person as one they stopped.
686
+ turnOutcome = "interrupted";
687
+ turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
688
+ this.#ctx.emit("agent/error", this, error);
593
689
  } else if (
594
690
  error instanceof EffectAdmissionFencedError ||
595
691
  signal.aborted
596
692
  ) {
597
693
  turnOutcome = "cancelled";
598
694
  turnReason = this.#cancelDetail;
695
+ } else if (error instanceof StepLimitReachedError) {
696
+ // The Turn ran out of room, which is not a failure of the model.
697
+ turnOutcome = "interrupted";
698
+ turnReason = turnEndReason(error.message);
599
699
  } else {
600
700
  turnOutcome = "model-error";
601
701
  turnReason = turnEndReason(modelFailureMessage(error));
602
702
  this.#ctx.emit("agent/error", this, error);
603
703
  }
604
704
  } finally {
705
+ this.#disarmTurnDeadline();
706
+ // A Turn owed a reconciliation writes no `turn/end`: its model request
707
+ // has no durable outcome, and a `turn/end` would claim to know how it
708
+ // ended. That is right for as long as the run might still resume — and
709
+ // the moment it will not, the Turn is closed by whoever settles it, in
710
+ // `kernel-do`'s `settledEventsV1`. Closing it here instead would either
711
+ // lie about an outcome or make the run unresumable (ADR 0028).
605
712
  if (!reconciliationRequired) {
606
713
  if (openStep !== undefined && turnOutcome === "cancelled") {
607
714
  await this.#settleCancelledStep(openTurn, openStep);
@@ -643,14 +750,18 @@ class LoopAgent implements Agent {
643
750
  { type: "turn/admission", turn, turnType: this.#turnType },
644
751
  { type: "input/admitted", messageId: input.messageId, turn },
645
752
  ]);
646
- await this.session.flush();
753
+ // Claimed before the flush, not after: the input has been journaled as
754
+ // admitted, and leaving it in the inbox while the write settles meant a
755
+ // failed first flush handed it straight back to `#wake`.
647
756
  this.#inbox.shift();
757
+ await this.session.flush();
648
758
  this.#ctx.emit("agent/inbox/claimed", this, [input], turn);
649
759
 
650
760
  let openStep: number | undefined;
651
761
  let turnOutcome: StepOutcome = "interrupted";
652
762
  let turnReason: string | undefined;
653
763
  let reconciliationRequired = false;
764
+ this.#armTurnDeadline();
654
765
  try {
655
766
  let inputs = [input];
656
767
  for (let step = 1; step <= this.#maxSteps; step += 1) {
@@ -740,7 +851,7 @@ class LoopAgent implements Agent {
740
851
  }
741
852
  inputs = [];
742
853
  }
743
- throw new Error(`agent exceeded ${this.#maxSteps} steps`);
854
+ throw new StepLimitReachedError(this.#maxSteps);
744
855
  } catch (error) {
745
856
  if (
746
857
  error instanceof ModelEffectReconciliationRequiredError ||
@@ -750,18 +861,36 @@ class LoopAgent implements Agent {
750
861
  ) {
751
862
  reconciliationRequired = true;
752
863
  this.#ctx.emit("agent/error", this, error);
864
+ } else if (this.#turnDeadlineReached) {
865
+ // Ahead of the cancellation branch on purpose: the deadline aborts the
866
+ // same controller Stop does, and a Turn the clock ended must not be
867
+ // reported to the person as one they stopped.
868
+ turnOutcome = "interrupted";
869
+ turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
870
+ this.#ctx.emit("agent/error", this, error);
753
871
  } else if (
754
872
  error instanceof EffectAdmissionFencedError ||
755
873
  signal.aborted
756
874
  ) {
757
875
  turnOutcome = "cancelled";
758
876
  turnReason = this.#cancelDetail;
877
+ } else if (error instanceof StepLimitReachedError) {
878
+ // The Turn ran out of room, which is not a failure of the model.
879
+ turnOutcome = "interrupted";
880
+ turnReason = turnEndReason(error.message);
759
881
  } else {
760
882
  turnOutcome = "model-error";
761
883
  turnReason = turnEndReason(modelFailureMessage(error));
762
884
  this.#ctx.emit("agent/error", this, error);
763
885
  }
764
886
  } finally {
887
+ this.#disarmTurnDeadline();
888
+ // A Turn owed a reconciliation writes no `turn/end`: its model request
889
+ // has no durable outcome, and a `turn/end` would claim to know how it
890
+ // ended. That is right for as long as the run might still resume — and
891
+ // the moment it will not, the Turn is closed by whoever settles it, in
892
+ // `kernel-do`'s `settledEventsV1`. Closing it here instead would either
893
+ // lie about an outcome or make the run unresumable (ADR 0028).
765
894
  if (!reconciliationRequired) {
766
895
  if (openStep !== undefined && turnOutcome === "cancelled") {
767
896
  await this.#settleCancelledStep(turn, openStep);
@@ -819,7 +948,16 @@ class LoopAgent implements Agent {
819
948
  turnType: this.#turnType,
820
949
  });
821
950
 
951
+ // One automatic retry, and only for a failure the provider itself
952
+ // classified as "the request never started" — a rejected key, an
953
+ // unresolvable binding, a connection refused before any byte was sent.
954
+ // Those are exactly the failures where retrying cannot duplicate anything,
955
+ // and the ones a person watching a blank screen would retry by hand. Every
956
+ // other failure is uncertain and is never retried, which is the whole of
957
+ // ADR 0024's durability contract.
958
+ let attempts = 0;
822
959
  while (true) {
960
+ attempts += 1;
823
961
  const proposedMessages = this.session.deriveMessages();
824
962
  const messages = await this.#ctx.waterfall(
825
963
  "agent/message-window",
@@ -924,14 +1062,24 @@ class LoopAgent implements Agent {
924
1062
  });
925
1063
  await this.session.flush();
926
1064
  await this.#notifyModelOutcome(request.requestId, "not-started");
1065
+ // The durable evidence of a retry is the log itself: a `model/request`,
1066
+ // the `model/effect-not-started` just journaled against it, and then a
1067
+ // second `model/request`. A Package listening on `agent/request-error`
1068
+ // still has the final say in either direction.
927
1069
  const action = await this.#ctx.waterfall(
928
1070
  "agent/request-error",
929
1071
  this,
930
1072
  error,
931
1073
  signal,
932
- () => Promise.resolve({ kind: "fail" as const }),
1074
+ () =>
1075
+ Promise.resolve(
1076
+ attempts < MODEL_REQUEST_ATTEMPTS_V1
1077
+ ? ({ kind: "retry" } as const)
1078
+ : ({ kind: "fail" } as const),
1079
+ ),
933
1080
  );
934
1081
  if (action.kind !== "retry") throw error;
1082
+ this.#ctx.emit("agent/error", this, error);
935
1083
  }
936
1084
  }
937
1085
  }
@@ -1275,6 +1423,7 @@ class LoopAgent implements Agent {
1275
1423
  export class AgentLoop extends Service implements AgentFactory {
1276
1424
  static inject = ["sessions", "systemPrompt", "llm", "tools", "agents"];
1277
1425
  private maxSteps: number;
1426
+ private turnDeadlineMs: number;
1278
1427
  private composition: CompositionPinV1;
1279
1428
  private handles = new Set<AgentHandle>();
1280
1429
 
@@ -1282,6 +1431,7 @@ export class AgentLoop extends Service implements AgentFactory {
1282
1431
  super(ctx, "agentLoop");
1283
1432
  this.composition = config.composition;
1284
1433
  this.maxSteps = config.maxSteps ?? 20;
1434
+ this.turnDeadlineMs = config.turnDeadlineMs ?? TURN_DEADLINE_MS_V1;
1285
1435
  if (!Number.isInteger(this.maxSteps) || this.maxSteps <= 0) {
1286
1436
  throw new Error("agent-loop maxSteps must be a positive integer");
1287
1437
  }
@@ -1298,6 +1448,7 @@ export class AgentLoop extends Service implements AgentFactory {
1298
1448
  options as EffectAdmittingAgentOptions,
1299
1449
  this.maxSteps,
1300
1450
  this.composition,
1451
+ this.turnDeadlineMs,
1301
1452
  );
1302
1453
  const unregister = this.ctx.agents.register(agent);
1303
1454
  let disposed = false;