fix(memory): set updated_at on creation and preserve pre-existing created_at (#4499)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
utkarsh240799
Claude Opus 4.6
parent
e8004b93db
commit
12624555b4
+8
-2
@@ -1200,7 +1200,9 @@ class Memory(MemoryBase):
|
|||||||
new_metadata = deepcopy(metadata) if metadata is not None else {}
|
new_metadata = deepcopy(metadata) if metadata is not None else {}
|
||||||
new_metadata["data"] = data
|
new_metadata["data"] = data
|
||||||
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||||
new_metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
if "created_at" not in new_metadata:
|
||||||
|
new_metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
new_metadata["updated_at"] = new_metadata["created_at"]
|
||||||
|
|
||||||
self.vector_store.insert(
|
self.vector_store.insert(
|
||||||
vectors=[embeddings],
|
vectors=[embeddings],
|
||||||
@@ -1213,6 +1215,7 @@ class Memory(MemoryBase):
|
|||||||
data,
|
data,
|
||||||
"ADD",
|
"ADD",
|
||||||
created_at=new_metadata.get("created_at"),
|
created_at=new_metadata.get("created_at"),
|
||||||
|
updated_at=new_metadata.get("updated_at"),
|
||||||
actor_id=new_metadata.get("actor_id"),
|
actor_id=new_metadata.get("actor_id"),
|
||||||
role=new_metadata.get("role"),
|
role=new_metadata.get("role"),
|
||||||
)
|
)
|
||||||
@@ -2329,7 +2332,9 @@ class AsyncMemory(MemoryBase):
|
|||||||
new_metadata = deepcopy(metadata) if metadata is not None else {}
|
new_metadata = deepcopy(metadata) if metadata is not None else {}
|
||||||
new_metadata["data"] = data
|
new_metadata["data"] = data
|
||||||
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||||
new_metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
if "created_at" not in new_metadata:
|
||||||
|
new_metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
new_metadata["updated_at"] = new_metadata["created_at"]
|
||||||
|
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self.vector_store.insert,
|
self.vector_store.insert,
|
||||||
@@ -2345,6 +2350,7 @@ class AsyncMemory(MemoryBase):
|
|||||||
data,
|
data,
|
||||||
"ADD",
|
"ADD",
|
||||||
created_at=new_metadata.get("created_at"),
|
created_at=new_metadata.get("created_at"),
|
||||||
|
updated_at=new_metadata.get("updated_at"),
|
||||||
actor_id=new_metadata.get("actor_id"),
|
actor_id=new_metadata.get("actor_id"),
|
||||||
role=new_metadata.get("role"),
|
role=new_metadata.get("role"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -208,6 +208,28 @@ def test_create_memory_uses_utc_timestamps(mocker):
|
|||||||
_assert_utc_timestamp(payload["created_at"])
|
_assert_utc_timestamp(payload["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_sets_updated_at(mocker):
|
||||||
|
memory = _build_memory_instance(mocker, Memory)
|
||||||
|
memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={})
|
||||||
|
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
|
||||||
|
assert "updated_at" in payload
|
||||||
|
assert payload["updated_at"] == payload["created_at"]
|
||||||
|
_assert_utc_timestamp(payload["updated_at"])
|
||||||
|
|
||||||
|
# History should also receive updated_at
|
||||||
|
history_kwargs = memory.db.add_history.call_args
|
||||||
|
assert history_kwargs.kwargs["updated_at"] == payload["updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_memory_preserves_existing_created_at(mocker):
|
||||||
|
memory = _build_memory_instance(mocker, Memory)
|
||||||
|
custom_ts = "2023-05-06T09:19:20+00:00"
|
||||||
|
memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={"created_at": custom_ts})
|
||||||
|
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
|
||||||
|
assert payload["created_at"] == custom_ts
|
||||||
|
assert payload["updated_at"] == custom_ts
|
||||||
|
|
||||||
|
|
||||||
def test_update_memory_uses_utc_timestamps(mocker):
|
def test_update_memory_uses_utc_timestamps(mocker):
|
||||||
memory = _build_memory_instance(mocker, Memory)
|
memory = _build_memory_instance(mocker, Memory)
|
||||||
memory.vector_store.get.return_value = MagicMock(
|
memory.vector_store.get.return_value = MagicMock(
|
||||||
@@ -227,6 +249,30 @@ async def test_async_create_memory_uses_utc_timestamps(mocker):
|
|||||||
_assert_utc_timestamp(payload["created_at"])
|
_assert_utc_timestamp(payload["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_create_memory_sets_updated_at(mocker):
|
||||||
|
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||||
|
await memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={})
|
||||||
|
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
|
||||||
|
assert "updated_at" in payload
|
||||||
|
assert payload["updated_at"] == payload["created_at"]
|
||||||
|
_assert_utc_timestamp(payload["updated_at"])
|
||||||
|
|
||||||
|
# History should also receive updated_at
|
||||||
|
history_kwargs = memory.db.add_history.call_args
|
||||||
|
assert history_kwargs.kwargs["updated_at"] == payload["updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_create_memory_preserves_existing_created_at(mocker):
|
||||||
|
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||||
|
custom_ts = "2023-05-06T09:19:20+00:00"
|
||||||
|
await memory._create_memory("new memory", {"new memory": [0.1, 0.2, 0.3]}, metadata={"created_at": custom_ts})
|
||||||
|
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
|
||||||
|
assert payload["created_at"] == custom_ts
|
||||||
|
assert payload["updated_at"] == custom_ts
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_async_update_memory_uses_utc_timestamps(mocker):
|
async def test_async_update_memory_uses_utc_timestamps(mocker):
|
||||||
memory = _build_memory_instance(mocker, AsyncMemory)
|
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||||
@@ -239,6 +285,107 @@ async def test_async_update_memory_uses_utc_timestamps(mocker):
|
|||||||
_assert_utc_timestamp(payload["updated_at"])
|
_assert_utc_timestamp(payload["updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_then_search_and_get_all_return_same_timestamps(mocker):
|
||||||
|
"""Reproduces issue #3720: created_at must be identical in search() and get_all()."""
|
||||||
|
memory = _build_memory_instance(mocker, Memory)
|
||||||
|
|
||||||
|
# Step 1: Create a memory — capture the payload stored in the vector store
|
||||||
|
memory._create_memory("Likes pizza", {"Likes pizza": [0.1, 0.2, 0.3]}, metadata={"user_id": "alice"})
|
||||||
|
stored_payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
|
||||||
|
stored_id = memory.vector_store.insert.call_args.kwargs["ids"][0]
|
||||||
|
|
||||||
|
# Verify both timestamps were stored
|
||||||
|
assert stored_payload["created_at"] is not None
|
||||||
|
assert stored_payload["updated_at"] is not None
|
||||||
|
assert stored_payload["updated_at"] == stored_payload["created_at"]
|
||||||
|
|
||||||
|
# Step 2: Simulate the vector store returning this memory for both search and get_all
|
||||||
|
mem_result = MagicMock()
|
||||||
|
mem_result.id = stored_id
|
||||||
|
mem_result.payload = stored_payload
|
||||||
|
mem_result.score = 0.95
|
||||||
|
|
||||||
|
memory.vector_store.search.return_value = [mem_result]
|
||||||
|
memory.vector_store.list.return_value = [[mem_result]]
|
||||||
|
|
||||||
|
# Step 3: Call search and get_all, compare timestamps
|
||||||
|
search_results = memory._search_vector_store("pizza", filters={"user_id": "alice"}, limit=10, threshold=None)
|
||||||
|
get_all_results = memory._get_all_from_vector_store(filters={"user_id": "alice"}, limit=100)
|
||||||
|
|
||||||
|
search_item = search_results[0]
|
||||||
|
get_all_item = get_all_results[0]
|
||||||
|
|
||||||
|
# The core assertion from issue #3720: created_at must be the same
|
||||||
|
assert search_item["created_at"] == get_all_item["created_at"], (
|
||||||
|
f"created_at mismatch: search={search_item['created_at']}, get_all={get_all_item['created_at']}"
|
||||||
|
)
|
||||||
|
assert search_item["updated_at"] == get_all_item["updated_at"], (
|
||||||
|
f"updated_at mismatch: search={search_item['updated_at']}, get_all={get_all_item['updated_at']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Neither should be None
|
||||||
|
assert search_item["created_at"] is not None
|
||||||
|
assert search_item["updated_at"] is not None
|
||||||
|
assert get_all_item["created_at"] is not None
|
||||||
|
assert get_all_item["updated_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_preserves_created_at_and_updates_updated_at(mocker):
|
||||||
|
"""After an update, created_at must stay the same and updated_at must change."""
|
||||||
|
memory = _build_memory_instance(mocker, Memory)
|
||||||
|
|
||||||
|
# Create a memory
|
||||||
|
memory._create_memory("Likes pizza", {"Likes pizza": [0.1, 0.2, 0.3]}, metadata={"user_id": "alice"})
|
||||||
|
created_payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
|
||||||
|
created_id = memory.vector_store.insert.call_args.kwargs["ids"][0]
|
||||||
|
original_created_at = created_payload["created_at"]
|
||||||
|
|
||||||
|
# Update the memory — simulate existing memory in vector store
|
||||||
|
memory.vector_store.get.return_value = MagicMock(
|
||||||
|
id=created_id,
|
||||||
|
payload=created_payload,
|
||||||
|
)
|
||||||
|
memory._update_memory(created_id, "Loves pizza", {"Loves pizza": [0.2, 0.3, 0.4]}, metadata={})
|
||||||
|
updated_payload = memory.vector_store.update.call_args.kwargs["payload"]
|
||||||
|
|
||||||
|
# created_at must be preserved
|
||||||
|
assert updated_payload["created_at"] == original_created_at
|
||||||
|
# updated_at must be set and different from creation time (or at least present)
|
||||||
|
assert updated_payload["updated_at"] is not None
|
||||||
|
_assert_utc_timestamp(updated_payload["updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_and_get_all_consistent_after_update(mocker):
|
||||||
|
"""After update, search and get_all must still return the same timestamps."""
|
||||||
|
memory = _build_memory_instance(mocker, Memory)
|
||||||
|
|
||||||
|
# Simulate a memory that was created then updated
|
||||||
|
updated_payload = {
|
||||||
|
"data": "Loves pizza",
|
||||||
|
"hash": "abc123",
|
||||||
|
"user_id": "alice",
|
||||||
|
"created_at": "2023-05-06T09:19:20+00:00",
|
||||||
|
"updated_at": "2026-03-23T10:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
mem_result = MagicMock()
|
||||||
|
mem_result.id = "mem-1"
|
||||||
|
mem_result.payload = updated_payload
|
||||||
|
mem_result.score = 0.9
|
||||||
|
|
||||||
|
memory.vector_store.search.return_value = [mem_result]
|
||||||
|
memory.vector_store.list.return_value = [[mem_result]]
|
||||||
|
|
||||||
|
search_results = memory._search_vector_store("pizza", filters={"user_id": "alice"}, limit=10, threshold=None)
|
||||||
|
get_all_results = memory._get_all_from_vector_store(filters={"user_id": "alice"}, limit=100)
|
||||||
|
|
||||||
|
assert search_results[0]["created_at"] == get_all_results[0]["created_at"]
|
||||||
|
assert search_results[0]["updated_at"] == get_all_results[0]["updated_at"]
|
||||||
|
# created_at should be the original, not the updated time
|
||||||
|
assert search_results[0]["created_at"] == "2023-05-06T09:19:20+00:00"
|
||||||
|
assert search_results[0]["updated_at"] == "2026-03-23T10:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
class TestMetadataNotMutated:
|
class TestMetadataNotMutated:
|
||||||
"""Tests that metadata dicts passed to memory methods are not mutated in-place (issue #2648)."""
|
"""Tests that metadata dicts passed to memory methods are not mutated in-place (issue #2648)."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user