Add Supermemory integration for Microsoft Agent Framework (#775)

## Summary

This PR introduces comprehensive Supermemory integration for the Microsoft Agent Framework, providing three complementary approaches to add persistent memory capabilities to agents: middleware for automatic memory injection, context providers for session-based memory management, and tools for explicit memory operations.

## Key Changes

- **SupermemoryChatMiddleware**: Automatic memory injection middleware that fetches relevant memories from Supermemory before LLM calls and optionally saves conversations. Supports three modes:
  - `"profile"`: Injects all static and dynamic profile memories
  - `"query"`: Searches for memories relevant to the current user message
  - `"full"`: Combines both profile and query modes

- **SupermemoryContextProvider**: Idiomatic context provider following the Agent Framework pattern (similar to built-in Mem0 integration). Integrates with the session pipeline via `before_run()` and `after_run()` hooks for automatic memory retrieval and storage.

- **SupermemoryTools**: FunctionTool-compatible tools that agents can use for explicit memory operations:
  - `search_memories()`: Search for specific memories
  - `add_memory()`: Add new memories
  - `get_profile()`: Retrieve user profile

- **Utility Functions**: Helper functions for:
  - Memory deduplication across static, dynamic, and search result sources
  - Profile-to-markdown conversion for LLM consumption
  - Message extraction and conversation formatting
  - Logging with configurable verbosity

- **Exception Hierarchy**: Custom exceptions for better error handling:
  - `SupermemoryConfigurationError`: Missing/invalid configuration
  - `SupermemoryAPIError`: API request failures
  - `SupermemoryNetworkError`: Network connectivity issues
  - `SupermemoryMemoryOperationError`: Memory operation failures

- **Comprehensive Documentation**: README with quick start examples, configuration options, and API reference for all three integration approaches.

- **Test Suite**: Unit tests covering middleware, context provider, tools, and utility functions with proper mocking and error scenarios.

## Implementation Details

- Supports both async (aiohttp) and sync (requests) HTTP clients with automatic fallback
- Handles multiple message formats (dict, objects with attributes, content arrays)
- Configurable memory storage with optional conversation grouping via `conversation_id`
- Environment variable fallback for API key configuration (`SUPERMEMORY_API_KEY`)
- Background task management for non-blocking memory operations in middleware
- Proper async/sync compatibility for the Supermemory SDK

https://claude.ai/code/session_012idB5y6UGK3zmeFULgTc4z
This commit is contained in:
Dhravya 2026-03-10 01:49:45 +00:00
parent 07875ad1a1
commit 984297b62d
No known key found for this signature in database
GPG key ID: 135A27003CF4F6CB
21 changed files with 2574 additions and 7 deletions

View file

@ -0,0 +1,60 @@
"""Tests for AgentSupermemory connection class."""
import os
from unittest.mock import patch
import pytest
from supermemory_agent_framework import AgentSupermemory, SupermemoryConfigurationError
class TestAgentSupermemory:
def test_requires_api_key(self) -> None:
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("SUPERMEMORY_API_KEY", None)
with pytest.raises(SupermemoryConfigurationError):
AgentSupermemory()
def test_accepts_api_key_param(self) -> None:
conn = AgentSupermemory(api_key="test-key")
assert conn.client is not None
@patch.dict(os.environ, {"SUPERMEMORY_API_KEY": "env-key"})
def test_reads_env_api_key(self) -> None:
conn = AgentSupermemory()
assert conn.client is not None
def test_default_container_tag(self) -> None:
conn = AgentSupermemory(api_key="test-key")
assert conn.container_tag == "msft_agent_chat"
def test_custom_container_tag(self) -> None:
conn = AgentSupermemory(api_key="test-key", container_tag="user-123")
assert conn.container_tag == "user-123"
def test_auto_generates_conversation_id(self) -> None:
conn = AgentSupermemory(api_key="test-key")
assert conn.conversation_id is not None
assert len(conn.conversation_id) > 0
assert conn.custom_id == f"conversation_{conn.conversation_id}"
def test_custom_conversation_id(self) -> None:
conn = AgentSupermemory(api_key="test-key", conversation_id="conv-abc")
assert conn.conversation_id == "conv-abc"
assert conn.custom_id == "conversation_conv-abc"
def test_entity_context(self) -> None:
conn = AgentSupermemory(
api_key="test-key",
entity_context="User is a Python developer",
)
assert conn.entity_context == "User is a Python developer"
def test_entity_context_default_none(self) -> None:
conn = AgentSupermemory(api_key="test-key")
assert conn.entity_context is None
def test_shared_client_instance(self) -> None:
conn = AgentSupermemory(api_key="test-key")
# Client should be the same object
assert conn.client is conn.client

View file

@ -0,0 +1,125 @@
"""Tests for Supermemory context provider."""
import pytest
from supermemory_agent_framework import AgentSupermemory, SupermemoryContextProvider
def _make_conn(**kwargs):
kwargs.setdefault("api_key", "test-key")
kwargs.setdefault("container_tag", "user-123")
return AgentSupermemory(**kwargs)
class TestContextProviderConfiguration:
def test_accepts_connection(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn)
assert provider._container_tag == "user-123"
assert provider.source_id == "supermemory"
def test_uses_connection_client(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn)
assert provider._client is conn.client
def test_custom_source_id(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(
conn, source_id="custom-source"
)
assert provider.source_id == "custom-source"
def test_default_mode(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn)
assert provider._mode == "full"
def test_custom_mode(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn, mode="profile")
assert provider._mode == "profile"
def test_store_conversations_default(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn)
assert provider._store_conversations is False
def test_conversation_id_from_connection(self) -> None:
conn = _make_conn(conversation_id="conv-xyz")
provider = SupermemoryContextProvider(conn)
assert provider._connection.conversation_id == "conv-xyz"
assert provider._connection.custom_id == "conversation_conv-xyz"
def test_entity_context_from_connection(self) -> None:
conn = _make_conn(entity_context="User prefers TypeScript")
provider = SupermemoryContextProvider(conn)
assert provider._connection.entity_context == "User prefers TypeScript"
class TestExtractQuery:
def test_dict_messages(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn)
class MockContext:
input_messages = [
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hi!"},
{"role": "user", "content": "How are you?"},
]
result = provider._extract_query_from_context(MockContext())
assert result == "How are you?"
def test_empty_messages(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn)
class MockContext:
input_messages = []
result = provider._extract_query_from_context(MockContext())
assert result == ""
def test_no_messages_attr(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn)
class MockContext:
pass
result = provider._extract_query_from_context(MockContext())
assert result == ""
class TestExtractConversation:
def test_basic_conversation(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn)
class MockContext:
input_messages = [
{"role": "user", "content": "Hello!"},
]
response = None
result = provider._extract_conversation_from_context(MockContext())
assert "User: Hello!" in result
def test_with_response(self) -> None:
conn = _make_conn()
provider = SupermemoryContextProvider(conn)
class MockResponse:
text = "Hi there!"
class MockContext:
input_messages = [
{"role": "user", "content": "Hello!"},
]
response = MockResponse()
result = provider._extract_conversation_from_context(MockContext())
assert "User: Hello!" in result
assert "Assistant: Hi there!" in result

View file

@ -0,0 +1,113 @@
"""Tests for Supermemory middleware."""
import pytest
from supermemory_agent_framework import (
AgentSupermemory,
SupermemoryChatMiddleware,
SupermemoryMiddlewareOptions,
)
from supermemory_agent_framework.middleware import (
_get_last_user_message,
_get_conversation_content,
)
def _make_conn(**kwargs):
kwargs.setdefault("api_key", "test-key")
kwargs.setdefault("container_tag", "user-123")
return AgentSupermemory(**kwargs)
class TestGetLastUserMessage:
def test_dict_messages(self) -> None:
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "How are you?"},
]
assert _get_last_user_message(messages) == "How are you?"
def test_no_user_message(self) -> None:
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "assistant", "content": "Hi!"},
]
assert _get_last_user_message(messages) == ""
def test_empty_messages(self) -> None:
assert _get_last_user_message([]) == ""
assert _get_last_user_message(None) == ""
def test_content_parts(self) -> None:
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{"type": "text", "text": "world"},
],
}
]
assert _get_last_user_message(messages) == "Hello world"
class TestGetConversationContent:
def test_basic_conversation(self) -> None:
messages = [
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "How are you?"},
]
result = _get_conversation_content(messages)
assert "User: Hello!" in result
assert "Assistant: Hi there!" in result
assert "User: How are you?" in result
class TestMiddlewareOptions:
def test_defaults(self) -> None:
options = SupermemoryMiddlewareOptions()
assert options.verbose is False
assert options.mode == "profile"
assert options.add_memory == "never"
def test_custom_options(self) -> None:
options = SupermemoryMiddlewareOptions(
verbose=True,
mode="full",
add_memory="always",
)
assert options.verbose is True
assert options.mode == "full"
assert options.add_memory == "always"
class TestMiddlewareConfiguration:
def test_accepts_connection(self) -> None:
conn = _make_conn()
middleware = SupermemoryChatMiddleware(conn)
assert middleware._container_tag == "user-123"
def test_uses_connection_client(self) -> None:
conn = _make_conn()
middleware = SupermemoryChatMiddleware(conn)
assert middleware._supermemory_client is conn.client
def test_conversation_id_from_connection(self) -> None:
conn = _make_conn(conversation_id="conv-abc")
middleware = SupermemoryChatMiddleware(conn)
assert middleware._connection.conversation_id == "conv-abc"
assert middleware._connection.custom_id == "conversation_conv-abc"
def test_auto_generated_conversation_id(self) -> None:
conn = _make_conn()
middleware = SupermemoryChatMiddleware(conn)
assert middleware._connection.conversation_id is not None
assert len(middleware._connection.conversation_id) > 0
def test_entity_context_from_connection(self) -> None:
conn = _make_conn(entity_context="User is a Python developer")
middleware = SupermemoryChatMiddleware(conn)
assert middleware._connection.entity_context == "User is a Python developer"

View file

@ -0,0 +1,49 @@
"""Tests for Supermemory tools."""
import pytest
from supermemory_agent_framework import AgentSupermemory, SupermemoryTools
def _make_conn(**kwargs):
kwargs.setdefault("api_key", "test-key")
kwargs.setdefault("container_tag", "msft_agent_chat")
return AgentSupermemory(**kwargs)
class TestSupermemoryTools:
def test_create_tools_instance(self) -> None:
conn = _make_conn()
tools = SupermemoryTools(conn)
assert tools._connection.container_tag == "msft_agent_chat"
def test_create_tools_with_custom_tag(self) -> None:
conn = _make_conn(container_tag="custom-tag")
tools = SupermemoryTools(conn)
assert tools._connection.container_tag == "custom-tag"
def test_get_tools_returns_list(self) -> None:
conn = _make_conn()
tools = SupermemoryTools(conn)
result = tools.get_tools()
assert isinstance(result, list)
assert len(result) == 3
def test_get_tools_names(self) -> None:
conn = _make_conn()
tools = SupermemoryTools(conn)
result = tools.get_tools()
names = [t.name for t in result]
assert "search_memories" in names
assert "add_memory" in names
assert "get_profile" in names
def test_uses_connection_client(self) -> None:
conn = _make_conn()
tools = SupermemoryTools(conn)
assert tools._client is conn.client
def test_shares_custom_id_with_connection(self) -> None:
conn = _make_conn(conversation_id="conv-123")
tools = SupermemoryTools(conn)
assert tools._connection.custom_id == "conversation_conv-123"

View file

@ -0,0 +1,107 @@
"""Tests for utility functions."""
import pytest
from supermemory_agent_framework.utils import (
DeduplicatedMemories,
SimpleLogger,
convert_profile_to_markdown,
create_logger,
deduplicate_memories,
)
class TestDeduplicateMemories:
def test_empty_inputs(self) -> None:
result = deduplicate_memories()
assert result.static == []
assert result.dynamic == []
assert result.search_results == []
def test_static_only(self) -> None:
result = deduplicate_memories(
static=[{"memory": "User likes Python"}],
)
assert result.static == ["User likes Python"]
assert result.dynamic == []
assert result.search_results == []
def test_deduplication_priority(self) -> None:
result = deduplicate_memories(
static=[{"memory": "User likes Python"}],
dynamic=[{"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.dynamic == ["User works remotely"]
assert result.search_results == ["User prefers async"]
def test_string_format(self) -> None:
result = deduplicate_memories(
static=["User likes Python"],
dynamic=["User works remotely"],
)
assert result.static == ["User likes Python"]
assert result.dynamic == ["User works remotely"]
def test_empty_strings_filtered(self) -> None:
result = deduplicate_memories(
static=["", " ", "User likes Python"],
)
assert result.static == ["User likes Python"]
def test_none_items_filtered(self) -> None:
result = deduplicate_memories(
static=[None, {"memory": "valid"}],
)
assert result.static == ["valid"]
class TestConvertProfileToMarkdown:
def test_empty_profile(self) -> None:
result = convert_profile_to_markdown({"profile": {}})
assert result == ""
def test_static_only(self) -> None:
result = convert_profile_to_markdown(
{"profile": {"static": ["Likes Python", "Lives in SF"]}}
)
assert "## Static Profile" in result
assert "- Likes Python" in result
assert "- Lives in SF" in result
def test_both_sections(self) -> None:
result = convert_profile_to_markdown(
{
"profile": {
"static": ["Likes Python"],
"dynamic": ["Asked about AI"],
}
}
)
assert "## Static Profile" in result
assert "## Dynamic Profile" in result
class TestLogger:
def test_verbose_logger(self, capsys: pytest.CaptureFixture[str]) -> None:
logger = SimpleLogger(verbose=True)
logger.info("test message")
captured = capsys.readouterr()
assert "[supermemory] test message" in captured.out
def test_silent_logger(self, capsys: pytest.CaptureFixture[str]) -> None:
logger = SimpleLogger(verbose=False)
logger.info("test message")
captured = capsys.readouterr()
assert captured.out == ""
def test_error_prefix(self, capsys: pytest.CaptureFixture[str]) -> None:
logger = SimpleLogger(verbose=True)
logger.error("something failed")
captured = capsys.readouterr()
assert "ERROR:" in captured.out
def test_create_logger(self) -> None:
logger = create_logger(True)
assert isinstance(logger, SimpleLogger)