@anteros/core 0.0.41 → 0.0.44
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 +9 -59
- package/database/collection.ts +3 -1
- package/database/deleteLog.ts +67 -3
- package/database/file.ts +4 -3
- package/database/mongodbadapter.ts +103 -42
- package/database/replication.ts +724 -58
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -0
- package/lib/agent.ts +1567 -0
- package/lib/agentMemory.ts +689 -0
- package/lib/agents.ts +180 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/auditResult.ts +13 -0
- package/lib/define.ts +43 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +2 -1
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +7 -1
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/mcpClient.ts +128 -0
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +970 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/skills.ts +481 -0
- 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 +472 -0
- package/server/api.ts +80 -65
- package/server/boot.ts +11 -0
- package/server/hono.ts +8 -3
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-provider.test.ts +288 -0
- package/tests/agent-runtime.test.ts +369 -0
- package/tests/agent-skills.test.ts +177 -0
- package/tests/agent-summarize.test.ts +124 -0
- package/tests/agent-working-memory.test.ts +404 -0
- package/tests/agent.test.ts +1241 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api-private-fields.test.ts +196 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-operations.test.ts +1 -1
- package/tests/audit-redact.test.ts +29 -3
- package/tests/audit-run-action.test.ts +1 -1
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +2 -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/repl-only/app.ts +3 -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/skills-tenant/agents/docs.agent.ts +23 -0
- package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
- package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
- package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
- package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
- package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
- package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
- package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
- package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
- package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
- package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
- package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
- package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
- package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
- 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/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +4 -2
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +246 -0
- package/tests/replication.test.ts +2 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +5 -2
- 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 +1067 -0
- package/types/config.d.ts +16 -0
- package/types/file.d.ts +7 -7
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +6 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +70 -4
- 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 +13 -0
- package/types/vars.d.ts +2 -2
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent puts **on the wire** for the two things the provider can enforce
|
|
3
|
+
* better than a prompt: a JSON **schema**, and the prompt **cache** of the stable
|
|
4
|
+
* prefix.
|
|
5
|
+
*
|
|
6
|
+
* Anthropic has no `response_format` — the schema travels as a tool the model is
|
|
7
|
+
* forced to call, and the adapter turns that call back into text, so the runtime
|
|
8
|
+
* never sees a tool call it would try to execute. A streaming run gets the same
|
|
9
|
+
* treatment, delta by delta.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, afterAll } from "bun:test";
|
|
12
|
+
import { define, v, Agent } from "../index";
|
|
13
|
+
import {
|
|
14
|
+
anthropicText,
|
|
15
|
+
anthropicTool,
|
|
16
|
+
at,
|
|
17
|
+
fakeProvider,
|
|
18
|
+
openaiText,
|
|
19
|
+
request,
|
|
20
|
+
sse,
|
|
21
|
+
type Fake,
|
|
22
|
+
} from "./fixtures/fake-provider";
|
|
23
|
+
|
|
24
|
+
const servers: Fake[] = [];
|
|
25
|
+
function track(fake: Fake): Fake {
|
|
26
|
+
servers.push(fake);
|
|
27
|
+
return fake;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
for (const server of servers) server.stop();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const priority = v.object({ priority: v.string().required() });
|
|
35
|
+
|
|
36
|
+
function makeAgent(
|
|
37
|
+
overrides: Record<string, any> = {},
|
|
38
|
+
provider: Record<string, any> = {},
|
|
39
|
+
options: Record<string, any> = {},
|
|
40
|
+
) {
|
|
41
|
+
return new Agent({
|
|
42
|
+
id: "test",
|
|
43
|
+
description: "A test agent.",
|
|
44
|
+
instructions: "You are a test agent.",
|
|
45
|
+
provider: {
|
|
46
|
+
model: "test-model",
|
|
47
|
+
apiKey: "test-key",
|
|
48
|
+
protocol: "openai",
|
|
49
|
+
options: { retries: 0, ...options },
|
|
50
|
+
...provider,
|
|
51
|
+
},
|
|
52
|
+
...overrides,
|
|
53
|
+
} as any);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const tool = () => define.Tool({
|
|
57
|
+
id: "forecast",
|
|
58
|
+
description: "Current weather for a city",
|
|
59
|
+
inputSchema: v.object({ city: v.string().required() }),
|
|
60
|
+
execute: async ({ city }: any) => ({ city, celsius: 21 }),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("structured output on an OpenAI-compatible gateway", () => {
|
|
64
|
+
it("sends the real `json_schema`, not just `json_object`", async () => {
|
|
65
|
+
const provider = track(fakeProvider(() => openaiText('{"priority":"high"}')));
|
|
66
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
67
|
+
|
|
68
|
+
const result = await agent.generate("Triage this", { schema: priority });
|
|
69
|
+
|
|
70
|
+
expect(result.object).toEqual({ priority: "high" });
|
|
71
|
+
|
|
72
|
+
const format = request(provider, 0).body.response_format;
|
|
73
|
+
expect(format.type).toBe("json_schema");
|
|
74
|
+
// The agent id names the schema when nothing else does
|
|
75
|
+
expect(format.json_schema.name).toBe("test");
|
|
76
|
+
expect(format.json_schema.schema.type).toBe("object");
|
|
77
|
+
expect(format.json_schema.schema.properties.priority.type).toBe("string");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("names it from `api.object`, and describes it", async () => {
|
|
81
|
+
const provider = track(fakeProvider(() => openaiText('{"priority":"low"}')));
|
|
82
|
+
// What the HTTP `object` action runs: a structured call carrying `api.object`
|
|
83
|
+
const api = { object: { schema: priority, name: "triage", description: "The triage verdict." } };
|
|
84
|
+
const agent = makeAgent({ api }, { baseUrl: provider.url });
|
|
85
|
+
|
|
86
|
+
await agent.generate("Triage this", { structuredOutput: api.object });
|
|
87
|
+
|
|
88
|
+
const format = request(provider, 0).body.response_format.json_schema;
|
|
89
|
+
expect(format.name).toBe("triage");
|
|
90
|
+
expect(format.description).toBe("The triage verdict.");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("sanitizes a name a provider would refuse", async () => {
|
|
94
|
+
const provider = track(fakeProvider(() => openaiText('{"priority":"low"}')));
|
|
95
|
+
const agent = makeAgent({
|
|
96
|
+
api: { object: { schema: priority, name: "support.billing" } },
|
|
97
|
+
}, { baseUrl: provider.url });
|
|
98
|
+
|
|
99
|
+
await agent.generate("Triage this", { structuredOutput: { schema: priority, name: "support.billing" } });
|
|
100
|
+
|
|
101
|
+
expect(request(provider, 0).body.response_format.json_schema.name).toBe("support_billing");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("falls back to `json_object` once when the gateway refuses the schema", async () => {
|
|
105
|
+
const provider = track(fakeProvider((body) => {
|
|
106
|
+
if (body.response_format?.type === "json_schema") {
|
|
107
|
+
return new Response(JSON.stringify({ error: { message: "response_format json_schema is not supported" } }), {
|
|
108
|
+
status: 400,
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return openaiText('{"priority":"high"}');
|
|
113
|
+
}));
|
|
114
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
115
|
+
|
|
116
|
+
const result = await agent.generate("Triage this", { schema: priority });
|
|
117
|
+
|
|
118
|
+
expect(result.object).toEqual({ priority: "high" });
|
|
119
|
+
expect(request(provider, 0).body.response_format.type).toBe("json_schema");
|
|
120
|
+
expect(request(provider, 1).body.response_format).toEqual({ type: "json_object" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not mask an unrelated 400", async () => {
|
|
124
|
+
const provider = track(fakeProvider(() => new Response(
|
|
125
|
+
JSON.stringify({ error: { message: "I do not like your temperature" } }),
|
|
126
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
127
|
+
)));
|
|
128
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
129
|
+
|
|
130
|
+
await expect(agent.generate("Triage this", { schema: priority }))
|
|
131
|
+
.rejects.toMatchObject({ code: "AGENT_PROVIDER_ERROR" });
|
|
132
|
+
expect(provider.hits()).toBe(1); // no retry: the schema was not the problem
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("structured output on Anthropic", () => {
|
|
137
|
+
it("forces a schema tool, and turns its call back into the answer", async () => {
|
|
138
|
+
const provider = track(fakeProvider(() => anthropicTool("structured_output", { priority: "high" })));
|
|
139
|
+
const agent = makeAgent({}, { protocol: "anthropic", baseUrl: provider.url });
|
|
140
|
+
|
|
141
|
+
const result = await agent.generate("Triage this", { schema: priority });
|
|
142
|
+
|
|
143
|
+
const body = request(provider, 0).body;
|
|
144
|
+
expect(body.tool_choice).toEqual({ type: "tool", name: "structured_output" });
|
|
145
|
+
expect(body.tools).toHaveLength(1);
|
|
146
|
+
expect(body.tools[0].name).toBe("structured_output");
|
|
147
|
+
expect(body.tools[0].input_schema.type).toBe("object");
|
|
148
|
+
|
|
149
|
+
// The forced call **is** the answer: parsed and validated, never a tool call
|
|
150
|
+
expect(result.object).toEqual({ priority: "high" });
|
|
151
|
+
expect(result.toolCalls).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps the agent's own tools, and the schema tool beside them", async () => {
|
|
155
|
+
const provider = track(fakeProvider(() => anthropicTool("structured_output", { priority: "low" })));
|
|
156
|
+
const agent = makeAgent({ tools: { forecast: tool() } }, { protocol: "anthropic", baseUrl: provider.url });
|
|
157
|
+
|
|
158
|
+
await agent.generate("Triage this", { schema: priority });
|
|
159
|
+
|
|
160
|
+
expect(request(provider, 0).body.tools.map((tool: any) => tool.name))
|
|
161
|
+
.toEqual(["forecast", "structured_output"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("does not force a tool when extended thinking is on, and still parses", async () => {
|
|
165
|
+
const provider = track(fakeProvider(() => anthropicText('{"priority":"high"}')));
|
|
166
|
+
const agent = makeAgent({}, { protocol: "anthropic", baseUrl: provider.url });
|
|
167
|
+
|
|
168
|
+
const result = await agent.generate("Triage this", { schema: priority, thinking: true });
|
|
169
|
+
|
|
170
|
+
const body = request(provider, 0).body;
|
|
171
|
+
expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 4096 });
|
|
172
|
+
// `tool_choice` is refused alongside thinking — the prompt carries the schema
|
|
173
|
+
expect(body.tool_choice).toBeUndefined();
|
|
174
|
+
expect(body.tools).toBeUndefined();
|
|
175
|
+
expect(result.object).toEqual({ priority: "high" });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("streams the forced tool's JSON as text", async () => {
|
|
179
|
+
const provider = track(fakeProvider(() => sse([
|
|
180
|
+
JSON.stringify({ type: "message_start", message: { usage: { input_tokens: 5, output_tokens: 0 } } }),
|
|
181
|
+
JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_1", name: "structured_output" } }),
|
|
182
|
+
JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"priority"' } }),
|
|
183
|
+
JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"high"}' } }),
|
|
184
|
+
JSON.stringify({ type: "content_block_stop", index: 0 }),
|
|
185
|
+
JSON.stringify({ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 7 } }),
|
|
186
|
+
JSON.stringify({ type: "message_stop" }),
|
|
187
|
+
])));
|
|
188
|
+
const agent = makeAgent({}, { protocol: "anthropic", baseUrl: provider.url });
|
|
189
|
+
|
|
190
|
+
const stream = agent.stream("Triage this", { schema: priority });
|
|
191
|
+
const chunks: any[] = [];
|
|
192
|
+
for await (const chunk of stream.fullStream) chunks.push(chunk);
|
|
193
|
+
|
|
194
|
+
expect(await stream.text).toBe('{"priority":"high"}');
|
|
195
|
+
expect(await stream.object).toEqual({ priority: "high" });
|
|
196
|
+
expect(await stream.toolCalls).toEqual([]);
|
|
197
|
+
// It arrived as text deltas — never as a tool call the run would execute
|
|
198
|
+
expect(chunks.filter((chunk) => chunk.type === "tool_call")).toHaveLength(0);
|
|
199
|
+
expect(at<any>(chunks, 0).type).toBe("text");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("prompt cache", () => {
|
|
204
|
+
it("marks the stable prefix on Anthropic — system and last tool", async () => {
|
|
205
|
+
const provider = track(fakeProvider(() => anthropicText("ok")));
|
|
206
|
+
const agent = makeAgent({ tools: { forecast: tool() } }, { protocol: "anthropic", baseUrl: provider.url }, { cache: true });
|
|
207
|
+
|
|
208
|
+
await agent.generate("hi");
|
|
209
|
+
|
|
210
|
+
const body = request(provider, 0).body;
|
|
211
|
+
expect(body.system).toEqual([{ type: "text", text: "You are a test agent.", cache_control: { type: "ephemeral" } }]);
|
|
212
|
+
expect(body.tools[body.tools.length - 1].cache_control).toEqual({ type: "ephemeral" });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("leaves the payload alone when it is not asked for", async () => {
|
|
216
|
+
const provider = track(fakeProvider(() => anthropicText("ok")));
|
|
217
|
+
const agent = makeAgent({ tools: { forecast: tool() } }, { protocol: "anthropic", baseUrl: provider.url });
|
|
218
|
+
|
|
219
|
+
await agent.generate("hi");
|
|
220
|
+
|
|
221
|
+
const body = request(provider, 0).body;
|
|
222
|
+
expect(body.system).toBe("You are a test agent.");
|
|
223
|
+
expect(body.tools[0].cache_control).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("is a no-op on an OpenAI-compatible gateway, which caches by itself", async () => {
|
|
227
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
228
|
+
const agent = makeAgent({}, { baseUrl: provider.url }, { cache: true });
|
|
229
|
+
|
|
230
|
+
await agent.generate("hi");
|
|
231
|
+
|
|
232
|
+
expect(request(provider, 0).body.messages[0]).toEqual({ role: "system", content: "You are a test agent." });
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("provider protocol", () => {
|
|
237
|
+
/** An agent whose provider is exactly what is passed — no injected default. */
|
|
238
|
+
function providerAgent(provider: Record<string, any>) {
|
|
239
|
+
return new Agent({
|
|
240
|
+
id: "test",
|
|
241
|
+
description: "A test agent.",
|
|
242
|
+
instructions: "You are a test agent.",
|
|
243
|
+
provider: { model: "test-model", apiKey: "test-key", options: { retries: 0 }, ...provider },
|
|
244
|
+
} as any);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
it("calls the Messages API when `protocol: 'anthropic'`", async () => {
|
|
248
|
+
const provider = track(fakeProvider(() => anthropicText("ok")));
|
|
249
|
+
const agent = providerAgent({ protocol: "anthropic", baseUrl: provider.url });
|
|
250
|
+
|
|
251
|
+
await agent.generate("hi");
|
|
252
|
+
|
|
253
|
+
expect(request(provider, 0).path).toBe("/v1/messages");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("still accepts the deprecated `compatible` alias", async () => {
|
|
257
|
+
const provider = track(fakeProvider(() => anthropicText("ok")));
|
|
258
|
+
const agent = providerAgent({ compatible: "anthropic", baseUrl: provider.url });
|
|
259
|
+
|
|
260
|
+
await agent.generate("hi");
|
|
261
|
+
|
|
262
|
+
expect(request(provider, 0).path).toBe("/v1/messages");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("lets `protocol` win when the deprecated alias is set too", async () => {
|
|
266
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
267
|
+
const agent = providerAgent({ compatible: "anthropic", protocol: "openai", baseUrl: provider.url });
|
|
268
|
+
|
|
269
|
+
await agent.generate("hi");
|
|
270
|
+
|
|
271
|
+
// A bare host gets `/v1` appended by the OpenAI adapter
|
|
272
|
+
expect(request(provider, 0).path).toBe("/v1/chat/completions");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("normalizes to `protocol` in the serializable view, never leaking the alias", () => {
|
|
276
|
+
const agent = providerAgent({ compatible: "anthropic" });
|
|
277
|
+
|
|
278
|
+
const info = agent.toJSON();
|
|
279
|
+
expect(info.provider.protocol).toBe("anthropic");
|
|
280
|
+
expect(info.provider.compatible).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("refuses an unknown protocol with `AGENT_PROVIDER_INVALID`", async () => {
|
|
284
|
+
const agent = providerAgent({ protocol: "gemini" });
|
|
285
|
+
|
|
286
|
+
await expect(agent.generate("hi")).rejects.toThrow(/Unknown provider 'protocol'/);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime's own guarantees — the ones a caller relies on when the outside world
|
|
3
|
+
* misbehaves or when the prompt decides on something expensive:
|
|
4
|
+
*
|
|
5
|
+
* - a tool that hangs is abandoned, and the run goes on;
|
|
6
|
+
* - a step is all-or-nothing, and a tool asking for **approval** pauses it;
|
|
7
|
+
* - a failing run can keep what it produced;
|
|
8
|
+
* - a step's tools can run together;
|
|
9
|
+
* - another agent is just a tool.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, afterAll } from "bun:test";
|
|
12
|
+
import { define, v, Agent, agents } from "../index";
|
|
13
|
+
import { InMemoryAgentMemory } from "../lib/agent";
|
|
14
|
+
import {
|
|
15
|
+
at,
|
|
16
|
+
fakeProvider,
|
|
17
|
+
openaiText,
|
|
18
|
+
openaiTool,
|
|
19
|
+
request,
|
|
20
|
+
type Fake,
|
|
21
|
+
} from "./fixtures/fake-provider";
|
|
22
|
+
|
|
23
|
+
const servers: Fake[] = [];
|
|
24
|
+
function track(fake: Fake): Fake {
|
|
25
|
+
servers.push(fake);
|
|
26
|
+
return fake;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
for (const server of servers) server.stop();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function makeAgent(
|
|
34
|
+
overrides: Record<string, any> = {},
|
|
35
|
+
provider: Record<string, any> = {},
|
|
36
|
+
options: Record<string, any> = {},
|
|
37
|
+
) {
|
|
38
|
+
return new Agent({
|
|
39
|
+
id: "test",
|
|
40
|
+
description: "A test agent.",
|
|
41
|
+
instructions: "You are a test agent.",
|
|
42
|
+
provider: {
|
|
43
|
+
model: "test-model",
|
|
44
|
+
apiKey: "test-key",
|
|
45
|
+
protocol: "openai",
|
|
46
|
+
options: { retries: 0, ...options },
|
|
47
|
+
...provider,
|
|
48
|
+
},
|
|
49
|
+
...overrides,
|
|
50
|
+
} as any);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Two tool calls in **one** answer — what a parallel step needs. */
|
|
54
|
+
function openaiTwoCalls(name: string, ids = ["c1", "c2"]): Response {
|
|
55
|
+
return Response.json({
|
|
56
|
+
choices: [{
|
|
57
|
+
message: {
|
|
58
|
+
role: "assistant",
|
|
59
|
+
content: null,
|
|
60
|
+
tool_calls: ids.map((id, index) => ({
|
|
61
|
+
id,
|
|
62
|
+
type: "function",
|
|
63
|
+
function: { name, arguments: JSON.stringify({ n: index + 1 }) },
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
finish_reason: "tool_calls",
|
|
67
|
+
}],
|
|
68
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("tool timeout", () => {
|
|
73
|
+
const slow = (calls: string[] = []) => define.Tool({
|
|
74
|
+
id: "slow",
|
|
75
|
+
description: "Never answers",
|
|
76
|
+
inputSchema: v.object({ n: v.number().optional() }),
|
|
77
|
+
execute: async ({ n }: any) => {
|
|
78
|
+
calls.push(String(n));
|
|
79
|
+
await Bun.sleep(500);
|
|
80
|
+
return "late";
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("abandons a tool that hangs, tells the model, and keeps going", async () => {
|
|
85
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
86
|
+
hits === 1 ? openaiTool("slow", {}) : openaiText("done")));
|
|
87
|
+
const agent = makeAgent({ tools: { slow: slow() } }, { baseUrl: provider.url });
|
|
88
|
+
|
|
89
|
+
const result = await agent.generate("hi", { toolTimeoutMs: 40 });
|
|
90
|
+
|
|
91
|
+
expect(at(result.toolResults, 0).error).toContain("timed out after 40ms");
|
|
92
|
+
// The run is not the tool's hostage
|
|
93
|
+
expect(result.text).toBe("done");
|
|
94
|
+
expect(result.finishReason).toBe("stop");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("takes the deadline from the tool, then from the agent", async () => {
|
|
98
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
99
|
+
hits === 1 ? openaiTool("slow", {}) : openaiText("done")));
|
|
100
|
+
const agent = makeAgent({
|
|
101
|
+
toolTimeoutMs: 40,
|
|
102
|
+
tools: { slow: { ...slow(), timeoutMs: 10 } },
|
|
103
|
+
}, { baseUrl: provider.url });
|
|
104
|
+
|
|
105
|
+
const result = await agent.generate("hi");
|
|
106
|
+
|
|
107
|
+
expect(at(result.toolResults, 0).error).toContain("timed out after 10ms");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("lets a cooperative tool notice the deadline through its signal", async () => {
|
|
111
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
112
|
+
hits === 1 ? openaiTool("watch", {}) : openaiText("done")));
|
|
113
|
+
let aborted = false;
|
|
114
|
+
const agent = makeAgent({
|
|
115
|
+
tools: {
|
|
116
|
+
watch: define.Tool({
|
|
117
|
+
id: "watch",
|
|
118
|
+
description: "Waits for its own deadline",
|
|
119
|
+
inputSchema: v.object({}),
|
|
120
|
+
execute: async (_args: any, { signal }: any) => {
|
|
121
|
+
await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
|
|
122
|
+
aborted = true;
|
|
123
|
+
return "stopped";
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
}, { baseUrl: provider.url });
|
|
128
|
+
|
|
129
|
+
await agent.generate("hi", { toolTimeoutMs: 30 });
|
|
130
|
+
|
|
131
|
+
expect(aborted).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("a failing run", () => {
|
|
136
|
+
const boom = () => define.Tool({
|
|
137
|
+
id: "boom",
|
|
138
|
+
description: "Works once",
|
|
139
|
+
inputSchema: v.object({}),
|
|
140
|
+
execute: async () => "ok",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** The provider answers the tool call, then dies. */
|
|
144
|
+
const dying = () => track(fakeProvider((_body, _req, hits) =>
|
|
145
|
+
hits === 1
|
|
146
|
+
? openaiTool("boom", {})
|
|
147
|
+
: new Response(JSON.stringify({ error: { message: "provider exploded" } }), {
|
|
148
|
+
status: 500,
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
})));
|
|
151
|
+
|
|
152
|
+
it("stores nothing by default", async () => {
|
|
153
|
+
const memory = new InMemoryAgentMemory();
|
|
154
|
+
const agent = makeAgent({ memory, tools: { boom: boom() } }, { baseUrl: dying().url });
|
|
155
|
+
|
|
156
|
+
await expect(agent.generate("hi", { thread: "t1" })).rejects.toThrow(/provider exploded/);
|
|
157
|
+
|
|
158
|
+
expect(await memory.get("t1")).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps the exchange when the caller asks for it", async () => {
|
|
162
|
+
const memory = new InMemoryAgentMemory();
|
|
163
|
+
const agent = makeAgent({ memory, tools: { boom: boom() } }, { baseUrl: dying().url });
|
|
164
|
+
|
|
165
|
+
await expect(agent.generate("hi", { thread: "t1", savePartial: true })).rejects.toThrow(/provider exploded/);
|
|
166
|
+
|
|
167
|
+
// The tokens are paid: what the run produced is in the thread, and the next
|
|
168
|
+
// call resumes from there instead of starting over
|
|
169
|
+
const stored = await memory.get("t1");
|
|
170
|
+
expect(stored.map((message) => message.role)).toEqual(["user", "assistant", "tool"]);
|
|
171
|
+
expect(stored[2]!.content).toBe("ok");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("parallel tools", () => {
|
|
176
|
+
/** Counts how many calls are in flight at the same time. */
|
|
177
|
+
const overlap = (state: { running: number; peak: number }) => define.Tool({
|
|
178
|
+
id: "probe",
|
|
179
|
+
description: "Measures concurrency",
|
|
180
|
+
inputSchema: v.object({ n: v.number().optional() }),
|
|
181
|
+
execute: async () => {
|
|
182
|
+
state.running += 1;
|
|
183
|
+
state.peak = Math.max(state.peak, state.running);
|
|
184
|
+
await Bun.sleep(30);
|
|
185
|
+
state.running -= 1;
|
|
186
|
+
return `n${state.running}`;
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("runs them one after the other by default", async () => {
|
|
191
|
+
const state = { running: 0, peak: 0 };
|
|
192
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
193
|
+
hits === 1 ? openaiTwoCalls("probe") : openaiText("done")));
|
|
194
|
+
const agent = makeAgent({ tools: { probe: overlap(state) } }, { baseUrl: provider.url });
|
|
195
|
+
|
|
196
|
+
const result = await agent.generate("hi");
|
|
197
|
+
|
|
198
|
+
expect(state.peak).toBe(1);
|
|
199
|
+
// The conversation stays in the model's order, whatever the execution order
|
|
200
|
+
expect(result.messages.filter((message) => message.role === "tool").map((message) => message.toolCallId))
|
|
201
|
+
.toEqual(["c1", "c2"]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("runs them together when the caller asks for it", async () => {
|
|
205
|
+
const state = { running: 0, peak: 0 };
|
|
206
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
207
|
+
hits === 1 ? openaiTwoCalls("probe") : openaiText("done")));
|
|
208
|
+
const agent = makeAgent({ tools: { probe: overlap(state) } }, { baseUrl: provider.url });
|
|
209
|
+
|
|
210
|
+
const result = await agent.generate("hi", { parallelTools: true });
|
|
211
|
+
|
|
212
|
+
expect(state.peak).toBe(2);
|
|
213
|
+
expect(result.toolResults).toHaveLength(2);
|
|
214
|
+
expect(result.messages.filter((message) => message.role === "tool").map((message) => message.toolCallId))
|
|
215
|
+
.toEqual(["c1", "c2"]);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("another agent as a tool", () => {
|
|
220
|
+
it("offers it, runs it with the caller's client, and feeds the answer back", async () => {
|
|
221
|
+
const provider = track(fakeProvider((body, _req, hits) => {
|
|
222
|
+
if (body.messages[0].content === "You are the researcher.") return openaiText("42");
|
|
223
|
+
return hits === 1 ? openaiTool("researcher", { input: "the answer?" }) : openaiText("It is 42.");
|
|
224
|
+
}));
|
|
225
|
+
|
|
226
|
+
const researcher = {
|
|
227
|
+
id: "researcher",
|
|
228
|
+
description: "Looks things up in the corpus.",
|
|
229
|
+
instructions: "You are the researcher.",
|
|
230
|
+
provider: { model: "test-model", apiKey: "test-key", baseUrl: provider.url, options: { retries: 0 } },
|
|
231
|
+
};
|
|
232
|
+
const agent = makeAgent({ agents: [researcher] }, { baseUrl: provider.url });
|
|
233
|
+
|
|
234
|
+
const result = await agent.generate("What is the answer?");
|
|
235
|
+
|
|
236
|
+
// The sub-agent is an ordinary tool, described by its own declaration
|
|
237
|
+
const offered = request(provider, 0).body.tools.find((tool: any) => tool.function.name === "researcher");
|
|
238
|
+
expect(offered.function.description).toBe("Looks things up in the corpus.");
|
|
239
|
+
expect(offered.function.parameters.required).toEqual(["input"]);
|
|
240
|
+
|
|
241
|
+
expect(at(result.toolResults, 0).result).toBe("42");
|
|
242
|
+
expect(result.text).toBe("It is 42.");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("is dropped by `tools: false` and by an allow-list, like any other tool", async () => {
|
|
246
|
+
const provider = track(fakeProvider(() => openaiText("direct")));
|
|
247
|
+
const researcher = { id: "researcher", description: "x", instructions: "y", provider: { model: "test-model" } };
|
|
248
|
+
const agent = makeAgent({ agents: [researcher] }, { baseUrl: provider.url });
|
|
249
|
+
|
|
250
|
+
await agent.generate("hi", { tools: false });
|
|
251
|
+
expect(request(provider, 0).body.tools).toBeUndefined();
|
|
252
|
+
|
|
253
|
+
await agent.generate("hi", { tools: ["something-else"] });
|
|
254
|
+
expect(request(provider, 1).body.tools).toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("tool approval", () => {
|
|
259
|
+
const refund = (calls: string[]) => define.Tool({
|
|
260
|
+
id: "refund",
|
|
261
|
+
description: "Refunds an order",
|
|
262
|
+
approval: true,
|
|
263
|
+
inputSchema: v.object({ order: v.string().required() }),
|
|
264
|
+
execute: async ({ order }: any) => {
|
|
265
|
+
calls.push(order);
|
|
266
|
+
return { refunded: order };
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("pauses before running it, then resumes on a decision — one model call each", async () => {
|
|
271
|
+
const calls: string[] = [];
|
|
272
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
273
|
+
hits === 1 ? openaiTool("refund", { order: "A-1" }) : openaiText("Refunded.")));
|
|
274
|
+
const agent = makeAgent({ tools: { refund: refund(calls) } }, { baseUrl: provider.url });
|
|
275
|
+
|
|
276
|
+
const paused = await agent.generate("Refund A-1", { thread: "t1" });
|
|
277
|
+
|
|
278
|
+
expect(paused.finishReason).toBe("approval_required");
|
|
279
|
+
expect(paused.pendingApprovals).toEqual([
|
|
280
|
+
{ toolCallId: "call_1", name: "refund", args: { order: "A-1" } },
|
|
281
|
+
]);
|
|
282
|
+
expect(calls).toEqual([]); // nothing ran before the decision
|
|
283
|
+
expect(provider.hits()).toBe(1);
|
|
284
|
+
|
|
285
|
+
const resumed = await agent.generate("", {
|
|
286
|
+
thread: "t1",
|
|
287
|
+
resume: { messages: paused.messages, decisions: { call_1: true } },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(calls).toEqual(["A-1"]);
|
|
291
|
+
expect(resumed.finishReason).toBe("stop");
|
|
292
|
+
expect(resumed.text).toBe("Refunded.");
|
|
293
|
+
// The paused step is not replayed through the model: approving costs one call
|
|
294
|
+
expect(provider.hits()).toBe(2);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("tells the model when the caller refuses", async () => {
|
|
298
|
+
const calls: string[] = [];
|
|
299
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
300
|
+
hits === 1 ? openaiTool("refund", { order: "A-1" }) : openaiText("Understood.")));
|
|
301
|
+
const agent = makeAgent({ tools: { refund: refund(calls) } }, { baseUrl: provider.url });
|
|
302
|
+
|
|
303
|
+
const paused = await agent.generate("Refund A-1", { thread: "t1" });
|
|
304
|
+
const resumed = await agent.generate("", {
|
|
305
|
+
thread: "t1",
|
|
306
|
+
resume: { messages: paused.messages, decisions: { call_1: false } },
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(calls).toEqual([]); // the tool never ran
|
|
310
|
+
expect(resumed.text).toBe("Understood.");
|
|
311
|
+
|
|
312
|
+
// The refusal is in the conversation the model saw, as a failed tool
|
|
313
|
+
const toolTurn = request(provider, 1).body.messages.find((message: any) => message.role === "tool");
|
|
314
|
+
expect(toolTurn.content).toContain("Refused by the caller");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("stays paused while a call has no decision", async () => {
|
|
318
|
+
const calls: string[] = [];
|
|
319
|
+
const provider = track(fakeProvider(() => openaiTool("refund", { order: "A-1" })));
|
|
320
|
+
const agent = makeAgent({ tools: { refund: refund(calls) } }, { baseUrl: provider.url });
|
|
321
|
+
|
|
322
|
+
const paused = await agent.generate("Refund A-1", { thread: "t1" });
|
|
323
|
+
const again = await agent.generate("", {
|
|
324
|
+
thread: "t1",
|
|
325
|
+
resume: { messages: paused.messages, decisions: {} },
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(again.finishReason).toBe("approval_required");
|
|
329
|
+
expect(again.pendingApprovals).toHaveLength(1);
|
|
330
|
+
expect(calls).toEqual([]);
|
|
331
|
+
expect(provider.hits()).toBe(1); // nobody was asked again
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("runs a tool that does not ask for approval without a decision", async () => {
|
|
335
|
+
const calls: string[] = [];
|
|
336
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
337
|
+
hits === 1
|
|
338
|
+
? openaiTwoCalls("both")
|
|
339
|
+
: openaiText("done")));
|
|
340
|
+
const agent = makeAgent({
|
|
341
|
+
tools: {
|
|
342
|
+
both: define.Tool({
|
|
343
|
+
id: "both",
|
|
344
|
+
description: "Needs no decision",
|
|
345
|
+
inputSchema: v.object({ n: v.number().optional() }),
|
|
346
|
+
execute: async ({ n }: any) => {
|
|
347
|
+
calls.push(String(n));
|
|
348
|
+
return `n${n}`;
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
},
|
|
352
|
+
}, { baseUrl: provider.url });
|
|
353
|
+
|
|
354
|
+
// No `approval: true` anywhere: the step runs, and nothing pauses
|
|
355
|
+
const result = await agent.generate("hi");
|
|
356
|
+
|
|
357
|
+
expect(calls).toEqual(["1", "2"]);
|
|
358
|
+
expect(result.finishReason).toBe("stop");
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("agent version", () => {
|
|
363
|
+
it("is served by `info`", () => {
|
|
364
|
+
const agent = makeAgent({ version: "2026-09-24" });
|
|
365
|
+
|
|
366
|
+
expect(agent.toJSON().version).toBe("2026-09-24");
|
|
367
|
+
expect(agents.get("nobody", "test")).toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
});
|