@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,195 @@
1
+ /**
2
+ * The durable checkpoint — a run that dies mid-loop (provider crash, process
3
+ * restart) continues from its last completed step instead of paying for the whole
4
+ * conversation again:
5
+ *
6
+ * - the state is saved at a step boundary, never half a step;
7
+ * - `resumeRun()` asks the model for the **next** step only;
8
+ * - a run that comes back clears its state, a failed one keeps it;
9
+ * - a store that misbehaves never fails the run.
10
+ */
11
+ import { describe, it, expect, afterAll, spyOn } from "bun:test";
12
+ import { Agent, define, v } from "../index";
13
+ import type { AgentRunState, AgentRunStore } from "../index";
14
+ import { at, fakeProvider, openaiText, openaiTool, request, type Fake } from "./fixtures/fake-provider";
15
+
16
+ const servers: Fake[] = [];
17
+ function track(fake: Fake): Fake {
18
+ servers.push(fake);
19
+ return fake;
20
+ }
21
+
22
+ afterAll(() => {
23
+ for (const server of servers) server.stop();
24
+ });
25
+
26
+ /** A store backed by a `Map`, counting what it is asked to do. */
27
+ function memoryStore() {
28
+ const states = new Map<string, AgentRunState>();
29
+ const calls = { loads: 0, saves: 0, clears: 0 };
30
+
31
+ const store = {
32
+ states,
33
+ calls,
34
+ load(runId: string) {
35
+ calls.loads += 1;
36
+ return states.get(runId);
37
+ },
38
+ save(runId: string, state: AgentRunState) {
39
+ calls.saves += 1;
40
+ states.set(runId, state);
41
+ },
42
+ clear(runId: string) {
43
+ calls.clears += 1;
44
+ states.delete(runId);
45
+ },
46
+ };
47
+
48
+ return store satisfies AgentRunStore;
49
+ }
50
+
51
+ function makeAgent(provider: Fake, overrides: Record<string, any> = {}) {
52
+ return new Agent({
53
+ id: "resumable",
54
+ description: "An agent whose runs can be resumed.",
55
+ instructions: "You are a test agent.",
56
+ provider: {
57
+ model: "test-model",
58
+ apiKey: "test-key",
59
+ protocol: "openai",
60
+ baseUrl: provider.url,
61
+ options: { retries: 0 },
62
+ },
63
+ tools: {
64
+ echo: define.Tool({
65
+ id: "echo",
66
+ description: "Echoes the text back.",
67
+ inputSchema: v.object({ text: v.string().required() }),
68
+ execute: async ({ text }: any) => ({ echoed: text }),
69
+ }),
70
+ },
71
+ ...overrides,
72
+ } as any);
73
+ }
74
+
75
+ /** A run that calls a tool on its first model call, then dies on the second. */
76
+ function dyingProvider(): Fake {
77
+ return track(fakeProvider((_body, _req, hits) => hits === 1
78
+ ? openaiTool("echo", { text: "hello" }, "call_echo")
79
+ : hits === 2
80
+ ? Response.json({ error: { message: "provider blew up" } }, { status: 400 })
81
+ : openaiText("done")));
82
+ }
83
+
84
+ describe("durable checkpoint", () => {
85
+ it("saves the last completed step, and a resume continues from it", async () => {
86
+ const provider = dyingProvider();
87
+ const store = memoryStore();
88
+ const agent = makeAgent(provider, { checkpoint: store });
89
+
90
+ await expect(agent.generate("say hello", { runId: "run-1" })).rejects.toThrow(/provider blew up/);
91
+
92
+ const saved = store.states.get("run-1");
93
+ expect(saved).toBeDefined();
94
+ expect(saved!.runId).toBe("run-1");
95
+ expect(saved!.agentId).toBe("resumable");
96
+ expect(saved!.step).toBe(1);
97
+ // The step finished with its tool result — the conversation is valid to replay
98
+ expect(at(saved!.messages, saved!.messages.length - 1).role).toBe("tool");
99
+ expect(saved!.usage.requests).toBe(1);
100
+ expect(saved!.toolStats.calls).toBe(1);
101
+ expect(typeof saved!.at).toBe("string");
102
+ // A failed run keeps its state
103
+ expect(store.calls.clears).toBe(0);
104
+
105
+ // A fresh agent — the store is the only thing carrying the run over
106
+ const fresh = makeAgent(provider, { checkpoint: store });
107
+ const before = provider.hits();
108
+ const resumed = await fresh.resumeRun("run-1");
109
+
110
+ // The completed step is not replayed through the model: one call, the next step
111
+ expect(provider.hits() - before).toBe(1);
112
+ expect(resumed.text).toBe("done");
113
+ expect(resumed.steps).toHaveLength(1);
114
+ expect(at(resumed.steps, 0).step).toBe(1);
115
+ // The whole conversation, not just the resumed segment; the counters are carried
116
+ expect(resumed.messages.slice(0, saved!.messages.length)).toEqual(saved!.messages);
117
+ expect(at(resumed.messages, resumed.messages.length - 1).role).toBe("assistant");
118
+ expect(resumed.usage.requests).toBe(2);
119
+ expect(resumed.toolStats.calls).toBe(1);
120
+ expect(request(provider, 2).body.messages.map((message: any) => message.role))
121
+ .toEqual(["system", "user", "assistant", "tool"]);
122
+ // A run that came back has nothing left to resume
123
+ expect(store.states.get("run-1")).toBeUndefined();
124
+ expect(store.calls.clears).toBe(1);
125
+ });
126
+
127
+ it("refuses an unknown run — and a resume without a store", async () => {
128
+ const provider = track(fakeProvider(() => openaiText("done")));
129
+ const store = memoryStore();
130
+
131
+ await expect(makeAgent(provider, { checkpoint: store }).resumeRun("never-started"))
132
+ .rejects.toMatchObject({ code: "AGENT_CHECKPOINT_NOT_FOUND" });
133
+
134
+ await expect(makeAgent(provider).resumeRun("run-1"))
135
+ .rejects.toMatchObject({ code: "AGENT_CHECKPOINT_REQUIRED" });
136
+ expect(provider.hits()).toBe(0);
137
+ });
138
+
139
+ it("clears the state of a successful run", async () => {
140
+ const provider = track(fakeProvider(() => openaiText("done")));
141
+ const store = memoryStore();
142
+
143
+ const result = await makeAgent(provider, { checkpoint: store }).generate("hi", { runId: "run-clear" });
144
+
145
+ expect(result.text).toBe("done");
146
+ expect(store.calls.saves).toBeGreaterThan(0);
147
+ expect(store.calls.clears).toBe(1);
148
+ expect(store.states.has("run-clear")).toBe(false);
149
+ });
150
+
151
+ it("takes the store from the call over the declaration", async () => {
152
+ const provider = track(fakeProvider(() => openaiText("done")));
153
+ const declared = memoryStore();
154
+ const callLevel = memoryStore();
155
+
156
+ await makeAgent(provider, { checkpoint: declared })
157
+ .generate("hi", { runId: "run-override", checkpoint: callLevel });
158
+
159
+ expect(callLevel.calls.saves).toBeGreaterThan(0);
160
+ expect(declared.calls.saves).toBe(0);
161
+ });
162
+
163
+ it("saves nothing without a run id", async () => {
164
+ const provider = track(fakeProvider(() => openaiText("done")));
165
+ const store = memoryStore();
166
+
167
+ await makeAgent(provider, { checkpoint: store }).generate("hi");
168
+
169
+ expect(store.calls.saves).toBe(0);
170
+ expect(store.calls.clears).toBe(0);
171
+ });
172
+
173
+ it("does not fail the run when the store cannot save", async () => {
174
+ const provider = track(fakeProvider(() => openaiText("done")));
175
+ const spy = spyOn(console, "error").mockImplementation(() => {});
176
+ const store: AgentRunStore = {
177
+ load: () => undefined,
178
+ save: () => {
179
+ throw new Error("disk full");
180
+ },
181
+ clear: () => {},
182
+ };
183
+
184
+ try {
185
+ const result = await makeAgent(provider, { checkpoint: store }).generate("hi", { runId: "run-broken-store" });
186
+
187
+ expect(result.text).toBe("done");
188
+ expect(result.finishReason).toBe("stop");
189
+ const logged = spy.mock.calls.map((call) => String(call[0])).join("\n");
190
+ expect(logged).toContain("run-broken-store");
191
+ } finally {
192
+ spy.mockRestore();
193
+ }
194
+ });
195
+ });
@@ -0,0 +1,238 @@
1
+ import { describe, it, expect, afterAll } from "bun:test";
2
+ import { Agent, define, v } from "../index";
3
+ import { InMemoryAgentMemory } from "../lib/agent";
4
+ import {
5
+ USAGE,
6
+ at,
7
+ fakeProvider,
8
+ openaiText,
9
+ openaiTool,
10
+ request,
11
+ type Fake,
12
+ } from "./fixtures/fake-provider";
13
+
14
+ /**
15
+ * The P1 hardening of `define.Agent`: delegation, fallbacks, budgets, cost,
16
+ * guardrails and the token-aware prompt window.
17
+ */
18
+
19
+ const servers: Fake[] = [];
20
+ function track(fake: Fake): Fake {
21
+ servers.push(fake);
22
+ return fake;
23
+ }
24
+
25
+ afterAll(() => {
26
+ for (const server of servers) server.stop();
27
+ });
28
+
29
+ function makeAgent(overrides: Record<string, any> = {}, provider: Record<string, any> = {}) {
30
+ return new Agent({
31
+ id: "hardened",
32
+ description: "A hardened test agent.",
33
+ instructions: "Answer.",
34
+ provider: {
35
+ model: "primary-model",
36
+ apiKey: "test-key",
37
+ protocol: "openai",
38
+ options: { retries: 0 },
39
+ ...provider,
40
+ },
41
+ ...overrides,
42
+ } as any);
43
+ }
44
+
45
+ /** A 500 the fallback chain must treat as retryable. */
46
+ const serverError = () => Response.json({ error: { message: "boom" } }, { status: 500 });
47
+
48
+ describe("model fallbacks", () => {
49
+ it("hands over to the next endpoint on a 5xx, and reports the model that answered", async () => {
50
+ const primary = track(fakeProvider(() => serverError()));
51
+ const secondary = track(fakeProvider(() => openaiText("from the fallback")));
52
+
53
+ const agent = makeAgent({}, {
54
+ baseUrl: primary.url,
55
+ fallbacks: [{ model: "fallback-model", baseUrl: secondary.url, apiKey: "fallback-key" }],
56
+ });
57
+
58
+ const result = await agent.generate("hi");
59
+
60
+ expect(result.text).toBe("from the fallback");
61
+ expect(result.model).toBe("fallback-model");
62
+ expect(primary.hits()).toBe(1);
63
+ expect(secondary.hits()).toBe(1);
64
+ });
65
+
66
+ it("does not fall back on a client error", async () => {
67
+ const primary = track(fakeProvider(() => Response.json({ error: { message: "bad schema" } }, { status: 400 })));
68
+ const secondary = track(fakeProvider(() => openaiText("should not happen")));
69
+
70
+ const agent = makeAgent({}, {
71
+ baseUrl: primary.url,
72
+ fallbacks: [{ model: "fallback-model", baseUrl: secondary.url }],
73
+ });
74
+
75
+ await expect(agent.generate("hi")).rejects.toBeDefined();
76
+ expect(secondary.hits()).toBe(0);
77
+ });
78
+ });
79
+
80
+ describe("run budget", () => {
81
+ it("stops on the token budget and answers the unanswered calls", async () => {
82
+ const provider = track(fakeProvider((_body, _req, hits) =>
83
+ hits === 1 ? openaiTool("act", {}) : openaiText("never asked")));
84
+
85
+ const agent = makeAgent({
86
+ tools: {
87
+ act: define.Tool({ id: "act", description: "Does something", inputSchema: v.object({}), execute: () => ({ ok: true }) }),
88
+ },
89
+ }, { baseUrl: provider.url });
90
+
91
+ // One call already exceeds it (USAGE totals 15 tokens)
92
+ const result = await agent.generate("go", { maxTokens: 5 });
93
+
94
+ expect(result.finishReason).toBe("budget");
95
+ expect(result.budget).toEqual({ limit: "tokens", value: 5 });
96
+ expect(at(result.toolResults, 0).errorCode).toBe("AGENT_BUDGET_EXHAUSTED");
97
+ // The assistant turn asked for a call: the conversation still answers it
98
+ expect(result.messages.at(-1)!.role).toBe("tool");
99
+ });
100
+
101
+ it("stops on the wall-clock budget between steps", async () => {
102
+ const provider = track(fakeProvider((_body, _req, hits) =>
103
+ hits === 1 ? openaiTool("slow", {}) : openaiText("never asked")));
104
+
105
+ const agent = makeAgent({
106
+ tools: {
107
+ slow: define.Tool({
108
+ id: "slow",
109
+ description: "Takes its time",
110
+ inputSchema: v.object({}),
111
+ execute: () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 40)),
112
+ }),
113
+ },
114
+ }, { baseUrl: provider.url });
115
+
116
+ const result = await agent.generate("go", { maxDurationMs: 5 });
117
+
118
+ expect(result.finishReason).toBe("budget");
119
+ expect(result.budget).toEqual({ limit: "duration", value: 5 });
120
+ });
121
+
122
+ it("turns pricing into a cost, and stops on the cost budget", async () => {
123
+ const provider = track(fakeProvider(() => openaiText("ok")));
124
+ const pricing = { input: 1_000, output: 0 }; // per million tokens
125
+
126
+ const cheap = makeAgent({}, { baseUrl: provider.url, pricing });
127
+ const first = await cheap.generate("hi");
128
+ // USAGE: 11 input tokens → 11 / 1e6 * 1000
129
+ expect(first.usage.cost).toBeCloseTo(0.011, 6);
130
+
131
+ const capped = makeAgent({}, { baseUrl: provider.url, pricing });
132
+ const second = await capped.generate("hi", { maxCost: 0.001 });
133
+ expect(second.finishReason).toBe("budget");
134
+ expect(second.budget).toEqual({ limit: "cost", value: 0.001 });
135
+ });
136
+ });
137
+
138
+ describe("guardrails", () => {
139
+ it("blocks the prompt before any model call", async () => {
140
+ const provider = track(fakeProvider(() => openaiText("should not happen")));
141
+ const agent = makeAgent({
142
+ guardrails: {
143
+ input: [{ name: "no-secrets", check: (value: string) => { if (value.includes("secret")) throw new Error("secret detected"); } }],
144
+ },
145
+ }, { baseUrl: provider.url });
146
+
147
+ await expect(agent.generate("my secret is 42")).rejects.toMatchObject({ code: "AGENT_GUARDRAIL_BLOCKED" });
148
+ expect(provider.hits()).toBe(0);
149
+ });
150
+
151
+ it("blocks the answer after the model produced it", async () => {
152
+ const provider = track(fakeProvider(() => openaiText("forbidden answer")));
153
+ const agent = makeAgent({
154
+ guardrails: {
155
+ output: [{ name: "no-forbidden", check: (value: string) => { if (value.includes("forbidden")) throw new Error("not allowed"); } }],
156
+ },
157
+ }, { baseUrl: provider.url });
158
+
159
+ await expect(agent.generate("hi")).rejects.toMatchObject({ code: "AGENT_GUARDRAIL_BLOCKED" });
160
+ expect(provider.hits()).toBe(1);
161
+ });
162
+ });
163
+
164
+ describe("prompt window in tokens", () => {
165
+ it("drops the oldest turns to respect `contextTokens`", async () => {
166
+ const provider = track(fakeProvider(() => openaiText("ok")));
167
+ const memory = new InMemoryAgentMemory();
168
+ const long = "a".repeat(2_400); // ~600 tokens each
169
+ await memory.save("t", [
170
+ { role: "user", content: long },
171
+ { role: "assistant", content: long },
172
+ { role: "user", content: long },
173
+ { role: "assistant", content: long },
174
+ ]);
175
+
176
+ const agent = makeAgent({ memory }, { baseUrl: provider.url });
177
+ await agent.generate("now", { thread: "t", contextTokens: 100 });
178
+
179
+ // The system prompt is part of `messages` on the OpenAI wire: only the
180
+ // current question survives the window, the four long turns are dropped.
181
+ const sent = request(provider, 0).body.messages;
182
+ expect(sent).toHaveLength(2);
183
+ expect(sent[0].role).toBe("system");
184
+ expect(sent[1].content).toBe("now");
185
+ });
186
+ });
187
+
188
+ describe("delegated runs", () => {
189
+ it("folds the sub-agent's usage into the parent's", async () => {
190
+ const child = track(fakeProvider(() => openaiText("child answer")));
191
+ const parent = track(fakeProvider((_body, _req, hits) =>
192
+ hits === 1 ? openaiTool("helper", { input: "do it" }) : openaiText("parent answer")));
193
+
194
+ const helper = define.Agent({
195
+ id: "helper",
196
+ description: "A delegated agent",
197
+ instructions: "Help.",
198
+ provider: { model: "child-model", apiKey: "k", protocol: "openai", baseUrl: child.url, options: { retries: 0 } },
199
+ });
200
+
201
+ const agent = makeAgent({ agents: [helper] }, { baseUrl: parent.url });
202
+ const result = await agent.generate("go", { maxSteps: 3 });
203
+
204
+ expect(result.text).toBe("parent answer");
205
+ // Parent: 2 calls (tool round + answer). Child: 1 call.
206
+ expect(result.usage.requests).toBe(3);
207
+ expect(result.usage.totalTokens).toBe(USAGE.total_tokens * 3);
208
+ });
209
+
210
+ it("refuses a delegated approval instead of losing it", async () => {
211
+ const child = track(fakeProvider((_body, _req, hits) =>
212
+ hits === 1 ? openaiTool("refund", {}) : openaiText("refunded")));
213
+ const parent = track(fakeProvider((_body, _req, hits) =>
214
+ hits === 1 ? openaiTool("helper", { input: "refund it" }) : openaiText("done")));
215
+
216
+ const helper = define.Agent({
217
+ id: "helper",
218
+ description: "A delegated agent",
219
+ instructions: "Help.",
220
+ provider: { model: "child-model", apiKey: "k", protocol: "openai", baseUrl: child.url, options: { retries: 0 } },
221
+ tools: {
222
+ refund: define.Tool({
223
+ id: "refund",
224
+ description: "Refunds an order",
225
+ approval: true,
226
+ inputSchema: v.object({}),
227
+ execute: () => ({ ok: true }),
228
+ }),
229
+ },
230
+ });
231
+
232
+ const agent = makeAgent({ agents: [helper] }, { baseUrl: parent.url });
233
+ const result = await agent.generate("go", { maxSteps: 3 });
234
+
235
+ expect(at(result.toolResults, 0).errorCode).toBe("AGENT_APPROVAL_IN_DELEGATION");
236
+ expect(result.toolStats.byCode.AGENT_APPROVAL_IN_DELEGATION).toBe(1);
237
+ });
238
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
2
  import { Agent } from "../lib/agent";
3
- import { InMemoryAgentMemory, MongoAgentMemory, RedisAgentMemory } from "../lib/agentMemory";
3
+ import { InMemoryAgentMemory, MongoAgentMemory, RedisAgentMemory, customAgentMemory, semanticRecall } from "../lib/agentMemory";
4
4
  import { replicatesCollection, replicatesMetaCollection } from "../database/deleteLog";
5
5
  import { formatConfig, cfg } from "../server/config";
6
6
  import { syncTenants } from "../database/tenant";
@@ -110,6 +110,180 @@ describe("agents.memory.InMemory", () => {
110
110
  });
111
111
  });
112
112
 
113
+ // ─── Custom store ────────────────────────────────────────────────────────
114
+
115
+ describe("agents.memory.custom", () => {
116
+ it("requires get, save and clear", () => {
117
+ expect(() => customAgentMemory({} as any)).toThrow(/needs `get`/);
118
+ expect(() => customAgentMemory({ get: () => [], save: () => {} } as any)).toThrow(/needs `clear`/);
119
+ });
120
+
121
+ it("scopes the key and caps the thread on save", async () => {
122
+ const seen: { key?: string; messages?: AgentMessage[] } = {};
123
+ const memory = customAgentMemory({
124
+ maxMessages: 2,
125
+ get: () => [],
126
+ save: (key, messages) => { seen.key = key; seen.messages = messages; },
127
+ clear: () => {},
128
+ });
129
+
130
+ await memory.save("chat", [
131
+ message("user", "1"),
132
+ message("assistant", "2"),
133
+ message("user", "3"),
134
+ ], { resourceId: "u1" });
135
+
136
+ expect(seen.key).toBe("u1:chat");
137
+ expect(seen.messages!.map((m) => m.content)).toEqual(["2", "3"]);
138
+ expect(await memory.get("chat", { resourceId: "u1" })).toEqual([]);
139
+ });
140
+
141
+ it("uses the bare thread id when the store is unscoped", async () => {
142
+ let key: string | undefined;
143
+ const memory = customAgentMemory({
144
+ scoped: false,
145
+ get: () => [],
146
+ save: (threadKey) => { key = threadKey; },
147
+ clear: () => {},
148
+ });
149
+ await memory.save("chat", [], { resourceId: "u1" });
150
+ expect(key).toBe("chat");
151
+ });
152
+
153
+ it("exposes the optional handlers only when given", async () => {
154
+ const plain = customAgentMemory({ get: () => [], save: () => {}, clear: () => {} });
155
+ expect("list" in plain).toBe(false);
156
+ expect("getState" in plain).toBe(false);
157
+ expect("collections" in plain).toBe(false);
158
+
159
+ const calls: string[] = [];
160
+ const full = customAgentMemory({
161
+ get: () => [],
162
+ save: () => {},
163
+ clear: () => {},
164
+ list: () => [{ threadId: "chat", messages: 2, updatedAt: new Date() }],
165
+ getState: () => ({ value: "# sheet" }),
166
+ setState: () => { calls.push("setState"); },
167
+ clearState: () => { calls.push("clearState"); },
168
+ collections: () => ["threads", "threads__state"],
169
+ });
170
+
171
+ expect((await full.list!({ resourceId: "u1", limit: 5 }))).toHaveLength(1);
172
+ expect(await full.getState!("u1", {})).toEqual({ value: "# sheet" });
173
+ await full.setState!("u1", { value: "x" });
174
+ await full.clearState!("u1");
175
+ expect(calls).toEqual(["setState", "clearState"]);
176
+ expect(full.collections!()).toEqual(["threads", "threads__state"]);
177
+ });
178
+
179
+ it("passes the caller context through to the handlers", async () => {
180
+ let seen: any;
181
+ const memory = customAgentMemory({
182
+ get: (_key, ctx) => { seen = ctx; return []; },
183
+ save: () => {},
184
+ clear: () => {},
185
+ });
186
+ await memory.get("chat", { resourceId: "u1", agentId: "support", tenant: "t1", thread: "chat" });
187
+ expect(seen).toMatchObject({ resourceId: "u1", agentId: "support", tenant: "t1", thread: "chat" });
188
+ });
189
+ });
190
+
191
+ // ─── Semantic recall ─────────────────────────────────────────────────────
192
+
193
+ describe("agents.memory.semanticRecall", () => {
194
+ it("requires embed and search", () => {
195
+ expect(() => semanticRecall({} as any)).toThrow(/needs `embed` and `search`/);
196
+ });
197
+
198
+ it("recalls by meaning and prepends the hits", async () => {
199
+ const calls: any[] = [];
200
+ const processor = semanticRecall({
201
+ topK: 3,
202
+ messageRange: 2,
203
+ scope: "resource",
204
+ filter: { project: "A" },
205
+ embed: (batch, kind) => { calls.push(["embed", batch, kind]); return [[0.1, 0.2]]; },
206
+ search: (vector, query, ctx) => {
207
+ calls.push(["search", vector, query, ctx]);
208
+ return [{ id: "m1", text: "Le client a demandé un remboursement.", role: "assistant" }];
209
+ },
210
+ });
211
+
212
+ const messages = [{ role: "user", content: "Et le remboursement ?" }] as AgentMessage[];
213
+ const context = { thread: "chat-1", resourceId: "u-1" };
214
+ const out = await processor.load!(messages, context) as AgentMessage[];
215
+
216
+ expect(out).toHaveLength(2);
217
+ expect(out[0]!.role).toBe("system");
218
+ expect(String(out[0]!.content)).toContain("remboursement");
219
+ expect(calls[0]).toEqual(["embed", ["Et le remboursement ?"], "query"]);
220
+ expect(calls[1]).toEqual(["search", [0.1, 0.2], {
221
+ topK: 3,
222
+ messageRange: 2,
223
+ scope: "resource",
224
+ filter: { project: "A" },
225
+ thread: "chat-1",
226
+ resourceId: "u-1",
227
+ }, context]);
228
+ });
229
+
230
+ it("leaves the messages alone without a question or without hits", async () => {
231
+ const processor = semanticRecall({ embed: () => [[1]], search: () => [] });
232
+
233
+ const noQuestion = [{ role: "assistant", content: "hello" }] as AgentMessage[];
234
+ expect(await processor.load!(noQuestion, {})).toBe(noQuestion);
235
+
236
+ const noHits = [{ role: "user", content: "q" }] as AgentMessage[];
237
+ expect(await processor.load!(noHits, {})).toBe(noHits);
238
+ });
239
+
240
+ it("indexes new messages as passages, once", async () => {
241
+ const embedded: any[] = [];
242
+ const records: any[] = [];
243
+ let upsertCtx: any;
244
+ const processor = semanticRecall({
245
+ embed: (batch, kind) => { embedded.push([batch, kind]); return batch.map(() => [1, 2]); },
246
+ search: () => [],
247
+ upsert: (batch, ctx) => { records.push(...batch); upsertCtx = ctx; },
248
+ });
249
+
250
+ const messages = [
251
+ { role: "system", content: "prompt" },
252
+ { role: "user", content: "Bonjour" },
253
+ { role: "assistant", content: "Salut" },
254
+ ] as AgentMessage[];
255
+
256
+ const context = { thread: "chat-1", resourceId: "u-1" };
257
+ await processor.save!(messages, context);
258
+ expect(embedded).toEqual([[["Bonjour", "Salut"], "passage"]]);
259
+ expect(records.map((record) => record.text)).toEqual(["Bonjour", "Salut"]);
260
+ expect(records[0]).toMatchObject({ thread: "chat-1", resourceId: "u-1", role: "user", vector: [1, 2] });
261
+ expect(typeof records[0]!.id).toBe("string");
262
+ expect(upsertCtx).toBe(context);
263
+
264
+ // A second save of the same messages embeds nothing new.
265
+ await processor.save!(messages, { thread: "chat-1", resourceId: "u-1" });
266
+ expect(embedded).toHaveLength(1);
267
+ expect(records).toHaveLength(2);
268
+ });
269
+
270
+ it("recalls only, without upsert", async () => {
271
+ const processor = semanticRecall({ embed: () => [[1]], search: () => [] });
272
+ const messages = [{ role: "user", content: "q" }] as AgentMessage[];
273
+ expect(await processor.save!(messages, {})).toBe(messages);
274
+ });
275
+
276
+ it("accepts a custom formatter", async () => {
277
+ const processor = semanticRecall({
278
+ embed: () => [[1]],
279
+ search: () => [{ text: "A" }, { text: "B" }],
280
+ format: (hits) => hits.map((hit) => hit.text).join(" | "),
281
+ });
282
+ const out = await processor.load!([{ role: "user", content: "q" }] as AgentMessage[], {}) as AgentMessage[];
283
+ expect(String(out[0]!.content)).toContain("A | B");
284
+ });
285
+ });
286
+
113
287
  // ─── MongoDB ─────────────────────────────────────────────────────────────
114
288
 
115
289
  describe("agents.memory.Mongo", () => {