@neutrome/lilsdk-ts 0.1.3 → 0.2.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/types.ts CHANGED
@@ -1,29 +1,91 @@
1
1
  import type { Program } from "@neutrome/lil-engine";
2
- import type { ExecutionRuntime, InvokeOptions } from "@neutrome/lil-engine";
3
2
 
4
- export type ModelRuntime = Pick<ExecutionRuntime, "execute" | "stream">;
3
+ export type TransformCapability =
4
+ | "read_messages"
5
+ | "write_messages"
6
+ | "write_config"
7
+ | "write_tools"
8
+ | "drop_content"
9
+ | "provider_extension";
5
10
 
6
- export type OutputSink = {
7
- write(chunk: Program): void | Promise<void>;
8
- close(): void | Promise<void>;
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 SdkEvent = {
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 SdkEventInput = Omit<SdkEvent, "timestamp"> & {
37
+ timestamp?: string;
9
38
  };
10
39
 
11
- export type ExecuteModelOptions = InvokeOptions;
40
+ export type InvokeOptions = {
41
+ target?: ExecutionTarget;
42
+ requestId?: string;
43
+ executionId?: string;
44
+ parentExecutionId?: string;
45
+ };
12
46
 
13
- export type StreamModelOptions = InvokeOptions & {
14
- close?: boolean;
47
+ export type TransformContext = {
48
+ observe(event: SdkEvent): void;
49
+ signal: AbortSignal;
15
50
  };
16
51
 
17
- export type ModelHandler = {
18
- execute(
19
- request: Program,
20
- runtime: ModelRuntime,
21
- options?: ExecuteModelOptions,
22
- ): Promise<Program>;
23
- stream(
24
- request: Program,
25
- runtime: ModelRuntime,
26
- sink: OutputSink,
27
- options?: StreamModelOptions,
28
- ): Promise<void>;
52
+ export type ProgramTransform = {
53
+ name: string;
54
+ capabilities: TransformCapability[];
55
+ apply(program: Program, ctx: TransformContext): Program;
29
56
  };
57
+
58
+ export type ExecutorContext = {
59
+ requestId: string;
60
+ executionId: string;
61
+ parentExecutionId?: string;
62
+ invoke(request: Program, options?: InvokeOptions): Promise<Program>;
63
+ invokeStream(request: Program, options?: InvokeOptions): AsyncIterable<Program>;
64
+ observe(event: SdkEvent): void;
65
+ signal: AbortSignal;
66
+ };
67
+
68
+ export type Executor = {
69
+ execute(request: Program, ctx: ExecutorContext): Promise<Program>;
70
+ stream(request: Program, ctx: ExecutorContext): AsyncIterable<Program>;
71
+ };
72
+
73
+ export type Tool = {
74
+ name: string;
75
+ description: string;
76
+ schema: Record<string, unknown>;
77
+ systemPromptFragment?: string;
78
+ execute(args: Record<string, unknown>, ctx: ExecutorContext): Promise<string>;
79
+ };
80
+
81
+ export type OutputSink = {
82
+ write(chunk: Program): void | Promise<void>;
83
+ close(): void | Promise<void>;
84
+ };
85
+
86
+ export function createSdkEvent(input: SdkEventInput): SdkEvent {
87
+ return {
88
+ ...input,
89
+ timestamp: input.timestamp ?? new Date().toISOString(),
90
+ };
91
+ }
@@ -1,32 +1,58 @@
1
1
  import {
2
- appendAssistantMessage,
3
2
  contentText,
4
3
  emitChatCompletionsRequest,
5
4
  emitChatCompletionsStreamChunk,
6
5
  getModel,
7
- isStreaming,
8
6
  parseChatCompletionsRequest,
9
7
  parseChatCompletionsStreamChunk,
10
- prependSystemPrompt,
8
+ Opcode,
11
9
  type Program,
12
10
  } from "@neutrome/lil-engine";
13
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";
14
+ import { observeExecutionStream, writeReasoning } from "../src/stream/index.ts";
15
+ import { fallback, goal, retry } from "../src/loops/index.ts";
14
16
  import {
15
- appendSyntheticToolExchange,
16
- executeWithModel,
17
- observeExecutionStream,
18
- passthroughPrograms,
19
- streamWithModel,
20
- writeReasoning,
21
- type ModelRuntime,
22
- type OutputSink,
23
- } from "../src/index.ts";
17
+ appendInternalDraft,
18
+ createTwoPassExecutor,
19
+ invokeModel,
20
+ INTERNAL_DRAFT_TOOL_NAME,
21
+ streamModel,
22
+ } from "../src/managed/twoPassExecutor.ts";
23
+ import type {
24
+ Executor,
25
+ ExecutorContext,
26
+ OutputSink,
27
+ } from "../src/types.ts";
24
28
 
25
29
  const encoder = new TextEncoder();
26
30
  const decoder = new TextDecoder();
27
31
 
28
32
  describe("@neutrome/lilsdk-ts", () => {
29
- it("observes tool-call streams and supports passthrough", async () => {
33
+ it("provides clone-safe structural primitives", () => {
34
+ const needle = { opcode: Opcode.MSG_START, value: { kind: "none" as const } };
35
+ const program: Program = {
36
+ code: [
37
+ needle,
38
+ { opcode: Opcode.TXT_CHUNK, value: { kind: "string", value: "hello" } },
39
+ ],
40
+ buffers: [new Uint8Array([1, 2, 3])],
41
+ };
42
+
43
+ expect(indexOf(program, needle)).toBe(0);
44
+ expect(has(program, needle)).toBe(true);
45
+ expect(find(program, needle)).toEqual(needle);
46
+ 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);
49
+
50
+ const mapped = map(program, (instruction) => instruction);
51
+ mapped.buffers[0]![0] = 9;
52
+ expect(program.buffers[0]![0]).toBe(1);
53
+ });
54
+
55
+ it("observes tool-call streams", async () => {
30
56
  const observed = await observeExecutionStream((async function* () {
31
57
  yield parseChatCompletionsStreamChunk(encoder.encode(JSON.stringify({
32
58
  id: "tool-stream",
@@ -63,88 +89,163 @@ describe("@neutrome/lilsdk-ts", () => {
63
89
  throw new Error("expected tool mode");
64
90
  }
65
91
 
66
- const emitted: string[] = [];
67
- const sink: OutputSink = {
68
- write(chunk) {
69
- emitted.push(decoder.decode(emitChatCompletionsStreamChunk(chunk)));
70
- },
71
- close() {
72
- emitted.push("[DONE]");
73
- },
74
- };
92
+ const parsed = observed.chunks.map((chunk) =>
93
+ JSON.parse(decoder.decode(emitChatCompletionsStreamChunk(chunk)))
94
+ );
95
+ expect(parsed.some((chunk) => chunk.choices?.[0]?.delta?.tool_calls)).toBe(true);
96
+ });
97
+
98
+ 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
+ })));
75
103
 
76
- await passthroughPrograms(observed.chunks, sink);
104
+ const updated = appendInternalDraft(request, "draft answer");
105
+ const emitted = JSON.parse(decoder.decode(emitChatCompletionsRequest(updated)));
77
106
 
78
- expect(emitted.at(-1)).toBe("[DONE]");
79
- const parsed = emitted.slice(0, -1).map((chunk) => JSON.parse(chunk));
80
- expect(parsed.some((chunk) => chunk.choices?.[0]?.delta?.tool_calls)).toBe(true);
107
+ expect(emitted.messages[1].tool_calls[0].function.name).toBe(INTERNAL_DRAFT_TOOL_NAME);
108
+ expect(emitted.messages[1].tool_calls[0].function.arguments).toBe("{}");
109
+ expect(emitted.messages[2].content).toBe("draft answer");
81
110
  });
82
111
 
83
- it("appends synthetic tool exchanges to a request program", () => {
112
+ it("appends generic synthetic tool interactions", () => {
84
113
  const request = parseChatCompletionsRequest(encoder.encode(JSON.stringify({
85
114
  model: "virtual-model",
86
115
  messages: [{ role: "user", content: "hello" }],
87
116
  })));
88
117
 
89
- const updated = appendSyntheticToolExchange(request, {
90
- callId: "knowledge_0",
91
- name: "knowledge",
92
- args: "{\"question\":\"thinking\"}",
93
- result: "draft answer",
118
+ const updated = appendToolInteraction(request, {
119
+ callId: "call_lookup",
120
+ name: "lookup",
121
+ args: { id: "42" },
122
+ result: { status: "active" },
94
123
  });
95
-
96
124
  const emitted = JSON.parse(decoder.decode(emitChatCompletionsRequest(updated)));
97
- expect(emitted.messages).toHaveLength(3);
98
- expect(emitted.messages[1].tool_calls[0].function.name).toBe("knowledge");
99
- expect(emitted.messages[2].tool_call_id).toBe("knowledge_0");
100
- expect(JSON.parse(decoder.decode(emitChatCompletionsRequest(request))).messages).toHaveLength(1);
125
+
126
+ expect(emitted.messages[1].tool_calls[0].function).toEqual({
127
+ name: "lookup",
128
+ arguments: "{\"id\":\"42\"}",
129
+ });
130
+ expect(emitted.messages[2]).toEqual({
131
+ role: "tool",
132
+ tool_call_id: "call_lookup",
133
+ content: "{\"status\":\"active\"}",
134
+ });
101
135
  });
102
136
 
103
- it("supports chained model execution across multiple passes", async () => {
104
- const calls: Array<{ model: string; streaming: boolean }> = [];
105
- const runtime: ModelRuntime = {
106
- async execute(request) {
107
- calls.push({ model: getModel(request) ?? "", streaming: isStreaming(request) });
108
- if (calls.length === 1) {
137
+ 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
+ })));
142
+ const seen: Program[] = [];
143
+ const ctx = buildExecutorContext({
144
+ async invoke(program) {
145
+ seen.push(program);
146
+ if (seen.length === 1) {
109
147
  return appendAssistantMessage({ code: [], buffers: [] }, "draft answer");
110
148
  }
111
149
  return appendAssistantMessage({ code: [], buffers: [] }, "final answer");
112
150
  },
113
- async *stream(): AsyncIterable<Program> {
151
+ async *invokeStream() {
114
152
  throw new Error("streaming path is not used in this test");
115
153
  },
116
- };
154
+ });
155
+
156
+ const executor = createTwoPassExecutor({
157
+ draftModel: "smart-model",
158
+ finalModel: "base-model",
159
+ finalSystemPrompt: "system prompt",
160
+ });
117
161
 
162
+ const result = await executor.execute(request, ctx);
163
+
164
+ expect(seen.map((program) => getModel(program))).toEqual(["smart-model", "base-model"]);
165
+ expect(contentText(result)).toBe("final answer");
166
+
167
+ const finalRequest = JSON.parse(decoder.decode(emitChatCompletionsRequest(seen[1]!)));
168
+ expect(finalRequest.messages[0]).toEqual({ role: "system", content: "system prompt" });
169
+ expect(finalRequest.messages[2].tool_calls[0].function).toEqual({
170
+ name: INTERNAL_DRAFT_TOOL_NAME,
171
+ arguments: "{}",
172
+ });
173
+ expect(finalRequest.messages[3]).toEqual({
174
+ role: "tool",
175
+ tool_call_id: "knowledge_0",
176
+ content: "draft answer",
177
+ });
178
+ });
179
+
180
+ it("replaces draft system prompt and moves existing system prompt to first user message", async () => {
118
181
  const request = parseChatCompletionsRequest(encoder.encode(JSON.stringify({
119
182
  model: "virtual-model",
120
- messages: [{ role: "user", content: "hello" }],
183
+ messages: [
184
+ { role: "system", content: "original system" },
185
+ { role: "user", content: "hello" },
186
+ ],
121
187
  })));
122
- const first = await executeWithModel(request, runtime, "smart-model");
123
- const draft = contentText(first);
124
-
125
- let secondRequest = appendSyntheticToolExchange(request, {
126
- callId: "knowledge_0",
127
- name: "knowledge",
128
- args: "{\"question\":\"thinking\"}",
129
- result: draft,
188
+ const seen: Program[] = [];
189
+ const ctx = buildExecutorContext({
190
+ async invoke(program) {
191
+ seen.push(program);
192
+ return appendAssistantMessage({ code: [], buffers: [] }, seen.length === 1 ? "draft" : "final");
193
+ },
194
+ async *invokeStream() {
195
+ throw new Error("streaming path is not used in this test");
196
+ },
130
197
  });
131
- secondRequest = prependSystemPrompt(secondRequest, "system prompt");
132
198
 
133
- const second = await executeWithModel(secondRequest, runtime, "base-model");
199
+ const executor = createTwoPassExecutor({
200
+ draftModel: "smart-model",
201
+ finalModel: "base-model",
202
+ draftSystemPrompt: "draft system",
203
+ });
134
204
 
135
- expect(calls).toEqual([
136
- { model: "smart-model", streaming: false },
137
- { model: "base-model", streaming: false },
205
+ await executor.execute(request, ctx);
206
+
207
+ const draftRequest = JSON.parse(decoder.decode(emitChatCompletionsRequest(seen[0]!)));
208
+ expect(draftRequest.messages).toEqual([
209
+ { role: "system", content: "draft system" },
210
+ { role: "user", content: "original system" },
211
+ { role: "user", content: "hello" },
138
212
  ]);
139
- expect(contentText(second)).toBe("final answer");
140
- expect(getModel(request)).toBe("virtual-model");
141
213
  });
142
214
 
143
- it("streams model output through a sink and writes reasoning helpers", async () => {
215
+ it("invokes and streams another model through executor context helpers", async () => {
216
+ const calls: Array<{ model: string; streaming: boolean }> = [];
217
+ const ctx = buildExecutorContext({
218
+ async invoke(program) {
219
+ calls.push({ model: getModel(program) ?? "", streaming: false });
220
+ return appendAssistantMessage({ code: [], buffers: [] }, "model answer");
221
+ },
222
+ async *invokeStream(program) {
223
+ 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
+ })));
229
+ },
230
+ });
231
+
144
232
  const request = parseChatCompletionsRequest(encoder.encode(JSON.stringify({
145
233
  model: "virtual-model",
146
234
  messages: [{ role: "user", content: "hello" }],
147
235
  })));
236
+ const first = await invokeModel(ctx, request, "smart-model");
237
+ const chunks: Program[] = [];
238
+ for await (const chunk of streamModel(ctx, request, "base-model")) {
239
+ chunks.push(chunk);
240
+ }
241
+
242
+ expect(calls).toEqual([{ model: "smart-model", streaming: false }, { model: "base-model", streaming: true }]);
243
+ expect(contentText(first)).toBe("model answer");
244
+ expect(decoder.decode(emitChatCompletionsStreamChunk(chunks[0]!))).toContain("hello");
245
+ expect(getModel(request)).toBe("virtual-model");
246
+ });
247
+
248
+ it("writes reasoning helpers to a sink", async () => {
148
249
  const emitted: string[] = [];
149
250
  const sink: OutputSink = {
150
251
  write(chunk) {
@@ -155,31 +256,90 @@ describe("@neutrome/lilsdk-ts", () => {
155
256
  },
156
257
  };
157
258
 
158
- const runtime: ModelRuntime = {
159
- async execute() {
160
- throw new Error("non-streaming path is not used in this test");
259
+ await writeReasoning(sink, "thinking");
260
+
261
+ expect(JSON.parse(emitted[0]!).choices[0].delta.reasoning_content).toBe("thinking");
262
+ });
263
+
264
+ it("retries executor failures", async () => {
265
+ 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
+ }));
273
+
274
+ const result = await executor.execute({ code: [], buffers: [] }, buildExecutorContext());
275
+
276
+ expect(calls).toBe(2);
277
+ expect(contentText(result)).toBe("ok");
278
+ });
279
+
280
+ it("falls back to the next executor after failure", async () => {
281
+ const executor = fallback([
282
+ executorFromExecute(async () => {
283
+ throw new Error("primary failed");
284
+ }),
285
+ executorFromExecute(async () => appendAssistantMessage({ code: [], buffers: [] }, "fallback")),
286
+ ]);
287
+
288
+ const result = await executor.execute({ code: [], buffers: [] }, buildExecutorContext());
289
+
290
+ expect(contentText(result)).toBe("fallback");
291
+ });
292
+
293
+ it("runs goal loops until the review is satisfied", async () => {
294
+ let attempts = 0;
295
+ const executor = goal({
296
+ draft: executorFromExecute(async () => {
297
+ attempts += 1;
298
+ return appendAssistantMessage({ code: [], buffers: [] }, `answer ${attempts}`);
299
+ }),
300
+ review: executorFromExecute(async () =>
301
+ appendAssistantMessage({ code: [], buffers: [] }, attempts > 1 ? "pass" : "retry")
302
+ ),
303
+ satisfied(review) {
304
+ return contentText(review) === "pass";
161
305
  },
162
- async *stream(program) {
163
- expect(getModel(program)).toBe("base-model");
164
- expect(isStreaming(program)).toBe(true);
165
- yield parseChatCompletionsStreamChunk(encoder.encode(JSON.stringify({
166
- id: "stream-response",
167
- model: "base-model",
168
- choices: [{ index: 0, delta: { content: "hello" } }],
169
- })));
170
- yield parseChatCompletionsStreamChunk(encoder.encode(JSON.stringify({
171
- id: "stream-response",
172
- model: "base-model",
173
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
174
- })));
306
+ refine(request) {
307
+ return request;
175
308
  },
176
- };
309
+ });
177
310
 
178
- await writeReasoning(sink, "thinking");
179
- await streamWithModel(request, runtime, sink, "base-model");
311
+ const result = await executor.execute({ code: [], buffers: [] }, buildExecutorContext());
180
312
 
181
- expect(emitted.at(-1)).toBe("[DONE]");
182
- expect(JSON.parse(emitted[0]!).choices[0].delta.reasoning_content).toBe("thinking");
183
- expect(JSON.parse(emitted[1]!).choices[0].delta.content).toBe("hello");
313
+ expect(attempts).toBe(2);
314
+ expect(contentText(result)).toBe("answer 2");
184
315
  });
185
316
  });
317
+
318
+ function buildExecutorContext(
319
+ impl: Pick<ExecutorContext, "invoke" | "invokeStream"> = {
320
+ async invoke(program) {
321
+ return program;
322
+ },
323
+ async *invokeStream(program) {
324
+ yield program;
325
+ },
326
+ },
327
+ ): ExecutorContext {
328
+ return {
329
+ requestId: "req_test",
330
+ executionId: "exec_test",
331
+ invoke: impl.invoke,
332
+ invokeStream: impl.invokeStream,
333
+ observe: () => {},
334
+ signal: new AbortController().signal,
335
+ };
336
+ }
337
+
338
+ function executorFromExecute(execute: Executor["execute"]): Executor {
339
+ return {
340
+ execute,
341
+ async *stream(request, ctx) {
342
+ yield await execute(request, ctx);
343
+ },
344
+ };
345
+ }