@neutrome/lilsdk 0.3.5
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/README.md +31 -0
- package/package.json +27 -0
- package/src/index.ts +14 -0
- package/src/loops/index.ts +266 -0
- package/src/managed/index.ts +18 -0
- package/src/managed/target-executor.ts +12 -0
- package/src/managed/two-pass-request.ts +164 -0
- package/src/managed/twoPassExecutor.ts +200 -0
- package/src/observe.ts +95 -0
- package/src/output.ts +31 -0
- package/src/primitives/index.ts +154 -0
- package/src/stream/index.ts +8 -0
- package/src/synthetic/index.ts +134 -0
- package/src/tools-support.ts +108 -0
- package/src/tools.ts +279 -0
- package/src/types.ts +101 -0
- package/test/lilsdk-ts.test.ts +660 -0
- package/test/tools.test.ts +452 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
callData,
|
|
4
|
+
createProgram,
|
|
5
|
+
Opcode,
|
|
6
|
+
type Instruction,
|
|
7
|
+
type Program,
|
|
8
|
+
} from "@neutrome/lil-engine";
|
|
9
|
+
import { createTargetExecutor } from "../src/managed/target-executor.ts";
|
|
10
|
+
import { connectTools, ToolArgumentsError } from "../src/tools.ts";
|
|
11
|
+
import type { ExecutionTarget, ExecutorContext, Tool } from "../src/types.ts";
|
|
12
|
+
|
|
13
|
+
function buildToolCallResponse(
|
|
14
|
+
calls: Array<{ id: string; name: string; args: string }>,
|
|
15
|
+
): Program {
|
|
16
|
+
const code: Instruction[] = [
|
|
17
|
+
{ opcode: Opcode.MSG_START, value: { kind: "none" } },
|
|
18
|
+
{ opcode: Opcode.ROLE_AST, value: { kind: "none" } },
|
|
19
|
+
];
|
|
20
|
+
for (const call of calls) {
|
|
21
|
+
code.push(
|
|
22
|
+
{ opcode: Opcode.CALL_START, value: { kind: "string", value: call.id } },
|
|
23
|
+
{ opcode: Opcode.CALL_NAME, value: { kind: "string", value: call.name } },
|
|
24
|
+
{
|
|
25
|
+
opcode: Opcode.CALL_ARGS,
|
|
26
|
+
value: { kind: "json", value: new TextEncoder().encode(call.args) },
|
|
27
|
+
},
|
|
28
|
+
{ opcode: Opcode.CALL_END, value: { kind: "none" } },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
code.push(
|
|
32
|
+
{
|
|
33
|
+
opcode: Opcode.RESP_DONE,
|
|
34
|
+
value: { kind: "string", value: "tool_calls" },
|
|
35
|
+
},
|
|
36
|
+
{ opcode: Opcode.MSG_END, value: { kind: "none" } },
|
|
37
|
+
);
|
|
38
|
+
return createProgram({ code });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildTextResponse(text: string): Program {
|
|
42
|
+
return createProgram({
|
|
43
|
+
code: [
|
|
44
|
+
{ opcode: Opcode.MSG_START, value: { kind: "none" } },
|
|
45
|
+
{ opcode: Opcode.ROLE_AST, value: { kind: "none" } },
|
|
46
|
+
{ opcode: Opcode.TXT_CHUNK, value: { kind: "string", value: text } },
|
|
47
|
+
{ opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } },
|
|
48
|
+
{ opcode: Opcode.MSG_END, value: { kind: "none" } },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const calculatorTool: Tool = {
|
|
54
|
+
name: "calculator",
|
|
55
|
+
description: "Evaluate a math expression",
|
|
56
|
+
schema: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: { expr: { type: "string" } },
|
|
59
|
+
required: ["expr"],
|
|
60
|
+
},
|
|
61
|
+
systemPromptFragment: "You have a calculator tool. Use it for math.",
|
|
62
|
+
async execute(args) {
|
|
63
|
+
return String(eval((args as { expr: string }).expr));
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe("connectTools", () => {
|
|
68
|
+
it("passes through when LLM returns no tool calls", async () => {
|
|
69
|
+
const target: ExecutionTarget = { kind: "provider", model: "test-model" };
|
|
70
|
+
const executor = connectTools(
|
|
71
|
+
[calculatorTool],
|
|
72
|
+
createTargetExecutor(target),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const request = createProgram({
|
|
76
|
+
code: [
|
|
77
|
+
{
|
|
78
|
+
opcode: Opcode.SET_MODEL,
|
|
79
|
+
value: { kind: "string", value: "test-model" },
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const ctx = buildCtx(
|
|
85
|
+
{
|
|
86
|
+
async invoke() {
|
|
87
|
+
return buildTextResponse("Hello!");
|
|
88
|
+
},
|
|
89
|
+
async *invokeStream() {
|
|
90
|
+
yield buildTextResponse("Hello!");
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
target,
|
|
94
|
+
);
|
|
95
|
+
const result = await executor.execute(request, ctx);
|
|
96
|
+
|
|
97
|
+
const hasText = result.code.some(
|
|
98
|
+
(i) =>
|
|
99
|
+
i.opcode === Opcode.TXT_CHUNK &&
|
|
100
|
+
i.value.kind === "string" &&
|
|
101
|
+
i.value.value === "Hello!",
|
|
102
|
+
);
|
|
103
|
+
expect(hasText).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("executes connected tool and loops until text response", async () => {
|
|
107
|
+
const target: ExecutionTarget = { kind: "provider", model: "test-model" };
|
|
108
|
+
const executor = connectTools(
|
|
109
|
+
[calculatorTool],
|
|
110
|
+
createTargetExecutor(target),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
let callCount = 0;
|
|
114
|
+
const request = createProgram({
|
|
115
|
+
code: [
|
|
116
|
+
{
|
|
117
|
+
opcode: Opcode.SET_MODEL,
|
|
118
|
+
value: { kind: "string", value: "test-model" },
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
|
|
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
|
+
},
|
|
137
|
+
},
|
|
138
|
+
target,
|
|
139
|
+
);
|
|
140
|
+
const result = await executor.execute(request, ctx);
|
|
141
|
+
|
|
142
|
+
expect(callCount).toBe(2);
|
|
143
|
+
const hasAnswer = result.code.some(
|
|
144
|
+
(i) =>
|
|
145
|
+
i.opcode === Opcode.TXT_CHUNK &&
|
|
146
|
+
i.value.kind === "string" &&
|
|
147
|
+
i.value.value === "The answer is 4",
|
|
148
|
+
);
|
|
149
|
+
expect(hasAnswer).toBe(true);
|
|
150
|
+
});
|
|
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
|
+
|
|
177
|
+
it("handles multiple tool calls in single response (parallel execution)", async () => {
|
|
178
|
+
const execOrder: string[] = [];
|
|
179
|
+
const multiTool: Tool = {
|
|
180
|
+
name: "lookup",
|
|
181
|
+
description: "Look up a value",
|
|
182
|
+
schema: { type: "object", properties: { key: { type: "string" } } },
|
|
183
|
+
async execute(args) {
|
|
184
|
+
const key = (args as { key: string }).key;
|
|
185
|
+
execOrder.push(key);
|
|
186
|
+
return `value_of_${key}`;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const target: ExecutionTarget = { kind: "provider", model: "test-model" };
|
|
191
|
+
const executor = connectTools([multiTool], createTargetExecutor(target));
|
|
192
|
+
|
|
193
|
+
let callCount = 0;
|
|
194
|
+
const request = createProgram();
|
|
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
|
+
},
|
|
210
|
+
},
|
|
211
|
+
target,
|
|
212
|
+
);
|
|
213
|
+
const result = await executor.execute(request, ctx);
|
|
214
|
+
|
|
215
|
+
expect(callCount).toBe(2);
|
|
216
|
+
expect(execOrder).toContain("alpha");
|
|
217
|
+
expect(execOrder).toContain("beta");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("returns immediately when outer (non-connected) tool calls are present", async () => {
|
|
221
|
+
const target: ExecutionTarget = { kind: "provider", model: "test-model" };
|
|
222
|
+
const executor = connectTools(
|
|
223
|
+
[calculatorTool],
|
|
224
|
+
createTargetExecutor(target),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const request = createProgram();
|
|
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
|
+
},
|
|
239
|
+
},
|
|
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);
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
const guardedExecutor = connectTools([tool], createTargetExecutor(target));
|
|
251
|
+
const result = await guardedExecutor.execute(request, ctx);
|
|
252
|
+
|
|
253
|
+
const calls = callData(result);
|
|
254
|
+
expect(calls).toHaveLength(2);
|
|
255
|
+
expect(calls.some((c) => c.name === "external_tool")).toBe(true);
|
|
256
|
+
expect(executions).toBe(0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("respects maxIterations", async () => {
|
|
260
|
+
const target: ExecutionTarget = { kind: "provider", model: "test-model" };
|
|
261
|
+
const executor = connectTools(
|
|
262
|
+
[calculatorTool],
|
|
263
|
+
createTargetExecutor(target),
|
|
264
|
+
{ maxIterations: 2 },
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
let callCount = 0;
|
|
268
|
+
const request = createProgram();
|
|
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
|
+
},
|
|
284
|
+
},
|
|
285
|
+
target,
|
|
286
|
+
);
|
|
287
|
+
await executor.execute(request, ctx);
|
|
288
|
+
|
|
289
|
+
// 2 iterations in the loop + 1 final invoke
|
|
290
|
+
expect(callCount).toBe(3);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("streams final response after tool loop completes", async () => {
|
|
294
|
+
const target: ExecutionTarget = { kind: "provider", model: "test-model" };
|
|
295
|
+
const executor = connectTools(
|
|
296
|
+
[calculatorTool],
|
|
297
|
+
createTargetExecutor(target),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
let callCount = 0;
|
|
301
|
+
const request = createProgram();
|
|
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
|
+
},
|
|
335
|
+
},
|
|
336
|
+
target,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const chunks: Program[] = [];
|
|
340
|
+
for await (const chunk of executor.stream(request, ctx)) {
|
|
341
|
+
chunks.push(chunk);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
|
345
|
+
const deltas = chunks.filter((c) =>
|
|
346
|
+
c.code.some((i) => i.opcode === Opcode.STREAM_DELTA),
|
|
347
|
+
);
|
|
348
|
+
expect(deltas.length).toBeGreaterThan(0);
|
|
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
|
+
});
|
|
419
|
+
});
|
|
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
|
+
|
|
435
|
+
function buildCtx(
|
|
436
|
+
impl: Pick<ExecutorContext, "invoke" | "invokeStream">,
|
|
437
|
+
target: ExecutionTarget,
|
|
438
|
+
): ExecutorContext {
|
|
439
|
+
const signal = new AbortController().signal;
|
|
440
|
+
return {
|
|
441
|
+
requestId: "req_test",
|
|
442
|
+
executionId: "exec_test",
|
|
443
|
+
async invoke(request: Program, options) {
|
|
444
|
+
return impl.invoke(request, { target: options?.target ?? target });
|
|
445
|
+
},
|
|
446
|
+
async *invokeStream(request: Program, options) {
|
|
447
|
+
yield* impl.invokeStream(request, { target: options?.target ?? target });
|
|
448
|
+
},
|
|
449
|
+
observe: () => {},
|
|
450
|
+
signal,
|
|
451
|
+
};
|
|
452
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"allowArbitraryExtensions": true,
|
|
10
|
+
"rewriteRelativeImportExtensions": true,
|
|
11
|
+
"noUncheckedIndexedAccess": true,
|
|
12
|
+
"exactOptionalPropertyTypes": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"types": ["node", "vitest/globals"],
|
|
17
|
+
"outDir": "dist"
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*.ts", "test/**/*.ts"],
|
|
20
|
+
"exclude": ["dist", "node_modules"]
|
|
21
|
+
}
|
package/vitest.config.ts
ADDED