Skip to content

Commit ef1ae05

Browse files
Aayush KatariaAayush Kataria
authored andcommitted
resolving comments
1 parent 50d906d commit ef1ae05

13 files changed

Lines changed: 336 additions & 185 deletions

File tree

Docs/public_api.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414

1515
### Connection
1616

17-
- `__init__(cosmos_endpoint=None, cosmos_credential=None, cosmos_key=None, cosmos_database=None, cosmos_container=None, cosmos_counter_container=None, cosmos_lease_container=None, cosmos_throughput_mode=None, cosmos_autoscale_max_ru=None, ai_foundry_endpoint=None, ai_foundry_credential=None, ai_foundry_api_key=None, embedding_deployment_name='text-embedding-3-large', embedding_dimensions=None, chat_deployment_name='gpt-4o-mini', use_default_credential=True, processor=None) -> None` — configure local state, model clients, optional Cosmos auto-connect, and optional processing backend.
17+
- `__init__(cosmos_endpoint=None, cosmos_credential=None, cosmos_key=None, cosmos_database=None, cosmos_container=None, cosmos_turns_container=None, cosmos_counter_container=None, cosmos_lease_container=None, cosmos_throughput_mode=None, cosmos_autoscale_max_ru=None, ai_foundry_endpoint=None, ai_foundry_credential=None, ai_foundry_api_key=None, embedding_deployment_name='text-embedding-3-large', embedding_dimensions=None, chat_deployment_name='gpt-4o-mini', use_default_credential=True, processor=None) -> None` — configure local state, model clients, optional Cosmos auto-connect, and optional processing backend. When `cosmos_turns_container` is set, turn-type documents land in a dedicated container so the main `memories` container only fires the Durable change-feed trigger for processed memory writes.
1818
- `close() -> None` — close Cosmos/model clients and owned credentials.
19-
- `connect_cosmos(endpoint=None, credential=None, key=None, database=None, container=None) -> None` — connect to an existing memory container.
20-
- `create_memory_store(database=None, container=None, counter_container=None, lease_container=None, endpoint=None, credential=None, key=None, embedding_dimensions=None, embedding_data_type=None, distance_function=None, full_text_language=None, throughput_mode=None, autoscale_max_ru=None) -> None` — create/connect the memory, counter, and lease containers.
19+
- `connect_cosmos(endpoint=None, credential=None, key=None, database=None, container=None, turns_container=None) -> None` — connect to an existing memory container.
20+
- `create_memory_store(database=None, container=None, turns_container=None, counter_container=None, lease_container=None, endpoint=None, credential=None, key=None, embedding_dimensions=None, embedding_data_type=None, distance_function=None, full_text_language=None, throughput_mode=None, autoscale_max_ru=None) -> None` — create/connect the memory, optional turns, counter, and lease containers.
2121

2222
### Memory CRUD
2323

@@ -65,10 +65,10 @@ Local-buffer methods remain synchronous in-memory operations; Cosmos, retrieval,
6565

6666
### Connection
6767

68-
- `__init__(cosmos_endpoint=None, cosmos_credential=None, cosmos_key=None, cosmos_database=None, cosmos_container=None, cosmos_counter_container=None, cosmos_lease_container=None, cosmos_throughput_mode=None, cosmos_autoscale_max_ru=None, ai_foundry_endpoint=None, ai_foundry_credential=None, ai_foundry_api_key=None, embedding_deployment_name='text-embedding-3-large', embedding_dimensions=None, chat_deployment_name='gpt-4o-mini', use_default_credential=True, processor=None) -> None` — configure async local state, model clients, and optional processing backend.
68+
- `__init__(cosmos_endpoint=None, cosmos_credential=None, cosmos_key=None, cosmos_database=None, cosmos_container=None, cosmos_turns_container=None, cosmos_counter_container=None, cosmos_lease_container=None, cosmos_throughput_mode=None, cosmos_autoscale_max_ru=None, ai_foundry_endpoint=None, ai_foundry_credential=None, ai_foundry_api_key=None, embedding_deployment_name='text-embedding-3-large', embedding_dimensions=None, chat_deployment_name='gpt-4o-mini', use_default_credential=True, processor=None) -> None` — configure async local state, model clients, and optional processing backend. When `cosmos_turns_container` is set, turn-type documents land in a dedicated container so the main `memories` container only fires the Durable change-feed trigger for processed memory writes.
6969
- `async close() -> None` — close async/sync resources and owned credentials.
70-
- `async connect_cosmos(endpoint=None, credential=None, key=None, database=None, container=None) -> None` — connect to an existing memory container.
71-
- `async create_memory_store(database=None, container=None, counter_container=None, lease_container=None, endpoint=None, credential=None, key=None, embedding_dimensions=None, embedding_data_type=None, distance_function=None, full_text_language=None, throughput_mode=None, autoscale_max_ru=None) -> None` — create/connect memory, counter, and lease containers.
70+
- `async connect_cosmos(endpoint=None, credential=None, key=None, database=None, container=None, turns_container=None) -> None` — connect to an existing memory container.
71+
- `async create_memory_store(database=None, container=None, turns_container=None, counter_container=None, lease_container=None, endpoint=None, credential=None, key=None, embedding_dimensions=None, embedding_data_type=None, distance_function=None, full_text_language=None, throughput_mode=None, autoscale_max_ru=None) -> None` — create/connect memory, optional turns, counter, and lease containers.
7272

7373
### Memory CRUD
7474

@@ -119,5 +119,5 @@ Sync extension protocols live in `agent_memory_toolkit.services`; async variants
119119
Concrete service classes are exported from their respective packages:
120120

121121
- Sync: `RetrievalService`, `PipelineService` from `agent_memory_toolkit.services` (sub-modules `retrieval`, `pipeline`).
122-
- Async: `AsyncRetrievalService` from `agent_memory_toolkit.aio.services` (sub-module `retrieval`). The pipeline is sync-only and is driven through `asyncio.to_thread` by the async client.
122+
- Async: `AsyncRetrievalService` and `AsyncPipelineService` from `agent_memory_toolkit.aio.services` (sub-modules `retrieval`, `pipeline`). The async pipeline is a fully-native asyncio implementation — not an `asyncio.to_thread` shim over the sync pipeline.
123123
- Threshold-driven auto-trigger: `maybe_trigger_steps` from `agent_memory_toolkit.auto_trigger` (sync) and `agent_memory_toolkit.aio.auto_trigger` (async).

agent_memory_toolkit/aio/processors/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ async def generate_user_summary(
6262
thread_summaries: list[dict[str, Any]],
6363
) -> UserSummaryResult: ...
6464

65+
async def synthesize_procedural(
66+
self,
67+
*,
68+
user_id: str,
69+
force: bool = False,
70+
) -> dict[str, Any]: ...
71+
6572
async def close(self) -> None: ...
6673

6774

agent_memory_toolkit/aio/services/pipeline.py

Lines changed: 79 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@
6464
_coerce_valence = coerce_valence
6565
_cap_structured_summary = cap_structured_summary
6666

67+
_ACTIVE_DOC_FILTER = "(NOT IS_DEFINED(c.superseded_by) OR IS_NULL(c.superseded_by))"
68+
_PROCEDURAL_MAX_CREATE_ATTEMPTS = 5
69+
6770

6871
class _AsyncStoreContainerAdapter:
6972
"""Async equivalent of ``services.pipeline._StoreContainerAdapter``.
@@ -195,7 +198,7 @@ async def _load_existing_memories(
195198
f"SELECT TOP {capped_limit} * FROM c "
196199
f"WHERE c.user_id = @user_id "
197200
f"AND c.type IN ({type_placeholders}) "
198-
f"AND (NOT IS_DEFINED(c.superseded_by) OR IS_NULL(c.superseded_by)) "
201+
f"AND {_ACTIVE_DOC_FILTER} "
199202
f"ORDER BY c._ts DESC"
200203
)
201204
parameters: list[dict[str, Any]] = [
@@ -277,7 +280,7 @@ async def _mark_extracted_superseded(
277280
try:
278281
q = (
279282
"SELECT * FROM c WHERE c.id = @id AND c.user_id = @uid "
280-
"AND (NOT IS_DEFINED(c.superseded_by) OR IS_NULL(c.superseded_by))"
283+
f"AND {_ACTIVE_DOC_FILTER}"
281284
)
282285
results = await self._container.query_items(
283286
query=q,
@@ -392,8 +395,6 @@ async def extract_memories_dry(
392395

393396
for fact in facts:
394397
action = fact.get("action", "ADD").upper()
395-
if action == "NONE":
396-
continue
397398
text = fact.get("text")
398399
if not text:
399400
logger.warning("extract_memories: dropping malformed fact (missing 'text'): %r", fact)
@@ -665,15 +666,13 @@ async def synthesize_procedural(
665666

666667
logger.info("synthesize_procedural started user_id=%s force=%s", user_id, force)
667668

668-
active_filter = "(NOT IS_DEFINED(c.superseded_by) OR c.superseded_by = null)"
669-
670669
async def _read_latest_procedural() -> Optional[dict[str, Any]]:
671670
docs = await self._container.query_items(
672671
query=(
673672
"SELECT * FROM c WHERE c.user_id = @uid "
674673
"AND c.thread_id = @thread_id "
675674
"AND c.type = @type "
676-
f"AND {active_filter}"
675+
f"AND {_ACTIVE_DOC_FILTER}"
677676
),
678677
parameters=[
679678
{"name": "@uid", "value": user_id},
@@ -700,7 +699,7 @@ async def _read_latest_procedural() -> Optional[dict[str, Any]]:
700699
query=(
701700
"SELECT TOP 50 * FROM c WHERE c.user_id = @uid "
702701
"AND c.type = @type "
703-
f"AND {active_filter} "
702+
f"AND {_ACTIVE_DOC_FILTER} "
704703
"AND ((IS_DEFINED(c.metadata.category) "
705704
"AND c.metadata.category IN ('preference', 'requirement')) "
706705
"OR (IS_DEFINED(c.salience) AND c.salience >= @min_salience)) "
@@ -724,7 +723,7 @@ async def _read_latest_procedural() -> Optional[dict[str, Any]]:
724723
query=(
725724
"SELECT TOP 50 * FROM c WHERE c.user_id = @uid "
726725
"AND c.type = @type "
727-
f"AND {active_filter} "
726+
f"AND {_ACTIVE_DOC_FILTER} "
728727
"AND IS_DEFINED(c.metadata.lesson) "
729728
"AND c.metadata.lesson != null "
730729
"ORDER BY c.salience DESC, c.created_at ASC, c.id ASC"
@@ -744,12 +743,14 @@ async def _read_latest_procedural() -> Optional[dict[str, Any]]:
744743
source_episodic_ids = [doc["id"] for doc in episodic_with_lessons]
745744

746745
current_source_ids = set(behavioral_fact_ids) | set(source_episodic_ids)
747-
prior_source_ids = (
748-
set(prior_doc.get("source_fact_ids") or []) | set(prior_doc.get("source_episodic_ids") or [])
749-
if prior_doc
750-
else set()
751-
)
752-
if prior_doc and not force and current_source_ids == prior_source_ids:
746+
747+
def _covered_by(prior: Optional[dict[str, Any]]) -> bool:
748+
if prior is None:
749+
return False
750+
covered = set(prior.get("source_fact_ids") or []) | set(prior.get("source_episodic_ids") or [])
751+
return current_source_ids.issubset(covered)
752+
753+
if prior_doc and not force and _covered_by(prior_doc):
753754
logger.info(
754755
"synthesize_procedural unchanged user_id=%s fact_count=%d episodic_count=%d",
755756
user_id,
@@ -769,7 +770,7 @@ async def _read_latest_procedural() -> Optional[dict[str, Any]]:
769770
query=(
770771
"SELECT TOP 1 * FROM c WHERE c.user_id = @uid "
771772
"AND c.type = @type "
772-
f"AND {active_filter} "
773+
f"AND {_ACTIVE_DOC_FILTER} "
773774
"AND IS_DEFINED(c.metadata.category) "
774775
"AND c.metadata.category = @category "
775776
"AND IS_DEFINED(c.metadata.predicate) "
@@ -799,58 +800,53 @@ def _render_bullets(values: list[str]) -> str:
799800
return "(none)"
800801
return "\n".join(f"- {value}" for value in cleaned)
801802

802-
response_text = await self._run_prompty(
803-
"synthesize_procedural.prompty",
804-
inputs={
805-
"prior_prompt": (prior_doc.get("content") or "") if prior_doc else "",
806-
"behavioral_facts": _render_bullets([doc.get("content", "") for doc in behavioral_fact_docs]),
807-
"episodic_lessons": _render_bullets(
808-
[doc.get("metadata", {}).get("lesson", "") for doc in episodic_with_lessons]
809-
),
810-
"user_name": user_name,
811-
},
812-
)
813-
814-
parsed = self._parse_llm_json(response_text)
815-
system_prompt = parsed.get("system_prompt") if isinstance(parsed, dict) else None
816-
if not isinstance(system_prompt, str) or not system_prompt.strip():
817-
raise LLMError("synthesize_procedural returned JSON without a non-empty 'system_prompt' string")
818-
system_prompt = system_prompt.strip()
819-
820-
new_seq = (int(prior_doc.get("version") or 0) + 1) if prior_doc else 1
821-
new_doc: dict[str, Any] = {
822-
"id": f"proc_{user_id}_{new_seq}",
823-
"user_id": user_id,
824-
"thread_id": "__procedural__",
825-
"type": "procedural",
826-
"version": new_seq,
827-
"content": system_prompt,
828-
"source_fact_ids": behavioral_fact_ids,
829-
"source_episodic_ids": source_episodic_ids,
830-
"supersedes_ids": [prior_doc["id"]] if prior_doc else [],
831-
"created_at": datetime.now(timezone.utc).isoformat(),
832-
"updated_at": datetime.now(timezone.utc).isoformat(),
833-
"role": "system",
834-
"tags": ["sys:procedural", "sys:synthesized"],
835-
**self._prompt_lineage("synthesize_procedural.prompty"),
836-
"metadata": {},
803+
static_prompty_inputs = {
804+
"behavioral_facts": _render_bullets([doc.get("content", "") for doc in behavioral_fact_docs]),
805+
"episodic_lessons": _render_bullets(
806+
[doc.get("metadata", {}).get("lesson", "") for doc in episodic_with_lessons]
807+
),
808+
"user_name": user_name,
837809
}
838-
if not new_doc["source_fact_ids"] and not new_doc["source_episodic_ids"]:
839-
logger.info(
840-
"synthesize_procedural skipping write user_id=%s — no source facts or episodics",
841-
user_id,
842-
)
843-
return {"status": "unchanged", "procedural": prior_doc}
844810

845-
max_attempts = 5
846-
new_id = new_doc["id"]
811+
# Retry loop: LLM call lives inside so that on a race-induced 409
812+
# we (a) check whether the winner already covers our source set and
813+
# short-circuit if so, and (b) re-call the LLM with the winner as
814+
# the new prior if not — keeping synthesized content monotonic in
815+
# source coverage, not just version number.
847816
written_doc: Optional[dict[str, Any]] = None
848-
for attempt in range(1, max_attempts + 1):
849-
new_doc["id"] = f"proc_{user_id}_{new_seq}"
850-
new_doc["version"] = new_seq
851-
new_doc["supersedes_ids"] = [prior_doc["id"]] if prior_doc else []
852-
new_doc["updated_at"] = datetime.now(timezone.utc).isoformat()
853-
new_id = new_doc["id"]
817+
for attempt in range(1, _PROCEDURAL_MAX_CREATE_ATTEMPTS + 1):
818+
response_text = await self._run_prompty(
819+
"synthesize_procedural.prompty",
820+
inputs={
821+
"prior_prompt": (prior_doc.get("content") or "") if prior_doc else "",
822+
**static_prompty_inputs,
823+
},
824+
)
825+
826+
parsed = self._parse_llm_json(response_text)
827+
system_prompt = parsed.get("system_prompt") if isinstance(parsed, dict) else None
828+
if not isinstance(system_prompt, str) or not system_prompt.strip():
829+
raise LLMError("synthesize_procedural returned JSON without a non-empty 'system_prompt' string")
830+
system_prompt = system_prompt.strip()
831+
832+
new_seq = (int(prior_doc.get("version") or 0) + 1) if prior_doc else 1
833+
new_doc: dict[str, Any] = {
834+
"id": f"proc_{user_id}_{new_seq}",
835+
"user_id": user_id,
836+
"thread_id": "__procedural__",
837+
"type": "procedural",
838+
"version": new_seq,
839+
"content": system_prompt,
840+
"source_fact_ids": behavioral_fact_ids,
841+
"source_episodic_ids": source_episodic_ids,
842+
"supersedes_ids": [prior_doc["id"]] if prior_doc else [],
843+
"created_at": datetime.now(timezone.utc).isoformat(),
844+
"updated_at": datetime.now(timezone.utc).isoformat(),
845+
"role": "system",
846+
"tags": ["sys:procedural", "sys:synthesized"],
847+
**self._prompt_lineage("synthesize_procedural.prompty"),
848+
"metadata": {},
849+
}
854850
validated = construct_internal(ProceduralRecord, new_doc).to_doc()
855851
try:
856852
await self._container.create_item(body=validated)
@@ -862,26 +858,34 @@ def _render_bullets(values: list[str]) -> str:
862858
user_id,
863859
new_seq,
864860
attempt,
865-
max_attempts,
861+
_PROCEDURAL_MAX_CREATE_ATTEMPTS,
866862
)
867863
latest = await _read_latest_procedural()
868-
if latest is not None:
869-
prior_doc = latest
870-
new_seq = int(latest.get("version") or 0) + 1
871-
else:
872-
new_seq += 1
864+
if latest is None:
865+
continue
866+
prior_doc = latest
867+
if _covered_by(prior_doc):
868+
logger.info(
869+
"synthesize_procedural race resolved by coverage user_id=%s winner=%s",
870+
user_id,
871+
prior_doc["id"],
872+
)
873+
return {"status": "unchanged", "procedural": prior_doc}
873874
if written_doc is None:
874875
raise MemoryConflictError(
875-
f"synthesize_procedural failed after {max_attempts} attempts due to id collisions user_id={user_id!r}"
876+
"synthesize_procedural failed after "
877+
f"{_PROCEDURAL_MAX_CREATE_ATTEMPTS} attempts due to id collisions "
878+
f"user_id={user_id!r}"
876879
)
877880

881+
new_id = written_doc["id"]
878882
if prior_doc:
879883
await self._mark_superseded(prior_doc, new_id, reason="update")
880884

881885
logger.info(
882886
"synthesize_procedural synthesized user_id=%s version=%d fact_count=%d episodic_count=%d",
883887
user_id,
884-
new_seq,
888+
written_doc["version"],
885889
len(behavioral_fact_ids),
886890
len(source_episodic_ids),
887891
)
@@ -1238,7 +1242,7 @@ async def reconcile_memories(self, user_id: str, n: int = 50) -> dict[str, int]:
12381242
f"SELECT TOP {n} * FROM c "
12391243
"WHERE c.user_id = @user_id "
12401244
"AND c.type = 'fact' "
1241-
"AND (NOT IS_DEFINED(c.superseded_by) OR IS_NULL(c.superseded_by)) "
1245+
f"AND {_ACTIVE_DOC_FILTER} "
12421246
"ORDER BY c.created_at DESC"
12431247
)
12441248
parameters: list[dict[str, Any]] = [
@@ -1643,7 +1647,7 @@ async def build_procedural_context(self, user_id: str) -> str:
16431647
query = (
16441648
"SELECT TOP 1 c.content, c.version FROM c WHERE c.user_id = @user_id "
16451649
"AND c.thread_id = @thread_id AND c.type = @type "
1646-
"AND (NOT IS_DEFINED(c.superseded_by) OR IS_NULL(c.superseded_by)) "
1650+
f"AND {_ACTIVE_DOC_FILTER} "
16471651
"ORDER BY c.version DESC"
16481652
)
16491653
items = await self._store.query(

agent_memory_toolkit/processors/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ def generate_user_summary(
9999
thread_summaries: list[dict[str, Any]],
100100
) -> UserSummaryResult: ...
101101

102+
def synthesize_procedural(
103+
self,
104+
*,
105+
user_id: str,
106+
force: bool = False,
107+
) -> dict[str, Any]: ...
108+
102109
def close(self) -> None: ...
103110

104111

0 commit comments

Comments
 (0)