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.
- ag2_memorysync-1.0.0/.gitignore +5 -0
- ag2_memorysync-1.0.0/PKG-INFO +107 -0
- ag2_memorysync-1.0.0/README.md +85 -0
- ag2_memorysync-1.0.0/pyproject.toml +46 -0
- ag2_memorysync-1.0.0/src/ag2_memorysync/__init__.py +15 -0
- ag2_memorysync-1.0.0/src/ag2_memorysync/_api.py +291 -0
- ag2_memorysync-1.0.0/src/ag2_memorysync/_bridge.py +96 -0
- ag2_memorysync-1.0.0/src/ag2_memorysync/_version.py +1 -0
- ag2_memorysync-1.0.0/src/ag2_memorysync/capability.py +353 -0
- ag2_memorysync-1.0.0/src/ag2_memorysync/tools.py +67 -0
- ag2_memorysync-1.0.0/tests/conftest.py +204 -0
- ag2_memorysync-1.0.0/tests/test_capability.py +365 -0
|
@@ -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"
|