@neutrome/lilsdk-ts 0.1.3 → 0.2.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.
@@ -0,0 +1,259 @@
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 { withTools } from "../src/tools.ts";
10
+ import type { ExecutionTarget, ExecutorContext, Tool } from "../src/types.ts";
11
+
12
+ function buildToolCallResponse(calls: Array<{ id: string; name: string; args: string }>): Program {
13
+ const code: Instruction[] = [
14
+ { opcode: Opcode.MSG_START, value: { kind: "none" } },
15
+ { opcode: Opcode.ROLE_AST, value: { kind: "none" } },
16
+ ];
17
+ for (const call of calls) {
18
+ code.push(
19
+ { opcode: Opcode.CALL_START, value: { kind: "string", value: call.id } },
20
+ { opcode: Opcode.CALL_NAME, value: { kind: "string", value: call.name } },
21
+ { opcode: Opcode.CALL_ARGS, value: { kind: "json", value: new TextEncoder().encode(call.args) } },
22
+ { opcode: Opcode.CALL_END, value: { kind: "none" } },
23
+ );
24
+ }
25
+ code.push(
26
+ { opcode: Opcode.RESP_DONE, value: { kind: "string", value: "tool_calls" } },
27
+ { opcode: Opcode.MSG_END, value: { kind: "none" } },
28
+ );
29
+ return createProgram({ code });
30
+ }
31
+
32
+ function buildTextResponse(text: string): Program {
33
+ return createProgram({
34
+ code: [
35
+ { opcode: Opcode.MSG_START, value: { kind: "none" } },
36
+ { opcode: Opcode.ROLE_AST, value: { kind: "none" } },
37
+ { opcode: Opcode.TXT_CHUNK, value: { kind: "string", value: text } },
38
+ { opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } },
39
+ { opcode: Opcode.MSG_END, value: { kind: "none" } },
40
+ ],
41
+ });
42
+ }
43
+
44
+ const calculatorTool: Tool = {
45
+ name: "calculator",
46
+ description: "Evaluate a math expression",
47
+ schema: { type: "object", properties: { expr: { type: "string" } }, required: ["expr"] },
48
+ systemPromptFragment: "You have a calculator tool. Use it for math.",
49
+ async execute(args) {
50
+ return String(eval((args as { expr: string }).expr));
51
+ },
52
+ };
53
+
54
+ describe("withTools", () => {
55
+ it("passes through when LLM returns no tool calls", async () => {
56
+ const target: ExecutionTarget = { kind: "provider", model: "test-model" };
57
+ const executor = withTools([calculatorTool], target);
58
+
59
+ const request = createProgram({
60
+ code: [{ opcode: Opcode.SET_MODEL, value: { kind: "string", value: "test-model" } }],
61
+ });
62
+
63
+ const ctx = buildCtx({
64
+ async invoke() {
65
+ return buildTextResponse("Hello!");
66
+ },
67
+ async *invokeStream() {
68
+ yield buildTextResponse("Hello!");
69
+ },
70
+ }, target);
71
+ const result = await executor.execute(request, ctx);
72
+
73
+ const hasText = result.code.some(
74
+ (i) => i.opcode === Opcode.TXT_CHUNK && i.value.kind === "string" && i.value.value === "Hello!",
75
+ );
76
+ expect(hasText).toBe(true);
77
+ });
78
+
79
+ it("executes connected tool and loops until text response", async () => {
80
+ const target: ExecutionTarget = { kind: "provider", model: "test-model" };
81
+ const executor = withTools([calculatorTool], target);
82
+
83
+ let callCount = 0;
84
+ const request = createProgram({
85
+ code: [{ opcode: Opcode.SET_MODEL, value: { kind: "string", value: "test-model" } }],
86
+ });
87
+
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");
100
+ },
101
+ }, target);
102
+ const result = await executor.execute(request, ctx);
103
+
104
+ expect(callCount).toBe(2);
105
+ const hasAnswer = result.code.some(
106
+ (i) => i.opcode === Opcode.TXT_CHUNK && i.value.kind === "string" && i.value.value === "The answer is 4",
107
+ );
108
+ expect(hasAnswer).toBe(true);
109
+ });
110
+
111
+ it("handles multiple tool calls in single response (parallel execution)", async () => {
112
+ const execOrder: string[] = [];
113
+ const multiTool: Tool = {
114
+ name: "lookup",
115
+ description: "Look up a value",
116
+ schema: { type: "object", properties: { key: { type: "string" } } },
117
+ async execute(args) {
118
+ const key = (args as { key: string }).key;
119
+ execOrder.push(key);
120
+ return `value_of_${key}`;
121
+ },
122
+ };
123
+
124
+ const target: ExecutionTarget = { kind: "provider", model: "test-model" };
125
+ const executor = withTools([multiTool], target);
126
+
127
+ let callCount = 0;
128
+ 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");
142
+ },
143
+ }, target);
144
+ const result = await executor.execute(request, ctx);
145
+
146
+ expect(callCount).toBe(2);
147
+ expect(execOrder).toContain("alpha");
148
+ expect(execOrder).toContain("beta");
149
+ });
150
+
151
+ it("returns immediately when outer (non-connected) tool calls are present", async () => {
152
+ const target: ExecutionTarget = { kind: "provider", model: "test-model" };
153
+ const executor = withTools([calculatorTool], target);
154
+
155
+ 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
+ ]);
162
+ },
163
+ async *invokeStream() {
164
+ return;
165
+ },
166
+ }, target);
167
+ const result = await executor.execute(request, ctx);
168
+
169
+ const calls = callData(result);
170
+ expect(calls).toHaveLength(2);
171
+ expect(calls.some((c) => c.name === "external_tool")).toBe(true);
172
+ });
173
+
174
+ it("respects maxIterations", async () => {
175
+ const target: ExecutionTarget = { kind: "provider", model: "test-model" };
176
+ const executor = withTools([calculatorTool], target, { maxIterations: 2 });
177
+
178
+ let callCount = 0;
179
+ 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");
189
+ },
190
+ }, target);
191
+ await executor.execute(request, ctx);
192
+
193
+ // 2 iterations in the loop + 1 final invoke
194
+ expect(callCount).toBe(3);
195
+ });
196
+
197
+ it("streams final response after tool loop completes", async () => {
198
+ const target: ExecutionTarget = { kind: "provider", model: "test-model" };
199
+ const executor = withTools([calculatorTool], target);
200
+
201
+ let callCount = 0;
202
+ 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
+ });
226
+ },
227
+ }, target);
228
+
229
+ const chunks: Program[] = [];
230
+ for await (const chunk of executor.stream(request, ctx)) {
231
+ chunks.push(chunk);
232
+ }
233
+
234
+ expect(chunks.length).toBeGreaterThanOrEqual(2);
235
+ const deltas = chunks.filter((c) =>
236
+ c.code.some((i) => i.opcode === Opcode.STREAM_DELTA),
237
+ );
238
+ expect(deltas.length).toBeGreaterThan(0);
239
+ });
240
+ });
241
+
242
+ function buildCtx(
243
+ impl: Pick<ExecutorContext, "invoke" | "invokeStream">,
244
+ target: ExecutionTarget,
245
+ ): ExecutorContext {
246
+ const signal = new AbortController().signal;
247
+ return {
248
+ requestId: "req_test",
249
+ executionId: "exec_test",
250
+ async invoke(request: Program, options) {
251
+ return impl.invoke(request, { target: options?.target ?? target });
252
+ },
253
+ async *invokeStream(request: Program, options) {
254
+ yield* impl.invokeStream(request, { target: options?.target ?? target });
255
+ },
256
+ observe: () => {},
257
+ signal,
258
+ };
259
+ }
package/src/execution.ts DELETED
@@ -1,64 +0,0 @@
1
- import { setModel, setStreaming, type Program } from "@neutrome/lil-engine";
2
- import type { ExecuteModelOptions, ModelRuntime, OutputSink, StreamModelOptions } from "./types.ts";
3
-
4
- export async function executeWithModel(
5
- request: Program,
6
- runtime: ModelRuntime,
7
- model: string,
8
- options?: ExecuteModelOptions,
9
- ): Promise<Program> {
10
- return runtime.execute(setStreaming(setModel(request, model), false), options);
11
- }
12
-
13
- export async function pipeProgramStream(
14
- source: AsyncIterable<Program>,
15
- sink: OutputSink,
16
- ): Promise<void> {
17
- for await (const chunk of source) {
18
- await sink.write(chunk);
19
- }
20
- }
21
-
22
- export async function streamWithModel(
23
- request: Program,
24
- runtime: ModelRuntime,
25
- sink: OutputSink,
26
- model: string,
27
- options: StreamModelOptions = {},
28
- ): Promise<void> {
29
- const { close = true, ...invokeOptions } = options;
30
- await pipeProgramStream(
31
- runtime.stream(setStreaming(setModel(request, model), true), invokeOptions),
32
- sink,
33
- );
34
- if (options?.close !== false) {
35
- await sink.close();
36
- }
37
- }
38
-
39
- export function passthroughPrograms(
40
- programs: Iterable<Program> | AsyncIterable<Program>,
41
- sink: OutputSink,
42
- options: { close?: boolean } = {},
43
- ): Promise<void> {
44
- return passthroughProgramsInternal(programs, sink, options);
45
- }
46
-
47
- async function passthroughProgramsInternal(
48
- programs: Iterable<Program> | AsyncIterable<Program>,
49
- sink: OutputSink,
50
- options: { close?: boolean },
51
- ): Promise<void> {
52
- if (Symbol.asyncIterator in Object(programs)) {
53
- for await (const chunk of programs as AsyncIterable<Program>) {
54
- await sink.write(chunk);
55
- }
56
- } else {
57
- for (const chunk of programs as Iterable<Program>) {
58
- await sink.write(chunk);
59
- }
60
- }
61
- if (options?.close !== false) {
62
- await sink.close();
63
- }
64
- }
package/src/request.ts DELETED
@@ -1,21 +0,0 @@
1
- import { appendToolExchange, type Program } from "@neutrome/lil-engine";
2
-
3
- export type SyntheticToolExchange = {
4
- callId: string;
5
- name: string;
6
- args: string;
7
- result: string;
8
- };
9
-
10
- export function appendSyntheticToolExchange(
11
- request: Program,
12
- exchange: SyntheticToolExchange,
13
- ): Program {
14
- return appendToolExchange(
15
- request,
16
- exchange.callId,
17
- exchange.name,
18
- exchange.args,
19
- exchange.result,
20
- );
21
- }