@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.
@@ -0,0 +1,108 @@
1
+ import { addTool, type Program } from "@neutrome/lil-engine";
2
+ import { prependSystemPrompt } from "./synthetic/index.ts";
3
+ import {
4
+ createExecutionEvent,
5
+ type ExecutorContext,
6
+ type Tool,
7
+ } from "./types.ts";
8
+
9
+ export type ToolCall = { id: string; name: string; args: string };
10
+ export type ToolExecution = ToolCall & { result: string };
11
+
12
+ export class ToolArgumentsError extends Error {
13
+ constructor(
14
+ readonly toolName: string,
15
+ readonly argumentsText: string,
16
+ ) {
17
+ super(`Tool "${toolName}" received invalid JSON object arguments`);
18
+ this.name = "ToolArgumentsError";
19
+ }
20
+ }
21
+
22
+ export function buildToolAugmenter(tools: readonly Tool[]) {
23
+ const encoder = new TextEncoder();
24
+ const fragments = tools
25
+ .map((tool) => tool.systemPromptFragment)
26
+ .filter((fragment): fragment is string => Boolean(fragment));
27
+
28
+ return (request: Program): Program => {
29
+ let augmented = request;
30
+ if (fragments.length > 0) {
31
+ augmented = prependSystemPrompt(augmented, fragments.join("\n\n"));
32
+ }
33
+ for (const tool of tools) {
34
+ augmented = addTool(
35
+ augmented,
36
+ tool.name,
37
+ tool.description,
38
+ encoder.encode(JSON.stringify(tool.schema)),
39
+ );
40
+ }
41
+ return augmented;
42
+ };
43
+ }
44
+
45
+ export function buildCallExecutor(toolMap: ReadonlyMap<string, Tool>) {
46
+ return async (
47
+ calls: readonly ToolCall[],
48
+ ctx: ExecutorContext,
49
+ ): Promise<ToolExecution[]> =>
50
+ Promise.all(
51
+ calls.map(async (call) => {
52
+ const tool = toolMap.get(call.name);
53
+ if (!tool) {
54
+ throw new Error(`No tool is registered for call "${call.name}"`);
55
+ }
56
+ const startedMs = Date.now();
57
+ const result = await tool.execute(
58
+ parseToolArguments(call.name, call.args),
59
+ ctx,
60
+ );
61
+ const finishedMs = Date.now();
62
+ ctx.observe(
63
+ createExecutionEvent({
64
+ kind: "tool.executed",
65
+ requestId: ctx.requestId,
66
+ executionId: `tool_${call.id || crypto.randomUUID()}`,
67
+ parentExecutionId: ctx.executionId,
68
+ data: {
69
+ toolName: call.name,
70
+ toolCallId: call.id,
71
+ startedAt: new Date(startedMs).toISOString(),
72
+ finishedAt: new Date(finishedMs).toISOString(),
73
+ durationMs: finishedMs - startedMs,
74
+ lilText: toolTrace(call, result),
75
+ },
76
+ }),
77
+ );
78
+ return { ...call, result };
79
+ }),
80
+ );
81
+ }
82
+
83
+ export function parseToolArguments(
84
+ toolName: string,
85
+ argumentsText: string,
86
+ ): Record<string, unknown> {
87
+ try {
88
+ const value: unknown = JSON.parse(argumentsText);
89
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
90
+ throw new ToolArgumentsError(toolName, argumentsText);
91
+ }
92
+ return value as Record<string, unknown>;
93
+ } catch {
94
+ throw new ToolArgumentsError(toolName, argumentsText);
95
+ }
96
+ }
97
+
98
+ function toolTrace(call: ToolCall, result: string): string {
99
+ return [
100
+ `CALL_START ${JSON.stringify(call.id)}`,
101
+ `CALL_NAME ${JSON.stringify(call.name)}`,
102
+ `CALL_ARGS ${call.args}`,
103
+ "CALL_END",
104
+ "RESULT_START",
105
+ `RESULT_DATA ${JSON.stringify(result)}`,
106
+ "RESULT_END",
107
+ ].join("\n");
108
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,279 @@
1
+ import {
2
+ callData,
3
+ createProgram,
4
+ decodeStreamToolDelta,
5
+ isStreaming,
6
+ Opcode,
7
+ setStreaming,
8
+ type Instruction,
9
+ type Program,
10
+ type StreamToolDelta,
11
+ } from "@neutrome/lil-engine";
12
+ import { appendToolInteraction } from "./synthetic/index.ts";
13
+ import { type Executor, type ExecutorContext, type Tool } from "./types.ts";
14
+ import {
15
+ buildCallExecutor,
16
+ buildToolAugmenter,
17
+ parseToolArguments,
18
+ type ToolCall,
19
+ type ToolExecution,
20
+ } from "./tools-support.ts";
21
+
22
+ export { ToolArgumentsError } from "./tools-support.ts";
23
+
24
+ export type WithToolsOptions = {
25
+ maxIterations?: number;
26
+ };
27
+
28
+ const DEFAULT_MAX_ITERATIONS = 10;
29
+
30
+ type ToolDeltaAcc = { index: number; id: string; name: string; args: string };
31
+ type DecodedToolDelta = { instruction: Instruction; delta: StreamToolDelta };
32
+
33
+ function collectToolDeltasFromChunk(
34
+ chunk: Program,
35
+ acc: Map<number, ToolDeltaAcc>,
36
+ ): DecodedToolDelta[] {
37
+ const deltas: DecodedToolDelta[] = [];
38
+ for (const instr of chunk.code) {
39
+ const delta = decodeStreamToolDelta(instr);
40
+ if (!delta) continue;
41
+ deltas.push({ instruction: instr, delta });
42
+ const current = acc.get(delta.index) ?? {
43
+ index: delta.index,
44
+ id: "",
45
+ name: "",
46
+ args: "",
47
+ };
48
+ if (delta.id) current.id = delta.id;
49
+ if (delta.name) current.name = delta.name;
50
+ if (delta.arguments) current.args += delta.arguments;
51
+ acc.set(delta.index, current);
52
+ }
53
+ return deltas;
54
+ }
55
+
56
+ function filterChunkForClient(
57
+ chunk: Program,
58
+ isIntermediate: boolean,
59
+ ): Program | null {
60
+ const kept: Instruction[] = [];
61
+
62
+ for (const instr of chunk.code) {
63
+ switch (instr.opcode) {
64
+ case Opcode.STREAM_TOOL_DELTA:
65
+ // Tool ownership can be revealed by a later fragment, so defer all deltas.
66
+ break;
67
+ case Opcode.RESP_DONE:
68
+ if (isIntermediate) continue;
69
+ kept.push(instr);
70
+ break;
71
+ case Opcode.STREAM_END:
72
+ if (isIntermediate) continue;
73
+ kept.push(instr);
74
+ break;
75
+ default:
76
+ kept.push(instr);
77
+ break;
78
+ }
79
+ }
80
+
81
+ if (kept.length === 0) return null;
82
+ return createProgram({ code: kept });
83
+ }
84
+
85
+ async function* streamToolLoop(
86
+ toolMap: Map<string, Tool>,
87
+ maxIterations: number,
88
+ augmentRequest: (r: Program) => Program,
89
+ executeConnectedCalls: ReturnType<typeof buildCallExecutor>,
90
+ request: Program,
91
+ ctx: ExecutorContext,
92
+ invokeStream: (req: Program) => AsyncIterable<Program>,
93
+ ): AsyncGenerator<Program> {
94
+ let state = createToolLoopState(augmentRequest(request));
95
+ let yieldedStart = false;
96
+
97
+ while (canRunToolLoop(state, maxIterations)) {
98
+ const toolAcc = new Map<number, ToolDeltaAcc>();
99
+ const toolDeltas: DecodedToolDelta[] = [];
100
+ const isIntermediate = true; // assume intermediate; we'll yield end markers later if final
101
+
102
+ for await (const chunk of invokeStream(state.request)) {
103
+ toolDeltas.push(...collectToolDeltasFromChunk(chunk, toolAcc));
104
+
105
+ const filtered = filterChunkForClient(chunk, isIntermediate);
106
+ if (filtered) {
107
+ // Skip STREAM_START on iterations after the first (client already got it)
108
+ if (
109
+ yieldedStart &&
110
+ filtered.code.length === 1 &&
111
+ filtered.code[0]?.opcode === Opcode.STREAM_START
112
+ ) {
113
+ continue;
114
+ }
115
+ yield filtered;
116
+ if (filtered.code.some((i) => i.opcode === Opcode.STREAM_START)) {
117
+ yieldedStart = true;
118
+ }
119
+ }
120
+ }
121
+
122
+ // Convert accumulated tool deltas to call data
123
+ const calls = [...toolAcc.values()]
124
+ .filter((t) => t.name)
125
+ .map((t) => ({ id: t.id, name: t.name, args: t.args }));
126
+
127
+ const decision = decideToolLoop(calls, toolMap);
128
+
129
+ if (decision.kind !== "continue") {
130
+ // Final iteration — emit suppressed end markers
131
+ const endInstructions: Instruction[] = [];
132
+ if (decision.kind === "external") {
133
+ const outerNames = new Set(decision.calls.map((call) => call.name));
134
+ const outerDeltas = toolDeltas
135
+ .filter(({ delta }) => {
136
+ const call = toolAcc.get(delta.index);
137
+ return call !== undefined && outerNames.has(call.name);
138
+ })
139
+ .map(({ instruction }) => instruction);
140
+ if (outerDeltas.length > 0) yield createProgram({ code: outerDeltas });
141
+ endInstructions.push({
142
+ opcode: Opcode.RESP_DONE,
143
+ value: { kind: "string", value: "tool_calls" },
144
+ });
145
+ } else if (calls.length === 0) {
146
+ endInstructions.push({
147
+ opcode: Opcode.RESP_DONE,
148
+ value: { kind: "string", value: "stop" },
149
+ });
150
+ }
151
+ endInstructions.push({
152
+ opcode: Opcode.STREAM_END,
153
+ value: { kind: "none" },
154
+ });
155
+ yield createProgram({ code: endInstructions });
156
+ return;
157
+ }
158
+
159
+ // Execute connected tools and loop
160
+ state = await advanceToolLoopState(
161
+ state,
162
+ decision.calls,
163
+ ctx,
164
+ executeConnectedCalls,
165
+ );
166
+ }
167
+
168
+ // Max iterations reached — emit end
169
+ yield createProgram({
170
+ code: [
171
+ { opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } },
172
+ { opcode: Opcode.STREAM_END, value: { kind: "none" } },
173
+ ],
174
+ });
175
+ }
176
+
177
+ export function connectTools(
178
+ tools: Tool[],
179
+ inner: Executor,
180
+ options: WithToolsOptions = {},
181
+ ): Executor {
182
+ const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
183
+ const toolMap = new Map(tools.map((t) => [t.name, t]));
184
+ const augmentRequest = buildToolAugmenter(tools);
185
+ const executeConnectedCalls = buildCallExecutor(toolMap);
186
+
187
+ return {
188
+ async execute(request, ctx) {
189
+ const augmented = augmentRequest(request);
190
+ let state = createToolLoopState(
191
+ isStreaming(augmented) ? setStreaming(augmented, false) : augmented,
192
+ );
193
+
194
+ while (canRunToolLoop(state, maxIterations)) {
195
+ const response = await inner.execute(state.request, ctx);
196
+ const calls = callData(response);
197
+ const decision = decideToolLoop(calls, toolMap);
198
+ if (decision.kind !== "continue") return response;
199
+
200
+ state = await advanceToolLoopState(
201
+ state,
202
+ decision.calls,
203
+ ctx,
204
+ executeConnectedCalls,
205
+ );
206
+ }
207
+
208
+ return inner.execute(state.request, ctx);
209
+ },
210
+
211
+ async *stream(request, ctx) {
212
+ yield* streamToolLoop(
213
+ toolMap,
214
+ maxIterations,
215
+ augmentRequest,
216
+ executeConnectedCalls,
217
+ request,
218
+ ctx,
219
+ (req) => inner.stream(req, ctx),
220
+ );
221
+ },
222
+ };
223
+ }
224
+
225
+ type ToolLoopState = { request: Program; iteration: number };
226
+ type ToolLoopDecision =
227
+ | { kind: "complete" }
228
+ | { kind: "external"; calls: ToolCall[] }
229
+ | { kind: "continue"; calls: ToolCall[] };
230
+
231
+ function decideToolLoop(
232
+ calls: ToolCall[],
233
+ toolMap: ReadonlyMap<string, Tool>,
234
+ ): ToolLoopDecision {
235
+ const connected: ToolCall[] = [];
236
+ const outer: ToolCall[] = [];
237
+ for (const call of calls) {
238
+ (toolMap.has(call.name) ? connected : outer).push(call);
239
+ }
240
+ if (outer.length > 0) return { kind: "external", calls: outer };
241
+ if (connected.length === 0) return { kind: "complete" };
242
+ return { kind: "continue", calls: connected };
243
+ }
244
+
245
+ function createToolLoopState(request: Program): ToolLoopState {
246
+ return { request, iteration: 0 };
247
+ }
248
+
249
+ function canRunToolLoop(state: ToolLoopState, maxIterations: number): boolean {
250
+ return state.iteration < maxIterations;
251
+ }
252
+
253
+ function appendToolResults(
254
+ request: Program,
255
+ results: readonly ToolExecution[],
256
+ ): Program {
257
+ return results.reduce(
258
+ (next, result) =>
259
+ appendToolInteraction(next, {
260
+ callId: result.id,
261
+ name: result.name,
262
+ args: parseToolArguments(result.name, result.args),
263
+ result: result.result,
264
+ }),
265
+ request,
266
+ );
267
+ }
268
+
269
+ async function advanceToolLoopState(
270
+ state: ToolLoopState,
271
+ calls: readonly ToolCall[],
272
+ ctx: ExecutorContext,
273
+ executeCalls: ReturnType<typeof buildCallExecutor>,
274
+ ): Promise<ToolLoopState> {
275
+ return {
276
+ request: appendToolResults(state.request, await executeCalls(calls, ctx)),
277
+ iteration: state.iteration + 1,
278
+ };
279
+ }
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ import type { Program } from "@neutrome/lil-engine";
2
+
3
+ export type TransformCapability =
4
+ | "read_messages"
5
+ | "write_messages"
6
+ | "write_config"
7
+ | "write_tools"
8
+ | "drop_content"
9
+ | "provider_extension";
10
+
11
+ export type ExecutionTarget =
12
+ | {
13
+ kind: "provider";
14
+ provider?: string;
15
+ model: string;
16
+ transforms?: string[];
17
+ }
18
+ | {
19
+ kind: "executor";
20
+ executorId: string;
21
+ alias: string;
22
+ transforms?: string[];
23
+ };
24
+
25
+ export type ExecutionEvent = {
26
+ kind: string;
27
+ requestId: string;
28
+ executionId: string;
29
+ timestamp: string;
30
+ parentExecutionId?: string;
31
+ target?: { kind: "provider" | "executor"; id: string };
32
+ errorKind?: string;
33
+ data?: Record<string, unknown>;
34
+ };
35
+
36
+ export type ExecutionEventInput = Omit<ExecutionEvent, "timestamp"> & {
37
+ timestamp?: string;
38
+ };
39
+
40
+ export type InvokeOptions = {
41
+ target?: ExecutionTarget;
42
+ requestId?: string;
43
+ executionId?: string;
44
+ parentExecutionId?: string;
45
+ };
46
+
47
+ export type TransformContext = {
48
+ invoke(request: Program, options?: InvokeOptions): Promise<Program>;
49
+ invokeStream(
50
+ request: Program,
51
+ options?: InvokeOptions,
52
+ ): AsyncIterable<Program>;
53
+ observe(event: ExecutionEvent): void;
54
+ signal: AbortSignal;
55
+ };
56
+
57
+ export type ProgramTransform = {
58
+ name: string;
59
+ capabilities: TransformCapability[];
60
+ apply(program: Program, ctx: TransformContext): Program | Promise<Program>;
61
+ };
62
+
63
+ export type ExecutorContext = {
64
+ requestId: string;
65
+ executionId: string;
66
+ parentExecutionId?: string;
67
+ invoke(request: Program, options?: InvokeOptions): Promise<Program>;
68
+ invokeStream(
69
+ request: Program,
70
+ options?: InvokeOptions,
71
+ ): AsyncIterable<Program>;
72
+ observe(event: ExecutionEvent): void;
73
+ signal: AbortSignal;
74
+ };
75
+
76
+ export type Executor = {
77
+ execute(request: Program, ctx: ExecutorContext): Promise<Program>;
78
+ stream(request: Program, ctx: ExecutorContext): AsyncIterable<Program>;
79
+ };
80
+
81
+ export type Tool = {
82
+ name: string;
83
+ description: string;
84
+ schema: Record<string, unknown>;
85
+ systemPromptFragment?: string;
86
+ execute(args: Record<string, unknown>, ctx: ExecutorContext): Promise<string>;
87
+ };
88
+
89
+ export type OutputSink = {
90
+ write(chunk: Program): void | Promise<void>;
91
+ close(): void | Promise<void>;
92
+ };
93
+
94
+ export function createExecutionEvent(
95
+ input: ExecutionEventInput,
96
+ ): ExecutionEvent {
97
+ return {
98
+ ...input,
99
+ timestamp: input.timestamp ?? new Date().toISOString(),
100
+ };
101
+ }