@frockbot/kernel-agent-loop 0.3.5 → 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.5",
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.5",
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.5",
20
- "@frockbot/plugin-prompt": "0.3.5",
21
- "@frockbot/plugin-tools": "0.3.5",
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.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
  }
@@ -103,6 +108,29 @@ class StepLimitReachedError extends Error {
103
108
  }
104
109
  }
105
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
+
106
134
  /** Durable Stop won the final effect-admission transaction. */
107
135
  class EffectAdmissionFencedError extends Error {
108
136
  constructor(readonly effectId: string) {
@@ -173,6 +201,16 @@ class LoopAgent implements Agent {
173
201
  #cancelDetail: string | undefined;
174
202
  #disposeRequested = false;
175
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;
176
214
 
177
215
  constructor(
178
216
  ctx: Context,
@@ -180,6 +218,7 @@ class LoopAgent implements Agent {
180
218
  options: EffectAdmittingAgentOptions,
181
219
  maxSteps: number,
182
220
  composition: CompositionPinV1,
221
+ turnDeadlineMs: number,
183
222
  ) {
184
223
  this.#ctx = ctx;
185
224
  this.#composition = composition;
@@ -193,6 +232,7 @@ class LoopAgent implements Agent {
193
232
  this.#turnType = options.turnType ?? "chat";
194
233
  this.#subagentRole = options.subagentRole;
195
234
  this.#maxSteps = maxSteps;
235
+ this.#turnDeadlineMs = turnDeadlineMs;
196
236
  }
197
237
 
198
238
  get status(): AgentStatus {
@@ -255,6 +295,27 @@ class LoopAgent implements Agent {
255
295
  this.#controller?.abort(new Error(`agent cancelled by ${reason}`));
256
296
  }
257
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
+
258
319
  async whenIdle(): Promise<void> {
259
320
  let activity: Promise<void>;
260
321
  do {
@@ -384,6 +445,7 @@ class LoopAgent implements Agent {
384
445
  let turnOutcome: StepOutcome = "interrupted";
385
446
  let turnReason: string | undefined;
386
447
  let reconciliationRequired = false;
448
+ this.#armTurnDeadline();
387
449
  try {
388
450
  if (latestAssistant) {
389
451
  await this.#notifyModelOutcome(latestAssistant.requestId, "completed");
@@ -617,6 +679,13 @@ class LoopAgent implements Agent {
617
679
  ) {
618
680
  reconciliationRequired = true;
619
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);
620
689
  } else if (
621
690
  error instanceof EffectAdmissionFencedError ||
622
691
  signal.aborted
@@ -633,6 +702,13 @@ class LoopAgent implements Agent {
633
702
  this.#ctx.emit("agent/error", this, error);
634
703
  }
635
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).
636
712
  if (!reconciliationRequired) {
637
713
  if (openStep !== undefined && turnOutcome === "cancelled") {
638
714
  await this.#settleCancelledStep(openTurn, openStep);
@@ -685,6 +761,7 @@ class LoopAgent implements Agent {
685
761
  let turnOutcome: StepOutcome = "interrupted";
686
762
  let turnReason: string | undefined;
687
763
  let reconciliationRequired = false;
764
+ this.#armTurnDeadline();
688
765
  try {
689
766
  let inputs = [input];
690
767
  for (let step = 1; step <= this.#maxSteps; step += 1) {
@@ -784,6 +861,13 @@ class LoopAgent implements Agent {
784
861
  ) {
785
862
  reconciliationRequired = true;
786
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);
787
871
  } else if (
788
872
  error instanceof EffectAdmissionFencedError ||
789
873
  signal.aborted
@@ -800,6 +884,13 @@ class LoopAgent implements Agent {
800
884
  this.#ctx.emit("agent/error", this, error);
801
885
  }
802
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).
803
894
  if (!reconciliationRequired) {
804
895
  if (openStep !== undefined && turnOutcome === "cancelled") {
805
896
  await this.#settleCancelledStep(turn, openStep);
@@ -857,7 +948,16 @@ class LoopAgent implements Agent {
857
948
  turnType: this.#turnType,
858
949
  });
859
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;
860
959
  while (true) {
960
+ attempts += 1;
861
961
  const proposedMessages = this.session.deriveMessages();
862
962
  const messages = await this.#ctx.waterfall(
863
963
  "agent/message-window",
@@ -962,14 +1062,24 @@ class LoopAgent implements Agent {
962
1062
  });
963
1063
  await this.session.flush();
964
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.
965
1069
  const action = await this.#ctx.waterfall(
966
1070
  "agent/request-error",
967
1071
  this,
968
1072
  error,
969
1073
  signal,
970
- () => 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
+ ),
971
1080
  );
972
1081
  if (action.kind !== "retry") throw error;
1082
+ this.#ctx.emit("agent/error", this, error);
973
1083
  }
974
1084
  }
975
1085
  }
@@ -1313,6 +1423,7 @@ class LoopAgent implements Agent {
1313
1423
  export class AgentLoop extends Service implements AgentFactory {
1314
1424
  static inject = ["sessions", "systemPrompt", "llm", "tools", "agents"];
1315
1425
  private maxSteps: number;
1426
+ private turnDeadlineMs: number;
1316
1427
  private composition: CompositionPinV1;
1317
1428
  private handles = new Set<AgentHandle>();
1318
1429
 
@@ -1320,6 +1431,7 @@ export class AgentLoop extends Service implements AgentFactory {
1320
1431
  super(ctx, "agentLoop");
1321
1432
  this.composition = config.composition;
1322
1433
  this.maxSteps = config.maxSteps ?? 20;
1434
+ this.turnDeadlineMs = config.turnDeadlineMs ?? TURN_DEADLINE_MS_V1;
1323
1435
  if (!Number.isInteger(this.maxSteps) || this.maxSteps <= 0) {
1324
1436
  throw new Error("agent-loop maxSteps must be a positive integer");
1325
1437
  }
@@ -1336,6 +1448,7 @@ export class AgentLoop extends Service implements AgentFactory {
1336
1448
  options as EffectAdmittingAgentOptions,
1337
1449
  this.maxSteps,
1338
1450
  this.composition,
1451
+ this.turnDeadlineMs,
1339
1452
  );
1340
1453
  const unregister = this.ctx.agents.register(agent);
1341
1454
  let disposed = false;