Migrate Agent Framework integration to Supermemory v5 (#1786)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Dhravya Shah 2026-10-09 14:52:36 -07:00 • committed by GitHub
parent 02474bb732
commit 2c46964dfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 710 additions and 168 deletions

View file

@ -31,10 +31,10 @@ jobs:
include: include:
- python-version: "3.10" - python-version: "3.10"
dependency-lane: minimum-supermemory dependency-lane: minimum-supermemory
supermemory-version: "3.16.0" supermemory-version: "5.0.0"
- python-version: "3.13" - python-version: "3.13"
dependency-lane: current-supermemory dependency-lane: current-supermemory
supermemory-version: "3.59.0" supermemory-version: "5.0.0"
defaults: defaults:
run: run:
working-directory: packages/agent-framework-python working-directory: packages/agent-framework-python

View file

@ -16,19 +16,19 @@ Microsoft's [Agent Framework](https://github.com/microsoft/agent-framework) is a
## Setup ## Setup
Install the package: Install adapter version `>=2.0.0,<3` with the OpenAI client package:
```bash ```bash
pip install --pre supermemory-agent-framework pip install "supermemory-agent-framework>=2.0.0,<3" agent-framework-openai
``` ```
Or with uv: Or with uv:
```bash ```bash
uv add --prerelease=allow supermemory-agent-framework uv add "supermemory-agent-framework>=2.0.0,<3" agent-framework-openai
``` ```
<Warning>The `--pre` / `--prerelease=allow` flag is required because `agent-framework-core` depends on pre-release versions of Azure packages.</Warning> The adapter requires `supermemory>=5.0.0,<6`. The OpenAI examples also require the separate `agent-framework-openai` package and use its current `OpenAIChatClient` Responses API client. Older supported framework cores can have different OpenAI client names and model arguments.
Set up your environment: Set up your environment:
@ -74,6 +74,12 @@ tools = SupermemoryTools(conn)
provider = SupermemoryContextProvider(conn, mode="full") provider = SupermemoryContextProvider(conn, mode="full")
``` ```
### Existing callers and data
Keep the existing `container_tag` and `conversation_id` arguments. The adapter sends the container tag as the v5 namespace and the unchanged `conversation_<conversation_id>` as the document `id`. Choose a distinct container tag per user or tenant: the default `msft_agent_chat` scope is shared. Repeated writes append/diff into the same conversation document; they do not replace previous turns. `entity_context` remains context prepended to retrieved memories, not an ingestion setting.
Existing Agent Framework session exports remain loadable because the provider does not change its scoped state. Reconstruct the connection with the same identifiers after loading a session; connections and API credentials are not part of provider state. This adapter migration does not move existing server-side data. Confirm old data is available in the corresponding v5 namespace using the [v5 migration guide](/migration/api-v5); the adapter does not delete or rewrite legacy data.
--- ---
## Context provider (recommended) ## Context provider (recommended)
@ -83,7 +89,7 @@ The most idiomatic integration. Follows the same pattern as Agent Framework's bu
```python ```python
import asyncio import asyncio
from agent_framework import AgentSession from agent_framework import AgentSession
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import AgentSupermemory, SupermemoryContextProvider from supermemory_agent_framework import AgentSupermemory, SupermemoryContextProvider
async def main(): async def main():
@ -91,7 +97,7 @@ async def main():
provider = SupermemoryContextProvider(conn, mode="full") provider = SupermemoryContextProvider(conn, mode="full")
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="You are a helpful assistant with memory.", instructions="You are a helpful assistant with memory.",
context_providers=[provider], context_providers=[provider],
@ -130,14 +136,14 @@ Give agents explicit control over memory operations. The agent decides when to s
```python ```python
import asyncio import asyncio
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import AgentSupermemory, SupermemoryTools from supermemory_agent_framework import AgentSupermemory, SupermemoryTools
async def main(): async def main():
conn = AgentSupermemory(container_tag="user-123") conn = AgentSupermemory(container_tag="user-123")
tools = SupermemoryTools(conn) tools = SupermemoryTools(conn)
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="""You are a helpful assistant with memory. instructions="""You are a helpful assistant with memory.
When users share preferences, save them. When they ask questions, search memories first.""", When users share preferences, save them. When they ask questions, search memories first.""",
@ -160,6 +166,8 @@ The agent gets three tools:
- **`add_memory`** — Store new information for later recall - **`add_memory`** — Store new information for later recall
- **`get_profile`** — Fetch the user's full profile (static + dynamic facts) - **`get_profile`** — Fetch the user's full profile (static + dynamic facts)
Tool JSON envelopes are preserved. Profiles still expose string arrays for static, dynamic, and bucket facts. A query passed to `get_profile` triggers a separate v5 search, returned under `search_results` with `results`, `timing`, and `total` (the number returned). Search results keep their top-level `updated_at` timestamp and include v5 metadata; legacy optional fields not returned by v5 remain present as `null` because their values cannot be reconstructed. The deprecated Python-only `include_full_docs` argument remains ignored and is not exposed to the model.
--- ---
## Chat middleware ## Chat middleware
@ -168,7 +176,7 @@ Intercept chat requests to automatically inject memory context. Useful when you
```python ```python
import asyncio import asyncio
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import ( from supermemory_agent_framework import (
AgentSupermemory, AgentSupermemory,
SupermemoryChatMiddleware, SupermemoryChatMiddleware,
@ -186,13 +194,14 @@ async def main():
), ),
) )
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="You are a helpful assistant.", instructions="You are a helpful assistant.",
middleware=[middleware], middleware=[middleware],
) )
response = await agent.run("What's my favorite programming language?") response = await agent.run("What's my favorite programming language?")
await middleware.wait_for_background_tasks()
print(response.text) print(response.text)
asyncio.run(main()) asyncio.run(main())
@ -212,6 +221,8 @@ SupermemoryContextProvider(conn, mode="full") # or "profile" / "query"
| `"query"` | Memories relevant to the current message only | Targeted recall, no profile data | | `"query"` | Memories relevant to the current message only | Targeted recall, no profile data |
| `"full"` (default) | Profile + query search combined | Maximum context | | `"full"` (default) | Profile + query search combined | Maximum context |
V5 profile retrieval does not accept a search query. Full mode makes separate profile and search requests and deduplicates overlapping facts before injection. Profile-associated searches keep the legacy memory-only mode and `0.6` threshold; the explicit search tool keeps hybrid mode and `0.6`. Writes are asynchronous; an accepted document may not be immediately searchable, and this migration does not change the SDK's default processing mode.
--- ---
## Example: support agent with memory ## Example: support agent with memory
@ -221,7 +232,7 @@ A support agent that remembers customers across sessions:
```python ```python
import asyncio import asyncio
from agent_framework import AgentSession from agent_framework import AgentSession
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import ( from supermemory_agent_framework import (
AgentSupermemory, AgentSupermemory,
SupermemoryChatMiddleware, SupermemoryChatMiddleware,
@ -247,13 +258,13 @@ async def main():
conn, conn,
options=SupermemoryMiddlewareOptions( options=SupermemoryMiddlewareOptions(
mode="full", mode="full",
add_memory="always", add_memory="never",
), ),
) )
tools = SupermemoryTools(conn) tools = SupermemoryTools(conn)
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="SupportAgent", name="SupportAgent",
instructions="""You are a customer support agent. instructions="""You are a customer support agent.
@ -285,12 +296,16 @@ Save important new information about the customer.""",
asyncio.run(main()) asyncio.run(main())
``` ```
This example stores conversations through the provider only. Enabling middleware storage as well can submit overlapping content; use both only when intentional.
--- ---
## Error handling ## Error handling
The package provides specific exception types: The package provides specific exception types:
Tools return operation failures as JSON (`success: false`, `error`). Provider retrieval/storage and middleware retrieval failures are logged and let the agent continue without memories. Middleware background write failures are logged rather than raised by `wait_for_background_tasks()`; a timeout on that wait still raises `asyncio.TimeoutError`.
```python ```python
from supermemory_agent_framework import ( from supermemory_agent_framework import (
AgentSupermemory, AgentSupermemory,

View file

@ -6,16 +6,20 @@ This package provides both **automatic memory injection middleware** and **manua
## Installation ## Installation
Adapter version `>=2.0.0,<3` supports the Supermemory Python SDK `>=5.0.0,<6`.
The OpenAI client is a separate Agent Framework package. Include `agent-framework-openai` when using the OpenAI examples below, which target its current `OpenAIChatClient` Responses API client. The adapter also supports older framework cores, but their OpenAI client names and model arguments can differ.
Install using uv (recommended): Install using uv (recommended):
```bash ```bash
uv add supermemory-agent-framework uv add "supermemory-agent-framework>=2.0.0,<3" agent-framework-openai
``` ```
Or with pip: Or with pip:
```bash ```bash
pip install supermemory-agent-framework pip install "supermemory-agent-framework>=2.0.0,<3" agent-framework-openai
``` ```
## Quick Start ## Quick Start
@ -26,7 +30,7 @@ The easiest way to add memory capabilities is using the `SupermemoryChatMiddlewa
```python ```python
import asyncio import asyncio
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import ( from supermemory_agent_framework import (
AgentSupermemory, AgentSupermemory,
SupermemoryChatMiddleware, SupermemoryChatMiddleware,
@ -49,7 +53,7 @@ async def main():
) )
# Create agent with middleware # Create agent with middleware
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="You are a helpful assistant with memory.", instructions="You are a helpful assistant with memory.",
middleware=[middleware], middleware=[middleware],
@ -59,6 +63,7 @@ async def main():
response = await agent.run( response = await agent.run(
"What's my favorite programming language?" "What's my favorite programming language?"
) )
await middleware.wait_for_background_tasks()
print(response.text) print(response.text)
asyncio.run(main()) asyncio.run(main())
@ -71,7 +76,7 @@ The most idiomatic way to add memory in Agent Framework, using the same pattern
```python ```python
import asyncio import asyncio
from agent_framework import AgentSession from agent_framework import AgentSession
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import AgentSupermemory, SupermemoryContextProvider from supermemory_agent_framework import AgentSupermemory, SupermemoryContextProvider
async def main(): async def main():
@ -87,7 +92,7 @@ async def main():
) )
# Create agent with context provider # Create agent with context provider
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="You are a helpful assistant with memory.", instructions="You are a helpful assistant with memory.",
context_providers=[provider], context_providers=[provider],
@ -110,7 +115,7 @@ For explicit tool-based memory access:
```python ```python
import asyncio import asyncio
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import AgentSupermemory, SupermemoryTools from supermemory_agent_framework import AgentSupermemory, SupermemoryTools
async def main(): async def main():
@ -121,7 +126,7 @@ async def main():
tools = SupermemoryTools(connection) tools = SupermemoryTools(connection)
# Create agent # Create agent
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="You are a helpful assistant with access to user memories.", instructions="You are a helpful assistant with access to user memories.",
) )
@ -142,7 +147,7 @@ For maximum flexibility, use both middleware (automatic context injection) and t
```python ```python
import asyncio import asyncio
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import ( from supermemory_agent_framework import (
AgentSupermemory, AgentSupermemory,
SupermemoryChatMiddleware, SupermemoryChatMiddleware,
@ -164,7 +169,7 @@ async def main():
tools = SupermemoryTools(connection) tools = SupermemoryTools(connection)
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="You are a helpful assistant with memory.", instructions="You are a helpful assistant with memory.",
middleware=[middleware], middleware=[middleware],
@ -258,10 +263,23 @@ result = await tools.add_memory("User prefers dark mode")
result = await tools.get_profile() result = await tools.get_profile()
``` ```
`search_memories` uses v4 hybrid search, so results can contain either a `search_memories` uses v5 hybrid search, so results can contain either a
structured memory or a source chunk. The old Python-only `include_full_docs` structured memory or a source chunk. The old Python-only `include_full_docs`
argument is deprecated and ignored because v4 search does not return full argument remains deprecated and ignored; this tool does not request full
source documents; it is not exposed to the model as a tool parameter. source documents, and the argument is not exposed to the model.
### V5 compatibility
- Keep passing `container_tag` and `conversation_id`. The adapter passes the container tag as the v5 namespace and uses the unchanged `conversation_<conversation_id>` value as the document `id`. Choose a separate container tag for each tenant; the default `msft_agent_chat` is shared, not tenant-specific.
- All writes still use add/append, including tool writes and automatic conversation storage. Reusing a conversation ID adds or diffs new content into its document; it does not replace earlier turns. No document update or replacement operation is used.
- `entity_context` remains display context prepended to retrieved memories; this migration does not start sending it as ingestion `supporting_context`.
- Profile mode makes one profile request, query mode makes one search request, and full mode makes both when there is a user query. V5 profiles no longer accept a query. Profile-associated search keeps the legacy memory-only mode and `0.6` threshold rather than adopting v5's broader defaults; the explicit search tool keeps its hybrid mode and `0.6` threshold. Provider and middleware context still contains fact text rather than `{id, memory}` objects and deduplicates facts across profile/search results.
- Tool JSON envelopes remain unchanged: search returns `success`, `results`, and `count`; add returns `success` and `memory`; profile returns `success`, `profile`, and `search_results`. Profile static/dynamic/bucket values remain strings. Profile search results retain `results`, `timing`, and `total` (the number returned). Search results retain a top-level `updated_at` mapped from v5 `system.updated_at`, along with v5 fields. Legacy optional fields that v5 does not return, such as version numbers and file paths, remain present as `null`; their values cannot be reconstructed.
- The provider has no adapter-owned persisted state schema and leaves its scoped session state unchanged. Existing framework session exports remain loadable; keep using the same container tag and conversation ID when reconstructing the connection. The API client's credentials are not serialized into session state.
This maps requests but does not move server-side data. If existing v3/v4 data has not been migrated into the corresponding v5 namespace, follow the [v5 migration guide](https://supermemory.ai/docs/migration/api-v5) before relying on historical recall. The adapter does not delete or rewrite the old data.
Writes are accepted asynchronously; `queued` is not a guarantee that a later search already contains the new memory. The SDK's default processing mode is unchanged. Enabling both provider storage and middleware storage can submit overlapping conversation content, so use one automatic storage path unless that is intentional.
### SupermemoryChatMiddleware ### SupermemoryChatMiddleware
@ -307,6 +325,8 @@ except SupermemoryConfigurationError as e:
### Exception Types ### Exception Types
Tools return failures as JSON with `success: false` and `error`. Provider retrieval/storage and middleware retrieval failures are logged and do not abort the agent run. Middleware background write failures are logged; `wait_for_background_tasks()` waits for those tasks but does not re-raise their operation errors (its own wait timeout still raises `asyncio.TimeoutError`). SDK connection and request timeout failures are classified separately for background writes.
- **`SupermemoryError`** - Base class for all Supermemory exceptions - **`SupermemoryError`** - Base class for all Supermemory exceptions
- **`SupermemoryConfigurationError`** - Missing API keys, invalid configuration - **`SupermemoryConfigurationError`** - Missing API keys, invalid configuration
- **`SupermemoryAPIError`** - API request failures (includes status codes) - **`SupermemoryAPIError`** - API request failures (includes status codes)
@ -323,7 +343,7 @@ except SupermemoryConfigurationError as e:
### Required ### Required
- `agent-framework-core>=1.0.0rc3` - Microsoft Agent Framework - `agent-framework-core>=1.0.0rc3` - Microsoft Agent Framework
- `supermemory>=3.16.0` - Supermemory client with v4 hybrid search support - `supermemory>=5.0.0,<6` - Namespace-first Supermemory v5 client
- `typing-extensions>=4.0.0` - Typing compatibility helpers - `typing-extensions>=4.0.0` - Typing compatibility helpers
## Development ## Development
@ -342,8 +362,11 @@ uv run mypy src/supermemory_agent_framework
# Formatting # Formatting
uv run black src/ tests/ uv run black src/ tests/
uv run isort src/ tests/ uv run isort src/ tests/
uv run flake8 src/ tests/ --ignore=E501,W503,E704
``` ```
The HTTP-transport regression suite uses the actual Supermemory SDK and runs a real Agent Framework agent/tool loop without API credentials or a live model. It is verified against both `agent-framework-core==1.0.0rc3` and `1.21.0`.
## License ## License
MIT License - see LICENSE file for details. MIT License - see LICENSE file for details.

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "supermemory-agent-framework" name = "supermemory-agent-framework"
version = "1.0.3" version = "2.0.0"
description = "Memory tools and middleware for Microsoft Agent Framework with supermemory" description = "Memory tools and middleware for Microsoft Agent Framework with supermemory"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
@ -25,7 +25,7 @@ classifiers = [
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"agent-framework-core>=1.0.0rc3", "agent-framework-core>=1.0.0rc3",
"supermemory>=3.16.0,<5", "supermemory>=5.0.0,<6",
"typing-extensions>=4.0.0", "typing-extensions>=4.0.0",
] ]

View file

@ -3,38 +3,33 @@
from .connection import ( from .connection import (
AgentSupermemory, AgentSupermemory,
) )
from .context_provider import (
from .tools import ( SupermemoryContextProvider,
SupermemoryTools, )
MemorySearchResult, from .exceptions import (
MemoryAddResult, SupermemoryAPIError,
ProfileResult, SupermemoryConfigurationError,
SupermemoryError,
SupermemoryMemoryOperationError,
SupermemoryNetworkError,
SupermemoryTimeoutError,
) )
from .middleware import ( from .middleware import (
SupermemoryChatMiddleware, SupermemoryChatMiddleware,
SupermemoryMiddlewareOptions, SupermemoryMiddlewareOptions,
) )
from .tools import (
from .context_provider import ( MemoryAddResult,
SupermemoryContextProvider, MemorySearchResult,
ProfileResult,
SupermemoryTools,
) )
from .utils import ( from .utils import (
DeduplicatedMemories,
Logger, Logger,
convert_profile_to_markdown,
create_logger, create_logger,
deduplicate_memories, deduplicate_memories,
DeduplicatedMemories,
convert_profile_to_markdown,
)
from .exceptions import (
SupermemoryError,
SupermemoryConfigurationError,
SupermemoryAPIError,
SupermemoryMemoryOperationError,
SupermemoryTimeoutError,
SupermemoryNetworkError,
) )
__all__ = [ __all__ = [
@ -57,4 +52,4 @@ __all__ = [
"SupermemoryMemoryOperationError", "SupermemoryMemoryOperationError",
"SupermemoryTimeoutError", "SupermemoryTimeoutError",
"SupermemoryNetworkError", "SupermemoryNetworkError",
] ]

View file

@ -14,13 +14,11 @@ from agent_framework import Message
try: try:
from agent_framework import BaseContextProvider # type: ignore[attr-defined] from agent_framework import BaseContextProvider # type: ignore[attr-defined]
except ImportError: except ImportError:
# Renamed in agent-framework-core 1.0.0 stable; the interface is
# unchanged (source_id __init__, before_run/after_run hooks with
# identical keyword-only signatures).
from agent_framework import ContextProvider as BaseContextProvider from agent_framework import ContextProvider as BaseContextProvider
from .connection import AgentSupermemory from .connection import AgentSupermemory
from .utils import ( from .utils import (
_fetch_profile_and_search,
convert_profile_to_markdown, convert_profile_to_markdown,
create_logger, create_logger,
deduplicate_memories, deduplicate_memories,
@ -40,7 +38,7 @@ class SupermemoryContextProvider(BaseContextProvider):
Example: Example:
```python ```python
from agent_framework import Agent, AgentSession from agent_framework import Agent, AgentSession
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import ( from supermemory_agent_framework import (
AgentSupermemory, AgentSupermemory,
SupermemoryContextProvider, SupermemoryContextProvider,
@ -54,7 +52,7 @@ class SupermemoryContextProvider(BaseContextProvider):
store_conversations=True, store_conversations=True,
) )
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="You are a helpful assistant with memory.", instructions="You are a helpful assistant with memory.",
context_providers=[provider], context_providers=[provider],
@ -107,7 +105,6 @@ class SupermemoryContextProvider(BaseContextProvider):
state: dict[str, Any], state: dict[str, Any],
) -> None: ) -> None:
"""Search Supermemory for relevant memories and inject into context.""" """Search Supermemory for relevant memories and inject into context."""
# Extract query text from input messages
query_text = "" query_text = ""
if self._mode != "profile": if self._mode != "profile":
query_text = self._extract_query_from_context(context) query_text = self._extract_query_from_context(context)
@ -137,11 +134,9 @@ class SupermemoryContextProvider(BaseContextProvider):
self._logger.debug("No memories found") self._logger.debug("No memories found")
return return
# Prepend entity context if available
if self._connection.entity_context: if self._connection.entity_context:
memories_text = f"{self._connection.entity_context}\n\n{memories_text}" memories_text = f"{self._connection.entity_context}\n\n{memories_text}"
# Inject memories into the session context
full_text = wrap_memory_injection(memories_text, self._context_prompt) full_text = wrap_memory_injection(memories_text, self._context_prompt)
self._logger.debug( self._logger.debug(
@ -149,11 +144,9 @@ class SupermemoryContextProvider(BaseContextProvider):
{"length": len(memories_text)}, {"length": len(memories_text)},
) )
# Use extend_instructions to add memory context
if hasattr(context, "extend_instructions"): if hasattr(context, "extend_instructions"):
context.extend_instructions(self.source_id, full_text) context.extend_instructions(self.source_id, full_text)
elif hasattr(context, "extend_messages"): elif hasattr(context, "extend_messages"):
# Fallback: add as a system message
context.extend_messages( context.extend_messages(
self.source_id, self.source_id,
[Message("system", [full_text])], [Message("system", [full_text])],
@ -185,13 +178,11 @@ class SupermemoryContextProvider(BaseContextProvider):
}, },
) )
add_params: dict[str, Any] = { await self._client.add(
"content": conversation_text, self._container_tag,
"container_tag": self._container_tag, content=conversation_text,
"custom_id": self._connection.custom_id, id=self._connection.custom_id,
} )
await self._client.add(**add_params)
self._logger.info("Conversation stored successfully") self._logger.info("Conversation stored successfully")
@ -203,20 +194,16 @@ class SupermemoryContextProvider(BaseContextProvider):
async def _fetch_memories(self, query_text: str = "") -> str: async def _fetch_memories(self, query_text: str = "") -> str:
"""Fetch and format memories from Supermemory.""" """Fetch and format memories from Supermemory."""
kwargs: dict[str, Any] = {"container_tag": self._container_tag} response, search = await _fetch_profile_and_search(
if query_text: self._client,
kwargs["q"] = query_text self._container_tag,
include_profile=self._mode != "query",
response = await self._client.profile(**kwargs) query=query_text if self._mode != "profile" else "",
)
profile = response.profile if response.profile else None profile = response.profile if response else None
static = list(profile.static) if profile and profile.static else [] static = list(profile.static) if profile and profile.static else []
dynamic = list(profile.dynamic) if profile and profile.dynamic else [] dynamic = list(profile.dynamic) if profile and profile.dynamic else []
search_results_raw = ( search_results_raw = list(search.results) if search else []
list(response.search_results.results)
if response.search_results and response.search_results.results
else []
)
deduplicated = deduplicate_memories( deduplicated = deduplicate_memories(
static=static if self._mode != "query" else [], static=static if self._mode != "query" else [],
@ -224,7 +211,6 @@ class SupermemoryContextProvider(BaseContextProvider):
search_results=search_results_raw, search_results=search_results_raw,
) )
# Build formatted text based on mode
profile_text = "" profile_text = ""
if self._mode != "query": if self._mode != "query":
profile_text = convert_profile_to_markdown( profile_text = convert_profile_to_markdown(
@ -290,13 +276,11 @@ class SupermemoryContextProvider(BaseContextProvider):
"""Extract conversation text from context for storage.""" """Extract conversation text from context for storage."""
messages: list[Any] = [] messages: list[Any] = []
# Gather input messages
if hasattr(context, "input_messages"): if hasattr(context, "input_messages"):
messages.extend(context.input_messages or []) messages.extend(context.input_messages or [])
elif hasattr(context, "messages"): elif hasattr(context, "messages"):
messages.extend(context.messages or []) messages.extend(context.messages or [])
# Gather response messages
if hasattr(context, "response") and context.response: if hasattr(context, "response") and context.response:
resp = context.response resp = context.response
if hasattr(resp, "text") and resp.text: if hasattr(resp, "text") and resp.text:

View file

@ -15,9 +15,11 @@ from .connection import AgentSupermemory
from .exceptions import ( from .exceptions import (
SupermemoryMemoryOperationError, SupermemoryMemoryOperationError,
SupermemoryNetworkError, SupermemoryNetworkError,
SupermemoryTimeoutError,
) )
from .utils import ( from .utils import (
Logger, Logger,
_fetch_profile_and_search,
convert_profile_to_markdown, convert_profile_to_markdown,
create_logger, create_logger,
deduplicate_memories, deduplicate_memories,
@ -125,20 +127,16 @@ async def _build_memories_text(
query_text: str = "", query_text: str = "",
) -> str: ) -> str:
"""Build formatted memories text from Supermemory API.""" """Build formatted memories text from Supermemory API."""
kwargs: dict[str, Any] = {"container_tag": container_tag} memories_response, search = await _fetch_profile_and_search(
if query_text: client,
kwargs["q"] = query_text container_tag,
include_profile=mode != "query",
memories_response = await client.profile(**kwargs) query=query_text if mode != "profile" else "",
)
profile = memories_response.profile if memories_response.profile else None profile = memories_response.profile if memories_response else None
static = list(profile.static) if profile and profile.static else [] static = list(profile.static) if profile and profile.static else []
dynamic = list(profile.dynamic) if profile and profile.dynamic else [] dynamic = list(profile.dynamic) if profile and profile.dynamic else []
search_results_raw = ( search_results_raw = list(search.results) if search else []
list(memories_response.search_results.results)
if memories_response.search_results and memories_response.search_results.results
else []
)
logger.info( logger.info(
"Memory search completed", "Memory search completed",
@ -146,9 +144,7 @@ async def _build_memories_text(
"container_tag": container_tag, "container_tag": container_tag,
"memory_count_static": len(static), "memory_count_static": len(static),
"memory_count_dynamic": len(dynamic), "memory_count_dynamic": len(dynamic),
"query_text": ( "query_text": (query_text[:100] + ("..." if len(query_text) > 100 else "")),
query_text[:100] + ("..." if len(query_text) > 100 else "")
),
"mode": mode, "mode": mode,
}, },
) )
@ -190,13 +186,7 @@ async def _save_memory(
) -> None: ) -> None:
"""Save a memory to Supermemory.""" """Save a memory to Supermemory."""
try: try:
add_params: dict[str, Any] = { response = await client.add(container_tag, content=content, id=custom_id)
"content": content,
"container_tag": container_tag,
"custom_id": custom_id,
}
response = await client.add(**add_params)
logger.info( logger.info(
"Memory saved successfully", "Memory saved successfully",
@ -207,10 +197,11 @@ async def _save_memory(
"memory_id": getattr(response, "id", None), "memory_id": getattr(response, "id", None),
}, },
) )
except (OSError, ConnectionError) as network_error: except supermemory.APITimeoutError as timeout_error:
logger.error( logger.error("Timeout while saving memory", {"error": str(timeout_error)})
"Network error while saving memory", {"error": str(network_error)} raise SupermemoryTimeoutError("Timed out saving memory", timeout_error)
) except (supermemory.APIConnectionError, OSError, ConnectionError) as network_error:
logger.error("Network error while saving memory", {"error": str(network_error)})
raise SupermemoryNetworkError( raise SupermemoryNetworkError(
"Failed to save memory due to network error", network_error "Failed to save memory due to network error", network_error
) )
@ -228,7 +219,7 @@ class SupermemoryChatMiddleware(ChatMiddleware):
Example: Example:
```python ```python
from agent_framework.openai import OpenAIResponsesClient from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import ( from supermemory_agent_framework import (
AgentSupermemory, AgentSupermemory,
SupermemoryChatMiddleware, SupermemoryChatMiddleware,
@ -246,7 +237,7 @@ class SupermemoryChatMiddleware(ChatMiddleware):
), ),
) )
agent = OpenAIResponsesClient().as_agent( agent = OpenAIChatClient(model="gpt-5").as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="You are a helpful assistant with memory.", instructions="You are a helpful assistant with memory.",
middleware=[middleware], middleware=[middleware],
@ -274,12 +265,9 @@ class SupermemoryChatMiddleware(ChatMiddleware):
call_next: Callable[[], Awaitable[None]], call_next: Callable[[], Awaitable[None]],
) -> None: ) -> None:
"""Process the chat request by injecting memories and optionally saving conversations.""" """Process the chat request by injecting memories and optionally saving conversations."""
# Remove stale SDK-owned context before every lifecycle path. A failed,
# empty, or skipped lookup must never leak memories from a prior run.
_inject_memories(context, "") _inject_memories(context, "")
messages = context.messages messages = context.messages
# Save conversation memory in background if configured
if self._options.add_memory == "always": if self._options.add_memory == "always":
user_message = _get_last_user_message(messages) user_message = _get_last_user_message(messages)
if user_message and user_message.strip(): if user_message and user_message.strip():
@ -310,7 +298,6 @@ class SupermemoryChatMiddleware(ChatMiddleware):
task.add_done_callback(_handle_task_exception) task.add_done_callback(_handle_task_exception)
# Determine query text based on mode
query_text = "" query_text = ""
if self._options.mode != "profile": if self._options.mode != "profile":
user_message = _get_last_user_message(messages) user_message = _get_last_user_message(messages)
@ -329,7 +316,6 @@ class SupermemoryChatMiddleware(ChatMiddleware):
}, },
) )
# Fetch and build memories text
try: try:
memories = await _build_memories_text( memories = await _build_memories_text(
self._container_tag, self._container_tag,
@ -347,7 +333,6 @@ class SupermemoryChatMiddleware(ChatMiddleware):
return return
if memories: if memories:
# Prepend entity context if available
if self._connection.entity_context: if self._connection.entity_context:
memories = f"{self._connection.entity_context}\n\n{memories}" memories = f"{self._connection.entity_context}\n\n{memories}"
@ -356,14 +341,11 @@ class SupermemoryChatMiddleware(ChatMiddleware):
{"content": memories[:200], "full_length": len(memories)}, {"content": memories[:200], "full_length": len(memories)},
) )
# Inject memories into messages
_inject_memories(context, memories) _inject_memories(context, memories)
await call_next() await call_next()
async def wait_for_background_tasks( async def wait_for_background_tasks(self, timeout: Optional[float] = 10.0) -> None:
self, timeout: Optional[float] = 10.0
) -> None:
"""Wait for all background memory storage tasks to complete.""" """Wait for all background memory storage tasks to complete."""
if not self._background_tasks: if not self._background_tasks:
return return

View file

@ -8,8 +8,10 @@ import warnings
from typing import Annotated, Any, Optional, TypedDict from typing import Annotated, Any, Optional, TypedDict
from agent_framework import FunctionTool, tool from agent_framework import FunctionTool, tool
from supermemory.types import SearchResponse
from .connection import AgentSupermemory from .connection import AgentSupermemory
from .utils import _fetch_profile_and_search
class MemorySearchResult(TypedDict, total=False): class MemorySearchResult(TypedDict, total=False):
@ -50,13 +52,30 @@ def _to_jsonable(value: Any) -> Any:
try: try:
return _to_jsonable(model_dump(mode="json")) return _to_jsonable(model_dump(mode="json"))
except TypeError: except TypeError:
# Compatibility with pydantic-like models whose model_dump does not
# accept Pydantic v2's ``mode`` argument.
return _to_jsonable(model_dump()) return _to_jsonable(model_dump())
return value return value
def _serialize_search_results(response: SearchResponse) -> dict[str, Any]:
"""Keep the tool's legacy result fields while retaining v5 metadata."""
results = [
{
"chunks": None,
"context": None,
"documents": None,
"filepath": None,
"is_aggregated": None,
"root_memory_id": None,
"version": None,
**_to_jsonable(item),
"updated_at": item.system.updated_at,
}
for item in response.results
]
return {"results": results, "timing": response.search_time, "total": len(results)}
class SupermemoryTools: class SupermemoryTools:
"""Memory tools for Microsoft Agent Framework. """Memory tools for Microsoft Agent Framework.
@ -92,28 +111,28 @@ class SupermemoryTools:
"""Search stored memories and source chunks. """Search stored memories and source chunks.
``include_full_docs`` remains a deprecated Python-only argument for ``include_full_docs`` remains a deprecated Python-only argument for
source compatibility. V4 search cannot return full source documents. source compatibility. This tool does not return full source documents.
""" """
if include_full_docs is not None: if include_full_docs is not None:
warnings.warn( warnings.warn(
"include_full_docs is deprecated and ignored because v4 search " "include_full_docs is deprecated and ignored; search_memories "
"does not return full source documents", "does not request full source documents",
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
try: try:
response = await self._client.search.memories( response = await self._client.search(
q=information_to_get, self._connection.container_tag,
container_tag=self._connection.container_tag, query=information_to_get,
limit=limit, limit=limit,
threshold=0.6, threshold=0.6,
search_mode="hybrid", search_mode="hybrid",
) )
results = response.results or [] results = _serialize_search_results(response)["results"]
result: MemorySearchResult = { result: MemorySearchResult = {
"success": True, "success": True,
"results": [_to_jsonable(item) for item in results], "results": results,
"count": len(results), "count": len(results),
} }
return json.dumps(result, default=str) return json.dumps(result, default=str)
@ -131,9 +150,9 @@ class SupermemoryTools:
"""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.""" """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."""
try: try:
response = await self._client.add( response = await self._client.add(
self._connection.container_tag,
content=memory, content=memory,
container_tag=self._connection.container_tag, id=self._connection.custom_id,
custom_id=self._connection.custom_id,
) )
result: MemoryAddResult = { result: MemoryAddResult = {
"success": True, "success": True,
@ -153,23 +172,24 @@ class SupermemoryTools:
) -> str: ) -> str:
"""Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query.""" """Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query."""
try: try:
kwargs: dict[str, Any] = {"container_tag": self._connection.container_tag} response, search = await _fetch_profile_and_search(
if query: self._client, self._connection.container_tag, query=query
kwargs["q"] = query )
response = await self._client.profile(**kwargs)
result: dict[str, Any] = { result: dict[str, Any] = {
"success": True, "success": True,
"profile": ( "profile": (
_to_jsonable(response.profile) {
if hasattr(response, "profile") "static": [fact.memory for fact in response.profile.static],
else None "dynamic": [fact.memory for fact in response.profile.dynamic],
), "buckets": {
"search_results": ( name: [fact.memory for fact in facts]
_to_jsonable(response.search_results) for name, facts in response.profile.buckets.items()
if hasattr(response, "search_results") },
}
if response
else None else None
), ),
"search_results": _serialize_search_results(search) if search else None,
} }
return json.dumps(result, default=str) return json.dumps(result, default=str)
except Exception as error: except Exception as error:

View file

@ -1,9 +1,13 @@
"""Utility functions for Supermemory Agent Framework integration.""" """Utility functions for Supermemory Agent Framework integration."""
import asyncio
import json import json
import re import re
from typing import Any, Optional, Protocol from typing import Any, Optional, Protocol
import supermemory
from supermemory.types import ProfileResponse, SearchResponse
DEFAULT_CONTEXT_PROMPT = "The following are retrieved memories about the user." DEFAULT_CONTEXT_PROMPT = "The following are retrieved memories about the user."
MEMORY_CONTEXT_PATTERN = re.compile( MEMORY_CONTEXT_PATTERN = re.compile(
r'(?:\r?\n)?<supermemory context="user-memories" readonly>.*?</supermemory>', r'(?:\r?\n)?<supermemory context="user-memories" readonly>.*?</supermemory>',
@ -15,6 +19,31 @@ SUPERMEMORY_TAG_PATTERN = re.compile(
) )
async def _fetch_profile_and_search(
client: supermemory.AsyncSupermemory,
container_tag: str,
*,
include_profile: bool = True,
query: str = "",
) -> tuple[Optional[ProfileResponse], Optional[SearchResponse]]:
"""Fetch the independent v5 profile and query resources in one namespace."""
if include_profile and query:
profile, search = await asyncio.gather(
client.profile(container_tag),
client.search(
container_tag, query=query, threshold=0.6, search_mode="memories"
),
)
return profile, search
if include_profile:
return await client.profile(container_tag), None
if query:
return None, await client.search(
container_tag, query=query, threshold=0.6, search_mode="memories"
)
return None, None
def _escape_supermemory_tags(content: str) -> str: def _escape_supermemory_tags(content: str) -> str:
"""Escape nested Supermemory tags supplied as untrusted memory data.""" """Escape nested Supermemory tags supplied as untrusted memory data."""
@ -134,7 +163,6 @@ def deduplicate_memories(
if isinstance(value, str) and value.strip(): if isinstance(value, str) and value.strip():
return value.strip() return value.strip()
return None return None
# Stainless SDK returns pydantic models (attribute access, snake_case).
for field in ("memory", "chunk", "content"): for field in ("memory", "chunk", "content"):
value = getattr(item, field, None) value = getattr(item, field, None)
if isinstance(value, str) and value.strip(): if isinstance(value, str) and value.strip():

View file

@ -1,6 +1,8 @@
import asyncio import asyncio
import os import os
from agent_framework.openai import OpenAIResponsesClient
from agent_framework.openai import OpenAIChatClient
from supermemory_agent_framework import ( from supermemory_agent_framework import (
AgentSupermemory, AgentSupermemory,
SupermemoryChatMiddleware, SupermemoryChatMiddleware,
@ -26,7 +28,9 @@ async def main():
tools = SupermemoryTools(conn) tools = SupermemoryTools(conn)
agent = OpenAIResponsesClient(api_key=os.environ["OPENAI_API_KEY"], model_id="gpt-4o-mini").as_agent( agent = OpenAIChatClient(
api_key=os.environ["OPENAI_API_KEY"], model="gpt-5"
).as_agent(
name="MemoryAgent", name="MemoryAgent",
instructions="You are a helpful assistant with memory.", instructions="You are a helpful assistant with memory.",
middleware=[middleware], middleware=[middleware],
@ -48,6 +52,7 @@ async def main():
break break
response = await agent.run(user_input) response = await agent.run(user_input)
await middleware.wait_for_background_tasks()
print(f"\nAgent: {response.text}") print(f"\nAgent: {response.text}")

View file

@ -28,9 +28,7 @@ class TestContextProviderConfiguration:
def test_custom_source_id(self) -> None: def test_custom_source_id(self) -> None:
conn = _make_conn() conn = _make_conn()
provider = SupermemoryContextProvider( provider = SupermemoryContextProvider(conn, source_id="custom-source")
conn, source_id="custom-source"
)
assert provider.source_id == "custom-source" assert provider.source_id == "custom-source"
def test_default_mode(self) -> None: def test_default_mode(self) -> None:
@ -136,13 +134,18 @@ class TestMemoryRetrieval:
conn.client.profile = AsyncMock( conn.client.profile = AsyncMock(
return_value=SimpleNamespace( return_value=SimpleNamespace(
profile=SimpleNamespace(static=[fact], dynamic=[]), profile=SimpleNamespace(static=[fact], dynamic=[]),
search_results=SimpleNamespace( search_results=SimpleNamespace(results=[SimpleNamespace(memory=fact)]),
results=[SimpleNamespace(memory=fact)]
),
) )
) )
conn.client.search = AsyncMock(
return_value=SimpleNamespace(results=[SimpleNamespace(memory=fact)])
)
provider = SupermemoryContextProvider(conn, mode="query") provider = SupermemoryContextProvider(conn, mode="query")
memories = await provider._fetch_memories("machine learning") memories = await provider._fetch_memories("machine learning")
assert fact in memories assert fact in memories
conn.client.profile.assert_not_awaited()
conn.client.search.assert_awaited_once_with(
"user-123", query="machine learning", threshold=0.6, search_mode="memories"
)

View file

@ -11,9 +11,9 @@ from supermemory_agent_framework import (
SupermemoryMiddlewareOptions, SupermemoryMiddlewareOptions,
) )
from supermemory_agent_framework.middleware import ( from supermemory_agent_framework.middleware import (
_get_last_user_message,
_get_conversation_content,
_build_memories_text, _build_memories_text,
_get_conversation_content,
_get_last_user_message,
_inject_memories, _inject_memories,
) )
@ -141,14 +141,15 @@ class TestMemoryInjection:
assert "Be helpful." in content assert "Be helpful." in content
assert "Fresh profile fact" in content assert "Fresh profile fact" in content
assert "Stale profile fact" not in content assert "Stale profile fact" not in content
assert content.count( assert content.count('<supermemory context="user-memories" readonly>') == 1
'<supermemory context="user-memories" readonly>'
) == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_query_mode_keeps_search_fact_also_present_in_profile(self) -> None: async def test_query_mode_keeps_search_fact_also_present_in_profile(self) -> None:
fact = "User likes machine learning projects" fact = "User likes machine learning projects"
client = SimpleNamespace( client = SimpleNamespace(
search=AsyncMock(
return_value=SimpleNamespace(results=[SimpleNamespace(memory=fact)])
),
profile=AsyncMock( profile=AsyncMock(
return_value=SimpleNamespace( return_value=SimpleNamespace(
profile=SimpleNamespace(static=[fact], dynamic=[]), profile=SimpleNamespace(static=[fact], dynamic=[]),
@ -156,7 +157,7 @@ class TestMemoryInjection:
results=[SimpleNamespace(memory=fact)] results=[SimpleNamespace(memory=fact)]
), ),
) )
) ),
) )
logger = Mock() logger = Mock()
@ -165,3 +166,7 @@ class TestMemoryInjection:
) )
assert fact in memories assert fact in memories
client.profile.assert_not_awaited()
client.search.assert_awaited_once_with(
"user-123", query="machine learning", threshold=0.6, search_mode="memories"
)

View file

@ -1,7 +1,5 @@
"""Tests for Supermemory tools.""" """Tests for Supermemory tools."""
import pytest
from supermemory_agent_framework import AgentSupermemory, SupermemoryTools from supermemory_agent_framework import AgentSupermemory, SupermemoryTools

View file

@ -3,7 +3,6 @@
import pytest import pytest
from supermemory_agent_framework.utils import ( from supermemory_agent_framework.utils import (
DeduplicatedMemories,
SimpleLogger, SimpleLogger,
convert_profile_to_markdown, convert_profile_to_markdown,
create_logger, create_logger,
@ -29,8 +28,14 @@ class TestDeduplicateMemories:
def test_deduplication_priority(self) -> None: def test_deduplication_priority(self) -> None:
result = deduplicate_memories( result = deduplicate_memories(
static=[{"memory": "User likes Python"}], static=[{"memory": "User likes Python"}],
dynamic=[{"memory": "User likes Python"}, {"memory": "User works remotely"}], dynamic=[
search_results=[{"memory": "User likes Python"}, {"memory": "User prefers async"}], {"memory": "User likes Python"},
{"memory": "User works remotely"},
],
search_results=[
{"memory": "User likes Python"},
{"memory": "User prefers async"},
],
) )
assert result.static == ["User likes Python"] assert result.static == ["User likes Python"]
assert result.dynamic == ["User works remotely"] assert result.dynamic == ["User works remotely"]

View file

@ -0,0 +1,479 @@
"""Exercise the published v5 SDK through HTTP and the real framework pipeline."""
import inspect
import json
from copy import deepcopy
from typing import Any
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
import pytest_asyncio
import supermemory
from agent_framework import (
AgentSession,
BaseChatClient,
ChatContext,
ChatMiddlewareLayer,
ChatResponse,
Content,
FunctionInvocationLayer,
Message,
SessionContext,
)
from supermemory_agent_framework import (
AgentSupermemory,
SupermemoryChatMiddleware,
SupermemoryContextProvider,
SupermemoryMemoryOperationError,
SupermemoryMiddlewareOptions,
SupermemoryNetworkError,
SupermemoryTimeoutError,
SupermemoryTools,
)
from supermemory_agent_framework.middleware import _save_memory
from supermemory_agent_framework.utils import wrap_memory_injection
class MemoryAPI:
def __init__(self) -> None:
self.requests: list[tuple[str, str, dict[str, Any]]] = []
self.failure: int | str | None = None
self.empty = False
def handle(self, request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
self.requests.append((request.method, request.url.path, body))
if self.failure == "network":
raise httpx.ConnectError("Offline", request=request)
if self.failure == "timeout":
raise httpx.ReadTimeout("Timed out", request=request)
if isinstance(self.failure, int):
return httpx.Response(self.failure, json={"error": "Unavailable"})
namespace = request.url.path.split("/")[2]
fact = f"{namespace} prefers Python"
if request.url.path.endswith("/profile"):
return httpx.Response(
200,
json={
"profile": {
"static": (
[] if self.empty else [{"id": "fact", "memory": fact}]
),
"dynamic": (
[]
if self.empty
else [
{
"id": "recent",
"memory": f"{namespace} is building an agent",
}
]
),
"buckets": {"work": [{"id": "work", "memory": "Uses async"}]},
}
},
)
if request.url.path.endswith("/search"):
response = httpx.Response(
200,
json={
"results": (
[]
if self.empty
else [
{
"id": "fact",
"memory": fact,
"metadata": {"tenant": namespace},
"similarity": 0.95,
"isLatest": True,
"isInference": False,
"system": {"updatedAt": "2026-10-08T00:00:00Z"},
},
{
"id": "chunk",
"chunk": f"{namespace} source passage",
"metadata": {},
"similarity": 0.9,
"isLatest": True,
"isInference": False,
"system": {"updatedAt": "2026-10-08T00:00:00Z"},
},
]
),
"searchTime": 2.5,
},
)
if body.get("searchMode") == "memories":
data = response.json()
data["results"] = [
result for result in data["results"] if "memory" in result
]
return httpx.Response(200, json=data)
return response
assert request.url.path.endswith("/document")
assert request.method == "POST"
return httpx.Response(200, json={"id": body["id"], "status": "queued"})
@pytest_asyncio.fixture
async def memory_api(monkeypatch: pytest.MonkeyPatch):
api = MemoryAPI()
client_class = supermemory.AsyncSupermemory
async with httpx.AsyncClient(transport=httpx.MockTransport(api.handle)) as http:
monkeypatch.setattr(
supermemory,
"AsyncSupermemory",
lambda **kwargs: client_class(**kwargs, http_client=http, max_retries=0),
)
yield api
@pytest.mark.parametrize("mode", ["profile", "query", "full"])
async def test_provider_and_middleware_retrieval_modes(memory_api, mode):
connection = AgentSupermemory(
api_key="test", container_tag="tenant-a", entity_context="Custom entity context"
)
provider = SupermemoryContextProvider(
connection, mode=mode, context_prompt="Known facts"
)
context = SessionContext(input_messages=[Message("user", ["preferences"])])
state = {"legacy_user_value": {"keep": True}}
await provider.before_run(agent=None, session=None, context=context, state=state)
injected = "\n".join(context.instructions)
assert "tenant-a prefers Python" in injected
assert injected.count("tenant-a prefers Python") == 1
assert "Custom entity context" in injected
assert "Known facts" in injected
assert ("tenant-a is building an agent" in injected) == (mode != "query")
assert "tenant-a source passage" not in injected
assert state == {"legacy_user_value": {"keep": True}}
await provider.after_run(agent=None, session=None, context=context, state=state)
expected = {"profile"} if mode == "profile" else {"search"}
if mode == "full":
expected.add("profile")
assert {path.rsplit("/", 1)[1] for _, path, _ in memory_api.requests} == expected
for _, path, body in memory_api.requests:
assert path.startswith("/ns/tenant-a/")
assert body == (
{"query": "preferences", "threshold": 0.6, "searchMode": "memories"}
if path.endswith("search")
else {}
)
memory_api.requests.clear()
middleware = SupermemoryChatMiddleware(
connection, SupermemoryMiddlewareOptions(mode=mode)
)
chat = ChatContext(
client=BaseChatClient,
messages=[Message("system", ["Be helpful"]), Message("user", ["preferences"])],
options={},
)
next_call = AsyncMock()
await middleware.process(chat, next_call)
next_call.assert_awaited_once()
text = chat.messages[0].text
assert "Be helpful" in text
assert "tenant-a prefers Python" in text
assert text.count("tenant-a prefers Python") == 1
assert "Custom entity context" in text
assert {path.rsplit("/", 1)[1] for _, path, _ in memory_api.requests} == expected
async def test_tool_formats_and_append_mapping(memory_api):
connection = AgentSupermemory(
api_key="test", container_tag="tenant-a", conversation_id="existing"
)
tools = SupermemoryTools(connection)
with pytest.warns(DeprecationWarning):
result = json.loads(await tools.search_memories("Python", True, limit=3))
assert result["success"] is True
assert result["count"] == 2
assert result["results"][0]["memory"] == "tenant-a prefers Python"
assert result["results"][1]["chunk"] == "tenant-a source passage"
assert result["results"][0]["updated_at"] == "2026-10-08T00:00:00Z"
for field in (
"chunks",
"context",
"documents",
"filepath",
"is_aggregated",
"root_memory_id",
"version",
):
assert result["results"][0][field] is None
assert memory_api.requests[-1] == (
"POST",
"/ns/tenant-a/search",
{"query": "Python", "limit": 3, "threshold": 0.6, "searchMode": "hybrid"},
)
profile = json.loads(await tools.get_profile("preferences"))
assert profile["profile"] == {
"static": ["tenant-a prefers Python"],
"dynamic": ["tenant-a is building an agent"],
"buckets": {"work": ["Uses async"]},
}
assert profile["search_results"]["total"] == 1
assert profile["search_results"]["timing"] == 2.5
assert json.loads(await tools.get_profile())["search_results"] is None
for memory in ["First fact", "Second fact"]:
added = json.loads(await tools.add_memory(memory))
assert added == {
"success": True,
"memory": {"id": "conversation_existing", "status": "queued"},
}
assert memory_api.requests[-1] == (
"POST",
"/ns/tenant-a/document",
{"content": memory, "id": "conversation_existing"},
)
search_tool = tools.get_tools()[0]
assert "include_full_docs" not in search_tool.parameters()["properties"]
@pytest.mark.parametrize("failure", [401, 429, 503, "network", "timeout"])
async def test_failure_policy_with_sdk_errors(memory_api, failure):
memory_api.failure = failure
connection = AgentSupermemory(api_key="test", container_tag="tenant-a")
tools = SupermemoryTools(connection)
for operation in [
tools.search_memories("preferences"),
tools.add_memory("A fact"),
tools.get_profile("preferences"),
]:
result = json.loads(await operation)
assert result["success"] is False
assert result["error"]
provider = SupermemoryContextProvider(connection, store_conversations=True)
context = SessionContext(input_messages=[Message("user", ["preferences"])])
await provider.before_run(agent=None, session=None, context=context, state={})
assert not context.instructions
await provider.after_run(agent=None, session=None, context=context, state={})
middleware = SupermemoryChatMiddleware(
connection, SupermemoryMiddlewareOptions(mode="full", add_memory="always")
)
chat = ChatContext(
client=BaseChatClient,
messages=[
Message("system", ["Be helpful\n" + wrap_memory_injection("Stale memory")]),
Message("user", ["preferences"]),
],
options={},
)
next_call = AsyncMock()
await middleware.process(chat, next_call)
await middleware.wait_for_background_tasks()
next_call.assert_awaited_once()
assert chat.messages[0].text == "Be helpful"
error_type = (
SupermemoryNetworkError
if failure == "network"
else (
SupermemoryTimeoutError
if failure == "timeout"
else SupermemoryMemoryOperationError
)
)
with pytest.raises(error_type) as caught:
await _save_memory(connection.client, "tenant-a", "fact", "existing", Mock())
assert isinstance(caught.value.original_error, supermemory.APIError)
async def test_empty_and_missing_query_do_not_inject_stale_memories(memory_api):
memory_api.empty = True
connection = AgentSupermemory(api_key="test")
provider = SupermemoryContextProvider(connection)
context = SessionContext(input_messages=[Message("user", ["hello"])])
await provider.before_run(agent=None, session=None, context=context, state={})
assert not context.instructions
assert json.loads(await SupermemoryTools(connection).search_memories("hello")) == {
"success": True,
"results": [],
"count": 0,
}
memory_api.requests.clear()
query_provider = SupermemoryContextProvider(connection, mode="query")
await query_provider.before_run(
agent=None,
session=None,
context=SessionContext(input_messages=[]),
state={},
)
assert not memory_api.requests
middleware = SupermemoryChatMiddleware(
connection, SupermemoryMiddlewareOptions(mode="query")
)
chat = ChatContext(
client=BaseChatClient,
messages=[Message("system", [wrap_memory_injection("Stale memory")])],
options={},
)
next_call = AsyncMock()
await middleware.process(chat, next_call)
next_call.assert_awaited_once()
assert not chat.messages
assert not memory_api.requests
if "function_middleware" in inspect.signature(FunctionInvocationLayer).parameters:
class SmokeChatClientBase(
ChatMiddlewareLayer, FunctionInvocationLayer, BaseChatClient
):
pass
else:
class SmokeChatClientBase(
FunctionInvocationLayer, ChatMiddlewareLayer, BaseChatClient
):
pass
class SmokeChatClient(SmokeChatClientBase):
def __init__(self) -> None:
super().__init__()
self.calls: list[tuple[list[Message], dict[str, Any]]] = []
async def _inner_get_response(self, *, messages, stream, options, **kwargs):
assert not stream
self.calls.append((deepcopy(list(messages)), dict(options)))
if not any(
content.type == "function_result"
for message in messages
for content in message.contents
):
return ChatResponse(
messages=[
Message(
"assistant",
[
Content.from_function_call(
"search",
"search_memories",
arguments={"information_to_get": "Python"},
),
Content.from_function_call(
"add",
"add_memory",
arguments={"memory": "An explicit new fact"},
),
Content.from_function_call(
"profile",
"get_profile",
arguments={"query": "preferences"},
),
],
)
]
)
return ChatResponse(messages=[Message("assistant", ["Remembered"])])
async def test_real_framework_pipeline_and_tenant_isolation(memory_api):
for tenant in ["tenant-a", "tenant-b"]:
connection = AgentSupermemory(
api_key="test", container_tag=tenant, conversation_id="existing"
)
provider = SupermemoryContextProvider(connection, store_conversations=True)
middleware = SupermemoryChatMiddleware(
connection, SupermemoryMiddlewareOptions(mode="full", add_memory="always")
)
model = SmokeChatClient()
agent = model.as_agent(
name="MemoryAgent",
instructions="Be helpful",
context_providers=[provider],
middleware=[middleware],
tools=SupermemoryTools(connection).get_tools(),
)
old_state = {
"type": "session",
"session_id": "persisted-session",
"service_session_id": None,
"state": {
"supermemory": {
"legacy_caller_data": {
"container_tag": tenant,
"conversation_id": "existing",
}
}
},
}
session = AgentSession.from_dict(deepcopy(old_state))
assert session.to_dict() == old_state
start = len(memory_api.requests)
response = await agent.run("My preferences?", session=session)
await middleware.wait_for_background_tasks()
assert response.text == "Remembered"
assert len(model.calls) == 2
assert session.state["supermemory"] == old_state["state"]["supermemory"]
assert (
AgentSession.from_dict(session.to_dict()).state["supermemory"]
== old_state["state"]["supermemory"]
)
for messages, options in model.calls:
text = "\n".join(message.text for message in messages)
assert f"{tenant} prefers Python" in text
assert f"{tenant} prefers Python" in options["instructions"]
other_tenant = "tenant-b" if tenant == "tenant-a" else "tenant-a"
assert other_tenant not in text
assert other_tenant not in options["instructions"]
tool_results = [
json.loads(content.result)
for message in model.calls[-1][0]
for content in message.contents
if content.type == "function_result"
]
assert len(tool_results) == 3
assert all(result["success"] for result in tool_results)
requests = memory_api.requests[start:]
assert all(path.startswith(f"/ns/{tenant}/") for _, path, _ in requests)
writes = [body for _, path, body in requests if path.endswith("/document")]
assert all(body["id"] == "conversation_existing" for body in writes)
assert any(body["content"] == "An explicit new fact" for body in writes)
assert any("Assistant: Remembered" in body["content"] for body in writes)
assert any(body["content"] == "User: My preferences?" for body in writes)
@pytest.mark.parametrize("failure", [503, "network", "timeout"])
async def test_real_framework_pipeline_continues_on_memory_failure(memory_api, failure):
memory_api.failure = failure
connection = AgentSupermemory(api_key="test", container_tag="tenant-a")
provider = SupermemoryContextProvider(connection, store_conversations=True)
middleware = SupermemoryChatMiddleware(
connection, SupermemoryMiddlewareOptions(mode="full", add_memory="always")
)
model = SmokeChatClient()
agent = model.as_agent(
name="MemoryAgent",
instructions="Be helpful",
context_providers=[provider],
middleware=[middleware],
tools=SupermemoryTools(connection).get_tools(),
)
response = await agent.run("My preferences?", session=AgentSession())
await middleware.wait_for_background_tasks()
assert response.text == "Remembered"
assert len(model.calls) == 2
for messages, options in model.calls:
assert "supermemory context=" not in "\n".join(
message.text for message in messages
)
assert options["instructions"] == "Be helpful"
results = [
json.loads(content.result)
for message in model.calls[-1][0]
for content in message.contents
if content.type == "function_result"
]
assert len(results) == 3
assert all(result["success"] is False and result["error"] for result in results)