@cline/agents 0.0.38 → 0.0.39
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 +3 -2
- package/dist/index.js +33 -33
- package/package.json +4 -6
- package/src/agent-runtime.provider-form.test.ts +0 -198
- package/src/agent-runtime.test.ts +0 -1301
- package/src/agent-runtime.ts +0 -1440
- package/src/index.ts +0 -56
|
@@ -1,1301 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AgentMessage,
|
|
3
|
-
AgentModel,
|
|
4
|
-
AgentModelEvent,
|
|
5
|
-
AgentModelRequest,
|
|
6
|
-
AgentRuntimePlugin,
|
|
7
|
-
AgentTool,
|
|
8
|
-
} from "@cline/shared";
|
|
9
|
-
import { describe, expect, it, vi } from "vitest";
|
|
10
|
-
import { AgentRuntime } from "./index";
|
|
11
|
-
|
|
12
|
-
class ScriptedModel implements AgentModel {
|
|
13
|
-
public readonly requests: AgentModelRequest[] = [];
|
|
14
|
-
|
|
15
|
-
constructor(
|
|
16
|
-
private readonly steps: Array<
|
|
17
|
-
(
|
|
18
|
-
request: AgentModelRequest,
|
|
19
|
-
) => Iterable<AgentModelEvent> | AsyncIterable<AgentModelEvent>
|
|
20
|
-
>,
|
|
21
|
-
) {}
|
|
22
|
-
|
|
23
|
-
async stream(
|
|
24
|
-
request: AgentModelRequest,
|
|
25
|
-
): Promise<AsyncIterable<AgentModelEvent>> {
|
|
26
|
-
this.requests.push(request);
|
|
27
|
-
const step = this.steps.shift();
|
|
28
|
-
if (!step) {
|
|
29
|
-
throw new Error("No scripted model step available");
|
|
30
|
-
}
|
|
31
|
-
return toAsyncIterable(step(request));
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function* toAsyncIterable(
|
|
36
|
-
events: Iterable<AgentModelEvent> | AsyncIterable<AgentModelEvent>,
|
|
37
|
-
): AsyncIterable<AgentModelEvent> {
|
|
38
|
-
for await (const event of events) {
|
|
39
|
-
yield event;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const createEchoTool = (): AgentTool<{ text: string }, { echoed: string }> => ({
|
|
44
|
-
name: "echo",
|
|
45
|
-
description: "Echo input text",
|
|
46
|
-
inputSchema: { type: "object" },
|
|
47
|
-
async execute(input) {
|
|
48
|
-
return { echoed: input.text };
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("AgentRuntime", () => {
|
|
53
|
-
it("completes a simple turn without tools", async () => {
|
|
54
|
-
const model = new ScriptedModel([
|
|
55
|
-
() => [
|
|
56
|
-
{ type: "text-delta", text: "hello" },
|
|
57
|
-
{ type: "finish", reason: "stop" },
|
|
58
|
-
],
|
|
59
|
-
]);
|
|
60
|
-
const runtime = new AgentRuntime({ model });
|
|
61
|
-
|
|
62
|
-
const result = await runtime.run("Hi");
|
|
63
|
-
|
|
64
|
-
expect(result.status).toBe("completed");
|
|
65
|
-
expect(result.outputText).toBe("hello");
|
|
66
|
-
expect(result.messages).toHaveLength(2);
|
|
67
|
-
expect(model.requests).toHaveLength(1);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("executes a tool call and continues the loop", async () => {
|
|
71
|
-
const model = new ScriptedModel([
|
|
72
|
-
() => [
|
|
73
|
-
{
|
|
74
|
-
type: "tool-call-delta",
|
|
75
|
-
toolCallId: "call_1",
|
|
76
|
-
toolName: "echo",
|
|
77
|
-
inputText: '{"text":"hi"}',
|
|
78
|
-
},
|
|
79
|
-
{ type: "finish", reason: "tool-calls" },
|
|
80
|
-
],
|
|
81
|
-
(request) => {
|
|
82
|
-
const toolMessage = request.messages.at(-1) as AgentMessage;
|
|
83
|
-
expect(toolMessage.role).toBe("tool");
|
|
84
|
-
return [
|
|
85
|
-
{ type: "text-delta", text: "done" },
|
|
86
|
-
{ type: "finish", reason: "stop" },
|
|
87
|
-
];
|
|
88
|
-
},
|
|
89
|
-
]);
|
|
90
|
-
const runtime = new AgentRuntime({ model, tools: [createEchoTool()] });
|
|
91
|
-
|
|
92
|
-
const result = await runtime.run("Start");
|
|
93
|
-
|
|
94
|
-
expect(result.status).toBe("completed");
|
|
95
|
-
expect(
|
|
96
|
-
result.messages.filter((message) => message.role === "tool"),
|
|
97
|
-
).toHaveLength(1);
|
|
98
|
-
expect(result.outputText).toBe("done");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("injects a pending user message after tool results and before the next model request", async () => {
|
|
102
|
-
const consumePendingUserMessage = vi.fn(() => "steer now");
|
|
103
|
-
const model = new ScriptedModel([
|
|
104
|
-
() => [
|
|
105
|
-
{
|
|
106
|
-
type: "tool-call-delta",
|
|
107
|
-
toolCallId: "call_1",
|
|
108
|
-
toolName: "echo",
|
|
109
|
-
inputText: '{"text":"hi"}',
|
|
110
|
-
},
|
|
111
|
-
{ type: "finish", reason: "tool-calls" },
|
|
112
|
-
],
|
|
113
|
-
(request) => {
|
|
114
|
-
const assistantMessage = request.messages.at(-3);
|
|
115
|
-
const toolMessage = request.messages.at(-2);
|
|
116
|
-
const steerMessage = request.messages.at(-1);
|
|
117
|
-
expect(assistantMessage?.role).toBe("assistant");
|
|
118
|
-
expect(
|
|
119
|
-
assistantMessage?.content.some((part) => part.type === "tool-call"),
|
|
120
|
-
).toBe(true);
|
|
121
|
-
expect(toolMessage?.role).toBe("tool");
|
|
122
|
-
expect(toolMessage?.content).toEqual([
|
|
123
|
-
expect.objectContaining({
|
|
124
|
-
type: "tool-result",
|
|
125
|
-
toolCallId: "call_1",
|
|
126
|
-
}),
|
|
127
|
-
]);
|
|
128
|
-
expect(steerMessage).toMatchObject({
|
|
129
|
-
role: "user",
|
|
130
|
-
content: [{ type: "text", text: "steer now" }],
|
|
131
|
-
});
|
|
132
|
-
return [
|
|
133
|
-
{ type: "text-delta", text: "steered done" },
|
|
134
|
-
{ type: "finish", reason: "stop" },
|
|
135
|
-
];
|
|
136
|
-
},
|
|
137
|
-
]);
|
|
138
|
-
const addedMessages: AgentMessage[] = [];
|
|
139
|
-
const runtime = new AgentRuntime({
|
|
140
|
-
model,
|
|
141
|
-
tools: [createEchoTool()],
|
|
142
|
-
consumePendingUserMessage,
|
|
143
|
-
});
|
|
144
|
-
runtime.subscribe((event) => {
|
|
145
|
-
if (event.type === "message-added") {
|
|
146
|
-
addedMessages.push(event.message);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
const result = await runtime.run("Start");
|
|
151
|
-
|
|
152
|
-
expect(consumePendingUserMessage).toHaveBeenCalledTimes(1);
|
|
153
|
-
expect(model.requests).toHaveLength(2);
|
|
154
|
-
expect(result.status).toBe("completed");
|
|
155
|
-
expect(result.messages.map((message) => message.role)).toEqual([
|
|
156
|
-
"user",
|
|
157
|
-
"assistant",
|
|
158
|
-
"tool",
|
|
159
|
-
"user",
|
|
160
|
-
"assistant",
|
|
161
|
-
]);
|
|
162
|
-
expect(
|
|
163
|
-
addedMessages.some(
|
|
164
|
-
(message) =>
|
|
165
|
-
message.role === "user" &&
|
|
166
|
-
message.content.some(
|
|
167
|
-
(part) => part.type === "text" && part.text === "steer now",
|
|
168
|
-
),
|
|
169
|
-
),
|
|
170
|
-
).toBe(true);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("injects pending user messages after prepareTurn rewrites the transcript", async () => {
|
|
174
|
-
const consumePendingUserMessage = vi.fn(() => "steer after prepare");
|
|
175
|
-
const prepareTurn = vi.fn(
|
|
176
|
-
(context: { messages: readonly AgentMessage[] }) => ({
|
|
177
|
-
messages: context.messages.slice(),
|
|
178
|
-
}),
|
|
179
|
-
);
|
|
180
|
-
const model = new ScriptedModel([
|
|
181
|
-
() => [
|
|
182
|
-
{
|
|
183
|
-
type: "tool-call-delta",
|
|
184
|
-
toolCallId: "call_1",
|
|
185
|
-
toolName: "echo",
|
|
186
|
-
inputText: '{"text":"hi"}',
|
|
187
|
-
},
|
|
188
|
-
{ type: "finish", reason: "tool-calls" },
|
|
189
|
-
],
|
|
190
|
-
(request) => {
|
|
191
|
-
expect(request.messages.at(-1)).toMatchObject({
|
|
192
|
-
role: "user",
|
|
193
|
-
content: [{ type: "text", text: "steer after prepare" }],
|
|
194
|
-
});
|
|
195
|
-
return [
|
|
196
|
-
{ type: "text-delta", text: "done" },
|
|
197
|
-
{ type: "finish", reason: "stop" },
|
|
198
|
-
];
|
|
199
|
-
},
|
|
200
|
-
]);
|
|
201
|
-
const runtime = new AgentRuntime({
|
|
202
|
-
model,
|
|
203
|
-
tools: [createEchoTool()],
|
|
204
|
-
prepareTurn,
|
|
205
|
-
consumePendingUserMessage,
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
const result = await runtime.run("Start");
|
|
209
|
-
|
|
210
|
-
expect(result.status).toBe("completed");
|
|
211
|
-
expect(prepareTurn).toHaveBeenCalledTimes(2);
|
|
212
|
-
expect(consumePendingUserMessage).toHaveBeenCalledTimes(1);
|
|
213
|
-
expect(result.messages.map((message) => message.role)).toEqual([
|
|
214
|
-
"user",
|
|
215
|
-
"assistant",
|
|
216
|
-
"tool",
|
|
217
|
-
"user",
|
|
218
|
-
"assistant",
|
|
219
|
-
]);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("continues when completionGuard rejects a no-tool response", async () => {
|
|
223
|
-
const submitTool: AgentTool<{ summary: string }, string> = {
|
|
224
|
-
name: "submit",
|
|
225
|
-
description: "Submit final answer",
|
|
226
|
-
inputSchema: { type: "object" },
|
|
227
|
-
lifecycle: { completesRun: true },
|
|
228
|
-
async execute(input) {
|
|
229
|
-
return `submitted: ${input.summary}`;
|
|
230
|
-
},
|
|
231
|
-
};
|
|
232
|
-
const model = new ScriptedModel([
|
|
233
|
-
() => [
|
|
234
|
-
{ type: "text-delta", text: "I am done" },
|
|
235
|
-
{ type: "finish", reason: "stop" },
|
|
236
|
-
],
|
|
237
|
-
(request) => {
|
|
238
|
-
const reminder = request.messages.at(-1);
|
|
239
|
-
expect(reminder?.role).toBe("user");
|
|
240
|
-
expect(
|
|
241
|
-
reminder?.content.some(
|
|
242
|
-
(part) => part.type === "text" && part.text.includes("submit"),
|
|
243
|
-
),
|
|
244
|
-
).toBe(true);
|
|
245
|
-
return [
|
|
246
|
-
{
|
|
247
|
-
type: "tool-call-delta",
|
|
248
|
-
toolCallId: "call_submit",
|
|
249
|
-
toolName: "submit",
|
|
250
|
-
inputText: '{"summary":"done"}',
|
|
251
|
-
},
|
|
252
|
-
{ type: "finish", reason: "tool-calls" },
|
|
253
|
-
];
|
|
254
|
-
},
|
|
255
|
-
]);
|
|
256
|
-
const runtime = new AgentRuntime({
|
|
257
|
-
model,
|
|
258
|
-
tools: [submitTool],
|
|
259
|
-
completionPolicy: {
|
|
260
|
-
completionGuard: () =>
|
|
261
|
-
"[SYSTEM] This run is not complete until you call submit.",
|
|
262
|
-
},
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
const result = await runtime.run("Start");
|
|
266
|
-
|
|
267
|
-
expect(result.status).toBe("completed");
|
|
268
|
-
expect(result.iterations).toBe(2);
|
|
269
|
-
expect(result.outputText).toBe("submitted: done");
|
|
270
|
-
expect(model.requests).toHaveLength(2);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it("announces and enforces required completion tools from tool lifecycle metadata", async () => {
|
|
274
|
-
const submitTool: AgentTool<{ summary: string }, string> = {
|
|
275
|
-
name: "custom_finish",
|
|
276
|
-
description: "Submit final answer",
|
|
277
|
-
inputSchema: { type: "object" },
|
|
278
|
-
lifecycle: { completesRun: true },
|
|
279
|
-
async execute(input) {
|
|
280
|
-
return `submitted: ${input.summary}`;
|
|
281
|
-
},
|
|
282
|
-
};
|
|
283
|
-
const model = new ScriptedModel([
|
|
284
|
-
(request) => {
|
|
285
|
-
const reminder = request.messages.at(-1);
|
|
286
|
-
expect(reminder?.role).toBe("user");
|
|
287
|
-
expect(
|
|
288
|
-
reminder?.content.some(
|
|
289
|
-
(part) =>
|
|
290
|
-
part.type === "text" && part.text.includes("custom_finish"),
|
|
291
|
-
),
|
|
292
|
-
).toBe(true);
|
|
293
|
-
return [
|
|
294
|
-
{ type: "text-delta", text: "I am done" },
|
|
295
|
-
{ type: "finish", reason: "stop" },
|
|
296
|
-
];
|
|
297
|
-
},
|
|
298
|
-
(request) => {
|
|
299
|
-
const reminder = request.messages.at(-1);
|
|
300
|
-
expect(reminder?.role).toBe("user");
|
|
301
|
-
expect(
|
|
302
|
-
reminder?.content.some(
|
|
303
|
-
(part) =>
|
|
304
|
-
part.type === "text" && part.text.includes("custom_finish"),
|
|
305
|
-
),
|
|
306
|
-
).toBe(true);
|
|
307
|
-
return [
|
|
308
|
-
{
|
|
309
|
-
type: "tool-call-delta",
|
|
310
|
-
toolCallId: "call_submit",
|
|
311
|
-
toolName: "custom_finish",
|
|
312
|
-
inputText: '{"summary":"done"}',
|
|
313
|
-
},
|
|
314
|
-
{ type: "finish", reason: "tool-calls" },
|
|
315
|
-
];
|
|
316
|
-
},
|
|
317
|
-
]);
|
|
318
|
-
const runtime = new AgentRuntime({
|
|
319
|
-
model,
|
|
320
|
-
tools: [submitTool],
|
|
321
|
-
completionPolicy: { requireCompletionTool: true },
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
const result = await runtime.run("Start");
|
|
325
|
-
|
|
326
|
-
expect(result.status).toBe("completed");
|
|
327
|
-
expect(result.iterations).toBe(2);
|
|
328
|
-
expect(result.outputText).toBe("submitted: done");
|
|
329
|
-
expect(model.requests).toHaveLength(2);
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it("finishes immediately after a successful terminal tool call", async () => {
|
|
333
|
-
const submitTool: AgentTool<{ summary: string }, string> = {
|
|
334
|
-
name: "submit",
|
|
335
|
-
description: "Submit final answer",
|
|
336
|
-
inputSchema: { type: "object" },
|
|
337
|
-
lifecycle: { completesRun: true },
|
|
338
|
-
async execute(input) {
|
|
339
|
-
return input.summary;
|
|
340
|
-
},
|
|
341
|
-
};
|
|
342
|
-
const model = new ScriptedModel([
|
|
343
|
-
() => [
|
|
344
|
-
{
|
|
345
|
-
type: "tool-call-delta",
|
|
346
|
-
toolCallId: "call_submit",
|
|
347
|
-
toolName: "submit",
|
|
348
|
-
inputText: '{"summary":"finished"}',
|
|
349
|
-
},
|
|
350
|
-
{ type: "finish", reason: "tool-calls" },
|
|
351
|
-
],
|
|
352
|
-
]);
|
|
353
|
-
const runtime = new AgentRuntime({
|
|
354
|
-
model,
|
|
355
|
-
tools: [submitTool],
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
const result = await runtime.run("Start");
|
|
359
|
-
|
|
360
|
-
expect(result.status).toBe("completed");
|
|
361
|
-
expect(result.iterations).toBe(1);
|
|
362
|
-
expect(result.outputText).toBe("finished");
|
|
363
|
-
expect(model.requests).toHaveLength(1);
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
it("preserves structured multimodal tool results for the next model request", async () => {
|
|
367
|
-
const structuredOutput = [
|
|
368
|
-
{ type: "text", text: "Successfully read image" },
|
|
369
|
-
{ type: "image", data: "BASE64DATA", mediaType: "image/jpeg" },
|
|
370
|
-
];
|
|
371
|
-
const model = new ScriptedModel([
|
|
372
|
-
() => [
|
|
373
|
-
{
|
|
374
|
-
type: "tool-call-delta",
|
|
375
|
-
toolCallId: "call_img",
|
|
376
|
-
toolName: "read_file",
|
|
377
|
-
inputText: '{"path":"/tmp/image.jpg"}',
|
|
378
|
-
},
|
|
379
|
-
{ type: "finish", reason: "tool-calls" },
|
|
380
|
-
],
|
|
381
|
-
(request) => {
|
|
382
|
-
const toolMessage = request.messages.at(-1) as AgentMessage;
|
|
383
|
-
expect(toolMessage.role).toBe("tool");
|
|
384
|
-
expect(toolMessage.content[0]).toMatchObject({
|
|
385
|
-
type: "tool-result",
|
|
386
|
-
toolCallId: "call_img",
|
|
387
|
-
toolName: "read_file",
|
|
388
|
-
output: structuredOutput,
|
|
389
|
-
});
|
|
390
|
-
return [
|
|
391
|
-
{ type: "text-delta", text: "saw image" },
|
|
392
|
-
{ type: "finish", reason: "stop" },
|
|
393
|
-
];
|
|
394
|
-
},
|
|
395
|
-
]);
|
|
396
|
-
const runtime = new AgentRuntime({
|
|
397
|
-
model,
|
|
398
|
-
tools: [
|
|
399
|
-
{
|
|
400
|
-
name: "read_file",
|
|
401
|
-
description: "Read file",
|
|
402
|
-
inputSchema: { type: "object" },
|
|
403
|
-
execute: async () => structuredOutput,
|
|
404
|
-
},
|
|
405
|
-
],
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
const result = await runtime.run("Inspect image");
|
|
409
|
-
|
|
410
|
-
expect(result.status).toBe("completed");
|
|
411
|
-
expect(result.outputText).toBe("saw image");
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it("preserves plain tool outputs that contain an output property", async () => {
|
|
415
|
-
const plainOutput = {
|
|
416
|
-
output: "nested value",
|
|
417
|
-
status: "ok",
|
|
418
|
-
count: 2,
|
|
419
|
-
};
|
|
420
|
-
const model = new ScriptedModel([
|
|
421
|
-
() => [
|
|
422
|
-
{
|
|
423
|
-
type: "tool-call-delta",
|
|
424
|
-
toolCallId: "call_plain",
|
|
425
|
-
toolName: "plain_output",
|
|
426
|
-
inputText: "{}",
|
|
427
|
-
},
|
|
428
|
-
{ type: "finish", reason: "tool-calls" },
|
|
429
|
-
],
|
|
430
|
-
(request) => {
|
|
431
|
-
const toolMessage = request.messages.at(-1) as AgentMessage;
|
|
432
|
-
expect(toolMessage.role).toBe("tool");
|
|
433
|
-
expect(toolMessage.content[0]).toMatchObject({
|
|
434
|
-
type: "tool-result",
|
|
435
|
-
toolCallId: "call_plain",
|
|
436
|
-
toolName: "plain_output",
|
|
437
|
-
output: plainOutput,
|
|
438
|
-
});
|
|
439
|
-
return [
|
|
440
|
-
{ type: "text-delta", text: "preserved" },
|
|
441
|
-
{ type: "finish", reason: "stop" },
|
|
442
|
-
];
|
|
443
|
-
},
|
|
444
|
-
]);
|
|
445
|
-
const runtime = new AgentRuntime({
|
|
446
|
-
model,
|
|
447
|
-
tools: [
|
|
448
|
-
{
|
|
449
|
-
name: "plain_output",
|
|
450
|
-
description: "Return a plain object with an output key",
|
|
451
|
-
inputSchema: { type: "object" },
|
|
452
|
-
execute: async () => plainOutput,
|
|
453
|
-
},
|
|
454
|
-
],
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
const result = await runtime.run("Run tool");
|
|
458
|
-
|
|
459
|
-
expect(result.status).toBe("completed");
|
|
460
|
-
expect(result.outputText).toBe("preserved");
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
it("requests approval when a tool policy disables auto-approval", async () => {
|
|
464
|
-
const executeTool = vi.fn(async () => ({ echoed: "hi" }));
|
|
465
|
-
const requestToolApproval = vi.fn(async () => ({
|
|
466
|
-
approved: false,
|
|
467
|
-
reason: "denied by test",
|
|
468
|
-
}));
|
|
469
|
-
const model = new ScriptedModel([
|
|
470
|
-
() => [
|
|
471
|
-
{
|
|
472
|
-
type: "tool-call-delta",
|
|
473
|
-
toolCallId: "call_approval",
|
|
474
|
-
toolName: "echo",
|
|
475
|
-
inputText: '{"text":"hi"}',
|
|
476
|
-
},
|
|
477
|
-
{ type: "finish", reason: "tool-calls" },
|
|
478
|
-
],
|
|
479
|
-
(request) => {
|
|
480
|
-
const toolMessage = request.messages.at(-1) as AgentMessage;
|
|
481
|
-
expect(toolMessage.role).toBe("tool");
|
|
482
|
-
expect(toolMessage.content[0]).toMatchObject({
|
|
483
|
-
type: "tool-result",
|
|
484
|
-
isError: true,
|
|
485
|
-
output: { error: "denied by test" },
|
|
486
|
-
});
|
|
487
|
-
return [
|
|
488
|
-
{ type: "text-delta", text: "approval handled" },
|
|
489
|
-
{ type: "finish", reason: "stop" },
|
|
490
|
-
];
|
|
491
|
-
},
|
|
492
|
-
]);
|
|
493
|
-
const runtime = new AgentRuntime({
|
|
494
|
-
sessionId: "session_test",
|
|
495
|
-
agentId: "agent_test",
|
|
496
|
-
conversationId: "conversation_test",
|
|
497
|
-
model,
|
|
498
|
-
tools: [
|
|
499
|
-
{
|
|
500
|
-
name: "echo",
|
|
501
|
-
description: "Echo input text",
|
|
502
|
-
inputSchema: { type: "object" },
|
|
503
|
-
execute: executeTool,
|
|
504
|
-
},
|
|
505
|
-
],
|
|
506
|
-
toolPolicies: { "*": { autoApprove: false } },
|
|
507
|
-
requestToolApproval,
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
const result = await runtime.run("Start");
|
|
511
|
-
|
|
512
|
-
expect(result.status).toBe("completed");
|
|
513
|
-
expect(result.outputText).toBe("approval handled");
|
|
514
|
-
expect(executeTool).not.toHaveBeenCalled();
|
|
515
|
-
expect(requestToolApproval).toHaveBeenCalledWith({
|
|
516
|
-
sessionId: "session_test",
|
|
517
|
-
agentId: "agent_test",
|
|
518
|
-
conversationId: "conversation_test",
|
|
519
|
-
iteration: 1,
|
|
520
|
-
toolCallId: "call_approval",
|
|
521
|
-
toolName: "echo",
|
|
522
|
-
input: { text: "hi" },
|
|
523
|
-
policy: { autoApprove: false },
|
|
524
|
-
});
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
it("stores tool calls but skips execution when metadata disables external execution", async () => {
|
|
528
|
-
const executeTool = vi.fn(async () => ({ echoed: "hi" }));
|
|
529
|
-
const model = new ScriptedModel([
|
|
530
|
-
() => [
|
|
531
|
-
{
|
|
532
|
-
type: "tool-call-delta",
|
|
533
|
-
toolCallId: "call_1",
|
|
534
|
-
toolName: "echo",
|
|
535
|
-
inputText: '{"text":"hi"}',
|
|
536
|
-
metadata: {
|
|
537
|
-
toolSource: {
|
|
538
|
-
providerId: "openai-codex",
|
|
539
|
-
modelId: "gpt-5-codex",
|
|
540
|
-
executionMode: "provider",
|
|
541
|
-
},
|
|
542
|
-
},
|
|
543
|
-
},
|
|
544
|
-
{ type: "finish", reason: "tool-calls" },
|
|
545
|
-
],
|
|
546
|
-
(request) => {
|
|
547
|
-
const toolMessage = request.messages.at(-1) as AgentMessage;
|
|
548
|
-
expect(toolMessage.role).toBe("tool");
|
|
549
|
-
return [
|
|
550
|
-
{ type: "text-delta", text: "done" },
|
|
551
|
-
{ type: "finish", reason: "stop" },
|
|
552
|
-
];
|
|
553
|
-
},
|
|
554
|
-
]);
|
|
555
|
-
const runtime = new AgentRuntime({
|
|
556
|
-
model,
|
|
557
|
-
tools: [
|
|
558
|
-
{
|
|
559
|
-
name: "echo",
|
|
560
|
-
description: "Echo input text",
|
|
561
|
-
inputSchema: { type: "object" },
|
|
562
|
-
execute: executeTool,
|
|
563
|
-
},
|
|
564
|
-
],
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
const result = await runtime.run("Start");
|
|
568
|
-
|
|
569
|
-
expect(result.status).toBe("completed");
|
|
570
|
-
expect(result.outputText).toBe("done");
|
|
571
|
-
expect(executeTool).not.toHaveBeenCalled();
|
|
572
|
-
const toolMessages = result.messages.filter(
|
|
573
|
-
(message) => message.role === "tool",
|
|
574
|
-
);
|
|
575
|
-
expect(toolMessages).toHaveLength(1);
|
|
576
|
-
expect(toolMessages[0]?.content).toEqual([
|
|
577
|
-
expect.objectContaining({
|
|
578
|
-
type: "tool-result",
|
|
579
|
-
toolCallId: "call_1",
|
|
580
|
-
toolName: "echo",
|
|
581
|
-
isError: true,
|
|
582
|
-
output: {
|
|
583
|
-
error: "Tool execution is disabled for provider openai-codex",
|
|
584
|
-
},
|
|
585
|
-
}),
|
|
586
|
-
]);
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
it("shows provider-disabled message even when tool is not registered locally", async () => {
|
|
590
|
-
const model = new ScriptedModel([
|
|
591
|
-
() => [
|
|
592
|
-
{
|
|
593
|
-
type: "tool-call-delta",
|
|
594
|
-
toolCallId: "call_1",
|
|
595
|
-
toolName: "shell",
|
|
596
|
-
inputText: '{"command":"echo hi"}',
|
|
597
|
-
metadata: {
|
|
598
|
-
toolSource: {
|
|
599
|
-
providerId: "openai-codex",
|
|
600
|
-
executionMode: "provider",
|
|
601
|
-
},
|
|
602
|
-
},
|
|
603
|
-
},
|
|
604
|
-
{ type: "finish", reason: "tool-calls" },
|
|
605
|
-
],
|
|
606
|
-
(request) => {
|
|
607
|
-
const toolMessage = request.messages.at(-1) as AgentMessage;
|
|
608
|
-
expect(toolMessage.role).toBe("tool");
|
|
609
|
-
return [
|
|
610
|
-
{ type: "text-delta", text: "done" },
|
|
611
|
-
{ type: "finish", reason: "stop" },
|
|
612
|
-
];
|
|
613
|
-
},
|
|
614
|
-
]);
|
|
615
|
-
const runtime = new AgentRuntime({
|
|
616
|
-
model,
|
|
617
|
-
tools: [], // shell tool is not registered
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
const result = await runtime.run("Start");
|
|
621
|
-
|
|
622
|
-
expect(result.status).toBe("completed");
|
|
623
|
-
expect(result.outputText).toBe("done");
|
|
624
|
-
const toolMessages = result.messages.filter(
|
|
625
|
-
(message) => message.role === "tool",
|
|
626
|
-
);
|
|
627
|
-
expect(toolMessages).toHaveLength(1);
|
|
628
|
-
expect(toolMessages[0]?.content).toEqual([
|
|
629
|
-
expect.objectContaining({
|
|
630
|
-
type: "tool-result",
|
|
631
|
-
toolCallId: "call_1",
|
|
632
|
-
toolName: "shell",
|
|
633
|
-
isError: true,
|
|
634
|
-
output: {
|
|
635
|
-
error: "Tool execution is disabled for provider openai-codex",
|
|
636
|
-
},
|
|
637
|
-
}),
|
|
638
|
-
]);
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
it("treats an unset maxIterations as unlimited", async () => {
|
|
642
|
-
const model = new ScriptedModel([
|
|
643
|
-
() => [
|
|
644
|
-
{
|
|
645
|
-
type: "tool-call-delta",
|
|
646
|
-
toolCallId: "call_1",
|
|
647
|
-
toolName: "echo",
|
|
648
|
-
inputText: '{"text":"first"}',
|
|
649
|
-
},
|
|
650
|
-
{ type: "finish", reason: "tool-calls" },
|
|
651
|
-
],
|
|
652
|
-
() => [
|
|
653
|
-
{
|
|
654
|
-
type: "tool-call-delta",
|
|
655
|
-
toolCallId: "call_2",
|
|
656
|
-
toolName: "echo",
|
|
657
|
-
inputText: '{"text":"second"}',
|
|
658
|
-
},
|
|
659
|
-
{ type: "finish", reason: "tool-calls" },
|
|
660
|
-
],
|
|
661
|
-
() => [
|
|
662
|
-
{ type: "text-delta", text: "done" },
|
|
663
|
-
{ type: "finish", reason: "stop" },
|
|
664
|
-
],
|
|
665
|
-
]);
|
|
666
|
-
const runtime = new AgentRuntime({ model, tools: [createEchoTool()] });
|
|
667
|
-
|
|
668
|
-
const result = await runtime.run("Start");
|
|
669
|
-
|
|
670
|
-
expect(result.status).toBe("completed");
|
|
671
|
-
expect(result.iterations).toBe(3);
|
|
672
|
-
expect(result.outputText).toBe("done");
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
it("supports plugin-contributed tools and hooks", async () => {
|
|
676
|
-
const beforeRun = vi.fn();
|
|
677
|
-
const plugin: AgentRuntimePlugin = {
|
|
678
|
-
name: "plugin-tool",
|
|
679
|
-
setup: () => ({
|
|
680
|
-
hooks: {
|
|
681
|
-
beforeRun,
|
|
682
|
-
},
|
|
683
|
-
tools: [
|
|
684
|
-
{
|
|
685
|
-
name: "plugin_tool",
|
|
686
|
-
description: "Provided by a plugin",
|
|
687
|
-
inputSchema: { type: "object" },
|
|
688
|
-
execute: async () => ({ ok: true }),
|
|
689
|
-
},
|
|
690
|
-
],
|
|
691
|
-
}),
|
|
692
|
-
};
|
|
693
|
-
const model = new ScriptedModel([
|
|
694
|
-
() => [
|
|
695
|
-
{
|
|
696
|
-
type: "tool-call-delta",
|
|
697
|
-
toolCallId: "call_plugin",
|
|
698
|
-
toolName: "plugin_tool",
|
|
699
|
-
inputText: "{}",
|
|
700
|
-
},
|
|
701
|
-
{ type: "finish", reason: "tool-calls" },
|
|
702
|
-
],
|
|
703
|
-
() => [
|
|
704
|
-
{ type: "text-delta", text: "plugin complete" },
|
|
705
|
-
{ type: "finish", reason: "stop" },
|
|
706
|
-
],
|
|
707
|
-
]);
|
|
708
|
-
|
|
709
|
-
const runtime = new AgentRuntime({
|
|
710
|
-
model,
|
|
711
|
-
conversationId: "conversation_plugin",
|
|
712
|
-
plugins: [plugin],
|
|
713
|
-
});
|
|
714
|
-
const result = await runtime.run("Run plugin");
|
|
715
|
-
|
|
716
|
-
expect(beforeRun).toHaveBeenCalledOnce();
|
|
717
|
-
expect(beforeRun).toHaveBeenCalledWith({
|
|
718
|
-
snapshot: expect.objectContaining({
|
|
719
|
-
conversationId: "conversation_plugin",
|
|
720
|
-
}),
|
|
721
|
-
});
|
|
722
|
-
expect(result.status).toBe("completed");
|
|
723
|
-
expect(result.outputText).toBe("plugin complete");
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
it("unwinds cleanly when beforeRun stops the run", async () => {
|
|
727
|
-
const events: string[] = [];
|
|
728
|
-
let stopNextRun = true;
|
|
729
|
-
const runtime = new AgentRuntime({
|
|
730
|
-
model: new ScriptedModel([
|
|
731
|
-
() => [
|
|
732
|
-
{ type: "text-delta", text: "second run" },
|
|
733
|
-
{ type: "finish", reason: "stop" },
|
|
734
|
-
],
|
|
735
|
-
]),
|
|
736
|
-
hooks: {
|
|
737
|
-
beforeRun: async () => {
|
|
738
|
-
if (!stopNextRun) {
|
|
739
|
-
return undefined;
|
|
740
|
-
}
|
|
741
|
-
stopNextRun = false;
|
|
742
|
-
return { stop: true, reason: "blocked" };
|
|
743
|
-
},
|
|
744
|
-
},
|
|
745
|
-
});
|
|
746
|
-
runtime.subscribe((event) => {
|
|
747
|
-
events.push(event.type);
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
const first = await runtime.run("first");
|
|
751
|
-
const second = await runtime.run("second");
|
|
752
|
-
|
|
753
|
-
expect(first.status).toBe("aborted");
|
|
754
|
-
expect(first.error).toBeUndefined();
|
|
755
|
-
expect(events[0]).toBe("run-finished");
|
|
756
|
-
expect(events).toContain("run-started");
|
|
757
|
-
expect(events.at(-1)).toBe("run-finished");
|
|
758
|
-
expect(second.status).toBe("completed");
|
|
759
|
-
expect(second.outputText).toBe("second run");
|
|
760
|
-
expect(runtime.snapshot().status).toBe("completed");
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
it("annotates assistant messages with per-turn metrics and model info", async () => {
|
|
764
|
-
const model = new ScriptedModel([
|
|
765
|
-
() => [
|
|
766
|
-
{
|
|
767
|
-
type: "usage",
|
|
768
|
-
usage: {
|
|
769
|
-
inputTokens: 12,
|
|
770
|
-
outputTokens: 7,
|
|
771
|
-
cacheReadTokens: 3,
|
|
772
|
-
cacheWriteTokens: 2,
|
|
773
|
-
totalCost: 0.42,
|
|
774
|
-
},
|
|
775
|
-
},
|
|
776
|
-
{ type: "text-delta", text: "hello" },
|
|
777
|
-
{ type: "finish", reason: "stop" },
|
|
778
|
-
],
|
|
779
|
-
]);
|
|
780
|
-
const runtime = new AgentRuntime({
|
|
781
|
-
model,
|
|
782
|
-
messageModelInfo: {
|
|
783
|
-
id: "anthropic/claude-sonnet-4.6",
|
|
784
|
-
provider: "openrouter",
|
|
785
|
-
family: "claude-sonnet",
|
|
786
|
-
},
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
const result = await runtime.run("Hi");
|
|
790
|
-
const assistant = result.messages.at(-1);
|
|
791
|
-
|
|
792
|
-
expect(assistant?.role).toBe("assistant");
|
|
793
|
-
expect(assistant?.modelInfo).toEqual({
|
|
794
|
-
id: "anthropic/claude-sonnet-4.6",
|
|
795
|
-
provider: "openrouter",
|
|
796
|
-
family: "claude-sonnet",
|
|
797
|
-
});
|
|
798
|
-
expect(assistant?.metrics).toEqual({
|
|
799
|
-
inputTokens: 12,
|
|
800
|
-
outputTokens: 7,
|
|
801
|
-
cacheReadTokens: 3,
|
|
802
|
-
cacheWriteTokens: 2,
|
|
803
|
-
cost: 0.42,
|
|
804
|
-
});
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
it("stops a run from beforeModel hooks and returns an aborted result", async () => {
|
|
808
|
-
const model = new ScriptedModel([
|
|
809
|
-
() => [
|
|
810
|
-
{ type: "text-delta", text: "should not happen" },
|
|
811
|
-
{ type: "finish", reason: "stop" },
|
|
812
|
-
],
|
|
813
|
-
]);
|
|
814
|
-
const runtime = new AgentRuntime({
|
|
815
|
-
model,
|
|
816
|
-
hooks: {
|
|
817
|
-
beforeModel: () => ({ stop: true, reason: "approval required" }),
|
|
818
|
-
},
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
const result = await runtime.run("Stop early");
|
|
822
|
-
|
|
823
|
-
expect(result.status).toBe("aborted");
|
|
824
|
-
expect(result.error).toBeUndefined();
|
|
825
|
-
expect(result.outputText).toBe("");
|
|
826
|
-
expect(model.requests).toHaveLength(0);
|
|
827
|
-
});
|
|
828
|
-
|
|
829
|
-
it("runs prepareTurn before beforeModel and persists rewritten messages", async () => {
|
|
830
|
-
const compactedMessage: AgentMessage = {
|
|
831
|
-
id: "msg_compacted",
|
|
832
|
-
role: "user",
|
|
833
|
-
content: [{ type: "text", text: "compacted context" }],
|
|
834
|
-
createdAt: 1,
|
|
835
|
-
};
|
|
836
|
-
const notices: string[] = [];
|
|
837
|
-
const prepareTurn = vi.fn((context) => {
|
|
838
|
-
expect(context.messages).toHaveLength(1);
|
|
839
|
-
expect(context.messages[0]?.content).toEqual([
|
|
840
|
-
{ type: "text", text: "large context" },
|
|
841
|
-
]);
|
|
842
|
-
context.emitStatusNotice?.("auto-compacting", {
|
|
843
|
-
reason: "auto_compaction",
|
|
844
|
-
});
|
|
845
|
-
return {
|
|
846
|
-
messages: [compactedMessage],
|
|
847
|
-
systemPrompt: "compacted system",
|
|
848
|
-
};
|
|
849
|
-
});
|
|
850
|
-
const beforeModel = vi.fn(({ request }) => {
|
|
851
|
-
expect(request.systemPrompt).toBe("compacted system");
|
|
852
|
-
expect(request.messages).toEqual([compactedMessage]);
|
|
853
|
-
return undefined;
|
|
854
|
-
});
|
|
855
|
-
const model = new ScriptedModel([
|
|
856
|
-
(request) => {
|
|
857
|
-
expect(request.systemPrompt).toBe("compacted system");
|
|
858
|
-
expect(request.messages).toEqual([compactedMessage]);
|
|
859
|
-
return [
|
|
860
|
-
{ type: "text-delta", text: "done" },
|
|
861
|
-
{ type: "finish", reason: "stop" },
|
|
862
|
-
];
|
|
863
|
-
},
|
|
864
|
-
]);
|
|
865
|
-
const runtime = new AgentRuntime({
|
|
866
|
-
model,
|
|
867
|
-
systemPrompt: "original system",
|
|
868
|
-
prepareTurn,
|
|
869
|
-
hooks: { beforeModel },
|
|
870
|
-
});
|
|
871
|
-
runtime.subscribe((event) => {
|
|
872
|
-
if (event.type === "status-notice") {
|
|
873
|
-
notices.push(event.message);
|
|
874
|
-
}
|
|
875
|
-
});
|
|
876
|
-
|
|
877
|
-
const result = await runtime.run("large context");
|
|
878
|
-
|
|
879
|
-
expect(prepareTurn).toHaveBeenCalledTimes(1);
|
|
880
|
-
expect(beforeModel).toHaveBeenCalledTimes(1);
|
|
881
|
-
expect(notices).toEqual(["auto-compacting"]);
|
|
882
|
-
expect(result.messages[0]).toEqual(compactedMessage);
|
|
883
|
-
expect(result.messages).toHaveLength(2);
|
|
884
|
-
expect(model.requests).toHaveLength(1);
|
|
885
|
-
});
|
|
886
|
-
|
|
887
|
-
it("preserves the existing system prompt when prepareTurn returns only messages", async () => {
|
|
888
|
-
const compactedMessage: AgentMessage = {
|
|
889
|
-
id: "msg_compacted",
|
|
890
|
-
role: "user",
|
|
891
|
-
content: [{ type: "text", text: "compacted context" }],
|
|
892
|
-
createdAt: 1,
|
|
893
|
-
};
|
|
894
|
-
const model = new ScriptedModel([
|
|
895
|
-
(request) => {
|
|
896
|
-
expect(request.systemPrompt).toBe("original system");
|
|
897
|
-
expect(request.messages).toEqual([compactedMessage]);
|
|
898
|
-
return [
|
|
899
|
-
{ type: "text-delta", text: "done" },
|
|
900
|
-
{ type: "finish", reason: "stop" },
|
|
901
|
-
];
|
|
902
|
-
},
|
|
903
|
-
]);
|
|
904
|
-
const runtime = new AgentRuntime({
|
|
905
|
-
model,
|
|
906
|
-
systemPrompt: "original system",
|
|
907
|
-
prepareTurn: () => ({ messages: [compactedMessage] }),
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
await runtime.run("large context");
|
|
911
|
-
|
|
912
|
-
expect(model.requests).toHaveLength(1);
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
it("can block a tool through beforeTool hooks", async () => {
|
|
916
|
-
const model = new ScriptedModel([
|
|
917
|
-
() => [
|
|
918
|
-
{
|
|
919
|
-
type: "tool-call-delta",
|
|
920
|
-
toolCallId: "blocked",
|
|
921
|
-
toolName: "echo",
|
|
922
|
-
inputText: '{"text":"x"}',
|
|
923
|
-
},
|
|
924
|
-
{ type: "finish", reason: "tool-calls" },
|
|
925
|
-
],
|
|
926
|
-
(request) => {
|
|
927
|
-
const toolResult = request.messages.at(-1)?.content[0];
|
|
928
|
-
expect(toolResult).toMatchObject({
|
|
929
|
-
type: "tool-result",
|
|
930
|
-
isError: true,
|
|
931
|
-
});
|
|
932
|
-
return [
|
|
933
|
-
{ type: "text-delta", text: "recovered" },
|
|
934
|
-
{ type: "finish", reason: "stop" },
|
|
935
|
-
];
|
|
936
|
-
},
|
|
937
|
-
]);
|
|
938
|
-
const runtime = new AgentRuntime({
|
|
939
|
-
model,
|
|
940
|
-
tools: [createEchoTool()],
|
|
941
|
-
hooks: {
|
|
942
|
-
beforeTool: () => ({ skip: true, reason: "policy denied" }),
|
|
943
|
-
},
|
|
944
|
-
});
|
|
945
|
-
|
|
946
|
-
const result = await runtime.run("Block it");
|
|
947
|
-
|
|
948
|
-
expect(result.status).toBe("completed");
|
|
949
|
-
const toolMessage = result.messages.find(
|
|
950
|
-
(message) => message.role === "tool",
|
|
951
|
-
);
|
|
952
|
-
expect(toolMessage?.content[0]).toMatchObject({
|
|
953
|
-
type: "tool-result",
|
|
954
|
-
isError: true,
|
|
955
|
-
});
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
it("treats invalid tool-call JSON as a tool error instead of failing the run", async () => {
|
|
959
|
-
const model = new ScriptedModel([
|
|
960
|
-
() => [
|
|
961
|
-
{
|
|
962
|
-
type: "tool-call-delta",
|
|
963
|
-
toolCallId: "bad_json",
|
|
964
|
-
toolName: "echo",
|
|
965
|
-
inputText: '{"text":"bad\\x"}',
|
|
966
|
-
},
|
|
967
|
-
{ type: "finish", reason: "tool-calls" },
|
|
968
|
-
],
|
|
969
|
-
(request) => {
|
|
970
|
-
const toolResult = request.messages.at(-1)?.content[0];
|
|
971
|
-
expect(toolResult).toMatchObject({
|
|
972
|
-
type: "tool-result",
|
|
973
|
-
toolName: "echo",
|
|
974
|
-
isError: true,
|
|
975
|
-
output: {
|
|
976
|
-
error: expect.stringContaining(
|
|
977
|
-
"Tool call echo emitted invalid JSON arguments",
|
|
978
|
-
),
|
|
979
|
-
},
|
|
980
|
-
});
|
|
981
|
-
return [
|
|
982
|
-
{ type: "text-delta", text: "recovered" },
|
|
983
|
-
{ type: "finish", reason: "stop" },
|
|
984
|
-
];
|
|
985
|
-
},
|
|
986
|
-
]);
|
|
987
|
-
const runtime = new AgentRuntime({ model, tools: [createEchoTool()] });
|
|
988
|
-
|
|
989
|
-
const result = await runtime.run("Start");
|
|
990
|
-
|
|
991
|
-
expect(result.status).toBe("completed");
|
|
992
|
-
expect(result.outputText).toBe("recovered");
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
it("recovers when a model stream reports an invalid tool input error after a tool call", async () => {
|
|
996
|
-
const model = new ScriptedModel([
|
|
997
|
-
() => [
|
|
998
|
-
{
|
|
999
|
-
type: "tool-call-delta",
|
|
1000
|
-
toolCallId: "bad_json",
|
|
1001
|
-
toolName: "echo",
|
|
1002
|
-
inputText: '{"text": find /tmp | head -20}',
|
|
1003
|
-
},
|
|
1004
|
-
{
|
|
1005
|
-
type: "finish",
|
|
1006
|
-
reason: "error",
|
|
1007
|
-
error: "Invalid input for tool echo",
|
|
1008
|
-
},
|
|
1009
|
-
],
|
|
1010
|
-
(request) => {
|
|
1011
|
-
const toolMessage = request.messages.at(-1) as AgentMessage;
|
|
1012
|
-
expect(toolMessage.role).toBe("tool");
|
|
1013
|
-
expect(toolMessage.content[0]).toMatchObject({
|
|
1014
|
-
type: "tool-result",
|
|
1015
|
-
toolName: "echo",
|
|
1016
|
-
isError: true,
|
|
1017
|
-
output: {
|
|
1018
|
-
error: expect.stringContaining(
|
|
1019
|
-
"Tool call echo emitted invalid JSON arguments",
|
|
1020
|
-
),
|
|
1021
|
-
},
|
|
1022
|
-
});
|
|
1023
|
-
return [
|
|
1024
|
-
{ type: "text-delta", text: "recovered" },
|
|
1025
|
-
{ type: "finish", reason: "stop" },
|
|
1026
|
-
];
|
|
1027
|
-
},
|
|
1028
|
-
]);
|
|
1029
|
-
const runtime = new AgentRuntime({ model, tools: [createEchoTool()] });
|
|
1030
|
-
|
|
1031
|
-
const result = await runtime.run("Start");
|
|
1032
|
-
|
|
1033
|
-
expect(result.status).toBe("completed");
|
|
1034
|
-
expect(result.outputText).toBe("recovered");
|
|
1035
|
-
expect(
|
|
1036
|
-
result.messages.filter((message) => message.role === "tool"),
|
|
1037
|
-
).toHaveLength(1);
|
|
1038
|
-
});
|
|
1039
|
-
|
|
1040
|
-
it("merges metadata from repeated tool-call deltas", async () => {
|
|
1041
|
-
const model = new ScriptedModel([
|
|
1042
|
-
() => [
|
|
1043
|
-
{
|
|
1044
|
-
type: "tool-call-delta",
|
|
1045
|
-
toolCallId: "call_with_metadata",
|
|
1046
|
-
toolName: "echo",
|
|
1047
|
-
inputText: '{"text":"hi"}',
|
|
1048
|
-
metadata: {
|
|
1049
|
-
thoughtSignature: "sig_123",
|
|
1050
|
-
},
|
|
1051
|
-
},
|
|
1052
|
-
{
|
|
1053
|
-
type: "tool-call-delta",
|
|
1054
|
-
toolCallId: "call_with_metadata",
|
|
1055
|
-
toolName: "echo",
|
|
1056
|
-
metadata: {
|
|
1057
|
-
inputParseError: "adapter rejected tool input",
|
|
1058
|
-
},
|
|
1059
|
-
},
|
|
1060
|
-
{ type: "finish", reason: "tool-calls" },
|
|
1061
|
-
],
|
|
1062
|
-
(request) => {
|
|
1063
|
-
const assistantMessage = request.messages.find(
|
|
1064
|
-
(message) => message.role === "assistant",
|
|
1065
|
-
);
|
|
1066
|
-
const toolCall = assistantMessage?.content.find(
|
|
1067
|
-
(part) => part.type === "tool-call",
|
|
1068
|
-
);
|
|
1069
|
-
expect(toolCall).toMatchObject({
|
|
1070
|
-
type: "tool-call",
|
|
1071
|
-
metadata: {
|
|
1072
|
-
thoughtSignature: "sig_123",
|
|
1073
|
-
inputParseError: "adapter rejected tool input",
|
|
1074
|
-
},
|
|
1075
|
-
});
|
|
1076
|
-
const toolResult = request.messages.at(-1)?.content[0];
|
|
1077
|
-
expect(toolResult).toMatchObject({
|
|
1078
|
-
type: "tool-result",
|
|
1079
|
-
isError: true,
|
|
1080
|
-
output: {
|
|
1081
|
-
error: "adapter rejected tool input",
|
|
1082
|
-
},
|
|
1083
|
-
});
|
|
1084
|
-
return [
|
|
1085
|
-
{ type: "text-delta", text: "recovered" },
|
|
1086
|
-
{ type: "finish", reason: "stop" },
|
|
1087
|
-
];
|
|
1088
|
-
},
|
|
1089
|
-
]);
|
|
1090
|
-
const executeTool = vi.fn(async () => ({ echoed: "hi" }));
|
|
1091
|
-
const runtime = new AgentRuntime({
|
|
1092
|
-
model,
|
|
1093
|
-
tools: [
|
|
1094
|
-
{
|
|
1095
|
-
name: "echo",
|
|
1096
|
-
description: "Echo input text",
|
|
1097
|
-
inputSchema: { type: "object" },
|
|
1098
|
-
execute: executeTool,
|
|
1099
|
-
},
|
|
1100
|
-
],
|
|
1101
|
-
});
|
|
1102
|
-
|
|
1103
|
-
const result = await runtime.run("Start");
|
|
1104
|
-
|
|
1105
|
-
expect(result.status).toBe("completed");
|
|
1106
|
-
expect(result.outputText).toBe("recovered");
|
|
1107
|
-
expect(executeTool).not.toHaveBeenCalled();
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
it("accepts corrected full argument snapshots for the same streamed tool call", async () => {
|
|
1111
|
-
const model = new ScriptedModel([
|
|
1112
|
-
() => [
|
|
1113
|
-
{
|
|
1114
|
-
type: "tool-call-delta",
|
|
1115
|
-
toolCallId: "call_1",
|
|
1116
|
-
toolName: "echo",
|
|
1117
|
-
inputText: '{"text":"oops"}',
|
|
1118
|
-
},
|
|
1119
|
-
{
|
|
1120
|
-
type: "tool-call-delta",
|
|
1121
|
-
toolCallId: "call_1",
|
|
1122
|
-
toolName: "echo",
|
|
1123
|
-
inputText: '{"text":"fixed"}',
|
|
1124
|
-
},
|
|
1125
|
-
{ type: "finish", reason: "tool-calls" },
|
|
1126
|
-
],
|
|
1127
|
-
(request) => {
|
|
1128
|
-
const toolResult = request.messages.at(-1)?.content[0];
|
|
1129
|
-
expect(toolResult).toMatchObject({
|
|
1130
|
-
type: "tool-result",
|
|
1131
|
-
toolName: "echo",
|
|
1132
|
-
output: { echoed: "fixed" },
|
|
1133
|
-
});
|
|
1134
|
-
return [
|
|
1135
|
-
{ type: "text-delta", text: "done" },
|
|
1136
|
-
{ type: "finish", reason: "stop" },
|
|
1137
|
-
];
|
|
1138
|
-
},
|
|
1139
|
-
]);
|
|
1140
|
-
const runtime = new AgentRuntime({ model, tools: [createEchoTool()] });
|
|
1141
|
-
|
|
1142
|
-
const result = await runtime.run("Start");
|
|
1143
|
-
|
|
1144
|
-
expect(result.status).toBe("completed");
|
|
1145
|
-
expect(result.outputText).toBe("done");
|
|
1146
|
-
});
|
|
1147
|
-
|
|
1148
|
-
it("executes tools in parallel but preserves assistant order in appended messages", async () => {
|
|
1149
|
-
const executionOrder: string[] = [];
|
|
1150
|
-
const finishOrder: string[] = [];
|
|
1151
|
-
const slow: AgentTool = {
|
|
1152
|
-
name: "slow",
|
|
1153
|
-
description: "slow tool",
|
|
1154
|
-
inputSchema: { type: "object" },
|
|
1155
|
-
async execute() {
|
|
1156
|
-
executionOrder.push("slow-start");
|
|
1157
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1158
|
-
finishOrder.push("slow-finish");
|
|
1159
|
-
return { name: "slow" };
|
|
1160
|
-
},
|
|
1161
|
-
};
|
|
1162
|
-
const fast: AgentTool = {
|
|
1163
|
-
name: "fast",
|
|
1164
|
-
description: "fast tool",
|
|
1165
|
-
inputSchema: { type: "object" },
|
|
1166
|
-
async execute() {
|
|
1167
|
-
executionOrder.push("fast-start");
|
|
1168
|
-
finishOrder.push("fast-finish");
|
|
1169
|
-
return { name: "fast" };
|
|
1170
|
-
},
|
|
1171
|
-
};
|
|
1172
|
-
const model = new ScriptedModel([
|
|
1173
|
-
() => [
|
|
1174
|
-
{
|
|
1175
|
-
type: "tool-call-delta",
|
|
1176
|
-
toolCallId: "slow_call",
|
|
1177
|
-
toolName: "slow",
|
|
1178
|
-
inputText: "{}",
|
|
1179
|
-
},
|
|
1180
|
-
{
|
|
1181
|
-
type: "tool-call-delta",
|
|
1182
|
-
toolCallId: "fast_call",
|
|
1183
|
-
toolName: "fast",
|
|
1184
|
-
inputText: "{}",
|
|
1185
|
-
},
|
|
1186
|
-
{ type: "finish", reason: "tool-calls" },
|
|
1187
|
-
],
|
|
1188
|
-
() => [
|
|
1189
|
-
{ type: "text-delta", text: "done" },
|
|
1190
|
-
{ type: "finish", reason: "stop" },
|
|
1191
|
-
],
|
|
1192
|
-
]);
|
|
1193
|
-
|
|
1194
|
-
const runtime = new AgentRuntime({
|
|
1195
|
-
model,
|
|
1196
|
-
tools: [slow, fast],
|
|
1197
|
-
toolExecution: "parallel",
|
|
1198
|
-
});
|
|
1199
|
-
|
|
1200
|
-
const result = await runtime.run("Parallel");
|
|
1201
|
-
|
|
1202
|
-
expect(executionOrder).toEqual(["slow-start", "fast-start"]);
|
|
1203
|
-
expect(finishOrder).toEqual(["fast-finish", "slow-finish"]);
|
|
1204
|
-
const toolMessages = result.messages.filter(
|
|
1205
|
-
(message) => message.role === "tool",
|
|
1206
|
-
);
|
|
1207
|
-
expect(toolMessages[0]?.content[0]).toMatchObject({ toolName: "slow" });
|
|
1208
|
-
expect(toolMessages[1]?.content[0]).toMatchObject({ toolName: "fast" });
|
|
1209
|
-
});
|
|
1210
|
-
|
|
1211
|
-
it("captures events, logger calls, telemetry, and failed tool runs", async () => {
|
|
1212
|
-
const telemetry = { capture: vi.fn() };
|
|
1213
|
-
const logger = {
|
|
1214
|
-
debug: vi.fn(),
|
|
1215
|
-
log: vi.fn(),
|
|
1216
|
-
error: vi.fn(),
|
|
1217
|
-
};
|
|
1218
|
-
const events: string[] = [];
|
|
1219
|
-
const model = new ScriptedModel([
|
|
1220
|
-
() => [
|
|
1221
|
-
{
|
|
1222
|
-
type: "tool-call-delta",
|
|
1223
|
-
toolCallId: "boom_call",
|
|
1224
|
-
toolName: "boom",
|
|
1225
|
-
inputText: "{}",
|
|
1226
|
-
},
|
|
1227
|
-
{ type: "finish", reason: "tool-calls" },
|
|
1228
|
-
],
|
|
1229
|
-
() => [{ type: "finish", reason: "error", error: "model failed" }],
|
|
1230
|
-
]);
|
|
1231
|
-
const runtime = new AgentRuntime({
|
|
1232
|
-
model,
|
|
1233
|
-
logger,
|
|
1234
|
-
telemetry,
|
|
1235
|
-
tools: [
|
|
1236
|
-
{
|
|
1237
|
-
name: "boom",
|
|
1238
|
-
description: "throws",
|
|
1239
|
-
inputSchema: { type: "object" },
|
|
1240
|
-
async execute() {
|
|
1241
|
-
throw new Error("tool exploded");
|
|
1242
|
-
},
|
|
1243
|
-
},
|
|
1244
|
-
],
|
|
1245
|
-
});
|
|
1246
|
-
runtime.subscribe((event) => {
|
|
1247
|
-
events.push(event.type);
|
|
1248
|
-
});
|
|
1249
|
-
|
|
1250
|
-
const result = await runtime.run("Fail");
|
|
1251
|
-
|
|
1252
|
-
expect(result.status).toBe("failed");
|
|
1253
|
-
expect(events).toContain("run-failed");
|
|
1254
|
-
expect(logger.error).toHaveBeenCalled();
|
|
1255
|
-
expect(telemetry.capture).toHaveBeenCalled();
|
|
1256
|
-
});
|
|
1257
|
-
|
|
1258
|
-
it("propagates agent identity including role through snapshots and plugin setup", async () => {
|
|
1259
|
-
const setup = vi.fn(() => undefined);
|
|
1260
|
-
const plugin: AgentRuntimePlugin = {
|
|
1261
|
-
name: "identity",
|
|
1262
|
-
setup,
|
|
1263
|
-
};
|
|
1264
|
-
const model = new ScriptedModel([
|
|
1265
|
-
() => [
|
|
1266
|
-
{ type: "text-delta", text: "ok" },
|
|
1267
|
-
{ type: "finish", reason: "stop" },
|
|
1268
|
-
],
|
|
1269
|
-
]);
|
|
1270
|
-
const runtime = new AgentRuntime({
|
|
1271
|
-
agentId: "lead-1",
|
|
1272
|
-
agentRole: "lead",
|
|
1273
|
-
model,
|
|
1274
|
-
plugins: [plugin],
|
|
1275
|
-
});
|
|
1276
|
-
|
|
1277
|
-
const snapshots: Array<{ agentId: string; agentRole?: string }> = [];
|
|
1278
|
-
runtime.subscribe((event) => {
|
|
1279
|
-
snapshots.push({
|
|
1280
|
-
agentId: event.snapshot.agentId,
|
|
1281
|
-
agentRole: event.snapshot.agentRole,
|
|
1282
|
-
});
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
const result = await runtime.run("Identity");
|
|
1286
|
-
|
|
1287
|
-
expect(setup).toHaveBeenCalledWith({
|
|
1288
|
-
agentId: "lead-1",
|
|
1289
|
-
agentRole: "lead",
|
|
1290
|
-
systemPrompt: undefined,
|
|
1291
|
-
});
|
|
1292
|
-
expect(result.agentId).toBe("lead-1");
|
|
1293
|
-
expect(result.agentRole).toBe("lead");
|
|
1294
|
-
expect(snapshots.every((snapshot) => snapshot.agentId === "lead-1")).toBe(
|
|
1295
|
-
true,
|
|
1296
|
-
);
|
|
1297
|
-
expect(snapshots.every((snapshot) => snapshot.agentRole === "lead")).toBe(
|
|
1298
|
-
true,
|
|
1299
|
-
);
|
|
1300
|
-
});
|
|
1301
|
-
});
|