@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,586 @@
|
|
|
1
|
+
import { AppError } from "./error";
|
|
2
|
+
import { parseDuration } from "../utils/func";
|
|
3
|
+
import { syncTtlRetention } from "../database/ttl";
|
|
4
|
+
import { writeDeleteMarkers, replicatesCollection } from "../database/deleteLog";
|
|
5
|
+
import { Redis } from "ioredis";
|
|
6
|
+
import type { Db } from "mongodb";
|
|
7
|
+
import type {
|
|
8
|
+
AgentMemory,
|
|
9
|
+
AgentMemoryContext,
|
|
10
|
+
AgentMemoryOptions,
|
|
11
|
+
AgentMemoryState,
|
|
12
|
+
AgentMessage,
|
|
13
|
+
AgentThread,
|
|
14
|
+
MongoAgentMemoryOptions,
|
|
15
|
+
RedisAgentMemoryOptions,
|
|
16
|
+
RedisClientLike,
|
|
17
|
+
} from "../types/agent";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Conversation stores — `agents.memory`.
|
|
21
|
+
*
|
|
22
|
+
* An agent with a `memory` reads its thread before a run and writes the updated
|
|
23
|
+
* conversation after it. Three implementations ship with the framework:
|
|
24
|
+
*
|
|
25
|
+
* - **`agents.memory.InMemory`** — a `Map` in the process. Fine for a demo, a
|
|
26
|
+
* script or a single-process server; useless across processes.
|
|
27
|
+
* - **`agents.memory.Mongo`** — one document per thread in a collection **you
|
|
28
|
+
* name** (`_memories_`, `support_threads`…). Durable, shared by every process,
|
|
29
|
+
* with a message cap, an optional TTL, a thread list, and replicated by the
|
|
30
|
+
* replication engine like a declared collection.
|
|
31
|
+
* - **`agents.memory.Redis`** — one key per thread, shared by every process
|
|
32
|
+
* without MongoDB. Same options, plus a per-thread expiry.
|
|
33
|
+
*
|
|
34
|
+
* All three share the same policy: **a thread is bounded** (`maxMessages`, default
|
|
35
|
+
* 100 — the oldest messages are dropped on save) and **namespaced per caller**
|
|
36
|
+
* (`resourceId:threadId`, unless `scoped: false`).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Default `maxMessages` — the cap is applied on every save. */
|
|
40
|
+
const DEFAULT_MAX_MESSAGES = 100;
|
|
41
|
+
const DEFAULT_LIST_LIMIT = 20;
|
|
42
|
+
|
|
43
|
+
/** Thread title derived from the first user message (the document's, set once). */
|
|
44
|
+
const TITLE_MAX = 80;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The document key of a thread.
|
|
48
|
+
*
|
|
49
|
+
* With `scoped` (default) the caller identity prefixes the id, so two callers
|
|
50
|
+
* using the same `threadId` never share a conversation. A missing `resourceId`
|
|
51
|
+
* falls back to the bare id.
|
|
52
|
+
*/
|
|
53
|
+
function threadKey(threadId: string, ctx?: AgentMemoryContext, scoped = true): string {
|
|
54
|
+
const resourceId = ctx?.resourceId;
|
|
55
|
+
if (!scoped || !resourceId) return threadId;
|
|
56
|
+
return `${resourceId}:${threadId}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Keep the last `maxMessages` — the cap is a hard limit, and the opening
|
|
61
|
+
* `system` (or `developer`) message, when there is one, takes one of the slots
|
|
62
|
+
* instead of being the first casualty of a long conversation.
|
|
63
|
+
*/
|
|
64
|
+
function trimMessages(messages: AgentMessage[], maxMessages: number): AgentMessage[] {
|
|
65
|
+
if (messages.length <= maxMessages) return messages;
|
|
66
|
+
|
|
67
|
+
const opening = messages[0]?.role === "system" || messages[0]?.role === "developer" ? messages[0] : undefined;
|
|
68
|
+
const budget = Math.max(0, maxMessages - (opening ? 1 : 0));
|
|
69
|
+
const kept = budget === 0 ? [] : messages.slice(messages.length - budget);
|
|
70
|
+
return opening ? [opening, ...kept] : kept;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function threadTitle(messages: AgentMessage[]): string | undefined {
|
|
74
|
+
const first = messages.find((message) => message.role === "user");
|
|
75
|
+
const text = typeof first?.content === "string"
|
|
76
|
+
? first.content
|
|
77
|
+
: Array.isArray(first?.content)
|
|
78
|
+
? first.content.filter((part) => part.type === "text").map((part) => (part as any).text).join(" ")
|
|
79
|
+
: "";
|
|
80
|
+
const title = text.trim().replace(/\s+/g, " ");
|
|
81
|
+
if (!title) return undefined;
|
|
82
|
+
return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1)}…` : title;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cloneMessages(messages: AgentMessage[]): AgentMessage[] {
|
|
86
|
+
return messages.map((message) => ({ ...message }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── In-memory ──────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* In-process conversation store — the default when an agent declares no
|
|
93
|
+
* `memory`.
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* import { agents } from '@anteros/core'
|
|
97
|
+
* const memory = new agents.memory.InMemory({ maxMessages: 40 })
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* One `Map` per process: in a multi-process deployment (`reusePort`, several
|
|
101
|
+
* containers) each process has its own threads — use `agents.memory.Mongo()`
|
|
102
|
+
* when a conversation must be shared.
|
|
103
|
+
*/
|
|
104
|
+
export class InMemoryAgentMemory implements AgentMemory {
|
|
105
|
+
#threads = new Map<string, { messages: AgentMessage[]; title?: string; createdAt: Date; updatedAt: Date }>();
|
|
106
|
+
#states = new Map<string, AgentMemoryState>();
|
|
107
|
+
#maxMessages: number;
|
|
108
|
+
#scoped: boolean;
|
|
109
|
+
|
|
110
|
+
constructor(options: AgentMemoryOptions = {}) {
|
|
111
|
+
this.#maxMessages = Math.max(1, options.maxMessages ?? DEFAULT_MAX_MESSAGES);
|
|
112
|
+
this.#scoped = options.scoped ?? true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get(threadId: string, ctx?: AgentMemoryContext): AgentMessage[] {
|
|
116
|
+
const thread = this.#threads.get(threadKey(threadId, ctx, this.#scoped));
|
|
117
|
+
return thread ? cloneMessages(thread.messages) : [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
save(threadId: string, messages: AgentMessage[], ctx?: AgentMemoryContext): void {
|
|
121
|
+
const key = threadKey(threadId, ctx, this.#scoped);
|
|
122
|
+
const existing = this.#threads.get(key);
|
|
123
|
+
const trimmed = trimMessages(messages, this.#maxMessages);
|
|
124
|
+
|
|
125
|
+
this.#threads.set(key, {
|
|
126
|
+
messages: cloneMessages(trimmed),
|
|
127
|
+
title: existing?.title ?? threadTitle(trimmed),
|
|
128
|
+
createdAt: existing?.createdAt ?? new Date(),
|
|
129
|
+
updatedAt: new Date(),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
clear(threadId: string, ctx?: AgentMemoryContext): void {
|
|
134
|
+
this.#threads.delete(threadKey(threadId, ctx, this.#scoped));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
list(ctx?: AgentMemoryContext & { limit?: number }): AgentThread[] {
|
|
138
|
+
const prefix = this.#scoped && ctx?.resourceId ? `${ctx.resourceId}:` : "";
|
|
139
|
+
return [...this.#threads.entries()]
|
|
140
|
+
.filter(([key]) => (prefix ? key.startsWith(prefix) : true))
|
|
141
|
+
.map(([key, thread]) => ({
|
|
142
|
+
threadId: prefix ? key.slice(prefix.length) : key,
|
|
143
|
+
resourceId: ctx?.resourceId,
|
|
144
|
+
title: thread.title,
|
|
145
|
+
messages: thread.messages.length,
|
|
146
|
+
createdAt: thread.createdAt,
|
|
147
|
+
updatedAt: thread.updatedAt,
|
|
148
|
+
}))
|
|
149
|
+
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
|
150
|
+
.slice(0, ctx?.limit ?? DEFAULT_LIST_LIMIT);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Thread keys currently held — debugging helper. */
|
|
154
|
+
threads(): string[] {
|
|
155
|
+
return [...this.#threads.keys()];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getState(key: string): AgentMemoryState | undefined {
|
|
159
|
+
const state = this.#states.get(key);
|
|
160
|
+
return state ? { ...state } : undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
setState(key: string, value: AgentMemoryState): void {
|
|
164
|
+
this.#states.set(key, { ...value });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
clearState(key: string): void {
|
|
168
|
+
this.#states.delete(key);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── MongoDB ────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Durable conversation store — one document per thread in the tenant database.
|
|
176
|
+
*
|
|
177
|
+
* ```ts
|
|
178
|
+
* export default define.Agent({
|
|
179
|
+
* // …
|
|
180
|
+
* memory: new agents.memory.Mongo({ collection: '_memories_', maxMessages: 60, ttl: '180d' }),
|
|
181
|
+
* })
|
|
182
|
+
* ```
|
|
183
|
+
*
|
|
184
|
+
* The **collection name is required**: the threads are your data, they live where
|
|
185
|
+
* you decide, and the replication engine copies that collection like a declared
|
|
186
|
+
* one (the loader publishes it — opt out of it with `replication.exclude: ['memory']`).
|
|
187
|
+
*
|
|
188
|
+
* Writes go through the **context's** `rest` (the tenant database), exactly like
|
|
189
|
+
* a tool: register it in the definition and every tenant keeps its own threads.
|
|
190
|
+
* The collection and its indexes are created on first use — never at boot.
|
|
191
|
+
*/
|
|
192
|
+
export class MongoAgentMemory implements AgentMemory {
|
|
193
|
+
#options: MongoAgentMemoryOptions;
|
|
194
|
+
#ready = new WeakMap<Db, Promise<void>>();
|
|
195
|
+
|
|
196
|
+
constructor(options: MongoAgentMemoryOptions) {
|
|
197
|
+
if (!options?.collection) {
|
|
198
|
+
throw new AppError(
|
|
199
|
+
"A Mongo memory store needs its `collection` — `new agents.memory.Mongo({ collection: '_memories_' })`",
|
|
200
|
+
{ status: 500, code: "AGENT_MEMORY_COLLECTION_REQUIRED" },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
this.#options = options;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Collection holding the threads, in the tenant database. */
|
|
207
|
+
get collection(): string {
|
|
208
|
+
return this.#options.collection;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The replication engine copies this collection — see `syncAgents()`. */
|
|
212
|
+
collections(): string[] {
|
|
213
|
+
return [this.#options.collection, this.stateCollection];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Where the working memory lives: `${collection}__state`, derived so a tenant
|
|
218
|
+
* names one collection and gets both (the threads, and the scratchpads).
|
|
219
|
+
*/
|
|
220
|
+
get stateCollection(): string {
|
|
221
|
+
return `${this.#options.collection}__state`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async getState(key: string, ctx?: AgentMemoryContext): Promise<AgentMemoryState | undefined> {
|
|
225
|
+
const db = this.#db(ctx);
|
|
226
|
+
const doc: any = await db.collection(this.stateCollection).findOne({ _id: key as any });
|
|
227
|
+
return doc ? { value: doc.value, updatedAt: doc.updatedAt } : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async setState(key: string, value: AgentMemoryState, ctx?: AgentMemoryContext): Promise<void> {
|
|
231
|
+
const db = this.#db(ctx);
|
|
232
|
+
await db.collection(this.stateCollection).updateOne(
|
|
233
|
+
{ _id: key as any },
|
|
234
|
+
{
|
|
235
|
+
$set: { value: value.value, updatedAt: value.updatedAt ?? new Date() },
|
|
236
|
+
$setOnInsert: { createdAt: new Date() },
|
|
237
|
+
},
|
|
238
|
+
{ upsert: true },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async clearState(key: string, ctx?: AgentMemoryContext): Promise<void> {
|
|
243
|
+
const db = this.#db(ctx);
|
|
244
|
+
await db.collection(this.stateCollection).deleteOne({ _id: key as any });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#db(ctx?: AgentMemoryContext): Db {
|
|
248
|
+
const db: any = (ctx?.rest as any)?.db;
|
|
249
|
+
if (!db?.collection) {
|
|
250
|
+
throw new AppError(
|
|
251
|
+
'A Mongo memory store needs a `rest` — resolve the agent from a context (`agents.get(id)`) or pass one (`agents.get(tenantId, id, rest)`)',
|
|
252
|
+
{ status: 500, code: "AGENT_MEMORY_NO_REST" },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return db as Db;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Indexes and retention, once per database connection. */
|
|
259
|
+
#ensure(db: Db): Promise<void> {
|
|
260
|
+
let pending = this.#ready.get(db);
|
|
261
|
+
if (pending) return pending;
|
|
262
|
+
|
|
263
|
+
pending = (async () => {
|
|
264
|
+
try {
|
|
265
|
+
const collection = db.collection(this.collection);
|
|
266
|
+
// `list()` — the caller's most recent threads
|
|
267
|
+
await collection.createIndex({ resourceId: 1, updatedAt: -1 }, { background: true });
|
|
268
|
+
if (this.#options.ttl !== undefined) {
|
|
269
|
+
await syncTtlRetention(db, {
|
|
270
|
+
collection: this.collection,
|
|
271
|
+
index: `${this.collection}_updatedAt_ttl`,
|
|
272
|
+
field: "updatedAt",
|
|
273
|
+
retention: this.#options.ttl,
|
|
274
|
+
label: "Agent memory",
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
} catch (err: any) {
|
|
278
|
+
// Never fatal: a missing index costs performance, not correctness
|
|
279
|
+
console.error(`Agent memory index setup failed: ${err?.message}`);
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
|
|
283
|
+
this.#ready.set(db, pending);
|
|
284
|
+
return pending;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async get(threadId: string, ctx?: AgentMemoryContext): Promise<AgentMessage[]> {
|
|
288
|
+
const db = this.#db(ctx);
|
|
289
|
+
await this.#ensure(db);
|
|
290
|
+
|
|
291
|
+
const doc: any = await db.collection(this.collection).findOne({
|
|
292
|
+
_id: threadKey(threadId, ctx, this.#options.scoped ?? true) as any,
|
|
293
|
+
});
|
|
294
|
+
return (doc?.messages ?? []) as AgentMessage[];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async save(threadId: string, messages: AgentMessage[], ctx?: AgentMemoryContext): Promise<void> {
|
|
298
|
+
const db = this.#db(ctx);
|
|
299
|
+
await this.#ensure(db);
|
|
300
|
+
|
|
301
|
+
const scoped = this.#options.scoped ?? true;
|
|
302
|
+
const trimmed = trimMessages(messages, Math.max(1, this.#options.maxMessages ?? DEFAULT_MAX_MESSAGES));
|
|
303
|
+
const now = new Date();
|
|
304
|
+
|
|
305
|
+
await db.collection(this.collection).updateOne(
|
|
306
|
+
{ _id: threadKey(threadId, ctx, scoped) as any },
|
|
307
|
+
{
|
|
308
|
+
$set: {
|
|
309
|
+
threadId,
|
|
310
|
+
resourceId: ctx?.resourceId,
|
|
311
|
+
agentId: ctx?.agentId,
|
|
312
|
+
messages: trimmed,
|
|
313
|
+
updatedAt: now,
|
|
314
|
+
},
|
|
315
|
+
// The title and the creation date tell the story of the thread —
|
|
316
|
+
// a later save must not overwrite them.
|
|
317
|
+
$setOnInsert: {
|
|
318
|
+
createdAt: now,
|
|
319
|
+
...(threadTitle(trimmed) ? { title: threadTitle(trimmed) } : {}),
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
{ upsert: true },
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async clear(threadId: string, ctx?: AgentMemoryContext): Promise<void> {
|
|
327
|
+
const db = this.#db(ctx);
|
|
328
|
+
await this.#ensure(db);
|
|
329
|
+
|
|
330
|
+
const key = threadKey(threadId, ctx, this.#options.scoped ?? true);
|
|
331
|
+
await db.collection(this.collection).deleteOne({ _id: key as any });
|
|
332
|
+
|
|
333
|
+
// A deleted thread no longer exists, so a date cursor can never see it —
|
|
334
|
+
// leave a tombstone so the deletion reaches the destinations too. Only when
|
|
335
|
+
// that collection is actually replicated for the tenant.
|
|
336
|
+
if (ctx?.tenant && replicatesCollection(ctx.tenant, this.collection)) {
|
|
337
|
+
await writeDeleteMarkers(db, ctx.tenant, this.collection, [key]);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async list(ctx?: AgentMemoryContext & { limit?: number }): Promise<AgentThread[]> {
|
|
342
|
+
const db = this.#db(ctx);
|
|
343
|
+
await this.#ensure(db);
|
|
344
|
+
|
|
345
|
+
const scoped = this.#options.scoped ?? true;
|
|
346
|
+
const filter = scoped && ctx?.resourceId ? { resourceId: ctx.resourceId } : {};
|
|
347
|
+
|
|
348
|
+
const docs: any[] = await db.collection(this.collection)
|
|
349
|
+
.find(filter)
|
|
350
|
+
.sort({ updatedAt: -1 })
|
|
351
|
+
.limit(ctx?.limit ?? DEFAULT_LIST_LIMIT)
|
|
352
|
+
.toArray();
|
|
353
|
+
|
|
354
|
+
return docs.map((doc) => ({
|
|
355
|
+
threadId: doc.threadId ?? doc._id,
|
|
356
|
+
resourceId: doc.resourceId,
|
|
357
|
+
title: doc.title,
|
|
358
|
+
messages: Array.isArray(doc.messages) ? doc.messages.length : 0,
|
|
359
|
+
createdAt: doc.createdAt,
|
|
360
|
+
updatedAt: doc.updatedAt,
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export {
|
|
366
|
+
DEFAULT_MAX_MESSAGES,
|
|
367
|
+
threadKey,
|
|
368
|
+
threadTitle,
|
|
369
|
+
trimMessages,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// ─── Redis ──────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Redis conversation store — shared by every process, no MongoDB involved.
|
|
376
|
+
*
|
|
377
|
+
* ```ts
|
|
378
|
+
* memory: new agents.memory.Redis({ url: Bun.env.REDIS_URL, ttl: '7d' })
|
|
379
|
+
* ```
|
|
380
|
+
*
|
|
381
|
+
* Layout (one key per thread, one sorted set per caller):
|
|
382
|
+
*
|
|
383
|
+
* ```
|
|
384
|
+
* anteros:agent-memory:<tenant>:thread:<resourceId>:<threadId> → JSON
|
|
385
|
+
* anteros:agent-memory:<tenant>:resource:<resourceId> → ZSET (score = updatedAt)
|
|
386
|
+
* ```
|
|
387
|
+
*
|
|
388
|
+
* The **tenant is part of every key** (unlike the Mongo store, whose database is
|
|
389
|
+
* already per tenant): a store instance created once at module scope can serve
|
|
390
|
+
* several tenants without mixing their conversations — and refuses to run without
|
|
391
|
+
* a tenant rather than putting everyone in one bucket.
|
|
392
|
+
*
|
|
393
|
+
* The caller index is what `list()` reads; it is pruned of threads that expired,
|
|
394
|
+
* and it expires on the same TTL as the threads it points to.
|
|
395
|
+
*/
|
|
396
|
+
export class RedisAgentMemory implements AgentMemory {
|
|
397
|
+
#options: RedisAgentMemoryOptions;
|
|
398
|
+
#client?: RedisClientLike;
|
|
399
|
+
|
|
400
|
+
constructor(options: RedisAgentMemoryOptions = {}) {
|
|
401
|
+
this.#options = options;
|
|
402
|
+
this.#client = options.client;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Key namespace. */
|
|
406
|
+
get prefix(): string {
|
|
407
|
+
return this.#options.prefix ?? "anteros:agent-memory";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The `ioredis` client, created on first use (`lazyConnect`). */
|
|
411
|
+
#redis(): RedisClientLike {
|
|
412
|
+
if (this.#client) return this.#client;
|
|
413
|
+
|
|
414
|
+
const { url, host, port, password } = this.#options;
|
|
415
|
+
const connection = url
|
|
416
|
+
?? Bun.env.REDIS_URL
|
|
417
|
+
?? (host || Bun.env.REDIS_HOST
|
|
418
|
+
? {
|
|
419
|
+
host: host ?? Bun.env.REDIS_HOST,
|
|
420
|
+
port: port ?? (Bun.env.REDIS_PORT ? Number(Bun.env.REDIS_PORT) : 6379),
|
|
421
|
+
password: password ?? Bun.env.REDIS_PASSWORD,
|
|
422
|
+
}
|
|
423
|
+
: "redis://localhost:6379");
|
|
424
|
+
|
|
425
|
+
// `lazyConnect`: defining the agent (module scope) must not open a socket
|
|
426
|
+
this.#client = new Redis(connection as any, { lazyConnect: true }) as unknown as RedisClientLike;
|
|
427
|
+
return this.#client;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A thread lives with its tenant: mixing them in one bucket is a data leak. */
|
|
431
|
+
#namespace(ctx?: AgentMemoryContext): string {
|
|
432
|
+
const tenant = ctx?.tenant;
|
|
433
|
+
if (!tenant) {
|
|
434
|
+
throw new AppError(
|
|
435
|
+
"A Redis memory store needs the tenant — resolve the agent from a context (`agents.get(id)`)",
|
|
436
|
+
{ status: 500, code: "AGENT_MEMORY_NO_TENANT" },
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
return tenant;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#threadKey(threadId: string, ctx?: AgentMemoryContext): string {
|
|
443
|
+
const scoped = this.#options.scoped ?? true;
|
|
444
|
+
return `${this.prefix}:${this.#namespace(ctx)}:thread:${threadKey(threadId, ctx, scoped)}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
#resourceKey(ctx?: AgentMemoryContext): string {
|
|
448
|
+
return `${this.prefix}:${this.#namespace(ctx)}:resource:${ctx?.resourceId ?? "-"}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
#stateKey(key: string, ctx?: AgentMemoryContext): string {
|
|
452
|
+
return `${this.prefix}:${this.#namespace(ctx)}:state:${key}`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** `ttl` as Redis seconds. */
|
|
456
|
+
#seconds(): number | null {
|
|
457
|
+
const ttl = this.#options.ttl;
|
|
458
|
+
if (ttl === undefined || ttl === false) return null;
|
|
459
|
+
const ms = parseDuration(ttl);
|
|
460
|
+
if (ms === null) throw new AppError(`Invalid memory ttl '${ttl}' — expected a duration like '7d'`, {
|
|
461
|
+
status: 500, code: "AGENT_MEMORY_INVALID_TTL",
|
|
462
|
+
});
|
|
463
|
+
return Math.max(1, Math.ceil(ms / 1000));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async get(threadId: string, ctx?: AgentMemoryContext): Promise<AgentMessage[]> {
|
|
467
|
+
const raw = await this.#redis().get(this.#threadKey(threadId, ctx));
|
|
468
|
+
if (!raw) return [];
|
|
469
|
+
try {
|
|
470
|
+
return (JSON.parse(raw)?.messages ?? []) as AgentMessage[];
|
|
471
|
+
} catch {
|
|
472
|
+
// Corrupted entry — never let it break a run
|
|
473
|
+
console.error(`Agent memory: unreadable thread '${threadId}' in Redis`);
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async save(threadId: string, messages: AgentMessage[], ctx?: AgentMemoryContext): Promise<void> {
|
|
479
|
+
const redis = this.#redis();
|
|
480
|
+
const scoped = this.#options.scoped ?? true;
|
|
481
|
+
const key = this.#threadKey(threadId, ctx);
|
|
482
|
+
const trimmed = trimMessages(messages, Math.max(1, this.#options.maxMessages ?? DEFAULT_MAX_MESSAGES));
|
|
483
|
+
|
|
484
|
+
// `title` and `createdAt` belong to the first exchange: Redis has no
|
|
485
|
+
// `$setOnInsert`, so the previous value is read back before writing.
|
|
486
|
+
const raw = await redis.get(key);
|
|
487
|
+
const previous = raw ? (() => { try { return JSON.parse(raw); } catch { return null; } })() : null;
|
|
488
|
+
|
|
489
|
+
const now = new Date();
|
|
490
|
+
const payload = {
|
|
491
|
+
threadId,
|
|
492
|
+
resourceId: ctx?.resourceId,
|
|
493
|
+
agentId: ctx?.agentId,
|
|
494
|
+
title: previous?.title ?? threadTitle(trimmed),
|
|
495
|
+
messages: trimmed,
|
|
496
|
+
createdAt: previous?.createdAt ?? now.toISOString(),
|
|
497
|
+
updatedAt: now.toISOString(),
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const seconds = this.#seconds();
|
|
501
|
+
if (seconds) await redis.set(key, JSON.stringify(payload), "EX", seconds);
|
|
502
|
+
else await redis.set(key, JSON.stringify(payload));
|
|
503
|
+
|
|
504
|
+
// The caller index — what `list()` walks
|
|
505
|
+
if (scoped && ctx?.resourceId) {
|
|
506
|
+
const index = this.#resourceKey(ctx);
|
|
507
|
+
await redis.zadd(index, now.getTime(), threadKey(threadId, ctx, true));
|
|
508
|
+
// An index that outlives its threads would grow forever
|
|
509
|
+
if (seconds) await redis.expire(index, seconds);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async clear(threadId: string, ctx?: AgentMemoryContext): Promise<void> {
|
|
514
|
+
const redis = this.#redis();
|
|
515
|
+
await redis.del(this.#threadKey(threadId, ctx));
|
|
516
|
+
if ((this.#options.scoped ?? true) && ctx?.resourceId) {
|
|
517
|
+
await redis.zrem(this.#resourceKey(ctx), threadKey(threadId, ctx, true));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async getState(key: string, ctx?: AgentMemoryContext): Promise<AgentMemoryState | undefined> {
|
|
522
|
+
const raw = await this.#redis().get(this.#stateKey(key, ctx));
|
|
523
|
+
if (!raw) return undefined;
|
|
524
|
+
try {
|
|
525
|
+
const parsed = JSON.parse(raw);
|
|
526
|
+
return { value: parsed?.value, updatedAt: parsed?.updatedAt ? new Date(parsed.updatedAt) : undefined };
|
|
527
|
+
} catch {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async setState(key: string, value: AgentMemoryState, ctx?: AgentMemoryContext): Promise<void> {
|
|
533
|
+
// No expiry: a profile must outlive the conversation that created it
|
|
534
|
+
await this.#redis().set(this.#stateKey(key, ctx), JSON.stringify({
|
|
535
|
+
value: value.value,
|
|
536
|
+
updatedAt: (value.updatedAt ?? new Date()).toISOString(),
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async clearState(key: string, ctx?: AgentMemoryContext): Promise<void> {
|
|
541
|
+
await this.#redis().del(this.#stateKey(key, ctx));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async list(ctx?: AgentMemoryContext & { limit?: number }): Promise<AgentThread[]> {
|
|
545
|
+
const redis = this.#redis();
|
|
546
|
+
const index = this.#resourceKey(ctx);
|
|
547
|
+
const limit = ctx?.limit ?? DEFAULT_LIST_LIMIT;
|
|
548
|
+
|
|
549
|
+
const members = await redis.zrevrange(index, 0, limit - 1);
|
|
550
|
+
if (!members.length) return [];
|
|
551
|
+
|
|
552
|
+
const raws = await redis.mget(...members.map((member) => `${this.prefix}:${this.#namespace(ctx)}:thread:${member}`));
|
|
553
|
+
|
|
554
|
+
const threads: AgentThread[] = [];
|
|
555
|
+
const stale: string[] = [];
|
|
556
|
+
|
|
557
|
+
members.forEach((member, position) => {
|
|
558
|
+
const raw = raws[position];
|
|
559
|
+
if (!raw) { stale.push(member); return; } // expired or cleared behind our back
|
|
560
|
+
try {
|
|
561
|
+
const doc = JSON.parse(raw);
|
|
562
|
+
threads.push({
|
|
563
|
+
threadId: doc.threadId ?? member,
|
|
564
|
+
resourceId: doc.resourceId,
|
|
565
|
+
title: doc.title,
|
|
566
|
+
messages: Array.isArray(doc.messages) ? doc.messages.length : 0,
|
|
567
|
+
createdAt: doc.createdAt ? new Date(doc.createdAt) : undefined,
|
|
568
|
+
updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(),
|
|
569
|
+
});
|
|
570
|
+
} catch {
|
|
571
|
+
stale.push(member);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
if (stale.length) await redis.zrem(index, ...stale);
|
|
576
|
+
return threads;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Close the client when the store owns it (a provided one is left alone). */
|
|
580
|
+
async close(): Promise<void> {
|
|
581
|
+
const client: any = this.#client;
|
|
582
|
+
if (this.#options.client || !client) return;
|
|
583
|
+
this.#client = undefined;
|
|
584
|
+
try { await client.quit?.(); } catch { /* already gone */ }
|
|
585
|
+
}
|
|
586
|
+
}
|