Skip to content

Commit 763ae6e

Browse files
aayush3011Aayush KatariaCopilot
authored
Users/akataria/improvements (#8)
* code improvements * code improvements * code improvements * Cache push_to_cosmos embeddings; clarify non-turn thread_id - push_to_cosmos (sync + async): persist generated embeddings back to local_memory so a repeat push doesn't re-embed already-embedded non-turn records. - Update test comment: _make_memory auto-generates a UUID thread_id for non-turn types when omitted (it does not user-scope them). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Aayush Kataria <aayushkataria@Aayushs-MacBook-Pro-2.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7a43443 commit 763ae6e

10 files changed

Lines changed: 289 additions & 52 deletions

File tree

agent_memory_toolkit/aio/cosmos_memory_client.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def __init__(
113113
) -> None:
114114
# Local store
115115
self.local_memory: list[dict[str, Any]] = []
116-
self._unflushed_turn_counts: dict[tuple[str, Optional[str]], int] = {}
116+
self._unflushed_turn_counts: dict[tuple[str, str], int] = {}
117117

118118
self._background_tasks: set[asyncio.Task[Any]] = set()
119119
try:
@@ -361,6 +361,11 @@ def add_local(
361361
ttl=ttl,
362362
salience=salience,
363363
)
364+
if memory_type == "turn" and not thread_id:
365+
raise ValidationError(
366+
"thread_id is required for memory_type='turn' so the auto-trigger "
367+
"counter can group turns per conversation. Set thread_id explicitly."
368+
)
364369
self.local_memory.append(memory)
365370
if memory_type == "turn":
366371
key = (user_id, thread_id)
@@ -491,6 +496,8 @@ async def connect_cosmos(
491496
try:
492497
from azure.cosmos.aio import CosmosClient
493498

499+
await self._drain_cosmos_client()
500+
494501
client = CosmosClient(self._cosmos_endpoint, credential=self._cosmos_credential)
495502
db = client.get_database_client(self._cosmos_database)
496503
container_handle = db.get_container_client(self._cosmos_container)
@@ -573,6 +580,8 @@ async def create_memory_store(
573580
from azure.cosmos import PartitionKey, ThroughputProperties
574581
from azure.cosmos.aio import CosmosClient
575582

583+
await self._drain_cosmos_client()
584+
576585
client = CosmosClient(self._cosmos_endpoint, credential=self._cosmos_credential)
577586

578587
db = await client.create_database_if_not_exists(id=self._cosmos_database)
@@ -733,7 +742,34 @@ async def push_to_cosmos(self, batch_size: int = 25) -> None:
733742

734743
for start in range(0, len(records), batch_size):
735744
batch = records[start : start + batch_size]
736-
tasks = [self._container_client.upsert_item(body=r.to_cosmos_dict()) for r in batch]
745+
bodies = [r.to_cosmos_dict() for r in batch]
746+
747+
# Batch-embed non-turn memories that don't already carry a
748+
# vector — one /embeddings POST per Cosmos batch instead of
749+
# N concurrent ones.
750+
to_embed_idx: list[int] = []
751+
to_embed_text: list[str] = []
752+
for i, body in enumerate(bodies):
753+
if body.get("type") != "turn" and body.get("content") and not body.get("embedding"):
754+
to_embed_idx.append(i)
755+
to_embed_text.append(body["content"])
756+
if to_embed_text:
757+
try:
758+
vectors = await self._embeddings_client.generate_batch(to_embed_text)
759+
for i, vec in zip(to_embed_idx, vectors):
760+
bodies[i]["embedding"] = vec
761+
# Persist the embedding back to local_memory so a
762+
# repeat push_to_cosmos() doesn't re-embed.
763+
self.local_memory[start + i]["embedding"] = vec
764+
except Exception as exc: # noqa: BLE001
765+
logger.warning(
766+
"push_to_cosmos: batch embedding generation failed (%s); "
767+
"proceeding without embeddings for %d records",
768+
exc,
769+
len(to_embed_text),
770+
)
771+
772+
tasks = [self._container_client.upsert_item(body=b) for b in bodies]
737773
try:
738774
await asyncio.gather(*tasks)
739775
except Exception as exc:
@@ -1247,7 +1283,7 @@ def _drain_pipeline_resources(self) -> None:
12471283
try:
12481284
close()
12491285
except Exception:
1250-
pass
1286+
logger.warning("Failed to close prior sync Cosmos client", exc_info=True)
12511287
self._sync_cosmos_client = None
12521288

12531289
prior_sync_embeddings = getattr(self, "_sync_embeddings_client", None)
@@ -1257,9 +1293,34 @@ def _drain_pipeline_resources(self) -> None:
12571293
try:
12581294
close()
12591295
except Exception:
1260-
pass
1296+
logger.warning("Failed to close prior sync EmbeddingsClient", exc_info=True)
12611297
self._sync_embeddings_client = None
12621298

1299+
async def _drain_cosmos_client(self) -> None:
1300+
"""Close any prior async Cosmos client before reassigning the field.
1301+
1302+
Also nulls the cached pipeline, counter container handle, and any
1303+
lazily-created processor — otherwise they retain references to
1304+
the drained sync container client and the next op fails with an
1305+
opaque "Object disposed" error. A user-supplied processor is left
1306+
intact since the SDK does not own its lifecycle.
1307+
"""
1308+
prior = self._cosmos_client
1309+
if prior is None:
1310+
return
1311+
close = getattr(prior, "close", None)
1312+
if callable(close):
1313+
try:
1314+
await close()
1315+
except Exception:
1316+
logger.warning("Failed to close prior async Cosmos client during reconnect", exc_info=True)
1317+
self._cosmos_client = None
1318+
self._container_client = None
1319+
self._counter_container_client = None
1320+
self._pipeline = None
1321+
if not self._processor_explicit:
1322+
self._processor = None
1323+
12631324
def _init_pipeline(self) -> None:
12641325
"""Initialize the ProcessingPipeline with a sync container client.
12651326

agent_memory_toolkit/aio/processors/inprocess.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,18 @@ async def process_dedup(self, *, user_id: str) -> int:
115115

116116
@staticmethod
117117
def _extract_dedup_count(dedup: Any) -> int:
118-
if isinstance(dedup, dict):
119-
for key in ("deduplicated", "merged", "removed", "deduplicated_count"):
120-
if key in dedup and isinstance(dedup[key], int):
121-
return dedup[key]
122-
return 0
118+
"""Sum the merged + superseded facts from a ``deduplicate_facts`` result.
119+
120+
``ProcessingPipeline.deduplicate_facts`` returns a dict with
121+
``{"kept", "merged", "superseded"}`` — both ``merged`` and
122+
``superseded`` represent facts that were consolidated, so they
123+
contribute to the dedup-count metric.
124+
"""
125+
if not isinstance(dedup, dict):
126+
return 0
127+
merged = dedup.get("merged", 0) if isinstance(dedup.get("merged"), int) else 0
128+
superseded = dedup.get("superseded", 0) if isinstance(dedup.get("superseded"), int) else 0
129+
return merged + superseded
123130

124131
async def generate_user_summary(
125132
self,

agent_memory_toolkit/chat.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@ def generate(
281281
)
282282

283283
attempt = 0
284+
unsupported_strips = 0
285+
max_unsupported_strips = 5
284286
while True:
285287
try:
286288
response = client.chat.completions.create(**kwargs)
@@ -315,13 +317,14 @@ def generate(
315317
# this does NOT consume a retry slot since it's a request-shape
316318
# repair, not a transient failure.
317319
bad_param = _unsupported_param(exc) if status == 400 else None
318-
if bad_param and bad_param in kwargs:
320+
if bad_param and bad_param in kwargs and unsupported_strips < max_unsupported_strips:
319321
logger.warning(
320322
"LLM model=%s rejected '%s'; retrying without it.",
321323
self._model,
322324
bad_param,
323325
)
324326
kwargs.pop(bad_param, None)
327+
unsupported_strips += 1
325328
continue
326329
if status in _RETRYABLE_STATUS_CODES and attempt < max_retries - 1:
327330
delay = base_delay * (2**attempt)
@@ -379,6 +382,8 @@ async def agenerate(
379382
)
380383

381384
attempt = 0
385+
unsupported_strips = 0
386+
max_unsupported_strips = 5
382387
while True:
383388
try:
384389
response = await client.chat.completions.create(**kwargs)
@@ -411,13 +416,14 @@ async def agenerate(
411416
# Strip-unsupported-param: request-shape repair, not a transient
412417
# failure — does NOT consume a retry slot.
413418
bad_param = _unsupported_param(exc) if status == 400 else None
414-
if bad_param and bad_param in kwargs:
419+
if bad_param and bad_param in kwargs and unsupported_strips < max_unsupported_strips:
415420
logger.warning(
416421
"LLM model=%s rejected '%s'; retrying without it.",
417422
self._model,
418423
bad_param,
419424
)
420425
kwargs.pop(bad_param, None)
426+
unsupported_strips += 1
421427
continue
422428
if status in _RETRYABLE_STATUS_CODES and attempt < max_retries - 1:
423429
delay = base_delay * (2**attempt)

agent_memory_toolkit/cosmos_memory_client.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def __init__(
112112
) -> None:
113113
# Local store
114114
self.local_memory: list[dict[str, Any]] = []
115-
self._unflushed_turn_counts: dict[tuple[str, Optional[str]], int] = {}
115+
self._unflushed_turn_counts: dict[tuple[str, str], int] = {}
116116
self._warned_owner_skip: bool = False
117117
self._warned_counter_unreachable: bool = False
118118

@@ -278,6 +278,11 @@ def add_local(
278278
ttl=ttl,
279279
salience=salience,
280280
)
281+
if memory_type == "turn" and not thread_id:
282+
raise ValidationError(
283+
"thread_id is required for memory_type='turn' so the auto-trigger "
284+
"counter can group turns per conversation. Set thread_id explicitly."
285+
)
281286
self.local_memory.append(memory)
282287
if memory_type == "turn":
283288
key = (user_id, thread_id)
@@ -408,6 +413,8 @@ def connect_cosmos(
408413
try:
409414
from azure.cosmos import CosmosClient
410415

416+
self._drain_cosmos_client()
417+
411418
client = CosmosClient(self._cosmos_endpoint, credential=self._cosmos_credential)
412419
db = client.get_database_client(self._cosmos_database)
413420
container_handle = db.get_container_client(self._cosmos_container)
@@ -489,6 +496,8 @@ def create_memory_store(
489496
try:
490497
from azure.cosmos import CosmosClient, PartitionKey, ThroughputProperties
491498

499+
self._drain_cosmos_client()
500+
492501
client = CosmosClient(self._cosmos_endpoint, credential=self._cosmos_credential)
493502

494503
db = client.create_database_if_not_exists(id=self._cosmos_database)
@@ -559,6 +568,28 @@ def _init_pipeline(self) -> None:
559568
)
560569
self._warn_on_embedding_dim_mismatch()
561570

571+
def _drain_cosmos_client(self) -> None:
572+
"""Close any prior Cosmos client before reassigning the field.
573+
574+
Repeated ``connect_cosmos`` / ``create_memory_store`` calls on the
575+
same instance must not leak the prior client's httpx pool / FDs.
576+
"""
577+
prior = self._cosmos_client
578+
if prior is None:
579+
return
580+
close = getattr(prior, "close", None)
581+
if callable(close):
582+
try:
583+
close()
584+
except Exception:
585+
logger.warning("Failed to close prior Cosmos client during reconnect", exc_info=True)
586+
self._cosmos_client = None
587+
self._container_client = None
588+
self._counter_container_client = None
589+
self._pipeline = None
590+
if not self._processor_explicit:
591+
self._processor = None
592+
562593
def _warn_on_embedding_dim_mismatch(self) -> None:
563594
"""Log a WARNING if the resolved embedding dim differs from the container's policy.
564595
@@ -948,8 +979,34 @@ def push_to_cosmos(self, batch_size: int = 25) -> None:
948979
records = [MemoryRecord.from_cosmos_dict(dict(m)) for m in self.local_memory]
949980
for start in range(0, len(records), batch_size):
950981
batch = records[start : start + batch_size]
951-
for record in batch:
952-
body = record.to_cosmos_dict()
982+
bodies = [r.to_cosmos_dict() for r in batch]
983+
984+
# Batch-embed non-turn memories that don't already carry a
985+
# vector — one /embeddings POST per Cosmos batch instead of
986+
# one per record.
987+
to_embed_idx: list[int] = []
988+
to_embed_text: list[str] = []
989+
for i, body in enumerate(bodies):
990+
if body.get("type") != "turn" and body.get("content") and not body.get("embedding"):
991+
to_embed_idx.append(i)
992+
to_embed_text.append(body["content"])
993+
if to_embed_text:
994+
try:
995+
vectors = self._embeddings_client.generate_batch(to_embed_text)
996+
for i, vec in zip(to_embed_idx, vectors):
997+
bodies[i]["embedding"] = vec
998+
# Persist the embedding back to local_memory so a
999+
# repeat push_to_cosmos() doesn't re-embed.
1000+
self.local_memory[start + i]["embedding"] = vec
1001+
except Exception as exc: # noqa: BLE001
1002+
logger.warning(
1003+
"push_to_cosmos: batch embedding generation failed (%s); "
1004+
"proceeding without embeddings for %d records",
1005+
exc,
1006+
len(to_embed_text),
1007+
)
1008+
1009+
for record, body in zip(batch, bodies):
9531010
try:
9541011
self._container_client.upsert_item(body=body)
9551012
except Exception as exc:

agent_memory_toolkit/pipeline.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,13 +229,20 @@ def _load_existing_memories(
229229
memory_types: list[str],
230230
limit: int = 100,
231231
) -> list[dict[str, Any]]:
232-
"""Query active (non-superseded) memories for reconciliation context."""
232+
"""Query active (non-superseded) memories for reconciliation context.
233+
234+
Results are ordered by ``c._ts DESC`` so the most recently written
235+
memories survive the cap — without ORDER BY, Cosmos returns rows
236+
in implementation-defined order and the dedup comparison set is
237+
non-deterministic.
238+
"""
233239
type_placeholders = ", ".join(f"@mtype{i}" for i in range(len(memory_types)))
234240
query = (
235241
f"SELECT TOP {limit} * FROM c "
236242
f"WHERE c.user_id = @user_id "
237243
f"AND c.type IN ({type_placeholders}) "
238-
f"AND (NOT IS_DEFINED(c.superseded_by) OR c.superseded_by = null)"
244+
f"AND (NOT IS_DEFINED(c.superseded_by) OR c.superseded_by = null) "
245+
f"ORDER BY c._ts DESC"
239246
)
240247
parameters: list[dict[str, Any]] = [
241248
{"name": "@user_id", "value": user_id},
@@ -355,6 +362,7 @@ def extract_memories(
355362
self._container.query_items(
356363
query=query,
357364
parameters=parameters,
365+
partition_key=[user_id, thread_id],
358366
)
359367
)
360368

@@ -720,6 +728,7 @@ def generate_thread_summary(
720728
self._container.query_items(
721729
query=query,
722730
parameters=parameters,
731+
partition_key=[user_id, thread_id],
723732
)
724733
)
725734

agent_memory_toolkit/processors/inprocess.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,18 @@ def process_dedup(self, *, user_id: str) -> int:
120120

121121
@staticmethod
122122
def _extract_dedup_count(dedup: Any) -> int:
123-
if isinstance(dedup, dict):
124-
for key in ("deduplicated", "merged", "removed", "deduplicated_count"):
125-
if key in dedup and isinstance(dedup[key], int):
126-
return dedup[key]
127-
return 0
123+
"""Sum the merged + superseded facts from a ``deduplicate_facts`` result.
124+
125+
``ProcessingPipeline.deduplicate_facts`` returns a dict with
126+
``{"kept", "merged", "superseded"}`` — both ``merged`` and
127+
``superseded`` represent facts that were consolidated, so they
128+
contribute to the dedup-count metric.
129+
"""
130+
if not isinstance(dedup, dict):
131+
return 0
132+
merged = dedup.get("merged", 0) if isinstance(dedup.get("merged"), int) else 0
133+
superseded = dedup.get("superseded", 0) if isinstance(dedup.get("superseded"), int) else 0
134+
return merged + superseded
128135

129136
def generate_user_summary(
130137
self,

tests/unit/aio/processors/test_inprocess.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ async def test_process_thread_calls_summarize_extract_dedup_in_order():
1414
pipeline = MagicMock()
1515
pipeline.generate_thread_summary.return_value = {"id": "summary"}
1616
pipeline.extract_memories.return_value = {"facts": 1}
17-
pipeline.deduplicate_facts.return_value = {"deduplicated": 2}
17+
pipeline.deduplicate_facts.return_value = {"merged": 2, "superseded": 0, "kept": 3}
1818

1919
proc = AsyncInProcessProcessor(pipeline=pipeline)
2020
result = await proc.process_thread(user_id="u", thread_id="t", turns=[])

0 commit comments

Comments
 (0)