@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 +5 -5
- package/src/index.test.ts +89 -0
- package/src/index.ts +46 -8
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.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.
|
|
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.
|
|
20
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
21
|
-
"@frockbot/plugin-tools": "0.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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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));
|