@anteros/core 0.0.44 → 0.0.47
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/LICENSE +21 -0
- package/README.md +1 -1
- package/database/field.crypto.ts +38 -13
- package/database/mongodbadapter.ts +85 -25
- package/database/vars.ts +22 -2
- package/index.ts +22 -0
- package/lib/agent.ts +707 -79
- package/lib/agentMemory.ts +199 -0
- package/lib/agents.ts +133 -52
- package/lib/attachment.ts +24 -3
- package/lib/define.ts +5 -1
- package/lib/eval.ts +123 -0
- package/lib/files.ts +21 -15
- package/lib/jsonSchema.ts +8 -0
- package/lib/mcp.ts +52 -30
- package/lib/messages.ts +202 -19
- package/lib/partialJson.ts +79 -0
- package/lib/providers.ts +26 -2
- package/lib/redact.ts +11 -0
- package/lib/tracing.ts +20 -0
- package/lib/zip.ts +23 -3
- package/package.json +4 -5
- package/server/agents.ts +66 -1
- package/server/api.ts +302 -20
- package/server/boot.ts +13 -4
- package/server/hono.ts +146 -6
- package/tests/access-log-body.test.ts +253 -0
- package/tests/agent-checkpoint.test.ts +195 -0
- package/tests/agent-hardening.test.ts +238 -0
- package/tests/agent-memory.test.ts +175 -1
- package/tests/agent-stream-advanced.test.ts +246 -0
- package/tests/agent-tracing-eval.test.ts +219 -0
- package/tests/agent-working-memory.test.ts +7 -4
- package/tests/agent.test.ts +54 -3
- package/tests/agents-api.test.ts +21 -0
- package/tests/api-external-guards.test.ts +304 -0
- package/tests/attachment.test.ts +63 -0
- package/tests/audit-redact.test.ts +26 -1
- package/tests/field-encryption.test.ts +47 -3
- package/tests/filters-hardening.test.ts +81 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
- package/tests/fixtures/fake-provider.ts +33 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
- package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
- package/tests/hono.test.ts +81 -1
- package/tests/jwt-tenant.test.ts +165 -0
- package/tests/logger.test.ts +62 -10
- package/tests/messages.test.ts +88 -32
- package/tests/tool-runtime.test.ts +291 -0
- package/tests/tool-types.ts +33 -0
- package/tests/upload.test.ts +175 -3
- package/tests/vars-api.test.ts +73 -1
- package/tests/vars.test.ts +1 -1
- package/types/agent.d.ts +386 -4
- package/types/config.d.ts +56 -2
- package/types/file.d.ts +1 -1
- package/types/mcp.d.ts +2 -2
- package/utils/func.ts +20 -0
- package/utils/logger.ts +125 -33
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, it, expect, afterAll } from "bun:test";
|
|
2
|
+
import { Agent, define, v } from "../index";
|
|
3
|
+
import { parsePartialJson } from "../lib/partialJson";
|
|
4
|
+
import {
|
|
5
|
+
at,
|
|
6
|
+
fakeProvider,
|
|
7
|
+
openaiStream,
|
|
8
|
+
openaiText,
|
|
9
|
+
openaiToolCalls,
|
|
10
|
+
request,
|
|
11
|
+
sse,
|
|
12
|
+
type Fake,
|
|
13
|
+
} from "./fixtures/fake-provider";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The advanced streaming surface: a delegated run streams through its parent,
|
|
17
|
+
* tool results pass through guardrails, and `streamObject()` yields partial
|
|
18
|
+
* objects as the answer is written.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const servers: Fake[] = [];
|
|
22
|
+
function track(fake: Fake): Fake {
|
|
23
|
+
servers.push(fake);
|
|
24
|
+
return fake;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterAll(() => {
|
|
28
|
+
for (const server of servers) server.stop();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function makeAgent(overrides: Record<string, any> = {}, provider: Record<string, any> = {}) {
|
|
32
|
+
return new Agent({
|
|
33
|
+
id: "advanced",
|
|
34
|
+
description: "An advanced test agent.",
|
|
35
|
+
instructions: "Answer.",
|
|
36
|
+
provider: {
|
|
37
|
+
model: "parent-model",
|
|
38
|
+
apiKey: "test-key",
|
|
39
|
+
protocol: "openai",
|
|
40
|
+
options: { retries: 0 },
|
|
41
|
+
...provider,
|
|
42
|
+
},
|
|
43
|
+
...overrides,
|
|
44
|
+
} as any);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A streamed `tool_calls` turn — a streamed call only reads `data:` events. */
|
|
48
|
+
function openaiToolStream(name: string, args: any, id = "call_1"): Response {
|
|
49
|
+
return sse([
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
choices: [{
|
|
52
|
+
delta: {
|
|
53
|
+
tool_calls: [{
|
|
54
|
+
index: 0,
|
|
55
|
+
id,
|
|
56
|
+
type: "function",
|
|
57
|
+
function: { name, arguments: JSON.stringify(args) },
|
|
58
|
+
}],
|
|
59
|
+
},
|
|
60
|
+
}],
|
|
61
|
+
}),
|
|
62
|
+
JSON.stringify({ choices: [{ delta: {}, finish_reason: "tool_calls" }] }),
|
|
63
|
+
"[DONE]",
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Drain a stream to the end and return what it produced. */
|
|
68
|
+
async function drain<T>(stream: AsyncIterable<T>): Promise<T[]> {
|
|
69
|
+
const out: T[] = [];
|
|
70
|
+
for await (const chunk of stream) out.push(chunk);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("sub-agent streaming", () => {
|
|
75
|
+
it("re-emits the child's chunks and folds its tool stats into the parent's", async () => {
|
|
76
|
+
const child = track(fakeProvider((_body, _req, hits) =>
|
|
77
|
+
hits === 1 ? openaiToolStream("lookup", {}) : openaiStream(["child ", "answer"])));
|
|
78
|
+
const parent = track(fakeProvider((_body, _req, hits) =>
|
|
79
|
+
hits === 1 ? openaiToolStream("helper", { input: "do it" }) : openaiStream(["parent ", "answer"])));
|
|
80
|
+
|
|
81
|
+
const helper = define.Agent({
|
|
82
|
+
id: "helper",
|
|
83
|
+
description: "A delegated agent",
|
|
84
|
+
instructions: "Help.",
|
|
85
|
+
provider: {
|
|
86
|
+
model: "child-model",
|
|
87
|
+
apiKey: "k",
|
|
88
|
+
protocol: "openai",
|
|
89
|
+
baseUrl: child.url,
|
|
90
|
+
options: { retries: 0 },
|
|
91
|
+
},
|
|
92
|
+
tools: {
|
|
93
|
+
lookup: define.Tool({
|
|
94
|
+
id: "lookup",
|
|
95
|
+
description: "Looks something up",
|
|
96
|
+
inputSchema: v.object({}),
|
|
97
|
+
execute: () => ({ hit: "child tool" }),
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const agent = makeAgent({ agents: [helper] }, { baseUrl: parent.url });
|
|
103
|
+
const run = agent.stream("go", { maxSteps: 3 });
|
|
104
|
+
const chunks = await drain(run.fullStream);
|
|
105
|
+
|
|
106
|
+
// The child's text travels **wrapped**, never on the parent's own channel
|
|
107
|
+
const wrapped = chunks.filter((chunk): chunk is Extract<typeof chunk, { type: "subagent" }> =>
|
|
108
|
+
chunk.type === "subagent");
|
|
109
|
+
expect(wrapped.every((chunk) => chunk.name === "helper")).toBe(true);
|
|
110
|
+
const childText = wrapped
|
|
111
|
+
.filter((chunk) => chunk.chunk.type === "text")
|
|
112
|
+
.map((chunk) => (chunk.chunk as { type: "text"; text: string }).text)
|
|
113
|
+
.join("");
|
|
114
|
+
expect(childText).toBe("child answer");
|
|
115
|
+
|
|
116
|
+
const ownText = chunks
|
|
117
|
+
.filter((chunk) => chunk.type === "text")
|
|
118
|
+
.map((chunk) => (chunk as { type: "text"; text: string }).text)
|
|
119
|
+
.join("");
|
|
120
|
+
expect(ownText).toBe("parent answer");
|
|
121
|
+
expect(await run.text).toBe("parent answer");
|
|
122
|
+
|
|
123
|
+
const results = await run.toolResults;
|
|
124
|
+
expect(at(results, 0).result).toBe("child answer");
|
|
125
|
+
|
|
126
|
+
// One parent call (the delegation) + one child call (its tool)
|
|
127
|
+
const stats = await run.toolStats;
|
|
128
|
+
expect(stats.calls).toBe(2);
|
|
129
|
+
expect(stats.errors).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("tool-result guardrails", () => {
|
|
134
|
+
it("blocks a result the model must not read, and finishes the run", async () => {
|
|
135
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
136
|
+
hits === 1
|
|
137
|
+
? openaiToolCalls([{ name: "safe", args: {} }, { name: "leaky", args: {} }])
|
|
138
|
+
: openaiText("done")));
|
|
139
|
+
|
|
140
|
+
const agent = makeAgent({
|
|
141
|
+
tools: {
|
|
142
|
+
safe: define.Tool({
|
|
143
|
+
id: "safe",
|
|
144
|
+
description: "Returns something harmless",
|
|
145
|
+
inputSchema: v.object({}),
|
|
146
|
+
execute: () => ({ ok: true }),
|
|
147
|
+
}),
|
|
148
|
+
leaky: define.Tool({
|
|
149
|
+
id: "leaky",
|
|
150
|
+
description: "Returns a secret",
|
|
151
|
+
inputSchema: v.object({}),
|
|
152
|
+
execute: () => ({ token: "sk-live-123" }),
|
|
153
|
+
}),
|
|
154
|
+
},
|
|
155
|
+
guardrails: {
|
|
156
|
+
tool: [{
|
|
157
|
+
name: "no-secret",
|
|
158
|
+
check: (value: string) => {
|
|
159
|
+
if (value.includes("sk-live")) throw new Error("secret in tool result");
|
|
160
|
+
},
|
|
161
|
+
}],
|
|
162
|
+
},
|
|
163
|
+
}, { baseUrl: provider.url });
|
|
164
|
+
|
|
165
|
+
const result = await agent.generate("go", { maxSteps: 3 });
|
|
166
|
+
|
|
167
|
+
// The run finished — the result was blocked, not the run
|
|
168
|
+
expect(result.text).toBe("done");
|
|
169
|
+
expect(result.finishReason).toBe("stop");
|
|
170
|
+
|
|
171
|
+
const safe = at(result.toolResults, 0);
|
|
172
|
+
const blocked = at(result.toolResults, 1);
|
|
173
|
+
expect(safe.name).toBe("safe");
|
|
174
|
+
expect(safe.error).toBeUndefined();
|
|
175
|
+
expect(safe.result).toEqual({ ok: true });
|
|
176
|
+
|
|
177
|
+
expect(blocked.error).toBe("Guardrail 'no-secret' blocked the tool result: secret in tool result");
|
|
178
|
+
expect(blocked.errorCode).toBe("AGENT_GUARDRAIL_BLOCKED");
|
|
179
|
+
expect("result" in blocked).toBe(false);
|
|
180
|
+
expect(result.toolStats.errors).toBe(1);
|
|
181
|
+
expect(result.toolStats.byCode.AGENT_GUARDRAIL_BLOCKED).toBe(1);
|
|
182
|
+
|
|
183
|
+
// The model reads the refusal as a tool error, with a code it can branch on
|
|
184
|
+
const toolTurns = request(provider, 1).body.messages.filter((message: any) => message.role === "tool");
|
|
185
|
+
const leakyTurn = toolTurns.find((message: any) => String(message.content).includes("AGENT_GUARDRAIL_BLOCKED"));
|
|
186
|
+
expect(leakyTurn).toBeDefined();
|
|
187
|
+
expect(String(leakyTurn.content)).toContain("Error [AGENT_GUARDRAIL_BLOCKED]");
|
|
188
|
+
expect(String(leakyTurn.content)).not.toContain("sk-live");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("parsePartialJson", () => {
|
|
193
|
+
it("closes an open string, an open object and an open array", () => {
|
|
194
|
+
expect(parsePartialJson('{"city": "Par')).toEqual({ city: "Par" });
|
|
195
|
+
expect(parsePartialJson('{"cities": ["Paris", "Lyo')).toEqual({ cities: ["Paris", "Lyo"] });
|
|
196
|
+
expect(parsePartialJson('{"a": {"b": [1, 2')).toEqual({ a: { b: [1, 2] } });
|
|
197
|
+
expect(parsePartialJson('{"a": 1,')).toEqual({ a: 1 });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("survives a dangling escape and gives up when the JSON cannot be repaired", () => {
|
|
201
|
+
expect(parsePartialJson('{"a": "x\\')).toEqual({ a: "x" });
|
|
202
|
+
expect(parsePartialJson('{"a": ')).toBeUndefined();
|
|
203
|
+
expect(parsePartialJson("")).toBeUndefined();
|
|
204
|
+
expect(parsePartialJson("not json")).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("strips a markdown fence, leading and trailing", () => {
|
|
208
|
+
expect(parsePartialJson('```json\n{"a": 1}')).toEqual({ a: 1 });
|
|
209
|
+
expect(parsePartialJson('```json\n{"a": [1, 2]\n```')).toEqual({ a: [1, 2] });
|
|
210
|
+
expect(parsePartialJson('{"a": {"b": "c')).toEqual({ a: { b: "c" } });
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("streamObject", () => {
|
|
215
|
+
it("yields parsed partials and the final validated object", async () => {
|
|
216
|
+
const provider = track(fakeProvider(() =>
|
|
217
|
+
openaiStream(["{", '"cities":', ' ["Paris"', ', "Lyon"]', "}"])));
|
|
218
|
+
|
|
219
|
+
const schema = v.object({ cities: v.array().items(v.string()).required() });
|
|
220
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
221
|
+
|
|
222
|
+
const run = agent.streamObject<{ cities: string[] }>("List cities", { schema });
|
|
223
|
+
const partials: Array<{ cities: string[] } | undefined> = [];
|
|
224
|
+
for await (const partial of run.partialObjectStream) partials.push(partial);
|
|
225
|
+
|
|
226
|
+
const parsed = partials.filter((partial): partial is { cities: string[] } => partial !== undefined);
|
|
227
|
+
expect(parsed.length).toBeGreaterThan(0);
|
|
228
|
+
expect(parsed.some((partial) => Array.isArray(partial.cities))).toBe(true);
|
|
229
|
+
|
|
230
|
+
expect(await run.object).toEqual({ cities: ["Paris", "Lyon"] });
|
|
231
|
+
expect(await run.text).toContain("Lyon");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("rejects without a schema", () => {
|
|
235
|
+
const agent = makeAgent();
|
|
236
|
+
|
|
237
|
+
let error: any;
|
|
238
|
+
try {
|
|
239
|
+
agent.streamObject("hi");
|
|
240
|
+
} catch (err) {
|
|
241
|
+
error = err;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
expect(error?.code).toBe("AGENT_SCHEMA_REQUIRED");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, it, expect, afterAll } from "bun:test";
|
|
2
|
+
import { Agent, define, evaluateAgent, v } from "../index";
|
|
3
|
+
import type { AgentTraceEvent } from "../index";
|
|
4
|
+
import {
|
|
5
|
+
at,
|
|
6
|
+
fakeProvider,
|
|
7
|
+
openaiText,
|
|
8
|
+
openaiTool,
|
|
9
|
+
request,
|
|
10
|
+
type Fake,
|
|
11
|
+
} from "./fixtures/fake-provider";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The tracing hook (`onTrace`) and the evaluation helper (`evaluateAgent`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const servers: Fake[] = [];
|
|
18
|
+
function track(fake: Fake): Fake {
|
|
19
|
+
servers.push(fake);
|
|
20
|
+
return fake;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
for (const server of servers) server.stop();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function makeAgent(overrides: Record<string, any> = {}, provider: Record<string, any> = {}) {
|
|
28
|
+
return new Agent({
|
|
29
|
+
id: "traced",
|
|
30
|
+
description: "A traced test agent.",
|
|
31
|
+
instructions: "Answer.",
|
|
32
|
+
provider: {
|
|
33
|
+
model: "test-model",
|
|
34
|
+
apiKey: "test-key",
|
|
35
|
+
protocol: "openai",
|
|
36
|
+
options: { retries: 0 },
|
|
37
|
+
...provider,
|
|
38
|
+
},
|
|
39
|
+
...overrides,
|
|
40
|
+
} as any);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function echoTool() {
|
|
44
|
+
return define.Tool({
|
|
45
|
+
id: "echo",
|
|
46
|
+
description: "Echoes the text back.",
|
|
47
|
+
inputSchema: v.object({ text: v.string().required() }),
|
|
48
|
+
execute: ({ text }: any) => `echo:${text}`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("agent tracing", () => {
|
|
53
|
+
it("emits the six event types, in order, with their fields", async () => {
|
|
54
|
+
const provider = track(fakeProvider((_body, _req, hits) =>
|
|
55
|
+
hits === 1 ? openaiTool("echo", { text: "hello" }) : openaiText("done")));
|
|
56
|
+
const agent = makeAgent({ tools: { echo: echoTool() } }, { baseUrl: provider.url });
|
|
57
|
+
|
|
58
|
+
const events: AgentTraceEvent[] = [];
|
|
59
|
+
const result = await agent.generate("echo hello", { onTrace: (event) => events.push(event) });
|
|
60
|
+
|
|
61
|
+
expect(result.text).toBe("done");
|
|
62
|
+
expect(events.map((event) => event.type)).toEqual([
|
|
63
|
+
"run.start",
|
|
64
|
+
"step.start",
|
|
65
|
+
"model.end",
|
|
66
|
+
"tool.start",
|
|
67
|
+
"tool.end",
|
|
68
|
+
"step.start",
|
|
69
|
+
"model.end",
|
|
70
|
+
"run.end",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
expect(at(events, 0)).toMatchObject({ type: "run.start", agentId: "traced" });
|
|
74
|
+
expect(at(events, 1)).toMatchObject({ type: "step.start", step: 0 });
|
|
75
|
+
expect(at(events, 2)).toMatchObject({ type: "model.end", step: 0, model: "test-model", finishReason: "tool_use" });
|
|
76
|
+
expect(at(events, 3)).toMatchObject({ type: "tool.start", step: 0, tool: "echo", toolCallId: "call_1" });
|
|
77
|
+
|
|
78
|
+
const toolEnd = at(events, 4) as Extract<AgentTraceEvent, { type: "tool.end" }>;
|
|
79
|
+
expect(toolEnd).toMatchObject({ type: "tool.end", step: 0, tool: "echo", toolCallId: "call_1" });
|
|
80
|
+
expect(toolEnd.durationMs).toBeGreaterThanOrEqual(0);
|
|
81
|
+
expect(toolEnd.errorCode).toBeUndefined();
|
|
82
|
+
|
|
83
|
+
const lastModel = at(events, 6) as Extract<AgentTraceEvent, { type: "model.end" }>;
|
|
84
|
+
expect(lastModel).toMatchObject({ step: 1, model: "test-model", finishReason: "stop" });
|
|
85
|
+
expect(lastModel.durationMs).toBeGreaterThanOrEqual(0);
|
|
86
|
+
expect(lastModel.usage.requests).toBe(1);
|
|
87
|
+
|
|
88
|
+
const end = at(events, 7) as Extract<AgentTraceEvent, { type: "run.end" }>;
|
|
89
|
+
expect(end).toMatchObject({ type: "run.end", finishReason: "stop" });
|
|
90
|
+
expect(end.durationMs).toBeGreaterThanOrEqual(0);
|
|
91
|
+
expect(end.toolStats.calls).toBe(1);
|
|
92
|
+
expect(end.usage.requests).toBe(2);
|
|
93
|
+
expect(end.error).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("never breaks a run when the tracer throws", async () => {
|
|
97
|
+
const provider = track(fakeProvider(() => openaiText("still fine")));
|
|
98
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
99
|
+
|
|
100
|
+
const result = await agent.generate("hi", {
|
|
101
|
+
onTrace: () => {
|
|
102
|
+
throw new Error("tracer boom");
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result.text).toBe("still fine");
|
|
107
|
+
expect(provider.hits()).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("hands the call's runId to run.start", async () => {
|
|
111
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
112
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
113
|
+
|
|
114
|
+
const events: AgentTraceEvent[] = [];
|
|
115
|
+
await agent.generate("hi", { runId: "run-42", onTrace: (event) => events.push(event) });
|
|
116
|
+
|
|
117
|
+
expect(at(events, 0)).toMatchObject({ type: "run.start", runId: "run-42" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("traces nothing when an input guardrail blocks the prompt", async () => {
|
|
121
|
+
const provider = track(fakeProvider(() => openaiText("never")));
|
|
122
|
+
const agent = makeAgent({
|
|
123
|
+
guardrails: {
|
|
124
|
+
input: [{ name: "no-secrets", check: () => { throw new Error("blocked"); } }],
|
|
125
|
+
},
|
|
126
|
+
}, { baseUrl: provider.url });
|
|
127
|
+
|
|
128
|
+
const events: AgentTraceEvent[] = [];
|
|
129
|
+
await expect(agent.generate("a secret", { onTrace: (event) => events.push(event) }))
|
|
130
|
+
.rejects.toMatchObject({ code: "AGENT_GUARDRAIL_BLOCKED" });
|
|
131
|
+
|
|
132
|
+
expect(events).toEqual([]);
|
|
133
|
+
expect(provider.hits()).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("traces the end of a run that throws", async () => {
|
|
137
|
+
const provider = track(fakeProvider(() => Response.json({ error: "boom" }, { status: 400 })));
|
|
138
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
139
|
+
|
|
140
|
+
const events: AgentTraceEvent[] = [];
|
|
141
|
+
await expect(agent.generate("hi", { onTrace: (event) => events.push(event) })).rejects.toBeDefined();
|
|
142
|
+
|
|
143
|
+
const end = at(events, events.length - 1) as Extract<AgentTraceEvent, { type: "run.end" }>;
|
|
144
|
+
expect(end.type).toBe("run.end");
|
|
145
|
+
expect(end.finishReason).toBe("error");
|
|
146
|
+
expect(end.error).toBeTruthy();
|
|
147
|
+
expect(end.error).not.toContain("\n");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("evaluateAgent", () => {
|
|
152
|
+
it("scores cases sequentially with their graders, merging the case options last", async () => {
|
|
153
|
+
const provider = track(fakeProvider((body) => {
|
|
154
|
+
const messages = body.messages as Array<{ role: string; content: string }>;
|
|
155
|
+
const lastUser = [...messages].reverse().find((message) => message.role === "user");
|
|
156
|
+
return openaiText(`answer:${lastUser?.content ?? ""}`);
|
|
157
|
+
}));
|
|
158
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
159
|
+
|
|
160
|
+
const seen: string[] = [];
|
|
161
|
+
const report = await evaluateAgent({
|
|
162
|
+
agent,
|
|
163
|
+
cases: [
|
|
164
|
+
{ name: "alpha", input: "alpha" },
|
|
165
|
+
{ name: "beta", input: "beta", options: { instructions: "case-two" } },
|
|
166
|
+
],
|
|
167
|
+
graders: [
|
|
168
|
+
(result, testCase) => {
|
|
169
|
+
seen.push(`${testCase.name}:${result.text}`);
|
|
170
|
+
return { score: 1, reason: `answered ${testCase.name}` };
|
|
171
|
+
},
|
|
172
|
+
() => {
|
|
173
|
+
throw new Error("grader exploded");
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
options: { instructions: "shared" },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(report.score).toBe(0.5);
|
|
180
|
+
expect(report.passed).toBe(2);
|
|
181
|
+
expect(report.cases.map((testCase) => testCase.name)).toEqual(["alpha", "beta"]);
|
|
182
|
+
expect(report.cases.map((testCase) => testCase.score)).toEqual([0.5, 0.5]);
|
|
183
|
+
expect(report.cases.map((testCase) => testCase.reasons)).toEqual([
|
|
184
|
+
["answered alpha", "grader exploded"],
|
|
185
|
+
["answered beta", "grader exploded"],
|
|
186
|
+
]);
|
|
187
|
+
expect(at(report.cases, 0).finishReason).toBe("stop");
|
|
188
|
+
expect(at(report.cases, 0).usage.requests).toBe(1);
|
|
189
|
+
|
|
190
|
+
// Sequential: the cases ran one after the other, in order
|
|
191
|
+
expect(seen).toEqual(["alpha:answer:alpha", "beta:answer:beta"]);
|
|
192
|
+
expect(provider.hits()).toBe(2);
|
|
193
|
+
|
|
194
|
+
// The case's options win over the shared ones
|
|
195
|
+
expect(at(request(provider, 0).body.messages, 0)).toMatchObject({ role: "system", content: "shared" });
|
|
196
|
+
expect(at(request(provider, 1).body.messages, 0)).toMatchObject({ role: "system", content: "case-two" });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reports nothing to score for an empty list of cases", async () => {
|
|
200
|
+
const provider = track(fakeProvider(() => openaiText("unused")));
|
|
201
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
202
|
+
|
|
203
|
+
const report = await evaluateAgent({ agent, cases: [], graders: [() => 1] });
|
|
204
|
+
|
|
205
|
+
expect(report).toEqual({ score: 0, passed: 0, cases: [] });
|
|
206
|
+
expect(provider.hits()).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("gives a case score 0 when it has no grader", async () => {
|
|
210
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
211
|
+
const agent = makeAgent({}, { baseUrl: provider.url });
|
|
212
|
+
|
|
213
|
+
const report = await evaluateAgent({ agent, cases: [{ name: "ungraded", input: "hi" }], graders: [] });
|
|
214
|
+
|
|
215
|
+
expect(report.score).toBe(0);
|
|
216
|
+
expect(report.passed).toBe(0);
|
|
217
|
+
expect(at(report.cases, 0)).toMatchObject({ name: "ungraded", score: 0, reasons: [] });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -396,9 +396,12 @@ describe("working memory — Anthropic", () => {
|
|
|
396
396
|
});
|
|
397
397
|
|
|
398
398
|
describe("working memory — the registry exposes nothing new by accident", () => {
|
|
399
|
-
it("
|
|
400
|
-
//
|
|
401
|
-
// your
|
|
402
|
-
|
|
399
|
+
it("lists exactly the store and processor factories", () => {
|
|
400
|
+
// Stores: InMemory, Mongo, Redis, custom (your own handlers).
|
|
401
|
+
// Processors: `semanticRecall` (your embeddings + vector index) and
|
|
402
|
+
// `summarize` (your model + prompt) — they shape the prompt, they store nothing.
|
|
403
|
+
expect(Object.keys(agents.memory).sort()).toEqual([
|
|
404
|
+
"InMemory", "Mongo", "Redis", "custom", "semanticRecall", "summarize",
|
|
405
|
+
]);
|
|
403
406
|
});
|
|
404
407
|
});
|
package/tests/agent.test.ts
CHANGED
|
@@ -147,6 +147,15 @@ describe("agent … files", () => {
|
|
|
147
147
|
expect(content).not.toContain("%PDF-1.7");
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
it("refuses a path that escapes the base directory", async () => {
|
|
151
|
+
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
152
|
+
const agent = attachmentAgent({ baseUrl: provider.url });
|
|
153
|
+
|
|
154
|
+
await expect(agent.generate("hi", { files: ["../../../package.json"] }))
|
|
155
|
+
.rejects.toMatchObject({ code: "AGENT_FILE_INVALID" });
|
|
156
|
+
expect(provider.hits()).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
150
159
|
it("refuses an unreadable file before calling the model", async () => {
|
|
151
160
|
const provider = track(fakeProvider(() => openaiText("ok")));
|
|
152
161
|
const agent = attachmentAgent({ baseUrl: provider.url });
|
|
@@ -585,7 +594,7 @@ describe("Agent", () => {
|
|
|
585
594
|
expect(definition._isAgent_).toBe(true);
|
|
586
595
|
expect(definition.enabled).toBe(true);
|
|
587
596
|
|
|
588
|
-
const tool = define.Tool({ id: "t", execute: () => 1 });
|
|
597
|
+
const tool = define.Tool({ id: "t", description: "t", execute: () => 1 });
|
|
589
598
|
expect(tool._isTool_).toBe(true);
|
|
590
599
|
expect(tool.enabled).toBe(true);
|
|
591
600
|
});
|
|
@@ -617,6 +626,13 @@ describe("Agent", () => {
|
|
|
617
626
|
expect(agent.toJSON().tools).toEqual(["forecast"]);
|
|
618
627
|
});
|
|
619
628
|
|
|
629
|
+
it("never exposes sensitive provider headers in getProvider() / toJSON()", () => {
|
|
630
|
+
const agent = makeAgent({}, { headers: { "x-api-key": "header-secret", "x-team": "core" } });
|
|
631
|
+
|
|
632
|
+
expect(agent.getProvider().headers).toEqual({ "x-team": "core" });
|
|
633
|
+
expect(JSON.stringify(agent.toJSON())).not.toContain("header-secret");
|
|
634
|
+
});
|
|
635
|
+
|
|
620
636
|
it("keeps the provider's options under `provider.options`", () => {
|
|
621
637
|
const agent = makeAgent({}, {}, { temperature: 0.2, maxTokens: 1000 });
|
|
622
638
|
const declared = {
|
|
@@ -741,7 +757,7 @@ describe("agent.generate", () => {
|
|
|
741
757
|
|
|
742
758
|
expect(cities).toEqual([]);
|
|
743
759
|
expect(at(result.toolResults, 0).error).toContain("Invalid arguments for tool 'forecast'");
|
|
744
|
-
expect(request(provider, 1).body.messages[3].content).toContain("Error:");
|
|
760
|
+
expect(request(provider, 1).body.messages[3].content).toContain("Error [AGENT_TOOL_ARGS_INVALID]:");
|
|
745
761
|
});
|
|
746
762
|
|
|
747
763
|
it("reports an unknown tool and a throwing tool, and keeps going", async () => {
|
|
@@ -754,7 +770,7 @@ describe("agent.generate", () => {
|
|
|
754
770
|
|
|
755
771
|
const agent = makeAgent({
|
|
756
772
|
tools: {
|
|
757
|
-
boom: define.Tool({ id: "boom", execute: () => { throw new Error("kaboom"); } }),
|
|
773
|
+
boom: define.Tool({ id: "boom", description: "Fails", execute: () => { throw new Error("kaboom"); } }),
|
|
758
774
|
},
|
|
759
775
|
}, { baseUrl: provider.url });
|
|
760
776
|
|
|
@@ -1239,3 +1255,38 @@ describe("syncAgents", () => {
|
|
|
1239
1255
|
expect(agents.reload).toBe(syncAgents);
|
|
1240
1256
|
});
|
|
1241
1257
|
});
|
|
1258
|
+
|
|
1259
|
+
describe("syncAgents — ai/ layout", () => {
|
|
1260
|
+
const TENANT = "ai-fixture";
|
|
1261
|
+
const TENANT_DIR = "packages/core/tests/fixtures/ai-tenant";
|
|
1262
|
+
|
|
1263
|
+
beforeAll(async () => {
|
|
1264
|
+
cfg.tenants = [{ id: TENANT, dir: TENANT_DIR, database: { uri: "mongodb://localhost:27017/none" } }] as any;
|
|
1265
|
+
await syncAgents();
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
afterAll(() => {
|
|
1269
|
+
cfg.agents = [];
|
|
1270
|
+
cfg.mcpTools = [];
|
|
1271
|
+
cfg.mcpResources = [];
|
|
1272
|
+
cfg.tenants = [];
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it("loads agents from ai/agents, with the tools declared beside them", () => {
|
|
1276
|
+
const registry = createAgents(TENANT);
|
|
1277
|
+
expect(registry.ids().sort()).toEqual(["billing", "support"]);
|
|
1278
|
+
|
|
1279
|
+
// `support.agent.ts` + `support.tool.ts`, same directory
|
|
1280
|
+
expect(registry.get("support")!.hasTool("greet")).toBe(true);
|
|
1281
|
+
// `support/billing.agent.ts` + `support/billing.tool.ts`, sub-directory
|
|
1282
|
+
expect(registry.get("billing")!.hasTool("refund")).toBe(true);
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
it("reads ai/mcp for tools and resources", async () => {
|
|
1286
|
+
const { syncMcpTools } = await import("../lib/mcp");
|
|
1287
|
+
await syncMcpTools();
|
|
1288
|
+
|
|
1289
|
+
expect(cfg.mcpTools?.some((tool) => tool._tenant_ === TENANT && tool.name === "echo")).toBe(true);
|
|
1290
|
+
expect(cfg.mcpResources?.some((resource) => resource._tenant_ === TENANT && resource.name === "readme")).toBe(true);
|
|
1291
|
+
});
|
|
1292
|
+
});
|
package/tests/agents-api.test.ts
CHANGED
|
@@ -398,6 +398,27 @@ describe("agents HTTP API — actions", () => {
|
|
|
398
398
|
expect(before).toBeLessThan(provider.hits());
|
|
399
399
|
});
|
|
400
400
|
|
|
401
|
+
it("refuses a server path in `files` — content only over HTTP", async () => {
|
|
402
|
+
const before = provider.hits();
|
|
403
|
+
const invalid = [
|
|
404
|
+
["../../../package.json"],
|
|
405
|
+
["/etc/hosts"],
|
|
406
|
+
[{ name: "a.csv" }],
|
|
407
|
+
[{ name: "a.csv", data: 42 }],
|
|
408
|
+
[{ name: "a.csv", data: "AAAA", encoding: "hex" }],
|
|
409
|
+
"not-an-array",
|
|
410
|
+
[null],
|
|
411
|
+
];
|
|
412
|
+
|
|
413
|
+
for (const files of invalid) {
|
|
414
|
+
const res = await json("assistant", "generate", { input: "hi", files });
|
|
415
|
+
expect({ files, status: res.status, code: res.body.code })
|
|
416
|
+
.toEqual({ files, status: 400, code: "AGENT_FILES_INVALID" });
|
|
417
|
+
}
|
|
418
|
+
// Nothing reached the model
|
|
419
|
+
expect(provider.hits()).toBe(before);
|
|
420
|
+
});
|
|
421
|
+
|
|
401
422
|
it("refuses a file a model cannot read", async () => {
|
|
402
423
|
const res = await json("assistant", "generate", {
|
|
403
424
|
input: "hi",
|