Skip to content

Commit 32f990b

Browse files
Aayush KatariaAayush Kataria
authored andcommitted
code improvements
1 parent 3ecc6f1 commit 32f990b

8 files changed

Lines changed: 161 additions & 31 deletions

File tree

agent_memory_toolkit/aio/cosmos_memory_client.py

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -115,18 +115,14 @@ def __init__(
115115
self.local_memory: list[dict[str, Any]] = []
116116

117117
self._background_tasks: set[asyncio.Task[Any]] = set()
118-
# Backpressure for fire-and-forget auto-trigger tasks. Without a
119-
# cap, FACT_EXTRACTION_EVERY_N=1 + a burst of pushes spawns one
120-
# extract pipeline per push and slams AI Foundry. Configurable via
121-
# MEMORY_AUTO_TRIGGER_CONCURRENCY (default 4).
122118
try:
123119
_max = int(os.environ.get("MEMORY_AUTO_TRIGGER_CONCURRENCY", "4"))
124120
except ValueError:
125121
_max = 4
126122
self._auto_trigger_semaphore: asyncio.Semaphore = asyncio.Semaphore(max(1, _max))
127-
# One-shot WARN guard: skip-due-to-MEMORY_PROCESSOR_OWNER=durable
128-
# would otherwise log on every push.
129123
self._warned_owner_skip: bool = False
124+
self._warned_counter_unreachable: bool = False
125+
self._pipeline_init_error: Exception | None = None
130126

131127
# Store kwargs directly
132128
self._cosmos_endpoint = cosmos_endpoint
@@ -249,16 +245,23 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
249245
async def close(self) -> None:
250246
"""Close all underlying async clients.
251247
252-
Drains any in-flight fire-and-forget auto-trigger tasks first so
253-
they don't continue running against torn-down Cosmos/embeddings
254-
handles.
248+
Cancels any in-flight fire-and-forget auto-trigger tasks first so
249+
``close()`` / ``__aexit__`` doesn't block for tens of seconds while
250+
a mid-LLM extract runs to completion. Auto-trigger work is
251+
recoverable on the next push, so cancellation is safe.
255252
"""
256-
# Drain fire-and-forget auto-trigger tasks before tearing down the
257-
# underlying clients, otherwise an in-flight extract+dedup will
258-
# crash with CosmosOperationError mid-shutdown.
253+
# Cancel + bounded-wait. Without the cancel, an in-flight extract+dedup
254+
# can hold close() for tens of seconds. With cancel + 5s wait we
255+
# guarantee bounded shutdown latency; remaining tasks raise
256+
# CancelledError and will be retried on the next push_to_cosmos.
259257
pending = list(self._background_tasks)
260258
if pending:
261-
await asyncio.gather(*pending, return_exceptions=True)
259+
for task in pending:
260+
task.cancel()
261+
try:
262+
await asyncio.wait(pending, timeout=5.0)
263+
except Exception:
264+
pass
262265
if self._cosmos_client is not None:
263266
await self._cosmos_client.close()
264267
self._cosmos_client = None
@@ -273,6 +276,16 @@ async def close(self) -> None:
273276
pass
274277
self._sync_cosmos_client = None
275278
await self._embeddings_client.close()
279+
# Async client's ChatClient is shared with the sync pipeline; close
280+
# its sync httpx pool too so we don't leak across __aexit__.
281+
try:
282+
self._chat_client.close_sync()
283+
except Exception:
284+
pass
285+
try:
286+
await self._chat_client.close()
287+
except Exception:
288+
pass
276289
if self._owns_cosmos_credential and self._cosmos_credential is not None:
277290
close = getattr(self._cosmos_credential, "close", None)
278291
if close is not None:
@@ -1213,7 +1226,14 @@ def _init_pipeline(self) -> None:
12131226
sync_db = sync_client.get_database_client(self._cosmos_database)
12141227
sync_container = sync_db.get_container_client(self._cosmos_container)
12151228
self._sync_cosmos_client = sync_client
1229+
self._pipeline_init_error = None
12161230
except Exception as exc:
1231+
# Capture the real failure so _require_pipeline can surface it
1232+
# later — the alternative is the user calls connect_cosmos()
1233+
# successfully, then sees CosmosNotConnectedError("call
1234+
# connect_cosmos() first") on the first extract, with the real
1235+
# cause buried in an old WARN.
1236+
self._pipeline_init_error = exc
12171237
logger.warning("Failed to create sync Cosmos client for pipeline: %s", exc)
12181238
sync_container = None
12191239

@@ -1268,6 +1288,14 @@ def _warn_on_embedding_dim_mismatch(self, sync_container: Any) -> None:
12681288
def _require_pipeline(self) -> None:
12691289
"""Raise if the processing pipeline is not available."""
12701290
if self._pipeline is None:
1291+
if self._pipeline_init_error is not None:
1292+
raise CosmosNotConnectedError(
1293+
"Processing pipeline failed to initialize "
1294+
f"({type(self._pipeline_init_error).__name__}: "
1295+
f"{self._pipeline_init_error}). The async client connected "
1296+
"but the sync Cosmos client used by the pipeline could "
1297+
"not be built. Check credentials and endpoint."
1298+
) from self._pipeline_init_error
12711299
raise CosmosNotConnectedError(
12721300
"Processing pipeline requires Cosmos DB connection. "
12731301
"Call connect_cosmos() or create_memory_store() first."
@@ -1281,7 +1309,11 @@ def _get_processor(self) -> AsyncMemoryProcessor:
12811309
return self._processor
12821310

12831311
def _get_counter_container(self) -> Any:
1284-
"""Lazy handle to the counter container (best-effort, returns None on failure)."""
1312+
"""Lazy handle to the counter container (best-effort, returns None on failure).
1313+
1314+
Logs a one-shot WARN on first failure so operators don't silently
1315+
lose all memory processing because of a missing counter container.
1316+
"""
12851317
if self._counter_container_client is not None:
12861318
return self._counter_container_client
12871319
if self._cosmos_client is None:
@@ -1291,12 +1323,18 @@ def _get_counter_container(self) -> Any:
12911323
self._counter_container_client = db.get_container_client(self._cosmos_counter_container)
12921324
return self._counter_container_client
12931325
except Exception as exc: # pragma: no cover - defensive
1294-
logger.warning(
1295-
"Counter container %s/%s unreachable; auto-trigger disabled: %s",
1296-
self._cosmos_database,
1297-
self._cosmos_counter_container,
1298-
exc,
1299-
)
1326+
if not self._warned_counter_unreachable:
1327+
self._warned_counter_unreachable = True
1328+
logger.warning(
1329+
"Counter container %s/%s unreachable (%s: %s); "
1330+
"auto-trigger DISABLED for the lifetime of this client. "
1331+
"Provision the container or set MEMORY_PROCESSOR_OWNER=durable "
1332+
"if processing runs in the Function App.",
1333+
self._cosmos_database,
1334+
self._cosmos_counter_container,
1335+
type(exc).__name__,
1336+
exc,
1337+
)
13001338
return None
13011339

13021340
async def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) -> None:
@@ -1421,6 +1459,14 @@ async def _run_auto_trigger_steps(
14211459
fire_summary = n_summary > 0 and crosses_threshold(old_count, new_count, n_summary)
14221460
fire_dedup = n_dedup_turns > 0 and crosses_threshold(old_count, new_count, n_dedup_turns)
14231461

1462+
# Order matters: extract → dedup → summary.
1463+
# Each step gates on its OWN threshold (independent crossings),
1464+
# but when multiple thresholds cross in the same batch we still
1465+
# want the data flow to be: write fresh facts, deduplicate them,
1466+
# then fold the deduplicated set into the summary. Reordering
1467+
# would risk a summary that includes since-removed duplicates
1468+
# or omits just-extracted facts. Keep in sync with the sync
1469+
# client and function_app/orchestrators/.
14241470
if fire_extract:
14251471
try:
14261472
await processor.process_extract_memories(user_id=user_id, thread_id=thread_id)

agent_memory_toolkit/chat.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import asyncio
1212
import logging
13+
import re
1314
import time
1415
from typing import Any
1516

@@ -62,10 +63,18 @@ async def _provider() -> str:
6263
def _unsupported_param(exc: Exception) -> str | None:
6364
"""If *exc* is a 400 about an unsupported sampling param, return its name."""
6465
msg = str(exc).lower()
65-
if "400" not in msg and "unsupported" not in msg and "does not support" not in msg:
66+
if "400" not in msg:
67+
return None
68+
if not (
69+
"does not support" in msg
70+
or "is not supported" in msg
71+
or "unsupported parameter" in msg
72+
or "unsupported value" in msg
73+
):
6674
return None
6775
for p in _SAMPLING_PARAMS:
68-
if p in msg:
76+
pattern = rf"(?<![a-z_]){re.escape(p)}(?![a-z_])"
77+
if re.search(pattern, msg):
6978
return p
7079
return None
7180

@@ -434,3 +443,19 @@ async def close(self) -> None:
434443
if self._async_client is not None:
435444
await self._async_client.close()
436445
self._async_client = None
446+
447+
def close_sync(self) -> None:
448+
"""Close the underlying sync HTTP client, if one has been created.
449+
450+
``openai.AzureOpenAI`` owns an httpx connection pool that leaks
451+
across ``with`` blocks unless closed explicitly. Sync callers should
452+
invoke this from their own ``close()`` to drain the pool.
453+
"""
454+
if self._client is not None:
455+
close = getattr(self._client, "close", None)
456+
if callable(close):
457+
try:
458+
close()
459+
except Exception:
460+
pass
461+
self._client = None

agent_memory_toolkit/cosmos_memory_client.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,8 @@ def __init__(
112112
) -> None:
113113
# Local store
114114
self.local_memory: list[dict[str, Any]] = []
115-
# One-shot WARN guard for MEMORY_PROCESSOR_OWNER=durable skip path
116-
# (see _maybe_auto_trigger).
117115
self._warned_owner_skip: bool = False
116+
self._warned_counter_unreachable: bool = False
118117

119118
# Store kwargs directly
120119
self._cosmos_endpoint = cosmos_endpoint
@@ -219,6 +218,16 @@ def close(self) -> None:
219218
self._container_client = None
220219
self._counter_container_client = None
221220
logger.info("Cosmos client closed")
221+
# Drain LLM/embeddings httpx pools — openai.AzureOpenAI keeps them
222+
# open across `with` blocks otherwise.
223+
try:
224+
self._chat_client.close_sync()
225+
except Exception:
226+
pass
227+
try:
228+
self._embeddings_client.close()
229+
except Exception:
230+
pass
222231
# Close credentials we created ourselves (sync DefaultAzureCredential
223232
# holds an underlying token cache + HTTP transport).
224233
for owns, cred in (
@@ -595,6 +604,9 @@ def _get_counter_container(self) -> Any:
595604
operator brought their own memory container without provisioning a
596605
counter container). Auto-trigger callers must tolerate ``None`` and
597606
skip the increment.
607+
608+
Logs a one-shot WARN on first failure so operators don't silently
609+
lose all memory processing because of a missing counter container.
598610
"""
599611
if self._counter_container_client is not None:
600612
return self._counter_container_client
@@ -605,12 +617,18 @@ def _get_counter_container(self) -> Any:
605617
self._counter_container_client = db.get_container_client(self._cosmos_counter_container)
606618
return self._counter_container_client
607619
except Exception as exc: # pragma: no cover - defensive
608-
logger.warning(
609-
"Counter container %s/%s unreachable; auto-trigger disabled: %s",
610-
self._cosmos_database,
611-
self._cosmos_counter_container,
612-
exc,
613-
)
620+
if not self._warned_counter_unreachable:
621+
self._warned_counter_unreachable = True
622+
logger.warning(
623+
"Counter container %s/%s unreachable (%s: %s); "
624+
"auto-trigger DISABLED for the lifetime of this client. "
625+
"Provision the container or set MEMORY_PROCESSOR_OWNER=durable "
626+
"if processing runs in the Function App.",
627+
self._cosmos_database,
628+
self._cosmos_counter_container,
629+
type(exc).__name__,
630+
exc,
631+
)
614632
return None
615633

616634
def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) -> None:
@@ -720,6 +738,15 @@ def _maybe_auto_trigger(self, turn_counts: dict[tuple[str, str], int]) -> None:
720738
fire_summary = n_summary > 0 and crosses_threshold(old_count, new_count, n_summary)
721739
fire_dedup = n_dedup_turns > 0 and crosses_threshold(old_count, new_count, n_dedup_turns)
722740

741+
# Order matters: extract → dedup → summary.
742+
# Each step gates on its OWN threshold (independent crossings),
743+
# but when multiple thresholds cross in the same batch we still
744+
# want the data flow to be: write fresh facts, deduplicate them,
745+
# then fold the deduplicated set into the summary. Reordering
746+
# would risk a summary that includes since-removed duplicates
747+
# or omits just-extracted facts. DO NOT reorder without
748+
# updating the equivalent block in aio/cosmos_memory_client.py
749+
# and function_app/orchestrators/.
723750
if fire_extract:
724751
try:
725752
processor.process_extract_memories(user_id=user_id, thread_id=thread_id)

agent_memory_toolkit/embeddings.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,18 @@ def generate_batch(self, texts: list[str]) -> list[list[float]]:
153153
# the caller receives embeddings in the same order as the input.
154154
sorted_data = sorted(response.data, key=lambda d: d.index)
155155
return [item.embedding for item in sorted_data]
156+
157+
def close(self) -> None:
158+
"""Close the underlying sync HTTP client, if one has been created.
159+
160+
``openai.AzureOpenAI`` owns an httpx connection pool that leaks
161+
across ``with`` blocks unless closed explicitly.
162+
"""
163+
if self._client is not None:
164+
close = getattr(self._client, "close", None)
165+
if callable(close):
166+
try:
167+
close()
168+
except Exception:
169+
pass
170+
self._client = None

function_app/host.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"applicationInsights": {
55
"samplingSettings": {
66
"isEnabled": true,
7-
"excludedTypes": "Request"
7+
"excludedTypes": "Request;Trace"
88
}
99
}
1010
},

function_app/shared/pipeline_factory.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def get_pipeline():
2222

2323
from azure.identity import DefaultAzureCredential
2424

25+
from agent_memory_toolkit._utils import _resolve_embedding_dimensions
2526
from agent_memory_toolkit.chat import ChatClient
2627
from agent_memory_toolkit.embeddings import EmbeddingsClient
2728
from agent_memory_toolkit.pipeline import ProcessingPipeline
@@ -30,6 +31,8 @@ def get_pipeline():
3031
container = get_memories_container()
3132
ai_endpoint = config.get_ai_foundry_endpoint()
3233

34+
embedding_dimensions = _resolve_embedding_dimensions(None)
35+
3336
llm = ChatClient(
3437
endpoint=ai_endpoint,
3538
credential=credential,
@@ -39,6 +42,7 @@ def get_pipeline():
3942
endpoint=ai_endpoint,
4043
credential=credential,
4144
model=config.get_embedding_deployment_name(),
45+
dimensions=embedding_dimensions,
4246
)
4347

4448
_pipeline = ProcessingPipeline(container, llm, embeddings)

infra/modules/cosmos.bicep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ resource counterContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/co
184184
'/thread_id'
185185
]
186186
}
187+
defaultTtl: 7776000
187188
}
188189
}
189190
}

infra/modules/functions.bicep

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ param aiFoundryEndpoint string
4747
@description('Embedding model deployment name.')
4848
param embeddingDeploymentName string = 'text-embedding-3-large'
4949

50+
@description('Embedding output dimensions. MUST match the dimensions configured in the Cosmos memories container vectorEmbeddingPolicy (default 1536).')
51+
param embeddingDimensions int = 1536
52+
5053
@description('LLM model deployment name.')
5154
param chatDeploymentName string = 'gpt-4o-mini'
5255

@@ -251,6 +254,15 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = {
251254
name: 'AI_FOUNDRY_EMBEDDING_DEPLOYMENT_NAME'
252255
value: embeddingDeploymentName
253256
}
257+
{
258+
// Pins the embedding output dim. Without this, text-embedding-3-large
259+
// returns its native 3072-dim vectors and Cosmos accepts them silently —
260+
// but DiskANN (configured for 1536 in cosmos.bicep) cannot match
261+
// them, so every FA-written memory becomes invisible to vector /
262+
// hybrid search. Must equal cosmos.bicep vectorEmbeddingPolicy dimensions.
263+
name: 'AI_FOUNDRY_EMBEDDING_DIMENSIONS'
264+
value: string(embeddingDimensions)
265+
}
254266
{
255267
name: 'AI_FOUNDRY_CHAT_DEPLOYMENT_NAME'
256268
value: chatDeploymentName

0 commit comments

Comments
 (0)