feat(integrations): strands-mem0 | Mem0 as a native Strands MemoryStore (#7021)
This commit is contained in:
@@ -11,8 +11,9 @@ Agent and editor integrations. Each subdirectory is self-contained: its own `pac
|
||||
| `pi-agent-plugin/` | `@mem0/pi-agent-plugin` | tsup | none | vitest |
|
||||
| `n8n-nodes-mem0/` | `@mem0/n8n-nodes-mem0` | tsc | ESLint (n8n-nodes-base) | none |
|
||||
| `zapier-mem0/` | `@mem0/zapier` | tsc | none | offline unit tests + `zapier validate` |
|
||||
| `strands-mem0/` | `strands-mem0` (PyPI) | hatch | Ruff + mypy | pytest |
|
||||
|
||||
pnpm everywhere except `.opencode-plugin/`, which uses Bun. Never npm, never yarn.
|
||||
pnpm everywhere except `.opencode-plugin/` (Bun) and `strands-mem0/` (Python: pip / hatch). Never npm, never yarn.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -42,6 +43,7 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
|
||||
- **`openclaw/`**, **`pi-agent-plugin/`** are editor and agent plugins with the same shape.
|
||||
- **`n8n-nodes-mem0/`** is an n8n community node: add, search, get, update, delete.
|
||||
- **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret).
|
||||
- **`strands-mem0/`** is a native Strands `MemoryStore` (Python, published to PyPI as `strands-mem0`). It plugs into the Strands `MemoryManager` for automatic recall and server-side extraction, over the hosted Mem0 platform or self-hosted Mem0 OSS. The package lives under `strands-mem0/python/`.
|
||||
|
||||
## Adding an integration
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
*.whl
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.hatch/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2023] [Taranjeet Singh]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,10 @@
|
||||
strands-mem0
|
||||
Copyright 2026 Mem0
|
||||
|
||||
This product includes software developed at Mem0 (https://mem0.ai).
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
@@ -0,0 +1,94 @@
|
||||
<div align="center">
|
||||
<h1>strands-mem0</h1>
|
||||
<h3>Persistent long-term memory for Strands Agents, backed by Mem0</h3>
|
||||
<p>
|
||||
A community <a href="https://strandsagents.com/">Strands Agents</a> integration that plugs
|
||||
<a href="https://mem0.ai">Mem0</a> in as a first-class <code>MemoryStore</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
`strands-mem0` gives [Strands](https://github.com/strands-agents/sdk-python) agents durable memory
|
||||
that survives across sessions, backed by [Mem0](https://mem0.ai). Where the
|
||||
`mem0_memory` tool is called explicitly by the model, `Mem0MemoryStore` plugs into the **agent loop**
|
||||
directly: the manager recalls context and injects it automatically, and writes new memories, either
|
||||
verbatim or by extracting facts from the conversation.
|
||||
|
||||
- **Automatic recall + injection** — relevant memories are searched and prepended to the prompt every turn, no tool call required.
|
||||
- **Server-side extraction** — raw conversation turns are handed to Mem0, which distills and de-duplicates facts on its own pipeline (no extra client-side model call).
|
||||
- **Hosted or self-hosted** — the managed [Mem0 Platform](https://app.mem0.ai) by default, or your own Mem0 OSS backend via a config dict.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install strands-mem0
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from strands import Agent
|
||||
from strands.memory import MemoryManager
|
||||
from strands_mem0 import Mem0MemoryStore
|
||||
|
||||
# Recall + write, distilling facts from the conversation via Mem0's server-side extraction.
|
||||
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
||||
agent = Agent(memory_manager=MemoryManager(stores=[store]))
|
||||
|
||||
# The agent now recalls from and writes to Mem0 without any explicit tool call.
|
||||
agent("Remember that I prefer dark-mode dashboards and only drink oat milk.")
|
||||
agent("How do I like my dashboards?") # recalls the stored preference
|
||||
```
|
||||
|
||||
Set `MEM0_API_KEY` for the hosted platform (get one at [app.mem0.ai](https://app.mem0.ai)), or pass
|
||||
`api_key=...`. For a self-hosted Mem0 OSS backend, pass a `config=...` dict instead.
|
||||
|
||||
## How it works
|
||||
|
||||
`Mem0MemoryStore` implements all three `MemoryStore` hooks:
|
||||
|
||||
| Method | Maps to | When it runs |
|
||||
|---|---|---|
|
||||
| `search(query)` | `mem0.search(query, filters={...})` | Every turn, to recall and inject context |
|
||||
| `add(content)` | `mem0.add(content, infer=False)` | The `add_memory` tool / a client-side extractor — stores a fact verbatim |
|
||||
| `add_messages(messages)` | `mem0.add(rendered_turns, infer=True)` | Extraction — renders conversation turns to text, then hands them to Mem0's **server-side** extraction |
|
||||
|
||||
Because `add_messages` is implemented, enabling `extraction` routes conversation turns straight to Mem0's own
|
||||
extraction pipeline. A store that only implemented `add` would instead need a client-side `ModelExtractor`
|
||||
(an extra model call) to distill facts first.
|
||||
|
||||
### Configuration
|
||||
|
||||
| Argument | Default | Description |
|
||||
|---|---|---|
|
||||
| `user_id` / `agent_id` / `run_id` / `app_id` | _(at least one required)_ | Mem0 entity scope that owns the memories |
|
||||
| `name` | `"mem0"` | Store identifier, used to target it from memory tools |
|
||||
| `writable` | `True` | Whether the manager may write to the store |
|
||||
| `extraction` | `None` | Automatic extraction (`bool` or `ExtractionConfig`) |
|
||||
| `max_search_results` | `None` | Default result cap per search (falls back to 5) |
|
||||
| `metadata` | `None` | Default metadata merged into every write |
|
||||
| `api_key` / `host` | env | Mem0 platform key / base URL (`api_key` defaults to `$MEM0_API_KEY`) |
|
||||
| `config` | `None` | Mem0 OSS config dict for a self-hosted backend |
|
||||
|
||||
## The explicit tool
|
||||
|
||||
For the model-called tool (`store` / `retrieve` / `get` / `delete`), use the
|
||||
[`mem0_memory`](https://github.com/strands-agents/tools) tool from `strands-agents-tools`. The store and
|
||||
the tool share one Mem0 backend and namespace.
|
||||
|
||||
## Development
|
||||
|
||||
The package lives under [`python/`](python/) (monorepo-style layout matching the
|
||||
[Strands extension-template](https://github.com/strands-agents/extension-template)).
|
||||
|
||||
```bash
|
||||
cd python
|
||||
pip install hatch
|
||||
hatch run test # pytest (no live server required — mocked client)
|
||||
hatch run prepare # format + lint + typecheck + test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Apache-2.0](LICENSE). Mem0 is a trademark of its respective owner. Strands Agents is a project of its respective authors.
|
||||
@@ -0,0 +1,37 @@
|
||||
# strands-mem0 (Python)
|
||||
|
||||
Persistent long-term memory for [Strands Agents](https://github.com/strands-agents/sdk-python),
|
||||
backed by [Mem0](https://mem0.ai).
|
||||
|
||||
See the [repository README](../README.md) for full usage. Quick start:
|
||||
|
||||
```bash
|
||||
pip install strands-mem0
|
||||
```
|
||||
|
||||
As a `MemoryStore` that plugs into the agent loop (Strands >= 1.45):
|
||||
|
||||
```python
|
||||
from strands import Agent
|
||||
from strands.memory import MemoryManager
|
||||
from strands_mem0 import Mem0MemoryStore
|
||||
|
||||
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
||||
agent = Agent(memory_manager=MemoryManager(stores=[store]))
|
||||
```
|
||||
|
||||
Set `MEM0_API_KEY` for the hosted platform, or pass `config=...` for a self-hosted Mem0 OSS backend.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
pip install hatch
|
||||
hatch run test # pytest (mocked client, no live server)
|
||||
hatch run prepare # format + lint + typecheck + test
|
||||
```
|
||||
|
||||
## Release
|
||||
|
||||
Publish a GitHub release tagged `strands-mem0-v*` (e.g. `strands-mem0-v0.1.0`). The
|
||||
release router (`.github/workflows/release.yml`) dispatches `strands-mem0-cd.yml`,
|
||||
which builds the wheel and publishes it to PyPI via trusted publishing (OIDC).
|
||||
@@ -0,0 +1,88 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "strands-mem0"
|
||||
version = "0.1.0"
|
||||
description = "Persistent long-term memory for Strands agents, backed by Mem0."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = "Apache-2.0"
|
||||
authors = [
|
||||
{name = "Mem0", email = "founders@mem0.ai"}
|
||||
]
|
||||
keywords = ["strands", "strands-agents", "agents", "ai", "memory", "mem0", "vector-search", "personalization"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
|
||||
# strands-agents>=1.45.0: first release shipping the `strands.memory` module
|
||||
# (MemoryStore / MemoryManager) that Mem0MemoryStore implements.
|
||||
dependencies = [
|
||||
"strands-agents>=1.45.0",
|
||||
"mem0ai>=2.0.11",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://mem0.ai"
|
||||
Documentation = "https://github.com/mem0ai/mem0/tree/main/integrations/strands-mem0#readme"
|
||||
Repository = "https://github.com/mem0ai/mem0"
|
||||
Issues = "https://github.com/mem0ai/mem0/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0,<9.0.0",
|
||||
"pytest-asyncio>=0.25.0,<1.0.0",
|
||||
"ruff>=0.11.0,<1.0.0",
|
||||
"mypy>=1.15.0,<2.0.0",
|
||||
"hatch",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/strands_mem0"]
|
||||
|
||||
[tool.hatch.envs.default]
|
||||
dependencies = [
|
||||
"pytest>=8.0.0,<9.0.0",
|
||||
"pytest-asyncio>=0.25.0,<1.0.0",
|
||||
"ruff>=0.11.0,<1.0.0",
|
||||
"mypy>=1.15.0,<2.0.0",
|
||||
]
|
||||
|
||||
[tool.hatch.envs.default.scripts]
|
||||
test = "pytest {args}"
|
||||
lint = "ruff check src tests"
|
||||
format = "ruff format src tests"
|
||||
typecheck = "mypy src"
|
||||
prepare = ["format", "lint", "typecheck", "test"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
include = ["src/**/*.py", "tests/**/*.py"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
asyncio_mode = "auto"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Strands Mem0 -- persistent long-term memory for Strands agents, backed by Mem0.
|
||||
|
||||
:class:`Mem0MemoryStore` is a Strands ``MemoryStore`` that plugs into the agent
|
||||
loop via a :class:`~strands.memory.MemoryManager`, with automatic memory injection
|
||||
and extraction. It implements both write sinks, so ``extraction`` uses Mem0's
|
||||
server-side extraction (no extra model call).
|
||||
|
||||
For the explicit, model-called tool (``store`` / ``retrieve`` / ``get`` / ``delete``),
|
||||
use the ``mem0_memory`` tool from ``strands-agents-tools``; a store and the tool can
|
||||
share one Mem0 backend and namespace.
|
||||
"""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
from strands_mem0.client import Mem0ServiceClient
|
||||
from strands_mem0.store import Mem0MemoryStore
|
||||
|
||||
__all__ = [
|
||||
"Mem0MemoryStore",
|
||||
"Mem0ServiceClient",
|
||||
]
|
||||
|
||||
try:
|
||||
__version__ = version("strands-mem0")
|
||||
except PackageNotFoundError: # pragma: no cover - only when running from a source tree
|
||||
__version__ = "0.0.0+unknown"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""A thin wrapper around the Mem0 SDK used by :class:`~strands_mem0.store.Mem0MemoryStore`.
|
||||
|
||||
Both Mem0 backends -- the hosted platform (:class:`mem0.MemoryClient`) and
|
||||
self-hosted OSS (:class:`mem0.Memory`) -- expose the same call shape to the store:
|
||||
|
||||
- **search** takes the entity scope inside a ``filters`` dict plus ``top_k``.
|
||||
- **add** takes the entity scope as top-level keyword arguments.
|
||||
|
||||
The wrapper hides the two remaining differences:
|
||||
|
||||
- ``app_id`` is a platform-only scope; OSS ``Memory.add`` has no ``app_id``
|
||||
parameter, so it is rejected up front for the OSS backend rather than surfacing
|
||||
as a ``TypeError`` mid-call.
|
||||
- the telemetry ``source`` tag is attached to platform writes only (OSS
|
||||
``Memory.add`` has a fixed signature and would reject an unknown kwarg).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
# Only the synchronous platform client is supported. ``AsyncMemoryClient``'s
|
||||
# ``add`` / ``search`` are coroutine functions, so ``asyncio.to_thread`` would hand
|
||||
# back an un-awaited coroutine and every write would silently no-op; it is rejected
|
||||
# in ``__init__`` rather than listed here.
|
||||
_PLATFORM_CLIENTS = {"MemoryClient"}
|
||||
|
||||
# Tags platform writes so Mem0's backend attributes the memory to this integration
|
||||
# in telemetry (recognized values live in the backend's KNOWN_EVENT_SOURCES
|
||||
# allowlist; unknown ones bucket into "OTHERS"). Platform only.
|
||||
_SOURCE = "STRANDS"
|
||||
|
||||
|
||||
def _is_platform_client(client: Any) -> bool:
|
||||
"""Whether ``client`` is a hosted Mem0 platform client (vs an OSS ``Memory``)."""
|
||||
return type(client).__name__ in _PLATFORM_CLIENTS
|
||||
|
||||
|
||||
def _is_async_client(client: Any) -> bool:
|
||||
"""Whether ``client``'s ``add`` / ``search`` are coroutine functions."""
|
||||
return inspect.iscoroutinefunction(getattr(client, "add", None)) or inspect.iscoroutinefunction(
|
||||
getattr(client, "search", None)
|
||||
)
|
||||
|
||||
|
||||
class Mem0ServiceClient:
|
||||
"""Thin wrapper around the Mem0 SDK for the memory store.
|
||||
|
||||
Exactly one backend is selected at construction time:
|
||||
|
||||
- ``client`` given: use it as-is (a :class:`mem0.MemoryClient` or
|
||||
:class:`mem0.Memory`); mainly for testing and advanced/OSS setups.
|
||||
- ``config`` given: build a self-hosted :class:`mem0.Memory` from it.
|
||||
- otherwise: build a hosted :class:`mem0.MemoryClient` from ``api_key`` /
|
||||
``$MEM0_API_KEY`` (and optional ``host``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
host: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
client: Any | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Mem0 client.
|
||||
|
||||
Args:
|
||||
api_key: Mem0 platform API key. Falls back to ``$MEM0_API_KEY``.
|
||||
host: Mem0 platform base URL. Defaults to the SDK default
|
||||
(``https://api.mem0.ai``).
|
||||
config: A Mem0 OSS config dict; when given, a self-hosted
|
||||
:class:`mem0.Memory` is built instead of the platform client.
|
||||
client: A pre-built Mem0 client to use directly (platform or OSS).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``client`` is an async Mem0 client (its coroutines
|
||||
would never be awaited off the worker thread).
|
||||
"""
|
||||
if client is not None:
|
||||
if _is_async_client(client):
|
||||
raise ValueError(
|
||||
"Async Mem0 clients are not supported. Pass a synchronous "
|
||||
"mem0.MemoryClient (or a mem0.Memory / config): the store runs the "
|
||||
"SDK in a worker thread, so an async client's coroutines would "
|
||||
"never be awaited and every write would silently no-op."
|
||||
)
|
||||
self.mem0 = client
|
||||
self.is_platform = _is_platform_client(client)
|
||||
return
|
||||
|
||||
if config is not None:
|
||||
try:
|
||||
from mem0 import Memory
|
||||
except ImportError as err: # pragma: no cover - exercised via install docs
|
||||
raise ImportError(
|
||||
"The mem0ai package is required. Install it with: pip install 'strands-mem0'"
|
||||
) from err
|
||||
self.mem0 = Memory.from_config(config)
|
||||
self.is_platform = False
|
||||
return
|
||||
|
||||
try:
|
||||
from mem0 import MemoryClient
|
||||
except ImportError as err: # pragma: no cover - exercised via install docs
|
||||
raise ImportError("The mem0ai package is required. Install it with: pip install 'strands-mem0'") from err
|
||||
api_key = api_key or os.environ.get("MEM0_API_KEY")
|
||||
# MemoryClient(host=None) would override the SDK default with None, so only
|
||||
# pass host when the caller actually set one.
|
||||
self.mem0 = MemoryClient(api_key=api_key, host=host) if host else MemoryClient(api_key=api_key)
|
||||
self.is_platform = True
|
||||
|
||||
def _check_scope(self, scope: dict[str, str]) -> None:
|
||||
"""Reject scope the selected backend cannot honor.
|
||||
|
||||
``app_id`` exists only on the platform; the OSS ``Memory`` API has no
|
||||
``app_id`` parameter, so we fail loudly here rather than let it surface as
|
||||
a ``TypeError`` on ``add`` or silently miss on ``search``.
|
||||
"""
|
||||
if not self.is_platform and "app_id" in scope:
|
||||
raise ValueError(
|
||||
"app_id is a Mem0 platform-only scope. The OSS backend supports "
|
||||
"user_id, agent_id, and run_id; drop app_id or use the platform client."
|
||||
)
|
||||
|
||||
def _write_extras(self) -> dict[str, str]:
|
||||
"""Extra kwargs attached to platform writes: the telemetry ``source`` tag."""
|
||||
return {"source": _SOURCE} if self.is_platform else {}
|
||||
|
||||
def store_memory(
|
||||
self,
|
||||
content: str,
|
||||
scope: dict[str, str],
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Store one discrete fact verbatim (``infer=False``).
|
||||
|
||||
Used by the store's ``add`` sink -- the content is already a distilled fact
|
||||
(from the ``add_memory`` tool or a client-side extractor), so Mem0's own
|
||||
extraction is skipped to preserve it exactly.
|
||||
"""
|
||||
self._check_scope(scope)
|
||||
return self.mem0.add(content, metadata=metadata, infer=False, **self._write_extras(), **scope)
|
||||
|
||||
def store_messages(self, messages: list[dict[str, Any]], scope: dict[str, str]) -> Any:
|
||||
"""Hand rendered conversation turns to Mem0 for server-side extraction (``infer=True``).
|
||||
|
||||
Used by the store's ``add_messages`` sink. Mem0 extracts and de-duplicates
|
||||
facts on the server, so no client-side model call is needed.
|
||||
"""
|
||||
self._check_scope(scope)
|
||||
return self.mem0.add(messages, infer=True, **self._write_extras(), **scope)
|
||||
|
||||
def search_memories(self, query: str, scope: dict[str, str], top_k: int) -> list[dict[str, Any]]:
|
||||
"""Semantic recall scoped to the store's entity.
|
||||
|
||||
Both backends take the scope inside ``filters`` and honor ``top_k``; the
|
||||
response is normalized to a plain list of memory dicts.
|
||||
"""
|
||||
self._check_scope(scope)
|
||||
response = self.mem0.search(query, filters=dict(scope), top_k=top_k)
|
||||
return _extract_results(response)
|
||||
|
||||
|
||||
def _extract_results(response: Any) -> list[dict[str, Any]]:
|
||||
"""Normalize a Mem0 search response to a list of memory dicts.
|
||||
|
||||
Mem0 returns ``{"results": [...]}`` (v1.1) or, on older paths, a bare list.
|
||||
"""
|
||||
if isinstance(response, dict):
|
||||
results = response.get("results", [])
|
||||
return list(results) if isinstance(results, list) else []
|
||||
if isinstance(response, list):
|
||||
return response
|
||||
return []
|
||||
@@ -0,0 +1,222 @@
|
||||
"""A Strands ``MemoryStore`` backed by Mem0.
|
||||
|
||||
A memory store gives a Strands agent cross-session recall: a
|
||||
:class:`~strands.memory.MemoryManager` searches it to recall facts and, when
|
||||
writable, writes new ones -- either directly or via automatic extraction from the
|
||||
conversation. Unlike the ``mem0_memory`` tool (which the model calls explicitly),
|
||||
a store plugs into the agent loop out of the box, with memory injection and
|
||||
extraction triggers handled by the manager.
|
||||
|
||||
``Mem0MemoryStore`` implements both write sinks, which is what sets it apart from a
|
||||
vector-DB-style store:
|
||||
|
||||
- :meth:`add` writes a single distilled fact verbatim (``infer=False``). This is
|
||||
the sink for the ``add_memory`` tool and for a client-side extractor.
|
||||
- :meth:`add_messages` renders raw conversation turns to text and hands them to
|
||||
Mem0 for **server-side extraction** (``infer=True``). Because this sink exists,
|
||||
enabling ``extraction`` routes messages straight to Mem0's own extraction
|
||||
pipeline -- no extra client-side model call, and Mem0's de-duplication applies.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from strands import Agent
|
||||
from strands.memory import MemoryManager
|
||||
from strands_mem0 import Mem0MemoryStore
|
||||
|
||||
# Recall + write, with Mem0 extracting facts from the conversation server-side.
|
||||
store = Mem0MemoryStore(user_id="alex", writable=True, extraction=True)
|
||||
agent = Agent(memory_manager=MemoryManager(stores=[store]))
|
||||
```
|
||||
|
||||
Configure the hosted platform via the ``api_key`` argument or the ``MEM0_API_KEY``
|
||||
environment variable, or pass a Mem0 OSS ``config`` dict for a self-hosted backend.
|
||||
``app_id`` scope is platform-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from strands.memory import AddMessagesContext, MemoryEntry, MemoryStore, SearchOptions
|
||||
from strands.types.content import Message
|
||||
|
||||
from strands_mem0.client import Mem0ServiceClient
|
||||
|
||||
DEFAULT_MAX_SEARCH_RESULTS = 5
|
||||
# Entity fields that scope a memory in Mem0. At least one must be set.
|
||||
_SCOPE_FIELDS = ("user_id", "agent_id", "run_id", "app_id")
|
||||
|
||||
|
||||
class Mem0MemoryStore(MemoryStore):
|
||||
"""A Strands :class:`~strands.memory.MemoryStore` backed by Mem0.
|
||||
|
||||
Implements :meth:`search` (semantic recall), :meth:`add` (a verbatim
|
||||
single-fact write sink) and :meth:`add_messages` (raw-message ingestion with
|
||||
Mem0 server-side extraction). Because ``add_messages`` is implemented, enabling
|
||||
``extraction`` uses Mem0's server-side extraction rather than a client-side
|
||||
model call.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
app_id: str | None = None,
|
||||
name: str = "mem0",
|
||||
description: str | None = "Persistent long-term memory backed by Mem0.",
|
||||
max_search_results: int | None = None,
|
||||
writable: bool = True,
|
||||
extraction: Any = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
api_key: str | None = None,
|
||||
host: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
client: Mem0ServiceClient | None = None,
|
||||
) -> None:
|
||||
"""Initialize the store.
|
||||
|
||||
Args:
|
||||
user_id: Mem0 user namespace that owns the memories.
|
||||
agent_id: Mem0 agent namespace.
|
||||
run_id: Mem0 run/session namespace.
|
||||
app_id: Mem0 app namespace (platform only).
|
||||
name: Unique store identifier, used to target it in tools.
|
||||
description: Human-readable description, included in tool descriptions.
|
||||
max_search_results: Default maximum results per search.
|
||||
writable: Whether the store accepts writes.
|
||||
extraction: Automatic-extraction config (``bool | ExtractionConfig``).
|
||||
metadata: Default metadata merged into every write.
|
||||
api_key: Mem0 platform API key (defaults to ``$MEM0_API_KEY``).
|
||||
host: Mem0 platform base URL.
|
||||
config: Mem0 OSS config dict for a self-hosted backend.
|
||||
client: A pre-built :class:`~strands_mem0.client.Mem0ServiceClient`
|
||||
(for testing, or to wrap your own raw Mem0 client via
|
||||
``Mem0ServiceClient(client=...)``); when omitted, one is
|
||||
constructed lazily on first use from ``api_key`` / ``config``.
|
||||
|
||||
Raises:
|
||||
ValueError: If no entity scope (``user_id`` / ``agent_id`` / ``run_id``
|
||||
/ ``app_id``) is provided.
|
||||
"""
|
||||
scope = {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"run_id": run_id,
|
||||
"app_id": app_id,
|
||||
}
|
||||
self.scope = {key: value for key, value in scope.items() if value}
|
||||
if not self.scope:
|
||||
raise ValueError("Mem0MemoryStore requires at least one of user_id, agent_id, run_id, or app_id")
|
||||
# app_id is platform-only. When a self-hosted OSS backend is requested via
|
||||
# `config`, fail at construction rather than as a TypeError on the first
|
||||
# write (OSS Memory.add has no app_id). The injected-client OSS case is
|
||||
# caught in Mem0ServiceClient, which is the only place that knows the backend.
|
||||
if "app_id" in self.scope and config is not None:
|
||||
raise ValueError(
|
||||
"app_id is a Mem0 platform-only scope and cannot be used with a self-hosted "
|
||||
"config (OSS Memory has no app_id). Drop app_id or use the platform backend."
|
||||
)
|
||||
|
||||
# MemoryStore Protocol attributes.
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.max_search_results = max_search_results
|
||||
self.writable = writable
|
||||
self.extraction = extraction
|
||||
|
||||
# Mem0-specific configuration.
|
||||
self.metadata = metadata
|
||||
|
||||
self._api_key = api_key
|
||||
self._host = host
|
||||
self._config = config
|
||||
self._client = client
|
||||
|
||||
@property
|
||||
def client(self) -> Mem0ServiceClient:
|
||||
"""The Mem0 service client, constructed lazily on first use.
|
||||
|
||||
Note: constructing the underlying SDK client can block (the platform client
|
||||
validates the API key over HTTP; the OSS client builds embedders / vector
|
||||
stores), so first use is deferred and always happens inside a worker thread
|
||||
via :func:`asyncio.to_thread`, never on the event loop.
|
||||
"""
|
||||
if self._client is None:
|
||||
self._client = Mem0ServiceClient(api_key=self._api_key, host=self._host, config=self._config)
|
||||
return self._client
|
||||
|
||||
async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]:
|
||||
"""Search Mem0 for entries matching ``query``, ordered by relevance."""
|
||||
top_k = options.get("max_search_results") if options is not None else None
|
||||
if top_k is None:
|
||||
top_k = self.max_search_results
|
||||
if top_k is None:
|
||||
top_k = DEFAULT_MAX_SEARCH_RESULTS
|
||||
|
||||
# ``self.client`` is resolved inside the thread so lazy construction (a
|
||||
# blocking call) does not run on the event loop.
|
||||
memories = await asyncio.to_thread(lambda: self.client.search_memories(query, self.scope, top_k))
|
||||
return [self._to_entry(memory) for memory in memories]
|
||||
|
||||
async def add(self, content: str, metadata: dict[str, Any] | None = None) -> Any:
|
||||
"""Write a single distilled fact to Mem0 verbatim (``infer=False``).
|
||||
|
||||
Extraction writes are at-least-once, so this tolerates duplicate content;
|
||||
Mem0 de-duplicates on the server.
|
||||
"""
|
||||
merged = self._merge_metadata(metadata)
|
||||
return await asyncio.to_thread(lambda: self.client.store_memory(content, self.scope, merged))
|
||||
|
||||
async def add_messages(self, messages: list[Message], context: AddMessagesContext | None = None) -> Any:
|
||||
"""Ingest raw conversation turns for Mem0 server-side extraction (``infer=True``).
|
||||
|
||||
A Strands ``Message.content`` is a list of content blocks (a text block is
|
||||
``{"text": "..."}``); Mem0 keeps only ``{"type": "text"}`` parts, so the raw
|
||||
blocks would be dropped. We render each turn's text blocks to a string and
|
||||
skip turns that render empty (a pure tool-use / tool-result turn), so nothing
|
||||
silently no-ops.
|
||||
"""
|
||||
payload: list[dict[str, str]] = []
|
||||
for message in messages:
|
||||
text = self._render_content(message.get("content"))
|
||||
if text:
|
||||
payload.append({"role": message["role"], "content": text})
|
||||
if not payload:
|
||||
return None
|
||||
return await asyncio.to_thread(lambda: self.client.store_messages(payload, self.scope))
|
||||
|
||||
@staticmethod
|
||||
def _render_content(content: Any) -> str:
|
||||
"""Flatten a Strands message ``content`` to plain text.
|
||||
|
||||
Accepts either a string or a list of content blocks; joins the text of
|
||||
every ``{"text": ...}`` block and ignores tool-use / image / other blocks.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(part["text"] for part in content if isinstance(part, dict) and part.get("text"))
|
||||
return ""
|
||||
|
||||
def _merge_metadata(self, metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Merge per-call metadata over the store's default metadata."""
|
||||
if self.metadata and metadata:
|
||||
return {**self.metadata, **metadata}
|
||||
return metadata or self.metadata
|
||||
|
||||
@staticmethod
|
||||
def _to_entry(memory: dict[str, Any]) -> MemoryEntry:
|
||||
"""Map a Mem0 memory dict to a Strands :class:`~strands.memory.MemoryEntry`."""
|
||||
content = memory.get("memory") or memory.get("content") or ""
|
||||
metadata: dict[str, Any] = {}
|
||||
for key in ("id", "score", "categories", "created_at", "updated_at", *_SCOPE_FIELDS):
|
||||
value = memory.get(key)
|
||||
if value is not None:
|
||||
metadata[key] = value
|
||||
extra = memory.get("metadata")
|
||||
if isinstance(extra, dict):
|
||||
metadata.update(extra)
|
||||
return MemoryEntry(content=content, metadata=metadata or None)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tests for Mem0ServiceClient: backend routing, call shapes, and response shaping.
|
||||
|
||||
The fakes mirror the *real* mem0ai signatures: a keyword-only ``search`` that
|
||||
rejects top-level entity params, and a fixed-signature OSS ``add`` with no
|
||||
``**kwargs``. So a call shape the real SDK would reject fails here too, which is
|
||||
what the earlier permissive fakes did not do.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from strands_mem0.client import Mem0ServiceClient, _extract_results, _is_platform_client
|
||||
|
||||
|
||||
class FakeMemoryClient:
|
||||
"""Stand-in for mem0.MemoryClient (platform: add/search take **kwargs)."""
|
||||
|
||||
def __init__(self):
|
||||
self.add_calls = []
|
||||
self.search_calls = []
|
||||
|
||||
def add(self, messages, **kwargs):
|
||||
self.add_calls.append((messages, kwargs))
|
||||
return {"results": [{"id": "m1"}]}
|
||||
|
||||
def search(self, query, **kwargs):
|
||||
self.search_calls.append((query, kwargs))
|
||||
return {"results": [{"id": "m1", "memory": "hi"}]}
|
||||
|
||||
|
||||
class FakeMemory:
|
||||
"""Stand-in for mem0.Memory (OSS) with the real, strict signatures.
|
||||
|
||||
``search`` is keyword-only and rejects top-level entity params; ``add`` has a
|
||||
fixed signature with no ``**kwargs`` (so ``source`` or ``app_id`` is a
|
||||
``TypeError``), exactly like the shipped SDK.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.add_calls = []
|
||||
self.search_calls = []
|
||||
|
||||
def add(
|
||||
self,
|
||||
messages,
|
||||
*,
|
||||
user_id=None,
|
||||
agent_id=None,
|
||||
run_id=None,
|
||||
metadata=None,
|
||||
infer=True,
|
||||
timestamp=None,
|
||||
expiration_date=None,
|
||||
memory_type=None,
|
||||
prompt=None,
|
||||
):
|
||||
self.add_calls.append(
|
||||
(
|
||||
messages,
|
||||
{"user_id": user_id, "agent_id": agent_id, "run_id": run_id, "metadata": metadata, "infer": infer},
|
||||
)
|
||||
)
|
||||
return {"results": []}
|
||||
|
||||
def search(self, query, *, top_k=20, filters=None, threshold=0.1, **kwargs):
|
||||
rejected = kwargs.keys() & {"user_id", "agent_id", "run_id", "app_id"}
|
||||
if rejected:
|
||||
raise ValueError(f"Top-level entity parameters {set(rejected)} are not supported in search().")
|
||||
self.search_calls.append((query, {"top_k": top_k, "filters": filters}))
|
||||
return {"results": []}
|
||||
|
||||
|
||||
class FakeAsyncMemoryClient:
|
||||
"""Stand-in for mem0.AsyncMemoryClient: coroutine add/search."""
|
||||
|
||||
async def add(self, messages, **kwargs): # pragma: no cover - never called
|
||||
return {}
|
||||
|
||||
async def search(self, query, **kwargs): # pragma: no cover - never called
|
||||
return {}
|
||||
|
||||
|
||||
def platform_client():
|
||||
"""A Mem0ServiceClient wrapping a fake platform client."""
|
||||
fake = FakeMemoryClient()
|
||||
fake.__class__.__name__ = "MemoryClient"
|
||||
return Mem0ServiceClient(client=fake), fake
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# backend detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detects_platform_by_class_name():
|
||||
assert _is_platform_client(FakeMemory()) is False
|
||||
fake = FakeMemoryClient()
|
||||
fake.__class__.__name__ = "MemoryClient"
|
||||
assert _is_platform_client(fake) is True
|
||||
|
||||
|
||||
def test_injected_client_sets_platform_flag():
|
||||
fake = FakeMemoryClient()
|
||||
fake.__class__.__name__ = "MemoryClient"
|
||||
assert Mem0ServiceClient(client=fake).is_platform is True
|
||||
assert Mem0ServiceClient(client=FakeMemory()).is_platform is False
|
||||
|
||||
|
||||
def test_async_client_is_rejected():
|
||||
"""An async Mem0 client cannot be driven from a worker thread; reject it loudly."""
|
||||
with pytest.raises(ValueError, match="Async Mem0 clients are not supported"):
|
||||
Mem0ServiceClient(client=FakeAsyncMemoryClient())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_memory_is_verbatim_and_tagged():
|
||||
"""Platform store_memory writes infer=False, scope top-level, and a source tag."""
|
||||
client, fake = platform_client()
|
||||
|
||||
client.store_memory("a fact", {"user_id": "alex"}, {"k": "v"})
|
||||
|
||||
messages, kwargs = fake.add_calls[0]
|
||||
assert messages == "a fact"
|
||||
assert kwargs["infer"] is False
|
||||
assert kwargs["user_id"] == "alex"
|
||||
assert kwargs["metadata"] == {"k": "v"}
|
||||
assert kwargs["source"] == "STRANDS"
|
||||
|
||||
|
||||
def test_store_messages_infers_and_tags():
|
||||
"""Platform store_messages hands turns to Mem0 with infer=True and a source tag."""
|
||||
client, fake = platform_client()
|
||||
|
||||
turns = [{"role": "user", "content": "hi"}]
|
||||
client.store_messages(turns, {"user_id": "alex"})
|
||||
|
||||
messages, kwargs = fake.add_calls[0]
|
||||
assert messages == turns
|
||||
assert kwargs["infer"] is True
|
||||
assert kwargs["source"] == "STRANDS"
|
||||
|
||||
|
||||
def test_oss_writes_omit_source():
|
||||
"""OSS Memory.add has no source parameter, so the tag must be platform-only.
|
||||
|
||||
(If the code passed source here, FakeMemory.add would raise TypeError.)
|
||||
"""
|
||||
fake = FakeMemory()
|
||||
client = Mem0ServiceClient(client=fake)
|
||||
|
||||
client.store_memory("a fact", {"user_id": "alex"}, None)
|
||||
client.store_messages([{"role": "user", "content": "hi"}], {"user_id": "alex"})
|
||||
|
||||
assert len(fake.add_calls) == 2
|
||||
for _, kwargs in fake.add_calls:
|
||||
assert "source" not in kwargs
|
||||
|
||||
|
||||
def test_oss_add_app_id_is_rejected():
|
||||
"""app_id is platform-only; the OSS path fails loudly rather than TypeError-ing."""
|
||||
client = Mem0ServiceClient(client=FakeMemory())
|
||||
with pytest.raises(ValueError, match="platform-only"):
|
||||
client.store_memory("f", {"user_id": "alex", "app_id": "app1"}, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search routing (filters + top_k on both backends)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_platform_search_uses_filters():
|
||||
"""Platform search passes scope inside filters with top_k, never top-level."""
|
||||
client, fake = platform_client()
|
||||
|
||||
client.search_memories("q", {"user_id": "alex"}, 5)
|
||||
|
||||
_, kwargs = fake.search_calls[0]
|
||||
assert kwargs["filters"] == {"user_id": "alex"}
|
||||
assert kwargs["top_k"] == 5
|
||||
assert "user_id" not in kwargs
|
||||
|
||||
|
||||
def test_oss_search_uses_filters():
|
||||
"""OSS search also takes filters + top_k. The strict fake would raise on the
|
||||
old top-level/limit call shape, so this is the regression test for blocker 1."""
|
||||
fake = FakeMemory()
|
||||
client = Mem0ServiceClient(client=fake)
|
||||
|
||||
client.search_memories("q", {"user_id": "alex"}, 5)
|
||||
|
||||
_, recorded = fake.search_calls[0]
|
||||
assert recorded["filters"] == {"user_id": "alex"}
|
||||
assert recorded["top_k"] == 5
|
||||
|
||||
|
||||
def test_oss_search_app_id_is_rejected():
|
||||
client = Mem0ServiceClient(client=FakeMemory())
|
||||
with pytest.raises(ValueError, match="platform-only"):
|
||||
client.search_memories("q", {"user_id": "alex", "app_id": "app1"}, 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# response normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_results_shapes():
|
||||
assert _extract_results({"results": [{"id": 1}]}) == [{"id": 1}]
|
||||
assert _extract_results([{"id": 1}]) == [{"id": 1}]
|
||||
assert _extract_results({"nope": 1}) == []
|
||||
assert _extract_results(None) == []
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Tests for the Mem0MemoryStore (Strands MemoryStore integration).
|
||||
|
||||
The store is exercised with a mocked Mem0ServiceClient, so no live Mem0 server
|
||||
(or the ``mem0ai`` SDK) is required.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from strands.memory import MemoryEntry, MemoryStore
|
||||
from strands.memory.types import _has_method, _has_write_sink
|
||||
|
||||
from strands_mem0 import Mem0MemoryStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""A mocked Mem0ServiceClient."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def make_store(mock_client, **kwargs):
|
||||
"""Build a store wired to the mocked client (default scope: user_id=alex)."""
|
||||
kwargs.setdefault("user_id", "alex")
|
||||
return Mem0MemoryStore(client=mock_client, **kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction / protocol conformance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_requires_a_scope():
|
||||
"""At least one of user_id / agent_id / run_id / app_id is mandatory."""
|
||||
with pytest.raises(ValueError, match="at least one of"):
|
||||
Mem0MemoryStore()
|
||||
|
||||
|
||||
def test_app_id_with_oss_config_rejected_at_construction():
|
||||
"""app_id is platform-only; pairing it with an OSS config fails at construction."""
|
||||
with pytest.raises(ValueError, match="platform-only"):
|
||||
Mem0MemoryStore(app_id="app1", config={"vector_store": {"provider": "qdrant"}})
|
||||
|
||||
|
||||
def test_scope_collects_only_set_fields(mock_client):
|
||||
"""Only the provided entity fields end up in the scope."""
|
||||
store = Mem0MemoryStore(client=mock_client, user_id="alex", agent_id="assistant")
|
||||
assert store.scope == {"user_id": "alex", "agent_id": "assistant"}
|
||||
|
||||
|
||||
def test_is_a_memory_store(mock_client):
|
||||
"""The store is a genuine MemoryStore subclass (MemoryStore is a
|
||||
non-runtime-checkable Protocol, so check the MRO rather than isinstance)."""
|
||||
store = make_store(mock_client)
|
||||
assert MemoryStore in type(store).__mro__
|
||||
|
||||
|
||||
def test_protocol_attributes_default(mock_client):
|
||||
"""Protocol attributes take sensible, writable-by-default values."""
|
||||
store = make_store(mock_client)
|
||||
assert store.name == "mem0"
|
||||
assert store.description is not None
|
||||
assert store.max_search_results is None
|
||||
assert store.writable is True
|
||||
assert store.extraction is None
|
||||
assert store.scope == {"user_id": "alex"}
|
||||
|
||||
|
||||
def test_protocol_attributes_override(mock_client):
|
||||
"""Config fields are honored."""
|
||||
store = make_store(
|
||||
mock_client,
|
||||
name="notes",
|
||||
description="d",
|
||||
max_search_results=3,
|
||||
writable=False,
|
||||
extraction=True,
|
||||
metadata={"team": "growth"},
|
||||
)
|
||||
assert store.name == "notes"
|
||||
assert store.max_search_results == 3
|
||||
assert store.writable is False
|
||||
assert store.extraction is True
|
||||
assert store.metadata == {"team": "growth"}
|
||||
|
||||
|
||||
def test_write_sink_detection(mock_client):
|
||||
"""Both `add` and `add_messages` are real sinks -- extraction defaults to
|
||||
Mem0's server-side path (add_messages), not a client-side ModelExtractor."""
|
||||
store = make_store(mock_client)
|
||||
assert _has_method(store, "search") is True
|
||||
assert _has_method(store, "add") is True
|
||||
assert _has_method(store, "add_messages") is True
|
||||
assert _has_method(store, "initialize") is False
|
||||
assert _has_method(store, "get_tools") is False
|
||||
assert _has_write_sink(store) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_search_maps_to_memory_entries(mock_client):
|
||||
"""Mem0 hits are mapped to MemoryEntry with metadata preserved."""
|
||||
mock_client.search_memories.return_value = [
|
||||
{
|
||||
"id": "mem-1",
|
||||
"memory": "Alex prefers dark roast",
|
||||
"score": 0.91,
|
||||
"categories": ["preferences"],
|
||||
"created_at": "2026-07-02T00:00:00Z",
|
||||
"user_id": "alex",
|
||||
"metadata": {"category": "prefs"},
|
||||
}
|
||||
]
|
||||
store = make_store(mock_client)
|
||||
|
||||
results = await store.search("coffee")
|
||||
|
||||
assert len(results) == 1
|
||||
entry = results[0]
|
||||
assert isinstance(entry, MemoryEntry)
|
||||
assert entry.content == "Alex prefers dark roast"
|
||||
assert entry.metadata["id"] == "mem-1"
|
||||
assert entry.metadata["score"] == 0.91
|
||||
assert entry.metadata["categories"] == ["preferences"]
|
||||
assert entry.metadata["category"] == "prefs"
|
||||
|
||||
|
||||
async def test_search_default_top_k(mock_client):
|
||||
"""With no options and no configured max, the default top_k is used."""
|
||||
mock_client.search_memories.return_value = []
|
||||
store = make_store(mock_client)
|
||||
|
||||
await store.search("q")
|
||||
|
||||
mock_client.search_memories.assert_called_once_with("q", {"user_id": "alex"}, 5)
|
||||
|
||||
|
||||
async def test_search_options_override_top_k(mock_client):
|
||||
"""SearchOptions.max_search_results wins over the configured default."""
|
||||
mock_client.search_memories.return_value = []
|
||||
store = make_store(mock_client, max_search_results=3)
|
||||
|
||||
await store.search("q", {"max_search_results": 10})
|
||||
|
||||
mock_client.search_memories.assert_called_once_with("q", {"user_id": "alex"}, 10)
|
||||
|
||||
|
||||
async def test_search_config_top_k(mock_client):
|
||||
"""The configured max is used when options omit it."""
|
||||
mock_client.search_memories.return_value = []
|
||||
store = make_store(mock_client, max_search_results=7)
|
||||
|
||||
await store.search("q")
|
||||
|
||||
mock_client.search_memories.assert_called_once_with("q", {"user_id": "alex"}, 7)
|
||||
|
||||
|
||||
async def test_search_handles_missing_content(mock_client):
|
||||
"""A hit without memory text maps to an empty string, not None."""
|
||||
mock_client.search_memories.return_value = [{"id": "mem-2"}]
|
||||
store = make_store(mock_client)
|
||||
|
||||
results = await store.search("q")
|
||||
|
||||
assert results[0].content == ""
|
||||
assert results[0].metadata == {"id": "mem-2"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add / add_messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_add_writes_a_verbatim_fact(mock_client):
|
||||
"""add() forwards content, scope and merged metadata to store_memory."""
|
||||
stored = {"id": "mem-9"}
|
||||
mock_client.store_memory.return_value = stored
|
||||
store = make_store(mock_client, metadata={"team": "growth"})
|
||||
|
||||
result = await store.add("new fact", {"source": "chat"})
|
||||
|
||||
assert result == stored
|
||||
mock_client.store_memory.assert_called_once_with(
|
||||
"new fact", {"user_id": "alex"}, {"team": "growth", "source": "chat"}
|
||||
)
|
||||
|
||||
|
||||
async def test_add_without_metadata_uses_store_default(mock_client):
|
||||
"""With no per-call metadata, the store's default metadata is used."""
|
||||
store = make_store(mock_client, metadata={"team": "growth"})
|
||||
|
||||
await store.add("fact")
|
||||
|
||||
mock_client.store_memory.assert_called_once_with("fact", {"user_id": "alex"}, {"team": "growth"})
|
||||
|
||||
|
||||
async def test_add_messages_renders_content_blocks(mock_client):
|
||||
"""add_messages renders Strands content blocks to text before sending.
|
||||
|
||||
Strands hands content as list[ContentBlock] (a text block is ``{"text": ...}``);
|
||||
mem0 keeps only text parts, so the store must flatten each turn to a string.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "user", "content": [{"text": "I love hiking"}]},
|
||||
{"role": "assistant", "content": [{"text": "Noted!"}]},
|
||||
]
|
||||
store = make_store(mock_client)
|
||||
|
||||
await store.add_messages(messages)
|
||||
|
||||
mock_client.store_messages.assert_called_once_with(
|
||||
[{"role": "user", "content": "I love hiking"}, {"role": "assistant", "content": "Noted!"}],
|
||||
{"user_id": "alex"},
|
||||
)
|
||||
|
||||
|
||||
async def test_add_messages_skips_empty_turns(mock_client):
|
||||
"""A turn with no text (a pure tool-use turn) renders to nothing and is not sent."""
|
||||
store = make_store(mock_client)
|
||||
|
||||
result = await store.add_messages([{"role": "assistant", "content": [{"toolUse": {"name": "x"}}]}])
|
||||
|
||||
assert result is None
|
||||
mock_client.store_messages.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lazy client construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_client_constructed_lazily(monkeypatch):
|
||||
"""No Mem0ServiceClient is built until the client property is accessed."""
|
||||
calls = {"n": 0}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, api_key=None, host=None, config=None, client=None):
|
||||
calls["n"] += 1
|
||||
self.api_key = api_key
|
||||
|
||||
monkeypatch.setattr("strands_mem0.store.Mem0ServiceClient", FakeClient)
|
||||
|
||||
store = Mem0MemoryStore(user_id="alex", api_key="m0-x")
|
||||
assert calls["n"] == 0 # not built yet
|
||||
|
||||
client = store.client
|
||||
assert calls["n"] == 1
|
||||
assert client.api_key == "m0-x"
|
||||
|
||||
# Second access reuses the same instance.
|
||||
assert store.client is client
|
||||
assert calls["n"] == 1
|
||||
Reference in New Issue
Block a user