ag2-memorysync 1.0.0__tar.gz

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,5 @@
1
+ venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
@@ -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,85 @@
1
+ # ag2-memorysync
2
+
3
+ [MemorySync](https://memorysync.io) for [AG2](https://github.com/ag2ai/ag2-classic)
4
+ (the classic AutoGen `ConversableAgent` framework, `pip install autogen`):
5
+ an automatic memory loop — recall injected before every reply, both sides
6
+ persisted, zero extra code per turn.
7
+
8
+ ```bash
9
+ pip install ag2-memorysync
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ ```python
15
+ from autogen import ConversableAgent
16
+ from ag2_memorysync import MemorySyncCapability
17
+
18
+ assistant = ConversableAgent("assistant", llm_config=...)
19
+
20
+ memory = MemorySyncCapability(
21
+ api_key="ms_...", # or MEMORYSYNC_API_KEY
22
+ user_id="customer-42", # required — who these memories belong to
23
+ session_id="support-chat", # scopes the transcript
24
+ )
25
+ memory.add_to_agent(assistant) # that's the whole integration
26
+ ```
27
+
28
+ From then on every incoming user message is persisted and enriched with
29
+ recalled context, and every outgoing reply is persisted — through AG2's
30
+ own hook system (`process_last_received_message` +
31
+ `process_message_before_send`).
32
+
33
+ ## Why this one
34
+
35
+ | | Mem0 | Zep (`zep-ag2`) | **MemorySync** |
36
+ | --- | --- | --- | --- |
37
+ | AG2 adapter exists | ✗ docs show an AutoGen-0.2 recipe with placeholder model names | ✓ | ✓ |
38
+ | 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) |
39
+ | 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) |
40
+ | Recall latency budget | — | ✗ none | ✓ hard 1.2s default — the reply is never late |
41
+ | Framework pin | — | ✗ `ag2<1` — breaks on the v1 rewrite | ✓ **zero framework dependency** (duck-typed attach; works with whichever classic distribution you installed) |
42
+ | Injected context re-stored? | — | system-message mutation, last-write-wins | ✓ hook output feeds the LLM only; the ORIGINAL text is what persists |
43
+
44
+ ## Semantics worth knowing
45
+
46
+ - **The reply is never stalled and never broken.** Recall blocks at most
47
+ `recall_timeout` (default 1.2s); persistence is fire-and-forget off
48
+ the hot path. Outages and quota exhaustion degrade to "no memories
49
+ this turn".
50
+ - Turns store verbatim under the `ag2::<session>` transcript scope with
51
+ deterministic idempotency seeds — retries and multi-agent echoes
52
+ converge on one stored row.
53
+ - Tool/function messages are never persisted.
54
+ - `register_memory_tools(memory, caller=..., executor=...)` adds
55
+ `search_memory` + `save_memory` tools (the caller needs an
56
+ `llm_config`, as usual for AG2 tools).
57
+ - `memory.flush()` waits for in-flight writes (shutdown/tests);
58
+ `memory.close()` flushes and releases the HTTP client.
59
+
60
+ ## Configuration
61
+
62
+ | Parameter | Default | Meaning |
63
+ | --- | --- | --- |
64
+ | `user_id` | — (required) | End user the memories belong to |
65
+ | `session_id` | `"default"` | Transcript scope |
66
+ | `top_k` | `5` | Memories considered per turn |
67
+ | `recall_timeout` | `1.2` | Hard recall budget, seconds |
68
+ | `min_prompt_chars` | `8` | Skip recall for trivial messages |
69
+ | `context_template` | built-in | `{context}` placeholder, brace-safe `.replace` rendering |
70
+ | `capture` | `"both"` | `"received"` / `"sent"` to capture one side only |
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ pip install -e . "autogen[openai]" pytest
76
+ python -m pytest tests -q # 22 tests through REAL ConversableAgent chats
77
+ ```
78
+
79
+ The suite includes a reproduction of zep-ag2's documented multi-agent
80
+ double-store scenario (we store once) and a chat driven from inside
81
+ `asyncio.run()` (no deadlock).
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ag2-memorysync"
7
+ dynamic = ["version"]
8
+ description = "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."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "MemorySync", email = "support@memorysync.io" }]
13
+ keywords = ["ag2", "autogen", "conversableagent", "agents", "memory", "memorysync", "long-term-memory"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ ]
24
+ # Deliberately NO dependency on any AutoGen distribution: the ecosystem
25
+ # splits across `autogen` (ag2-classic), `pyautogen` (redirect), and
26
+ # `ag2` (the v1 rewrite). The capability duck-types against the agent
27
+ # instance it is attached to, so it works with whichever classic
28
+ # distribution the application already installed — and can never create
29
+ # a version conflict (Zep's zep-ag2 pins `ag2<1` and will break).
30
+ dependencies = [
31
+ "httpx>=0.25,<1",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://docs.memorysync.io/guides/ag2"
36
+ Documentation = "https://docs.memorysync.io/guides/ag2"
37
+ Repository = "https://github.com/Rafay121/memorysync-plugins"
38
+
39
+ [tool.hatch.version]
40
+ path = "src/ag2_memorysync/_version.py"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/ag2_memorysync"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -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
+ ]
@@ -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"