agent-framework-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.
- agent_framework_memorysync/__init__.py +18 -0
- agent_framework_memorysync/_api.py +291 -0
- agent_framework_memorysync/_version.py +1 -0
- agent_framework_memorysync/provider.py +330 -0
- agent_framework_memorysync-1.0.0.dist-info/METADATA +104 -0
- agent_framework_memorysync-1.0.0.dist-info/RECORD +7 -0
- agent_framework_memorysync-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""MemorySync for the Microsoft Agent Framework."""
|
|
2
|
+
|
|
3
|
+
from ._api import MemorySyncAPIError, fnv1a64
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .provider import (
|
|
6
|
+
DEFAULT_CONTEXT_TEMPLATE,
|
|
7
|
+
RUN_OPTION_USER_ID,
|
|
8
|
+
MemorySyncContextProvider,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"MemorySyncContextProvider",
|
|
13
|
+
"MemorySyncAPIError",
|
|
14
|
+
"DEFAULT_CONTEXT_TEMPLATE",
|
|
15
|
+
"RUN_OPTION_USER_ID",
|
|
16
|
+
"fnv1a64",
|
|
17
|
+
"__version__",
|
|
18
|
+
]
|
|
@@ -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: the Agent Framework's ContextProvider
|
|
10
|
+
lifecycle is fully async, so the client rides the same event loop the
|
|
11
|
+
agent runs on.
|
|
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"agent-framework-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."""
|
|
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 = "agent-framework",
|
|
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 = "agent-framework",
|
|
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 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""MemorySync context provider for the Microsoft Agent Framework.
|
|
2
|
+
|
|
3
|
+
Design rules, in priority order:
|
|
4
|
+
|
|
5
|
+
1. **A run is never stalled and never broken.** ``before_run`` recalls
|
|
6
|
+
under a hard budget (default 1.2s) and injects nothing on a miss;
|
|
7
|
+
``after_run`` persistence is entirely fail-open. The first-party Mem0
|
|
8
|
+
provider does the opposite on both counts: no timeout anywhere, and
|
|
9
|
+
an ``after_run`` failure PROPAGATES and crashes the agent.
|
|
10
|
+
|
|
11
|
+
2. **Context belongs in the instructions layer.** Recalled memories are
|
|
12
|
+
injected via ``context.extend_instructions`` — the system layer —
|
|
13
|
+
never as a fabricated ``role="user"`` message (Mem0's approach, which
|
|
14
|
+
pollutes the conversation the model believes the user wrote).
|
|
15
|
+
|
|
16
|
+
3. **One identity drives everything.** The same resolved user id scopes
|
|
17
|
+
recall AND capture. Mem0 splits storage (``user_id``) from retrieval
|
|
18
|
+
(``search_user_id``); forget the second and your agent is silently
|
|
19
|
+
memoryless. Here that bug cannot exist.
|
|
20
|
+
|
|
21
|
+
4. **Per-run identity exists.** Both Mem0 and Zep bind identity at
|
|
22
|
+
construction and document it as a framework limitation. This provider
|
|
23
|
+
adds two escapes: pass ``memorysync_user_id=...`` straight through
|
|
24
|
+
``agent.run(...)`` (it arrives in ``context.options``), or supply a
|
|
25
|
+
``user_id_resolver`` callable. Construction-time ``user_id`` is the
|
|
26
|
+
fallback.
|
|
27
|
+
|
|
28
|
+
5. **Capture is duplicate-proof and once-per-turn.**
|
|
29
|
+
``after_run_once_per_turn = True`` so tool-loop agents persist a turn
|
|
30
|
+
exactly once, and every stored turn carries a deterministic
|
|
31
|
+
idempotency seed — retries converge on one row.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import logging
|
|
38
|
+
from typing import Any, Callable, List, Optional
|
|
39
|
+
|
|
40
|
+
import httpx
|
|
41
|
+
|
|
42
|
+
from agent_framework import AgentSession, ContextProvider
|
|
43
|
+
|
|
44
|
+
from ._api import (
|
|
45
|
+
MAX_TURN_CHARS,
|
|
46
|
+
AsyncV1Api,
|
|
47
|
+
MemorySyncAPIError,
|
|
48
|
+
fnv1a64,
|
|
49
|
+
resolve_api_key,
|
|
50
|
+
resolve_base_url,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("agent_framework_memorysync")
|
|
54
|
+
|
|
55
|
+
DEFAULT_CONTEXT_TEMPLATE = (
|
|
56
|
+
"The following long-term memories about the user were retrieved from "
|
|
57
|
+
"MemorySync. Use them to inform your response.\n\n"
|
|
58
|
+
"<MEMORYSYNC_CONTEXT>\n{context}\n</MEMORYSYNC_CONTEXT>"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
#: The agent.run() option key carrying a per-run user id.
|
|
62
|
+
RUN_OPTION_USER_ID = "memorysync_user_id"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _text_of_message(message: Any) -> str:
|
|
66
|
+
"""Best-effort plain text from an agent-framework Message."""
|
|
67
|
+
text = getattr(message, "text", None)
|
|
68
|
+
if isinstance(text, str) and text.strip():
|
|
69
|
+
return text.strip()
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _role_of_message(message: Any) -> str:
|
|
74
|
+
role = getattr(message, "role", "")
|
|
75
|
+
value = getattr(role, "value", role)
|
|
76
|
+
return str(value or "").lower()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MemorySyncContextProvider(ContextProvider):
|
|
80
|
+
"""Long-term memory for Agent Framework agents.
|
|
81
|
+
|
|
82
|
+
Wire it up::
|
|
83
|
+
|
|
84
|
+
from agent_framework import Agent
|
|
85
|
+
from agent_framework_memorysync import MemorySyncContextProvider
|
|
86
|
+
|
|
87
|
+
provider = MemorySyncContextProvider(
|
|
88
|
+
api_key="ms_...", # or MEMORYSYNC_API_KEY
|
|
89
|
+
user_id="customer-42",
|
|
90
|
+
session_id="support",
|
|
91
|
+
)
|
|
92
|
+
agent = Agent(client=..., context_providers=[provider])
|
|
93
|
+
|
|
94
|
+
Every run recalls relevant memories into the instructions layer, and
|
|
95
|
+
both sides of the exchange persist automatically after the run.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
# Tool-loop agents make several model round-trips per user turn; the
|
|
99
|
+
# capture must fire once per turn, not once per round-trip.
|
|
100
|
+
after_run_once_per_turn: bool = True
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
user_id: str,
|
|
106
|
+
session_id: str = "default",
|
|
107
|
+
api_key: Optional[str] = None,
|
|
108
|
+
base_url: Optional[str] = None,
|
|
109
|
+
project_id: Optional[str] = None,
|
|
110
|
+
source_id: str = "memorysync",
|
|
111
|
+
top_k: int = 5,
|
|
112
|
+
recall_timeout: float = 1.2,
|
|
113
|
+
min_prompt_chars: int = 8,
|
|
114
|
+
context_template: str = DEFAULT_CONTEXT_TEMPLATE,
|
|
115
|
+
capture: bool = True,
|
|
116
|
+
expose_search_tool: bool = False,
|
|
117
|
+
user_id_resolver: Optional[Callable[[AgentSession], Optional[str]]] = None,
|
|
118
|
+
source: str = "agent-framework",
|
|
119
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
super().__init__(source_id)
|
|
122
|
+
if not user_id or not str(user_id).strip():
|
|
123
|
+
raise ValueError("user_id is required and must be non-empty")
|
|
124
|
+
self.user_id = str(user_id).strip()
|
|
125
|
+
self.session_id = str(session_id or "default").strip() or "default"
|
|
126
|
+
self.top_k = top_k
|
|
127
|
+
self.recall_timeout = recall_timeout
|
|
128
|
+
self.min_prompt_chars = min_prompt_chars
|
|
129
|
+
self.context_template = context_template
|
|
130
|
+
self.capture = capture
|
|
131
|
+
self.expose_search_tool = expose_search_tool
|
|
132
|
+
self.user_id_resolver = user_id_resolver
|
|
133
|
+
self.source = source
|
|
134
|
+
self._api = AsyncV1Api(
|
|
135
|
+
api_key=resolve_api_key(api_key),
|
|
136
|
+
base_url=resolve_base_url(base_url),
|
|
137
|
+
project_id=project_id,
|
|
138
|
+
timeout=max(recall_timeout * 4, 8.0),
|
|
139
|
+
transport=transport,
|
|
140
|
+
)
|
|
141
|
+
self._warmed = False
|
|
142
|
+
# Warm the connection off the hot path when a loop is already
|
|
143
|
+
# running: resolve the tenant and open the TLS/HTTP session now, so
|
|
144
|
+
# the FIRST run's bounded recall spends its budget on the recall
|
|
145
|
+
# itself — not connection setup.
|
|
146
|
+
try:
|
|
147
|
+
asyncio.get_running_loop().create_task(self._api.resolve_tenant_id())
|
|
148
|
+
except Exception: # no running loop yet — the first-call grace covers it
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
# ── identity ─────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def _resolve_user_id(
|
|
154
|
+
self, session: AgentSession, context: Any, state: dict
|
|
155
|
+
) -> str:
|
|
156
|
+
"""Per-run identity ladder: run options → resolver → constructor.
|
|
157
|
+
|
|
158
|
+
The resolved id is pinned in the provider's ``state`` slice so
|
|
159
|
+
``after_run`` persists under exactly the identity ``before_run``
|
|
160
|
+
recalled for — the two can never diverge within a run.
|
|
161
|
+
"""
|
|
162
|
+
candidate: Optional[str] = None
|
|
163
|
+
options = getattr(context, "options", None)
|
|
164
|
+
if isinstance(options, dict):
|
|
165
|
+
raw = options.get(RUN_OPTION_USER_ID)
|
|
166
|
+
if isinstance(raw, str) and raw.strip():
|
|
167
|
+
candidate = raw.strip()
|
|
168
|
+
if candidate is None and self.user_id_resolver is not None:
|
|
169
|
+
try:
|
|
170
|
+
raw = self.user_id_resolver(session)
|
|
171
|
+
if isinstance(raw, str) and raw.strip():
|
|
172
|
+
candidate = raw.strip()
|
|
173
|
+
except Exception as exc: # resolver bugs must not kill the run
|
|
174
|
+
logger.warning("MemorySync user_id_resolver failed (%s)", exc)
|
|
175
|
+
resolved = candidate or self.user_id
|
|
176
|
+
state["run_user_id"] = resolved
|
|
177
|
+
return resolved
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def _session_scope(self) -> str:
|
|
181
|
+
return f"agent-framework::{self.session_id}"
|
|
182
|
+
|
|
183
|
+
# ── before_run: budgeted recall into the instructions layer ──────
|
|
184
|
+
|
|
185
|
+
async def before_run(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
agent: Any,
|
|
189
|
+
session: AgentSession,
|
|
190
|
+
context: Any,
|
|
191
|
+
state: dict,
|
|
192
|
+
) -> None:
|
|
193
|
+
user_id = self._resolve_user_id(session, context, state)
|
|
194
|
+
|
|
195
|
+
if self.expose_search_tool:
|
|
196
|
+
try:
|
|
197
|
+
context.extend_tools(self.source_id, self._build_tools(user_id))
|
|
198
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
199
|
+
logger.warning("MemorySync tool registration failed (%s)", exc)
|
|
200
|
+
|
|
201
|
+
query = ""
|
|
202
|
+
for message in reversed(list(getattr(context, "input_messages", []) or [])):
|
|
203
|
+
if _role_of_message(message) == "user":
|
|
204
|
+
query = _text_of_message(message)
|
|
205
|
+
if query:
|
|
206
|
+
break
|
|
207
|
+
if len(query.strip()) < self.min_prompt_chars:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
# One-time cold-start grace: the first recall of a fresh client
|
|
212
|
+
# may pay TLS + tenant resolution on top of the query itself.
|
|
213
|
+
# Give it room once; every later run keeps the strict budget.
|
|
214
|
+
budget = self.recall_timeout if self._warmed else max(self.recall_timeout, 3.0)
|
|
215
|
+
self._warmed = True
|
|
216
|
+
tenant_id = await asyncio.wait_for(
|
|
217
|
+
self._api.resolve_tenant_id(), timeout=budget
|
|
218
|
+
)
|
|
219
|
+
response = await asyncio.wait_for(
|
|
220
|
+
self._api.recall(
|
|
221
|
+
tenant_id=tenant_id,
|
|
222
|
+
user_id=user_id,
|
|
223
|
+
prompt=query,
|
|
224
|
+
k=self.top_k,
|
|
225
|
+
),
|
|
226
|
+
timeout=budget,
|
|
227
|
+
)
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
logger.warning(
|
|
230
|
+
"MemorySync recall unavailable this run (%s) — continuing without memories.",
|
|
231
|
+
exc,
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
block = response.get("context") if isinstance(response, dict) else ""
|
|
236
|
+
if not isinstance(block, str) or not block.strip():
|
|
237
|
+
return
|
|
238
|
+
# .replace, never .format: memory text may contain { } % legally.
|
|
239
|
+
rendered = self.context_template.replace("{context}", block)
|
|
240
|
+
context.extend_instructions(self.source_id, rendered)
|
|
241
|
+
|
|
242
|
+
# ── after_run: fail-open, duplicate-proof capture ────────────────
|
|
243
|
+
|
|
244
|
+
async def after_run(
|
|
245
|
+
self,
|
|
246
|
+
*,
|
|
247
|
+
agent: Any,
|
|
248
|
+
session: AgentSession,
|
|
249
|
+
context: Any,
|
|
250
|
+
state: dict,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Persist both sides of the exchange. NEVER raises — a memory
|
|
253
|
+
outage after a successful run must not turn it into a failure
|
|
254
|
+
(the first-party Mem0 provider crashes the agent here)."""
|
|
255
|
+
if not self.capture:
|
|
256
|
+
return
|
|
257
|
+
try:
|
|
258
|
+
user_id = state.get("run_user_id") or self.user_id
|
|
259
|
+
turns: List[tuple] = []
|
|
260
|
+
for message in list(getattr(context, "input_messages", []) or []):
|
|
261
|
+
if _role_of_message(message) == "user":
|
|
262
|
+
text = _text_of_message(message)
|
|
263
|
+
if text:
|
|
264
|
+
turns.append(("human", text))
|
|
265
|
+
response = getattr(context, "response", None)
|
|
266
|
+
for message in list(getattr(response, "messages", []) or []):
|
|
267
|
+
if _role_of_message(message) == "assistant":
|
|
268
|
+
text = _text_of_message(message)
|
|
269
|
+
if text:
|
|
270
|
+
turns.append(("ai", text))
|
|
271
|
+
|
|
272
|
+
if not turns:
|
|
273
|
+
return
|
|
274
|
+
tenant_id = await self._api.resolve_tenant_id()
|
|
275
|
+
for role, text in turns:
|
|
276
|
+
if len(text) > MAX_TURN_CHARS:
|
|
277
|
+
text = text[:MAX_TURN_CHARS]
|
|
278
|
+
seed = f"{role}@{self._session_scope}#h{fnv1a64(f'{role}:{text}')}"
|
|
279
|
+
await self._api.add_turn(
|
|
280
|
+
tenant_id=tenant_id,
|
|
281
|
+
user_id=user_id,
|
|
282
|
+
text=f"{role}: {text}",
|
|
283
|
+
speaker=seed,
|
|
284
|
+
metadata={"session_id": self._session_scope},
|
|
285
|
+
source=self.source,
|
|
286
|
+
)
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
logger.warning(
|
|
289
|
+
"MemorySync capture skipped for this run (%s) — the agent run itself succeeded.",
|
|
290
|
+
exc,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# ── tools ────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
def _build_tools(self, user_id: str) -> List[Any]:
|
|
296
|
+
api = self._api
|
|
297
|
+
source = self.source
|
|
298
|
+
top_k = self.top_k
|
|
299
|
+
|
|
300
|
+
async def search_memory(query: str, limit: int = top_k) -> str:
|
|
301
|
+
"""Search the user's long-term memory for relevant context and preferences."""
|
|
302
|
+
tenant_id = await api.resolve_tenant_id()
|
|
303
|
+
response = await api.query(
|
|
304
|
+
tenant_id=tenant_id, user_id=user_id, prompt=query, k=limit
|
|
305
|
+
)
|
|
306
|
+
memories = response.get("memories") if isinstance(response, dict) else []
|
|
307
|
+
if not memories:
|
|
308
|
+
return "No relevant memories found."
|
|
309
|
+
lines = []
|
|
310
|
+
for i, entry in enumerate(memories, 1):
|
|
311
|
+
text = entry.get("value") or entry.get("text") or entry.get("raw_text") or ""
|
|
312
|
+
lines.append(f"{i}. {text}")
|
|
313
|
+
return "\n".join(lines)
|
|
314
|
+
|
|
315
|
+
async def save_memory(text: str) -> str:
|
|
316
|
+
"""Save an important, durable fact about the user to long-term memory."""
|
|
317
|
+
response = await api.add_memory(user_id=user_id, text=text, source=source)
|
|
318
|
+
if isinstance(response, dict) and response.get("id") is not None:
|
|
319
|
+
return f"Saved (id m_{response['id']})."
|
|
320
|
+
if isinstance(response, dict) and response.get("status") == "skipped":
|
|
321
|
+
return "Not saved: no durable content."
|
|
322
|
+
return "Accepted."
|
|
323
|
+
|
|
324
|
+
return [search_memory, save_memory]
|
|
325
|
+
|
|
326
|
+
# ── lifecycle ────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
async def close(self) -> None:
|
|
329
|
+
"""Release the HTTP client."""
|
|
330
|
+
await self._api.aclose()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agent-framework-memorysync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MemorySync for Microsoft Agent Framework: a ContextProvider with budgeted instruction-layer recall, per-run identity, once-per-turn duplicate-proof capture, and fail-open everything.
|
|
5
|
+
Project-URL: Homepage, https://docs.memorysync.io/guides/agent-framework
|
|
6
|
+
Project-URL: Documentation, https://docs.memorysync.io/guides/agent-framework
|
|
7
|
+
Project-URL: Repository, https://github.com/memorysyncio/memorysync-plugins
|
|
8
|
+
Author-email: MemorySync <support@memorysync.io>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agent-framework,agents,context-provider,long-term-memory,memory,memorysync,microsoft
|
|
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: agent-framework-core<2,>=1.8
|
|
21
|
+
Requires-Dist: httpx<1,>=0.25
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# agent-framework-memorysync
|
|
25
|
+
|
|
26
|
+
[MemorySync](https://memorysync.io) for the
|
|
27
|
+
[Microsoft Agent Framework](https://github.com/microsoft/agent-framework):
|
|
28
|
+
agents that remember users across sessions — without ever stalling a run.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install agent-framework-memorysync
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from agent_framework import Agent
|
|
38
|
+
from agent_framework_memorysync import MemorySyncContextProvider
|
|
39
|
+
|
|
40
|
+
provider = MemorySyncContextProvider(
|
|
41
|
+
api_key="ms_...", # or MEMORYSYNC_API_KEY
|
|
42
|
+
user_id="customer-42", # required — who these memories belong to
|
|
43
|
+
session_id="support", # scopes the transcript
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
agent = Agent(client=..., context_providers=[provider])
|
|
47
|
+
result = await agent.run("which seat should I book?")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Every run recalls relevant memories into the **instructions layer** under
|
|
51
|
+
a hard budget, and both sides of the exchange persist automatically after
|
|
52
|
+
the run — no extra code per turn.
|
|
53
|
+
|
|
54
|
+
## Why this one
|
|
55
|
+
|
|
56
|
+
| | Mem0 (`agent-framework-mem0`, in-repo) | Zep (`zep-ms-agent-framework`) | **MemorySync** |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| Injection layer | ✗ fabricates a `role="user"` message | ✓ instructions | ✓ instructions |
|
|
59
|
+
| Recall latency budget | ✗ none | ✗ none | ✓ hard 1.2s default — a slow backend means an unenriched run, never a late one |
|
|
60
|
+
| Capture failure behaviour | ✗ **`after_run` errors crash the agent** | swallowed | ✓ entirely fail-open, logged |
|
|
61
|
+
| Scope model | ✗ storage ≠ retrieval scopes — forget `search_user_id` and the agent is silently memoryless | single | ✓ one `user_id` drives both — the bug cannot exist |
|
|
62
|
+
| Per-run identity | ✗ construction-only | ✗ construction-only (documented) | ✓ `agent.run(..., options={"memorysync_user_id": ...})` or a `user_id_resolver` |
|
|
63
|
+
| Write dedup | ✗ re-adds every turn | ✗ | ✓ deterministic idempotency seeds |
|
|
64
|
+
| Tool-loop double-capture | ✗ | — | ✓ `after_run_once_per_turn = True` |
|
|
65
|
+
| Release status | beta (`1.0.0b…`) | 0.2.1 | ✓ stable 1.0.0 |
|
|
66
|
+
| Python | ≥3.10 | ✗ ≥3.11 only | ✓ ≥3.10 (matches the framework) |
|
|
67
|
+
|
|
68
|
+
## Semantics worth knowing
|
|
69
|
+
|
|
70
|
+
- Turns store verbatim under the `agent-framework::<session>` transcript
|
|
71
|
+
scope — separate history, same shared user memories as every other
|
|
72
|
+
MemorySync surface.
|
|
73
|
+
- The resolved run identity is pinned in the provider's session-state
|
|
74
|
+
slice, so recall and capture can never diverge within a run — and the
|
|
75
|
+
slice stays JSON-native, so `AgentSession` serialization keeps working.
|
|
76
|
+
- `expose_search_tool=True` adds `search_memory` + `save_memory` tools
|
|
77
|
+
via `context.extend_tools`.
|
|
78
|
+
- Free-tier quota exhaustion is silent by design (adds accepted-without-
|
|
79
|
+
storing, reads empty); evaluation keys surface strict `429`s.
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
| Parameter | Default | Meaning |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `user_id` | — (required) | End user the memories belong to |
|
|
86
|
+
| `session_id` | `"default"` | Transcript scope |
|
|
87
|
+
| `top_k` | `5` | Memories considered per run |
|
|
88
|
+
| `recall_timeout` | `1.2` | Hard recall budget, seconds |
|
|
89
|
+
| `min_prompt_chars` | `8` | Skip recall for trivial prompts |
|
|
90
|
+
| `context_template` | built-in | `{context}` placeholder, brace-safe `.replace` rendering |
|
|
91
|
+
| `capture` | `True` | Persist the exchange after each run |
|
|
92
|
+
| `expose_search_tool` | `False` | Register memory tools on every run |
|
|
93
|
+
| `user_id_resolver` | `None` | `Callable[[AgentSession], str]` for dynamic identity |
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install -e . agent-framework-core pytest pytest-asyncio
|
|
99
|
+
python -m pytest tests -q # 21 tests incl. a REAL Agent run (stub chat client)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
agent_framework_memorysync/__init__.py,sha256=V0AmdssJbpMSqldiXn7Ubu4iS2yJAKQm3QOBaarxDe8,428
|
|
2
|
+
agent_framework_memorysync/_api.py,sha256=yyvQa4F2Bl8ys1iwoeVfcNl9lc2O0inDcM3PeIbkOU8,10317
|
|
3
|
+
agent_framework_memorysync/_version.py,sha256=ZhzQKWZ8RFrTIkj7z87B144DI95e8LMfA5w8NDWQDtg,23
|
|
4
|
+
agent_framework_memorysync/provider.py,sha256=mj6BByKg-4Bug9f8NiQkhqRF6jzWeldx_M1Lu7mAvrk,13700
|
|
5
|
+
agent_framework_memorysync-1.0.0.dist-info/METADATA,sha256=nc0mWkt0Im9HB_CT2gMxFuHLq_uR1W0F9aiWHBuaCWA,4808
|
|
6
|
+
agent_framework_memorysync-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
agent_framework_memorysync-1.0.0.dist-info/RECORD,,
|