@anteros/core 0.0.42 → 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/replication.ts +614 -26
- package/lib/agent.ts +279 -39
- package/lib/agentMemory.ts +103 -0
- package/lib/agents.ts +25 -2
- package/lib/define.ts +1 -1
- package/lib/mcpClient.ts +128 -0
- package/lib/providers.ts +169 -66
- package/lib/skills.ts +481 -0
- package/package.json +1 -1
- package/server/agents.ts +27 -6
- package/server/api.ts +74 -1
- package/tests/agent-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +6 -6
- 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 +5 -3
- package/tests/agent.test.ts +18 -16
- package/tests/agents-api.test.ts +1 -1
- package/tests/api-private-fields.test.ts +196 -0
- package/tests/file-collection-replication.test.ts +2 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +1 -1
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/plain.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/private.agent.ts +1 -1
- package/tests/fixtures/repl-only/app.ts +3 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +1 -1
- 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/replication-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +2 -0
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +3 -0
- package/tests/replication.test.ts +2 -0
- package/tests/vars-replication.test.ts +2 -0
- package/types/agent.d.ts +237 -7
- package/types/replication.d.ts +41 -4
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP **client** — an agent consuming a remote server's tools.
|
|
3
|
+
*
|
|
4
|
+
* The endpoint under test is the framework's own (`/api/:tenant/mcp`, served by
|
|
5
|
+
* `@hono/mcp`): the SDK client and our server are two independent implementations of
|
|
6
|
+
* the protocol, which is exactly what makes the round trip worth testing end to end —
|
|
7
|
+
* list, call, and back into the conversation as if the tool were local.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
10
|
+
import { Agent, define, v } from "../index";
|
|
11
|
+
import { cfg, formatConfig } from "../server/config";
|
|
12
|
+
import { syncTenants } from "../database/tenant";
|
|
13
|
+
import { syncMcpTools } from "../lib/mcp";
|
|
14
|
+
import { closeMcpClients } from "../lib/mcpClient";
|
|
15
|
+
import { createApp } from "../server/hono";
|
|
16
|
+
import { at, fakeProvider, openaiText, openaiTool, request, type Fake } from "./fixtures/fake-provider";
|
|
17
|
+
|
|
18
|
+
const TENANT = "mcp-client";
|
|
19
|
+
const DIR = "packages/core/tests/fixtures/mcp-tenant";
|
|
20
|
+
|
|
21
|
+
let server: any;
|
|
22
|
+
let mcpUrl: string;
|
|
23
|
+
const servers: Fake[] = [];
|
|
24
|
+
|
|
25
|
+
const track = (fake: Fake): Fake => (servers.push(fake), fake);
|
|
26
|
+
|
|
27
|
+
beforeAll(async () => {
|
|
28
|
+
formatConfig({
|
|
29
|
+
server: { port: 4000 },
|
|
30
|
+
tenants: [{ id: TENANT, dir: DIR, database: { uri: "mongodb://localhost:27017/none" } }],
|
|
31
|
+
});
|
|
32
|
+
await syncTenants();
|
|
33
|
+
await syncMcpTools();
|
|
34
|
+
|
|
35
|
+
server = Bun.serve({ port: 0, fetch: createApp().fetch });
|
|
36
|
+
mcpUrl = `${server.url.href.replace(/\/$/, "")}/api/${TENANT}/mcp`;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
for (const fake of servers) fake.stop();
|
|
41
|
+
await closeMcpClients();
|
|
42
|
+
try { server?.stop(true); } catch (_) {}
|
|
43
|
+
cfg.agents = [];
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function makeAgent(provider: Fake, overrides: Record<string, any> = {}) {
|
|
47
|
+
return new Agent({
|
|
48
|
+
id: "test",
|
|
49
|
+
description: "A test agent.",
|
|
50
|
+
instructions: "You are a test agent.",
|
|
51
|
+
provider: { model: "test-model", apiKey: "test-key", baseUrl: provider.url, options: { retries: 0 } },
|
|
52
|
+
mcp: [mcpUrl],
|
|
53
|
+
...overrides,
|
|
54
|
+
} as any);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("agent mcp — a remote server's tools", () => {
|
|
58
|
+
it("lists them as tools, calls one, and feeds the result back", async () => {
|
|
59
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
60
|
+
hits === 1 ? openaiTool("echo", { msg: "hello mcp", times: 1 }) : openaiText("done")));
|
|
61
|
+
const agent = makeAgent(provider);
|
|
62
|
+
|
|
63
|
+
const result = await agent.generate("echo something");
|
|
64
|
+
|
|
65
|
+
// The remote tools are offered like any other
|
|
66
|
+
const names = request(provider, 0).body.tools.map((tool: any) => tool.function.name);
|
|
67
|
+
expect(names).toContain("echo");
|
|
68
|
+
expect(names).toContain("badge");
|
|
69
|
+
|
|
70
|
+
// …and the call really crossed the wire: the answer is the server's
|
|
71
|
+
const entry = at(result.toolResults, 0);
|
|
72
|
+
expect(entry.error).toBeUndefined();
|
|
73
|
+
expect(entry.result.content[0].text).toContain("hello mcp");
|
|
74
|
+
// What the model saw is the flattened text, not the protocol envelope
|
|
75
|
+
const toolMessage = result.messages.find((message) => message.role === "tool");
|
|
76
|
+
expect(toolMessage?.content).toContain("hello mcp");
|
|
77
|
+
expect(result.text).toBe("done");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("lets a declared tool win over a remote one of the same name", async () => {
|
|
81
|
+
const provider = track(fakeProvider(() => openaiText("done")));
|
|
82
|
+
const local = define.Tool({
|
|
83
|
+
id: "echo",
|
|
84
|
+
description: "The tenant's own echo.",
|
|
85
|
+
inputSchema: v.object({}),
|
|
86
|
+
execute: async () => "local",
|
|
87
|
+
});
|
|
88
|
+
const agent = makeAgent(provider, { tools: { echo: local } });
|
|
89
|
+
|
|
90
|
+
await agent.generate("hi");
|
|
91
|
+
|
|
92
|
+
const offered = request(provider, 0).body.tools.filter((tool: any) => tool.function.name === "echo");
|
|
93
|
+
expect(offered).toHaveLength(1);
|
|
94
|
+
expect(offered[0].function.description).toBe("The tenant's own echo.");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("contacts nothing with `tools: false`", async () => {
|
|
98
|
+
const provider = track(fakeProvider(() => openaiText("direct")));
|
|
99
|
+
const agent = makeAgent(provider);
|
|
100
|
+
|
|
101
|
+
await agent.generate("hi", { tools: false });
|
|
102
|
+
|
|
103
|
+
expect(request(provider, 0).body.tools).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("runs anyway when the server is unreachable", async () => {
|
|
107
|
+
const provider = track(fakeProvider(() => openaiText("still here")));
|
|
108
|
+
const agent = makeAgent(provider, { mcp: ["http://127.0.0.1:9/mcp"] });
|
|
109
|
+
|
|
110
|
+
const result = await agent.generate("hi");
|
|
111
|
+
|
|
112
|
+
// A remote that is down costs its tools, not the run
|
|
113
|
+
expect(result.text).toBe("still here");
|
|
114
|
+
expect(request(provider, 0).body.tools).toBeUndefined();
|
|
115
|
+
}, 20_000);
|
|
116
|
+
});
|
|
@@ -336,7 +336,7 @@ describe("agent memory — end to end", () => {
|
|
|
336
336
|
id: "memo",
|
|
337
337
|
description: "A test agent.",
|
|
338
338
|
instructions: "You are a test agent.",
|
|
339
|
-
provider: { model: "test-model", apiKey: "test-key",
|
|
339
|
+
provider: { model: "test-model", apiKey: "test-key", protocol: "openai", baseUrl: provider.url, options: { retries: 0 } },
|
|
340
340
|
memory,
|
|
341
341
|
} as any);
|
|
342
342
|
agent.setRest(rest);
|
|
@@ -371,7 +371,7 @@ describe("agent memory — end to end", () => {
|
|
|
371
371
|
id: "shorthand",
|
|
372
372
|
description: "A test agent.",
|
|
373
373
|
instructions: "You are a test agent.",
|
|
374
|
-
provider: { model: "test-model", apiKey: "test-key",
|
|
374
|
+
provider: { model: "test-model", apiKey: "test-key", protocol: "openai", baseUrl: provider.url, options: { retries: 0 } },
|
|
375
375
|
memory: new MongoAgentMemory({ collection: MEMORY }),
|
|
376
376
|
} as any);
|
|
377
377
|
agent.setRest(rest);
|
|
@@ -397,7 +397,7 @@ describe("agent memory — end to end", () => {
|
|
|
397
397
|
id: "preview",
|
|
398
398
|
description: "A test agent.",
|
|
399
399
|
instructions: "You are a test agent.",
|
|
400
|
-
provider: { model: "test-model", apiKey: "test-key",
|
|
400
|
+
provider: { model: "test-model", apiKey: "test-key", protocol: "openai", baseUrl: provider.url, options: { retries: 0 } },
|
|
401
401
|
memory: new MongoAgentMemory({ collection: MEMORY }),
|
|
402
402
|
} as any);
|
|
403
403
|
agent.setRest(rest);
|
|
@@ -416,7 +416,7 @@ describe("agent memory — end to end", () => {
|
|
|
416
416
|
id: "plain",
|
|
417
417
|
description: "x",
|
|
418
418
|
instructions: "x",
|
|
419
|
-
provider: { model: "m",
|
|
419
|
+
provider: { model: "m", protocol: "openai", apiKey: "k" },
|
|
420
420
|
} as any);
|
|
421
421
|
expect(await agent.listThreads()).toEqual([]);
|
|
422
422
|
|
|
@@ -424,7 +424,7 @@ describe("agent memory — end to end", () => {
|
|
|
424
424
|
id: "minimal",
|
|
425
425
|
description: "x",
|
|
426
426
|
instructions: "x",
|
|
427
|
-
provider: { model: "m",
|
|
427
|
+
provider: { model: "m", protocol: "openai", apiKey: "k" },
|
|
428
428
|
// A custom store only has to implement get / save / clear
|
|
429
429
|
memory: { get: () => [], save: () => { }, clear: () => { } },
|
|
430
430
|
} as any);
|
|
@@ -599,7 +599,7 @@ describe("agents.memory.Redis", () => {
|
|
|
599
599
|
id: "redis-agent",
|
|
600
600
|
description: "A test agent.",
|
|
601
601
|
instructions: "You are a test agent.",
|
|
602
|
-
provider: { model: "test-model", apiKey: "test-key",
|
|
602
|
+
provider: { model: "test-model", apiKey: "test-key", protocol: "openai", baseUrl: provider.url, options: { retries: 0 } },
|
|
603
603
|
memory: new RedisAgentMemory({ client }),
|
|
604
604
|
} as any);
|
|
605
605
|
agent._tenant_ = "v1";
|
|
@@ -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
|
+
});
|