@neutrome/lilsdk-ts 0.2.4 → 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.
@@ -9,10 +9,21 @@ import {
9
9
  type Program,
10
10
  } from "@neutrome/lil-engine";
11
11
  import { describe, expect, it } from "vitest";
12
- import { all, any, find, has, indexOf, map, reduce } from "../src/primitives/index.ts";
13
- import { appendAssistantMessage, appendToolInteraction } from "../src/synthetic/index.ts";
12
+ import {
13
+ all,
14
+ any,
15
+ find,
16
+ has,
17
+ indexOf,
18
+ map,
19
+ reduce,
20
+ } from "../src/primitives/index.ts";
21
+ import {
22
+ appendAssistantMessage,
23
+ appendToolInteraction,
24
+ } from "../src/synthetic/index.ts";
14
25
  import { observeExecutionStream, writeReasoning } from "../src/stream/index.ts";
15
- import { fallback, goal, retry } from "../src/loops/index.ts";
26
+ import { fallback, retry, runGoal } from "../src/loops/index.ts";
16
27
  import {
17
28
  appendInternalDraft,
18
29
  createTwoPassExecutor,
@@ -20,18 +31,17 @@ import {
20
31
  INTERNAL_DRAFT_TOOL_NAME,
21
32
  streamModel,
22
33
  } from "../src/managed/twoPassExecutor.ts";
23
- import type {
24
- Executor,
25
- ExecutorContext,
26
- OutputSink,
27
- } from "../src/types.ts";
34
+ import type { Executor, ExecutorContext, OutputSink } from "../src/types.ts";
28
35
 
29
36
  const encoder = new TextEncoder();
30
37
  const decoder = new TextDecoder();
31
38
 
32
39
  describe("@neutrome/lilsdk-ts", () => {
33
40
  it("provides clone-safe structural primitives", () => {
34
- const needle = { opcode: Opcode.MSG_START, value: { kind: "none" as const } };
41
+ const needle = {
42
+ opcode: Opcode.MSG_START,
43
+ value: { kind: "none" as const },
44
+ };
35
45
  const program: Program = {
36
46
  code: [
37
47
  needle,
@@ -44,8 +54,12 @@ describe("@neutrome/lilsdk-ts", () => {
44
54
  expect(has(program, needle)).toBe(true);
45
55
  expect(find(program, needle)).toEqual(needle);
46
56
  expect(reduce(program, (count) => count + 1, 0)).toBe(2);
47
- expect(all(program, (instruction) => instruction.opcode !== Opcode.MSG_END)).toBe(true);
48
- expect(any(program, (instruction) => instruction.opcode === Opcode.TXT_CHUNK)).toBe(true);
57
+ expect(
58
+ all(program, (instruction) => instruction.opcode !== Opcode.MSG_END),
59
+ ).toBe(true);
60
+ expect(
61
+ any(program, (instruction) => instruction.opcode === Opcode.TXT_CHUNK),
62
+ ).toBe(true);
49
63
 
50
64
  const mapped = map(program, (instruction) => instruction);
51
65
  mapped.buffers[0]![0] = 9;
@@ -53,36 +67,54 @@ describe("@neutrome/lilsdk-ts", () => {
53
67
  });
54
68
 
55
69
  it("observes tool-call streams", async () => {
56
- const observed = await observeExecutionStream((async function* () {
57
- yield parseChatCompletionsStreamChunk(encoder.encode(JSON.stringify({
58
- id: "tool-stream",
59
- object: "chat.completion.chunk",
60
- model: "smart",
61
- choices: [{ index: 0, delta: { role: "assistant" } }],
62
- })));
63
- yield parseChatCompletionsStreamChunk(encoder.encode(JSON.stringify({
64
- id: "tool-stream",
65
- object: "chat.completion.chunk",
66
- model: "smart",
67
- choices: [{
68
- index: 0,
69
- delta: {
70
- tool_calls: [{
71
- index: 0,
72
- id: "call_lookup",
73
- type: "function",
74
- function: { name: "lookup", arguments: "{\"q\":\"kyiv\"}" },
75
- }],
76
- },
77
- }],
78
- })));
79
- yield parseChatCompletionsStreamChunk(encoder.encode(JSON.stringify({
80
- id: "tool-stream",
81
- object: "chat.completion.chunk",
82
- model: "smart",
83
- choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
84
- })));
85
- })());
70
+ const observed = await observeExecutionStream(
71
+ (async function* () {
72
+ yield parseChatCompletionsStreamChunk(
73
+ encoder.encode(
74
+ JSON.stringify({
75
+ id: "tool-stream",
76
+ object: "chat.completion.chunk",
77
+ model: "smart",
78
+ choices: [{ index: 0, delta: { role: "assistant" } }],
79
+ }),
80
+ ),
81
+ );
82
+ yield parseChatCompletionsStreamChunk(
83
+ encoder.encode(
84
+ JSON.stringify({
85
+ id: "tool-stream",
86
+ object: "chat.completion.chunk",
87
+ model: "smart",
88
+ choices: [
89
+ {
90
+ index: 0,
91
+ delta: {
92
+ tool_calls: [
93
+ {
94
+ index: 0,
95
+ id: "call_lookup",
96
+ type: "function",
97
+ function: { name: "lookup", arguments: '{"q":"kyiv"}' },
98
+ },
99
+ ],
100
+ },
101
+ },
102
+ ],
103
+ }),
104
+ ),
105
+ );
106
+ yield parseChatCompletionsStreamChunk(
107
+ encoder.encode(
108
+ JSON.stringify({
109
+ id: "tool-stream",
110
+ object: "chat.completion.chunk",
111
+ model: "smart",
112
+ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
113
+ }),
114
+ ),
115
+ );
116
+ })(),
117
+ );
86
118
 
87
119
  expect(observed.mode).toBe("tool");
88
120
  if (observed.mode !== "tool") {
@@ -90,30 +122,44 @@ describe("@neutrome/lilsdk-ts", () => {
90
122
  }
91
123
 
92
124
  const parsed = observed.chunks.map((chunk) =>
93
- JSON.parse(decoder.decode(emitChatCompletionsStreamChunk(chunk)))
125
+ JSON.parse(decoder.decode(emitChatCompletionsStreamChunk(chunk))),
126
+ );
127
+ expect(parsed.some((chunk) => chunk.choices?.[0]?.delta?.tool_calls)).toBe(
128
+ true,
94
129
  );
95
- expect(parsed.some((chunk) => chunk.choices?.[0]?.delta?.tool_calls)).toBe(true);
96
130
  });
97
131
 
98
132
  it("appends internal drafts with fixed SDK metadata and empty args", () => {
99
- const request = parseChatCompletionsRequest(encoder.encode(JSON.stringify({
100
- model: "virtual-model",
101
- messages: [{ role: "user", content: "hello" }],
102
- })));
133
+ const request = parseChatCompletionsRequest(
134
+ encoder.encode(
135
+ JSON.stringify({
136
+ model: "virtual-model",
137
+ messages: [{ role: "user", content: "hello" }],
138
+ }),
139
+ ),
140
+ );
103
141
 
104
142
  const updated = appendInternalDraft(request, "draft answer");
105
- const emitted = JSON.parse(decoder.decode(emitChatCompletionsRequest(updated)));
143
+ const emitted = JSON.parse(
144
+ decoder.decode(emitChatCompletionsRequest(updated)),
145
+ );
106
146
 
107
- expect(emitted.messages[1].tool_calls[0].function.name).toBe(INTERNAL_DRAFT_TOOL_NAME);
147
+ expect(emitted.messages[1].tool_calls[0].function.name).toBe(
148
+ INTERNAL_DRAFT_TOOL_NAME,
149
+ );
108
150
  expect(emitted.messages[1].tool_calls[0].function.arguments).toBe("{}");
109
151
  expect(emitted.messages[2].content).toBe("draft answer");
110
152
  });
111
153
 
112
154
  it("appends generic synthetic tool interactions", () => {
113
- const request = parseChatCompletionsRequest(encoder.encode(JSON.stringify({
114
- model: "virtual-model",
115
- messages: [{ role: "user", content: "hello" }],
116
- })));
155
+ const request = parseChatCompletionsRequest(
156
+ encoder.encode(
157
+ JSON.stringify({
158
+ model: "virtual-model",
159
+ messages: [{ role: "user", content: "hello" }],
160
+ }),
161
+ ),
162
+ );
117
163
 
118
164
  const updated = appendToolInteraction(request, {
119
165
  callId: "call_lookup",
@@ -121,32 +167,44 @@ describe("@neutrome/lilsdk-ts", () => {
121
167
  args: { id: "42" },
122
168
  result: { status: "active" },
123
169
  });
124
- const emitted = JSON.parse(decoder.decode(emitChatCompletionsRequest(updated)));
170
+ const emitted = JSON.parse(
171
+ decoder.decode(emitChatCompletionsRequest(updated)),
172
+ );
125
173
 
126
174
  expect(emitted.messages[1].tool_calls[0].function).toEqual({
127
175
  name: "lookup",
128
- arguments: "{\"id\":\"42\"}",
176
+ arguments: '{"id":"42"}',
129
177
  });
130
178
  expect(emitted.messages[2]).toEqual({
131
179
  role: "tool",
132
180
  tool_call_id: "call_lookup",
133
- content: "{\"status\":\"active\"}",
181
+ content: '{"status":"active"}',
134
182
  });
135
183
  });
136
184
 
137
185
  it("builds two-pass executors from model and prompt config", async () => {
138
- const request = parseChatCompletionsRequest(encoder.encode(JSON.stringify({
139
- model: "virtual-model",
140
- messages: [{ role: "user", content: "hello" }],
141
- })));
186
+ const request = parseChatCompletionsRequest(
187
+ encoder.encode(
188
+ JSON.stringify({
189
+ model: "virtual-model",
190
+ messages: [{ role: "user", content: "hello" }],
191
+ }),
192
+ ),
193
+ );
142
194
  const seen: Program[] = [];
143
195
  const ctx = buildExecutorContext({
144
196
  async invoke(program) {
145
197
  seen.push(program);
146
198
  if (seen.length === 1) {
147
- return appendAssistantMessage({ code: [], buffers: [] }, "draft answer");
199
+ return appendAssistantMessage(
200
+ { code: [], buffers: [] },
201
+ "draft answer",
202
+ );
148
203
  }
149
- return appendAssistantMessage({ code: [], buffers: [] }, "final answer");
204
+ return appendAssistantMessage(
205
+ { code: [], buffers: [] },
206
+ "final answer",
207
+ );
150
208
  },
151
209
  async *invokeStream() {
152
210
  throw new Error("streaming path is not used in this test");
@@ -161,11 +219,19 @@ describe("@neutrome/lilsdk-ts", () => {
161
219
 
162
220
  const result = await executor.execute(request, ctx);
163
221
 
164
- expect(seen.map((program) => getModel(program))).toEqual(["smart-model", "base-model"]);
222
+ expect(seen.map((program) => getModel(program))).toEqual([
223
+ "smart-model",
224
+ "base-model",
225
+ ]);
165
226
  expect(contentText(result)).toBe("final answer");
166
227
 
167
- const finalRequest = JSON.parse(decoder.decode(emitChatCompletionsRequest(seen[1]!)));
168
- expect(finalRequest.messages[0]).toEqual({ role: "system", content: "system prompt" });
228
+ const finalRequest = JSON.parse(
229
+ decoder.decode(emitChatCompletionsRequest(seen[1]!)),
230
+ );
231
+ expect(finalRequest.messages[0]).toEqual({
232
+ role: "system",
233
+ content: "system prompt",
234
+ });
169
235
  expect(finalRequest.messages[2].tool_calls[0].function).toEqual({
170
236
  name: INTERNAL_DRAFT_TOOL_NAME,
171
237
  arguments: "{}",
@@ -178,18 +244,25 @@ describe("@neutrome/lilsdk-ts", () => {
178
244
  });
179
245
 
180
246
  it("replaces draft system prompt and moves existing system prompt to first user message", async () => {
181
- const request = parseChatCompletionsRequest(encoder.encode(JSON.stringify({
182
- model: "virtual-model",
183
- messages: [
184
- { role: "system", content: "original system" },
185
- { role: "user", content: "hello" },
186
- ],
187
- })));
247
+ const request = parseChatCompletionsRequest(
248
+ encoder.encode(
249
+ JSON.stringify({
250
+ model: "virtual-model",
251
+ messages: [
252
+ { role: "system", content: "original system" },
253
+ { role: "user", content: "hello" },
254
+ ],
255
+ }),
256
+ ),
257
+ );
188
258
  const seen: Program[] = [];
189
259
  const ctx = buildExecutorContext({
190
260
  async invoke(program) {
191
261
  seen.push(program);
192
- return appendAssistantMessage({ code: [], buffers: [] }, seen.length === 1 ? "draft" : "final");
262
+ return appendAssistantMessage(
263
+ { code: [], buffers: [] },
264
+ seen.length === 1 ? "draft" : "final",
265
+ );
193
266
  },
194
267
  async *invokeStream() {
195
268
  throw new Error("streaming path is not used in this test");
@@ -204,7 +277,9 @@ describe("@neutrome/lilsdk-ts", () => {
204
277
 
205
278
  await executor.execute(request, ctx);
206
279
 
207
- const draftRequest = JSON.parse(decoder.decode(emitChatCompletionsRequest(seen[0]!)));
280
+ const draftRequest = JSON.parse(
281
+ decoder.decode(emitChatCompletionsRequest(seen[0]!)),
282
+ );
208
283
  expect(draftRequest.messages).toEqual([
209
284
  { role: "system", content: "draft system" },
210
285
  { role: "user", content: "original system" },
@@ -217,31 +292,47 @@ describe("@neutrome/lilsdk-ts", () => {
217
292
  const ctx = buildExecutorContext({
218
293
  async invoke(program) {
219
294
  calls.push({ model: getModel(program) ?? "", streaming: false });
220
- return appendAssistantMessage({ code: [], buffers: [] }, "model answer");
295
+ return appendAssistantMessage(
296
+ { code: [], buffers: [] },
297
+ "model answer",
298
+ );
221
299
  },
222
300
  async *invokeStream(program) {
223
301
  calls.push({ model: getModel(program) ?? "", streaming: true });
224
- yield parseChatCompletionsStreamChunk(encoder.encode(JSON.stringify({
225
- id: "stream-response",
226
- model: "base-model",
227
- choices: [{ index: 0, delta: { content: "hello" } }],
228
- })));
302
+ yield parseChatCompletionsStreamChunk(
303
+ encoder.encode(
304
+ JSON.stringify({
305
+ id: "stream-response",
306
+ model: "base-model",
307
+ choices: [{ index: 0, delta: { content: "hello" } }],
308
+ }),
309
+ ),
310
+ );
229
311
  },
230
312
  });
231
313
 
232
- const request = parseChatCompletionsRequest(encoder.encode(JSON.stringify({
233
- model: "virtual-model",
234
- messages: [{ role: "user", content: "hello" }],
235
- })));
314
+ const request = parseChatCompletionsRequest(
315
+ encoder.encode(
316
+ JSON.stringify({
317
+ model: "virtual-model",
318
+ messages: [{ role: "user", content: "hello" }],
319
+ }),
320
+ ),
321
+ );
236
322
  const first = await invokeModel(ctx, request, "smart-model");
237
323
  const chunks: Program[] = [];
238
324
  for await (const chunk of streamModel(ctx, request, "base-model")) {
239
325
  chunks.push(chunk);
240
326
  }
241
327
 
242
- expect(calls).toEqual([{ model: "smart-model", streaming: false }, { model: "base-model", streaming: true }]);
328
+ expect(calls).toEqual([
329
+ { model: "smart-model", streaming: false },
330
+ { model: "base-model", streaming: true },
331
+ ]);
243
332
  expect(contentText(first)).toBe("model answer");
244
- expect(decoder.decode(emitChatCompletionsStreamChunk(chunks[0]!))).toContain("hello");
333
+ expect(
334
+ decoder.decode(emitChatCompletionsStreamChunk(chunks[0]!)),
335
+ ).toContain("hello");
245
336
  expect(getModel(request)).toBe("virtual-model");
246
337
  });
247
338
 
@@ -258,20 +349,27 @@ describe("@neutrome/lilsdk-ts", () => {
258
349
 
259
350
  await writeReasoning(sink, "thinking");
260
351
 
261
- expect(JSON.parse(emitted[0]!).choices[0].delta.reasoning_content).toBe("thinking");
352
+ expect(JSON.parse(emitted[0]!).choices[0].delta.reasoning_content).toBe(
353
+ "thinking",
354
+ );
262
355
  });
263
356
 
264
357
  it("retries executor failures", async () => {
265
358
  let calls = 0;
266
- const executor = retry(executorFromExecute(async () => {
267
- calls += 1;
268
- if (calls === 1) {
269
- throw new Error("transient");
270
- }
271
- return appendAssistantMessage({ code: [], buffers: [] }, "ok");
272
- }));
359
+ const executor = retry(
360
+ executorFromExecute(async () => {
361
+ calls += 1;
362
+ if (calls === 1) {
363
+ throw new Error("transient");
364
+ }
365
+ return appendAssistantMessage({ code: [], buffers: [] }, "ok");
366
+ }),
367
+ );
273
368
 
274
- const result = await executor.execute({ code: [], buffers: [] }, buildExecutorContext());
369
+ const result = await executor.execute(
370
+ { code: [], buffers: [] },
371
+ buildExecutorContext(),
372
+ );
275
373
 
276
374
  expect(calls).toBe(2);
277
375
  expect(contentText(result)).toBe("ok");
@@ -282,23 +380,75 @@ describe("@neutrome/lilsdk-ts", () => {
282
380
  executorFromExecute(async () => {
283
381
  throw new Error("primary failed");
284
382
  }),
285
- executorFromExecute(async () => appendAssistantMessage({ code: [], buffers: [] }, "fallback")),
383
+ executorFromExecute(async () =>
384
+ appendAssistantMessage({ code: [], buffers: [] }, "fallback"),
385
+ ),
286
386
  ]);
287
387
 
288
- const result = await executor.execute({ code: [], buffers: [] }, buildExecutorContext());
388
+ const result = await executor.execute(
389
+ { code: [], buffers: [] },
390
+ buildExecutorContext(),
391
+ );
289
392
 
290
393
  expect(contentText(result)).toBe("fallback");
291
394
  });
292
395
 
293
- it("runs goal loops until the review is satisfied", async () => {
396
+ it("does not retry a stream after exposing a partial chunk", async () => {
397
+ let retryCalls = 0;
398
+ const executor = retry(
399
+ streamingExecutor(async function* () {
400
+ retryCalls += 1;
401
+ yield appendAssistantMessage({ code: [], buffers: [] }, "partial");
402
+ throw new Error("stream interrupted");
403
+ }),
404
+ { attempts: 2 },
405
+ );
406
+ const context = buildExecutorContext();
407
+ const stream = executor
408
+ .stream({ code: [], buffers: [] }, context)
409
+ [Symbol.asyncIterator]();
410
+
411
+ await expect(stream.next()).resolves.toMatchObject({ done: false });
412
+ await expect(stream.next()).rejects.toThrow("stream interrupted");
413
+ expect(retryCalls).toBe(1);
414
+ });
415
+
416
+ it("does not fall back after exposing a partial stream chunk", async () => {
417
+ let fallbackCalls = 0;
418
+ const executor = fallback([
419
+ streamingExecutor(async function* () {
420
+ yield appendAssistantMessage({ code: [], buffers: [] }, "partial");
421
+ throw new Error("stream interrupted");
422
+ }),
423
+ streamingExecutor(async function* () {
424
+ fallbackCalls += 1;
425
+ yield appendAssistantMessage({ code: [], buffers: [] }, "fallback");
426
+ }),
427
+ ]);
428
+ const stream = executor
429
+ .stream({ code: [], buffers: [] }, buildExecutorContext())
430
+ [Symbol.asyncIterator]();
431
+
432
+ await expect(stream.next()).resolves.toMatchObject({ done: false });
433
+ await expect(stream.next()).rejects.toThrow("stream interrupted");
434
+ expect(fallbackCalls).toBe(0);
435
+ });
436
+
437
+ it("runs execute-only goal loops until the review is satisfied", async () => {
294
438
  let attempts = 0;
295
- const executor = goal({
439
+ const executor = runGoal({
296
440
  draft: executorFromExecute(async () => {
297
441
  attempts += 1;
298
- return appendAssistantMessage({ code: [], buffers: [] }, `answer ${attempts}`);
442
+ return appendAssistantMessage(
443
+ { code: [], buffers: [] },
444
+ `answer ${attempts}`,
445
+ );
299
446
  }),
300
447
  review: executorFromExecute(async () =>
301
- appendAssistantMessage({ code: [], buffers: [] }, attempts > 1 ? "pass" : "retry")
448
+ appendAssistantMessage(
449
+ { code: [], buffers: [] },
450
+ attempts > 1 ? "pass" : "retry",
451
+ ),
302
452
  ),
303
453
  satisfied(review) {
304
454
  return contentText(review) === "pass";
@@ -308,7 +458,10 @@ describe("@neutrome/lilsdk-ts", () => {
308
458
  },
309
459
  });
310
460
 
311
- const result = await executor.execute({ code: [], buffers: [] }, buildExecutorContext());
461
+ const result = await executor.execute(
462
+ { code: [], buffers: [] },
463
+ buildExecutorContext(),
464
+ );
312
465
 
313
466
  expect(attempts).toBe(2);
314
467
  expect(contentText(result)).toBe("answer 2");
@@ -343,3 +496,14 @@ function executorFromExecute(execute: Executor["execute"]): Executor {
343
496
  },
344
497
  };
345
498
  }
499
+
500
+ function streamingExecutor(stream: Executor["stream"]): Executor {
501
+ return {
502
+ async execute(request, ctx) {
503
+ let result = request;
504
+ for await (const chunk of stream(request, ctx)) result = chunk;
505
+ return result;
506
+ },
507
+ stream,
508
+ };
509
+ }