@neutrome/lilsdk 0.4.4 → 0.4.6

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": "@neutrome/lilsdk",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -13,7 +13,7 @@
13
13
  "./managed": "./src/managed/index.ts"
14
14
  },
15
15
  "dependencies": {
16
- "@neutrome/lil-engine": "0.4.3"
16
+ "@neutrome/lil-engine": "0.4.6"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/node": "^25.9.3",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "scripts": {
24
24
  "test": "vitest run",
25
- "typecheck": "tsc -p tsconfig.json --noEmit"
25
+ "typecheck": "tsc -p tsconfig.json --noEmit",
26
+ "npm:publish": "pnpm publish --no-git-checks"
26
27
  }
27
28
  }
@@ -20,6 +20,7 @@ const encoder = new TextEncoder();
20
20
  export type CapabilitiesExecutorOptions = {
21
21
  enabledIterations?: number;
22
22
  cacheKey?: (ctx: ExecutorContext) => string;
23
+ skipPrefixes?: readonly string[];
23
24
  };
24
25
 
25
26
  type CapabilitySelection = { toolName: string; remaining: number };
@@ -30,16 +31,22 @@ export function createCapabilitiesExecutor(
30
31
  ): Executor {
31
32
  const enabledIterations = positiveInteger(options.enabledIterations ?? 5);
32
33
  const cacheKey = options.cacheKey ?? ((ctx) => ctx.requestId);
34
+ const skipPrefixes = options.skipPrefixes ?? [];
33
35
 
34
36
  return {
35
37
  async execute(request, ctx) {
36
38
  const tools = viewProgram(request).tools;
37
39
  if (tools.length === 0) return ctx.invoke(inner, request);
38
40
 
41
+ const managedTools = tools.filter(
42
+ (tool) => !skipPrefixes.some((prefix) => tool.name.startsWith(prefix)),
43
+ );
44
+ if (managedTools.length === 0) return ctx.invoke(inner, request);
45
+
39
46
  const key = selectionKey(cacheKey(ctx));
40
- await discardMissingSelection(ctx, key, tools);
41
- return connectCapabilities(inner, tools, key, enabledIterations).execute(
42
- withoutTools(request),
47
+ await discardMissingSelection(ctx, key, managedTools);
48
+ return connectCapabilities(inner, managedTools, key, enabledIterations).execute(
49
+ withoutManagedTools(request, managedTools),
43
50
  ctx,
44
51
  );
45
52
  },
@@ -51,10 +58,18 @@ export function createCapabilitiesExecutor(
51
58
  return;
52
59
  }
53
60
 
61
+ const managedTools = tools.filter(
62
+ (tool) => !skipPrefixes.some((prefix) => tool.name.startsWith(prefix)),
63
+ );
64
+ if (managedTools.length === 0) {
65
+ yield* ctx.invokeStream(inner, request);
66
+ return;
67
+ }
68
+
54
69
  const key = selectionKey(cacheKey(ctx));
55
- await discardMissingSelection(ctx, key, tools);
56
- yield* connectCapabilities(inner, tools, key, enabledIterations).stream(
57
- withoutTools(request),
70
+ await discardMissingSelection(ctx, key, managedTools);
71
+ yield* connectCapabilities(inner, managedTools, key, enabledIterations).stream(
72
+ withoutManagedTools(request, managedTools),
58
73
  ctx,
59
74
  );
60
75
  },
@@ -153,9 +168,14 @@ async function discardMissingSelection(
153
168
  }
154
169
  }
155
170
 
156
- function withoutTools(request: Program): Program {
171
+ function withoutManagedTools(
172
+ request: Program,
173
+ managedTools: readonly ProgramTool[],
174
+ ): Program {
175
+ const managedNames = new Set(managedTools.map((tool) => tool.name));
157
176
  const indices: number[] = [];
158
177
  for (const definition of toolDefinitions(request)) {
178
+ if (!definition.name || !managedNames.has(definition.name)) continue;
159
179
  for (let index = definition.start; index <= definition.end; index += 1) {
160
180
  indices.push(index);
161
181
  }
@@ -5,17 +5,3 @@ export type {
5
5
  AttachmentReaderRule,
6
6
  AttachmentToTextOptions,
7
7
  } from "./attachment-to-text.ts";
8
-
9
- export {
10
- appendInternalDraft,
11
- createTwoPassExecutor,
12
- INTERNAL_DRAFT_CALL_ID,
13
- INTERNAL_DRAFT_TOOL_NAME,
14
- } from "./two-pass.ts";
15
-
16
- export type {
17
- InternalDraft,
18
- TwoPassExecutorOptions,
19
- TwoPassSettings,
20
- TwoPassSettingsResolver,
21
- } from "./two-pass.ts";
@@ -7,7 +7,12 @@ import {
7
7
  } from "./types.ts";
8
8
 
9
9
  export type ToolCall = { id: string; name: string; args: string };
10
- export type ToolExecution = ToolCall & { result: string };
10
+ export type ToolExecution = {
11
+ id: string;
12
+ name: string;
13
+ args: Record<string, unknown>;
14
+ result: string;
15
+ };
11
16
 
12
17
  export class ToolArgumentsError extends Error {
13
18
  constructor(
@@ -49,37 +54,52 @@ export function buildCallExecutor(toolMap: ReadonlyMap<string, Tool>) {
49
54
  ): Promise<ToolExecution[]> =>
50
55
  Promise.all(
51
56
  calls.map(async (call) => {
52
- const tool = toolMap.get(call.name);
53
- if (!tool) {
54
- throw new Error(`No tool is registered for call "${call.name}"`);
55
- }
56
57
  const startedMs = Date.now();
57
- const result = await tool.execute(
58
- parseToolArguments(call.name, call.args),
59
- ctx,
60
- );
61
- const finishedMs = Date.now();
62
- ctx.observe(
63
- createExecutionEvent({
64
- kind: "tool.executed",
65
- requestId: ctx.requestId,
66
- executionId: `tool_${call.id || crypto.randomUUID()}`,
67
- parentExecutionId: ctx.executionId,
68
- data: {
69
- toolName: call.name,
70
- toolCallId: call.id,
71
- startedAt: new Date(startedMs).toISOString(),
72
- finishedAt: new Date(finishedMs).toISOString(),
73
- durationMs: finishedMs - startedMs,
74
- lilText: toolTrace(call, result),
75
- },
76
- }),
77
- );
78
- return { ...call, result };
58
+ try {
59
+ const tool = toolMap.get(call.name);
60
+ if (!tool) {
61
+ throw new Error(`No tool is registered for call "${call.name}"`);
62
+ }
63
+ const args = parseToolArguments(call.name, call.args);
64
+ const result = await tool.execute(args, ctx);
65
+ observeToolExecution(ctx, call, startedMs, result);
66
+ return { id: call.id, name: call.name, args, result };
67
+ } catch (error) {
68
+ const result = toolFailureResult(call.name, error);
69
+ observeToolExecution(ctx, call, startedMs, result, error);
70
+ return { id: call.id, name: call.name, args: {}, result };
71
+ }
79
72
  }),
80
73
  );
81
74
  }
82
75
 
76
+ function observeToolExecution(
77
+ ctx: ExecutorContext,
78
+ call: ToolCall,
79
+ startedMs: number,
80
+ result: string,
81
+ error?: unknown,
82
+ ): void {
83
+ const finishedMs = Date.now();
84
+ ctx.observe(
85
+ createExecutionEvent({
86
+ kind: error ? "tool.failed" : "tool.executed",
87
+ requestId: ctx.requestId,
88
+ executionId: `tool_${call.id || crypto.randomUUID()}`,
89
+ parentExecutionId: ctx.executionId,
90
+ ...(error instanceof Error ? { errorKind: error.name } : {}),
91
+ data: {
92
+ toolName: call.name,
93
+ toolCallId: call.id,
94
+ startedAt: new Date(startedMs).toISOString(),
95
+ finishedAt: new Date(finishedMs).toISOString(),
96
+ durationMs: finishedMs - startedMs,
97
+ lilText: toolTrace(call, result),
98
+ },
99
+ }),
100
+ );
101
+ }
102
+
83
103
  export function parseToolArguments(
84
104
  toolName: string,
85
105
  argumentsText: string,
@@ -106,3 +126,8 @@ function toolTrace(call: ToolCall, result: string): string {
106
126
  "RESULT_END",
107
127
  ].join("\n");
108
128
  }
129
+
130
+ function toolFailureResult(toolName: string, error: unknown): string {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ return `Tool "${toolName}" failed: ${message}`;
133
+ }
package/src/tools.ts CHANGED
@@ -264,7 +264,7 @@ function appendToolResults(
264
264
  appendToolInteraction(next, {
265
265
  callId: result.id,
266
266
  name: result.name,
267
- args: parseToolArguments(result.name, result.args),
267
+ args: result.args,
268
268
  result: result.result,
269
269
  }),
270
270
  request,
@@ -1,10 +1,8 @@
1
1
  import {
2
2
  contentText,
3
3
  createProgram,
4
- deltaText,
5
4
  emitChatCompletionsRequest,
6
5
  emitChatCompletionsStreamChunk,
7
- getModel,
8
6
  parseChatCompletionsRequest,
9
7
  parseChatCompletionsStreamChunk,
10
8
  programAttachments,
@@ -33,18 +31,42 @@ import {
33
31
  writeReasoning,
34
32
  } from "../src/stream/index.ts";
35
33
  import { createGoalExecutor, fallback, retry } from "../src/loops/index.ts";
36
- import {
37
- appendInternalDraft,
38
- createTwoPassExecutor,
39
- INTERNAL_DRAFT_TOOL_NAME,
40
- } from "../src/managed/two-pass.ts";
41
34
  import { createAttachmentToTextExecutor } from "../src/managed/attachment-to-text.ts";
35
+ import { createCapabilitiesExecutor } from "../src/managed/capabilities.ts";
42
36
  import type { Executor, ExecutorContext, OutputSink } from "../src/types.ts";
43
37
 
44
38
  const encoder = new TextEncoder();
45
39
  const decoder = new TextDecoder();
46
40
 
47
41
  describe("@neutrome/lilsdk", () => {
42
+ it("leaves capability tool prefixes directly available", async () => {
43
+ const request = parseChatCompletionsRequest(
44
+ encoder.encode(
45
+ JSON.stringify({
46
+ tools: [
47
+ { type: "function", function: { name: "keep_search" } },
48
+ { type: "function", function: { name: "managed_weather" } },
49
+ ],
50
+ }),
51
+ ),
52
+ );
53
+ let innerRequest: Program | undefined;
54
+ const executor = createCapabilitiesExecutor(
55
+ executorFromExecute(async (value) => {
56
+ innerRequest = value;
57
+ return value;
58
+ }),
59
+ { skipPrefixes: ["keep_"] },
60
+ );
61
+
62
+ await executor.execute(request, buildExecutorContext());
63
+
64
+ expect(viewProgram(innerRequest!).tools.map((tool) => tool.name)).toEqual([
65
+ "keep_search",
66
+ "learn_capability",
67
+ ]);
68
+ });
69
+
48
70
  it("describes the latest attachment and removes it before the inner executor", async () => {
49
71
  const request = parseChatCompletionsRequest(
50
72
  encoder.encode(
@@ -188,27 +210,6 @@ describe("@neutrome/lilsdk", () => {
188
210
  );
189
211
  });
190
212
 
191
- it("appends internal drafts with fixed SDK metadata and empty args", () => {
192
- const request = parseChatCompletionsRequest(
193
- encoder.encode(
194
- JSON.stringify({
195
- model: "virtual-model",
196
- messages: [{ role: "user", content: "hello" }],
197
- }),
198
- ),
199
- );
200
-
201
- const updated = appendInternalDraft(request, "draft answer");
202
- const emitted = JSON.parse(
203
- decoder.decode(emitChatCompletionsRequest(updated)),
204
- );
205
-
206
- expect(emitted.messages[1].tool_calls[0].function.name).toBe(
207
- INTERNAL_DRAFT_TOOL_NAME,
208
- );
209
- expect(emitted.messages[1].tool_calls[0].function.arguments).toBe("{}");
210
- expect(emitted.messages[2].content).toBe("draft answer");
211
- });
212
213
 
213
214
  it("appends generic synthetic tool interactions", () => {
214
215
  const request = parseChatCompletionsRequest(
@@ -241,279 +242,6 @@ describe("@neutrome/lilsdk", () => {
241
242
  });
242
243
  });
243
244
 
244
- it("builds two-pass executors from model and settings resolvers", async () => {
245
- const request = parseChatCompletionsRequest(
246
- encoder.encode(
247
- JSON.stringify({
248
- model: "virtual-model",
249
- messages: [{ role: "user", content: "hello" }],
250
- }),
251
- ),
252
- );
253
- const seen: Program[] = [];
254
- const ctx = buildExecutorContext({
255
- async invoke(_executor, program) {
256
- seen.push(program);
257
- if (seen.length === 1) {
258
- return appendAssistantMessage(
259
- { code: [], buffers: [] },
260
- "draft answer",
261
- );
262
- }
263
- return appendAssistantMessage(
264
- { code: [], buffers: [] },
265
- "final answer",
266
- );
267
- },
268
- async *invokeStream() {
269
- throw new Error("streaming path is not used in this test");
270
- },
271
- });
272
-
273
- const executor = createTwoPassExecutor({
274
- draft: "smart-model",
275
- final: "base-model",
276
- resolveFinalSettings: () => ({
277
- reasoningLevel: "high",
278
- systemPrompt: "system prompt",
279
- }),
280
- });
281
-
282
- const result = await executor.execute(request, ctx);
283
-
284
- expect(seen.map((program) => getModel(program))).toEqual([
285
- "smart-model",
286
- "base-model",
287
- ]);
288
- expect(contentText(result)).toBe("final answer");
289
-
290
- const finalRequest = JSON.parse(
291
- decoder.decode(emitChatCompletionsRequest(seen[1]!)),
292
- );
293
- expect(finalRequest.messages[0]).toEqual({
294
- role: "system",
295
- content: "system prompt",
296
- });
297
- expect(finalRequest.reasoning_effort).toBe("high");
298
- expect(finalRequest.messages[2].tool_calls[0].function).toEqual({
299
- name: INTERNAL_DRAFT_TOOL_NAME,
300
- arguments: "{}",
301
- });
302
- expect(finalRequest.messages[3]).toEqual({
303
- role: "tool",
304
- tool_call_id: "knowledge_0",
305
- content: "draft answer",
306
- });
307
- });
308
-
309
- it("replaces draft system prompt and moves existing system prompt to first user message", async () => {
310
- const request = parseChatCompletionsRequest(
311
- encoder.encode(
312
- JSON.stringify({
313
- model: "virtual-model",
314
- messages: [
315
- { role: "system", content: "original system" },
316
- { role: "user", content: "hello" },
317
- ],
318
- }),
319
- ),
320
- );
321
- const seen: Program[] = [];
322
- const ctx = buildExecutorContext({
323
- async invoke(_executor, program) {
324
- seen.push(program);
325
- return appendAssistantMessage(
326
- { code: [], buffers: [] },
327
- seen.length === 1 ? "draft" : "final",
328
- );
329
- },
330
- async *invokeStream() {
331
- throw new Error("streaming path is not used in this test");
332
- },
333
- });
334
-
335
- const executor = createTwoPassExecutor({
336
- draft: "smart-model",
337
- final: "base-model",
338
- resolveDraftSettings: () => ({ systemPrompt: "draft system" }),
339
- });
340
-
341
- await executor.execute(request, ctx);
342
-
343
- const draftRequest = JSON.parse(
344
- decoder.decode(emitChatCompletionsRequest(seen[0]!)),
345
- );
346
- expect(draftRequest.messages).toEqual([
347
- { role: "system", content: "draft system" },
348
- { role: "user", content: "original system" },
349
- { role: "user", content: "hello" },
350
- ]);
351
- });
352
-
353
- it("composes model strings and custom executors", async () => {
354
- const calls: Array<{ model: string; streaming: boolean }> = [];
355
- const ctx = buildExecutorContext({
356
- async invoke(_executor, program) {
357
- calls.push({ model: getModel(program) ?? "", streaming: false });
358
- return appendAssistantMessage(
359
- { code: [], buffers: [] },
360
- "model answer",
361
- );
362
- },
363
- async *invokeStream(_executor, program) {
364
- calls.push({ model: getModel(program) ?? "", streaming: true });
365
- yield parseChatCompletionsStreamChunk(
366
- encoder.encode(
367
- JSON.stringify({
368
- id: "stream-response",
369
- model: "base-model",
370
- choices: [{ index: 0, delta: { content: "hello" } }],
371
- }),
372
- ),
373
- );
374
- },
375
- });
376
-
377
- const request = parseChatCompletionsRequest(
378
- encoder.encode(
379
- JSON.stringify({
380
- model: "virtual-model",
381
- messages: [{ role: "user", content: "hello" }],
382
- }),
383
- ),
384
- );
385
- const first = await ctx.invoke("smart-model", request);
386
- const chunks: Program[] = [];
387
- for await (const chunk of ctx.invokeStream("base-model", request)) {
388
- chunks.push(chunk);
389
- }
390
-
391
- expect(calls).toEqual([
392
- { model: "smart-model", streaming: false },
393
- { model: "base-model", streaming: true },
394
- ]);
395
- expect(contentText(first)).toBe("model answer");
396
- expect(
397
- decoder.decode(emitChatCompletionsStreamChunk(chunks[0]!)),
398
- ).toContain("hello");
399
- expect(getModel(request)).toBe("virtual-model");
400
-
401
- const customExecutor = executorFromExecute(async () =>
402
- appendAssistantMessage({ code: [], buffers: [] }, "custom answer"),
403
- );
404
- const custom = await ctx.invoke(customExecutor, request);
405
- expect(contentText(custom)).toBe("custom answer");
406
-
407
- const twoPass = createTwoPassExecutor({
408
- draft: customExecutor,
409
- final: customExecutor,
410
- });
411
- expect(contentText(await twoPass.execute(request, ctx))).toBe(
412
- "custom answer",
413
- );
414
- });
415
-
416
- it("can skip the draft pass and limits the final context length", async () => {
417
- const request = parseChatCompletionsRequest(
418
- encoder.encode(
419
- JSON.stringify({
420
- model: "virtual-model",
421
- messages: [{ role: "user", content: "request" }],
422
- }),
423
- ),
424
- );
425
- const seen: Program[] = [];
426
- const ctx = buildExecutorContext({
427
- async invoke(_executor, program) {
428
- seen.push(program);
429
- return appendAssistantMessage(
430
- { code: [], buffers: [] },
431
- seen.length === 1 ? "a very long draft" : "final",
432
- );
433
- },
434
- async *invokeStream() {
435
- throw new Error("streaming path is not used in this test");
436
- },
437
- });
438
-
439
- const executor = createTwoPassExecutor({
440
- draft: "smart-model",
441
- final: "base-model",
442
- maxTotalContextLength: 10,
443
- resolveDraftSettings: () => ({ systemPrompt: "draft" }),
444
- });
445
- await executor.execute(request, ctx);
446
-
447
- const finalRequest = JSON.parse(
448
- decoder.decode(emitChatCompletionsRequest(seen[1]!)),
449
- );
450
- expect(finalRequest.messages.at(-1)?.content).toBe("a v");
451
-
452
- let receivedRequest: Program | undefined;
453
- let receivedContext: ExecutorContext | undefined;
454
- const directFinal = createTwoPassExecutor({
455
- draft: "unused",
456
- final: "base-model",
457
- resolveDraftSettings: (incomingRequest, incomingContext) => {
458
- receivedRequest = incomingRequest;
459
- receivedContext = incomingContext;
460
- return null;
461
- },
462
- });
463
- await directFinal.execute(request, ctx);
464
- expect(getModel(seen.at(-1)!)).toBe("base-model");
465
- expect(receivedRequest).toBe(request);
466
- expect(receivedContext).toBe(ctx);
467
-
468
- const draftOnly = createTwoPassExecutor({
469
- draft: "smart-model",
470
- final: "unused",
471
- resolveFinalSettings: () => null,
472
- });
473
- await draftOnly.execute(request, ctx);
474
- expect(getModel(seen.at(-1)!)).toBe("smart-model");
475
- });
476
-
477
- it("keeps two-pass drafts inside the final stream", async () => {
478
- const executor = createTwoPassExecutor({
479
- draft: "draft",
480
- final: "final",
481
- });
482
- const ctx = buildExecutorContext({
483
- async invoke() {
484
- throw new Error("non-streaming path is not used in this test");
485
- },
486
- async *invokeStream(executor) {
487
- const text = executor === "draft" ? "draft" : "final";
488
- yield createProgram({
489
- code: [
490
- {
491
- opcode: Opcode.STREAM_DELTA,
492
- value: { kind: "string", value: text },
493
- },
494
- {
495
- opcode: Opcode.RESP_DONE,
496
- value: { kind: "string", value: "stop" },
497
- },
498
- { opcode: Opcode.STREAM_END, value: { kind: "none" } },
499
- ],
500
- });
501
- },
502
- });
503
-
504
- const chunks: Program[] = [];
505
- for await (const chunk of executor.stream({ code: [], buffers: [] }, ctx)) {
506
- chunks.push(chunk);
507
- }
508
- const opcodes = chunks.flatMap((chunk) =>
509
- chunk.code.map((instruction) => instruction.opcode),
510
- );
511
-
512
- expect(
513
- opcodes.filter((opcode) => opcode === Opcode.STREAM_END),
514
- ).toHaveLength(1);
515
- expect(chunks.map(deltaText).filter(Boolean)).toContain("final");
516
- });
517
245
 
518
246
  it("writes reasoning helpers to a sink", async () => {
519
247
  const emitted: string[] = [];
@@ -5,8 +5,9 @@ import {
5
5
  Opcode,
6
6
  type Instruction,
7
7
  type Program,
8
+ viewProgram,
8
9
  } from "@neutrome/lil-engine";
9
- import { connectTools, ToolArgumentsError } from "../src/tools.ts";
10
+ import { connectTools } from "../src/tools.ts";
10
11
  import type { ExecutorContext, Tool } from "../src/types.ts";
11
12
 
12
13
  function buildToolCallResponse(
@@ -173,22 +174,67 @@ describe("connectTools", () => {
173
174
  expect(hasAnswer).toBe(true);
174
175
  });
175
176
 
176
- it("rejects malformed connected-tool arguments", async () => {
177
+ it("returns malformed connected-tool arguments to the model", async () => {
177
178
  const executor = connectTools([calculatorTool], "test-model");
179
+ let retryRequest: Program | undefined;
180
+ let invocations = 0;
178
181
  const ctx = buildCtx({
179
- async invoke() {
180
- return buildToolCallResponse([
181
- { id: "call_1", name: "calculator", args: "{not json}" },
182
- ]);
182
+ async invoke(_executor, request) {
183
+ invocations += 1;
184
+ if (invocations === 1) {
185
+ return buildToolCallResponse([
186
+ { id: "call_1", name: "calculator", args: "{not json}" },
187
+ ]);
188
+ }
189
+ retryRequest = request;
190
+ return buildTextResponse("I need valid calculator arguments.");
183
191
  },
184
192
  async *invokeStream() {
185
193
  return;
186
194
  },
187
195
  });
188
196
 
189
- await expect(executor.execute(createProgram(), ctx)).rejects.toBeInstanceOf(
190
- ToolArgumentsError,
191
- );
197
+ const result = await executor.execute(createProgram(), ctx);
198
+
199
+ expect(invocations).toBe(2);
200
+ expect(
201
+ viewProgram(retryRequest!).messages.at(-1)?.toolResult?.text,
202
+ ).toBe('Tool "calculator" failed: Tool "calculator" received invalid JSON object arguments');
203
+ expect(callData(result)).toHaveLength(0);
204
+ });
205
+
206
+ it("returns thrown connected-tool errors to the model", async () => {
207
+ const failingTool: Tool = {
208
+ ...calculatorTool,
209
+ async execute() {
210
+ throw new Error("Calculator service is unavailable");
211
+ },
212
+ };
213
+ const executor = connectTools([failingTool], "test-model");
214
+ let retryRequest: Program | undefined;
215
+ let invocations = 0;
216
+ const ctx = buildCtx({
217
+ async invoke(_executor, request) {
218
+ invocations += 1;
219
+ if (invocations === 1) {
220
+ return buildToolCallResponse([
221
+ { id: "call_1", name: "calculator", args: '{"expr":"2+2"}' },
222
+ ]);
223
+ }
224
+ retryRequest = request;
225
+ return buildTextResponse("I cannot calculate that right now.");
226
+ },
227
+ async *invokeStream() {
228
+ return;
229
+ },
230
+ });
231
+
232
+ await executor.execute(createProgram(), ctx);
233
+
234
+ expect(invocations).toBe(2);
235
+ expect(
236
+ viewProgram(retryRequest!).messages.at(-1)?.toolResult?.text,
237
+ ).toBe('Tool "calculator" failed: Calculator service is unavailable');
192
238
  });
193
239
 
194
240
  it("handles multiple tool calls in single response (parallel execution)", async () => {