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.
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ *.whl
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .hatch/
13
+ .DS_Store
14
+ .idea/
15
+ .vscode/
@@ -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