@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.
- package/README.md +7 -4
- package/package.json +2 -2
- package/src/index.ts +3 -3
- package/src/loops/index.ts +50 -19
- package/src/managed/index.ts +1 -1
- package/src/managed/target-executor.ts +12 -0
- package/src/managed/twoPassExecutor.ts +18 -6
- package/src/observe.ts +2 -1
- package/src/output.ts +8 -2
- package/src/primitives/index.ts +45 -11
- package/src/stream/index.ts +5 -1
- package/src/synthetic/index.ts +22 -39
- package/src/tools-support.ts +108 -0
- package/src/tools.ts +144 -228
- package/src/types.ts +15 -7
- package/test/lilsdk-ts.test.ts +266 -102
- package/test/tools.test.ts +287 -94
- package/src/managed/wrap.ts +0 -17
package/test/tools.test.ts
CHANGED
|
@@ -6,10 +6,13 @@ import {
|
|
|
6
6
|
type Instruction,
|
|
7
7
|
type Program,
|
|
8
8
|
} from "@neutrome/lil-engine";
|
|
9
|
-
import {
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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: {
|
|
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("
|
|
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 =
|
|
70
|
+
const executor = connectTools(
|
|
71
|
+
[calculatorTool],
|
|
72
|
+
createTargetExecutor(target),
|
|
73
|
+
);
|
|
58
74
|
|
|
59
75
|
const request = createProgram({
|
|
60
|
-
code: [
|
|
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
|
-
|
|
65
|
-
|
|
84
|
+
const ctx = buildCtx(
|
|
85
|
+
{
|
|
86
|
+
async invoke() {
|
|
87
|
+
return buildTextResponse("Hello!");
|
|
88
|
+
},
|
|
89
|
+
async *invokeStream() {
|
|
90
|
+
yield buildTextResponse("Hello!");
|
|
91
|
+
},
|
|
66
92
|
},
|
|
67
|
-
|
|
68
|
-
|
|
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) =>
|
|
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 =
|
|
108
|
+
const executor = connectTools(
|
|
109
|
+
[calculatorTool],
|
|
110
|
+
createTargetExecutor(target),
|
|
111
|
+
);
|
|
82
112
|
|
|
83
113
|
let callCount = 0;
|
|
84
114
|
const request = createProgram({
|
|
85
|
-
code: [
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
191
|
+
const executor = connectTools([multiTool], createTargetExecutor(target));
|
|
126
192
|
|
|
127
193
|
let callCount = 0;
|
|
128
194
|
const request = createProgram();
|
|
129
|
-
const ctx = buildCtx(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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 =
|
|
222
|
+
const executor = connectTools(
|
|
223
|
+
[calculatorTool],
|
|
224
|
+
createTargetExecutor(target),
|
|
225
|
+
);
|
|
154
226
|
|
|
155
227
|
const request = createProgram();
|
|
156
|
-
const ctx = buildCtx(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
}
|
|
167
|
-
const
|
|
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 =
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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,
|
package/src/managed/wrap.ts
DELETED
|
@@ -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
|
-
}
|