@neutrome/lilsdk 0.3.5
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 +31 -0
- package/package.json +27 -0
- package/src/index.ts +14 -0
- package/src/loops/index.ts +266 -0
- package/src/managed/index.ts +18 -0
- package/src/managed/target-executor.ts +12 -0
- package/src/managed/two-pass-request.ts +164 -0
- package/src/managed/twoPassExecutor.ts +200 -0
- package/src/observe.ts +95 -0
- package/src/output.ts +31 -0
- package/src/primitives/index.ts +154 -0
- package/src/stream/index.ts +8 -0
- package/src/synthetic/index.ts +134 -0
- package/src/tools-support.ts +108 -0
- package/src/tools.ts +279 -0
- package/src/types.ts +101 -0
- package/test/lilsdk-ts.test.ts +660 -0
- package/test/tools.test.ts +452 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import {
|
|
2
|
+
contentText,
|
|
3
|
+
emitChatCompletionsRequest,
|
|
4
|
+
emitChatCompletionsStreamChunk,
|
|
5
|
+
getModel,
|
|
6
|
+
parseChatCompletionsRequest,
|
|
7
|
+
parseChatCompletionsStreamChunk,
|
|
8
|
+
Opcode,
|
|
9
|
+
type Program,
|
|
10
|
+
} from "@neutrome/lil-engine";
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
all,
|
|
14
|
+
any,
|
|
15
|
+
find,
|
|
16
|
+
has,
|
|
17
|
+
indexOf,
|
|
18
|
+
map,
|
|
19
|
+
reduce,
|
|
20
|
+
} from "../src/primitives/index.ts";
|
|
21
|
+
import {
|
|
22
|
+
appendAssistantMessage,
|
|
23
|
+
appendToolInteraction,
|
|
24
|
+
} from "../src/synthetic/index.ts";
|
|
25
|
+
import {
|
|
26
|
+
observeExecutionStream,
|
|
27
|
+
streamTextResponse,
|
|
28
|
+
writeReasoning,
|
|
29
|
+
} from "../src/stream/index.ts";
|
|
30
|
+
import { createGoalExecutor, fallback, retry } from "../src/loops/index.ts";
|
|
31
|
+
import {
|
|
32
|
+
appendInternalDraft,
|
|
33
|
+
createTwoPassExecutor,
|
|
34
|
+
invokeExecutor,
|
|
35
|
+
INTERNAL_DRAFT_TOOL_NAME,
|
|
36
|
+
streamExecutor,
|
|
37
|
+
} from "../src/managed/twoPassExecutor.ts";
|
|
38
|
+
import type { Executor, ExecutorContext, OutputSink } from "../src/types.ts";
|
|
39
|
+
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
const decoder = new TextDecoder();
|
|
42
|
+
|
|
43
|
+
describe("@neutrome/lilsdk", () => {
|
|
44
|
+
it("provides clone-safe structural primitives", () => {
|
|
45
|
+
const needle = {
|
|
46
|
+
opcode: Opcode.MSG_START,
|
|
47
|
+
value: { kind: "none" as const },
|
|
48
|
+
};
|
|
49
|
+
const program: Program = {
|
|
50
|
+
code: [
|
|
51
|
+
needle,
|
|
52
|
+
{ opcode: Opcode.TXT_CHUNK, value: { kind: "string", value: "hello" } },
|
|
53
|
+
],
|
|
54
|
+
buffers: [new Uint8Array([1, 2, 3])],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
expect(indexOf(program, needle)).toBe(0);
|
|
58
|
+
expect(has(program, needle)).toBe(true);
|
|
59
|
+
expect(find(program, needle)).toEqual(needle);
|
|
60
|
+
expect(reduce(program, (count) => count + 1, 0)).toBe(2);
|
|
61
|
+
expect(
|
|
62
|
+
all(program, (instruction) => instruction.opcode !== Opcode.MSG_END),
|
|
63
|
+
).toBe(true);
|
|
64
|
+
expect(
|
|
65
|
+
any(program, (instruction) => instruction.opcode === Opcode.TXT_CHUNK),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
|
|
68
|
+
const mapped = map(program, (instruction) => instruction);
|
|
69
|
+
mapped.buffers[0]![0] = 9;
|
|
70
|
+
expect(program.buffers[0]![0]).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("observes tool-call streams", async () => {
|
|
74
|
+
const observed = await observeExecutionStream(
|
|
75
|
+
(async function* () {
|
|
76
|
+
yield parseChatCompletionsStreamChunk(
|
|
77
|
+
encoder.encode(
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
id: "tool-stream",
|
|
80
|
+
object: "chat.completion.chunk",
|
|
81
|
+
model: "smart",
|
|
82
|
+
choices: [{ index: 0, delta: { role: "assistant" } }],
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
yield parseChatCompletionsStreamChunk(
|
|
87
|
+
encoder.encode(
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
id: "tool-stream",
|
|
90
|
+
object: "chat.completion.chunk",
|
|
91
|
+
model: "smart",
|
|
92
|
+
choices: [
|
|
93
|
+
{
|
|
94
|
+
index: 0,
|
|
95
|
+
delta: {
|
|
96
|
+
tool_calls: [
|
|
97
|
+
{
|
|
98
|
+
index: 0,
|
|
99
|
+
id: "call_lookup",
|
|
100
|
+
type: "function",
|
|
101
|
+
function: { name: "lookup", arguments: '{"q":"kyiv"}' },
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
yield parseChatCompletionsStreamChunk(
|
|
111
|
+
encoder.encode(
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
id: "tool-stream",
|
|
114
|
+
object: "chat.completion.chunk",
|
|
115
|
+
model: "smart",
|
|
116
|
+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
})(),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(observed.mode).toBe("tool");
|
|
124
|
+
if (observed.mode !== "tool") {
|
|
125
|
+
throw new Error("expected tool mode");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const parsed = observed.chunks.map((chunk) =>
|
|
129
|
+
JSON.parse(decoder.decode(emitChatCompletionsStreamChunk(chunk))),
|
|
130
|
+
);
|
|
131
|
+
expect(parsed.some((chunk) => chunk.choices?.[0]?.delta?.tool_calls)).toBe(
|
|
132
|
+
true,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("appends internal drafts with fixed SDK metadata and empty args", () => {
|
|
137
|
+
const request = parseChatCompletionsRequest(
|
|
138
|
+
encoder.encode(
|
|
139
|
+
JSON.stringify({
|
|
140
|
+
model: "virtual-model",
|
|
141
|
+
messages: [{ role: "user", content: "hello" }],
|
|
142
|
+
}),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const updated = appendInternalDraft(request, "draft answer");
|
|
147
|
+
const emitted = JSON.parse(
|
|
148
|
+
decoder.decode(emitChatCompletionsRequest(updated)),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(emitted.messages[1].tool_calls[0].function.name).toBe(
|
|
152
|
+
INTERNAL_DRAFT_TOOL_NAME,
|
|
153
|
+
);
|
|
154
|
+
expect(emitted.messages[1].tool_calls[0].function.arguments).toBe("{}");
|
|
155
|
+
expect(emitted.messages[2].content).toBe("draft answer");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("appends generic synthetic tool interactions", () => {
|
|
159
|
+
const request = parseChatCompletionsRequest(
|
|
160
|
+
encoder.encode(
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
model: "virtual-model",
|
|
163
|
+
messages: [{ role: "user", content: "hello" }],
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const updated = appendToolInteraction(request, {
|
|
169
|
+
callId: "call_lookup",
|
|
170
|
+
name: "lookup",
|
|
171
|
+
args: { id: "42" },
|
|
172
|
+
result: { status: "active" },
|
|
173
|
+
});
|
|
174
|
+
const emitted = JSON.parse(
|
|
175
|
+
decoder.decode(emitChatCompletionsRequest(updated)),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
expect(emitted.messages[1].tool_calls[0].function).toEqual({
|
|
179
|
+
name: "lookup",
|
|
180
|
+
arguments: '{"id":"42"}',
|
|
181
|
+
});
|
|
182
|
+
expect(emitted.messages[2]).toEqual({
|
|
183
|
+
role: "tool",
|
|
184
|
+
tool_call_id: "call_lookup",
|
|
185
|
+
content: '{"status":"active"}',
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("builds two-pass executors from model and settings resolvers", async () => {
|
|
190
|
+
const request = parseChatCompletionsRequest(
|
|
191
|
+
encoder.encode(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
model: "virtual-model",
|
|
194
|
+
messages: [{ role: "user", content: "hello" }],
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
const seen: Program[] = [];
|
|
199
|
+
const ctx = buildExecutorContext({
|
|
200
|
+
async invoke(program) {
|
|
201
|
+
seen.push(program);
|
|
202
|
+
if (seen.length === 1) {
|
|
203
|
+
return appendAssistantMessage(
|
|
204
|
+
{ code: [], buffers: [] },
|
|
205
|
+
"draft answer",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return appendAssistantMessage(
|
|
209
|
+
{ code: [], buffers: [] },
|
|
210
|
+
"final answer",
|
|
211
|
+
);
|
|
212
|
+
},
|
|
213
|
+
async *invokeStream() {
|
|
214
|
+
throw new Error("streaming path is not used in this test");
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const executor = createTwoPassExecutor({
|
|
219
|
+
draftModel: "smart-model",
|
|
220
|
+
finalModel: "base-model",
|
|
221
|
+
resolveFinalSettings: () => ({
|
|
222
|
+
reasoningLevel: "high",
|
|
223
|
+
systemPrompt: "system prompt",
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const result = await executor.execute(request, ctx);
|
|
228
|
+
|
|
229
|
+
expect(seen.map((program) => getModel(program))).toEqual([
|
|
230
|
+
"smart-model",
|
|
231
|
+
"base-model",
|
|
232
|
+
]);
|
|
233
|
+
expect(contentText(result)).toBe("final answer");
|
|
234
|
+
|
|
235
|
+
const finalRequest = JSON.parse(
|
|
236
|
+
decoder.decode(emitChatCompletionsRequest(seen[1]!)),
|
|
237
|
+
);
|
|
238
|
+
expect(finalRequest.messages[0]).toEqual({
|
|
239
|
+
role: "system",
|
|
240
|
+
content: "system prompt",
|
|
241
|
+
});
|
|
242
|
+
expect(finalRequest.reasoning_effort).toBe("high");
|
|
243
|
+
expect(finalRequest.messages[2].tool_calls[0].function).toEqual({
|
|
244
|
+
name: INTERNAL_DRAFT_TOOL_NAME,
|
|
245
|
+
arguments: "{}",
|
|
246
|
+
});
|
|
247
|
+
expect(finalRequest.messages[3]).toEqual({
|
|
248
|
+
role: "tool",
|
|
249
|
+
tool_call_id: "knowledge_0",
|
|
250
|
+
content: "draft answer",
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("replaces draft system prompt and moves existing system prompt to first user message", async () => {
|
|
255
|
+
const request = parseChatCompletionsRequest(
|
|
256
|
+
encoder.encode(
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
model: "virtual-model",
|
|
259
|
+
messages: [
|
|
260
|
+
{ role: "system", content: "original system" },
|
|
261
|
+
{ role: "user", content: "hello" },
|
|
262
|
+
],
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
const seen: Program[] = [];
|
|
267
|
+
const ctx = buildExecutorContext({
|
|
268
|
+
async invoke(program) {
|
|
269
|
+
seen.push(program);
|
|
270
|
+
return appendAssistantMessage(
|
|
271
|
+
{ code: [], buffers: [] },
|
|
272
|
+
seen.length === 1 ? "draft" : "final",
|
|
273
|
+
);
|
|
274
|
+
},
|
|
275
|
+
async *invokeStream() {
|
|
276
|
+
throw new Error("streaming path is not used in this test");
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const executor = createTwoPassExecutor({
|
|
281
|
+
draftModel: "smart-model",
|
|
282
|
+
finalModel: "base-model",
|
|
283
|
+
resolveDraftSettings: () => ({ systemPrompt: "draft system" }),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await executor.execute(request, ctx);
|
|
287
|
+
|
|
288
|
+
const draftRequest = JSON.parse(
|
|
289
|
+
decoder.decode(emitChatCompletionsRequest(seen[0]!)),
|
|
290
|
+
);
|
|
291
|
+
expect(draftRequest.messages).toEqual([
|
|
292
|
+
{ role: "system", content: "draft system" },
|
|
293
|
+
{ role: "user", content: "original system" },
|
|
294
|
+
{ role: "user", content: "hello" },
|
|
295
|
+
]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("invokes and streams string or custom executors", async () => {
|
|
299
|
+
const calls: Array<{ model: string; streaming: boolean }> = [];
|
|
300
|
+
const ctx = buildExecutorContext({
|
|
301
|
+
async invoke(program) {
|
|
302
|
+
calls.push({ model: getModel(program) ?? "", streaming: false });
|
|
303
|
+
return appendAssistantMessage(
|
|
304
|
+
{ code: [], buffers: [] },
|
|
305
|
+
"model answer",
|
|
306
|
+
);
|
|
307
|
+
},
|
|
308
|
+
async *invokeStream(program) {
|
|
309
|
+
calls.push({ model: getModel(program) ?? "", streaming: true });
|
|
310
|
+
yield parseChatCompletionsStreamChunk(
|
|
311
|
+
encoder.encode(
|
|
312
|
+
JSON.stringify({
|
|
313
|
+
id: "stream-response",
|
|
314
|
+
model: "base-model",
|
|
315
|
+
choices: [{ index: 0, delta: { content: "hello" } }],
|
|
316
|
+
}),
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const request = parseChatCompletionsRequest(
|
|
323
|
+
encoder.encode(
|
|
324
|
+
JSON.stringify({
|
|
325
|
+
model: "virtual-model",
|
|
326
|
+
messages: [{ role: "user", content: "hello" }],
|
|
327
|
+
}),
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
const first = await invokeExecutor(ctx, request, "smart-model");
|
|
331
|
+
const chunks: Program[] = [];
|
|
332
|
+
for await (const chunk of streamExecutor(ctx, request, "base-model")) {
|
|
333
|
+
chunks.push(chunk);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
expect(calls).toEqual([
|
|
337
|
+
{ model: "smart-model", streaming: false },
|
|
338
|
+
{ model: "base-model", streaming: true },
|
|
339
|
+
]);
|
|
340
|
+
expect(contentText(first)).toBe("model answer");
|
|
341
|
+
expect(
|
|
342
|
+
decoder.decode(emitChatCompletionsStreamChunk(chunks[0]!)),
|
|
343
|
+
).toContain("hello");
|
|
344
|
+
expect(getModel(request)).toBe("virtual-model");
|
|
345
|
+
|
|
346
|
+
const customExecutor = executorFromExecute(async () =>
|
|
347
|
+
appendAssistantMessage({ code: [], buffers: [] }, "custom answer"),
|
|
348
|
+
);
|
|
349
|
+
const custom = await invokeExecutor(ctx, request, customExecutor);
|
|
350
|
+
expect(contentText(custom)).toBe("custom answer");
|
|
351
|
+
|
|
352
|
+
const twoPass = createTwoPassExecutor({
|
|
353
|
+
draftModel: customExecutor,
|
|
354
|
+
finalModel: customExecutor,
|
|
355
|
+
});
|
|
356
|
+
expect(contentText(await twoPass.execute(request, ctx))).toBe(
|
|
357
|
+
"custom answer",
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("can skip the draft pass and limits the final context length", async () => {
|
|
362
|
+
const request = parseChatCompletionsRequest(
|
|
363
|
+
encoder.encode(
|
|
364
|
+
JSON.stringify({
|
|
365
|
+
model: "virtual-model",
|
|
366
|
+
messages: [{ role: "user", content: "request" }],
|
|
367
|
+
}),
|
|
368
|
+
),
|
|
369
|
+
);
|
|
370
|
+
const seen: Program[] = [];
|
|
371
|
+
const ctx = buildExecutorContext({
|
|
372
|
+
async invoke(program) {
|
|
373
|
+
seen.push(program);
|
|
374
|
+
return appendAssistantMessage(
|
|
375
|
+
{ code: [], buffers: [] },
|
|
376
|
+
seen.length === 1 ? "a very long draft" : "final",
|
|
377
|
+
);
|
|
378
|
+
},
|
|
379
|
+
async *invokeStream() {
|
|
380
|
+
throw new Error("streaming path is not used in this test");
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const executor = createTwoPassExecutor({
|
|
385
|
+
draftModel: "smart-model",
|
|
386
|
+
finalModel: "base-model",
|
|
387
|
+
maxTotalContextLength: 10,
|
|
388
|
+
resolveDraftSettings: () => ({ systemPrompt: "draft" }),
|
|
389
|
+
});
|
|
390
|
+
await executor.execute(request, ctx);
|
|
391
|
+
|
|
392
|
+
const finalRequest = JSON.parse(
|
|
393
|
+
decoder.decode(emitChatCompletionsRequest(seen[1]!)),
|
|
394
|
+
);
|
|
395
|
+
expect(finalRequest.messages.at(-1)?.content).toBe("a v");
|
|
396
|
+
|
|
397
|
+
let receivedRequest: Program | undefined;
|
|
398
|
+
let receivedContext: ExecutorContext | undefined;
|
|
399
|
+
const directFinal = createTwoPassExecutor({
|
|
400
|
+
draftModel: "unused",
|
|
401
|
+
finalModel: "base-model",
|
|
402
|
+
resolveDraftSettings: (incomingRequest, incomingContext) => {
|
|
403
|
+
receivedRequest = incomingRequest;
|
|
404
|
+
receivedContext = incomingContext;
|
|
405
|
+
return null;
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
await directFinal.execute(request, ctx);
|
|
409
|
+
expect(getModel(seen.at(-1)!)).toBe("base-model");
|
|
410
|
+
expect(receivedRequest).toBe(request);
|
|
411
|
+
expect(receivedContext).toBe(ctx);
|
|
412
|
+
|
|
413
|
+
const draftOnly = createTwoPassExecutor({
|
|
414
|
+
draftModel: "smart-model",
|
|
415
|
+
finalModel: "unused",
|
|
416
|
+
resolveFinalSettings: () => null,
|
|
417
|
+
});
|
|
418
|
+
await draftOnly.execute(request, ctx);
|
|
419
|
+
expect(getModel(seen.at(-1)!)).toBe("smart-model");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("writes reasoning helpers to a sink", async () => {
|
|
423
|
+
const emitted: string[] = [];
|
|
424
|
+
const sink: OutputSink = {
|
|
425
|
+
write(chunk) {
|
|
426
|
+
emitted.push(decoder.decode(emitChatCompletionsStreamChunk(chunk)));
|
|
427
|
+
},
|
|
428
|
+
close() {
|
|
429
|
+
emitted.push("[DONE]");
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
await writeReasoning(sink, "thinking");
|
|
434
|
+
|
|
435
|
+
expect(JSON.parse(emitted[0]!).choices[0].delta.reasoning_content).toBe(
|
|
436
|
+
"thinking",
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("retries executor failures", async () => {
|
|
441
|
+
let calls = 0;
|
|
442
|
+
const executor = retry(
|
|
443
|
+
executorFromExecute(async () => {
|
|
444
|
+
calls += 1;
|
|
445
|
+
if (calls === 1) {
|
|
446
|
+
throw new Error("transient");
|
|
447
|
+
}
|
|
448
|
+
return appendAssistantMessage({ code: [], buffers: [] }, "ok");
|
|
449
|
+
}),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const result = await executor.execute(
|
|
453
|
+
{ code: [], buffers: [] },
|
|
454
|
+
buildExecutorContext(),
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
expect(calls).toBe(2);
|
|
458
|
+
expect(contentText(result)).toBe("ok");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("falls back to the next executor after failure", async () => {
|
|
462
|
+
const executor = fallback([
|
|
463
|
+
executorFromExecute(async () => {
|
|
464
|
+
throw new Error("primary failed");
|
|
465
|
+
}),
|
|
466
|
+
executorFromExecute(async () =>
|
|
467
|
+
appendAssistantMessage({ code: [], buffers: [] }, "fallback"),
|
|
468
|
+
),
|
|
469
|
+
]);
|
|
470
|
+
|
|
471
|
+
const result = await executor.execute(
|
|
472
|
+
{ code: [], buffers: [] },
|
|
473
|
+
buildExecutorContext(),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
expect(contentText(result)).toBe("fallback");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("does not retry a stream after exposing a partial chunk", async () => {
|
|
480
|
+
let retryCalls = 0;
|
|
481
|
+
const executor = retry(
|
|
482
|
+
streamingExecutor(async function* () {
|
|
483
|
+
retryCalls += 1;
|
|
484
|
+
yield appendAssistantMessage({ code: [], buffers: [] }, "partial");
|
|
485
|
+
throw new Error("stream interrupted");
|
|
486
|
+
}),
|
|
487
|
+
{ attempts: 2 },
|
|
488
|
+
);
|
|
489
|
+
const context = buildExecutorContext();
|
|
490
|
+
const stream = executor
|
|
491
|
+
.stream({ code: [], buffers: [] }, context)
|
|
492
|
+
[Symbol.asyncIterator]();
|
|
493
|
+
|
|
494
|
+
await expect(stream.next()).resolves.toMatchObject({ done: false });
|
|
495
|
+
await expect(stream.next()).rejects.toThrow("stream interrupted");
|
|
496
|
+
expect(retryCalls).toBe(1);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("does not fall back after exposing a partial stream chunk", async () => {
|
|
500
|
+
let fallbackCalls = 0;
|
|
501
|
+
const executor = fallback([
|
|
502
|
+
streamingExecutor(async function* () {
|
|
503
|
+
yield appendAssistantMessage({ code: [], buffers: [] }, "partial");
|
|
504
|
+
throw new Error("stream interrupted");
|
|
505
|
+
}),
|
|
506
|
+
streamingExecutor(async function* () {
|
|
507
|
+
fallbackCalls += 1;
|
|
508
|
+
yield appendAssistantMessage({ code: [], buffers: [] }, "fallback");
|
|
509
|
+
}),
|
|
510
|
+
]);
|
|
511
|
+
const stream = executor
|
|
512
|
+
.stream({ code: [], buffers: [] }, buildExecutorContext())
|
|
513
|
+
[Symbol.asyncIterator]();
|
|
514
|
+
|
|
515
|
+
await expect(stream.next()).resolves.toMatchObject({ done: false });
|
|
516
|
+
await expect(stream.next()).rejects.toThrow("stream interrupted");
|
|
517
|
+
expect(fallbackCalls).toBe(0);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("runs goal loops until the review is satisfied", async () => {
|
|
521
|
+
let attempts = 0;
|
|
522
|
+
const executor = createGoalExecutor({
|
|
523
|
+
draft: executorFromExecute(async () => {
|
|
524
|
+
attempts += 1;
|
|
525
|
+
return appendAssistantMessage(
|
|
526
|
+
{ code: [], buffers: [] },
|
|
527
|
+
`answer ${attempts}`,
|
|
528
|
+
);
|
|
529
|
+
}),
|
|
530
|
+
review: executorFromExecute(async () =>
|
|
531
|
+
appendAssistantMessage(
|
|
532
|
+
{ code: [], buffers: [] },
|
|
533
|
+
attempts > 1 ? "pass" : "retry",
|
|
534
|
+
),
|
|
535
|
+
),
|
|
536
|
+
satisfied(review) {
|
|
537
|
+
return contentText(review) === "pass";
|
|
538
|
+
},
|
|
539
|
+
refine(request) {
|
|
540
|
+
return request;
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const result = await executor.execute(
|
|
545
|
+
{ code: [], buffers: [] },
|
|
546
|
+
buildExecutorContext(),
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
expect(attempts).toBe(2);
|
|
550
|
+
expect(contentText(result)).toBe("answer 2");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("streams draft and review passes through each goal iteration", async () => {
|
|
554
|
+
let attempts = 0;
|
|
555
|
+
const executor = createGoalExecutor({
|
|
556
|
+
draft: streamingExecutor(async function* () {
|
|
557
|
+
attempts += 1;
|
|
558
|
+
yield appendAssistantMessage(
|
|
559
|
+
{ code: [], buffers: [] },
|
|
560
|
+
`answer ${attempts}`,
|
|
561
|
+
);
|
|
562
|
+
}),
|
|
563
|
+
review: streamingExecutor(async function* () {
|
|
564
|
+
yield appendAssistantMessage(
|
|
565
|
+
{ code: [], buffers: [] },
|
|
566
|
+
attempts > 1 ? "pass" : "retry",
|
|
567
|
+
);
|
|
568
|
+
}),
|
|
569
|
+
satisfied(review) {
|
|
570
|
+
return contentText(review) === "pass";
|
|
571
|
+
},
|
|
572
|
+
refine(request) {
|
|
573
|
+
return request;
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const chunks: Program[] = [];
|
|
578
|
+
for await (const chunk of executor.stream(
|
|
579
|
+
{ code: [], buffers: [] },
|
|
580
|
+
buildExecutorContext(),
|
|
581
|
+
)) {
|
|
582
|
+
chunks.push(chunk);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
expect(chunks.map(contentText)).toEqual([
|
|
586
|
+
"answer 1",
|
|
587
|
+
"retry",
|
|
588
|
+
"answer 2",
|
|
589
|
+
"pass",
|
|
590
|
+
]);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("uses streamed text as the next goal stage's response", async () => {
|
|
594
|
+
let reviewedAnswer = "";
|
|
595
|
+
const executor = createGoalExecutor({
|
|
596
|
+
draft: streamingExecutor(async function* () {
|
|
597
|
+
yield* streamTextResponse("answer");
|
|
598
|
+
}),
|
|
599
|
+
review: streamingExecutor(async function* (request) {
|
|
600
|
+
reviewedAnswer = contentText(request);
|
|
601
|
+
yield* streamTextResponse("pass");
|
|
602
|
+
}),
|
|
603
|
+
satisfied(review) {
|
|
604
|
+
return contentText(review) === "pass";
|
|
605
|
+
},
|
|
606
|
+
refine(request) {
|
|
607
|
+
return request;
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
for await (const _chunk of executor.stream(
|
|
612
|
+
{ code: [], buffers: [] },
|
|
613
|
+
buildExecutorContext(),
|
|
614
|
+
)) {
|
|
615
|
+
// Consume the entire goal stream.
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
expect(reviewedAnswer).toBe("answer");
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
function buildExecutorContext(
|
|
623
|
+
impl: Pick<ExecutorContext, "invoke" | "invokeStream"> = {
|
|
624
|
+
async invoke(program) {
|
|
625
|
+
return program;
|
|
626
|
+
},
|
|
627
|
+
async *invokeStream(program) {
|
|
628
|
+
yield program;
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
): ExecutorContext {
|
|
632
|
+
return {
|
|
633
|
+
requestId: "req_test",
|
|
634
|
+
executionId: "exec_test",
|
|
635
|
+
invoke: impl.invoke,
|
|
636
|
+
invokeStream: impl.invokeStream,
|
|
637
|
+
observe: () => {},
|
|
638
|
+
signal: new AbortController().signal,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function executorFromExecute(execute: Executor["execute"]): Executor {
|
|
643
|
+
return {
|
|
644
|
+
execute,
|
|
645
|
+
async *stream(request, ctx) {
|
|
646
|
+
yield await execute(request, ctx);
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function streamingExecutor(stream: Executor["stream"]): Executor {
|
|
652
|
+
return {
|
|
653
|
+
async execute(request, ctx) {
|
|
654
|
+
let result = request;
|
|
655
|
+
for await (const chunk of stream(request, ctx)) result = chunk;
|
|
656
|
+
return result;
|
|
657
|
+
},
|
|
658
|
+
stream,
|
|
659
|
+
};
|
|
660
|
+
}
|