@frockbot/kernel-agent-loop 0.3.5 → 0.3.7

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.5",
3
+ "version": "0.3.7",
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.5",
15
+ "@frockbot/kernel-contracts": "0.3.7",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
19
- "@frockbot/plugin-models": "0.3.5",
20
- "@frockbot/plugin-prompt": "0.3.5",
21
- "@frockbot/plugin-tools": "0.3.5",
19
+ "@frockbot/plugin-models": "0.3.7",
20
+ "@frockbot/plugin-prompt": "0.3.7",
21
+ "@frockbot/plugin-tools": "0.3.7",
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
@@ -1223,7 +1223,7 @@ describe("AgentLoop", () => {
1223
1223
  ).toBe(false);
1224
1224
  });
1225
1225
 
1226
- test("keeps an unretrievable provider effect open without repeating it", async () => {
1226
+ test("settles a turn whose provider cannot retrieve the effect", async () => {
1227
1227
  let streams = 0;
1228
1228
  const provider: LlmProvider = {
1229
1229
  id: "unretrievable",
@@ -1279,28 +1279,26 @@ describe("AgentLoop", () => {
1279
1279
  model: "model-1",
1280
1280
  });
1281
1281
 
1282
- handle.agent.resume();
1283
- await handle.agent.whenIdle();
1284
1282
  handle.agent.resume();
1285
1283
  await handle.agent.whenIdle();
1286
1284
 
1285
+ // A provider that declares no retrieval will never grow one, so the run
1286
+ // settles as a model error rather than parking on a reconciliation that
1287
+ // can never happen (and throwing out of the Durable Object).
1287
1288
  expect(streams).toBe(0);
1288
- expect(
1289
- handle.agent.session.events.filter(
1290
- (event) => event.type === "model/reconciliation-required",
1291
- ),
1292
- ).toEqual([
1293
- expect.objectContaining({
1294
- requestId: "unretrievable-effect-1",
1295
- reason:
1296
- 'LLM provider "unretrievable" does not support provider-bound retrieval',
1297
- }),
1298
- ]);
1299
1289
  expect(
1300
1290
  handle.agent.session.events.some(
1301
- (event) => event.type === "step/end" || event.type === "turn/end",
1291
+ (event) => event.type === "model/reconciliation-required",
1302
1292
  ),
1303
1293
  ).toBe(false);
1294
+ const turnEnd = handle.agent.session.events.findLast(
1295
+ (event) => event.type === "turn/end",
1296
+ );
1297
+ expect(turnEnd).toMatchObject({
1298
+ outcome: "model-error",
1299
+ reason:
1300
+ 'LLM provider "unretrievable" does not support provider-bound retrieval',
1301
+ });
1304
1302
  });
1305
1303
 
1306
1304
  test("keeps an ambiguous dispatched effect open for provider reconciliation", async () => {
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
  }
@@ -66,7 +71,8 @@ interface ModelResponse {
66
71
 
67
72
  type ModelReconciliation =
68
73
  | { status: "recovered"; response: ModelResponse }
69
- | { status: "unavailable"; reason: string };
74
+ | { status: "unavailable"; reason: string }
75
+ | { status: "not-retrievable"; reason: string };
70
76
 
71
77
  class ModelEffectReconciliationRequiredError extends Error {
72
78
  constructor(
@@ -103,6 +109,29 @@ class StepLimitReachedError extends Error {
103
109
  }
104
110
  }
105
111
 
112
+ /**
113
+ * The longest a single Turn may run before the loop stops waiting for it.
114
+ *
115
+ * Nothing bounded a Turn's wall clock before this: one hung for seventeen
116
+ * minutes with an animated avatar and nothing else, and would have hung until
117
+ * the isolate died. Fifteen minutes is well past any Turn a person is watching
118
+ * and well inside the point at which they have concluded the product is broken.
119
+ */
120
+ export const TURN_DEADLINE_MS_V1 = 15 * 60 * 1000;
121
+
122
+ /**
123
+ * How many times one step will send its model request.
124
+ *
125
+ * Two: the first attempt and one retry. It applies only to a failure the
126
+ * provider classified as never having started, so no retry can duplicate a
127
+ * call that may already have run.
128
+ */
129
+ export const MODEL_REQUEST_ATTEMPTS_V1 = 2;
130
+
131
+ /** What a `turn/end` records when the Turn ran out of wall clock. */
132
+ export const TURN_DEADLINE_REASON_V1 =
133
+ "This Turn ran for 15 minutes without finishing and was stopped. Try sending it again.";
134
+
106
135
  /** Durable Stop won the final effect-admission transaction. */
107
136
  class EffectAdmissionFencedError extends Error {
108
137
  constructor(readonly effectId: string) {
@@ -173,6 +202,16 @@ class LoopAgent implements Agent {
173
202
  #cancelDetail: string | undefined;
174
203
  #disposeRequested = false;
175
204
  #resumeRequested = false;
205
+ /**
206
+ * The Turn's wall clock, rearmed for each Turn a wake runs.
207
+ *
208
+ * It aborts the same controller Stop uses, so nothing in the step loop has
209
+ * to learn about a second signal; the flag beside it is what tells the
210
+ * settlement that the abort was a deadline rather than a person.
211
+ */
212
+ #turnDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
213
+ #turnDeadlineReached = false;
214
+ #turnDeadlineMs: number;
176
215
 
177
216
  constructor(
178
217
  ctx: Context,
@@ -180,6 +219,7 @@ class LoopAgent implements Agent {
180
219
  options: EffectAdmittingAgentOptions,
181
220
  maxSteps: number,
182
221
  composition: CompositionPinV1,
222
+ turnDeadlineMs: number,
183
223
  ) {
184
224
  this.#ctx = ctx;
185
225
  this.#composition = composition;
@@ -193,6 +233,7 @@ class LoopAgent implements Agent {
193
233
  this.#turnType = options.turnType ?? "chat";
194
234
  this.#subagentRole = options.subagentRole;
195
235
  this.#maxSteps = maxSteps;
236
+ this.#turnDeadlineMs = turnDeadlineMs;
196
237
  }
197
238
 
198
239
  get status(): AgentStatus {
@@ -255,6 +296,27 @@ class LoopAgent implements Agent {
255
296
  this.#controller?.abort(new Error(`agent cancelled by ${reason}`));
256
297
  }
257
298
 
299
+ /**
300
+ * Start this Turn's clock. Any previous Turn's is cleared first, so a wake
301
+ * that runs three queued Turns gives each of them the full allowance rather
302
+ * than sharing one.
303
+ */
304
+ #armTurnDeadline(): void {
305
+ this.#disarmTurnDeadline();
306
+ this.#turnDeadlineReached = false;
307
+ this.#turnDeadlineTimer = setTimeout(() => {
308
+ this.#turnDeadlineReached = true;
309
+ this.#controller?.abort(new Error(TURN_DEADLINE_REASON_V1));
310
+ }, this.#turnDeadlineMs);
311
+ }
312
+
313
+ #disarmTurnDeadline(): void {
314
+ if (this.#turnDeadlineTimer !== undefined) {
315
+ clearTimeout(this.#turnDeadlineTimer);
316
+ this.#turnDeadlineTimer = undefined;
317
+ }
318
+ }
319
+
258
320
  async whenIdle(): Promise<void> {
259
321
  let activity: Promise<void>;
260
322
  do {
@@ -384,6 +446,7 @@ class LoopAgent implements Agent {
384
446
  let turnOutcome: StepOutcome = "interrupted";
385
447
  let turnReason: string | undefined;
386
448
  let reconciliationRequired = false;
449
+ this.#armTurnDeadline();
387
450
  try {
388
451
  if (latestAssistant) {
389
452
  await this.#notifyModelOutcome(latestAssistant.requestId, "completed");
@@ -411,6 +474,19 @@ class LoopAgent implements Agent {
411
474
  latestStep,
412
475
  signal,
413
476
  );
477
+ if (reconciliation.status === "not-retrievable") {
478
+ // No later attempt can retrieve this effect, so the run settles now.
479
+ // The chunks already journaled stay in the session, so whatever the
480
+ // model produced before the interruption is still shown.
481
+ await this.#notifyModelOutcome(
482
+ unresolvedRequest.requestId,
483
+ "not-started",
484
+ );
485
+ turnOutcome = "model-error";
486
+ turnReason = turnEndReason(reconciliation.reason);
487
+ this.#ctx.emit("agent/error", this, new Error(reconciliation.reason));
488
+ return;
489
+ }
414
490
  if (reconciliation.status === "unavailable") {
415
491
  const existing = this.session.events.findLast(
416
492
  (event) =>
@@ -617,6 +693,13 @@ class LoopAgent implements Agent {
617
693
  ) {
618
694
  reconciliationRequired = true;
619
695
  this.#ctx.emit("agent/error", this, error);
696
+ } else if (this.#turnDeadlineReached) {
697
+ // Ahead of the cancellation branch on purpose: the deadline aborts the
698
+ // same controller Stop does, and a Turn the clock ended must not be
699
+ // reported to the person as one they stopped.
700
+ turnOutcome = "interrupted";
701
+ turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
702
+ this.#ctx.emit("agent/error", this, error);
620
703
  } else if (
621
704
  error instanceof EffectAdmissionFencedError ||
622
705
  signal.aborted
@@ -633,6 +716,13 @@ class LoopAgent implements Agent {
633
716
  this.#ctx.emit("agent/error", this, error);
634
717
  }
635
718
  } finally {
719
+ this.#disarmTurnDeadline();
720
+ // A Turn owed a reconciliation writes no `turn/end`: its model request
721
+ // has no durable outcome, and a `turn/end` would claim to know how it
722
+ // ended. That is right for as long as the run might still resume — and
723
+ // the moment it will not, the Turn is closed by whoever settles it, in
724
+ // `kernel-do`'s `settledEventsV1`. Closing it here instead would either
725
+ // lie about an outcome or make the run unresumable (ADR 0028).
636
726
  if (!reconciliationRequired) {
637
727
  if (openStep !== undefined && turnOutcome === "cancelled") {
638
728
  await this.#settleCancelledStep(openTurn, openStep);
@@ -685,6 +775,7 @@ class LoopAgent implements Agent {
685
775
  let turnOutcome: StepOutcome = "interrupted";
686
776
  let turnReason: string | undefined;
687
777
  let reconciliationRequired = false;
778
+ this.#armTurnDeadline();
688
779
  try {
689
780
  let inputs = [input];
690
781
  for (let step = 1; step <= this.#maxSteps; step += 1) {
@@ -784,6 +875,13 @@ class LoopAgent implements Agent {
784
875
  ) {
785
876
  reconciliationRequired = true;
786
877
  this.#ctx.emit("agent/error", this, error);
878
+ } else if (this.#turnDeadlineReached) {
879
+ // Ahead of the cancellation branch on purpose: the deadline aborts the
880
+ // same controller Stop does, and a Turn the clock ended must not be
881
+ // reported to the person as one they stopped.
882
+ turnOutcome = "interrupted";
883
+ turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
884
+ this.#ctx.emit("agent/error", this, error);
787
885
  } else if (
788
886
  error instanceof EffectAdmissionFencedError ||
789
887
  signal.aborted
@@ -800,6 +898,13 @@ class LoopAgent implements Agent {
800
898
  this.#ctx.emit("agent/error", this, error);
801
899
  }
802
900
  } finally {
901
+ this.#disarmTurnDeadline();
902
+ // A Turn owed a reconciliation writes no `turn/end`: its model request
903
+ // has no durable outcome, and a `turn/end` would claim to know how it
904
+ // ended. That is right for as long as the run might still resume — and
905
+ // the moment it will not, the Turn is closed by whoever settles it, in
906
+ // `kernel-do`'s `settledEventsV1`. Closing it here instead would either
907
+ // lie about an outcome or make the run unresumable (ADR 0028).
803
908
  if (!reconciliationRequired) {
804
909
  if (openStep !== undefined && turnOutcome === "cancelled") {
805
910
  await this.#settleCancelledStep(turn, openStep);
@@ -857,7 +962,16 @@ class LoopAgent implements Agent {
857
962
  turnType: this.#turnType,
858
963
  });
859
964
 
965
+ // One automatic retry, and only for a failure the provider itself
966
+ // classified as "the request never started" — a rejected key, an
967
+ // unresolvable binding, a connection refused before any byte was sent.
968
+ // Those are exactly the failures where retrying cannot duplicate anything,
969
+ // and the ones a person watching a blank screen would retry by hand. Every
970
+ // other failure is uncertain and is never retried, which is the whole of
971
+ // ADR 0024's durability contract.
972
+ let attempts = 0;
860
973
  while (true) {
974
+ attempts += 1;
861
975
  const proposedMessages = this.session.deriveMessages();
862
976
  const messages = await this.#ctx.waterfall(
863
977
  "agent/message-window",
@@ -962,14 +1076,24 @@ class LoopAgent implements Agent {
962
1076
  });
963
1077
  await this.session.flush();
964
1078
  await this.#notifyModelOutcome(request.requestId, "not-started");
1079
+ // The durable evidence of a retry is the log itself: a `model/request`,
1080
+ // the `model/effect-not-started` just journaled against it, and then a
1081
+ // second `model/request`. A Package listening on `agent/request-error`
1082
+ // still has the final say in either direction.
965
1083
  const action = await this.#ctx.waterfall(
966
1084
  "agent/request-error",
967
1085
  this,
968
1086
  error,
969
1087
  signal,
970
- () => Promise.resolve({ kind: "fail" as const }),
1088
+ () =>
1089
+ Promise.resolve(
1090
+ attempts < MODEL_REQUEST_ATTEMPTS_V1
1091
+ ? ({ kind: "retry" } as const)
1092
+ : ({ kind: "fail" } as const),
1093
+ ),
971
1094
  );
972
1095
  if (action.kind !== "retry") throw error;
1096
+ this.#ctx.emit("agent/error", this, error);
973
1097
  }
974
1098
  }
975
1099
  }
@@ -1016,7 +1140,7 @@ class LoopAgent implements Agent {
1016
1140
  signal: AbortSignal,
1017
1141
  ): Promise<ModelReconciliation> {
1018
1142
  const reconciliation = await this.#ctx.llm.reconcile(request, signal);
1019
- if (reconciliation.status === "unavailable") return reconciliation;
1143
+ if (reconciliation.status !== "recovered") return reconciliation;
1020
1144
  const durablePrefix = this.session.events.flatMap((event) =>
1021
1145
  event.type === "assistant/chunk" &&
1022
1146
  event.turn === turn &&
@@ -1313,6 +1437,7 @@ class LoopAgent implements Agent {
1313
1437
  export class AgentLoop extends Service implements AgentFactory {
1314
1438
  static inject = ["sessions", "systemPrompt", "llm", "tools", "agents"];
1315
1439
  private maxSteps: number;
1440
+ private turnDeadlineMs: number;
1316
1441
  private composition: CompositionPinV1;
1317
1442
  private handles = new Set<AgentHandle>();
1318
1443
 
@@ -1320,6 +1445,7 @@ export class AgentLoop extends Service implements AgentFactory {
1320
1445
  super(ctx, "agentLoop");
1321
1446
  this.composition = config.composition;
1322
1447
  this.maxSteps = config.maxSteps ?? 20;
1448
+ this.turnDeadlineMs = config.turnDeadlineMs ?? TURN_DEADLINE_MS_V1;
1323
1449
  if (!Number.isInteger(this.maxSteps) || this.maxSteps <= 0) {
1324
1450
  throw new Error("agent-loop maxSteps must be a positive integer");
1325
1451
  }
@@ -1336,6 +1462,7 @@ export class AgentLoop extends Service implements AgentFactory {
1336
1462
  options as EffectAdmittingAgentOptions,
1337
1463
  this.maxSteps,
1338
1464
  this.composition,
1465
+ this.turnDeadlineMs,
1339
1466
  );
1340
1467
  const unregister = this.ctx.agents.register(agent);
1341
1468
  let disposed = false;