@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 +5 -5
- package/src/deadlines.test.ts +69 -3
- package/src/index.ts +54 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-agent-loop",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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.
|
|
20
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
21
|
-
"@frockbot/plugin-tools": "0.3.
|
|
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"
|
package/src/deadlines.test.ts
CHANGED
|
@@ -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
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|