chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# Mem0 Package (agent-framework-mem0)
|
||||
|
||||
Integration with Mem0 for agent memory management.
|
||||
|
||||
## Main Classes
|
||||
|
||||
- **`Mem0ContextProvider`** - Context provider that integrates Mem0 memory into agents
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from agent_framework.mem0 import Mem0ContextProvider
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
api_key="your-key",
|
||||
user_id="user-id",
|
||||
)
|
||||
```
|
||||
|
||||
## Import Path
|
||||
|
||||
```python
|
||||
from agent_framework.mem0 import Mem0ContextProvider
|
||||
# or directly:
|
||||
from agent_framework_mem0 import Mem0ContextProvider
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
Mem0 telemetry is disabled by default. Set `MEM0_TELEMETRY=true` to enable.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,31 @@
|
||||
# Get Started with Microsoft Agent Framework Mem0
|
||||
|
||||
Please install this package via pip:
|
||||
|
||||
```bash
|
||||
pip install agent-framework-mem0 --pre
|
||||
```
|
||||
|
||||
## Memory Context Provider
|
||||
|
||||
The Mem0 context provider enables persistent memory capabilities for your agents, allowing them to remember user preferences and conversation context across different sessions and threads.
|
||||
|
||||
### Basic Usage Example
|
||||
|
||||
See the [Mem0 basic example](../../samples/02-agents/context_providers/mem0/mem0_basic.py) which demonstrates:
|
||||
|
||||
- Setting up an agent with Mem0 context provider
|
||||
- Teaching the agent user preferences
|
||||
- Retrieving information using remembered context across new threads
|
||||
- Persistent memory
|
||||
|
||||
## Telemetry
|
||||
|
||||
Mem0's telemetry is **disabled by default** when using this package. If you want to enable telemetry, set the environment variable before importing:
|
||||
|
||||
```python
|
||||
import os
|
||||
os.environ["MEM0_TELEMETRY"] = "true"
|
||||
|
||||
from agent_framework.mem0 import Mem0ContextProvider
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
|
||||
# Disable Mem0 telemetry by default to prevent usage data from being sent to telemetry provider.
|
||||
# Users can opt-in by setting MEM0_TELEMETRY=true before importing this package.
|
||||
if os.environ.get("MEM0_TELEMETRY") is None:
|
||||
os.environ["MEM0_TELEMETRY"] = "false"
|
||||
|
||||
from ._context_provider import Mem0ContextProvider
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0" # Fallback for development mode
|
||||
|
||||
__all__ = [
|
||||
"Mem0ContextProvider",
|
||||
"__version__",
|
||||
]
|
||||
@@ -0,0 +1,271 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""New-pattern Mem0 context provider using ContextProvider.
|
||||
|
||||
This module provides ``Mem0ContextProvider``, built on the new
|
||||
:class:`ContextProvider` hooks pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Awaitable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypedDict
|
||||
|
||||
from agent_framework import Message
|
||||
from agent_framework._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from mem0 import AsyncMemory, AsyncMemoryClient
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework._agents import SupportsAgentRun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MemoryRecord: TypeAlias = dict[str, object]
|
||||
|
||||
|
||||
class SearchResults(TypedDict):
|
||||
results: list[MemoryRecord]
|
||||
|
||||
|
||||
SearchResponse: TypeAlias = list[MemoryRecord] | SearchResults
|
||||
|
||||
|
||||
class Mem0ContextProvider(ContextProvider):
|
||||
"""Mem0 context provider using the new ContextProvider hooks pattern.
|
||||
|
||||
Integrates Mem0 for persistent semantic memory, searching and storing
|
||||
memories via the Mem0 API.
|
||||
"""
|
||||
|
||||
DEFAULT_CONTEXT_PROMPT = "## Memories\nConsider the following memories when answering user questions:"
|
||||
DEFAULT_SOURCE_ID: ClassVar[str] = "mem0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = DEFAULT_SOURCE_ID,
|
||||
mem0_client: AsyncMemory | AsyncMemoryClient | None = None,
|
||||
api_key: str | None = None,
|
||||
application_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
*,
|
||||
context_prompt: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Mem0 context provider.
|
||||
|
||||
Args:
|
||||
source_id: Unique identifier for this provider instance.
|
||||
mem0_client: A pre-created Mem0 MemoryClient or None to create a default client.
|
||||
api_key: The API key for authenticating with the Mem0 API.
|
||||
application_id: The application ID for scoping memories. Platform-only:
|
||||
the OSS ``AsyncMemory`` client does not recognize an application
|
||||
scope (it scopes only by user_id/agent_id in this provider), so
|
||||
application_id cannot be used with an OSS client.
|
||||
agent_id: The agent ID for scoping memories.
|
||||
user_id: The user ID for scoping memories.
|
||||
context_prompt: The prompt to prepend to retrieved memories.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
should_close_client = False
|
||||
if mem0_client is None:
|
||||
mem0_client = AsyncMemoryClient(api_key=api_key)
|
||||
should_close_client = True
|
||||
|
||||
self.api_key = api_key
|
||||
self.application_id = application_id
|
||||
self.agent_id = agent_id
|
||||
self.user_id = user_id
|
||||
self.context_prompt = context_prompt or self.DEFAULT_CONTEXT_PROMPT
|
||||
self.mem0_client = mem0_client
|
||||
self._should_close_client = should_close_client
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Async context manager entry."""
|
||||
if self.mem0_client and isinstance(self.mem0_client, AbstractAsyncContextManager):
|
||||
await self.mem0_client.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
|
||||
"""Async context manager exit."""
|
||||
if self._should_close_client and self.mem0_client and isinstance(self.mem0_client, AbstractAsyncContextManager):
|
||||
await self.mem0_client.__aexit__(exc_type, exc_val, exc_tb) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
# -- Hooks pattern ---------------------------------------------------------
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: SupportsAgentRun,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Search Mem0 for relevant memories and add to the session context."""
|
||||
self._validate_filters()
|
||||
input_text = "\n".join(msg.text for msg in context.input_messages if msg and msg.text and msg.text.strip())
|
||||
if not input_text.strip():
|
||||
return
|
||||
|
||||
# Query entity partitions independently to bypass strict logical AND limitations
|
||||
# Mem0 OSS and Platform SDKs expose inconsistent search typings.
|
||||
search_tasks: list[Awaitable[Any]] = []
|
||||
|
||||
# 1. Query User partition independently
|
||||
if self.user_id:
|
||||
user_kwargs = self._build_search_kwargs(input_text, "user_id", self.user_id)
|
||||
search_tasks.append(self.mem0_client.search(**user_kwargs)) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
|
||||
# 2. Query Agent partition independently
|
||||
if self.agent_id:
|
||||
agent_kwargs = self._build_search_kwargs(input_text, "agent_id", self.agent_id)
|
||||
search_tasks.append(self.mem0_client.search(**agent_kwargs)) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
|
||||
# Fall back to an app-scoped search when only application_id is configured.
|
||||
if not search_tasks and self.application_id:
|
||||
app_kwargs: dict[str, Any] = {"query": input_text, "filters": self._build_filters()}
|
||||
search_tasks.append(self.mem0_client.search(**app_kwargs)) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]
|
||||
if not search_tasks:
|
||||
return
|
||||
|
||||
results: list[SearchResponse | BaseException] = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
# Merge and deduplicate results
|
||||
memories: list[MemoryRecord] = []
|
||||
seen_memory_ids: set[str] = set()
|
||||
failed_tasks_count: int = 0
|
||||
|
||||
for search_response in results:
|
||||
if isinstance(search_response, asyncio.CancelledError):
|
||||
raise search_response
|
||||
|
||||
if isinstance(search_response, BaseException):
|
||||
failed_tasks_count += 1
|
||||
logger.error(
|
||||
"Mem0 partition search task failed: %s",
|
||||
search_response,
|
||||
exc_info=(type(search_response), search_response, search_response.__traceback__),
|
||||
)
|
||||
continue
|
||||
|
||||
current_memories: list[MemoryRecord] = []
|
||||
if isinstance(search_response, list):
|
||||
current_memories = [mem for mem in search_response if isinstance(mem, dict)]
|
||||
elif isinstance(search_response, dict):
|
||||
results_field = search_response.get("results")
|
||||
if isinstance(results_field, list):
|
||||
current_memories = [item for item in results_field if isinstance(item, dict)]
|
||||
else:
|
||||
logger.warning(
|
||||
"Unexpected Mem0 search response format: %s",
|
||||
type(results_field).__name__,
|
||||
)
|
||||
|
||||
for mem in current_memories:
|
||||
mem_id = mem.get("id")
|
||||
if mem_id is not None and not isinstance(mem_id, str):
|
||||
mem_id = str(mem_id)
|
||||
|
||||
if mem_id is not None and mem_id in seen_memory_ids:
|
||||
continue
|
||||
|
||||
if mem_id is not None:
|
||||
seen_memory_ids.add(mem_id)
|
||||
|
||||
memories.append(mem)
|
||||
|
||||
if failed_tasks_count == len(search_tasks):
|
||||
logger.error("All Mem0 retrieval tasks failed. Context provider is unable to verify memory state.")
|
||||
|
||||
line_separated_memories = "\n".join(str(memory.get("memory", "")) for memory in memories)
|
||||
if line_separated_memories:
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[Message(role="user", contents=[f"{self.context_prompt}\n{line_separated_memories}"])],
|
||||
)
|
||||
|
||||
async def after_run(
|
||||
self,
|
||||
*,
|
||||
agent: SupportsAgentRun,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Store request/response messages to Mem0 for future retrieval."""
|
||||
self._validate_filters()
|
||||
|
||||
messages_to_store: list[Message] = list(context.input_messages)
|
||||
if context.response and context.response.messages:
|
||||
messages_to_store.extend(context.response.messages)
|
||||
|
||||
def get_role_value(role: Any) -> str:
|
||||
return role.value if hasattr(role, "value") else str(role)
|
||||
|
||||
messages: list[dict[str, str]] = [
|
||||
{"role": get_role_value(message.role), "content": message.text}
|
||||
for message in messages_to_store
|
||||
if get_role_value(message.role) in {"user", "assistant", "system"} and message.text and message.text.strip()
|
||||
]
|
||||
|
||||
if messages:
|
||||
add_kwargs: dict[str, Any] = {
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
if isinstance(self.mem0_client, AsyncMemory):
|
||||
add_kwargs["user_id"] = self.user_id
|
||||
add_kwargs["agent_id"] = self.agent_id
|
||||
else:
|
||||
add_kwargs["filters"] = self._build_filters()
|
||||
|
||||
await self.mem0_client.add(**add_kwargs) # type: ignore[misc, call-arg]
|
||||
|
||||
# -- Internal methods ------------------------------------------------------
|
||||
|
||||
def _validate_filters(self) -> None:
|
||||
"""Validates that at least one usable filter is provided for the configured client."""
|
||||
if not self.agent_id and not self.user_id and not self.application_id:
|
||||
raise ValueError("At least one of the filters: agent_id, user_id, or application_id is required.")
|
||||
if isinstance(self.mem0_client, AsyncMemory) and self.application_id:
|
||||
raise ValueError(
|
||||
"application_id is not supported by the OSS AsyncMemory client, which scopes "
|
||||
"memories only by user_id/agent_id. Remove application_id or use AsyncMemoryClient."
|
||||
)
|
||||
|
||||
def _build_search_kwargs(self, input_text: str, entity_key: str, entity_value: str) -> dict[str, Any]:
|
||||
"""Build search keyword arguments formatted for OSS vs Platform clients."""
|
||||
filters: dict[str, Any] = {"query": input_text}
|
||||
|
||||
if self.application_id and isinstance(self.mem0_client, AsyncMemory):
|
||||
raise ValueError(
|
||||
"application_id is not supported by the OSS AsyncMemory client, which scopes "
|
||||
"memories only by user_id/agent_id. Remove application_id or use AsyncMemoryClient."
|
||||
)
|
||||
|
||||
filters["filters"] = {entity_key: entity_value}
|
||||
if self.application_id and not isinstance(self.mem0_client, AsyncMemory):
|
||||
filters["filters"]["app_id"] = self.application_id
|
||||
|
||||
return filters
|
||||
|
||||
def _build_filters(self) -> dict[str, Any]:
|
||||
"""Build identity filters from initialization parameters."""
|
||||
filters: dict[str, Any] = {}
|
||||
if self.user_id:
|
||||
filters["user_id"] = self.user_id
|
||||
if self.agent_id:
|
||||
filters["agent_id"] = self.agent_id
|
||||
if self.application_id:
|
||||
filters["app_id"] = self.application_id
|
||||
return filters
|
||||
|
||||
|
||||
__all__ = ["Mem0ContextProvider"]
|
||||
@@ -0,0 +1,98 @@
|
||||
[project]
|
||||
name = "agent-framework-mem0"
|
||||
description = "Mem0 integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260709"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.11.0,<2",
|
||||
"mem0ai>=2.0.0,<3",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
|
||||
]
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_mem0"]
|
||||
|
||||
[tool.mypy]
|
||||
plugins = ['pydantic.mypy']
|
||||
strict = true
|
||||
python_version = "3.10"
|
||||
ignore_missing_imports = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
check_untyped_defs = true
|
||||
warn_return_any = true
|
||||
show_error_codes = true
|
||||
warn_unused_ignores = false
|
||||
disallow_incomplete_defs = true
|
||||
disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_mem0"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_mem0"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_mem0 --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,644 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, Message
|
||||
from agent_framework._sessions import AgentSession, SessionContext
|
||||
|
||||
from agent_framework_mem0._context_provider import Mem0ContextProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mem0_client() -> AsyncMock:
|
||||
"""Create a mock Mem0 AsyncMemoryClient."""
|
||||
from mem0 import AsyncMemoryClient
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncMemoryClient)
|
||||
mock_client.add = AsyncMock()
|
||||
mock_client.search = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_oss_mem0_client() -> AsyncMock:
|
||||
"""Create a mock Mem0 OSS AsyncMemory client."""
|
||||
from mem0 import AsyncMemory
|
||||
|
||||
mock_client = AsyncMock(spec=AsyncMemory)
|
||||
mock_client.add = AsyncMock()
|
||||
mock_client.search = AsyncMock()
|
||||
return mock_client
|
||||
|
||||
|
||||
# -- Initialization tests ------------------------------------------------------
|
||||
|
||||
|
||||
class TestInit:
|
||||
"""Test Mem0ContextProvider initialization."""
|
||||
|
||||
def test_init_with_all_params(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0",
|
||||
mem0_client=mock_mem0_client,
|
||||
api_key="key-123",
|
||||
application_id="app1",
|
||||
agent_id="agent1",
|
||||
user_id="user1",
|
||||
context_prompt="Custom prompt",
|
||||
)
|
||||
assert provider.source_id == "mem0"
|
||||
assert provider.api_key == "key-123"
|
||||
assert provider.application_id == "app1"
|
||||
assert provider.agent_id == "agent1"
|
||||
assert provider.user_id == "user1"
|
||||
assert provider.context_prompt == "Custom prompt"
|
||||
assert provider.mem0_client is mock_mem0_client
|
||||
assert provider._should_close_client is False
|
||||
|
||||
def test_init_default_context_prompt(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
assert provider.context_prompt == Mem0ContextProvider.DEFAULT_CONTEXT_PROMPT
|
||||
|
||||
def test_init_auto_creates_client_when_none(self) -> None:
|
||||
"""When no client is provided, a default AsyncMemoryClient is created and flagged for closing."""
|
||||
with (
|
||||
patch("mem0.client.main.AsyncMemoryClient.__init__", return_value=None) as mock_init,
|
||||
patch("mem0.client.main.AsyncMemoryClient._validate_api_key", return_value=None),
|
||||
):
|
||||
provider = Mem0ContextProvider(source_id="mem0", api_key="test-key", user_id="u1")
|
||||
mock_init.assert_called_once_with(api_key="test-key")
|
||||
assert provider._should_close_client is True
|
||||
|
||||
def test_provided_client_not_flagged_for_close(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
assert provider._should_close_client is False
|
||||
|
||||
|
||||
# -- before_run tests ----------------------------------------------------------
|
||||
|
||||
|
||||
class TestBeforeRun:
|
||||
"""Test before_run hook."""
|
||||
|
||||
async def test_memories_added_to_context(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Mocked mem0 search returns memories → messages added to context with prompt."""
|
||||
mock_mem0_client.search.return_value = [
|
||||
{"memory": "User likes Python"},
|
||||
{"memory": "User prefers dark mode"},
|
||||
]
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
await provider.before_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
mock_mem0_client.search.assert_awaited_once()
|
||||
assert "mem0" in ctx.context_messages
|
||||
added = ctx.context_messages["mem0"]
|
||||
assert len(added) == 1
|
||||
assert "User likes Python" in added[0].text # type: ignore[operator]
|
||||
assert "User prefers dark mode" in added[0].text # type: ignore[operator]
|
||||
assert provider.context_prompt in added[0].text # type: ignore[operator]
|
||||
|
||||
async def test_empty_input_skips_search(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Empty input messages → no search performed."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=[""])], session_id="s1")
|
||||
|
||||
await provider.before_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
mock_mem0_client.search.assert_not_awaited()
|
||||
assert "mem0" not in ctx.context_messages
|
||||
|
||||
async def test_empty_search_results_no_messages(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Empty search results → no messages added."""
|
||||
mock_mem0_client.search.return_value = []
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1")
|
||||
|
||||
await provider.before_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
assert "mem0" not in ctx.context_messages
|
||||
|
||||
async def test_validates_filters_before_search(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Raises ValueError when no filters."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1")
|
||||
|
||||
with pytest.raises(ValueError, match="At least one of the filters"):
|
||||
await provider.before_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state,
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
async def test_v1_1_response_format(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Search response in v1.1 dict format with 'results' key."""
|
||||
mock_mem0_client.search.return_value = {"results": [{"memory": "remembered fact"}]}
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1")
|
||||
|
||||
await provider.before_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
added = ctx.context_messages["mem0"]
|
||||
assert "remembered fact" in added[0].text # type: ignore[operator]
|
||||
|
||||
async def test_search_query_combines_input_messages(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Multiple input messages are joined for the search query."""
|
||||
mock_mem0_client.search.return_value = []
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(
|
||||
input_messages=[
|
||||
Message(role="user", contents=["Hello"]),
|
||||
Message(role="user", contents=["World"]),
|
||||
],
|
||||
session_id="s1",
|
||||
)
|
||||
|
||||
await provider.before_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
call_kwargs = mock_mem0_client.search.call_args.kwargs
|
||||
assert call_kwargs["query"] == "Hello\nWorld"
|
||||
|
||||
async def test_oss_client_passes_filters_dict(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS AsyncMemory client should receive entity IDs in a filters dict (mem0 >=2.0)."""
|
||||
mock_oss_mem0_client.search.return_value = [{"memory": "User likes Python"}]
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
await provider.before_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
call_kwargs = mock_oss_mem0_client.search.call_args.kwargs
|
||||
assert call_kwargs["query"] == "Hello"
|
||||
assert call_kwargs["filters"] == {"user_id": "u1"}
|
||||
assert "user_id" not in call_kwargs
|
||||
|
||||
async def test_oss_client_rejects_application_id_with_user_or_agent(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS client rejects application_id even when user_id/agent_id are provided."""
|
||||
mock_oss_mem0_client.search.return_value = []
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0",
|
||||
mem0_client=mock_oss_mem0_client,
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
application_id="app1",
|
||||
)
|
||||
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.text = "hello"
|
||||
mock_context.input_messages = [mock_msg]
|
||||
mock_context.response = None
|
||||
|
||||
with pytest.raises(ValueError, match="application_id is not supported"):
|
||||
await provider.before_run(
|
||||
agent=MagicMock(), session=MagicMock(spec=AgentSession), context=mock_context, state={}
|
||||
)
|
||||
|
||||
mock_oss_mem0_client.search.assert_not_awaited()
|
||||
|
||||
async def test_oss_client_rejects_application_id_only(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS client with only application_id set raises and never searches."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, application_id="app1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
with pytest.raises(ValueError, match="application_id is not supported"):
|
||||
await provider.before_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
mock_oss_mem0_client.search.assert_not_awaited()
|
||||
|
||||
async def test_platform_client_passes_filters_dict_except_app_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Platform client passes scoping parameters concurrently inside the nested filters dictionary."""
|
||||
mock_mem0_client.search.return_value = []
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0",
|
||||
mem0_client=mock_mem0_client,
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
)
|
||||
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.text = "hello"
|
||||
mock_context.input_messages = [mock_msg]
|
||||
mock_context.response = None
|
||||
|
||||
await provider.before_run(
|
||||
agent=MagicMock(), session=MagicMock(spec=AgentSession), context=mock_context, state={}
|
||||
)
|
||||
|
||||
# Re-aligned assertion: Platform client isolates filters per call to bypass AND limitations
|
||||
assert mock_mem0_client.search.call_count == 2
|
||||
mock_mem0_client.search.assert_any_call(query="hello", filters={"user_id": "u1"})
|
||||
mock_mem0_client.search.assert_any_call(query="hello", filters={"agent_id": "a1"})
|
||||
|
||||
async def test_platform_client_keeps_app_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Platform client keeps app_id in filters for each entity-scoped partition."""
|
||||
mock_mem0_client.search.return_value = []
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_mem0_client, user_id="u1", application_id="app1"
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
await provider.before_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
mock_mem0_client.search.assert_awaited_once_with(query="Hello", filters={"user_id": "u1", "app_id": "app1"})
|
||||
|
||||
|
||||
# -- after_run tests -----------------------------------------------------------
|
||||
|
||||
|
||||
class TestAfterRun:
|
||||
"""Test after_run hook."""
|
||||
|
||||
async def test_stores_input_and_response(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Stores input+response messages to mem0 via client.add."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["question"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["answer"])])
|
||||
|
||||
await provider.after_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
mock_mem0_client.add.assert_awaited_once()
|
||||
call_kwargs = mock_mem0_client.add.call_args.kwargs
|
||||
assert call_kwargs["messages"] == [
|
||||
{"role": "user", "content": "question"},
|
||||
{"role": "assistant", "content": "answer"},
|
||||
]
|
||||
assert call_kwargs["filters"] == {"user_id": "u1"}
|
||||
assert "user_id" not in call_kwargs
|
||||
assert "agent_id" not in call_kwargs
|
||||
assert "run_id" not in call_kwargs
|
||||
|
||||
async def test_only_stores_user_assistant_system(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Only stores user/assistant/system messages with text."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(
|
||||
input_messages=[
|
||||
Message(role="user", contents=["hello"]),
|
||||
Message(role="tool", contents=["tool output"]),
|
||||
],
|
||||
session_id="s1",
|
||||
)
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["reply"])])
|
||||
|
||||
await provider.after_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
call_kwargs = mock_mem0_client.add.call_args.kwargs
|
||||
roles = [m["role"] for m in call_kwargs["messages"]]
|
||||
assert "tool" not in roles
|
||||
assert roles == ["user", "assistant"]
|
||||
|
||||
async def test_skips_empty_messages(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Skips messages with empty text."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(
|
||||
input_messages=[
|
||||
Message(role="user", contents=[""]),
|
||||
Message(role="user", contents=[" "]),
|
||||
],
|
||||
session_id="s1",
|
||||
)
|
||||
ctx._response = AgentResponse(messages=[])
|
||||
|
||||
await provider.after_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
mock_mem0_client.add.assert_not_awaited()
|
||||
|
||||
async def test_no_run_id_in_storage(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""run_id is not passed to mem0 add, so memories are not scoped to sessions."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="my-session")
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])])
|
||||
|
||||
await provider.after_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
assert "run_id" not in mock_mem0_client.add.call_args.kwargs
|
||||
assert "run_id" not in mock_mem0_client.add.call_args.kwargs["filters"]
|
||||
|
||||
async def test_validates_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Raises ValueError when no filters."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])])
|
||||
|
||||
with pytest.raises(ValueError, match="At least one of the filters"):
|
||||
await provider.after_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state,
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
async def test_platform_stores_identity_fields_in_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Platform add receives all identity fields in filters for mem0ai 2.x."""
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_mem0_client, user_id="u1", agent_id="a1", application_id="app1"
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[])
|
||||
|
||||
await provider.after_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
call_kwargs = mock_mem0_client.add.call_args.kwargs
|
||||
assert call_kwargs["filters"] == {"user_id": "u1", "agent_id": "a1", "app_id": "app1"}
|
||||
assert "user_id" not in call_kwargs
|
||||
assert "agent_id" not in call_kwargs
|
||||
|
||||
async def test_oss_stores_identity_fields_as_direct_kwargs(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS add keeps user_id/agent_id as direct kwargs because AsyncMemory.add uses that signature."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", agent_id="a1")
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[])
|
||||
|
||||
await provider.after_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
call_kwargs = mock_oss_mem0_client.add.call_args.kwargs
|
||||
assert call_kwargs["user_id"] == "u1"
|
||||
assert call_kwargs["agent_id"] == "a1"
|
||||
assert "filters" not in call_kwargs
|
||||
|
||||
async def test_oss_storage_rejects_application_id(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS storage rejects Platform-only application_id because AsyncMemory.add has no app_id parameter."""
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", application_id="app1"
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[])
|
||||
|
||||
with pytest.raises(ValueError, match="application_id is not supported"):
|
||||
await provider.after_run(
|
||||
agent=cast(Any, None),
|
||||
session=session,
|
||||
context=ctx,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
mock_oss_mem0_client.add.assert_not_awaited()
|
||||
|
||||
|
||||
# -- _validate_filters tests --------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateFilters:
|
||||
"""Test _validate_filters method."""
|
||||
|
||||
def test_raises_when_no_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
|
||||
with pytest.raises(ValueError, match="At least one of the filters"):
|
||||
provider._validate_filters()
|
||||
|
||||
def test_passes_with_user_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
provider._validate_filters() # should not raise
|
||||
|
||||
def test_passes_with_agent_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, agent_id="a1")
|
||||
provider._validate_filters()
|
||||
|
||||
def test_passes_with_application_id(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, application_id="app1")
|
||||
provider._validate_filters()
|
||||
|
||||
def test_oss_application_id_only_raises(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS client with only application_id is rejected because application scope is Platform-only."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, application_id="app1")
|
||||
with pytest.raises(ValueError, match="application_id is not supported"):
|
||||
provider._validate_filters()
|
||||
|
||||
def test_oss_application_id_with_user_id_raises(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS client rejects application_id even with a supported user scope."""
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", application_id="app1"
|
||||
)
|
||||
with pytest.raises(ValueError, match="application_id is not supported"):
|
||||
provider._validate_filters()
|
||||
|
||||
def test_oss_passes_with_user_id(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS client with user_id is accepted."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1")
|
||||
provider._validate_filters()
|
||||
|
||||
|
||||
# -- _build_search_kwargs tests -----------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildSearchKwargs:
|
||||
"""Test _build_search_kwargs method."""
|
||||
|
||||
def test_user_id_only(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
|
||||
# Pass the 3 required arguments
|
||||
result = provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
# AsyncMock triggers the Platform client nested 'filters' structure
|
||||
assert result == {"query": "test query", "filters": {"user_id": "u1"}}
|
||||
|
||||
def test_all_params(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0",
|
||||
mem0_client=mock_mem0_client,
|
||||
user_id="u1",
|
||||
agent_id="a1",
|
||||
application_id="app1",
|
||||
)
|
||||
|
||||
# Test that app_id correctly merges with the isolated target entity
|
||||
result = provider._build_search_kwargs("test query", "agent_id", "a1")
|
||||
|
||||
assert result == {
|
||||
"query": "test query",
|
||||
"filters": {
|
||||
"agent_id": "a1",
|
||||
"app_id": "app1",
|
||||
},
|
||||
}
|
||||
|
||||
def test_excludes_none_values(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
|
||||
# application_id is None by default, it should not appear in the dictionary
|
||||
result = provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
assert "app_id" not in result.get("filters", {})
|
||||
|
||||
def test_no_run_id_in_search_filters(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""run_id is excluded from search filters so memories work across sessions."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
|
||||
result = provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
assert "run_id" not in result.get("filters", {})
|
||||
assert "run_id" not in result
|
||||
|
||||
def test_oss_search_filters_reject_app_id(self, mock_oss_mem0_client: AsyncMock) -> None:
|
||||
"""OSS search filters reject application_id because app_id is only supported by Platform."""
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", application_id="app1"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="application_id is not supported"):
|
||||
provider._build_search_kwargs("test query", "user_id", "u1")
|
||||
|
||||
def test_empty_when_no_params(self, mock_mem0_client: AsyncMock) -> None:
|
||||
# Validates base query payload generation
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client)
|
||||
|
||||
result = provider._build_search_kwargs("test query", "custom_key", "custom_val")
|
||||
|
||||
assert result == {"query": "test query", "filters": {"custom_key": "custom_val"}}
|
||||
|
||||
async def test_before_run_application_only_fallback(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0", mem0_client=mock_mem0_client, application_id="app_fallback_test"
|
||||
)
|
||||
|
||||
# Mock a valid message list and session container setup
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
mock_msg.text = "Retrieve systemic fallback memory traces"
|
||||
mock_context.input_messages = [mock_msg]
|
||||
mock_context.response = None
|
||||
|
||||
mock_mem0_client.search = AsyncMock(return_value=[{"id": "m1", "memory": "System configuration template"}])
|
||||
|
||||
await provider.before_run(
|
||||
agent=MagicMock(), session=MagicMock(spec=AgentSession), context=mock_context, state={}
|
||||
)
|
||||
|
||||
# Verify that an application-scoped search task executed successfully
|
||||
mock_mem0_client.search.assert_awaited_once_with(
|
||||
query="Retrieve systemic fallback memory traces",
|
||||
filters={"app_id": "app_fallback_test"},
|
||||
)
|
||||
mock_context.extend_messages.assert_called_once()
|
||||
|
||||
|
||||
# -- Context manager tests -----------------------------------------------------
|
||||
|
||||
|
||||
class TestContextManager:
|
||||
"""Test __aenter__/__aexit__ delegation."""
|
||||
|
||||
async def test_aenter_delegates_to_client(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
result = await provider.__aenter__()
|
||||
assert result is provider
|
||||
mock_mem0_client.__aenter__.assert_awaited_once()
|
||||
|
||||
async def test_aexit_closes_auto_created_client(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Auto-created clients (_should_close_client=True) are closed on exit."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
provider._should_close_client = True
|
||||
await provider.__aexit__(None, None, None)
|
||||
mock_mem0_client.__aexit__.assert_awaited_once()
|
||||
|
||||
async def test_aexit_does_not_close_provided_client(self, mock_mem0_client: AsyncMock) -> None:
|
||||
"""Provided clients (_should_close_client=False) are NOT closed on exit."""
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
assert provider._should_close_client is False
|
||||
await provider.__aexit__(None, None, None)
|
||||
mock_mem0_client.__aexit__.assert_not_awaited()
|
||||
|
||||
async def test_async_with_syntax(self, mock_mem0_client: AsyncMock) -> None:
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_mem0_client, user_id="u1")
|
||||
async with provider as p:
|
||||
assert p is provider
|
||||
Reference in New Issue
Block a user