@neutrome/lilsdk-ts 0.2.5 → 0.3.0

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.
@@ -6,10 +6,13 @@ import {
6
6
  type Instruction,
7
7
  type Program,
8
8
  } from "@neutrome/lil-engine";
9
- import { withTools } from "../src/tools.ts";
9
+ import { createTargetExecutor } from "../src/managed/target-executor.ts";
10
+ import { connectTools, ToolArgumentsError } from "../src/tools.ts";
10
11
  import type { ExecutionTarget, ExecutorContext, Tool } from "../src/types.ts";
11
12
 
12
- function buildToolCallResponse(calls: Array<{ id: string; name: string; args: string }>): Program {
13
+ function buildToolCallResponse(
14
+ calls: Array<{ id: string; name: string; args: string }>,
15
+ ): Program {
13
16
  const code: Instruction[] = [
14
17
  { opcode: Opcode.MSG_START, value: { kind: "none" } },
15
18
  { opcode: Opcode.ROLE_AST, value: { kind: "none" } },
@@ -18,12 +21,18 @@ function buildToolCallResponse(calls: Array<{ id: string; name: string; args: st
18
21
  code.push(
19
22
  { opcode: Opcode.CALL_START, value: { kind: "string", value: call.id } },
20
23
  { opcode: Opcode.CALL_NAME, value: { kind: "string", value: call.name } },
21
- { opcode: Opcode.CALL_ARGS, value: { kind: "json", value: new TextEncoder().encode(call.args) } },
24
+ {
25
+ opcode: Opcode.CALL_ARGS,
26
+ value: { kind: "json", value: new TextEncoder().encode(call.args) },
27
+ },
22
28
  { opcode: Opcode.CALL_END, value: { kind: "none" } },
23
29
  );
24
30
  }
25
31
  code.push(
26
- { opcode: Opcode.RESP_DONE, value: { kind: "string", value: "tool_calls" } },
32
+ {
33
+ opcode: Opcode.RESP_DONE,
34
+ value: { kind: "string", value: "tool_calls" },
35
+ },
27
36
  { opcode: Opcode.MSG_END, value: { kind: "none" } },
28
37
  );
29
38
  return createProgram({ code });
@@ -44,70 +53,127 @@ function buildTextResponse(text: string): Program {
44
53
  const calculatorTool: Tool = {
45
54
  name: "calculator",
46
55
  description: "Evaluate a math expression",
47
- schema: { type: "object", properties: { expr: { type: "string" } }, required: ["expr"] },
56
+ schema: {
57
+ type: "object",
58
+ properties: { expr: { type: "string" } },
59
+ required: ["expr"],
60
+ },
48
61
  systemPromptFragment: "You have a calculator tool. Use it for math.",
49
62
  async execute(args) {
50
63
  return String(eval((args as { expr: string }).expr));
51
64
  },
52
65
  };
53
66
 
54
- describe("withTools", () => {
67
+ describe("connectTools", () => {
55
68
  it("passes through when LLM returns no tool calls", async () => {
56
69
  const target: ExecutionTarget = { kind: "provider", model: "test-model" };
57
- const executor = withTools([calculatorTool], target);
70
+ const executor = connectTools(
71
+ [calculatorTool],
72
+ createTargetExecutor(target),
73
+ );
58
74
 
59
75
  const request = createProgram({
60
- code: [{ opcode: Opcode.SET_MODEL, value: { kind: "string", value: "test-model" } }],
76
+ code: [
77
+ {
78
+ opcode: Opcode.SET_MODEL,
79
+ value: { kind: "string", value: "test-model" },
80
+ },
81
+ ],
61
82
  });
62
83
 
63
- const ctx = buildCtx({
64
- async invoke() {
65
- return buildTextResponse("Hello!");
84
+ const ctx = buildCtx(
85
+ {
86
+ async invoke() {
87
+ return buildTextResponse("Hello!");
88
+ },
89
+ async *invokeStream() {
90
+ yield buildTextResponse("Hello!");
91
+ },
66
92
  },
67
- async *invokeStream() {
68
- yield buildTextResponse("Hello!");
69
- },
70
- }, target);
93
+ target,
94
+ );
71
95
  const result = await executor.execute(request, ctx);
72
96
 
73
97
  const hasText = result.code.some(
74
- (i) => i.opcode === Opcode.TXT_CHUNK && i.value.kind === "string" && i.value.value === "Hello!",
98
+ (i) =>
99
+ i.opcode === Opcode.TXT_CHUNK &&
100
+ i.value.kind === "string" &&
101
+ i.value.value === "Hello!",
75
102
  );
76
103
  expect(hasText).toBe(true);
77
104
  });
78
105
 
79
106
  it("executes connected tool and loops until text response", async () => {
80
107
  const target: ExecutionTarget = { kind: "provider", model: "test-model" };
81
- const executor = withTools([calculatorTool], target);
108
+ const executor = connectTools(
109
+ [calculatorTool],
110
+ createTargetExecutor(target),
111
+ );
82
112
 
83
113
  let callCount = 0;
84
114
  const request = createProgram({
85
- code: [{ opcode: Opcode.SET_MODEL, value: { kind: "string", value: "test-model" } }],
115
+ code: [
116
+ {
117
+ opcode: Opcode.SET_MODEL,
118
+ value: { kind: "string", value: "test-model" },
119
+ },
120
+ ],
86
121
  });
87
122
 
88
- const ctx = buildCtx({
89
- async invoke() {
90
- callCount += 1;
91
- if (callCount === 1) {
92
- return buildToolCallResponse([
93
- { id: "call_1", name: "calculator", args: '{"expr":"2+2"}' },
94
- ]);
95
- }
96
- return buildTextResponse("The answer is 4");
97
- },
98
- async *invokeStream() {
99
- yield buildTextResponse("The answer is 4");
123
+ const ctx = buildCtx(
124
+ {
125
+ async invoke() {
126
+ callCount += 1;
127
+ if (callCount === 1) {
128
+ return buildToolCallResponse([
129
+ { id: "call_1", name: "calculator", args: '{"expr":"2+2"}' },
130
+ ]);
131
+ }
132
+ return buildTextResponse("The answer is 4");
133
+ },
134
+ async *invokeStream() {
135
+ yield buildTextResponse("The answer is 4");
136
+ },
100
137
  },
101
- }, target);
138
+ target,
139
+ );
102
140
  const result = await executor.execute(request, ctx);
103
141
 
104
142
  expect(callCount).toBe(2);
105
143
  const hasAnswer = result.code.some(
106
- (i) => i.opcode === Opcode.TXT_CHUNK && i.value.kind === "string" && i.value.value === "The answer is 4",
144
+ (i) =>
145
+ i.opcode === Opcode.TXT_CHUNK &&
146
+ i.value.kind === "string" &&
147
+ i.value.value === "The answer is 4",
107
148
  );
108
149
  expect(hasAnswer).toBe(true);
109
150
  });
110
151
 
152
+ it("rejects malformed connected-tool arguments", async () => {
153
+ const target: ExecutionTarget = { kind: "provider", model: "test-model" };
154
+ const executor = connectTools(
155
+ [calculatorTool],
156
+ createTargetExecutor(target),
157
+ );
158
+ const ctx = buildCtx(
159
+ {
160
+ async invoke() {
161
+ return buildToolCallResponse([
162
+ { id: "call_1", name: "calculator", args: "{not json}" },
163
+ ]);
164
+ },
165
+ async *invokeStream() {
166
+ return;
167
+ },
168
+ },
169
+ target,
170
+ );
171
+
172
+ await expect(executor.execute(createProgram(), ctx)).rejects.toBeInstanceOf(
173
+ ToolArgumentsError,
174
+ );
175
+ });
176
+
111
177
  it("handles multiple tool calls in single response (parallel execution)", async () => {
112
178
  const execOrder: string[] = [];
113
179
  const multiTool: Tool = {
@@ -122,25 +188,28 @@ describe("withTools", () => {
122
188
  };
123
189
 
124
190
  const target: ExecutionTarget = { kind: "provider", model: "test-model" };
125
- const executor = withTools([multiTool], target);
191
+ const executor = connectTools([multiTool], createTargetExecutor(target));
126
192
 
127
193
  let callCount = 0;
128
194
  const request = createProgram();
129
- const ctx = buildCtx({
130
- async invoke() {
131
- callCount += 1;
132
- if (callCount === 1) {
133
- return buildToolCallResponse([
134
- { id: "call_a", name: "lookup", args: '{"key":"alpha"}' },
135
- { id: "call_b", name: "lookup", args: '{"key":"beta"}' },
136
- ]);
137
- }
138
- return buildTextResponse("Done");
139
- },
140
- async *invokeStream() {
141
- yield buildTextResponse("Done");
195
+ const ctx = buildCtx(
196
+ {
197
+ async invoke() {
198
+ callCount += 1;
199
+ if (callCount === 1) {
200
+ return buildToolCallResponse([
201
+ { id: "call_a", name: "lookup", args: '{"key":"alpha"}' },
202
+ { id: "call_b", name: "lookup", args: '{"key":"beta"}' },
203
+ ]);
204
+ }
205
+ return buildTextResponse("Done");
206
+ },
207
+ async *invokeStream() {
208
+ yield buildTextResponse("Done");
209
+ },
142
210
  },
143
- }, target);
211
+ target,
212
+ );
144
213
  const result = await executor.execute(request, ctx);
145
214
 
146
215
  expect(callCount).toBe(2);
@@ -150,44 +219,71 @@ describe("withTools", () => {
150
219
 
151
220
  it("returns immediately when outer (non-connected) tool calls are present", async () => {
152
221
  const target: ExecutionTarget = { kind: "provider", model: "test-model" };
153
- const executor = withTools([calculatorTool], target);
222
+ const executor = connectTools(
223
+ [calculatorTool],
224
+ createTargetExecutor(target),
225
+ );
154
226
 
155
227
  const request = createProgram();
156
- const ctx = buildCtx({
157
- async invoke() {
158
- return buildToolCallResponse([
159
- { id: "call_1", name: "calculator", args: '{"expr":"1+1"}' },
160
- { id: "call_2", name: "external_tool", args: "{}" },
161
- ]);
228
+ const ctx = buildCtx(
229
+ {
230
+ async invoke() {
231
+ return buildToolCallResponse([
232
+ { id: "call_1", name: "calculator", args: '{"expr":"1+1"}' },
233
+ { id: "call_2", name: "external_tool", args: "{}" },
234
+ ]);
235
+ },
236
+ async *invokeStream() {
237
+ return;
238
+ },
162
239
  },
163
- async *invokeStream() {
164
- return;
240
+ target,
241
+ );
242
+ let executions = 0;
243
+ const tool: Tool = {
244
+ ...calculatorTool,
245
+ async execute(args) {
246
+ executions += 1;
247
+ return calculatorTool.execute(args, ctx);
165
248
  },
166
- }, target);
167
- const result = await executor.execute(request, ctx);
249
+ };
250
+ const guardedExecutor = connectTools([tool], createTargetExecutor(target));
251
+ const result = await guardedExecutor.execute(request, ctx);
168
252
 
169
253
  const calls = callData(result);
170
254
  expect(calls).toHaveLength(2);
171
255
  expect(calls.some((c) => c.name === "external_tool")).toBe(true);
256
+ expect(executions).toBe(0);
172
257
  });
173
258
 
174
259
  it("respects maxIterations", async () => {
175
260
  const target: ExecutionTarget = { kind: "provider", model: "test-model" };
176
- const executor = withTools([calculatorTool], target, { maxIterations: 2 });
261
+ const executor = connectTools(
262
+ [calculatorTool],
263
+ createTargetExecutor(target),
264
+ { maxIterations: 2 },
265
+ );
177
266
 
178
267
  let callCount = 0;
179
268
  const request = createProgram();
180
- const ctx = buildCtx({
181
- async invoke() {
182
- callCount += 1;
183
- return buildToolCallResponse([
184
- { id: `call_${callCount}`, name: "calculator", args: '{"expr":"1"}' },
185
- ]);
186
- },
187
- async *invokeStream() {
188
- yield buildTextResponse("final");
269
+ const ctx = buildCtx(
270
+ {
271
+ async invoke() {
272
+ callCount += 1;
273
+ return buildToolCallResponse([
274
+ {
275
+ id: `call_${callCount}`,
276
+ name: "calculator",
277
+ args: '{"expr":"1"}',
278
+ },
279
+ ]);
280
+ },
281
+ async *invokeStream() {
282
+ yield buildTextResponse("final");
283
+ },
189
284
  },
190
- }, target);
285
+ target,
286
+ );
191
287
  await executor.execute(request, ctx);
192
288
 
193
289
  // 2 iterations in the loop + 1 final invoke
@@ -196,35 +292,49 @@ describe("withTools", () => {
196
292
 
197
293
  it("streams final response after tool loop completes", async () => {
198
294
  const target: ExecutionTarget = { kind: "provider", model: "test-model" };
199
- const executor = withTools([calculatorTool], target);
295
+ const executor = connectTools(
296
+ [calculatorTool],
297
+ createTargetExecutor(target),
298
+ );
200
299
 
201
300
  let callCount = 0;
202
301
  const request = createProgram();
203
- const ctx = buildCtx({
204
- async invoke() {
205
- callCount += 1;
206
- if (callCount === 1) {
207
- return buildToolCallResponse([
208
- { id: "call_1", name: "calculator", args: '{"expr":"3+3"}' },
209
- ]);
210
- }
211
- return buildTextResponse("6");
212
- },
213
- async *invokeStream() {
214
- yield createProgram({
215
- code: [{ opcode: Opcode.STREAM_START, value: { kind: "none" } }],
216
- });
217
- yield createProgram({
218
- code: [{ opcode: Opcode.STREAM_DELTA, value: { kind: "string", value: "6" } }],
219
- });
220
- yield createProgram({
221
- code: [
222
- { opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } },
223
- { opcode: Opcode.STREAM_END, value: { kind: "none" } },
224
- ],
225
- });
302
+ const ctx = buildCtx(
303
+ {
304
+ async invoke() {
305
+ callCount += 1;
306
+ if (callCount === 1) {
307
+ return buildToolCallResponse([
308
+ { id: "call_1", name: "calculator", args: '{"expr":"3+3"}' },
309
+ ]);
310
+ }
311
+ return buildTextResponse("6");
312
+ },
313
+ async *invokeStream() {
314
+ yield createProgram({
315
+ code: [{ opcode: Opcode.STREAM_START, value: { kind: "none" } }],
316
+ });
317
+ yield createProgram({
318
+ code: [
319
+ {
320
+ opcode: Opcode.STREAM_DELTA,
321
+ value: { kind: "string", value: "6" },
322
+ },
323
+ ],
324
+ });
325
+ yield createProgram({
326
+ code: [
327
+ {
328
+ opcode: Opcode.RESP_DONE,
329
+ value: { kind: "string", value: "stop" },
330
+ },
331
+ { opcode: Opcode.STREAM_END, value: { kind: "none" } },
332
+ ],
333
+ });
334
+ },
226
335
  },
227
- }, target);
336
+ target,
337
+ );
228
338
 
229
339
  const chunks: Program[] = [];
230
340
  for await (const chunk of executor.stream(request, ctx)) {
@@ -237,8 +347,91 @@ describe("withTools", () => {
237
347
  );
238
348
  expect(deltas.length).toBeGreaterThan(0);
239
349
  });
350
+
351
+ it("assembles split stream tool deltas before executing a connected tool", async () => {
352
+ const target: ExecutionTarget = { kind: "provider", model: "test-model" };
353
+ const calls: unknown[] = [];
354
+ const tool: Tool = {
355
+ ...calculatorTool,
356
+ async execute(args) {
357
+ calls.push(args);
358
+ return "4";
359
+ },
360
+ };
361
+ const executor = connectTools([tool], createTargetExecutor(target));
362
+ let invocation = 0;
363
+ const ctx = buildCtx(
364
+ {
365
+ async invoke() {
366
+ return buildTextResponse("unused");
367
+ },
368
+ async *invokeStream() {
369
+ invocation += 1;
370
+ if (invocation === 1) {
371
+ yield streamToolDelta({ index: 0, id: "call_1" });
372
+ yield streamToolDelta({ index: 0, name: "calculator" });
373
+ yield streamToolDelta({ index: 0, arguments: '{"expr":"2+2"}' });
374
+ yield createProgram({
375
+ code: [
376
+ {
377
+ opcode: Opcode.RESP_DONE,
378
+ value: { kind: "string", value: "tool_calls" },
379
+ },
380
+ { opcode: Opcode.STREAM_END, value: { kind: "none" } },
381
+ ],
382
+ });
383
+ return;
384
+ }
385
+ yield createProgram({
386
+ code: [
387
+ {
388
+ opcode: Opcode.STREAM_DELTA,
389
+ value: { kind: "string", value: "4" },
390
+ },
391
+ {
392
+ opcode: Opcode.RESP_DONE,
393
+ value: { kind: "string", value: "stop" },
394
+ },
395
+ { opcode: Opcode.STREAM_END, value: { kind: "none" } },
396
+ ],
397
+ });
398
+ },
399
+ },
400
+ target,
401
+ );
402
+
403
+ const chunks: Program[] = [];
404
+ for await (const chunk of executor.stream(createProgram(), ctx))
405
+ chunks.push(chunk);
406
+
407
+ expect(calls).toEqual([{ expr: "2+2" }]);
408
+ expect(
409
+ chunks.some((chunk) =>
410
+ chunk.code.some((item) => item.opcode === Opcode.STREAM_DELTA),
411
+ ),
412
+ ).toBe(true);
413
+ expect(
414
+ chunks.some((chunk) =>
415
+ chunk.code.some((item) => item.opcode === Opcode.STREAM_TOOL_DELTA),
416
+ ),
417
+ ).toBe(false);
418
+ });
240
419
  });
241
420
 
421
+ function streamToolDelta(delta: Record<string, unknown>): Program {
422
+ return createProgram({
423
+ code: [
424
+ {
425
+ opcode: Opcode.STREAM_TOOL_DELTA,
426
+ value: {
427
+ kind: "json",
428
+ value: new TextEncoder().encode(JSON.stringify(delta)),
429
+ },
430
+ },
431
+ ],
432
+ });
433
+ }
434
+
242
435
  function buildCtx(
243
436
  impl: Pick<ExecutorContext, "invoke" | "invokeStream">,
244
437
  target: ExecutionTarget,
@@ -1,17 +0,0 @@
1
- import { setModel } from "@neutrome/lil-engine";
2
- import { Executor, type ExecutionTarget } from "../types";
3
-
4
- export function wrap(model: string): Executor {
5
- return {
6
- execute: (program, ctx) => ctx.invoke(setModel(program, model)),
7
- stream: (request, ctx) => ctx.invokeStream(setModel(request, model)),
8
- };
9
- }
10
-
11
- export function wrapNoAcl(provider: string, model: string): Executor {
12
- const target: ExecutionTarget = { kind: "provider", provider, model };
13
- return {
14
- execute: (program, ctx) => ctx.invoke(program, { target }),
15
- stream: (request, ctx) => ctx.invokeStream(request, { target }),
16
- };
17
- }