@anteros/core 0.0.40 → 0.0.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/database/audit.ts +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/redact.ts +117 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +451 -0
- package/server/api.ts +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/lifecycle.test.ts +96 -0
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +837 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -0
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- package/utils/func.ts +67 -52
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { Agent } from "../lib/agent";
|
|
3
|
+
import { InMemoryAgentMemory, MongoAgentMemory, RedisAgentMemory } from "../lib/agentMemory";
|
|
4
|
+
import { replicatesCollection, replicatesMetaCollection } from "../database/deleteLog";
|
|
5
|
+
import { formatConfig, cfg } from "../server/config";
|
|
6
|
+
import { syncTenants } from "../database/tenant";
|
|
7
|
+
import { useRest } from "../database/rest";
|
|
8
|
+
import { fakeProvider, openaiText, at, type Fake } from "./fixtures/fake-provider";
|
|
9
|
+
import type { AgentMessage } from "../types/agent";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Conversation stores — `agents.memory.InMemory` (a Map) and
|
|
13
|
+
* `agents.memory.Mongo` (durable, one document per thread in a collection **the tenant names**).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const TENANT = "agent-memory";
|
|
17
|
+
const DB = "mongodb://localhost:27017/_AGENT_MEMORY_TEST";
|
|
18
|
+
/** The collection the tenant chose for its agent threads. */
|
|
19
|
+
const MEMORY = "_memories_test_";
|
|
20
|
+
|
|
21
|
+
let rest: InstanceType<typeof useRest>;
|
|
22
|
+
let provider: Fake;
|
|
23
|
+
|
|
24
|
+
const message = (role: string, content: string): AgentMessage => ({ role, content } as AgentMessage);
|
|
25
|
+
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
formatConfig({
|
|
28
|
+
server: { port: 4000 },
|
|
29
|
+
tenants: [{ id: TENANT, dir: "packages/core/tests/fixtures/agent-tenant", database: { uri: DB } }],
|
|
30
|
+
});
|
|
31
|
+
await syncTenants();
|
|
32
|
+
rest = new useRest({ internal: true, tenant_id: TENANT });
|
|
33
|
+
// The agent loader publishes the memory collections it found — here, none:
|
|
34
|
+
// the tests declare the one they use, like `syncAgents()` would.
|
|
35
|
+
cfg.agentMemories = [{ _tenant_: TENANT, collection: MEMORY }];
|
|
36
|
+
provider = fakeProvider(() => openaiText("ok"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
try { await rest.db.dropDatabase(); } catch (_) { /* already gone */ }
|
|
41
|
+
try { provider.stop(); } catch (_) { /* already stopped */ }
|
|
42
|
+
cfg.tenants = [];
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ─── In-memory ───────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("agents.memory.InMemory", () => {
|
|
48
|
+
it("round-trips a thread and returns copies", () => {
|
|
49
|
+
const memory = new InMemoryAgentMemory();
|
|
50
|
+
memory.save("t1", [message("user", "hi"), message("assistant", "hello")]);
|
|
51
|
+
|
|
52
|
+
const read = memory.get("t1");
|
|
53
|
+
expect(read.map((m) => m.content)).toEqual(["hi", "hello"]);
|
|
54
|
+
|
|
55
|
+
read[0]!.content = "mutated";
|
|
56
|
+
expect(memory.get("t1")[0]!.content).toBe("hi");
|
|
57
|
+
expect(memory.get("missing")).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("bounds a thread, keeping the opening system message", () => {
|
|
61
|
+
const memory = new InMemoryAgentMemory({ maxMessages: 3 });
|
|
62
|
+
memory.save("t1", [
|
|
63
|
+
message("system", "persona"),
|
|
64
|
+
message("user", "1"),
|
|
65
|
+
message("assistant", "2"),
|
|
66
|
+
message("user", "3"),
|
|
67
|
+
message("assistant", "4"),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
expect(memory.get("t1").map((m) => m.content)).toEqual(["persona", "3", "4"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("namespaces threads per caller by default", () => {
|
|
74
|
+
const memory = new InMemoryAgentMemory();
|
|
75
|
+
memory.save("same", [message("user", "from A")], { resourceId: "A" });
|
|
76
|
+
memory.save("same", [message("user", "from B")], { resourceId: "B" });
|
|
77
|
+
|
|
78
|
+
expect(memory.get("same", { resourceId: "A" })[0]!.content).toBe("from A");
|
|
79
|
+
expect(memory.get("same", { resourceId: "B" })[0]!.content).toBe("from B");
|
|
80
|
+
expect(memory.threads()).toEqual(["A:same", "B:same"]);
|
|
81
|
+
|
|
82
|
+
// …unless the store is unscoped
|
|
83
|
+
const shared = new InMemoryAgentMemory({ scoped: false });
|
|
84
|
+
shared.save("same", [message("user", "A")], { resourceId: "A" });
|
|
85
|
+
shared.save("same", [message("user", "B")], { resourceId: "B" });
|
|
86
|
+
expect(shared.get("same", { resourceId: "A" })[0]!.content).toBe("B");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("lists a caller's threads, most recent first", async () => {
|
|
90
|
+
const memory = new InMemoryAgentMemory();
|
|
91
|
+
memory.save("older", [message("user", "What is the weather in Paris?")], { resourceId: "A" });
|
|
92
|
+
await Bun.sleep(2);
|
|
93
|
+
memory.save("newer", [message("user", "hi")], { resourceId: "A" });
|
|
94
|
+
memory.save("other", [message("user", "elsewhere")], { resourceId: "B" });
|
|
95
|
+
|
|
96
|
+
const threads = await memory.list({ resourceId: "A" });
|
|
97
|
+
expect(threads.map((t) => t.threadId)).toEqual(["newer", "older"]);
|
|
98
|
+
expect(threads[1]!.title).toBe("What is the weather in Paris?");
|
|
99
|
+
expect(threads[1]!.messages).toBe(1);
|
|
100
|
+
expect(threads.every((t) => t.updatedAt instanceof Date)).toBe(true);
|
|
101
|
+
|
|
102
|
+
expect((await memory.list({ limit: 1 })).length).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("clears a thread", () => {
|
|
106
|
+
const memory = new InMemoryAgentMemory();
|
|
107
|
+
memory.save("t1", [message("user", "hi")]);
|
|
108
|
+
memory.clear("t1");
|
|
109
|
+
expect(memory.get("t1")).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ─── MongoDB ─────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("agents.memory.Mongo", () => {
|
|
116
|
+
it("round-trips a thread in the tenant database", async () => {
|
|
117
|
+
const memory = new MongoAgentMemory({ collection: MEMORY });
|
|
118
|
+
await memory.save("t1", [message("user", "hi"), message("assistant", "hello")], { rest, resourceId: "u1" });
|
|
119
|
+
|
|
120
|
+
expect((await memory.get("t1", { rest, resourceId: "u1" })).map((m) => m.content)).toEqual(["hi", "hello"]);
|
|
121
|
+
expect(await memory.get("t1", { rest, resourceId: "u2" })).toEqual([]);
|
|
122
|
+
expect(await memory.get("missing", { rest, resourceId: "u1" })).toEqual([]);
|
|
123
|
+
|
|
124
|
+
const doc: any = await rest.db.collection(MEMORY).findOne({ _id: "u1:t1" as any });
|
|
125
|
+
expect(doc.threadId).toBe("t1");
|
|
126
|
+
expect(doc.resourceId).toBe("u1");
|
|
127
|
+
expect(doc.title).toBe("hi");
|
|
128
|
+
expect(doc.createdAt).toBeInstanceOf(Date);
|
|
129
|
+
expect(doc.updatedAt).toBeInstanceOf(Date);
|
|
130
|
+
|
|
131
|
+
await memory.clear("t1", { rest, resourceId: "u1" });
|
|
132
|
+
expect(await memory.get("t1", { rest, resourceId: "u1" })).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("upserts one document per thread and keeps the original title", async () => {
|
|
136
|
+
const memory = new MongoAgentMemory({ collection: MEMORY });
|
|
137
|
+
await memory.save("t2", [message("user", "first question")], { rest, resourceId: "u1" });
|
|
138
|
+
await memory.save("t2", [message("user", "first question"), message("assistant", "answer")], { rest, resourceId: "u1" });
|
|
139
|
+
|
|
140
|
+
const docs = await rest.db.collection(MEMORY).find({ threadId: "t2" }).toArray();
|
|
141
|
+
expect(docs.length).toBe(1);
|
|
142
|
+
expect((docs[0] as any).messages.length).toBe(2);
|
|
143
|
+
expect((docs[0] as any).title).toBe("first question");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("bounds the stored conversation", async () => {
|
|
147
|
+
const memory = new MongoAgentMemory({ collection: MEMORY, maxMessages: 3 });
|
|
148
|
+
await memory.save("t3", [
|
|
149
|
+
message("system", "persona"),
|
|
150
|
+
message("user", "1"),
|
|
151
|
+
message("assistant", "2"),
|
|
152
|
+
message("user", "3"),
|
|
153
|
+
message("assistant", "4"),
|
|
154
|
+
], { rest, resourceId: "u1" });
|
|
155
|
+
|
|
156
|
+
expect((await memory.get("t3", { rest, resourceId: "u1" })).map((m) => m.content))
|
|
157
|
+
.toEqual(["persona", "3", "4"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("lists a caller's threads, most recent first", async () => {
|
|
161
|
+
const memory = new MongoAgentMemory({ collection: MEMORY });
|
|
162
|
+
await memory.save("older", [message("user", "hello")], { rest, resourceId: "lister" });
|
|
163
|
+
await Bun.sleep(5);
|
|
164
|
+
await memory.save("newer", [message("user", "hi")], { rest, resourceId: "lister" });
|
|
165
|
+
await memory.save("other", [message("user", "hi")], { rest, resourceId: "someone-else" });
|
|
166
|
+
|
|
167
|
+
const threads = await memory.list({ rest, resourceId: "lister" });
|
|
168
|
+
expect(threads.map((t) => t.threadId)).toEqual(["newer", "older"]);
|
|
169
|
+
expect(threads[0]!.messages).toBe(1);
|
|
170
|
+
expect(threads.every((t) => t.resourceId === "lister")).toBe(true);
|
|
171
|
+
|
|
172
|
+
expect((await memory.list({ rest, resourceId: "lister", limit: 1 })).length).toBe(1);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("creates its indexes on first use — and never at boot", async () => {
|
|
176
|
+
const memory = new MongoAgentMemory({ collection: MEMORY });
|
|
177
|
+
const collection = `_memories_idx_${crypto.randomUUID().slice(0, 8)}_`;
|
|
178
|
+
const scoped = new MongoAgentMemory({ collection });
|
|
179
|
+
|
|
180
|
+
const before = await rest.db.listCollections({ name: collection }).toArray();
|
|
181
|
+
expect(before.length).toBe(0);
|
|
182
|
+
|
|
183
|
+
await scoped.save("t", [message("user", "hi")], { rest, resourceId: "e2e" });
|
|
184
|
+
|
|
185
|
+
const indexes: any[] = await rest.db.collection(collection).listIndexes().toArray();
|
|
186
|
+
const listIndex = indexes.find((index) => index.key?.resourceId === 1);
|
|
187
|
+
expect(listIndex).toBeDefined();
|
|
188
|
+
expect(listIndex.key.updatedAt).toBe(-1);
|
|
189
|
+
expect(listIndex.background).toBe(true);
|
|
190
|
+
|
|
191
|
+
// No TTL unless asked for
|
|
192
|
+
expect(indexes.some((index) => index.expireAfterSeconds !== undefined)).toBe(false);
|
|
193
|
+
await rest.db.dropCollection(collection);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("applies the retention only when a TTL is configured", async () => {
|
|
197
|
+
const collection = `_memories_ttl_${crypto.randomUUID().slice(0, 8)}_`;
|
|
198
|
+
const memory = new MongoAgentMemory({ collection, ttl: "30d" });
|
|
199
|
+
|
|
200
|
+
await memory.save("t", [message("user", "hi")], { rest, resourceId: "e2e" });
|
|
201
|
+
|
|
202
|
+
const ttl: any = (await rest.db.collection(collection).listIndexes().toArray())
|
|
203
|
+
.find((index: any) => index.expireAfterSeconds !== undefined);
|
|
204
|
+
expect(ttl.key).toEqual({ updatedAt: 1 });
|
|
205
|
+
expect(ttl.expireAfterSeconds).toBe(30 * 86400);
|
|
206
|
+
|
|
207
|
+
await rest.db.dropCollection(collection);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps the working memory in a derived collection, and publishes both", async () => {
|
|
211
|
+
const memory = new MongoAgentMemory({ collection: MEMORY });
|
|
212
|
+
|
|
213
|
+
// The replication engine copies what `collections()` lists — state included
|
|
214
|
+
expect(memory.stateCollection).toBe(`${MEMORY}__state`);
|
|
215
|
+
expect(memory.collections()).toEqual([MEMORY, `${MEMORY}__state`]);
|
|
216
|
+
|
|
217
|
+
await memory.setState("resource:u1", { value: { name: "Sam" } }, { rest });
|
|
218
|
+
expect(await memory.getState("resource:u1", { rest })).toMatchObject({ value: { name: "Sam" } });
|
|
219
|
+
expect(await memory.getState("resource:u2", { rest })).toBeUndefined();
|
|
220
|
+
|
|
221
|
+
// One document per scope key, updated in place
|
|
222
|
+
await memory.setState("resource:u1", { value: { name: "Sam", plan: "pro" } }, { rest });
|
|
223
|
+
expect((await rest.db.collection(`${MEMORY}__state`).countDocuments()).valueOf()).toBe(1);
|
|
224
|
+
expect((await memory.getState("resource:u1", { rest }))?.value.plan).toBe("pro");
|
|
225
|
+
|
|
226
|
+
await memory.clearState("resource:u1", { rest });
|
|
227
|
+
expect(await memory.getState("resource:u1", { rest })).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("refuses to work without a `rest`", async () => {
|
|
231
|
+
const memory = new MongoAgentMemory({ collection: MEMORY });
|
|
232
|
+
await expect(memory.get("t", {})).rejects.toMatchObject({ code: "AGENT_MEMORY_NO_REST" });
|
|
233
|
+
await expect(memory.save("t", [], {})).rejects.toMatchObject({ code: "AGENT_MEMORY_NO_REST" });
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ─── Delete propagation ─────────────────────────────────�───────────────────
|
|
238
|
+
|
|
239
|
+
const tenantConfig = () => (cfg.tenants ?? []).find((t) => t.id === TENANT) as any;
|
|
240
|
+
const markersFor = (docId: string) => rest.db.collection("_replication_").countDocuments({
|
|
241
|
+
type: "delete", collection: MEMORY, docId,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("replicatesCollection / replicatesMetaCollection", () => {
|
|
245
|
+
it("follows enabled, destinations and exclude", () => {
|
|
246
|
+
// Nothing configured
|
|
247
|
+
expect(replicatesCollection(TENANT, MEMORY)).toBe(false);
|
|
248
|
+
expect(replicatesMetaCollection(TENANT, "memory")).toBe(false);
|
|
249
|
+
expect(replicatesMetaCollection("unknown", "memory")).toBe(false);
|
|
250
|
+
|
|
251
|
+
tenantConfig().replication = { enabled: false, destinations: [{ id: "b", uri: "mongodb://localhost:27017/x" }] };
|
|
252
|
+
expect(replicatesCollection(TENANT, MEMORY)).toBe(false);
|
|
253
|
+
|
|
254
|
+
tenantConfig().replication = { destinations: [] };
|
|
255
|
+
expect(replicatesCollection(TENANT, MEMORY)).toBe(false);
|
|
256
|
+
|
|
257
|
+
tenantConfig().replication = { destinations: [{ id: "b", uri: "mongodb://localhost:27017/x" }] };
|
|
258
|
+
// The published memory collection is replicated — by its own slug, under the `memory` name
|
|
259
|
+
expect(replicatesCollection(TENANT, MEMORY)).toBe(true);
|
|
260
|
+
expect(replicatesMetaCollection(TENANT, "memory")).toBe(true);
|
|
261
|
+
expect(replicatesMetaCollection(TENANT, "audit")).toBe(true);
|
|
262
|
+
expect(replicatesCollection(TENANT, "_audit_")).toBe(true);
|
|
263
|
+
expect(replicatesCollection(TENANT, "_vars_")).toBe(true);
|
|
264
|
+
// A collection nobody knows (not declared, not opted in) is not replicated
|
|
265
|
+
expect(replicatesCollection(TENANT, "whatever")).toBe(false);
|
|
266
|
+
|
|
267
|
+
tenantConfig().replication.exclude = ["memory"];
|
|
268
|
+
expect(replicatesCollection(TENANT, MEMORY)).toBe(false);
|
|
269
|
+
expect(replicatesMetaCollection(TENANT, "memory")).toBe(false);
|
|
270
|
+
expect(replicatesMetaCollection(TENANT, "audit")).toBe(true);
|
|
271
|
+
expect(replicatesCollection(TENANT, "_audit_")).toBe(true);
|
|
272
|
+
|
|
273
|
+
delete tenantConfig().replication;
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("agents.memory.Mongo — delete propagation", () => {
|
|
278
|
+
const replicated = { destinations: [{ id: "backup", uri: "mongodb://localhost:27017/_AGENT_MEMORY_DEST" }] };
|
|
279
|
+
|
|
280
|
+
it("writes a tombstone for a cleared thread so the deletion reaches the destinations", async () => {
|
|
281
|
+
tenantConfig().replication = { ...replicated };
|
|
282
|
+
try {
|
|
283
|
+
const memory = new MongoAgentMemory({ collection: MEMORY });
|
|
284
|
+
await memory.save("gone", [message("user", "hi")], { rest, tenant: TENANT, resourceId: "u1" });
|
|
285
|
+
await memory.clear("gone", { rest, tenant: TENANT, resourceId: "u1" });
|
|
286
|
+
|
|
287
|
+
const marker: any = await rest.db.collection("_replication_").findOne({
|
|
288
|
+
type: "delete", collection: MEMORY, docId: "u1:gone",
|
|
289
|
+
});
|
|
290
|
+
expect(marker).toBeDefined();
|
|
291
|
+
expect(marker.tenant).toBe(TENANT);
|
|
292
|
+
expect(marker.synced).toEqual([]);
|
|
293
|
+
} finally {
|
|
294
|
+
delete tenantConfig().replication;
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("writes none when nobody replicates, when `memory` is excluded, or for a custom collection", async () => {
|
|
299
|
+
const memory = new MongoAgentMemory({ collection: MEMORY });
|
|
300
|
+
|
|
301
|
+
// Nothing configured
|
|
302
|
+
await memory.save("a", [message("user", "hi")], { rest, tenant: TENANT, resourceId: "u1" });
|
|
303
|
+
await memory.clear("a", { rest, tenant: TENANT, resourceId: "u1" });
|
|
304
|
+
expect(await markersFor("u1:a")).toBe(0);
|
|
305
|
+
|
|
306
|
+
// Replicated, but `_memories_` is excluded — a tombstone could never be flushed
|
|
307
|
+
tenantConfig().replication = { ...replicated, exclude: ["memory"] };
|
|
308
|
+
try {
|
|
309
|
+
await memory.save("b", [message("user", "hi")], { rest, tenant: TENANT, resourceId: "u1" });
|
|
310
|
+
await memory.clear("b", { rest, tenant: TENANT, resourceId: "u1" });
|
|
311
|
+
expect(await markersFor("u1:b")).toBe(0);
|
|
312
|
+
} finally {
|
|
313
|
+
delete tenantConfig().replication;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A custom collection is the tenant's business, not the framework's
|
|
317
|
+
tenantConfig().replication = { ...replicated };
|
|
318
|
+
try {
|
|
319
|
+
const custom = new MongoAgentMemory({ collection: `_memories_custom_${crypto.randomUUID().slice(0, 6)}_` });
|
|
320
|
+
await custom.save("c", [message("user", "hi")], { rest, tenant: TENANT, resourceId: "u1" });
|
|
321
|
+
await custom.clear("c", { rest, tenant: TENANT, resourceId: "u1" });
|
|
322
|
+
expect(await markersFor("u1:c")).toBe(0);
|
|
323
|
+
await rest.db.dropCollection(custom.collection);
|
|
324
|
+
} finally {
|
|
325
|
+
delete tenantConfig().replication;
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ─── Wired into an agent ────────────────────────────────�───────────────────
|
|
331
|
+
|
|
332
|
+
describe("agent memory — end to end", () => {
|
|
333
|
+
it("replays the thread stored in Mongo, and exposes it to the caller", async () => {
|
|
334
|
+
const memory = new MongoAgentMemory({ collection: MEMORY, maxMessages: 50 });
|
|
335
|
+
const agent = new Agent({
|
|
336
|
+
id: "memo",
|
|
337
|
+
description: "A test agent.",
|
|
338
|
+
instructions: "You are a test agent.",
|
|
339
|
+
provider: { model: "test-model", apiKey: "test-key", compatible: "openai", baseUrl: provider.url, options: { retries: 0 } },
|
|
340
|
+
memory,
|
|
341
|
+
} as any);
|
|
342
|
+
agent.setRest(rest);
|
|
343
|
+
|
|
344
|
+
await agent.generate("first", { thread: "chat-1", resource: "e2e" });
|
|
345
|
+
await agent.generate("second", { thread: "chat-1", resource: "e2e" });
|
|
346
|
+
|
|
347
|
+
// The second run replayed the first exchange
|
|
348
|
+
const second = provider.requests[1]!.body.messages;
|
|
349
|
+
expect(second.map((m: any) => m.role)).toEqual(["system", "user", "assistant", "user"]);
|
|
350
|
+
expect(second[1].content).toBe("first");
|
|
351
|
+
expect(second[2].content).toBe("ok");
|
|
352
|
+
expect(second[3].content).toBe("second");
|
|
353
|
+
|
|
354
|
+
// The caller can read it back — and list its threads
|
|
355
|
+
expect((await agent.getMessages("chat-1", { resource: "e2e" })).map((m) => m.role))
|
|
356
|
+
.toEqual(["user", "assistant", "user", "assistant"]);
|
|
357
|
+
|
|
358
|
+
const threads = await agent.listThreads({ resource: "e2e" });
|
|
359
|
+
expect(threads.map((t) => t.threadId)).toEqual(["chat-1"]);
|
|
360
|
+
expect(threads[0]!.title).toBe("first");
|
|
361
|
+
|
|
362
|
+
// Another caller sees nothing
|
|
363
|
+
expect(await agent.getMessages("chat-1", { resource: "u2" })).toEqual([]);
|
|
364
|
+
|
|
365
|
+
await agent.clearMessages("chat-1", { resource: "e2e" });
|
|
366
|
+
expect(await agent.listThreads({ resource: "e2e" })).toEqual([]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("accepts the `memory: { resource, thread }` shorthand", async () => {
|
|
370
|
+
const agent = new Agent({
|
|
371
|
+
id: "shorthand",
|
|
372
|
+
description: "A test agent.",
|
|
373
|
+
instructions: "You are a test agent.",
|
|
374
|
+
provider: { model: "test-model", apiKey: "test-key", compatible: "openai", baseUrl: provider.url, options: { retries: 0 } },
|
|
375
|
+
memory: new MongoAgentMemory({ collection: MEMORY }),
|
|
376
|
+
} as any);
|
|
377
|
+
agent.setRest(rest);
|
|
378
|
+
|
|
379
|
+
const memory = { resource: "u-short", thread: "c-short" };
|
|
380
|
+
await agent.generate("Remember my favorite color is blue.", { memory });
|
|
381
|
+
await agent.generate("What is my favorite color?", { memory });
|
|
382
|
+
|
|
383
|
+
// The second run replayed the first exchange
|
|
384
|
+
const second = at(provider.requests, provider.requests.length - 1).body.messages;
|
|
385
|
+
expect(second.map((m: any) => m.role)).toEqual(["system", "user", "assistant", "user"]);
|
|
386
|
+
expect(second[1].content).toBe("Remember my favorite color is blue.");
|
|
387
|
+
|
|
388
|
+
// Stored under `resource:thread`, readable with either spelling
|
|
389
|
+
expect((await agent.getMessages("c-short", { resource: "u-short" })).length).toBe(4);
|
|
390
|
+
expect((await agent.getMessages("c-short", { resourceId: "u-short" })).length).toBe(4);
|
|
391
|
+
expect(await agent.getMessages("c-short")).toEqual([]); // no caller → another namespace
|
|
392
|
+
expect((await agent.listThreads({ resource: "u-short" })).map((t) => t.threadId)).toEqual(["c-short"]);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("writes nothing back when the run is readOnly", async () => {
|
|
396
|
+
const agent = new Agent({
|
|
397
|
+
id: "preview",
|
|
398
|
+
description: "A test agent.",
|
|
399
|
+
instructions: "You are a test agent.",
|
|
400
|
+
provider: { model: "test-model", apiKey: "test-key", compatible: "openai", baseUrl: provider.url, options: { retries: 0 } },
|
|
401
|
+
memory: new MongoAgentMemory({ collection: MEMORY }),
|
|
402
|
+
} as any);
|
|
403
|
+
agent.setRest(rest);
|
|
404
|
+
|
|
405
|
+
const { text } = await agent.generate("just looking", {
|
|
406
|
+
memory: { resource: "u-ro", thread: "c-ro", readOnly: true },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
expect(text).toBe("ok");
|
|
410
|
+
expect(await agent.getMessages("c-ro", { resource: "u-ro" })).toEqual([]);
|
|
411
|
+
expect(await agent.listThreads({ resource: "u-ro" })).toEqual([]);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("reports no thread list for a store that does not support it", async () => {
|
|
415
|
+
const agent = new Agent({
|
|
416
|
+
id: "plain",
|
|
417
|
+
description: "x",
|
|
418
|
+
instructions: "x",
|
|
419
|
+
provider: { model: "m", compatible: "openai", apiKey: "k" },
|
|
420
|
+
} as any);
|
|
421
|
+
expect(await agent.listThreads()).toEqual([]);
|
|
422
|
+
|
|
423
|
+
const minimal = new Agent({
|
|
424
|
+
id: "minimal",
|
|
425
|
+
description: "x",
|
|
426
|
+
instructions: "x",
|
|
427
|
+
provider: { model: "m", compatible: "openai", apiKey: "k" },
|
|
428
|
+
// A custom store only has to implement get / save / clear
|
|
429
|
+
memory: { get: () => [], save: () => { }, clear: () => { } },
|
|
430
|
+
} as any);
|
|
431
|
+
expect(await minimal.listThreads()).toEqual([]);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// ─── Redis ─────────────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
/** A structural stand-in for ioredis — the store only uses these commands. */
|
|
438
|
+
function mockRedis() {
|
|
439
|
+
const values = new Map<string, string>();
|
|
440
|
+
const expiries = new Map<string, number>();
|
|
441
|
+
const sets = new Map<string, Map<string, number>>();
|
|
442
|
+
return {
|
|
443
|
+
values, expiries, sets,
|
|
444
|
+
get: async (key: string) => values.get(key) ?? null,
|
|
445
|
+
set: async (key: string, value: string, ...args: any[]) => {
|
|
446
|
+
values.set(key, value);
|
|
447
|
+
if (args[0] === "EX") expiries.set(key, Number(args[1]));
|
|
448
|
+
return "OK";
|
|
449
|
+
},
|
|
450
|
+
del: async (...keys: string[]) => keys.reduce((count, key) => count + (values.delete(key) ? 1 : 0), 0),
|
|
451
|
+
mget: async (...keys: string[]) => keys.map((key) => values.get(key) ?? null),
|
|
452
|
+
expire: async (key: string, seconds: number) => { expiries.set(key, seconds); return 1; },
|
|
453
|
+
zadd: async (key: string, score: number, member: string) => {
|
|
454
|
+
const set = sets.get(key) ?? new Map<string, number>();
|
|
455
|
+
const added = set.has(member) ? 0 : 1;
|
|
456
|
+
set.set(member, score);
|
|
457
|
+
sets.set(key, set);
|
|
458
|
+
return added;
|
|
459
|
+
},
|
|
460
|
+
zrevrange: async (key: string, start: number, stop: number) =>
|
|
461
|
+
[...(sets.get(key) ?? new Map<string, number>())]
|
|
462
|
+
.sort((a, b) => b[1] - a[1])
|
|
463
|
+
.slice(start, stop + 1)
|
|
464
|
+
.map(([member]) => member),
|
|
465
|
+
zrem: async (key: string, ...members: string[]) => {
|
|
466
|
+
const set = sets.get(key);
|
|
467
|
+
return members.reduce((count, member) => count + (set?.delete(member) ? 1 : 0), 0);
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const REDIS_CTX = { tenant: "v1", resourceId: "u1", agentId: "support" };
|
|
473
|
+
const threadKeyOf = (threadId: string) => `anteros:agent-memory:v1:thread:u1:${threadId}`;
|
|
474
|
+
const indexKey = "anteros:agent-memory:v1:resource:u1";
|
|
475
|
+
|
|
476
|
+
describe("agents.memory.Redis", () => {
|
|
477
|
+
it("round-trips a thread, namespaced by tenant and caller", async () => {
|
|
478
|
+
const client = mockRedis();
|
|
479
|
+
const memory = new RedisAgentMemory({ client });
|
|
480
|
+
|
|
481
|
+
await memory.save("chat-1", [message("user", "hi"), message("assistant", "hello")], REDIS_CTX);
|
|
482
|
+
|
|
483
|
+
expect((await memory.get("chat-1", REDIS_CTX)).map((m) => m.content)).toEqual(["hi", "hello"]);
|
|
484
|
+
// Another caller — and another tenant — see nothing
|
|
485
|
+
expect(await memory.get("chat-1", { ...REDIS_CTX, resourceId: "u2" })).toEqual([]);
|
|
486
|
+
expect(await memory.get("chat-1", { ...REDIS_CTX, tenant: "v2" })).toEqual([]);
|
|
487
|
+
|
|
488
|
+
const payload = JSON.parse(client.values.get(threadKeyOf("chat-1"))!);
|
|
489
|
+
expect(payload.title).toBe("hi");
|
|
490
|
+
expect(payload.resourceId).toBe("u1");
|
|
491
|
+
expect(payload.agentId).toBe("support");
|
|
492
|
+
expect(payload.messages).toHaveLength(2);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("refuses to run without a tenant", async () => {
|
|
496
|
+
const memory = new RedisAgentMemory({ client: mockRedis() });
|
|
497
|
+
await expect(memory.get("t", {})).rejects.toMatchObject({ code: "AGENT_MEMORY_NO_TENANT" });
|
|
498
|
+
await expect(memory.save("t", [], {})).rejects.toMatchObject({ code: "AGENT_MEMORY_NO_TENANT" });
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("keeps the title and createdAt of the first exchange", async () => {
|
|
502
|
+
const client = mockRedis();
|
|
503
|
+
const memory = new RedisAgentMemory({ client });
|
|
504
|
+
|
|
505
|
+
await memory.save("t", [message("user", "first question")], REDIS_CTX);
|
|
506
|
+
const created = JSON.parse(client.values.get(threadKeyOf("t"))!).createdAt;
|
|
507
|
+
|
|
508
|
+
await Bun.sleep(3);
|
|
509
|
+
await memory.save("t", [message("user", "first question"), message("assistant", "answer")], REDIS_CTX);
|
|
510
|
+
|
|
511
|
+
const payload = JSON.parse(client.values.get(threadKeyOf("t"))!);
|
|
512
|
+
expect(payload.title).toBe("first question");
|
|
513
|
+
expect(payload.createdAt).toBe(created);
|
|
514
|
+
expect(payload.messages).toHaveLength(2);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("bounds the conversation", async () => {
|
|
518
|
+
const memory = new RedisAgentMemory({ client: mockRedis(), maxMessages: 3 });
|
|
519
|
+
await memory.save("t", [
|
|
520
|
+
message("system", "persona"),
|
|
521
|
+
message("user", "1"),
|
|
522
|
+
message("assistant", "2"),
|
|
523
|
+
message("user", "3"),
|
|
524
|
+
message("assistant", "4"),
|
|
525
|
+
], REDIS_CTX);
|
|
526
|
+
|
|
527
|
+
expect((await memory.get("t", REDIS_CTX)).map((m) => m.content)).toEqual(["persona", "3", "4"]);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("applies the ttl to the thread and to the caller index", async () => {
|
|
531
|
+
const client = mockRedis();
|
|
532
|
+
const memory = new RedisAgentMemory({ client, ttl: "1h" });
|
|
533
|
+
await memory.save("t", [message("user", "hi")], REDIS_CTX);
|
|
534
|
+
|
|
535
|
+
expect(client.expiries.get(threadKeyOf("t"))).toBe(3600);
|
|
536
|
+
expect(client.expiries.get(indexKey)).toBe(3600);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("rejects an invalid ttl", async () => {
|
|
540
|
+
const memory = new RedisAgentMemory({ client: mockRedis(), ttl: "soon" });
|
|
541
|
+
await expect(memory.save("t", [], REDIS_CTX)).rejects.toMatchObject({ code: "AGENT_MEMORY_INVALID_TTL" });
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("lists a caller's threads, most recent first, and prunes the expired ones", async () => {
|
|
545
|
+
const client = mockRedis();
|
|
546
|
+
const memory = new RedisAgentMemory({ client });
|
|
547
|
+
|
|
548
|
+
await memory.save("older", [message("user", "What is the weather?")], REDIS_CTX);
|
|
549
|
+
await Bun.sleep(3);
|
|
550
|
+
await memory.save("newer", [message("user", "hi")], REDIS_CTX);
|
|
551
|
+
await memory.save("elsewhere", [message("user", "hi")], { ...REDIS_CTX, resourceId: "u2" });
|
|
552
|
+
|
|
553
|
+
const threads = await memory.list({ tenant: "v1", resourceId: "u1" });
|
|
554
|
+
expect(threads.map((t) => t.threadId)).toEqual(["newer", "older"]);
|
|
555
|
+
expect(threads[1]!.title).toBe("What is the weather?");
|
|
556
|
+
expect(threads[1]!.messages).toBe(1);
|
|
557
|
+
expect(threads[0]!.updatedAt).toBeInstanceOf(Date);
|
|
558
|
+
|
|
559
|
+
// A thread that expired behind the index is dropped on read
|
|
560
|
+
client.values.delete(threadKeyOf("older"));
|
|
561
|
+
expect((await memory.list({ tenant: "v1", resourceId: "u1" })).map((t) => t.threadId)).toEqual(["newer"]);
|
|
562
|
+
expect(client.sets.get(indexKey)!.has("u1:older")).toBe(false);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("clears the thread and its index entry", async () => {
|
|
566
|
+
const client = mockRedis();
|
|
567
|
+
const memory = new RedisAgentMemory({ client });
|
|
568
|
+
|
|
569
|
+
await memory.save("t", [message("user", "hi")], REDIS_CTX);
|
|
570
|
+
await memory.clear("t", REDIS_CTX);
|
|
571
|
+
|
|
572
|
+
expect(await memory.get("t", REDIS_CTX)).toEqual([]);
|
|
573
|
+
expect(client.sets.get(indexKey)!.has("u1:t")).toBe(false);
|
|
574
|
+
expect(await memory.list({ tenant: "v1", resourceId: "u1" })).toEqual([]);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("shares one thread across callers when `scoped` is off", async () => {
|
|
578
|
+
const client = mockRedis();
|
|
579
|
+
const memory = new RedisAgentMemory({ client, scoped: false });
|
|
580
|
+
|
|
581
|
+
await memory.save("shared", [message("user", "from A")], { tenant: "v1", resourceId: "A" });
|
|
582
|
+
await memory.save("shared", [message("user", "from B")], { tenant: "v1", resourceId: "B" });
|
|
583
|
+
|
|
584
|
+
expect((await memory.get("shared", { tenant: "v1", resourceId: "A" }))[0]!.content).toBe("from B");
|
|
585
|
+
expect(client.values.has("anteros:agent-memory:v1:thread:shared")).toBe(true);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("reads a corrupted entry as an empty thread", async () => {
|
|
589
|
+
const client = mockRedis();
|
|
590
|
+
const memory = new RedisAgentMemory({ client });
|
|
591
|
+
client.values.set(threadKeyOf("broken"), "not json");
|
|
592
|
+
|
|
593
|
+
expect(await memory.get("broken", REDIS_CTX)).toEqual([]);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("replays a Redis thread through a real agent, and lists it", async () => {
|
|
597
|
+
const client = mockRedis();
|
|
598
|
+
const agent = new Agent({
|
|
599
|
+
id: "redis-agent",
|
|
600
|
+
description: "A test agent.",
|
|
601
|
+
instructions: "You are a test agent.",
|
|
602
|
+
provider: { model: "test-model", apiKey: "test-key", compatible: "openai", baseUrl: provider.url, options: { retries: 0 } },
|
|
603
|
+
memory: new RedisAgentMemory({ client }),
|
|
604
|
+
} as any);
|
|
605
|
+
agent._tenant_ = "v1";
|
|
606
|
+
agent.setRest(rest);
|
|
607
|
+
|
|
608
|
+
await agent.generate("first", { thread: "c1", resource: "u1" });
|
|
609
|
+
await agent.generate("second", { thread: "c1", resource: "u1" });
|
|
610
|
+
|
|
611
|
+
const last = at(provider.requests, provider.requests.length - 1).body.messages;
|
|
612
|
+
expect(last.map((m: any) => m.role)).toEqual(["system", "user", "assistant", "user"]);
|
|
613
|
+
expect(last[1].content).toBe("first");
|
|
614
|
+
|
|
615
|
+
expect((await agent.listThreads({ resource: "u1" })).map((t) => t.threadId)).toEqual(["c1"]);
|
|
616
|
+
});
|
|
617
|
+
});
|