@frockbot/kernel-agent-loop 0.3.9 → 0.3.11

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.11",
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.11",
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.11",
20
+ "@frockbot/plugin-prompt": "0.3.11",
21
+ "@frockbot/plugin-tools": "0.3.11",
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,10 @@ class LoopAgent implements Agent {
685
685
  }
686
686
  throw new StepLimitReachedError(this.#maxSteps);
687
687
  } catch (error) {
688
- if (
688
+ if (this.#turnDeadlineReached) {
689
+ turnOutcome = "interrupted";
690
+ turnReason = this.#deadlineTurnReason(error);
691
+ } else if (
689
692
  error instanceof ModelEffectReconciliationRequiredError ||
690
693
  error instanceof ToolEffectReconciliationRequiredError ||
691
694
  error instanceof ModelOutcomeSettlementRequiredError ||
@@ -693,13 +696,6 @@ class LoopAgent implements Agent {
693
696
  ) {
694
697
  reconciliationRequired = true;
695
698
  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
699
  } else if (
704
700
  error instanceof EffectAdmissionFencedError ||
705
701
  signal.aborted
@@ -724,7 +720,13 @@ class LoopAgent implements Agent {
724
720
  // `kernel-do`'s `settledEventsV1`. Closing it here instead would either
725
721
  // lie about an outcome or make the run unresumable (ADR 0028).
726
722
  if (!reconciliationRequired) {
727
- if (openStep !== undefined && turnOutcome === "cancelled") {
723
+ // A deadline settles the same way a Stop does: an open tool
724
+ // occurrence gets an `interrupted` result before the step closes,
725
+ // so the journal never carries a `turn/end` over an open call.
726
+ if (
727
+ openStep !== undefined &&
728
+ (turnOutcome === "cancelled" || this.#turnDeadlineReached)
729
+ ) {
728
730
  await this.#settleCancelledStep(openTurn, openStep);
729
731
  }
730
732
  if (openStep !== undefined) {
@@ -867,7 +869,10 @@ class LoopAgent implements Agent {
867
869
  }
868
870
  throw new StepLimitReachedError(this.#maxSteps);
869
871
  } catch (error) {
870
- if (
872
+ if (this.#turnDeadlineReached) {
873
+ turnOutcome = "interrupted";
874
+ turnReason = this.#deadlineTurnReason(error);
875
+ } else if (
871
876
  error instanceof ModelEffectReconciliationRequiredError ||
872
877
  error instanceof ToolEffectReconciliationRequiredError ||
873
878
  error instanceof ModelOutcomeSettlementRequiredError ||
@@ -875,13 +880,6 @@ class LoopAgent implements Agent {
875
880
  ) {
876
881
  reconciliationRequired = true;
877
882
  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
883
  } else if (
886
884
  error instanceof EffectAdmissionFencedError ||
887
885
  signal.aborted
@@ -906,7 +904,13 @@ class LoopAgent implements Agent {
906
904
  // `kernel-do`'s `settledEventsV1`. Closing it here instead would either
907
905
  // lie about an outcome or make the run unresumable (ADR 0028).
908
906
  if (!reconciliationRequired) {
909
- if (openStep !== undefined && turnOutcome === "cancelled") {
907
+ // A deadline settles the same way a Stop does: an open tool
908
+ // occurrence gets an `interrupted` result before the step closes,
909
+ // so the journal never carries a `turn/end` over an open call.
910
+ if (
911
+ openStep !== undefined &&
912
+ (turnOutcome === "cancelled" || this.#turnDeadlineReached)
913
+ ) {
910
914
  await this.#settleCancelledStep(turn, openStep);
911
915
  }
912
916
  if (openStep !== undefined) {
@@ -1385,6 +1389,29 @@ class LoopAgent implements Agent {
1385
1389
  return decision.kind === "stop";
1386
1390
  }
1387
1391
 
1392
+ /**
1393
+ * The reason a Turn the clock ended carries, and the one place that decides
1394
+ * a deadline is not a cancellation and not a reconciliation.
1395
+ *
1396
+ * Its branch runs ahead of both. Ahead of cancellation, because the deadline
1397
+ * aborts the same controller Stop does and a Turn the clock ended must not
1398
+ * be reported to the person as one they stopped. Ahead of reconciliation,
1399
+ * because that branch writes no `turn/end` on the promise the run may still
1400
+ * resume — and a run the deadline stopped never will. Deferring to it left
1401
+ * `model/reconciliation-required` as the last event of an open Turn, and
1402
+ * every later Turn on that Bot refused with `409` for the life of the Bot.
1403
+ *
1404
+ * The uncertainty is still recorded: whatever the model request wrote before
1405
+ * the clock ran out stays in the journal. What changes is that the Turn is
1406
+ * settled — the open step's tool occurrences closed as `interrupted`, then
1407
+ * `step/end` and `turn/end` — exactly as `kernel-do`'s `settledEventsV1`
1408
+ * settles a Stop or a supersede.
1409
+ */
1410
+ #deadlineTurnReason(error: unknown): string | undefined {
1411
+ this.#ctx.emit("agent/error", this, error);
1412
+ return turnEndReason(TURN_DEADLINE_REASON_V1);
1413
+ }
1414
+
1388
1415
  async #settleCancelledStep(turn: number, step: number): Promise<void> {
1389
1416
  const assistant = this.session.events.findLast(
1390
1417
  (event) =>