@frockbot/kernel-agent-loop 0.3.11 → 0.3.13
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/agent.ts +20 -0
- package/src/index.test.ts +75 -0
- package/src/index.ts +34 -5
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.13",
|
|
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.13",
|
|
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.13",
|
|
20
|
+
"@frockbot/plugin-prompt": "0.3.13",
|
|
21
|
+
"@frockbot/plugin-tools": "0.3.13",
|
|
22
22
|
"@types/bun": "1.4.0",
|
|
23
23
|
"@types/node": "26.2.0",
|
|
24
24
|
"typescript": "^7.0.2"
|
package/src/agent.ts
CHANGED
|
@@ -107,6 +107,26 @@ declare module "cordis" {
|
|
|
107
107
|
outcome: "completed" | "not-started",
|
|
108
108
|
) => Promise<void>;
|
|
109
109
|
"agent/turn-stopping": (agent: Agent, turn: number) => Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* A step where the model wrote something *and* called tools, raised the
|
|
112
|
+
* moment the assistant message is journaled and before any tool runs.
|
|
113
|
+
*
|
|
114
|
+
* The kernel has no opinion about what that text is for — a Package that
|
|
115
|
+
* gives the Bot a voice does. In the Shell, the only thing a person sees
|
|
116
|
+
* is a `send_to_user` call, so a model that writes "On it — building the
|
|
117
|
+
* countdown applet now." and then goes on to call three tools has said it
|
|
118
|
+
* to nobody: the client draws one bubble per send, and there was no send.
|
|
119
|
+
* The prompt asks for the call, and this is what catches the model that
|
|
120
|
+
* narrates its acknowledgement in text anyway.
|
|
121
|
+
*
|
|
122
|
+
* Serial, and before the tools, so the promoted line lands ahead of the
|
|
123
|
+
* first tool result rather than after the work it was announcing.
|
|
124
|
+
*/
|
|
125
|
+
"agent/assistant-text": (
|
|
126
|
+
agent: Agent,
|
|
127
|
+
text: string,
|
|
128
|
+
position: { turn: number; step: number; requestId: string },
|
|
129
|
+
) => Promise<void>;
|
|
110
130
|
}
|
|
111
131
|
}
|
|
112
132
|
|
package/src/index.test.ts
CHANGED
|
@@ -3057,4 +3057,79 @@ describe("AgentLoop", () => {
|
|
|
3057
3057
|
),
|
|
3058
3058
|
).toHaveLength(1);
|
|
3059
3059
|
});
|
|
3060
|
+
|
|
3061
|
+
// A model that writes an acknowledgement and then calls tools has said
|
|
3062
|
+
// something to the person, and the Package that owns the Bot's voice has to
|
|
3063
|
+
// hear about it *before* the work it was announcing produces results.
|
|
3064
|
+
test("raises assistant text ahead of the tools the same step called", async () => {
|
|
3065
|
+
const provider: LlmProvider = {
|
|
3066
|
+
id: "acknowledging-model",
|
|
3067
|
+
async *stream() {
|
|
3068
|
+
yield { type: "text-delta", text: "On it — building it now." };
|
|
3069
|
+
yield {
|
|
3070
|
+
type: "tool-call",
|
|
3071
|
+
call: { id: "call-1", name: "build", input: {} },
|
|
3072
|
+
};
|
|
3073
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
3074
|
+
},
|
|
3075
|
+
};
|
|
3076
|
+
const order: string[] = [];
|
|
3077
|
+
const root = await mountRuntime(provider, {
|
|
3078
|
+
name: "build",
|
|
3079
|
+
description: "Does the work the acknowledgement announced.",
|
|
3080
|
+
inputSchema: { type: "object" },
|
|
3081
|
+
execute: () => {
|
|
3082
|
+
order.push("tool");
|
|
3083
|
+
return Promise.resolve({ content: "built", isError: true });
|
|
3084
|
+
},
|
|
3085
|
+
});
|
|
3086
|
+
const seen: string[] = [];
|
|
3087
|
+
root.on("agent/assistant-text", async (_agent, text, position) => {
|
|
3088
|
+
order.push("assistant-text");
|
|
3089
|
+
seen.push(`${position.turn}:${position.step}:${text}`);
|
|
3090
|
+
});
|
|
3091
|
+
const handle = await root.agents.create({
|
|
3092
|
+
...allowEffectOptions,
|
|
3093
|
+
botId: "bot-ack",
|
|
3094
|
+
sessionId: "acknowledging-model",
|
|
3095
|
+
provider: provider.id,
|
|
3096
|
+
model: "model-1",
|
|
3097
|
+
});
|
|
3098
|
+
|
|
3099
|
+
handle.agent.send("build me a countdown");
|
|
3100
|
+
await handle.agent.whenIdle();
|
|
3101
|
+
|
|
3102
|
+
expect(seen[0]).toBe("1:1:On it — building it now.");
|
|
3103
|
+
expect(order[0]).toBe("assistant-text");
|
|
3104
|
+
expect(order).toContain("tool");
|
|
3105
|
+
});
|
|
3106
|
+
|
|
3107
|
+
// A step with nothing to say, or one that says everything it has and stops,
|
|
3108
|
+
// raises nothing: the first has no text, and the second's text is the reply.
|
|
3109
|
+
test("raises nothing for a step with no text or no tools", async () => {
|
|
3110
|
+
const provider: LlmProvider = {
|
|
3111
|
+
id: "quiet-model",
|
|
3112
|
+
async *stream() {
|
|
3113
|
+
yield { type: "text-delta", text: "Here is your answer." };
|
|
3114
|
+
yield { type: "finish", reason: "completed" };
|
|
3115
|
+
},
|
|
3116
|
+
};
|
|
3117
|
+
const root = await mountRuntime(provider);
|
|
3118
|
+
let raised = 0;
|
|
3119
|
+
root.on("agent/assistant-text", async () => {
|
|
3120
|
+
raised += 1;
|
|
3121
|
+
});
|
|
3122
|
+
const handle = await root.agents.create({
|
|
3123
|
+
...allowEffectOptions,
|
|
3124
|
+
botId: "bot-quiet",
|
|
3125
|
+
sessionId: "quiet-model",
|
|
3126
|
+
provider: provider.id,
|
|
3127
|
+
model: "model-1",
|
|
3128
|
+
});
|
|
3129
|
+
|
|
3130
|
+
handle.agent.send("answer me");
|
|
3131
|
+
await handle.agent.whenIdle();
|
|
3132
|
+
|
|
3133
|
+
expect(raised).toBe(0);
|
|
3134
|
+
});
|
|
3060
3135
|
});
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type ToolCallOccurrence,
|
|
23
23
|
type ToolExecutionResult,
|
|
24
24
|
type TurnTypeV1,
|
|
25
|
+
TURN_DEADLINE_MS_V1,
|
|
25
26
|
toolCallOccurrences,
|
|
26
27
|
turnEndReason,
|
|
27
28
|
validateSettledToolOccurrenceJournal,
|
|
@@ -112,12 +113,12 @@ class StepLimitReachedError extends Error {
|
|
|
112
113
|
/**
|
|
113
114
|
* The longest a single Turn may run before the loop stops waiting for it.
|
|
114
115
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
116
|
+
* Defined in the contracts, because the loop is not its only reader: anything
|
|
117
|
+
* deciding whether a run still marked `running` can still be running needs the
|
|
118
|
+
* same number. Re-exported here because this is where every caller looks for
|
|
119
|
+
* it.
|
|
119
120
|
*/
|
|
120
|
-
export
|
|
121
|
+
export { TURN_DEADLINE_MS_V1 };
|
|
121
122
|
|
|
122
123
|
/**
|
|
123
124
|
* How many times one step will send its model request.
|
|
@@ -520,6 +521,7 @@ class LoopAgent implements Agent {
|
|
|
520
521
|
await this.session.flush();
|
|
521
522
|
signal.throwIfAborted();
|
|
522
523
|
await this.#notifyModelOutcome(response.request.requestId, "completed");
|
|
524
|
+
await this.#announceAssistantText(response, openTurn, latestStep);
|
|
523
525
|
if (response.toolCalls.length === 0) {
|
|
524
526
|
const shouldStop = await this.#stepShouldStop(
|
|
525
527
|
openTurn,
|
|
@@ -640,6 +642,7 @@ class LoopAgent implements Agent {
|
|
|
640
642
|
await this.session.flush();
|
|
641
643
|
signal.throwIfAborted();
|
|
642
644
|
await this.#notifyModelOutcome(response.request.requestId, "completed");
|
|
645
|
+
await this.#announceAssistantText(response, openTurn, step);
|
|
643
646
|
if (response.toolCalls.length === 0) {
|
|
644
647
|
const shouldStop = await this.#stepShouldStop(
|
|
645
648
|
openTurn,
|
|
@@ -820,6 +823,7 @@ class LoopAgent implements Agent {
|
|
|
820
823
|
await this.session.flush();
|
|
821
824
|
signal.throwIfAborted();
|
|
822
825
|
await this.#notifyModelOutcome(response.request.requestId, "completed");
|
|
826
|
+
await this.#announceAssistantText(response, turn, step);
|
|
823
827
|
|
|
824
828
|
if (response.toolCalls.length === 0) {
|
|
825
829
|
const shouldStop = await this.#stepShouldStop(
|
|
@@ -1371,6 +1375,31 @@ class LoopAgent implements Agent {
|
|
|
1371
1375
|
return endsTurn;
|
|
1372
1376
|
}
|
|
1373
1377
|
|
|
1378
|
+
/**
|
|
1379
|
+
* Raises `agent/assistant-text` for a step that wrote something and then
|
|
1380
|
+
* called tools, so a Package that owns the Bot's voice can do something with
|
|
1381
|
+
* words the model addressed to the person.
|
|
1382
|
+
*
|
|
1383
|
+
* Only that shape. A step with no tool calls ends the Turn on its assistant
|
|
1384
|
+
* message, which every surface already draws; a step with tools and no text
|
|
1385
|
+
* has nothing to say. The narrow case is the one that went missing: text and
|
|
1386
|
+
* tools together, where the text is an acknowledgement and the tools are the
|
|
1387
|
+
* work it was announcing.
|
|
1388
|
+
*/
|
|
1389
|
+
async #announceAssistantText(
|
|
1390
|
+
response: ModelResponse,
|
|
1391
|
+
turn: number,
|
|
1392
|
+
step: number,
|
|
1393
|
+
): Promise<void> {
|
|
1394
|
+
if (response.toolCalls.length === 0) return;
|
|
1395
|
+
if (response.text.trim().length === 0) return;
|
|
1396
|
+
await this.#ctx.serial("agent/assistant-text", this, response.text, {
|
|
1397
|
+
turn,
|
|
1398
|
+
step,
|
|
1399
|
+
requestId: response.request.requestId,
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1374
1403
|
async #stepShouldStop(
|
|
1375
1404
|
turn: number,
|
|
1376
1405
|
step: number,
|