@frockbot/kernel-agent-loop 0.3.9 → 0.3.10

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.9",
3
+ "version": "0.3.10",
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.9",
15
+ "@frockbot/kernel-contracts": "0.3.10",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
19
- "@frockbot/plugin-models": "0.3.9",
20
- "@frockbot/plugin-prompt": "0.3.9",
21
- "@frockbot/plugin-tools": "0.3.9",
19
+ "@frockbot/plugin-models": "0.3.10",
20
+ "@frockbot/plugin-prompt": "0.3.10",
21
+ "@frockbot/plugin-tools": "0.3.10",
22
22
  "@types/bun": "1.4.0",
23
23
  "@types/node": "26.2.0",
24
24
  "typescript": "^7.0.2"
@@ -78,16 +78,82 @@ describe("a Turn that runs out of wall clock", () => {
78
78
  handle.agent.send("Take your time.");
79
79
  await handle.agent.whenIdle();
80
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.
81
+ // The model effect is unsettled, so the uncertainty is recorded but the
82
+ // deadline *settles the Turn anyway*, because a run the clock stopped will
83
+ // never resume to make that outcome certain. Leaving
84
+ // `model/reconciliation-required` as the last event of an open Turn parked
85
+ // the run in `reconciliation-required` and refused every later Turn on
86
+ // that Bot with `409` for the life of the Bot.
84
87
  const journal = handle.agent.session.events;
85
88
  expect(
86
89
  journal.some((event) => event.type === "model/reconciliation-required"),
87
90
  ).toBe(true);
91
+ const step = journal.findLast((event) => event.type === "step/end");
92
+ if (step?.type !== "step/end") throw new Error("the step never ended");
93
+ expect(step.outcome).toBe("interrupted");
94
+ const end = journal.findLast((event) => event.type === "turn/end");
95
+ if (end?.type !== "turn/end") throw new Error("the Turn never ended");
96
+ expect(end.outcome).toBe("interrupted");
97
+ expect(end.reason).toBe(TURN_DEADLINE_REASON_V1);
98
+ // The last event settles the Turn; nothing is left open behind it.
99
+ expect(journal[journal.length - 1]?.type).toBe("turn/end");
88
100
  expect(handle.agent.status).toBe("idle");
89
101
  });
90
102
 
103
+ test("settles an open tool call too, so the journal never ends over one", async () => {
104
+ // The deadline landing mid-tool is the other way an open Turn was left
105
+ // behind: a `turn/end` written over an unresolved tool occurrence is an
106
+ // invalid journal, so the occurrence has to be closed first.
107
+ const provider: LlmProvider = {
108
+ id: "one-tool-then-silence",
109
+ async *stream() {
110
+ yield {
111
+ type: "tool-call" as const,
112
+ call: { id: "provider-call", name: "stall", input: {} },
113
+ };
114
+ yield { type: "finish" as const, reason: "tool-calls" as const };
115
+ },
116
+ };
117
+ const root = await mount(provider, { turnDeadlineMs: 40 });
118
+ const toolPlugin: Plugin.Function = (ctx) =>
119
+ ctx.tools.register({
120
+ name: "stall",
121
+ description: "Never answers.",
122
+ inputSchema: { type: "object" },
123
+ execute: (_input, context) =>
124
+ new Promise((_resolve, reject) => {
125
+ context.signal.addEventListener(
126
+ "abort",
127
+ () => reject(context.signal.reason),
128
+ { once: true },
129
+ );
130
+ }),
131
+ });
132
+ toolPlugin.inject = ["tools"];
133
+ await root.plugin(toolPlugin);
134
+ const handle = await root.agents.create({
135
+ botId: "deadline-tool-bot",
136
+ sessionId: "deadline-tool",
137
+ provider: "one-tool-then-silence",
138
+ model: "test-model",
139
+ admitEffect: allowEffect,
140
+ });
141
+
142
+ handle.agent.send("Call the tool that never answers.");
143
+ await handle.agent.whenIdle();
144
+
145
+ const journal = handle.agent.session.events;
146
+ const result = journal.findLast((event) => event.type === "tool/result");
147
+ if (result?.type !== "tool/result") {
148
+ throw new Error("the tool call was left open");
149
+ }
150
+ expect(result.status).toBe("interrupted");
151
+ const end = journal.findLast((event) => event.type === "turn/end");
152
+ if (end?.type !== "turn/end") throw new Error("the Turn never ended");
153
+ expect(end.outcome).toBe("interrupted");
154
+ expect(journal[journal.length - 1]?.type).toBe("turn/end");
155
+ });
156
+
91
157
  test("is reported as the deadline, never as a Stop the person did not press", async () => {
92
158
  const provider: LlmProvider = {
93
159
  id: "never-reached",
package/src/index.ts CHANGED
@@ -685,7 +685,26 @@ class LoopAgent implements Agent {
685
685
  }
686
686
  throw new StepLimitReachedError(this.#maxSteps);
687
687
  } catch (error) {
688
- if (
688
+ if (this.#turnDeadlineReached) {
689
+ // Ahead of both the reconciliation and the cancellation branch on
690
+ // purpose. Cancellation, because the deadline aborts the same
691
+ // controller Stop does and a Turn the clock ended must not be reported
692
+ // to the person as one they stopped. Reconciliation, because the
693
+ // reconciliation branch writes no `turn/end` on the promise that the
694
+ // run may still resume — and a run the deadline stopped never will.
695
+ // Deferring to it left `model/reconciliation-required` as the last
696
+ // event of an open Turn, and every later Turn on that Bot refused with
697
+ // `409` for the life of the Bot.
698
+ //
699
+ // The uncertainty is still recorded: whatever the model request wrote
700
+ // before the clock ran out stays in the journal. What changes is that
701
+ // the Turn is settled here — the open step's tool occurrences closed
702
+ // as `interrupted`, then `step/end` and `turn/end` — exactly as
703
+ // `kernel-do`'s `settledEventsV1` settles a Stop or a supersede.
704
+ turnOutcome = "interrupted";
705
+ turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
706
+ this.#ctx.emit("agent/error", this, error);
707
+ } else if (
689
708
  error instanceof ModelEffectReconciliationRequiredError ||
690
709
  error instanceof ToolEffectReconciliationRequiredError ||
691
710
  error instanceof ModelOutcomeSettlementRequiredError ||
@@ -693,13 +712,6 @@ class LoopAgent implements Agent {
693
712
  ) {
694
713
  reconciliationRequired = true;
695
714
  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);
703
715
  } else if (
704
716
  error instanceof EffectAdmissionFencedError ||
705
717
  signal.aborted
@@ -724,7 +736,13 @@ class LoopAgent implements Agent {
724
736
  // `kernel-do`'s `settledEventsV1`. Closing it here instead would either
725
737
  // lie about an outcome or make the run unresumable (ADR 0028).
726
738
  if (!reconciliationRequired) {
727
- if (openStep !== undefined && turnOutcome === "cancelled") {
739
+ // A deadline settles the same way a Stop does: an open tool
740
+ // occurrence gets an `interrupted` result before the step closes,
741
+ // so the journal never carries a `turn/end` over an open call.
742
+ if (
743
+ openStep !== undefined &&
744
+ (turnOutcome === "cancelled" || this.#turnDeadlineReached)
745
+ ) {
728
746
  await this.#settleCancelledStep(openTurn, openStep);
729
747
  }
730
748
  if (openStep !== undefined) {
@@ -867,7 +885,26 @@ class LoopAgent implements Agent {
867
885
  }
868
886
  throw new StepLimitReachedError(this.#maxSteps);
869
887
  } catch (error) {
870
- if (
888
+ if (this.#turnDeadlineReached) {
889
+ // Ahead of both the reconciliation and the cancellation branch on
890
+ // purpose. Cancellation, because the deadline aborts the same
891
+ // controller Stop does and a Turn the clock ended must not be reported
892
+ // to the person as one they stopped. Reconciliation, because the
893
+ // reconciliation branch writes no `turn/end` on the promise that the
894
+ // run may still resume — and a run the deadline stopped never will.
895
+ // Deferring to it left `model/reconciliation-required` as the last
896
+ // event of an open Turn, and every later Turn on that Bot refused with
897
+ // `409` for the life of the Bot.
898
+ //
899
+ // The uncertainty is still recorded: whatever the model request wrote
900
+ // before the clock ran out stays in the journal. What changes is that
901
+ // the Turn is settled here — the open step's tool occurrences closed
902
+ // as `interrupted`, then `step/end` and `turn/end` — exactly as
903
+ // `kernel-do`'s `settledEventsV1` settles a Stop or a supersede.
904
+ turnOutcome = "interrupted";
905
+ turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
906
+ this.#ctx.emit("agent/error", this, error);
907
+ } else if (
871
908
  error instanceof ModelEffectReconciliationRequiredError ||
872
909
  error instanceof ToolEffectReconciliationRequiredError ||
873
910
  error instanceof ModelOutcomeSettlementRequiredError ||
@@ -875,13 +912,6 @@ class LoopAgent implements Agent {
875
912
  ) {
876
913
  reconciliationRequired = true;
877
914
  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);
885
915
  } else if (
886
916
  error instanceof EffectAdmissionFencedError ||
887
917
  signal.aborted
@@ -906,7 +936,13 @@ class LoopAgent implements Agent {
906
936
  // `kernel-do`'s `settledEventsV1`. Closing it here instead would either
907
937
  // lie about an outcome or make the run unresumable (ADR 0028).
908
938
  if (!reconciliationRequired) {
909
- if (openStep !== undefined && turnOutcome === "cancelled") {
939
+ // A deadline settles the same way a Stop does: an open tool
940
+ // occurrence gets an `interrupted` result before the step closes,
941
+ // so the journal never carries a `turn/end` over an open call.
942
+ if (
943
+ openStep !== undefined &&
944
+ (turnOutcome === "cancelled" || this.#turnDeadlineReached)
945
+ ) {
910
946
  await this.#settleCancelledStep(turn, openStep);
911
947
  }
912
948
  if (openStep !== undefined) {