Skip to content

Commit 6fe8f4b

Browse files
Aayush KatariaAayush Kataria
authored andcommitted
resolving comments
1 parent 9fe7219 commit 6fe8f4b

9 files changed

Lines changed: 201 additions & 82 deletions

File tree

Docs/concepts.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,9 @@ Set any value to `0` to disable that processing type. For example, setting `THRE
179179

180180
| Container | Partition Key | Purpose |
181181
|-----------|---------------|---------|
182-
| `memories` | `/user_id`, `/thread_id` (hierarchical) | Existing memory store |
182+
| `memories` | `/user_id`, `/thread_id` (hierarchical) | Durable derived memories (`fact`, `episodic`, `procedural`) |
183+
| `memories_turns` | `/user_id`, `/thread_id` (hierarchical) | Raw conversation turns (`turn`) — append-only, TTL-pruned |
184+
| `memories_summaries` | `/user_id`, `/thread_id` (hierarchical) | Thread + user summaries (`thread_summary`, `user_summary`) |
183185
| `counter` | `/user_id`, `/thread_id` (hierarchical) | Message count tracking for automatic processing |
184186
| `leases` | `/id` | Change feed checkpointing container created by `create_memory_store()` |
185187

agent_memory_toolkit/aio/cosmos_memory_client.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,8 +831,22 @@ async def process_now_and_wait(self, *, user_id: str, thread_id: str, timeout: f
831831
return False
832832

833833
async def _summary_exists(self, *, user_id: str, thread_id: str) -> bool:
834+
from azure.cosmos.exceptions import (
835+
CosmosHttpResponseError,
836+
CosmosResourceNotFoundError,
837+
)
838+
834839
try:
835840
results = await self.get_thread_summary(user_id=user_id, thread_id=thread_id, recent_k=1)
836-
except Exception:
841+
except CosmosResourceNotFoundError:
842+
return False
843+
except CosmosHttpResponseError as exc:
844+
logger.warning(
845+
"_summary_exists: Cosmos error user_id=%s thread_id=%s status=%s: %s",
846+
user_id,
847+
thread_id,
848+
getattr(exc, "status_code", None),
849+
exc,
850+
)
837851
return False
838852
return bool(results)

agent_memory_toolkit/aio/store/memory_store.py

Lines changed: 69 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -361,34 +361,56 @@ async def update(
361361
metadata: Optional[dict[str, Any]] = None,
362362
) -> None:
363363
"""Update a memory document via point read in the container for ``memory_type``."""
364-
from azure.cosmos.exceptions import CosmosResourceNotFoundError
365-
366-
container = self._container_for_type(memory_type)
367-
try:
368-
doc = await container.read_item(item=memory_id, partition_key=[user_id, thread_id])
369-
except CosmosResourceNotFoundError as exc:
370-
raise MemoryNotFoundError(memory_id=memory_id, user_id=user_id, thread_id=thread_id) from exc
371-
except Exception as exc:
372-
raise _wrap_cosmos_exception(exc, message=f"async update read failed for {memory_id}: {exc}") from exc
373-
374-
actual_type = doc.get("type")
375-
if actual_type != memory_type:
376-
raise MemoryTypeMismatchError(memory_id=memory_id, expected=memory_type, actual=actual_type)
364+
import random
377365

378-
if content is not None:
379-
doc["content"] = content
380-
if role is not None:
381-
doc["role"] = role
382-
if metadata is not None:
383-
doc["metadata"] = metadata
384-
doc["updated_at"] = datetime.now(timezone.utc).isoformat()
366+
from azure.core import MatchConditions
367+
from azure.cosmos.exceptions import (
368+
CosmosAccessConditionFailedError,
369+
CosmosResourceNotFoundError,
370+
)
385371

386-
try:
387-
await container.replace_item(item=doc["id"], body=doc)
388-
except Exception as exc:
389-
raise _wrap_cosmos_exception(exc, message=f"async update replace failed for {memory_id}: {exc}") from exc
372+
container = self._container_for_type(memory_type)
373+
max_attempts = 5
374+
attempts = 0
375+
while True:
376+
try:
377+
doc = await container.read_item(item=memory_id, partition_key=[user_id, thread_id])
378+
except CosmosResourceNotFoundError as exc:
379+
raise MemoryNotFoundError(memory_id=memory_id, user_id=user_id, thread_id=thread_id) from exc
380+
except Exception as exc:
381+
raise _wrap_cosmos_exception(exc, message=f"async update read failed for {memory_id}: {exc}") from exc
382+
383+
actual_type = doc.get("type")
384+
if actual_type != memory_type:
385+
raise MemoryTypeMismatchError(memory_id=memory_id, expected=memory_type, actual=actual_type)
386+
387+
if content is not None:
388+
doc["content"] = content
389+
if role is not None:
390+
doc["role"] = role
391+
if metadata is not None:
392+
doc["metadata"] = metadata
393+
doc["updated_at"] = datetime.now(timezone.utc).isoformat()
390394

391-
logger.info("Async updated record %s", memory_id)
395+
kwargs: dict[str, Any] = {"item": doc["id"], "body": doc}
396+
if etag := doc.get("_etag"):
397+
kwargs.update(match_condition=MatchConditions.IfNotModified, etag=etag)
398+
try:
399+
await container.replace_item(**kwargs)
400+
logger.info("Async updated record %s", memory_id)
401+
return
402+
except CosmosAccessConditionFailedError as exc:
403+
attempts += 1
404+
if attempts >= max_attempts:
405+
raise MemoryConflictError(
406+
f"update conflicted after {max_attempts} attempts for memory_id={memory_id!r}"
407+
) from exc
408+
base = 0.02 * (2 ** (attempts - 1))
409+
await asyncio.sleep(base + random.uniform(0, base))
410+
except Exception as exc:
411+
raise _wrap_cosmos_exception(
412+
exc, message=f"async update replace failed for {memory_id}: {exc}"
413+
) from exc
392414

393415
async def delete(
394416
self,
@@ -400,11 +422,16 @@ async def delete(
400422
) -> None:
401423
"""Delete a memory document from the container for ``memory_type``.
402424
403-
Reads the doc first to verify its ``type`` matches ``memory_type`` so a
404-
wrong routing key cannot silently delete the wrong document (within
405-
MEMORIES, fact/episodic/procedural share the same partition key).
425+
Reads the doc first to verify its ``type`` matches ``memory_type`` and
426+
then issues the delete with ``If-Match`` on the read ETag, so a
427+
concurrent type mutation between read and delete is rejected (412)
428+
rather than silently dropping the wrong document.
406429
"""
407-
from azure.cosmos.exceptions import CosmosResourceNotFoundError
430+
from azure.core import MatchConditions
431+
from azure.cosmos.exceptions import (
432+
CosmosAccessConditionFailedError,
433+
CosmosResourceNotFoundError,
434+
)
408435

409436
container = self._container_for_type(memory_type)
410437
try:
@@ -418,10 +445,17 @@ async def delete(
418445
if actual_type != memory_type:
419446
raise MemoryTypeMismatchError(memory_id=memory_id, expected=memory_type, actual=actual_type)
420447

448+
kwargs: dict[str, Any] = {"item": memory_id, "partition_key": [user_id, thread_id]}
449+
if etag := doc.get("_etag"):
450+
kwargs.update(match_condition=MatchConditions.IfNotModified, etag=etag)
421451
try:
422-
await container.delete_item(item=memory_id, partition_key=[user_id, thread_id])
452+
await container.delete_item(**kwargs)
423453
except CosmosResourceNotFoundError as exc:
424454
raise MemoryNotFoundError(memory_id=memory_id, user_id=user_id, thread_id=thread_id) from exc
455+
except CosmosAccessConditionFailedError as exc:
456+
raise MemoryConflictError(
457+
f"delete conflicted for memory_id={memory_id!r} — document was modified after the type check"
458+
) from exc
425459
except Exception as exc:
426460
raise _wrap_cosmos_exception(exc, message=f"async delete failed for {memory_id}: {exc}") from exc
427461

@@ -664,11 +698,13 @@ async def mark_superseded(
664698
)
665699
del exc
666700
return False
667-
except CosmosHttpResponseError:
668-
logger.exception(
669-
"supersede transient failure id=%s superseder=%s",
701+
except CosmosHttpResponseError as exc:
702+
logger.warning(
703+
"supersede failed id=%s superseder=%s status=%s: %s",
670704
old_doc.get("id"),
671705
superseder_id,
706+
getattr(exc, "status_code", None),
707+
exc,
672708
)
673709
return False
674710

agent_memory_toolkit/cosmos_memory_client.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -809,8 +809,22 @@ def process_now_and_wait(self, *, user_id: str, thread_id: str, timeout: float =
809809
return False
810810

811811
def _summary_exists(self, *, user_id: str, thread_id: str) -> bool:
812+
from azure.cosmos.exceptions import (
813+
CosmosHttpResponseError,
814+
CosmosResourceNotFoundError,
815+
)
816+
812817
try:
813818
results = self.get_thread_summary(user_id=user_id, thread_id=thread_id, recent_k=1)
814-
except Exception:
819+
except CosmosResourceNotFoundError:
820+
return False
821+
except CosmosHttpResponseError as exc:
822+
logger.warning(
823+
"_summary_exists: Cosmos error user_id=%s thread_id=%s status=%s: %s",
824+
user_id,
825+
thread_id,
826+
getattr(exc, "status_code", None),
827+
exc,
828+
)
815829
return False
816830
return bool(results)

agent_memory_toolkit/store/memory_store.py

Lines changed: 71 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -272,15 +272,16 @@ def push(self, local_memory: list[dict[str, Any]], batch_size: int = 25) -> None
272272
)
273273

274274
bodies = [self._prepare_doc(body) for body in bodies]
275-
for record, body in zip(batch, bodies):
275+
for body in bodies:
276276
memory_type = body.get("type")
277277
if memory_type not in _CONTAINER_FOR_TYPE:
278278
raise ValueError(
279279
f"push: record id={body.get('id')!r} has invalid type={memory_type!r}. "
280280
f"Set 'type' to one of {sorted(_CONTAINER_FOR_TYPE)} on every local "
281281
f"memory before calling push_to_cosmos."
282282
)
283-
container = self._container_for_type(memory_type)
283+
for record, body in zip(batch, bodies):
284+
container = self._container_for_type(body.get("type"))
284285
try:
285286
container.upsert_item(body=body)
286287
except Exception as exc:
@@ -375,34 +376,55 @@ def update(
375376
metadata: Optional[dict[str, Any]] = None,
376377
) -> None:
377378
"""Update a memory document via point read in the container for ``memory_type``."""
378-
from azure.cosmos.exceptions import CosmosResourceNotFoundError
379-
380-
container = self._container_for_type(memory_type)
381-
try:
382-
doc = container.read_item(item=memory_id, partition_key=[user_id, thread_id])
383-
except CosmosResourceNotFoundError as exc:
384-
raise MemoryNotFoundError(memory_id=memory_id, user_id=user_id, thread_id=thread_id) from exc
385-
except Exception as exc:
386-
raise _wrap_cosmos_exception(exc, message=f"update read failed for {memory_id}: {exc}") from exc
387-
388-
actual_type = doc.get("type")
389-
if actual_type != memory_type:
390-
raise MemoryTypeMismatchError(memory_id=memory_id, expected=memory_type, actual=actual_type)
379+
import random
380+
import time
391381

392-
if content is not None:
393-
doc["content"] = content
394-
if role is not None:
395-
doc["role"] = role
396-
if metadata is not None:
397-
doc["metadata"] = metadata
398-
doc["updated_at"] = datetime.now(timezone.utc).isoformat()
382+
from azure.core import MatchConditions
383+
from azure.cosmos.exceptions import (
384+
CosmosAccessConditionFailedError,
385+
CosmosResourceNotFoundError,
386+
)
399387

400-
try:
401-
container.replace_item(item=doc["id"], body=doc)
402-
except Exception as exc:
403-
raise _wrap_cosmos_exception(exc, message=f"update replace failed for {memory_id}: {exc}") from exc
388+
container = self._container_for_type(memory_type)
389+
max_attempts = 5
390+
attempts = 0
391+
while True:
392+
try:
393+
doc = container.read_item(item=memory_id, partition_key=[user_id, thread_id])
394+
except CosmosResourceNotFoundError as exc:
395+
raise MemoryNotFoundError(memory_id=memory_id, user_id=user_id, thread_id=thread_id) from exc
396+
except Exception as exc:
397+
raise _wrap_cosmos_exception(exc, message=f"update read failed for {memory_id}: {exc}") from exc
398+
399+
actual_type = doc.get("type")
400+
if actual_type != memory_type:
401+
raise MemoryTypeMismatchError(memory_id=memory_id, expected=memory_type, actual=actual_type)
402+
403+
if content is not None:
404+
doc["content"] = content
405+
if role is not None:
406+
doc["role"] = role
407+
if metadata is not None:
408+
doc["metadata"] = metadata
409+
doc["updated_at"] = datetime.now(timezone.utc).isoformat()
404410

405-
logger.info("Updated record %s", memory_id)
411+
kwargs: dict[str, Any] = {"item": doc["id"], "body": doc}
412+
if etag := doc.get("_etag"):
413+
kwargs.update(match_condition=MatchConditions.IfNotModified, etag=etag)
414+
try:
415+
container.replace_item(**kwargs)
416+
logger.info("Updated record %s", memory_id)
417+
return
418+
except CosmosAccessConditionFailedError as exc:
419+
attempts += 1
420+
if attempts >= max_attempts:
421+
raise MemoryConflictError(
422+
f"update conflicted after {max_attempts} attempts for memory_id={memory_id!r}"
423+
) from exc
424+
base = 0.02 * (2 ** (attempts - 1))
425+
time.sleep(base + random.uniform(0, base))
426+
except Exception as exc:
427+
raise _wrap_cosmos_exception(exc, message=f"update replace failed for {memory_id}: {exc}") from exc
406428

407429
def delete(
408430
self,
@@ -414,11 +436,16 @@ def delete(
414436
) -> None:
415437
"""Delete a memory document from the container for ``memory_type``.
416438
417-
Reads the doc first to verify its ``type`` matches ``memory_type`` so a
418-
wrong routing key cannot silently delete the wrong document (within
419-
MEMORIES, fact/episodic/procedural share the same partition key).
439+
Reads the doc first to verify its ``type`` matches ``memory_type`` and
440+
then issues the delete with ``If-Match`` on the read ETag, so a
441+
concurrent type mutation between read and delete is rejected (412)
442+
rather than silently dropping the wrong document.
420443
"""
421-
from azure.cosmos.exceptions import CosmosResourceNotFoundError
444+
from azure.core import MatchConditions
445+
from azure.cosmos.exceptions import (
446+
CosmosAccessConditionFailedError,
447+
CosmosResourceNotFoundError,
448+
)
422449

423450
container = self._container_for_type(memory_type)
424451
try:
@@ -432,10 +459,17 @@ def delete(
432459
if actual_type != memory_type:
433460
raise MemoryTypeMismatchError(memory_id=memory_id, expected=memory_type, actual=actual_type)
434461

462+
kwargs: dict[str, Any] = {"item": memory_id, "partition_key": [user_id, thread_id]}
463+
if etag := doc.get("_etag"):
464+
kwargs.update(match_condition=MatchConditions.IfNotModified, etag=etag)
435465
try:
436-
container.delete_item(item=memory_id, partition_key=[user_id, thread_id])
466+
container.delete_item(**kwargs)
437467
except CosmosResourceNotFoundError as exc:
438468
raise MemoryNotFoundError(memory_id=memory_id, user_id=user_id, thread_id=thread_id) from exc
469+
except CosmosAccessConditionFailedError as exc:
470+
raise MemoryConflictError(
471+
f"delete conflicted for memory_id={memory_id!r} — document was modified after the type check"
472+
) from exc
439473
except Exception as exc:
440474
raise _wrap_cosmos_exception(exc, message=f"delete failed for {memory_id}: {exc}") from exc
441475

@@ -680,11 +714,13 @@ def mark_superseded(
680714
)
681715
del exc
682716
return False
683-
except CosmosHttpResponseError:
684-
logger.exception(
685-
"supersede transient failure id=%s superseder=%s",
717+
except CosmosHttpResponseError as exc:
718+
logger.warning(
719+
"supersede failed id=%s superseder=%s status=%s: %s",
686720
old_doc.get("id"),
687721
superseder_id,
722+
getattr(exc, "status_code", None),
723+
exc,
688724
)
689725
return False
690726

tests/integration/test_processor_integration.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,13 @@ def _fake_monotonic():
163163
assert client.get_thread_summary.call_count >= 1
164164

165165
def test_timeout_swallows_search_errors(self, monkeypatch):
166+
from azure.cosmos.exceptions import CosmosHttpResponseError
167+
166168
client = _build_client(processor=DurableFunctionProcessor())
167169
client.get_thread = MagicMock(return_value=[])
168-
client.get_thread_summary = MagicMock(side_effect=RuntimeError("transient"))
170+
client.get_thread_summary = MagicMock(
171+
side_effect=CosmosHttpResponseError(message="429 throttled", status_code=429)
172+
)
169173

170174
monkeypatch.setattr("time.sleep", lambda *_a, **_k: None)
171175

tests/integration/test_processor_integration_async.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,13 @@ async def _no_sleep(*_a, **_k):
165165

166166
@pytest.mark.asyncio
167167
async def test_timeout_swallows_search_errors(self, monkeypatch):
168+
from azure.cosmos.exceptions import CosmosHttpResponseError
169+
168170
client = _build_client(processor=AsyncDurableFunctionProcessor())
169171
client.get_thread = AsyncMock(return_value=[])
170-
client.get_thread_summary = AsyncMock(side_effect=RuntimeError("transient"))
172+
client.get_thread_summary = AsyncMock(
173+
side_effect=CosmosHttpResponseError(message="429 throttled", status_code=429)
174+
)
171175

172176
async def _no_sleep(*_a, **_k):
173177
return None

0 commit comments

Comments
 (0)