Skip to content

Commit ec47a2f

Browse files
Aayush KatariaAayush Kataria
authored andcommitted
resolving comments
1 parent 6b1b762 commit ec47a2f

14 files changed

Lines changed: 208 additions & 31 deletions

File tree

Docs/azure_testing.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ az functionapp config appsettings set \
120120
MEMORY_PROCESSOR_OWNER="durable"
121121
```
122122

123-
`COSMOS_DB_THROUGHPUT_MODE=serverless` is the default and creates the `memories`, `counter`, and `leases` containers without specifying RU/s. Set `COSMOS_DB_THROUGHPUT_MODE=autoscale` to apply the shared `COSMOS_DB_AUTOSCALE_MAX_RU` cap to all required containers.
123+
`COSMOS_DB_THROUGHPUT_MODE=serverless` is the default and creates the `memories`, `memories_turns`, `memories_summaries`, `counter`, and `leases` containers without specifying RU/s. Set `COSMOS_DB_THROUGHPUT_MODE=autoscale` to apply the shared `COSMOS_DB_AUTOSCALE_MAX_RU` cap to all required containers.
124124

125125
`MEMORY_PROCESSOR_OWNER=durable` tells the SDK that the deployed Function App owns processing, so any `CosmosMemoryClient` pointed at the same container will skip its in-process auto-trigger and avoid double-extraction. See the README's processor-ownership table for details.
126126

@@ -258,7 +258,7 @@ await memory.connect_cosmos()
258258
await memory.create_memory_store()
259259
```
260260

261-
This provisions the `memories`, `counter`, and `leases` containers. `serverless` is the default throughput mode; if you set `COSMOS_DB_THROUGHPUT_MODE=autoscale`, the shared `COSMOS_DB_AUTOSCALE_MAX_RU` value is applied to all three containers.
261+
This provisions the `memories`, `memories_turns`, `memories_summaries`, `counter`, and `leases` containers. `serverless` is the default throughput mode; if you set `COSMOS_DB_THROUGHPUT_MODE=autoscale`, the shared `COSMOS_DB_AUTOSCALE_MAX_RU` value is applied to all five containers.
262262

263263
---
264264

Docs/concepts.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,8 @@ Set any value to `0` to disable that processing type. For example, setting `THRE
187187

188188
The toolkit provisions all required Cosmos containers under one shared throughput mode:
189189

190-
- `serverless` is the default. The toolkit creates the `memories`, `counter`, and `leases` containers without specifying RU/s.
191-
- `autoscale` applies the shared `COSMOS_DB_AUTOSCALE_MAX_RU` cap to all three containers.
190+
- `serverless` is the default. The toolkit creates the `memories`, `memories_turns`, `memories_summaries`, `counter`, and `leases` containers without specifying RU/s.
191+
- `autoscale` applies the shared `COSMOS_DB_AUTOSCALE_MAX_RU` cap to all five containers.
192192

193193
This keeps the change feed dependencies aligned with the main memory store instead of letting the Functions trigger create the lease container independently.
194194

Docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ RBAC changes can take several minutes to propagate.
9494

9595
## 3. Cosmos DB Store Creation
9696

97-
Run `create_memory_store()` before relying on cloud operations. It creates the database plus the `memories`, `counter`, and `leases` containers.
97+
Run `create_memory_store()` before relying on cloud operations. It creates the database plus the `memories`, `memories_turns`, `memories_summaries`, `counter`, and `leases` containers.
9898

9999
The memories container is created with:
100100

agent_memory_toolkit/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
LLMError,
1212
MemoryConflictError,
1313
MemoryNotFoundError,
14+
MemoryTypeMismatchError,
1415
ValidationError,
1516
)
1617
from agent_memory_toolkit.models import MemoryRecord, MemoryRole, MemoryType, SearchResult
@@ -53,6 +54,7 @@
5354
"LLMError",
5455
"MemoryConflictError",
5556
"MemoryNotFoundError",
57+
"MemoryTypeMismatchError",
5658
"ValidationError",
5759
"DEFAULT_FACT_EXTRACTION_EVERY_N",
5860
"DEFAULT_THREAD_SUMMARY_EVERY_N",

agent_memory_toolkit/aio/store/memory_store.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Any, Optional
99

1010
from agent_memory_toolkit._container_routing import (
11+
_CONTAINER_FOR_TYPE,
1112
ContainerKey,
1213
container_key_for_type,
1314
)
@@ -24,6 +25,7 @@
2425
CosmosOperationError,
2526
MemoryConflictError,
2627
MemoryNotFoundError,
28+
MemoryTypeMismatchError,
2729
)
2830
from agent_memory_toolkit.logging import get_logger
2931
from agent_memory_toolkit.models import MemoryRecord
@@ -131,7 +133,13 @@ async def _query_items(
131133
async def add_cosmos(self, record: dict[str, Any]) -> dict[str, Any]:
132134
"""Upsert a pre-built Cosmos memory document and return the stored body."""
133135
body = self._prepare_doc(record)
134-
container = self._container_for_type(body.get("type"))
136+
memory_type = body.get("type")
137+
if memory_type not in _CONTAINER_FOR_TYPE:
138+
raise ValueError(
139+
f"add_cosmos: record id={body.get('id')!r} has invalid type={memory_type!r}. "
140+
f"Set 'type' to one of {sorted(_CONTAINER_FOR_TYPE)} before calling add_cosmos."
141+
)
142+
container = self._container_for_type(memory_type)
135143
try:
136144
response = await container.upsert_item(body=body)
137145
except Exception as exc:
@@ -251,6 +259,14 @@ async def push(self, local_memory: list[dict[str, Any]], batch_size: int = 25) -
251259
)
252260

253261
bodies = [self._prepare_doc(body) for body in bodies]
262+
for body in bodies:
263+
memory_type = body.get("type")
264+
if memory_type not in _CONTAINER_FOR_TYPE:
265+
raise ValueError(
266+
f"push: record id={body.get('id')!r} has invalid type={memory_type!r}. "
267+
f"Set 'type' to one of {sorted(_CONTAINER_FOR_TYPE)} on every local "
268+
f"memory before calling push_to_cosmos."
269+
)
254270
tasks = [self._container_for_type(b.get("type")).upsert_item(body=b) for b in bodies]
255271
try:
256272
await asyncio.gather(*tasks)
@@ -355,6 +371,10 @@ async def update(
355371
except Exception as exc:
356372
raise _wrap_cosmos_exception(exc, message=f"async update read failed for {memory_id}: {exc}") from exc
357373

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)
377+
358378
if content is not None:
359379
doc["content"] = content
360380
if role is not None:
@@ -378,10 +398,26 @@ async def delete(
378398
thread_id: str,
379399
memory_type: str,
380400
) -> None:
381-
"""Delete a memory document from the container for ``memory_type``."""
401+
"""Delete a memory document from the container for ``memory_type``.
402+
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).
406+
"""
382407
from azure.cosmos.exceptions import CosmosResourceNotFoundError
383408

384409
container = self._container_for_type(memory_type)
410+
try:
411+
doc = await container.read_item(item=memory_id, partition_key=[user_id, thread_id])
412+
except CosmosResourceNotFoundError as exc:
413+
raise MemoryNotFoundError(memory_id=memory_id, user_id=user_id, thread_id=thread_id) from exc
414+
except Exception as exc:
415+
raise _wrap_cosmos_exception(exc, message=f"async delete read failed for {memory_id}: {exc}") from exc
416+
417+
actual_type = doc.get("type")
418+
if actual_type != memory_type:
419+
raise MemoryTypeMismatchError(memory_id=memory_id, expected=memory_type, actual=actual_type)
420+
385421
try:
386422
await container.delete_item(item=memory_id, partition_key=[user_id, thread_id])
387423
except CosmosResourceNotFoundError as exc:
@@ -595,7 +631,10 @@ async def mark_superseded(
595631
) -> bool:
596632
"""Set supersession audit fields using ETag protection when available."""
597633
from azure.core import MatchConditions
598-
from azure.cosmos.exceptions import CosmosAccessConditionFailedError
634+
from azure.cosmos.exceptions import (
635+
CosmosAccessConditionFailedError,
636+
CosmosHttpResponseError,
637+
)
599638

600639
etag = old_doc.get("_etag")
601640
new_doc = {
@@ -625,8 +664,12 @@ async def mark_superseded(
625664
)
626665
del exc
627666
return False
628-
except Exception:
629-
logger.exception("supersede failed id=%s superseder=%s", old_doc.get("id"), superseder_id)
667+
except CosmosHttpResponseError:
668+
logger.exception(
669+
"supersede transient failure id=%s superseder=%s",
670+
old_doc.get("id"),
671+
superseder_id,
672+
)
630673
return False
631674

632675
async def get_procedural_prompt(self, user_id: str) -> Optional[str]:

agent_memory_toolkit/exceptions.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,33 @@ def _build_message(self) -> str:
103103
return "Memory not found"
104104

105105

106+
class MemoryTypeMismatchError(AgentMemoryError):
107+
"""Raised when an operation's ``memory_type`` argument does not match the
108+
actual type of the stored document. Prevents silent cross-type mutation
109+
or deletion when callers pass the wrong routing key.
110+
"""
111+
112+
error_code = "memory_type_mismatch"
113+
114+
def __init__(
115+
self,
116+
message: str | None = None,
117+
*,
118+
memory_id: str | None = None,
119+
expected: str | None = None,
120+
actual: str | None = None,
121+
):
122+
self.memory_id = memory_id
123+
self.expected = expected
124+
self.actual = actual
125+
if message is None:
126+
message = (
127+
f"Memory type mismatch for memory_id={memory_id!r}: "
128+
f"caller passed memory_type={expected!r}, actual stored type is {actual!r}."
129+
)
130+
super().__init__(message)
131+
132+
106133
class LLMError(AgentMemoryError):
107134
"""Raised when the LLM returns a response shape the SDK does not surface
108135
as an exception (no choices, empty content, invalid JSON)."""

agent_memory_toolkit/store/memory_store.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any, Optional
77

88
from agent_memory_toolkit._container_routing import (
9+
_CONTAINER_FOR_TYPE,
910
ContainerKey,
1011
container_key_for_type,
1112
)
@@ -22,6 +23,7 @@
2223
CosmosOperationError,
2324
MemoryConflictError,
2425
MemoryNotFoundError,
26+
MemoryTypeMismatchError,
2527
)
2628
from agent_memory_toolkit.logging import get_logger
2729
from agent_memory_toolkit.models import MemoryRecord
@@ -148,7 +150,13 @@ def _query_items(
148150
def add_cosmos(self, record: dict[str, Any]) -> dict[str, Any]:
149151
"""Upsert a pre-built Cosmos memory document and return the stored body."""
150152
body = self._prepare_doc(record)
151-
container = self._container_for_type(body.get("type"))
153+
memory_type = body.get("type")
154+
if memory_type not in _CONTAINER_FOR_TYPE:
155+
raise ValueError(
156+
f"add_cosmos: record id={body.get('id')!r} has invalid type={memory_type!r}. "
157+
f"Set 'type' to one of {sorted(_CONTAINER_FOR_TYPE)} before calling add_cosmos."
158+
)
159+
container = self._container_for_type(memory_type)
152160
try:
153161
response = container.upsert_item(body=body)
154162
except Exception as exc:
@@ -265,7 +273,14 @@ def push(self, local_memory: list[dict[str, Any]], batch_size: int = 25) -> None
265273

266274
bodies = [self._prepare_doc(body) for body in bodies]
267275
for record, body in zip(batch, bodies):
268-
container = self._container_for_type(body.get("type"))
276+
memory_type = body.get("type")
277+
if memory_type not in _CONTAINER_FOR_TYPE:
278+
raise ValueError(
279+
f"push: record id={body.get('id')!r} has invalid type={memory_type!r}. "
280+
f"Set 'type' to one of {sorted(_CONTAINER_FOR_TYPE)} on every local "
281+
f"memory before calling push_to_cosmos."
282+
)
283+
container = self._container_for_type(memory_type)
269284
try:
270285
container.upsert_item(body=body)
271286
except Exception as exc:
@@ -370,6 +385,10 @@ def update(
370385
except Exception as exc:
371386
raise _wrap_cosmos_exception(exc, message=f"update read failed for {memory_id}: {exc}") from exc
372387

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)
391+
373392
if content is not None:
374393
doc["content"] = content
375394
if role is not None:
@@ -393,10 +412,26 @@ def delete(
393412
thread_id: str,
394413
memory_type: str,
395414
) -> None:
396-
"""Delete a memory document from the container for ``memory_type``."""
415+
"""Delete a memory document from the container for ``memory_type``.
416+
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).
420+
"""
397421
from azure.cosmos.exceptions import CosmosResourceNotFoundError
398422

399423
container = self._container_for_type(memory_type)
424+
try:
425+
doc = container.read_item(item=memory_id, partition_key=[user_id, thread_id])
426+
except CosmosResourceNotFoundError as exc:
427+
raise MemoryNotFoundError(memory_id=memory_id, user_id=user_id, thread_id=thread_id) from exc
428+
except Exception as exc:
429+
raise _wrap_cosmos_exception(exc, message=f"delete read failed for {memory_id}: {exc}") from exc
430+
431+
actual_type = doc.get("type")
432+
if actual_type != memory_type:
433+
raise MemoryTypeMismatchError(memory_id=memory_id, expected=memory_type, actual=actual_type)
434+
400435
try:
401436
container.delete_item(item=memory_id, partition_key=[user_id, thread_id])
402437
except CosmosResourceNotFoundError as exc:
@@ -612,7 +647,10 @@ def mark_superseded(
612647
) -> bool:
613648
"""Set supersession audit fields using ETag protection when available."""
614649
from azure.core import MatchConditions
615-
from azure.cosmos.exceptions import CosmosAccessConditionFailedError
650+
from azure.cosmos.exceptions import (
651+
CosmosAccessConditionFailedError,
652+
CosmosHttpResponseError,
653+
)
616654

617655
etag = old_doc.get("_etag")
618656
new_doc = {
@@ -642,8 +680,12 @@ def mark_superseded(
642680
)
643681
del exc
644682
return False
645-
except Exception:
646-
logger.exception("supersede failed id=%s superseder=%s", old_doc.get("id"), superseder_id)
683+
except CosmosHttpResponseError:
684+
logger.exception(
685+
"supersede transient failure id=%s superseder=%s",
686+
old_doc.get("id"),
687+
superseder_id,
688+
)
647689
return False
648690

649691
def get_procedural_prompt(self, user_id: str) -> Optional[str]:

infra/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ azd provision # skips Function app, Storage, App Insights, Log Analytics, stor
7878

7979
> Use **`azd provision`** (not `azd up`) for SDK-only. `azd up` always invokes `azd deploy --all`, which looks for a resource tagged `azd-service-name: function_app`. With the Function app turned off there is no such resource and the deploy step fails. `azd provision` runs Bicep without the deploy step.
8080
81+
When `DEPLOY_FUNCTION_APP=false`, the Bicep template automatically sets `MEMORY_PROCESSOR_OWNER=inprocess` in the generated `.env` so the in-process processor's auto-trigger fires. If you previously deployed with `DEPLOY_FUNCTION_APP=true` and are switching to SDK-only mode, also clear the override explicitly:
82+
83+
```bash
84+
azd env set MEMORY_PROCESSOR_OWNER inprocess
85+
```
86+
8187
## Model / deployment names
8288

8389
Two concepts kept separate:

infra/main.bicep

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,4 +266,4 @@ output AZURE_USER_ASSIGNED_IDENTITY_ID string = identity.outputs.id
266266
output FUNCTION_APP_NAME string = deployFunctionApp ? functions!.outputs.functionAppName : ''
267267
output FUNCTION_APP_URL string = deployFunctionApp ? functions!.outputs.functionAppUrl : ''
268268

269-
output MEMORY_PROCESSOR_OWNER string = memoryProcessorOwner
269+
output MEMORY_PROCESSOR_OWNER string = deployFunctionApp ? memoryProcessorOwner : 'inprocess'

tests/unit/aio/store/test_memory_store.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from agent_memory_toolkit._container_routing import ContainerKey
99
from agent_memory_toolkit.aio.store import AsyncMemoryStore
10-
from agent_memory_toolkit.exceptions import MemoryNotFoundError
10+
from agent_memory_toolkit.exceptions import MemoryNotFoundError, MemoryTypeMismatchError
1111

1212

1313
class AsyncIterator:
@@ -153,20 +153,21 @@ async def test_update_raises_when_missing():
153153
await store.update("missing", user_id="u1", thread_id="t1", memory_type="fact")
154154

155155

156-
async def test_update_does_not_mutate_type_field():
156+
async def test_update_raises_on_type_mismatch():
157157
memories = MagicMock()
158158
memories.read_item = AsyncMock(return_value=_doc(id="m1", type="fact"))
159159
memories.replace_item = AsyncMock()
160160
store = AsyncMemoryStore(containers=_containers(memories=memories))
161161

162-
await store.update("m1", user_id="u1", thread_id="t1", memory_type="episodic", content="x")
162+
with pytest.raises(MemoryTypeMismatchError):
163+
await store.update("m1", user_id="u1", thread_id="t1", memory_type="episodic", content="x")
163164

164-
body = memories.replace_item.call_args.kwargs["body"]
165-
assert body["type"] == "fact"
165+
memories.replace_item.assert_not_awaited()
166166

167167

168168
async def test_delete_calls_delete_item_directly():
169169
memories = MagicMock()
170+
memories.read_item = AsyncMock(return_value=_doc(id="m1", type="fact"))
170171
memories.delete_item = AsyncMock()
171172
store = AsyncMemoryStore(containers=_containers(memories=memories))
172173

@@ -179,12 +180,27 @@ async def test_delete_raises_when_missing():
179180
from azure.cosmos.exceptions import CosmosResourceNotFoundError
180181

181182
memories = MagicMock()
182-
memories.delete_item = AsyncMock(side_effect=CosmosResourceNotFoundError(message="404"))
183+
memories.read_item = AsyncMock(side_effect=CosmosResourceNotFoundError(message="404"))
184+
memories.delete_item = AsyncMock()
183185
store = AsyncMemoryStore(containers=_containers(memories=memories))
184186

185187
with pytest.raises(MemoryNotFoundError):
186188
await store.delete("m1", user_id="u1", thread_id="t1", memory_type="fact")
187189

190+
memories.delete_item.assert_not_awaited()
191+
192+
193+
async def test_delete_raises_on_type_mismatch():
194+
memories = MagicMock()
195+
memories.read_item = AsyncMock(return_value=_doc(id="m1", type="fact"))
196+
memories.delete_item = AsyncMock()
197+
store = AsyncMemoryStore(containers=_containers(memories=memories))
198+
199+
with pytest.raises(MemoryTypeMismatchError):
200+
await store.delete("m1", user_id="u1", thread_id="t1", memory_type="episodic")
201+
202+
memories.delete_item.assert_not_awaited()
203+
188204

189205
async def test_read_and_tag_mutation_use_point_reads():
190206
memories = MagicMock()

0 commit comments

Comments
 (0)