mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-19 07:42:43 +00:00
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:
parent
07875ad1a1
commit
984297b62d
21 changed files with 2574 additions and 7 deletions
0
packages/agent-framework-python/tests/__init__.py
Normal file
0
packages/agent-framework-python/tests/__init__.py
Normal file
60
packages/agent-framework-python/tests/test_connection.py
Normal file
60
packages/agent-framework-python/tests/test_connection.py
Normal 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
|
||||
125
packages/agent-framework-python/tests/test_context_provider.py
Normal file
125
packages/agent-framework-python/tests/test_context_provider.py
Normal 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
|
||||
113
packages/agent-framework-python/tests/test_middleware.py
Normal file
113
packages/agent-framework-python/tests/test_middleware.py
Normal 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"
|
||||
49
packages/agent-framework-python/tests/test_tools.py
Normal file
49
packages/agent-framework-python/tests/test_tools.py
Normal 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"
|
||||
107
packages/agent-framework-python/tests/test_utils.py
Normal file
107
packages/agent-framework-python/tests/test_utils.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue