@neutrome/lilsdk 0.3.5 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neutrome/lilsdk",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -13,7 +13,7 @@
13
13
  "./managed": "./src/managed/index.ts"
14
14
  },
15
15
  "dependencies": {
16
- "@neutrome/lil-engine": "0.3.4"
16
+ "@neutrome/lil-engine": "0.4.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/node": "^25.9.3",
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export type {
2
- ExecutionTarget,
2
+ Cache,
3
+ CachePutOptions,
3
4
  Executor,
5
+ ExecutorInput,
4
6
  ExecutorContext,
5
- InvokeOptions,
6
7
  OutputSink,
7
8
  ProgramTransform,
8
9
  ExecutionEvent,
@@ -1,5 +1,6 @@
1
1
  import { createProgram, deltaText, type Program } from "@neutrome/lil-engine";
2
- import type { Executor, ExecutorContext } from "../types.ts";
2
+ import type { Executor, ExecutorContext, ExecutorInput } from "../types.ts";
3
+ import { completeStream, streamStage } from "../stream/stages.ts";
3
4
  import { appendAssistantMessage } from "../synthetic/index.ts";
4
5
 
5
6
  export type RetryOptions = {
@@ -17,8 +18,8 @@ export type FallbackOptions = {
17
18
  };
18
19
 
19
20
  export type GoalExecutorOptions = {
20
- draft: Executor;
21
- review: Executor;
21
+ draft: ExecutorInput;
22
+ review: ExecutorInput;
22
23
  refine: (
23
24
  request: Program,
24
25
  answer: Program,
@@ -30,7 +31,7 @@ export type GoalExecutorOptions = {
30
31
  };
31
32
 
32
33
  export function retry(
33
- executor: Executor,
34
+ executor: ExecutorInput,
34
35
  options: RetryOptions = {},
35
36
  ): Executor {
36
37
  const attempts = positiveInteger(options.attempts ?? 2, "retry attempts");
@@ -38,7 +39,7 @@ export function retry(
38
39
  return {
39
40
  execute(request, ctx) {
40
41
  return runRetry(
41
- (attempt) => executor.execute(request, childContext(ctx, attempt)),
42
+ (attempt) => childContext(ctx, attempt).invoke(executor, request),
42
43
  attempts,
43
44
  options,
44
45
  );
@@ -50,9 +51,9 @@ export function retry(
50
51
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
51
52
  let emitted = false;
52
53
  try {
53
- for await (const chunk of executor.stream(
54
+ for await (const chunk of childContext(ctx, attempt).invokeStream(
55
+ executor,
54
56
  request,
55
- childContext(ctx, attempt),
56
57
  )) {
57
58
  emitted = true;
58
59
  yield chunk;
@@ -77,7 +78,7 @@ export function retry(
77
78
  }
78
79
 
79
80
  export function fallback(
80
- executors: readonly Executor[],
81
+ executors: readonly ExecutorInput[],
81
82
  options: FallbackOptions = {},
82
83
  ): Executor {
83
84
  if (executors.length === 0) {
@@ -88,7 +89,7 @@ export function fallback(
88
89
  execute(request, ctx) {
89
90
  return runFallback(
90
91
  (executor, index) =>
91
- executor.execute(request, childContext(ctx, index + 1)),
92
+ childContext(ctx, index + 1).invoke(executor, request),
92
93
  executors,
93
94
  options,
94
95
  );
@@ -100,9 +101,9 @@ export function fallback(
100
101
  for (const [index, executor] of executors.entries()) {
101
102
  let emitted = false;
102
103
  try {
103
- for await (const chunk of executor.stream(
104
+ for await (const chunk of childContext(ctx, index + 1).invokeStream(
105
+ executor,
104
106
  request,
105
- childContext(ctx, index + 1),
106
107
  )) {
107
108
  emitted = true;
108
109
  yield chunk;
@@ -139,8 +140,8 @@ export function createGoalExecutor(options: GoalExecutorOptions): Executor {
139
140
 
140
141
  for (let attempt = 1; attempt <= maxIterations; attempt += 1) {
141
142
  const attemptContext = childContext(ctx, attempt);
142
- answer = await options.draft.execute(current, attemptContext);
143
- const review = await options.review.execute(answer, attemptContext);
143
+ answer = await attemptContext.invoke(options.draft, current);
144
+ const review = await attemptContext.invoke(options.review, answer);
144
145
  if (await options.satisfied(review, attempt)) return answer;
145
146
  if (attempt < maxIterations) {
146
147
  current = await options.refine(current, answer, review, attempt);
@@ -157,19 +158,24 @@ export function createGoalExecutor(options: GoalExecutorOptions): Executor {
157
158
  for (let attempt = 1; attempt <= maxIterations; attempt += 1) {
158
159
  const attemptContext = childContext(ctx, attempt);
159
160
  answer = yield* streamGoalStage(
160
- options.draft.stream(current, attemptContext),
161
+ streamStage(attemptContext.invokeStream(options.draft, current)),
161
162
  current,
162
163
  );
163
164
  const review = yield* streamGoalStage(
164
- options.review.stream(answer, attemptContext),
165
+ streamStage(attemptContext.invokeStream(options.review, answer)),
165
166
  answer,
166
167
  );
167
168
 
168
- if (await options.satisfied(review, attempt)) return;
169
+ if (await options.satisfied(review, attempt)) {
170
+ yield completeStream();
171
+ return;
172
+ }
169
173
  if (attempt < maxIterations) {
170
174
  current = await options.refine(current, answer, review, attempt);
171
175
  }
172
176
  }
177
+
178
+ yield completeStream();
173
179
  },
174
180
  };
175
181
  }
@@ -218,8 +224,8 @@ async function runRetry<T>(
218
224
  }
219
225
 
220
226
  async function runFallback<T>(
221
- operation: (executor: Executor, index: number) => Promise<T>,
222
- executors: readonly Executor[],
227
+ operation: (executor: ExecutorInput, index: number) => Promise<T>,
228
+ executors: readonly ExecutorInput[],
223
229
  options: FallbackOptions,
224
230
  ): Promise<T> {
225
231
  let lastError: unknown;
@@ -0,0 +1,215 @@
1
+ import {
2
+ addTool,
3
+ clearIndices,
4
+ toolDefinitions,
5
+ viewProgram,
6
+ type Program,
7
+ type ProgramTool,
8
+ } from "@neutrome/lil-engine";
9
+ import { connectTools } from "../tools.ts";
10
+ import type {
11
+ Executor,
12
+ ExecutorContext,
13
+ ExecutorInput,
14
+ Tool,
15
+ } from "../types.ts";
16
+
17
+ const capabilityToolName = "learn_capability";
18
+ const encoder = new TextEncoder();
19
+
20
+ export type CapabilitiesExecutorOptions = {
21
+ enabledIterations?: number;
22
+ cacheKey?: (ctx: ExecutorContext) => string;
23
+ };
24
+
25
+ type CapabilitySelection = { toolName: string; remaining: number };
26
+
27
+ export function createCapabilitiesExecutor(
28
+ inner: ExecutorInput,
29
+ options: CapabilitiesExecutorOptions = {},
30
+ ): Executor {
31
+ const enabledIterations = positiveInteger(options.enabledIterations ?? 5);
32
+ const cacheKey = options.cacheKey ?? ((ctx) => ctx.requestId);
33
+
34
+ return {
35
+ async execute(request, ctx) {
36
+ const tools = viewProgram(request).tools;
37
+ if (tools.length === 0) return ctx.invoke(inner, request);
38
+
39
+ const key = selectionKey(cacheKey(ctx));
40
+ await discardMissingSelection(ctx, key, tools);
41
+ return connectCapabilities(inner, tools, key, enabledIterations).execute(
42
+ withoutTools(request),
43
+ ctx,
44
+ );
45
+ },
46
+
47
+ async *stream(request, ctx) {
48
+ const tools = viewProgram(request).tools;
49
+ if (tools.length === 0) {
50
+ yield* ctx.invokeStream(inner, request);
51
+ return;
52
+ }
53
+
54
+ const key = selectionKey(cacheKey(ctx));
55
+ await discardMissingSelection(ctx, key, tools);
56
+ yield* connectCapabilities(inner, tools, key, enabledIterations).stream(
57
+ withoutTools(request),
58
+ ctx,
59
+ );
60
+ },
61
+ };
62
+ }
63
+
64
+ function connectCapabilities(
65
+ inner: ExecutorInput,
66
+ tools: readonly ProgramTool[],
67
+ key: string,
68
+ enabledIterations: number,
69
+ ): Executor {
70
+ const selectedInner: Executor = {
71
+ async execute(request, ctx) {
72
+ return ctx.invoke(
73
+ inner,
74
+ await withSelectedTool(request, ctx, key, tools),
75
+ );
76
+ },
77
+ async *stream(request, ctx) {
78
+ yield* ctx.invokeStream(
79
+ inner,
80
+ await withSelectedTool(request, ctx, key, tools),
81
+ );
82
+ },
83
+ };
84
+
85
+ return connectTools(
86
+ [learnCapabilityTool(tools, key, enabledIterations)],
87
+ selectedInner,
88
+ );
89
+ }
90
+
91
+ function learnCapabilityTool(
92
+ tools: readonly ProgramTool[],
93
+ key: string,
94
+ enabledIterations: number,
95
+ ): Tool {
96
+ return {
97
+ name: capabilityToolName,
98
+ description: `Enable one capability for the next ${enabledIterations} upstream iterations. Available capabilities: ${tools.map((tool) => tool.name).join(", ")}.`,
99
+ schema: {
100
+ type: "object",
101
+ properties: { capability: { type: "string" } },
102
+ required: ["capability"],
103
+ additionalProperties: false,
104
+ },
105
+ async execute(args, ctx) {
106
+ const name = typeof args.capability === "string" ? args.capability : "";
107
+ if (!tools.some((tool) => tool.name === name)) {
108
+ return `Unknown capability: ${name || "(missing)"}.`;
109
+ }
110
+ await writeSelection(ctx, key, {
111
+ toolName: name,
112
+ remaining: enabledIterations,
113
+ });
114
+ return `Enabled ${name} for the next ${enabledIterations} upstream iterations.`;
115
+ },
116
+ };
117
+ }
118
+
119
+ async function withSelectedTool(
120
+ request: Program,
121
+ ctx: ExecutorContext,
122
+ key: string,
123
+ tools: readonly ProgramTool[],
124
+ ): Promise<Program> {
125
+ const selection = await readSelection(ctx, key);
126
+ if (!selection || selection.remaining < 1) return request;
127
+
128
+ const tool = tools.find((candidate) => candidate.name === selection.toolName);
129
+ if (!tool) {
130
+ await ctx.cache.delete(key);
131
+ return request;
132
+ }
133
+
134
+ if (selection.remaining === 1) {
135
+ await ctx.cache.delete(key);
136
+ } else {
137
+ await writeSelection(ctx, key, {
138
+ ...selection,
139
+ remaining: selection.remaining - 1,
140
+ });
141
+ }
142
+ return addProgramTool(request, tool);
143
+ }
144
+
145
+ async function discardMissingSelection(
146
+ ctx: ExecutorContext,
147
+ key: string,
148
+ tools: readonly ProgramTool[],
149
+ ): Promise<void> {
150
+ const selection = await readSelection(ctx, key);
151
+ if (selection && !tools.some((tool) => tool.name === selection.toolName)) {
152
+ await ctx.cache.delete(key);
153
+ }
154
+ }
155
+
156
+ function withoutTools(request: Program): Program {
157
+ const indices: number[] = [];
158
+ for (const definition of toolDefinitions(request)) {
159
+ for (let index = definition.start; index <= definition.end; index += 1) {
160
+ indices.push(index);
161
+ }
162
+ }
163
+ return clearIndices(request, indices);
164
+ }
165
+
166
+ function addProgramTool(request: Program, tool: ProgramTool): Program {
167
+ return addTool(
168
+ request,
169
+ tool.name,
170
+ tool.description,
171
+ encoder.encode(JSON.stringify(tool.schema)),
172
+ );
173
+ }
174
+
175
+ async function readSelection(
176
+ ctx: ExecutorContext,
177
+ key: string,
178
+ ): Promise<CapabilitySelection | null> {
179
+ const value = await ctx.cache.get(key);
180
+ if (!value) return null;
181
+ try {
182
+ const parsed: unknown = JSON.parse(value);
183
+ if (
184
+ parsed &&
185
+ typeof parsed === "object" &&
186
+ typeof (parsed as CapabilitySelection).toolName === "string" &&
187
+ Number.isInteger((parsed as CapabilitySelection).remaining)
188
+ ) {
189
+ return parsed as CapabilitySelection;
190
+ }
191
+ } catch {
192
+ // Treat malformed cache data as an expired selection.
193
+ }
194
+ await ctx.cache.delete(key);
195
+ return null;
196
+ }
197
+
198
+ function writeSelection(
199
+ ctx: ExecutorContext,
200
+ key: string,
201
+ selection: CapabilitySelection,
202
+ ): Promise<void> {
203
+ return ctx.cache.put(key, JSON.stringify(selection));
204
+ }
205
+
206
+ function selectionKey(key: string): string {
207
+ return `capabilities:${key}`;
208
+ }
209
+
210
+ function positiveInteger(value: number): number {
211
+ if (!Number.isInteger(value) || value < 1) {
212
+ throw new Error("enabledIterations must be a positive integer");
213
+ }
214
+ return value;
215
+ }
@@ -1,18 +1,16 @@
1
- export { createTargetExecutor } from "./target-executor.ts";
1
+ export { createCapabilitiesExecutor } from "./capabilities.ts";
2
+ export type { CapabilitiesExecutorOptions } from "./capabilities.ts";
2
3
 
3
4
  export {
4
5
  appendInternalDraft,
5
6
  createTwoPassExecutor,
6
7
  INTERNAL_DRAFT_CALL_ID,
7
8
  INTERNAL_DRAFT_TOOL_NAME,
8
- invokeExecutor,
9
- streamExecutor,
10
- } from "./twoPassExecutor.ts";
9
+ } from "./two-pass.ts";
11
10
 
12
11
  export type {
13
12
  InternalDraft,
14
- ModelExecutor,
15
13
  TwoPassExecutorOptions,
16
14
  TwoPassSettings,
17
15
  TwoPassSettingsResolver,
18
- } from "./twoPassExecutor.ts";
16
+ } from "./two-pass.ts";
@@ -1,15 +1,16 @@
1
1
  import {
2
2
  callData,
3
3
  contentText,
4
+ createProgram,
4
5
  deltaText,
5
6
  finishReason,
6
7
  hasToolDelta,
7
8
  lastMessageRole,
8
- setModel,
9
9
  type Program,
10
10
  } from "@neutrome/lil-engine";
11
11
  import { streamReasoningDelta } from "../output.ts";
12
- import type { Executor, ExecutorContext, InvokeOptions } from "../types.ts";
12
+ import { completeStream, streamStage } from "../stream/stages.ts";
13
+ import type { Executor, ExecutorContext, ExecutorInput } from "../types.ts";
13
14
  import {
14
15
  appendInternalDraft,
15
16
  buildDraftRequest,
@@ -34,41 +35,15 @@ export {
34
35
  INTERNAL_DRAFT_TOOL_NAME,
35
36
  };
36
37
 
37
- export type ModelExecutor = string | Executor;
38
-
39
38
  export type TwoPassExecutorOptions = TwoPassRequestOptions & {
40
- draftModel: ModelExecutor;
41
- finalModel: ModelExecutor;
39
+ draft: ExecutorInput;
40
+ final: ExecutorInput;
42
41
  resolveDraftSettings?: TwoPassSettingsResolver;
43
42
  resolveFinalSettings?: TwoPassSettingsResolver;
44
43
  reasoningIntro?: string;
45
44
  reasoningSeparator?: string;
46
45
  };
47
46
 
48
- export function invokeExecutor(
49
- ctx: ExecutorContext,
50
- request: Program,
51
- executor: ModelExecutor,
52
- options?: InvokeOptions,
53
- ): Promise<Program> {
54
- return typeof executor === "string"
55
- ? ctx.invoke(setModel(request, executor), options)
56
- : executor.execute(request, ctx);
57
- }
58
-
59
- export async function* streamExecutor(
60
- ctx: ExecutorContext,
61
- request: Program,
62
- executor: ModelExecutor,
63
- options?: InvokeOptions,
64
- ): AsyncIterable<Program> {
65
- if (typeof executor === "string") {
66
- yield* ctx.invokeStream(setModel(request, executor), options);
67
- return;
68
- }
69
- yield* executor.stream(request, ctx);
70
- }
71
-
72
47
  export function createTwoPassExecutor(
73
48
  options: TwoPassExecutorOptions,
74
49
  ): Executor {
@@ -84,14 +59,10 @@ export function createTwoPassExecutor(
84
59
  }
85
60
  const draftRequest = buildDraftRequest(request, settings.draft);
86
61
  if (!settings.final || lastMessageRole(request) === "tool") {
87
- return invokeExecutor(ctx, draftRequest, options.draftModel);
62
+ return ctx.invoke(options.draft, draftRequest);
88
63
  }
89
64
 
90
- const draftResponse = await invokeExecutor(
91
- ctx,
92
- draftRequest,
93
- options.draftModel,
94
- );
65
+ const draftResponse = await ctx.invoke(options.draft, draftRequest);
95
66
  if (callData(draftResponse).length > 0) return draftResponse;
96
67
 
97
68
  return invokeFinal(
@@ -111,17 +82,15 @@ export function createTwoPassExecutor(
111
82
  }
112
83
  const draftRequest = buildDraftRequest(request, settings.draft);
113
84
  if (!settings.final || lastMessageRole(request) === "tool") {
114
- yield* streamExecutor(ctx, draftRequest, options.draftModel);
85
+ yield* ctx.invokeStream(options.draft, draftRequest);
115
86
  return;
116
87
  }
117
88
 
118
89
  let transcript = "";
119
90
  let emittedReasoning = false;
120
91
  let toolMode = false;
121
- for await (const chunk of streamExecutor(
122
- ctx,
123
- draftRequest,
124
- options.draftModel,
92
+ for await (const chunk of streamStage(
93
+ ctx.invokeStream(options.draft, draftRequest),
125
94
  )) {
126
95
  if (ctx.signal.aborted) return;
127
96
  if (toolMode) {
@@ -143,7 +112,11 @@ export function createTwoPassExecutor(
143
112
  yield streamReasoningDelta(text);
144
113
  }
145
114
 
146
- if (toolMode || ctx.signal.aborted) return;
115
+ if (ctx.signal.aborted) return;
116
+ if (toolMode) {
117
+ yield completeStream("tool_calls");
118
+ return;
119
+ }
147
120
  if (emittedReasoning && reasoningSeparator) {
148
121
  yield streamReasoningDelta(reasoningSeparator);
149
122
  }
@@ -178,10 +151,9 @@ function invokeFinal(
178
151
  settings: TwoPassSettings,
179
152
  draft = "",
180
153
  ): Promise<Program> {
181
- return invokeExecutor(
182
- ctx,
154
+ return ctx.invoke(
155
+ options.final,
183
156
  buildFinalRequest(options, request, settings, draft),
184
- options.finalModel,
185
157
  );
186
158
  }
187
159
 
@@ -192,9 +164,8 @@ async function* streamFinal(
192
164
  settings: TwoPassSettings,
193
165
  draft = "",
194
166
  ): AsyncIterable<Program> {
195
- yield* streamExecutor(
196
- ctx,
167
+ yield* ctx.invokeStream(
168
+ options.final,
197
169
  buildFinalRequest(options, request, settings, draft),
198
- options.finalModel,
199
170
  );
200
171
  }
@@ -1,6 +1,7 @@
1
1
  export type { ObservedExecution, StreamObservationHooks } from "../observe.ts";
2
2
 
3
3
  export { observeExecutionStream } from "../observe.ts";
4
+ export { completeStream, streamStage } from "./stages.ts";
4
5
  export {
5
6
  streamReasoningDelta,
6
7
  streamTextResponse,
@@ -0,0 +1,25 @@
1
+ import { createProgram, Opcode, type Program } from "@neutrome/lil-engine";
2
+
3
+ /** Streams one stage without allowing it to complete the enclosing stream. */
4
+ export async function* streamStage(
5
+ source: AsyncIterable<Program>,
6
+ ): AsyncGenerator<Program> {
7
+ for await (const chunk of source) {
8
+ const code = chunk.code.filter(
9
+ (instruction) =>
10
+ instruction.opcode !== Opcode.RESP_DONE &&
11
+ instruction.opcode !== Opcode.STREAM_END,
12
+ );
13
+ if (code.length === 0) continue;
14
+ yield code.length === chunk.code.length ? chunk : { ...chunk, code };
15
+ }
16
+ }
17
+
18
+ export function completeStream(reason = "stop"): Program {
19
+ return createProgram({
20
+ code: [
21
+ { opcode: Opcode.RESP_DONE, value: { kind: "string", value: reason } },
22
+ { opcode: Opcode.STREAM_END, value: { kind: "none" } },
23
+ ],
24
+ });
25
+ }
package/src/tools.ts CHANGED
@@ -10,7 +10,12 @@ import {
10
10
  type StreamToolDelta,
11
11
  } from "@neutrome/lil-engine";
12
12
  import { appendToolInteraction } from "./synthetic/index.ts";
13
- import { type Executor, type ExecutorContext, type Tool } from "./types.ts";
13
+ import {
14
+ type Executor,
15
+ type ExecutorContext,
16
+ type ExecutorInput,
17
+ type Tool,
18
+ } from "./types.ts";
14
19
  import {
15
20
  buildCallExecutor,
16
21
  buildToolAugmenter,
@@ -176,7 +181,7 @@ async function* streamToolLoop(
176
181
 
177
182
  export function connectTools(
178
183
  tools: Tool[],
179
- inner: Executor,
184
+ inner: ExecutorInput,
180
185
  options: WithToolsOptions = {},
181
186
  ): Executor {
182
187
  const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
@@ -192,7 +197,7 @@ export function connectTools(
192
197
  );
193
198
 
194
199
  while (canRunToolLoop(state, maxIterations)) {
195
- const response = await inner.execute(state.request, ctx);
200
+ const response = await ctx.invoke(inner, state.request);
196
201
  const calls = callData(response);
197
202
  const decision = decideToolLoop(calls, toolMap);
198
203
  if (decision.kind !== "continue") return response;
@@ -205,7 +210,7 @@ export function connectTools(
205
210
  );
206
211
  }
207
212
 
208
- return inner.execute(state.request, ctx);
213
+ return ctx.invoke(inner, state.request);
209
214
  },
210
215
 
211
216
  async *stream(request, ctx) {
@@ -216,7 +221,7 @@ export function connectTools(
216
221
  executeConnectedCalls,
217
222
  request,
218
223
  ctx,
219
- (req) => inner.stream(req, ctx),
224
+ (req) => ctx.invokeStream(inner, req),
220
225
  );
221
226
  },
222
227
  };