@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-agent-loop",
3
- "version": "0.3.11",
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.11",
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.11",
20
- "@frockbot/plugin-prompt": "0.3.11",
21
- "@frockbot/plugin-tools": "0.3.11",
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
- * Nothing bounded a Turn's wall clock before this: one hung for seventeen
116
- * minutes with an animated avatar and nothing else, and would have hung until
117
- * the isolate died. Fifteen minutes is well past any Turn a person is watching
118
- * and well inside the point at which they have concluded the product is broken.
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 const TURN_DEADLINE_MS_V1 = 15 * 60 * 1000;
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,