mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-10 03:28:14 +00:00
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:
parent
02474bb732
commit
2c46964dfa
15 changed files with 710 additions and 168 deletions
4
.github/workflows/ci-python.yml
vendored
4
.github/workflows/ci-python.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
479
packages/agent-framework-python/tests/test_v5_transport.py
Normal file
479
packages/agent-framework-python/tests/test_v5_transport.py
Normal 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)
|
||||||
Loading…
Add table
Reference in a new issue