Compare commits

..
Author SHA1 Message Date
Saket AryanandGitHub 50db9e428d chore(release): bump SDK versions to next beta (#4859) 2026-04-16 16:23:50 +05:30
fb87349664 fix(oss): v3 entity cleanup, filter fixes, and QA hardening (TS + Python) (#4858)
Co-authored-by: Soumil Rathi <soumilrathi@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:17:13 +05:30
Saket AryanandGitHub 8827553576 fix: adopt new v3 memory endpoints in Python + TS clients (#4856) 2026-04-16 05:28:49 +05:30
Kabir KohliandGitHub c8e20a9bb5 fix(docs): resolve duplicate operationIds and expiration_date type in openapi spec (#4854) 2026-04-16 04:09:51 +05:30
KartikandGitHub 93a51f4763 test: update integration tests for v1.1 output_format (#4847) 2026-04-16 01:37:51 +05:30
Saket AryanandGitHub 86fe275f53 fix(ts): entity store isolation, backward compat, pgvector + redis init fixes (#4841) 2026-04-15 21:01:01 +05:30
KartikandGitHub e6d6276bb9 refactor: add entity ID and search param validation, rename textLemmatized field, update tests (#4843) 2026-04-15 20:57:09 +05:30
9692726db4 fix(ts-oss): isolate entity store from memory store by default (#4829)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-04-15 14:47:43 +05:30
d8d776636f fix(v3): migration crashes + entity linking on OSS (#4836)
Co-authored-by: Soumil Rathi <soumilrathi@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 14:46:28 +05:30
51 changed files with 1488 additions and 260 deletions
@@ -216,7 +216,7 @@ memory.delete_all(user_id="alice")
## Put it into practice
- Review the <Link href="/api-reference/memory/delete-memory">Delete Memory API reference</Link>, plus <Link href="/api-reference/memory/batch-delete">Batch Delete</Link> and <Link href="/api-reference/memory/delete-memories">Filtered Delete</Link>.
- Pair deletes with <Link href="/cookbooks/essentials/memory-expiration-short-and-long-term">Expiration Policies</Link> to automate retention.
- Pair deletes with <Link href="/platform/features/expiration-date">Expiration Policies</Link> to automate retention.
## See it live
@@ -236,6 +236,6 @@ memory.delete_all(user_id="alice")
title="Enable Expiration Policies"
description="Automate retention with the platform’s expiration feature."
icon="clock"
href="/cookbooks/essentials/memory-expiration-short-and-long-term"
href="/platform/features/expiration-date"
/>
</CardGroup>
+1 -1
View File
@@ -621,7 +621,7 @@
},
{
"source": "/platform/features/expiration-date",
"destination": "/cookbooks/essentials/memory-expiration-short-and-long-term"
"destination": "/"
},
{
"source": "/platform/features/async-mode-default-change",
+12 -11
View File
@@ -1048,8 +1048,8 @@
},
"expiration_date": {
"type": "string",
"format": "date-time",
"description": "The date and time when the memory will expire. Format: YYYY-MM-DD.",
"format": "date",
"description": "The date when the memory will expire. Format: YYYY-MM-DD.",
"title": "Expiration date",
"nullable": true,
"default": null
@@ -1233,7 +1233,7 @@
"memories"
],
"description": "Delete memories by filter. At least one filter is required \u2014 previously omitting all filters silently deleted everything; now it returns a validation error.",
"operationId": "memories_delete",
"operationId": "memories_delete_all",
"parameters": [
{
"name": "user_id",
@@ -1393,8 +1393,8 @@
},
"expiration_date": {
"type": "string",
"format": "date-time",
"description": "The date and time when the memory will expire. Format: YYYY-MM-DD.",
"format": "date",
"description": "The date when the memory will expire. Format: YYYY-MM-DD.",
"title": "Expiration date",
"nullable": true,
"default": null
@@ -1540,8 +1540,8 @@
},
"expiration_date": {
"type": "string",
"format": "date-time",
"description": "The date and time when the memory will expire. Format: YYYY-MM-DD.",
"format": "date",
"description": "The date when the memory will expire. Format: YYYY-MM-DD.",
"title": "Expiration date",
"nullable": true,
"default": null
@@ -1675,8 +1675,8 @@
},
"expiration_date": {
"type": "string",
"format": "date-time",
"description": "The date and time when the memory will expire. Format: YYYY-MM-DD.",
"format": "date",
"description": "The date when the memory will expire. Format: YYYY-MM-DD.",
"title": "Expiration date",
"nullable": true,
"default": null
@@ -1739,7 +1739,7 @@
"tags": [
"memories"
],
"operationId": "memories_read",
"operationId": "memories_entity_read",
"responses": {
"200": {
"description": "Successfully retrieved memories.",
@@ -5176,9 +5176,10 @@
"nullable": true
},
"expiration_date": {
"description": "The date and time when the memory will expire. Format: YYYY-MM-DD",
"description": "The date when the memory will expire. Format: YYYY-MM-DD",
"title": "Expiration date",
"type": "string",
"format": "date",
"nullable": true
},
"org_id": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.0.0-beta.1",
"version": "3.0.0-beta.2",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+5 -5
View File
@@ -1,6 +1,7 @@
import axios from "axios";
import {
AllUsers,
PaginatedMemories,
ProjectOptions,
Memory,
MemoryHistory,
@@ -221,7 +222,7 @@ export default class MemoryClient {
this._captureEvent("add", [payloadKeys]);
const response = await this._fetchWithErrorHandling(
`${this.host}/v3/memories/`,
`${this.host}/v3/memories/add/`,
{
method: "POST",
headers: this.headers,
@@ -284,7 +285,7 @@ export default class MemoryClient {
);
}
async getAll(options?: GetAllMemoryOptions): Promise<Array<Memory>> {
async getAll(options?: GetAllMemoryOptions): Promise<PaginatedMemories> {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(options as Record<string, any>, "getAll");
@@ -293,12 +294,11 @@ export default class MemoryClient {
this._captureEvent("get_all", [payloadKeys]);
const { page, pageSize, filters, ...rest } = options ?? {};
const body: Record<string, any> = {
output_format: "v1.1",
...camelToSnakeKeys(rest),
...(filters && { filters }),
};
let url = `${this.host}/v2/memories/`;
let url = `${this.host}/v3/memories/`;
if (page && pageSize) {
url += `?page=${page}&page_size=${pageSize}`;
}
@@ -308,7 +308,7 @@ export default class MemoryClient {
headers: this.headers,
body: JSON.stringify(body),
});
return Array.isArray(response) ? response : (response?.results ?? response);
return response;
}
async search(
+7
View File
@@ -142,6 +142,13 @@ export interface AllUsers {
previous: any;
}
export interface PaginatedMemories {
count: number;
next: string | null;
previous: string | null;
results: Array<Memory>;
}
export interface ProjectResponse {
customInstructions?: string;
customCategories?: string[];
@@ -26,7 +26,7 @@ describeIntegration("MemoryClient Integration — Batch Operations", () => {
beforeAll(async () => {
cleanup = suppressTelemetryNoise();
client = createTestClient();
client = await createTestClient();
memoryIds = await seedTestMemories(client, TEST_USER_ID);
});
@@ -26,9 +26,9 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
let cleanup: () => void;
let memoryIds: string[] = [];
beforeAll(() => {
beforeAll(async () => {
cleanup = suppressTelemetryNoise();
client = createTestClient();
client = await createTestClient();
});
afterAll(async () => {
@@ -120,14 +120,20 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
// ─── Get all ──────────────────────────────────────────────
describe("get all memories", () => {
test("returns all memories for test user", async () => {
const memories = await client.getAll({
const response = await client.getAll({
filters: { user_id: TEST_USER_ID },
});
expect(Array.isArray(memories)).toBe(true);
expect(memories.length).toBeGreaterThanOrEqual(memoryIds.length);
// Paginated shape: { count, next, previous, results: [...] }
expect(response).toHaveProperty("count");
expect(response).toHaveProperty("next");
expect(response).toHaveProperty("previous");
expect(response).toHaveProperty("results");
expect(typeof response.count).toBe("number");
expect(Array.isArray(response.results)).toBe(true);
expect(response.results.length).toBeGreaterThanOrEqual(memoryIds.length);
for (const mem of memories) {
for (const mem of response.results) {
expect(typeof mem.id).toBe("string");
expect(typeof mem.memory).toBe("string");
}
@@ -140,8 +146,12 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
pageSize: 1,
});
// Paginated response is an object with results array
expect(page1).toBeDefined();
expect(page1).toHaveProperty("count");
expect(page1).toHaveProperty("next");
expect(page1).toHaveProperty("previous");
expect(Array.isArray(page1.results)).toBe(true);
expect(page1.results.length).toBeLessThanOrEqual(1);
});
});
@@ -197,13 +207,15 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
expect(result).toHaveProperty("eventId");
});
test("getAll for non-existent user returns empty array", async () => {
const memories = await client.getAll({
test("getAll for non-existent user returns empty results", async () => {
const response = await client.getAll({
filters: { user_id: `nonexistent-user-${randomUUID()}` },
});
expect(Array.isArray(memories)).toBe(true);
expect(memories.length).toBe(0);
expect(response).toHaveProperty("results");
expect(Array.isArray(response.results)).toBe(true);
expect(response.results.length).toBe(0);
expect(response.count).toBe(0);
});
test("deleteAll for non-existent user does not throw", async () => {
@@ -18,9 +18,13 @@ export const describeIntegration = API_KEY ? describe : describe.skip;
* Create a MemoryClient with the real API key.
* Call this inside beforeAll — not at module scope — so it only
* runs when the suite is not skipped.
*
* Returns an initialized client ready for immediate use.
*/
export function createTestClient(): MemoryClient {
return new MemoryClient({ apiKey: API_KEY! });
export async function createTestClient(): Promise<MemoryClient> {
const client = new MemoryClient({ apiKey: API_KEY! });
await client.ping();
return client;
}
/**
@@ -63,10 +67,11 @@ export async function waitForMemories(
maxRetries = 4,
): Promise<Memory[]> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const memories = await withRetry(() =>
const response = await withRetry(() =>
client.getAll({ filters: { user_id: userId } }),
);
if (Array.isArray(memories) && memories.length >= minCount) {
const memories = response.results ?? [];
if (memories.length >= minCount) {
return memories;
}
if (attempt < maxRetries) {
@@ -24,9 +24,9 @@ describeIntegration("MemoryClient Integration — Initialization", () => {
let client: MemoryClient;
let cleanup: () => void;
beforeAll(() => {
beforeAll(async () => {
cleanup = suppressTelemetryNoise();
client = createTestClient();
client = await createTestClient();
});
afterAll(() => cleanup());
@@ -27,7 +27,7 @@ describeIntegration("MemoryClient Integration — Users & Project", () => {
beforeAll(async () => {
cleanup = suppressTelemetryNoise();
client = createTestClient();
client = await createTestClient();
await seedTestMemories(client, TEST_USER_ID);
});
@@ -27,7 +27,7 @@ describeIntegration("MemoryClient Integration — Search & History", () => {
beforeAll(async () => {
cleanup = suppressTelemetryNoise();
client = createTestClient();
client = await createTestClient();
memoryIds = await seedTestMemories(client, TEST_USER_ID);
});
@@ -21,33 +21,33 @@ installConsoleSuppression();
// ─── add() ───────────────────────────────────────────────
describe("MemoryClient - add()", () => {
test("sends POST to /v3/memories/", async () => {
test("sends POST to /v3/memories/add/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] });
extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([{ role: "user", content: "Hello" }], { userId: "u1" });
expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined();
expect(findFetchCall(mock, "/v3/memories/add/", "POST")).toBeDefined();
});
test("includes messages in request body", async () => {
const messages = [{ role: "user" as const, content: "Hello, I am Alex" }];
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] });
extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add(messages, { userId: "u1" });
const call = findFetchCall(mock, "/v3/memories/", "POST");
const call = findFetchCall(mock, "/v3/memories/add/", "POST");
expect(getFetchBody(call!).messages).toEqual(messages);
});
test("includes user_id in request body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", { status: 200, body: [createMockMemory()] });
extra.set("/v3/memories/add/", { status: 200, body: [createMockMemory()] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
@@ -55,19 +55,19 @@ describe("MemoryClient - add()", () => {
user_id: "user_1",
});
const call = findFetchCall(mock, "/v3/memories/", "POST");
const call = findFetchCall(mock, "/v3/memories/add/", "POST");
expect(getFetchBody(call!).user_id).toBe("user_1");
});
test("sends empty messages array without crashing", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v3/memories/", { status: 200, body: [] });
extra.set("/v3/memories/add/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.add([], { userId: "u1" });
const call = findFetchCall(mock, "/v3/memories/", "POST");
const call = findFetchCall(mock, "/v3/memories/add/", "POST");
expect(getFetchBody(call!).messages).toEqual([]);
});
});
@@ -254,7 +254,7 @@ describe("MemoryClient - getAll() entity param rejection", () => {
test("accepts filters with user_id", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/v2/memories/", {
extra.set("/v3/memories/", {
status: 200,
body: { results: [] },
});
@@ -262,6 +262,6 @@ describe("MemoryClient - getAll() entity param rejection", () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.getAll({ filters: { user_id: "u1" } });
expect(findFetchCall(mock, "/v2/memories/", "POST")).toBeDefined();
expect(findFetchCall(mock, "/v3/memories/", "POST")).toBeDefined();
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ export const DEFAULT_MEMORY_CONFIG: MemoryConfig = {
config: {
baseURL: "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY || "",
model: "gpt-4.1-nano-2025-04-14",
model: "gpt-5-mini",
modelProperties: undefined,
},
},
+1 -1
View File
@@ -18,7 +18,7 @@ export class AzureOpenAILLM implements LLM {
endpoint: endpoint as string,
...rest,
});
this.model = config.model || "gpt-4";
this.model = config.model || "gpt-5-mini";
}
async generateResponse(
+1 -1
View File
@@ -11,7 +11,7 @@ export class OpenAILLM implements LLM {
apiKey: config.apiKey,
baseURL: config.baseURL,
});
this.model = config.model || "gpt-4.1-nano-2025-04-14";
this.model = config.model || "gpt-5-mini";
}
async generateResponse(
@@ -8,7 +8,7 @@ export class OpenAIStructuredLLM implements LLM {
constructor(config: LLMConfig) {
this.openai = new OpenAI({ apiKey: config.apiKey });
this.model = config.model || "gpt-4-turbo-preview";
this.model = config.model || "gpt-5-mini";
}
async generateResponse(
+315 -25
View File
@@ -52,6 +52,7 @@ import {
ENTITY_BOOST_WEIGHT,
ScoredResult,
} from "../utils/scoring";
import { getDefaultVectorStoreDbPath } from "../utils/sqlite";
// Entity params that must be passed via filters - check both snake_case and camelCase
const ENTITY_PARAMS = [
@@ -82,6 +83,58 @@ function rejectTopLevelEntityParams(
}
}
/**
* Validates and normalizes an entity ID.
* - Trims leading/trailing whitespace
* - Rejects empty or whitespace-only strings
* - Rejects strings containing internal whitespace
* @returns The trimmed entity ID, or undefined if input is undefined
* @throws Error if entity ID is invalid
*/
function validateAndTrimEntityId(
value: string | undefined,
name: string,
): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
if (trimmed === "") {
throw new Error(
`Invalid ${name}: cannot be empty or whitespace-only. Provide a valid identifier.`,
);
}
if (/\s/.test(trimmed)) {
throw new Error(
`Invalid ${name}: cannot contain whitespace. Provide a valid identifier without spaces.`,
);
}
return trimmed;
}
/**
* Validates search parameters.
* @throws Error if threshold or topK are invalid
*/
function validateSearchParams(threshold?: number, topK?: number): void {
if (threshold !== undefined) {
if (typeof threshold !== "number" || isNaN(threshold)) {
throw new Error("threshold must be a valid number");
}
if (threshold < 0 || threshold > 1) {
throw new Error(
`Invalid threshold: ${threshold}. Must be between 0 and 1 (inclusive).`,
);
}
}
if (topK !== undefined) {
if (typeof topK !== "number" || isNaN(topK) || !Number.isInteger(topK)) {
throw new Error("topK must be a valid integer");
}
if (topK < 0) {
throw new Error(`Invalid topK: ${topK}. Must be a non-negative integer.`);
}
}
}
export class Memory {
private config: MemoryConfig;
private customInstructions: string | undefined;
@@ -195,12 +248,10 @@ export class Memory {
...this.config.vectorStore.config,
collectionName: entityCollectionName,
};
// For file-based stores (memory/SQLite), use a separate DB path for entities
if (entityConfig.dbPath) {
entityConfig.dbPath = entityConfig.dbPath.replace(
/\.db$/,
"_entities.db",
);
// For file-based stores (memory/SQLite), always use a separate DB for entities
if (this.config.vectorStore.provider === "memory") {
const basePath = entityConfig.dbPath || getDefaultVectorStoreDbPath();
entityConfig.dbPath = basePath.replace(/\.db$/, "_entities.db");
}
this._entityStore = VectorStoreFactory.create(
this.config.vectorStore.provider,
@@ -211,6 +262,181 @@ export class Memory {
return this._entityStore;
}
/**
* Normalize a filters object for entity-store scoping: keeps only
* user_id/agent_id/run_id keys whose values are defined.
*/
private _sessionFiltersFromPayload(
payload: Record<string, any>,
): Record<string, any> {
const filters: Record<string, any> = {};
if (payload.user_id) filters.user_id = payload.user_id;
if (payload.agent_id) filters.agent_id = payload.agent_id;
if (payload.run_id) filters.run_id = payload.run_id;
return filters;
}
/**
* Remove `memoryId` from every entity record scoped to `filters`.
* If an entity's `linkedMemoryIds` becomes empty after removal, the
* entity record itself is deleted. Errors on individual entities are
* swallowed so one bad record does not break the whole operation.
*
* No-op if the entity store has not been initialized yet.
*/
private async _removeMemoryFromEntityStore(
memoryId: string,
filters: Record<string, any>,
): Promise<void> {
let entityStore: VectorStore;
try {
entityStore = await this.getEntityStore();
} catch (e) {
console.debug(`Entity store unavailable during cleanup: ${e}`);
return;
}
let rows: Array<{ id: string; payload: Record<string, any> }> = [];
try {
const listed = await entityStore.list(filters, 10000);
rows = (
Array.isArray(listed) && Array.isArray(listed[0])
? listed[0]
: (listed as any)
) as Array<{ id: string; payload: Record<string, any> }>;
} catch (e) {
console.debug(`Entity store list failed during cleanup: ${e}`);
return;
}
for (const row of rows) {
try {
const payload = row.payload || {};
const linked: string[] = Array.isArray(payload.linkedMemoryIds)
? payload.linkedMemoryIds
: [];
if (!linked.includes(memoryId)) continue;
const remaining = linked.filter((id) => id !== memoryId);
if (remaining.length === 0) {
try {
await entityStore.delete(row.id);
} catch (e) {
console.debug(`Entity delete failed for id=${row.id}: ${e}`);
}
} else {
const newPayload = { ...payload, linkedMemoryIds: remaining };
// entityStore.update requires a vector — re-embed entity text.
const entityText =
typeof payload.data === "string" ? payload.data : "";
if (!entityText) {
// Can't re-embed without text; skip gracefully.
console.debug(
`Entity id=${row.id} missing 'data'; skipping update during cleanup`,
);
continue;
}
let vec: number[];
try {
vec = await this.embedder.embed(entityText);
} catch (e) {
console.debug(`Entity re-embed failed for '${entityText}': ${e}`);
continue;
}
try {
await entityStore.update(row.id, vec, newPayload);
} catch (e) {
console.debug(`Entity update failed for id=${row.id}: ${e}`);
}
}
} catch (e) {
console.debug(`Entity cleanup error for id=${row?.id}: ${e}`);
}
}
}
/**
* Extract entities from `text` and link them to `memoryId` in the
* entity store, scoped to `filters` (user_id / agent_id / run_id).
*
* Simpler single-memory variant of Phase 7 in add(): no cross-memory
* dedup, but still does per-entity "search for existing, update if
* match >= 0.95 else insert new". Non-fatal errors are swallowed.
*/
private async _linkEntitiesForMemory(
memoryId: string,
text: string,
filters: Record<string, any>,
): Promise<void> {
try {
const entities = extractEntities(text);
if (entities.length === 0) return;
const entityStore = await this.getEntityStore();
for (const entity of entities) {
try {
let entityVec: number[];
try {
entityVec = await this.embedder.embed(entity.text);
} catch (e) {
console.debug(`Entity embed failed for '${entity.text}': ${e}`);
continue;
}
let matches: Array<{
id: string;
score?: number;
payload: Record<string, any>;
}> = [];
try {
matches = await entityStore.search(entityVec, 1, filters);
} catch {}
if (matches.length > 0 && (matches[0].score ?? 0) >= 0.95) {
const match = matches[0];
const payload = match.payload || {};
const linked = new Set<string>(
Array.isArray(payload.linkedMemoryIds)
? payload.linkedMemoryIds
: [],
);
linked.add(memoryId);
payload.linkedMemoryIds = Array.from(linked).sort();
try {
await entityStore.update(match.id, entityVec, payload);
} catch (e) {
console.debug(`Entity update failed for '${entity.text}': ${e}`);
}
} else {
const entityPayload: Record<string, any> = {
data: entity.text,
entityType: entity.type,
linkedMemoryIds: [memoryId],
};
if (filters.user_id) entityPayload.user_id = filters.user_id;
if (filters.agent_id) entityPayload.agent_id = filters.agent_id;
if (filters.run_id) entityPayload.run_id = filters.run_id;
try {
await entityStore.insert(
[entityVec],
[uuidv4()],
[entityPayload],
);
} catch (e) {
console.debug(`Entity insert failed for '${entity.text}': ${e}`);
}
}
} catch (e) {
console.debug(`Entity link error for '${entity.text}': ${e}`);
}
}
} catch (e) {
console.warn(`Entity linking failed during update: ${e}`);
}
}
private buildSessionScope(filters: SearchFilters): string {
const parts: string[] = [];
for (const key of ["agent_id", "run_id", "user_id"].sort()) {
@@ -276,6 +502,13 @@ export class Memory {
messages: string | Message[],
config: AddMemoryOptions,
): Promise<SearchResult> {
// Validate messages input
if (messages === undefined || messages === null) {
throw new Error(
"messages is required and cannot be undefined or null. Provide a string or array of messages.",
);
}
await this._ensureInitialized();
await this._captureEvent("add", {
message_count: Array.isArray(messages) ? messages.length : 1,
@@ -283,14 +516,12 @@ export class Memory {
has_filters: !!config.filters,
infer: config.infer,
});
const {
userId,
agentId,
runId,
metadata = {},
filters = {},
infer = true,
} = config;
const { metadata = {}, filters = {}, infer = true } = config;
// Validate and trim entity IDs
const userId = validateAndTrimEntityId(config.userId, "userId");
const agentId = validateAndTrimEntityId(config.agentId, "agentId");
const runId = validateAndTrimEntityId(config.runId, "runId");
// Convert camelCase entity params to snake_case for storage (matches API and search/getAll filters)
if (userId) filters.user_id = metadata.user_id = userId;
@@ -804,14 +1035,38 @@ export class Memory {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(config as Record<string, any>, "search");
// Validate search parameters (before applying defaults)
validateSearchParams(config.threshold, config.topK);
// Validate and trim entity IDs in filters. Only include keys whose
// validated value is defined — otherwise downstream vector stores
// receive `agent_id: undefined` / `run_id: undefined` and fail
// (Qdrant rejects the malformed match, pgvector binds NULL, Redis
// emits a literal "undefined" string in TAG filters).
const normalizedFilters: Record<string, any> = config.filters
? Object.fromEntries(
Object.entries({
...config.filters,
user_id: validateAndTrimEntityId(config.filters.user_id, "user_id"),
agent_id: validateAndTrimEntityId(
config.filters.agent_id,
"agent_id",
),
run_id: validateAndTrimEntityId(config.filters.run_id, "run_id"),
}).filter(([, v]) => v !== undefined),
)
: {};
await this._ensureInitialized();
const { topK = 20, threshold = 0.1 } = config;
await this._captureEvent("search", {
query_length: query.length,
topK: config.topK,
topK,
has_filters: !!config.filters,
});
const { topK = 100, threshold = 0.1 } = config;
let effectiveFilters: Record<string, any> = { ...(config.filters || {}) };
let effectiveFilters: Record<string, any> = { ...normalizedFilters };
// Apply enhanced metadata filtering if advanced operators are detected
if (this._hasAdvancedOperators(effectiveFilters)) {
@@ -993,12 +1248,9 @@ export class Memory {
createdAt: payload.createdAt,
updatedAt: payload.updatedAt,
score: scored.score,
metadata: {
...Object.entries(payload)
.filter(([key]) => !excludedKeys.has(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
scoreBreakdown: scored.scoreBreakdown,
},
metadata: Object.entries(payload)
.filter(([key]) => !excludedKeys.has(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
...(payload.user_id && { user_id: payload.user_id }),
...(payload.agent_id && { agent_id: payload.agent_id }),
...(payload.run_id && { run_id: payload.run_id }),
@@ -1116,11 +1368,27 @@ export class Memory {
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(config as Record<string, any>, "getAll");
// Validate topK if provided (before applying defaults)
validateSearchParams(undefined, config.topK);
await this._ensureInitialized();
const { topK = 100, filters = {} } = config;
const { topK = 20 } = config;
// Validate and trim entity IDs in filters. Drop keys that resolve to
// undefined so downstream vector stores don't receive
// `agent_id: undefined` / `run_id: undefined` and fail.
const filters: Record<string, any> = Object.fromEntries(
Object.entries({
...(config.filters || {}),
user_id: validateAndTrimEntityId(config.filters?.user_id, "user_id"),
agent_id: validateAndTrimEntityId(config.filters?.agent_id, "agent_id"),
run_id: validateAndTrimEntityId(config.filters?.run_id, "run_id"),
}).filter(([, v]) => v !== undefined),
);
await this._captureEvent("get_all", {
topK: topK,
topK,
has_user_id: !!filters.user_id,
has_agent_id: !!filters.agent_id,
has_run_id: !!filters.run_id,
@@ -1177,6 +1445,7 @@ export class Memory {
...metadata,
data,
hash: createHash("md5").update(data).digest("hex"),
textLemmatized: lemmatizeForBm25(data),
createdAt: new Date().toISOString(),
};
@@ -1234,6 +1503,16 @@ export class Memory {
newMetadata.updatedAt,
);
// Entity-store cleanup: strip this memory's id from old-text entities,
// then re-extract entities from the new text and link them back.
try {
const sessionFilters = this._sessionFiltersFromPayload(newMetadata);
await this._removeMemoryFromEntityStore(memoryId, sessionFilters);
await this._linkEntitiesForMemory(memoryId, data, sessionFilters);
} catch (e) {
console.warn(`Entity store cleanup/link failed during update: ${e}`);
}
return memoryId;
}
@@ -1244,6 +1523,9 @@ export class Memory {
}
const prevValue = existingMemory.payload.data;
const sessionFilters = this._sessionFiltersFromPayload(
existingMemory.payload || {},
);
await this.vectorStore.delete(memoryId);
await this.db.addHistory(
memoryId,
@@ -1255,6 +1537,14 @@ export class Memory {
1,
);
// Entity-store cleanup: strip this memory's id from any entity records
// that linked to it. Non-fatal — log and continue on error.
try {
await this._removeMemoryFromEntityStore(memoryId, sessionFilters);
} catch (e) {
console.warn(`Entity store cleanup failed during delete: ${e}`);
}
return memoryId;
}
@@ -197,7 +197,7 @@ describe("MemoryVectorStore (better-sqlite3)", () => {
expect(result).not.toBeNull();
expect(result!.id).toBe("id-1");
expect(result!.payload.data).toBe("hello");
expect(result!.payload.userId).toBe("u1");
expect(result!.payload.user_id).toBe("u1");
});
it("get returns null for non-existent id", async () => {
@@ -253,7 +253,7 @@ describe("MemoryVectorStore (better-sqlite3)", () => {
],
);
const results = await store.search(v, 10, { userId: "alice" });
const results = await store.search(v, 10, { user_id: "alice" });
expect(results).toHaveLength(1);
expect(results[0].id).toBe("id-1");
});
@@ -320,7 +320,7 @@ describe("MemoryVectorStore (better-sqlite3)", () => {
expect(all).toHaveLength(3);
expect(totalAll).toBe(3);
const [filtered, totalFiltered] = await store.list({ userId: "alice" });
const [filtered, totalFiltered] = await store.list({ user_id: "alice" });
expect(filtered).toHaveLength(2);
expect(totalFiltered).toBe(2);
});
@@ -308,7 +308,7 @@ describe("backward compat: MemoryVectorStore", () => {
);
const results = await store.search(normalize([1, 0, 0]), 10, {
userId: "user2",
user_id: "user2",
});
expect(results).toHaveLength(1);
expect(results[0].id).toBe("b");
@@ -660,6 +660,9 @@ export function extractEntities(text: string): ExtractedEntity[] {
txt = txt.replace(/\s*:+$/, "");
// Strip leading numbered list markers
txt = txt.replace(/^\d+\s*\.\s*/, "");
// Strip trailing sentence punctuation (".", ",", ";", "!", "?") — otherwise
// "Paris." and "Paris" produce different embeddings and break entity dedup.
txt = txt.replace(/[.,;!?]+$/, "").trim();
if (!txt || txt.length <= 2 || hasArtifacts(txt)) {
continue;
-10
View File
@@ -59,11 +59,6 @@ export function normalizeBm25(
export interface ScoredResult {
id: string;
score: number;
scoreBreakdown: {
semantic: number;
bm25: number;
entityBoost: number;
};
payload: Record<string, any>;
}
@@ -134,11 +129,6 @@ export function scoreAndRank(
scored.push({
id: memIdStr,
score: combined,
scoreBreakdown: {
semantic: semanticScore,
bm25: bm25Score,
entityBoost: entityBoost,
},
payload: result.payload,
});
}
+23 -5
View File
@@ -19,6 +19,24 @@ export class MemoryVectorStore implements VectorStore {
private dimension: number;
private dbPath: string;
private static readonly CAMEL_TO_SNAKE: Record<string, string> = {
userId: "user_id",
agentId: "agent_id",
runId: "run_id",
};
private normalizePayload(payload: Record<string, any>): Record<string, any> {
for (const [camel, snake] of Object.entries(
MemoryVectorStore.CAMEL_TO_SNAKE,
)) {
if (camel in payload && !(snake in payload)) {
payload[snake] = payload[camel];
delete payload[camel];
}
}
return payload;
}
constructor(config: VectorStoreConfig) {
this.dimension = config.dimension || 1536; // Default OpenAI dimension
this.dbPath = config.dbPath || getDefaultVectorStoreDbPath();
@@ -246,7 +264,7 @@ export class MemoryVectorStore implements VectorStore {
}[] = [];
for (const row of rows) {
const payload = JSON.parse(row.payload);
const payload = this.normalizePayload(JSON.parse(row.payload));
const memoryVector: MemoryVector = {
id: row.id,
vector: Array.from(
@@ -260,7 +278,7 @@ export class MemoryVectorStore implements VectorStore {
};
if (this.filterVector(memoryVector, filters)) {
const text = payload.text_lemmatized || payload.data || "";
const text = payload.textLemmatized || payload.data || "";
candidates.push({ id: row.id, payload, tokens: this.tokenize(text) });
}
}
@@ -351,7 +369,7 @@ export class MemoryVectorStore implements VectorStore {
row.vector.byteOffset,
row.vector.byteLength / 4,
);
const payload = JSON.parse(row.payload);
const payload = this.normalizePayload(JSON.parse(row.payload));
const memoryVector: MemoryVector = {
id: row.id,
vector: Array.from(vector),
@@ -378,7 +396,7 @@ export class MemoryVectorStore implements VectorStore {
.get(vectorId) as any;
if (!row) return null;
const payload = JSON.parse(row.payload);
const payload = this.normalizePayload(JSON.parse(row.payload));
return {
id: row.id,
payload,
@@ -418,7 +436,7 @@ export class MemoryVectorStore implements VectorStore {
const results: VectorStoreResult[] = [];
for (const row of rows) {
const payload = JSON.parse(row.payload);
const payload = this.normalizePayload(JSON.parse(row.payload));
const memoryVector: MemoryVector = {
id: row.id,
vector: Array.from(
+10 -2
View File
@@ -22,6 +22,7 @@ export class PGVector implements VectorStore {
private useHnsw: boolean;
private readonly dbName: string;
private config: PGVectorConfig;
private _initPromise?: Promise<void>;
constructor(config: PGVectorConfig) {
this.collectionName = config.collectionName || "memories";
@@ -41,6 +42,13 @@ export class PGVector implements VectorStore {
}
async initialize(): Promise<void> {
if (!this._initPromise) {
this._initPromise = this._doInitialize();
}
return this._initPromise;
}
private async _doInitialize(): Promise<void> {
try {
await this.client.connect();
@@ -186,9 +194,9 @@ export class PGVector implements VectorStore {
: "";
const searchQuery = `
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'text_lemmatized'), plainto_tsquery('simple', $1)) AS score, payload
SELECT id, ts_rank_cd(to_tsvector('simple', payload->>'textLemmatized'), plainto_tsquery('simple', $1)) AS score, payload
FROM ${this.collectionName}
WHERE to_tsvector('simple', payload->>'text_lemmatized') @@ plainto_tsquery('simple', $1)
WHERE to_tsvector('simple', payload->>'textLemmatized') @@ plainto_tsquery('simple', $1)
${filterClause}
ORDER BY score DESC
LIMIT $2
+35 -14
View File
@@ -9,6 +9,19 @@ import type {
import { VectorStore } from "./base";
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
/**
* Escape RediSearch TAG filter special characters. Any punctuation in the
* value (including `-`, which appears in every UUID) must be backslash-
* escaped, otherwise RediSearch either parses it as an operator (`-` is
* minus, `|` is OR) or rejects the whole expression as a syntax error.
*/
function escapeRedisTagValue(value: unknown): string {
return String(value).replace(
/([,.<>{}\[\]"':;!@#$%^&*()\-+=~|/\\\s])/g,
"\\$1",
);
}
interface RedisConfig extends VectorStoreConfig {
redisUrl: string;
collectionName: string;
@@ -256,17 +269,25 @@ export class RedisDB implements VectorStore {
const modulesResponse =
(await this.client.moduleList()) as unknown as any[];
// Parse module list to find search module
const hasSearch = modulesResponse.some((module: any[]) => {
const moduleMap = new Map();
for (let i = 0; i < module.length; i += 2) {
moduleMap.set(module[i], module[i + 1]);
const hasSearch = modulesResponse.some((mod: any) => {
// node-redis v4+ returns objects: { name: "search", ver: ..., ... }
if (typeof mod === "object" && !Array.isArray(mod) && mod.name) {
const name = String(mod.name).toLowerCase();
return name === "search" || name === "searchlight";
}
const moduleName = moduleMap.get("name");
return (
moduleName?.toLowerCase() === "search" ||
moduleName?.toLowerCase() === "searchlight"
);
// Fallback: legacy flat array format [key, value, key, value, ...]
if (Array.isArray(mod)) {
const moduleMap = new Map();
for (let i = 0; i < mod.length; i += 2) {
moduleMap.set(mod[i], mod[i + 1]);
}
const name = moduleMap.get("name");
return (
name?.toLowerCase() === "search" ||
name?.toLowerCase() === "searchlight"
);
}
return false;
});
if (!hasSearch) {
@@ -369,8 +390,8 @@ export class RedisDB implements VectorStore {
const snakeFilters = filters ? toSnakeCase(filters) : undefined;
const filterExpr = snakeFilters
? Object.entries(snakeFilters)
.filter(([_, value]) => value !== null)
.map(([key, value]) => `@${key}:{${value}}`)
.filter(([_, value]) => value !== null && value !== undefined)
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`)
.join(" ")
: "*";
@@ -607,8 +628,8 @@ export class RedisDB implements VectorStore {
const snakeFilters = filters ? toSnakeCase(filters) : undefined;
const filterExpr = snakeFilters
? Object.entries(snakeFilters)
.filter(([_, value]) => value !== null)
.map(([key, value]) => `@${key}:{${value}}`)
.filter(([_, value]) => value !== null && value !== undefined)
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`)
.join(" ")
: "*";
@@ -469,7 +469,7 @@ describe("Memory – auto-initialization", () => {
},
llm: {
provider: "openai",
config: { apiKey: "sk-fake", model: "gpt-4-turbo-preview" },
config: { apiKey: "sk-fake", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
disableHistory: true,
+1 -1
View File
@@ -75,7 +75,7 @@ function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
...overrides,
+1 -1
View File
@@ -75,7 +75,7 @@ function createMemory(): Memory {
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
});
+2 -2
View File
@@ -69,7 +69,7 @@ function createMemory(overrides: Partial<MemoryConfig> = {}): Memory {
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4-turbo-preview" },
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
...overrides,
@@ -94,7 +94,7 @@ describe("Memory - Initialization", () => {
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-4" },
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
};
const mem = Memory.fromConfig(config);
@@ -0,0 +1,299 @@
/**
* Unit tests for OSS SDK input validation.
*
* Validates fixes for:
* - Undefined/null message handling in add()
* - Threshold bounds validation (must be 0-1) in search()
* - TopK validation (must be non-negative) in search() and getAll()
* - Whitespace-only entity ID rejection in add(), search(), getAll()
*/
/// <reference types="jest" />
import { Memory } from "../src/memory";
jest.setTimeout(15000);
// Mock Google modules to prevent @google/genai crash in CI
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest.fn().mockResolvedValue(
JSON.stringify({
memory: [{ id: "0", text: "test memory", attributed_to: "user" }],
}),
),
})),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest.fn().mockResolvedValue([mockEmbedding]),
})),
}));
describe("Memory Input Validation", () => {
let memory: Memory;
const testUserId = "test-user-validation";
beforeAll(async () => {
memory = new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
vectorStore: {
provider: "memory",
config: { collectionName: "validation-test" },
},
});
// Wait for initialization
await new Promise((resolve) => setTimeout(resolve, 2000));
});
afterAll(async () => {
try {
await memory.reset();
} catch (e) {
// ignore cleanup errors
}
});
describe("add() message validation", () => {
it("should throw error when messages is undefined", async () => {
await expect(
// @ts-ignore - intentionally passing undefined
memory.add(undefined, { userId: testUserId }),
).rejects.toThrow("messages is required");
});
it("should throw error when messages is null", async () => {
await expect(
// @ts-ignore - intentionally passing null
memory.add(null, { userId: testUserId }),
).rejects.toThrow("messages is required");
});
});
describe("search() threshold validation", () => {
it("should throw error when threshold > 1.0", async () => {
await expect(
memory.search("test query", {
filters: { user_id: testUserId },
threshold: 1.5,
}),
).rejects.toThrow("Invalid threshold");
});
it("should throw error when threshold = 1.1", async () => {
await expect(
memory.search("test query", {
filters: { user_id: testUserId },
threshold: 1.1,
}),
).rejects.toThrow("Invalid threshold");
});
it("should throw error when threshold is negative", async () => {
await expect(
memory.search("test query", {
filters: { user_id: testUserId },
threshold: -0.5,
}),
).rejects.toThrow("Invalid threshold");
});
it("should throw error when threshold = -0.1", async () => {
await expect(
memory.search("test query", {
filters: { user_id: testUserId },
threshold: -0.1,
}),
).rejects.toThrow("Invalid threshold");
});
it("should accept threshold = 0 (edge case)", async () => {
const result = await memory.search("test query", {
filters: { user_id: testUserId },
threshold: 0,
});
expect(result).toBeDefined();
expect(result.results).toBeDefined();
});
it("should accept threshold = 1.0 (edge case)", async () => {
const result = await memory.search("test query", {
filters: { user_id: testUserId },
threshold: 1.0,
});
expect(result).toBeDefined();
expect(result.results).toBeDefined();
});
it("should accept threshold = 0.5 (normal valid value)", async () => {
const result = await memory.search("test query", {
filters: { user_id: testUserId },
threshold: 0.5,
});
expect(result).toBeDefined();
expect(result.results).toBeDefined();
});
});
describe("search() topK validation", () => {
it("should throw error when topK is negative", async () => {
await expect(
memory.search("test query", {
filters: { user_id: testUserId },
topK: -5,
}),
).rejects.toThrow("Invalid topK");
});
it("should throw error when topK = -1", async () => {
await expect(
memory.search("test query", {
filters: { user_id: testUserId },
topK: -1,
}),
).rejects.toThrow("Invalid topK");
});
it("should accept topK = 0 (returns empty)", async () => {
const result = await memory.search("test query", {
filters: { user_id: testUserId },
topK: 0,
});
expect(result).toBeDefined();
expect(result.results).toBeDefined();
});
it("should accept topK = 20 (normal value)", async () => {
const result = await memory.search("test query", {
filters: { user_id: testUserId },
topK: 20,
});
expect(result).toBeDefined();
expect(result.results).toBeDefined();
});
});
describe("add() entity ID validation", () => {
it("should throw error when userId is whitespace-only", async () => {
await expect(
memory.add("test message", { userId: " " }),
).rejects.toThrow("Invalid userId");
});
it("should throw error when userId is tabs and newlines", async () => {
await expect(
memory.add("test message", { userId: "\t\n\t" }),
).rejects.toThrow("Invalid userId");
});
it("should throw error when agentId is whitespace-only", async () => {
await expect(
memory.add("test message", { agentId: " " }),
).rejects.toThrow("Invalid agentId");
});
it("should throw error when runId is whitespace-only", async () => {
await expect(
memory.add("test message", { runId: " " }),
).rejects.toThrow("Invalid runId");
});
it("should throw error when userId contains internal whitespace", async () => {
await expect(
memory.add("test message", { userId: "user 123" }),
).rejects.toThrow("Invalid userId: cannot contain whitespace");
});
it("should throw error when userId contains tab character", async () => {
await expect(
memory.add("test message", { userId: "user\t123" }),
).rejects.toThrow("Invalid userId: cannot contain whitespace");
});
it("should accept userId with leading/trailing whitespace (trimmed)", async () => {
// Should not throw - leading/trailing whitespace is trimmed
const result = await memory.add("test message", {
userId: " valid-user ",
});
expect(result).toBeDefined();
});
});
describe("search() filter entity ID validation", () => {
it("should throw error when user_id in filters is whitespace-only", async () => {
await expect(
memory.search("test query", {
filters: { user_id: " " },
}),
).rejects.toThrow("Invalid user_id");
});
it("should throw error when agent_id in filters is whitespace-only", async () => {
await expect(
memory.search("test query", {
filters: { agent_id: " " },
}),
).rejects.toThrow("Invalid agent_id");
});
it("should throw error when user_id contains internal whitespace", async () => {
await expect(
memory.search("test query", {
filters: { user_id: "user 123" },
}),
).rejects.toThrow("Invalid user_id: cannot contain whitespace");
});
it("should accept user_id with leading/trailing whitespace (trimmed)", async () => {
const result = await memory.search("test query", {
filters: { user_id: " valid-user " },
});
expect(result).toBeDefined();
expect(result.results).toBeDefined();
});
});
describe("getAll() validation", () => {
it("should throw error when user_id is whitespace-only", async () => {
await expect(
memory.getAll({ filters: { user_id: " " } }),
).rejects.toThrow("Invalid user_id");
});
it("should throw error when topK is negative", async () => {
await expect(
memory.getAll({ filters: { user_id: testUserId }, topK: -1 }),
).rejects.toThrow("Invalid topK");
});
it("should throw error when user_id contains internal whitespace", async () => {
await expect(
memory.getAll({ filters: { user_id: "user 123" } }),
).rejects.toThrow("Invalid user_id: cannot contain whitespace");
});
it("should accept user_id with leading/trailing whitespace (trimmed)", async () => {
const result = await memory.getAll({
filters: { user_id: " valid-user " },
});
expect(result).toBeDefined();
expect(result.results).toBeDefined();
});
});
});
@@ -85,14 +85,16 @@ describe("MemoryVectorStore - search", () => {
expect(results).toHaveLength(1);
});
test("filters by userId", async () => {
const results = await store.search(vec([1, 0, 0, 0]), 10, { userId: "u2" });
expect(results.every((r) => r.payload.userId === "u2")).toBe(true);
test("filters by user_id", async () => {
const results = await store.search(vec([1, 0, 0, 0]), 10, {
user_id: "u2",
});
expect(results.every((r) => r.payload.user_id === "u2")).toBe(true);
});
test("returns empty when filter matches nothing", async () => {
const results = await store.search(vec([1, 0, 0, 0]), 10, {
userId: "nobody",
user_id: "nobody",
});
expect(results).toHaveLength(0);
});
@@ -168,10 +170,10 @@ describe("MemoryVectorStore - list", () => {
expect(results).toHaveLength(3);
});
test("filters by userId", async () => {
const [results, count] = await store.list({ userId: "u1" });
test("filters by user_id", async () => {
const [results, count] = await store.list({ user_id: "u1" });
expect(count).toBe(2);
expect(results.every((r) => r.payload.userId === "u1")).toBe(true);
expect(results.every((r) => r.payload.user_id === "u1")).toBe(true);
});
test("respects limit", async () => {
@@ -95,19 +95,19 @@ describe("MemoryVectorStore – full backward compat", () => {
expect(results.length).toBe(2);
expect(results[0].id).toBe("id-1");
// Search with filters
const filtered = await store.search(vec1, 2, { userId: "u1" });
// Search with filters (camelCase in payload is normalized to snake_case)
const filtered = await store.search(vec1, 2, { user_id: "u1" });
expect(filtered.length).toBe(2);
// Update
const vec3 = new Array(1536).fill(0);
vec3[2] = 1.0;
await store.update("id-1", vec3, { data: "updated", userId: "u1" });
await store.update("id-1", vec3, { data: "updated", user_id: "u1" });
const updated = await store.get("id-1");
expect(updated!.payload.data).toBe("updated");
// List
const [listed, count] = await store.list({ userId: "u1" });
const [listed, count] = await store.list({ user_id: "u1" });
expect(count).toBe(2);
// List with limit
+10 -20
View File
@@ -167,7 +167,7 @@ class MemoryClient:
kwargs = self._prepare_params(kwargs)
payload = self._prepare_payload(messages, kwargs)
response = self.client.post("/v3/memories/", json=payload)
response = self.client.post("/v3/memories/add/", json=payload)
response.raise_for_status()
if "metadata" in kwargs:
del kwargs["metadata"]
@@ -207,7 +207,7 @@ class MemoryClient:
**kwargs: Optional parameters for filtering (filters, page, page_size).
Returns:
A dictionary containing memories in v1.1 format: {"results": [...]}
A paginated dict: {"count": int, "next": str | None, "previous": str | None, "results": [...]}
Raises:
ValidationError: If the input data is invalid.
@@ -233,9 +233,9 @@ class MemoryClient:
"page": params.pop("page"),
"page_size": params.pop("page_size"),
}
response = self.client.post("/v2/memories/", json=params, params=query_params)
response = self.client.post("/v3/memories/", json=params, params=query_params)
else:
response = self.client.post("/v2/memories/", json=params)
response = self.client.post("/v3/memories/", json=params)
response.raise_for_status()
if "metadata" in kwargs:
del kwargs["metadata"]
@@ -247,12 +247,7 @@ class MemoryClient:
"sync_type": "sync",
},
)
result = response.json()
# Ensure v1.1 format (wrap raw list if needed)
if isinstance(result, list):
return {"results": result}
return result
return response.json()
@api_error_handler
def search(self, query: str, options: Optional[SearchMemoryOptions] = None, **kwargs) -> Dict[str, Any]:
@@ -1095,7 +1090,7 @@ class AsyncMemoryClient:
kwargs = self._prepare_params(kwargs)
payload = self._prepare_payload(messages, kwargs)
response = await self.async_client.post("/v3/memories/", json=payload)
response = await self.async_client.post("/v3/memories/add/", json=payload)
response.raise_for_status()
if "metadata" in kwargs:
del kwargs["metadata"]
@@ -1119,7 +1114,7 @@ class AsyncMemoryClient:
**kwargs: Optional parameters for filtering (filters, page, page_size).
Returns:
A dictionary containing memories in v1.1 format: {"results": [...]}
A paginated dict: {"count": int, "next": str | None, "previous": str | None, "results": [...]}
Raises:
ValidationError: If the input data is invalid.
@@ -1145,9 +1140,9 @@ class AsyncMemoryClient:
"page": params.pop("page"),
"page_size": params.pop("page_size"),
}
response = await self.async_client.post("/v2/memories/", json=params, params=query_params)
response = await self.async_client.post("/v3/memories/", json=params, params=query_params)
else:
response = await self.async_client.post("/v2/memories/", json=params)
response = await self.async_client.post("/v3/memories/", json=params)
response.raise_for_status()
if "metadata" in kwargs:
del kwargs["metadata"]
@@ -1159,12 +1154,7 @@ class AsyncMemoryClient:
"sync_type": "async",
},
)
result = response.json()
# Ensure v1.1 format (wrap raw list if needed)
if isinstance(result, list):
return {"results": result}
return result
return response.json()
@api_error_handler
async def search(self, query: str, options: Optional[SearchMemoryOptions] = None, **kwargs) -> Dict[str, Any]:
+1 -1
View File
@@ -39,7 +39,7 @@ class AzureOpenAILLM(LLMBase):
# Model name should match the custom deployment name chosen for it.
if not self.config.model:
self.config.model = "gpt-4.1-nano-2025-04-14"
self.config.model = "gpt-5-mini"
api_key = self.config.azure_kwargs.api_key or os.getenv("LLM_AZURE_OPENAI_API_KEY")
azure_deployment = self.config.azure_kwargs.azure_deployment or os.getenv("LLM_AZURE_DEPLOYMENT")
+1 -1
View File
@@ -18,7 +18,7 @@ class AzureOpenAIStructuredLLM(LLMBase):
# Model name should match the custom deployment name chosen for it.
if not self.config.model:
self.config.model = "gpt-4.1-nano-2025-04-14"
self.config.model = "gpt-5-mini"
api_key = self.config.azure_kwargs.api_key or os.getenv("LLM_AZURE_OPENAI_API_KEY")
azure_deployment = self.config.azure_kwargs.azure_deployment or os.getenv("LLM_AZURE_DEPLOYMENT")
+1 -1
View File
@@ -16,7 +16,7 @@ class LiteLLM(LLMBase):
super().__init__(config)
if not self.config.model:
self.config.model = "gpt-4.1-nano-2025-04-14"
self.config.model = "gpt-5-mini"
def _parse_response(self, response, tools):
"""
+1 -1
View File
@@ -36,7 +36,7 @@ class OpenAILLM(LLMBase):
super().__init__(config)
if not self.config.model:
self.config.model = "gpt-4.1-nano-2025-04-14"
self.config.model = "gpt-5-mini"
if os.environ.get("OPENROUTER_API_KEY"): # Use OpenRouter
self.client = OpenAI(
+1 -1
View File
@@ -12,7 +12,7 @@ class OpenAIStructuredLLM(LLMBase):
super().__init__(config)
if not self.config.model:
self.config.model = "gpt-4o-2024-08-06"
self.config.model = "gpt-5-mini"
api_key = self.config.api_key or os.getenv("OPENAI_API_KEY")
base_url = self.config.openai_base_url or os.getenv("OPENAI_API_BASE") or "https://api.openai.com/v1"
+379 -24
View File
@@ -110,6 +110,64 @@ def _reject_top_level_entity_params(kwargs: Dict[str, Any], method_name: str) ->
)
def _validate_and_trim_entity_id(value: Optional[str], name: str) -> Optional[str]:
"""
Validates and normalizes an entity ID.
- Trims leading/trailing whitespace
- Rejects empty or whitespace-only strings
- Rejects strings containing internal whitespace
Args:
value: The entity ID value to validate
name: The parameter name (for error messages)
Returns:
The trimmed entity ID, or None if input is None
Raises:
ValueError: If entity ID is invalid
"""
if value is None:
return None
trimmed = value.strip()
if trimmed == "":
raise ValueError(
f"Invalid {name}: cannot be empty or whitespace-only. Provide a valid identifier."
)
if any(c.isspace() for c in trimmed):
raise ValueError(
f"Invalid {name}: cannot contain whitespace. Provide a valid identifier without spaces."
)
return trimmed
def _validate_search_params(threshold: Optional[float] = None, top_k: Optional[int] = None) -> None:
"""
Validates search parameters.
Args:
threshold: Similarity threshold (must be between 0 and 1)
top_k: Number of results to return (must be non-negative integer)
Raises:
ValueError: If threshold or top_k are invalid
"""
if threshold is not None:
if not isinstance(threshold, (int, float)):
raise ValueError("threshold must be a valid number")
if threshold < 0 or threshold > 1:
raise ValueError(
f"Invalid threshold: {threshold}. Must be between 0 and 1 (inclusive)."
)
if top_k is not None:
if not isinstance(top_k, int) or isinstance(top_k, bool):
raise ValueError("top_k must be a valid integer")
if top_k < 0:
raise ValueError(
f"Invalid top_k: {top_k}. Must be a non-negative integer."
)
def _is_sensitive_field(field_name: str) -> bool:
"""Check if a field should be redacted for telemetry safety.
@@ -217,9 +275,14 @@ def _build_filters_and_metadata(
base_metadata_template = deepcopy(input_metadata) if input_metadata else {}
effective_query_filters = deepcopy(input_filters) if input_filters else {}
# ---------- add all provided session ids ----------
# ---------- validate and add all provided session ids ----------
session_ids_provided = []
# Validate and trim entity IDs
user_id = _validate_and_trim_entity_id(user_id, "user_id")
agent_id = _validate_and_trim_entity_id(agent_id, "agent_id")
run_id = _validate_and_trim_entity_id(run_id, "run_id")
if user_id:
base_metadata_template["user_id"] = user_id
effective_query_filters["user_id"] = user_id
@@ -334,6 +397,14 @@ class Memory(MemoryBase):
entity_config.collection_name = entity_collection
elif isinstance(entity_config, dict):
entity_config['collection_name'] = entity_collection
# For Qdrant, share the existing client to avoid RocksDB lock contention
# when using embedded mode (path=...). QdrantConfig.client takes precedence
# over host/port/path.
if self.config.vector_store.provider == "qdrant" and hasattr(self.vector_store, "client"):
if hasattr(entity_config, "client"):
entity_config.client = self.vector_store.client
elif isinstance(entity_config, dict):
entity_config["client"] = self.vector_store.client
self._entity_store = VectorStoreFactory.create(
self.config.vector_store.provider, entity_config
)
@@ -382,6 +453,84 @@ class Memory(MemoryBase):
except Exception as e:
logger.warning(f"Entity upsert failed for '{entity_text}': {e}")
def _remove_memory_from_entity_store(self, memory_id, filters):
"""Strip `memory_id` from every entity record scoped to `filters`.
For each entity whose `linked_memory_ids` contains `memory_id`:
- remove the id; if the list becomes empty, delete the entity record.
- otherwise re-embed the entity text and update the payload
(the vector store's update() requires a vector).
No-op if the entity store has never been initialized in this process.
Errors on individual entities are swallowed at debug level; outer
failures are swallowed at warning level so the primary delete/update
path is never broken by entity cleanup.
"""
if self._entity_store is None:
return
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
try:
listed = self.entity_store.list(filters=search_filters, top_k=10000)
rows = listed[0] if isinstance(listed, (list, tuple)) and listed and isinstance(listed[0], list) else listed
for row in rows or []:
try:
payload = getattr(row, "payload", None) or {}
linked = payload.get("linked_memory_ids", [])
if not isinstance(linked, list) or memory_id not in linked:
continue
remaining = [mid for mid in linked if mid != memory_id]
if not remaining:
try:
self.entity_store.delete(vector_id=row.id)
except Exception as e:
logger.debug(f"Entity delete failed for id={row.id}: {e}")
else:
entity_text = payload.get("data")
if not isinstance(entity_text, str) or not entity_text:
logger.debug(f"Entity id={row.id} missing 'data'; skipping update during cleanup")
continue
try:
vec = self.embedding_model.embed(entity_text, "update")
except Exception as e:
logger.debug(f"Entity re-embed failed for '{entity_text}': {e}")
continue
new_payload = {**payload, "linked_memory_ids": remaining}
try:
self.entity_store.update(
vector_id=row.id,
vector=vec,
payload=new_payload,
)
except Exception as e:
logger.debug(f"Entity update failed for id={row.id}: {e}")
except Exception as e:
logger.debug(f"Entity cleanup error: {e}")
except Exception as e:
logger.warning(f"Entity store cleanup failed for memory_id={memory_id}: {e}")
def _link_entities_for_memory(self, memory_id, text, filters):
"""Extract entities from `text` and link them to `memory_id` in the
entity store, scoped to `filters`. Simpler single-memory variant of
Phase 7 in add(): per-entity search-then-update-or-insert via the
existing `_upsert_entity` helper. Non-fatal on any failure.
"""
try:
entities = extract_entities(text)
if not entities:
return
seen = set()
for entity_type, entity_text in entities:
key = entity_text.strip().lower()
if not key or key in seen:
continue
seen.add(key)
try:
self._upsert_entity(entity_text, entity_type, memory_id, filters)
except Exception as e:
logger.debug(f"Entity link failed for '{entity_text}': {e}")
except Exception as e:
logger.warning(f"Entity linking failed for memory_id={memory_id}: {e}")
@classmethod
def from_config(cls, config_dict: Dict[str, Any]):
try:
@@ -868,7 +1017,7 @@ class Memory(MemoryBase):
self,
*,
filters: Optional[Dict[str, Any]] = None,
top_k: int = 100,
top_k: int = 20,
**kwargs,
):
"""
@@ -878,20 +1027,38 @@ class Memory(MemoryBase):
filters (dict): Filter dict containing entity IDs and optional metadata filters.
Must contain at least one of: user_id, agent_id, run_id.
Example: filters={"user_id": "u1", "agent_id": "a1"}
top_k (int, optional): The maximum number of memories to return. Defaults to 100.
top_k (int, optional): The maximum number of memories to return. Defaults to 20.
Returns:
dict: A dictionary containing a list of memories under the "results" key.
Example for v1.1+: `{"results": [{"id": "...", "memory": "...", ...}]}`
Raises:
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id.
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id,
or if top_k is invalid.
"""
# Reject top-level entity params - must use filters instead
_reject_top_level_entity_params(kwargs, "get_all")
# Validate top_k
_validate_search_params(top_k=top_k)
# Validate and trim entity IDs in filters
effective_filters = dict(filters) if filters else {}
if "user_id" in effective_filters:
effective_filters["user_id"] = _validate_and_trim_entity_id(
effective_filters["user_id"], "user_id"
)
if "agent_id" in effective_filters:
effective_filters["agent_id"] = _validate_and_trim_entity_id(
effective_filters["agent_id"], "agent_id"
)
if "run_id" in effective_filters:
effective_filters["run_id"] = _validate_and_trim_entity_id(
effective_filters["run_id"], "run_id"
)
# Validate filters contains at least one entity ID
effective_filters = filters or {}
if not any(key in effective_filters for key in ("user_id", "agent_id", "run_id")):
raise ValueError(
"filters must contain at least one of: user_id, agent_id, run_id. "
@@ -960,7 +1127,7 @@ class Memory(MemoryBase):
self,
query: str,
*,
top_k: int = 100,
top_k: int = 20,
filters: Optional[Dict[str, Any]] = None,
threshold: float = 0.1,
rerank: bool = False,
@@ -971,7 +1138,7 @@ class Memory(MemoryBase):
Args:
query (str): Query to search for.
top_k (int, optional): Maximum number of results to return. Defaults to 100.
top_k (int, optional): Maximum number of results to return. Defaults to 20.
filters (dict): Filter dict containing entity IDs and optional metadata filters.
Must contain at least one of: user_id, agent_id, run_id.
Example: filters={"user_id": "u1", "agent_id": "a1"}
@@ -1000,13 +1167,29 @@ class Memory(MemoryBase):
Example for v1.1+: `{"results": [{"id": "...", "memory": "...", "score": 0.8, ...}]}`
Raises:
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id.
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id,
or if threshold/top_k values are invalid.
"""
# Reject top-level entity params - must use filters instead
_reject_top_level_entity_params(kwargs, "search")
# Validate filters contains at least one entity ID
# Validate search parameters (before applying defaults)
_validate_search_params(threshold=threshold, top_k=top_k)
# Validate and trim entity IDs in filters
effective_filters = filters.copy() if filters else {}
if "user_id" in effective_filters:
effective_filters["user_id"] = _validate_and_trim_entity_id(
effective_filters["user_id"], "user_id"
)
if "agent_id" in effective_filters:
effective_filters["agent_id"] = _validate_and_trim_entity_id(
effective_filters["agent_id"], "agent_id"
)
if "run_id" in effective_filters:
effective_filters["run_id"] = _validate_and_trim_entity_id(
effective_filters["run_id"], "run_id"
)
if not any(key in effective_filters for key in ("user_id", "agent_id", "run_id")):
raise ValueError(
"filters must contain at least one of: user_id, agent_id, run_id. "
@@ -1188,8 +1371,6 @@ class Memory(MemoryBase):
entity_boosts = self._compute_entity_boosts(query_entities, filters)
# Step 7: Build candidate set from semantic results
# BM25 acts as a boost signal only (not recall-expanding) -- candidates must
# pass the semantic threshold gate, so only semantic results are candidates.
candidates = []
for mem in semantic_results:
mem_id = str(mem.id)
@@ -1234,9 +1415,6 @@ class Memory(MemoryBase):
score=scored["score"],
).model_dump()
# Add score breakdown to metadata
memory_item_dict["score_breakdown"] = scored.get("score_breakdown", {})
for key in promoted_payload_keys:
if key in payload:
memory_item_dict[key] = payload[key]
@@ -1524,6 +1702,13 @@ class Memory(MemoryBase):
actor_id=new_metadata.get("actor_id"),
role=new_metadata.get("role"),
)
# Entity-store cleanup: strip this memory's id from old-text entities,
# then re-extract entities from the new text and link them back.
session_filters = {k: new_metadata[k] for k in ("user_id", "agent_id", "run_id") if new_metadata.get(k)}
self._remove_memory_from_entity_store(memory_id, session_filters)
self._link_entities_for_memory(memory_id, data, session_filters)
return memory_id
def _delete_memory(self, memory_id, existing_memory=None):
@@ -1535,6 +1720,8 @@ class Memory(MemoryBase):
prev_value = existing_memory.payload.get("data", "")
created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
updated_at = datetime.now(timezone.utc).isoformat()
payload = existing_memory.payload or {}
session_filters = {k: payload[k] for k in ("user_id", "agent_id", "run_id") if payload.get(k)}
self.vector_store.delete(vector_id=memory_id)
self.db.add_history(
memory_id,
@@ -1547,6 +1734,11 @@ class Memory(MemoryBase):
role=existing_memory.payload.get("role"),
is_deleted=1,
)
# Entity-store cleanup: strip this memory's id from any entity records
# that linked to it. Non-fatal — the helper swallows errors.
self._remove_memory_from_entity_store(memory_id, session_filters)
return memory_id
def reset(self):
@@ -1640,11 +1832,127 @@ class AsyncMemory(MemoryBase):
entity_config.collection_name = entity_collection
elif isinstance(entity_config, dict):
entity_config['collection_name'] = entity_collection
# For Qdrant, share the existing client to avoid RocksDB lock contention
# when using embedded mode (path=...). QdrantConfig.client takes precedence
# over host/port/path.
if self.config.vector_store.provider == "qdrant" and hasattr(self.vector_store, "client"):
if hasattr(entity_config, "client"):
entity_config.client = self.vector_store.client
elif isinstance(entity_config, dict):
entity_config["client"] = self.vector_store.client
self._entity_store = VectorStoreFactory.create(
self.config.vector_store.provider, entity_config
)
return self._entity_store
async def _upsert_entity_async(self, entity_text, entity_type, memory_id, filters):
"""Async variant of `_upsert_entity` — per-entity search-then-update-or-insert."""
try:
entity_embedding = await asyncio.to_thread(self.embedding_model.embed, entity_text, "add")
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
existing = await asyncio.to_thread(
self.entity_store.search,
query=entity_text,
vectors=entity_embedding,
top_k=1,
filters=search_filters,
)
if existing and existing[0].score >= 0.95:
match = existing[0]
payload = match.payload or {}
linked_ids = payload.get("linked_memory_ids", [])
if memory_id not in linked_ids:
linked_ids.append(memory_id)
payload["linked_memory_ids"] = linked_ids
await asyncio.to_thread(
self.entity_store.update,
vector_id=match.id,
vector=None,
payload=payload,
)
else:
entity_id = str(uuid.uuid4())
entity_payload = {
"data": entity_text,
"entity_type": entity_type,
"linked_memory_ids": [memory_id],
**{k: v for k, v in search_filters.items()},
}
await asyncio.to_thread(
self.entity_store.insert,
vectors=[entity_embedding],
ids=[entity_id],
payloads=[entity_payload],
)
except Exception as e:
logger.warning(f"Entity upsert failed for '{entity_text}' (async): {e}")
async def _remove_memory_from_entity_store(self, memory_id, filters):
"""Async variant of `Memory._remove_memory_from_entity_store`."""
if self._entity_store is None:
return
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
try:
listed = await asyncio.to_thread(self.entity_store.list, filters=search_filters, top_k=10000)
rows = listed[0] if isinstance(listed, (list, tuple)) and listed and isinstance(listed[0], list) else listed
for row in rows or []:
try:
payload = getattr(row, "payload", None) or {}
linked = payload.get("linked_memory_ids", [])
if not isinstance(linked, list) or memory_id not in linked:
continue
remaining = [mid for mid in linked if mid != memory_id]
if not remaining:
try:
await asyncio.to_thread(self.entity_store.delete, vector_id=row.id)
except Exception as e:
logger.debug(f"Entity delete failed for id={row.id} (async): {e}")
else:
entity_text = payload.get("data")
if not isinstance(entity_text, str) or not entity_text:
logger.debug(f"Entity id={row.id} missing 'data'; skipping update during cleanup (async)")
continue
try:
vec = await asyncio.to_thread(self.embedding_model.embed, entity_text, "update")
except Exception as e:
logger.debug(f"Entity re-embed failed for '{entity_text}' (async): {e}")
continue
new_payload = {**payload, "linked_memory_ids": remaining}
try:
await asyncio.to_thread(
self.entity_store.update,
vector_id=row.id,
vector=vec,
payload=new_payload,
)
except Exception as e:
logger.debug(f"Entity update failed for id={row.id} (async): {e}")
except Exception as e:
logger.debug(f"Entity cleanup error (async): {e}")
except Exception as e:
logger.warning(f"Entity store cleanup failed for memory_id={memory_id} (async): {e}")
async def _link_entities_for_memory(self, memory_id, text, filters):
"""Async variant of `Memory._link_entities_for_memory`."""
try:
entities = await asyncio.to_thread(extract_entities, text)
if not entities:
return
seen = set()
for entity_type, entity_text in entities:
key = entity_text.strip().lower()
if not key or key in seen:
continue
seen.add(key)
try:
await self._upsert_entity_async(entity_text, entity_type, memory_id, filters)
except Exception as e:
logger.debug(f"Entity link failed for '{entity_text}' (async): {e}")
except Exception as e:
logger.warning(f"Entity linking failed for memory_id={memory_id} (async): {e}")
@classmethod
def from_config(cls, config_dict: Dict[str, Any]):
try:
@@ -2115,7 +2423,7 @@ class AsyncMemory(MemoryBase):
self,
*,
filters: Optional[Dict[str, Any]] = None,
top_k: int = 100,
top_k: int = 20,
**kwargs,
):
"""
@@ -2125,20 +2433,38 @@ class AsyncMemory(MemoryBase):
filters (dict): Filter dict containing entity IDs and optional metadata filters.
Must contain at least one of: user_id, agent_id, run_id.
Example: filters={"user_id": "u1", "agent_id": "a1"}
top_k (int, optional): The maximum number of memories to return. Defaults to 100.
top_k (int, optional): The maximum number of memories to return. Defaults to 20.
Returns:
dict: A dictionary containing a list of memories under the "results" key.
Example for v1.1+: `{"results": [{"id": "...", "memory": "...", ...}]}`
Raises:
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id.
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id,
or if top_k is invalid.
"""
# Reject top-level entity params - must use filters instead
_reject_top_level_entity_params(kwargs, "get_all")
# Validate top_k
_validate_search_params(top_k=top_k)
# Validate and trim entity IDs in filters
effective_filters = dict(filters) if filters else {}
if "user_id" in effective_filters:
effective_filters["user_id"] = _validate_and_trim_entity_id(
effective_filters["user_id"], "user_id"
)
if "agent_id" in effective_filters:
effective_filters["agent_id"] = _validate_and_trim_entity_id(
effective_filters["agent_id"], "agent_id"
)
if "run_id" in effective_filters:
effective_filters["run_id"] = _validate_and_trim_entity_id(
effective_filters["run_id"], "run_id"
)
# Validate filters contains at least one entity ID
effective_filters = filters or {}
if not any(key in effective_filters for key in ("user_id", "agent_id", "run_id")):
raise ValueError(
"filters must contain at least one of: user_id, agent_id, run_id. "
@@ -2207,7 +2533,7 @@ class AsyncMemory(MemoryBase):
self,
query: str,
*,
top_k: int = 100,
top_k: int = 20,
filters: Optional[Dict[str, Any]] = None,
threshold: float = 0.1,
rerank: bool = False,
@@ -2218,7 +2544,7 @@ class AsyncMemory(MemoryBase):
Args:
query (str): Query to search for.
top_k (int, optional): Maximum number of results to return. Defaults to 100.
top_k (int, optional): Maximum number of results to return. Defaults to 20.
filters (dict): Filter dict containing entity IDs and optional metadata filters.
Must contain at least one of: user_id, agent_id, run_id.
Example: filters={"user_id": "u1", "agent_id": "a1"}
@@ -2247,13 +2573,31 @@ class AsyncMemory(MemoryBase):
Example for v1.1+: `{"results": [{"id": "...", "memory": "...", "score": 0.8, ...}]}`
Raises:
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id.
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id,
or if threshold/top_k values are invalid.
"""
# Reject top-level entity params - must use filters instead
_reject_top_level_entity_params(kwargs, "search")
# Validate filters contains at least one entity ID
# Validate search parameters (before applying defaults)
_validate_search_params(threshold=threshold, top_k=top_k)
# Validate and trim entity IDs in filters
effective_filters = filters.copy() if filters else {}
if "user_id" in effective_filters:
effective_filters["user_id"] = _validate_and_trim_entity_id(
effective_filters["user_id"], "user_id"
)
if "agent_id" in effective_filters:
effective_filters["agent_id"] = _validate_and_trim_entity_id(
effective_filters["agent_id"], "agent_id"
)
if "run_id" in effective_filters:
effective_filters["run_id"] = _validate_and_trim_entity_id(
effective_filters["run_id"], "run_id"
)
# Validate filters contains at least one entity ID
if not any(key in effective_filters for key in ("user_id", "agent_id", "run_id")):
raise ValueError(
"filters must contain at least one of: user_id, agent_id, run_id. "
@@ -2480,8 +2824,6 @@ class AsyncMemory(MemoryBase):
score=scored["score"],
).model_dump()
memory_item_dict["score_breakdown"] = scored.get("score_breakdown", {})
for key in promoted_payload_keys:
if key in payload:
memory_item_dict[key] = payload[key]
@@ -2784,6 +3126,13 @@ class AsyncMemory(MemoryBase):
actor_id=new_metadata.get("actor_id"),
role=new_metadata.get("role"),
)
# Entity-store cleanup: strip this memory's id from old-text entities,
# then re-extract entities from the new text and link them back.
session_filters = {k: new_metadata[k] for k in ("user_id", "agent_id", "run_id") if new_metadata.get(k)}
await self._remove_memory_from_entity_store(memory_id, session_filters)
await self._link_entities_for_memory(memory_id, data, session_filters)
return memory_id
async def _delete_memory(self, memory_id, existing_memory=None):
@@ -2795,6 +3144,8 @@ class AsyncMemory(MemoryBase):
prev_value = existing_memory.payload.get("data", "")
created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
updated_at = datetime.now(timezone.utc).isoformat()
payload = existing_memory.payload or {}
session_filters = {k: payload[k] for k in ("user_id", "agent_id", "run_id") if payload.get(k)}
await asyncio.to_thread(self.vector_store.delete, vector_id=memory_id)
await asyncio.to_thread(
@@ -2810,6 +3161,10 @@ class AsyncMemory(MemoryBase):
is_deleted=1,
)
# Entity-store cleanup: strip this memory's id from any entity records
# that linked to it. Non-fatal — the helper swallows errors.
await self._remove_memory_from_entity_store(memory_id, session_filters)
return memory_id
async def reset(self):
+14 -3
View File
@@ -32,15 +32,26 @@ def get_user_id():
return "anonymous_user"
def get_or_create_user_id(vector_store):
"""Store user_id in vector store and return it."""
def get_or_create_user_id(vector_store=None):
"""Store user_id in vector store and return it.
If vector_store is None, simply returns the user_id from config.
This ensures telemetry initialization never fails due to missing vector store.
"""
user_id = get_user_id()
# If no vector store provided, just return the user_id
if vector_store is None:
return user_id
# Try to get existing user_id from vector store
try:
existing = vector_store.get(vector_id=user_id)
if existing and hasattr(existing, "payload") and existing.payload and "user_id" in existing.payload:
return existing.payload["user_id"]
stored_id = existing.payload["user_id"]
# Ensure we never return None from vector store
if stored_id is not None:
return stored_id
except Exception:
pass
+47 -23
View File
@@ -88,6 +88,12 @@ class AnonymousTelemetry:
if self.posthog is None:
return
# Determine distinct_id, skip if None to prevent crashes
distinct_id = self.user_id if user_email is None else user_email
if distinct_id is None:
_logger.debug("Skipping telemetry event %r: no distinct_id available", event_name)
return
if properties is None:
properties = {}
properties = {
@@ -101,8 +107,10 @@ class AnonymousTelemetry:
"machine": platform.machine(),
**properties,
}
distinct_id = self.user_id if user_email is None else user_email
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
try:
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
except Exception as e:
_logger.debug("Failed to capture telemetry event %r: %s", event_name, e)
def close(self):
if self.posthog is not None:
@@ -158,36 +166,52 @@ atexit.register(client_telemetry.close)
def capture_event(event_name, memory_instance, additional_data=None):
"""Capture telemetry event for OSS Memory instances.
This function is designed to never raise exceptions - telemetry failures
should not affect the main application flow.
"""
if not MEM0_TELEMETRY:
return
oss_telemetry = _get_oss_telemetry()
if oss_telemetry is None:
return
try:
oss_telemetry = _get_oss_telemetry()
if oss_telemetry is None:
return
event_data = {
"collection": memory_instance.collection_name,
"vector_size": memory_instance.embedding_model.config.embedding_dims,
"history_store": "sqlite",
"vector_store": f"{memory_instance.vector_store.__class__.__module__}.{memory_instance.vector_store.__class__.__name__}",
"llm": f"{memory_instance.llm.__class__.__module__}.{memory_instance.llm.__class__.__name__}",
"embedding_model": f"{memory_instance.embedding_model.__class__.__module__}.{memory_instance.embedding_model.__class__.__name__}",
"function": f"{memory_instance.__class__.__module__}.{memory_instance.__class__.__name__}.{memory_instance.api_version}",
}
if additional_data:
event_data.update(additional_data)
event_data = {
"collection": memory_instance.collection_name,
"vector_size": memory_instance.embedding_model.config.embedding_dims,
"history_store": "sqlite",
"vector_store": f"{memory_instance.vector_store.__class__.__module__}.{memory_instance.vector_store.__class__.__name__}",
"llm": f"{memory_instance.llm.__class__.__module__}.{memory_instance.llm.__class__.__name__}",
"embedding_model": f"{memory_instance.embedding_model.__class__.__module__}.{memory_instance.embedding_model.__class__.__name__}",
"function": f"{memory_instance.__class__.__module__}.{memory_instance.__class__.__name__}.{memory_instance.api_version}",
}
if additional_data:
event_data.update(additional_data)
oss_telemetry.capture_event(event_name, event_data)
oss_telemetry.capture_event(event_name, event_data)
except Exception as e:
_logger.debug("Failed to capture OSS telemetry event %r: %s", event_name, e)
def capture_client_event(event_name, instance, additional_data=None):
"""Capture telemetry event for hosted MemoryClient instances.
This function is designed to never raise exceptions - telemetry failures
should not affect the main application flow.
"""
if not MEM0_TELEMETRY:
return
event_data = {
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
}
if additional_data:
event_data.update(additional_data)
try:
event_data = {
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
}
if additional_data:
event_data.update(additional_data)
client_telemetry.capture_event(event_name, event_data, instance.user_email)
client_telemetry.capture_event(event_name, event_data, instance.user_email)
except Exception as e:
_logger.debug("Failed to capture client telemetry event %r: %s", event_name, e)
-5
View File
@@ -113,11 +113,6 @@ def score_and_rank(
{
"id": mem_id_str,
"score": combined,
"score_breakdown": {
"semantic": semantic_score,
"bm25": bm25_score,
"entity_boost": entity_boost,
},
"payload": result.get("payload"),
}
)
+40 -17
View File
@@ -46,6 +46,9 @@ class MilvusDB(VectorStoreBase):
self.embedding_model_dims = embedding_model_dims
self.metric_type = metric_type
self.client = MilvusClient(uri=url, token=token, db_name=db_name)
# Whether this collection has the `text` + `sparse` fields for v3 BM25.
# Pre-v3 collections lack them; writing a top-level `text` field is rejected.
self._has_bm25_schema = False
self.create_col(
collection_name=self.collection_name,
vector_size=self.embedding_model_dims,
@@ -68,6 +71,15 @@ class MilvusDB(VectorStoreBase):
if self.client.has_collection(collection_name):
logger.info(f"Collection {collection_name} already exists. Skipping creation.")
desc = self.client.describe_collection(collection_name=collection_name)
field_names = {f.get("name") for f in desc.get("fields", [])}
self._has_bm25_schema = "text" in field_names and "sparse" in field_names
if not self._has_bm25_schema:
logger.warning(
f"Collection '{collection_name}' predates v3 hybrid search (no 'text'/'sparse' fields). "
"BM25 keyword scoring will be disabled for this collection; semantic search works normally. "
"To enable hybrid search, use a fresh collection."
)
else:
fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=512),
@@ -101,6 +113,7 @@ class MilvusDB(VectorStoreBase):
index_name="sparse_index",
)
self.client.create_collection(collection_name=collection_name, schema=schema, index_params=index_params)
self._has_bm25_schema = True
def insert(self, ids, vectors, payloads, **kwargs: Optional[dict[str, any]]):
"""Insert vectors into a collection.
@@ -110,17 +123,17 @@ class MilvusDB(VectorStoreBase):
payloads (List[Dict], optional): List of payloads corresponding to vectors.
ids (List[str], optional): List of IDs corresponding to vectors.
"""
# Batch insert all records at once for better performance and consistency
data = [
{
"id": idx,
"vectors": embedding,
"metadata": metadata,
# Batch insert all records at once for better performance and consistency.
# Only include the `text` field when the collection's schema has it — legacy
# collections created pre-v3 reject unknown top-level fields.
def _build_record(idx, embedding, metadata):
record = {"id": idx, "vectors": embedding, "metadata": metadata}
if self._has_bm25_schema:
# Populate the text field for BM25 sparse search; prefer lemmatized text, fall back to raw data
"text": (metadata.get("text_lemmatized") or metadata.get("data", ""))[:65535] if metadata else "",
}
for idx, embedding, metadata in zip(ids, vectors, payloads)
]
record["text"] = (metadata.get("text_lemmatized") or metadata.get("data", ""))[:65535] if metadata else ""
return record
data = [_build_record(idx, embedding, metadata) for idx, embedding, metadata in zip(ids, vectors, payloads)]
self.client.insert(collection_name=self.collection_name, data=data, **kwargs)
def _create_filter(self, filters: dict):
@@ -179,13 +192,21 @@ class MilvusDB(VectorStoreBase):
list: Search results.
"""
query_filter = self._create_filter(filters) if filters else None
hits = self.client.search(
collection_name=self.collection_name,
data=[vectors],
limit=top_k,
filter=query_filter,
output_fields=["*"],
)
# v3 collections carry both a dense `vectors` field and a sparse `sparse`
# field (for BM25), which makes anns_field ambiguous — Milvus rejects the
# query otherwise with "multiple anns_fields exist". Legacy single-vector
# collections don't need the hint, so only pass it when the hybrid schema
# is present.
search_kwargs = {
"collection_name": self.collection_name,
"data": [vectors],
"limit": top_k,
"filter": query_filter,
"output_fields": ["*"],
}
if self._has_bm25_schema:
search_kwargs["anns_field"] = "vectors"
hits = self.client.search(**search_kwargs)
result = self._parse_output(data=hits[0])
return result
@@ -206,6 +227,8 @@ class MilvusDB(VectorStoreBase):
list: Search results in the same format as search(), or None if sparse search
is not supported on this collection.
"""
if not self._has_bm25_schema:
return None
try:
query_filter = self._create_filter(filters) if filters else None
hits = self.client.search(
+31 -13
View File
@@ -79,6 +79,10 @@ class Qdrant(VectorStoreBase):
self.embedding_model_dims = embedding_model_dims
self.on_disk = on_disk
self._bm25_encoder = None
# Whether this collection has the `bm25` named sparse vector slot.
# Pre-v3 collections lack it; writing a `bm25` sparse vector into such a
# collection is rejected by Qdrant ("Not existing vector name error: bm25").
self._has_bm25_slot = False
self.create_col(embedding_model_dims, on_disk)
def _get_bm25_encoder(self):
@@ -127,6 +131,15 @@ class Qdrant(VectorStoreBase):
for collection in response.collections:
if collection.name == self.collection_name:
logger.debug(f"Collection {self.collection_name} already exists. Skipping creation.")
info = self.client.get_collection(self.collection_name)
sparse_cfg = info.config.params.sparse_vectors
self._has_bm25_slot = bool(sparse_cfg and "bm25" in sparse_cfg)
if not self._has_bm25_slot:
logger.warning(
f"Collection '{self.collection_name}' predates v3 hybrid search (no 'bm25' sparse slot). "
"BM25 keyword scoring will be disabled for this collection; semantic search works normally. "
"To enable hybrid search, use a fresh collection."
)
self._create_filter_indexes()
return
@@ -139,6 +152,7 @@ class Qdrant(VectorStoreBase):
),
},
)
self._has_bm25_slot = True
self._create_filter_indexes()
def _create_filter_indexes(self):
@@ -177,13 +191,14 @@ class Qdrant(VectorStoreBase):
payload = payloads[idx] if payloads else {}
point_id = idx if ids is None else ids[idx]
# Build named vectors: dense + optional BM25 sparse
# Build named vectors: dense + optional BM25 sparse (only if collection has the slot).
named_vectors = {"": vector}
text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "")
if text_for_bm25:
sparse = self._encode_bm25(text_for_bm25)
if sparse is not None:
named_vectors["bm25"] = sparse
if self._has_bm25_slot:
text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "")
if text_for_bm25:
sparse = self._encode_bm25(text_for_bm25)
if sparse is not None:
named_vectors["bm25"] = sparse
points.append(PointStruct(id=point_id, vector=named_vectors, payload=payload))
@@ -382,7 +397,7 @@ class Qdrant(VectorStoreBase):
"""Batch search using Qdrant's query_batch_points for efficiency."""
query_filter = self._create_filter(filters) if filters else None
requests = [
models.QueryRequest(query=vec, filter=query_filter, limit=top_k)
models.QueryRequest(query=vec, filter=query_filter, limit=top_k, with_payload=True)
for vec in vectors_list
]
try:
@@ -407,6 +422,8 @@ class Qdrant(VectorStoreBase):
Returns:
list: Search results, or None if BM25 is not available.
"""
if not self._has_bm25_slot:
return None
sparse_query = self._encode_bm25(query)
if sparse_query is None:
return None
@@ -449,13 +466,14 @@ class Qdrant(VectorStoreBase):
payload (dict, optional): Updated payload. Defaults to None.
"""
if vector is not None and payload is not None:
# Full update: attach BM25 sparse vector alongside dense vector
# Full update: attach BM25 sparse vector alongside dense vector (only if slot exists).
named_vectors = {"": vector}
text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "")
if text_for_bm25:
sparse = self._encode_bm25(text_for_bm25)
if sparse is not None:
named_vectors["bm25"] = sparse
if self._has_bm25_slot:
text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "")
if text_for_bm25:
sparse = self._encode_bm25(text_for_bm25)
if sparse is not None:
named_vectors["bm25"] = sparse
point = PointStruct(id=vector_id, vector=named_vectors, payload=payload)
self.client.upsert(collection_name=self.collection_name, points=[point])
else:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "2.0.0b1"
version = "2.0.0b2"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "support@mem0.ai" }
+2 -2
View File
@@ -286,8 +286,8 @@ def test_init_with_env_vars(monkeypatch):
http_client=None,
default_headers=None,
)
# Should default to "gpt-4.1-nano-2025-04-14" if model is None
assert llm.config.model == "gpt-4.1-nano-2025-04-14"
# Should default to "gpt-5-mini" if model is None
assert llm.config.model == "gpt-5-mini"
def test_init_with_default_azure_credential(monkeypatch):
+2 -2
View File
@@ -57,7 +57,7 @@ def test_init_with_default_credential(mock_credential, mock_token_provider, mock
mock_token_provider.return_value = "token-provider"
llm = AzureOpenAIStructuredLLM(config)
# Should set default model if not provided
assert llm.config.model == "gpt-4.1-nano-2025-04-14"
assert llm.config.model == "gpt-5-mini"
mock_credential.assert_called_once()
mock_token_provider.assert_called_once_with(mock_credential.return_value, SCOPE)
mock_azure_openai.assert_called_once()
@@ -92,7 +92,7 @@ def test_init_with_placeholder_api_key_uses_default_credential(
config = DummyConfig(model=None, azure_kwargs=DummyAzureKwargs(api_key="your-api-key"))
mock_token_provider.return_value = "token-provider"
llm = AzureOpenAIStructuredLLM(config)
assert llm.config.model == "gpt-4.1-nano-2025-04-14"
assert llm.config.model == "gpt-5-mini"
mock_credential.assert_called_once()
mock_token_provider.assert_called_once_with(mock_credential.return_value, SCOPE)
mock_azure_openai.assert_called_once()
+110 -4
View File
@@ -110,11 +110,10 @@ def test_search(memory_instance):
assert result["results"][0]["user_id"] == "test_user"
# Score is now combined score (semantic only since no BM25/entity), still 0.9
assert result["results"][0]["score"] == pytest.approx(0.9)
assert "score_breakdown" in result["results"][0]
# Hybrid pipeline over-fetches: max(100*4, 60) = 400
# Hybrid pipeline over-fetches: max(20*4, 60) = 80 (top_k default is now 20)
memory_instance.vector_store.search.assert_called_once_with(
query="test query", vectors=[0.1, 0.2, 0.3], top_k=400, filters={"user_id": "test_user"}
query="test query", vectors=[0.1, 0.2, 0.3], top_k=80, filters={"user_id": "test_user"}
)
@@ -201,7 +200,7 @@ def test_get_all(memory_instance):
assert result["results"][0]["memory"] == "Memory 1"
assert result["results"][0]["user_id"] == "test_user"
memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "test_user"}, top_k=100)
memory_instance.vector_store.list.assert_called_once_with(filters={"user_id": "test_user"}, top_k=20)
def test_no_telemetry_vector_store_when_disabled():
@@ -242,3 +241,110 @@ def test_telemetry_vector_store_created_when_enabled():
# VectorStoreFactory.create should be called twice — user data + telemetry
assert mock_vector_store.create.call_count == 2
# =============================================================================
# Input Validation Tests
# =============================================================================
class TestEntityIdValidation:
"""Tests for entity ID validation (whitespace rejection and trimming)."""
def test_search_rejects_whitespace_only_user_id(self, memory_instance):
"""Search should reject whitespace-only user_id in filters."""
with pytest.raises(ValueError, match="Invalid user_id.*cannot be empty"):
memory_instance.search("test query", filters={"user_id": " "})
def test_search_rejects_internal_whitespace_user_id(self, memory_instance):
"""Search should reject user_id with internal whitespace."""
with pytest.raises(ValueError, match="Invalid user_id.*cannot contain whitespace"):
memory_instance.search("test query", filters={"user_id": "user 123"})
def test_search_rejects_tab_in_user_id(self, memory_instance):
"""Search should reject user_id with tab character."""
with pytest.raises(ValueError, match="Invalid user_id.*cannot contain whitespace"):
memory_instance.search("test query", filters={"user_id": "user\t123"})
def test_get_all_rejects_whitespace_only_user_id(self, memory_instance):
"""get_all should reject whitespace-only user_id in filters."""
with pytest.raises(ValueError, match="Invalid user_id.*cannot be empty"):
memory_instance.get_all(filters={"user_id": " "})
def test_get_all_rejects_internal_whitespace_user_id(self, memory_instance):
"""get_all should reject user_id with internal whitespace."""
with pytest.raises(ValueError, match="Invalid user_id.*cannot contain whitespace"):
memory_instance.get_all(filters={"user_id": "user 123"})
def test_add_rejects_whitespace_only_user_id(self, memory_instance):
"""add should reject whitespace-only user_id."""
with pytest.raises(ValueError, match="Invalid user_id.*cannot be empty"):
memory_instance.add("test message", user_id=" ")
def test_add_rejects_internal_whitespace_user_id(self, memory_instance):
"""add should reject user_id with internal whitespace."""
with pytest.raises(ValueError, match="Invalid user_id.*cannot contain whitespace"):
memory_instance.add("test message", user_id="user 123")
class TestSearchParamValidation:
"""Tests for search parameter validation (threshold and top_k)."""
def test_search_rejects_threshold_above_1(self, memory_instance):
"""Search should reject threshold > 1."""
with pytest.raises(ValueError, match="Invalid threshold.*Must be between 0 and 1"):
memory_instance.search("test query", filters={"user_id": "test"}, threshold=1.5)
def test_search_rejects_negative_threshold(self, memory_instance):
"""Search should reject negative threshold."""
with pytest.raises(ValueError, match="Invalid threshold.*Must be between 0 and 1"):
memory_instance.search("test query", filters={"user_id": "test"}, threshold=-0.5)
def test_search_rejects_negative_top_k(self, memory_instance):
"""Search should reject negative top_k."""
with pytest.raises(ValueError, match="Invalid top_k.*Must be a non-negative"):
memory_instance.search("test query", filters={"user_id": "test"}, top_k=-5)
def test_get_all_rejects_negative_top_k(self, memory_instance):
"""get_all should reject negative top_k."""
with pytest.raises(ValueError, match="Invalid top_k.*Must be a non-negative"):
memory_instance.get_all(filters={"user_id": "test"}, top_k=-1)
def test_search_accepts_threshold_zero(self, memory_instance):
"""Search should accept threshold=0 (edge case)."""
mock_memories = []
memory_instance.vector_store.search = Mock(return_value=mock_memories)
memory_instance.vector_store.keyword_search = Mock(return_value=None)
memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3])
with patch("mem0.memory.main.lemmatize_for_bm25", return_value="test"), \
patch("mem0.memory.main.extract_entities", return_value=[]):
result = memory_instance.search("test", filters={"user_id": "test"}, threshold=0)
assert "results" in result
def test_search_accepts_threshold_one(self, memory_instance):
"""Search should accept threshold=1.0 (edge case)."""
mock_memories = []
memory_instance.vector_store.search = Mock(return_value=mock_memories)
memory_instance.vector_store.keyword_search = Mock(return_value=None)
memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3])
with patch("mem0.memory.main.lemmatize_for_bm25", return_value="test"), \
patch("mem0.memory.main.extract_entities", return_value=[]):
result = memory_instance.search("test", filters={"user_id": "test"}, threshold=1.0)
assert "results" in result
def test_search_accepts_top_k_zero(self, memory_instance):
"""Search should accept top_k=0."""
mock_memories = []
memory_instance.vector_store.search = Mock(return_value=mock_memories)
memory_instance.vector_store.keyword_search = Mock(return_value=None)
memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3])
with patch("mem0.memory.main.lemmatize_for_bm25", return_value="test"), \
patch("mem0.memory.main.extract_entities", return_value=[]):
result = memory_instance.search("test", filters={"user_id": "test"}, top_k=0)
assert "results" in result
+60
View File
@@ -359,3 +359,63 @@ class TestTelemetryEnvVar:
def test_env_var_parsing(self, value, expected):
result = value.lower() in ("true", "1", "yes")
assert result == expected
class TestTelemetryNullUserIdHandling:
"""Verify telemetry doesn't crash when user_id is None.
This is a regression test for the bug where Memory.from_config() crashed
with AssertionError because distinct_id was None in PostHog.capture().
"""
def test_capture_event_skips_when_user_id_is_none(self):
"""AnonymousTelemetry.capture_event should not crash when user_id is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
at = telemetry_module.AnonymousTelemetry()
at.user_id = None # Simulate the bug condition
# This should not raise, even with user_id=None
at.capture_event("test.event", {"key": "value"})
# PostHog.capture should NOT have been called since user_id is None
mock_posthog_cls.return_value.capture.assert_not_called()
def test_capture_event_does_not_crash_on_posthog_error(self):
"""AnonymousTelemetry.capture_event should catch PostHog exceptions."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
at = telemetry_module.AnonymousTelemetry()
mock_posthog_cls.return_value.capture.side_effect = Exception("PostHog error")
# This should not raise, even when PostHog.capture fails
at.capture_event("test.event", {"key": "value"})
def test_oss_capture_event_does_not_crash_memory_init(self):
"""capture_event() should never raise, even if everything inside fails."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
# Make _get_oss_telemetry return a broken telemetry object
mock_at = MagicMock()
mock_at.capture_event.side_effect = Exception("Telemetry is broken")
with patch.object(telemetry_module, "_oss_telemetry_instance", mock_at):
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
# This should not raise, even when telemetry fails
telemetry_module.capture_event("mem0.init", mock_memory)
def test_client_capture_event_does_not_crash(self):
"""capture_client_event() should never raise exceptions."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
mock_client_telemetry = MagicMock()
mock_client_telemetry.capture_event.side_effect = Exception("Client telemetry broken")
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
mock_instance = MagicMock()
mock_instance.user_email = "test@example.com"
# This should not raise
telemetry_module.capture_client_event("test.event", mock_instance)
-10
View File
@@ -101,16 +101,6 @@ class TestScoreAndRank:
scored = score_and_rank(results, {}, {}, threshold=0.1, top_k=5)
assert len(scored) == 5
def test_score_breakdown_present(self):
results = [{"id": "a", "score": 0.8, "payload": {"data": "x"}}]
bm25 = {"a": 0.4}
entity = {"a": 0.2}
scored = score_and_rank(results, bm25, entity, threshold=0.1, top_k=10)
breakdown = scored[0]["score_breakdown"]
assert breakdown["semantic"] == 0.8
assert breakdown["bm25"] == 0.4
assert breakdown["entity_boost"] == 0.2
def test_adaptive_divisor_semantic_only(self):
results = [{"id": "a", "score": 0.8, "payload": {}}]
scored = score_and_rank(results, {}, {}, threshold=0.1, top_k=10)