@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/README.md CHANGED
@@ -3,18 +3,26 @@
3
3
  High-level TypeScript SDK for transport-neutral model authoring on top of `@neutrome/lil-engine`.
4
4
 
5
5
  ```ts
6
- import {
7
- appendSyntheticToolExchange,
8
- executeWithModel,
9
- observeExecutionStream,
10
- writeReasoning,
11
- type ModelHandler,
12
- } from "@neutrome/lilsdk-ts";
6
+ import { createTwoPassExecutor, type Executor } from "@neutrome/lilsdk-ts";
7
+ import { retry } from "@neutrome/lilsdk-ts/loops";
8
+ import { observeExecutionStream, writeReasoning } from "@neutrome/lilsdk-ts/stream";
9
+ import { connectTools } from "@neutrome/lilsdk-ts/tools";
13
10
  ```
14
11
 
15
12
  This package owns:
16
13
 
17
- - model execution interfaces
18
- - request/program rewriting helpers
19
- - stream passthrough helpers
20
- - multi-pass authoring primitives used by virtual models such as `enei-1`
14
+ - executor and tool contracts
15
+ - `@neutrome/lilsdk-ts/primitives`: structural program helpers such as `walk`, `map`, `reduce`, `has`, `find`, `indexOf`, `all`, and `any`
16
+ - `@neutrome/lilsdk-ts/synthetic`: request/message/tool synthesis helpers such as `makeRequest`, `makeResponse`, `makeMessage`, `makeToolCall`, `makeToolResponse`, and `appendToolInteraction`
17
+ - `@neutrome/lilsdk-ts/tools`: `connectTools` and `withTools` tool-call loops
18
+ - `@neutrome/lilsdk-ts/stream`: stream observation and output helpers
19
+ - `@neutrome/lilsdk-ts/loops`: `retry`, `fallback`, and `goal` executor control-flow helpers
20
+ - `@neutrome/lilsdk-ts/managed/twoPassExecutor`: `createTwoPassExecutor` and model invocation helpers
21
+ - multi-pass authoring primitives used by virtual models
22
+
23
+ The root export is intentionally small: SDK contracts plus the most common
24
+ high-level two-pass executor helper.
25
+
26
+ It intentionally does not own HTTP routing, provider credentials, target
27
+ resolution, or provider JSON conversion. Those live in `@neutrome/open-ai-router`
28
+ and `@neutrome/lil-engine`.
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "@neutrome/lilsdk-ts",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "exports": {
6
- ".": "./src/index.ts"
6
+ ".": "./src/index.ts",
7
+ "./types": "./src/types.ts",
8
+ "./primitives": "./src/primitives/index.ts",
9
+ "./synthetic": "./src/synthetic/index.ts",
10
+ "./stream": "./src/stream/index.ts",
11
+ "./tools": "./src/tools.ts",
12
+ "./loops": "./src/loops/index.ts",
13
+ "./managed": "./src/managed/index.ts"
7
14
  },
8
15
  "dependencies": {
9
- "@neutrome/lil-engine": "0.1.5"
16
+ "@neutrome/lil-engine": "0.2.0"
10
17
  },
11
18
  "devDependencies": {
12
19
  "@types/node": "^25.9.3",
package/src/index.ts CHANGED
@@ -1,9 +1,14 @@
1
- export type { ModelHandler, ModelRuntime, OutputSink } from "./types.ts";
2
- export type { ExecuteModelOptions, StreamModelOptions } from "./types.ts";
3
- export type { ObservedExecution, StreamObservationHooks } from "./observe.ts";
4
- export type { SyntheticToolExchange } from "./request.ts";
5
-
6
- export { appendSyntheticToolExchange } from "./request.ts";
7
- export { writeReasoning } from "./output.ts";
8
- export { executeWithModel, passthroughPrograms, pipeProgramStream, streamWithModel } from "./execution.ts";
9
- export { observeExecutionStream } from "./observe.ts";
1
+ export type {
2
+ ExecutionTarget,
3
+ Executor,
4
+ ExecutorContext,
5
+ InvokeOptions,
6
+ OutputSink,
7
+ ProgramTransform,
8
+ SdkEvent,
9
+ SdkEventInput,
10
+ Tool,
11
+ TransformCapability,
12
+ TransformContext,
13
+ createSdkEvent,
14
+ } from "./types.ts";
@@ -0,0 +1,190 @@
1
+ import type { Program } from "@neutrome/lil-engine";
2
+ import type { Executor, ExecutorContext } from "../types.ts";
3
+
4
+ export type RetryOptions = {
5
+ attempts?: number;
6
+ shouldRetry?: (error: unknown, attempt: number) => boolean | Promise<boolean>;
7
+ onRetry?: (error: unknown, attempt: number) => void | Promise<void>;
8
+ };
9
+
10
+ export type FallbackOptions = {
11
+ shouldFallback?: (error: unknown, executorIndex: number) => boolean | Promise<boolean>;
12
+ onFallback?: (error: unknown, executorIndex: number) => void | Promise<void>;
13
+ };
14
+
15
+ export type GoalOptions = {
16
+ draft: Executor;
17
+ review: Executor;
18
+ refine: (
19
+ request: Program,
20
+ answer: Program,
21
+ review: Program,
22
+ attempt: number,
23
+ ) => Program | Promise<Program>;
24
+ satisfied: (review: Program, attempt: number) => boolean | Promise<boolean>;
25
+ maxIterations?: number;
26
+ };
27
+
28
+ export function retry(executor: Executor, options: RetryOptions = {}): Executor {
29
+ const attempts = positiveInteger(options.attempts ?? 2, "retry attempts");
30
+
31
+ return {
32
+ execute(request, ctx) {
33
+ return runRetry(
34
+ (attempt) => executor.execute(request, childContext(ctx, attempt)),
35
+ attempts,
36
+ options,
37
+ );
38
+ },
39
+
40
+ stream(request, ctx) {
41
+ return streamBuffered(() =>
42
+ runRetry(
43
+ (attempt) => collect(executor.stream(request, childContext(ctx, attempt))),
44
+ attempts,
45
+ options,
46
+ ),
47
+ );
48
+ },
49
+ };
50
+ }
51
+
52
+ export function fallback(executors: readonly Executor[], options: FallbackOptions = {}): Executor {
53
+ if (executors.length === 0) {
54
+ throw new Error("fallback requires at least one executor");
55
+ }
56
+
57
+ return {
58
+ execute(request, ctx) {
59
+ return runFallback(
60
+ (executor, index) => executor.execute(request, childContext(ctx, index + 1)),
61
+ executors,
62
+ options,
63
+ );
64
+ },
65
+
66
+ stream(request, ctx) {
67
+ return streamBuffered(() =>
68
+ runFallback(
69
+ (executor, index) => collect(executor.stream(request, childContext(ctx, index + 1))),
70
+ executors,
71
+ options,
72
+ ),
73
+ );
74
+ },
75
+ };
76
+ }
77
+
78
+ export function goal(options: GoalOptions): Executor {
79
+ const maxIterations = positiveInteger(options.maxIterations ?? 3, "goal maxIterations");
80
+ const executeGoal: Executor["execute"] = async (request, ctx) => {
81
+ let current = request;
82
+ let answer = request;
83
+
84
+ for (let attempt = 1; attempt <= maxIterations; attempt += 1) {
85
+ answer = await options.draft.execute(current, childContext(ctx, attempt));
86
+ const review = await options.review.execute(answer, childContext(ctx, attempt));
87
+ if (await options.satisfied(review, attempt)) {
88
+ return answer;
89
+ }
90
+ if (attempt < maxIterations) {
91
+ current = await options.refine(current, answer, review, attempt);
92
+ }
93
+ }
94
+
95
+ return answer;
96
+ };
97
+
98
+ return {
99
+ execute: executeGoal,
100
+
101
+ stream(request, ctx) {
102
+ return streamBuffered(async () => [await executeGoal(request, ctx)]);
103
+ },
104
+ };
105
+ }
106
+
107
+ async function runRetry<T>(
108
+ operation: (attempt: number) => Promise<T>,
109
+ attempts: number,
110
+ options: RetryOptions,
111
+ ): Promise<T> {
112
+ let lastError: unknown;
113
+
114
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
115
+ try {
116
+ return await operation(attempt);
117
+ } catch (error) {
118
+ lastError = error;
119
+ const canRetry = attempt < attempts && (await shouldContinue(error, attempt, options.shouldRetry));
120
+ if (!canRetry) {
121
+ throw error;
122
+ }
123
+ await options.onRetry?.(error, attempt);
124
+ }
125
+ }
126
+
127
+ throw lastError;
128
+ }
129
+
130
+ async function runFallback<T>(
131
+ operation: (executor: Executor, index: number) => Promise<T>,
132
+ executors: readonly Executor[],
133
+ options: FallbackOptions,
134
+ ): Promise<T> {
135
+ let lastError: unknown;
136
+
137
+ for (const [index, executor] of executors.entries()) {
138
+ try {
139
+ return await operation(executor, index);
140
+ } catch (error) {
141
+ lastError = error;
142
+ const canFallback =
143
+ index < executors.length - 1 &&
144
+ (await shouldContinue(error, index, options.shouldFallback));
145
+ if (!canFallback) {
146
+ throw error;
147
+ }
148
+ await options.onFallback?.(error, index);
149
+ }
150
+ }
151
+
152
+ throw lastError;
153
+ }
154
+
155
+ async function shouldContinue(
156
+ error: unknown,
157
+ attempt: number,
158
+ predicate?: (error: unknown, attempt: number) => boolean | Promise<boolean>,
159
+ ): Promise<boolean> {
160
+ return predicate ? predicate(error, attempt) : true;
161
+ }
162
+
163
+ async function collect(stream: AsyncIterable<Program>): Promise<Program[]> {
164
+ const chunks: Program[] = [];
165
+ for await (const chunk of stream) {
166
+ chunks.push(chunk);
167
+ }
168
+ return chunks;
169
+ }
170
+
171
+ async function* streamBuffered(load: () => Promise<Program[]>): AsyncIterable<Program> {
172
+ for (const chunk of await load()) {
173
+ yield chunk;
174
+ }
175
+ }
176
+
177
+ function childContext(ctx: ExecutorContext, attempt: number): ExecutorContext {
178
+ return {
179
+ ...ctx,
180
+ executionId: `${ctx.executionId}:${attempt}`,
181
+ parentExecutionId: ctx.executionId,
182
+ };
183
+ }
184
+
185
+ function positiveInteger(value: number, label: string): number {
186
+ if (!Number.isInteger(value) || value < 1) {
187
+ throw new Error(`${label} must be a positive integer`);
188
+ }
189
+ return value;
190
+ }
@@ -0,0 +1,15 @@
1
+ export { wrap, wrapNoAcl } from "./wrap.ts";
2
+
3
+ export {
4
+ appendInternalDraft,
5
+ createTwoPassExecutor,
6
+ INTERNAL_DRAFT_CALL_ID,
7
+ INTERNAL_DRAFT_TOOL_NAME,
8
+ invokeModel,
9
+ streamModel,
10
+ } from "./twoPassExecutor.ts";
11
+
12
+ export type {
13
+ InternalDraft,
14
+ TwoPassExecutorOptions,
15
+ } from "./twoPassExecutor.ts";
@@ -0,0 +1,214 @@
1
+ import {
2
+ callData,
3
+ contentText,
4
+ deltaText,
5
+ finishReason,
6
+ hasToolDelta,
7
+ insertBefore,
8
+ lastMessageRole,
9
+ messageText,
10
+ messages,
11
+ removeRange,
12
+ setModel,
13
+ type Program,
14
+ } from "@neutrome/lil-engine";
15
+ import { streamReasoningDelta } from "../output.ts";
16
+ import {
17
+ appendToolInteraction,
18
+ makeMessage,
19
+ prependSystemPrompt,
20
+ } from "../synthetic/index.ts";
21
+ import type { Executor, ExecutorContext, InvokeOptions } from "../types.ts";
22
+
23
+ export const INTERNAL_DRAFT_TOOL_NAME = "knowledge";
24
+ export const INTERNAL_DRAFT_CALL_ID = "knowledge_0";
25
+
26
+ export type InternalDraft =
27
+ | string
28
+ | readonly string[]
29
+ | readonly { text: string }[];
30
+
31
+ export type TwoPassExecutorOptions = {
32
+ draftModel: string;
33
+ finalModel: string;
34
+ draftSystemPrompt?: string;
35
+ finalSystemPrompt?: string;
36
+ reasoningIntro?: string;
37
+ reasoningSeparator?: string;
38
+ buildFinalRequest?: (request: Program, draft: string) => Program;
39
+ };
40
+
41
+ export function invokeModel(
42
+ ctx: ExecutorContext,
43
+ request: Program,
44
+ model: string,
45
+ options?: InvokeOptions,
46
+ ): Promise<Program> {
47
+ return ctx.invoke(setModel(request, model), options);
48
+ }
49
+
50
+ export async function* streamModel(
51
+ ctx: ExecutorContext,
52
+ request: Program,
53
+ model: string,
54
+ options?: InvokeOptions,
55
+ ): AsyncIterable<Program> {
56
+ yield* ctx.invokeStream(setModel(request, model), options);
57
+ }
58
+
59
+ export function appendInternalDraft(
60
+ request: Program,
61
+ draft: InternalDraft,
62
+ options: { callId?: string } = {},
63
+ ): Program {
64
+ const callId = options.callId ?? INTERNAL_DRAFT_CALL_ID;
65
+ const drafts = normalizeDrafts(draft);
66
+ if (drafts.length === 0) {
67
+ return request;
68
+ }
69
+
70
+ return drafts.reduce(
71
+ (program, text, index) =>
72
+ appendToolInteraction(program, {
73
+ callId: drafts.length === 1 ? callId : `${callId}_${index}`,
74
+ name: INTERNAL_DRAFT_TOOL_NAME,
75
+ args: {},
76
+ result: text,
77
+ }),
78
+ request,
79
+ );
80
+ }
81
+
82
+ export function createTwoPassExecutor(options: TwoPassExecutorOptions): Executor {
83
+ const reasoningIntro = options.reasoningIntro ?? "Let me think...\n\n";
84
+ const reasoningSeparator = options.reasoningSeparator ?? "\n\n";
85
+
86
+ return {
87
+ async execute(request, ctx) {
88
+ const draftRequest = buildDraftRequest(options, request);
89
+ if (lastMessageRole(request) === "tool") {
90
+ return invokeModel(ctx, draftRequest, options.draftModel);
91
+ }
92
+
93
+ const draftResponse = await invokeModel(ctx, draftRequest, options.draftModel);
94
+ if (callData(draftResponse).length > 0) {
95
+ return draftResponse;
96
+ }
97
+
98
+ return invokeModel(
99
+ ctx,
100
+ buildFinalRequest(options, request, contentText(draftResponse)),
101
+ options.finalModel,
102
+ );
103
+ },
104
+
105
+ async *stream(request, ctx) {
106
+ const draftRequest = buildDraftRequest(options, request);
107
+ if (lastMessageRole(request) === "tool") {
108
+ yield* streamModel(ctx, draftRequest, options.draftModel);
109
+ return;
110
+ }
111
+
112
+ let transcript = "";
113
+ let emittedReasoning = false;
114
+ let toolMode = false;
115
+ const draftStream = streamModel(ctx, draftRequest, options.draftModel);
116
+
117
+ for await (const chunk of draftStream) {
118
+ if (ctx.signal.aborted) {
119
+ return;
120
+ }
121
+
122
+ if (toolMode) {
123
+ yield chunk;
124
+ continue;
125
+ }
126
+
127
+ const toolRequested = hasToolDelta(chunk) || finishReason(chunk) === "tool_calls";
128
+ if (toolRequested) {
129
+ toolMode = true;
130
+ yield chunk;
131
+ continue;
132
+ }
133
+
134
+ const text = deltaText(chunk);
135
+ if (!text) {
136
+ continue;
137
+ }
138
+
139
+ transcript += text;
140
+ if (!emittedReasoning) {
141
+ emittedReasoning = true;
142
+ yield streamReasoningDelta(reasoningIntro);
143
+ }
144
+ yield streamReasoningDelta(text);
145
+ }
146
+
147
+ if (toolMode || ctx.signal.aborted) {
148
+ return;
149
+ }
150
+
151
+ if (emittedReasoning && reasoningSeparator) {
152
+ yield streamReasoningDelta(reasoningSeparator);
153
+ }
154
+
155
+ yield* streamModel(
156
+ ctx,
157
+ buildFinalRequest(options, request, transcript),
158
+ options.finalModel,
159
+ );
160
+ },
161
+ };
162
+ }
163
+
164
+ function buildDraftRequest(options: TwoPassExecutorOptions, request: Program): Program {
165
+ if (!options.draftSystemPrompt) {
166
+ return request;
167
+ }
168
+
169
+ const systemSpans = messages(request).filter((span) => span.role === "system");
170
+ const previousSystemPrompt = systemSpans
171
+ .map((span) => messageText(request, span).trim())
172
+ .filter((text) => text.length > 0)
173
+ .join("\n\n");
174
+
175
+ let draft = request;
176
+ for (const span of systemSpans.slice().reverse()) {
177
+ draft = removeRange(draft, span.start, span.end);
178
+ }
179
+
180
+ draft = prependSystemPrompt(draft, options.draftSystemPrompt);
181
+
182
+ if (!previousSystemPrompt) {
183
+ return draft;
184
+ }
185
+
186
+ const firstNonSystem = messages(draft).find((span) => span.role !== "system");
187
+ return insertBefore(
188
+ draft,
189
+ firstNonSystem?.start ?? draft.code.length,
190
+ makeMessage("user", previousSystemPrompt).code,
191
+ );
192
+ }
193
+
194
+ function buildFinalRequest(
195
+ options: TwoPassExecutorOptions,
196
+ request: Program,
197
+ draft: string,
198
+ ): Program {
199
+ if (options.buildFinalRequest) {
200
+ return options.buildFinalRequest(request, draft);
201
+ }
202
+
203
+ const withDraft = appendInternalDraft(request, draft);
204
+ return options.finalSystemPrompt
205
+ ? prependSystemPrompt(withDraft, options.finalSystemPrompt)
206
+ : withDraft;
207
+ }
208
+
209
+ function normalizeDrafts(draft: InternalDraft): string[] {
210
+ const drafts = Array.isArray(draft)
211
+ ? draft.map((item) => typeof item === "string" ? item : item.text)
212
+ : [draft];
213
+ return drafts.map((text) => text.trim()).filter((text) => text.length > 0);
214
+ }
@@ -0,0 +1,17 @@
1
+ import { setModel } from "@neutrome/lil-engine";
2
+ import { Executor, type ExecutionTarget } from "../types";
3
+
4
+ export function wrap(model: string): Executor {
5
+ return {
6
+ execute: (program, ctx) => ctx.invoke(setModel(program, model)),
7
+ stream: (request, ctx) => ctx.invokeStream(setModel(request, model)),
8
+ };
9
+ }
10
+
11
+ export function wrapNoAcl(provider: string, model: string): Executor {
12
+ const target: ExecutionTarget = { kind: "provider", provider, model };
13
+ return {
14
+ execute: (program, ctx) => ctx.invoke(program, { target }),
15
+ stream: (request, ctx) => ctx.invokeStream(request, { target }),
16
+ };
17
+ }
package/src/observe.ts CHANGED
@@ -1,2 +1,94 @@
1
- export { observeExecutionStream } from "@neutrome/lil-engine";
2
- export type { ObservedExecution, StreamObservationHooks } from "@neutrome/lil-engine";
1
+ import {
2
+ cloneProgram,
3
+ deltaText,
4
+ finishReason,
5
+ hasToolDelta,
6
+ type Program,
7
+ } from "@neutrome/lil-engine";
8
+
9
+ export type StreamObservationHooks = {
10
+ onTextStart?: () => void;
11
+ onTextChunk?: (text: string, chunk: Program) => void;
12
+ };
13
+
14
+ export type ObservedExecution =
15
+ | {
16
+ mode: "text";
17
+ transcript: string;
18
+ emittedText: boolean;
19
+ }
20
+ | {
21
+ mode: "tool";
22
+ transcript: string;
23
+ emittedText: boolean;
24
+ chunks: Program[];
25
+ };
26
+
27
+ export async function observeExecutionStream(
28
+ source: AsyncIterable<Program>,
29
+ hooks: StreamObservationHooks = {},
30
+ ): Promise<ObservedExecution> {
31
+ const pending: Program[] = [];
32
+ const passthrough: Program[] = [];
33
+ let transcript = "";
34
+ let mode: "pending" | "text" | "tool" = "pending";
35
+ let emittedText = false;
36
+
37
+ for await (const chunk of source) {
38
+ const text = deltaText(chunk);
39
+ const toolRequested = hasToolDelta(chunk) || finishReason(chunk) === "tool_calls";
40
+
41
+ if (mode === "pending") {
42
+ const clone = cloneProgram(chunk);
43
+ pending.push(clone);
44
+
45
+ if (toolRequested) {
46
+ mode = "tool";
47
+ passthrough.push(...pending.splice(0));
48
+ continue;
49
+ }
50
+
51
+ if (!text) {
52
+ continue;
53
+ }
54
+
55
+ mode = "text";
56
+ emittedText = true;
57
+ hooks.onTextStart?.();
58
+ for (const pendingChunk of pending.splice(0)) {
59
+ const pendingText = deltaText(pendingChunk);
60
+ if (pendingText) {
61
+ transcript += pendingText;
62
+ hooks.onTextChunk?.(pendingText, pendingChunk);
63
+ }
64
+ }
65
+ continue;
66
+ }
67
+
68
+ if (mode === "tool") {
69
+ passthrough.push(cloneProgram(chunk));
70
+ continue;
71
+ }
72
+
73
+ if (toolRequested) {
74
+ mode = "tool";
75
+ passthrough.push(cloneProgram(chunk));
76
+ continue;
77
+ }
78
+
79
+ if (text) {
80
+ if (!emittedText) {
81
+ emittedText = true;
82
+ hooks.onTextStart?.();
83
+ }
84
+ transcript += text;
85
+ hooks.onTextChunk?.(text, chunk);
86
+ }
87
+ }
88
+
89
+ if (mode === "tool") {
90
+ return { mode: "tool", transcript, emittedText, chunks: passthrough };
91
+ }
92
+
93
+ return { mode: "text", transcript, emittedText };
94
+ }
package/src/output.ts CHANGED
@@ -1,10 +1,25 @@
1
- import { createProgram, Opcode } from "@neutrome/lil-engine";
1
+ import {
2
+ createProgram,
3
+ Opcode,
4
+ streamEnd,
5
+ streamStart,
6
+ streamTextDelta,
7
+ type Program,
8
+ } from "@neutrome/lil-engine";
2
9
  import type { OutputSink } from "./types.ts";
3
10
 
4
11
  export async function writeReasoning(sink: OutputSink, text: string): Promise<void> {
5
- await sink.write(createProgram({
12
+ await sink.write(streamReasoningDelta(text));
13
+ }
14
+
15
+ export function streamTextResponse(text: string): Program[] {
16
+ return [streamStart(), streamTextDelta(text), streamEnd()];
17
+ }
18
+
19
+ export function streamReasoningDelta(text: string): Program {
20
+ return createProgram({
6
21
  code: [
7
22
  { opcode: Opcode.STREAM_THINK_DELTA, value: { kind: "string", value: text } },
8
23
  ],
9
- }));
24
+ });
10
25
  }