diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 32d548d90..b88729b60 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -23,6 +23,7 @@ Package workflows keep their own push-to-main and manual triggers. Their `pull_r | Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (`integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup on Node 20, 22 | | n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (`integrations/n8n-nodes-mem0/`), manual | ESLint + tsc build on Node 20 | | Zapier App | `zapier-mem0-checks.yml` | Push to main (`integrations/zapier-mem0/`), manual | tsc + `zapier validate` + offline unit tests on Node 22 | +| strands-mem0 | `strands-mem0-checks.yml` | Push to main (`integrations/strands-mem0/`), manual | Ruff + mypy + pytest + hatch build on Python 3.10, 3.11, 3.12 | | docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage | | GitHub Scripts | inline in `ci-gate.yml` | none | `node` over every `.github/scripts/*.test.js` | @@ -59,6 +60,7 @@ Requiring `CI Gate` also means fork PRs from first-time contributors cannot merg | OpenCode Plugin | `opencode-plugin-cd.yml` | `opencode-v*` | npm (`@mem0/opencode-plugin`) | | Pi Agent Plugin | `pi-agent-plugin-cd.yml` | `pi-agent-v*` | npm (`@mem0/pi-agent-plugin`) | | n8n Node | `n8n-nodes-mem0-cd.yml` | `n8n-nodes-mem0-v*` | npm (`@mem0/n8n-nodes-mem0`) | +| strands-mem0 | `strands-mem0-cd.yml` | `strands-mem0-v*` | PyPI (`strands-mem0`) | - Package CD workflows are `workflow_dispatch`-only, with `tag` and `prerelease` inputs. They check out and build the given tag. - All publishing uses **OIDC trusted publishing**. No tokens, no secrets. diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml index 41d0dd42b..826e2db38 100644 --- a/.github/workflows/ci-gate.yml +++ b/.github/workflows/ci-gate.yml @@ -43,6 +43,7 @@ jobs: pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }} n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }} zapier_mem0: ${{ steps.filter.outputs.zapier_mem0 }} + strands_mem0: ${{ steps.filter.outputs.strands_mem0 }} docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }} github_scripts: ${{ steps.filter.outputs.github_scripts }} steps: @@ -95,6 +96,10 @@ jobs: - 'integrations/zapier-mem0/**' - '.github/workflows/zapier-mem0-checks.yml' - '.github/workflows/ci-gate.yml' + strands_mem0: + - 'integrations/strands-mem0/**' + - '.github/workflows/strands-mem0-checks.yml' + - '.github/workflows/ci-gate.yml' docs_llms_txt: - 'docs/**/*.mdx' - 'docs/llms.txt' @@ -178,6 +183,13 @@ jobs: uses: ./.github/workflows/zapier-mem0-checks.yml secrets: inherit + strands-mem0: + name: strands-mem0 + needs: changes + if: needs.changes.outputs.strands_mem0 == 'true' + uses: ./.github/workflows/strands-mem0-checks.yml + secrets: inherit + docs-llms-txt: name: docs llms.txt needs: changes @@ -217,6 +229,7 @@ jobs: - pi-agent-plugin - n8n-nodes-mem0 - zapier-mem0 + - strands-mem0 - docs-llms-txt - github-scripts if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2d474f56..423a4542b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,7 @@ jobs: opencode-v*) workflow="opencode-plugin-cd.yml" ;; pi-agent-v*) workflow="pi-agent-plugin-cd.yml" ;; n8n-nodes-mem0-v*) workflow="n8n-nodes-mem0-cd.yml" ;; + strands-mem0-v*) workflow="strands-mem0-cd.yml" ;; v*) workflow="cd.yml" ;; *) echo "::error::Release tag '$TAG' does not match any known package prefix — nothing will be published. See the tag prefix table in AGENTS.md." diff --git a/.github/workflows/strands-mem0-cd.yml b/.github/workflows/strands-mem0-cd.yml new file mode 100644 index 000000000..ee80101a1 --- /dev/null +++ b/.github/workflows/strands-mem0-cd.yml @@ -0,0 +1,50 @@ +name: Publish strands-mem0 🐍 distribution 📦 to PyPI + +# Dispatched by release.yml (Release Router) when a release tagged +# strands-mem0-v* is published. Can also be dispatched manually to re-publish +# a tag. Publishing uses PyPI Trusted Publishing (OIDC), so no API token is +# stored; the `strands-mem0` PyPI project must have a trusted publisher +# configured for mem0ai/mem0 + this workflow. +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build and publish (e.g. strands-mem0-v0.1.0)' + required: true + type: string + prerelease: + description: 'Unused for PyPI (pre-releases are expressed in the version itself); accepted for router uniformity' + required: false + type: boolean + default: false + +jobs: + build-n-publish: + name: Build and publish strands-mem0 📦 to PyPI + if: startsWith(inputs.tag, 'strands-mem0-v') + runs-on: ubuntu-latest + permissions: + id-token: write + defaults: + run: + working-directory: integrations/strands-mem0/python + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Hatch + run: pip install hatch + + - name: Build a binary wheel and a source tarball + run: hatch build --clean + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: integrations/strands-mem0/python/dist/ diff --git a/.github/workflows/strands-mem0-checks.yml b/.github/workflows/strands-mem0-checks.yml new file mode 100644 index 000000000..2a45d80b5 --- /dev/null +++ b/.github/workflows/strands-mem0-checks.yml @@ -0,0 +1,82 @@ +name: strands-mem0 CI + +# On PRs this is invoked by ci-gate.yml (the single required check); +# push-to-main and manual runs remain standalone. +on: + workflow_dispatch: + push: + branches: [main] + paths: + - 'integrations/strands-mem0/**' + - '.github/workflows/strands-mem0-checks.yml' + workflow_call: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dev dependencies + working-directory: integrations/strands-mem0/python + run: pip install -e ".[dev]" + + - name: Lint with ruff + working-directory: integrations/strands-mem0/python + run: ruff check . + + - name: Check formatting + working-directory: integrations/strands-mem0/python + run: ruff format --check . + + - name: Type-check with mypy + working-directory: integrations/strands-mem0/python + run: mypy src + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dev dependencies + working-directory: integrations/strands-mem0/python + run: pip install -e ".[dev]" + + - name: Run tests + working-directory: integrations/strands-mem0/python + run: pytest + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Hatch + run: pip install hatch + + - name: Build + working-directory: integrations/strands-mem0/python + run: hatch build --clean + + - name: Verify dist output + run: | + ls integrations/strands-mem0/python/dist/*.whl || (echo "Wheel file missing" && exit 1) + ls integrations/strands-mem0/python/dist/*.tar.gz || (echo "Source dist missing" && exit 1) diff --git a/docs/docs.json b/docs/docs.json index 109835721..5c743cf5a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -325,7 +325,8 @@ "integrations/google-ai-adk", "integrations/mastra", "integrations/vercel-ai-sdk", - "integrations/chatdev" + "integrations/chatdev", + "integrations/strands" ] }, { diff --git a/docs/integrations/strands.mdx b/docs/integrations/strands.mdx new file mode 100644 index 000000000..51675625a --- /dev/null +++ b/docs/integrations/strands.mdx @@ -0,0 +1,69 @@ +--- +title: Strands Agents +description: "Add persistent long-term memory to AWS Strands agents with Mem0, as a native MemoryStore that plugs into the agent loop." +--- + +Integrate [**Mem0**](https://github.com/mem0ai/mem0) with [Strands Agents](https://github.com/strands-agents/sdk-python), AWS's open-source SDK for building AI agents. The [`strands-mem0`](https://github.com/mem0ai/mem0/tree/main/integrations/strands-mem0) package ships a native `MemoryStore`, so recall and writes happen automatically inside the agent loop, not as tool calls the model has to remember. + +## Overview + +1. A `MemoryStore` the `MemoryManager` drives on every turn: it searches Mem0 and injects the results into the prompt, and writes memory back when extraction is enabled. +2. Server-side extraction: because the store implements `add_messages`, enabling `extraction` routes raw conversation turns to Mem0's own extraction pipeline, with no extra client-side model call. +3. Works with the hosted Mem0 Platform (an API key) or self-hosted Mem0 OSS (a config dict). + +## Prerequisites + +Before setting up Mem0 with Strands, ensure you have: + +1. Installed the required packages: +```bash +pip install strands-mem0 +``` + +2. A valid API key: + - Mem0 API Key (set as `MEM0_API_KEY`) + +## Basic Integration Example + +Hand a `Mem0MemoryStore` to a `MemoryManager`, and the agent gets automatic recall and memory writes: + +```python +import os +from strands import Agent +from strands.memory import MemoryManager +from strands_mem0 import Mem0MemoryStore + +os.environ["MEM0_API_KEY"] = "your-mem0-api-key" + +# extraction=True routes conversation turns to Mem0's server-side extraction. +store = Mem0MemoryStore(user_id="alex", extraction=True) +agent = Agent(memory_manager=MemoryManager(stores=[store])) + +agent("Remember I use Neovim and deploy on Fridays.") # writes memory +print(agent("What editor do I use?")) # recalls it, injected automatically +``` + +Scope memories with any of `user_id`, `agent_id`, `run_id`, or `app_id` (`app_id` is platform-only). Pass `max_search_results` to bound how many memories are injected per turn. + +## Self-hosted Mem0 (OSS) + +To run against self-hosted Mem0 instead of the platform, pass a `config` dict: + +```python +store = Mem0MemoryStore( + user_id="alex", + extraction=True, + config={ + "vector_store": {"provider": "qdrant", "config": {"host": "localhost", "port": 6333}}, + }, +) +``` + +## Explicit memory tool + +If you want the model to call memory explicitly instead of (or alongside) the automatic store, use the `mem0_memory` tool from `strands-agents-tools`. A store and the tool can share the same Mem0 backend and namespace. + +## Learn more + +- [strands-mem0 on GitHub](https://github.com/mem0ai/mem0/tree/main/integrations/strands-mem0) +- [Strands Agents documentation](https://strandsagents.com) diff --git a/docs/llms.txt b/docs/llms.txt index 61f135a5d..83b2c644a 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -262,6 +262,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st - [Mastra](https://docs.mem0.ai/integrations/mastra) [Platform]: Use when the user is on Mastra (TypeScript). - [OpenClaw](https://docs.mem0.ai/integrations/openclaw) [Both]: Use when wiring Mem0 into Claude Code or editors via OpenClaw. - [Vercel AI SDK](https://docs.mem0.ai/integrations/vercel-ai-sdk) [Both]: Use when the user is on the Vercel AI SDK. +- [Strands Agents](https://docs.mem0.ai/integrations/strands) [Both]: Use when the user is on AWS Strands and wants a native MemoryStore. ### AI Coding Tools - [Claude Code](https://docs.mem0.ai/integrations/claude-code) [Both]: Use when wiring memory into Claude Code. diff --git a/integrations/AGENTS.md b/integrations/AGENTS.md index 27afc48bc..cca1ed43c 100644 --- a/integrations/AGENTS.md +++ b/integrations/AGENTS.md @@ -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 diff --git a/integrations/strands-mem0/.gitignore b/integrations/strands-mem0/.gitignore new file mode 100644 index 000000000..fbefb1c27 --- /dev/null +++ b/integrations/strands-mem0/.gitignore @@ -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/ diff --git a/integrations/strands-mem0/LICENSE b/integrations/strands-mem0/LICENSE new file mode 100644 index 000000000..d20d5102c --- /dev/null +++ b/integrations/strands-mem0/LICENSE @@ -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. diff --git a/integrations/strands-mem0/NOTICE b/integrations/strands-mem0/NOTICE new file mode 100644 index 000000000..b0bfc7889 --- /dev/null +++ b/integrations/strands-mem0/NOTICE @@ -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 diff --git a/integrations/strands-mem0/README.md b/integrations/strands-mem0/README.md new file mode 100644 index 000000000..550206852 --- /dev/null +++ b/integrations/strands-mem0/README.md @@ -0,0 +1,94 @@ +
+

strands-mem0

+

Persistent long-term memory for Strands Agents, backed by Mem0

+

+ A community Strands Agents integration that plugs + Mem0 in as a first-class MemoryStore. +

+
+ +--- + +`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. diff --git a/integrations/strands-mem0/python/README.md b/integrations/strands-mem0/python/README.md new file mode 100644 index 000000000..f9faeee22 --- /dev/null +++ b/integrations/strands-mem0/python/README.md @@ -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). diff --git a/integrations/strands-mem0/python/pyproject.toml b/integrations/strands-mem0/python/pyproject.toml new file mode 100644 index 000000000..78d2823bc --- /dev/null +++ b/integrations/strands-mem0/python/pyproject.toml @@ -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" diff --git a/integrations/strands-mem0/python/src/strands_mem0/__init__.py b/integrations/strands-mem0/python/src/strands_mem0/__init__.py new file mode 100644 index 000000000..18dea99e6 --- /dev/null +++ b/integrations/strands-mem0/python/src/strands_mem0/__init__.py @@ -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" diff --git a/integrations/strands-mem0/python/src/strands_mem0/client.py b/integrations/strands-mem0/python/src/strands_mem0/client.py new file mode 100644 index 000000000..637001b46 --- /dev/null +++ b/integrations/strands-mem0/python/src/strands_mem0/client.py @@ -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 [] diff --git a/integrations/strands-mem0/python/src/strands_mem0/py.typed b/integrations/strands-mem0/python/src/strands_mem0/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/integrations/strands-mem0/python/src/strands_mem0/store.py b/integrations/strands-mem0/python/src/strands_mem0/store.py new file mode 100644 index 000000000..c86278e2a --- /dev/null +++ b/integrations/strands-mem0/python/src/strands_mem0/store.py @@ -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) diff --git a/integrations/strands-mem0/python/tests/test_client.py b/integrations/strands-mem0/python/tests/test_client.py new file mode 100644 index 000000000..412255244 --- /dev/null +++ b/integrations/strands-mem0/python/tests/test_client.py @@ -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) == [] diff --git a/integrations/strands-mem0/python/tests/test_store.py b/integrations/strands-mem0/python/tests/test_store.py new file mode 100644 index 000000000..2018ee8c1 --- /dev/null +++ b/integrations/strands-mem0/python/tests/test_store.py @@ -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