@neutrome/lilsdk-ts 0.1.2 → 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,120 @@
1
+ import {
2
+ cloneInstruction,
3
+ type Instruction,
4
+ type Program,
5
+ } from "@neutrome/lil-engine";
6
+
7
+ export function indexOf(program: Program, needle: Instruction): number {
8
+ return program.code.findIndex((instruction) => instructionEquals(instruction, needle));
9
+ }
10
+
11
+ export function has(program: Program, needle: Instruction): boolean {
12
+ return indexOf(program, needle) >= 0;
13
+ }
14
+
15
+ export function find(program: Program, needle: Instruction): Instruction | undefined {
16
+ const i = indexOf(program, needle);
17
+ return i >= 0 ? cloneInstruction(program.code[i]!) : undefined;
18
+ }
19
+
20
+ export function walk(
21
+ program: Program,
22
+ callback: (instruction: Instruction, index: number, program: Program) => void,
23
+ ): void {
24
+ program.code.forEach((instruction, index) => callback(instruction, index, program));
25
+ }
26
+
27
+ export function map(
28
+ program: Program,
29
+ callback: (instruction: Instruction, index: number, program: Program) => Instruction,
30
+ ): Program {
31
+ return {
32
+ code: program.code.map((instruction, index) =>
33
+ cloneInstruction(callback(instruction, index, program))
34
+ ),
35
+ buffers: cloneBuffers(program.buffers),
36
+ };
37
+ }
38
+
39
+ export function filter(
40
+ program: Program,
41
+ callback: (instruction: Instruction, index: number, program: Program) => boolean,
42
+ ): Program {
43
+ return {
44
+ code: program.code
45
+ .filter((instruction, index) => callback(instruction, index, program))
46
+ .map(cloneInstruction),
47
+ buffers: cloneBuffers(program.buffers),
48
+ };
49
+ }
50
+
51
+ export function reduce<T>(
52
+ program: Program,
53
+ callback: (accumulator: T, instruction: Instruction, index: number, program: Program) => T,
54
+ initialValue: T,
55
+ ): T {
56
+ let accumulator = initialValue;
57
+ program.code.forEach((instruction, index) => {
58
+ accumulator = callback(accumulator, instruction, index, program);
59
+ });
60
+ return accumulator;
61
+ }
62
+
63
+ export function all(
64
+ program: Program,
65
+ callback: (instruction: Instruction, index: number, program: Program) => boolean,
66
+ ): boolean {
67
+ for (const [index, instruction] of program.code.entries()) {
68
+ if (!callback(instruction, index, program)) return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ export function any(
74
+ program: Program,
75
+ callback: (instruction: Instruction, index: number, program: Program) => boolean,
76
+ ): boolean {
77
+ for (const [index, instruction] of program.code.entries()) {
78
+ if (callback(instruction, index, program)) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ function instructionEquals(left: Instruction, right: Instruction): boolean {
84
+ if (left.opcode !== right.opcode || left.value.kind !== right.value.kind) {
85
+ return false;
86
+ }
87
+
88
+ switch (left.value.kind) {
89
+ case "none":
90
+ return true;
91
+ case "string":
92
+ case "float":
93
+ case "int":
94
+ case "ref":
95
+ return right.value.kind === left.value.kind && left.value.value === right.value.value;
96
+ case "json":
97
+ return right.value.kind === "json" && bytesEqual(left.value.value, right.value.value);
98
+ case "key_string":
99
+ return (
100
+ right.value.kind === "key_string" &&
101
+ left.value.key === right.value.key &&
102
+ left.value.value === right.value.value
103
+ );
104
+ case "key_json":
105
+ return (
106
+ right.value.kind === "key_json" &&
107
+ left.value.key === right.value.key &&
108
+ bytesEqual(left.value.value, right.value.value)
109
+ );
110
+ }
111
+ }
112
+
113
+ function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
114
+ if (left.byteLength !== right.byteLength) return false;
115
+ return left.every((value, index) => value === right[index]);
116
+ }
117
+
118
+ function cloneBuffers(buffers: Uint8Array[]): Uint8Array[] {
119
+ return buffers.map((buffer) => buffer.slice());
120
+ }
@@ -0,0 +1,4 @@
1
+ export type { ObservedExecution, StreamObservationHooks } from "../observe.ts";
2
+
3
+ export { observeExecutionStream } from "../observe.ts";
4
+ export { streamReasoningDelta, streamTextResponse, writeReasoning } from "../output.ts";
@@ -0,0 +1,151 @@
1
+ import {
2
+ appendInstructions,
3
+ createProgram,
4
+ insertAfter,
5
+ insertBefore,
6
+ messages,
7
+ Opcode,
8
+ setModel,
9
+ toolDefinitions,
10
+ type Instruction,
11
+ type Opcode as OpcodeValue,
12
+ type Program,
13
+ } from "@neutrome/lil-engine";
14
+
15
+ const encoder = new TextEncoder();
16
+
17
+ export type TextMessageRole = "system" | "user" | "assistant";
18
+
19
+ export type ToolInteraction = {
20
+ callId: string;
21
+ name: string;
22
+ args?: unknown;
23
+ result: unknown;
24
+ };
25
+
26
+ export function makeRequest(model: string): Program {
27
+ return setModel(createProgram(), model);
28
+ }
29
+
30
+ export function makeResponse(model: string): Program {
31
+ return setModel(createProgram(), model);
32
+ }
33
+
34
+ export function makeMessage(role: TextMessageRole, text: string): Program {
35
+ return createProgram({ code: buildMessage(role, text) });
36
+ }
37
+
38
+ export function prependSystemPrompt(program: Program, text: string): Program {
39
+ const systemMessage = buildMessage("system", text);
40
+ const spans = messages(program);
41
+ if (spans.length === 0) {
42
+ return appendInstructions(program, systemMessage);
43
+ }
44
+ return insertBefore(program, spans[0]!.start, systemMessage);
45
+ }
46
+
47
+ export function appendUserMessage(program: Program, text: string): Program {
48
+ return appendInstructions(program, buildMessage("user", text));
49
+ }
50
+
51
+ export function appendAssistantMessage(program: Program, text: string): Program {
52
+ return appendInstructions(program, buildMessage("assistant", text));
53
+ }
54
+
55
+ export function makeToolCall(
56
+ name: string,
57
+ args: unknown = {},
58
+ callId: string = crypto.randomUUID(),
59
+ ): Program {
60
+ return createProgram({
61
+ code: [
62
+ { opcode: Opcode.MSG_START, value: { kind: "none" } },
63
+ { opcode: Opcode.ROLE_AST, value: { kind: "none" } },
64
+ { opcode: Opcode.CALL_START, value: { kind: "string", value: callId } },
65
+ { opcode: Opcode.CALL_NAME, value: { kind: "string", value: name } },
66
+ { opcode: Opcode.CALL_ARGS, value: { kind: "json", value: encodeJson(args) } },
67
+ { opcode: Opcode.CALL_END, value: { kind: "none" } },
68
+ { opcode: Opcode.MSG_END, value: { kind: "none" } },
69
+ ],
70
+ });
71
+ }
72
+
73
+ export function makeToolResponse(callId: string, result: unknown): Program {
74
+ return createProgram({ code: buildToolResponse(callId, result) });
75
+ }
76
+
77
+ export function appendToolInteraction(
78
+ program: Program,
79
+ interaction: ToolInteraction,
80
+ ): Program {
81
+ return appendInstructions(program, [
82
+ ...makeToolCall(interaction.name, interaction.args ?? {}, interaction.callId).code,
83
+ ...buildToolResponse(interaction.callId, interaction.result),
84
+ ]);
85
+ }
86
+
87
+ export function addTool(
88
+ program: Program,
89
+ name: string,
90
+ description: string,
91
+ schemaJson: Uint8Array,
92
+ ): Program {
93
+ const definition: Instruction[] = [
94
+ { opcode: Opcode.DEF_START, value: { kind: "none" } },
95
+ { opcode: Opcode.DEF_NAME, value: { kind: "string", value: name } },
96
+ { opcode: Opcode.DEF_DESC, value: { kind: "string", value: description } },
97
+ { opcode: Opcode.DEF_SCHEMA, value: { kind: "json", value: schemaJson.slice() } },
98
+ { opcode: Opcode.DEF_END, value: { kind: "none" } },
99
+ ];
100
+
101
+ const defs = toolDefinitions(program);
102
+ if (defs.length > 0) {
103
+ return insertAfter(program, defs.at(-1)!.end, definition);
104
+ }
105
+
106
+ const spans = messages(program);
107
+ if (spans.length > 0) {
108
+ return insertBefore(program, spans[0]!.start, definition);
109
+ }
110
+
111
+ return appendInstructions(program, definition);
112
+ }
113
+
114
+ function buildMessage(role: TextMessageRole, text: string): Instruction[] {
115
+ return [
116
+ { opcode: Opcode.MSG_START, value: { kind: "none" } },
117
+ { opcode: roleOpcode(role), value: { kind: "none" } },
118
+ { opcode: Opcode.TXT_CHUNK, value: { kind: "string", value: text } },
119
+ { opcode: Opcode.MSG_END, value: { kind: "none" } },
120
+ ];
121
+ }
122
+
123
+ function buildToolResponse(callId: string, result: unknown): Instruction[] {
124
+ return [
125
+ { opcode: Opcode.MSG_START, value: { kind: "none" } },
126
+ { opcode: Opcode.ROLE_TOOL, value: { kind: "none" } },
127
+ { opcode: Opcode.RESULT_START, value: { kind: "string", value: callId } },
128
+ { opcode: Opcode.RESULT_DATA, value: { kind: "string", value: stringifyResult(result) } },
129
+ { opcode: Opcode.RESULT_END, value: { kind: "none" } },
130
+ { opcode: Opcode.MSG_END, value: { kind: "none" } },
131
+ ];
132
+ }
133
+
134
+ function roleOpcode(role: TextMessageRole): OpcodeValue {
135
+ switch (role) {
136
+ case "system":
137
+ return Opcode.ROLE_SYS;
138
+ case "user":
139
+ return Opcode.ROLE_USR;
140
+ case "assistant":
141
+ return Opcode.ROLE_AST;
142
+ }
143
+ }
144
+
145
+ function encodeJson(value: unknown): Uint8Array {
146
+ return encoder.encode(JSON.stringify(value));
147
+ }
148
+
149
+ function stringifyResult(result: unknown): string {
150
+ return typeof result === "string" ? result : JSON.stringify(result);
151
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,357 @@
1
+ import {
2
+ callData,
3
+ createProgram,
4
+ isStreaming,
5
+ Opcode,
6
+ setStreaming,
7
+ type Instruction,
8
+ type Program,
9
+ } from "@neutrome/lil-engine";
10
+ import {
11
+ addTool,
12
+ appendToolInteraction,
13
+ prependSystemPrompt,
14
+ } from "./synthetic/index.ts";
15
+ import {
16
+ createSdkEvent,
17
+ type ExecutionTarget,
18
+ type Executor,
19
+ type ExecutorContext,
20
+ type Tool,
21
+ } from "./types.ts";
22
+
23
+ export type WithToolsOptions = {
24
+ maxIterations?: number;
25
+ };
26
+
27
+ const DEFAULT_MAX_ITERATIONS = 10;
28
+
29
+ function buildAugmenter(tools: Tool[]) {
30
+ const encoder = new TextEncoder();
31
+ const fragments = tools
32
+ .map((t) => t.systemPromptFragment)
33
+ .filter((f): f is string => !!f);
34
+
35
+ return function augmentRequest(request: Program): Program {
36
+ let augmented = request;
37
+ if (fragments.length > 0) {
38
+ augmented = prependSystemPrompt(augmented, fragments.join("\n\n"));
39
+ }
40
+ for (const tool of tools) {
41
+ augmented = addTool(
42
+ augmented,
43
+ tool.name,
44
+ tool.description,
45
+ encoder.encode(JSON.stringify(tool.schema)),
46
+ );
47
+ }
48
+ return augmented;
49
+ };
50
+ }
51
+
52
+ function buildToolTrace(call: { id: string; name: string; args: string }, result: string): string {
53
+ return [
54
+ `CALL_START ${JSON.stringify(call.id)}`,
55
+ `CALL_NAME ${JSON.stringify(call.name)}`,
56
+ `CALL_ARGS ${call.args}`,
57
+ "CALL_END",
58
+ "RESULT_START",
59
+ `RESULT_DATA ${JSON.stringify(result)}`,
60
+ "RESULT_END",
61
+ ].join("\n");
62
+ }
63
+
64
+ function buildCallExecutor(toolMap: Map<string, Tool>) {
65
+ return function executeConnectedCalls(
66
+ calls: Array<{ id: string; name: string; args: string }>,
67
+ ctx: ExecutorContext,
68
+ ): Promise<Array<{ id: string; name: string; args: string; result: string }>> {
69
+ return Promise.all(
70
+ calls.map(async (call) => {
71
+ const tool = toolMap.get(call.name)!;
72
+ let parsed: Record<string, unknown>;
73
+ try {
74
+ parsed = JSON.parse(call.args);
75
+ } catch {
76
+ parsed = {};
77
+ }
78
+ const result = await tool.execute(parsed, ctx);
79
+ ctx.observe(createSdkEvent({
80
+ kind: "tool.executed",
81
+ requestId: ctx.requestId,
82
+ executionId: `tool_${call.id || crypto.randomUUID()}`,
83
+ parentExecutionId: ctx.executionId,
84
+ data: {
85
+ toolName: call.name,
86
+ toolCallId: call.id,
87
+ lilText: buildToolTrace(call, result),
88
+ },
89
+ }));
90
+ return { ...call, result };
91
+ }),
92
+ );
93
+ };
94
+ }
95
+
96
+ type ToolDeltaAcc = { index: number; id: string; name: string; args: string };
97
+
98
+ function collectToolDeltasFromChunk(
99
+ chunk: Program,
100
+ acc: Map<number, ToolDeltaAcc>,
101
+ ) {
102
+ for (const instr of chunk.code) {
103
+ if (instr.opcode === Opcode.STREAM_TOOL_DELTA && instr.value.kind === "json") {
104
+ const parsed = JSON.parse(new TextDecoder().decode(instr.value.value)) as Record<string, unknown>;
105
+ const index = typeof parsed.index === "number" ? parsed.index : 0;
106
+ const existing = acc.get(index);
107
+ if (existing) {
108
+ if (typeof parsed.arguments === "string") existing.args += parsed.arguments;
109
+ } else {
110
+ acc.set(index, {
111
+ index,
112
+ id: typeof parsed.id === "string" ? parsed.id : "",
113
+ name: typeof parsed.name === "string" ? parsed.name : "",
114
+ args: typeof parsed.arguments === "string" ? parsed.arguments : "",
115
+ });
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ function filterChunkForClient(
122
+ chunk: Program,
123
+ connectedNames: Set<string>,
124
+ toolAcc: Map<number, ToolDeltaAcc>,
125
+ isIntermediate: boolean,
126
+ ): Program | null {
127
+ const kept: Instruction[] = [];
128
+
129
+ for (const instr of chunk.code) {
130
+ switch (instr.opcode) {
131
+ case Opcode.STREAM_TOOL_DELTA:
132
+ // Suppress connected tool deltas; pass through outer ones
133
+ if (instr.value.kind === "json") {
134
+ const parsed = JSON.parse(new TextDecoder().decode(instr.value.value)) as Record<string, unknown>;
135
+ const index = typeof parsed.index === "number" ? parsed.index : 0;
136
+ const tool = toolAcc.get(index);
137
+ if (tool && connectedNames.has(tool.name)) {
138
+ continue;
139
+ }
140
+ }
141
+ kept.push(instr);
142
+ break;
143
+ case Opcode.RESP_DONE:
144
+ if (isIntermediate) continue;
145
+ kept.push(instr);
146
+ break;
147
+ case Opcode.STREAM_END:
148
+ if (isIntermediate) continue;
149
+ kept.push(instr);
150
+ break;
151
+ default:
152
+ kept.push(instr);
153
+ break;
154
+ }
155
+ }
156
+
157
+ if (kept.length === 0) return null;
158
+ return createProgram({ code: kept });
159
+ }
160
+
161
+ async function* streamToolLoop(
162
+ toolMap: Map<string, Tool>,
163
+ maxIterations: number,
164
+ augmentRequest: (r: Program) => Program,
165
+ executeConnectedCalls: ReturnType<typeof buildCallExecutor>,
166
+ request: Program,
167
+ ctx: ExecutorContext,
168
+ invokeStream: (req: Program) => AsyncIterable<Program>,
169
+ ): AsyncGenerator<Program> {
170
+ const connectedNames = new Set(toolMap.keys());
171
+ let current = augmentRequest(request);
172
+ let yieldedStart = false;
173
+
174
+ for (let iteration = 0; iteration < maxIterations; iteration += 1) {
175
+ const toolAcc = new Map<number, ToolDeltaAcc>();
176
+ const isIntermediate = true; // assume intermediate; we'll yield end markers later if final
177
+
178
+ for await (const chunk of invokeStream(current)) {
179
+ collectToolDeltasFromChunk(chunk, toolAcc);
180
+
181
+ const filtered = filterChunkForClient(chunk, connectedNames, toolAcc, isIntermediate);
182
+ if (filtered) {
183
+ // Skip STREAM_START on iterations after the first (client already got it)
184
+ if (yieldedStart && filtered.code.length === 1 && filtered.code[0]!.opcode === Opcode.STREAM_START) {
185
+ continue;
186
+ }
187
+ yield filtered;
188
+ if (filtered.code.some((i) => i.opcode === Opcode.STREAM_START)) {
189
+ yieldedStart = true;
190
+ }
191
+ }
192
+ }
193
+
194
+ // Convert accumulated tool deltas to call data
195
+ const calls = [...toolAcc.values()]
196
+ .filter((t) => t.name)
197
+ .map((t) => ({ id: t.id, name: t.name, args: t.args }));
198
+
199
+ const connected = calls.filter((c) => toolMap.has(c.name));
200
+ const outer = calls.filter((c) => !toolMap.has(c.name));
201
+
202
+ if (connected.length === 0 || outer.length > 0) {
203
+ // Final iteration — emit suppressed end markers
204
+ const endInstructions: Instruction[] = [];
205
+ if (calls.length > 0 && outer.length > 0) {
206
+ endInstructions.push({ opcode: Opcode.RESP_DONE, value: { kind: "string", value: "tool_calls" } });
207
+ } else if (calls.length === 0) {
208
+ endInstructions.push({ opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } });
209
+ }
210
+ endInstructions.push({ opcode: Opcode.STREAM_END, value: { kind: "none" } });
211
+ yield createProgram({ code: endInstructions });
212
+ return;
213
+ }
214
+
215
+ // Execute connected tools and loop
216
+ const results = await executeConnectedCalls(connected, ctx);
217
+ let next = current;
218
+ for (const r of results) {
219
+ next = appendToolInteraction(next, {
220
+ callId: r.id,
221
+ name: r.name,
222
+ args: parseToolArgs(r.args),
223
+ result: r.result,
224
+ });
225
+ }
226
+ current = next;
227
+ }
228
+
229
+ // Max iterations reached — emit end
230
+ yield createProgram({ code: [
231
+ { opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } },
232
+ { opcode: Opcode.STREAM_END, value: { kind: "none" } },
233
+ ] });
234
+ }
235
+
236
+ export function withTools(
237
+ tools: Tool[],
238
+ target: ExecutionTarget,
239
+ options: WithToolsOptions = {},
240
+ ): Executor {
241
+ const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
242
+ const toolMap = new Map(tools.map((t) => [t.name, t]));
243
+ const augmentRequest = buildAugmenter(tools);
244
+ const executeConnectedCalls = buildCallExecutor(toolMap);
245
+
246
+ return {
247
+ async execute(request, ctx) {
248
+ const augmented = augmentRequest(request);
249
+ let current = isStreaming(augmented) ? setStreaming(augmented, false) : augmented;
250
+
251
+ for (let iteration = 0; iteration < maxIterations; iteration += 1) {
252
+ const response = await ctx.invoke(current, { target });
253
+ const calls = callData(response);
254
+ const connected = calls.filter((c) => toolMap.has(c.name));
255
+ const outer = calls.filter((c) => !toolMap.has(c.name));
256
+
257
+ if (connected.length === 0) return response;
258
+
259
+ const results = await executeConnectedCalls(connected, ctx);
260
+ let next = current;
261
+ for (const r of results) {
262
+ next = appendToolInteraction(next, {
263
+ callId: r.id,
264
+ name: r.name,
265
+ args: parseToolArgs(r.args),
266
+ result: r.result,
267
+ });
268
+ }
269
+
270
+ if (outer.length > 0) return response;
271
+ current = next;
272
+ }
273
+
274
+ return ctx.invoke(current, { target });
275
+ },
276
+
277
+ async *stream(request, ctx) {
278
+ yield* streamToolLoop(
279
+ toolMap,
280
+ maxIterations,
281
+ augmentRequest,
282
+ executeConnectedCalls,
283
+ request,
284
+ ctx,
285
+ (req) => ctx.invokeStream(req, { target }),
286
+ );
287
+ },
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Wraps an existing executor with connected tools. The inner executor is called
293
+ * with augmented requests (tool definitions + system fragments injected), and
294
+ * any tool calls it returns are automatically resolved in a loop.
295
+ */
296
+ export function connectTools(
297
+ tools: Tool[],
298
+ inner: Executor,
299
+ options: WithToolsOptions = {},
300
+ ): Executor {
301
+ const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
302
+ const toolMap = new Map(tools.map((t) => [t.name, t]));
303
+ const augmentRequest = buildAugmenter(tools);
304
+ const executeConnectedCalls = buildCallExecutor(toolMap);
305
+
306
+ return {
307
+ async execute(request, ctx) {
308
+ const augmented = augmentRequest(request);
309
+ let current = isStreaming(augmented) ? setStreaming(augmented, false) : augmented;
310
+
311
+ for (let iteration = 0; iteration < maxIterations; iteration += 1) {
312
+ const response = await inner.execute(current, ctx);
313
+ const calls = callData(response);
314
+ const connected = calls.filter((c) => toolMap.has(c.name));
315
+ const outer = calls.filter((c) => !toolMap.has(c.name));
316
+
317
+ if (connected.length === 0) return response;
318
+
319
+ const results = await executeConnectedCalls(connected, ctx);
320
+ let next = current;
321
+ for (const r of results) {
322
+ next = appendToolInteraction(next, {
323
+ callId: r.id,
324
+ name: r.name,
325
+ args: parseToolArgs(r.args),
326
+ result: r.result,
327
+ });
328
+ }
329
+
330
+ if (outer.length > 0) return response;
331
+ current = next;
332
+ }
333
+
334
+ return inner.execute(current, ctx);
335
+ },
336
+
337
+ async *stream(request, ctx) {
338
+ yield* streamToolLoop(
339
+ toolMap,
340
+ maxIterations,
341
+ augmentRequest,
342
+ executeConnectedCalls,
343
+ request,
344
+ ctx,
345
+ (req) => inner.stream(req, ctx),
346
+ );
347
+ },
348
+ };
349
+ }
350
+
351
+ function parseToolArgs(args: string): unknown {
352
+ try {
353
+ return JSON.parse(args || "{}");
354
+ } catch {
355
+ return {};
356
+ }
357
+ }