@frockbot/kernel-agent-loop 0.3.3 → 0.3.5

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.3",
3
+ "version": "0.3.5",
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.3",
15
+ "@frockbot/kernel-contracts": "0.3.5",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
19
- "@frockbot/plugin-models": "0.3.3",
20
- "@frockbot/plugin-prompt": "0.3.3",
21
- "@frockbot/plugin-tools": "0.3.3",
19
+ "@frockbot/plugin-models": "0.3.5",
20
+ "@frockbot/plugin-prompt": "0.3.5",
21
+ "@frockbot/plugin-tools": "0.3.5",
22
22
  "@types/bun": "1.4.0",
23
23
  "@types/node": "26.2.0",
24
24
  "typescript": "^7.0.2"
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
@@ -88,6 +88,21 @@ class ToolEffectReconciliationRequiredError extends Error {
88
88
  }
89
89
  }
90
90
 
91
+ /**
92
+ * The Turn used every step it was allowed.
93
+ *
94
+ * Not a model error: nothing failed, and everything the Turn did in those
95
+ * steps is durable. It is reported as what it is — a Turn that stopped after
96
+ * so many steps — so the person is told the Bot ran out of room rather than
97
+ * that their model broke.
98
+ */
99
+ class StepLimitReachedError extends Error {
100
+ constructor(readonly steps: number) {
101
+ super(`stopped after ${steps} steps`);
102
+ this.name = "StepLimitReachedError";
103
+ }
104
+ }
105
+
91
106
  /** Durable Stop won the final effect-admission transaction. */
92
107
  class EffectAdmissionFencedError extends Error {
93
108
  constructor(readonly effectId: string) {
@@ -272,11 +287,23 @@ class LoopAgent implements Agent {
272
287
  }
273
288
  this.#controller = new AbortController();
274
289
  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
- });
290
+ let failed = false;
291
+ const activity = this.#drive(this.#controller.signal)
292
+ .catch((error: unknown) => {
293
+ // A Turn that could not even journal its own start throws out of
294
+ // `#drive`. Re-waking on the inbox it left behind would append another
295
+ // `turn/start`, fail the same way, and spin — so the failure ends the
296
+ // waking and reaches whoever is awaiting this Turn.
297
+ failed = true;
298
+ throw error;
299
+ })
300
+ .finally(() => {
301
+ this.#controller = undefined;
302
+ if (!this.#disposeRequested) this.#setStatus("idle");
303
+ if (!this.#disposeRequested && !failed && this.#inbox.length > 0) {
304
+ this.#wake();
305
+ }
306
+ });
280
307
  this.#activity = activity;
281
308
  }
282
309
 
@@ -580,7 +607,7 @@ class LoopAgent implements Agent {
580
607
  }
581
608
  }
582
609
  }
583
- throw new Error(`agent exceeded ${this.#maxSteps} steps`);
610
+ throw new StepLimitReachedError(this.#maxSteps);
584
611
  } catch (error) {
585
612
  if (
586
613
  error instanceof ModelEffectReconciliationRequiredError ||
@@ -596,6 +623,10 @@ class LoopAgent implements Agent {
596
623
  ) {
597
624
  turnOutcome = "cancelled";
598
625
  turnReason = this.#cancelDetail;
626
+ } else if (error instanceof StepLimitReachedError) {
627
+ // The Turn ran out of room, which is not a failure of the model.
628
+ turnOutcome = "interrupted";
629
+ turnReason = turnEndReason(error.message);
599
630
  } else {
600
631
  turnOutcome = "model-error";
601
632
  turnReason = turnEndReason(modelFailureMessage(error));
@@ -643,8 +674,11 @@ class LoopAgent implements Agent {
643
674
  { type: "turn/admission", turn, turnType: this.#turnType },
644
675
  { type: "input/admitted", messageId: input.messageId, turn },
645
676
  ]);
646
- await this.session.flush();
677
+ // Claimed before the flush, not after: the input has been journaled as
678
+ // admitted, and leaving it in the inbox while the write settles meant a
679
+ // failed first flush handed it straight back to `#wake`.
647
680
  this.#inbox.shift();
681
+ await this.session.flush();
648
682
  this.#ctx.emit("agent/inbox/claimed", this, [input], turn);
649
683
 
650
684
  let openStep: number | undefined;
@@ -740,7 +774,7 @@ class LoopAgent implements Agent {
740
774
  }
741
775
  inputs = [];
742
776
  }
743
- throw new Error(`agent exceeded ${this.#maxSteps} steps`);
777
+ throw new StepLimitReachedError(this.#maxSteps);
744
778
  } catch (error) {
745
779
  if (
746
780
  error instanceof ModelEffectReconciliationRequiredError ||
@@ -756,6 +790,10 @@ class LoopAgent implements Agent {
756
790
  ) {
757
791
  turnOutcome = "cancelled";
758
792
  turnReason = this.#cancelDetail;
793
+ } else if (error instanceof StepLimitReachedError) {
794
+ // The Turn ran out of room, which is not a failure of the model.
795
+ turnOutcome = "interrupted";
796
+ turnReason = turnEndReason(error.message);
759
797
  } else {
760
798
  turnOutcome = "model-error";
761
799
  turnReason = turnEndReason(modelFailureMessage(error));