@neutrome/lilsdk-ts 0.2.5 → 0.3.1

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/src/tools.ts CHANGED
@@ -1,24 +1,25 @@
1
1
  import {
2
2
  callData,
3
3
  createProgram,
4
+ decodeStreamToolDelta,
4
5
  isStreaming,
5
6
  Opcode,
6
7
  setStreaming,
7
8
  type Instruction,
8
9
  type Program,
10
+ type StreamToolDelta,
9
11
  } from "@neutrome/lil-engine";
12
+ import { appendToolInteraction } from "./synthetic/index.ts";
13
+ import { type Executor, type ExecutorContext, type Tool } from "./types.ts";
10
14
  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";
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";
22
23
 
23
24
  export type WithToolsOptions = {
24
25
  maxIterations?: number;
@@ -26,108 +27,34 @@ export type WithToolsOptions = {
26
27
 
27
28
  const DEFAULT_MAX_ITERATIONS = 10;
28
29
 
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 startedMs = Date.now();
79
- const startedAt = new Date(startedMs).toISOString();
80
- const result = await tool.execute(parsed, ctx);
81
- const finishedMs = Date.now();
82
- ctx.observe(createSdkEvent({
83
- kind: "tool.executed",
84
- requestId: ctx.requestId,
85
- executionId: `tool_${call.id || crypto.randomUUID()}`,
86
- parentExecutionId: ctx.executionId,
87
- data: {
88
- toolName: call.name,
89
- toolCallId: call.id,
90
- startedAt,
91
- finishedAt: new Date(finishedMs).toISOString(),
92
- durationMs: finishedMs - startedMs,
93
- lilText: buildToolTrace(call, result),
94
- },
95
- }));
96
- return { ...call, result };
97
- }),
98
- );
99
- };
100
- }
101
-
102
30
  type ToolDeltaAcc = { index: number; id: string; name: string; args: string };
31
+ type DecodedToolDelta = { instruction: Instruction; delta: StreamToolDelta };
103
32
 
104
33
  function collectToolDeltasFromChunk(
105
34
  chunk: Program,
106
35
  acc: Map<number, ToolDeltaAcc>,
107
- ) {
36
+ ): DecodedToolDelta[] {
37
+ const deltas: DecodedToolDelta[] = [];
108
38
  for (const instr of chunk.code) {
109
- if (instr.opcode === Opcode.STREAM_TOOL_DELTA && instr.value.kind === "json") {
110
- const parsed = JSON.parse(new TextDecoder().decode(instr.value.value)) as Record<string, unknown>;
111
- const index = typeof parsed.index === "number" ? parsed.index : 0;
112
- const existing = acc.get(index);
113
- if (existing) {
114
- if (typeof parsed.arguments === "string") existing.args += parsed.arguments;
115
- } else {
116
- acc.set(index, {
117
- index,
118
- id: typeof parsed.id === "string" ? parsed.id : "",
119
- name: typeof parsed.name === "string" ? parsed.name : "",
120
- args: typeof parsed.arguments === "string" ? parsed.arguments : "",
121
- });
122
- }
123
- }
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);
124
52
  }
53
+ return deltas;
125
54
  }
126
55
 
127
56
  function filterChunkForClient(
128
57
  chunk: Program,
129
- connectedNames: Set<string>,
130
- toolAcc: Map<number, ToolDeltaAcc>,
131
58
  isIntermediate: boolean,
132
59
  ): Program | null {
133
60
  const kept: Instruction[] = [];
@@ -135,16 +62,7 @@ function filterChunkForClient(
135
62
  for (const instr of chunk.code) {
136
63
  switch (instr.opcode) {
137
64
  case Opcode.STREAM_TOOL_DELTA:
138
- // Suppress connected tool deltas; pass through outer ones
139
- if (instr.value.kind === "json") {
140
- const parsed = JSON.parse(new TextDecoder().decode(instr.value.value)) as Record<string, unknown>;
141
- const index = typeof parsed.index === "number" ? parsed.index : 0;
142
- const tool = toolAcc.get(index);
143
- if (tool && connectedNames.has(tool.name)) {
144
- continue;
145
- }
146
- }
147
- kept.push(instr);
65
+ // Tool ownership can be revealed by a later fragment, so defer all deltas.
148
66
  break;
149
67
  case Opcode.RESP_DONE:
150
68
  if (isIntermediate) continue;
@@ -173,21 +91,25 @@ async function* streamToolLoop(
173
91
  ctx: ExecutorContext,
174
92
  invokeStream: (req: Program) => AsyncIterable<Program>,
175
93
  ): AsyncGenerator<Program> {
176
- const connectedNames = new Set(toolMap.keys());
177
- let current = augmentRequest(request);
94
+ let state = createToolLoopState(augmentRequest(request));
178
95
  let yieldedStart = false;
179
96
 
180
- for (let iteration = 0; iteration < maxIterations; iteration += 1) {
97
+ while (canRunToolLoop(state, maxIterations)) {
181
98
  const toolAcc = new Map<number, ToolDeltaAcc>();
99
+ const toolDeltas: DecodedToolDelta[] = [];
182
100
  const isIntermediate = true; // assume intermediate; we'll yield end markers later if final
183
101
 
184
- for await (const chunk of invokeStream(current)) {
185
- collectToolDeltasFromChunk(chunk, toolAcc);
102
+ for await (const chunk of invokeStream(state.request)) {
103
+ toolDeltas.push(...collectToolDeltasFromChunk(chunk, toolAcc));
186
104
 
187
- const filtered = filterChunkForClient(chunk, connectedNames, toolAcc, isIntermediate);
105
+ const filtered = filterChunkForClient(chunk, isIntermediate);
188
106
  if (filtered) {
189
107
  // Skip STREAM_START on iterations after the first (client already got it)
190
- if (yieldedStart && filtered.code.length === 1 && filtered.code[0]!.opcode === Opcode.STREAM_START) {
108
+ if (
109
+ yieldedStart &&
110
+ filtered.code.length === 1 &&
111
+ filtered.code[0]?.opcode === Opcode.STREAM_START
112
+ ) {
191
113
  continue;
192
114
  }
193
115
  yield filtered;
@@ -202,82 +124,88 @@ async function* streamToolLoop(
202
124
  .filter((t) => t.name)
203
125
  .map((t) => ({ id: t.id, name: t.name, args: t.args }));
204
126
 
205
- const connected = calls.filter((c) => toolMap.has(c.name));
206
- const outer = calls.filter((c) => !toolMap.has(c.name));
127
+ const decision = decideToolLoop(calls, toolMap);
207
128
 
208
- if (connected.length === 0 || outer.length > 0) {
129
+ if (decision.kind !== "continue") {
209
130
  // Final iteration — emit suppressed end markers
210
131
  const endInstructions: Instruction[] = [];
211
- if (calls.length > 0 && outer.length > 0) {
212
- endInstructions.push({ opcode: Opcode.RESP_DONE, value: { kind: "string", value: "tool_calls" } });
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
+ });
213
145
  } else if (calls.length === 0) {
214
- endInstructions.push({ opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } });
146
+ endInstructions.push({
147
+ opcode: Opcode.RESP_DONE,
148
+ value: { kind: "string", value: "stop" },
149
+ });
215
150
  }
216
- endInstructions.push({ opcode: Opcode.STREAM_END, value: { kind: "none" } });
151
+ endInstructions.push({
152
+ opcode: Opcode.STREAM_END,
153
+ value: { kind: "none" },
154
+ });
217
155
  yield createProgram({ code: endInstructions });
218
156
  return;
219
157
  }
220
158
 
221
159
  // Execute connected tools and loop
222
- const results = await executeConnectedCalls(connected, ctx);
223
- let next = current;
224
- for (const r of results) {
225
- next = appendToolInteraction(next, {
226
- callId: r.id,
227
- name: r.name,
228
- args: parseToolArgs(r.args),
229
- result: r.result,
230
- });
231
- }
232
- current = next;
160
+ state = await advanceToolLoopState(
161
+ state,
162
+ decision.calls,
163
+ ctx,
164
+ executeConnectedCalls,
165
+ );
233
166
  }
234
167
 
235
168
  // Max iterations reached — emit end
236
- yield createProgram({ code: [
237
- { opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } },
238
- { opcode: Opcode.STREAM_END, value: { kind: "none" } },
239
- ] });
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
+ });
240
175
  }
241
176
 
242
- export function withTools(
177
+ export function connectTools(
243
178
  tools: Tool[],
244
- target: ExecutionTarget,
179
+ inner: Executor,
245
180
  options: WithToolsOptions = {},
246
181
  ): Executor {
247
182
  const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
248
183
  const toolMap = new Map(tools.map((t) => [t.name, t]));
249
- const augmentRequest = buildAugmenter(tools);
184
+ const augmentRequest = buildToolAugmenter(tools);
250
185
  const executeConnectedCalls = buildCallExecutor(toolMap);
251
186
 
252
187
  return {
253
188
  async execute(request, ctx) {
254
189
  const augmented = augmentRequest(request);
255
- let current = isStreaming(augmented) ? setStreaming(augmented, false) : augmented;
190
+ let state = createToolLoopState(
191
+ isStreaming(augmented) ? setStreaming(augmented, false) : augmented,
192
+ );
256
193
 
257
- for (let iteration = 0; iteration < maxIterations; iteration += 1) {
258
- const response = await ctx.invoke(current, { target });
194
+ while (canRunToolLoop(state, maxIterations)) {
195
+ const response = await inner.execute(state.request, ctx);
259
196
  const calls = callData(response);
260
- const connected = calls.filter((c) => toolMap.has(c.name));
261
- const outer = calls.filter((c) => !toolMap.has(c.name));
262
-
263
- if (connected.length === 0) return response;
264
-
265
- const results = await executeConnectedCalls(connected, ctx);
266
- let next = current;
267
- for (const r of results) {
268
- next = appendToolInteraction(next, {
269
- callId: r.id,
270
- name: r.name,
271
- args: parseToolArgs(r.args),
272
- result: r.result,
273
- });
274
- }
275
-
276
- if (outer.length > 0) return response;
277
- current = next;
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
+ );
278
206
  }
279
207
 
280
- return ctx.invoke(current, { target });
208
+ return inner.execute(state.request, ctx);
281
209
  },
282
210
 
283
211
  async *stream(request, ctx) {
@@ -288,76 +216,64 @@ export function withTools(
288
216
  executeConnectedCalls,
289
217
  request,
290
218
  ctx,
291
- (req) => ctx.invokeStream(req, { target }),
219
+ (req) => inner.stream(req, ctx),
292
220
  );
293
221
  },
294
222
  };
295
223
  }
296
224
 
297
- /**
298
- * Wraps an existing executor with connected tools. The inner executor is called
299
- * with augmented requests (tool definitions + system fragments injected), and
300
- * any tool calls it returns are automatically resolved in a loop.
301
- */
302
- export function connectTools(
303
- tools: Tool[],
304
- inner: Executor,
305
- options: WithToolsOptions = {},
306
- ): Executor {
307
- const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
308
- const toolMap = new Map(tools.map((t) => [t.name, t]));
309
- const augmentRequest = buildAugmenter(tools);
310
- const executeConnectedCalls = buildCallExecutor(toolMap);
311
-
312
- return {
313
- async execute(request, ctx) {
314
- const augmented = augmentRequest(request);
315
- let current = isStreaming(augmented) ? setStreaming(augmented, false) : augmented;
316
-
317
- for (let iteration = 0; iteration < maxIterations; iteration += 1) {
318
- const response = await inner.execute(current, ctx);
319
- const calls = callData(response);
320
- const connected = calls.filter((c) => toolMap.has(c.name));
321
- const outer = calls.filter((c) => !toolMap.has(c.name));
322
-
323
- if (connected.length === 0) return response;
324
-
325
- const results = await executeConnectedCalls(connected, ctx);
326
- let next = current;
327
- for (const r of results) {
328
- next = appendToolInteraction(next, {
329
- callId: r.id,
330
- name: r.name,
331
- args: parseToolArgs(r.args),
332
- result: r.result,
333
- });
334
- }
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
+ }
335
244
 
336
- if (outer.length > 0) return response;
337
- current = next;
338
- }
245
+ function createToolLoopState(request: Program): ToolLoopState {
246
+ return { request, iteration: 0 };
247
+ }
339
248
 
340
- return inner.execute(current, ctx);
341
- },
249
+ function canRunToolLoop(state: ToolLoopState, maxIterations: number): boolean {
250
+ return state.iteration < maxIterations;
251
+ }
342
252
 
343
- async *stream(request, ctx) {
344
- yield* streamToolLoop(
345
- toolMap,
346
- maxIterations,
347
- augmentRequest,
348
- executeConnectedCalls,
349
- request,
350
- ctx,
351
- (req) => inner.stream(req, ctx),
352
- );
353
- },
354
- };
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
+ );
355
267
  }
356
268
 
357
- function parseToolArgs(args: string): unknown {
358
- try {
359
- return JSON.parse(args || "{}");
360
- } catch {
361
- return {};
362
- }
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
+ };
363
279
  }
package/src/types.ts CHANGED
@@ -22,7 +22,7 @@ export type ExecutionTarget =
22
22
  transforms?: string[];
23
23
  };
24
24
 
25
- export type SdkEvent = {
25
+ export type ExecutionEvent = {
26
26
  kind: string;
27
27
  requestId: string;
28
28
  executionId: string;
@@ -33,7 +33,7 @@ export type SdkEvent = {
33
33
  data?: Record<string, unknown>;
34
34
  };
35
35
 
36
- export type SdkEventInput = Omit<SdkEvent, "timestamp"> & {
36
+ export type ExecutionEventInput = Omit<ExecutionEvent, "timestamp"> & {
37
37
  timestamp?: string;
38
38
  };
39
39
 
@@ -46,8 +46,11 @@ export type InvokeOptions = {
46
46
 
47
47
  export type TransformContext = {
48
48
  invoke(request: Program, options?: InvokeOptions): Promise<Program>;
49
- invokeStream(request: Program, options?: InvokeOptions): AsyncIterable<Program>;
50
- observe(event: SdkEvent): void;
49
+ invokeStream(
50
+ request: Program,
51
+ options?: InvokeOptions,
52
+ ): AsyncIterable<Program>;
53
+ observe(event: ExecutionEvent): void;
51
54
  signal: AbortSignal;
52
55
  };
53
56
 
@@ -62,8 +65,11 @@ export type ExecutorContext = {
62
65
  executionId: string;
63
66
  parentExecutionId?: string;
64
67
  invoke(request: Program, options?: InvokeOptions): Promise<Program>;
65
- invokeStream(request: Program, options?: InvokeOptions): AsyncIterable<Program>;
66
- observe(event: SdkEvent): void;
68
+ invokeStream(
69
+ request: Program,
70
+ options?: InvokeOptions,
71
+ ): AsyncIterable<Program>;
72
+ observe(event: ExecutionEvent): void;
67
73
  signal: AbortSignal;
68
74
  };
69
75
 
@@ -85,7 +91,9 @@ export type OutputSink = {
85
91
  close(): void | Promise<void>;
86
92
  };
87
93
 
88
- export function createSdkEvent(input: SdkEventInput): SdkEvent {
94
+ export function createExecutionEvent(
95
+ input: ExecutionEventInput,
96
+ ): ExecutionEvent {
89
97
  return {
90
98
  ...input,
91
99
  timestamp: input.timestamp ?? new Date().toISOString(),