From 4d949022f25c2f985f7a2156733f8914f68c4909 Mon Sep 17 00:00:00 2001 From: Hrushikesh Yadav <136978914+HrushiYadav@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:29:44 +0530 Subject: [PATCH] fix: preserve custom metadata fields during memory update (#5480) --- mem0-ts/src/oss/src/memory/index.ts | 11 +- mem0-ts/src/oss/tests/memory.crud.test.ts | 15 +++ mem0/memory/main.py | 29 ++--- tests/test_memory.py | 151 ++++++++++++++++++++++ 4 files changed, 176 insertions(+), 30 deletions(-) diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 456ee18b9..4768c635e 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -1722,20 +1722,13 @@ export class Memory { existingEmbeddings[data] || (await this.embedder.embed(data)); const newMetadata = { + ...existingMemory.payload, ...metadata, data, hash: createHash("md5").update(data).digest("hex"), + textLemmatized: lemmatizeForBm25(data), createdAt: existingMemory.payload.createdAt, updatedAt: new Date().toISOString(), - ...(existingMemory.payload.user_id && { - user_id: existingMemory.payload.user_id, - }), - ...(existingMemory.payload.agent_id && { - agent_id: existingMemory.payload.agent_id, - }), - ...(existingMemory.payload.run_id && { - run_id: existingMemory.payload.run_id, - }), }; await this.vectorStore.update(memoryId, embedding, newMetadata); diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts index 4155d5c94..7fb8d01d4 100644 --- a/mem0-ts/src/oss/tests/memory.crud.test.ts +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -191,6 +191,21 @@ describe("Memory - update()", () => { const after: MemoryItem | null = await memory.get(id); expect(after!.hash).not.toBe(before!.hash); }); + + test("preserves custom metadata fields after update", async () => { + const addResult: SearchResult = await memory.add("Original text", { + userId, + metadata: { category: "hobbies", priority: "high" }, + infer: false, + }); + const id = addResult.results[0].id; + await memory.update(id, "Updated text"); + const after: MemoryItem | null = await memory.get(id); + expect(after!.memory).toBe("Updated text"); + expect(after!.metadata).toEqual( + expect.objectContaining({ category: "hobbies", priority: "high" }), + ); + }); }); // ─── delete() ──────────────────────────────────────────── diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 1fe390bd1..f3dc9051d 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -1839,7 +1839,9 @@ class Memory(MemoryBase): prev_value = existing_memory.payload.get("data") - new_metadata = deepcopy(metadata) if metadata is not None else {} + new_metadata = deepcopy(existing_memory.payload) + if metadata is not None: + new_metadata.update(metadata) new_metadata["data"] = data new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest() @@ -1847,17 +1849,9 @@ class Memory(MemoryBase): new_metadata["created_at"] = existing_memory.payload.get("created_at") new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() - # Preserve session identifiers from existing memory only if not provided in new metadata - if "user_id" not in new_metadata and "user_id" in existing_memory.payload: - new_metadata["user_id"] = existing_memory.payload["user_id"] - if "agent_id" not in new_metadata and "agent_id" in existing_memory.payload: - new_metadata["agent_id"] = existing_memory.payload["agent_id"] - if "run_id" not in new_metadata and "run_id" in existing_memory.payload: - new_metadata["run_id"] = existing_memory.payload["run_id"] + # actor_id is immutable after creation (issue #4490) if "actor_id" in existing_memory.payload: new_metadata["actor_id"] = existing_memory.payload["actor_id"] - if "role" not in new_metadata and "role" in existing_memory.payload: - new_metadata["role"] = existing_memory.payload["role"] if data in existing_embeddings: embeddings = existing_embeddings[data] @@ -3371,7 +3365,9 @@ class AsyncMemory(MemoryBase): prev_value = existing_memory.payload.get("data") - new_metadata = deepcopy(metadata) if metadata is not None else {} + new_metadata = deepcopy(existing_memory.payload) + if metadata is not None: + new_metadata.update(metadata) new_metadata["data"] = data new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest() @@ -3379,18 +3375,9 @@ class AsyncMemory(MemoryBase): new_metadata["created_at"] = existing_memory.payload.get("created_at") new_metadata["updated_at"] = datetime.now(timezone.utc).isoformat() - # Preserve session identifiers from existing memory only if not provided in new metadata - if "user_id" not in new_metadata and "user_id" in existing_memory.payload: - new_metadata["user_id"] = existing_memory.payload["user_id"] - if "agent_id" not in new_metadata and "agent_id" in existing_memory.payload: - new_metadata["agent_id"] = existing_memory.payload["agent_id"] - if "run_id" not in new_metadata and "run_id" in existing_memory.payload: - new_metadata["run_id"] = existing_memory.payload["run_id"] - + # actor_id is immutable after creation (issue #4490) if "actor_id" in existing_memory.payload: new_metadata["actor_id"] = existing_memory.payload["actor_id"] - if "role" not in new_metadata and "role" in existing_memory.payload: - new_metadata["role"] = existing_memory.payload["role"] if data in existing_embeddings: embeddings = existing_embeddings[data] diff --git a/tests/test_memory.py b/tests/test_memory.py index 371dd71ae..423425332 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -1056,3 +1056,154 @@ class TestHybridSearchWarning: Memory(config) assert not any("does not support keyword search" in r.message for r in caplog.records) + + +class TestPreserveCustomMetadata: + + @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_update_preserves_custom_metadata(self, mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): + mock_embedder_factory.return_value = MagicMock() + mock_vector_store = MagicMock() + mock_vector_factory.return_value = mock_vector_store + mock_llm_factory.return_value = MagicMock() + mock_sqlite.return_value = MagicMock() + + existing_payload = { + "data": "I love playing tennis", + "hash": "abc123", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + "user_id": "user_1", + "category": "hobbies", + "priority": "high", + "source": "chat", + } + mock_vector_store.get.return_value = MockVectorMemory("mem-1", existing_payload) + + embedder = MagicMock() + embedder.embed.return_value = [0.1, 0.2, 0.3] + mock_embedder_factory.return_value = embedder + + config = MemoryConfig() + memory = Memory(config) + + memory._update_memory("mem-1", "I love playing tennis and swimming", {"I love playing tennis and swimming": [0.1, 0.2, 0.3]}) + + call_args = mock_vector_store.update.call_args + payload = call_args.kwargs.get("payload") or call_args[1].get("payload") + assert payload["category"] == "hobbies" + assert payload["priority"] == "high" + assert payload["source"] == "chat" + assert payload["data"] == "I love playing tennis and swimming" + assert payload["user_id"] == "user_1" + + @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_update_with_new_metadata_overrides_existing(self, mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): + mock_embedder_factory.return_value = MagicMock() + mock_vector_store = MagicMock() + mock_vector_factory.return_value = mock_vector_store + mock_llm_factory.return_value = MagicMock() + mock_sqlite.return_value = MagicMock() + + existing_payload = { + "data": "I love playing tennis", + "hash": "abc123", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + "user_id": "user_1", + "category": "hobbies", + "priority": "high", + } + mock_vector_store.get.return_value = MockVectorMemory("mem-1", existing_payload) + + config = MemoryConfig() + memory = Memory(config) + + memory._update_memory( + "mem-1", "Updated text", + {"Updated text": [0.1, 0.2, 0.3]}, + metadata={"priority": "low", "new_field": "value"}, + ) + + call_args = mock_vector_store.update.call_args + payload = call_args.kwargs.get("payload") or call_args[1].get("payload") + assert payload["category"] == "hobbies" + assert payload["priority"] == "low" + assert payload["new_field"] == "value" + assert payload["data"] == "Updated text" + + @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_update_preserves_actor_id_from_original(self, mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): + mock_embedder_factory.return_value = MagicMock() + mock_vector_store = MagicMock() + mock_vector_factory.return_value = mock_vector_store + mock_llm_factory.return_value = MagicMock() + mock_sqlite.return_value = MagicMock() + + existing_payload = { + "data": "I am player #1", + "hash": "abc123", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + "user_id": "team", + "actor_id": "Alice", + } + mock_vector_store.get.return_value = MockVectorMemory("mem-1", existing_payload) + + config = MemoryConfig() + memory = Memory(config) + + memory._update_memory( + "mem-1", "Player #1 is great", + {"Player #1 is great": [0.1, 0.2, 0.3]}, + metadata={"user_id": "team", "actor_id": "Bob"}, + ) + + call_args = mock_vector_store.update.call_args + payload = call_args.kwargs.get("payload") or call_args[1].get("payload") + assert payload["actor_id"] == "Alice" + + @pytest.mark.asyncio + @patch('mem0.utils.factory.EmbedderFactory.create') + @patch('mem0.utils.factory.VectorStoreFactory.create') + @patch('mem0.utils.factory.LlmFactory.create') + @patch('mem0.memory.storage.SQLiteManager') + async def test_async_update_preserves_custom_metadata(self, mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): + mock_embedder_factory.return_value = MagicMock() + mock_vector_store = MagicMock() + mock_vector_factory.return_value = mock_vector_store + mock_llm_factory.return_value = MagicMock() + mock_sqlite.return_value = MagicMock() + + existing_payload = { + "data": "I love playing tennis", + "hash": "abc123", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + "user_id": "user_1", + "category": "hobbies", + "source": "chat", + } + mock_vector_store.get.return_value = MockVectorMemory("mem-1", existing_payload) + + from mem0.memory.main import AsyncMemory + config = MemoryConfig() + memory = AsyncMemory(config) + + await memory._update_memory("mem-1", "I love swimming", {"I love swimming": [0.1, 0.2, 0.3]}) + + call_args = mock_vector_store.update.call_args + payload = call_args.kwargs.get("payload") or call_args[1].get("payload") + assert payload["category"] == "hobbies" + assert payload["source"] == "chat" + assert payload["data"] == "I love swimming" + assert payload["user_id"] == "user_1"