supermemory-agent-framework 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,421 @@
1
+ """Supermemory middleware for Microsoft Agent Framework.
2
+
3
+ Provides ChatMiddleware that automatically injects relevant memories into
4
+ the system prompt before LLM calls, and optionally saves conversations.
5
+ """
6
+
7
+ import asyncio
8
+ from dataclasses import dataclass
9
+ from typing import Any, Awaitable, Callable, Literal, Optional
10
+
11
+ import supermemory
12
+ from agent_framework import ChatMiddleware, Message
13
+
14
+ from .connection import AgentSupermemory
15
+ from .exceptions import (
16
+ SupermemoryMemoryOperationError,
17
+ SupermemoryNetworkError,
18
+ )
19
+ from .utils import (
20
+ Logger,
21
+ convert_profile_to_markdown,
22
+ create_logger,
23
+ deduplicate_memories,
24
+ wrap_memory_injection,
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class SupermemoryMiddlewareOptions:
30
+ """Configuration options for Supermemory middleware."""
31
+
32
+ verbose: bool = False
33
+ mode: Literal["profile", "query", "full"] = "profile"
34
+ add_memory: Literal["always", "never"] = "never"
35
+
36
+
37
+ def _get_last_user_message(messages: Any) -> str:
38
+ """Extract the last user message from the messages sequence."""
39
+ if not messages:
40
+ return ""
41
+
42
+ for msg in reversed(list(messages)):
43
+ role = None
44
+ content = None
45
+
46
+ if hasattr(msg, "role"):
47
+ role = msg.role
48
+ elif isinstance(msg, dict):
49
+ role = msg.get("role")
50
+
51
+ if role == "user":
52
+ if hasattr(msg, "text"):
53
+ content = msg.text
54
+ elif hasattr(msg, "content"):
55
+ content = msg.content
56
+ elif isinstance(msg, dict):
57
+ content = msg.get("content", "") or msg.get("text", "")
58
+
59
+ if isinstance(content, str):
60
+ return content
61
+ if isinstance(content, list):
62
+ text_parts = []
63
+ for part in content:
64
+ if isinstance(part, dict) and part.get("type") == "text":
65
+ text_parts.append(part.get("text", ""))
66
+ elif isinstance(part, str):
67
+ text_parts.append(part)
68
+ return " ".join(text_parts)
69
+ return ""
70
+
71
+
72
+ def _get_conversation_content(messages: Any) -> str:
73
+ """Convert messages into a formatted conversation string."""
74
+ conversation_parts = []
75
+
76
+ for msg in messages:
77
+ role = None
78
+ content = None
79
+
80
+ if hasattr(msg, "role"):
81
+ role = msg.role
82
+ elif isinstance(msg, dict):
83
+ role = msg.get("role")
84
+
85
+ if hasattr(msg, "text"):
86
+ content = msg.text
87
+ elif hasattr(msg, "content"):
88
+ content = msg.content
89
+ elif isinstance(msg, dict):
90
+ content = msg.get("content", "") or msg.get("text", "")
91
+
92
+ if role and content:
93
+ role_display = {
94
+ "user": "User",
95
+ "assistant": "Assistant",
96
+ "system": "System",
97
+ }.get(role, role.capitalize() if isinstance(role, str) else str(role))
98
+
99
+ if isinstance(content, str):
100
+ content_text = content
101
+ elif isinstance(content, list):
102
+ text_parts = []
103
+ for part in content:
104
+ if isinstance(part, dict) and part.get("type") == "text":
105
+ text_parts.append(part.get("text", ""))
106
+ elif isinstance(part, str):
107
+ text_parts.append(part)
108
+ content_text = " ".join(text_parts)
109
+ else:
110
+ content_text = str(content)
111
+
112
+ if content_text:
113
+ conversation_parts.append(f"{role_display}: {content_text}")
114
+
115
+ return "\n\n".join(conversation_parts)
116
+
117
+
118
+ async def _build_memories_text(
119
+ container_tag: str,
120
+ logger: Logger,
121
+ mode: Literal["profile", "query", "full"],
122
+ client: supermemory.AsyncSupermemory,
123
+ query_text: str = "",
124
+ ) -> str:
125
+ """Build formatted memories text from Supermemory API."""
126
+ kwargs: dict[str, Any] = {"container_tag": container_tag}
127
+ if query_text:
128
+ kwargs["q"] = query_text
129
+
130
+ memories_response = await client.profile(**kwargs)
131
+
132
+ profile = memories_response.profile if memories_response.profile else None
133
+ static = list(profile.static) if profile and profile.static else []
134
+ dynamic = list(profile.dynamic) if profile and profile.dynamic else []
135
+ search_results_raw = (
136
+ list(memories_response.search_results.results)
137
+ if memories_response.search_results and memories_response.search_results.results
138
+ else []
139
+ )
140
+
141
+ logger.info(
142
+ "Memory search completed",
143
+ {
144
+ "container_tag": container_tag,
145
+ "memory_count_static": len(static),
146
+ "memory_count_dynamic": len(dynamic),
147
+ "query_text": (
148
+ query_text[:100] + ("..." if len(query_text) > 100 else "")
149
+ ),
150
+ "mode": mode,
151
+ },
152
+ )
153
+
154
+ deduplicated = deduplicate_memories(
155
+ static=static,
156
+ dynamic=dynamic,
157
+ search_results=search_results_raw,
158
+ )
159
+
160
+ profile_data = ""
161
+ if mode != "query":
162
+ profile_data = convert_profile_to_markdown(
163
+ {
164
+ "profile": {
165
+ "static": deduplicated.static,
166
+ "dynamic": deduplicated.dynamic,
167
+ },
168
+ "searchResults": {"results": []},
169
+ }
170
+ )
171
+
172
+ search_results_memories = ""
173
+ if mode != "profile" and deduplicated.search_results:
174
+ search_results_memories = (
175
+ "Search results for user's recent message: \n"
176
+ + "\n".join(f"- {memory}" for memory in deduplicated.search_results)
177
+ )
178
+
179
+ return f"{profile_data}\n{search_results_memories}".strip()
180
+
181
+
182
+ async def _save_memory(
183
+ client: supermemory.AsyncSupermemory,
184
+ container_tag: str,
185
+ content: str,
186
+ custom_id: str,
187
+ logger: Logger,
188
+ ) -> None:
189
+ """Save a memory to Supermemory."""
190
+ try:
191
+ add_params: dict[str, Any] = {
192
+ "content": content,
193
+ "container_tag": container_tag,
194
+ "custom_id": custom_id,
195
+ }
196
+
197
+ response = await client.add(**add_params)
198
+
199
+ logger.info(
200
+ "Memory saved successfully",
201
+ {
202
+ "container_tag": container_tag,
203
+ "custom_id": custom_id,
204
+ "content_length": len(content),
205
+ "memory_id": getattr(response, "id", None),
206
+ },
207
+ )
208
+ except (OSError, ConnectionError) as network_error:
209
+ logger.error(
210
+ "Network error while saving memory", {"error": str(network_error)}
211
+ )
212
+ raise SupermemoryNetworkError(
213
+ "Failed to save memory due to network error", network_error
214
+ )
215
+ except Exception as error:
216
+ logger.error("Error saving memory", {"error": str(error)})
217
+ raise SupermemoryMemoryOperationError("Failed to save memory", error)
218
+
219
+
220
+ class SupermemoryChatMiddleware(ChatMiddleware):
221
+ """Chat middleware that injects Supermemory memories into the system prompt.
222
+
223
+ This middleware intercepts chat requests before they reach the LLM,
224
+ fetches relevant memories from Supermemory, and injects them into
225
+ the system prompt. It can also save conversations as memories.
226
+
227
+ Example:
228
+ ```python
229
+ from agent_framework.openai import OpenAIResponsesClient
230
+ from supermemory_agent_framework import (
231
+ AgentSupermemory,
232
+ SupermemoryChatMiddleware,
233
+ SupermemoryMiddlewareOptions,
234
+ )
235
+
236
+ conn = AgentSupermemory(api_key="your-key", container_tag="user-123")
237
+
238
+ middleware = SupermemoryChatMiddleware(
239
+ conn,
240
+ options=SupermemoryMiddlewareOptions(
241
+ mode="full",
242
+ verbose=True,
243
+ add_memory="always",
244
+ ),
245
+ )
246
+
247
+ agent = OpenAIResponsesClient().as_agent(
248
+ name="MemoryAgent",
249
+ instructions="You are a helpful assistant with memory.",
250
+ middleware=[middleware],
251
+ )
252
+
253
+ response = await agent.run("What's my favorite language?")
254
+ ```
255
+ """
256
+
257
+ def __init__(
258
+ self,
259
+ connection: AgentSupermemory,
260
+ options: Optional[SupermemoryMiddlewareOptions] = None,
261
+ ) -> None:
262
+ self._connection = connection
263
+ self._container_tag = connection.container_tag
264
+ self._options = options or SupermemoryMiddlewareOptions()
265
+ self._logger = create_logger(self._options.verbose)
266
+ self._supermemory_client = connection.client
267
+ self._background_tasks: set[asyncio.Task[None]] = set()
268
+
269
+ async def process(
270
+ self,
271
+ context: Any,
272
+ call_next: Callable[[], Awaitable[None]],
273
+ ) -> None:
274
+ """Process the chat request by injecting memories and optionally saving conversations."""
275
+ messages = context.messages
276
+
277
+ # Save conversation memory in background if configured
278
+ if self._options.add_memory == "always":
279
+ user_message = _get_last_user_message(messages)
280
+ if user_message and user_message.strip():
281
+ content = _get_conversation_content(messages)
282
+
283
+ task = asyncio.create_task(
284
+ _save_memory(
285
+ self._supermemory_client,
286
+ self._container_tag,
287
+ content,
288
+ self._connection.custom_id,
289
+ self._logger,
290
+ )
291
+ )
292
+ self._background_tasks.add(task)
293
+ task.add_done_callback(self._background_tasks.discard)
294
+
295
+ def _handle_task_exception(task_obj: asyncio.Task[None]) -> None:
296
+ try:
297
+ exc = task_obj.exception()
298
+ if exc is not None:
299
+ self._logger.warn(
300
+ "Background memory storage failed",
301
+ {"error": str(exc), "type": type(exc).__name__},
302
+ )
303
+ except asyncio.CancelledError:
304
+ self._logger.debug("Memory storage task was cancelled")
305
+
306
+ task.add_done_callback(_handle_task_exception)
307
+
308
+ # Determine query text based on mode
309
+ query_text = ""
310
+ if self._options.mode != "profile":
311
+ user_message = _get_last_user_message(messages)
312
+ if not user_message:
313
+ self._logger.debug("No user message found, skipping memory search")
314
+ await call_next()
315
+ return
316
+ query_text = user_message
317
+
318
+ self._logger.info(
319
+ "Starting memory search",
320
+ {
321
+ "container_tag": self._container_tag,
322
+ "conversation_id": self._connection.conversation_id,
323
+ "mode": self._options.mode,
324
+ },
325
+ )
326
+
327
+ # Fetch and build memories text
328
+ try:
329
+ memories = await _build_memories_text(
330
+ self._container_tag,
331
+ self._logger,
332
+ self._options.mode,
333
+ self._supermemory_client,
334
+ query_text,
335
+ )
336
+ except Exception as e:
337
+ self._logger.error(
338
+ "Failed to fetch memories, proceeding without",
339
+ {"error": str(e)},
340
+ )
341
+ await call_next()
342
+ return
343
+
344
+ if memories:
345
+ # Prepend entity context if available
346
+ if self._connection.entity_context:
347
+ memories = f"{self._connection.entity_context}\n\n{memories}"
348
+
349
+ self._logger.debug(
350
+ "Memory content preview",
351
+ {"content": memories[:200], "full_length": len(memories)},
352
+ )
353
+
354
+ # Inject memories into messages
355
+ _inject_memories(context, memories)
356
+
357
+ await call_next()
358
+
359
+ async def wait_for_background_tasks(
360
+ self, timeout: Optional[float] = 10.0
361
+ ) -> None:
362
+ """Wait for all background memory storage tasks to complete."""
363
+ if not self._background_tasks:
364
+ return
365
+
366
+ self._logger.debug(
367
+ f"Waiting for {len(self._background_tasks)} background tasks"
368
+ )
369
+
370
+ try:
371
+ if timeout is not None:
372
+ await asyncio.wait_for(
373
+ asyncio.gather(*self._background_tasks, return_exceptions=True),
374
+ timeout=timeout,
375
+ )
376
+ else:
377
+ await asyncio.gather(*self._background_tasks, return_exceptions=True)
378
+ self._logger.debug("All background tasks completed")
379
+ except asyncio.TimeoutError:
380
+ self._logger.warn(
381
+ f"Background tasks did not complete within {timeout}s timeout"
382
+ )
383
+ for task in self._background_tasks:
384
+ if not task.done():
385
+ task.cancel()
386
+ raise
387
+
388
+
389
+ def _inject_memories(context: Any, memories: str) -> None:
390
+ """Inject memories into the chat context messages.
391
+
392
+ Handles both object-based and dict-based message formats used by
393
+ different Agent Framework providers.
394
+ """
395
+ messages = context.messages
396
+ memory_text = f"\n\n{wrap_memory_injection(memories)}"
397
+
398
+ # Try to find and augment existing system message
399
+ for i, msg in enumerate(messages):
400
+ role = None
401
+ if hasattr(msg, "role"):
402
+ role = msg.role
403
+ elif isinstance(msg, dict):
404
+ role = msg.get("role")
405
+
406
+ if role == "system":
407
+ if hasattr(msg, "text"):
408
+ msg.text = (msg.text or "") + memory_text
409
+ elif hasattr(msg, "content"):
410
+ msg.content = (msg.content or "") + memory_text
411
+ elif isinstance(msg, dict):
412
+ msg["content"] = (msg.get("content", "") or "") + memory_text
413
+ return
414
+
415
+ # No system message found - prepend one
416
+ try:
417
+ if isinstance(messages, list):
418
+ messages.insert(0, Message("system", [memories]))
419
+ except Exception:
420
+ # If messages is immutable, log a warning
421
+ pass
@@ -0,0 +1,176 @@
1
+ """Supermemory tools for Microsoft Agent Framework.
2
+
3
+ Provides FunctionTool-compatible tools that can be passed to Agent.run(tools=[...]).
4
+ """
5
+
6
+ import json
7
+ from typing import Annotated, Any, TypedDict
8
+
9
+ from agent_framework import FunctionTool, tool
10
+
11
+ from .connection import AgentSupermemory
12
+
13
+
14
+ class MemorySearchResult(TypedDict, total=False):
15
+ """Result type for memory search operations."""
16
+
17
+ success: bool
18
+ results: list[Any] | None
19
+ count: int | None
20
+ error: str | None
21
+
22
+
23
+ class MemoryAddResult(TypedDict, total=False):
24
+ """Result type for memory add operations."""
25
+
26
+ success: bool
27
+ memory: Any | None
28
+ error: str | None
29
+
30
+
31
+ class ProfileResult(TypedDict, total=False):
32
+ """Result type for profile operations."""
33
+
34
+ success: bool
35
+ profile: dict[str, Any] | None
36
+ search_results: dict[str, Any] | None
37
+ error: str | None
38
+
39
+
40
+ class SupermemoryTools:
41
+ """Memory tools for Microsoft Agent Framework.
42
+
43
+ Creates FunctionTool instances that can be passed to Agent.run(tools=[...]).
44
+
45
+ Example:
46
+ ```python
47
+ from supermemory_agent_framework import AgentSupermemory, SupermemoryTools
48
+
49
+ conn = AgentSupermemory(api_key="your-key", container_tag="user-123")
50
+ tools = SupermemoryTools(conn)
51
+ agent_tools = tools.get_tools()
52
+
53
+ response = await agent.run(
54
+ "What do you remember about me?",
55
+ tools=agent_tools,
56
+ )
57
+ ```
58
+ """
59
+
60
+ def __init__(self, connection: AgentSupermemory) -> None:
61
+ self._connection = connection
62
+ self._client = connection.client
63
+
64
+ async def search_memories(
65
+ self,
66
+ information_to_get: Annotated[
67
+ str, "Terms to search for in the user's memories"
68
+ ],
69
+ include_full_docs: Annotated[
70
+ bool,
71
+ "Whether to include full document content. Defaults to true for better AI context.",
72
+ ] = True,
73
+ limit: Annotated[int, "Maximum number of results to return"] = 10,
74
+ ) -> str:
75
+ """Search (recall) memories/details/information about the user or other facts or entities. Run when explicitly asked or when context about user's past choices would be helpful."""
76
+ try:
77
+ response = await self._client.search.execute(
78
+ q=information_to_get,
79
+ container_tags=[self._connection.container_tag],
80
+ limit=limit,
81
+ chunk_threshold=0.6,
82
+ include_full_docs=include_full_docs,
83
+ )
84
+ result: MemorySearchResult = {
85
+ "success": True,
86
+ "results": response.results,
87
+ "count": len(response.results) if response.results else 0,
88
+ }
89
+ return json.dumps(result, default=str)
90
+ except Exception as error:
91
+ result = {"success": False, "error": str(error)}
92
+ return json.dumps(result)
93
+
94
+ async def add_memory(
95
+ self,
96
+ memory: Annotated[
97
+ str,
98
+ "The text content of the memory to add. Should be a single sentence or short paragraph.",
99
+ ],
100
+ ) -> str:
101
+ """Add (remember) memories/details/information about the user or other facts or entities. Run when explicitly asked or when the user mentions any information generalizable beyond the context of the current conversation."""
102
+ try:
103
+ response = await self._client.add(
104
+ content=memory,
105
+ container_tag=self._connection.container_tag,
106
+ custom_id=self._connection.custom_id,
107
+ )
108
+ result: MemoryAddResult = {
109
+ "success": True,
110
+ "memory": response,
111
+ }
112
+ return json.dumps(result, default=str)
113
+ except Exception as error:
114
+ result = {"success": False, "error": str(error)}
115
+ return json.dumps(result)
116
+
117
+ async def get_profile(
118
+ self,
119
+ query: Annotated[
120
+ str,
121
+ "Optional search query to include relevant search results.",
122
+ ] = "",
123
+ ) -> str:
124
+ """Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query."""
125
+ try:
126
+ kwargs: dict[str, Any] = {"container_tag": self._connection.container_tag}
127
+ if query:
128
+ kwargs["q"] = query
129
+
130
+ response = await self._client.profile(**kwargs)
131
+ result: dict[str, Any] = {
132
+ "success": True,
133
+ "profile": response.profile if hasattr(response, "profile") else None,
134
+ "search_results": (
135
+ response.search_results
136
+ if hasattr(response, "search_results")
137
+ else None
138
+ ),
139
+ }
140
+ return json.dumps(result, default=str)
141
+ except Exception as error:
142
+ result = {"success": False, "error": str(error)}
143
+ return json.dumps(result)
144
+
145
+ def get_tools(self) -> list[FunctionTool]:
146
+ """Get all Supermemory tools as FunctionTool instances.
147
+
148
+ Returns:
149
+ List of FunctionTool instances ready to pass to Agent.run(tools=...)
150
+ """
151
+ return [
152
+ tool(
153
+ name="search_memories",
154
+ description=(
155
+ "Search (recall) memories/details/information about the user or other "
156
+ "facts or entities. Run when explicitly asked or when context about "
157
+ "user's past choices would be helpful."
158
+ ),
159
+ )(self.search_memories),
160
+ tool(
161
+ name="add_memory",
162
+ description=(
163
+ "Add (remember) memories/details/information about the user or other "
164
+ "facts or entities. Run when explicitly asked or when the user mentions "
165
+ "any information generalizable beyond the context of the current conversation."
166
+ ),
167
+ )(self.add_memory),
168
+ tool(
169
+ name="get_profile",
170
+ description=(
171
+ "Get user profile containing static memories (permanent facts) and "
172
+ "dynamic memories (recent context). Optionally include search results "
173
+ "by providing a query."
174
+ ),
175
+ )(self.get_profile),
176
+ ]