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.
@@ -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,,
@@ -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