Skip to content

Commit 22a61a9

Browse files
Aayush KatariaAayush Kataria
authored andcommitted
code improvements
1 parent 32f990b commit 22a61a9

11 files changed

Lines changed: 267 additions & 51 deletions

File tree

agent_memory_toolkit/aio/cosmos_memory_client.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +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] = {}
116117

117118
self._background_tasks: set[asyncio.Task[Any]] = set()
118119
try:
@@ -155,6 +156,8 @@ def __init__(
155156
# accidentally close a user-supplied credential or leak one we created.
156157
self._owns_cosmos_credential = False
157158
self._owns_ai_foundry_credential = False
159+
self._owns_sync_cosmos_credential = False
160+
self._owns_sync_ai_foundry_credential = False
158161
if self._cosmos_credential is None and self._cosmos_key:
159162
self._cosmos_credential = self._cosmos_key
160163
# Keep a sync credential for the pipeline's sync Cosmos container
@@ -188,6 +191,7 @@ def __init__(
188191
from azure.identity import DefaultAzureCredential as SyncDefaultAzureCredential
189192

190193
self._sync_cosmos_credential = SyncDefaultAzureCredential()
194+
self._owns_sync_cosmos_credential = True
191195
except ImportError:
192196
self._sync_cosmos_credential = None
193197
# Sync credential for AI Foundry (used by sync ChatClient inside
@@ -197,6 +201,7 @@ def __init__(
197201
from azure.identity import DefaultAzureCredential as SyncDefaultAzureCredential
198202

199203
self._sync_ai_foundry_credential = SyncDefaultAzureCredential()
204+
self._owns_sync_ai_foundry_credential = True
200205
except ImportError:
201206
self._sync_ai_foundry_credential = None
202207

@@ -300,9 +305,11 @@ async def close(self) -> None:
300305
await close()
301306
except Exception:
302307
pass
303-
# Sync credentials (Cosmos + AI Foundry fallback) close synchronously.
304-
for sync_cred in (self._sync_cosmos_credential, self._sync_ai_foundry_credential):
305-
if sync_cred is None:
308+
for owns, sync_cred in (
309+
(self._owns_sync_cosmos_credential, self._sync_cosmos_credential),
310+
(self._owns_sync_ai_foundry_credential, self._sync_ai_foundry_credential),
311+
):
312+
if not owns or sync_cred is None:
306313
continue
307314
close = getattr(sync_cred, "close", None)
308315
if callable(close):
@@ -343,6 +350,9 @@ def add_local(
343350
salience=salience,
344351
)
345352
self.local_memory.append(memory)
353+
if memory_type == "turn":
354+
key = (user_id, thread_id)
355+
self._unflushed_turn_counts[key] = self._unflushed_turn_counts.get(key, 0) + 1
346356
logger.debug("add_local id=%s role=%s type=%s", memory["id"], role, memory_type)
347357

348358
def get_local(
@@ -701,11 +711,6 @@ async def push_to_cosmos(self, batch_size: int = 25) -> None:
701711
batch_size,
702712
)
703713
records = [MemoryRecord.from_cosmos_dict(dict(m)) for m in self.local_memory]
704-
turn_counts: dict[tuple[str, str], int] = {}
705-
for r in records:
706-
if str(getattr(r, "memory_type", "")) == "turn":
707-
key = (r.user_id, r.thread_id)
708-
turn_counts[key] = turn_counts.get(key, 0) + 1
709714

710715
for start in range(0, len(records), batch_size):
711716
batch = records[start : start + batch_size]
@@ -717,6 +722,9 @@ async def push_to_cosmos(self, batch_size: int = 25) -> None:
717722

718723
logger.info("Async upserted batch of %d records", len(records))
719724

725+
turn_counts = self._unflushed_turn_counts
726+
self._unflushed_turn_counts = {}
727+
720728
if turn_counts:
721729
try:
722730
task = asyncio.create_task(self._maybe_auto_trigger(turn_counts))

agent_memory_toolkit/cosmos_memory_client.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +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] = {}
115116
self._warned_owner_skip: bool = False
116117
self._warned_counter_unreachable: bool = False
117118

@@ -278,6 +279,9 @@ def add_local(
278279
salience=salience,
279280
)
280281
self.local_memory.append(memory)
282+
if memory_type == "turn":
283+
key = (user_id, thread_id)
284+
self._unflushed_turn_counts[key] = self._unflushed_turn_counts.get(key, 0) + 1
281285
logger.debug("add_local id=%s role=%s type=%s", memory["id"], role, memory_type)
282286

283287
def get_local(
@@ -935,7 +939,6 @@ def push_to_cosmos(self, batch_size: int = 25) -> None:
935939
batch_size,
936940
)
937941
records = [MemoryRecord.from_cosmos_dict(dict(m)) for m in self.local_memory]
938-
turn_counts: dict[tuple[str, str], int] = {}
939942
for start in range(0, len(records), batch_size):
940943
batch = records[start : start + batch_size]
941944
for record in batch:
@@ -944,11 +947,11 @@ def push_to_cosmos(self, batch_size: int = 25) -> None:
944947
self._container_client.upsert_item(body=body)
945948
except Exception as exc:
946949
raise CosmosOperationError(f"Upsert failed for record {record.id}: {exc}") from exc
947-
if str(getattr(record, "memory_type", "")) == "turn":
948-
key = (record.user_id, record.thread_id)
949-
turn_counts[key] = turn_counts.get(key, 0) + 1
950950
logger.info("Upserted batch of %d records", len(records))
951951

952+
turn_counts = self._unflushed_turn_counts
953+
self._unflushed_turn_counts = {}
954+
952955
try:
953956
self._maybe_auto_trigger(turn_counts)
954957
except Exception as exc: # pragma: no cover - defensive

agent_memory_toolkit/pipeline.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from typing import Any, Optional
1717

1818
from ._utils import DEFAULT_TTL_BY_TYPE, compute_content_hash
19-
from .exceptions import ValidationError
19+
from .exceptions import LLMError, ValidationError
2020

2121
logger = logging.getLogger(__name__)
2222

@@ -301,16 +301,24 @@ def _mark_superseded(self, old_doc: dict[str, Any], superseder_id: str) -> bool:
301301
return False
302302

303303
@staticmethod
304-
def _parse_llm_json(text: str) -> dict[str, Any]:
304+
def _parse_llm_json(text: str | None) -> dict[str, Any]:
305305
"""Parse JSON from an LLM response, stripping markdown fences."""
306+
if text is None:
307+
raise LLMError("LLM returned no content (None response body)")
306308
cleaned = text.strip()
307309
if cleaned.startswith("```"):
308-
# Remove opening fence (possibly with language hint)
309-
first_newline = cleaned.index("\n")
310-
cleaned = cleaned[first_newline + 1 :]
310+
first_newline = cleaned.find("\n")
311+
if first_newline >= 0:
312+
cleaned = cleaned[first_newline + 1 :]
313+
else:
314+
cleaned = cleaned.lstrip("`").lstrip()
311315
if cleaned.endswith("```"):
312316
cleaned = cleaned[:-3]
313-
return json.loads(cleaned.strip())
317+
try:
318+
return json.loads(cleaned.strip())
319+
except json.JSONDecodeError as exc:
320+
preview = (text or "")[:200].replace("\n", " ")
321+
raise LLMError(f"LLM returned invalid JSON (preview={preview!r}): {exc}") from exc
314322

315323
# -----------------------------------------------------------------------
316324
# Public API
@@ -651,7 +659,9 @@ def extract_memories(
651659
self._upsert_memory(doc)
652660

653661
result = {
654-
"facts_count": sum(1 for d in docs_to_embed if d["type"] == "fact"),
662+
"facts_count": sum(
663+
1 for d in docs_to_embed if d["type"] == "fact" and "sys:unclassified" not in d.get("tags", [])
664+
),
655665
"procedural_count": sum(1 for d in docs_to_embed if d["type"] == "procedural"),
656666
"episodic_count": sum(1 for d in docs_to_embed if d["type"] == "episodic"),
657667
"unclassified_count": sum(1 for d in docs_to_embed if "sys:unclassified" in d.get("tags", [])),
@@ -704,6 +714,8 @@ def generate_thread_summary(
704714
query += " AND c.created_at > @since"
705715
parameters.append({"name": "@since", "value": since})
706716

717+
query_started_at = datetime.now(timezone.utc).isoformat()
718+
707719
items = list(
708720
self._container.query_items(
709721
query=query,
@@ -762,7 +774,6 @@ def generate_thread_summary(
762774
topic_tags = [f"topic:{t}" for t in topics]
763775
tags = ["sys:summary"] + topic_tags
764776

765-
now = datetime.now(timezone.utc).isoformat()
766777
summary_doc: dict[str, Any] = {
767778
"id": summary_id,
768779
"user_id": user_id,
@@ -779,8 +790,8 @@ def generate_thread_summary(
779790
"recent_k": recent_k,
780791
"incremental_update": existing_summary is not None,
781792
},
782-
"created_at": existing_summary["created_at"] if existing_summary else now,
783-
"updated_at": now,
793+
"created_at": existing_summary["created_at"] if existing_summary else query_started_at,
794+
"updated_at": query_started_at,
784795
}
785796

786797
self._upsert_memory(summary_doc)
@@ -834,6 +845,8 @@ def generate_user_summary(
834845
for i, tid in enumerate(thread_ids):
835846
parameters.append({"name": f"@tid{i}", "value": tid})
836847

848+
query_started_at = datetime.now(timezone.utc).isoformat()
849+
837850
items = list(
838851
self._container.query_items(
839852
query=query,
@@ -904,7 +917,6 @@ def generate_user_summary(
904917
all_thread_ids = sorted(new_thread_ids)
905918
total_memory_count = len(items)
906919

907-
now = datetime.now(timezone.utc).isoformat()
908920
summary_doc: dict[str, Any] = {
909921
"id": user_summary_id,
910922
"user_id": user_id,
@@ -923,8 +935,8 @@ def generate_user_summary(
923935
"recent_k": recent_k,
924936
"incremental_update": existing_summary is not None,
925937
},
926-
"created_at": existing_summary["created_at"] if existing_summary else now,
927-
"updated_at": now,
938+
"created_at": existing_summary["created_at"] if existing_summary else query_started_at,
939+
"updated_at": query_started_at,
928940
}
929941

930942
self._upsert_memory(summary_doc)
@@ -1040,6 +1052,18 @@ def deduplicate_facts(
10401052
cluster_facts[0],
10411053
)
10421054

1055+
merged_tags: list[str] = []
1056+
seen_tags: set[str] = set()
1057+
for f in cluster_facts:
1058+
if f["id"] not in source_ids:
1059+
continue
1060+
for t in f.get("tags", []):
1061+
if t not in seen_tags:
1062+
seen_tags.add(t)
1063+
merged_tags.append(t)
1064+
if not merged_tags:
1065+
merged_tags = ["sys:fact"]
1066+
10431067
source_confidences = [
10441068
c for f in cluster_facts if f["id"] in source_ids and (c := f.get("confidence")) is not None
10451069
]
@@ -1059,7 +1083,7 @@ def deduplicate_facts(
10591083
},
10601084
"salience": act.get("salience"),
10611085
"supersedes_ids": source_ids,
1062-
"tags": source_fact.get("tags", ["sys:fact"]),
1086+
"tags": merged_tags,
10631087
"created_at": now,
10641088
}
10651089
if merged_confidence is not None:
98.4 KB
Binary file not shown.

function_app/shared/counters.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,14 @@ async def increment_counter_by(
9090
times on HTTP 412.
9191
* Uses ``create_item`` for the first-write path, retrying on HTTP 409 in
9292
case multiple Function workers raced to seed the counter.
93-
* If ``batch_max_lsn`` matches the LSN persisted on the existing doc, this
94-
is treated as a change-feed replay and we return the cached
95-
``(pre_batch_count, current_count)`` **without writing** so the
96-
threshold-crossing semantics are preserved without double-counting.
93+
* If ``batch_max_lsn`` is *less than or equal to* the LSN persisted on the
94+
existing doc, this is treated as a change-feed replay (immediate or
95+
after a lease re-balance / host crash where checkpoints regressed) and
96+
we return without writing. For the equal case we return the cached
97+
``(pre_batch_count, current_count)`` so threshold-crossing semantics
98+
hold; for the strict-less case we return ``(current, current)`` (no
99+
crossing) because some other batch already advanced the counter past
100+
this one.
97101
* Preserves SDK-written failure breadcrumbs (``last_failure_at`` /
98102
``last_failure_reason``) so monitors don't flap when the FA writes
99103
after an SDK failure stamp.
@@ -123,18 +127,35 @@ async def increment_counter_by(
123127
)
124128

125129
# ---- Replay detection via LSN ----
130+
# Use ``>=`` not ``==`` so out-of-order redeliveries (lease
131+
# re-balance, host crash → checkpoint regression where another
132+
# batch landed in between) also short-circuit. For the exact
133+
# match we replay the cached result; for the strict-greater case
134+
# we return (current, current) — no threshold crossing — because
135+
# the batch's effect is already absorbed in a later state we have
136+
# no cached pre-batch value for.
126137
if (
127138
batch_max_lsn is not None
128139
and existing_doc is not None
129-
and existing_doc.get("last_batch_lsn") == batch_max_lsn
140+
and existing_doc.get("last_batch_lsn") is not None
141+
and existing_doc["last_batch_lsn"] >= batch_max_lsn
130142
):
131-
replay_old = existing_doc.get("last_batch_old_count", old_count)
143+
stored_lsn = existing_doc["last_batch_lsn"]
144+
if stored_lsn == batch_max_lsn:
145+
replay_old = existing_doc.get("last_batch_old_count", old_count)
146+
logger.info(
147+
"Counter replay detected counter_id=%s lsn=%s, returning cached result",
148+
counter_id,
149+
batch_max_lsn,
150+
)
151+
return (replay_old, old_count)
132152
logger.info(
133-
"Counter replay detected counter_id=%s lsn=%s, returning cached result",
153+
"Counter out-of-order replay counter_id=%s redelivered_lsn=%s stored_lsn=%s; no-op",
134154
counter_id,
135155
batch_max_lsn,
156+
stored_lsn,
136157
)
137-
return (replay_old, old_count)
158+
return (old_count, old_count)
138159

139160
new_count = old_count + count
140161
new_doc = {

0 commit comments

Comments
 (0)