ag2-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.
- ag2_memorysync/__init__.py +15 -0
- ag2_memorysync/_api.py +291 -0
- ag2_memorysync/_bridge.py +96 -0
- ag2_memorysync/_version.py +1 -0
- ag2_memorysync/capability.py +353 -0
- ag2_memorysync/tools.py +67 -0
- ag2_memorysync-1.0.0.dist-info/METADATA +107 -0
- ag2_memorysync-1.0.0.dist-info/RECORD +9 -0
- ag2_memorysync-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""MemorySync for AG2 (AutoGen classic ConversableAgent framework)."""
|
|
2
|
+
|
|
3
|
+
from ._api import MemorySyncAPIError, fnv1a64
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .capability import DEFAULT_CONTEXT_TEMPLATE, MemorySyncCapability
|
|
6
|
+
from .tools import register_memory_tools
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"MemorySyncCapability",
|
|
10
|
+
"MemorySyncAPIError",
|
|
11
|
+
"DEFAULT_CONTEXT_TEMPLATE",
|
|
12
|
+
"register_memory_tools",
|
|
13
|
+
"fnv1a64",
|
|
14
|
+
"__version__",
|
|
15
|
+
]
|
ag2_memorysync/_api.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
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: it lives on the bridge's dedicated
|
|
10
|
+
event loop, and AG2's synchronous hooks submit work to it without ever
|
|
11
|
+
touching the caller's loop.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://api.memorysync.io"
|
|
24
|
+
_USER_AGENT = f"ag2-memorysync/{__version__}"
|
|
25
|
+
|
|
26
|
+
#: Namespace used when the key cannot list projects (see resolve_tenant_id).
|
|
27
|
+
FALLBACK_TENANT = "default"
|
|
28
|
+
|
|
29
|
+
#: One turn beyond this length is truncated before storage.
|
|
30
|
+
MAX_TURN_CHARS = 16000
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MemorySyncAPIError(Exception):
|
|
34
|
+
"""A MemorySync call failed. Carries the status code and server detail."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.status_code = status_code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_api_key(api_key: Optional[str]) -> str:
|
|
42
|
+
key = api_key or os.environ.get("MEMORYSYNC_API_KEY", "")
|
|
43
|
+
if not key or not key.strip():
|
|
44
|
+
raise ValueError(
|
|
45
|
+
"A MemorySync API key is required. Pass api_key=... or set the "
|
|
46
|
+
"MEMORYSYNC_API_KEY environment variable."
|
|
47
|
+
)
|
|
48
|
+
return key.strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_base_url(base_url: Optional[str]) -> str:
|
|
52
|
+
url = base_url or os.environ.get("MEMORYSYNC_BASE_URL", "") or DEFAULT_BASE_URL
|
|
53
|
+
return url.rstrip("/")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fnv1a64(value: str) -> str:
|
|
57
|
+
"""FNV-1a 64-bit over UTF-16 code units, as a fixed-width hex string.
|
|
58
|
+
|
|
59
|
+
Over UTF-16 code units — not code points, not UTF-8 bytes — so the
|
|
60
|
+
output matches every other MemorySync adapter (Python and JS)
|
|
61
|
+
character for character. Identical seeds across surfaces mean a turn
|
|
62
|
+
persisted here and again elsewhere converge on one stored row.
|
|
63
|
+
"""
|
|
64
|
+
prime = 0x100000001B3
|
|
65
|
+
mask = 0xFFFFFFFFFFFFFFFF
|
|
66
|
+
h = 0xCBF29CE484222325
|
|
67
|
+
data = value.encode("utf-16-le")
|
|
68
|
+
for i in range(0, len(data), 2):
|
|
69
|
+
unit = data[i] | (data[i + 1] << 8)
|
|
70
|
+
h ^= unit
|
|
71
|
+
h = (h * prime) & mask
|
|
72
|
+
return format(h, "016x")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AsyncV1Api:
|
|
76
|
+
"""Minimal asynchronous v1 client: add_turn, recall, query, list, forget."""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
api_key: str,
|
|
82
|
+
base_url: str,
|
|
83
|
+
project_id: Optional[str] = None,
|
|
84
|
+
timeout: float = 30.0,
|
|
85
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self._api_key = api_key
|
|
88
|
+
self._base_url = base_url.rstrip("/")
|
|
89
|
+
self._project_id = project_id
|
|
90
|
+
self._timeout = timeout
|
|
91
|
+
self._transport = transport
|
|
92
|
+
self._http = httpx.AsyncClient(timeout=timeout, transport=transport)
|
|
93
|
+
self._tenant_id: Optional[str] = None
|
|
94
|
+
self._tenant_is_fallback = False
|
|
95
|
+
|
|
96
|
+
async def aclose(self) -> None:
|
|
97
|
+
await self._http.aclose()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def base_url(self) -> str:
|
|
101
|
+
return self._base_url
|
|
102
|
+
|
|
103
|
+
# ── plumbing ─────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def _headers(self, *, end_user_id: Optional[str] = None) -> Dict[str, str]:
|
|
106
|
+
h = {
|
|
107
|
+
"X-API-Key": self._api_key,
|
|
108
|
+
"Accept": "application/json",
|
|
109
|
+
"User-Agent": _USER_AGENT,
|
|
110
|
+
}
|
|
111
|
+
if self._project_id:
|
|
112
|
+
h["X-Project-ID"] = self._project_id
|
|
113
|
+
if end_user_id:
|
|
114
|
+
h["X-End-User-ID"] = end_user_id
|
|
115
|
+
return h
|
|
116
|
+
|
|
117
|
+
async def _request(
|
|
118
|
+
self,
|
|
119
|
+
method: str,
|
|
120
|
+
path: str,
|
|
121
|
+
*,
|
|
122
|
+
json: Optional[Dict[str, Any]] = None,
|
|
123
|
+
params: Optional[Dict[str, Any]] = None,
|
|
124
|
+
end_user_id: Optional[str] = None,
|
|
125
|
+
) -> Any:
|
|
126
|
+
url = f"{self._base_url}{path}"
|
|
127
|
+
try:
|
|
128
|
+
response = await self._http.request(
|
|
129
|
+
method,
|
|
130
|
+
url,
|
|
131
|
+
headers=self._headers(end_user_id=end_user_id),
|
|
132
|
+
json=json,
|
|
133
|
+
params=params,
|
|
134
|
+
)
|
|
135
|
+
except httpx.TimeoutException as e:
|
|
136
|
+
raise MemorySyncAPIError(f"Request timed out: {e}") from e
|
|
137
|
+
except httpx.HTTPError as e:
|
|
138
|
+
raise MemorySyncAPIError(f"Network error: {e}") from e
|
|
139
|
+
|
|
140
|
+
if response.status_code == 204:
|
|
141
|
+
return None
|
|
142
|
+
try:
|
|
143
|
+
body: Any = response.json()
|
|
144
|
+
except ValueError:
|
|
145
|
+
body = response.text or None
|
|
146
|
+
if response.status_code >= 400:
|
|
147
|
+
detail = body.get("detail") if isinstance(body, dict) else body
|
|
148
|
+
raise MemorySyncAPIError(
|
|
149
|
+
f"{method} {path} failed with HTTP {response.status_code}: {detail}",
|
|
150
|
+
status_code=response.status_code,
|
|
151
|
+
)
|
|
152
|
+
return body
|
|
153
|
+
|
|
154
|
+
# ── calls ────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
async def resolve_tenant_id(self) -> str:
|
|
157
|
+
"""The tenant id, which the v1 routes need in path or body.
|
|
158
|
+
|
|
159
|
+
Derived from the project listing rather than asked for. Cached for
|
|
160
|
+
the lifetime of this client. Keys without the ``projects:read``
|
|
161
|
+
scope (evaluation keys) fall back to the fixed namespace
|
|
162
|
+
``"default"`` — deterministic, so every read and write through
|
|
163
|
+
this client lands in one namespace. Only a definite 401/403
|
|
164
|
+
triggers the fallback; a transient server error re-raises rather
|
|
165
|
+
than silently switching namespaces.
|
|
166
|
+
"""
|
|
167
|
+
if self._tenant_id:
|
|
168
|
+
return self._tenant_id
|
|
169
|
+
try:
|
|
170
|
+
projects = await self._request("GET", "/org/projects")
|
|
171
|
+
except MemorySyncAPIError as exc:
|
|
172
|
+
if exc.status_code in (401, 403):
|
|
173
|
+
self._tenant_id = FALLBACK_TENANT
|
|
174
|
+
self._tenant_is_fallback = True
|
|
175
|
+
return self._tenant_id
|
|
176
|
+
raise
|
|
177
|
+
first = projects[0] if isinstance(projects, list) and projects else None
|
|
178
|
+
tenant = first.get("tenant_id") if isinstance(first, dict) else None
|
|
179
|
+
if not tenant:
|
|
180
|
+
raise MemorySyncAPIError(
|
|
181
|
+
"Could not determine the tenant for this API key. Pass "
|
|
182
|
+
"tenant_id explicitly, or verify the key with `memorysync doctor`."
|
|
183
|
+
)
|
|
184
|
+
self._tenant_id = str(tenant)
|
|
185
|
+
return self._tenant_id
|
|
186
|
+
|
|
187
|
+
def set_tenant_id(self, tenant_id: str) -> None:
|
|
188
|
+
self._tenant_id = tenant_id
|
|
189
|
+
|
|
190
|
+
async def add_turn(
|
|
191
|
+
self,
|
|
192
|
+
*,
|
|
193
|
+
tenant_id: str,
|
|
194
|
+
user_id: str,
|
|
195
|
+
text: str,
|
|
196
|
+
speaker: Optional[str] = None,
|
|
197
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
198
|
+
source: str = "ag2",
|
|
199
|
+
sync_embed: bool = False,
|
|
200
|
+
) -> Dict[str, Any]:
|
|
201
|
+
"""Store one item verbatim (episodic ingestion).
|
|
202
|
+
|
|
203
|
+
``speaker`` participates in the server's idempotency seed, so
|
|
204
|
+
retrying an identical payload is recognised
|
|
205
|
+
(``already_exists: true``) instead of stored twice.
|
|
206
|
+
"""
|
|
207
|
+
body: Dict[str, Any] = {
|
|
208
|
+
"tenant_id": tenant_id,
|
|
209
|
+
"user_id": user_id,
|
|
210
|
+
"source": source,
|
|
211
|
+
"text": text,
|
|
212
|
+
"sync_embed": sync_embed,
|
|
213
|
+
}
|
|
214
|
+
if speaker is not None:
|
|
215
|
+
body["speaker"] = speaker
|
|
216
|
+
if metadata is not None:
|
|
217
|
+
body["metadata"] = metadata
|
|
218
|
+
return await self._request("POST", "/v1/memory/add_turn", json=body) or {}
|
|
219
|
+
|
|
220
|
+
async def recall(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
tenant_id: str,
|
|
224
|
+
user_id: str,
|
|
225
|
+
prompt: str,
|
|
226
|
+
k: Optional[int] = None,
|
|
227
|
+
) -> Dict[str, Any]:
|
|
228
|
+
"""Hierarchical recall: grouped, prompt-ready context block."""
|
|
229
|
+
body: Dict[str, Any] = {
|
|
230
|
+
"tenant_id": tenant_id,
|
|
231
|
+
"user_id": user_id,
|
|
232
|
+
"prompt": prompt,
|
|
233
|
+
}
|
|
234
|
+
if k is not None:
|
|
235
|
+
body["k"] = k
|
|
236
|
+
return await self._request("POST", "/v1/memory/recall", json=body) or {}
|
|
237
|
+
|
|
238
|
+
async def query(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
tenant_id: str,
|
|
242
|
+
user_id: str,
|
|
243
|
+
prompt: str,
|
|
244
|
+
k: Optional[int] = None,
|
|
245
|
+
) -> Dict[str, Any]:
|
|
246
|
+
"""Plain semantic search over the pair's memories (episodic included)."""
|
|
247
|
+
body: Dict[str, Any] = {
|
|
248
|
+
"tenant_id": tenant_id,
|
|
249
|
+
"user_id": user_id,
|
|
250
|
+
"prompt": prompt,
|
|
251
|
+
}
|
|
252
|
+
if k is not None:
|
|
253
|
+
body["k"] = k
|
|
254
|
+
return await self._request("POST", "/v1/memory/query", json=body) or {}
|
|
255
|
+
|
|
256
|
+
async def list_memories(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
tenant_id: str,
|
|
260
|
+
user_id: str,
|
|
261
|
+
limit: int = 0,
|
|
262
|
+
) -> List[Dict[str, Any]]:
|
|
263
|
+
"""Every memory for the tenant/user pair, newest first."""
|
|
264
|
+
from urllib.parse import quote
|
|
265
|
+
|
|
266
|
+
raw = await self._request(
|
|
267
|
+
"GET",
|
|
268
|
+
f"/v1/memory/{quote(tenant_id, safe='')}/{quote(user_id, safe='')}/list",
|
|
269
|
+
params={"limit": limit},
|
|
270
|
+
)
|
|
271
|
+
memories = raw.get("memories") if isinstance(raw, dict) else None
|
|
272
|
+
return list(memories) if isinstance(memories, list) else []
|
|
273
|
+
|
|
274
|
+
async def add_memory(
|
|
275
|
+
self,
|
|
276
|
+
*,
|
|
277
|
+
user_id: str,
|
|
278
|
+
text: str,
|
|
279
|
+
source: str = "ag2",
|
|
280
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
281
|
+
) -> Dict[str, Any]:
|
|
282
|
+
"""Store a fact through the extraction path (server-side gating)."""
|
|
283
|
+
body: Dict[str, Any] = {"text": text, "source": source}
|
|
284
|
+
if metadata is not None:
|
|
285
|
+
body["metadata"] = metadata
|
|
286
|
+
return (
|
|
287
|
+
await self._request(
|
|
288
|
+
"POST", "/memory/add", json=body, end_user_id=user_id
|
|
289
|
+
)
|
|
290
|
+
or {}
|
|
291
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""One persistent background event loop bridging AG2's sync hooks to
|
|
2
|
+
async MemorySync calls.
|
|
3
|
+
|
|
4
|
+
AG2-classic's ``register_hook`` accepts only synchronous callables, so
|
|
5
|
+
every memory integration must bridge to async I/O somehow. The naive
|
|
6
|
+
bridge — spin up a fresh event loop per call — pays loop-startup latency
|
|
7
|
+
on every turn and carries a documented deadlock caveat in Zep's adapter
|
|
8
|
+
when the caller is already inside an async context.
|
|
9
|
+
|
|
10
|
+
This bridge instead runs ONE daemon thread with ONE long-lived event
|
|
11
|
+
loop per process. Callers submit coroutines with
|
|
12
|
+
``run_coroutine_threadsafe``:
|
|
13
|
+
|
|
14
|
+
- **The caller's own event loop is never touched.** Whether the hook
|
|
15
|
+
fires from plain ``initiate_chat`` or from inside somebody's
|
|
16
|
+
``asyncio.run(...)``, the coroutine executes on the bridge's loop and
|
|
17
|
+
the hook thread blocks — bounded by an explicit timeout — on a plain
|
|
18
|
+
``concurrent.futures`` future. No re-entrancy, no deadlock.
|
|
19
|
+
- **Fire-and-forget writes stay off the hot path.** Persistence submits
|
|
20
|
+
and returns immediately; ``flush()`` awaits stragglers at shutdown or
|
|
21
|
+
in tests.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import concurrent.futures
|
|
28
|
+
import logging
|
|
29
|
+
import threading
|
|
30
|
+
from typing import Any, Coroutine, Optional, Set
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("ag2_memorysync")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LoopBridge:
|
|
36
|
+
_shared: Optional["LoopBridge"] = None
|
|
37
|
+
_shared_lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._loop = asyncio.new_event_loop()
|
|
41
|
+
self._thread = threading.Thread(
|
|
42
|
+
target=self._run, name="memorysync-ag2-bridge", daemon=True
|
|
43
|
+
)
|
|
44
|
+
self._pending: Set[concurrent.futures.Future] = set()
|
|
45
|
+
self._pending_lock = threading.Lock()
|
|
46
|
+
self._thread.start()
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def shared(cls) -> "LoopBridge":
|
|
50
|
+
with cls._shared_lock:
|
|
51
|
+
if cls._shared is None or not cls._shared._thread.is_alive():
|
|
52
|
+
cls._shared = cls()
|
|
53
|
+
return cls._shared
|
|
54
|
+
|
|
55
|
+
def _run(self) -> None:
|
|
56
|
+
asyncio.set_event_loop(self._loop)
|
|
57
|
+
self._loop.run_forever()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def loop(self) -> asyncio.AbstractEventLoop:
|
|
61
|
+
return self._loop
|
|
62
|
+
|
|
63
|
+
def call(self, coro: Coroutine[Any, Any, Any], timeout: float) -> Any:
|
|
64
|
+
"""Run a coroutine on the bridge loop; block at most ``timeout``.
|
|
65
|
+
|
|
66
|
+
On timeout the underlying task is cancelled so a slow backend
|
|
67
|
+
cannot pile up abandoned work.
|
|
68
|
+
"""
|
|
69
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
70
|
+
try:
|
|
71
|
+
return future.result(timeout)
|
|
72
|
+
except concurrent.futures.TimeoutError:
|
|
73
|
+
future.cancel()
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
def submit(self, coro: Coroutine[Any, Any, Any], label: str) -> None:
|
|
77
|
+
"""Fire-and-forget: schedule, log failures, never block."""
|
|
78
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
79
|
+
with self._pending_lock:
|
|
80
|
+
self._pending.add(future)
|
|
81
|
+
|
|
82
|
+
def _done(f: concurrent.futures.Future) -> None:
|
|
83
|
+
with self._pending_lock:
|
|
84
|
+
self._pending.discard(f)
|
|
85
|
+
exc = f.exception() if not f.cancelled() else None
|
|
86
|
+
if exc is not None:
|
|
87
|
+
logger.warning("MemorySync %s skipped (%s)", label, exc)
|
|
88
|
+
|
|
89
|
+
future.add_done_callback(_done)
|
|
90
|
+
|
|
91
|
+
def flush(self, timeout: float = 10.0) -> None:
|
|
92
|
+
"""Wait for all in-flight fire-and-forget work to land."""
|
|
93
|
+
with self._pending_lock:
|
|
94
|
+
pending = list(self._pending)
|
|
95
|
+
if pending:
|
|
96
|
+
concurrent.futures.wait(pending, timeout=timeout)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""The automatic MemorySync memory loop for AG2-classic ConversableAgents.
|
|
2
|
+
|
|
3
|
+
AG2-classic has no memory protocol; ``ConversableAgent.register_hook``
|
|
4
|
+
is its only per-turn seam, and every hook must be a synchronous
|
|
5
|
+
callable. This capability wires two hooks:
|
|
6
|
+
|
|
7
|
+
- ``process_last_received_message`` (fires inside ``generate_reply``,
|
|
8
|
+
before the LLM): persists the ORIGINAL incoming text as a human turn
|
|
9
|
+
(fire-and-forget), recalls relevant context under a hard budget, and
|
|
10
|
+
returns the message with a context prefix. Verified AG2 behaviour: the
|
|
11
|
+
hook's return value feeds the LLM only — it is never written back to
|
|
12
|
+
``_oai_messages`` — so the injected prefix can never re-persist.
|
|
13
|
+
- ``process_message_before_send`` (fires on ``send``/``a_send``):
|
|
14
|
+
persists the outgoing reply as an ai turn (fire-and-forget) and
|
|
15
|
+
returns the message untouched.
|
|
16
|
+
|
|
17
|
+
Design rules, in priority order:
|
|
18
|
+
|
|
19
|
+
1. **The reply is never stalled and never broken.** Recall blocks the
|
|
20
|
+
hook for at most ``recall_timeout`` seconds (default 1.2); on
|
|
21
|
+
timeout, outage, or quota exhaustion the message passes through
|
|
22
|
+
unmodified. Persistence never blocks the hot path at all.
|
|
23
|
+
2. **No duplicate rows — even in multi-agent chats.** Zep's zep-ag2
|
|
24
|
+
README documents this bug in their own adapter: when two attached
|
|
25
|
+
agents share a session, A's outgoing "X" (ai) is B's incoming "X"
|
|
26
|
+
(human), so every utterance stores twice with conflicting roles. A
|
|
27
|
+
process-wide registry of recently persisted content marks lets the
|
|
28
|
+
receiving side recognise and skip what the sending side just stored.
|
|
29
|
+
Same-role retries additionally converge server-side via
|
|
30
|
+
deterministic idempotency seeds.
|
|
31
|
+
3. **No event-loop games.** All async work runs on one persistent
|
|
32
|
+
background loop (``_bridge.LoopBridge``); the caller's thread — and
|
|
33
|
+
the caller's event loop, if any — are never touched. Zep spins a new
|
|
34
|
+
loop per call and documents a deadlock caveat for async callers.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
|
43
|
+
|
|
44
|
+
import httpx
|
|
45
|
+
|
|
46
|
+
from ._api import (
|
|
47
|
+
MAX_TURN_CHARS,
|
|
48
|
+
AsyncV1Api,
|
|
49
|
+
MemorySyncAPIError,
|
|
50
|
+
fnv1a64,
|
|
51
|
+
resolve_api_key,
|
|
52
|
+
resolve_base_url,
|
|
53
|
+
)
|
|
54
|
+
from ._bridge import LoopBridge
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger("ag2_memorysync")
|
|
57
|
+
|
|
58
|
+
DEFAULT_CONTEXT_TEMPLATE = (
|
|
59
|
+
"Relevant user memories (MemorySync):\n{context}\n\n"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
#: How long a cross-agent dedup mark stays fresh, in seconds.
|
|
63
|
+
_MARK_TTL_S = 300.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _DedupRegistry:
|
|
67
|
+
"""Process-wide marks of recently persisted turns.
|
|
68
|
+
|
|
69
|
+
Key: ``(session_scope, content_hash)``. When agent A persists its
|
|
70
|
+
outgoing message, the mark lets agent B — receiving that same text a
|
|
71
|
+
microsecond later — recognise it and skip the double-store.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self._marks: Dict[Tuple[str, str], float] = {}
|
|
76
|
+
self._lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
def mark(self, session_scope: str, content_hash: str) -> None:
|
|
79
|
+
with self._lock:
|
|
80
|
+
self._prune()
|
|
81
|
+
self._marks[(session_scope, content_hash)] = time.monotonic()
|
|
82
|
+
|
|
83
|
+
def seen(self, session_scope: str, content_hash: str) -> bool:
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._prune()
|
|
86
|
+
return (session_scope, content_hash) in self._marks
|
|
87
|
+
|
|
88
|
+
def _prune(self) -> None:
|
|
89
|
+
cutoff = time.monotonic() - _MARK_TTL_S
|
|
90
|
+
stale = [k for k, t in self._marks.items() if t < cutoff]
|
|
91
|
+
for k in stale:
|
|
92
|
+
del self._marks[k]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_registry = _DedupRegistry()
|
|
96
|
+
|
|
97
|
+
_MessageShape = Union[Dict[str, Any], str]
|
|
98
|
+
_ContentShape = Union[str, List[Any]]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _content_of(message: Any) -> str:
|
|
102
|
+
if isinstance(message, str):
|
|
103
|
+
return message
|
|
104
|
+
if isinstance(message, list):
|
|
105
|
+
# Multimodal content: a list of {"type": "text", "text": ...} parts.
|
|
106
|
+
return "\n".join(
|
|
107
|
+
part.get("text", "")
|
|
108
|
+
for part in message
|
|
109
|
+
if isinstance(part, dict) and part.get("type") == "text"
|
|
110
|
+
).strip()
|
|
111
|
+
if isinstance(message, dict):
|
|
112
|
+
return _content_of(message.get("content"))
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _role_of(message: _MessageShape) -> str:
|
|
117
|
+
if isinstance(message, dict):
|
|
118
|
+
return str(message.get("role") or "")
|
|
119
|
+
return ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class MemorySyncCapability:
|
|
123
|
+
"""Attachable long-term memory for one AG2-classic agent.
|
|
124
|
+
|
|
125
|
+
Usage::
|
|
126
|
+
|
|
127
|
+
from autogen import ConversableAgent
|
|
128
|
+
from ag2_memorysync import MemorySyncCapability
|
|
129
|
+
|
|
130
|
+
agent = ConversableAgent("assistant", llm_config=...)
|
|
131
|
+
memory = MemorySyncCapability(user_id="customer-42", session_id="support")
|
|
132
|
+
memory.add_to_agent(agent)
|
|
133
|
+
|
|
134
|
+
From then on every incoming user message is persisted and enriched
|
|
135
|
+
with recalled context, and every outgoing reply is persisted — with
|
|
136
|
+
no further code.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
user_id: str,
|
|
143
|
+
session_id: str = "default",
|
|
144
|
+
api_key: Optional[str] = None,
|
|
145
|
+
base_url: Optional[str] = None,
|
|
146
|
+
project_id: Optional[str] = None,
|
|
147
|
+
top_k: int = 5,
|
|
148
|
+
recall_timeout: float = 1.2,
|
|
149
|
+
min_prompt_chars: int = 8,
|
|
150
|
+
context_template: str = DEFAULT_CONTEXT_TEMPLATE,
|
|
151
|
+
capture: str = "both",
|
|
152
|
+
source: str = "ag2",
|
|
153
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
if not user_id or not str(user_id).strip():
|
|
156
|
+
raise ValueError("user_id is required and must be non-empty")
|
|
157
|
+
if capture not in ("both", "received", "sent"):
|
|
158
|
+
raise ValueError('capture must be "both", "received", or "sent"')
|
|
159
|
+
self.user_id = str(user_id).strip()
|
|
160
|
+
self.session_id = str(session_id or "default").strip() or "default"
|
|
161
|
+
self.top_k = top_k
|
|
162
|
+
self.recall_timeout = recall_timeout
|
|
163
|
+
self.min_prompt_chars = min_prompt_chars
|
|
164
|
+
self.context_template = context_template
|
|
165
|
+
self.capture = capture
|
|
166
|
+
self.source = source
|
|
167
|
+
self._api = AsyncV1Api(
|
|
168
|
+
api_key=resolve_api_key(api_key),
|
|
169
|
+
base_url=resolve_base_url(base_url),
|
|
170
|
+
project_id=project_id,
|
|
171
|
+
timeout=max(recall_timeout * 4, 8.0),
|
|
172
|
+
transport=transport,
|
|
173
|
+
)
|
|
174
|
+
self._bridge = LoopBridge.shared()
|
|
175
|
+
self._attached: Set[int] = set()
|
|
176
|
+
|
|
177
|
+
# ── scope helpers ────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def _session_scope(self) -> str:
|
|
181
|
+
return f"ag2::{self.session_id}"
|
|
182
|
+
|
|
183
|
+
# ── attachment ───────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
def add_to_agent(self, agent: Any) -> None:
|
|
186
|
+
"""Register the memory hooks on a ConversableAgent.
|
|
187
|
+
|
|
188
|
+
Duck-typed: any object with AG2-classic's ``register_hook``
|
|
189
|
+
contract works, so this package needs no dependency on any
|
|
190
|
+
specific AutoGen distribution (and cannot version-conflict).
|
|
191
|
+
"""
|
|
192
|
+
register = getattr(agent, "register_hook", None)
|
|
193
|
+
if not callable(register):
|
|
194
|
+
raise TypeError(
|
|
195
|
+
"add_to_agent expects an AG2-classic ConversableAgent "
|
|
196
|
+
"(an object with register_hook). Install the classic "
|
|
197
|
+
"framework with `pip install autogen` and construct a "
|
|
198
|
+
"ConversableAgent — the rewritten `pip install ag2` v1 "
|
|
199
|
+
"package has no hook system yet."
|
|
200
|
+
)
|
|
201
|
+
if id(agent) in self._attached:
|
|
202
|
+
return # attaching twice must not double-register hooks
|
|
203
|
+
if self.capture in ("both", "received"):
|
|
204
|
+
register(
|
|
205
|
+
hookable_method="process_last_received_message",
|
|
206
|
+
hook=self._on_received,
|
|
207
|
+
)
|
|
208
|
+
if self.capture in ("both", "sent"):
|
|
209
|
+
register(
|
|
210
|
+
hookable_method="process_message_before_send",
|
|
211
|
+
hook=self._on_send,
|
|
212
|
+
)
|
|
213
|
+
self._attached.add(id(agent))
|
|
214
|
+
|
|
215
|
+
# ── hook 1: incoming ─────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
def _on_received(self, content: _ContentShape) -> _ContentShape:
|
|
218
|
+
"""Persist the ORIGINAL text, then return it context-prefixed.
|
|
219
|
+
|
|
220
|
+
AG2's dispatcher hands hooks the last message's CONTENT only —
|
|
221
|
+
a string, or a multimodal part list — and has already filtered
|
|
222
|
+
out function calls, context-carrying messages, and "exit". The
|
|
223
|
+
hook's return value replaces the content the LLM sees; it is
|
|
224
|
+
never written back to the stored conversation.
|
|
225
|
+
"""
|
|
226
|
+
text = _content_of(content).strip()
|
|
227
|
+
if not text:
|
|
228
|
+
return content
|
|
229
|
+
|
|
230
|
+
if self.capture in ("both", "received"):
|
|
231
|
+
self._persist(role="human", text=text)
|
|
232
|
+
|
|
233
|
+
if len(text) < self.min_prompt_chars:
|
|
234
|
+
return content
|
|
235
|
+
|
|
236
|
+
recalled = self._recall_bounded(text)
|
|
237
|
+
if not recalled:
|
|
238
|
+
return content
|
|
239
|
+
|
|
240
|
+
# .replace, never .format: memory text may contain { } % legally.
|
|
241
|
+
prefix = self.context_template.replace("{context}", recalled)
|
|
242
|
+
if isinstance(content, str):
|
|
243
|
+
return prefix + content
|
|
244
|
+
if isinstance(content, list):
|
|
245
|
+
return [{"type": "text", "text": prefix}] + list(content)
|
|
246
|
+
return content
|
|
247
|
+
|
|
248
|
+
# ── hook 2: outgoing ─────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def _on_send(
|
|
251
|
+
self,
|
|
252
|
+
sender: Any,
|
|
253
|
+
message: _MessageShape,
|
|
254
|
+
recipient: Any,
|
|
255
|
+
silent: bool,
|
|
256
|
+
) -> _MessageShape:
|
|
257
|
+
"""Persist the outgoing reply; return the message untouched."""
|
|
258
|
+
if isinstance(message, dict) and (
|
|
259
|
+
_role_of(message) in ("tool", "function") or "function_call" in message or "tool_calls" in message
|
|
260
|
+
):
|
|
261
|
+
return message # never persist tool noise
|
|
262
|
+
text = _content_of(message).strip()
|
|
263
|
+
if text:
|
|
264
|
+
self._persist(role="ai", text=text)
|
|
265
|
+
return message
|
|
266
|
+
|
|
267
|
+
# ── persistence (fire-and-forget, duplicate-proof) ───────────────
|
|
268
|
+
|
|
269
|
+
def _persist(self, *, role: str, text: str) -> None:
|
|
270
|
+
if len(text) > MAX_TURN_CHARS:
|
|
271
|
+
text = text[:MAX_TURN_CHARS]
|
|
272
|
+
content_hash = fnv1a64(text)
|
|
273
|
+
if _registry.seen(self._session_scope, content_hash):
|
|
274
|
+
# The other side of this exchange just stored the same
|
|
275
|
+
# content in this session (Zep's documented double-store
|
|
276
|
+
# scenario) — one row is the truth, skip the second.
|
|
277
|
+
return
|
|
278
|
+
_registry.mark(self._session_scope, content_hash)
|
|
279
|
+
seed = f"{role}@{self._session_scope}#h{fnv1a64(f'{role}:{text}')}"
|
|
280
|
+
|
|
281
|
+
async def _store() -> None:
|
|
282
|
+
tenant_id = await self._api.resolve_tenant_id()
|
|
283
|
+
await self._api.add_turn(
|
|
284
|
+
tenant_id=tenant_id,
|
|
285
|
+
user_id=self.user_id,
|
|
286
|
+
text=f"{role}: {text}",
|
|
287
|
+
speaker=seed,
|
|
288
|
+
metadata={"session_id": self._session_scope},
|
|
289
|
+
source=self.source,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
self._bridge.submit(_store(), label=f"{role}-turn store")
|
|
293
|
+
|
|
294
|
+
# ── recall (budgeted, fail-open) ─────────────────────────────────
|
|
295
|
+
|
|
296
|
+
def _recall_bounded(self, prompt: str) -> str:
|
|
297
|
+
async def _recall() -> str:
|
|
298
|
+
tenant_id = await self._api.resolve_tenant_id()
|
|
299
|
+
response = await self._api.recall(
|
|
300
|
+
tenant_id=tenant_id,
|
|
301
|
+
user_id=self.user_id,
|
|
302
|
+
prompt=prompt,
|
|
303
|
+
k=self.top_k,
|
|
304
|
+
)
|
|
305
|
+
block = response.get("context") if isinstance(response, dict) else ""
|
|
306
|
+
return block if isinstance(block, str) else ""
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
context = self._bridge.call(_recall(), timeout=self.recall_timeout)
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
logger.warning(
|
|
312
|
+
"MemorySync recall unavailable this turn (%s) — continuing without memories.",
|
|
313
|
+
exc,
|
|
314
|
+
)
|
|
315
|
+
return ""
|
|
316
|
+
return context.strip() if isinstance(context, str) else ""
|
|
317
|
+
|
|
318
|
+
# ── explicit operations ──────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
def search(self, query: str, *, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
|
321
|
+
"""Synchronous semantic search (raises on definite failure)."""
|
|
322
|
+
|
|
323
|
+
async def _query() -> List[Dict[str, Any]]:
|
|
324
|
+
tenant_id = await self._api.resolve_tenant_id()
|
|
325
|
+
response = await self._api.query(
|
|
326
|
+
tenant_id=tenant_id,
|
|
327
|
+
user_id=self.user_id,
|
|
328
|
+
prompt=query,
|
|
329
|
+
k=limit or self.top_k,
|
|
330
|
+
)
|
|
331
|
+
raw = response.get("memories") if isinstance(response, dict) else []
|
|
332
|
+
return list(raw) if isinstance(raw, list) else []
|
|
333
|
+
|
|
334
|
+
return self._bridge.call(_query(), timeout=max(self.recall_timeout * 4, 8.0))
|
|
335
|
+
|
|
336
|
+
def save(self, text: str) -> Dict[str, Any]:
|
|
337
|
+
"""Synchronously store a fact through the extraction path."""
|
|
338
|
+
|
|
339
|
+
async def _save() -> Dict[str, Any]:
|
|
340
|
+
return await self._api.add_memory(
|
|
341
|
+
user_id=self.user_id, text=text, source=self.source
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return self._bridge.call(_save(), timeout=max(self.recall_timeout * 4, 8.0))
|
|
345
|
+
|
|
346
|
+
def flush(self, timeout: float = 10.0) -> None:
|
|
347
|
+
"""Wait for all fire-and-forget writes to land (tests, shutdown)."""
|
|
348
|
+
self._bridge.flush(timeout=timeout)
|
|
349
|
+
|
|
350
|
+
def close(self) -> None:
|
|
351
|
+
"""Flush pending writes and release the HTTP client."""
|
|
352
|
+
self.flush()
|
|
353
|
+
self._bridge.call(self._api.aclose(), timeout=10.0)
|
ag2_memorysync/tools.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Model-callable memory tools for AG2-classic agents.
|
|
2
|
+
|
|
3
|
+
No ``from __future__ import annotations`` here on purpose: AG2's
|
|
4
|
+
function-schema generator resolves tool annotations at registration
|
|
5
|
+
time, and postponed annotations become unresolvable strings inside its
|
|
6
|
+
TypeAdapter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .capability import MemorySyncCapability
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register_memory_tools(
|
|
15
|
+
memory: MemorySyncCapability,
|
|
16
|
+
*,
|
|
17
|
+
caller: Any,
|
|
18
|
+
executor: Any,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Register ``search_memory`` and ``save_memory`` on an agent pair.
|
|
21
|
+
|
|
22
|
+
``caller`` is the LLM agent that decides to call the tool;
|
|
23
|
+
``executor`` actually runs it (AG2-classic's standard split). Both
|
|
24
|
+
functions are synchronous — they ride the capability's bridge, so
|
|
25
|
+
they work in plain ``initiate_chat`` without event-loop concerns.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
from autogen import register_function
|
|
29
|
+
except ImportError as exc: # pragma: no cover - guarded by tests
|
|
30
|
+
raise ImportError(
|
|
31
|
+
"register_memory_tools needs AG2-classic: `pip install autogen`."
|
|
32
|
+
) from exc
|
|
33
|
+
|
|
34
|
+
def search_memory(query: str, limit: int = 5) -> str:
|
|
35
|
+
"""Search the user's long-term memory for relevant context."""
|
|
36
|
+
hits = memory.search(query, limit=limit)
|
|
37
|
+
if not hits:
|
|
38
|
+
return "No relevant memories found."
|
|
39
|
+
lines = []
|
|
40
|
+
for i, hit in enumerate(hits, 1):
|
|
41
|
+
text = hit.get("value") or hit.get("text") or hit.get("raw_text") or ""
|
|
42
|
+
lines.append(f"{i}. {text}")
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
def save_memory(text: str) -> str:
|
|
46
|
+
"""Save an important, durable fact about the user to long-term memory."""
|
|
47
|
+
response = memory.save(text)
|
|
48
|
+
if isinstance(response, dict) and response.get("id") is not None:
|
|
49
|
+
return f"Saved (id m_{response['id']})."
|
|
50
|
+
if isinstance(response, dict) and response.get("status") == "skipped":
|
|
51
|
+
return "Not saved: no durable content."
|
|
52
|
+
return "Accepted."
|
|
53
|
+
|
|
54
|
+
register_function(
|
|
55
|
+
search_memory,
|
|
56
|
+
caller=caller,
|
|
57
|
+
executor=executor,
|
|
58
|
+
name="search_memory",
|
|
59
|
+
description="Search the user's long-term memory for relevant context and preferences.",
|
|
60
|
+
)
|
|
61
|
+
register_function(
|
|
62
|
+
save_memory,
|
|
63
|
+
caller=caller,
|
|
64
|
+
executor=executor,
|
|
65
|
+
name="save_memory",
|
|
66
|
+
description="Save an important, durable fact about the user to long-term memory.",
|
|
67
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ag2-memorysync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MemorySync for AG2 (AutoGen classic): an automatic memory loop on ConversableAgent's hook system — budgeted recall injection, duplicate-proof both-side capture, and a deadlock-free sync bridge.
|
|
5
|
+
Project-URL: Homepage, https://docs.memorysync.io/guides/ag2
|
|
6
|
+
Project-URL: Documentation, https://docs.memorysync.io/guides/ag2
|
|
7
|
+
Project-URL: Repository, https://github.com/Rafay121/memorysync-plugins
|
|
8
|
+
Author-email: MemorySync <support@memorysync.io>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ag2,agents,autogen,conversableagent,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: httpx<1,>=0.25
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ag2-memorysync
|
|
24
|
+
|
|
25
|
+
[MemorySync](https://memorysync.io) for [AG2](https://github.com/ag2ai/ag2-classic)
|
|
26
|
+
(the classic AutoGen `ConversableAgent` framework, `pip install autogen`):
|
|
27
|
+
an automatic memory loop — recall injected before every reply, both sides
|
|
28
|
+
persisted, zero extra code per turn.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install ag2-memorysync
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from autogen import ConversableAgent
|
|
38
|
+
from ag2_memorysync import MemorySyncCapability
|
|
39
|
+
|
|
40
|
+
assistant = ConversableAgent("assistant", llm_config=...)
|
|
41
|
+
|
|
42
|
+
memory = MemorySyncCapability(
|
|
43
|
+
api_key="ms_...", # or MEMORYSYNC_API_KEY
|
|
44
|
+
user_id="customer-42", # required — who these memories belong to
|
|
45
|
+
session_id="support-chat", # scopes the transcript
|
|
46
|
+
)
|
|
47
|
+
memory.add_to_agent(assistant) # that's the whole integration
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
From then on every incoming user message is persisted and enriched with
|
|
51
|
+
recalled context, and every outgoing reply is persisted — through AG2's
|
|
52
|
+
own hook system (`process_last_received_message` +
|
|
53
|
+
`process_message_before_send`).
|
|
54
|
+
|
|
55
|
+
## Why this one
|
|
56
|
+
|
|
57
|
+
| | Mem0 | Zep (`zep-ag2`) | **MemorySync** |
|
|
58
|
+
| --- | --- | --- | --- |
|
|
59
|
+
| AG2 adapter exists | ✗ docs show an AutoGen-0.2 recipe with placeholder model names | ✓ | ✓ |
|
|
60
|
+
| Multi-agent duplication | — | ✗ **documented bug**: two attached agents store every utterance twice with conflicting roles | ✓ cross-hook dedup registry + idempotency seeds — one utterance, one row (by test) |
|
|
61
|
+
| Sync→async bridge | — | per-call event-loop spin; documented deadlock caveat under asyncio | ✓ one persistent background loop; never touches the caller's loop (asyncio-driven chats pass, by test) |
|
|
62
|
+
| Recall latency budget | — | ✗ none | ✓ hard 1.2s default — the reply is never late |
|
|
63
|
+
| Framework pin | — | ✗ `ag2<1` — breaks on the v1 rewrite | ✓ **zero framework dependency** (duck-typed attach; works with whichever classic distribution you installed) |
|
|
64
|
+
| Injected context re-stored? | — | system-message mutation, last-write-wins | ✓ hook output feeds the LLM only; the ORIGINAL text is what persists |
|
|
65
|
+
|
|
66
|
+
## Semantics worth knowing
|
|
67
|
+
|
|
68
|
+
- **The reply is never stalled and never broken.** Recall blocks at most
|
|
69
|
+
`recall_timeout` (default 1.2s); persistence is fire-and-forget off
|
|
70
|
+
the hot path. Outages and quota exhaustion degrade to "no memories
|
|
71
|
+
this turn".
|
|
72
|
+
- Turns store verbatim under the `ag2::<session>` transcript scope with
|
|
73
|
+
deterministic idempotency seeds — retries and multi-agent echoes
|
|
74
|
+
converge on one stored row.
|
|
75
|
+
- Tool/function messages are never persisted.
|
|
76
|
+
- `register_memory_tools(memory, caller=..., executor=...)` adds
|
|
77
|
+
`search_memory` + `save_memory` tools (the caller needs an
|
|
78
|
+
`llm_config`, as usual for AG2 tools).
|
|
79
|
+
- `memory.flush()` waits for in-flight writes (shutdown/tests);
|
|
80
|
+
`memory.close()` flushes and releases the HTTP client.
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
| Parameter | Default | Meaning |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `user_id` | — (required) | End user the memories belong to |
|
|
87
|
+
| `session_id` | `"default"` | Transcript scope |
|
|
88
|
+
| `top_k` | `5` | Memories considered per turn |
|
|
89
|
+
| `recall_timeout` | `1.2` | Hard recall budget, seconds |
|
|
90
|
+
| `min_prompt_chars` | `8` | Skip recall for trivial messages |
|
|
91
|
+
| `context_template` | built-in | `{context}` placeholder, brace-safe `.replace` rendering |
|
|
92
|
+
| `capture` | `"both"` | `"received"` / `"sent"` to capture one side only |
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install -e . "autogen[openai]" pytest
|
|
98
|
+
python -m pytest tests -q # 22 tests through REAL ConversableAgent chats
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The suite includes a reproduction of zep-ag2's documented multi-agent
|
|
102
|
+
double-store scenario (we store once) and a chat driven from inside
|
|
103
|
+
`asyncio.run()` (no deadlock).
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ag2_memorysync/__init__.py,sha256=GiAIlC4rS7-sYNvUZ_ysYPFXgWYEx-Wrx4l9dffd93U,443
|
|
2
|
+
ag2_memorysync/_api.py,sha256=1mpd0aZO1e_OFyo2CShklvorhuTCaoxItqd7C7hG5Vg,10300
|
|
3
|
+
ag2_memorysync/_bridge.py,sha256=MMWfuaHf4586kAhtoxuEwBELObdDLottBoTBHDlOzhw,3640
|
|
4
|
+
ag2_memorysync/_version.py,sha256=ZhzQKWZ8RFrTIkj7z87B144DI95e8LMfA5w8NDWQDtg,23
|
|
5
|
+
ag2_memorysync/capability.py,sha256=I6LYCdOhShojHQvbXhDlI9LFq3W0kHvb9gSnRiFftYU,14265
|
|
6
|
+
ag2_memorysync/tools.py,sha256=uPiqQBSX1MKen9MK5c26LD6xWU97cnXPWfXx9vzAdzY,2496
|
|
7
|
+
ag2_memorysync-1.0.0.dist-info/METADATA,sha256=hQ6RMYa8JYwoFDxMSFbOOOSWiZakd1-CM4E2mQCuvNY,4838
|
|
8
|
+
ag2_memorysync-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
ag2_memorysync-1.0.0.dist-info/RECORD,,
|