mem0-strands 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mem0_strands-0.1.0/.gitignore +15 -0
- mem0_strands-0.1.0/PKG-INFO +67 -0
- mem0_strands-0.1.0/README.md +37 -0
- mem0_strands-0.1.0/pyproject.toml +88 -0
- mem0_strands-0.1.0/src/mem0_strands/__init__.py +26 -0
- mem0_strands-0.1.0/src/mem0_strands/client.py +176 -0
- mem0_strands-0.1.0/src/mem0_strands/py.typed +0 -0
- mem0_strands-0.1.0/src/mem0_strands/store.py +222 -0
- mem0_strands-0.1.0/tests/test_client.py +214 -0
- mem0_strands-0.1.0/tests/test_store.py +255 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mem0-strands
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Persistent long-term memory for Strands agents, backed by Mem0.
|
|
5
|
+
Project-URL: Homepage, https://mem0.ai
|
|
6
|
+
Project-URL: Documentation, https://github.com/mem0ai/mem0/tree/main/integrations/mem0-strands#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/mem0ai/mem0
|
|
8
|
+
Project-URL: Issues, https://github.com/mem0ai/mem0/issues
|
|
9
|
+
Author-email: Mem0 <founders@mem0.ai>
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: agents,ai,mem0,memory,personalization,strands,strands-agents,vector-search
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: mem0ai>=2.0.11
|
|
22
|
+
Requires-Dist: strands-agents>=1.45.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: hatch; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy<2.0.0,>=1.15.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio<1.0.0,>=0.25.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest<9.0.0,>=8.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff<1.0.0,>=0.11.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# mem0-strands (Python)
|
|
32
|
+
|
|
33
|
+
Persistent long-term memory for [Strands Agents](https://github.com/strands-agents/sdk-python),
|
|
34
|
+
backed by [Mem0](https://mem0.ai).
|
|
35
|
+
|
|
36
|
+
See the [repository README](../README.md) for full usage. Quick start:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install mem0-strands
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
As a `MemoryStore` that plugs into the agent loop (Strands >= 1.45):
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from strands import Agent
|
|
46
|
+
from strands.memory import MemoryManager
|
|
47
|
+
from mem0_strands import Mem0MemoryStore
|
|
48
|
+
|
|
49
|
+
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
|
50
|
+
agent = Agent(memory_manager=MemoryManager(stores=[store]))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Set `MEM0_API_KEY` for the hosted platform, or pass `config=...` for a self-hosted Mem0 OSS backend.
|
|
54
|
+
|
|
55
|
+
## Local development
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install hatch
|
|
59
|
+
hatch run test # pytest (mocked client, no live server)
|
|
60
|
+
hatch run prepare # format + lint + typecheck + test
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Release
|
|
64
|
+
|
|
65
|
+
Publish a GitHub release tagged `mem0-strands-v*` (e.g. `mem0-strands-v0.1.0`). The
|
|
66
|
+
release router (`.github/workflows/release.yml`) dispatches `mem0-strands-cd.yml`,
|
|
67
|
+
which builds the wheel and publishes it to PyPI via trusted publishing (OIDC).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# mem0-strands (Python)
|
|
2
|
+
|
|
3
|
+
Persistent long-term memory for [Strands Agents](https://github.com/strands-agents/sdk-python),
|
|
4
|
+
backed by [Mem0](https://mem0.ai).
|
|
5
|
+
|
|
6
|
+
See the [repository README](../README.md) for full usage. Quick start:
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install mem0-strands
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
As a `MemoryStore` that plugs into the agent loop (Strands >= 1.45):
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from strands import Agent
|
|
16
|
+
from strands.memory import MemoryManager
|
|
17
|
+
from mem0_strands import Mem0MemoryStore
|
|
18
|
+
|
|
19
|
+
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
|
20
|
+
agent = Agent(memory_manager=MemoryManager(stores=[store]))
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Set `MEM0_API_KEY` for the hosted platform, or pass `config=...` for a self-hosted Mem0 OSS backend.
|
|
24
|
+
|
|
25
|
+
## Local development
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install hatch
|
|
29
|
+
hatch run test # pytest (mocked client, no live server)
|
|
30
|
+
hatch run prepare # format + lint + typecheck + test
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Release
|
|
34
|
+
|
|
35
|
+
Publish a GitHub release tagged `mem0-strands-v*` (e.g. `mem0-strands-v0.1.0`). The
|
|
36
|
+
release router (`.github/workflows/release.yml`) dispatches `mem0-strands-cd.yml`,
|
|
37
|
+
which builds the wheel and publishes it to PyPI via trusted publishing (OIDC).
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mem0-strands"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Persistent long-term memory for Strands agents, backed by Mem0."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Mem0", email = "founders@mem0.ai"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["strands", "strands-agents", "agents", "ai", "memory", "mem0", "vector-search", "personalization"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# strands-agents>=1.45.0: first release shipping the `strands.memory` module
|
|
28
|
+
# (MemoryStore / MemoryManager) that Mem0MemoryStore implements.
|
|
29
|
+
dependencies = [
|
|
30
|
+
"strands-agents>=1.45.0",
|
|
31
|
+
"mem0ai>=2.0.11",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://mem0.ai"
|
|
36
|
+
Documentation = "https://github.com/mem0ai/mem0/tree/main/integrations/mem0-strands#readme"
|
|
37
|
+
Repository = "https://github.com/mem0ai/mem0"
|
|
38
|
+
Issues = "https://github.com/mem0ai/mem0/issues"
|
|
39
|
+
|
|
40
|
+
[project.optional-dependencies]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=8.0.0,<9.0.0",
|
|
43
|
+
"pytest-asyncio>=0.25.0,<1.0.0",
|
|
44
|
+
"ruff>=0.11.0,<1.0.0",
|
|
45
|
+
"mypy>=1.15.0,<2.0.0",
|
|
46
|
+
"hatch",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/mem0_strands"]
|
|
51
|
+
|
|
52
|
+
[tool.hatch.envs.default]
|
|
53
|
+
dependencies = [
|
|
54
|
+
"pytest>=8.0.0,<9.0.0",
|
|
55
|
+
"pytest-asyncio>=0.25.0,<1.0.0",
|
|
56
|
+
"ruff>=0.11.0,<1.0.0",
|
|
57
|
+
"mypy>=1.15.0,<2.0.0",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.hatch.envs.default.scripts]
|
|
61
|
+
test = "pytest {args}"
|
|
62
|
+
lint = "ruff check src tests"
|
|
63
|
+
format = "ruff format src tests"
|
|
64
|
+
typecheck = "mypy src"
|
|
65
|
+
prepare = ["format", "lint", "typecheck", "test"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff]
|
|
68
|
+
line-length = 120
|
|
69
|
+
include = ["src/**/*.py", "tests/**/*.py"]
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = [
|
|
73
|
+
"E", # pycodestyle
|
|
74
|
+
"F", # pyflakes
|
|
75
|
+
"I", # isort
|
|
76
|
+
"B", # flake8-bugbear
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.mypy]
|
|
80
|
+
python_version = "3.10"
|
|
81
|
+
warn_return_any = true
|
|
82
|
+
warn_unused_configs = true
|
|
83
|
+
ignore_missing_imports = true
|
|
84
|
+
|
|
85
|
+
[tool.pytest.ini_options]
|
|
86
|
+
testpaths = ["tests"]
|
|
87
|
+
pythonpath = ["src"]
|
|
88
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Strands Mem0 -- persistent long-term memory for Strands agents, backed by Mem0.
|
|
2
|
+
|
|
3
|
+
:class:`Mem0MemoryStore` is a Strands ``MemoryStore`` that plugs into the agent
|
|
4
|
+
loop via a :class:`~strands.memory.MemoryManager`, with automatic memory injection
|
|
5
|
+
and extraction. It implements both write sinks, so ``extraction`` uses Mem0's
|
|
6
|
+
server-side extraction (no extra model call).
|
|
7
|
+
|
|
8
|
+
For the explicit, model-called tool (``store`` / ``retrieve`` / ``get`` / ``delete``),
|
|
9
|
+
use the ``mem0_memory`` tool from ``strands-agents-tools``; a store and the tool can
|
|
10
|
+
share one Mem0 backend and namespace.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
|
|
15
|
+
from mem0_strands.client import Mem0ServiceClient
|
|
16
|
+
from mem0_strands.store import Mem0MemoryStore
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Mem0MemoryStore",
|
|
20
|
+
"Mem0ServiceClient",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
__version__ = version("mem0-strands")
|
|
25
|
+
except PackageNotFoundError: # pragma: no cover - only when running from a source tree
|
|
26
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""A thin wrapper around the Mem0 SDK used by :class:`~mem0_strands.store.Mem0MemoryStore`.
|
|
2
|
+
|
|
3
|
+
Both Mem0 backends -- the hosted platform (:class:`mem0.MemoryClient`) and
|
|
4
|
+
self-hosted OSS (:class:`mem0.Memory`) -- expose the same call shape to the store:
|
|
5
|
+
|
|
6
|
+
- **search** takes the entity scope inside a ``filters`` dict plus ``top_k``.
|
|
7
|
+
- **add** takes the entity scope as top-level keyword arguments.
|
|
8
|
+
|
|
9
|
+
The wrapper hides the two remaining differences:
|
|
10
|
+
|
|
11
|
+
- ``app_id`` is a platform-only scope; OSS ``Memory.add`` has no ``app_id``
|
|
12
|
+
parameter, so it is rejected up front for the OSS backend rather than surfacing
|
|
13
|
+
as a ``TypeError`` mid-call.
|
|
14
|
+
- the telemetry ``source`` tag is attached to platform writes only (OSS
|
|
15
|
+
``Memory.add`` has a fixed signature and would reject an unknown kwarg).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import inspect
|
|
21
|
+
import os
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
# Only the synchronous platform client is supported. ``AsyncMemoryClient``'s
|
|
25
|
+
# ``add`` / ``search`` are coroutine functions, so ``asyncio.to_thread`` would hand
|
|
26
|
+
# back an un-awaited coroutine and every write would silently no-op; it is rejected
|
|
27
|
+
# in ``__init__`` rather than listed here.
|
|
28
|
+
_PLATFORM_CLIENTS = {"MemoryClient"}
|
|
29
|
+
|
|
30
|
+
# Tags platform writes so Mem0's backend attributes the memory to this integration
|
|
31
|
+
# in telemetry (recognized values live in the backend's KNOWN_EVENT_SOURCES
|
|
32
|
+
# allowlist; unknown ones bucket into "OTHERS"). Platform only.
|
|
33
|
+
_SOURCE = "STRANDS"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_platform_client(client: Any) -> bool:
|
|
37
|
+
"""Whether ``client`` is a hosted Mem0 platform client (vs an OSS ``Memory``)."""
|
|
38
|
+
return type(client).__name__ in _PLATFORM_CLIENTS
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_async_client(client: Any) -> bool:
|
|
42
|
+
"""Whether ``client``'s ``add`` / ``search`` are coroutine functions."""
|
|
43
|
+
return inspect.iscoroutinefunction(getattr(client, "add", None)) or inspect.iscoroutinefunction(
|
|
44
|
+
getattr(client, "search", None)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Mem0ServiceClient:
|
|
49
|
+
"""Thin wrapper around the Mem0 SDK for the memory store.
|
|
50
|
+
|
|
51
|
+
Exactly one backend is selected at construction time:
|
|
52
|
+
|
|
53
|
+
- ``client`` given: use it as-is (a :class:`mem0.MemoryClient` or
|
|
54
|
+
:class:`mem0.Memory`); mainly for testing and advanced/OSS setups.
|
|
55
|
+
- ``config`` given: build a self-hosted :class:`mem0.Memory` from it.
|
|
56
|
+
- otherwise: build a hosted :class:`mem0.MemoryClient` from ``api_key`` /
|
|
57
|
+
``$MEM0_API_KEY`` (and optional ``host``).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
api_key: str | None = None,
|
|
63
|
+
host: str | None = None,
|
|
64
|
+
config: dict[str, Any] | None = None,
|
|
65
|
+
client: Any | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Initialize the Mem0 client.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
api_key: Mem0 platform API key. Falls back to ``$MEM0_API_KEY``.
|
|
71
|
+
host: Mem0 platform base URL. Defaults to the SDK default
|
|
72
|
+
(``https://api.mem0.ai``).
|
|
73
|
+
config: A Mem0 OSS config dict; when given, a self-hosted
|
|
74
|
+
:class:`mem0.Memory` is built instead of the platform client.
|
|
75
|
+
client: A pre-built Mem0 client to use directly (platform or OSS).
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If ``client`` is an async Mem0 client (its coroutines
|
|
79
|
+
would never be awaited off the worker thread).
|
|
80
|
+
"""
|
|
81
|
+
if client is not None:
|
|
82
|
+
if _is_async_client(client):
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"Async Mem0 clients are not supported. Pass a synchronous "
|
|
85
|
+
"mem0.MemoryClient (or a mem0.Memory / config): the store runs the "
|
|
86
|
+
"SDK in a worker thread, so an async client's coroutines would "
|
|
87
|
+
"never be awaited and every write would silently no-op."
|
|
88
|
+
)
|
|
89
|
+
self.mem0 = client
|
|
90
|
+
self.is_platform = _is_platform_client(client)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if config is not None:
|
|
94
|
+
try:
|
|
95
|
+
from mem0 import Memory
|
|
96
|
+
except ImportError as err: # pragma: no cover - exercised via install docs
|
|
97
|
+
raise ImportError(
|
|
98
|
+
"The mem0ai package is required. Install it with: pip install 'mem0-strands'"
|
|
99
|
+
) from err
|
|
100
|
+
self.mem0 = Memory.from_config(config)
|
|
101
|
+
self.is_platform = False
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
from mem0 import MemoryClient
|
|
106
|
+
except ImportError as err: # pragma: no cover - exercised via install docs
|
|
107
|
+
raise ImportError("The mem0ai package is required. Install it with: pip install 'mem0-strands'") from err
|
|
108
|
+
api_key = api_key or os.environ.get("MEM0_API_KEY")
|
|
109
|
+
# MemoryClient(host=None) would override the SDK default with None, so only
|
|
110
|
+
# pass host when the caller actually set one.
|
|
111
|
+
self.mem0 = MemoryClient(api_key=api_key, host=host) if host else MemoryClient(api_key=api_key)
|
|
112
|
+
self.is_platform = True
|
|
113
|
+
|
|
114
|
+
def _check_scope(self, scope: dict[str, str]) -> None:
|
|
115
|
+
"""Reject scope the selected backend cannot honor.
|
|
116
|
+
|
|
117
|
+
``app_id`` exists only on the platform; the OSS ``Memory`` API has no
|
|
118
|
+
``app_id`` parameter, so we fail loudly here rather than let it surface as
|
|
119
|
+
a ``TypeError`` on ``add`` or silently miss on ``search``.
|
|
120
|
+
"""
|
|
121
|
+
if not self.is_platform and "app_id" in scope:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"app_id is a Mem0 platform-only scope. The OSS backend supports "
|
|
124
|
+
"user_id, agent_id, and run_id; drop app_id or use the platform client."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _write_extras(self) -> dict[str, str]:
|
|
128
|
+
"""Extra kwargs attached to platform writes: the telemetry ``source`` tag."""
|
|
129
|
+
return {"source": _SOURCE} if self.is_platform else {}
|
|
130
|
+
|
|
131
|
+
def store_memory(
|
|
132
|
+
self,
|
|
133
|
+
content: str,
|
|
134
|
+
scope: dict[str, str],
|
|
135
|
+
metadata: dict[str, Any] | None = None,
|
|
136
|
+
) -> Any:
|
|
137
|
+
"""Store one discrete fact verbatim (``infer=False``).
|
|
138
|
+
|
|
139
|
+
Used by the store's ``add`` sink -- the content is already a distilled fact
|
|
140
|
+
(from the ``add_memory`` tool or a client-side extractor), so Mem0's own
|
|
141
|
+
extraction is skipped to preserve it exactly.
|
|
142
|
+
"""
|
|
143
|
+
self._check_scope(scope)
|
|
144
|
+
return self.mem0.add(content, metadata=metadata, infer=False, **self._write_extras(), **scope)
|
|
145
|
+
|
|
146
|
+
def store_messages(self, messages: list[dict[str, Any]], scope: dict[str, str]) -> Any:
|
|
147
|
+
"""Hand rendered conversation turns to Mem0 for server-side extraction (``infer=True``).
|
|
148
|
+
|
|
149
|
+
Used by the store's ``add_messages`` sink. Mem0 extracts and de-duplicates
|
|
150
|
+
facts on the server, so no client-side model call is needed.
|
|
151
|
+
"""
|
|
152
|
+
self._check_scope(scope)
|
|
153
|
+
return self.mem0.add(messages, infer=True, **self._write_extras(), **scope)
|
|
154
|
+
|
|
155
|
+
def search_memories(self, query: str, scope: dict[str, str], top_k: int) -> list[dict[str, Any]]:
|
|
156
|
+
"""Semantic recall scoped to the store's entity.
|
|
157
|
+
|
|
158
|
+
Both backends take the scope inside ``filters`` and honor ``top_k``; the
|
|
159
|
+
response is normalized to a plain list of memory dicts.
|
|
160
|
+
"""
|
|
161
|
+
self._check_scope(scope)
|
|
162
|
+
response = self.mem0.search(query, filters=dict(scope), top_k=top_k)
|
|
163
|
+
return _extract_results(response)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _extract_results(response: Any) -> list[dict[str, Any]]:
|
|
167
|
+
"""Normalize a Mem0 search response to a list of memory dicts.
|
|
168
|
+
|
|
169
|
+
Mem0 returns ``{"results": [...]}`` (v1.1) or, on older paths, a bare list.
|
|
170
|
+
"""
|
|
171
|
+
if isinstance(response, dict):
|
|
172
|
+
results = response.get("results", [])
|
|
173
|
+
return list(results) if isinstance(results, list) else []
|
|
174
|
+
if isinstance(response, list):
|
|
175
|
+
return response
|
|
176
|
+
return []
|
|
File without changes
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""A Strands ``MemoryStore`` backed by Mem0.
|
|
2
|
+
|
|
3
|
+
A memory store gives a Strands agent cross-session recall: a
|
|
4
|
+
:class:`~strands.memory.MemoryManager` searches it to recall facts and, when
|
|
5
|
+
writable, writes new ones -- either directly or via automatic extraction from the
|
|
6
|
+
conversation. Unlike the ``mem0_memory`` tool (which the model calls explicitly),
|
|
7
|
+
a store plugs into the agent loop out of the box, with memory injection and
|
|
8
|
+
extraction triggers handled by the manager.
|
|
9
|
+
|
|
10
|
+
``Mem0MemoryStore`` implements both write sinks, which is what sets it apart from a
|
|
11
|
+
vector-DB-style store:
|
|
12
|
+
|
|
13
|
+
- :meth:`add` writes a single distilled fact verbatim (``infer=False``). This is
|
|
14
|
+
the sink for the ``add_memory`` tool and for a client-side extractor.
|
|
15
|
+
- :meth:`add_messages` renders raw conversation turns to text and hands them to
|
|
16
|
+
Mem0 for **server-side extraction** (``infer=True``). Because this sink exists,
|
|
17
|
+
enabling ``extraction`` routes messages straight to Mem0's own extraction
|
|
18
|
+
pipeline -- no extra client-side model call, and Mem0's de-duplication applies.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
```python
|
|
22
|
+
from strands import Agent
|
|
23
|
+
from strands.memory import MemoryManager
|
|
24
|
+
from mem0_strands import Mem0MemoryStore
|
|
25
|
+
|
|
26
|
+
# Recall + write, with Mem0 extracting facts from the conversation server-side.
|
|
27
|
+
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
|
28
|
+
agent = Agent(memory_manager=MemoryManager(stores=[store]))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Configure the hosted platform via the ``api_key`` argument or the ``MEM0_API_KEY``
|
|
32
|
+
environment variable, or pass a Mem0 OSS ``config`` dict for a self-hosted backend.
|
|
33
|
+
``app_id`` scope is platform-only.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import asyncio
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from strands.memory import AddMessagesContext, MemoryEntry, MemoryStore, SearchOptions
|
|
42
|
+
from strands.types.content import Message
|
|
43
|
+
|
|
44
|
+
from mem0_strands.client import Mem0ServiceClient
|
|
45
|
+
|
|
46
|
+
DEFAULT_MAX_SEARCH_RESULTS = 5
|
|
47
|
+
# Entity fields that scope a memory in Mem0. At least one must be set.
|
|
48
|
+
_SCOPE_FIELDS = ("user_id", "agent_id", "run_id", "app_id")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Mem0MemoryStore(MemoryStore):
|
|
52
|
+
"""A Strands :class:`~strands.memory.MemoryStore` backed by Mem0.
|
|
53
|
+
|
|
54
|
+
Implements :meth:`search` (semantic recall), :meth:`add` (a verbatim
|
|
55
|
+
single-fact write sink) and :meth:`add_messages` (raw-message ingestion with
|
|
56
|
+
Mem0 server-side extraction). Because ``add_messages`` is implemented, enabling
|
|
57
|
+
``extraction`` uses Mem0's server-side extraction rather than a client-side
|
|
58
|
+
model call.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
user_id: str | None = None,
|
|
65
|
+
agent_id: str | None = None,
|
|
66
|
+
run_id: str | None = None,
|
|
67
|
+
app_id: str | None = None,
|
|
68
|
+
name: str = "mem0",
|
|
69
|
+
description: str | None = "Persistent long-term memory backed by Mem0.",
|
|
70
|
+
max_search_results: int | None = None,
|
|
71
|
+
writable: bool = True,
|
|
72
|
+
extraction: Any = None,
|
|
73
|
+
metadata: dict[str, Any] | None = None,
|
|
74
|
+
api_key: str | None = None,
|
|
75
|
+
host: str | None = None,
|
|
76
|
+
config: dict[str, Any] | None = None,
|
|
77
|
+
client: Mem0ServiceClient | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Initialize the store.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
user_id: Mem0 user namespace that owns the memories.
|
|
83
|
+
agent_id: Mem0 agent namespace.
|
|
84
|
+
run_id: Mem0 run/session namespace.
|
|
85
|
+
app_id: Mem0 app namespace (platform only).
|
|
86
|
+
name: Unique store identifier, used to target it in tools.
|
|
87
|
+
description: Human-readable description, included in tool descriptions.
|
|
88
|
+
max_search_results: Default maximum results per search.
|
|
89
|
+
writable: Whether the store accepts writes.
|
|
90
|
+
extraction: Automatic-extraction config (``bool | ExtractionConfig``).
|
|
91
|
+
metadata: Default metadata merged into every write.
|
|
92
|
+
api_key: Mem0 platform API key (defaults to ``$MEM0_API_KEY``).
|
|
93
|
+
host: Mem0 platform base URL.
|
|
94
|
+
config: Mem0 OSS config dict for a self-hosted backend.
|
|
95
|
+
client: A pre-built :class:`~mem0_strands.client.Mem0ServiceClient`
|
|
96
|
+
(for testing, or to wrap your own raw Mem0 client via
|
|
97
|
+
``Mem0ServiceClient(client=...)``); when omitted, one is
|
|
98
|
+
constructed lazily on first use from ``api_key`` / ``config``.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
ValueError: If no entity scope (``user_id`` / ``agent_id`` / ``run_id``
|
|
102
|
+
/ ``app_id``) is provided.
|
|
103
|
+
"""
|
|
104
|
+
scope = {
|
|
105
|
+
"user_id": user_id,
|
|
106
|
+
"agent_id": agent_id,
|
|
107
|
+
"run_id": run_id,
|
|
108
|
+
"app_id": app_id,
|
|
109
|
+
}
|
|
110
|
+
self.scope = {key: value for key, value in scope.items() if value}
|
|
111
|
+
if not self.scope:
|
|
112
|
+
raise ValueError("Mem0MemoryStore requires at least one of user_id, agent_id, run_id, or app_id")
|
|
113
|
+
# app_id is platform-only. When a self-hosted OSS backend is requested via
|
|
114
|
+
# `config`, fail at construction rather than as a TypeError on the first
|
|
115
|
+
# write (OSS Memory.add has no app_id). The injected-client OSS case is
|
|
116
|
+
# caught in Mem0ServiceClient, which is the only place that knows the backend.
|
|
117
|
+
if "app_id" in self.scope and config is not None:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"app_id is a Mem0 platform-only scope and cannot be used with a self-hosted "
|
|
120
|
+
"config (OSS Memory has no app_id). Drop app_id or use the platform backend."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# MemoryStore Protocol attributes.
|
|
124
|
+
self.name = name
|
|
125
|
+
self.description = description
|
|
126
|
+
self.max_search_results = max_search_results
|
|
127
|
+
self.writable = writable
|
|
128
|
+
self.extraction = extraction
|
|
129
|
+
|
|
130
|
+
# Mem0-specific configuration.
|
|
131
|
+
self.metadata = metadata
|
|
132
|
+
|
|
133
|
+
self._api_key = api_key
|
|
134
|
+
self._host = host
|
|
135
|
+
self._config = config
|
|
136
|
+
self._client = client
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def client(self) -> Mem0ServiceClient:
|
|
140
|
+
"""The Mem0 service client, constructed lazily on first use.
|
|
141
|
+
|
|
142
|
+
Note: constructing the underlying SDK client can block (the platform client
|
|
143
|
+
validates the API key over HTTP; the OSS client builds embedders / vector
|
|
144
|
+
stores), so first use is deferred and always happens inside a worker thread
|
|
145
|
+
via :func:`asyncio.to_thread`, never on the event loop.
|
|
146
|
+
"""
|
|
147
|
+
if self._client is None:
|
|
148
|
+
self._client = Mem0ServiceClient(api_key=self._api_key, host=self._host, config=self._config)
|
|
149
|
+
return self._client
|
|
150
|
+
|
|
151
|
+
async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]:
|
|
152
|
+
"""Search Mem0 for entries matching ``query``, ordered by relevance."""
|
|
153
|
+
top_k = options.get("max_search_results") if options is not None else None
|
|
154
|
+
if top_k is None:
|
|
155
|
+
top_k = self.max_search_results
|
|
156
|
+
if top_k is None:
|
|
157
|
+
top_k = DEFAULT_MAX_SEARCH_RESULTS
|
|
158
|
+
|
|
159
|
+
# ``self.client`` is resolved inside the thread so lazy construction (a
|
|
160
|
+
# blocking call) does not run on the event loop.
|
|
161
|
+
memories = await asyncio.to_thread(lambda: self.client.search_memories(query, self.scope, top_k))
|
|
162
|
+
return [self._to_entry(memory) for memory in memories]
|
|
163
|
+
|
|
164
|
+
async def add(self, content: str, metadata: dict[str, Any] | None = None) -> Any:
|
|
165
|
+
"""Write a single distilled fact to Mem0 verbatim (``infer=False``).
|
|
166
|
+
|
|
167
|
+
Extraction writes are at-least-once, so this tolerates duplicate content;
|
|
168
|
+
Mem0 de-duplicates on the server.
|
|
169
|
+
"""
|
|
170
|
+
merged = self._merge_metadata(metadata)
|
|
171
|
+
return await asyncio.to_thread(lambda: self.client.store_memory(content, self.scope, merged))
|
|
172
|
+
|
|
173
|
+
async def add_messages(self, messages: list[Message], context: AddMessagesContext | None = None) -> Any:
|
|
174
|
+
"""Ingest raw conversation turns for Mem0 server-side extraction (``infer=True``).
|
|
175
|
+
|
|
176
|
+
A Strands ``Message.content`` is a list of content blocks (a text block is
|
|
177
|
+
``{"text": "..."}``); Mem0 keeps only ``{"type": "text"}`` parts, so the raw
|
|
178
|
+
blocks would be dropped. We render each turn's text blocks to a string and
|
|
179
|
+
skip turns that render empty (a pure tool-use / tool-result turn), so nothing
|
|
180
|
+
silently no-ops.
|
|
181
|
+
"""
|
|
182
|
+
payload: list[dict[str, str]] = []
|
|
183
|
+
for message in messages:
|
|
184
|
+
text = self._render_content(message.get("content"))
|
|
185
|
+
if text:
|
|
186
|
+
payload.append({"role": message["role"], "content": text})
|
|
187
|
+
if not payload:
|
|
188
|
+
return None
|
|
189
|
+
return await asyncio.to_thread(lambda: self.client.store_messages(payload, self.scope))
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _render_content(content: Any) -> str:
|
|
193
|
+
"""Flatten a Strands message ``content`` to plain text.
|
|
194
|
+
|
|
195
|
+
Accepts either a string or a list of content blocks; joins the text of
|
|
196
|
+
every ``{"text": ...}`` block and ignores tool-use / image / other blocks.
|
|
197
|
+
"""
|
|
198
|
+
if isinstance(content, str):
|
|
199
|
+
return content
|
|
200
|
+
if isinstance(content, list):
|
|
201
|
+
return "\n".join(part["text"] for part in content if isinstance(part, dict) and part.get("text"))
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
def _merge_metadata(self, metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
205
|
+
"""Merge per-call metadata over the store's default metadata."""
|
|
206
|
+
if self.metadata and metadata:
|
|
207
|
+
return {**self.metadata, **metadata}
|
|
208
|
+
return metadata or self.metadata
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _to_entry(memory: dict[str, Any]) -> MemoryEntry:
|
|
212
|
+
"""Map a Mem0 memory dict to a Strands :class:`~strands.memory.MemoryEntry`."""
|
|
213
|
+
content = memory.get("memory") or memory.get("content") or ""
|
|
214
|
+
metadata: dict[str, Any] = {}
|
|
215
|
+
for key in ("id", "score", "categories", "created_at", "updated_at", *_SCOPE_FIELDS):
|
|
216
|
+
value = memory.get(key)
|
|
217
|
+
if value is not None:
|
|
218
|
+
metadata[key] = value
|
|
219
|
+
extra = memory.get("metadata")
|
|
220
|
+
if isinstance(extra, dict):
|
|
221
|
+
metadata.update(extra)
|
|
222
|
+
return MemoryEntry(content=content, metadata=metadata or None)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Tests for Mem0ServiceClient: backend routing, call shapes, and response shaping.
|
|
2
|
+
|
|
3
|
+
The fakes mirror the *real* mem0ai signatures: a keyword-only ``search`` that
|
|
4
|
+
rejects top-level entity params, and a fixed-signature OSS ``add`` with no
|
|
5
|
+
``**kwargs``. So a call shape the real SDK would reject fails here too, which is
|
|
6
|
+
what the earlier permissive fakes did not do.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from mem0_strands.client import Mem0ServiceClient, _extract_results, _is_platform_client
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeMemoryClient:
|
|
15
|
+
"""Stand-in for mem0.MemoryClient (platform: add/search take **kwargs)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.add_calls = []
|
|
19
|
+
self.search_calls = []
|
|
20
|
+
|
|
21
|
+
def add(self, messages, **kwargs):
|
|
22
|
+
self.add_calls.append((messages, kwargs))
|
|
23
|
+
return {"results": [{"id": "m1"}]}
|
|
24
|
+
|
|
25
|
+
def search(self, query, **kwargs):
|
|
26
|
+
self.search_calls.append((query, kwargs))
|
|
27
|
+
return {"results": [{"id": "m1", "memory": "hi"}]}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeMemory:
|
|
31
|
+
"""Stand-in for mem0.Memory (OSS) with the real, strict signatures.
|
|
32
|
+
|
|
33
|
+
``search`` is keyword-only and rejects top-level entity params; ``add`` has a
|
|
34
|
+
fixed signature with no ``**kwargs`` (so ``source`` or ``app_id`` is a
|
|
35
|
+
``TypeError``), exactly like the shipped SDK.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.add_calls = []
|
|
40
|
+
self.search_calls = []
|
|
41
|
+
|
|
42
|
+
def add(
|
|
43
|
+
self,
|
|
44
|
+
messages,
|
|
45
|
+
*,
|
|
46
|
+
user_id=None,
|
|
47
|
+
agent_id=None,
|
|
48
|
+
run_id=None,
|
|
49
|
+
metadata=None,
|
|
50
|
+
infer=True,
|
|
51
|
+
timestamp=None,
|
|
52
|
+
expiration_date=None,
|
|
53
|
+
memory_type=None,
|
|
54
|
+
prompt=None,
|
|
55
|
+
):
|
|
56
|
+
self.add_calls.append(
|
|
57
|
+
(
|
|
58
|
+
messages,
|
|
59
|
+
{"user_id": user_id, "agent_id": agent_id, "run_id": run_id, "metadata": metadata, "infer": infer},
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
return {"results": []}
|
|
63
|
+
|
|
64
|
+
def search(self, query, *, top_k=20, filters=None, threshold=0.1, **kwargs):
|
|
65
|
+
rejected = kwargs.keys() & {"user_id", "agent_id", "run_id", "app_id"}
|
|
66
|
+
if rejected:
|
|
67
|
+
raise ValueError(f"Top-level entity parameters {set(rejected)} are not supported in search().")
|
|
68
|
+
self.search_calls.append((query, {"top_k": top_k, "filters": filters}))
|
|
69
|
+
return {"results": []}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class FakeAsyncMemoryClient:
|
|
73
|
+
"""Stand-in for mem0.AsyncMemoryClient: coroutine add/search."""
|
|
74
|
+
|
|
75
|
+
async def add(self, messages, **kwargs): # pragma: no cover - never called
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
async def search(self, query, **kwargs): # pragma: no cover - never called
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def platform_client():
|
|
83
|
+
"""A Mem0ServiceClient wrapping a fake platform client."""
|
|
84
|
+
fake = FakeMemoryClient()
|
|
85
|
+
fake.__class__.__name__ = "MemoryClient"
|
|
86
|
+
return Mem0ServiceClient(client=fake), fake
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# backend detection
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_detects_platform_by_class_name():
|
|
95
|
+
assert _is_platform_client(FakeMemory()) is False
|
|
96
|
+
fake = FakeMemoryClient()
|
|
97
|
+
fake.__class__.__name__ = "MemoryClient"
|
|
98
|
+
assert _is_platform_client(fake) is True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_injected_client_sets_platform_flag():
|
|
102
|
+
fake = FakeMemoryClient()
|
|
103
|
+
fake.__class__.__name__ = "MemoryClient"
|
|
104
|
+
assert Mem0ServiceClient(client=fake).is_platform is True
|
|
105
|
+
assert Mem0ServiceClient(client=FakeMemory()).is_platform is False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_async_client_is_rejected():
|
|
109
|
+
"""An async Mem0 client cannot be driven from a worker thread; reject it loudly."""
|
|
110
|
+
with pytest.raises(ValueError, match="Async Mem0 clients are not supported"):
|
|
111
|
+
Mem0ServiceClient(client=FakeAsyncMemoryClient())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# write routing
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_store_memory_is_verbatim_and_tagged():
|
|
120
|
+
"""Platform store_memory writes infer=False, scope top-level, and a source tag."""
|
|
121
|
+
client, fake = platform_client()
|
|
122
|
+
|
|
123
|
+
client.store_memory("a fact", {"user_id": "alex"}, {"k": "v"})
|
|
124
|
+
|
|
125
|
+
messages, kwargs = fake.add_calls[0]
|
|
126
|
+
assert messages == "a fact"
|
|
127
|
+
assert kwargs["infer"] is False
|
|
128
|
+
assert kwargs["user_id"] == "alex"
|
|
129
|
+
assert kwargs["metadata"] == {"k": "v"}
|
|
130
|
+
assert kwargs["source"] == "STRANDS"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_store_messages_infers_and_tags():
|
|
134
|
+
"""Platform store_messages hands turns to Mem0 with infer=True and a source tag."""
|
|
135
|
+
client, fake = platform_client()
|
|
136
|
+
|
|
137
|
+
turns = [{"role": "user", "content": "hi"}]
|
|
138
|
+
client.store_messages(turns, {"user_id": "alex"})
|
|
139
|
+
|
|
140
|
+
messages, kwargs = fake.add_calls[0]
|
|
141
|
+
assert messages == turns
|
|
142
|
+
assert kwargs["infer"] is True
|
|
143
|
+
assert kwargs["source"] == "STRANDS"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_oss_writes_omit_source():
|
|
147
|
+
"""OSS Memory.add has no source parameter, so the tag must be platform-only.
|
|
148
|
+
|
|
149
|
+
(If the code passed source here, FakeMemory.add would raise TypeError.)
|
|
150
|
+
"""
|
|
151
|
+
fake = FakeMemory()
|
|
152
|
+
client = Mem0ServiceClient(client=fake)
|
|
153
|
+
|
|
154
|
+
client.store_memory("a fact", {"user_id": "alex"}, None)
|
|
155
|
+
client.store_messages([{"role": "user", "content": "hi"}], {"user_id": "alex"})
|
|
156
|
+
|
|
157
|
+
assert len(fake.add_calls) == 2
|
|
158
|
+
for _, kwargs in fake.add_calls:
|
|
159
|
+
assert "source" not in kwargs
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_oss_add_app_id_is_rejected():
|
|
163
|
+
"""app_id is platform-only; the OSS path fails loudly rather than TypeError-ing."""
|
|
164
|
+
client = Mem0ServiceClient(client=FakeMemory())
|
|
165
|
+
with pytest.raises(ValueError, match="platform-only"):
|
|
166
|
+
client.store_memory("f", {"user_id": "alex", "app_id": "app1"}, None)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# search routing (filters + top_k on both backends)
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_platform_search_uses_filters():
|
|
175
|
+
"""Platform search passes scope inside filters with top_k, never top-level."""
|
|
176
|
+
client, fake = platform_client()
|
|
177
|
+
|
|
178
|
+
client.search_memories("q", {"user_id": "alex"}, 5)
|
|
179
|
+
|
|
180
|
+
_, kwargs = fake.search_calls[0]
|
|
181
|
+
assert kwargs["filters"] == {"user_id": "alex"}
|
|
182
|
+
assert kwargs["top_k"] == 5
|
|
183
|
+
assert "user_id" not in kwargs
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_oss_search_uses_filters():
|
|
187
|
+
"""OSS search also takes filters + top_k. The strict fake would raise on the
|
|
188
|
+
old top-level/limit call shape, so this is the regression test for blocker 1."""
|
|
189
|
+
fake = FakeMemory()
|
|
190
|
+
client = Mem0ServiceClient(client=fake)
|
|
191
|
+
|
|
192
|
+
client.search_memories("q", {"user_id": "alex"}, 5)
|
|
193
|
+
|
|
194
|
+
_, recorded = fake.search_calls[0]
|
|
195
|
+
assert recorded["filters"] == {"user_id": "alex"}
|
|
196
|
+
assert recorded["top_k"] == 5
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_oss_search_app_id_is_rejected():
|
|
200
|
+
client = Mem0ServiceClient(client=FakeMemory())
|
|
201
|
+
with pytest.raises(ValueError, match="platform-only"):
|
|
202
|
+
client.search_memories("q", {"user_id": "alex", "app_id": "app1"}, 5)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# response normalization
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_extract_results_shapes():
|
|
211
|
+
assert _extract_results({"results": [{"id": 1}]}) == [{"id": 1}]
|
|
212
|
+
assert _extract_results([{"id": 1}]) == [{"id": 1}]
|
|
213
|
+
assert _extract_results({"nope": 1}) == []
|
|
214
|
+
assert _extract_results(None) == []
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Tests for the Mem0MemoryStore (Strands MemoryStore integration).
|
|
2
|
+
|
|
3
|
+
The store is exercised with a mocked Mem0ServiceClient, so no live Mem0 server
|
|
4
|
+
(or the ``mem0ai`` SDK) is required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from unittest.mock import MagicMock
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from strands.memory import MemoryEntry, MemoryStore
|
|
11
|
+
from strands.memory.types import _has_method, _has_write_sink
|
|
12
|
+
|
|
13
|
+
from mem0_strands import Mem0MemoryStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def mock_client():
|
|
18
|
+
"""A mocked Mem0ServiceClient."""
|
|
19
|
+
return MagicMock()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_store(mock_client, **kwargs):
|
|
23
|
+
"""Build a store wired to the mocked client (default scope: user_id=alex)."""
|
|
24
|
+
kwargs.setdefault("user_id", "alex")
|
|
25
|
+
return Mem0MemoryStore(client=mock_client, **kwargs)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Construction / protocol conformance
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_requires_a_scope():
|
|
34
|
+
"""At least one of user_id / agent_id / run_id / app_id is mandatory."""
|
|
35
|
+
with pytest.raises(ValueError, match="at least one of"):
|
|
36
|
+
Mem0MemoryStore()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_app_id_with_oss_config_rejected_at_construction():
|
|
40
|
+
"""app_id is platform-only; pairing it with an OSS config fails at construction."""
|
|
41
|
+
with pytest.raises(ValueError, match="platform-only"):
|
|
42
|
+
Mem0MemoryStore(app_id="app1", config={"vector_store": {"provider": "qdrant"}})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_scope_collects_only_set_fields(mock_client):
|
|
46
|
+
"""Only the provided entity fields end up in the scope."""
|
|
47
|
+
store = Mem0MemoryStore(client=mock_client, user_id="alex", agent_id="assistant")
|
|
48
|
+
assert store.scope == {"user_id": "alex", "agent_id": "assistant"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_is_a_memory_store(mock_client):
|
|
52
|
+
"""The store is a genuine MemoryStore subclass (MemoryStore is a
|
|
53
|
+
non-runtime-checkable Protocol, so check the MRO rather than isinstance)."""
|
|
54
|
+
store = make_store(mock_client)
|
|
55
|
+
assert MemoryStore in type(store).__mro__
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_protocol_attributes_default(mock_client):
|
|
59
|
+
"""Protocol attributes take sensible, writable-by-default values."""
|
|
60
|
+
store = make_store(mock_client)
|
|
61
|
+
assert store.name == "mem0"
|
|
62
|
+
assert store.description is not None
|
|
63
|
+
assert store.max_search_results is None
|
|
64
|
+
assert store.writable is True
|
|
65
|
+
assert store.extraction is None
|
|
66
|
+
assert store.scope == {"user_id": "alex"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_protocol_attributes_override(mock_client):
|
|
70
|
+
"""Config fields are honored."""
|
|
71
|
+
store = make_store(
|
|
72
|
+
mock_client,
|
|
73
|
+
name="notes",
|
|
74
|
+
description="d",
|
|
75
|
+
max_search_results=3,
|
|
76
|
+
writable=False,
|
|
77
|
+
extraction=True,
|
|
78
|
+
metadata={"team": "growth"},
|
|
79
|
+
)
|
|
80
|
+
assert store.name == "notes"
|
|
81
|
+
assert store.max_search_results == 3
|
|
82
|
+
assert store.writable is False
|
|
83
|
+
assert store.extraction is True
|
|
84
|
+
assert store.metadata == {"team": "growth"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_write_sink_detection(mock_client):
|
|
88
|
+
"""Both `add` and `add_messages` are real sinks -- extraction defaults to
|
|
89
|
+
Mem0's server-side path (add_messages), not a client-side ModelExtractor."""
|
|
90
|
+
store = make_store(mock_client)
|
|
91
|
+
assert _has_method(store, "search") is True
|
|
92
|
+
assert _has_method(store, "add") is True
|
|
93
|
+
assert _has_method(store, "add_messages") is True
|
|
94
|
+
assert _has_method(store, "initialize") is False
|
|
95
|
+
assert _has_method(store, "get_tools") is False
|
|
96
|
+
assert _has_write_sink(store) is True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# search
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_search_maps_to_memory_entries(mock_client):
|
|
105
|
+
"""Mem0 hits are mapped to MemoryEntry with metadata preserved."""
|
|
106
|
+
mock_client.search_memories.return_value = [
|
|
107
|
+
{
|
|
108
|
+
"id": "mem-1",
|
|
109
|
+
"memory": "Alex prefers dark roast",
|
|
110
|
+
"score": 0.91,
|
|
111
|
+
"categories": ["preferences"],
|
|
112
|
+
"created_at": "2026-07-02T00:00:00Z",
|
|
113
|
+
"user_id": "alex",
|
|
114
|
+
"metadata": {"category": "prefs"},
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
store = make_store(mock_client)
|
|
118
|
+
|
|
119
|
+
results = await store.search("coffee")
|
|
120
|
+
|
|
121
|
+
assert len(results) == 1
|
|
122
|
+
entry = results[0]
|
|
123
|
+
assert isinstance(entry, MemoryEntry)
|
|
124
|
+
assert entry.content == "Alex prefers dark roast"
|
|
125
|
+
assert entry.metadata["id"] == "mem-1"
|
|
126
|
+
assert entry.metadata["score"] == 0.91
|
|
127
|
+
assert entry.metadata["categories"] == ["preferences"]
|
|
128
|
+
assert entry.metadata["category"] == "prefs"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def test_search_default_top_k(mock_client):
|
|
132
|
+
"""With no options and no configured max, the default top_k is used."""
|
|
133
|
+
mock_client.search_memories.return_value = []
|
|
134
|
+
store = make_store(mock_client)
|
|
135
|
+
|
|
136
|
+
await store.search("q")
|
|
137
|
+
|
|
138
|
+
mock_client.search_memories.assert_called_once_with("q", {"user_id": "alex"}, 5)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def test_search_options_override_top_k(mock_client):
|
|
142
|
+
"""SearchOptions.max_search_results wins over the configured default."""
|
|
143
|
+
mock_client.search_memories.return_value = []
|
|
144
|
+
store = make_store(mock_client, max_search_results=3)
|
|
145
|
+
|
|
146
|
+
await store.search("q", {"max_search_results": 10})
|
|
147
|
+
|
|
148
|
+
mock_client.search_memories.assert_called_once_with("q", {"user_id": "alex"}, 10)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def test_search_config_top_k(mock_client):
|
|
152
|
+
"""The configured max is used when options omit it."""
|
|
153
|
+
mock_client.search_memories.return_value = []
|
|
154
|
+
store = make_store(mock_client, max_search_results=7)
|
|
155
|
+
|
|
156
|
+
await store.search("q")
|
|
157
|
+
|
|
158
|
+
mock_client.search_memories.assert_called_once_with("q", {"user_id": "alex"}, 7)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def test_search_handles_missing_content(mock_client):
|
|
162
|
+
"""A hit without memory text maps to an empty string, not None."""
|
|
163
|
+
mock_client.search_memories.return_value = [{"id": "mem-2"}]
|
|
164
|
+
store = make_store(mock_client)
|
|
165
|
+
|
|
166
|
+
results = await store.search("q")
|
|
167
|
+
|
|
168
|
+
assert results[0].content == ""
|
|
169
|
+
assert results[0].metadata == {"id": "mem-2"}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# add / add_messages
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def test_add_writes_a_verbatim_fact(mock_client):
|
|
178
|
+
"""add() forwards content, scope and merged metadata to store_memory."""
|
|
179
|
+
stored = {"id": "mem-9"}
|
|
180
|
+
mock_client.store_memory.return_value = stored
|
|
181
|
+
store = make_store(mock_client, metadata={"team": "growth"})
|
|
182
|
+
|
|
183
|
+
result = await store.add("new fact", {"source": "chat"})
|
|
184
|
+
|
|
185
|
+
assert result == stored
|
|
186
|
+
mock_client.store_memory.assert_called_once_with(
|
|
187
|
+
"new fact", {"user_id": "alex"}, {"team": "growth", "source": "chat"}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def test_add_without_metadata_uses_store_default(mock_client):
|
|
192
|
+
"""With no per-call metadata, the store's default metadata is used."""
|
|
193
|
+
store = make_store(mock_client, metadata={"team": "growth"})
|
|
194
|
+
|
|
195
|
+
await store.add("fact")
|
|
196
|
+
|
|
197
|
+
mock_client.store_memory.assert_called_once_with("fact", {"user_id": "alex"}, {"team": "growth"})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def test_add_messages_renders_content_blocks(mock_client):
|
|
201
|
+
"""add_messages renders Strands content blocks to text before sending.
|
|
202
|
+
|
|
203
|
+
Strands hands content as list[ContentBlock] (a text block is ``{"text": ...}``);
|
|
204
|
+
mem0 keeps only text parts, so the store must flatten each turn to a string.
|
|
205
|
+
"""
|
|
206
|
+
messages = [
|
|
207
|
+
{"role": "user", "content": [{"text": "I love hiking"}]},
|
|
208
|
+
{"role": "assistant", "content": [{"text": "Noted!"}]},
|
|
209
|
+
]
|
|
210
|
+
store = make_store(mock_client)
|
|
211
|
+
|
|
212
|
+
await store.add_messages(messages)
|
|
213
|
+
|
|
214
|
+
mock_client.store_messages.assert_called_once_with(
|
|
215
|
+
[{"role": "user", "content": "I love hiking"}, {"role": "assistant", "content": "Noted!"}],
|
|
216
|
+
{"user_id": "alex"},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def test_add_messages_skips_empty_turns(mock_client):
|
|
221
|
+
"""A turn with no text (a pure tool-use turn) renders to nothing and is not sent."""
|
|
222
|
+
store = make_store(mock_client)
|
|
223
|
+
|
|
224
|
+
result = await store.add_messages([{"role": "assistant", "content": [{"toolUse": {"name": "x"}}]}])
|
|
225
|
+
|
|
226
|
+
assert result is None
|
|
227
|
+
mock_client.store_messages.assert_not_called()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# lazy client construction
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_client_constructed_lazily(monkeypatch):
|
|
236
|
+
"""No Mem0ServiceClient is built until the client property is accessed."""
|
|
237
|
+
calls = {"n": 0}
|
|
238
|
+
|
|
239
|
+
class FakeClient:
|
|
240
|
+
def __init__(self, api_key=None, host=None, config=None, client=None):
|
|
241
|
+
calls["n"] += 1
|
|
242
|
+
self.api_key = api_key
|
|
243
|
+
|
|
244
|
+
monkeypatch.setattr("mem0_strands.store.Mem0ServiceClient", FakeClient)
|
|
245
|
+
|
|
246
|
+
store = Mem0MemoryStore(user_id="alex", api_key="m0-x")
|
|
247
|
+
assert calls["n"] == 0 # not built yet
|
|
248
|
+
|
|
249
|
+
client = store.client
|
|
250
|
+
assert calls["n"] == 1
|
|
251
|
+
assert client.api_key == "m0-x"
|
|
252
|
+
|
|
253
|
+
# Second access reuses the same instance.
|
|
254
|
+
assert store.client is client
|
|
255
|
+
assert calls["n"] == 1
|