@anteros/core 0.0.40 → 0.0.42
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/database/audit.ts +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/redact.ts +117 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +451 -0
- package/server/api.ts +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/lifecycle.test.ts +96 -0
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +837 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -0
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- package/utils/func.ts +67 -52
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { define, v, Agent, agents } from "../index";
|
|
3
|
+
import { cfg } from "../server/config";
|
|
4
|
+
import { syncAgents, createAgents, agentsStats } from "../lib/agents";
|
|
5
|
+
import { InMemoryAgentMemory } from "../lib/agent";
|
|
6
|
+
import {
|
|
7
|
+
anthropicMessages,
|
|
8
|
+
normalizeFinishReason,
|
|
9
|
+
openaiMessages,
|
|
10
|
+
resolveBaseUrl,
|
|
11
|
+
sseData,
|
|
12
|
+
} from "../lib/providers";
|
|
13
|
+
import { joiToJsonSchema, toJsonSchema, validateWithSchema } from "../lib/jsonSchema";
|
|
14
|
+
import { AppError } from "../lib/error";
|
|
15
|
+
import {
|
|
16
|
+
A_USAGE,
|
|
17
|
+
USAGE,
|
|
18
|
+
anthropicStream,
|
|
19
|
+
anthropicText,
|
|
20
|
+
anthropicTool,
|
|
21
|
+
at,
|
|
22
|
+
fakeProvider,
|
|
23
|
+
openaiStream,
|
|
24
|
+
openaiText,
|
|
25
|
+
openaiTool,
|
|
26
|
+
request,
|
|
27
|
+
sse,
|
|
28
|
+
type Fake,
|
|
29
|
+
} from "./fixtures/fake-provider";
|
|
30
|
+
|
|
31
|
+
const servers: Fake[] = [];
|
|
32
|
+
function track(fake: Fake): Fake {
|
|
33
|
+
servers.push(fake);
|
|
34
|
+
return fake;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
for (const server of servers) server.stop();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** The tool used by most tests: a Joi-validated object, executed for real. */
|
|
42
|
+
function forecastTool(calls: any[] = []) {
|
|
43
|
+
return define.Tool({
|
|
44
|
+
id: "forecast",
|
|
45
|
+
description: "Current weather for a city",
|
|
46
|
+
inputSchema: v.object({ city: v.string().required() }),
|
|
47
|
+
execute: async ({ city }: any) => {
|
|
48
|
+
calls.push(city);
|
|
49
|
+
return { city, celsius: 21 };
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeAgent(
|
|
55
|
+
overrides: Record<string, any> = {},
|
|
56
|
+
provider: Record<string, any> = {},
|
|
57
|
+
options: Record<string, any> = {},
|
|
58
|
+
) {
|
|
59
|
+
return new Agent({
|
|
60
|
+
id: "test",
|
|
61
|
+
description: "A test agent.",
|
|
62
|
+
instructions: "You are a test agent.",
|
|
63
|
+
provider: {
|
|
64
|
+
model: "test-model",
|
|
65
|
+
apiKey: "test-key",
|
|
66
|
+
compatible: "openai",
|
|
67
|
+
// Deterministic by default: a test must not wait on the retry backoff
|
|
68
|
+
options: { retries: 0, ...options },
|
|
69
|
+
...provider,
|
|
70
|
+
},
|
|
71
|
+
...overrides,
|
|
72
|
+
} as any);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("agent … files", () => {
|
|
76
|
+
const attachmentAgent = (providerOverrides: Record<string, any> = {}, overrides: Record<string, any> = {}) =>
|
|
77
|
+
new Agent({
|
|
78
|
+
id: "files",
|
|
79
|
+
description: "A test agent.",
|
|
80
|
+
instructions: "You are a test agent.",
|
|
81
|
+
provider: {
|
|
82
|
+
model: "test-model",
|
|
83
|
+
apiKey: "test-key",
|
|
84
|
+
compatible: "openai",
|
|
85
|
+
options: { retries: 0 },
|
|
86
|
+
...providerOverrides,
|
|
87
|
+
},
|
|
88
|
+
...overrides,
|
|
89
|
+
} as any);
|
|
90
|
+
|
|
91
|
+
it("attaches a file to the turn being asked, as a text part", async () => {
|
|
92
|
+
const provider = track(fakeProvider(() => openaiText("got it")));
|
|
93
|
+
const agent = attachmentAgent({ baseUrl: provider.url });
|
|
94
|
+
|
|
95
|
+
const result = await agent.generate("What are the totals?", {
|
|
96
|
+
files: ["packages/core/tests/fixtures/attachment/data.csv"],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result.text).toBe("got it");
|
|
100
|
+
const user = at<any>(request(provider, 0).body.messages, 1);
|
|
101
|
+
expect(user.role).toBe("user");
|
|
102
|
+
expect(user.content[0]).toEqual({ type: "text", text: "What are the totals?" });
|
|
103
|
+
expect(user.content[1].text).toContain("--- data.csv ---");
|
|
104
|
+
expect(user.content[1].text).toContain("ref,total");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("sends an image as an image part (base64)", async () => {
|
|
108
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
109
|
+
const agent = attachmentAgent({ baseUrl: provider.url });
|
|
110
|
+
|
|
111
|
+
await agent.generate("What is on this screenshot?", {
|
|
112
|
+
files: [{ name: "shot.png", data: Buffer.from("PNG!").toString("base64") }],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const user = at<any>(request(provider, 0).body.messages, 1);
|
|
116
|
+
expect(user.content[1]).toEqual({
|
|
117
|
+
type: "image_url",
|
|
118
|
+
image_url: { url: `data:image/png;base64,${Buffer.from("PNG!").toString("base64")}` },
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps the text in the memory, and a note for the binaries", async () => {
|
|
123
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
124
|
+
const memory = new InMemoryAgentMemory();
|
|
125
|
+
const agent = attachmentAgent({ baseUrl: provider.url }, { memory });
|
|
126
|
+
|
|
127
|
+
await agent.generate("Summarize this", {
|
|
128
|
+
thread: "f1",
|
|
129
|
+
files: [
|
|
130
|
+
"packages/core/tests/fixtures/attachment/data.csv",
|
|
131
|
+
{ name: "shot.png", data: Buffer.from("PNG!").toString("base64") },
|
|
132
|
+
{ name: "invoice.pdf", data: Buffer.from("%PDF-1.7").toString("base64") },
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const stored = await agent.getMessages("f1");
|
|
137
|
+
const content = at(stored, 0).content as any;
|
|
138
|
+
|
|
139
|
+
expect(content).toContain("Summarize this");
|
|
140
|
+
// A text file is conversational: it replays with the thread
|
|
141
|
+
expect(content).toContain("--- data.csv ---");
|
|
142
|
+
expect(content).toContain("ref,total");
|
|
143
|
+
// A binary is not: the payload never reaches the store
|
|
144
|
+
expect(content).toContain("[image]");
|
|
145
|
+
expect(content).toContain("[file invoice.pdf]");
|
|
146
|
+
expect(content).not.toContain(Buffer.from("PNG!").toString("base64"));
|
|
147
|
+
expect(content).not.toContain("%PDF-1.7");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("refuses an unreadable file before calling the model", async () => {
|
|
151
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
152
|
+
const agent = attachmentAgent({ baseUrl: provider.url });
|
|
153
|
+
|
|
154
|
+
await expect(agent.generate("hi", { files: [{ name: "old.doc", data: "AAAA" }] }))
|
|
155
|
+
.rejects.toMatchObject({ code: "AGENT_FILE_UNSUPPORTED" });
|
|
156
|
+
expect(provider.hits()).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("agent … unified messages", () => {
|
|
161
|
+
it("accepts one message, or a whole conversation", async () => {
|
|
162
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
163
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
164
|
+
|
|
165
|
+
await agent.generate({ role: "user", content: "hi" });
|
|
166
|
+
expect(request(provider, 0).body.messages.map((m: any) => m.role)).toEqual(["system", "user"]);
|
|
167
|
+
expect(at<any>(request(provider, 0).body.messages, 1).content).toBe("hi");
|
|
168
|
+
|
|
169
|
+
await agent.generate([
|
|
170
|
+
{ role: "user", content: "hi" },
|
|
171
|
+
{ role: "assistant", content: "hello" },
|
|
172
|
+
{ role: "user", content: "and?" },
|
|
173
|
+
]);
|
|
174
|
+
expect(request(provider, 1).body.messages.map((m: any) => m.role))
|
|
175
|
+
.toEqual(["system", "user", "assistant", "user"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("replays a tool round written with `tool-call` / `tool-result` parts", async () => {
|
|
179
|
+
const provider = track(fakeProvider(() => openaiText("21°C and sunny")));
|
|
180
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
181
|
+
|
|
182
|
+
const result = await agent.generate([
|
|
183
|
+
{ role: "user", content: "Weather in Paris?" },
|
|
184
|
+
{
|
|
185
|
+
role: "assistant",
|
|
186
|
+
content: [{ type: "tool-call", id: "call_1", name: "forecast", arguments: { city: "Paris" } }],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
role: "user",
|
|
190
|
+
content: [{ type: "tool-result", id: "call_1", name: "forecast", result: { celsius: 21 } }],
|
|
191
|
+
},
|
|
192
|
+
{ role: "user", content: [{ type: "text", text: "and in Lyon?" }] },
|
|
193
|
+
] as any);
|
|
194
|
+
|
|
195
|
+
// The parts become the turns they mean — the provider sees a normal tool round
|
|
196
|
+
const messages = request(provider, 0).body.messages;
|
|
197
|
+
expect(messages.map((m: any) => m.role)).toEqual(["system", "user", "assistant", "tool", "user"]);
|
|
198
|
+
expect(messages[2].tool_calls[0]).toEqual({
|
|
199
|
+
id: "call_1",
|
|
200
|
+
type: "function",
|
|
201
|
+
function: { name: "forecast", arguments: '{"city":"Paris"}' },
|
|
202
|
+
});
|
|
203
|
+
expect(messages[3]).toEqual({ role: "tool", tool_call_id: "call_1", content: '{"celsius":21}' });
|
|
204
|
+
expect(messages[4].content).toEqual([{ type: "text", text: "and in Lyon?" }]);
|
|
205
|
+
|
|
206
|
+
expect(result.text).toBe("21°C and sunny");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("refuses a conversation the model could not have seen", async () => {
|
|
210
|
+
const agent = makeAgent({}, { baseUrl: "http://127.0.0.1:1" });
|
|
211
|
+
|
|
212
|
+
const error: any = await agent.generate([
|
|
213
|
+
{ role: "assistant", content: [{ type: "image", url: "https://example.com/a.png" }] },
|
|
214
|
+
] as any).catch((err) => err);
|
|
215
|
+
|
|
216
|
+
expect(error).toBeInstanceOf(Error);
|
|
217
|
+
expect(error.code).toBe("INVALID_INPUT");
|
|
218
|
+
expect(error.status).toBe(400);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("agent … tools & thinking options", () => {
|
|
223
|
+
const toolAgent = (providerOverrides: Record<string, any> = {}, overrides: Record<string, any> = {}) =>
|
|
224
|
+
new Agent({
|
|
225
|
+
id: "opts",
|
|
226
|
+
description: "A test agent.",
|
|
227
|
+
instructions: "You are a test agent.",
|
|
228
|
+
provider: {
|
|
229
|
+
model: "test-model",
|
|
230
|
+
apiKey: "test-key",
|
|
231
|
+
compatible: "openai",
|
|
232
|
+
options: { retries: 0 },
|
|
233
|
+
...providerOverrides,
|
|
234
|
+
},
|
|
235
|
+
tools: {
|
|
236
|
+
forecast: forecastTool(),
|
|
237
|
+
echo: { id: "echo", description: "echo", execute: () => "ok" },
|
|
238
|
+
},
|
|
239
|
+
...overrides,
|
|
240
|
+
} as any);
|
|
241
|
+
|
|
242
|
+
it("sends no tool at all with `tools: false`", async () => {
|
|
243
|
+
const provider = track(fakeProvider(() => openaiText("direct")));
|
|
244
|
+
const agent = toolAgent({ baseUrl: provider.url });
|
|
245
|
+
|
|
246
|
+
const result = await agent.generate("hi", { tools: false });
|
|
247
|
+
|
|
248
|
+
expect(result.text).toBe("direct");
|
|
249
|
+
expect(request(provider, 0).body.tools).toBeUndefined();
|
|
250
|
+
expect(request(provider, 0).body.tool_choice).toBeUndefined();
|
|
251
|
+
// Nothing was offered, and the step says so
|
|
252
|
+
expect(at(result.steps, 0).tools).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("restricts the run to a list of tool names", async () => {
|
|
256
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
257
|
+
const agent = toolAgent({ baseUrl: provider.url });
|
|
258
|
+
|
|
259
|
+
const result = await agent.generate("hi", { tools: ["echo", "not-declared"] });
|
|
260
|
+
|
|
261
|
+
const names = request(provider, 0).body.tools.map((tool: any) => tool.function.name);
|
|
262
|
+
expect(names).toEqual(["echo"]); // an unknown name is ignored, not fatal
|
|
263
|
+
|
|
264
|
+
// What was sent is on the step — the answer to "why didn't it call X?"
|
|
265
|
+
expect(at(result.steps, 0).tools).toEqual(["echo"]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("`tools: true` sends everything the agent declares, and a record merges in", async () => {
|
|
269
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
270
|
+
const agent = toolAgent({ baseUrl: provider.url });
|
|
271
|
+
|
|
272
|
+
await agent.generate("hi", { tools: true });
|
|
273
|
+
expect(request(provider, 0).body.tools.map((tool: any) => tool.function.name)).toEqual(["forecast", "echo"]);
|
|
274
|
+
|
|
275
|
+
await agent.generate("hi", { tools: { extra: { id: "extra", description: "extra", execute: () => "x" } } });
|
|
276
|
+
expect(request(provider, 1).body.tools.map((tool: any) => tool.function.name))
|
|
277
|
+
.toEqual(["forecast", "echo", "extra"]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("forwards `toolChoice` to the provider", async () => {
|
|
281
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
282
|
+
const agent = toolAgent({ baseUrl: provider.url });
|
|
283
|
+
|
|
284
|
+
await agent.generate("hi", { toolChoice: "required" });
|
|
285
|
+
expect(request(provider, 0).body.tool_choice).toBe("required");
|
|
286
|
+
|
|
287
|
+
await agent.generate("hi", { toolChoice: "none" });
|
|
288
|
+
expect(request(provider, 1).body.tool_choice).toBe("none");
|
|
289
|
+
|
|
290
|
+
await agent.generate("hi", { toolChoice: { name: "forecast" } });
|
|
291
|
+
expect(request(provider, 2).body.tool_choice).toEqual({ type: "function", function: { name: "forecast" } });
|
|
292
|
+
|
|
293
|
+
// `'auto'` is the default — it reaches OpenAI as `'auto'`, and is omitted on Anthropic
|
|
294
|
+
await agent.generate("hi", { toolChoice: "auto" });
|
|
295
|
+
expect(request(provider, 3).body.tool_choice).toBe("auto");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("lets a call release a tool choice declared on the agent", async () => {
|
|
299
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
300
|
+
const agent = toolAgent({ baseUrl: provider.url }, { toolChoice: { name: "forecast" } });
|
|
301
|
+
|
|
302
|
+
await agent.generate("hi");
|
|
303
|
+
expect(request(provider, 0).body.tool_choice).toEqual({ type: "function", function: { name: "forecast" } });
|
|
304
|
+
|
|
305
|
+
// The declaration is a default: `'auto'` gives the choice back to the model
|
|
306
|
+
await agent.generate("hi", { toolChoice: "auto" });
|
|
307
|
+
expect(request(provider, 1).body.tool_choice).toBe("auto");
|
|
308
|
+
|
|
309
|
+
const anthropic = track(fakeProvider(() => anthropicText("ok")));
|
|
310
|
+
const forced = toolAgent({ compatible: "anthropic", baseUrl: anthropic.url }, { toolChoice: { name: "forecast" } });
|
|
311
|
+
await forced.generate("hi");
|
|
312
|
+
expect(request(anthropic, 0).body.tool_choice).toEqual({ type: "tool", name: "forecast" });
|
|
313
|
+
await forced.generate("hi", { toolChoice: "auto" });
|
|
314
|
+
expect(request(anthropic, 1).body.tool_choice).toBeUndefined();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("maps `thinking` to a reasoning effort on OpenAI-compatible endpoints", async () => {
|
|
318
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
319
|
+
const agent = toolAgent({ baseUrl: provider.url });
|
|
320
|
+
|
|
321
|
+
await agent.generate("hi");
|
|
322
|
+
expect(request(provider, 0).body.reasoning_effort).toBeUndefined();
|
|
323
|
+
|
|
324
|
+
await agent.generate("hi", { thinking: "high" });
|
|
325
|
+
expect(request(provider, 1).body.reasoning_effort).toBe("high");
|
|
326
|
+
|
|
327
|
+
await agent.generate("hi", { thinking: { budgetTokens: 2048 } });
|
|
328
|
+
expect(request(provider, 2).body.reasoning_effort).toBe("low"); // a budget, rounded to a level
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("takes `thinking` from the declaration, and lets a call override or switch it off", async () => {
|
|
332
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
333
|
+
const agent = toolAgent({ baseUrl: provider.url }, { thinking: "high" });
|
|
334
|
+
|
|
335
|
+
// Declared once — no per-call option needed
|
|
336
|
+
await agent.generate("hi");
|
|
337
|
+
expect(request(provider, 0).body.reasoning_effort).toBe("high");
|
|
338
|
+
|
|
339
|
+
await agent.generate("hi", { thinking: "low" });
|
|
340
|
+
expect(request(provider, 1).body.reasoning_effort).toBe("low");
|
|
341
|
+
|
|
342
|
+
// `false` is a value, not an absence: it turns the declaration off for one run
|
|
343
|
+
await agent.generate("hi", { thinking: false });
|
|
344
|
+
expect(request(provider, 2).body.reasoning_effort).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("maps `thinking` to a real budget on Anthropic, and raises max_tokens above it", async () => {
|
|
348
|
+
const provider = track(fakeProvider(() => anthropicText("ok")));
|
|
349
|
+
const agent = toolAgent(
|
|
350
|
+
{ compatible: "anthropic", baseUrl: provider.url, options: { retries: 0, maxTokens: 2000, temperature: 0.5 } },
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await agent.generate("hi", { thinking: true });
|
|
354
|
+
|
|
355
|
+
const body = request(provider, 0).body;
|
|
356
|
+
expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 4096 });
|
|
357
|
+
expect(body.max_tokens).toBe(4096 + 1024); // raised above the budget
|
|
358
|
+
expect(body.temperature).toBeUndefined(); // thinking requires the default
|
|
359
|
+
|
|
360
|
+
await agent.generate("hi", { thinking: { budgetTokens: 1_500 } });
|
|
361
|
+
expect(request(provider, 1).body.thinking).toEqual({ type: "enabled", budget_tokens: 1_500 });
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("refuses a thinking budget that makes no sense", async () => {
|
|
365
|
+
const agent = toolAgent();
|
|
366
|
+
await expect(agent.generate("hi", { thinking: { budgetTokens: 12 } }))
|
|
367
|
+
.rejects.toMatchObject({ code: "AGENT_THINKING_INVALID" });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("surfaces the reasoning without mixing it into the answer", async () => {
|
|
371
|
+
const provider = track(fakeProvider(() => Response.json({
|
|
372
|
+
content: [
|
|
373
|
+
{ type: "thinking", thinking: "Let me check the tool list.", signature: "sig-1" },
|
|
374
|
+
{ type: "text", text: "I need the forecast." },
|
|
375
|
+
{ type: "tool_use", id: "toolu_1", name: "forecast", input: { city: "Paris" } },
|
|
376
|
+
],
|
|
377
|
+
stop_reason: "tool_use",
|
|
378
|
+
usage: A_USAGE,
|
|
379
|
+
})));
|
|
380
|
+
const agent = toolAgent({ compatible: "anthropic", baseUrl: provider.url });
|
|
381
|
+
|
|
382
|
+
await agent.generate("Weather?", { thinking: 'medium' });
|
|
383
|
+
|
|
384
|
+
// The tool round echoes the **signed** reasoning block first
|
|
385
|
+
const second = request(provider, 1).body.messages;
|
|
386
|
+
const assistant = second[second.length - 2];
|
|
387
|
+
expect(assistant.role).toBe("assistant");
|
|
388
|
+
expect(assistant.content[0]).toEqual({ type: "thinking", thinking: "Let me check the tool list.", signature: "sig-1" });
|
|
389
|
+
expect(assistant.content[1]).toEqual({ type: "text", text: "I need the forecast." });
|
|
390
|
+
expect(assistant.content[2].type).toBe("tool_use");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("exposes the reasoning on the result and the step", async () => {
|
|
394
|
+
const provider = track(fakeProvider((_body, _req, hits) => hits === 1
|
|
395
|
+
? Response.json({
|
|
396
|
+
content: [
|
|
397
|
+
{ type: "thinking", thinking: "Step one: think. ", signature: "sig-a" },
|
|
398
|
+
{ type: "text", text: "Checking." },
|
|
399
|
+
{ type: "tool_use", id: "toolu_1", name: "echo", input: {} },
|
|
400
|
+
],
|
|
401
|
+
stop_reason: "tool_use",
|
|
402
|
+
usage: A_USAGE,
|
|
403
|
+
})
|
|
404
|
+
: Response.json({
|
|
405
|
+
content: [
|
|
406
|
+
{ type: "thinking", thinking: "Step two: answer.", signature: "sig-b" },
|
|
407
|
+
{ type: "text", text: "Done." },
|
|
408
|
+
],
|
|
409
|
+
stop_reason: "end_turn",
|
|
410
|
+
usage: A_USAGE,
|
|
411
|
+
})));
|
|
412
|
+
const agent = toolAgent({ compatible: "anthropic", baseUrl: provider.url });
|
|
413
|
+
|
|
414
|
+
const result = await agent.generate("go", { thinking: true });
|
|
415
|
+
|
|
416
|
+
expect(result.text).toBe("Done."); // never the reasoning
|
|
417
|
+
expect(result.reasoning).toBe("Step one: think. \n\nStep two: answer.");
|
|
418
|
+
expect(result.steps[0]!.reasoning).toBe("Step one: think. ");
|
|
419
|
+
expect(result.steps[1]!.reasoning).toBe("Step two: answer.");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("streams the reasoning on its own channel, signatures included", async () => {
|
|
423
|
+
const provider = track(fakeProvider(() => sse([
|
|
424
|
+
JSON.stringify({ type: "message_start", message: { usage: { input_tokens: 5, output_tokens: 0 } } }),
|
|
425
|
+
JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }),
|
|
426
|
+
JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Thinking… " } }),
|
|
427
|
+
JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig-stream" } }),
|
|
428
|
+
JSON.stringify({ type: "content_block_stop", index: 0 }),
|
|
429
|
+
JSON.stringify({ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }),
|
|
430
|
+
JSON.stringify({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Answer" } }),
|
|
431
|
+
JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } }),
|
|
432
|
+
])));
|
|
433
|
+
const agent = toolAgent({ compatible: "anthropic", baseUrl: provider.url });
|
|
434
|
+
|
|
435
|
+
const stream = agent.stream("hi", { thinking: true });
|
|
436
|
+
const kinds: string[] = [];
|
|
437
|
+
for await (const chunk of stream.fullStream) kinds.push(chunk.type);
|
|
438
|
+
|
|
439
|
+
expect(kinds).toContain("reasoning");
|
|
440
|
+
expect(await stream.text).toBe("Answer"); // the reasoning is not text
|
|
441
|
+
expect(await stream.reasoning).toBe("Thinking… "); // …and it is on the result too
|
|
442
|
+
const steps = await stream.steps;
|
|
443
|
+
expect(steps[0]!.reasoning).toBe("Thinking… ");
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// ─── Pure conversions ────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
describe("providers — conversion", () => {
|
|
450
|
+
it("normalizes finish reasons across both protocols", () => {
|
|
451
|
+
expect(normalizeFinishReason("tool_calls")).toBe("tool_use");
|
|
452
|
+
expect(normalizeFinishReason("end_turn")).toBe("stop");
|
|
453
|
+
expect(normalizeFinishReason("max_tokens")).toBe("length");
|
|
454
|
+
expect(normalizeFinishReason(null)).toBe("stop");
|
|
455
|
+
expect(normalizeFinishReason("something_new")).toBe("something_new");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("normalizes the base URL — a bare host gets /v1 on OpenAI-compatible", () => {
|
|
459
|
+
expect(resolveBaseUrl({ model: "m" }, "openai")).toBe("https://api.openai.com/v1");
|
|
460
|
+
expect(resolveBaseUrl({ model: "m", baseUrl: "http://localhost:11434" }, "openai")).toBe("http://localhost:11434/v1");
|
|
461
|
+
expect(resolveBaseUrl({ model: "m", baseUrl: "https://api.groq.com/openai/" }, "openai")).toBe("https://api.groq.com/openai");
|
|
462
|
+
expect(resolveBaseUrl({ model: "m", baseUrl: "https://api.anthropic.com/" }, "anthropic")).toBe("https://api.anthropic.com");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("converts messages to the OpenAI shape (tool calls echoed, images inlined)", () => {
|
|
466
|
+
const messages = openaiMessages("sys", [
|
|
467
|
+
{ role: "user", content: "hi" },
|
|
468
|
+
{ role: "assistant", content: null, toolCalls: [{ id: "c1", name: "f", args: { a: 1 }, argsText: '{"a":1}' }] },
|
|
469
|
+
{ role: "tool", toolCallId: "c1", name: "f", content: "42" },
|
|
470
|
+
{ role: "user", content: [{ type: "image", data: "AAA", mimeType: "image/jpeg" }] },
|
|
471
|
+
]);
|
|
472
|
+
|
|
473
|
+
expect(messages[0]).toEqual({ role: "system", content: "sys" });
|
|
474
|
+
expect(messages[2].tool_calls[0]).toEqual({
|
|
475
|
+
id: "c1", type: "function", function: { name: "f", arguments: '{"a":1}' },
|
|
476
|
+
});
|
|
477
|
+
expect(messages[3]).toEqual({ role: "tool", tool_call_id: "c1", content: "42" });
|
|
478
|
+
expect(messages[4].content[0].image_url.url).toBe("data:image/jpeg;base64,AAA");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("converts messages to the Anthropic shape (tool results merged into one user turn)", () => {
|
|
482
|
+
const { system, messages } = anthropicMessages([
|
|
483
|
+
{ role: "system", content: "inline system" },
|
|
484
|
+
{ role: "user", content: "hi" },
|
|
485
|
+
{ role: "assistant", content: "let me check", toolCalls: [{ id: "t1", name: "f", args: { a: 1 } }] },
|
|
486
|
+
{ role: "tool", toolCallId: "t1", name: "f", content: "42" },
|
|
487
|
+
{ role: "tool", toolCallId: "t2", name: "g", content: "43", isError: true },
|
|
488
|
+
]);
|
|
489
|
+
|
|
490
|
+
expect(system).toBe("inline system");
|
|
491
|
+
expect(messages[1].content[0]).toEqual({ type: "text", text: "let me check" });
|
|
492
|
+
expect(messages[1].content[1]).toEqual({ type: "tool_use", id: "t1", name: "f", input: { a: 1 } });
|
|
493
|
+
// Both results land in a single `user` message
|
|
494
|
+
expect(messages[2].role).toBe("user");
|
|
495
|
+
expect(messages[2].content).toHaveLength(2);
|
|
496
|
+
expect(messages[2].content[0]).toEqual({ type: "tool_result", tool_use_id: "t1", content: "42" });
|
|
497
|
+
expect(messages[2].content[1].is_error).toBe(true);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("hands a URL image to both protocols (the endpoint fetches it)", () => {
|
|
501
|
+
const parts = [{ type: "image" as const, url: "https://example.com/a.png" }];
|
|
502
|
+
|
|
503
|
+
const openai = openaiMessages(undefined, [{ role: "user", content: parts }]);
|
|
504
|
+
expect(openai[0].content[0]).toEqual({ type: "image_url", image_url: { url: "https://example.com/a.png" } });
|
|
505
|
+
|
|
506
|
+
const anthropic = anthropicMessages([{ role: "user", content: parts }]);
|
|
507
|
+
expect(anthropic.messages[0].content[0]).toEqual({
|
|
508
|
+
type: "image", source: { type: "url", url: "https://example.com/a.png" },
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("maps `developer` per protocol — its own role on OpenAI, the system prompt on Anthropic", () => {
|
|
513
|
+
const messages: any[] = [
|
|
514
|
+
{ role: "developer", content: "answer in one line" },
|
|
515
|
+
{ role: "user", content: "hi" },
|
|
516
|
+
];
|
|
517
|
+
|
|
518
|
+
expect(openaiMessages("sys", messages)[1]).toEqual({ role: "developer", content: "answer in one line" });
|
|
519
|
+
// Anthropic has no such role: it joins the system prompt, after the instructions
|
|
520
|
+
expect(anthropicMessages(messages).system).toBe("answer in one line");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("forwards a message `name` to OpenAI, and drops it on Anthropic", () => {
|
|
524
|
+
const messages: any[] = [{ role: "user", content: "hi", name: "ada" }];
|
|
525
|
+
|
|
526
|
+
expect(openaiMessages(undefined, messages)[0]).toEqual({ role: "user", content: "hi", name: "ada" });
|
|
527
|
+
expect(anthropicMessages(messages).messages[0].name).toBeUndefined();
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("reads SSE data payloads (CRLF and multi-line included)", async () => {
|
|
531
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
532
|
+
start(controller) {
|
|
533
|
+
const encoder = new TextEncoder();
|
|
534
|
+
controller.enqueue(encoder.encode("data: one\r\n\r\n"));
|
|
535
|
+
controller.enqueue(encoder.encode("data: two\n\n: comment\ndata: three\n\n"));
|
|
536
|
+
controller.close();
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
const seen: string[] = [];
|
|
540
|
+
for await (const data of sseData(stream)) seen.push(data);
|
|
541
|
+
expect(seen).toEqual(["one", "two", "three"]);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
describe("jsonSchema", () => {
|
|
546
|
+
it("converts a Joi object to JSON Schema, required fields included", () => {
|
|
547
|
+
const schema = joiToJsonSchema(v.object({
|
|
548
|
+
city: v.string().required().description("City name"),
|
|
549
|
+
days: v.number().integer().default(3),
|
|
550
|
+
tags: v.array().items(v.string()),
|
|
551
|
+
}));
|
|
552
|
+
expect(schema.type).toBe("object");
|
|
553
|
+
expect(schema.properties.city).toEqual({ type: "string", description: "City name" });
|
|
554
|
+
expect(schema.properties.days.type).toBe("number");
|
|
555
|
+
expect(schema.properties.tags).toEqual({ type: "array", items: { type: "string" } });
|
|
556
|
+
expect(schema.required).toEqual(["city"]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("validates with Joi and reports the offending path", async () => {
|
|
560
|
+
const schema = v.object({ city: v.string().required() });
|
|
561
|
+
expect(await validateWithSchema(schema, { city: "Paris" })).toEqual({ value: { city: "Paris" }, error: null });
|
|
562
|
+
const invalid = await validateWithSchema(schema, { city: 12 });
|
|
563
|
+
expect(invalid.error).toContain("city");
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("passes a plain JSON Schema through and keeps an open object by default", async () => {
|
|
567
|
+
const raw = { type: "object", properties: { a: { type: "string" } } };
|
|
568
|
+
expect(await toJsonSchema(raw)).toBe(raw);
|
|
569
|
+
expect(await toJsonSchema(undefined)).toEqual({ type: "object", properties: {}, additionalProperties: true });
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// ─── Agent — identity, tools, provider ───────────────────────────────────
|
|
574
|
+
|
|
575
|
+
describe("Agent", () => {
|
|
576
|
+
it("refuses a definition without instructions or provider.model", () => {
|
|
577
|
+
expect(() => new Agent({ provider: { model: "m" } } as any)).toThrow(/instructions/);
|
|
578
|
+
expect(() => new Agent({ id: "x", instructions: "x" } as any)).toThrow(/description/);
|
|
579
|
+
expect(() => new Agent({ description: "x", instructions: "x" } as any)).toThrow(/requires an `id`/);
|
|
580
|
+
expect(() => new Agent({ id: "x", description: "x", instructions: "x" } as any)).toThrow(/provider\.model/);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("marks definitions through define.Agent / define.Tool", () => {
|
|
584
|
+
const definition = define.Agent({ id: "x", description: "x", instructions: "x", provider: { model: "m" } });
|
|
585
|
+
expect(definition._isAgent_).toBe(true);
|
|
586
|
+
expect(definition.enabled).toBe(true);
|
|
587
|
+
|
|
588
|
+
const tool = define.Tool({ id: "t", execute: () => 1 });
|
|
589
|
+
expect(tool._isTool_).toBe(true);
|
|
590
|
+
expect(tool.enabled).toBe(true);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("registers tools by id, and refuses a definition without one", () => {
|
|
594
|
+
const agent = makeAgent({ tools: { forecast: forecastTool() } });
|
|
595
|
+
expect(agent.hasTool("forecast")).toBe(true);
|
|
596
|
+
expect(Object.keys(agent.getTools())).toEqual(["forecast"]);
|
|
597
|
+
|
|
598
|
+
// `define.Tool` requires an id at the type level — a raw object is caught here
|
|
599
|
+
expect(() => makeAgent({ tools: { broken: { description: "no id", execute: () => 1 } } }))
|
|
600
|
+
.toThrow(/requires an `id`/);
|
|
601
|
+
|
|
602
|
+
// The record key is a label: it must agree with the id the model calls
|
|
603
|
+
expect(() => makeAgent({ tools: { weather: forecastTool() } }))
|
|
604
|
+
.toThrow(/key must match the id/);
|
|
605
|
+
|
|
606
|
+
expect(() => agent.addTool({ id: "broken" } as any)).toThrow(/execute/);
|
|
607
|
+
expect(agent.removeTool("forecast")).toBe(true);
|
|
608
|
+
expect(agent.hasTool("forecast")).toBe(false);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("never exposes the API key in getProvider() / toJSON()", () => {
|
|
612
|
+
const agent = makeAgent({ tools: { forecast: forecastTool() } }, { apiKey: "super-secret" });
|
|
613
|
+
expect(agent.getProvider().apiKey).toBeUndefined();
|
|
614
|
+
const json = JSON.stringify(agent.toJSON());
|
|
615
|
+
expect(json).not.toContain("super-secret");
|
|
616
|
+
expect(json).toContain("test-model");
|
|
617
|
+
expect(agent.toJSON().tools).toEqual(["forecast"]);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("keeps the provider's options under `provider.options`", () => {
|
|
621
|
+
const agent = makeAgent({}, {}, { temperature: 0.2, maxTokens: 1000 });
|
|
622
|
+
const declared = {
|
|
623
|
+
model: "test-model",
|
|
624
|
+
compatible: "openai" as const,
|
|
625
|
+
options: { retries: 0, temperature: 0.2, maxTokens: 1000 },
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
expect(agent.getProvider()).toEqual(declared);
|
|
629
|
+
expect(agent.toJSON().provider.options).toEqual(declared.options);
|
|
630
|
+
expect(agent.setOptions({ topP: 0.5 }).getOptions().topP).toBe(0.5);
|
|
631
|
+
expect(agent.setProvider({ model: "other" }).getOptions()).toEqual({ retries: 0, temperature: 0.2, maxTokens: 1000, topP: 0.5 });
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("resolves dynamic instructions per call", async () => {
|
|
635
|
+
const agent = makeAgent({ instructions: ({ resource }: any) => `Hello ${resource}` });
|
|
636
|
+
expect(await agent.resolveInstructions({ resource: "acme" })).toBe("Hello acme");
|
|
637
|
+
// The deprecated alias resolves to the same value
|
|
638
|
+
expect(await agent.resolveInstructions({ resourceId: "acme" })).toBe("Hello acme");
|
|
639
|
+
expect(agent.getInstructions()).toBeInstanceOf(Function);
|
|
640
|
+
expect(agent.setInstructions("static").getInstructions()).toBe("static");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("exposes the in-memory memory through the public helper", async () => {
|
|
644
|
+
const memory = new InMemoryAgentMemory();
|
|
645
|
+
const agent = makeAgent({ memory });
|
|
646
|
+
expect(agent.getMemory()).toBe(memory);
|
|
647
|
+
await memory.save("t", [{ role: "user", content: "hi" }]);
|
|
648
|
+
expect(await agent.getMessages("t")).toHaveLength(1);
|
|
649
|
+
await agent.clearMessages("t");
|
|
650
|
+
expect(await agent.getMessages("t")).toEqual([]);
|
|
651
|
+
expect(agents.memory.InMemory).toBe(InMemoryAgentMemory);
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// ─── generate() ──────────────────────────────────────────────────────────
|
|
656
|
+
|
|
657
|
+
describe("agent.generate", () => {
|
|
658
|
+
it("sends the instructions as a system message and returns text + usage", async () => {
|
|
659
|
+
const provider = track(fakeProvider(() => openaiText("Hello world")));
|
|
660
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
661
|
+
|
|
662
|
+
const result = await agent.generate("hi");
|
|
663
|
+
|
|
664
|
+
expect(result.text).toBe("Hello world");
|
|
665
|
+
expect(result.finishReason).toBe("stop");
|
|
666
|
+
expect(result.usage).toEqual({ inputTokens: 11, outputTokens: 4, totalTokens: 15, requests: 1 });
|
|
667
|
+
expect(result.steps).toHaveLength(1);
|
|
668
|
+
// A bare host gets `/v1` appended on the OpenAI-compatible protocol
|
|
669
|
+
expect(request(provider, 0).path).toBe("/v1/chat/completions");
|
|
670
|
+
expect(request(provider, 0).headers.authorization).toBe("Bearer test-key");
|
|
671
|
+
expect(request(provider, 0).body.messages).toEqual([
|
|
672
|
+
{ role: "system", content: "You are a test agent." },
|
|
673
|
+
{ role: "user", content: "hi" },
|
|
674
|
+
]);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("sends the generation options, and lets a call override them", async () => {
|
|
678
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
679
|
+
const agent = makeAgent({}, { baseUrl: provider.url }, { temperature: 0.3, maxTokens: 100, topP: 0.9 });
|
|
680
|
+
|
|
681
|
+
await agent.generate("hi");
|
|
682
|
+
expect(request(provider, 0).body.temperature).toBe(0.3);
|
|
683
|
+
expect(request(provider, 0).body.max_tokens).toBe(100);
|
|
684
|
+
expect(request(provider, 0).body.top_p).toBe(0.9);
|
|
685
|
+
|
|
686
|
+
await agent.generate("hi", { provider: { options: { temperature: 0, maxTokens: 500 } } });
|
|
687
|
+
expect(request(provider, 1).body.temperature).toBe(0);
|
|
688
|
+
expect(request(provider, 1).body.max_tokens).toBe(500);
|
|
689
|
+
expect(request(provider, 1).body.top_p).toBe(0.9); // untouched by the call
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("enforces the configured timeout", async () => {
|
|
693
|
+
const provider = track(fakeProvider(async () => {
|
|
694
|
+
await Bun.sleep(150);
|
|
695
|
+
return openaiText("too late");
|
|
696
|
+
}));
|
|
697
|
+
const agent = makeAgent({}, { baseUrl: provider.url }, { timeout: 40 });
|
|
698
|
+
|
|
699
|
+
await expect(agent.generate("hi")).rejects.toMatchObject({ code: "AGENT_PROVIDER_TIMEOUT" });
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it("runs the tool loop and feeds the result back to the model", async () => {
|
|
703
|
+
const cities: any[] = [];
|
|
704
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
705
|
+
hits === 1 ? openaiTool("forecast", { city: "Paris" }) : openaiText("Paris is 21°C.")));
|
|
706
|
+
|
|
707
|
+
const agent = makeAgent({ tools: { forecast: forecastTool(cities) } }, { baseUrl: provider.url });
|
|
708
|
+
const result = await agent.generate("Weather?");
|
|
709
|
+
|
|
710
|
+
expect(cities).toEqual(["Paris"]);
|
|
711
|
+
expect(result.text).toBe("Paris is 21°C.");
|
|
712
|
+
expect(result.finishReason).toBe("stop");
|
|
713
|
+
expect(result.toolCalls).toEqual([{ id: "call_1", name: "forecast", args: { city: "Paris" }, argsText: '{"city":"Paris"}' }]);
|
|
714
|
+
expect(at(result.toolResults, 0).result).toEqual({ city: "Paris", celsius: 21 });
|
|
715
|
+
expect(at(result.toolResults, 0).error).toBeUndefined();
|
|
716
|
+
expect(result.steps).toHaveLength(2);
|
|
717
|
+
expect(result.usage.requests).toBe(2);
|
|
718
|
+
|
|
719
|
+
// The tool was advertised in JSON Schema…
|
|
720
|
+
const tools = request(provider, 0).body.tools;
|
|
721
|
+
expect(tools[0].function.name).toBe("forecast");
|
|
722
|
+
expect(tools[0].function.description).toBe("Current weather for a city");
|
|
723
|
+
expect(tools[0].function.parameters.properties.city.type).toBe("string");
|
|
724
|
+
expect(tools[0].function.parameters.required).toEqual(["city"]);
|
|
725
|
+
|
|
726
|
+
// …and the second call carries the assistant turn + the tool result
|
|
727
|
+
const second = request(provider, 1).body.messages;
|
|
728
|
+
expect(second).toHaveLength(4);
|
|
729
|
+
expect(second[2].role).toBe("assistant");
|
|
730
|
+
expect(second[2].tool_calls[0].id).toBe("call_1");
|
|
731
|
+
expect(second[3]).toEqual({ role: "tool", tool_call_id: "call_1", content: '{"city":"Paris","celsius":21}' });
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("reports invalid tool arguments instead of running the tool", async () => {
|
|
735
|
+
const cities: any[] = [];
|
|
736
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
737
|
+
hits === 1 ? openaiTool("forecast", { city: 42 }) : openaiText("sorry")));
|
|
738
|
+
|
|
739
|
+
const agent = makeAgent({ tools: { forecast: forecastTool(cities) } }, { baseUrl: provider.url });
|
|
740
|
+
const result = await agent.generate("Weather?");
|
|
741
|
+
|
|
742
|
+
expect(cities).toEqual([]);
|
|
743
|
+
expect(at(result.toolResults, 0).error).toContain("Invalid arguments for tool 'forecast'");
|
|
744
|
+
expect(request(provider, 1).body.messages[3].content).toContain("Error:");
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it("reports an unknown tool and a throwing tool, and keeps going", async () => {
|
|
748
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
749
|
+
hits === 1
|
|
750
|
+
? openaiTool("nope", {})
|
|
751
|
+
: hits === 2
|
|
752
|
+
? openaiTool("boom", {}, "call_2")
|
|
753
|
+
: openaiText("done")));
|
|
754
|
+
|
|
755
|
+
const agent = makeAgent({
|
|
756
|
+
tools: {
|
|
757
|
+
boom: define.Tool({ id: "boom", execute: () => { throw new Error("kaboom"); } }),
|
|
758
|
+
},
|
|
759
|
+
}, { baseUrl: provider.url });
|
|
760
|
+
|
|
761
|
+
const result = await agent.generate("go");
|
|
762
|
+
|
|
763
|
+
expect(result.text).toBe("done");
|
|
764
|
+
expect(at(result.toolResults, 0).error).toBe("Unknown tool 'nope'");
|
|
765
|
+
expect(at(result.toolResults, 1).error).toBe("kaboom");
|
|
766
|
+
expect(result.usage.requests).toBe(3);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("flattens an MCP tool result to its text", async () => {
|
|
770
|
+
const mcpTool = define.McpTool({
|
|
771
|
+
name: "echo",
|
|
772
|
+
description: "Echoes the message",
|
|
773
|
+
inputSchema: v.object({ msg: v.string().required() }),
|
|
774
|
+
exec: async ({ args }: any) => ({ content: [{ type: "text", text: `echoed:${args.msg}` }] }),
|
|
775
|
+
});
|
|
776
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
777
|
+
hits === 1 ? openaiTool("echo", { msg: "hi" }) : openaiText("ok")));
|
|
778
|
+
|
|
779
|
+
const agent = makeAgent({ tools: { echo: mcpTool } }, { baseUrl: provider.url });
|
|
780
|
+
const result = await agent.generate("ping");
|
|
781
|
+
|
|
782
|
+
expect(result.text).toBe("ok");
|
|
783
|
+
expect(request(provider, 1).body.messages[3].content).toBe("echoed:hi");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("stops at maxSteps and says so", async () => {
|
|
787
|
+
const provider = track(fakeProvider(() => openaiTool("forecast", { city: "Paris" })));
|
|
788
|
+
const agent = makeAgent({ tools: { forecast: forecastTool() }, maxSteps: 3 }, { baseUrl: provider.url });
|
|
789
|
+
|
|
790
|
+
const result = await agent.generate("Weather?");
|
|
791
|
+
|
|
792
|
+
expect(provider.hits()).toBe(3);
|
|
793
|
+
expect(result.finishReason).toBe("max_steps");
|
|
794
|
+
expect(result.steps).toHaveLength(3);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("returns a partial result when the caller aborts before the first call", async () => {
|
|
798
|
+
const provider = track(fakeProvider(() => openaiText("never")));
|
|
799
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
800
|
+
|
|
801
|
+
const result = await agent.generate("hi", { signal: AbortSignal.abort() });
|
|
802
|
+
|
|
803
|
+
expect(result.finishReason).toBe("aborted");
|
|
804
|
+
expect(result.text).toBe("");
|
|
805
|
+
expect(provider.hits()).toBe(0);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("raises a stable error when the provider rejects the request", async () => {
|
|
809
|
+
const provider = track(fakeProvider(() => Response.json({ error: { message: "bad model" } }, { status: 400 })));
|
|
810
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
811
|
+
|
|
812
|
+
await expect(agent.generate("hi")).rejects.toThrow(/bad model/);
|
|
813
|
+
expect(provider.hits()).toBe(1); // 400 is not retried
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("retries 5xx up to the configured count", async () => {
|
|
817
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
818
|
+
hits < 3 ? new Response("upstream down", { status: 503 }) : openaiText("finally")));
|
|
819
|
+
const agent = makeAgent({}, { baseUrl: provider.url }, { retries: 2 });
|
|
820
|
+
|
|
821
|
+
const result = await agent.generate("hi");
|
|
822
|
+
|
|
823
|
+
expect(result.text).toBe("finally");
|
|
824
|
+
expect(provider.hits()).toBe(3);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("requires an API key, with the environment as fallback", async () => {
|
|
828
|
+
const provider = track(fakeProvider(() => anthropicText("ok")));
|
|
829
|
+
const previous = { a: Bun.env.ANTEROS_AI_API_KEY, o: Bun.env.ANTHROPIC_API_KEY };
|
|
830
|
+
delete Bun.env.ANTEROS_AI_API_KEY;
|
|
831
|
+
delete Bun.env.ANTHROPIC_API_KEY;
|
|
832
|
+
|
|
833
|
+
try {
|
|
834
|
+
const agent = makeAgent({}, { compatible: "anthropic", apiKey: undefined, baseUrl: provider.url });
|
|
835
|
+
await expect(agent.generate("hi")).rejects.toMatchObject({ code: "AGENT_PROVIDER_NO_API_KEY" });
|
|
836
|
+
|
|
837
|
+
Bun.env.ANTHROPIC_API_KEY = "from-env";
|
|
838
|
+
const result = await agent.generate("hi");
|
|
839
|
+
expect(result.text).toBe("ok");
|
|
840
|
+
expect(request(provider, 0).headers["x-api-key"]).toBe("from-env");
|
|
841
|
+
} finally {
|
|
842
|
+
if (previous.a === undefined) delete Bun.env.ANTEROS_AI_API_KEY;
|
|
843
|
+
else Bun.env.ANTEROS_AI_API_KEY = previous.a;
|
|
844
|
+
if (previous.o === undefined) delete Bun.env.ANTHROPIC_API_KEY;
|
|
845
|
+
else Bun.env.ANTHROPIC_API_KEY = previous.o;
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// ─── Anthropic ───────────────────────────────────────────────────────────
|
|
851
|
+
|
|
852
|
+
describe("agent with an Anthropic-compatible provider", () => {
|
|
853
|
+
it("puts the system prompt out of band and returns the text", async () => {
|
|
854
|
+
const provider = track(fakeProvider(() => anthropicText("Bonjour")));
|
|
855
|
+
const agent = makeAgent({}, { compatible: "anthropic", baseUrl: provider.url });
|
|
856
|
+
|
|
857
|
+
const result = await agent.generate("salut");
|
|
858
|
+
|
|
859
|
+
expect(result.text).toBe("Bonjour");
|
|
860
|
+
expect(result.usage).toEqual({ inputTokens: 13, outputTokens: 6, totalTokens: 19, requests: 1 });
|
|
861
|
+
expect(request(provider, 0).path).toBe("/v1/messages");
|
|
862
|
+
expect(request(provider, 0).headers["x-api-key"]).toBe("test-key");
|
|
863
|
+
expect(request(provider, 0).headers["anthropic-version"]).toBe("2023-06-01");
|
|
864
|
+
expect(request(provider, 0).body.system).toBe("You are a test agent.");
|
|
865
|
+
expect(request(provider, 0).body.max_tokens).toBe(4096);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("parses tool_use and sends the result back as a tool_result user turn", async () => {
|
|
869
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
870
|
+
hits === 1 ? anthropicTool("forecast", { city: "Lyon" }) : anthropicText("Lyon: 21°C")));
|
|
871
|
+
|
|
872
|
+
const agent = makeAgent({ tools: { forecast: forecastTool() } }, { compatible: "anthropic", baseUrl: provider.url });
|
|
873
|
+
const result = await agent.generate("Weather in Lyon?");
|
|
874
|
+
|
|
875
|
+
expect(result.text).toBe("Lyon: 21°C");
|
|
876
|
+
expect(result.toolCalls[0]).toEqual({ id: "toolu_1", name: "forecast", args: { city: "Lyon" }, argsText: '{"city":"Lyon"}' });
|
|
877
|
+
|
|
878
|
+
const tools = request(provider, 0).body.tools;
|
|
879
|
+
expect(tools[0].name).toBe("forecast");
|
|
880
|
+
expect(tools[0].input_schema.properties.city.type).toBe("string");
|
|
881
|
+
|
|
882
|
+
const second = request(provider, 1).body.messages;
|
|
883
|
+
// system is out of band — the conversation starts at the user turn
|
|
884
|
+
expect(second[1].content[0]).toEqual({ type: "tool_use", id: "toolu_1", name: "forecast", input: { city: "Lyon" } });
|
|
885
|
+
expect(second[2].content[0].type).toBe("tool_result");
|
|
886
|
+
expect(second[2].content[0].tool_use_id).toBe("toolu_1");
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it("streams text deltas and merges the partial usage", async () => {
|
|
890
|
+
const provider = track(fakeProvider(() => anthropicStream(["Bon", "jour"])));
|
|
891
|
+
const agent = makeAgent({}, { compatible: "anthropic", baseUrl: provider.url });
|
|
892
|
+
|
|
893
|
+
const stream = agent.stream("salut");
|
|
894
|
+
let text = "";
|
|
895
|
+
for await (const chunk of stream.textStream) text += chunk;
|
|
896
|
+
|
|
897
|
+
expect(text).toBe("Bonjour");
|
|
898
|
+
expect(await stream.text).toBe("Bonjour");
|
|
899
|
+
expect(await stream.usage).toEqual({ inputTokens: 9, outputTokens: 5, totalTokens: 14, requests: 1 });
|
|
900
|
+
expect(await stream.finishReason).toBe("stop");
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// ─── stream() ────────────────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
describe("agent.stream", () => {
|
|
907
|
+
it("streams the tokens and resolves the promises at the end of the run", async () => {
|
|
908
|
+
const provider = track(fakeProvider(() => openaiStream(["Hel", "lo ", "world"])));
|
|
909
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
910
|
+
|
|
911
|
+
const stream = agent.stream("hi");
|
|
912
|
+
const chunks: string[] = [];
|
|
913
|
+
for await (const chunk of stream.textStream) chunks.push(chunk);
|
|
914
|
+
|
|
915
|
+
expect(chunks).toEqual(["Hel", "lo ", "world"]);
|
|
916
|
+
expect(await stream.text).toBe("Hello world");
|
|
917
|
+
expect(await stream.steps).toHaveLength(1);
|
|
918
|
+
expect(await stream.finishReason).toBe("stop");
|
|
919
|
+
expect((await stream.usage).requests).toBe(1);
|
|
920
|
+
expect(await stream.messages).toHaveLength(2); // system is not part of the conversation
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("streams a tool round and exposes both channels", async () => {
|
|
924
|
+
const provider = track(fakeProvider((_body, _req, hits) => {
|
|
925
|
+
if (hits > 1) return openaiStream(["21", "°C"]);
|
|
926
|
+
const events = [
|
|
927
|
+
JSON.stringify({ choices: [{ delta: { content: "Let me check. " } }] }),
|
|
928
|
+
JSON.stringify({
|
|
929
|
+
choices: [{
|
|
930
|
+
delta: {
|
|
931
|
+
tool_calls: [{
|
|
932
|
+
index: 0, id: "call_1", function: { name: "forecast", arguments: '{"city":' },
|
|
933
|
+
}],
|
|
934
|
+
},
|
|
935
|
+
}],
|
|
936
|
+
}),
|
|
937
|
+
JSON.stringify({
|
|
938
|
+
choices: [{
|
|
939
|
+
delta: {
|
|
940
|
+
tool_calls: [{ index: 0, function: { arguments: '"Paris"}' } }],
|
|
941
|
+
},
|
|
942
|
+
finish_reason: "tool_calls",
|
|
943
|
+
}],
|
|
944
|
+
}),
|
|
945
|
+
JSON.stringify({ choices: [], usage: USAGE }),
|
|
946
|
+
"[DONE]",
|
|
947
|
+
];
|
|
948
|
+
return new Response(events.map((event) => `data: ${event}\n\n`).join(""), {
|
|
949
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
950
|
+
});
|
|
951
|
+
}));
|
|
952
|
+
|
|
953
|
+
const agent = makeAgent({ tools: { forecast: forecastTool() } }, { baseUrl: provider.url });
|
|
954
|
+
const stream = agent.stream("Weather?");
|
|
955
|
+
const kinds: string[] = [];
|
|
956
|
+
for await (const chunk of stream.fullStream) kinds.push(chunk.type);
|
|
957
|
+
|
|
958
|
+
expect(kinds).toContain("text");
|
|
959
|
+
expect(kinds).toContain("tool_result");
|
|
960
|
+
expect(kinds).toContain("finish");
|
|
961
|
+
expect(await stream.toolCalls).toEqual([
|
|
962
|
+
{ id: "call_1", name: "forecast", args: { city: "Paris" }, argsText: '{"city":"Paris"}' },
|
|
963
|
+
]);
|
|
964
|
+
expect(await stream.text).toBe("21°C");
|
|
965
|
+
expect((await stream.usage).requests).toBe(2);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
it("fails the stream when the provider is rejected", async () => {
|
|
969
|
+
const provider = track(fakeProvider(() => new Response("nope", { status: 400 })));
|
|
970
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
971
|
+
|
|
972
|
+
const stream = agent.stream("hi");
|
|
973
|
+
let thrown: any;
|
|
974
|
+
try {
|
|
975
|
+
for await (const _chunk of stream.textStream) { /* draining */ }
|
|
976
|
+
} catch (err) {
|
|
977
|
+
thrown = err;
|
|
978
|
+
}
|
|
979
|
+
expect(thrown).toBeInstanceOf(AppError);
|
|
980
|
+
expect(thrown.code).toBe("AGENT_PROVIDER_ERROR");
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// ─── Memory ──────────────────────────────────────────────────────────────
|
|
985
|
+
|
|
986
|
+
describe("agent memory", () => {
|
|
987
|
+
it("replays the thread and stores the new exchange", async () => {
|
|
988
|
+
const provider = track(fakeProvider((_body, _req, hits) => openaiText(`answer ${hits}`)));
|
|
989
|
+
const memory = new InMemoryAgentMemory();
|
|
990
|
+
const agent = makeAgent({ memory }, { baseUrl: provider.url });
|
|
991
|
+
|
|
992
|
+
await agent.generate("first", { thread: "t1" });
|
|
993
|
+
await agent.generate("second", { thread: "t1" });
|
|
994
|
+
|
|
995
|
+
const second = request(provider, 1).body.messages;
|
|
996
|
+
expect(second.map((m: any) => m.role)).toEqual(["system", "user", "assistant", "user"]);
|
|
997
|
+
expect(second[1].content).toBe("first");
|
|
998
|
+
expect(second[2].content).toBe("answer 1");
|
|
999
|
+
expect(second[3].content).toBe("second");
|
|
1000
|
+
|
|
1001
|
+
expect(await agent.getMessages("t1")).toHaveLength(4);
|
|
1002
|
+
// A different thread starts over
|
|
1003
|
+
await agent.generate("other", { thread: "t2" });
|
|
1004
|
+
expect(request(provider, 2).body.messages).toHaveLength(2);
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it("reads without writing when the memory is read-only", async () => {
|
|
1008
|
+
const memory = new InMemoryAgentMemory();
|
|
1009
|
+
await memory.save("t1", [{ role: "user", content: "old" }]);
|
|
1010
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
1011
|
+
const agent = makeAgent({ memory }, { baseUrl: provider.url });
|
|
1012
|
+
|
|
1013
|
+
await agent.generate("new", { thread: "t1", memory: { threadId: "t1", readOnly: true } });
|
|
1014
|
+
|
|
1015
|
+
expect(request(provider, 0).body.messages.map((m: any) => m.content)).toEqual([
|
|
1016
|
+
"You are a test agent.", "old", "new",
|
|
1017
|
+
]);
|
|
1018
|
+
expect(await agent.getMessages("t1")).toHaveLength(1);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it("injects `messages` after the thread history, before the question", async () => {
|
|
1022
|
+
const memory = new InMemoryAgentMemory();
|
|
1023
|
+
await memory.save("t1", [{ role: "user", content: "an old turn" }]);
|
|
1024
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
1025
|
+
const agent = makeAgent({ memory }, { baseUrl: provider.url });
|
|
1026
|
+
|
|
1027
|
+
await agent.generate("And in Lyon?", {
|
|
1028
|
+
thread: "t1",
|
|
1029
|
+
messages: [
|
|
1030
|
+
{ role: "user", content: "Weather in Paris?" },
|
|
1031
|
+
{ role: "assistant", content: "18°C and sunny." },
|
|
1032
|
+
],
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
// history → injected → the question: the thread is not rewritten by the injection
|
|
1036
|
+
expect(request(provider, 0).body.messages.map((m: any) => m.content)).toEqual([
|
|
1037
|
+
"You are a test agent.", "an old turn", "Weather in Paris?", "18°C and sunny.", "And in Lyon?",
|
|
1038
|
+
]);
|
|
1039
|
+
|
|
1040
|
+
// …and the injected turns are part of the thread from now on
|
|
1041
|
+
expect((await agent.getMessages("t1")).map((m) => m.content)).toEqual([
|
|
1042
|
+
"an old turn", "Weather in Paris?", "18°C and sunny.", "And in Lyon?", "ok",
|
|
1043
|
+
]);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it("sends `messages` without a thread, and stores nothing", async () => {
|
|
1047
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
1048
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
1049
|
+
|
|
1050
|
+
await agent.generate("and?", {
|
|
1051
|
+
messages: [{ role: "user", content: "first" }, { role: "assistant", content: "answer" }],
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
expect(request(provider, 0).body.messages.map((m: any) => m.content))
|
|
1055
|
+
.toEqual(["You are a test agent.", "first", "answer", "and?"]);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("refuses injected turns the model could not have seen", async () => {
|
|
1059
|
+
const agent = makeAgent({ memory: new InMemoryAgentMemory() }, { baseUrl: "http://127.0.0.1:1" });
|
|
1060
|
+
|
|
1061
|
+
const error: any = await agent.generate("hi", {
|
|
1062
|
+
thread: "t1",
|
|
1063
|
+
messages: [{ role: "assistant", content: [{ type: "image", url: "https://example.com/a.png" }] }],
|
|
1064
|
+
} as any).catch((err) => err);
|
|
1065
|
+
|
|
1066
|
+
expect(error.code).toBe("INVALID_INPUT");
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it("remembers turns without calling the model", async () => {
|
|
1070
|
+
const memory = new InMemoryAgentMemory();
|
|
1071
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
1072
|
+
const agent = makeAgent({ memory }, { baseUrl: provider.url });
|
|
1073
|
+
|
|
1074
|
+
const thread = await agent.remember("t1", [
|
|
1075
|
+
{ role: "user", content: "Bonjour" },
|
|
1076
|
+
{ role: "assistant", content: "Bonjour ! Comment puis-je aider ?" },
|
|
1077
|
+
]);
|
|
1078
|
+
|
|
1079
|
+
expect(thread.map((message) => message.content)).toEqual([
|
|
1080
|
+
"Bonjour", "Bonjour ! Comment puis-je aider ?",
|
|
1081
|
+
]);
|
|
1082
|
+
expect(provider.hits()).toBe(0); // nobody was called
|
|
1083
|
+
|
|
1084
|
+
// What was remembered is replayed by the next run, after the system prompt
|
|
1085
|
+
await agent.generate("Une question", { thread: "t1" });
|
|
1086
|
+
expect(request(provider, 0).body.messages.map((m: any) => m.content)).toEqual([
|
|
1087
|
+
"You are a test agent.", "Bonjour", "Bonjour ! Comment puis-je aider ?", "Une question",
|
|
1088
|
+
]);
|
|
1089
|
+
|
|
1090
|
+
// Appending is additive — and a store namespace still applies
|
|
1091
|
+
await agent.remember("t1", { role: "user", content: "encore" });
|
|
1092
|
+
// 2 remembered + the question + the answer + `encore`
|
|
1093
|
+
expect(await agent.getMessages("t1")).toHaveLength(5);
|
|
1094
|
+
await agent.remember("t1", { role: "user", content: "ailleurs" }, { resource: "someone-else" });
|
|
1095
|
+
expect(await agent.getMessages("t1")).toHaveLength(5);
|
|
1096
|
+
expect(await agent.getMessages("t1", { resource: "someone-else" })).toHaveLength(1);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
it("refuses to remember on an agent without a memory", async () => {
|
|
1100
|
+
const agent = makeAgent({}, { baseUrl: "http://127.0.0.1:1" });
|
|
1101
|
+
const error: any = await agent.remember("t1", { role: "user", content: "hi" }).catch((err) => err);
|
|
1102
|
+
expect(error.code).toBe("AGENT_NO_MEMORY");
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// ─── Structured output ───────────────────────────────────────────────────
|
|
1107
|
+
|
|
1108
|
+
describe("agent structured output", () => {
|
|
1109
|
+
const schema = v.object({ city: v.string().required(), celsius: v.number().required() });
|
|
1110
|
+
const answer = '```json\n{"city":"Paris","celsius":21}\n```';
|
|
1111
|
+
|
|
1112
|
+
it("parses and validates the answer (generateObject)", async () => {
|
|
1113
|
+
const provider = track(fakeProvider(() => openaiText(answer)));
|
|
1114
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
1115
|
+
|
|
1116
|
+
const object = await agent.generateObject("Weather?", { schema });
|
|
1117
|
+
|
|
1118
|
+
expect(object).toEqual({ city: "Paris", celsius: 21 });
|
|
1119
|
+
// The schema was injected in the prompt and asked for a JSON object
|
|
1120
|
+
expect(request(provider, 0).body.messages[0].content).toContain("JSON Schema");
|
|
1121
|
+
expect(request(provider, 0).body.response_format).toEqual({ type: "json_object" });
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
it("repairs an invalid answer with one extra turn", async () => {
|
|
1125
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
1126
|
+
hits === 1 ? openaiText("I think it is 21 degrees in Paris") : openaiText(answer)));
|
|
1127
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
1128
|
+
|
|
1129
|
+
const result = await agent.generate("Weather?", { schema });
|
|
1130
|
+
|
|
1131
|
+
expect(result.object).toEqual({ city: "Paris", celsius: 21 });
|
|
1132
|
+
expect(provider.hits()).toBe(2);
|
|
1133
|
+
expect(request(provider, 1).body.messages.at(-1).content).toContain("rejected");
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it("fails with AGENT_OUTPUT_INVALID when it stays invalid", async () => {
|
|
1137
|
+
const provider = track(fakeProvider(() => openaiText("still not json")));
|
|
1138
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
1139
|
+
|
|
1140
|
+
await expect(agent.generate("Weather?", { schema })).rejects.toMatchObject({ code: "AGENT_OUTPUT_INVALID" });
|
|
1141
|
+
expect(provider.hits()).toBe(2);
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
it("refuses generateObject without a schema", async () => {
|
|
1145
|
+
const agent = makeAgent({});
|
|
1146
|
+
await expect(agent.generateObject("hi")).rejects.toThrow(/requires a `schema`/);
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
// ─── Loader ─────────────────────────────────────────────────────────────
|
|
1151
|
+
|
|
1152
|
+
describe("syncAgents", () => {
|
|
1153
|
+
const TENANT = "agent-fixture";
|
|
1154
|
+
const TENANT_DIR = "packages/core/tests/fixtures/agent-tenant";
|
|
1155
|
+
|
|
1156
|
+
beforeAll(async () => {
|
|
1157
|
+
cfg.tenants = [{ id: TENANT, dir: TENANT_DIR, database: { uri: "mongodb://localhost:27017/none" } }] as any;
|
|
1158
|
+
await syncAgents();
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
afterAll(() => {
|
|
1162
|
+
cfg.agents = [];
|
|
1163
|
+
cfg.tenants = [];
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
it("loads the definitions with their ids", () => {
|
|
1167
|
+
expect(cfg.agents?.map((agent) => agent.id).sort()).toEqual(["support", "weather"]);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it("instantiates them per tenant with their tools", () => {
|
|
1171
|
+
const registry = createAgents(TENANT);
|
|
1172
|
+
expect(registry.ids().sort()).toEqual(["support", "weather"]);
|
|
1173
|
+
expect(registry.has("weather")).toBe(true);
|
|
1174
|
+
|
|
1175
|
+
const weather = registry.get("weather")!;
|
|
1176
|
+
expect(weather).toBeInstanceOf(Agent);
|
|
1177
|
+
expect(weather.getTenant()).toBe(TENANT);
|
|
1178
|
+
expect(weather.getName()).toBe("Weather Agent");
|
|
1179
|
+
expect(weather.hasTool("forecast")).toBe(true);
|
|
1180
|
+
|
|
1181
|
+
expect(registry.get("support")!.getModel()).toBe("claude-test");
|
|
1182
|
+
expect(registry.get("missing")).toBeUndefined();
|
|
1183
|
+
expect(registry.list()).toHaveLength(2);
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
it("binds the calling rest to a copy, never to the shared instance", () => {
|
|
1187
|
+
const fakeRest = { tenant_id: TENANT } as any;
|
|
1188
|
+
const bound = createAgents(TENANT, fakeRest).get("weather")!;
|
|
1189
|
+
const shared = createAgents(TENANT).get("weather")!;
|
|
1190
|
+
|
|
1191
|
+
expect(bound.getRest()).toBe(fakeRest);
|
|
1192
|
+
// The registry instance is untouched: two concurrent requests must not
|
|
1193
|
+
// share a client (nor its Mongo session)
|
|
1194
|
+
expect(shared.getRest()).toBeUndefined();
|
|
1195
|
+
expect(bound).not.toBe(shared);
|
|
1196
|
+
expect(bound.getConfig()).toBe(shared.getConfig());
|
|
1197
|
+
expect(bound.getMemory()).toBe(shared.getMemory());
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
it("is what every context receives, bound to its own `rest`", async () => {
|
|
1201
|
+
const { useRest } = await import("../database/rest");
|
|
1202
|
+
const rest = new useRest({ tenant_id: TENANT });
|
|
1203
|
+
const agents = createAgents(TENANT, rest);
|
|
1204
|
+
|
|
1205
|
+
expect(agents.ids().sort()).toEqual(["support", "weather"]);
|
|
1206
|
+
expect(agents.has("weather")).toBe(true);
|
|
1207
|
+
expect(agents.get("weather")!.getRest()).toBe(rest);
|
|
1208
|
+
expect(agents.get("support")!.getModel()).toBe("claude-test");
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
it("counts what it loaded for the boot banner", () => {
|
|
1212
|
+
expect(agentsStats()).toEqual({ total: 2, tenants: [TENANT] });
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it("is importable without a `rest` (scripts, middlewares, cron…)", () => {
|
|
1216
|
+
// The package export — the tenant is passed explicitly
|
|
1217
|
+
expect(agents.stats()).toEqual({ total: 2, tenants: [TENANT] });
|
|
1218
|
+
expect(agents.has(TENANT, "weather")).toBe(true);
|
|
1219
|
+
expect(agents.ids(TENANT).sort()).toEqual(["support", "weather"]);
|
|
1220
|
+
expect(agents.list(TENANT)).toHaveLength(2);
|
|
1221
|
+
|
|
1222
|
+
const weather = agents.get(TENANT, "weather")!;
|
|
1223
|
+
expect(weather).toBeInstanceOf(Agent);
|
|
1224
|
+
expect(weather.getTenant()).toBe(TENANT);
|
|
1225
|
+
// No `rest` given → the tools run without a database client
|
|
1226
|
+
expect(weather.getRest()).toBeUndefined();
|
|
1227
|
+
|
|
1228
|
+
// …and a `rest` can be passed explicitly
|
|
1229
|
+
const fakeRest = { tenant_id: TENANT } as any;
|
|
1230
|
+
expect(agents.get(TENANT, "weather", fakeRest)!.getRest()).toBe(fakeRest);
|
|
1231
|
+
expect(agents.list(TENANT, fakeRest)[0]!.getRest()).toBe(fakeRest);
|
|
1232
|
+
|
|
1233
|
+
// Same registry as the `agents` member of a context / `createAgents`
|
|
1234
|
+
expect(agents.use(TENANT).get("weather")).toBe(weather);
|
|
1235
|
+
expect(agents.get(TENANT, "missing")).toBeUndefined();
|
|
1236
|
+
expect(agents.get("unknown-tenant", "weather")).toBeUndefined();
|
|
1237
|
+
expect(agents.reload).toBe(syncAgents);
|
|
1238
|
+
});
|
|
1239
|
+
});
|