@neutrome/lilsdk 0.4.5 → 0.5.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.
@@ -1,12 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
- callData,
4
3
  createProgram,
4
+ extractToolCalls,
5
5
  Opcode,
6
+ ProgramView,
6
7
  type Instruction,
7
8
  type Program,
8
9
  } from "@neutrome/lil-engine";
9
- import { connectTools, ToolArgumentsError } from "../src/tools.ts";
10
+ import { createToolsExecutor } from "../src/tools.ts";
10
11
  import type { ExecutorContext, Tool } from "../src/types.ts";
11
12
 
12
13
  function buildToolCallResponse(
@@ -63,7 +64,7 @@ const calculatorTool: Tool = {
63
64
  },
64
65
  };
65
66
 
66
- describe("connectTools", () => {
67
+ describe("createToolsExecutor", () => {
67
68
  it("passes the executor cache to connected tools", async () => {
68
69
  let callCount = 0;
69
70
  const cache = {
@@ -82,7 +83,7 @@ describe("connectTools", () => {
82
83
  return (await ctx.cache.get("tool-key")) ?? "missing";
83
84
  },
84
85
  };
85
- const executor = connectTools([tool], "test-model");
86
+ const executor = createToolsExecutor("test-model", [tool]);
86
87
  const ctx = buildCtx({
87
88
  async invoke() {
88
89
  callCount += 1;
@@ -104,7 +105,7 @@ describe("connectTools", () => {
104
105
  });
105
106
 
106
107
  it("passes through when LLM returns no tool calls", async () => {
107
- const executor = connectTools([calculatorTool], "test-model");
108
+ const executor = createToolsExecutor("test-model", [calculatorTool]);
108
109
 
109
110
  const request = createProgram({
110
111
  code: [
@@ -135,7 +136,7 @@ describe("connectTools", () => {
135
136
  });
136
137
 
137
138
  it("executes connected tool and loops until text response", async () => {
138
- const executor = connectTools([calculatorTool], "test-model");
139
+ const executor = createToolsExecutor("test-model", [calculatorTool]);
139
140
 
140
141
  let callCount = 0;
141
142
  const request = createProgram({
@@ -173,22 +174,69 @@ describe("connectTools", () => {
173
174
  expect(hasAnswer).toBe(true);
174
175
  });
175
176
 
176
- it("rejects malformed connected-tool arguments", async () => {
177
- const executor = connectTools([calculatorTool], "test-model");
177
+ it("returns malformed connected-tool arguments to the model", async () => {
178
+ const executor = createToolsExecutor("test-model", [calculatorTool]);
179
+ let retryRequest: Program | undefined;
180
+ let invocations = 0;
178
181
  const ctx = buildCtx({
179
- async invoke() {
180
- return buildToolCallResponse([
181
- { id: "call_1", name: "calculator", args: "{not json}" },
182
- ]);
182
+ async invoke(_executor, request) {
183
+ invocations += 1;
184
+ if (invocations === 1) {
185
+ return buildToolCallResponse([
186
+ { id: "call_1", name: "calculator", args: "{not json}" },
187
+ ]);
188
+ }
189
+ retryRequest = request;
190
+ return buildTextResponse("I need valid calculator arguments.");
183
191
  },
184
192
  async *invokeStream() {
185
193
  return;
186
194
  },
187
195
  });
188
196
 
189
- await expect(executor.execute(createProgram(), ctx)).rejects.toBeInstanceOf(
190
- ToolArgumentsError,
197
+ const result = await executor.execute(createProgram(), ctx);
198
+
199
+ expect(invocations).toBe(2);
200
+ expect(
201
+ new ProgramView(retryRequest!).messages.at(-1)?.toolResult?.text,
202
+ ).toBe(
203
+ 'Tool "calculator" failed: Tool "calculator" received invalid JSON object arguments',
191
204
  );
205
+ expect(extractToolCalls(result)).toHaveLength(0);
206
+ });
207
+
208
+ it("returns thrown connected-tool errors to the model", async () => {
209
+ const failingTool: Tool = {
210
+ ...calculatorTool,
211
+ async execute() {
212
+ throw new Error("Calculator service is unavailable");
213
+ },
214
+ };
215
+ const executor = createToolsExecutor("test-model", [failingTool]);
216
+ let retryRequest: Program | undefined;
217
+ let invocations = 0;
218
+ const ctx = buildCtx({
219
+ async invoke(_executor, request) {
220
+ invocations += 1;
221
+ if (invocations === 1) {
222
+ return buildToolCallResponse([
223
+ { id: "call_1", name: "calculator", args: '{"expr":"2+2"}' },
224
+ ]);
225
+ }
226
+ retryRequest = request;
227
+ return buildTextResponse("I cannot calculate that right now.");
228
+ },
229
+ async *invokeStream() {
230
+ return;
231
+ },
232
+ });
233
+
234
+ await executor.execute(createProgram(), ctx);
235
+
236
+ expect(invocations).toBe(2);
237
+ expect(
238
+ new ProgramView(retryRequest!).messages.at(-1)?.toolResult?.text,
239
+ ).toBe('Tool "calculator" failed: Calculator service is unavailable');
192
240
  });
193
241
 
194
242
  it("handles multiple tool calls in single response (parallel execution)", async () => {
@@ -203,7 +251,7 @@ describe("connectTools", () => {
203
251
  return `value_of_${key}`;
204
252
  },
205
253
  };
206
- const executor = connectTools([multiTool], "test-model");
254
+ const executor = createToolsExecutor("test-model", [multiTool]);
207
255
 
208
256
  let callCount = 0;
209
257
  const request = createProgram();
@@ -230,7 +278,7 @@ describe("connectTools", () => {
230
278
  });
231
279
 
232
280
  it("returns immediately when outer (non-connected) tool calls are present", async () => {
233
- const executor = connectTools([calculatorTool], "test-model");
281
+ const executor = createToolsExecutor("test-model", [calculatorTool]);
234
282
 
235
283
  const request = createProgram();
236
284
  const ctx = buildCtx({
@@ -252,17 +300,17 @@ describe("connectTools", () => {
252
300
  return calculatorTool.execute(args, ctx);
253
301
  },
254
302
  };
255
- const guardedExecutor = connectTools([tool], "test-model");
303
+ const guardedExecutor = createToolsExecutor("test-model", [tool]);
256
304
  const result = await guardedExecutor.execute(request, ctx);
257
305
 
258
- const calls = callData(result);
306
+ const calls = extractToolCalls(result);
259
307
  expect(calls).toHaveLength(2);
260
308
  expect(calls.some((c) => c.name === "external_tool")).toBe(true);
261
309
  expect(executions).toBe(0);
262
310
  });
263
311
 
264
312
  it("respects maxIterations", async () => {
265
- const executor = connectTools([calculatorTool], "test-model", {
313
+ const executor = createToolsExecutor("test-model", [calculatorTool], {
266
314
  maxIterations: 2,
267
315
  });
268
316
 
@@ -290,7 +338,7 @@ describe("connectTools", () => {
290
338
  });
291
339
 
292
340
  it("streams final response after tool loop completes", async () => {
293
- const executor = connectTools([calculatorTool], "test-model");
341
+ const executor = createToolsExecutor("test-model", [calculatorTool]);
294
342
 
295
343
  let callCount = 0;
296
344
  const request = createProgram();
@@ -349,7 +397,7 @@ describe("connectTools", () => {
349
397
  return "4";
350
398
  },
351
399
  };
352
- const executor = connectTools([tool], "test-model");
400
+ const executor = createToolsExecutor("test-model", [tool]);
353
401
  let invocation = 0;
354
402
  const ctx = buildCtx({
355
403
  async invoke() {
@@ -1,164 +0,0 @@
1
- import {
2
- appendInstructions,
3
- contentText,
4
- insertBefore,
5
- messageText,
6
- messages,
7
- removeRange,
8
- type Program,
9
- } from "@neutrome/lil-engine";
10
- import { Opcode } from "@neutrome/lil-engine";
11
- import {
12
- appendToolInteraction,
13
- makeMessage,
14
- prependSystemPrompt,
15
- } from "../synthetic/index.ts";
16
- import type { ExecutorContext } from "../types.ts";
17
-
18
- export const INTERNAL_DRAFT_TOOL_NAME = "knowledge";
19
- export const INTERNAL_DRAFT_CALL_ID = "knowledge_0";
20
-
21
- export type InternalDraft =
22
- | string
23
- | readonly string[]
24
- | readonly { text: string }[];
25
-
26
- export type TwoPassSettings = {
27
- reasoningLevel?: string;
28
- systemPrompt?: string;
29
- };
30
-
31
- export type TwoPassSettingsResolver = (
32
- request: Program,
33
- ctx: ExecutorContext,
34
- ) => TwoPassSettings | null | Promise<TwoPassSettings | null>;
35
-
36
- export type TwoPassRequestOptions = {
37
- maxTotalContextLength?: number;
38
- buildFinalRequest?: (request: Program, draft: string) => Program;
39
- };
40
-
41
- export function appendInternalDraft(
42
- request: Program,
43
- draft: InternalDraft,
44
- options: { callId?: string } = {},
45
- ): Program {
46
- const callId = options.callId ?? INTERNAL_DRAFT_CALL_ID;
47
- const drafts = normalizeDrafts(draft);
48
- if (drafts.length === 0) return request;
49
- return drafts.reduce(
50
- (program, text, index) =>
51
- appendToolInteraction(program, {
52
- callId: drafts.length === 1 ? callId : `${callId}_${index}`,
53
- name: INTERNAL_DRAFT_TOOL_NAME,
54
- args: {},
55
- result: text,
56
- }),
57
- request,
58
- );
59
- }
60
-
61
- export function buildDraftRequest(
62
- request: Program,
63
- settings: TwoPassSettings,
64
- ): Program {
65
- const withPrompt = settings.systemPrompt
66
- ? replaceSystemPrompt(request, settings.systemPrompt)
67
- : request;
68
- return applySettings(withPrompt, settings);
69
- }
70
-
71
- export function buildFinalRequest(
72
- options: TwoPassRequestOptions,
73
- request: Program,
74
- settings: TwoPassSettings,
75
- draft: string,
76
- ): Program {
77
- const limitedDraft = limitDraft(options, request, settings, draft);
78
- const withDraft = options.buildFinalRequest
79
- ? options.buildFinalRequest(request, limitedDraft)
80
- : appendInternalDraft(request, limitedDraft);
81
- const withPrompt = settings.systemPrompt
82
- ? prependSystemPrompt(withDraft, settings.systemPrompt)
83
- : withDraft;
84
- return applySettings(withPrompt, settings);
85
- }
86
-
87
- export function validateMaxContextLength(value: number | undefined): void {
88
- if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
89
- throw new Error("maxTotalContextLength must be a non-negative integer");
90
- }
91
- }
92
-
93
- function replaceSystemPrompt(request: Program, systemPrompt: string): Program {
94
- const systemSpans = messages(request).filter(
95
- (span) => span.role === "system",
96
- );
97
- const previousSystemPrompt = systemSpans
98
- .map((span) => messageText(request, span).trim())
99
- .filter(Boolean)
100
- .join("\n\n");
101
- let draft = request;
102
- for (const span of systemSpans.slice().reverse()) {
103
- draft = removeRange(draft, span.start, span.end);
104
- }
105
- draft = prependSystemPrompt(draft, systemPrompt);
106
- if (!previousSystemPrompt) return draft;
107
- const firstNonSystem = messages(draft).find((span) => span.role !== "system");
108
- return insertBefore(
109
- draft,
110
- firstNonSystem?.start ?? draft.code.length,
111
- makeMessage("user", previousSystemPrompt).code,
112
- );
113
- }
114
-
115
- function applySettings(request: Program, settings: TwoPassSettings): Program {
116
- if (!settings.reasoningLevel) return request;
117
- return appendInstructions(
118
- {
119
- ...request,
120
- code: request.code.filter(
121
- (instruction) => instruction.opcode !== Opcode.SET_REASON_EFFORT,
122
- ),
123
- },
124
- [
125
- {
126
- opcode: Opcode.SET_REASON_EFFORT,
127
- value: { kind: "string", value: settings.reasoningLevel },
128
- },
129
- ],
130
- );
131
- }
132
-
133
- function limitDraft(
134
- options: TwoPassRequestOptions,
135
- request: Program,
136
- settings: TwoPassSettings,
137
- draft: string,
138
- ): string {
139
- if (options.maxTotalContextLength === undefined) return draft;
140
- const base = buildFinalRequestWithoutDraft(options, request, settings);
141
- const available = options.maxTotalContextLength - contentText(base).length;
142
- return available > 0 ? draft.slice(0, available) : "";
143
- }
144
-
145
- function buildFinalRequestWithoutDraft(
146
- options: TwoPassRequestOptions,
147
- request: Program,
148
- settings: TwoPassSettings,
149
- ): Program {
150
- const withDraft = options.buildFinalRequest
151
- ? options.buildFinalRequest(request, "")
152
- : request;
153
- const withPrompt = settings.systemPrompt
154
- ? prependSystemPrompt(withDraft, settings.systemPrompt)
155
- : withDraft;
156
- return applySettings(withPrompt, settings);
157
- }
158
-
159
- function normalizeDrafts(draft: InternalDraft): string[] {
160
- const drafts = Array.isArray(draft)
161
- ? draft.map((item) => (typeof item === "string" ? item : item.text))
162
- : [draft];
163
- return drafts.map((text) => text.trim()).filter((text) => text.length > 0);
164
- }
@@ -1,171 +0,0 @@
1
- import {
2
- callData,
3
- contentText,
4
- createProgram,
5
- deltaText,
6
- finishReason,
7
- hasToolDelta,
8
- lastMessageRole,
9
- type Program,
10
- } from "@neutrome/lil-engine";
11
- import { streamReasoningDelta } from "../output.ts";
12
- import { completeStream, streamStage } from "../stream/stages.ts";
13
- import type { Executor, ExecutorContext, ExecutorInput } from "../types.ts";
14
- import {
15
- appendInternalDraft,
16
- buildDraftRequest,
17
- buildFinalRequest,
18
- INTERNAL_DRAFT_CALL_ID,
19
- INTERNAL_DRAFT_TOOL_NAME,
20
- validateMaxContextLength,
21
- type InternalDraft,
22
- type TwoPassRequestOptions,
23
- type TwoPassSettings,
24
- type TwoPassSettingsResolver,
25
- } from "./two-pass-request.ts";
26
-
27
- export type {
28
- InternalDraft,
29
- TwoPassSettings,
30
- TwoPassSettingsResolver,
31
- } from "./two-pass-request.ts";
32
- export {
33
- appendInternalDraft,
34
- INTERNAL_DRAFT_CALL_ID,
35
- INTERNAL_DRAFT_TOOL_NAME,
36
- };
37
-
38
- export type TwoPassExecutorOptions = TwoPassRequestOptions & {
39
- draft: ExecutorInput;
40
- final: ExecutorInput;
41
- resolveDraftSettings?: TwoPassSettingsResolver;
42
- resolveFinalSettings?: TwoPassSettingsResolver;
43
- reasoningIntro?: string;
44
- reasoningSeparator?: string;
45
- };
46
-
47
- export function createTwoPassExecutor(
48
- options: TwoPassExecutorOptions,
49
- ): Executor {
50
- const reasoningIntro = options.reasoningIntro ?? "Let me think...\n\n";
51
- const reasoningSeparator = options.reasoningSeparator ?? "\n\n";
52
- validateMaxContextLength(options.maxTotalContextLength);
53
-
54
- return {
55
- async execute(request, ctx) {
56
- const settings = await resolveSettings(options, request, ctx);
57
- if (!settings.draft) {
58
- return invokeFinal(options, request, ctx, settings.final!);
59
- }
60
- const draftRequest = buildDraftRequest(request, settings.draft);
61
- if (!settings.final || lastMessageRole(request) === "tool") {
62
- return ctx.invoke(options.draft, draftRequest);
63
- }
64
-
65
- const draftResponse = await ctx.invoke(options.draft, draftRequest);
66
- if (callData(draftResponse).length > 0) return draftResponse;
67
-
68
- return invokeFinal(
69
- options,
70
- request,
71
- ctx,
72
- settings.final,
73
- contentText(draftResponse),
74
- );
75
- },
76
-
77
- async *stream(request, ctx) {
78
- const settings = await resolveSettings(options, request, ctx);
79
- if (!settings.draft) {
80
- yield* streamFinal(options, request, ctx, settings.final!);
81
- return;
82
- }
83
- const draftRequest = buildDraftRequest(request, settings.draft);
84
- if (!settings.final || lastMessageRole(request) === "tool") {
85
- yield* ctx.invokeStream(options.draft, draftRequest);
86
- return;
87
- }
88
-
89
- let transcript = "";
90
- let emittedReasoning = false;
91
- let toolMode = false;
92
- for await (const chunk of streamStage(
93
- ctx.invokeStream(options.draft, draftRequest),
94
- )) {
95
- if (ctx.signal.aborted) return;
96
- if (toolMode) {
97
- yield chunk;
98
- continue;
99
- }
100
- if (hasToolDelta(chunk) || finishReason(chunk) === "tool_calls") {
101
- toolMode = true;
102
- yield chunk;
103
- continue;
104
- }
105
- const text = deltaText(chunk);
106
- if (!text) continue;
107
- transcript += text;
108
- if (!emittedReasoning) {
109
- emittedReasoning = true;
110
- yield streamReasoningDelta(reasoningIntro);
111
- }
112
- yield streamReasoningDelta(text);
113
- }
114
-
115
- if (ctx.signal.aborted) return;
116
- if (toolMode) {
117
- yield completeStream("tool_calls");
118
- return;
119
- }
120
- if (emittedReasoning && reasoningSeparator) {
121
- yield streamReasoningDelta(reasoningSeparator);
122
- }
123
- yield* streamFinal(options, request, ctx, settings.final, transcript);
124
- },
125
- };
126
- }
127
-
128
- async function resolveSettings(
129
- options: TwoPassExecutorOptions,
130
- request: Program,
131
- ctx: ExecutorContext,
132
- ): Promise<{ draft: TwoPassSettings | null; final: TwoPassSettings | null }> {
133
- const [draft, final] = await Promise.all([
134
- options.resolveDraftSettings
135
- ? options.resolveDraftSettings(request, ctx)
136
- : {},
137
- options.resolveFinalSettings
138
- ? options.resolveFinalSettings(request, ctx)
139
- : {},
140
- ]);
141
- if (!draft && !final) {
142
- throw new Error("Two-pass executor requires at least one enabled pass");
143
- }
144
- return { draft, final };
145
- }
146
-
147
- function invokeFinal(
148
- options: TwoPassExecutorOptions,
149
- request: Program,
150
- ctx: ExecutorContext,
151
- settings: TwoPassSettings,
152
- draft = "",
153
- ): Promise<Program> {
154
- return ctx.invoke(
155
- options.final,
156
- buildFinalRequest(options, request, settings, draft),
157
- );
158
- }
159
-
160
- async function* streamFinal(
161
- options: TwoPassExecutorOptions,
162
- request: Program,
163
- ctx: ExecutorContext,
164
- settings: TwoPassSettings,
165
- draft = "",
166
- ): AsyncIterable<Program> {
167
- yield* ctx.invokeStream(
168
- options.final,
169
- buildFinalRequest(options, request, settings, draft),
170
- );
171
- }
@@ -1,134 +0,0 @@
1
- import {
2
- addTool,
3
- appendInstructions,
4
- createProgram,
5
- insertBefore,
6
- messages,
7
- Opcode,
8
- setModel,
9
- type Instruction,
10
- type Opcode as OpcodeValue,
11
- type Program,
12
- } from "@neutrome/lil-engine";
13
-
14
- const encoder = new TextEncoder();
15
-
16
- export type TextMessageRole = "system" | "user" | "assistant";
17
-
18
- export type ToolInteraction = {
19
- callId: string;
20
- name: string;
21
- args?: unknown;
22
- result: unknown;
23
- };
24
-
25
- export function createModelProgram(model: string): Program {
26
- return setModel(createProgram(), model);
27
- }
28
-
29
- export function makeMessage(role: TextMessageRole, text: string): Program {
30
- return createProgram({ code: buildMessage(role, text) });
31
- }
32
-
33
- export function prependSystemPrompt(program: Program, text: string): Program {
34
- const systemMessage = buildMessage("system", text);
35
- const spans = messages(program);
36
- if (spans.length === 0) {
37
- return appendInstructions(program, systemMessage);
38
- }
39
- const firstSpan = spans[0];
40
- if (!firstSpan) return appendInstructions(program, systemMessage);
41
- return insertBefore(program, firstSpan.start, systemMessage);
42
- }
43
-
44
- export function appendUserMessage(program: Program, text: string): Program {
45
- return appendInstructions(program, buildMessage("user", text));
46
- }
47
-
48
- export function appendAssistantMessage(
49
- program: Program,
50
- text: string,
51
- ): Program {
52
- return appendInstructions(program, buildMessage("assistant", text));
53
- }
54
-
55
- export function makeToolCall(
56
- name: string,
57
- args: unknown = {},
58
- callId: string = crypto.randomUUID(),
59
- ): Program {
60
- return createProgram({
61
- code: [
62
- { opcode: Opcode.MSG_START, value: { kind: "none" } },
63
- { opcode: Opcode.ROLE_AST, value: { kind: "none" } },
64
- { opcode: Opcode.CALL_START, value: { kind: "string", value: callId } },
65
- { opcode: Opcode.CALL_NAME, value: { kind: "string", value: name } },
66
- {
67
- opcode: Opcode.CALL_ARGS,
68
- value: { kind: "json", value: encodeJson(args) },
69
- },
70
- { opcode: Opcode.CALL_END, value: { kind: "none" } },
71
- { opcode: Opcode.MSG_END, value: { kind: "none" } },
72
- ],
73
- });
74
- }
75
-
76
- export function makeToolResponse(callId: string, result: unknown): Program {
77
- return createProgram({ code: buildToolResponse(callId, result) });
78
- }
79
-
80
- export function appendToolInteraction(
81
- program: Program,
82
- interaction: ToolInteraction,
83
- ): Program {
84
- return appendInstructions(program, [
85
- ...makeToolCall(
86
- interaction.name,
87
- interaction.args ?? {},
88
- interaction.callId,
89
- ).code,
90
- ...buildToolResponse(interaction.callId, interaction.result),
91
- ]);
92
- }
93
-
94
- function buildMessage(role: TextMessageRole, text: string): Instruction[] {
95
- return [
96
- { opcode: Opcode.MSG_START, value: { kind: "none" } },
97
- { opcode: roleOpcode(role), value: { kind: "none" } },
98
- { opcode: Opcode.TXT_CHUNK, value: { kind: "string", value: text } },
99
- { opcode: Opcode.MSG_END, value: { kind: "none" } },
100
- ];
101
- }
102
-
103
- function buildToolResponse(callId: string, result: unknown): Instruction[] {
104
- return [
105
- { opcode: Opcode.MSG_START, value: { kind: "none" } },
106
- { opcode: Opcode.ROLE_TOOL, value: { kind: "none" } },
107
- { opcode: Opcode.RESULT_START, value: { kind: "string", value: callId } },
108
- {
109
- opcode: Opcode.RESULT_DATA,
110
- value: { kind: "string", value: stringifyResult(result) },
111
- },
112
- { opcode: Opcode.RESULT_END, value: { kind: "none" } },
113
- { opcode: Opcode.MSG_END, value: { kind: "none" } },
114
- ];
115
- }
116
-
117
- function roleOpcode(role: TextMessageRole): OpcodeValue {
118
- switch (role) {
119
- case "system":
120
- return Opcode.ROLE_SYS;
121
- case "user":
122
- return Opcode.ROLE_USR;
123
- case "assistant":
124
- return Opcode.ROLE_AST;
125
- }
126
- }
127
-
128
- function encodeJson(value: unknown): Uint8Array {
129
- return encoder.encode(JSON.stringify(value));
130
- }
131
-
132
- function stringifyResult(result: unknown): string {
133
- return typeof result === "string" ? result : JSON.stringify(result);
134
- }