Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]anddeshraj eac9b473a9 feat: per-agent memory isolation for openclaw-mem0 plugin
Co-authored-by: deshraj <2945708+deshraj@users.noreply.github.com>
2026-02-23 01:39:50 +00:00
copilot-swe-agent[bot] ce05224bad Initial plan 2026-02-23 01:30:44 +00:00
17 changed files with 99 additions and 380 deletions
-5
View File
@@ -7,11 +7,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-03-03" description="v1.0.5">
- **Telemetry Fix**
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
</Update>
<Update label="2026-02-17" description="v1.0.4">
**New Features & Updates:**
@@ -107,12 +107,6 @@ client = MemoryClient(api_key="your-api-key")
# Delete all memories for a specific user
client.delete_all(user_id="alice")
# Delete all memories for a specific agent
client.delete_all(agent_id="support-bot")
# Delete all memories for a specific run
client.delete_all(run_id="session-xyz")
```
```javascript JavaScript
@@ -133,48 +127,7 @@ You can also filter by other parameters such as:
- `metadata` (as JSON string)
<Warning>
**Breaking change:** `delete_all` previously wiped all project memories when called with no filters. It now **raises an error** if no filters are provided. Use `"*"` wildcards for intentional bulk deletion (see below).
</Warning>
### Wildcard deletes
Setting a filter to `"*"` deletes **all memories** for that entity type across the entire project. This is an intentionally explicit opt-in to bulk deletion.
<CodeGroup>
```python Python
from mem0 import MemoryClient
client = MemoryClient(api_key="your-api-key")
# Delete all memories across every user in the project
client.delete_all(user_id="*")
# Delete all memories across every agent in the project
client.delete_all(agent_id="*")
# Full project wipe — all four filters must be explicitly set to "*"
client.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*")
```
```javascript JavaScript
import MemoryClient from 'mem0ai';
const client = new MemoryClient({ apiKey: "your-api-key" });
// Delete all memories across every user in the project
client.deleteAll({ user_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
// Full project wipe — all four filters must be explicitly set to "*"
client.deleteAll({ user_id: "*", agent_id: "*", app_id: "*", run_id: "*" })
.then(result => console.log(result))
.catch(error => console.error(error));
```
</CodeGroup>
<Warning>
A full project wipe requires **all four** filters set to `"*"`. Setting only some to `"*"` deletes memories only for those entity types, not the entire project.
`delete_all` requires at least one filter (user, agent, run, or metadata). Calling it with no filters raises an error to prevent accidental data loss.
</Warning>
## Delete with Mem0 OSS
+6 -21
View File
@@ -255,28 +255,13 @@ def delete(
### delete_all() Method
#### Breaking Change — Empty filter no longer silently deletes everything
**Before:** calling `delete_all()` with no filters silently deleted **all memories in the project**.
**After:**
- No filters → raises a validation error (prevents accidental full-project wipe).
- Concrete ID (e.g. `user_id="alice"`) → deletes memories for that entity (unchanged).
- `"*"` for a filter → deletes all memories for that entity type across the project (new).
- All four filters set to `"*"` → explicit full project wipe (new, requires opt-in on every parameter).
This change replaces the silent full-project delete (triggered by an empty or missing filter) with a validation error, and introduces `"*"` wildcards as the intentional path for bulk deletion.
#### No Breaking Changes
```python
# v0.x — no filter silently wiped all project memories
m.delete_all() # DANGER: deleted everything
m.delete_all(user_id="alice") # deleted alice's memories
# v1.x — no filter now raises an error; use "*" for intentional bulk deletes
m.delete_all() # ERROR: at least one filter required
m.delete_all(user_id="alice") # unchanged
m.delete_all(user_id="*") # NEW — delete all users' memories
m.delete_all(user_id="*", agent_id="*", app_id="*", run_id="*") # NEW — full project wipe
# Same signature in both versions
def delete_all(
self,
user_id: str
) -> dict
```
## Platform Client (MemoryClient) Changes
+13 -13
View File
@@ -1180,7 +1180,7 @@
"tags": [
"memories"
],
"description": "Delete memories by filter. At least one filter is required — previously omitting all filters silently deleted everything; now it returns a validation error.",
"description": "Delete memories.",
"operationId": "memories_delete",
"parameters": [
{
@@ -1189,7 +1189,7 @@
"schema": {
"type": "string"
},
"description": "Filter by user ID. Pass `*` to delete memories for all users."
"description": "Filter memories by user ID."
},
{
"name": "agent_id",
@@ -1197,7 +1197,7 @@
"schema": {
"type": "string"
},
"description": "Filter by agent ID. Pass `*` to delete memories for all agents."
"description": "Filter memories by agent ID."
},
{
"name": "app_id",
@@ -1205,7 +1205,7 @@
"schema": {
"type": "string"
},
"description": "Filter by app ID. Pass `*` to delete memories for all apps."
"description": "Filter memories by app ID."
},
{
"name": "run_id",
@@ -1213,7 +1213,7 @@
"schema": {
"type": "string"
},
"description": "Filter by run ID. Pass `*` to delete memories for all runs."
"description": "Filter memories by run ID."
},
{
"name": "metadata",
@@ -1263,15 +1263,15 @@
"x-code-samples": [
{
"lang": "Python",
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")\n\n# Delete all memories for every user in the project (wildcard)\nclient.delete_all(user_id=\"*\")\n\n# Full project wipe — all four filters must be explicitly set to \"*\"\nclient.delete_all(user_id=\"*\", agent_id=\"*\", app_id=\"*\", run_id=\"*\")\n\n# NOTE: Calling delete_all() with no filters raises a validation error.\n# At least one filter is required to prevent accidental data loss."
"source": "# To use the Python SDK, install the package:\n# pip install mem0ai\n\nfrom mem0 import MemoryClient\nclient = MemoryClient(api_key=\"your_api_key\", org_id=\"your_org_id\", project_id=\"your_project_id\")\n\n# Delete all memories for a specific user\nclient.delete_all(user_id=\"<user_id>\")"
},
{
"lang": "JavaScript",
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Delete all memories for every user in the project (wildcard)\nclient.deleteAll({ user_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));\n\n// Full project wipe — all four filters must be explicitly set to \"*\"\nclient.deleteAll({ user_id: \"*\", agent_id: \"*\", app_id: \"*\", run_id: \"*\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
"source": "// To use the JavaScript SDK, install the package:\n// npm i mem0ai\n\nimport MemoryClient from 'mem0ai';\nconst client = new MemoryClient({ apiKey: \"your-api-key\" });\n\n// Delete all memories for a specific user\nclient.deleteAll({ user_id: \"<user_id>\" })\n .then(result => console.log(result))\n .catch(error => console.error(error));"
},
{
"lang": "cURL",
"source": "# Delete memories for a specific user\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=<user_id>' \\\n --header 'Authorization: Token <api-key>'\n\n# Delete memories for all users (wildcard)\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*' \\\n --header 'Authorization: Token <api-key>'\n\n# Full project wipe — all four filters must be set to *\ncurl --request DELETE \\\n --url 'https://api.mem0.ai/v1/memories/?user_id=*&agent_id=*&app_id=*&run_id=*' \\\n --header 'Authorization: Token <api-key>'"
"source": "curl --request DELETE \\\n --url https://api.mem0.ai/v1/memories/ \\\n --header 'Authorization: Token <api-key>'"
},
{
"lang": "Go",
@@ -5570,26 +5570,26 @@
},
"DeleteMemoriesInput": {
"type": "object",
"description": "Filters for bulk memory deletion. At least one field is required. Pass \"*\" for a field to delete all memories for that entity type. Set all four to \"*\" for a full project wipe.",
"description": "Input for deleting memories associated with a specific user, agent, app, or run.",
"properties": {
"user_id": {
"type": "string",
"description": "User ID to delete memories for. Pass \"*\" for all users.",
"description": "The unique identifier of the user whose memories should be deleted.",
"nullable": true
},
"agent_id": {
"type": "string",
"description": "Agent ID to delete memories for. Pass \"*\" for all agents.",
"description": "The unique identifier of the agent whose memories should be deleted.",
"nullable": true
},
"app_id": {
"type": "string",
"description": "App ID to delete memories for. Pass \"*\" for all apps.",
"description": "The unique identifier of the application whose memories should be deleted.",
"nullable": true
},
"run_id": {
"type": "string",
"description": "Run ID to delete memories for. Pass \"*\" for all runs.",
"description": "The unique identifier of the run whose memories should be deleted.",
"nullable": true
}
},
-4
View File
@@ -123,10 +123,6 @@ await client.deleteAll({ user_id: "alice" });
</CodeGroup>
<Note>
At least one filter (`user_id`, `agent_id`, `app_id`, or `run_id`) is required — calling `delete_all` with no filters raises an error to prevent accidental data loss. You can pass `"*"` as a value to delete all memories for a given entity type (e.g., `user_id="*"` removes memories for every user). A full project wipe requires all four filters set to `"*"`.
</Note>
### History
Get the history of a specific memory asynchronously.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.2.4",
"version": "2.2.3",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -91,7 +91,7 @@
"typescript": "5.5.4"
},
"dependencies": {
"axios": "1.13.6",
"axios": "1.7.7",
"openai": "^4.93.0",
"uuid": "9.0.1",
"zod": "^3.24.1"
+10 -25
View File
@@ -44,8 +44,8 @@ importers:
specifier: 3.1.11
version: 3.1.11
axios:
specifier: 1.13.6
version: 1.13.6
specifier: 1.7.7
version: 1.7.7
cloudflare:
specifier: ^4.2.0
version: 4.3.0(encoding@0.1.13)
@@ -1621,10 +1621,10 @@ packages:
integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==,
}
axios@1.13.6:
axios@1.7.7:
resolution:
{
integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==,
integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==,
}
babel-jest@29.7.0:
@@ -2560,10 +2560,10 @@ packages:
}
hasBin: true
follow-redirects@1.15.11:
follow-redirects@1.15.9:
resolution:
{
integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==,
integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==,
}
engines: { node: ">=4.0" }
peerDependencies:
@@ -2592,13 +2592,6 @@ packages:
}
engines: { node: ">= 6" }
form-data@4.0.5:
resolution:
{
integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==,
}
engines: { node: ">= 6" }
formdata-node@4.4.1:
resolution:
{
@@ -6573,10 +6566,10 @@ snapshots:
asynckit@0.4.0: {}
axios@1.13.6:
axios@1.7.7:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
follow-redirects: 1.15.9
form-data: 4.0.2
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
@@ -7177,7 +7170,7 @@ snapshots:
kolorist: 1.8.0
read-pkg: 8.1.0
follow-redirects@1.15.11: {}
follow-redirects@1.15.9: {}
foreground-child@3.3.1:
dependencies:
@@ -7193,14 +7186,6 @@ snapshots:
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
+1 -4
View File
@@ -27,12 +27,9 @@ export class OllamaEmbedder implements Embedder {
} catch (err) {
logger.error(`Error ensuring model exists: ${err}`);
}
// Ollama's Go server requires prompt to be a string. Coerce defensively
// since callers may pass values parsed from untrusted LLM JSON output.
const prompt = typeof text === "string" ? text : JSON.stringify(text);
const response = await this.ollama.embeddings({
model: this.model,
prompt,
prompt: text,
});
return response.embedding;
}
+1 -3
View File
@@ -15,7 +15,6 @@ import {
HistoryManagerFactory,
} from "../utils/factory";
import {
FactRetrievalSchema,
getFactRetrievalMessages,
getUpdateMemoryMessages,
parseMessages,
@@ -262,8 +261,7 @@ export class Memory {
const cleanResponse = removeCodeBlocks(response as string);
let facts: string[] = [];
try {
const parsed = FactRetrievalSchema.parse(JSON.parse(cleanResponse));
facts = parsed.facts;
facts = JSON.parse(cleanResponse).facts || [];
} catch (e) {
console.error(
"Failed to parse facts from LLM response:",
+1 -10
View File
@@ -1,18 +1,9 @@
import { z } from "zod";
// Accepts a string directly, or an object with a "fact" or "text" key
// (common malformed shapes from smaller LLMs like llama3.1:8b).
const factItem = z.union([
z.string(),
z.object({ fact: z.string() }).transform((o) => o.fact),
z.object({ text: z.string() }).transform((o) => o.text),
]);
// Define Zod schema for fact retrieval output
export const FactRetrievalSchema = z.object({
facts: z
.array(factItem)
.transform((arr) => arr.filter((s) => s.length > 0))
.array(z.string())
.describe("An array of distinct facts extracted from the conversation."),
});
+1 -4
View File
@@ -28,7 +28,6 @@ from mem0.memory.telemetry import capture_event
from mem0.memory.utils import (
extract_json,
get_fact_retrieval_messages,
normalize_facts,
parse_messages,
parse_vision_messages,
process_telemetry_filters,
@@ -38,8 +37,8 @@ from mem0.utils.factory import (
EmbedderFactory,
GraphStoreFactory,
LlmFactory,
RerankerFactory,
VectorStoreFactory,
RerankerFactory,
)
# Suppress SWIG deprecation warnings globally
@@ -452,7 +451,6 @@ class Memory(MemoryBase):
# Try extracting JSON from response using built-in function
extracted_json = extract_json(response)
new_retrieved_facts = json.loads(extracted_json)["facts"]
new_retrieved_facts = normalize_facts(new_retrieved_facts)
except Exception as e:
logger.error(f"Error in new_retrieved_facts: {e}")
new_retrieved_facts = []
@@ -1479,7 +1477,6 @@ class AsyncMemory(MemoryBase):
# Try extracting JSON from response using built-in function
extracted_json = extract_json(response)
new_retrieved_facts = json.loads(extracted_json)["facts"]
new_retrieved_facts = normalize_facts(new_retrieved_facts)
except Exception as e:
logger.error(f"Error in new_retrieved_facts: {e}")
new_retrieved_facts = []
+5 -16
View File
@@ -24,18 +24,14 @@ logging.getLogger("urllib3").setLevel(logging.CRITICAL + 1)
class AnonymousTelemetry:
def __init__(self, vector_store=None):
if not MEM0_TELEMETRY:
self.posthog = None
self.user_id = None
return
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
self.user_id = get_or_create_user_id(vector_store)
def capture_event(self, event_name, properties=None, user_email=None):
if self.posthog is None:
return
if not MEM0_TELEMETRY:
self.posthog.disabled = True
def capture_event(self, event_name, properties=None, user_email=None):
if properties is None:
properties = {}
properties = {
@@ -53,17 +49,13 @@ class AnonymousTelemetry:
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
def close(self):
if self.posthog is not None:
self.posthog.shutdown()
self.posthog.shutdown()
client_telemetry = AnonymousTelemetry()
def capture_event(event_name, memory_instance, additional_data=None):
if not MEM0_TELEMETRY:
return
oss_telemetry = AnonymousTelemetry(
vector_store=memory_instance._telemetry_vector_store
if hasattr(memory_instance, "_telemetry_vector_store")
@@ -89,9 +81,6 @@ def capture_event(event_name, memory_instance, additional_data=None):
def capture_client_event(event_name, instance, additional_data=None):
if not MEM0_TELEMETRY:
return
event_data = {
"function": f"{instance.__class__.__module__}.{instance.__class__.__name__}",
}
+1 -28
View File
@@ -1,15 +1,12 @@
import hashlib
import logging
import re
from mem0.configs.prompts import (
AGENT_MEMORY_EXTRACTION_PROMPT,
FACT_RETRIEVAL_PROMPT,
USER_MEMORY_EXTRACTION_PROMPT,
AGENT_MEMORY_EXTRACTION_PROMPT,
)
logger = logging.getLogger(__name__)
def get_fact_retrieval_messages(message, is_agent_memory=False):
"""Get fact retrieval messages based on the memory type.
@@ -55,30 +52,6 @@ def format_entities(entities):
return "\n".join(formatted_lines)
def normalize_facts(raw_facts):
"""Normalize LLM-extracted facts to a list of strings.
Smaller LLMs (e.g. llama3.1:8b) sometimes return facts as objects
like {"fact": "..."} or {"text": "..."} instead of plain strings.
This mirrors the TypeScript FactRetrievalSchema validation.
"""
if not raw_facts:
return []
normalized = []
for item in raw_facts:
if isinstance(item, str):
fact = item
elif isinstance(item, dict):
fact = item.get("fact") or item.get("text")
if fact is None:
logger.warning("Unexpected fact shape from LLM, skipping: %s", item)
continue
else:
fact = str(item)
if fact:
normalized.append(fact)
return normalized
def remove_code_blocks(content: str) -> str:
"""
+30 -16
View File
@@ -268,17 +268,9 @@ class OSSProvider implements Mem0Provider {
if (options.keyword_search != null) opts.keyword_search = options.keyword_search;
if (options.reranking != null) opts.reranking = options.reranking;
if (options.source) opts.source = options.source;
if (options.threshold != null) opts.threshold = options.threshold;
const results = await this.memory.search(query, opts);
const normalized = normalizeSearchResults(results);
// Filter results by threshold if specified (client-side filtering as fallback)
if (options.threshold != null) {
return normalized.filter(item => (item.score ?? 0) >= options.threshold!);
}
return normalized;
return normalizeSearchResults(results);
}
async get(memoryId: string): Promise<MemoryItem> {
@@ -624,6 +616,22 @@ const memoryPlugin = {
// Track current session ID for tool-level session scoping
let currentSessionId: string | undefined;
// Track current agent ID for per-agent memory isolation
let currentAgentId: string | undefined;
// Helper: extract agentId from session key (pattern: agent:<agentId>:...)
// Returns undefined for the "main" agent to preserve default behavior.
function extractAgentId(sessionKey: string): string | undefined {
const match = sessionKey.match(/^agent:([^:]+):/);
return match && match[1] !== "main" ? match[1] : undefined;
}
// Helper: get effective userId for Mem0 operations.
// Precedence: explicit override > current agentId (non-main) > configured userId.
function effectiveUserId(userIdOverride?: string): string {
return userIdOverride || currentAgentId || cfg.userId;
}
api.logger.info(
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
);
@@ -631,7 +639,7 @@ const memoryPlugin = {
// Helper: build add options
function buildAddOptions(userIdOverride?: string, runId?: string): AddOptions {
const opts: AddOptions = {
user_id: userIdOverride || cfg.userId,
user_id: effectiveUserId(userIdOverride),
source: "OPENCLAW",
};
if (runId) opts.run_id = runId;
@@ -651,7 +659,7 @@ const memoryPlugin = {
runId?: string,
): SearchOptions {
const opts: SearchOptions = {
user_id: userIdOverride || cfg.userId,
user_id: effectiveUserId(userIdOverride),
top_k: limit ?? cfg.topK,
limit: limit ?? cfg.topK,
threshold: cfg.searchThreshold,
@@ -943,7 +951,7 @@ const memoryPlugin = {
try {
let memories: MemoryItem[] = [];
const uid = userId || cfg.userId;
const uid = effectiveUserId(userId);
if (scope === "session") {
if (currentSessionId) {
@@ -1244,9 +1252,12 @@ const memoryPlugin = {
api.on("before_agent_start", async (event, ctx) => {
if (!event.prompt || event.prompt.length < 5) return;
// Track session ID
// Track session ID and agent ID
const sessionId = (ctx as any)?.sessionKey ?? undefined;
if (sessionId) currentSessionId = sessionId;
if (sessionId) {
currentSessionId = sessionId;
currentAgentId = extractAgentId(sessionId);
}
try {
// Search long-term memories (user-scoped)
@@ -1311,9 +1322,12 @@ const memoryPlugin = {
return;
}
// Track session ID
// Track session ID and agent ID
const sessionId = (ctx as any)?.sessionKey ?? undefined;
if (sessionId) currentSessionId = sessionId;
if (sessionId) {
currentSessionId = sessionId;
currentAgentId = extractAgentId(sessionId);
}
try {
// Extract messages, limiting to last 10
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "1.0.5"
version = "1.0.4"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "founders@mem0.ai" }
+1 -76
View File
@@ -1,11 +1,9 @@
import json
from unittest.mock import MagicMock, patch
import pytest
from mem0 import Memory
from mem0.configs.base import MemoryConfig
from mem0.memory.utils import normalize_facts
class MockVectorMemory:
@@ -246,77 +244,4 @@ def test_get_all_handles_flat_list_from_postgres(mock_sqlite, mock_llm_factory,
assert len(result) == 2
assert result[0]["memory"] == "Memory 1"
assert result[1]["memory"] == "Memory 2"
@patch('mem0.utils.factory.EmbedderFactory.create')
@patch('mem0.utils.factory.VectorStoreFactory.create')
@patch('mem0.utils.factory.LlmFactory.create')
@patch('mem0.memory.storage.SQLiteManager')
def test_add_infer_with_malformed_llm_facts(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
"""
Repro for: 'list' object has no attribute 'replace' on infer=true.
When an LLM (especially smaller models like llama3.1:8b) returns facts as
objects ({"fact": "..."} or {"text": "..."}) instead of plain strings,
the embedding model's .replace() call crashes with AttributeError.
"""
mock_embedder = MagicMock()
mock_embedder.embed.side_effect = lambda text, action: (_ for _ in ()).throw(
AttributeError("'dict' object has no attribute 'replace'")
) if not isinstance(text, str) else [0.1, 0.2, 0.3]
mock_embedder_factory.return_value = mock_embedder
mock_vector_store = MagicMock()
mock_vector_store.search.return_value = []
mock_vector_factory.return_value = mock_vector_store
# LLM returns malformed facts: dicts instead of strings
malformed_response = json.dumps({
"facts": [
{"fact": "User likes Python"},
{"text": "User is a developer"},
]
})
mock_llm = MagicMock()
mock_llm.generate_response.return_value = malformed_response
mock_llm_factory.return_value = mock_llm
mock_sqlite.return_value = MagicMock()
from mem0.memory.main import Memory as MemoryClass
config = MemoryConfig()
memory = MemoryClass(config)
# This should NOT raise AttributeError
memory._add_to_vector_store(
messages=[{"role": "user", "content": "I like Python and I'm a developer"}],
metadata={"user_id": "test_user"},
filters={"user_id": "test_user"},
infer=True,
)
def test_normalize_facts_plain_strings():
assert normalize_facts(["fact one", "fact two"]) == ["fact one", "fact two"]
def test_normalize_facts_dict_with_fact_key():
assert normalize_facts([{"fact": "User likes Python"}]) == ["User likes Python"]
def test_normalize_facts_dict_with_text_key():
assert normalize_facts([{"text": "User is a developer"}]) == ["User is a developer"]
def test_normalize_facts_mixed():
raw = [
"plain string",
{"fact": "from fact key"},
{"text": "from text key"},
]
assert normalize_facts(raw) == ["plain string", "from fact key", "from text key"]
def test_normalize_facts_filters_empty_strings():
assert normalize_facts(["", "valid", ""]) == ["valid"]
assert result[1]["memory"] == "Memory 2"
+25 -104
View File
@@ -1,114 +1,35 @@
import threading
from unittest.mock import MagicMock, patch
import os
from unittest.mock import patch
import pytest
import mem0.memory.telemetry as telemetry_module
MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True")
if isinstance(MEM0_TELEMETRY, str):
MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes")
class TestTelemetryDisabled:
"""Verify PostHog is never instantiated when telemetry is disabled."""
def test_posthog_not_created_when_disabled(self):
"""Posthog() constructor should never be called when MEM0_TELEMETRY=False."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_not_called()
assert at.posthog is None
assert at.user_id is None
def test_capture_event_noop_when_disabled(self):
"""capture_event() should return immediately without creating AnonymousTelemetry."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
telemetry_module.capture_event("test.event", MagicMock())
mock_cls.assert_not_called()
def test_capture_client_event_noop_when_disabled(self):
"""capture_client_event() should return immediately without calling posthog."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
mock_instance = MagicMock()
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_not_called()
def test_instance_capture_event_noop_when_posthog_is_none(self):
"""AnonymousTelemetry.capture_event() should be a no-op when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.capture_event("test.event", {"key": "value"}) # should not raise
def test_close_noop_when_posthog_is_none(self):
"""close() should not raise when posthog is None."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
at = telemetry_module.AnonymousTelemetry()
at.close() # should not raise
def test_no_threads_spawned_when_disabled(self):
"""No consumer threads should be created when telemetry is disabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", False):
threads_before = threading.active_count()
telemetry_module.AnonymousTelemetry()
threads_after = threading.active_count()
assert threads_after == threads_before
def use_telemetry():
if os.getenv("MEM0_TELEMETRY", "true").lower() == "true":
return True
return False
class TestTelemetryEnabled:
"""Verify PostHog works normally when telemetry is enabled."""
def test_posthog_created_when_enabled(self):
"""Posthog() should be instantiated when MEM0_TELEMETRY=True."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_called_once()
assert at.posthog is not None
assert at.user_id == "test-user"
def test_capture_event_sends_when_enabled(self):
"""capture_event() should create AnonymousTelemetry and call capture when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.AnonymousTelemetry") as mock_cls:
mock_at = MagicMock()
mock_cls.return_value = mock_at
mock_memory = MagicMock()
mock_memory.config.graph_store.config = None
mock_memory.api_version = "v1"
telemetry_module.capture_event("test.event", mock_memory)
mock_at.capture_event.assert_called_once()
def test_capture_client_event_sends_when_enabled(self):
"""capture_client_event() should call client_telemetry.capture_event when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
mock_client_telemetry = MagicMock()
with patch.object(telemetry_module, "client_telemetry", mock_client_telemetry):
mock_instance = MagicMock()
mock_instance.user_email = "test@example.com"
telemetry_module.capture_client_event("test.event", mock_instance)
mock_client_telemetry.capture_event.assert_called_once()
@pytest.fixture(autouse=True)
def reset_env():
with patch.dict(os.environ, {}, clear=True):
yield
class TestTelemetryEnvVar:
"""Verify the MEM0_TELEMETRY env var parsing logic."""
def test_telemetry_enabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "true"}):
assert use_telemetry() is True
@pytest.mark.parametrize(
"value,expected",
[
("true", True),
("True", True),
("TRUE", True),
("1", True),
("yes", True),
("false", False),
("False", False),
("0", False),
("no", False),
("anything_else", False),
],
)
def test_env_var_parsing(self, value, expected):
result = value.lower() in ("true", "1", "yes")
assert result == expected
def test_telemetry_disabled():
with patch.dict(os.environ, {"MEM0_TELEMETRY": "false"}):
assert use_telemetry() is False
def test_telemetry_default_enabled():
assert use_telemetry() is True