@frockbot/kernel-agent-loop 0.3.16 → 0.3.18

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.16",
3
+ "version": "0.3.18",
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.16",
15
+ "@frockbot/kernel-contracts": "0.3.18",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
19
- "@frockbot/plugin-models": "0.3.16",
20
- "@frockbot/plugin-prompt": "0.3.16",
21
- "@frockbot/plugin-tools": "0.3.16",
19
+ "@frockbot/plugin-models": "0.3.18",
20
+ "@frockbot/plugin-prompt": "0.3.18",
21
+ "@frockbot/plugin-tools": "0.3.18",
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
@@ -126,7 +126,17 @@ declare module "cordis" {
126
126
  "agent/assistant-text": (
127
127
  agent: Agent,
128
128
  text: string,
129
- position: { turn: number; step: number; requestId: string },
129
+ position: {
130
+ turn: number;
131
+ step: number;
132
+ requestId: string;
133
+ /**
134
+ * The tools the same step is about to call, by name, so a listener
135
+ * can tell an acknowledgement the model *only* narrated from one it
136
+ * is also delivering through its own send tool.
137
+ */
138
+ toolNames?: readonly string[];
139
+ },
130
140
  ) => Promise<void>;
131
141
  }
132
142
  }
package/src/index.test.ts CHANGED
@@ -17,7 +17,7 @@ import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
17
17
  import { ToolRegistry } from "@frockbot/plugin-tools";
18
18
  import { AgentRegistry, type AgentOptions } from "./agent.js";
19
19
  import { Context, type Plugin } from "cordis";
20
- import { AgentLoop } from "./index.js";
20
+ import { AgentLoop, STEP_LIMIT_REASON_V1 } from "./index.js";
21
21
 
22
22
  const roots: Context[] = [];
23
23
  const allowEffect = () => Promise.resolve(true);
@@ -151,6 +151,170 @@ afterEach(async () => {
151
151
  });
152
152
 
153
153
  describe("AgentLoop", () => {
154
+ test("journals one reported usage event for a model request", async () => {
155
+ const provider: LlmProvider = {
156
+ id: "reported-usage",
157
+ async *stream() {
158
+ yield { type: "text-delta", text: "done" };
159
+ yield {
160
+ type: "usage",
161
+ usage: {
162
+ inputTokens: 41,
163
+ outputTokens: 9,
164
+ cachedInputTokens: 7,
165
+ reasoningTokens: 3,
166
+ },
167
+ };
168
+ yield { type: "finish", reason: "completed" };
169
+ },
170
+ };
171
+ const root = await mountRuntime(provider);
172
+ const handle = await root.agents.create({
173
+ ...allowEffectOptions,
174
+ botId: "bot-usage",
175
+ sessionId: "reported-usage",
176
+ provider: provider.id,
177
+ model: "priced-model",
178
+ });
179
+
180
+ handle.agent.send("count this");
181
+ await handle.agent.whenIdle();
182
+
183
+ expect(
184
+ handle.agent.session.events.filter(
185
+ (event) => event.type === "model/usage",
186
+ ),
187
+ ).toEqual([
188
+ expect.objectContaining({
189
+ type: "model/usage",
190
+ provider: "reported-usage",
191
+ model: "priced-model",
192
+ inputTokens: 41,
193
+ outputTokens: 9,
194
+ cachedInputTokens: 7,
195
+ reasoningTokens: 3,
196
+ estimated: false,
197
+ }),
198
+ ]);
199
+ });
200
+
201
+ test("estimates and marks usage when a provider reports no counts", async () => {
202
+ const provider: LlmProvider = {
203
+ id: "estimated-usage",
204
+ async *stream() {
205
+ yield { type: "text-delta", text: "an answer" };
206
+ yield { type: "finish", reason: "completed" };
207
+ },
208
+ };
209
+ const root = await mountRuntime(provider);
210
+ const handle = await root.agents.create({
211
+ ...allowEffectOptions,
212
+ botId: "bot-usage",
213
+ sessionId: "estimated-usage",
214
+ provider: provider.id,
215
+ model: "unmetered-model",
216
+ });
217
+
218
+ handle.agent.send("count approximately");
219
+ await handle.agent.whenIdle();
220
+
221
+ const usage = handle.agent.session.events.filter(
222
+ (event) => event.type === "model/usage",
223
+ );
224
+ expect(usage).toHaveLength(1);
225
+ expect(usage[0]).toMatchObject({
226
+ type: "model/usage",
227
+ provider: "estimated-usage",
228
+ model: "unmetered-model",
229
+ estimated: true,
230
+ });
231
+ if (usage[0]?.type !== "model/usage") throw new Error("usage missing");
232
+ expect(usage[0].inputTokens).toBeGreaterThan(0);
233
+ expect(usage[0].outputTokens).toBeGreaterThan(0);
234
+ });
235
+
236
+ test("journals a structured-output downgrade and typed validation failure", async () => {
237
+ const provider: LlmProvider = {
238
+ id: "structured-failure",
239
+ supports: { structuredOutput: "none" },
240
+ async *stream() {
241
+ yield {
242
+ type: "response-format-note",
243
+ note: {
244
+ code: "structured-output-downgraded",
245
+ requested: "json_schema",
246
+ effective: "prompt",
247
+ message: "Fake provider used prompt guidance",
248
+ },
249
+ };
250
+ yield { type: "text-delta", text: '{"answer":4}' };
251
+ yield { type: "finish", reason: "completed" };
252
+ },
253
+ };
254
+ const root = await mountRuntime(provider);
255
+ root.on("agent/request", async (_agent, _request, _signal, next) => ({
256
+ ...(await next()),
257
+ responseFormat: {
258
+ type: "json_schema",
259
+ name: "answer",
260
+ schema: {
261
+ type: "object",
262
+ properties: { answer: { type: "string" } },
263
+ required: ["answer"],
264
+ additionalProperties: false,
265
+ },
266
+ },
267
+ }));
268
+ const handle = await root.agents.create({
269
+ ...allowEffectOptions,
270
+ botId: "bot-structured-failure",
271
+ sessionId: "structured-failure",
272
+ provider: provider.id,
273
+ model: "fake",
274
+ });
275
+
276
+ handle.agent.send("answer as an object");
277
+ await handle.agent.whenIdle();
278
+
279
+ expect(
280
+ handle.agent.session.events.find(
281
+ (event) => event.type === "model/response-format-note",
282
+ ),
283
+ ).toMatchObject({
284
+ type: "model/response-format-note",
285
+ note: { effective: "prompt" },
286
+ });
287
+ expect(
288
+ handle.agent.session.events.find(
289
+ (event) => event.type === "model/response-failed",
290
+ ),
291
+ ).toMatchObject({
292
+ type: "model/response-failed",
293
+ failure: { code: "schema-mismatch" },
294
+ });
295
+ expect(
296
+ handle.agent.session.events.filter(
297
+ (event) => event.type === "model/usage",
298
+ ),
299
+ ).toEqual([
300
+ expect.objectContaining({
301
+ type: "model/usage",
302
+ provider: "structured-failure",
303
+ estimated: true,
304
+ }),
305
+ ]);
306
+ expect(
307
+ handle.agent.session.events.some(
308
+ (event) => event.type === "model/reconciliation-required",
309
+ ),
310
+ ).toBe(false);
311
+ expect(
312
+ handle.agent.session.events.findLast(
313
+ (event) => event.type === "turn/end",
314
+ ),
315
+ ).toMatchObject({ type: "turn/end", outcome: "model-error" });
316
+ });
317
+
154
318
  test("records exactly the hook-shaped request received by the provider", async () => {
155
319
  let received: NormalizedModelRequest | undefined;
156
320
  const provider: LlmProvider = {
@@ -1355,6 +1519,16 @@ describe("AgentLoop", () => {
1355
1519
  "Model response outcome is uncertain: response lost after dispatch",
1356
1520
  }),
1357
1521
  );
1522
+ expect(
1523
+ handle.agent.session.events.filter(
1524
+ (event) => event.type === "model/usage",
1525
+ ),
1526
+ ).toEqual([
1527
+ expect.objectContaining({
1528
+ requestId: request.request.requestId,
1529
+ estimated: true,
1530
+ }),
1531
+ ]);
1358
1532
  expect(
1359
1533
  handle.agent.session.events.some(
1360
1534
  (event) => event.type === "step/end" || event.type === "turn/end",
@@ -3008,7 +3182,7 @@ describe("AgentLoop", () => {
3008
3182
  expect(handle.agent.session.events.at(-1)).toMatchObject({
3009
3183
  type: "turn/end",
3010
3184
  outcome: "interrupted",
3011
- reason: "stopped after 4 steps",
3185
+ reason: STEP_LIMIT_REASON_V1,
3012
3186
  });
3013
3187
  // Nothing about the model failed, so nothing is reported as if it had.
3014
3188
  expect(errors).toEqual([]);
@@ -3087,6 +3261,7 @@ describe("AgentLoop", () => {
3087
3261
  root.on("agent/assistant-text", async (_agent, text, position) => {
3088
3262
  order.push("assistant-text");
3089
3263
  seen.push(`${position.turn}:${position.step}:${text}`);
3264
+ seen.push(`tools:${(position.toolNames ?? []).join(",")}`);
3090
3265
  });
3091
3266
  const handle = await root.agents.create({
3092
3267
  ...allowEffectOptions,
@@ -3100,6 +3275,7 @@ describe("AgentLoop", () => {
3100
3275
  await handle.agent.whenIdle();
3101
3276
 
3102
3277
  expect(seen[0]).toBe("1:1:On it — building it now.");
3278
+ expect(seen[1]).toBe("tools:build");
3103
3279
  expect(order[0]).toBe("assistant-text");
3104
3280
  expect(order).toContain("tool");
3105
3281
  });
package/src/index.ts CHANGED
@@ -13,12 +13,14 @@ import {
13
13
  decodeSkillRefsV1,
14
14
  LlmEffectNotStartedError,
15
15
  type LlmStreamEvent,
16
+ type LlmUsageV1,
16
17
  type LoopStepContinuationV1,
17
18
  type NormalizedModelRequest,
18
19
  ModelProviderFailureError,
19
20
  type Session,
20
21
  type SessionEvent,
21
22
  type StepOutcome,
23
+ StructuredOutputValidationError,
22
24
  type ToolCall,
23
25
  type ToolCallOccurrence,
24
26
  type ToolExecutionResult,
@@ -80,6 +82,31 @@ interface ModelResponse {
80
82
  toolCalls: ToolCall[];
81
83
  }
82
84
 
85
+ const TOKEN_ESTIMATE_BYTES_PER_TOKEN_V1 = 4;
86
+
87
+ function estimatedTokensV1(value: unknown): number {
88
+ const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
89
+ return Math.ceil(bytes / TOKEN_ESTIMATE_BYTES_PER_TOKEN_V1);
90
+ }
91
+
92
+ /**
93
+ * The provider-neutral fallback for transports that return no token counts.
94
+ * It is intentionally based on the exact normalized request and assembled
95
+ * response that are journaled, and is always marked estimated at the event.
96
+ */
97
+ export function estimateModelUsageV1(
98
+ request: NormalizedModelRequest,
99
+ response: Pick<ModelResponse, "text" | "toolCalls">,
100
+ ): LlmUsageV1 {
101
+ return {
102
+ inputTokens: estimatedTokensV1(request),
103
+ outputTokens: estimatedTokensV1({
104
+ text: response.text,
105
+ toolCalls: response.toolCalls,
106
+ }),
107
+ };
108
+ }
109
+
83
110
  type ModelReconciliation =
84
111
  | { status: "recovered"; response: ModelResponse }
85
112
  | { status: "unavailable"; reason: string }
@@ -115,11 +142,24 @@ class ToolEffectReconciliationRequiredError extends Error {
115
142
  */
116
143
  class StepLimitReachedError extends Error {
117
144
  constructor(readonly steps: number) {
118
- super(`stopped after ${steps} steps`);
145
+ // The sentence is written for the person, the way the Turn deadline's is:
146
+ // it is what reaches the chat bubble once `kernel-do` wraps it into the
147
+ // run's failure. The step count stays on the error for the log.
148
+ super(STEP_LIMIT_REASON_V1);
119
149
  this.name = "StepLimitReachedError";
120
150
  }
121
151
  }
122
152
 
153
+ /**
154
+ * What a person is told when a reply used every step it was allowed. Bob
155
+ * (2026-09-04) ran a to-do applet build to the 64-step ceiling and the thread
156
+ * showed the generic "stopped before it finished" under a spinner that never
157
+ * ended; this names what happened and what to do, and stays true whatever
158
+ * the ceiling is.
159
+ */
160
+ export const STEP_LIMIT_REASON_V1 =
161
+ "This Bot used all the steps it had for one reply and stopped. What it finished is saved. Send another message to carry on.";
162
+
123
163
  /**
124
164
  * The longest a single Turn may run before the loop stops waiting for it.
125
165
  *
@@ -397,6 +437,8 @@ class LoopAgent implements Agent {
397
437
  let unresolvedRequest: NormalizedModelRequest | undefined;
398
438
  let definitiveNoEffect:
399
439
  Extract<SessionEvent, { type: "model/effect-not-started" }> | undefined;
440
+ let definitiveResponseFailure:
441
+ Extract<SessionEvent, { type: "model/response-failed" }> | undefined;
400
442
  for (const event of this.session.events) {
401
443
  if (event.type === "turn/start") {
402
444
  openTurn = event.turn;
@@ -405,6 +447,7 @@ class LoopAgent implements Agent {
405
447
  latestStepOutcome = undefined;
406
448
  unresolvedRequest = undefined;
407
449
  definitiveNoEffect = undefined;
450
+ definitiveResponseFailure = undefined;
408
451
  }
409
452
  if (event.type === "turn/end" && event.turn === openTurn)
410
453
  openTurn = undefined;
@@ -424,6 +467,7 @@ class LoopAgent implements Agent {
424
467
  if (event.type === "model/request" && event.turn === openTurn) {
425
468
  unresolvedRequest = event.request;
426
469
  definitiveNoEffect = undefined;
470
+ definitiveResponseFailure = undefined;
427
471
  }
428
472
  if (
429
473
  event.type === "model/effect-not-started" &&
@@ -431,6 +475,12 @@ class LoopAgent implements Agent {
431
475
  ) {
432
476
  definitiveNoEffect = event;
433
477
  }
478
+ if (
479
+ event.type === "model/response-failed" &&
480
+ event.requestId === unresolvedRequest?.requestId
481
+ ) {
482
+ definitiveResponseFailure = event;
483
+ }
434
484
  if (
435
485
  event.type === "assistant/message" &&
436
486
  event.requestId === unresolvedRequest?.requestId
@@ -464,6 +514,22 @@ class LoopAgent implements Agent {
464
514
  let nextStep = latestStep === 0 ? 1 : latestStep + 1;
465
515
  if (unresolvedRequest) {
466
516
  openStep = latestStep;
517
+ if (definitiveResponseFailure) {
518
+ await this.#notifyModelOutcome(
519
+ definitiveResponseFailure.requestId,
520
+ "completed",
521
+ );
522
+ turnOutcome = "model-error";
523
+ turnReason = turnEndReason(definitiveResponseFailure.failure.message);
524
+ this.#ctx.emit(
525
+ "agent/error",
526
+ this,
527
+ new StructuredOutputValidationError(
528
+ definitiveResponseFailure.failure,
529
+ ),
530
+ );
531
+ return;
532
+ }
467
533
  if (definitiveNoEffect) {
468
534
  await this.#notifyModelOutcome(
469
535
  definitiveNoEffect.requestId,
@@ -977,6 +1043,9 @@ class LoopAgent implements Agent {
977
1043
  // The same turn type the tool catalog is trimmed to. A section that
978
1044
  // renders what a Turn may do would otherwise have to guess it.
979
1045
  turnType: this.#turnType,
1046
+ // Where the Turn is in its budget, so a section can warn the model
1047
+ // before the loop stops it.
1048
+ step: { current: step, max: this.#maxSteps },
980
1049
  });
981
1050
 
982
1051
  // One automatic retry, and only for a failure the provider itself
@@ -1054,6 +1123,11 @@ class LoopAgent implements Agent {
1054
1123
  try {
1055
1124
  return await this.#consumeStream(request, turn, step, signal);
1056
1125
  } catch (error) {
1126
+ if (error instanceof StructuredOutputValidationError) {
1127
+ await this.session.flush();
1128
+ await this.#notifyModelOutcome(request.requestId, "completed");
1129
+ throw error;
1130
+ }
1057
1131
  if (signal.aborted) {
1058
1132
  const reason = `Model response outcome is uncertain after cancellation: ${modelFailureMessage(error)}`;
1059
1133
  this.session.append({
@@ -1140,11 +1214,20 @@ class LoopAgent implements Agent {
1140
1214
  ): Promise<ModelResponse> {
1141
1215
  let text = "";
1142
1216
  const toolCalls: ToolCall[] = [];
1217
+ let usage: LlmUsageV1 | undefined;
1218
+ let structuredFailure:
1219
+ | Extract<
1220
+ LlmStreamEvent,
1221
+ { type: "structured-output-failure" }
1222
+ >["failure"]
1223
+ | undefined;
1143
1224
  let receivedProviderEvent = false;
1225
+ const startedAt = Date.now();
1144
1226
  try {
1145
1227
  for await (const event of this.#ctx.llm.stream(request, signal)) {
1146
- receivedProviderEvent = true;
1228
+ if (event.type !== "response-format-note") receivedProviderEvent = true;
1147
1229
  signal.throwIfAborted();
1230
+ if (event.type === "usage") usage = structuredClone(event.usage);
1148
1231
  this.#applyStreamEvent(
1149
1232
  event,
1150
1233
  request.requestId,
@@ -1155,16 +1238,57 @@ class LoopAgent implements Agent {
1155
1238
  text += delta;
1156
1239
  },
1157
1240
  );
1241
+ if (event.type === "structured-output-failure") {
1242
+ structuredFailure = event.failure;
1243
+ }
1158
1244
  }
1159
1245
  } catch (error) {
1160
1246
  if (receivedProviderEvent && error instanceof ModelProviderFailureError) {
1161
- throw new Error(
1247
+ const invalidNoEffectClaim = new Error(
1162
1248
  error.message ||
1163
1249
  "Model provider reported a retryable failure after returning response data",
1164
1250
  );
1251
+ this.#recordModelUsage(
1252
+ request,
1253
+ turn,
1254
+ step,
1255
+ usage,
1256
+ text,
1257
+ toolCalls,
1258
+ Math.max(0, Date.now() - startedAt),
1259
+ );
1260
+ throw invalidNoEffectClaim;
1261
+ }
1262
+ // Once dispatch may have begun, the call can have incurred spend even
1263
+ // when its terminal response is lost. Preserve the provider's partial
1264
+ // counts when present and otherwise write the same explicit estimate as
1265
+ // a successful unmetered stream. A definitive no-effect result is the
1266
+ // sole exception because the provider says no billable call occurred.
1267
+ if (!(error instanceof ModelProviderFailureError)) {
1268
+ this.#recordModelUsage(
1269
+ request,
1270
+ turn,
1271
+ step,
1272
+ usage,
1273
+ text,
1274
+ toolCalls,
1275
+ Math.max(0, Date.now() - startedAt),
1276
+ );
1165
1277
  }
1166
1278
  throw error;
1167
1279
  }
1280
+ this.#recordModelUsage(
1281
+ request,
1282
+ turn,
1283
+ step,
1284
+ usage,
1285
+ text,
1286
+ toolCalls,
1287
+ Math.max(0, Date.now() - startedAt),
1288
+ );
1289
+ if (structuredFailure) {
1290
+ throw new StructuredOutputValidationError(structuredFailure);
1291
+ }
1168
1292
  return { request, text, toolCalls };
1169
1293
  }
1170
1294
 
@@ -1211,9 +1335,18 @@ class LoopAgent implements Agent {
1211
1335
  }
1212
1336
  let text = "";
1213
1337
  const toolCalls: ToolCall[] = [];
1338
+ let usage: LlmUsageV1 | undefined;
1339
+ let structuredFailure:
1340
+ | Extract<
1341
+ LlmStreamEvent,
1342
+ { type: "structured-output-failure" }
1343
+ >["failure"]
1344
+ | undefined;
1214
1345
  let textDeltaIndex = 0;
1346
+ const startedAt = Date.now();
1215
1347
  for (const event of reconciliation.events) {
1216
1348
  signal.throwIfAborted();
1349
+ if (event.type === "usage") usage = structuredClone(event.usage);
1217
1350
  const journalTextDelta =
1218
1351
  event.type !== "text-delta" || textDeltaIndex >= durablePrefix.length;
1219
1352
  this.#applyStreamEvent(
@@ -1228,6 +1361,23 @@ class LoopAgent implements Agent {
1228
1361
  journalTextDelta,
1229
1362
  );
1230
1363
  if (event.type === "text-delta") textDeltaIndex += 1;
1364
+ if (event.type === "structured-output-failure") {
1365
+ structuredFailure = event.failure;
1366
+ }
1367
+ }
1368
+ this.#recordModelUsage(
1369
+ request,
1370
+ turn,
1371
+ step,
1372
+ usage,
1373
+ text,
1374
+ toolCalls,
1375
+ Math.max(0, Date.now() - startedAt),
1376
+ );
1377
+ if (structuredFailure) {
1378
+ await this.session.flush();
1379
+ await this.#notifyModelOutcome(request.requestId, "completed");
1380
+ throw new StructuredOutputValidationError(structuredFailure);
1231
1381
  }
1232
1382
  return {
1233
1383
  status: "recovered",
@@ -1235,6 +1385,39 @@ class LoopAgent implements Agent {
1235
1385
  };
1236
1386
  }
1237
1387
 
1388
+ #recordModelUsage(
1389
+ request: NormalizedModelRequest,
1390
+ turn: number,
1391
+ step: number,
1392
+ reported: LlmUsageV1 | undefined,
1393
+ text: string,
1394
+ toolCalls: readonly ToolCall[],
1395
+ latencyMs: number,
1396
+ ): void {
1397
+ const existing = this.session.events.some(
1398
+ (event) =>
1399
+ event.type === "model/usage" && event.requestId === request.requestId,
1400
+ );
1401
+ if (existing) return;
1402
+ const usage =
1403
+ reported ??
1404
+ estimateModelUsageV1(request, { text, toolCalls: [...toolCalls] });
1405
+ this.session.append({
1406
+ type: "model/usage",
1407
+ turn,
1408
+ step,
1409
+ requestId: request.requestId,
1410
+ provider: request.provider,
1411
+ model: request.model,
1412
+ ...(request.modelBinding
1413
+ ? { modelBinding: structuredClone(request.modelBinding) }
1414
+ : {}),
1415
+ ...usage,
1416
+ latencyMs,
1417
+ estimated: reported === undefined,
1418
+ });
1419
+ }
1420
+
1238
1421
  #applyStreamEvent(
1239
1422
  event: LlmStreamEvent,
1240
1423
  requestId: string,
@@ -1257,6 +1440,22 @@ class LoopAgent implements Agent {
1257
1440
  }
1258
1441
  } else if (event.type === "tool-call") {
1259
1442
  toolCalls.push(event.call);
1443
+ } else if (event.type === "response-format-note") {
1444
+ this.session.append({
1445
+ type: "model/response-format-note",
1446
+ turn,
1447
+ step,
1448
+ requestId,
1449
+ note: event.note,
1450
+ });
1451
+ } else if (event.type === "structured-output-failure") {
1452
+ this.session.append({
1453
+ type: "model/response-failed",
1454
+ turn,
1455
+ step,
1456
+ requestId,
1457
+ failure: event.failure,
1458
+ });
1260
1459
  }
1261
1460
  }
1262
1461
 
@@ -1424,6 +1623,7 @@ class LoopAgent implements Agent {
1424
1623
  turn,
1425
1624
  step,
1426
1625
  requestId: response.request.requestId,
1626
+ toolNames: response.toolCalls.map((call) => call.name),
1427
1627
  });
1428
1628
  }
1429
1629