@neutrome/lilsdk 0.4.4 → 0.4.6
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 +2 -6
- package/etc/api/index.api.md +103 -0
- package/etc/api/loops.api.md +108 -0
- package/etc/api/managed.api.md +102 -0
- package/etc/api/primitives.api.md +39 -0
- package/etc/api/stream.api.md +55 -0
- package/etc/api/synthetic.api.md +46 -0
- package/etc/api/tools.api.md +103 -0
- package/etc/api/types.api.md +103 -0
- package/package.json +4 -3
- package/src/managed/capabilities.ts +27 -7
- package/src/managed/index.ts +0 -14
- package/src/tools-support.ts +52 -27
- package/src/tools.ts +1 -1
- package/test/lilsdk-ts.test.ts +29 -301
- package/test/tools.test.ts +55 -9
- package/src/managed/two-pass-request.ts +0 -164
- package/src/managed/two-pass.ts +0 -171
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neutrome/lilsdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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.4.
|
|
16
|
+
"@neutrome/lil-engine": "0.4.6"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@types/node": "^25.9.3",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "vitest run",
|
|
25
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
26
|
+
"npm:publish": "pnpm publish --no-git-checks"
|
|
26
27
|
}
|
|
27
28
|
}
|
|
@@ -20,6 +20,7 @@ const encoder = new TextEncoder();
|
|
|
20
20
|
export type CapabilitiesExecutorOptions = {
|
|
21
21
|
enabledIterations?: number;
|
|
22
22
|
cacheKey?: (ctx: ExecutorContext) => string;
|
|
23
|
+
skipPrefixes?: readonly string[];
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
type CapabilitySelection = { toolName: string; remaining: number };
|
|
@@ -30,16 +31,22 @@ export function createCapabilitiesExecutor(
|
|
|
30
31
|
): Executor {
|
|
31
32
|
const enabledIterations = positiveInteger(options.enabledIterations ?? 5);
|
|
32
33
|
const cacheKey = options.cacheKey ?? ((ctx) => ctx.requestId);
|
|
34
|
+
const skipPrefixes = options.skipPrefixes ?? [];
|
|
33
35
|
|
|
34
36
|
return {
|
|
35
37
|
async execute(request, ctx) {
|
|
36
38
|
const tools = viewProgram(request).tools;
|
|
37
39
|
if (tools.length === 0) return ctx.invoke(inner, request);
|
|
38
40
|
|
|
41
|
+
const managedTools = tools.filter(
|
|
42
|
+
(tool) => !skipPrefixes.some((prefix) => tool.name.startsWith(prefix)),
|
|
43
|
+
);
|
|
44
|
+
if (managedTools.length === 0) return ctx.invoke(inner, request);
|
|
45
|
+
|
|
39
46
|
const key = selectionKey(cacheKey(ctx));
|
|
40
|
-
await discardMissingSelection(ctx, key,
|
|
41
|
-
return connectCapabilities(inner,
|
|
42
|
-
|
|
47
|
+
await discardMissingSelection(ctx, key, managedTools);
|
|
48
|
+
return connectCapabilities(inner, managedTools, key, enabledIterations).execute(
|
|
49
|
+
withoutManagedTools(request, managedTools),
|
|
43
50
|
ctx,
|
|
44
51
|
);
|
|
45
52
|
},
|
|
@@ -51,10 +58,18 @@ export function createCapabilitiesExecutor(
|
|
|
51
58
|
return;
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
const managedTools = tools.filter(
|
|
62
|
+
(tool) => !skipPrefixes.some((prefix) => tool.name.startsWith(prefix)),
|
|
63
|
+
);
|
|
64
|
+
if (managedTools.length === 0) {
|
|
65
|
+
yield* ctx.invokeStream(inner, request);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
const key = selectionKey(cacheKey(ctx));
|
|
55
|
-
await discardMissingSelection(ctx, key,
|
|
56
|
-
yield* connectCapabilities(inner,
|
|
57
|
-
|
|
70
|
+
await discardMissingSelection(ctx, key, managedTools);
|
|
71
|
+
yield* connectCapabilities(inner, managedTools, key, enabledIterations).stream(
|
|
72
|
+
withoutManagedTools(request, managedTools),
|
|
58
73
|
ctx,
|
|
59
74
|
);
|
|
60
75
|
},
|
|
@@ -153,9 +168,14 @@ async function discardMissingSelection(
|
|
|
153
168
|
}
|
|
154
169
|
}
|
|
155
170
|
|
|
156
|
-
function
|
|
171
|
+
function withoutManagedTools(
|
|
172
|
+
request: Program,
|
|
173
|
+
managedTools: readonly ProgramTool[],
|
|
174
|
+
): Program {
|
|
175
|
+
const managedNames = new Set(managedTools.map((tool) => tool.name));
|
|
157
176
|
const indices: number[] = [];
|
|
158
177
|
for (const definition of toolDefinitions(request)) {
|
|
178
|
+
if (!definition.name || !managedNames.has(definition.name)) continue;
|
|
159
179
|
for (let index = definition.start; index <= definition.end; index += 1) {
|
|
160
180
|
indices.push(index);
|
|
161
181
|
}
|
package/src/managed/index.ts
CHANGED
|
@@ -5,17 +5,3 @@ export type {
|
|
|
5
5
|
AttachmentReaderRule,
|
|
6
6
|
AttachmentToTextOptions,
|
|
7
7
|
} from "./attachment-to-text.ts";
|
|
8
|
-
|
|
9
|
-
export {
|
|
10
|
-
appendInternalDraft,
|
|
11
|
-
createTwoPassExecutor,
|
|
12
|
-
INTERNAL_DRAFT_CALL_ID,
|
|
13
|
-
INTERNAL_DRAFT_TOOL_NAME,
|
|
14
|
-
} from "./two-pass.ts";
|
|
15
|
-
|
|
16
|
-
export type {
|
|
17
|
-
InternalDraft,
|
|
18
|
-
TwoPassExecutorOptions,
|
|
19
|
-
TwoPassSettings,
|
|
20
|
-
TwoPassSettingsResolver,
|
|
21
|
-
} from "./two-pass.ts";
|
package/src/tools-support.ts
CHANGED
|
@@ -7,7 +7,12 @@ import {
|
|
|
7
7
|
} from "./types.ts";
|
|
8
8
|
|
|
9
9
|
export type ToolCall = { id: string; name: string; args: string };
|
|
10
|
-
export type ToolExecution =
|
|
10
|
+
export type ToolExecution = {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
args: Record<string, unknown>;
|
|
14
|
+
result: string;
|
|
15
|
+
};
|
|
11
16
|
|
|
12
17
|
export class ToolArgumentsError extends Error {
|
|
13
18
|
constructor(
|
|
@@ -49,37 +54,52 @@ export function buildCallExecutor(toolMap: ReadonlyMap<string, Tool>) {
|
|
|
49
54
|
): Promise<ToolExecution[]> =>
|
|
50
55
|
Promise.all(
|
|
51
56
|
calls.map(async (call) => {
|
|
52
|
-
const tool = toolMap.get(call.name);
|
|
53
|
-
if (!tool) {
|
|
54
|
-
throw new Error(`No tool is registered for call "${call.name}"`);
|
|
55
|
-
}
|
|
56
57
|
const startedMs = Date.now();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
startedAt: new Date(startedMs).toISOString(),
|
|
72
|
-
finishedAt: new Date(finishedMs).toISOString(),
|
|
73
|
-
durationMs: finishedMs - startedMs,
|
|
74
|
-
lilText: toolTrace(call, result),
|
|
75
|
-
},
|
|
76
|
-
}),
|
|
77
|
-
);
|
|
78
|
-
return { ...call, result };
|
|
58
|
+
try {
|
|
59
|
+
const tool = toolMap.get(call.name);
|
|
60
|
+
if (!tool) {
|
|
61
|
+
throw new Error(`No tool is registered for call "${call.name}"`);
|
|
62
|
+
}
|
|
63
|
+
const args = parseToolArguments(call.name, call.args);
|
|
64
|
+
const result = await tool.execute(args, ctx);
|
|
65
|
+
observeToolExecution(ctx, call, startedMs, result);
|
|
66
|
+
return { id: call.id, name: call.name, args, result };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const result = toolFailureResult(call.name, error);
|
|
69
|
+
observeToolExecution(ctx, call, startedMs, result, error);
|
|
70
|
+
return { id: call.id, name: call.name, args: {}, result };
|
|
71
|
+
}
|
|
79
72
|
}),
|
|
80
73
|
);
|
|
81
74
|
}
|
|
82
75
|
|
|
76
|
+
function observeToolExecution(
|
|
77
|
+
ctx: ExecutorContext,
|
|
78
|
+
call: ToolCall,
|
|
79
|
+
startedMs: number,
|
|
80
|
+
result: string,
|
|
81
|
+
error?: unknown,
|
|
82
|
+
): void {
|
|
83
|
+
const finishedMs = Date.now();
|
|
84
|
+
ctx.observe(
|
|
85
|
+
createExecutionEvent({
|
|
86
|
+
kind: error ? "tool.failed" : "tool.executed",
|
|
87
|
+
requestId: ctx.requestId,
|
|
88
|
+
executionId: `tool_${call.id || crypto.randomUUID()}`,
|
|
89
|
+
parentExecutionId: ctx.executionId,
|
|
90
|
+
...(error instanceof Error ? { errorKind: error.name } : {}),
|
|
91
|
+
data: {
|
|
92
|
+
toolName: call.name,
|
|
93
|
+
toolCallId: call.id,
|
|
94
|
+
startedAt: new Date(startedMs).toISOString(),
|
|
95
|
+
finishedAt: new Date(finishedMs).toISOString(),
|
|
96
|
+
durationMs: finishedMs - startedMs,
|
|
97
|
+
lilText: toolTrace(call, result),
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
export function parseToolArguments(
|
|
84
104
|
toolName: string,
|
|
85
105
|
argumentsText: string,
|
|
@@ -106,3 +126,8 @@ function toolTrace(call: ToolCall, result: string): string {
|
|
|
106
126
|
"RESULT_END",
|
|
107
127
|
].join("\n");
|
|
108
128
|
}
|
|
129
|
+
|
|
130
|
+
function toolFailureResult(toolName: string, error: unknown): string {
|
|
131
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
132
|
+
return `Tool "${toolName}" failed: ${message}`;
|
|
133
|
+
}
|
package/src/tools.ts
CHANGED
package/test/lilsdk-ts.test.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
contentText,
|
|
3
3
|
createProgram,
|
|
4
|
-
deltaText,
|
|
5
4
|
emitChatCompletionsRequest,
|
|
6
5
|
emitChatCompletionsStreamChunk,
|
|
7
|
-
getModel,
|
|
8
6
|
parseChatCompletionsRequest,
|
|
9
7
|
parseChatCompletionsStreamChunk,
|
|
10
8
|
programAttachments,
|
|
@@ -33,18 +31,42 @@ import {
|
|
|
33
31
|
writeReasoning,
|
|
34
32
|
} from "../src/stream/index.ts";
|
|
35
33
|
import { createGoalExecutor, fallback, retry } from "../src/loops/index.ts";
|
|
36
|
-
import {
|
|
37
|
-
appendInternalDraft,
|
|
38
|
-
createTwoPassExecutor,
|
|
39
|
-
INTERNAL_DRAFT_TOOL_NAME,
|
|
40
|
-
} from "../src/managed/two-pass.ts";
|
|
41
34
|
import { createAttachmentToTextExecutor } from "../src/managed/attachment-to-text.ts";
|
|
35
|
+
import { createCapabilitiesExecutor } from "../src/managed/capabilities.ts";
|
|
42
36
|
import type { Executor, ExecutorContext, OutputSink } from "../src/types.ts";
|
|
43
37
|
|
|
44
38
|
const encoder = new TextEncoder();
|
|
45
39
|
const decoder = new TextDecoder();
|
|
46
40
|
|
|
47
41
|
describe("@neutrome/lilsdk", () => {
|
|
42
|
+
it("leaves capability tool prefixes directly available", async () => {
|
|
43
|
+
const request = parseChatCompletionsRequest(
|
|
44
|
+
encoder.encode(
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
tools: [
|
|
47
|
+
{ type: "function", function: { name: "keep_search" } },
|
|
48
|
+
{ type: "function", function: { name: "managed_weather" } },
|
|
49
|
+
],
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
let innerRequest: Program | undefined;
|
|
54
|
+
const executor = createCapabilitiesExecutor(
|
|
55
|
+
executorFromExecute(async (value) => {
|
|
56
|
+
innerRequest = value;
|
|
57
|
+
return value;
|
|
58
|
+
}),
|
|
59
|
+
{ skipPrefixes: ["keep_"] },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await executor.execute(request, buildExecutorContext());
|
|
63
|
+
|
|
64
|
+
expect(viewProgram(innerRequest!).tools.map((tool) => tool.name)).toEqual([
|
|
65
|
+
"keep_search",
|
|
66
|
+
"learn_capability",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
48
70
|
it("describes the latest attachment and removes it before the inner executor", async () => {
|
|
49
71
|
const request = parseChatCompletionsRequest(
|
|
50
72
|
encoder.encode(
|
|
@@ -188,27 +210,6 @@ describe("@neutrome/lilsdk", () => {
|
|
|
188
210
|
);
|
|
189
211
|
});
|
|
190
212
|
|
|
191
|
-
it("appends internal drafts with fixed SDK metadata and empty args", () => {
|
|
192
|
-
const request = parseChatCompletionsRequest(
|
|
193
|
-
encoder.encode(
|
|
194
|
-
JSON.stringify({
|
|
195
|
-
model: "virtual-model",
|
|
196
|
-
messages: [{ role: "user", content: "hello" }],
|
|
197
|
-
}),
|
|
198
|
-
),
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
const updated = appendInternalDraft(request, "draft answer");
|
|
202
|
-
const emitted = JSON.parse(
|
|
203
|
-
decoder.decode(emitChatCompletionsRequest(updated)),
|
|
204
|
-
);
|
|
205
|
-
|
|
206
|
-
expect(emitted.messages[1].tool_calls[0].function.name).toBe(
|
|
207
|
-
INTERNAL_DRAFT_TOOL_NAME,
|
|
208
|
-
);
|
|
209
|
-
expect(emitted.messages[1].tool_calls[0].function.arguments).toBe("{}");
|
|
210
|
-
expect(emitted.messages[2].content).toBe("draft answer");
|
|
211
|
-
});
|
|
212
213
|
|
|
213
214
|
it("appends generic synthetic tool interactions", () => {
|
|
214
215
|
const request = parseChatCompletionsRequest(
|
|
@@ -241,279 +242,6 @@ describe("@neutrome/lilsdk", () => {
|
|
|
241
242
|
});
|
|
242
243
|
});
|
|
243
244
|
|
|
244
|
-
it("builds two-pass executors from model and settings resolvers", async () => {
|
|
245
|
-
const request = parseChatCompletionsRequest(
|
|
246
|
-
encoder.encode(
|
|
247
|
-
JSON.stringify({
|
|
248
|
-
model: "virtual-model",
|
|
249
|
-
messages: [{ role: "user", content: "hello" }],
|
|
250
|
-
}),
|
|
251
|
-
),
|
|
252
|
-
);
|
|
253
|
-
const seen: Program[] = [];
|
|
254
|
-
const ctx = buildExecutorContext({
|
|
255
|
-
async invoke(_executor, program) {
|
|
256
|
-
seen.push(program);
|
|
257
|
-
if (seen.length === 1) {
|
|
258
|
-
return appendAssistantMessage(
|
|
259
|
-
{ code: [], buffers: [] },
|
|
260
|
-
"draft answer",
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
return appendAssistantMessage(
|
|
264
|
-
{ code: [], buffers: [] },
|
|
265
|
-
"final answer",
|
|
266
|
-
);
|
|
267
|
-
},
|
|
268
|
-
async *invokeStream() {
|
|
269
|
-
throw new Error("streaming path is not used in this test");
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
const executor = createTwoPassExecutor({
|
|
274
|
-
draft: "smart-model",
|
|
275
|
-
final: "base-model",
|
|
276
|
-
resolveFinalSettings: () => ({
|
|
277
|
-
reasoningLevel: "high",
|
|
278
|
-
systemPrompt: "system prompt",
|
|
279
|
-
}),
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
const result = await executor.execute(request, ctx);
|
|
283
|
-
|
|
284
|
-
expect(seen.map((program) => getModel(program))).toEqual([
|
|
285
|
-
"smart-model",
|
|
286
|
-
"base-model",
|
|
287
|
-
]);
|
|
288
|
-
expect(contentText(result)).toBe("final answer");
|
|
289
|
-
|
|
290
|
-
const finalRequest = JSON.parse(
|
|
291
|
-
decoder.decode(emitChatCompletionsRequest(seen[1]!)),
|
|
292
|
-
);
|
|
293
|
-
expect(finalRequest.messages[0]).toEqual({
|
|
294
|
-
role: "system",
|
|
295
|
-
content: "system prompt",
|
|
296
|
-
});
|
|
297
|
-
expect(finalRequest.reasoning_effort).toBe("high");
|
|
298
|
-
expect(finalRequest.messages[2].tool_calls[0].function).toEqual({
|
|
299
|
-
name: INTERNAL_DRAFT_TOOL_NAME,
|
|
300
|
-
arguments: "{}",
|
|
301
|
-
});
|
|
302
|
-
expect(finalRequest.messages[3]).toEqual({
|
|
303
|
-
role: "tool",
|
|
304
|
-
tool_call_id: "knowledge_0",
|
|
305
|
-
content: "draft answer",
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it("replaces draft system prompt and moves existing system prompt to first user message", async () => {
|
|
310
|
-
const request = parseChatCompletionsRequest(
|
|
311
|
-
encoder.encode(
|
|
312
|
-
JSON.stringify({
|
|
313
|
-
model: "virtual-model",
|
|
314
|
-
messages: [
|
|
315
|
-
{ role: "system", content: "original system" },
|
|
316
|
-
{ role: "user", content: "hello" },
|
|
317
|
-
],
|
|
318
|
-
}),
|
|
319
|
-
),
|
|
320
|
-
);
|
|
321
|
-
const seen: Program[] = [];
|
|
322
|
-
const ctx = buildExecutorContext({
|
|
323
|
-
async invoke(_executor, program) {
|
|
324
|
-
seen.push(program);
|
|
325
|
-
return appendAssistantMessage(
|
|
326
|
-
{ code: [], buffers: [] },
|
|
327
|
-
seen.length === 1 ? "draft" : "final",
|
|
328
|
-
);
|
|
329
|
-
},
|
|
330
|
-
async *invokeStream() {
|
|
331
|
-
throw new Error("streaming path is not used in this test");
|
|
332
|
-
},
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
const executor = createTwoPassExecutor({
|
|
336
|
-
draft: "smart-model",
|
|
337
|
-
final: "base-model",
|
|
338
|
-
resolveDraftSettings: () => ({ systemPrompt: "draft system" }),
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
await executor.execute(request, ctx);
|
|
342
|
-
|
|
343
|
-
const draftRequest = JSON.parse(
|
|
344
|
-
decoder.decode(emitChatCompletionsRequest(seen[0]!)),
|
|
345
|
-
);
|
|
346
|
-
expect(draftRequest.messages).toEqual([
|
|
347
|
-
{ role: "system", content: "draft system" },
|
|
348
|
-
{ role: "user", content: "original system" },
|
|
349
|
-
{ role: "user", content: "hello" },
|
|
350
|
-
]);
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it("composes model strings and custom executors", async () => {
|
|
354
|
-
const calls: Array<{ model: string; streaming: boolean }> = [];
|
|
355
|
-
const ctx = buildExecutorContext({
|
|
356
|
-
async invoke(_executor, program) {
|
|
357
|
-
calls.push({ model: getModel(program) ?? "", streaming: false });
|
|
358
|
-
return appendAssistantMessage(
|
|
359
|
-
{ code: [], buffers: [] },
|
|
360
|
-
"model answer",
|
|
361
|
-
);
|
|
362
|
-
},
|
|
363
|
-
async *invokeStream(_executor, program) {
|
|
364
|
-
calls.push({ model: getModel(program) ?? "", streaming: true });
|
|
365
|
-
yield parseChatCompletionsStreamChunk(
|
|
366
|
-
encoder.encode(
|
|
367
|
-
JSON.stringify({
|
|
368
|
-
id: "stream-response",
|
|
369
|
-
model: "base-model",
|
|
370
|
-
choices: [{ index: 0, delta: { content: "hello" } }],
|
|
371
|
-
}),
|
|
372
|
-
),
|
|
373
|
-
);
|
|
374
|
-
},
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
const request = parseChatCompletionsRequest(
|
|
378
|
-
encoder.encode(
|
|
379
|
-
JSON.stringify({
|
|
380
|
-
model: "virtual-model",
|
|
381
|
-
messages: [{ role: "user", content: "hello" }],
|
|
382
|
-
}),
|
|
383
|
-
),
|
|
384
|
-
);
|
|
385
|
-
const first = await ctx.invoke("smart-model", request);
|
|
386
|
-
const chunks: Program[] = [];
|
|
387
|
-
for await (const chunk of ctx.invokeStream("base-model", request)) {
|
|
388
|
-
chunks.push(chunk);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
expect(calls).toEqual([
|
|
392
|
-
{ model: "smart-model", streaming: false },
|
|
393
|
-
{ model: "base-model", streaming: true },
|
|
394
|
-
]);
|
|
395
|
-
expect(contentText(first)).toBe("model answer");
|
|
396
|
-
expect(
|
|
397
|
-
decoder.decode(emitChatCompletionsStreamChunk(chunks[0]!)),
|
|
398
|
-
).toContain("hello");
|
|
399
|
-
expect(getModel(request)).toBe("virtual-model");
|
|
400
|
-
|
|
401
|
-
const customExecutor = executorFromExecute(async () =>
|
|
402
|
-
appendAssistantMessage({ code: [], buffers: [] }, "custom answer"),
|
|
403
|
-
);
|
|
404
|
-
const custom = await ctx.invoke(customExecutor, request);
|
|
405
|
-
expect(contentText(custom)).toBe("custom answer");
|
|
406
|
-
|
|
407
|
-
const twoPass = createTwoPassExecutor({
|
|
408
|
-
draft: customExecutor,
|
|
409
|
-
final: customExecutor,
|
|
410
|
-
});
|
|
411
|
-
expect(contentText(await twoPass.execute(request, ctx))).toBe(
|
|
412
|
-
"custom answer",
|
|
413
|
-
);
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
it("can skip the draft pass and limits the final context length", async () => {
|
|
417
|
-
const request = parseChatCompletionsRequest(
|
|
418
|
-
encoder.encode(
|
|
419
|
-
JSON.stringify({
|
|
420
|
-
model: "virtual-model",
|
|
421
|
-
messages: [{ role: "user", content: "request" }],
|
|
422
|
-
}),
|
|
423
|
-
),
|
|
424
|
-
);
|
|
425
|
-
const seen: Program[] = [];
|
|
426
|
-
const ctx = buildExecutorContext({
|
|
427
|
-
async invoke(_executor, program) {
|
|
428
|
-
seen.push(program);
|
|
429
|
-
return appendAssistantMessage(
|
|
430
|
-
{ code: [], buffers: [] },
|
|
431
|
-
seen.length === 1 ? "a very long draft" : "final",
|
|
432
|
-
);
|
|
433
|
-
},
|
|
434
|
-
async *invokeStream() {
|
|
435
|
-
throw new Error("streaming path is not used in this test");
|
|
436
|
-
},
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
const executor = createTwoPassExecutor({
|
|
440
|
-
draft: "smart-model",
|
|
441
|
-
final: "base-model",
|
|
442
|
-
maxTotalContextLength: 10,
|
|
443
|
-
resolveDraftSettings: () => ({ systemPrompt: "draft" }),
|
|
444
|
-
});
|
|
445
|
-
await executor.execute(request, ctx);
|
|
446
|
-
|
|
447
|
-
const finalRequest = JSON.parse(
|
|
448
|
-
decoder.decode(emitChatCompletionsRequest(seen[1]!)),
|
|
449
|
-
);
|
|
450
|
-
expect(finalRequest.messages.at(-1)?.content).toBe("a v");
|
|
451
|
-
|
|
452
|
-
let receivedRequest: Program | undefined;
|
|
453
|
-
let receivedContext: ExecutorContext | undefined;
|
|
454
|
-
const directFinal = createTwoPassExecutor({
|
|
455
|
-
draft: "unused",
|
|
456
|
-
final: "base-model",
|
|
457
|
-
resolveDraftSettings: (incomingRequest, incomingContext) => {
|
|
458
|
-
receivedRequest = incomingRequest;
|
|
459
|
-
receivedContext = incomingContext;
|
|
460
|
-
return null;
|
|
461
|
-
},
|
|
462
|
-
});
|
|
463
|
-
await directFinal.execute(request, ctx);
|
|
464
|
-
expect(getModel(seen.at(-1)!)).toBe("base-model");
|
|
465
|
-
expect(receivedRequest).toBe(request);
|
|
466
|
-
expect(receivedContext).toBe(ctx);
|
|
467
|
-
|
|
468
|
-
const draftOnly = createTwoPassExecutor({
|
|
469
|
-
draft: "smart-model",
|
|
470
|
-
final: "unused",
|
|
471
|
-
resolveFinalSettings: () => null,
|
|
472
|
-
});
|
|
473
|
-
await draftOnly.execute(request, ctx);
|
|
474
|
-
expect(getModel(seen.at(-1)!)).toBe("smart-model");
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
it("keeps two-pass drafts inside the final stream", async () => {
|
|
478
|
-
const executor = createTwoPassExecutor({
|
|
479
|
-
draft: "draft",
|
|
480
|
-
final: "final",
|
|
481
|
-
});
|
|
482
|
-
const ctx = buildExecutorContext({
|
|
483
|
-
async invoke() {
|
|
484
|
-
throw new Error("non-streaming path is not used in this test");
|
|
485
|
-
},
|
|
486
|
-
async *invokeStream(executor) {
|
|
487
|
-
const text = executor === "draft" ? "draft" : "final";
|
|
488
|
-
yield createProgram({
|
|
489
|
-
code: [
|
|
490
|
-
{
|
|
491
|
-
opcode: Opcode.STREAM_DELTA,
|
|
492
|
-
value: { kind: "string", value: text },
|
|
493
|
-
},
|
|
494
|
-
{
|
|
495
|
-
opcode: Opcode.RESP_DONE,
|
|
496
|
-
value: { kind: "string", value: "stop" },
|
|
497
|
-
},
|
|
498
|
-
{ opcode: Opcode.STREAM_END, value: { kind: "none" } },
|
|
499
|
-
],
|
|
500
|
-
});
|
|
501
|
-
},
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
const chunks: Program[] = [];
|
|
505
|
-
for await (const chunk of executor.stream({ code: [], buffers: [] }, ctx)) {
|
|
506
|
-
chunks.push(chunk);
|
|
507
|
-
}
|
|
508
|
-
const opcodes = chunks.flatMap((chunk) =>
|
|
509
|
-
chunk.code.map((instruction) => instruction.opcode),
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
expect(
|
|
513
|
-
opcodes.filter((opcode) => opcode === Opcode.STREAM_END),
|
|
514
|
-
).toHaveLength(1);
|
|
515
|
-
expect(chunks.map(deltaText).filter(Boolean)).toContain("final");
|
|
516
|
-
});
|
|
517
245
|
|
|
518
246
|
it("writes reasoning helpers to a sink", async () => {
|
|
519
247
|
const emitted: string[] = [];
|
package/test/tools.test.ts
CHANGED
|
@@ -5,8 +5,9 @@ import {
|
|
|
5
5
|
Opcode,
|
|
6
6
|
type Instruction,
|
|
7
7
|
type Program,
|
|
8
|
+
viewProgram,
|
|
8
9
|
} from "@neutrome/lil-engine";
|
|
9
|
-
import { connectTools
|
|
10
|
+
import { connectTools } from "../src/tools.ts";
|
|
10
11
|
import type { ExecutorContext, Tool } from "../src/types.ts";
|
|
11
12
|
|
|
12
13
|
function buildToolCallResponse(
|
|
@@ -173,22 +174,67 @@ describe("connectTools", () => {
|
|
|
173
174
|
expect(hasAnswer).toBe(true);
|
|
174
175
|
});
|
|
175
176
|
|
|
176
|
-
it("
|
|
177
|
+
it("returns malformed connected-tool arguments to the model", async () => {
|
|
177
178
|
const executor = connectTools([calculatorTool], "test-model");
|
|
179
|
+
let retryRequest: Program | undefined;
|
|
180
|
+
let invocations = 0;
|
|
178
181
|
const ctx = buildCtx({
|
|
179
|
-
async invoke() {
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
);
|
|
197
|
+
const result = await executor.execute(createProgram(), ctx);
|
|
198
|
+
|
|
199
|
+
expect(invocations).toBe(2);
|
|
200
|
+
expect(
|
|
201
|
+
viewProgram(retryRequest!).messages.at(-1)?.toolResult?.text,
|
|
202
|
+
).toBe('Tool "calculator" failed: Tool "calculator" received invalid JSON object arguments');
|
|
203
|
+
expect(callData(result)).toHaveLength(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns thrown connected-tool errors to the model", async () => {
|
|
207
|
+
const failingTool: Tool = {
|
|
208
|
+
...calculatorTool,
|
|
209
|
+
async execute() {
|
|
210
|
+
throw new Error("Calculator service is unavailable");
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
const executor = connectTools([failingTool], "test-model");
|
|
214
|
+
let retryRequest: Program | undefined;
|
|
215
|
+
let invocations = 0;
|
|
216
|
+
const ctx = buildCtx({
|
|
217
|
+
async invoke(_executor, request) {
|
|
218
|
+
invocations += 1;
|
|
219
|
+
if (invocations === 1) {
|
|
220
|
+
return buildToolCallResponse([
|
|
221
|
+
{ id: "call_1", name: "calculator", args: '{"expr":"2+2"}' },
|
|
222
|
+
]);
|
|
223
|
+
}
|
|
224
|
+
retryRequest = request;
|
|
225
|
+
return buildTextResponse("I cannot calculate that right now.");
|
|
226
|
+
},
|
|
227
|
+
async *invokeStream() {
|
|
228
|
+
return;
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await executor.execute(createProgram(), ctx);
|
|
233
|
+
|
|
234
|
+
expect(invocations).toBe(2);
|
|
235
|
+
expect(
|
|
236
|
+
viewProgram(retryRequest!).messages.at(-1)?.toolResult?.text,
|
|
237
|
+
).toBe('Tool "calculator" failed: Calculator service is unavailable');
|
|
192
238
|
});
|
|
193
239
|
|
|
194
240
|
it("handles multiple tool calls in single response (parallel execution)", async () => {
|