@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 +5 -5
- package/src/deadlines.test.ts +69 -3
- package/src/index.ts +45 -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.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.
|
|
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.
|
|
20
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
21
|
-
"@frockbot/plugin-tools": "0.3.
|
|
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"
|
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,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
|
-
|
|
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
|
-
|
|
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) =>
|