Skip to content

Commit 3ecc6f1

Browse files
Aayush KatariaAayush Kataria
authored andcommitted
code improvements
1 parent 6ce71ba commit 3ecc6f1

20 files changed

Lines changed: 339 additions & 121 deletions

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,12 @@ By default, the **InProcess processor** runs each pipeline step independently as
211211

212212
| Env var | Default | Step that fires | Async behavior |
213213
|---|---|---|---|
214-
| `FACT_EXTRACTION_EVERY_N` | `1` (every turn) | `process_extract_memories` (extract + dedup) | scheduled via `asyncio.create_task` |
214+
| `FACT_EXTRACTION_EVERY_N` | `1` (every turn) | `process_extract_memories` | scheduled via `asyncio.create_task` |
215+
| `DEDUP_EVERY_N` | `5` | `process_dedup` (fires every Nth extract → effectively every `FACT_EXTRACTION_EVERY_N × DEDUP_EVERY_N` turns) | scheduled via `asyncio.create_task` |
215216
| `THREAD_SUMMARY_EVERY_N` | `10` | `process_thread_summary` | scheduled via `asyncio.create_task` |
216217
| `USER_SUMMARY_EVERY_N` | `20` | `process_user_summary` | scheduled via `asyncio.create_task` |
217218

218-
Each `*_EVERY_N=0` disables only that step. The Durable backend uses the same defaults via the change-feed function app, so the in-process and durable backends fire on the same turn boundaries — only the *where* differs. Calling `process_now()` is normally redundant — it remains as an explicit "process now" hook for tests, manual workflows, and operators who set every threshold to `0`.
219+
Each `*_EVERY_N=0` disables only that step. Dedup is gated independently of extract because cross-thread dedup is dramatically more expensive than per-thread extract (it reads every active fact for the user) — running it on every extract slammed AI Foundry. The Durable backend uses the same defaults via the change-feed function app (the function-app `azd` deploy bumps `FACT_EXTRACTION_EVERY_N` to `5` since the FA path is intended for higher-volume workloads). Calling `process_now()` is normally redundant — it remains as an explicit "process now" hook for tests, manual workflows, and operators who set every threshold to `0`.
219220

220221
The async client (`AsyncCosmosMemoryClient.push_to_cosmos`) does **not** await the auto-trigger; it schedules it as a background `asyncio.Task` so the write call returns as soon as the Cosmos upserts complete. Background failures are surfaced via `logger.warning` (search for `"Background auto-trigger task failed"`).
221222

agent_memory_toolkit/aio/cosmos_memory_client.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,11 @@ def __init__(
154154
# 2. Explicit cosmos_key / ai_foundry_api_key (relief for environments
155155
# where Cosmos control-plane RBAC is in private preview, etc.)
156156
# 3. DefaultAzureCredential() when use_default_credential=True
157-
self._owns_credential = False
157+
#
158+
# Track ownership separately for each credential so close() doesn't
159+
# accidentally close a user-supplied credential or leak one we created.
160+
self._owns_cosmos_credential = False
161+
self._owns_ai_foundry_credential = False
158162
if self._cosmos_credential is None and self._cosmos_key:
159163
self._cosmos_credential = self._cosmos_key
160164
# Keep a sync credential for the pipeline's sync Cosmos container
@@ -171,14 +175,17 @@ def __init__(
171175
try:
172176
from azure.identity.aio import DefaultAzureCredential
173177

174-
_default = DefaultAzureCredential()
175-
self._owns_credential = True
178+
# Use independent instances per consumer so close()
179+
# ordering is unambiguous and one consumer's lifecycle
180+
# can't tear down a credential another is still using.
181+
if needs_cosmos:
182+
self._cosmos_credential = DefaultAzureCredential()
183+
self._owns_cosmos_credential = True
184+
if needs_embed:
185+
self._ai_foundry_credential = DefaultAzureCredential()
186+
self._owns_ai_foundry_credential = True
176187
except ImportError:
177-
_default = None
178-
if needs_cosmos and _default is not None:
179-
self._cosmos_credential = _default
180-
if needs_embed and _default is not None:
181-
self._ai_foundry_credential = _default
188+
pass
182189
# Sync credential for pipeline (only when we don't already have a key)
183190
if self._sync_cosmos_credential is None:
184191
try:
@@ -266,10 +273,20 @@ async def close(self) -> None:
266273
pass
267274
self._sync_cosmos_client = None
268275
await self._embeddings_client.close()
269-
if self._owns_credential and self._cosmos_credential is not None:
276+
if self._owns_cosmos_credential and self._cosmos_credential is not None:
270277
close = getattr(self._cosmos_credential, "close", None)
271278
if close is not None:
272-
await close()
279+
try:
280+
await close()
281+
except Exception:
282+
pass
283+
if self._owns_ai_foundry_credential and self._ai_foundry_credential is not None:
284+
close = getattr(self._ai_foundry_credential, "close", None)
285+
if close is not None:
286+
try:
287+
await close()
288+
except Exception:
289+
pass
273290
# Sync credentials (Cosmos + AI Foundry fallback) close synchronously.
274291
for sync_cred in (self._sync_cosmos_credential, self._sync_ai_foundry_credential):
275292
if sync_cred is None:
@@ -1297,6 +1314,7 @@ async def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) ->
12971314
from ..thresholds import (
12981315
PROCESSOR_OWNER_DURABLE,
12991316
PROCESSOR_OWNER_INPROCESS,
1317+
get_dedup_every_n,
13001318
get_fact_extraction_every_n,
13011319
get_processor_owner,
13021320
get_thread_summary_every_n,
@@ -1330,9 +1348,14 @@ async def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) ->
13301348
n_facts = get_fact_extraction_every_n()
13311349
n_summary = get_thread_summary_every_n()
13321350
n_user = get_user_summary_every_n()
1351+
n_dedup = get_dedup_every_n()
13331352
if n_facts == 0 and n_summary == 0 and n_user == 0:
13341353
return
13351354

1355+
# Dedup fires every Nth EXTRACT, not every Nth turn — see the
1356+
# sync-client mirror for the same combined-threshold pattern.
1357+
n_dedup_turns = n_facts * n_dedup if (n_facts > 0 and n_dedup > 0) else 0
1358+
13361359
counter_container = self._get_counter_container()
13371360
if counter_container is None:
13381361
return
@@ -1349,6 +1372,7 @@ async def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) ->
13491372
n_facts=n_facts,
13501373
n_summary=n_summary,
13511374
n_user=n_user,
1375+
n_dedup_turns=n_dedup_turns,
13521376
)
13531377

13541378
async def _run_auto_trigger_steps(
@@ -1361,6 +1385,7 @@ async def _run_auto_trigger_steps(
13611385
n_facts: int,
13621386
n_summary: int,
13631387
n_user: int,
1388+
n_dedup_turns: int = 0,
13641389
) -> None:
13651390
from .._counters import (
13661391
USER_COUNTER_THREAD_ID,
@@ -1394,6 +1419,7 @@ async def _run_auto_trigger_steps(
13941419

13951420
fire_extract = n_facts > 0 and crosses_threshold(old_count, new_count, n_facts)
13961421
fire_summary = n_summary > 0 and crosses_threshold(old_count, new_count, n_summary)
1422+
fire_dedup = n_dedup_turns > 0 and crosses_threshold(old_count, new_count, n_dedup_turns)
13971423

13981424
if fire_extract:
13991425
try:
@@ -1413,6 +1439,23 @@ async def _run_auto_trigger_steps(
14131439
f"process_extract_memories: {exc!r}",
14141440
)
14151441

1442+
if fire_dedup:
1443+
try:
1444+
await processor.process_dedup(user_id=user_id)
1445+
except Exception as exc:
1446+
logger.warning(
1447+
"Auto-trigger process_dedup failed for %s: %s",
1448+
user_id,
1449+
exc,
1450+
)
1451+
await stamp_failure_async(
1452+
counter_container,
1453+
thread_counter_id(user_id, thread_id),
1454+
user_id,
1455+
thread_id,
1456+
f"process_dedup: {exc!r}",
1457+
)
1458+
14161459
if fire_summary:
14171460
try:
14181461
await processor.process_thread_summary(user_id=user_id, thread_id=thread_id)

agent_memory_toolkit/aio/processors/inprocess.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,6 @@ async def process_extract_memories(
8989
thread_id: str,
9090
) -> dict[str, int]:
9191
extracted = await asyncio.to_thread(self._pipeline.extract_memories, user_id, thread_id)
92-
try:
93-
await asyncio.to_thread(self._pipeline.deduplicate_facts, user_id)
94-
except Exception as exc: # pragma: no cover - defensive
95-
logger.warning("post-extract dedup failed user_id=%s: %s", user_id, exc)
9692
return {k: v for k, v in extracted.items() if isinstance(v, int)} if isinstance(extracted, dict) else {}
9793

9894
async def process_thread_summary(

agent_memory_toolkit/chat.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,25 @@ def _unsupported_param(exc: Exception) -> str | None:
7575
# ---------------------------------------------------------------------------
7676

7777

78+
def _extract_content(response: Any, model: str) -> str:
79+
"""Pull the assistant content out of a chat-completions response.
80+
81+
Raises ``LLMError`` when the model returned no content (content filter,
82+
max-tokens-no-output, or certain reasoning-model paths). Without this
83+
guard, downstream JSON parsing crashes with ``AttributeError: 'NoneType'
84+
object has no attribute 'strip'`` and the actual root cause is invisible
85+
in App Insights.
86+
"""
87+
if not response.choices:
88+
raise LLMError(f"LLM returned no choices (model={model})")
89+
choice = response.choices[0]
90+
content = getattr(choice.message, "content", None)
91+
if content is None:
92+
finish_reason = getattr(choice, "finish_reason", "unknown")
93+
raise LLMError(f"LLM returned no content (model={model}, finish_reason={finish_reason})")
94+
return content
95+
96+
7897
class ChatClient:
7998
"""Synchronous LLM chat completion client backed by Azure OpenAI.
8099
@@ -265,7 +284,7 @@ def generate(
265284
usage.completion_tokens,
266285
usage.total_tokens,
267286
)
268-
return response.choices[0].message.content
287+
return _extract_content(response, self._model)
269288
except openai.RateLimitError as exc:
270289
if attempt < max_retries - 1:
271290
delay = base_delay * (2**attempt)
@@ -363,7 +382,7 @@ async def agenerate(
363382
usage.completion_tokens,
364383
usage.total_tokens,
365384
)
366-
return response.choices[0].message.content
385+
return _extract_content(response, self._model)
367386
except openai.RateLimitError as exc:
368387
if attempt < max_retries - 1:
369388
delay = base_delay * (2**attempt)

agent_memory_toolkit/cosmos_memory_client.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ def __init__(
142142
# 2. Explicit cosmos_key / ai_foundry_api_key (relief for environments
143143
# where Cosmos control-plane RBAC is in private preview, etc.)
144144
# 3. DefaultAzureCredential() when use_default_credential=True
145+
#
146+
# Track ownership separately so close() doesn't accidentally close a
147+
# user-supplied credential or leak one we created.
148+
self._owns_cosmos_credential = False
149+
self._owns_ai_foundry_credential = False
145150
if self._cosmos_credential is None and self._cosmos_key:
146151
self._cosmos_credential = self._cosmos_key
147152

@@ -152,13 +157,14 @@ def __init__(
152157
try:
153158
from azure.identity import DefaultAzureCredential
154159

155-
_default = DefaultAzureCredential()
160+
if needs_cosmos:
161+
self._cosmos_credential = DefaultAzureCredential()
162+
self._owns_cosmos_credential = True
163+
if needs_embed:
164+
self._ai_foundry_credential = DefaultAzureCredential()
165+
self._owns_ai_foundry_credential = True
156166
except ImportError:
157-
_default = None
158-
if needs_cosmos and _default is not None:
159-
self._cosmos_credential = _default
160-
if needs_embed and _default is not None:
161-
self._ai_foundry_credential = _default
167+
pass
162168

163169
# Internal Cosmos SDK handles
164170
self._cosmos_client: Any = None
@@ -213,6 +219,20 @@ def close(self) -> None:
213219
self._container_client = None
214220
self._counter_container_client = None
215221
logger.info("Cosmos client closed")
222+
# Close credentials we created ourselves (sync DefaultAzureCredential
223+
# holds an underlying token cache + HTTP transport).
224+
for owns, cred in (
225+
(self._owns_cosmos_credential, self._cosmos_credential),
226+
(self._owns_ai_foundry_credential, self._ai_foundry_credential),
227+
):
228+
if not owns or cred is None:
229+
continue
230+
close = getattr(cred, "close", None)
231+
if callable(close):
232+
try:
233+
close()
234+
except Exception:
235+
pass
216236

217237
# ------------------------------------------------------------------
218238
# Read-only properties
@@ -621,6 +641,7 @@ def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) -> None:
621641
from .thresholds import (
622642
PROCESSOR_OWNER_DURABLE,
623643
PROCESSOR_OWNER_INPROCESS,
644+
get_dedup_every_n,
624645
get_fact_extraction_every_n,
625646
get_processor_owner,
626647
get_thread_summary_every_n,
@@ -660,9 +681,15 @@ def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) -> None:
660681
n_facts = get_fact_extraction_every_n()
661682
n_summary = get_thread_summary_every_n()
662683
n_user = get_user_summary_every_n()
684+
n_dedup = get_dedup_every_n()
663685
if n_facts == 0 and n_summary == 0 and n_user == 0:
664686
return
665687

688+
# Dedup fires every Nth EXTRACT, not every Nth turn. Express that
689+
# via the same thread counter using the combined threshold
690+
# n_facts * n_dedup. Disabled when either knob is 0.
691+
n_dedup_turns = n_facts * n_dedup if (n_facts > 0 and n_dedup > 0) else 0
692+
666693
counter_container = self._get_counter_container()
667694
if counter_container is None:
668695
return
@@ -691,6 +718,7 @@ def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) -> None:
691718

692719
fire_extract = n_facts > 0 and crosses_threshold(old_count, new_count, n_facts)
693720
fire_summary = n_summary > 0 and crosses_threshold(old_count, new_count, n_summary)
721+
fire_dedup = n_dedup_turns > 0 and crosses_threshold(old_count, new_count, n_dedup_turns)
694722

695723
if fire_extract:
696724
try:
@@ -710,6 +738,23 @@ def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) -> None:
710738
f"process_extract_memories: {exc!r}",
711739
)
712740

741+
if fire_dedup:
742+
try:
743+
processor.process_dedup(user_id=user_id)
744+
except Exception as exc:
745+
logger.warning(
746+
"Auto-trigger process_dedup failed for %s: %s",
747+
user_id,
748+
exc,
749+
)
750+
stamp_failure_sync(
751+
counter_container,
752+
thread_counter_id(user_id, thread_id),
753+
user_id,
754+
thread_id,
755+
f"process_dedup: {exc!r}",
756+
)
757+
713758
if fire_summary:
714759
try:
715760
processor.process_thread_summary(user_id=user_id, thread_id=thread_id)

0 commit comments

Comments
 (0)