@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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/database/field.crypto.ts +38 -13
  4. package/database/mongodbadapter.ts +85 -25
  5. package/database/vars.ts +22 -2
  6. package/index.ts +22 -0
  7. package/lib/agent.ts +707 -79
  8. package/lib/agentMemory.ts +199 -0
  9. package/lib/agents.ts +133 -52
  10. package/lib/attachment.ts +24 -3
  11. package/lib/define.ts +5 -1
  12. package/lib/eval.ts +123 -0
  13. package/lib/files.ts +21 -15
  14. package/lib/jsonSchema.ts +8 -0
  15. package/lib/mcp.ts +52 -30
  16. package/lib/messages.ts +202 -19
  17. package/lib/partialJson.ts +79 -0
  18. package/lib/providers.ts +26 -2
  19. package/lib/redact.ts +11 -0
  20. package/lib/tracing.ts +20 -0
  21. package/lib/zip.ts +23 -3
  22. package/package.json +4 -5
  23. package/server/agents.ts +66 -1
  24. package/server/api.ts +302 -20
  25. package/server/boot.ts +13 -4
  26. package/server/hono.ts +146 -6
  27. package/tests/access-log-body.test.ts +253 -0
  28. package/tests/agent-checkpoint.test.ts +195 -0
  29. package/tests/agent-hardening.test.ts +238 -0
  30. package/tests/agent-memory.test.ts +175 -1
  31. package/tests/agent-stream-advanced.test.ts +246 -0
  32. package/tests/agent-tracing-eval.test.ts +219 -0
  33. package/tests/agent-working-memory.test.ts +7 -4
  34. package/tests/agent.test.ts +54 -3
  35. package/tests/agents-api.test.ts +21 -0
  36. package/tests/api-external-guards.test.ts +304 -0
  37. package/tests/attachment.test.ts +63 -0
  38. package/tests/audit-redact.test.ts +26 -1
  39. package/tests/field-encryption.test.ts +47 -3
  40. package/tests/filters-hardening.test.ts +81 -0
  41. package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
  42. package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
  43. package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
  44. package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
  45. package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
  46. package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
  47. package/tests/fixtures/fake-provider.ts +33 -0
  48. package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
  49. package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
  50. package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
  51. package/tests/hono.test.ts +81 -1
  52. package/tests/jwt-tenant.test.ts +165 -0
  53. package/tests/logger.test.ts +62 -10
  54. package/tests/messages.test.ts +88 -32
  55. package/tests/tool-runtime.test.ts +291 -0
  56. package/tests/tool-types.ts +33 -0
  57. package/tests/upload.test.ts +175 -3
  58. package/tests/vars-api.test.ts +73 -1
  59. package/tests/vars.test.ts +1 -1
  60. package/types/agent.d.ts +386 -4
  61. package/types/config.d.ts +56 -2
  62. package/types/file.d.ts +1 -1
  63. package/types/mcp.d.ts +2 -2
  64. package/utils/func.ts +20 -0
  65. 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("keeps `agents.memory` as the only store factory", () => {
400
- // `summarize` is the one deliberate addition: a **processor** factory (it needs
401
- // your model and your prompt), not a store
402
- expect(Object.keys(agents.memory).sort()).toEqual(["InMemory", "Mongo", "Redis", "summarize"]);
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
  });
@@ -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
+ });
@@ -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",