fix: preserve custom metadata fields during memory update (#5480)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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() ────────────────────────────────────────────
|
||||
|
||||
+8
-21
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user