autogen-memorysync 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- autogen_memorysync/__init__.py +20 -0
- autogen_memorysync/_api.py +311 -0
- autogen_memorysync/_version.py +1 -0
- autogen_memorysync/memory.py +457 -0
- autogen_memorysync/tools.py +53 -0
- autogen_memorysync-1.0.0.dist-info/METADATA +111 -0
- autogen_memorysync-1.0.0.dist-info/RECORD +8 -0
- autogen_memorysync-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""MemorySync for Microsoft AutoGen (autogen-agentchat 0.4+)."""
|
|
2
|
+
|
|
3
|
+
from ._api import MemorySyncAPIError, fnv1a64
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .memory import (
|
|
6
|
+
DEFAULT_CONTEXT_TEMPLATE,
|
|
7
|
+
MemorySyncMemory,
|
|
8
|
+
MemorySyncMemoryConfig,
|
|
9
|
+
)
|
|
10
|
+
from .tools import create_memory_tools
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"MemorySyncMemory",
|
|
14
|
+
"MemorySyncMemoryConfig",
|
|
15
|
+
"MemorySyncAPIError",
|
|
16
|
+
"DEFAULT_CONTEXT_TEMPLATE",
|
|
17
|
+
"create_memory_tools",
|
|
18
|
+
"fnv1a64",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Async client for the MemorySync v1 data plane used by this adapter.
|
|
2
|
+
|
|
3
|
+
Conversation turns persist through the *episodic* ingestion path
|
|
4
|
+
(``POST /v1/memory/add_turn``), which stores text verbatim — no fact
|
|
5
|
+
extraction, no low-value-chatter gate, no rewriting. An agent transcript
|
|
6
|
+
must round-trip byte-for-byte; a plane that second-guessed it would
|
|
7
|
+
corrupt the user's history.
|
|
8
|
+
|
|
9
|
+
Everything here is async-native because AutoGen's ``Memory`` protocol is
|
|
10
|
+
fully async — a blocking HTTP client inside ``update_context`` would
|
|
11
|
+
stall the event loop for every agent in the process (the exact bug in
|
|
12
|
+
the Mem0 adapter that ships inside autogen-ext).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
DEFAULT_BASE_URL = "https://api.memorysync.io"
|
|
25
|
+
_USER_AGENT = f"autogen-memorysync/{__version__}"
|
|
26
|
+
|
|
27
|
+
#: Namespace used when the key cannot list projects (see resolve_tenant_id).
|
|
28
|
+
FALLBACK_TENANT = "default"
|
|
29
|
+
|
|
30
|
+
#: One turn beyond this length is truncated before storage.
|
|
31
|
+
MAX_TURN_CHARS = 16000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MemorySyncAPIError(Exception):
|
|
35
|
+
"""A MemorySync call failed. Carries the status code and server detail."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.status_code = status_code
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_api_key(api_key: Optional[str]) -> str:
|
|
43
|
+
key = api_key or os.environ.get("MEMORYSYNC_API_KEY", "")
|
|
44
|
+
if not key or not key.strip():
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"A MemorySync API key is required. Pass api_key=... or set the "
|
|
47
|
+
"MEMORYSYNC_API_KEY environment variable."
|
|
48
|
+
)
|
|
49
|
+
return key.strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_base_url(base_url: Optional[str]) -> str:
|
|
53
|
+
url = base_url or os.environ.get("MEMORYSYNC_BASE_URL", "") or DEFAULT_BASE_URL
|
|
54
|
+
return url.rstrip("/")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fnv1a64(value: str) -> str:
|
|
58
|
+
"""FNV-1a 64-bit over UTF-16 code units, as a fixed-width hex string.
|
|
59
|
+
|
|
60
|
+
Over UTF-16 code units — not code points, not UTF-8 bytes — so the
|
|
61
|
+
output matches every other MemorySync adapter (Python and JS)
|
|
62
|
+
character for character. Identical seeds across surfaces mean a turn
|
|
63
|
+
persisted here and again elsewhere converge on one stored row.
|
|
64
|
+
"""
|
|
65
|
+
prime = 0x100000001B3
|
|
66
|
+
mask = 0xFFFFFFFFFFFFFFFF
|
|
67
|
+
h = 0xCBF29CE484222325
|
|
68
|
+
data = value.encode("utf-16-le")
|
|
69
|
+
for i in range(0, len(data), 2):
|
|
70
|
+
unit = data[i] | (data[i + 1] << 8)
|
|
71
|
+
h ^= unit
|
|
72
|
+
h = (h * prime) & mask
|
|
73
|
+
return format(h, "016x")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AsyncV1Api:
|
|
77
|
+
"""Minimal asynchronous v1 client: add_turn, recall, query, list, forget."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
api_key: str,
|
|
83
|
+
base_url: str,
|
|
84
|
+
project_id: Optional[str] = None,
|
|
85
|
+
timeout: float = 30.0,
|
|
86
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
self._api_key = api_key
|
|
89
|
+
self._base_url = base_url.rstrip("/")
|
|
90
|
+
self._project_id = project_id
|
|
91
|
+
self._timeout = timeout
|
|
92
|
+
self._transport = transport
|
|
93
|
+
self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
|
|
94
|
+
self._tenant_id: Optional[str] = None
|
|
95
|
+
self._tenant_is_fallback = False
|
|
96
|
+
|
|
97
|
+
async def aclose(self) -> None:
|
|
98
|
+
await self._http.aclose()
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def base_url(self) -> str:
|
|
102
|
+
return self._base_url
|
|
103
|
+
|
|
104
|
+
# ── plumbing ─────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def _headers(self, *, end_user_id: Optional[str] = None) -> Dict[str, str]:
|
|
107
|
+
h = {
|
|
108
|
+
"X-API-Key": self._api_key,
|
|
109
|
+
"Accept": "application/json",
|
|
110
|
+
"User-Agent": _USER_AGENT,
|
|
111
|
+
}
|
|
112
|
+
if self._project_id:
|
|
113
|
+
h["X-Project-ID"] = self._project_id
|
|
114
|
+
if end_user_id:
|
|
115
|
+
h["X-End-User-ID"] = end_user_id
|
|
116
|
+
return h
|
|
117
|
+
|
|
118
|
+
async def _request(
|
|
119
|
+
self,
|
|
120
|
+
method: str,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
json: Optional[Dict[str, Any]] = None,
|
|
124
|
+
params: Optional[Dict[str, Any]] = None,
|
|
125
|
+
end_user_id: Optional[str] = None,
|
|
126
|
+
) -> Any:
|
|
127
|
+
url = f"{self._base_url}{path}"
|
|
128
|
+
try:
|
|
129
|
+
response = await self._http.request(
|
|
130
|
+
method,
|
|
131
|
+
url,
|
|
132
|
+
headers=self._headers(end_user_id=end_user_id),
|
|
133
|
+
json=json,
|
|
134
|
+
params=params,
|
|
135
|
+
)
|
|
136
|
+
except httpx.TimeoutException as e:
|
|
137
|
+
raise MemorySyncAPIError(f"Request timed out: {e}") from e
|
|
138
|
+
except httpx.HTTPError as e:
|
|
139
|
+
raise MemorySyncAPIError(f"Network error: {e}") from e
|
|
140
|
+
|
|
141
|
+
if response.status_code == 204:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
body: Any = response.json()
|
|
145
|
+
except ValueError:
|
|
146
|
+
body = response.text or None
|
|
147
|
+
if response.status_code >= 400:
|
|
148
|
+
detail = body.get("detail") if isinstance(body, dict) else body
|
|
149
|
+
raise MemorySyncAPIError(
|
|
150
|
+
f"{method} {path} failed with HTTP {response.status_code}: {detail}",
|
|
151
|
+
status_code=response.status_code,
|
|
152
|
+
)
|
|
153
|
+
return body
|
|
154
|
+
|
|
155
|
+
# ── calls ────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
async def resolve_tenant_id(self) -> str:
|
|
158
|
+
"""The tenant id, which the v1 routes need in path or body.
|
|
159
|
+
|
|
160
|
+
Derived from the project listing rather than asked for. Cached for
|
|
161
|
+
the lifetime of this client. Keys without the ``projects:read``
|
|
162
|
+
scope (evaluation keys) fall back to the fixed namespace
|
|
163
|
+
``"default"`` — deterministic, so every read and write through
|
|
164
|
+
this client lands in one namespace. Only a definite 401/403
|
|
165
|
+
triggers the fallback; a transient server error re-raises rather
|
|
166
|
+
than silently switching namespaces.
|
|
167
|
+
"""
|
|
168
|
+
if self._tenant_id:
|
|
169
|
+
return self._tenant_id
|
|
170
|
+
try:
|
|
171
|
+
projects = await self._request("GET", "/org/projects")
|
|
172
|
+
except MemorySyncAPIError as exc:
|
|
173
|
+
if exc.status_code in (401, 403):
|
|
174
|
+
self._tenant_id = FALLBACK_TENANT
|
|
175
|
+
self._tenant_is_fallback = True
|
|
176
|
+
return self._tenant_id
|
|
177
|
+
raise
|
|
178
|
+
first = projects[0] if isinstance(projects, list) and projects else None
|
|
179
|
+
tenant = first.get("tenant_id") if isinstance(first, dict) else None
|
|
180
|
+
if not tenant:
|
|
181
|
+
raise MemorySyncAPIError(
|
|
182
|
+
"Could not determine the tenant for this API key. Pass "
|
|
183
|
+
"tenant_id explicitly, or verify the key with `memorysync doctor`."
|
|
184
|
+
)
|
|
185
|
+
self._tenant_id = str(tenant)
|
|
186
|
+
return self._tenant_id
|
|
187
|
+
|
|
188
|
+
def set_tenant_id(self, tenant_id: str) -> None:
|
|
189
|
+
self._tenant_id = tenant_id
|
|
190
|
+
|
|
191
|
+
async def add_turn(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
tenant_id: str,
|
|
195
|
+
user_id: str,
|
|
196
|
+
text: str,
|
|
197
|
+
speaker: Optional[str] = None,
|
|
198
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
199
|
+
source: str = "autogen",
|
|
200
|
+
sync_embed: bool = False,
|
|
201
|
+
) -> Dict[str, Any]:
|
|
202
|
+
"""Store one item verbatim (episodic ingestion).
|
|
203
|
+
|
|
204
|
+
``speaker`` participates in the server's idempotency seed, so
|
|
205
|
+
retrying an identical payload is recognised
|
|
206
|
+
(``already_exists: true``) instead of stored twice.
|
|
207
|
+
"""
|
|
208
|
+
body: Dict[str, Any] = {
|
|
209
|
+
"tenant_id": tenant_id,
|
|
210
|
+
"user_id": user_id,
|
|
211
|
+
"source": source,
|
|
212
|
+
"text": text,
|
|
213
|
+
"sync_embed": sync_embed,
|
|
214
|
+
}
|
|
215
|
+
if speaker is not None:
|
|
216
|
+
body["speaker"] = speaker
|
|
217
|
+
if metadata is not None:
|
|
218
|
+
body["metadata"] = metadata
|
|
219
|
+
return await self._request("POST", "/v1/memory/add_turn", json=body) or {}
|
|
220
|
+
|
|
221
|
+
async def recall(
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
tenant_id: str,
|
|
225
|
+
user_id: str,
|
|
226
|
+
prompt: str,
|
|
227
|
+
k: Optional[int] = None,
|
|
228
|
+
) -> Dict[str, Any]:
|
|
229
|
+
"""Hierarchical recall: grouped, prompt-ready context block."""
|
|
230
|
+
body: Dict[str, Any] = {
|
|
231
|
+
"tenant_id": tenant_id,
|
|
232
|
+
"user_id": user_id,
|
|
233
|
+
"prompt": prompt,
|
|
234
|
+
}
|
|
235
|
+
if k is not None:
|
|
236
|
+
body["k"] = k
|
|
237
|
+
return await self._request("POST", "/v1/memory/recall", json=body) or {}
|
|
238
|
+
|
|
239
|
+
async def query(
|
|
240
|
+
self,
|
|
241
|
+
*,
|
|
242
|
+
tenant_id: str,
|
|
243
|
+
user_id: str,
|
|
244
|
+
prompt: str,
|
|
245
|
+
k: Optional[int] = None,
|
|
246
|
+
) -> Dict[str, Any]:
|
|
247
|
+
"""Plain semantic search over the pair's memories (episodic included)."""
|
|
248
|
+
body: Dict[str, Any] = {
|
|
249
|
+
"tenant_id": tenant_id,
|
|
250
|
+
"user_id": user_id,
|
|
251
|
+
"prompt": prompt,
|
|
252
|
+
}
|
|
253
|
+
if k is not None:
|
|
254
|
+
body["k"] = k
|
|
255
|
+
return await self._request("POST", "/v1/memory/query", json=body) or {}
|
|
256
|
+
|
|
257
|
+
async def list_memories(
|
|
258
|
+
self,
|
|
259
|
+
*,
|
|
260
|
+
tenant_id: str,
|
|
261
|
+
user_id: str,
|
|
262
|
+
limit: int = 0,
|
|
263
|
+
) -> List[Dict[str, Any]]:
|
|
264
|
+
"""Every memory for the tenant/user pair, newest first.
|
|
265
|
+
|
|
266
|
+
``limit=0`` means no limit — a scoped clear must see every row,
|
|
267
|
+
so that is the default here.
|
|
268
|
+
"""
|
|
269
|
+
from urllib.parse import quote
|
|
270
|
+
|
|
271
|
+
raw = await self._request(
|
|
272
|
+
"GET",
|
|
273
|
+
f"/v1/memory/{quote(tenant_id, safe='')}/{quote(user_id, safe='')}/list",
|
|
274
|
+
params={"limit": limit},
|
|
275
|
+
)
|
|
276
|
+
memories = raw.get("memories") if isinstance(raw, dict) else None
|
|
277
|
+
return list(memories) if isinstance(memories, list) else []
|
|
278
|
+
|
|
279
|
+
async def forget(self, *, user_id: str, memory_ids: List[int]) -> None:
|
|
280
|
+
"""Delete specific memories by numeric id, scoped to one end user."""
|
|
281
|
+
if not memory_ids:
|
|
282
|
+
return
|
|
283
|
+
# Deletion in bounded batches so one enormous clear cannot build
|
|
284
|
+
# an unbounded request body.
|
|
285
|
+
for start in range(0, len(memory_ids), 100):
|
|
286
|
+
batch = memory_ids[start : start + 100]
|
|
287
|
+
await self._request(
|
|
288
|
+
"DELETE",
|
|
289
|
+
"/memory/forget",
|
|
290
|
+
json={"memory_ids": batch},
|
|
291
|
+
end_user_id=user_id,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
async def add_memory(
|
|
295
|
+
self,
|
|
296
|
+
*,
|
|
297
|
+
user_id: str,
|
|
298
|
+
text: str,
|
|
299
|
+
source: str = "autogen",
|
|
300
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
301
|
+
) -> Dict[str, Any]:
|
|
302
|
+
"""Store a fact through the extraction path (server-side gating)."""
|
|
303
|
+
body: Dict[str, Any] = {"text": text, "source": source}
|
|
304
|
+
if metadata is not None:
|
|
305
|
+
body["metadata"] = metadata
|
|
306
|
+
return (
|
|
307
|
+
await self._request(
|
|
308
|
+
"POST", "/memory/add", json=body, end_user_id=user_id
|
|
309
|
+
)
|
|
310
|
+
or {}
|
|
311
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""MemorySync memory for Microsoft AutoGen (``autogen-agentchat`` 0.4+).
|
|
2
|
+
|
|
3
|
+
Design rules, in priority order:
|
|
4
|
+
|
|
5
|
+
1. **The turn is never stalled and never broken.** ``update_context`` runs
|
|
6
|
+
on the hot path before every LLM call, so recall waits at most
|
|
7
|
+
``recall_timeout`` seconds (default 1.2). On timeout, outage, or quota
|
|
8
|
+
exhaustion it injects nothing and returns an empty result — the agent
|
|
9
|
+
answers without memories rather than late or not at all. (The Mem0
|
|
10
|
+
adapter that ships inside autogen-ext has no budget at all — and calls
|
|
11
|
+
a *synchronous* client inside these async methods, blocking the entire
|
|
12
|
+
event loop.)
|
|
13
|
+
|
|
14
|
+
2. **Retrieval is role-aware.** The query is the last *user* message,
|
|
15
|
+
found by walking the context backwards — not blindly ``messages[-1]``,
|
|
16
|
+
which after a tool result is assistant text (a real retrieval-quality
|
|
17
|
+
bug in the Mem0 adapter).
|
|
18
|
+
|
|
19
|
+
3. **Writes are duplicate-proof.** ``add`` stores verbatim with a
|
|
20
|
+
deterministic idempotency seed derived from role, session, and
|
|
21
|
+
content; a retried call converges on one stored row.
|
|
22
|
+
|
|
23
|
+
4. **``clear()`` cannot nuke a customer.** By default it deletes only
|
|
24
|
+
this adapter's session transcript (the ``autogen::<session>`` scope).
|
|
25
|
+
Wiping the user's entire memory requires the explicit
|
|
26
|
+
``clear_scope="user"`` opt-in. Both Mem0 and Zep delete everything.
|
|
27
|
+
|
|
28
|
+
5. **Hot path fails open, explicit calls fail loud.** ``update_context``
|
|
29
|
+
catches everything and logs; ``add``/``query``/``clear`` raise on
|
|
30
|
+
definite failure because the application called them deliberately.
|
|
31
|
+
(Mem0 does the reverse for query — it silently returns empty, so a
|
|
32
|
+
broken connection looks like an empty memory.)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
from typing import Any, Dict, List, Optional
|
|
41
|
+
|
|
42
|
+
import httpx
|
|
43
|
+
from pydantic import BaseModel
|
|
44
|
+
|
|
45
|
+
from autogen_core import CancellationToken, Component
|
|
46
|
+
from autogen_core.memory import (
|
|
47
|
+
Memory,
|
|
48
|
+
MemoryContent,
|
|
49
|
+
MemoryMimeType,
|
|
50
|
+
MemoryQueryResult,
|
|
51
|
+
UpdateContextResult,
|
|
52
|
+
)
|
|
53
|
+
from autogen_core.model_context import ChatCompletionContext
|
|
54
|
+
from autogen_core.models import SystemMessage, UserMessage
|
|
55
|
+
|
|
56
|
+
from ._api import (
|
|
57
|
+
MAX_TURN_CHARS,
|
|
58
|
+
AsyncV1Api,
|
|
59
|
+
MemorySyncAPIError,
|
|
60
|
+
fnv1a64,
|
|
61
|
+
resolve_api_key,
|
|
62
|
+
resolve_base_url,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger("autogen_memorysync")
|
|
66
|
+
|
|
67
|
+
DEFAULT_CONTEXT_TEMPLATE = (
|
|
68
|
+
"\nRelevant user memories (MemorySync):\n{context}\n"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MemorySyncMemoryConfig(BaseModel):
|
|
73
|
+
"""Declarative config for component serialization.
|
|
74
|
+
|
|
75
|
+
``api_key`` is deliberately excluded from serialized output: a dumped
|
|
76
|
+
component must never carry a credential. On load the key comes from
|
|
77
|
+
the ``MEMORYSYNC_API_KEY`` environment variable (or pass ``api_key``
|
|
78
|
+
when constructing directly).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
user_id: str
|
|
82
|
+
session_id: str = "default"
|
|
83
|
+
base_url: Optional[str] = None
|
|
84
|
+
project_id: Optional[str] = None
|
|
85
|
+
top_k: int = 5
|
|
86
|
+
recall_timeout: float = 1.2
|
|
87
|
+
min_prompt_chars: int = 8
|
|
88
|
+
context_template: str = DEFAULT_CONTEXT_TEMPLATE
|
|
89
|
+
clear_scope: str = "session"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_cancelled(token: Optional[CancellationToken]) -> bool:
|
|
93
|
+
if token is None:
|
|
94
|
+
return False
|
|
95
|
+
checker = getattr(token, "is_cancelled", None)
|
|
96
|
+
return bool(checker()) if callable(checker) else False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _text_of(content: Any) -> str:
|
|
100
|
+
"""Best-effort plain text from an AutoGen message content value."""
|
|
101
|
+
if isinstance(content, str):
|
|
102
|
+
return content
|
|
103
|
+
if isinstance(content, list):
|
|
104
|
+
return "\n".join(part for part in content if isinstance(part, str)).strip()
|
|
105
|
+
return ""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MemorySyncMemory(Memory, Component[MemorySyncMemoryConfig]):
|
|
109
|
+
"""AutoGen ``Memory`` backed by MemorySync.
|
|
110
|
+
|
|
111
|
+
Wire it into an agent::
|
|
112
|
+
|
|
113
|
+
from autogen_agentchat.agents import AssistantAgent
|
|
114
|
+
from autogen_memorysync import MemorySyncMemory
|
|
115
|
+
|
|
116
|
+
memory = MemorySyncMemory(user_id="customer-42", session_id="support")
|
|
117
|
+
agent = AssistantAgent("assistant", model_client=..., memory=[memory])
|
|
118
|
+
|
|
119
|
+
AutoGen calls ``update_context`` automatically before every model
|
|
120
|
+
call. It never calls ``add`` — persist turns yourself, most simply::
|
|
121
|
+
|
|
122
|
+
result = await agent.run(task=user_text)
|
|
123
|
+
await memory.add_turn_pair(user_text, result.messages[-1].content)
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
component_type = "memory"
|
|
127
|
+
component_config_schema = MemorySyncMemoryConfig
|
|
128
|
+
component_provider_override = "autogen_memorysync.MemorySyncMemory"
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
user_id: str,
|
|
134
|
+
session_id: str = "default",
|
|
135
|
+
api_key: Optional[str] = None,
|
|
136
|
+
base_url: Optional[str] = None,
|
|
137
|
+
project_id: Optional[str] = None,
|
|
138
|
+
top_k: int = 5,
|
|
139
|
+
recall_timeout: float = 1.2,
|
|
140
|
+
min_prompt_chars: int = 8,
|
|
141
|
+
context_template: str = DEFAULT_CONTEXT_TEMPLATE,
|
|
142
|
+
clear_scope: str = "session",
|
|
143
|
+
source: str = "autogen",
|
|
144
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
# user_id is required, never auto-generated: the Mem0 adapter
|
|
147
|
+
# invents a random UUID per process when omitted, which silently
|
|
148
|
+
# strands every stored memory in a namespace nobody will ever
|
|
149
|
+
# query again. Refusing is kinder than losing data.
|
|
150
|
+
if not user_id or not str(user_id).strip():
|
|
151
|
+
raise ValueError("user_id is required and must be non-empty")
|
|
152
|
+
if clear_scope not in ("session", "user"):
|
|
153
|
+
raise ValueError('clear_scope must be "session" or "user"')
|
|
154
|
+
self.user_id = str(user_id).strip()
|
|
155
|
+
self.session_id = str(session_id or "default").strip() or "default"
|
|
156
|
+
self.top_k = top_k
|
|
157
|
+
self.recall_timeout = recall_timeout
|
|
158
|
+
self.min_prompt_chars = min_prompt_chars
|
|
159
|
+
self.context_template = context_template
|
|
160
|
+
self.clear_scope = clear_scope
|
|
161
|
+
self.source = source
|
|
162
|
+
self._api = AsyncV1Api(
|
|
163
|
+
api_key=resolve_api_key(api_key),
|
|
164
|
+
base_url=resolve_base_url(base_url),
|
|
165
|
+
project_id=project_id,
|
|
166
|
+
timeout=max(recall_timeout * 4, 8.0),
|
|
167
|
+
transport=transport,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# ── component serialization ──────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
def _to_config(self) -> MemorySyncMemoryConfig:
|
|
173
|
+
return MemorySyncMemoryConfig(
|
|
174
|
+
user_id=self.user_id,
|
|
175
|
+
session_id=self.session_id,
|
|
176
|
+
base_url=self._api.base_url,
|
|
177
|
+
top_k=self.top_k,
|
|
178
|
+
recall_timeout=self.recall_timeout,
|
|
179
|
+
min_prompt_chars=self.min_prompt_chars,
|
|
180
|
+
context_template=self.context_template,
|
|
181
|
+
clear_scope=self.clear_scope,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def _from_config(cls, config: MemorySyncMemoryConfig) -> "MemorySyncMemory":
|
|
186
|
+
return cls(
|
|
187
|
+
user_id=config.user_id,
|
|
188
|
+
session_id=config.session_id,
|
|
189
|
+
base_url=config.base_url,
|
|
190
|
+
project_id=config.project_id,
|
|
191
|
+
top_k=config.top_k,
|
|
192
|
+
recall_timeout=config.recall_timeout,
|
|
193
|
+
min_prompt_chars=config.min_prompt_chars,
|
|
194
|
+
context_template=config.context_template,
|
|
195
|
+
clear_scope=config.clear_scope,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# ── scope helpers ────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def _session_scope(self) -> str:
|
|
202
|
+
return f"autogen::{self.session_id}"
|
|
203
|
+
|
|
204
|
+
# ── the hot path ─────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
async def update_context(
|
|
207
|
+
self, model_context: ChatCompletionContext
|
|
208
|
+
) -> UpdateContextResult:
|
|
209
|
+
"""Inject relevant memories before the model call — budgeted.
|
|
210
|
+
|
|
211
|
+
Called automatically by ``AssistantAgent`` before every LLM
|
|
212
|
+
inference. Any failure here degrades to "no memories this turn";
|
|
213
|
+
it never raises into the agent.
|
|
214
|
+
"""
|
|
215
|
+
empty = UpdateContextResult(memories=MemoryQueryResult(results=[]))
|
|
216
|
+
try:
|
|
217
|
+
messages = await model_context.get_messages()
|
|
218
|
+
except Exception: # pragma: no cover - defensive
|
|
219
|
+
return empty
|
|
220
|
+
if not messages:
|
|
221
|
+
return empty
|
|
222
|
+
|
|
223
|
+
# Role-aware query: the last *user* message, not messages[-1].
|
|
224
|
+
query_text = ""
|
|
225
|
+
for message in reversed(messages):
|
|
226
|
+
if isinstance(message, UserMessage):
|
|
227
|
+
query_text = _text_of(message.content)
|
|
228
|
+
if query_text:
|
|
229
|
+
break
|
|
230
|
+
if len(query_text.strip()) < self.min_prompt_chars:
|
|
231
|
+
return empty
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
tenant_id = await asyncio.wait_for(
|
|
235
|
+
self._api.resolve_tenant_id(), timeout=self.recall_timeout
|
|
236
|
+
)
|
|
237
|
+
response = await asyncio.wait_for(
|
|
238
|
+
self._api.recall(
|
|
239
|
+
tenant_id=tenant_id,
|
|
240
|
+
user_id=self.user_id,
|
|
241
|
+
prompt=query_text,
|
|
242
|
+
k=self.top_k,
|
|
243
|
+
),
|
|
244
|
+
timeout=self.recall_timeout,
|
|
245
|
+
)
|
|
246
|
+
except (asyncio.TimeoutError, MemorySyncAPIError, Exception) as exc:
|
|
247
|
+
logger.warning(
|
|
248
|
+
"MemorySync recall unavailable this turn (%s) — continuing without memories.",
|
|
249
|
+
exc,
|
|
250
|
+
)
|
|
251
|
+
return empty
|
|
252
|
+
|
|
253
|
+
context_block = response.get("context") if isinstance(response, dict) else ""
|
|
254
|
+
if not isinstance(context_block, str) or not context_block.strip():
|
|
255
|
+
return empty
|
|
256
|
+
|
|
257
|
+
results = self._memories_to_content(response.get("memories"))
|
|
258
|
+
if not results:
|
|
259
|
+
# Surface the injected block itself so MemoryQueryEvent still
|
|
260
|
+
# tells observers what the model saw.
|
|
261
|
+
results = [
|
|
262
|
+
MemoryContent(
|
|
263
|
+
content=context_block,
|
|
264
|
+
mime_type=MemoryMimeType.MARKDOWN,
|
|
265
|
+
metadata={"kind": "context_block"},
|
|
266
|
+
)
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
# .replace, never .format: memory text may contain { } % legally.
|
|
270
|
+
block = self.context_template.replace("{context}", context_block)
|
|
271
|
+
await model_context.add_message(SystemMessage(content=block))
|
|
272
|
+
return UpdateContextResult(memories=MemoryQueryResult(results=results))
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _memories_to_content(raw: Any) -> List[MemoryContent]:
|
|
276
|
+
items: List[MemoryContent] = []
|
|
277
|
+
if not isinstance(raw, list):
|
|
278
|
+
return items
|
|
279
|
+
for entry in raw:
|
|
280
|
+
if not isinstance(entry, dict):
|
|
281
|
+
continue
|
|
282
|
+
text = (
|
|
283
|
+
entry.get("value")
|
|
284
|
+
or entry.get("text")
|
|
285
|
+
or entry.get("raw_text")
|
|
286
|
+
or ""
|
|
287
|
+
)
|
|
288
|
+
if not isinstance(text, str) or not text:
|
|
289
|
+
continue
|
|
290
|
+
metadata: Dict[str, Any] = {}
|
|
291
|
+
if entry.get("memory_id") is not None:
|
|
292
|
+
metadata["memory_id"] = str(entry["memory_id"])
|
|
293
|
+
elif entry.get("id") is not None:
|
|
294
|
+
metadata["memory_id"] = str(entry["id"])
|
|
295
|
+
if isinstance(entry.get("score"), (int, float)):
|
|
296
|
+
metadata["score"] = float(entry["score"])
|
|
297
|
+
items.append(
|
|
298
|
+
MemoryContent(
|
|
299
|
+
content=text,
|
|
300
|
+
mime_type=MemoryMimeType.TEXT,
|
|
301
|
+
metadata=metadata or None,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
return items
|
|
305
|
+
|
|
306
|
+
# ── explicit calls: fail loud ────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
async def add(
|
|
309
|
+
self,
|
|
310
|
+
content: MemoryContent,
|
|
311
|
+
cancellation_token: Optional[CancellationToken] = None,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Persist one turn verbatim with a deterministic idempotency seed.
|
|
314
|
+
|
|
315
|
+
Role comes from ``content.metadata["role"]`` (``"user"`` or
|
|
316
|
+
``"assistant"``; anything else counts as user). The caller's
|
|
317
|
+
metadata dict is **copied, never mutated** — the Mem0 adapter
|
|
318
|
+
pops keys out of the dict you handed it.
|
|
319
|
+
"""
|
|
320
|
+
if _is_cancelled(cancellation_token):
|
|
321
|
+
return
|
|
322
|
+
text = self._render_text(content)
|
|
323
|
+
text = text.strip()
|
|
324
|
+
if not text:
|
|
325
|
+
return
|
|
326
|
+
if len(text) > MAX_TURN_CHARS:
|
|
327
|
+
text = text[:MAX_TURN_CHARS]
|
|
328
|
+
|
|
329
|
+
metadata = dict(content.metadata or {}) # copy: never mutate caller's dict
|
|
330
|
+
raw_role = str(metadata.pop("role", "user")).lower()
|
|
331
|
+
role = "ai" if raw_role in ("assistant", "ai") else "human"
|
|
332
|
+
metadata.pop("user_id", None) # scoping is this adapter's job
|
|
333
|
+
|
|
334
|
+
seed = f"{role}@{self._session_scope}#h{fnv1a64(f'{role}:{text}')}"
|
|
335
|
+
tenant_id = await self._api.resolve_tenant_id()
|
|
336
|
+
payload_metadata: Dict[str, Any] = {"session_id": self._session_scope}
|
|
337
|
+
if metadata:
|
|
338
|
+
payload_metadata.update(metadata)
|
|
339
|
+
# Silent-quota responses ({"status": "ok"} with no id) are
|
|
340
|
+
# accepted-by-contract, not errors; definite failures raise.
|
|
341
|
+
await self._api.add_turn(
|
|
342
|
+
tenant_id=tenant_id,
|
|
343
|
+
user_id=self.user_id,
|
|
344
|
+
text=f"{role}: {text}",
|
|
345
|
+
speaker=seed,
|
|
346
|
+
metadata=payload_metadata,
|
|
347
|
+
source=self.source,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _render_text(content: MemoryContent) -> str:
|
|
352
|
+
mime = content.mime_type
|
|
353
|
+
mime_value = mime.value if isinstance(mime, MemoryMimeType) else str(mime)
|
|
354
|
+
value = content.content
|
|
355
|
+
if mime_value == "application/json" and not isinstance(value, str):
|
|
356
|
+
try:
|
|
357
|
+
return json.dumps(value, ensure_ascii=False)
|
|
358
|
+
except (TypeError, ValueError):
|
|
359
|
+
return str(value)
|
|
360
|
+
return value if isinstance(value, str) else str(value)
|
|
361
|
+
|
|
362
|
+
async def add_turn_pair(self, user_text: str, assistant_text: str) -> None:
|
|
363
|
+
"""Persist one user/assistant exchange — the capture loop AutoGen
|
|
364
|
+
deliberately leaves to the application, reduced to one call."""
|
|
365
|
+
if user_text and user_text.strip():
|
|
366
|
+
await self.add(
|
|
367
|
+
MemoryContent(
|
|
368
|
+
content=user_text,
|
|
369
|
+
mime_type=MemoryMimeType.TEXT,
|
|
370
|
+
metadata={"role": "user"},
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
if assistant_text and assistant_text.strip():
|
|
374
|
+
await self.add(
|
|
375
|
+
MemoryContent(
|
|
376
|
+
content=assistant_text,
|
|
377
|
+
mime_type=MemoryMimeType.TEXT,
|
|
378
|
+
metadata={"role": "assistant"},
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
async def query(
|
|
383
|
+
self,
|
|
384
|
+
query: str | MemoryContent = "",
|
|
385
|
+
cancellation_token: Optional[CancellationToken] = None,
|
|
386
|
+
**kwargs: Any,
|
|
387
|
+
) -> MemoryQueryResult:
|
|
388
|
+
"""Semantic search. Raises on failure — an explicit query that
|
|
389
|
+
silently returns empty would make an outage look like amnesia."""
|
|
390
|
+
if _is_cancelled(cancellation_token):
|
|
391
|
+
return MemoryQueryResult(results=[])
|
|
392
|
+
query_text = (
|
|
393
|
+
query if isinstance(query, str) else _text_of(query.content) or str(query.content)
|
|
394
|
+
)
|
|
395
|
+
limit = kwargs.pop("limit", None) or kwargs.pop("k", None) or self.top_k
|
|
396
|
+
tenant_id = await self._api.resolve_tenant_id()
|
|
397
|
+
response = await self._api.query(
|
|
398
|
+
tenant_id=tenant_id,
|
|
399
|
+
user_id=self.user_id,
|
|
400
|
+
prompt=query_text,
|
|
401
|
+
k=int(limit),
|
|
402
|
+
)
|
|
403
|
+
raw = response.get("memories") if isinstance(response, dict) else []
|
|
404
|
+
return MemoryQueryResult(results=self._memories_to_content(raw))
|
|
405
|
+
|
|
406
|
+
async def clear(self) -> None:
|
|
407
|
+
"""Delete stored memories — scoped to this session by default.
|
|
408
|
+
|
|
409
|
+
``clear_scope="session"`` (default): only rows this adapter wrote
|
|
410
|
+
under ``autogen::<session>``. ``clear_scope="user"``: every memory
|
|
411
|
+
for the user — an explicit, documented opt-in, because an agent
|
|
412
|
+
framework calling ``clear()`` must never be able to erase a
|
|
413
|
+
customer's whole history by accident.
|
|
414
|
+
"""
|
|
415
|
+
tenant_id = await self._api.resolve_tenant_id()
|
|
416
|
+
items = await self._api.list_memories(
|
|
417
|
+
tenant_id=tenant_id, user_id=self.user_id
|
|
418
|
+
)
|
|
419
|
+
ids: List[int] = []
|
|
420
|
+
for item in items:
|
|
421
|
+
if self.clear_scope == "session" and not self._in_session(item):
|
|
422
|
+
continue
|
|
423
|
+
parsed = self._parse_id(item.get("memory_id"))
|
|
424
|
+
if parsed is not None:
|
|
425
|
+
ids.append(parsed)
|
|
426
|
+
await self._api.forget(user_id=self.user_id, memory_ids=ids)
|
|
427
|
+
|
|
428
|
+
def _in_session(self, item: Dict[str, Any]) -> bool:
|
|
429
|
+
# Production nests client metadata one level deeper
|
|
430
|
+
# (metadata.metadata.session_id); older shapes were flat. Accept
|
|
431
|
+
# both — the same dual-envelope rule as every other adapter.
|
|
432
|
+
outer = item.get("metadata")
|
|
433
|
+
if not isinstance(outer, dict):
|
|
434
|
+
return False
|
|
435
|
+
inner = outer.get("metadata")
|
|
436
|
+
session = None
|
|
437
|
+
if isinstance(inner, dict):
|
|
438
|
+
session = inner.get("session_id")
|
|
439
|
+
if session is None:
|
|
440
|
+
session = outer.get("session_id")
|
|
441
|
+
return session == self._session_scope
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def _parse_id(raw: Any) -> Optional[int]:
|
|
445
|
+
if raw is None:
|
|
446
|
+
return None
|
|
447
|
+
value = str(raw)
|
|
448
|
+
if value.startswith("m_"):
|
|
449
|
+
value = value[2:]
|
|
450
|
+
try:
|
|
451
|
+
return int(value)
|
|
452
|
+
except ValueError:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
async def close(self) -> None:
|
|
456
|
+
"""Release the HTTP client (Mem0's close() is a no-op ``pass``)."""
|
|
457
|
+
await self._api.aclose()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Model-callable memory tools for tool-equipped AutoGen agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from autogen_core.tools import FunctionTool
|
|
8
|
+
|
|
9
|
+
from .memory import MemorySyncMemory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_memory_tools(memory: MemorySyncMemory) -> List[FunctionTool]:
|
|
13
|
+
"""Two ``FunctionTool``s bound to one adapter: search and save.
|
|
14
|
+
|
|
15
|
+
``search_memory`` reads through the same budgetless explicit path as
|
|
16
|
+
``Memory.query`` (the model chose to wait). ``save_memory`` goes
|
|
17
|
+
through the server's extraction path, so the server — not the model —
|
|
18
|
+
decides whether the text is durable enough to keep.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
async def search_memory(query: str, limit: int = 5) -> str:
|
|
22
|
+
"""Search the user's long-term memory for relevant context."""
|
|
23
|
+
result = await memory.query(query, limit=limit)
|
|
24
|
+
if not result.results:
|
|
25
|
+
return "No relevant memories found."
|
|
26
|
+
lines = []
|
|
27
|
+
for i, item in enumerate(result.results, 1):
|
|
28
|
+
lines.append(f"{i}. {item.content}")
|
|
29
|
+
return "\n".join(lines)
|
|
30
|
+
|
|
31
|
+
async def save_memory(text: str) -> str:
|
|
32
|
+
"""Save an important fact about the user to long-term memory."""
|
|
33
|
+
response = await memory._api.add_memory(
|
|
34
|
+
user_id=memory.user_id, text=text, source=memory.source
|
|
35
|
+
)
|
|
36
|
+
if isinstance(response, dict) and response.get("id") is not None:
|
|
37
|
+
return f"Saved (id m_{response['id']})."
|
|
38
|
+
if isinstance(response, dict) and response.get("status") == "skipped":
|
|
39
|
+
return "Not saved: no durable content."
|
|
40
|
+
return "Accepted."
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
FunctionTool(
|
|
44
|
+
search_memory,
|
|
45
|
+
name="search_memory",
|
|
46
|
+
description="Search the user's long-term memory for relevant context and preferences.",
|
|
47
|
+
),
|
|
48
|
+
FunctionTool(
|
|
49
|
+
save_memory,
|
|
50
|
+
name="save_memory",
|
|
51
|
+
description="Save an important, durable fact about the user to long-term memory.",
|
|
52
|
+
),
|
|
53
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: autogen-memorysync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MemorySync for Microsoft AutoGen: an async-native Memory implementation with a hard recall budget, role-aware retrieval, duplicate-proof persistence, and session-scoped clear.
|
|
5
|
+
Project-URL: Homepage, https://docs.memorysync.io/guides/autogen
|
|
6
|
+
Project-URL: Documentation, https://docs.memorysync.io/guides/autogen
|
|
7
|
+
Project-URL: Repository, https://github.com/Rafay121/memorysync-plugins
|
|
8
|
+
Author-email: MemorySync <support@memorysync.io>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,autogen,autogen-agentchat,long-term-memory,memory,memorysync
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: autogen-core<0.8,>=0.4.0
|
|
21
|
+
Requires-Dist: httpx<1,>=0.25
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# autogen-memorysync
|
|
25
|
+
|
|
26
|
+
[MemorySync](https://memorysync.io) for [Microsoft AutoGen](https://github.com/microsoft/autogen)
|
|
27
|
+
(`autogen-agentchat` 0.4+): agents that remember users across sessions —
|
|
28
|
+
without ever stalling a turn.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install autogen-memorysync
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from autogen_agentchat.agents import AssistantAgent
|
|
38
|
+
from autogen_memorysync import MemorySyncMemory
|
|
39
|
+
|
|
40
|
+
memory = MemorySyncMemory(
|
|
41
|
+
api_key="ms_...", # or MEMORYSYNC_API_KEY
|
|
42
|
+
user_id="customer-42", # required — who these memories belong to
|
|
43
|
+
session_id="support-chat", # scopes the transcript
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
agent = AssistantAgent("assistant", model_client=..., memory=[memory])
|
|
47
|
+
|
|
48
|
+
result = await agent.run(task=user_text)
|
|
49
|
+
# AutoGen never persists automatically — capture the exchange in one call:
|
|
50
|
+
await memory.add_turn_pair(user_text, result.messages[-1].content)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`update_context` runs automatically before every model call: relevant
|
|
54
|
+
memories are recalled and injected as a `SystemMessage`, and the
|
|
55
|
+
retrieval surfaces to observers as a `MemoryQueryEvent`.
|
|
56
|
+
|
|
57
|
+
## Why this one
|
|
58
|
+
|
|
59
|
+
| | Mem0 (`autogen-ext[mem0]`) | Zep (`zep-autogen`) | **MemorySync** |
|
|
60
|
+
| --- | --- | --- | --- |
|
|
61
|
+
| Async correctness | ✗ sync client inside `async def` — blocks the event loop | ✓ | ✓ `httpx.AsyncClient` throughout |
|
|
62
|
+
| Recall latency budget | ✗ none | ✗ none | ✓ hard 1.2s default — a slow backend means an unenriched turn, never a late one |
|
|
63
|
+
| Retrieval query | ✗ `messages[-1]` even when it's assistant/tool text | last context message | ✓ last **user** message, role-aware |
|
|
64
|
+
| Query errors | ✗ swallowed — outage looks like amnesia | logged | ✓ explicit `query()` raises; only the hot path fails open |
|
|
65
|
+
| `clear()` blast radius | ✗ entire user | ✗ entire user | ✓ **session-scoped by default**; whole-user wipe is an explicit opt-in |
|
|
66
|
+
| Retry safety | ✗ | ✗ | ✓ deterministic idempotency seeds — retries converge on one row |
|
|
67
|
+
| Caller's metadata dict | ✗ mutated (`pop`) | — | ✓ copied, never touched |
|
|
68
|
+
| `close()` | `pass` | ✓ | ✓ releases the HTTP client |
|
|
69
|
+
|
|
70
|
+
## Semantics worth knowing
|
|
71
|
+
|
|
72
|
+
- **The turn is never stalled and never broken.** Recall waits at most
|
|
73
|
+
`recall_timeout` (default 1.2s); on timeout, outage, or quota
|
|
74
|
+
exhaustion the agent simply answers without memories.
|
|
75
|
+
- **`user_id` is required.** No silent auto-generated UUID namespaces
|
|
76
|
+
where stored memories can never be found again.
|
|
77
|
+
- Turns store verbatim under the `autogen::<session>` transcript scope —
|
|
78
|
+
separate history, same shared user memories as every other MemorySync
|
|
79
|
+
surface.
|
|
80
|
+
- Free-tier quota exhaustion is silent by design (adds accepted-without-
|
|
81
|
+
storing, reads empty); evaluation keys surface strict `429`s.
|
|
82
|
+
- `create_memory_tools(memory)` returns `search_memory` + `save_memory`
|
|
83
|
+
`FunctionTool`s for tool-equipped agents.
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
| Parameter | Default | Meaning |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `user_id` | — (required) | End user the memories belong to |
|
|
90
|
+
| `session_id` | `"default"` | Transcript scope |
|
|
91
|
+
| `top_k` | `5` | Memories considered per turn |
|
|
92
|
+
| `recall_timeout` | `1.2` | Hard recall budget, seconds |
|
|
93
|
+
| `min_prompt_chars` | `8` | Skip recall for trivial prompts |
|
|
94
|
+
| `context_template` | built-in | `{context}` placeholder, brace-safe `.replace` rendering |
|
|
95
|
+
| `clear_scope` | `"session"` | `clear()` blast radius; `"user"` opt-in wipes everything |
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install -e . autogen-agentchat autogen-ext pytest pytest-asyncio
|
|
101
|
+
python -m pytest tests -q # 33 tests incl. a REAL AssistantAgent drive
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Note: `autogen-agentchat` is in maintenance mode (Microsoft's successor
|
|
105
|
+
is the Microsoft Agent Framework — a separate MemorySync adapter target).
|
|
106
|
+
Maintenance mode means the `Memory` protocol this package implements is
|
|
107
|
+
frozen and stable.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
autogen_memorysync/__init__.py,sha256=PiFu40CqP-kZOvQTZuWZbBmPO_W4_LHCgODOQWB0RX4,497
|
|
2
|
+
autogen_memorysync/_api.py,sha256=B2NsPJRRoHPCNv86yA6XrDdDxudVu-EBKm_sfWfpb7w,11153
|
|
3
|
+
autogen_memorysync/_version.py,sha256=ZhzQKWZ8RFrTIkj7z87B144DI95e8LMfA5w8NDWQDtg,23
|
|
4
|
+
autogen_memorysync/memory.py,sha256=96U2XS4Y763PLL7Q_whlv0YB971gZxwASLzNbseUAwk,18230
|
|
5
|
+
autogen_memorysync/tools.py,sha256=1qJncGUnaGdWCy9wjocHK51SX-I6TsHvOzv71MU4jGs,2048
|
|
6
|
+
autogen_memorysync-1.0.0.dist-info/METADATA,sha256=R7MCtmXzext1rWTwAeOz4SeW-tLtmsQVRcVr0D3VivA,4997
|
|
7
|
+
autogen_memorysync-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
autogen_memorysync-1.0.0.dist-info/RECORD,,
|