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.
@@ -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)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any