Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09494c2acc |
@@ -1,55 +1,41 @@
|
||||
name: Bug Report
|
||||
description: Report a bug in mem0
|
||||
labels: ["bug"]
|
||||
name: 🐛 Bug Report
|
||||
description: Create a report to help us reproduce and fix the bug
|
||||
|
||||
body:
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
description: Which part of mem0 is affected?
|
||||
options:
|
||||
- Core / Python SDK
|
||||
- TypeScript SDK
|
||||
- Vector Store (Qdrant, PGVector, Redis, Chroma, etc.)
|
||||
- Graph Memory (Neo4j, Memgraph, etc.)
|
||||
- Ollama / Local Models
|
||||
- OpenClaw
|
||||
- REST API
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
#### Before submitting a bug, please make sure the issue hasn't been already addressed by searching through [the existing and past issues](https://github.com/embedchain/embedchain/issues?q=is%3Aissue+sort%3Acreated-desc+).
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 🐛 Describe the bug
|
||||
description: |
|
||||
Please provide a clear and concise description of what the bug is.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
value: |
|
||||
### Summary
|
||||
If relevant, add a minimal example so that we can reproduce the error by running the code. It is very important for the snippet to be as succinct (minimal) as possible, so please take time to trim down any irrelevant code to help us debug efficiently. We are going to copy-paste your code and we expect to get the same result as you did: avoid any external data, and include the relevant imports, etc. For example:
|
||||
|
||||
A clear summary of the bug.
|
||||
```python
|
||||
# All necessary imports at the beginning
|
||||
import embedchain as ec
|
||||
# Your code goes here
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
```python
|
||||
from mem0 import Memory
|
||||
```
|
||||
|
||||
m = Memory()
|
||||
# Your code here...
|
||||
```
|
||||
Please also paste or describe the results you observe instead of the expected results. If you observe an error, please paste the error message including the **full** traceback of the exception. It may be relevant to wrap error messages in ```` ```triple quotes blocks``` ````.
|
||||
placeholder: |
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
### Expected Behavior
|
||||
```python
|
||||
Sample code to reproduce the problem
|
||||
```
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
What actually happened. Paste the full error traceback if applicable.
|
||||
|
||||
### Environment
|
||||
|
||||
- mem0 version:
|
||||
- Python/Node version:
|
||||
- OS:
|
||||
validations:
|
||||
required: true
|
||||
```
|
||||
The error message you got, with the full traceback.
|
||||
````
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
Thanks for contributing 🎉!
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
- name: 1-on-1 Session
|
||||
url: https://cal.com/taranjeetio/ec
|
||||
about: Speak directly with Taranjeet, the founder, to discuss issues, share feedback, or explore improvements for Embedchain
|
||||
- name: Discord
|
||||
url: https://discord.gg/6PzXDgEjG5
|
||||
about: Ask questions and discuss with the community
|
||||
- name: Documentation
|
||||
url: https://docs.mem0.ai
|
||||
about: Read the official mem0 documentation
|
||||
about: General community discussions
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
name: Documentation Issue
|
||||
description: Report an issue or suggest an improvement to the mem0 docs
|
||||
labels: ["documentation"]
|
||||
name: Documentation
|
||||
description: Report an issue related to the Embedchain docs.
|
||||
title: "DOC: <Please write a comprehensive title after the 'DOC: ' prefix>"
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
value: |
|
||||
### Page
|
||||
|
||||
Link to the docs page: https://docs.mem0.ai/...
|
||||
|
||||
### What's Wrong or Missing
|
||||
|
||||
Describe what's incorrect, unclear, or missing.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
How should the docs be improved?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Issue with current documentation:"
|
||||
description: >
|
||||
Please make sure to leave a reference to the document/code you're
|
||||
referring to.
|
||||
|
||||
@@ -1,41 +1,23 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement for mem0
|
||||
labels: ["enhancement"]
|
||||
name: 🚀 Feature request
|
||||
description: Submit a proposal/request for a new Embedchain feature
|
||||
|
||||
body:
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
description: Which part of mem0 does this relate to?
|
||||
options:
|
||||
- Core / Python SDK
|
||||
- TypeScript SDK
|
||||
- Vector Store (Qdrant, PGVector, Redis, Chroma, etc.)
|
||||
- Graph Memory (Neo4j, Memgraph, etc.)
|
||||
- Ollama / Local Models
|
||||
- OpenClaw
|
||||
- REST API
|
||||
- Benchmarks / Evals
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
value: |
|
||||
### Use Case
|
||||
|
||||
What problem are you trying to solve?
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
How should this work? Include API examples or pseudocode if helpful.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
Any workarounds you've tried or other approaches considered.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-request
|
||||
attributes:
|
||||
label: 🚀 The feature
|
||||
description: >
|
||||
A clear and concise description of the feature proposal
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Motivation, pitch
|
||||
description: >
|
||||
Please outline the motivation for the proposal. Is your feature request related to a specific problem? e.g., *"I'm working on X and would like Y to be possible"*. If this is related to another GitHub issue, please link here too.
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
Thanks for contributing 🎉!
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
## Linked Issue
|
||||
|
||||
Closes #<!-- issue number -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- What does this PR do? Why is it needed? -->
|
||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
||||
|
||||
## Type of Change
|
||||
Fixes # (issue)
|
||||
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] Refactor (no functional changes)
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Refactor (does not change functionality, e.g. code style improvements, linting)
|
||||
- [ ] Documentation update
|
||||
|
||||
## Breaking Changes
|
||||
## How Has This Been Tested?
|
||||
|
||||
<!-- If this is a breaking change, describe what breaks and the migration path. Delete this section if not applicable. -->
|
||||
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
|
||||
|
||||
N/A
|
||||
Please delete options that are not relevant.
|
||||
|
||||
## Test Coverage
|
||||
- [ ] Unit Test
|
||||
- [ ] Test Script (please provide)
|
||||
|
||||
- [ ] I added/updated unit tests
|
||||
- [ ] I added/updated integration tests
|
||||
- [ ] I tested manually (describe below)
|
||||
- [ ] No tests needed (explain why)
|
||||
## Checklist:
|
||||
|
||||
<!-- Describe how you tested this, or link to CI results. -->
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published in downstream modules
|
||||
- [ ] I have checked my code and corrected any misspellings
|
||||
|
||||
## Checklist
|
||||
## Maintainer Checklist
|
||||
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have added tests that prove my fix/feature works
|
||||
- [ ] New and existing tests pass locally
|
||||
- [ ] I have updated documentation if needed
|
||||
- [ ] closes #xxxx (Replace xxxx with the GitHub issue number)
|
||||
- [ ] Made sure Checks passed
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Maps dropdown selections to GitHub labels
|
||||
# Used by the advanced-issue-labeler GitHub Action
|
||||
|
||||
component:
|
||||
- label: "sdk-python"
|
||||
matcher: "Core / Python SDK"
|
||||
- label: "sdk-typescript"
|
||||
matcher: "TypeScript SDK"
|
||||
- label: "vector-store"
|
||||
matcher: "Vector Store"
|
||||
- label: "graph-memory"
|
||||
matcher: "Graph Memory"
|
||||
- label: "ollama"
|
||||
matcher: "Ollama"
|
||||
- label: "openclaw"
|
||||
matcher: "OpenClaw"
|
||||
- label: "rest-api"
|
||||
matcher: "REST API"
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Auto-label issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: stefanbuck/github-issue-parser@v3
|
||||
id: issue-parser
|
||||
with:
|
||||
template-path: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
|
||||
- uses: redhat-plumbers-in-action/advanced-issue-labeler@v3
|
||||
with:
|
||||
issue-form: ${{ steps.issue-parser.outputs.jsonString }}
|
||||
section: component
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-path: .github/advanced-issue-labeler.yml
|
||||
|
||||
- uses: stefanbuck/github-issue-parser@v3
|
||||
id: feature-parser
|
||||
if: contains(github.event.issue.labels.*.name, 'enhancement')
|
||||
with:
|
||||
template-path: .github/ISSUE_TEMPLATE/feature_request.yml
|
||||
|
||||
- uses: redhat-plumbers-in-action/advanced-issue-labeler@v3
|
||||
if: contains(github.event.issue.labels.*.name, 'enhancement')
|
||||
with:
|
||||
issue-form: ${{ steps.feature-parser.outputs.jsonString }}
|
||||
section: component
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-path: .github/advanced-issue-labeler.yml
|
||||
@@ -1,48 +0,0 @@
|
||||
name: Close stale issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
# Issue settings
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 14
|
||||
stale-issue-label: 'stale'
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not
|
||||
had any activity in 90 days. It will be closed in 14 days if no
|
||||
further activity occurs. If this is still relevant, please leave a
|
||||
comment or remove the `stale` label.
|
||||
close-issue-message: >
|
||||
This issue has been closed due to inactivity. If this is still
|
||||
relevant, feel free to reopen it or create a new issue.
|
||||
|
||||
# PR settings — mark stale but never auto-close
|
||||
days-before-pr-stale: 90
|
||||
days-before-pr-close: -1
|
||||
stale-pr-label: 'stale'
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale because it
|
||||
has not had any activity in 90 days. Please update your branch and
|
||||
address any review comments, or it may be closed in the future.
|
||||
|
||||
# Exempt these labels from stale processing
|
||||
exempt-issue-labels: 'P0-critical,P1-high,good first issue,security'
|
||||
exempt-pr-labels: 'P0-critical,P1-high'
|
||||
|
||||
# Remove stale label when there is new activity
|
||||
remove-stale-when-updated: true
|
||||
|
||||
# Process up to 100 issues per run to stay within API limits
|
||||
operations-per-run: 100
|
||||
@@ -15,6 +15,8 @@
|
||||
<a href="https://mem0.dev/DiG">Join Discord</a>
|
||||
·
|
||||
<a href="https://mem0.dev/demo">Demo</a>
|
||||
·
|
||||
<a href="https://mem0.dev/openmemory">OpenMemory</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -17,10 +17,8 @@ config = {
|
||||
"workspace_url": "https://your-workspace.databricks.com",
|
||||
"access_token": "your-access-token",
|
||||
"endpoint_name": "your-vector-search-endpoint",
|
||||
"catalog": "your_catalog",
|
||||
"schema": "your_schema",
|
||||
"table_name": "your_table",
|
||||
"collection_name": "your_index_name",
|
||||
"index_name": "catalog.schema.index_name",
|
||||
"source_table_name": "catalog.schema.source_table",
|
||||
"embedding_dimension": 1536
|
||||
}
|
||||
}
|
||||
@@ -44,22 +42,17 @@ Here are the parameters available for configuring Databricks Vector Search:
|
||||
| --- | --- | --- |
|
||||
| `workspace_url` | The URL of your Databricks workspace | **Required** |
|
||||
| `access_token` | Personal Access Token for authentication | `None` |
|
||||
| `client_id` | Service principal client ID (alternative to access_token) | `None` |
|
||||
| `client_secret` | Service principal client secret (required with client_id) | `None` |
|
||||
| `azure_client_id` | Azure AD application client ID (for Azure Databricks) | `None` |
|
||||
| `azure_client_secret` | Azure AD application client secret (for Azure Databricks) | `None` |
|
||||
| `service_principal_client_id` | Service principal client ID (alternative to access_token) | `None` |
|
||||
| `service_principal_client_secret` | Service principal client secret (required with client_id) | `None` |
|
||||
| `endpoint_name` | Name of the Vector Search endpoint | **Required** |
|
||||
| `catalog` | Unity Catalog catalog name | **Required** |
|
||||
| `schema` | Unity Catalog schema name | **Required** |
|
||||
| `table_name` | Source Delta table name | **Required** |
|
||||
| `collection_name` | Vector search index name | `mem0` |
|
||||
| `index_type` | Index type: `DELTA_SYNC` or `DIRECT_ACCESS` | `DELTA_SYNC` |
|
||||
| `embedding_model_endpoint_name` | Databricks serving endpoint for embeddings | `None` |
|
||||
| `index_name` | Name of the vector index (Unity Catalog format: catalog.schema.index) | **Required** |
|
||||
| `source_table_name` | Name of the source Delta table (Unity Catalog format: catalog.schema.table) | **Required** |
|
||||
| `embedding_dimension` | Dimension of self-managed embeddings | `1536` |
|
||||
| `embedding_source_column` | Column name for text when using Databricks-computed embeddings | `None` |
|
||||
| `embedding_model_endpoint_name` | Databricks serving endpoint for embeddings | `None` |
|
||||
| `embedding_vector_column` | Column name for self-managed embedding vectors | `embedding` |
|
||||
| `endpoint_type` | Type of endpoint (`STANDARD` or `STORAGE_OPTIMIZED`) | `STANDARD` |
|
||||
| `pipeline_type` | Sync pipeline type: `TRIGGERED` or `CONTINUOUS` | `TRIGGERED` |
|
||||
| `warehouse_name` | Databricks SQL warehouse name (if using SQL warehouse) | `None` |
|
||||
| `query_type` | Query type: `ANN` or `HYBRID` | `ANN` |
|
||||
| `sync_computed_embeddings` | Whether to sync computed embeddings automatically | `True` |
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -72,13 +65,11 @@ config = {
|
||||
"provider": "databricks",
|
||||
"config": {
|
||||
"workspace_url": "https://your-workspace.databricks.com",
|
||||
"client_id": "your-service-principal-id",
|
||||
"client_secret": "your-service-principal-secret",
|
||||
"service_principal_client_id": "your-service-principal-id",
|
||||
"service_principal_client_secret": "your-service-principal-secret",
|
||||
"endpoint_name": "your-endpoint",
|
||||
"catalog": "your_catalog",
|
||||
"schema": "your_schema",
|
||||
"table_name": "your_table",
|
||||
"collection_name": "your_index_name",
|
||||
"index_name": "catalog.schema.index_name",
|
||||
"source_table_name": "catalog.schema.source_table"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,10 +84,8 @@ config = {
|
||||
"workspace_url": "https://your-workspace.databricks.com",
|
||||
"access_token": "your-personal-access-token",
|
||||
"endpoint_name": "your-endpoint",
|
||||
"catalog": "your_catalog",
|
||||
"schema": "your_schema",
|
||||
"table_name": "your_table",
|
||||
"collection_name": "your_index_name",
|
||||
"index_name": "catalog.schema.index_name",
|
||||
"source_table_name": "catalog.schema.source_table"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +103,7 @@ config = {
|
||||
"config": {
|
||||
# ... authentication config ...
|
||||
"embedding_dimension": 768, # Match your embedding model
|
||||
"embedding_vector_column": "embedding"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +118,7 @@ config = {
|
||||
"provider": "databricks",
|
||||
"config": {
|
||||
# ... authentication config ...
|
||||
"embedding_source_column": "text",
|
||||
"embedding_model_endpoint_name": "e5-small-v2"
|
||||
}
|
||||
}
|
||||
@@ -136,8 +127,8 @@ config = {
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Index Types**: This implementation supports both `DELTA_SYNC` (auto-syncs with source Delta table) and `DIRECT_ACCESS` (manage vectors directly) index types.
|
||||
- **Unity Catalog**: The source table and index are created under the specified `catalog.schema` namespace.
|
||||
- **Delta Sync Index**: This implementation uses Delta Sync Index, which automatically syncs with your source Delta table. Direct vector insertion/deletion/update operations will log warnings as they're not supported with Delta Sync.
|
||||
- **Unity Catalog**: Both the source table and index must be in Unity Catalog format (`catalog.schema.table_name`).
|
||||
- **Endpoint Auto-Creation**: If the specified endpoint doesn't exist, it will be created automatically.
|
||||
- **Index Auto-Creation**: If the specified index doesn't exist, it will be created automatically with the provided configuration.
|
||||
- **Filter Support**: Supports filtering by metadata fields, with different syntax for STANDARD vs STORAGE_OPTIMIZED endpoints.
|
||||
|
||||
@@ -52,7 +52,7 @@ Here are the parameters available for configuring FAISS:
|
||||
| Parameter | Description | Default Value |
|
||||
| --- | --- | --- |
|
||||
| `collection_name` | The name of the collection | `mem0` |
|
||||
| `path` | Path to store FAISS index and metadata | `~/.local/share/mem0/faiss/<collection_name>` |
|
||||
| `path` | Path to store FAISS index and metadata | `/tmp/faiss/<collection_name>` |
|
||||
| `distance_strategy` | Distance metric strategy to use (options: 'euclidean', 'inner_product', 'cosine') | `euclidean` |
|
||||
| `normalize_L2` | Whether to normalize L2 vectors (only applicable for euclidean distance) | `False` |
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const config = {
|
||||
password: '123',
|
||||
host: '127.0.0.1',
|
||||
port: 5432,
|
||||
dbname: 'vector_store', // Optional; TypeScript OSS defaults to `vector_store` when omitted
|
||||
dbname: 'vector_store', // Optional, defaults to 'postgres'
|
||||
diskann: false, // Optional, requires pgvectorscale extension
|
||||
hnsw: false, // Optional, for HNSW indexing
|
||||
},
|
||||
@@ -85,8 +85,6 @@ Here are the parameters available for configuring pgvector:
|
||||
| `connection_string` | PostgreSQL connection string (overrides individual connection parameters) | `None` |
|
||||
| `connection_pool` | psycopg2 connection pool object (overrides connection string and individual parameters) | `None` |
|
||||
|
||||
**Note (TypeScript OSS):** If you omit `dbname`, the TypeScript client uses the database name `vector_store`. Python defaults to `postgres` for `dbname`, as in the table above.
|
||||
|
||||
**Note**: The connection parameters have the following priority:
|
||||
1. `connection_pool` (highest priority)
|
||||
2. `connection_string`
|
||||
|
||||
@@ -73,7 +73,7 @@ Let's see the available parameters for the `qdrant` config:
|
||||
| `client` | Custom client for qdrant | `None` |
|
||||
| `host` | The host where the qdrant server is running | `None` |
|
||||
| `port` | The port where the qdrant server is running | `None` |
|
||||
| `path` | Path for the qdrant database | `~/.local/share/mem0/qdrant` |
|
||||
| `path` | Path for the qdrant database | `/tmp/qdrant` |
|
||||
| `url` | Full URL for the qdrant server | `None` |
|
||||
| `api_key` | API key for the qdrant server | `None` |
|
||||
| `on_disk` | For enabling persistent storage | `False` |
|
||||
@@ -85,7 +85,7 @@ Let's see the available parameters for the `qdrant` config:
|
||||
| `embeddingModelDims` | Dimensions of the embedding model | `1536` |
|
||||
| `host` | The host where the Qdrant server is running | `None` |
|
||||
| `port` | The port where the Qdrant server is running | `None` |
|
||||
| `path` | Path for the Qdrant database | `~/.local/share/mem0/qdrant` |
|
||||
| `path` | Path for the Qdrant database | `/tmp/qdrant` |
|
||||
| `url` | Full URL for the Qdrant server | `None` |
|
||||
| `apiKey` | API key for the Qdrant server | `None` |
|
||||
| `onDisk` | For enabling persistent storage | `False` |
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
title: "Turbopuffer"
|
||||
description: "Use Turbopuffer as a serverless vector database in Mem0 for low-latency search at scale with native metadata filtering."
|
||||
---
|
||||
[Turbopuffer](https://turbopuffer.com) is a serverless vector database optimized for low-latency search at scale. It offers cost-effective vector storage with native metadata filtering.
|
||||
|
||||
### Usage
|
||||
|
||||
```python
|
||||
import os
|
||||
from mem0 import Memory
|
||||
|
||||
os.environ["OPENAI_API_KEY"] = "sk-xx"
|
||||
os.environ["TURBOPUFFER_API_KEY"] = "tpuf_xxxxxxxxxxxx"
|
||||
|
||||
config = {
|
||||
"vector_store": {
|
||||
"provider": "turbopuffer",
|
||||
"config": {
|
||||
"collection_name": "movie_preferences",
|
||||
"embedding_model_dims": 1536,
|
||||
"region": "gcp-us-central1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m = Memory.from_config(config)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"},
|
||||
{"role": "assistant", "content": "How about thriller movies? They can be quite engaging."},
|
||||
{"role": "user", "content": "I'm not a big fan of thrillers but I love sci-fi."},
|
||||
{"role": "assistant", "content": "Got it! I'll suggest sci-fi movies instead."}
|
||||
]
|
||||
|
||||
m.add(messages, user_id="alice", metadata={"category": "movies"})
|
||||
|
||||
# Search memories
|
||||
results = m.search(query="sci-fi recommendations", user_id="alice")
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
Here are the parameters available for configuring Turbopuffer:
|
||||
|
||||
| Parameter | Description | Default Value |
|
||||
| --- | --- | --- |
|
||||
| `collection_name` | Name of the namespace/collection | `mem0` |
|
||||
| `embedding_model_dims` | Dimensions of the embedding model (must match your chosen embedding model) | `1536` |
|
||||
| `api_key` | Turbopuffer API key | Environment variable: `TURBOPUFFER_API_KEY` |
|
||||
| `region` | Turbopuffer region | `gcp-us-central1` |
|
||||
| `distance_metric` | Distance metric for vector similarity (`cosine_distance` or `euclidean_squared`) | `cosine_distance` |
|
||||
| `batch_size` | Batch size for bulk operations | `100` |
|
||||
| `extra_params` | Additional parameters for the Turbopuffer client | `None` |
|
||||
|
||||
### Regions
|
||||
|
||||
| Region | Location |
|
||||
| --- | --- |
|
||||
| `gcp-us-central1` | Iowa, USA (Default) |
|
||||
| `aws-us-west-2` | Oregon, USA |
|
||||
|
||||
### Config Example
|
||||
|
||||
```python
|
||||
config = {
|
||||
"vector_store": {
|
||||
"provider": "turbopuffer",
|
||||
"config": {
|
||||
"collection_name": "my_memories",
|
||||
"embedding_model_dims": 1536,
|
||||
"api_key": "tpuf_xxxxxxxxxxxx",
|
||||
"region": "aws-us-west-2",
|
||||
"distance_metric": "cosine_distance",
|
||||
"batch_size": 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -33,7 +33,6 @@ See the list of supported vector databases below.
|
||||
<Card title="LangChain" href="/components/vectordbs/dbs/langchain"></Card>
|
||||
<Card title="Amazon S3 Vectors" href="/components/vectordbs/dbs/s3_vectors"></Card>
|
||||
<Card title="Databricks" href="/components/vectordbs/dbs/databricks"></Card>
|
||||
<Card title="Turbopuffer" href="/components/vectordbs/dbs/turbopuffer"></Card>
|
||||
</CardGroup>
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -3,7 +3,7 @@ title: "Gemini 3 with Mem0 MCP"
|
||||
description: "Create snappy, smart, memory-aware agents by pairing Gemini 3 with Mem0 MCP server."
|
||||
---
|
||||
|
||||
Gemini 3, when paired with Mem0's cloud MCP server, works in synergy to create snappy, smart, memory-aware agents.
|
||||
Gemini 3, when paired with mem0-mcp-server, works in synergy to create snappy, smart, memory-aware agents.
|
||||
|
||||
<Callout type="info" icon="sparkles" color="#8B5CF6">
|
||||
This is the primary example of MCP integration - the same patterns work with Claude Desktop, Cursor, or any MCP-compatible client.
|
||||
@@ -27,22 +27,10 @@ The Mem0 MCP server provides these tools to Gemini:
|
||||
|
||||
## Setup
|
||||
|
||||
### Configure Mem0 MCP
|
||||
|
||||
Add Mem0 MCP to your MCP client:
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
|
||||
```
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
pip install pydantic-ai nest-asyncio python-dotenv google-genai
|
||||
pip install pydantic-ai nest-asyncio python-dotenv uv google-genai
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
@@ -52,6 +40,7 @@ Create a file named `.env`:
|
||||
```bash
|
||||
MEM0_API_KEY=m0-xxxxxxxxxxxxxxxxx
|
||||
GEMINI_API_KEY=your-gemini-api-key-here
|
||||
MEM0_DEFAULT_USER_ID=demo-user
|
||||
```
|
||||
|
||||
<Note>
|
||||
@@ -71,7 +60,7 @@ import asyncio
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.mcp import MCPServerHTTP
|
||||
from pydantic_ai.mcp import MCPServerStdio
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
@@ -86,9 +75,11 @@ class MemoryAgent:
|
||||
|
||||
def _setup(self):
|
||||
"""Initialize the agent with MCP tools"""
|
||||
# Connect to Mem0's cloud MCP server
|
||||
self.server = MCPServerHTTP(
|
||||
url="https://mcp.mem0.ai/mcp"
|
||||
# Create MCP server directly
|
||||
self.server = MCPServerStdio(
|
||||
command="uvx",
|
||||
args=["mem0-mcp-server"],
|
||||
env=os.environ
|
||||
)
|
||||
|
||||
# Create agent with Gemini and memory tools
|
||||
|
||||
+15
-16
@@ -40,7 +40,6 @@
|
||||
"icon": "rocket",
|
||||
"pages": [
|
||||
"platform/overview",
|
||||
"vibecoding",
|
||||
"platform/mem0-mcp",
|
||||
"platform/platform-vs-oss",
|
||||
"platform/quickstart"
|
||||
@@ -143,7 +142,6 @@
|
||||
"icon": "rocket",
|
||||
"pages": [
|
||||
"open-source/overview",
|
||||
"vibecoding",
|
||||
"open-source/python-quickstart",
|
||||
"open-source/node-quickstart"
|
||||
]
|
||||
@@ -233,8 +231,7 @@
|
||||
"components/vectordbs/dbs/cassandra",
|
||||
"components/vectordbs/dbs/s3_vectors",
|
||||
"components/vectordbs/dbs/databricks",
|
||||
"components/vectordbs/dbs/neptune_analytics",
|
||||
"components/vectordbs/dbs/turbopuffer"
|
||||
"components/vectordbs/dbs/neptune_analytics"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -296,6 +293,20 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "OpenMemory",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Overview & Quickstart",
|
||||
"icon": "square-terminal",
|
||||
"pages": [
|
||||
"openmemory/overview",
|
||||
"openmemory/quickstart",
|
||||
"openmemory/integrations"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tab": "Cookbooks",
|
||||
"groups": [
|
||||
@@ -1041,18 +1052,6 @@
|
||||
{
|
||||
"source": "/cookbooks/customer-support-agent",
|
||||
"destination": "/cookbooks/operations/support-inbox"
|
||||
},
|
||||
{
|
||||
"source": "/openmemory/overview",
|
||||
"destination": "/introduction"
|
||||
},
|
||||
{
|
||||
"source": "/openmemory/quickstart",
|
||||
"destination": "/introduction"
|
||||
},
|
||||
{
|
||||
"source": "/openmemory/integrations",
|
||||
"destination": "/introduction"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 426 KiB |
+26
-1
@@ -31,7 +31,7 @@ mode: "custom"
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a
|
||||
href="/platform/overview"
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-gray-200 dark:border-zinc-800/40 bg-white dark:bg-zinc-900/40 transition hover:border-primary/60 hover:bg-gray-50 dark:hover:bg-zinc-900"
|
||||
@@ -84,6 +84,31 @@ mode: "custom"
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/openmemory/overview"
|
||||
className="group flex h-full flex-col overflow-hidden rounded-2xl border border-gray-200 dark:border-zinc-800/40 bg-white dark:bg-zinc-900/40 transition hover:border-primary/60 hover:bg-gray-50 dark:hover:bg-zinc-900"
|
||||
>
|
||||
<img
|
||||
className="block dark:hidden aspect-[4/3] w-full object-cover"
|
||||
src="/images/docs thumbnails/light/mem0_openmemory.png"
|
||||
alt="OpenMemory thumbnail"
|
||||
style={{pointerEvents: "none"}}
|
||||
/>
|
||||
<img
|
||||
className="hidden dark:block aspect-[4/3] w-full object-cover"
|
||||
src="/images/docs thumbnails/dark/mem0_openmemory.png"
|
||||
alt="OpenMemory thumbnail"
|
||||
style={{pointerEvents: "none"}}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-3 px-5 pb-6 pt-5 text-left">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-zinc-100 group-hover:text-primary">
|
||||
OpenMemory
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-zinc-400">
|
||||
Workspace-based memory for teams collaborating across agents and projects.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -213,3 +213,4 @@ Key differentiators:
|
||||
- [FAQs](https://docs.mem0.ai/platform/faqs): Frequently asked questions about Mem0's Platform capabilities and implementation details
|
||||
- [Changelog](https://docs.mem0.ai/changelog): Detailed product updates and version history for tracking new features and improvements
|
||||
- [Contributing Guide](https://docs.mem0.ai/contributing/development): Guidelines for contributing to Mem0's open-source development
|
||||
- [OpenMemory](https://docs.mem0.ai/openmemory/overview): Open-source memory infrastructure for research and experimentation
|
||||
|
||||
@@ -77,7 +77,7 @@ Mem0 Open Source delivers the same adaptive memory engine as the platform, but p
|
||||
Mem0 OSS works out of the box with sensible defaults:
|
||||
- LLM: OpenAI `gpt-4.1-nano-2025-04-14` (via `OPENAI_API_KEY`)
|
||||
- Embeddings: OpenAI `text-embedding-3-small`
|
||||
- Vector store: Local Qdrant instance storing data at `~/.local/share/mem0/qdrant`
|
||||
- Vector store: Local Qdrant instance storing data at `/tmp/qdrant`
|
||||
- History store: SQLite database at `~/.mem0/history.db`
|
||||
- Reranker: Disabled until you configure a provider
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ print(results)
|
||||
By default `Memory()` wires up:
|
||||
- OpenAI `gpt-4.1-nano-2025-04-14` for fact extraction and updates
|
||||
- OpenAI `text-embedding-3-small` embeddings (1536 dimensions)
|
||||
- Qdrant vector store with on-disk data at `~/.local/share/mem0/qdrant`
|
||||
- Qdrant vector store with on-disk data at `/tmp/qdrant`
|
||||
- SQLite history at `~/.mem0/history.db`
|
||||
- No reranker (add one in the config when you need it)
|
||||
</Note>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: MCP Client Integration Guide
|
||||
description: "Connect MCP-compatible clients to a locally running OpenMemory server for seamless memory integration."
|
||||
icon: "plug"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
## Connecting an MCP Client
|
||||
|
||||
Once your OpenMemory server is running locally, you can connect any compatible MCP client to your personal memory stream. This enables a seamless memory layer integration for AI tools and agents.
|
||||
|
||||
Ensure the following environment variables are correctly set in your configuration files:
|
||||
|
||||
**In `/ui/.env`:**
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8765
|
||||
NEXT_PUBLIC_USER_ID=<user-id>
|
||||
```
|
||||
|
||||
**In `/api/.env`:**
|
||||
```env
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
USER=<user-id>
|
||||
```
|
||||
|
||||
These values define where your MCP server is running and which user's memory is accessed.
|
||||
|
||||
### MCP Client Setup
|
||||
|
||||
Use the following one-step command to configure OpenMemory Local MCP to a client. The general command format is as follows:
|
||||
|
||||
```bash
|
||||
npx @openmemory/install local http://localhost:8765/mcp/<client-name>/sse/<user-id> --client <client-name>
|
||||
```
|
||||
|
||||
Replace `<client-name>` with the desired client name and `<user-id>` with the value specified in your environment variables.
|
||||
|
||||
### Example Commands for Supported Clients
|
||||
|
||||
| Client | Command |
|
||||
|-------------|---------|
|
||||
| Claude | `npx install-mcp http://localhost:8765/mcp/claude/sse/<user-id> --client claude` |
|
||||
| Cursor | `npx install-mcp http://localhost:8765/mcp/cursor/sse/<user-id> --client cursor` |
|
||||
| Cline | `npx install-mcp http://localhost:8765/mcp/cline/sse/<user-id> --client cline` |
|
||||
| RooCline | `npx install-mcp http://localhost:8765/mcp/roocline/sse/<user-id> --client roocline` |
|
||||
| Windsurf | `npx install-mcp http://localhost:8765/mcp/windsurf/sse/<user-id> --client windsurf` |
|
||||
| Witsy | `npx install-mcp http://localhost:8765/mcp/witsy/sse/<user-id> --client witsy` |
|
||||
| Enconvo | `npx install-mcp http://localhost:8765/mcp/enconvo/sse/<user-id> --client enconvo` |
|
||||
| Augment | `npx install-mcp http://localhost:8765/mcp/augment/sse/<user-id> --client augment` |
|
||||
|
||||
### What This Does
|
||||
|
||||
Running one of the above commands registers the specified MCP client and connects it to your OpenMemory server. This enables the client to stream and store contextual memory for the provided user ID.
|
||||
|
||||
The connection status and memory activity can be monitored via the OpenMemory UI at [http://localhost:3000](http://localhost:3000).
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: Overview
|
||||
description: "Overview of OpenMemory, a local and hosted memory infrastructure powered by Mem0 with MCP server support."
|
||||
icon: "info"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
## Hosted OpenMemory MCP Now Available
|
||||
|
||||
#### Sign Up Now - [app.openmemory.dev](https://app.openmemory.dev)
|
||||
|
||||
Everything you love about OpenMemory MCP but with zero setup.
|
||||
|
||||
- Works with all MCP-compatible tools (Claude Desktop, Cursor, etc.)
|
||||
- Same standard memory operations: `add_memories`, `search_memory`, etc.
|
||||
- One-click provisioning, no Docker required
|
||||
- Powered by Mem0
|
||||
|
||||
Add shared, persistent, low-friction memory to your MCP-compatible clients in seconds.
|
||||
|
||||
### Get Started Now
|
||||
Sign up and get your access key at [app.openmemory.dev](https://app.openmemory.dev).
|
||||
|
||||
Example installation: `npx @openmemory/install --client claude --env OPENMEMORY_API_KEY=your-key`
|
||||
|
||||
OpenMemory is a local memory infrastructure powered by Mem0 that lets you carry your memory across any AI app. It provides a unified memory layer that stays with you, enabling agents and assistants to remember what matters across applications.
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b" alt="OpenMemory UI" />
|
||||
|
||||
## What is the OpenMemory MCP Server
|
||||
|
||||
The OpenMemory MCP Server is a private, local-first memory server that creates a shared, persistent memory layer for your MCP-compatible tools. It runs entirely on your machine, enabling seamless context handoff across tools. Whether you're switching between development, planning, or debugging environments, your AI assistants can access relevant memory without needing repeated instructions.
|
||||
|
||||
The OpenMemory MCP Server ensures all memory stays local, structured, and under your control with no cloud sync or external storage.
|
||||
|
||||
## OpenMemory Easy Setup
|
||||
|
||||
### Prerequisites
|
||||
- Docker
|
||||
- OpenAI API Key
|
||||
|
||||
You can quickly run OpenMemory by running the following command:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
|
||||
```
|
||||
|
||||
You should set the `OPENAI_API_KEY` as a global environment variable:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
You can also set the `OPENAI_API_KEY` as a parameter to the script:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
|
||||
```
|
||||
|
||||
This will start the OpenMemory server and the OpenMemory UI. Deleting the container will lead to the deletion of the memory store. We suggest you follow the instructions [here](/openmemory/quickstart#setting-up-openmemory) to set up OpenMemory on your local machine with a more persistent memory store.
|
||||
|
||||
## How the OpenMemory MCP Server Works
|
||||
|
||||
Built around the Model Context Protocol (MCP), the OpenMemory MCP Server exposes a standardized set of memory tools:
|
||||
- `add_memories`: Store new memory objects
|
||||
- `search_memory`: Retrieve relevant memories
|
||||
- `list_memories`: View all stored memory
|
||||
- `delete_all_memories`: Clear memory entirely
|
||||
|
||||
Any MCP-compatible tool can connect to the server and use these APIs to persist and access memory.
|
||||
|
||||
## What It Enables
|
||||
|
||||
### Cross-Client Memory Access
|
||||
Store context in Cursor and retrieve it later in Claude or Windsurf without repeating yourself.
|
||||
|
||||
### Fully Local Memory Store
|
||||
All memory is stored on your machine. Nothing goes to the cloud. You maintain full ownership and control.
|
||||
|
||||
### Unified Memory UI
|
||||
The built-in OpenMemory dashboard provides a central view of everything stored. Add, browse, delete, and control memory access to clients directly from the dashboard.
|
||||
|
||||
## Supported Clients
|
||||
|
||||
The OpenMemory MCP Server is compatible with any client that supports the Model Context Protocol. This includes:
|
||||
- Cursor
|
||||
- Claude Desktop
|
||||
- Windsurf
|
||||
- Cline
|
||||
- And more
|
||||
|
||||
As more AI systems adopt MCP, your private memory becomes more valuable.
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Scenario 1: Cross-Tool Project Flow
|
||||
Define technical requirements of a project in Claude Desktop. Build in Cursor. Debug issues in Windsurf - all with shared context passed through OpenMemory.
|
||||
|
||||
### Scenario 2: Preferences That Persist
|
||||
Set your preferred code style or tone in one tool. When you switch to another MCP client, it can access those same preferences without redefining them.
|
||||
|
||||
### Scenario 3: Project Knowledge
|
||||
Save important project details once, then access them from any compatible AI tool - no more repetitive explanations.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The OpenMemory MCP Server brings memory to MCP-compatible tools without giving up control or privacy. It solves a foundational limitation in modern LLM workflows: the loss of context across tools, sessions, and environments.
|
||||
|
||||
By standardizing memory operations and keeping all data local, it reduces token overhead, improves performance, and unlocks more intelligent interactions across the growing ecosystem of AI assistants.
|
||||
|
||||
This is just the beginning. The MCP server is the first core layer in the OpenMemory platform, a broader effort to make memory portable, private, and interoperable across AI systems.
|
||||
|
||||
## Getting Started Today
|
||||
|
||||
- Repository: [GitHub](https://github.com/mem0ai/mem0/tree/main/openmemory)
|
||||
- Join our community: [Discord](https://discord.gg/6PzXDgEjG5)
|
||||
|
||||
With OpenMemory, your AI memories stay private, portable, and under your control, exactly where they belong.
|
||||
|
||||
OpenMemory: Your memories, your control.
|
||||
|
||||
## Contributing
|
||||
|
||||
OpenMemory is open source and we welcome contributions. Please see the [CONTRIBUTING.md](https://github.com/mem0ai/mem0/blob/main/openmemory/CONTRIBUTING.md) file for more information.
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
title: Quickstart
|
||||
description: "Get started with hosted or self-hosted OpenMemory MCP, including API key setup and client installation."
|
||||
icon: "terminal"
|
||||
iconType: "solid"
|
||||
---
|
||||
|
||||
## Hosted OpenMemory MCP Now Available
|
||||
|
||||
#### Sign Up Now - [app.openmemory.dev](https://app.openmemory.dev)
|
||||
|
||||
Everything you love about OpenMemory MCP but with zero setup.
|
||||
|
||||
- Works with all MCP-compatible tools (Claude Desktop, Cursor, etc.)
|
||||
- Same standard memory operations: `add_memories`, `search_memory`, etc.
|
||||
- One-click provisioning, no Docker required
|
||||
- Powered by Mem0
|
||||
|
||||
Add shared, persistent, low-friction memory to your MCP-compatible clients in seconds.
|
||||
|
||||
### Get Started Now
|
||||
Sign up and get your access key at [app.openmemory.dev](https://app.openmemory.dev).
|
||||
|
||||
Example installation: `npx @openmemory/install --client claude --env OPENMEMORY_API_KEY=your-key`
|
||||
|
||||
## Getting Started with Hosted OpenMemory
|
||||
|
||||
The fastest way to get started is with our hosted version - no setup required.
|
||||
|
||||
### 1. Get Your API Key
|
||||
Visit [app.openmemory.dev](https://app.openmemory.dev) to sign up and get your `OPENMEMORY_API_KEY`.
|
||||
|
||||
### 2. Install and Connect to Your Preferred Client
|
||||
Example commands (replace `your-key` with your actual API key):
|
||||
|
||||
**For Claude Desktop:**
|
||||
```bash
|
||||
npx @openmemory/install --client claude --env OPENMEMORY_API_KEY=your-key
|
||||
```
|
||||
|
||||
**For Cursor:**
|
||||
```bash
|
||||
npx @openmemory/install --client cursor --env OPENMEMORY_API_KEY=your-key
|
||||
```
|
||||
|
||||
**For Windsurf:**
|
||||
```bash
|
||||
npx @openmemory/install --client windsurf --env OPENMEMORY_API_KEY=your-key
|
||||
```
|
||||
|
||||
That's it! Your AI client now has persistent memory across sessions.
|
||||
|
||||
## Local Setup (Self-Hosted)
|
||||
|
||||
Prefer to run OpenMemory locally? Follow the instructions below for a self-hosted setup.
|
||||
|
||||
## OpenMemory Easy Setup
|
||||
|
||||
### Prerequisites
|
||||
- Docker
|
||||
- OpenAI API Key
|
||||
|
||||
You can quickly run OpenMemory by running the following command:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
|
||||
```
|
||||
|
||||
You should set the `OPENAI_API_KEY` as a global environment variable:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your_api_key
|
||||
```
|
||||
|
||||
You can also set the `OPENAI_API_KEY` as a parameter to the script:
|
||||
|
||||
```bash
|
||||
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
|
||||
```
|
||||
|
||||
This will start the OpenMemory server and the OpenMemory UI. Deleting the container will lead to the deletion of the memory store. We suggest you follow the instructions below to set up OpenMemory on your local machine with a more persistent memory store.
|
||||
|
||||
## Setting Up OpenMemory
|
||||
|
||||
Getting started with OpenMemory is straightforward and takes just a few minutes to set up on your local machine. Follow these steps:
|
||||
|
||||
### 1. Clone the Repository
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/mem0ai/mem0.git
|
||||
cd mem0/openmemory
|
||||
```
|
||||
|
||||
### 2. Set Up Environment Variables
|
||||
|
||||
Before running the project, you need to configure environment variables for both the API and the UI.
|
||||
|
||||
You can do this in one of the following ways:
|
||||
|
||||
- **Manually:** Create a `.env` file in each of the following directories:
|
||||
- `/api/.env`
|
||||
- `/ui/.env`
|
||||
|
||||
- **Using `.env.example` files:** Copy and rename the example files:
|
||||
```bash
|
||||
cp api/.env.example api/.env
|
||||
cp ui/.env.example ui/.env
|
||||
```
|
||||
|
||||
- **Using Makefile** (if supported): Run:
|
||||
```bash
|
||||
make env
|
||||
```
|
||||
|
||||
#### Example `/api/.env`
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
USER=<user-id> # The User ID you want to associate the memories with
|
||||
```
|
||||
|
||||
#### LLM Configuration (optional)
|
||||
|
||||
By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider by adding these variables to `/api/.env`:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` |
|
||||
| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) |
|
||||
| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var |
|
||||
| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default |
|
||||
| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` |
|
||||
| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` |
|
||||
| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) |
|
||||
| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var |
|
||||
| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default |
|
||||
|
||||
**Example: Using Ollama (fully local)**
|
||||
```bash
|
||||
LLM_PROVIDER=ollama
|
||||
LLM_MODEL=llama3.1:latest
|
||||
EMBEDDER_PROVIDER=ollama
|
||||
EMBEDDER_MODEL=nomic-embed-text
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
```
|
||||
|
||||
**Example: Using Anthropic**
|
||||
```bash
|
||||
LLM_PROVIDER=anthropic
|
||||
LLM_MODEL=claude-sonnet-4-20250514
|
||||
LLM_API_KEY=sk-ant-xxx
|
||||
```
|
||||
|
||||
#### Example `/ui/.env`
|
||||
```bash
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8765
|
||||
NEXT_PUBLIC_USER_ID=<user-id> # Same as the user ID for environment variable in api
|
||||
```
|
||||
|
||||
### 3. Build and Run the Project
|
||||
You can run the project using the following two commands:
|
||||
```bash
|
||||
make build # Builds the MCP server and UI
|
||||
make up # Runs OpenMemory MCP server and UI
|
||||
```
|
||||
|
||||
After running these commands, you will have:
|
||||
- OpenMemory MCP server running at http://localhost:8765 (API documentation available at http://localhost:8765/docs)
|
||||
- OpenMemory UI running at http://localhost:3000
|
||||
|
||||
#### UI Not Working on http://localhost:3000?
|
||||
|
||||
If the UI does not start properly on http://localhost:3000, try running it manually:
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
You can configure the MCP client using the following command (replace `username` with your username):
|
||||
|
||||
```bash
|
||||
npx @openmemory/install local "http://localhost:8765/mcp/cursor/sse/username" --client cursor
|
||||
```
|
||||
|
||||
The OpenMemory dashboard will be available at http://localhost:3000. From here, you can view and manage your memories and check connection status with your MCP clients.
|
||||
|
||||
Once set up, OpenMemory runs locally on your machine, ensuring all your AI memories remain private and secure while being accessible across any compatible MCP client.
|
||||
|
||||
## Getting Started Today
|
||||
|
||||
GitHub Repository: https://github.com/mem0ai/mem0/tree/main/openmemory
|
||||
@@ -9,48 +9,11 @@ description: "Connect any AI client to Mem0 using Model Context Protocol for uni
|
||||
|
||||
When building AI applications, memory management often requires manual integration. MCP eliminates this complexity by:
|
||||
|
||||
- **Universal compatibility**: Works with any MCP-compatible client (Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode)
|
||||
- **Universal compatibility**: Works with any MCP-compatible client (Claude Desktop, Cursor, custom agents)
|
||||
- **Agent autonomy**: AI agents decide when to save, search, or update memories
|
||||
- **Zero infrastructure**: No servers to maintain - Mem0's cloud MCP handles everything
|
||||
- **Zero infrastructure**: No servers to maintain - Mem0 handles everything
|
||||
- **Standardized protocol**: One integration works across all your AI tools
|
||||
|
||||
## Setup
|
||||
|
||||
Add Mem0 MCP to all supported clients with a single command:
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
|
||||
```
|
||||
|
||||
Or configure a specific client:
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "cursor"
|
||||
```
|
||||
|
||||
For manual configuration, add this to your MCP client config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0-mcp": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For detailed per-client instructions, see the [Mem0 MCP Quickstart](/platform/mem0-mcp).
|
||||
|
||||
## Available tools
|
||||
|
||||
The MCP server exposes 9 memory tools to your AI client:
|
||||
@@ -67,12 +30,139 @@ The MCP server exposes 9 memory tools to your AI client:
|
||||
| `get_memory` | Retrieve single memory by ID |
|
||||
| `list_entities` | View stored entities |
|
||||
|
||||
## How it works
|
||||
## Deployment options
|
||||
|
||||
1. **Configure the MCP server** - Add Mem0 MCP to your AI client using the setup command above
|
||||
2. **Agent connects** - Your AI client connects to Mem0's cloud MCP server over HTTP
|
||||
3. **Autonomous memory** - The agent decides when to store/retrieve memories as part of its reasoning
|
||||
4. **No manual API calls** - The agent manages memory automatically through MCP tools
|
||||
Choose the deployment method that fits your workflow:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Python package (recommended)">
|
||||
Install and run locally with uvx:
|
||||
|
||||
```bash
|
||||
uv pip install mem0-mcp-server
|
||||
```
|
||||
|
||||
Configure your client:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"command": "uvx",
|
||||
"args": ["mem0-mcp-server"],
|
||||
"env": {
|
||||
"MEM0_API_KEY": "m0-...",
|
||||
"MEM0_DEFAULT_USER_ID": "your-handle"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Docker container">
|
||||
Containerized deployment with HTTP endpoint:
|
||||
|
||||
```bash
|
||||
docker build -t mem0-mcp-server https://github.com/mem0ai/mem0-mcp.git
|
||||
docker run --rm -d -e MEM0_API_KEY="m0-..." -p 8080:8081 mem0-mcp-server
|
||||
```
|
||||
|
||||
Configure for HTTP:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0-docker": {
|
||||
"command": "curl",
|
||||
"args": ["-X", "POST", "http://localhost:8080/mcp", "--data-binary", "@"],
|
||||
"env": {
|
||||
"MEM0_API_KEY": "m0-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Smithery">
|
||||
One-click setup with managed service:
|
||||
|
||||
Visit [smithery.ai/server/@mem0ai/mem0-memory-mcp](https://smithery.ai/server/@mem0ai/mem0-memory-mcp) and:
|
||||
|
||||
1. Select your AI client (Cursor, Claude Desktop, etc.)
|
||||
2. Configure your Mem0 API key
|
||||
3. Set your default user ID
|
||||
4. Enable graph memory (optional)
|
||||
5. Copy the generated configuration
|
||||
|
||||
Your client connects automatically - no installation required.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required environment variables
|
||||
```bash
|
||||
MEM0_API_KEY="m0-..." # Your Mem0 API key
|
||||
MEM0_DEFAULT_USER_ID="your-handle" # Default user ID
|
||||
```
|
||||
|
||||
### Optional variables
|
||||
```bash
|
||||
MEM0_ENABLE_GRAPH_DEFAULT="true" # Enable graph memories
|
||||
MEM0_MCP_AGENT_MODEL="gpt-4o-mini" # LLM for bundled examples
|
||||
```
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Test your setup with the Python agent">
|
||||
The included Pydantic AI agent provides an interactive REPL to test memory operations:
|
||||
|
||||
```bash
|
||||
# Install the package
|
||||
pip install mem0-mcp-server
|
||||
|
||||
# Set your API keys
|
||||
export MEM0_API_KEY="m0-..."
|
||||
export OPENAI_API_KEY="sk-openai-..."
|
||||
|
||||
# Clone and test with the agent
|
||||
git clone https://github.com/mem0ai/mem0-mcp.git
|
||||
cd mem0-mcp-server
|
||||
python example/pydantic_ai_repl.py
|
||||
```
|
||||
|
||||
**Testing different server configurations:**
|
||||
|
||||
- **Local server** (default): `python example/pydantic_ai_repl.py`
|
||||
|
||||
- **Docker container**:
|
||||
```bash
|
||||
export MEM0_MCP_CONFIG_PATH=example/docker-config.json
|
||||
export MEM0_MCP_CONFIG_SERVER=mem0-docker
|
||||
python example/pydantic_ai_repl.py
|
||||
```
|
||||
|
||||
- **Smithery remote**:
|
||||
```bash
|
||||
export MEM0_MCP_CONFIG_PATH=example/config-smithery.json
|
||||
export MEM0_MCP_CONFIG_SERVER=mem0-memory-mcp
|
||||
python example/pydantic_ai_repl.py
|
||||
```
|
||||
|
||||
Try these test prompts:
|
||||
- "Remember that I love tiramisu"
|
||||
- "Search for my food preferences"
|
||||
- "Update my project: the mobile app is now 80% complete"
|
||||
- "Show me all memories about project Phoenix"
|
||||
- "Delete memories from 2023"
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## How the testing works
|
||||
|
||||
1. **Configuration loads** - Reads from `example/config.json` by default
|
||||
2. **Server starts** - Launches or connects to the Mem0 MCP server
|
||||
3. **Agent connects** - Pydantic AI agent (Mem0Guide) attaches to the server
|
||||
4. **Interactive REPL** - You get a chat interface to test all memory operations
|
||||
|
||||
## Example interactions
|
||||
|
||||
@@ -135,11 +225,14 @@ The Mem0 MCP server enables powerful memory capabilities for your AI application
|
||||
|
||||
## Best practices
|
||||
|
||||
- **Use the cloud MCP**: The hosted MCP server at `https://mcp.mem0.ai/mcp` handles infrastructure for you
|
||||
- **Start simple**: Use the Python package for development
|
||||
- **Use wildcards**: `user_id: "*"` to search across all users
|
||||
- **Test locally**: Use the bundled Python agent to verify setup
|
||||
- **Monitor usage**: Track memory operations in the dashboard
|
||||
- **Document patterns**: Share successful prompt patterns with your team
|
||||
|
||||
{/* DEBUG: verify CTA targets */}
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Memory Filters"
|
||||
@@ -153,4 +246,4 @@ The Mem0 MCP server enables powerful memory capabilities for your AI application
|
||||
icon="book-open"
|
||||
href="/cookbooks/frameworks/gemini-3-with-mem0-mcp"
|
||||
/>
|
||||
</CardGroup>
|
||||
</CardGroup>
|
||||
+141
-108
@@ -2,34 +2,28 @@
|
||||
title: "Mem0 MCP"
|
||||
description: "Connect any AI client to Mem0 using Model Context Protocol in minutes"
|
||||
icon: "puzzle-piece"
|
||||
estimatedTime: "~2 minutes"
|
||||
estimatedTime: "~5 minutes"
|
||||
---
|
||||
|
||||
<Info>
|
||||
**Prerequisites**
|
||||
- Mem0 Platform account ([Sign up here](https://app.mem0.ai))
|
||||
- API key ([Get one from dashboard](https://app.mem0.ai/settings/api-keys))
|
||||
- Node.js 14+ (for npx)
|
||||
- An MCP-compatible client (Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode)
|
||||
- Python 3.10+, Docker, or Node.js 14+
|
||||
- An MCP-compatible client (Claude Desktop, Cursor, or custom agent)
|
||||
</Info>
|
||||
|
||||
## What is Mem0 MCP?
|
||||
|
||||
Mem0 MCP Server exposes Mem0's memory capabilities as MCP tools, letting AI agents decide when to save, search, or update information. The cloud-hosted MCP server requires no local installation — just connect and start using memory.
|
||||
Mem0 MCP Server exposes Mem0's memory capabilities as MCP tools, letting AI agents decide when to save, search, or update information.
|
||||
|
||||
## Quick Setup
|
||||
## Deployment Options
|
||||
|
||||
Add Mem0 MCP to your preferred clients with a single command:
|
||||
Choose from three deployment methods:
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
|
||||
```
|
||||
|
||||
This automatically configures Mem0 MCP for all supported clients at once.
|
||||
1. **Python Package (Recommended)** - Install locally with `uvx` for instant setup
|
||||
2. **Docker Container** - Isolated deployment with HTTP endpoint
|
||||
3. **Smithery** - Remote hosted service for managed deployments
|
||||
|
||||
## Available Tools
|
||||
|
||||
@@ -49,105 +43,54 @@ The MCP server exposes these memory tools to your AI client:
|
||||
|
||||
---
|
||||
|
||||
## Client-Specific Setup
|
||||
## Quickstart with Python (UVX)
|
||||
|
||||
You can also configure individual clients:
|
||||
<Steps>
|
||||
<Step title="Install the MCP Server">
|
||||
```bash
|
||||
uv pip install mem0-mcp-server
|
||||
```
|
||||
</Step>
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Claude Desktop">
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "claude"
|
||||
```
|
||||
<Step title="Configure your MCP client">
|
||||
Add this to your MCP client (e.g., Claude Desktop):
|
||||
|
||||
Or manually add to your Claude Desktop configuration (`claude_desktop_config.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0-mcp": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp"
|
||||
}
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"command": "uvx",
|
||||
"args": ["mem0-mcp-server"],
|
||||
"env": {
|
||||
"MEM0_API_KEY": "m0-...",
|
||||
"MEM0_DEFAULT_USER_ID": "your-handle"
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Accordion title="Claude Code">
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "claude code"
|
||||
```
|
||||
</Accordion>
|
||||
Set your environment variables:
|
||||
|
||||
<Accordion title="Cursor">
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "cursor"
|
||||
```
|
||||
```bash
|
||||
export MEM0_API_KEY="m0-..."
|
||||
export MEM0_DEFAULT_USER_ID="your-handle"
|
||||
```
|
||||
</Step>
|
||||
|
||||
Or go to Cursor → Settings → MCP and add:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0-mcp": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.mem0.ai/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
<Step title="Test with the Python agent">
|
||||
```bash
|
||||
# Clone the mem0-mcp repository
|
||||
git clone https://github.com/mem0ai/mem0-mcp.git
|
||||
cd mem0-mcp
|
||||
|
||||
<Accordion title="Windsurf">
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "windsurf"
|
||||
```
|
||||
</Accordion>
|
||||
# Set your API keys
|
||||
export MEM0_API_KEY="m0-..."
|
||||
export OPENAI_API_KEY="sk-openai-..."
|
||||
|
||||
<Accordion title="VS Code">
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "vscode"
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenCode">
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "opencode"
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
---
|
||||
|
||||
## Verify Your Setup
|
||||
|
||||
Once configured, your AI client can:
|
||||
- Automatically save information with `add_memory`
|
||||
- Search memories with `search_memories`
|
||||
- Update memories with `update_memory`
|
||||
- Delete memories with `delete_memory`
|
||||
# Run the interactive agent
|
||||
python example/pydantic_ai_repl.py
|
||||
```
|
||||
|
||||
**Sample Interactions:**
|
||||
|
||||
@@ -161,18 +104,107 @@ Agent: Based on your memories, you love tiramisu.
|
||||
User: Update my project: the mobile app is now 80% complete
|
||||
Agent: Updated your project status successfully.
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Verify the setup">
|
||||
Your AI client can now:
|
||||
- Automatically save information with `add_memory`
|
||||
- Search memories with `search_memories`
|
||||
- Update memories with `update_memory`
|
||||
- Delete memories with `delete_memory`
|
||||
|
||||
<Info icon="check">
|
||||
If you get "Connection failed", ensure you have a valid API key from [Mem0 Dashboard](https://app.mem0.ai/settings/api-keys).
|
||||
If you get "Connection failed", ensure your API key is valid and the server is running.
|
||||
</Info>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
---
|
||||
|
||||
## Quickstart with Docker
|
||||
|
||||
<Steps>
|
||||
<Step title="Build the Docker image">
|
||||
```bash
|
||||
docker build -t mem0-mcp-server https://github.com/mem0ai/mem0-mcp.git
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Run the container">
|
||||
```bash
|
||||
docker run --rm -d \
|
||||
--name mem0-mcp \
|
||||
-e MEM0_API_KEY="m0-..." \
|
||||
-p 8080:8081 \
|
||||
mem0-mcp-server
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Configure your client for HTTP">
|
||||
For clients that connect via HTTP (instead of stdio):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0-docker": {
|
||||
"command": "curl",
|
||||
"args": ["-X", "POST", "http://localhost:8080/mcp", "--data-binary", "@-"],
|
||||
"env": {
|
||||
"MEM0_API_KEY": "m0-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Verify the setup">
|
||||
```bash
|
||||
# Check container logs
|
||||
docker logs mem0-mcp
|
||||
|
||||
# Test HTTP endpoint
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
<Info icon="check">
|
||||
The container should start successfully and respond to HTTP requests. If port 8080 is occupied, change it with `-p 8081:8081`.
|
||||
</Info>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
---
|
||||
|
||||
## Quickstart with Smithery (Hosted)
|
||||
|
||||
For the simplest integration, use Smithery's hosted Mem0 MCP server - no installation required.
|
||||
|
||||
**Example: One-click setup in Cursor**
|
||||
|
||||
1. Visit [smithery.ai/server/@mem0ai/mem0-memory-mcp](https://smithery.ai/server/@mem0ai/mem0-memory-mcp) and select Cursor as your client
|
||||
|
||||

|
||||
|
||||
2. Open Cursor → Settings → MCP
|
||||
3. Click `mem0-mcp` → Initiate authorization
|
||||
4. Configure Smithery with your environment:
|
||||
- `MEM0_API_KEY`: Your Mem0 API key
|
||||
- `MEM0_DEFAULT_USER_ID`: Your user ID
|
||||
- `MEM0_ENABLE_GRAPH_DEFAULT`: Optional, set to `true` for graph memories
|
||||
5. Return to Cursor settings and wait for tools to load
|
||||
6. Start chatting with Cursor and begin storing preferences
|
||||
|
||||
**For other clients:**
|
||||
Visit [smithery.ai/server/@mem0ai/mem0-memory-mcp](https://smithery.ai/server/@mem0ai/mem0-memory-mcp) to connect any MCP-compatible client with your Mem0 credentials.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recovery
|
||||
|
||||
- **"Connection refused"** → Check your internet connection and ensure the MCP client is correctly configured
|
||||
- **"uvx command not found"** → Install with `pip install uv` or use `pip install mem0-mcp-server` instead. Make sure your Python environment has `uv` installed (or system-wide).
|
||||
- **"Connection refused"** → Check that the server is running and the correct port is configured
|
||||
- **"Invalid API key"** → Get a new key from [Mem0 Dashboard](https://app.mem0.ai/settings/api-keys)
|
||||
- **"npx command not found"** → Install Node.js from [nodejs.org](https://nodejs.org)
|
||||
- **"Permission denied"** → Ensure Docker has access to bind ports (try with `sudo` on Linux)
|
||||
|
||||
---
|
||||
|
||||
@@ -195,5 +227,6 @@ Agent: Updated your project status successfully.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **[Mem0 MCP Repository](https://github.com/mem0ai/mem0-mcp)** - Source code and examples
|
||||
- **[Platform Quickstart](/platform/quickstart)** - Direct API integration guide
|
||||
- **[MCP Specification](https://modelcontextprotocol.io)** - Learn about MCP protocol
|
||||
- **[MCP Specification](https://modelcontextprotocol.io)** - Learn about MCP protocol
|
||||
Vendored
+2
-2
@@ -166,13 +166,13 @@ Call out the most common mistake or edge case for this layer.
|
||||
title="[Related cookbook / deep dive]"
|
||||
description="[Why this pairs well with the current guide]"
|
||||
icon="arrow-right"
|
||||
href="#related-link"
|
||||
href="#"
|
||||
/>
|
||||
<Card
|
||||
title="[Next cookbook in journey]"
|
||||
description="[Set expectation for the next step]"
|
||||
icon="rocket"
|
||||
href="#next-link"
|
||||
href="#"
|
||||
/>
|
||||
</CardGroup>
|
||||
```
|
||||
|
||||
+2
-2
@@ -145,13 +145,13 @@ npm install mem0ai@[version]
|
||||
title="[Deep dive reference]"
|
||||
description="[Why this reference matters post-migration]"
|
||||
icon="book"
|
||||
href="#reference-link"
|
||||
href="#"
|
||||
/>
|
||||
<Card
|
||||
title="[Applied example or next step]"
|
||||
description="[What readers can build now]"
|
||||
icon="rocket"
|
||||
href="#example-link"
|
||||
href="#"
|
||||
/>
|
||||
</CardGroup>
|
||||
```
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
---
|
||||
title: "Vibecoding with Mem0"
|
||||
sidebarTitle: "Vibecoding"
|
||||
description: "Agent skills, starter prompts, and setup for building with Mem0 using AI coding tools."
|
||||
icon: "wand-magic-sparkles"
|
||||
---
|
||||
|
||||
These docs are designed to be easily consumable by LLMs. Each page has a button that lets you copy the page as Markdown or paste directly into ChatGPT, Claude, or any AI coding tool.
|
||||
|
||||
We follow the llms.txt standard:
|
||||
|
||||
- [llms.txt](https://docs.mem0.ai/llms.txt)
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Get an API Key" icon="key" href="https://app.mem0.ai">
|
||||
Sign up for Mem0 Platform and start building
|
||||
</Card>
|
||||
<Card title="Quickstart" icon="rocket" href="/platform/quickstart">
|
||||
Store your first memory in under 5 minutes
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Agent Skills
|
||||
|
||||
Teach your coding assistant how to build with Mem0:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/mem0ai/mem0 --skill mem0
|
||||
```
|
||||
|
||||
Works with Claude Code, Cursor, Windsurf, and any assistant that supports skills. Once installed, your assistant understands Mem0's full API, framework integrations, and common patterns.
|
||||
|
||||
## MCP Server Setup
|
||||
|
||||
Connect Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode, or any MCP-compatible client to Mem0.
|
||||
|
||||
Get your API key from [app.mem0.ai](https://app.mem0.ai), then add Mem0 MCP with a single command:
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
|
||||
```
|
||||
|
||||
For per-client setup and advanced options, see [Mem0 MCP Setup](/platform/mem0-mcp).
|
||||
|
||||
## Universal Starter Prompt
|
||||
|
||||
Copy this into any AI tool to start building with Mem0:
|
||||
|
||||
```text
|
||||
I want to start building with Mem0 — a self-improving memory layer for LLM
|
||||
applications that gives agents persistent context across sessions.
|
||||
|
||||
## Mem0 Resources
|
||||
|
||||
**Documentation:**
|
||||
- Main docs: https://docs.mem0.ai
|
||||
- Platform Quickstart: https://docs.mem0.ai/platform/quickstart
|
||||
- OSS Python Quickstart: https://docs.mem0.ai/open-source/python-quickstart
|
||||
- OSS Node.js Quickstart: https://docs.mem0.ai/open-source/node-quickstart
|
||||
- API Reference: https://docs.mem0.ai/api-reference
|
||||
- Full LLM-friendly docs: https://docs.mem0.ai/llms.txt
|
||||
|
||||
**Code & Examples:**
|
||||
- Core repo: https://github.com/mem0ai/mem0
|
||||
- Python SDK: pip install mem0ai
|
||||
- TypeScript SDK: npm install mem0ai
|
||||
- Cookbooks: https://docs.mem0.ai/cookbooks/overview
|
||||
|
||||
**What Mem0 Does:**
|
||||
Mem0 is a memory layer for AI apps — managed (Mem0 Platform) or self-hosted
|
||||
(Open Source). It stores, retrieves, and manages user memories so agents
|
||||
remember preferences, learn from interactions, and personalize over time.
|
||||
Sub-50ms retrieval. Dual storage: vector embeddings + graph databases.
|
||||
|
||||
**Architecture Overview:**
|
||||
- Memory is scoped by user_id, agent_id, or run_id
|
||||
- Core operations: add, search, update, delete
|
||||
- Memory types: factual (preferences, facts), episodic (past interactions),
|
||||
semantic (concept relationships), working (session state)
|
||||
- Integration pattern: retrieve relevant memories → generate response → store
|
||||
new memories
|
||||
|
||||
**Quick Usage (Python Platform):**
|
||||
from mem0 import MemoryClient
|
||||
client = MemoryClient(api_key="m0-xxx")
|
||||
client.add("I prefer dark mode and use VS Code.", user_id="user1")
|
||||
results = client.search("What editor do they use?", user_id="user1")
|
||||
|
||||
**Quick Usage (JavaScript Platform):**
|
||||
import MemoryClient from 'mem0ai';
|
||||
const client = new MemoryClient({ apiKey: 'm0-xxx' });
|
||||
await client.add([{ role: "user", content: "I prefer dark mode." }], { user_id: "user1" });
|
||||
const results = await client.search("What editor?", { user_id: "user1" });
|
||||
|
||||
**Quick Usage (Python Open Source):**
|
||||
from mem0 import Memory
|
||||
m = Memory()
|
||||
m.add("I prefer dark mode and use VS Code.", user_id="user1")
|
||||
results = m.search("What editor do they use?", user_id="user1")
|
||||
|
||||
Help me integrate Mem0 into my project. Start by asking what I'm building,
|
||||
what language/framework I'm using, and whether I want managed or self-hosted.
|
||||
```
|
||||
|
||||
## Go Deeper
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Platform Quickstart" icon="cloud" href="/platform/quickstart">
|
||||
Get started with the managed API
|
||||
</Card>
|
||||
<Card title="Open Source" icon="code-branch" href="/open-source/overview">
|
||||
Self-host with full control
|
||||
</Card>
|
||||
<Card title="Cookbooks" icon="book" href="/cookbooks/overview">
|
||||
Production-ready tutorials and examples
|
||||
</Card>
|
||||
<Card title="API Reference" icon="code" href="/api-reference">
|
||||
Explore every REST endpoint
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -1,419 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# install-mem0-plugin.sh
|
||||
#
|
||||
# Installs and configures the @mem0/openclaw-mem0 plugin for an existing
|
||||
# NemoClaw sandbox. Assumes NemoClaw is already installed and onboarded.
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x install-mem0-plugin.sh && ./install-mem0-plugin.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - NemoClaw installed and onboarded (sandbox in Ready state)
|
||||
# - Mem0 API key (from app.mem0.ai)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colors and formatting ────────────────────────────────────────────────────
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
step() { echo -e "\n${BOLD}${CYAN}── $* ──${NC}\n"; }
|
||||
ask() { echo -en "${BOLD}$*${NC}"; }
|
||||
|
||||
die() {
|
||||
error "$*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
MEM0_USER_ID="${MEM0_USER_ID:-default}"
|
||||
PLUGIN_PKG="@mem0/openclaw-mem0"
|
||||
CONTAINER_NAME="nemoclaw-dev"
|
||||
|
||||
# ── Detect platform ──────────────────────────────────────────────────────────
|
||||
|
||||
OS_TYPE="$(uname -s)"
|
||||
IS_MACOS=false
|
||||
IS_LINUX=false
|
||||
|
||||
case "$OS_TYPE" in
|
||||
Darwin) IS_MACOS=true ;;
|
||||
Linux) IS_LINUX=true ;;
|
||||
*) die "Unsupported OS: $OS_TYPE" ;;
|
||||
esac
|
||||
|
||||
# ── Helper functions ─────────────────────────────────────────────────────────
|
||||
|
||||
check_command() {
|
||||
command -v "$1" &>/dev/null
|
||||
}
|
||||
|
||||
ensure_nvm() {
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
if [[ -s "$NVM_DIR/nvm.sh" ]]; then
|
||||
source "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_path() {
|
||||
for p in "$HOME/.local/bin" "$HOME/.nvm/versions/node/"*/bin; do
|
||||
if [[ -d "$p" ]] && [[ ":$PATH:" != *":$p:"* ]]; then
|
||||
export PATH="$p:$PATH"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Wrapper to run a command natively (Linux) or in the container (macOS)
|
||||
run_cmd() {
|
||||
if $IS_MACOS; then
|
||||
docker exec "$CONTAINER_NAME" bash -c "export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh && $*"
|
||||
else
|
||||
eval "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Banner ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ Mem0 Plugin Installer for NemoClaw ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ Long-term memory for your OpenClaw agent ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
if $IS_MACOS; then
|
||||
info "Platform: macOS (using Docker container '$CONTAINER_NAME')"
|
||||
else
|
||||
info "Platform: Linux"
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# PRE-CHECK: Verify NemoClaw is installed and sandbox is ready
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Pre-check: Verifying NemoClaw setup"
|
||||
|
||||
if $IS_LINUX; then
|
||||
ensure_nvm
|
||||
ensure_path
|
||||
fi
|
||||
|
||||
# Verify nemoclaw/openshell are available
|
||||
if $IS_MACOS; then
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
die "Docker container '$CONTAINER_NAME' is not running. Start it with: docker start $CONTAINER_NAME"
|
||||
fi
|
||||
if ! run_cmd "command -v nemoclaw" &>/dev/null; then
|
||||
die "NemoClaw is not installed in container '$CONTAINER_NAME'. Run the full setup script first."
|
||||
fi
|
||||
else
|
||||
if ! check_command openshell; then
|
||||
die "openshell not found. Is NemoClaw installed and onboarded? Try: source ~/.bashrc"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Detect sandbox
|
||||
SANDBOX_NAME=$(run_cmd "openshell sandbox list 2>/dev/null" | awk 'NR>1 && $1!="" {print $1; exit}' || true)
|
||||
|
||||
if [[ -z "$SANDBOX_NAME" ]]; then
|
||||
die "No sandbox found. Run 'nemoclaw onboard' first."
|
||||
fi
|
||||
|
||||
# Verify sandbox is ready
|
||||
SANDBOX_PHASE=$(run_cmd "openshell sandbox get '$SANDBOX_NAME' 2>/dev/null" | grep -i "phase" | awk '{print $NF}' || true)
|
||||
if [[ "$SANDBOX_PHASE" != "Ready" ]]; then
|
||||
warn "Sandbox '$SANDBOX_NAME' is not in Ready state (current: ${SANDBOX_PHASE:-unknown})."
|
||||
warn "Waiting up to 2 minutes..."
|
||||
WAIT_OK=false
|
||||
for i in $(seq 1 24); do
|
||||
SANDBOX_PHASE=$(run_cmd "openshell sandbox get '$SANDBOX_NAME' 2>/dev/null" | grep -i "phase" | awk '{print $NF}' || true)
|
||||
if [[ "$SANDBOX_PHASE" == "Ready" ]]; then
|
||||
WAIT_OK=true
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
if ! $WAIT_OK; then
|
||||
die "Sandbox '$SANDBOX_NAME' did not become Ready. Run: openshell sandbox list"
|
||||
fi
|
||||
fi
|
||||
|
||||
success "NemoClaw installed"
|
||||
success "Sandbox '$SANDBOX_NAME' is ready"
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# STEP 1: Install Mem0 Plugin
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Step 1: Installing Mem0 plugin ($PLUGIN_PKG)"
|
||||
|
||||
# Helper: run a command inside the sandbox non-interactively via piped stdin
|
||||
sandbox_exec() {
|
||||
local cmd="$1"
|
||||
if $IS_MACOS; then
|
||||
printf '%s\nexit\n' "$cmd" | docker exec -i "$CONTAINER_NAME" bash -c \
|
||||
"export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh && openshell sandbox connect '$SANDBOX_NAME'" 2>&1
|
||||
else
|
||||
printf '%s\nexit\n' "$cmd" | openshell sandbox connect "$SANDBOX_NAME" 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if plugin is already installed
|
||||
PLUGIN_EXISTS=false
|
||||
if run_cmd "openshell sandbox download '$SANDBOX_NAME' /sandbox/.openclaw/extensions/openclaw-mem0/package.json /tmp/_mem0_plugin_check.json" &>/dev/null; then
|
||||
if [[ -f /tmp/_mem0_plugin_check.json ]] || run_cmd "test -f /tmp/_mem0_plugin_check.json" &>/dev/null; then
|
||||
PLUGIN_EXISTS=true
|
||||
fi
|
||||
fi
|
||||
rm -f /tmp/_mem0_plugin_check.json 2>/dev/null || true
|
||||
run_cmd "rm -f /tmp/_mem0_plugin_check.json" 2>/dev/null || true
|
||||
|
||||
if $PLUGIN_EXISTS; then
|
||||
success "Mem0 plugin already installed in sandbox"
|
||||
else
|
||||
info "Downloading $PLUGIN_PKG..."
|
||||
|
||||
# Download and build outside the sandbox (on host or in container)
|
||||
run_cmd "cd /tmp && rm -rf openclaw-mem0-full mem0-openclaw-mem0-*.tgz openclaw-mem0-full.tgz"
|
||||
|
||||
if ! run_cmd "cd /tmp && npm pack '$PLUGIN_PKG' 2>/dev/null"; then
|
||||
die "Failed to download $PLUGIN_PKG from npm. Check your internet connection."
|
||||
fi
|
||||
|
||||
success "Downloaded plugin"
|
||||
|
||||
info "Installing plugin dependencies..."
|
||||
run_cmd "mkdir -p /tmp/openclaw-mem0-full && cd /tmp/openclaw-mem0-full && tar xzf /tmp/mem0-openclaw-mem0-*.tgz --strip-components=1 && npm install --omit=dev 2>&1 | tail -3"
|
||||
|
||||
success "Dependencies installed"
|
||||
|
||||
info "Uploading plugin to sandbox..."
|
||||
run_cmd "cd /tmp && tar czf openclaw-mem0-full.tgz -C openclaw-mem0-full ."
|
||||
|
||||
if ! run_cmd "openshell sandbox upload '$SANDBOX_NAME' /tmp/openclaw-mem0-full.tgz /sandbox/openclaw-mem0-full.tgz 2>&1"; then
|
||||
die "Failed to upload plugin to sandbox. Check: openshell sandbox list"
|
||||
fi
|
||||
|
||||
success "Plugin uploaded"
|
||||
|
||||
info "Extracting plugin inside sandbox..."
|
||||
sandbox_exec "mkdir -p ~/.openclaw/extensions/openclaw-mem0 && tar xzf /sandbox/openclaw-mem0-full.tgz/openclaw-mem0-full.tgz -C ~/.openclaw/extensions/openclaw-mem0 2>/dev/null || tar xzf /sandbox/openclaw-mem0-full.tgz -C ~/.openclaw/extensions/openclaw-mem0 2>/dev/null && echo EXTRACT_OK" >/dev/null 2>&1 || true
|
||||
|
||||
# Verify
|
||||
VERIFY_OK=false
|
||||
if run_cmd "openshell sandbox download '$SANDBOX_NAME' /sandbox/.openclaw/extensions/openclaw-mem0/package.json /tmp/_mem0_verify.json" &>/dev/null; then
|
||||
VERIFY_OK=true
|
||||
fi
|
||||
rm -f /tmp/_mem0_verify.json 2>/dev/null || true
|
||||
run_cmd "rm -f /tmp/_mem0_verify.json" 2>/dev/null || true
|
||||
if $VERIFY_OK; then
|
||||
success "Plugin extracted inside sandbox"
|
||||
else
|
||||
warn "Could not verify plugin extraction. You may need to extract manually."
|
||||
if $IS_MACOS; then
|
||||
echo " docker exec -it $CONTAINER_NAME bash"
|
||||
echo " export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh"
|
||||
fi
|
||||
echo " nemoclaw $SANDBOX_NAME connect"
|
||||
echo " mkdir -p ~/.openclaw/extensions/openclaw-mem0"
|
||||
echo " tar xzf /sandbox/openclaw-mem0-full.tgz/openclaw-mem0-full.tgz -C ~/.openclaw/extensions/openclaw-mem0"
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
run_cmd "rm -rf /tmp/openclaw-mem0-full /tmp/mem0-openclaw-mem0-*.tgz /tmp/openclaw-mem0-full.tgz" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# STEP 2: Update Network Policy
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Step 2: Updating network policy to allow api.mem0.ai and telemetry"
|
||||
|
||||
# Find baseline policy
|
||||
BASELINE_POLICY=$(run_cmd "find / -path '*/nemoclaw-blueprint/policies/openclaw-sandbox.yaml' 2>/dev/null | head -1" || true)
|
||||
|
||||
if [[ -z "$BASELINE_POLICY" ]]; then
|
||||
die "Cannot find NemoClaw baseline policy file. Is NemoClaw installed?"
|
||||
fi
|
||||
|
||||
info "Baseline policy: $BASELINE_POLICY"
|
||||
|
||||
# Check if mem0_api already exists
|
||||
HAS_MEM0=$(run_cmd "grep -c mem0_api '$BASELINE_POLICY' 2>/dev/null" || echo "0")
|
||||
|
||||
if [[ "$HAS_MEM0" != "0" ]]; then
|
||||
success "mem0_api already in baseline policy"
|
||||
else
|
||||
info "Adding api.mem0.ai to network policy..."
|
||||
fi
|
||||
|
||||
# Create custom policy with mem0_api + telemetry
|
||||
run_cmd "node -e \"
|
||||
const fs = require('fs');
|
||||
let c = fs.readFileSync('$BASELINE_POLICY', 'utf8');
|
||||
const mem0Block = '\\n mem0_api:\\n name: mem0_api\\n endpoints:\\n - host: api.mem0.ai\\n port: 443\\n access: full\\n binaries:\\n - { path: /usr/local/bin/node }\\n - { path: /usr/local/bin/openclaw }\\n';
|
||||
const telemetryBlock = '\\n mem0_telemetry:\\n name: mem0_telemetry\\n endpoints:\\n - host: us.i.posthog.com\\n port: 443\\n access: full\\n binaries:\\n - { path: /usr/local/bin/node }\\n - { path: /usr/local/bin/openclaw }\\n';
|
||||
if (!c.includes('mem0_api')) {
|
||||
if (c.includes('# ── Messaging')) {
|
||||
c = c.replace(' # ── Messaging', mem0Block + '\\n # ── Messaging');
|
||||
} else {
|
||||
c += mem0Block;
|
||||
}
|
||||
}
|
||||
if (!c.includes('mem0_telemetry')) {
|
||||
if (c.includes('mem0_api:')) {
|
||||
c = c.replace(' mem0_api:', telemetryBlock + '\\n mem0_api:');
|
||||
} else {
|
||||
c += telemetryBlock;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync('/tmp/nemoclaw-mem0-policy.yaml', c);
|
||||
console.log('ok');
|
||||
\""
|
||||
|
||||
success "Custom policy file created"
|
||||
|
||||
# Apply the policy
|
||||
info "Applying network policy..."
|
||||
if ! run_cmd "openshell policy set '$SANDBOX_NAME' --policy /tmp/nemoclaw-mem0-policy.yaml --wait 2>&1"; then
|
||||
error "Failed to apply network policy."
|
||||
echo ""
|
||||
echo " If you see 'sandbox not found', re-run: nemoclaw onboard"
|
||||
echo " Then re-run this script."
|
||||
echo ""
|
||||
die "Network policy update failed."
|
||||
fi
|
||||
|
||||
success "Network policy applied — api.mem0.ai and telemetry allowed"
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# STEP 3: Configure Plugin
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Step 3: Configuring Mem0 plugin"
|
||||
|
||||
echo ""
|
||||
echo -e " ${DIM}Get your Mem0 API key from: https://app.mem0.ai${NC}"
|
||||
echo -e " ${DIM}The key starts with 'm0-'${NC}"
|
||||
echo ""
|
||||
ask "Enter your Mem0 API key: "
|
||||
read -r MEM0_API_KEY
|
||||
|
||||
if [[ -z "$MEM0_API_KEY" ]]; then
|
||||
die "Mem0 API key is required."
|
||||
fi
|
||||
|
||||
if [[ ! "$MEM0_API_KEY" =~ ^m0- ]]; then
|
||||
warn "Key doesn't start with 'm0-'. Make sure this is correct."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e " ${DIM}The user ID scopes all memories. Pick any unique identifier.${NC}"
|
||||
echo -e " ${DIM}Examples: alice, user_123, your-email@example.com${NC}"
|
||||
echo ""
|
||||
ask "Enter user ID [$MEM0_USER_ID]: "
|
||||
read -r custom_user_id
|
||||
MEM0_USER_ID="${custom_user_id:-$MEM0_USER_ID}"
|
||||
|
||||
info "Configuring plugin inside sandbox..."
|
||||
|
||||
CONFIG_SCRIPT="openclaw config set plugins.slots.memory openclaw-mem0 2>&1 | tail -1 && \
|
||||
openclaw config set plugins.entries.openclaw-mem0.enabled true 2>&1 | tail -1 && \
|
||||
openclaw config set plugins.entries.openclaw-mem0.config.apiKey '$MEM0_API_KEY' 2>&1 | tail -1 && \
|
||||
openclaw config set plugins.entries.openclaw-mem0.config.userId '$MEM0_USER_ID' 2>&1 | tail -1 && \
|
||||
echo SETUP_DONE"
|
||||
|
||||
CONFIG_OUTPUT=$(sandbox_exec "$CONFIG_SCRIPT" || true)
|
||||
|
||||
if echo "$CONFIG_OUTPUT" | grep -q "SETUP_DONE"; then
|
||||
success "Plugin configured (mode: platform, user: $MEM0_USER_ID)"
|
||||
else
|
||||
warn "Could not verify config. You may need to configure manually:"
|
||||
echo ""
|
||||
if $IS_MACOS; then
|
||||
echo " docker exec -it $CONTAINER_NAME bash"
|
||||
echo " export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh"
|
||||
fi
|
||||
echo " nemoclaw $SANDBOX_NAME connect"
|
||||
echo " openclaw config set plugins.slots.memory openclaw-mem0"
|
||||
echo " openclaw config set plugins.entries.openclaw-mem0.enabled true"
|
||||
echo " openclaw config set plugins.entries.openclaw-mem0.config.apiKey \"$MEM0_API_KEY\""
|
||||
echo " openclaw config set plugins.entries.openclaw-mem0.config.userId \"$MEM0_USER_ID\""
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Done
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Verification"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}${GREEN}║ Setup Complete! ║${NC}"
|
||||
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Sandbox:${NC} $SANDBOX_NAME"
|
||||
echo -e " ${BOLD}Plugin:${NC} @mem0/openclaw-mem0 (platform mode)"
|
||||
echo -e " ${BOLD}User ID:${NC} $MEM0_USER_ID"
|
||||
if $IS_MACOS; then
|
||||
echo -e " ${BOLD}Container:${NC} $CONTAINER_NAME"
|
||||
fi
|
||||
echo ""
|
||||
echo -e " ${BOLD}${CYAN}Next steps:${NC}"
|
||||
echo ""
|
||||
|
||||
if $IS_MACOS; then
|
||||
echo -e " 1. Open a shell in the container:"
|
||||
echo ""
|
||||
echo -e " ${DIM}docker exec -it $CONTAINER_NAME bash${NC}"
|
||||
echo -e " ${DIM}export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh${NC}"
|
||||
echo ""
|
||||
echo -e " 2. Connect to the sandbox and start the gateway:"
|
||||
echo ""
|
||||
echo -e " ${DIM}nemoclaw $SANDBOX_NAME connect${NC}"
|
||||
echo -e " ${DIM}nemoclaw-start${NC}"
|
||||
else
|
||||
echo -e " 1. Connect to the sandbox and start the gateway:"
|
||||
echo ""
|
||||
echo -e " ${DIM}source ~/.bashrc${NC}"
|
||||
echo -e " ${DIM}nemoclaw $SANDBOX_NAME connect${NC}"
|
||||
echo -e " ${DIM}nemoclaw-start${NC}"
|
||||
fi
|
||||
echo ""
|
||||
echo -e " Then verify the plugin loaded (look for 'openclaw-mem0: registered'):"
|
||||
echo ""
|
||||
echo -e " ${DIM}openclaw plugins list${NC}"
|
||||
echo ""
|
||||
echo -e " Test auto-capture (storing memories):"
|
||||
echo ""
|
||||
echo -e " ${DIM}openclaw agent --agent main --local -m \"My name is Alice\" --session-id test1${NC}"
|
||||
echo ""
|
||||
echo -e " Test auto-recall (new session, memories should appear):"
|
||||
echo ""
|
||||
echo -e " ${DIM}openclaw agent --agent main --local -m \"What do you know about me?\" --session-id test2${NC}"
|
||||
echo ""
|
||||
echo -e " Or use the interactive TUI:"
|
||||
echo ""
|
||||
echo -e " ${DIM}openclaw tui${NC}"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Note:${NC} You may see 'Telemetry event capture failed' errors."
|
||||
echo -e " These are harmless and do not affect memory functionality."
|
||||
echo ""
|
||||
echo -e " ${BOLD}Documentation:${NC} https://docs.mem0.ai"
|
||||
echo -e " ${BOLD}Plugin source:${NC} https://www.npmjs.com/package/@mem0/openclaw-mem0"
|
||||
echo ""
|
||||
@@ -1,272 +0,0 @@
|
||||
# Mem0 Plugin for NemoClaw — Quickstart
|
||||
|
||||
Add persistent long-term memory to your [NemoClaw](https://docs.nvidia.com/nemoclaw/latest/get-started/quickstart.html) OpenClaw agent using the `@mem0/openclaw-mem0` plugin.
|
||||
|
||||
> **Note:** This plugin requires **Mem0 Platform mode** (i.e., a Mem0 API key from [app.mem0.ai](https://app.mem0.ai)). Open-source mode is not supported in NemoClaw sandboxes because the sandbox proxy blocks `/v1/embeddings` requests required by the open-source backend. See [Known Limitations](#known-limitations) for details.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Resource | Recommended | Minimum |
|
||||
|----------|-------------|---------|
|
||||
| CPU | 4+ vCPU | 2 vCPU |
|
||||
| RAM | 16 GB | 8 GB |
|
||||
| Disk | 40 GB free | 20 GB free |
|
||||
|
||||
**Accounts required:**
|
||||
|
||||
- **NVIDIA** — sign up at [build.nvidia.com](https://build.nvidia.com), generate an API key at [build.nvidia.com/settings/api-keys](https://build.nvidia.com/settings/api-keys) (starts with `nvapi-`)
|
||||
- **Mem0** — sign up at [app.mem0.ai](https://app.mem0.ai), generate an API key from the dashboard (starts with `m0-`)
|
||||
|
||||
**Supported platforms:** Ubuntu 22.04+, macOS (via Docker), Windows (WSL 2 + Docker)
|
||||
|
||||
---
|
||||
|
||||
## Choose Your Path
|
||||
|
||||
### Option A: Full Setup (NemoClaw + Mem0 Plugin)
|
||||
|
||||
Use this if you **don't have NemoClaw installed yet**. The script handles everything: Docker, Node.js, NemoClaw installation, onboarding, Mem0 plugin installation, network policy, and configuration.
|
||||
|
||||
```bash
|
||||
# Download
|
||||
curl -fsSL https://raw.githubusercontent.com/mem0ai/mem0/main/examples/nemoclaw/setup-mem0-nemoclaw.sh -o setup-mem0-nemoclaw.sh
|
||||
|
||||
# Run
|
||||
chmod +x setup-mem0-nemoclaw.sh
|
||||
./setup-mem0-nemoclaw.sh
|
||||
```
|
||||
|
||||
The script runs through 7 phases:
|
||||
|
||||
| Phase | What it does | User input |
|
||||
|-------|-------------|------------|
|
||||
| 1 | Prerequisites (Docker, RAM, disk) | None (automatic) |
|
||||
| 2 | Install NemoClaw | None (automatic) |
|
||||
| 3 | NemoClaw onboarding (sandbox + k3s) | Sandbox name, NVIDIA API key |
|
||||
| 4 | Install Mem0 plugin into sandbox | None (automatic) |
|
||||
| 5 | Update network policy for `api.mem0.ai` | None (automatic) |
|
||||
| 6 | Configure plugin | Mem0 API key, user ID |
|
||||
| 7 | Verification | None |
|
||||
|
||||
### Option B: Plugin Only (NemoClaw Already Installed)
|
||||
|
||||
Use this if you **already have NemoClaw installed and onboarded** with a sandbox in `Ready` state.
|
||||
|
||||
```bash
|
||||
# Download
|
||||
curl -fsSL https://raw.githubusercontent.com/mem0ai/mem0/main/examples/nemoclaw/install-mem0-plugin.sh -o install-mem0-plugin.sh
|
||||
|
||||
# Run
|
||||
chmod +x install-mem0-plugin.sh
|
||||
./install-mem0-plugin.sh
|
||||
```
|
||||
|
||||
The script auto-detects your sandbox and runs 3 steps:
|
||||
|
||||
| Step | What it does | User input |
|
||||
|------|-------------|------------|
|
||||
| 1 | Install Mem0 plugin into sandbox | None (automatic) |
|
||||
| 2 | Update network policy for `api.mem0.ai` | None (automatic) |
|
||||
| 3 | Configure plugin | Mem0 API key, user ID |
|
||||
|
||||
---
|
||||
|
||||
## Verify the Installation
|
||||
|
||||
After either script completes, connect to the sandbox and start the gateway:
|
||||
|
||||
```bash
|
||||
source ~/.bashrc
|
||||
nemoclaw <sandbox-name> connect
|
||||
nemoclaw-start
|
||||
```
|
||||
|
||||
Look for this line in the startup output:
|
||||
|
||||
```
|
||||
openclaw-mem0: registered (mode: platform, user: your-user-id, graph: false, autoRecall: true, autoCapture: true)
|
||||
```
|
||||
|
||||
You can also verify with:
|
||||
|
||||
```bash
|
||||
openclaw plugins list
|
||||
```
|
||||
|
||||
The `Memory (Mem0)` plugin should show status **loaded**.
|
||||
|
||||
## Test the Integration
|
||||
|
||||
All test commands run **inside the sandbox**.
|
||||
|
||||
### Test 1: Auto-capture (storing memories)
|
||||
|
||||
```bash
|
||||
openclaw agent --agent main --local \
|
||||
-m "My name is Alice and I work on distributed systems" \
|
||||
--session-id test1
|
||||
```
|
||||
|
||||
Look for: `openclaw-mem0: auto-captured 1 memories`
|
||||
|
||||
### Test 2: Auto-recall (retrieving memories across sessions)
|
||||
|
||||
Start a **new session** (different `--session-id`):
|
||||
|
||||
```bash
|
||||
openclaw agent --agent main --local \
|
||||
-m "What do you know about me?" \
|
||||
--session-id test2
|
||||
```
|
||||
|
||||
Look for: `openclaw-mem0: injecting 1 memories into context (1 long-term, 0 session)`
|
||||
|
||||
The agent should respond with information from the previous session ("Alice", "distributed systems").
|
||||
|
||||
### Test 3: Interactive TUI
|
||||
|
||||
```bash
|
||||
openclaw tui
|
||||
```
|
||||
|
||||
Send messages and the plugin will automatically capture and recall memories in the background.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Configuration Reference
|
||||
|
||||
All options are set inside the sandbox via:
|
||||
|
||||
```bash
|
||||
openclaw config set plugins.entries.openclaw-mem0.config.<key> <value>
|
||||
```
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `mode` | `"platform"` \| `"open-source"` | `"platform"` | Backend mode |
|
||||
| `apiKey` | string | — | Mem0 API key (starts with `m0-`) |
|
||||
| `userId` | string | `"default"` | Unique identifier for the user |
|
||||
| `autoRecall` | boolean | `true` | Inject memories before each agent turn |
|
||||
| `autoCapture` | boolean | `true` | Store facts after each agent turn |
|
||||
| `topK` | number | `5` | Max memories per recall |
|
||||
| `searchThreshold` | number | `0.3` | Min similarity score (0-1) |
|
||||
| `orgId` | string | — | Mem0 organization ID |
|
||||
| `projectId` | string | — | Mem0 project ID |
|
||||
| `enableGraph` | boolean | `false` | Enable entity graph for relationships |
|
||||
| `customInstructions` | string | — | Rules for what Mem0 should store/exclude |
|
||||
|
||||
## Agent Memory Tools
|
||||
|
||||
Once the plugin is active, the agent can use these tools during conversations:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `memory_search` | Search memories by natural language |
|
||||
| `memory_list` | List all stored memories for a user |
|
||||
| `memory_store` | Explicitly save a fact |
|
||||
| `memory_get` | Retrieve a memory by ID |
|
||||
| `memory_forget` | Delete by ID or by query |
|
||||
|
||||
## CLI Commands
|
||||
|
||||
Run these inside the sandbox:
|
||||
|
||||
```bash
|
||||
# Search all memories (long-term + session)
|
||||
openclaw mem0 search "what languages does the user know"
|
||||
|
||||
# Search only long-term memories
|
||||
openclaw mem0 search "user preferences" --scope long-term
|
||||
|
||||
# Search only session memories
|
||||
openclaw mem0 search "current task" --scope session
|
||||
|
||||
# Memory stats
|
||||
openclaw mem0 stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
For detailed troubleshooting steps, see the [troubleshooting guide](troubleshooting-guide.pdf) included in this directory.
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### `npm tar TAR_ENTRY_ERROR ENOENT` during NemoClaw installation
|
||||
|
||||
This is a known npm bug where concurrent tar extraction races cause `ENOENT` errors on deeply nested packages. The `setup-mem0-nemoclaw.sh` script works around this by cloning the NemoClaw repo and installing with `--maxsockets=1` to serialize downloads. If you installed NemoClaw manually and hit this error:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 https://github.com/NVIDIA/NemoClaw.git ~/.nemoclaw-src
|
||||
cd ~/.nemoclaw-src
|
||||
npm install --maxsockets=1
|
||||
npm link
|
||||
```
|
||||
|
||||
#### `sandbox not found` during onboarding step 7
|
||||
|
||||
The gateway restarted during onboarding and lost the sandbox state. Re-run `nemoclaw onboard`. When prompted that the sandbox already exists, choose `y` to recreate it.
|
||||
|
||||
#### `npm error 403 Forbidden` when installing plugin inside sandbox
|
||||
|
||||
The OpenShell gateway's TLS proxy blocks scoped npm packages (the `%2f` in the URL). Use the manual installation method (Method 2 in the scripts) which downloads outside the sandbox and uploads the tarball.
|
||||
|
||||
#### `capture failed: Connection error` or `recall failed: Connection error`
|
||||
|
||||
The network policy is not applied or missing the `mem0_api` entry. Re-run the plugin install script or manually apply the policy:
|
||||
|
||||
```bash
|
||||
openshell policy set <sandbox-name> --policy /tmp/nemoclaw-mem0-policy.yaml --wait
|
||||
```
|
||||
|
||||
#### `Telemetry event capture failed: TypeError: fetch failed`
|
||||
|
||||
This is harmless. The Mem0 SDK's telemetry endpoint (`us.i.posthog.com`) is blocked by the sandbox proxy. It does not affect memory functionality.
|
||||
|
||||
#### Plugin shows `disabled` in `openclaw plugins list`
|
||||
|
||||
The memory slot is not set to `openclaw-mem0`. Inside the sandbox, run:
|
||||
|
||||
```bash
|
||||
openclaw config set plugins.slots.memory openclaw-mem0
|
||||
```
|
||||
|
||||
#### `K8s namespace not ready` on Ubuntu 24.04
|
||||
|
||||
Ubuntu 24.04 defaults to cgroup v2 which causes k3s (used by NemoClaw) to fail. Apply the cgroup fix:
|
||||
|
||||
```bash
|
||||
sudo python3 -c "
|
||||
import json, os
|
||||
p = '/etc/docker/daemon.json'
|
||||
c = json.load(open(p)) if os.path.exists(p) else {}
|
||||
c['default-cgroupns-mode'] = 'host'
|
||||
json.dump(c, open(p, 'w'), indent=2)
|
||||
"
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
Then re-run `nemoclaw onboard`.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
**Open-source mode is not supported in NemoClaw sandboxes.** The Mem0 open-source mode requires calling `/v1/embeddings` on an external LLM provider. NemoClaw's sandbox proxy intercepts all OpenAI-compatible API requests but only allows `/v1/chat/completions` through. Use **platform mode** instead.
|
||||
|
||||
---
|
||||
|
||||
## Files in This Directory
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `setup-mem0-nemoclaw.sh` | Full setup script (NemoClaw + Mem0 plugin) |
|
||||
| `install-mem0-plugin.sh` | Plugin-only install script (NemoClaw already set up) |
|
||||
| `troubleshooting-guide.pdf` | Detailed setup and troubleshooting guide |
|
||||
| `quickstart.md` | This file |
|
||||
|
||||
## Links
|
||||
|
||||
- [Mem0 Documentation](https://docs.mem0.ai)
|
||||
- [NemoClaw Documentation](https://docs.nvidia.com/nemoclaw/latest/get-started/quickstart.html)
|
||||
- [`@mem0/openclaw-mem0` on npm](https://www.npmjs.com/package/@mem0/openclaw-mem0)
|
||||
- [Mem0 Dashboard](https://app.mem0.ai)
|
||||
@@ -1,864 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# setup-mem0-nemoclaw.sh
|
||||
#
|
||||
# One-script setup for NemoClaw + Mem0 OpenClaw plugin.
|
||||
# Supports Ubuntu servers (native) and macOS (via Docker container).
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://raw.githubusercontent.com/mem0ai/mem0/main/scripts/setup-mem0-nemoclaw.sh | bash
|
||||
# # or
|
||||
# chmod +x setup-mem0-nemoclaw.sh && ./setup-mem0-nemoclaw.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - Ubuntu 22.04+ OR macOS with Docker Desktop OR Windows with WSL 2 + Docker Desktop
|
||||
# - 8 GB RAM minimum (16 GB recommended)
|
||||
# - 40 GB free disk (20 GB minimum)
|
||||
# - NVIDIA API key (from build.nvidia.com)
|
||||
# - Mem0 API key (from app.mem0.ai)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colors and formatting ────────────────────────────────────────────────────
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
success() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||||
step() { echo -e "\n${BOLD}${CYAN}── $* ──${NC}\n"; }
|
||||
ask() { echo -en "${BOLD}$*${NC}"; }
|
||||
|
||||
die() {
|
||||
error "$*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Defaults ─────────────────────────────────────────────────────────────────
|
||||
|
||||
SANDBOX_NAME="${SANDBOX_NAME:-my-assistant}"
|
||||
MEM0_USER_ID="${MEM0_USER_ID:-default}"
|
||||
MIN_RAM_MB=6000
|
||||
MIN_DISK_MB=15000
|
||||
PLUGIN_PKG="@mem0/openclaw-mem0"
|
||||
CONTAINER_NAME="nemoclaw-dev"
|
||||
|
||||
# ── Detect platform ─────────────────────────────────────────────────────────
|
||||
|
||||
OS_TYPE="$(uname -s)"
|
||||
IS_MACOS=false
|
||||
IS_LINUX=false
|
||||
IS_WSL=false
|
||||
|
||||
case "$OS_TYPE" in
|
||||
Darwin) IS_MACOS=true ;;
|
||||
Linux)
|
||||
IS_LINUX=true
|
||||
# Detect WSL (Windows Subsystem for Linux)
|
||||
if grep -qi "microsoft\|wsl" /proc/version 2>/dev/null; then
|
||||
IS_WSL=true
|
||||
fi
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
error "This script cannot run in Git Bash, MSYS2, or Cygwin."
|
||||
echo ""
|
||||
echo " NemoClaw requires a full Linux environment. On Windows, use WSL 2:"
|
||||
echo ""
|
||||
echo " 1. Open PowerShell as Administrator and run:"
|
||||
echo " wsl --install -d Ubuntu-24.04"
|
||||
echo ""
|
||||
echo " 2. Restart your computer when prompted"
|
||||
echo ""
|
||||
echo " 3. Open 'Ubuntu' from the Start menu (this opens a WSL 2 shell)"
|
||||
echo ""
|
||||
echo " 4. Install Docker Desktop for Windows:"
|
||||
echo " https://www.docker.com/products/docker-desktop/"
|
||||
echo " Enable 'Use the WSL 2 based engine' in Docker Desktop settings"
|
||||
echo " Enable 'Ubuntu-24.04' under Resources → WSL Integration"
|
||||
echo ""
|
||||
echo " 5. In the Ubuntu WSL 2 shell, re-run this script:"
|
||||
echo " curl -fsSL https://raw.githubusercontent.com/mem0ai/mem0/main/scripts/setup-mem0-nemoclaw.sh | bash"
|
||||
echo ""
|
||||
die "Please use WSL 2 instead."
|
||||
;;
|
||||
*)
|
||||
die "Unsupported OS: $OS_TYPE. This script supports Linux (Ubuntu), macOS, and Windows (WSL 2)."
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Helper functions ─────────────────────────────────────────────────────────
|
||||
|
||||
check_command() {
|
||||
command -v "$1" &>/dev/null
|
||||
}
|
||||
|
||||
get_ram_mb() {
|
||||
if $IS_MACOS; then
|
||||
sysctl -n hw.memsize 2>/dev/null | awk '{printf "%.0f", $1/1024/1024}' || echo "0"
|
||||
else
|
||||
free -m 2>/dev/null | awk '/^Mem:/ {print $2}' || echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
get_disk_mb() {
|
||||
if $IS_MACOS; then
|
||||
df -m / 2>/dev/null | awk 'NR==2 {print $4}' || echo "0"
|
||||
else
|
||||
df -m / 2>/dev/null | awk 'NR==2 {print $4}' || echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_nvm() {
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
if [[ -s "$NVM_DIR/nvm.sh" ]]; then
|
||||
source "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_path() {
|
||||
for p in "$HOME/.local/bin" "$HOME/.nvm/versions/node/"*/bin; do
|
||||
if [[ -d "$p" ]] && [[ ":$PATH:" != *":$p:"* ]]; then
|
||||
export PATH="$p:$PATH"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_sandbox() {
|
||||
local name="$1"
|
||||
local timeout=120
|
||||
local elapsed=0
|
||||
while (( elapsed < timeout )); do
|
||||
local phase
|
||||
phase=$($RUN_CMD openshell sandbox get "$name" 2>/dev/null | grep -i "phase" | awk '{print $NF}' || true)
|
||||
if [[ "$phase" == "Ready" ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── macOS: run command inside the container ──────────────────────────────────
|
||||
# On macOS, NemoClaw runs inside a Docker container. All nemoclaw/openshell
|
||||
# commands must be exec'd into the container. On Linux, they run natively.
|
||||
|
||||
setup_run_cmd() {
|
||||
if $IS_MACOS; then
|
||||
RUN_CMD="docker exec $CONTAINER_NAME bash -c 'export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh && "
|
||||
RUN_CMD_SUFFIX="'"
|
||||
RUN_CMD_IT="docker exec -it $CONTAINER_NAME bash -c 'export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh && "
|
||||
RUN_CMD_IT_SUFFIX="'"
|
||||
else
|
||||
RUN_CMD=""
|
||||
RUN_CMD_SUFFIX=""
|
||||
RUN_CMD_IT=""
|
||||
RUN_CMD_IT_SUFFIX=""
|
||||
fi
|
||||
}
|
||||
|
||||
# Wrapper to run a command natively (Linux) or in the container (macOS)
|
||||
run_cmd() {
|
||||
if $IS_MACOS; then
|
||||
docker exec "$CONTAINER_NAME" bash -c "export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh && $*"
|
||||
else
|
||||
eval "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_cmd_it() {
|
||||
if $IS_MACOS; then
|
||||
docker exec -it "$CONTAINER_NAME" bash -c "export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh && $*"
|
||||
else
|
||||
eval "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Banner ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ Mem0 Plugin Setup for NemoClaw ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}║ Long-term memory for your OpenClaw agent ║${NC}"
|
||||
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
if $IS_MACOS; then
|
||||
info "Platform: macOS (will use Docker container)"
|
||||
elif $IS_WSL; then
|
||||
info "Platform: Windows (WSL 2)"
|
||||
else
|
||||
info "Platform: Linux"
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# PHASE 1: Prerequisites
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Phase 1: Checking prerequisites"
|
||||
|
||||
# ── OS check ─────────────────────────────────────────────────────────────────
|
||||
|
||||
if $IS_LINUX; then
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
source /etc/os-release
|
||||
if [[ "$ID" != "ubuntu" ]]; then
|
||||
warn "Detected OS: $PRETTY_NAME (not Ubuntu). This script is tested on Ubuntu 22.04+."
|
||||
ask "Continue anyway? [y/N]: "
|
||||
read -r cont
|
||||
[[ "$cont" =~ ^[Yy]$ ]] || exit 0
|
||||
else
|
||||
success "OS: $PRETTY_NAME"
|
||||
fi
|
||||
else
|
||||
warn "Cannot detect Linux distribution. Proceeding anyway."
|
||||
fi
|
||||
elif $IS_MACOS; then
|
||||
MACOS_VERSION=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
|
||||
success "OS: macOS $MACOS_VERSION"
|
||||
fi
|
||||
|
||||
# ── RAM check ────────────────────────────────────────────────────────────────
|
||||
|
||||
RAM_MB=$(get_ram_mb)
|
||||
if (( RAM_MB < MIN_RAM_MB )); then
|
||||
error "Insufficient RAM: ${RAM_MB} MB available, ${MIN_RAM_MB} MB required."
|
||||
echo ""
|
||||
echo " NemoClaw needs at least 8 GB RAM (16 GB recommended)."
|
||||
if $IS_WSL; then
|
||||
echo ""
|
||||
echo " WSL 2 may have limited memory. Increase it:"
|
||||
echo " 1. Create/edit %USERPROFILE%\\.wslconfig in Windows"
|
||||
echo " 2. Add:"
|
||||
echo " [wsl2]"
|
||||
echo " memory=8GB"
|
||||
echo " 3. Restart WSL: wsl --shutdown (from PowerShell)"
|
||||
elif $IS_LINUX; then
|
||||
echo ""
|
||||
echo " If running on AWS EC2:"
|
||||
echo " 1. Stop the instance"
|
||||
echo " 2. Change instance type to t3.large (8 GB) or t3.xlarge (16 GB)"
|
||||
echo " 3. Start the instance and re-run this script"
|
||||
fi
|
||||
echo ""
|
||||
die "Aborting due to insufficient RAM."
|
||||
else
|
||||
success "RAM: ${RAM_MB} MB available"
|
||||
fi
|
||||
|
||||
# ── Disk check ───────────────────────────────────────────────────────────────
|
||||
|
||||
DISK_MB=$(get_disk_mb)
|
||||
if (( DISK_MB < MIN_DISK_MB )); then
|
||||
error "Insufficient disk space: ${DISK_MB} MB available, ${MIN_DISK_MB} MB required."
|
||||
echo ""
|
||||
echo " NemoClaw needs at least 20 GB free disk (40 GB recommended)."
|
||||
echo ""
|
||||
echo " Quick fix:"
|
||||
echo " docker system prune -a -f # Remove unused Docker data"
|
||||
if $IS_LINUX; then
|
||||
echo ""
|
||||
echo " If running on AWS EC2, expand the EBS volume:"
|
||||
echo " 1. Go to AWS Console → EC2 → Volumes"
|
||||
echo " 2. Select the volume, Actions → Modify Volume → increase size"
|
||||
echo " 3. Then run:"
|
||||
echo " sudo growpart /dev/xvda 1"
|
||||
echo " sudo resize2fs /dev/xvda1"
|
||||
fi
|
||||
echo ""
|
||||
die "Aborting due to insufficient disk space."
|
||||
else
|
||||
success "Disk: ${DISK_MB} MB available"
|
||||
fi
|
||||
|
||||
# ── Docker check ─────────────────────────────────────────────────────────────
|
||||
|
||||
if ! check_command docker; then
|
||||
if $IS_MACOS; then
|
||||
error "Docker not found."
|
||||
echo ""
|
||||
echo " Install Docker Desktop for macOS:"
|
||||
echo " https://www.docker.com/products/docker-desktop/"
|
||||
echo ""
|
||||
echo " After installing, start Docker Desktop and re-run this script."
|
||||
echo ""
|
||||
die "Docker Desktop is required on macOS."
|
||||
elif $IS_WSL; then
|
||||
error "Docker not found inside WSL."
|
||||
echo ""
|
||||
echo " Docker Desktop must be installed on Windows with WSL 2 integration enabled:"
|
||||
echo ""
|
||||
echo " 1. Install Docker Desktop for Windows:"
|
||||
echo " https://www.docker.com/products/docker-desktop/"
|
||||
echo ""
|
||||
echo " 2. Open Docker Desktop → Settings → General:"
|
||||
echo " ✓ Enable 'Use the WSL 2 based engine'"
|
||||
echo ""
|
||||
echo " 3. Open Docker Desktop → Settings → Resources → WSL Integration:"
|
||||
echo " ✓ Enable integration with your Ubuntu distribution"
|
||||
echo ""
|
||||
echo " 4. Click 'Apply & restart', then re-run this script in WSL."
|
||||
echo ""
|
||||
die "Docker Desktop WSL 2 integration is required."
|
||||
else
|
||||
info "Docker not found. Installing Docker..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq docker.io >/dev/null 2>&1
|
||||
sudo systemctl enable --now docker
|
||||
sudo usermod -aG docker "$USER"
|
||||
success "Docker installed"
|
||||
fi
|
||||
fi
|
||||
|
||||
if $IS_LINUX; then
|
||||
# Ensure Docker daemon is running
|
||||
if ! sudo docker info &>/dev/null; then
|
||||
info "Starting Docker daemon..."
|
||||
sudo systemctl start docker
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Handle Docker group permissions
|
||||
if ! docker info &>/dev/null; then
|
||||
if id -nG "$USER" | grep -qw docker || grep -q "^docker:.*\b${USER}\b" /etc/group; then
|
||||
info "Activating docker group for current session..."
|
||||
exec sg docker -c "$0 $*"
|
||||
else
|
||||
info "Adding $USER to docker group..."
|
||||
sudo usermod -aG docker "$USER"
|
||||
info "Activating docker group for current session..."
|
||||
exec sg docker -c "$0 $*"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# macOS: just verify Docker is responding
|
||||
if ! docker info &>/dev/null; then
|
||||
error "Docker is not running."
|
||||
echo ""
|
||||
echo " Start Docker Desktop and wait for it to be ready, then re-run this script."
|
||||
echo ""
|
||||
die "Docker Desktop is not running."
|
||||
fi
|
||||
fi
|
||||
|
||||
success "Docker: running ($(docker --version | awk '{print $3}' | tr -d ','))"
|
||||
|
||||
# ── Linux-only: cgroup v2 fix for Ubuntu 24.04 ──────────────────────────────
|
||||
|
||||
if $IS_LINUX && [[ "${VERSION_ID:-}" == "24.04" ]]; then
|
||||
DAEMON_JSON="/etc/docker/daemon.json"
|
||||
NEEDS_CGROUP_FIX=false
|
||||
|
||||
if [[ ! -f "$DAEMON_JSON" ]]; then
|
||||
NEEDS_CGROUP_FIX=true
|
||||
elif ! grep -q '"default-cgroupns-mode"' "$DAEMON_JSON" 2>/dev/null; then
|
||||
NEEDS_CGROUP_FIX=true
|
||||
fi
|
||||
|
||||
if $NEEDS_CGROUP_FIX; then
|
||||
warn "Ubuntu 24.04 detected — applying cgroup v2 fix for Docker."
|
||||
warn "This prevents 'K8s namespace not ready' errors during onboarding."
|
||||
sudo python3 -c "
|
||||
import json, os
|
||||
p = '$DAEMON_JSON'
|
||||
c = json.load(open(p)) if os.path.exists(p) else {}
|
||||
c['default-cgroupns-mode'] = 'host'
|
||||
json.dump(c, open(p, 'w'), indent=2)
|
||||
"
|
||||
sudo systemctl restart docker
|
||||
success "Docker cgroup v2 fix applied"
|
||||
else
|
||||
success "Docker cgroup v2: already configured"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Linux-only: Swap check ──────────────────────────────────────────────────
|
||||
|
||||
if $IS_LINUX; then
|
||||
SWAP_MB=$(free -m 2>/dev/null | awk '/^Swap:/ {print $2}' || echo "0")
|
||||
if (( RAM_MB < 12000 && SWAP_MB < 2000 )); then
|
||||
warn "Low RAM (${RAM_MB} MB) and low swap. Adding 4 GB swap to prevent OOM kills."
|
||||
if [[ ! -f /swapfile ]]; then
|
||||
sudo fallocate -l 4G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile >/dev/null
|
||||
sudo swapon /swapfile
|
||||
success "4 GB swap enabled"
|
||||
else
|
||||
success "Swap file already exists"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── macOS-only: Create Docker container ──────────────────────────────────────
|
||||
|
||||
if $IS_MACOS; then
|
||||
step "Phase 1b: Setting up NemoClaw Docker container"
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
success "Container '$CONTAINER_NAME' is already running"
|
||||
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
info "Starting existing container '$CONTAINER_NAME'..."
|
||||
docker start "$CONTAINER_NAME"
|
||||
success "Container started"
|
||||
else
|
||||
info "Creating Ubuntu container '$CONTAINER_NAME' for NemoClaw..."
|
||||
echo -e " ${DIM}This container runs NemoClaw with --network host and Docker socket access.${NC}"
|
||||
|
||||
docker run -d \
|
||||
--name "$CONTAINER_NAME" \
|
||||
--privileged \
|
||||
--network host \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
ubuntu:24.04 sleep infinity
|
||||
|
||||
success "Container '$CONTAINER_NAME' created"
|
||||
|
||||
info "Installing dependencies inside container..."
|
||||
docker exec "$CONTAINER_NAME" bash -c "apt-get update -qq && apt-get install -y -qq curl git docker.io >/dev/null 2>&1"
|
||||
success "Dependencies installed"
|
||||
fi
|
||||
|
||||
# Check if NemoClaw is installed in the container
|
||||
NEMO_IN_CONTAINER=$(docker exec "$CONTAINER_NAME" bash -c "export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh 2>/dev/null && command -v nemoclaw" 2>/dev/null || true)
|
||||
if [[ -z "$NEMO_IN_CONTAINER" ]]; then
|
||||
info "Installing NemoClaw inside container (this takes a few minutes)..."
|
||||
# The NVIDIA installer triggers npm tar race conditions. Workaround: clone
|
||||
# and install with --maxsockets=1 to serialize downloads.
|
||||
docker exec -it "$CONTAINER_NAME" bash -c "
|
||||
export NVM_DIR=/root/.nvm &&
|
||||
if [ ! -s \"\$NVM_DIR/nvm.sh\" ]; then
|
||||
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash &&
|
||||
source \"\$NVM_DIR/nvm.sh\" &&
|
||||
nvm install 22
|
||||
else
|
||||
source \"\$NVM_DIR/nvm.sh\"
|
||||
fi &&
|
||||
git clone --depth 1 https://github.com/NVIDIA/NemoClaw.git /root/.nemoclaw-src &&
|
||||
cd /root/.nemoclaw-src &&
|
||||
npm install --maxsockets=1 &&
|
||||
npm link
|
||||
"
|
||||
success "NemoClaw installed in container"
|
||||
else
|
||||
success "NemoClaw already installed in container"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# PHASE 2: Install NemoClaw (Linux-only, macOS handled above)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
if $IS_LINUX; then
|
||||
step "Phase 2: Installing NemoClaw"
|
||||
|
||||
ensure_nvm
|
||||
ensure_path
|
||||
|
||||
if check_command nemoclaw; then
|
||||
success "NemoClaw already installed: $(nemoclaw --version 2>/dev/null || echo 'found')"
|
||||
else
|
||||
info "Installing NemoClaw (this takes a few minutes)..."
|
||||
|
||||
# Install Node.js via nvm if not available
|
||||
if ! check_command node; then
|
||||
info "Node.js not found — installing via nvm..."
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
|
||||
source "$NVM_DIR/nvm.sh"
|
||||
nvm install 22
|
||||
ensure_nvm
|
||||
ensure_path
|
||||
fi
|
||||
|
||||
# The NVIDIA installer (curl | bash) triggers npm tar race conditions
|
||||
# causing ENOENT errors on deeply nested packages. Workaround: clone the
|
||||
# repo and install with --maxsockets=1 to serialize downloads.
|
||||
NEMOCLAW_DIR="$HOME/.nemoclaw-src"
|
||||
rm -rf "$NEMOCLAW_DIR"
|
||||
info "Cloning NemoClaw from GitHub..."
|
||||
git clone --depth 1 https://github.com/NVIDIA/NemoClaw.git "$NEMOCLAW_DIR"
|
||||
cd "$NEMOCLAW_DIR"
|
||||
info "Installing dependencies (serialized to avoid tar race)..."
|
||||
npm install --maxsockets=1
|
||||
npm link
|
||||
cd - >/dev/null
|
||||
|
||||
ensure_nvm
|
||||
ensure_path
|
||||
source "$HOME/.bashrc" 2>/dev/null || true
|
||||
hash -r 2>/dev/null || true
|
||||
|
||||
if ! check_command nemoclaw; then
|
||||
# npm link may place the binary outside the current PATH; find and add it
|
||||
NEMOCLAW_BIN=$(find "$HOME/.nvm" -name nemoclaw \( -type f -o -type l \) -path "*/bin/*" 2>/dev/null | head -1)
|
||||
if [[ -n "$NEMOCLAW_BIN" ]]; then
|
||||
export PATH="$(dirname "$NEMOCLAW_BIN"):$PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! check_command nemoclaw; then
|
||||
die "NemoClaw installation failed. Try: source ~/.bashrc && nemoclaw --help"
|
||||
fi
|
||||
|
||||
success "NemoClaw installed"
|
||||
fi
|
||||
|
||||
ensure_path
|
||||
if ! check_command openshell; then
|
||||
warn "openshell not on PATH yet — it will be installed during onboarding."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# PHASE 3: NemoClaw Onboarding
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Phase 3: NemoClaw Onboarding"
|
||||
|
||||
# Check if a sandbox already exists
|
||||
EXISTING_SANDBOX=""
|
||||
if $IS_MACOS; then
|
||||
EXISTING_SANDBOX=$(run_cmd "openshell sandbox list 2>/dev/null" | awk 'NR>1 && $1!="" {print $1; exit}' || true)
|
||||
else
|
||||
ensure_path
|
||||
if check_command openshell; then
|
||||
EXISTING_SANDBOX=$(openshell sandbox list 2>/dev/null | awk 'NR>1 && $1!="" {print $1; exit}' || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$EXISTING_SANDBOX" ]]; then
|
||||
success "Sandbox already exists: $EXISTING_SANDBOX"
|
||||
SANDBOX_NAME="$EXISTING_SANDBOX"
|
||||
ask "Use existing sandbox '$SANDBOX_NAME'? [Y/n]: "
|
||||
read -r use_existing
|
||||
if [[ "$use_existing" =~ ^[Nn]$ ]]; then
|
||||
ask "Enter sandbox name [my-assistant]: "
|
||||
read -r custom_name
|
||||
SANDBOX_NAME="${custom_name:-my-assistant}"
|
||||
info "Running NemoClaw onboarding..."
|
||||
echo -e " ${DIM}You'll need your NVIDIA API key (nvapi-...) from build.nvidia.com${NC}"
|
||||
echo ""
|
||||
run_cmd_it "nemoclaw onboard"
|
||||
if $IS_LINUX; then ensure_path; fi
|
||||
fi
|
||||
else
|
||||
info "No existing sandbox found. Running NemoClaw onboarding..."
|
||||
echo ""
|
||||
echo -e " ${DIM}The onboarding wizard will guide you through 7 steps:${NC}"
|
||||
echo -e " ${DIM} 1. Preflight checks (automatic)${NC}"
|
||||
echo -e " ${DIM} 2. Start gateway (automatic, takes 1-2 min)${NC}"
|
||||
echo -e " ${DIM} 3. Sandbox name — enter a name (e.g. my-assistant)${NC}"
|
||||
echo -e " ${DIM} 4. NVIDIA API key — paste your nvapi-... key${NC}"
|
||||
echo -e " ${DIM} 5. Inference provider (automatic)${NC}"
|
||||
echo -e " ${DIM} 6. OpenClaw setup (automatic)${NC}"
|
||||
echo -e " ${DIM} 7. Policy presets — type Y to apply pypi and npm${NC}"
|
||||
echo ""
|
||||
ask "Press Enter to start onboarding..."
|
||||
read -r
|
||||
|
||||
run_cmd_it "nemoclaw onboard"
|
||||
|
||||
if $IS_LINUX; then
|
||||
ensure_nvm
|
||||
ensure_path
|
||||
fi
|
||||
|
||||
# Detect sandbox name
|
||||
SANDBOX_NAME=$(run_cmd "openshell sandbox list 2>/dev/null" | awk 'NR>1 && $1!="" {print $1; exit}' || echo "$SANDBOX_NAME")
|
||||
fi
|
||||
|
||||
# Verify sandbox exists
|
||||
SANDBOX_PHASE=$(run_cmd "openshell sandbox get '$SANDBOX_NAME' 2>/dev/null" | grep -i "phase" | awk '{print $NF}' || true)
|
||||
if [[ "$SANDBOX_PHASE" != "Ready" ]]; then
|
||||
warn "Sandbox '$SANDBOX_NAME' is not in Ready state (current: ${SANDBOX_PHASE:-unknown})."
|
||||
warn "Waiting up to 2 minutes..."
|
||||
WAIT_OK=false
|
||||
for i in $(seq 1 24); do
|
||||
SANDBOX_PHASE=$(run_cmd "openshell sandbox get '$SANDBOX_NAME' 2>/dev/null" | grep -i "phase" | awk '{print $NF}' || true)
|
||||
if [[ "$SANDBOX_PHASE" == "Ready" ]]; then
|
||||
WAIT_OK=true
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
if ! $WAIT_OK; then
|
||||
die "Sandbox '$SANDBOX_NAME' did not become Ready. Run: openshell sandbox list"
|
||||
fi
|
||||
fi
|
||||
|
||||
success "Sandbox '$SANDBOX_NAME' is ready"
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# PHASE 4: Install Mem0 Plugin
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Phase 4: Installing Mem0 plugin ($PLUGIN_PKG)"
|
||||
|
||||
# Helper: run a command inside the sandbox non-interactively via piped stdin
|
||||
sandbox_exec() {
|
||||
local cmd="$1"
|
||||
if $IS_MACOS; then
|
||||
printf '%s\nexit\n' "$cmd" | docker exec -i "$CONTAINER_NAME" bash -c \
|
||||
"export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh && openshell sandbox connect '$SANDBOX_NAME'" 2>&1
|
||||
else
|
||||
printf '%s\nexit\n' "$cmd" | openshell sandbox connect "$SANDBOX_NAME" 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if plugin is already installed by trying to download a known file from the sandbox.
|
||||
# We avoid sandbox_exec for the check because piped stdin to openshell sandbox connect
|
||||
# produces shell prompt noise that causes false positives with grep.
|
||||
PLUGIN_EXISTS=false
|
||||
if run_cmd "openshell sandbox download '$SANDBOX_NAME' /sandbox/.openclaw/extensions/openclaw-mem0/package.json /tmp/_mem0_plugin_check.json" &>/dev/null; then
|
||||
if [[ -f /tmp/_mem0_plugin_check.json ]] || run_cmd "test -f /tmp/_mem0_plugin_check.json" &>/dev/null; then
|
||||
PLUGIN_EXISTS=true
|
||||
fi
|
||||
fi
|
||||
rm -f /tmp/_mem0_plugin_check.json 2>/dev/null || true
|
||||
run_cmd "rm -f /tmp/_mem0_plugin_check.json" 2>/dev/null || true
|
||||
|
||||
if $PLUGIN_EXISTS; then
|
||||
success "Mem0 plugin already installed in sandbox"
|
||||
else
|
||||
info "Downloading $PLUGIN_PKG..."
|
||||
|
||||
# Download and build outside the sandbox (on host or in container)
|
||||
run_cmd "cd /tmp && rm -rf openclaw-mem0-full mem0-openclaw-mem0-*.tgz openclaw-mem0-full.tgz"
|
||||
|
||||
if ! run_cmd "cd /tmp && npm pack '$PLUGIN_PKG' 2>/dev/null"; then
|
||||
die "Failed to download $PLUGIN_PKG from npm. Check your internet connection."
|
||||
fi
|
||||
|
||||
success "Downloaded plugin"
|
||||
|
||||
info "Installing plugin dependencies..."
|
||||
run_cmd "mkdir -p /tmp/openclaw-mem0-full && cd /tmp/openclaw-mem0-full && tar xzf /tmp/mem0-openclaw-mem0-*.tgz --strip-components=1 && npm install --omit=dev 2>&1 | tail -3"
|
||||
|
||||
success "Dependencies installed"
|
||||
|
||||
info "Uploading plugin to sandbox..."
|
||||
run_cmd "cd /tmp && tar czf openclaw-mem0-full.tgz -C openclaw-mem0-full ."
|
||||
|
||||
if ! run_cmd "openshell sandbox upload '$SANDBOX_NAME' /tmp/openclaw-mem0-full.tgz /sandbox/openclaw-mem0-full.tgz 2>&1"; then
|
||||
die "Failed to upload plugin to sandbox. Check: openshell sandbox list"
|
||||
fi
|
||||
|
||||
success "Plugin uploaded"
|
||||
|
||||
info "Extracting plugin inside sandbox..."
|
||||
sandbox_exec "mkdir -p ~/.openclaw/extensions/openclaw-mem0 && tar xzf /sandbox/openclaw-mem0-full.tgz/openclaw-mem0-full.tgz -C ~/.openclaw/extensions/openclaw-mem0 2>/dev/null || tar xzf /sandbox/openclaw-mem0-full.tgz -C ~/.openclaw/extensions/openclaw-mem0 2>/dev/null && echo EXTRACT_OK" >/dev/null 2>&1 || true
|
||||
|
||||
# Verify
|
||||
VERIFY_OK=false
|
||||
if run_cmd "openshell sandbox download '$SANDBOX_NAME' /sandbox/.openclaw/extensions/openclaw-mem0/package.json /tmp/_mem0_verify.json" &>/dev/null; then
|
||||
VERIFY_OK=true
|
||||
fi
|
||||
rm -f /tmp/_mem0_verify.json 2>/dev/null || true
|
||||
run_cmd "rm -f /tmp/_mem0_verify.json" 2>/dev/null || true
|
||||
if $VERIFY_OK; then
|
||||
success "Plugin extracted inside sandbox"
|
||||
else
|
||||
warn "Could not verify plugin extraction. You may need to extract manually."
|
||||
if $IS_MACOS; then
|
||||
echo " docker exec -it $CONTAINER_NAME bash"
|
||||
echo " export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh"
|
||||
fi
|
||||
echo " nemoclaw $SANDBOX_NAME connect"
|
||||
echo " mkdir -p ~/.openclaw/extensions/openclaw-mem0"
|
||||
echo " tar xzf /sandbox/openclaw-mem0-full.tgz/openclaw-mem0-full.tgz -C ~/.openclaw/extensions/openclaw-mem0"
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
run_cmd "rm -rf /tmp/openclaw-mem0-full /tmp/mem0-openclaw-mem0-*.tgz /tmp/openclaw-mem0-full.tgz" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# PHASE 5: Update Network Policy
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Phase 5: Updating network policy to allow api.mem0.ai and telemetry"
|
||||
|
||||
# Find baseline policy
|
||||
BASELINE_POLICY=$(run_cmd "find / -path '*/nemoclaw-blueprint/policies/openclaw-sandbox.yaml' 2>/dev/null | head -1" || true)
|
||||
|
||||
if [[ -z "$BASELINE_POLICY" ]]; then
|
||||
die "Cannot find NemoClaw baseline policy file. Is NemoClaw installed?"
|
||||
fi
|
||||
|
||||
info "Baseline policy: $BASELINE_POLICY"
|
||||
|
||||
# Check if mem0_api already exists
|
||||
HAS_MEM0=$(run_cmd "grep -c mem0_api '$BASELINE_POLICY' 2>/dev/null" || echo "0")
|
||||
|
||||
if [[ "$HAS_MEM0" != "0" ]]; then
|
||||
success "mem0_api already in baseline policy"
|
||||
else
|
||||
info "Adding api.mem0.ai to network policy..."
|
||||
fi
|
||||
|
||||
# Create custom policy with mem0_api + telemetry — use node for reliable cross-platform YAML editing
|
||||
run_cmd "node -e \"
|
||||
const fs = require('fs');
|
||||
let c = fs.readFileSync('$BASELINE_POLICY', 'utf8');
|
||||
const mem0Block = '\\n mem0_api:\\n name: mem0_api\\n endpoints:\\n - host: api.mem0.ai\\n port: 443\\n access: full\\n binaries:\\n - { path: /usr/local/bin/node }\\n - { path: /usr/local/bin/openclaw }\\n';
|
||||
const telemetryBlock = '\\n mem0_telemetry:\\n name: mem0_telemetry\\n endpoints:\\n - host: us.i.posthog.com\\n port: 443\\n access: full\\n binaries:\\n - { path: /usr/local/bin/node }\\n - { path: /usr/local/bin/openclaw }\\n';
|
||||
if (!c.includes('mem0_api')) {
|
||||
if (c.includes('# ── Messaging')) {
|
||||
c = c.replace(' # ── Messaging', mem0Block + '\\n # ── Messaging');
|
||||
} else {
|
||||
c += mem0Block;
|
||||
}
|
||||
}
|
||||
if (!c.includes('mem0_telemetry')) {
|
||||
if (c.includes('mem0_api:')) {
|
||||
c = c.replace(' mem0_api:', telemetryBlock + '\\n mem0_api:');
|
||||
} else {
|
||||
c += telemetryBlock;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync('/tmp/nemoclaw-mem0-policy.yaml', c);
|
||||
console.log('ok');
|
||||
\""
|
||||
|
||||
success "Custom policy file created"
|
||||
|
||||
# Apply the policy
|
||||
info "Applying network policy..."
|
||||
if ! run_cmd "openshell policy set '$SANDBOX_NAME' --policy /tmp/nemoclaw-mem0-policy.yaml --wait 2>&1"; then
|
||||
error "Failed to apply network policy."
|
||||
echo ""
|
||||
echo " If you see 'sandbox not found', re-run: nemoclaw onboard"
|
||||
echo " Then re-run this script."
|
||||
echo ""
|
||||
die "Network policy update failed."
|
||||
fi
|
||||
|
||||
success "Network policy applied — api.mem0.ai and telemetry allowed"
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# PHASE 6: Configure Plugin
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Phase 6: Configuring Mem0 plugin"
|
||||
|
||||
echo ""
|
||||
echo -e " ${DIM}Get your Mem0 API key from: https://app.mem0.ai${NC}"
|
||||
echo -e " ${DIM}The key starts with 'm0-'${NC}"
|
||||
echo ""
|
||||
ask "Enter your Mem0 API key: "
|
||||
read -r MEM0_API_KEY
|
||||
|
||||
if [[ -z "$MEM0_API_KEY" ]]; then
|
||||
die "Mem0 API key is required."
|
||||
fi
|
||||
|
||||
if [[ ! "$MEM0_API_KEY" =~ ^m0- ]]; then
|
||||
warn "Key doesn't start with 'm0-'. Make sure this is correct."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e " ${DIM}The user ID scopes all memories. Pick any unique identifier.${NC}"
|
||||
echo -e " ${DIM}Examples: alice, user_123, your-email@example.com${NC}"
|
||||
echo ""
|
||||
ask "Enter user ID [$MEM0_USER_ID]: "
|
||||
read -r custom_user_id
|
||||
MEM0_USER_ID="${custom_user_id:-$MEM0_USER_ID}"
|
||||
|
||||
info "Configuring plugin inside sandbox..."
|
||||
|
||||
CONFIG_SCRIPT="openclaw config set plugins.slots.memory openclaw-mem0 2>&1 | tail -1 && \
|
||||
openclaw config set plugins.entries.openclaw-mem0.enabled true 2>&1 | tail -1 && \
|
||||
openclaw config set plugins.entries.openclaw-mem0.config.apiKey '$MEM0_API_KEY' 2>&1 | tail -1 && \
|
||||
openclaw config set plugins.entries.openclaw-mem0.config.userId '$MEM0_USER_ID' 2>&1 | tail -1 && \
|
||||
echo SETUP_DONE"
|
||||
|
||||
CONFIG_OUTPUT=$(sandbox_exec "$CONFIG_SCRIPT" || true)
|
||||
|
||||
if echo "$CONFIG_OUTPUT" | grep -q "SETUP_DONE"; then
|
||||
success "Plugin configured (mode: platform, user: $MEM0_USER_ID)"
|
||||
else
|
||||
warn "Could not verify config. You may need to configure manually:"
|
||||
echo ""
|
||||
if $IS_MACOS; then
|
||||
echo " docker exec -it $CONTAINER_NAME bash"
|
||||
echo " export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh"
|
||||
fi
|
||||
echo " nemoclaw $SANDBOX_NAME connect"
|
||||
echo " openclaw config set plugins.slots.memory openclaw-mem0"
|
||||
echo " openclaw config set plugins.entries.openclaw-mem0.enabled true"
|
||||
echo " openclaw config set plugins.entries.openclaw-mem0.config.apiKey \"$MEM0_API_KEY\""
|
||||
echo " openclaw config set plugins.entries.openclaw-mem0.config.userId \"$MEM0_USER_ID\""
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# PHASE 7: Verify & Test
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
step "Phase 7: Verification"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}${GREEN}║ Setup Complete! ║${NC}"
|
||||
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Sandbox:${NC} $SANDBOX_NAME"
|
||||
echo -e " ${BOLD}Plugin:${NC} @mem0/openclaw-mem0 (platform mode)"
|
||||
echo -e " ${BOLD}User ID:${NC} $MEM0_USER_ID"
|
||||
if $IS_MACOS; then
|
||||
echo -e " ${BOLD}Container:${NC} $CONTAINER_NAME"
|
||||
fi
|
||||
echo ""
|
||||
echo -e " ${BOLD}${CYAN}Next steps:${NC}"
|
||||
echo ""
|
||||
|
||||
if $IS_MACOS; then
|
||||
echo -e " 1. Open a shell in the container:"
|
||||
echo ""
|
||||
echo -e " ${DIM}docker exec -it $CONTAINER_NAME bash${NC}"
|
||||
echo -e " ${DIM}export NVM_DIR=/root/.nvm && source /root/.nvm/nvm.sh${NC}"
|
||||
echo ""
|
||||
echo -e " 2. Connect to the sandbox and start the gateway:"
|
||||
echo ""
|
||||
echo -e " ${DIM}nemoclaw $SANDBOX_NAME connect${NC}"
|
||||
echo -e " ${DIM}nemoclaw-start${NC}"
|
||||
else
|
||||
echo -e " 1. Connect to the sandbox and start the gateway:"
|
||||
echo ""
|
||||
echo -e " ${DIM}source ~/.bashrc${NC}"
|
||||
echo -e " ${DIM}nemoclaw $SANDBOX_NAME connect${NC}"
|
||||
echo -e " ${DIM}nemoclaw-start${NC}"
|
||||
fi
|
||||
echo ""
|
||||
echo -e " Then verify the plugin loaded (look for 'openclaw-mem0: registered'):"
|
||||
echo ""
|
||||
echo -e " ${DIM}openclaw plugins list${NC}"
|
||||
echo ""
|
||||
echo -e " Test auto-capture (storing memories):"
|
||||
echo ""
|
||||
echo -e " ${DIM}openclaw agent --agent main --local -m \"My name is Alice\" --session-id test1${NC}"
|
||||
echo ""
|
||||
echo -e " Test auto-recall (new session, memories should appear):"
|
||||
echo ""
|
||||
echo -e " ${DIM}openclaw agent --agent main --local -m \"What do you know about me?\" --session-id test2${NC}"
|
||||
echo ""
|
||||
echo -e " Or use the interactive TUI:"
|
||||
echo ""
|
||||
echo -e " ${DIM}openclaw tui${NC}"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}Note:${NC} You may see 'Telemetry event capture failed' errors."
|
||||
echo -e " These are harmless and do not affect memory functionality."
|
||||
echo ""
|
||||
echo -e " ${BOLD}Documentation:${NC} https://docs.mem0.ai"
|
||||
echo -e " ${BOLD}Plugin source:${NC} https://www.npmjs.com/package/@mem0/openclaw-mem0"
|
||||
echo ""
|
||||
Binary file not shown.
@@ -34,6 +34,12 @@ export const DEFAULT_MEMORY_CONFIG: MemoryConfig = {
|
||||
username: process.env.NEO4J_USERNAME || "neo4j",
|
||||
password: process.env.NEO4J_PASSWORD || "password",
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: {
|
||||
model: "gpt-4-turbo-preview",
|
||||
},
|
||||
},
|
||||
},
|
||||
historyStore: {
|
||||
provider: "sqlite",
|
||||
|
||||
@@ -80,18 +80,18 @@ export class MemoryGraph {
|
||||
);
|
||||
|
||||
this.llmProvider = "openai";
|
||||
let llmConfig = this.config.llm.config;
|
||||
|
||||
if (this.config.llm?.provider) {
|
||||
this.llmProvider = this.config.llm.provider;
|
||||
}
|
||||
if (this.config.graphStore?.llm?.provider) {
|
||||
this.llmProvider = this.config.graphStore.llm.provider;
|
||||
llmConfig = this.config.graphStore.llm.config ?? llmConfig;
|
||||
}
|
||||
|
||||
this.llm = LLMFactory.create(this.llmProvider, llmConfig);
|
||||
this.structuredLlm = LLMFactory.create(this.llmProvider, llmConfig);
|
||||
this.llm = LLMFactory.create(this.llmProvider, this.config.llm.config);
|
||||
this.structuredLlm = LLMFactory.create(
|
||||
this.llmProvider,
|
||||
this.config.llm.config,
|
||||
);
|
||||
this.threshold = 0.7;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ export const MemoryUpdateSchema = z.object({
|
||||
old_memory: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The previous content of the memory item if the event was UPDATE.",
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Client } from "pg";
|
||||
import { Client, Pool } from "pg";
|
||||
import { VectorStore } from "./base";
|
||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||
|
||||
@@ -35,7 +35,6 @@ export class PGVector implements VectorStore {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
});
|
||||
this.initialize().catch(console.error);
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
|
||||
@@ -349,78 +349,6 @@ describe("ConfigManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Graph store LLM config propagation (issue #3425)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
describe("mergeConfig - graph store LLM config (issue #3425)", () => {
|
||||
const baseEmbedder = {
|
||||
provider: "openai",
|
||||
config: { apiKey: "test-key" },
|
||||
};
|
||||
const baseVectorStore = {
|
||||
provider: "memory",
|
||||
config: { collectionName: "test" },
|
||||
};
|
||||
const graphStoreNeo4j = {
|
||||
provider: "neo4j",
|
||||
config: {
|
||||
url: "neo4j://localhost:7687",
|
||||
username: "neo4j",
|
||||
password: "password",
|
||||
},
|
||||
};
|
||||
|
||||
it("should NOT have a default graphStore.llm — root llm should be the fallback", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "anthropic",
|
||||
config: { model: "claude-sonnet-4-20250514" },
|
||||
},
|
||||
graphStore: graphStoreNeo4j,
|
||||
});
|
||||
|
||||
// graphStore should NOT have its own llm after merge
|
||||
expect(config.graphStore?.llm).toBeUndefined();
|
||||
// root llm should be anthropic
|
||||
expect(config.llm.provider).toBe("anthropic");
|
||||
expect(config.llm.config.model).toBe("claude-sonnet-4-20250514");
|
||||
});
|
||||
|
||||
it("should preserve explicit graphStore.llm when user provides it", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: {
|
||||
provider: "anthropic",
|
||||
config: { model: "claude-sonnet-4-20250514" },
|
||||
},
|
||||
graphStore: {
|
||||
...graphStoreNeo4j,
|
||||
llm: { provider: "openai", config: { model: "gpt-4o" } },
|
||||
},
|
||||
});
|
||||
|
||||
// graphStore should have its own llm
|
||||
expect(config.graphStore?.llm?.provider).toBe("openai");
|
||||
expect(config.graphStore?.llm?.config).toEqual({ model: "gpt-4o" });
|
||||
// root llm should still be anthropic
|
||||
expect(config.llm.provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("should not have graphStore.llm when user does not provide one", () => {
|
||||
const config = ConfigManager.mergeConfig({
|
||||
embedder: baseEmbedder,
|
||||
vectorStore: baseVectorStore,
|
||||
llm: { provider: "openai", config: { model: "gpt-4o" } },
|
||||
});
|
||||
|
||||
// Default graphStore should not have llm
|
||||
expect(config.graphStore?.llm).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Memory class – LM Studio end-to-end flow (mocked factories)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -118,11 +118,6 @@ jest.mock("../src/vector_stores/azure_ai_search", () => ({
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "azure-ai-search", config })),
|
||||
}));
|
||||
jest.mock("../src/vector_stores/pgvector", () => ({
|
||||
PGVector: jest
|
||||
.fn()
|
||||
.mockImplementation((config) => ({ type: "pgvector", config })),
|
||||
}));
|
||||
jest.mock("../src/storage/SupabaseHistoryManager", () => ({
|
||||
SupabaseHistoryManager: jest
|
||||
.fn()
|
||||
@@ -241,7 +236,6 @@ describe("VectorStoreFactory", () => {
|
||||
["langchain"],
|
||||
["vectorize"],
|
||||
["azure-ai-search"],
|
||||
["pgvector"],
|
||||
])("creates vector store for provider '%s'", (provider) => {
|
||||
expect(() =>
|
||||
VectorStoreFactory.create(provider, dummyVSConfig),
|
||||
|
||||
@@ -511,137 +511,6 @@ describe("Prompt construction — all json_object sites include 'json'", () => {
|
||||
// 5. Edge cases – malformed entity fields in _removeSpacesFromEntities
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 5a. LLM config propagation — graph store uses correct provider & config
|
||||
// Regression test for https://github.com/mem0ai/mem0/issues/3425
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe("LLM config propagation to graph store (issue #3425)", () => {
|
||||
const { LLMFactory } = require("../src/utils/factory");
|
||||
|
||||
beforeEach(() => {
|
||||
(LLMFactory.create as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
it("uses root llm config when no graphStore.llm is provided", () => {
|
||||
const config = {
|
||||
graphStore: {
|
||||
config: {
|
||||
url: "bolt://localhost:7687",
|
||||
username: "neo4j",
|
||||
password: "test",
|
||||
},
|
||||
},
|
||||
embedder: { provider: "openai", config: {} },
|
||||
llm: {
|
||||
provider: "anthropic",
|
||||
config: { model: "claude-sonnet-4-20250514", apiKey: "sk-ant-test" },
|
||||
},
|
||||
} as any;
|
||||
|
||||
new MemoryGraph(config);
|
||||
|
||||
expect(LLMFactory.create).toHaveBeenCalledWith("anthropic", {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
apiKey: "sk-ant-test",
|
||||
});
|
||||
// Both llm and structuredLlm should use the same config
|
||||
expect(LLMFactory.create).toHaveBeenCalledTimes(2);
|
||||
expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "anthropic", {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
apiKey: "sk-ant-test",
|
||||
});
|
||||
expect(LLMFactory.create).toHaveBeenNthCalledWith(2, "anthropic", {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
apiKey: "sk-ant-test",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses graphStore.llm config when provided, overriding root llm", () => {
|
||||
const config = {
|
||||
graphStore: {
|
||||
config: {
|
||||
url: "bolt://localhost:7687",
|
||||
username: "neo4j",
|
||||
password: "test",
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
config: { model: "gpt-4o", apiKey: "sk-openai-test" },
|
||||
},
|
||||
},
|
||||
embedder: { provider: "openai", config: {} },
|
||||
llm: {
|
||||
provider: "anthropic",
|
||||
config: { model: "claude-sonnet-4-20250514", apiKey: "sk-ant-test" },
|
||||
},
|
||||
} as any;
|
||||
|
||||
new MemoryGraph(config);
|
||||
|
||||
// Should use graphStore.llm, NOT root llm
|
||||
expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "openai", {
|
||||
model: "gpt-4o",
|
||||
apiKey: "sk-openai-test",
|
||||
});
|
||||
expect(LLMFactory.create).toHaveBeenNthCalledWith(2, "openai", {
|
||||
model: "gpt-4o",
|
||||
apiKey: "sk-openai-test",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to root llm config when graphStore.llm.config is undefined", () => {
|
||||
// Note: in practice, Zod schema requires config when graphStore.llm is
|
||||
// present. This tests the defensive fallback in MemoryGraph itself.
|
||||
const config = {
|
||||
graphStore: {
|
||||
config: {
|
||||
url: "bolt://localhost:7687",
|
||||
username: "neo4j",
|
||||
password: "test",
|
||||
},
|
||||
llm: {
|
||||
provider: "openai",
|
||||
// config explicitly undefined
|
||||
config: undefined,
|
||||
},
|
||||
},
|
||||
embedder: { provider: "openai", config: {} },
|
||||
llm: {
|
||||
provider: "anthropic",
|
||||
config: { model: "claude-sonnet-4-20250514" },
|
||||
},
|
||||
} as any;
|
||||
|
||||
new MemoryGraph(config);
|
||||
|
||||
// Provider from graphStore.llm, but config falls back to root llm.config
|
||||
expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "openai", {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to openai when neither root nor graphStore llm provider is set", () => {
|
||||
const config = {
|
||||
graphStore: {
|
||||
config: {
|
||||
url: "bolt://localhost:7687",
|
||||
username: "neo4j",
|
||||
password: "test",
|
||||
},
|
||||
},
|
||||
embedder: { provider: "openai", config: {} },
|
||||
llm: { config: { model: "gpt-4" } },
|
||||
} as any;
|
||||
|
||||
new MemoryGraph(config);
|
||||
|
||||
expect(LLMFactory.create).toHaveBeenNthCalledWith(1, "openai", {
|
||||
model: "gpt-4",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("_removeSpacesFromEntities (via _establishNodesRelationsFromData)", () => {
|
||||
it("normalises spaces and case in entity source/relationship/destination", async () => {
|
||||
mockGenerateResponse.mockResolvedValueOnce({
|
||||
|
||||
@@ -16,7 +16,7 @@ class AnthropicConfig(BaseLlmConfig):
|
||||
temperature: float = 0.1,
|
||||
api_key: Optional[str] = None,
|
||||
max_tokens: int = 2000,
|
||||
top_p: Optional[float] = None,
|
||||
top_p: float = 0.1,
|
||||
top_k: int = 1,
|
||||
enable_vision: bool = False,
|
||||
vision_details: Optional[str] = "auto",
|
||||
@@ -32,7 +32,7 @@ class AnthropicConfig(BaseLlmConfig):
|
||||
temperature: Controls randomness, defaults to 0.1
|
||||
api_key: Anthropic API key, defaults to None
|
||||
max_tokens: Maximum tokens to generate, defaults to 2000
|
||||
top_p: Nucleus sampling parameter, defaults to None (omitted to avoid conflict with temperature)
|
||||
top_p: Nucleus sampling parameter, defaults to 0.1
|
||||
top_k: Top-k sampling parameter, defaults to 1
|
||||
enable_vision: Enable vision capabilities, defaults to False
|
||||
vision_details: Vision detail level, defaults to "auto"
|
||||
|
||||
@@ -16,7 +16,7 @@ class AWSBedrockConfig(BaseLlmConfig):
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.1,
|
||||
max_tokens: int = 2000,
|
||||
top_p: Optional[float] = None,
|
||||
top_p: float = 0.9,
|
||||
top_k: int = 1,
|
||||
aws_access_key_id: Optional[str] = None,
|
||||
aws_secret_access_key: Optional[str] = None,
|
||||
@@ -33,8 +33,7 @@ class AWSBedrockConfig(BaseLlmConfig):
|
||||
model: Bedrock model identifier (e.g., "amazon.nova-3-mini-20241119-v1:0")
|
||||
temperature: Controls randomness (0.0 to 2.0)
|
||||
max_tokens: Maximum tokens to generate
|
||||
top_p: Nucleus sampling (0.0–1.0). Default None omits topP on Converse
|
||||
(required for Anthropic, which rejects temperature and topP together).
|
||||
top_p: Nucleus sampling parameter (0.0 to 1.0)
|
||||
top_k: Top-k sampling parameter (1 to 40)
|
||||
aws_access_key_id: AWS access key (optional, uses env vars if not provided)
|
||||
aws_secret_access_key: AWS secret key (optional, uses env vars if not provided)
|
||||
@@ -76,16 +75,13 @@ class AWSBedrockConfig(BaseLlmConfig):
|
||||
|
||||
def get_model_config(self) -> Dict[str, Any]:
|
||||
"""Get model-specific configuration parameters."""
|
||||
base_config: Dict[str, Any] = {
|
||||
base_config = {
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
"top_p": self.top_p,
|
||||
"top_k": self.top_k,
|
||||
}
|
||||
|
||||
# Only include top_p when explicitly set by the user.
|
||||
if self.top_p is not None:
|
||||
base_config["top_p"] = self.top_p
|
||||
|
||||
# Add custom model kwargs
|
||||
base_config.update(self.model_kwargs)
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ from typing import Any, ClassVar, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from mem0.vector_stores.configs import get_default_vector_store_path
|
||||
|
||||
|
||||
class ChromaDbConfig(BaseModel):
|
||||
try:
|
||||
@@ -25,25 +23,24 @@ class ChromaDbConfig(BaseModel):
|
||||
def check_connection_config(cls, values):
|
||||
host, port, path = values.get("host"), values.get("port"), values.get("path")
|
||||
api_key, tenant = values.get("api_key"), values.get("tenant")
|
||||
default_path = get_default_vector_store_path("chroma")
|
||||
|
||||
|
||||
# Check if cloud configuration is provided
|
||||
cloud_config = bool(api_key and tenant)
|
||||
|
||||
|
||||
# If cloud configuration is provided, remove any default path that might have been added
|
||||
if cloud_config and path == default_path:
|
||||
if cloud_config and path == "/tmp/chroma":
|
||||
values.pop("path", None)
|
||||
return values
|
||||
|
||||
# Check if local/server configuration is provided
|
||||
local_config = bool(path) or bool(host and port)
|
||||
|
||||
|
||||
# Check if local/server configuration is provided (excluding default tmp path for cloud config)
|
||||
local_config = bool(path and path != "/tmp/chroma") or bool(host and port)
|
||||
|
||||
if not cloud_config and not local_config:
|
||||
raise ValueError("Either ChromaDB Cloud configuration (api_key, tenant) or local configuration (path or host/port) must be provided.")
|
||||
|
||||
|
||||
if cloud_config and local_config:
|
||||
raise ValueError("Cannot specify both cloud configuration and local configuration. Choose one.")
|
||||
|
||||
|
||||
return values
|
||||
|
||||
@model_validator(mode="before")
|
||||
|
||||
@@ -2,8 +2,6 @@ from typing import Any, ClassVar, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from mem0.vector_stores.configs import get_default_vector_store_path
|
||||
|
||||
|
||||
class QdrantConfig(BaseModel):
|
||||
from qdrant_client import QdrantClient
|
||||
@@ -15,10 +13,10 @@ class QdrantConfig(BaseModel):
|
||||
client: Optional[QdrantClient] = Field(None, description="Existing Qdrant client instance")
|
||||
host: Optional[str] = Field(None, description="Host address for Qdrant server")
|
||||
port: Optional[int] = Field(None, description="Port for Qdrant server")
|
||||
path: Optional[str] = Field(default_factory=lambda: get_default_vector_store_path("qdrant"), description="Path for local Qdrant database")
|
||||
path: Optional[str] = Field("/tmp/qdrant", description="Path for local Qdrant database")
|
||||
url: Optional[str] = Field(None, description="Full URL for Qdrant server")
|
||||
api_key: Optional[str] = Field(None, description="API key for Qdrant server")
|
||||
on_disk: Optional[bool] = Field(False,description="Enables persistent storage. Vectors are kept on disk (True) or in memory (False). Does not delete the local database path.")
|
||||
on_disk: Optional[bool] = Field(False, description="Enables persistent storage")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class TurbopufferConfig(BaseModel):
|
||||
collection_name: str = Field("mem0", description="Name of the namespace/collection")
|
||||
embedding_model_dims: int = Field(1536, description="Dimensions of the embedding model")
|
||||
api_key: Optional[str] = Field(None, description="API key for Turbopuffer")
|
||||
region: str = Field("gcp-us-central1", description="Turbopuffer region (e.g., 'gcp-us-central1', 'aws-us-west-2')")
|
||||
distance_metric: str = Field(
|
||||
"cosine_distance",
|
||||
description="Distance metric for vector similarity ('cosine_distance' or 'euclidean_squared')",
|
||||
)
|
||||
batch_size: int = Field(100, description="Batch size for bulk operations")
|
||||
extra_params: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description="Additional parameters for Turbopuffer client",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def check_api_key(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
api_key = values.get("api_key")
|
||||
if not api_key and "TURBOPUFFER_API_KEY" not in os.environ:
|
||||
raise ValueError(
|
||||
"Either 'api_key' must be provided or TURBOPUFFER_API_KEY environment variable must be set."
|
||||
)
|
||||
return values
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def validate_extra_fields(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
allowed_fields = set(cls.model_fields.keys())
|
||||
input_fields = set(values.keys())
|
||||
extra_fields = input_fields - allowed_fields
|
||||
if extra_fields:
|
||||
raise ValueError(
|
||||
f"Extra fields not allowed: {', '.join(extra_fields)}. "
|
||||
f"Please input only the following fields: {', '.join(allowed_fields)}"
|
||||
)
|
||||
return values
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
@@ -409,28 +409,6 @@ class NeptuneBase(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def delete(self, data, filters):
|
||||
"""
|
||||
Delete graph entities associated with the given memory text.
|
||||
|
||||
Extracts entities and relationships from the memory text using the same
|
||||
pipeline as add(), then deletes the matching relationships in the graph.
|
||||
|
||||
Args:
|
||||
data (str): The memory text whose graph entities should be removed.
|
||||
filters (dict): Scope filters (user_id, agent_id, run_id).
|
||||
"""
|
||||
try:
|
||||
entity_type_map = self._retrieve_nodes_from_data(data, filters)
|
||||
if not entity_type_map:
|
||||
logger.debug("No entities found in memory text, skipping graph cleanup")
|
||||
return
|
||||
to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map)
|
||||
if to_be_deleted:
|
||||
self._delete_entities(to_be_deleted, filters["user_id"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error during graph cleanup for memory delete: {e}")
|
||||
|
||||
def delete_all(self, filters):
|
||||
cypher, params = self._delete_all_cypher(filters)
|
||||
self.graph.query(cypher, params=params)
|
||||
|
||||
@@ -40,31 +40,6 @@ class AnthropicLLM(LLMBase):
|
||||
api_key = self.config.api_key or os.getenv("ANTHROPIC_API_KEY")
|
||||
self.client = anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
def _get_common_params(self, **kwargs) -> Dict:
|
||||
"""Get common parameters, avoiding sending both temperature and top_p together.
|
||||
|
||||
Anthropic rejects requests that include both temperature and top_p.
|
||||
When both are set, we keep temperature and drop top_p.
|
||||
"""
|
||||
params = {}
|
||||
|
||||
if self.config.max_tokens is not None:
|
||||
params["max_tokens"] = self.config.max_tokens
|
||||
|
||||
has_temperature = self.config.temperature is not None
|
||||
has_top_p = self.config.top_p is not None
|
||||
|
||||
if has_temperature and has_top_p:
|
||||
# Anthropic forbids both; prefer temperature
|
||||
params["temperature"] = self.config.temperature
|
||||
elif has_temperature:
|
||||
params["temperature"] = self.config.temperature
|
||||
elif has_top_p:
|
||||
params["top_p"] = self.config.top_p
|
||||
|
||||
params.update(kwargs)
|
||||
return params
|
||||
|
||||
def generate_response(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
|
||||
+41
-43
@@ -228,12 +228,6 @@ class AWSBedrockLLM(LLMBase):
|
||||
|
||||
return "\n\nHuman: " + "".join(formatted_messages) + "\n\nAssistant:"
|
||||
|
||||
def _merge_optional_top_p(self, target: Dict[str, Any], *, key: str = "top_p") -> None:
|
||||
"""Add nucleus sampling to ``target`` only when ``model_config`` has ``top_p`` set."""
|
||||
top_p = self.model_config.get("top_p")
|
||||
if top_p is not None:
|
||||
target[key] = top_p
|
||||
|
||||
def _prepare_input(self, prompt: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Prepare input for the current provider's model.
|
||||
@@ -274,38 +268,44 @@ class AWSBedrockLLM(LLMBase):
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": self.model_config.get("max_tokens", 5000),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
"top_p": self.model_config.get("top_p", 0.9),
|
||||
}
|
||||
self._merge_optional_top_p(input_body)
|
||||
else:
|
||||
# Legacy Amazon models
|
||||
text_gen_config: Dict[str, Any] = {
|
||||
"maxTokenCount": self.model_config.get("max_tokens", 5000),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
input_body = {
|
||||
"inputText": prompt,
|
||||
"textGenerationConfig": {
|
||||
"maxTokenCount": self.model_config.get("max_tokens", 5000),
|
||||
"topP": self.model_config.get("top_p", 0.9),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
},
|
||||
}
|
||||
# Remove None values
|
||||
input_body["textGenerationConfig"] = {
|
||||
k: v for k, v in input_body["textGenerationConfig"].items() if v is not None
|
||||
}
|
||||
self._merge_optional_top_p(text_gen_config, key="topP")
|
||||
input_body = {"inputText": prompt, "textGenerationConfig": text_gen_config}
|
||||
elif self.provider == "anthropic":
|
||||
input_body = {
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": prompt}]}],
|
||||
"max_tokens": self.model_config.get("max_tokens", 2000),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
"top_p": self.model_config.get("top_p", 0.9),
|
||||
"anthropic_version": "bedrock-2023-05-31",
|
||||
}
|
||||
self._merge_optional_top_p(input_body)
|
||||
elif self.provider == "meta":
|
||||
input_body = {
|
||||
"prompt": prompt,
|
||||
"max_gen_len": self.model_config.get("max_tokens", 5000),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
"top_p": self.model_config.get("top_p", 0.9),
|
||||
}
|
||||
self._merge_optional_top_p(input_body)
|
||||
elif self.provider == "mistral":
|
||||
input_body = {
|
||||
"prompt": prompt,
|
||||
"max_tokens": self.model_config.get("max_tokens", 5000),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
"top_p": self.model_config.get("top_p", 0.9),
|
||||
}
|
||||
self._merge_optional_top_p(input_body)
|
||||
else:
|
||||
# Generic case - add all model config parameters
|
||||
input_body.update(self.model_config)
|
||||
@@ -479,29 +479,6 @@ class AWSBedrockLLM(LLMBase):
|
||||
|
||||
return converse_tools
|
||||
|
||||
def _default_max_tokens_for_converse(self) -> int:
|
||||
"""Default maxTokens if ``max_tokens`` is missing (Nova: 5000, else 2000)."""
|
||||
model_id = (self.config.model or "").lower()
|
||||
if self.provider == "amazon" and "nova" in model_id:
|
||||
return 5000
|
||||
return 2000
|
||||
|
||||
def _build_inference_config(self) -> Dict[str, Any]:
|
||||
"""Build Converse ``inferenceConfig``. Anthropic allows only one of temperature or topP; we keep temperature and omit topP."""
|
||||
inference_config: Dict[str, Any] = {
|
||||
"maxTokens": self.model_config.get("max_tokens", self._default_max_tokens_for_converse()),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
}
|
||||
|
||||
top_p = self.model_config.get("top_p")
|
||||
if top_p is not None:
|
||||
if self.provider == "anthropic":
|
||||
logger.debug("Omitting topP for Anthropic Converse (using temperature); top_p=%s", top_p)
|
||||
else:
|
||||
inference_config["topP"] = top_p
|
||||
|
||||
return inference_config
|
||||
|
||||
def _generate_with_tools(self, messages: List[Dict[str, str]], tools: List[Dict], stream: bool = False) -> Dict[str, Any]:
|
||||
"""Generate response with tool calling support using correct message format."""
|
||||
# Format messages for tool-enabled models
|
||||
@@ -524,7 +501,11 @@ class AWSBedrockLLM(LLMBase):
|
||||
converse_params = {
|
||||
"modelId": self.config.model,
|
||||
"messages": formatted_messages,
|
||||
"inferenceConfig": self._build_inference_config(),
|
||||
"inferenceConfig": {
|
||||
"maxTokens": self.model_config.get("max_tokens", 2000),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
"topP": self.model_config.get("top_p", 0.9),
|
||||
}
|
||||
}
|
||||
|
||||
# Add system message if present (for Anthropic)
|
||||
@@ -550,7 +531,11 @@ class AWSBedrockLLM(LLMBase):
|
||||
converse_params = {
|
||||
"modelId": self.config.model,
|
||||
"messages": formatted_messages,
|
||||
"inferenceConfig": self._build_inference_config(),
|
||||
"inferenceConfig": {
|
||||
"maxTokens": self.model_config.get("max_tokens", 2000),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
"topP": self.model_config.get("top_p", 0.9),
|
||||
}
|
||||
}
|
||||
|
||||
# Add system message if present
|
||||
@@ -569,13 +554,26 @@ class AWSBedrockLLM(LLMBase):
|
||||
return str(response)
|
||||
|
||||
elif self.provider == "amazon" and "nova" in self.config.model.lower():
|
||||
# Nova models use the Converse API even without tools
|
||||
# Nova models use converse API even without tools
|
||||
formatted_messages = self._format_messages_amazon(messages)
|
||||
input_body = {
|
||||
"messages": formatted_messages,
|
||||
"max_tokens": self.model_config.get("max_tokens", 5000),
|
||||
"temperature": self.model_config.get("temperature", 0.1),
|
||||
"top_p": self.model_config.get("top_p", 0.9),
|
||||
}
|
||||
|
||||
# Use converse API for Nova models
|
||||
response = self.client.converse(
|
||||
modelId=self.config.model,
|
||||
messages=formatted_messages,
|
||||
inferenceConfig=self._build_inference_config(),
|
||||
messages=input_body["messages"],
|
||||
inferenceConfig={
|
||||
"maxTokens": input_body["max_tokens"],
|
||||
"temperature": input_body["temperature"],
|
||||
"topP": input_body["top_p"],
|
||||
}
|
||||
)
|
||||
|
||||
return self._parse_response(response)
|
||||
else:
|
||||
# For other providers and legacy Amazon models (like Titan)
|
||||
|
||||
+8
-11
@@ -32,25 +32,22 @@ class GeminiLLM(LLMBase):
|
||||
Returns:
|
||||
str or dict: The processed response.
|
||||
"""
|
||||
# Get parts safely — content can be None when Gemini blocks the response
|
||||
candidate = response.candidates[0] if response.candidates else None
|
||||
parts = candidate.content.parts if candidate and candidate.content else None
|
||||
|
||||
if tools:
|
||||
processed_response = {
|
||||
"content": None,
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
if parts:
|
||||
# Extract content from the first candidate
|
||||
for part in parts:
|
||||
# Extract content from the first candidate
|
||||
if response.candidates and response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if hasattr(part, "text") and part.text:
|
||||
processed_response["content"] = part.text
|
||||
break
|
||||
|
||||
# Extract function calls
|
||||
for part in parts:
|
||||
# Extract function calls
|
||||
if response.candidates and response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if hasattr(part, "function_call") and part.function_call:
|
||||
fn = part.function_call
|
||||
processed_response["tool_calls"].append(
|
||||
@@ -62,8 +59,8 @@ class GeminiLLM(LLMBase):
|
||||
|
||||
return processed_response
|
||||
else:
|
||||
if parts:
|
||||
for part in parts:
|
||||
if response.candidates and response.candidates[0].content.parts:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if hasattr(part, "text") and part.text:
|
||||
return part.text
|
||||
return ""
|
||||
|
||||
@@ -246,28 +246,6 @@ class MemoryGraph:
|
||||
logger.info(f"Returned {len(search_results)} search results")
|
||||
return search_results
|
||||
|
||||
def delete(self, data, filters):
|
||||
"""
|
||||
Delete graph entities associated with the given memory text.
|
||||
|
||||
Extracts entities and relationships from the memory text using the same
|
||||
pipeline as add(), then deletes the matching relationships in the graph.
|
||||
|
||||
Args:
|
||||
data (str): The memory text whose graph entities should be removed.
|
||||
filters (dict): Scope filters (user_id, agent_id, run_id).
|
||||
"""
|
||||
try:
|
||||
entity_type_map = self._retrieve_nodes_from_data(data, filters)
|
||||
if not entity_type_map:
|
||||
logger.debug("No entities found in memory text, skipping graph cleanup")
|
||||
return
|
||||
to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map)
|
||||
if to_be_deleted:
|
||||
self._delete_entities(to_be_deleted, filters)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during graph cleanup for memory delete: {e}")
|
||||
|
||||
def delete_all(self, filters):
|
||||
"""Delete all nodes and relationships for a user or specific agent."""
|
||||
where_parts = ["n.user_id = %s"]
|
||||
|
||||
+18
-68
@@ -129,28 +129,6 @@ class MemoryGraph:
|
||||
|
||||
return search_results
|
||||
|
||||
def delete(self, data, filters):
|
||||
"""
|
||||
Delete graph entities associated with the given memory text.
|
||||
|
||||
Extracts entities and relationships from the memory text using the same
|
||||
pipeline as add(), then soft-deletes the matching relationships in the graph.
|
||||
|
||||
Args:
|
||||
data (str): The memory text whose graph entities should be removed.
|
||||
filters (dict): Scope filters (user_id, agent_id, run_id).
|
||||
"""
|
||||
try:
|
||||
entity_type_map = self._retrieve_nodes_from_data(data, filters)
|
||||
if not entity_type_map:
|
||||
logger.debug("No entities found in memory text, skipping graph cleanup")
|
||||
return
|
||||
to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map)
|
||||
if to_be_deleted:
|
||||
self._delete_entities(to_be_deleted, filters)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during graph cleanup for memory delete: {e}")
|
||||
|
||||
def delete_all(self, filters):
|
||||
# Build node properties for filtering
|
||||
node_props = ["user_id: $user_id"]
|
||||
@@ -196,7 +174,6 @@ class MemoryGraph:
|
||||
|
||||
query = f"""
|
||||
MATCH (n {self.node_label} {{{node_props_str}}})-[r]->(m {self.node_label} {{{node_props_str}}})
|
||||
WHERE r.valid IS NULL OR r.valid = true
|
||||
RETURN n.name AS source, type(r) AS relationship, m.name AS target
|
||||
LIMIT $limit
|
||||
"""
|
||||
@@ -314,12 +291,10 @@ class MemoryGraph:
|
||||
CALL {{
|
||||
WITH n
|
||||
MATCH (n)-[r]->(m {self.node_label} {{{node_props_str}}})
|
||||
WHERE r.valid IS NULL OR r.valid = true
|
||||
RETURN n.name AS source, elementId(n) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, m.name AS destination, elementId(m) AS destination_id
|
||||
UNION
|
||||
WITH n
|
||||
MATCH (n)<-[r]-(m {self.node_label} {{{node_props_str}}})
|
||||
WHERE r.valid IS NULL OR r.valid = true
|
||||
RETURN m.name AS source, elementId(m) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, n.name AS destination, elementId(n) AS destination_id
|
||||
}}
|
||||
WITH distinct source, source_id, relationship, relation_id, destination, destination_id, similarity
|
||||
@@ -417,15 +392,13 @@ class MemoryGraph:
|
||||
source_props_str = ", ".join(source_props)
|
||||
dest_props_str = ", ".join(dest_props)
|
||||
|
||||
# Soft-delete: mark relationship as invalid instead of removing it,
|
||||
# enabling temporal reasoning over historical graph state.
|
||||
# See: https://github.com/mem0ai/mem0/issues/4187
|
||||
# Delete the specific relationship between nodes
|
||||
cypher = f"""
|
||||
MATCH (n {self.node_label} {{{source_props_str}}})
|
||||
-[r:{relationship}]->
|
||||
(m {self.node_label} {{{dest_props_str}}})
|
||||
WHERE r.valid IS NULL OR r.valid = true
|
||||
SET r.valid = false, r.invalidated_at = datetime()
|
||||
|
||||
DELETE r
|
||||
RETURN
|
||||
n.name AS source,
|
||||
m.name AS target,
|
||||
@@ -491,16 +464,11 @@ class MemoryGraph:
|
||||
CALL db.create.setNodeVectorProperty(destination, 'embedding', $destination_embedding)
|
||||
WITH source, destination
|
||||
MERGE (source)-[r:{relationship}]->(destination)
|
||||
ON CREATE SET
|
||||
r.created_at = timestamp(),
|
||||
r.updated_at = timestamp(),
|
||||
r.mentions = 1,
|
||||
r.valid = true
|
||||
ON CREATE SET
|
||||
r.created = timestamp(),
|
||||
r.mentions = 1
|
||||
ON MATCH SET
|
||||
r.mentions = coalesce(r.mentions, 0) + 1,
|
||||
r.valid = true,
|
||||
r.updated_at = timestamp(),
|
||||
r.invalidated_at = null
|
||||
r.mentions = coalesce(r.mentions, 0) + 1
|
||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||
"""
|
||||
|
||||
@@ -540,16 +508,11 @@ class MemoryGraph:
|
||||
CALL db.create.setNodeVectorProperty(source, 'embedding', $source_embedding)
|
||||
WITH source, destination
|
||||
MERGE (source)-[r:{relationship}]->(destination)
|
||||
ON CREATE SET
|
||||
r.created_at = timestamp(),
|
||||
r.updated_at = timestamp(),
|
||||
r.mentions = 1,
|
||||
r.valid = true
|
||||
ON CREATE SET
|
||||
r.created = timestamp(),
|
||||
r.mentions = 1
|
||||
ON MATCH SET
|
||||
r.mentions = coalesce(r.mentions, 0) + 1,
|
||||
r.valid = true,
|
||||
r.updated_at = timestamp(),
|
||||
r.invalidated_at = null
|
||||
r.mentions = coalesce(r.mentions, 0) + 1
|
||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||
"""
|
||||
|
||||
@@ -574,16 +537,11 @@ class MemoryGraph:
|
||||
WHERE elementId(destination) = $destination_id
|
||||
SET destination.mentions = coalesce(destination.mentions, 0) + 1
|
||||
MERGE (source)-[r:{relationship}]->(destination)
|
||||
ON CREATE SET
|
||||
ON CREATE SET
|
||||
r.created_at = timestamp(),
|
||||
r.updated_at = timestamp(),
|
||||
r.mentions = 1,
|
||||
r.valid = true
|
||||
ON MATCH SET
|
||||
r.mentions = coalesce(r.mentions, 0) + 1,
|
||||
r.valid = true,
|
||||
r.updated_at = timestamp(),
|
||||
r.invalidated_at = null
|
||||
r.mentions = 1
|
||||
ON MATCH SET r.mentions = coalesce(r.mentions, 0) + 1
|
||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||
"""
|
||||
|
||||
@@ -627,18 +585,10 @@ class MemoryGraph:
|
||||
WITH source, destination
|
||||
CALL db.create.setNodeVectorProperty(destination, 'embedding', $dest_embedding)
|
||||
WITH source, destination
|
||||
MERGE (source)-[r:{relationship}]->(destination)
|
||||
ON CREATE SET
|
||||
r.created_at = timestamp(),
|
||||
r.updated_at = timestamp(),
|
||||
r.mentions = 1,
|
||||
r.valid = true
|
||||
ON MATCH SET
|
||||
r.mentions = coalesce(r.mentions, 0) + 1,
|
||||
r.valid = true,
|
||||
r.updated_at = timestamp(),
|
||||
r.invalidated_at = null
|
||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||
MERGE (source)-[rel:{relationship}]->(destination)
|
||||
ON CREATE SET rel.created = timestamp(), rel.mentions = 1
|
||||
ON MATCH SET rel.mentions = coalesce(rel.mentions, 0) + 1
|
||||
RETURN source.name AS source, type(rel) AS relationship, destination.name AS target
|
||||
"""
|
||||
|
||||
params = {
|
||||
|
||||
@@ -149,28 +149,6 @@ class MemoryGraph:
|
||||
|
||||
return search_results
|
||||
|
||||
def delete(self, data, filters):
|
||||
"""
|
||||
Delete graph entities associated with the given memory text.
|
||||
|
||||
Extracts entities and relationships from the memory text using the same
|
||||
pipeline as add(), then deletes the matching relationships in the graph.
|
||||
|
||||
Args:
|
||||
data (str): The memory text whose graph entities should be removed.
|
||||
filters (dict): Scope filters (user_id, agent_id, run_id).
|
||||
"""
|
||||
try:
|
||||
entity_type_map = self._retrieve_nodes_from_data(data, filters)
|
||||
if not entity_type_map:
|
||||
logger.debug("No entities found in memory text, skipping graph cleanup")
|
||||
return
|
||||
to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map)
|
||||
if to_be_deleted:
|
||||
self._delete_entities(to_be_deleted, filters)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during graph cleanup for memory delete: {e}")
|
||||
|
||||
def delete_all(self, filters):
|
||||
# Build node properties for filtering
|
||||
node_props = ["user_id: $user_id"]
|
||||
|
||||
+39
-111
@@ -9,7 +9,7 @@ import uuid
|
||||
import warnings
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
@@ -479,8 +479,7 @@ class Memory(MemoryBase):
|
||||
|
||||
msg_content = message_dict["content"]
|
||||
msg_embeddings = self.embedding_model.embed(msg_content, "add")
|
||||
# Pass embeddings as a dict so _create_memory can reuse the cached embedding
|
||||
mem_id = self._create_memory(msg_content, {msg_content: msg_embeddings}, per_msg_meta)
|
||||
mem_id = self._create_memory(msg_content, msg_embeddings, per_msg_meta)
|
||||
|
||||
returned_memories.append(
|
||||
{
|
||||
@@ -609,9 +608,6 @@ class Memory(MemoryBase):
|
||||
|
||||
event_type = resp.get("event")
|
||||
if event_type == "ADD":
|
||||
# Ensure action_text has an embedding cached to avoid redundant API calls
|
||||
if action_text not in new_message_embeddings:
|
||||
new_message_embeddings[action_text] = self.embedding_model.embed(action_text, "add")
|
||||
memory_id = self._create_memory(
|
||||
data=action_text,
|
||||
existing_embeddings=new_message_embeddings,
|
||||
@@ -619,9 +615,6 @@ class Memory(MemoryBase):
|
||||
)
|
||||
returned_memories.append({"id": memory_id, "memory": action_text, "event": event_type})
|
||||
elif event_type == "UPDATE":
|
||||
# Ensure action_text has an embedding cached to avoid redundant API calls
|
||||
if action_text not in new_message_embeddings:
|
||||
new_message_embeddings[action_text] = self.embedding_model.embed(action_text, "update")
|
||||
self._update_memory(
|
||||
memory_id=temp_uuid_mapping[resp.get("id")],
|
||||
data=action_text,
|
||||
@@ -1108,27 +1101,7 @@ class Memory(MemoryBase):
|
||||
memory_id (str): ID of the memory to delete.
|
||||
"""
|
||||
capture_event("mem0.delete", self, {"memory_id": memory_id, "sync_type": "sync"})
|
||||
|
||||
existing_memory = self.vector_store.get(vector_id=memory_id)
|
||||
if existing_memory is None:
|
||||
raise ValueError(f"Memory with id {memory_id} not found")
|
||||
|
||||
# Clean up graph entities before deleting from vector store
|
||||
if self.enable_graph:
|
||||
try:
|
||||
memory_text = existing_memory.payload.get("data", "")
|
||||
if memory_text:
|
||||
filters = {}
|
||||
for key in ("user_id", "agent_id", "run_id"):
|
||||
val = existing_memory.payload.get(key)
|
||||
if val:
|
||||
filters[key] = val
|
||||
if filters.get("user_id"):
|
||||
self.graph.delete(memory_text, filters)
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up graph for memory {memory_id}: {e}")
|
||||
|
||||
self._delete_memory(memory_id, existing_memory)
|
||||
self._delete_memory(memory_id)
|
||||
return {"message": "Memory deleted successfully!"}
|
||||
|
||||
def delete_all(self, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None):
|
||||
@@ -1180,34 +1153,31 @@ class Memory(MemoryBase):
|
||||
capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "sync"})
|
||||
return self.db.get_history(memory_id)
|
||||
|
||||
def _create_memory(self, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None):
|
||||
def _create_memory(self, data, existing_embeddings, metadata=None):
|
||||
logger.debug(f"Creating memory with {data=}")
|
||||
# existing_embeddings may be a dict (preferred) or a precomputed vector
|
||||
if isinstance(existing_embeddings, dict) and data in existing_embeddings:
|
||||
if data in existing_embeddings:
|
||||
embeddings = existing_embeddings[data]
|
||||
elif not isinstance(existing_embeddings, dict):
|
||||
embeddings = existing_embeddings
|
||||
else:
|
||||
embeddings = self.embedding_model.embed(data, memory_action="add")
|
||||
memory_id = str(uuid.uuid4())
|
||||
new_metadata = deepcopy(metadata) if metadata is not None else {}
|
||||
new_metadata["data"] = data
|
||||
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||
new_metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||
metadata = metadata or {}
|
||||
metadata["data"] = data
|
||||
metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||
metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
self.vector_store.insert(
|
||||
vectors=[embeddings],
|
||||
ids=[memory_id],
|
||||
payloads=[new_metadata],
|
||||
payloads=[metadata],
|
||||
)
|
||||
self.db.add_history(
|
||||
memory_id,
|
||||
None,
|
||||
data,
|
||||
"ADD",
|
||||
created_at=new_metadata.get("created_at"),
|
||||
actor_id=new_metadata.get("actor_id"),
|
||||
role=new_metadata.get("role"),
|
||||
created_at=metadata.get("created_at"),
|
||||
actor_id=metadata.get("actor_id"),
|
||||
role=metadata.get("role"),
|
||||
)
|
||||
return memory_id
|
||||
|
||||
@@ -1241,17 +1211,16 @@ class Memory(MemoryBase):
|
||||
if metadata is None:
|
||||
raise ValueError("Metadata cannot be done for procedural memory.")
|
||||
|
||||
new_metadata = deepcopy(metadata)
|
||||
new_metadata["memory_type"] = MemoryType.PROCEDURAL.value
|
||||
metadata["memory_type"] = MemoryType.PROCEDURAL.value
|
||||
embeddings = self.embedding_model.embed(procedural_memory, memory_action="add")
|
||||
memory_id = self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=new_metadata)
|
||||
memory_id = self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=metadata)
|
||||
capture_event("mem0._create_procedural_memory", self, {"memory_id": memory_id, "sync_type": "sync"})
|
||||
|
||||
result = {"results": [{"id": memory_id, "memory": procedural_memory, "event": "ADD"}]}
|
||||
|
||||
return result
|
||||
|
||||
def _update_memory(self, memory_id, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None):
|
||||
def _update_memory(self, memory_id, data, existing_embeddings, metadata=None):
|
||||
logger.info(f"Updating memory with {data=}")
|
||||
|
||||
try:
|
||||
@@ -1284,10 +1253,8 @@ class Memory(MemoryBase):
|
||||
if "role" not in new_metadata and "role" in existing_memory.payload:
|
||||
new_metadata["role"] = existing_memory.payload["role"]
|
||||
|
||||
if isinstance(existing_embeddings, dict) and data in existing_embeddings:
|
||||
if data in existing_embeddings:
|
||||
embeddings = existing_embeddings[data]
|
||||
elif not isinstance(existing_embeddings, dict):
|
||||
embeddings = existing_embeddings
|
||||
else:
|
||||
embeddings = self.embedding_model.embed(data, "update")
|
||||
|
||||
@@ -1310,12 +1277,11 @@ class Memory(MemoryBase):
|
||||
)
|
||||
return memory_id
|
||||
|
||||
def _delete_memory(self, memory_id, existing_memory=None):
|
||||
def _delete_memory(self, memory_id):
|
||||
logger.info(f"Deleting memory with {memory_id=}")
|
||||
existing_memory = self.vector_store.get(vector_id=memory_id)
|
||||
if existing_memory is None:
|
||||
existing_memory = self.vector_store.get(vector_id=memory_id)
|
||||
if existing_memory is None:
|
||||
raise ValueError(f"Memory with id {memory_id} not found")
|
||||
raise ValueError(f"Memory with id {memory_id} not found")
|
||||
prev_value = existing_memory.payload.get("data", "")
|
||||
self.vector_store.delete(vector_id=memory_id)
|
||||
self.db.add_history(
|
||||
@@ -1557,8 +1523,7 @@ class AsyncMemory(MemoryBase):
|
||||
|
||||
msg_content = message_dict["content"]
|
||||
msg_embeddings = await asyncio.to_thread(self.embedding_model.embed, msg_content, "add")
|
||||
# Pass embeddings as a dict so _create_memory can reuse the cached embedding
|
||||
mem_id = await self._create_memory(msg_content, {msg_content: msg_embeddings}, per_msg_meta)
|
||||
mem_id = await self._create_memory(msg_content, msg_embeddings, per_msg_meta)
|
||||
|
||||
returned_memories.append(
|
||||
{
|
||||
@@ -1686,11 +1651,6 @@ class AsyncMemory(MemoryBase):
|
||||
event_type = resp.get("event")
|
||||
|
||||
if event_type == "ADD":
|
||||
# Ensure action_text has an embedding cached to avoid redundant API calls
|
||||
if action_text not in new_message_embeddings:
|
||||
new_message_embeddings[action_text] = await asyncio.to_thread(
|
||||
self.embedding_model.embed, action_text, "add"
|
||||
)
|
||||
task = asyncio.create_task(
|
||||
self._create_memory(
|
||||
data=action_text,
|
||||
@@ -1700,11 +1660,6 @@ class AsyncMemory(MemoryBase):
|
||||
)
|
||||
memory_tasks.append((task, resp, "ADD", None))
|
||||
elif event_type == "UPDATE":
|
||||
# Ensure action_text has an embedding cached to avoid redundant API calls
|
||||
if action_text not in new_message_embeddings:
|
||||
new_message_embeddings[action_text] = await asyncio.to_thread(
|
||||
self.embedding_model.embed, action_text, "update"
|
||||
)
|
||||
task = asyncio.create_task(
|
||||
self._update_memory(
|
||||
memory_id=temp_uuid_mapping[resp["id"]],
|
||||
@@ -2219,27 +2174,7 @@ class AsyncMemory(MemoryBase):
|
||||
memory_id (str): ID of the memory to delete.
|
||||
"""
|
||||
capture_event("mem0.delete", self, {"memory_id": memory_id, "sync_type": "async"})
|
||||
|
||||
existing_memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id)
|
||||
if existing_memory is None:
|
||||
raise ValueError(f"Memory with id {memory_id} not found")
|
||||
|
||||
# Clean up graph entities before deleting from vector store
|
||||
if self.enable_graph:
|
||||
try:
|
||||
memory_text = existing_memory.payload.get("data", "")
|
||||
if memory_text:
|
||||
filters = {}
|
||||
for key in ("user_id", "agent_id", "run_id"):
|
||||
val = existing_memory.payload.get(key)
|
||||
if val:
|
||||
filters[key] = val
|
||||
if filters.get("user_id"):
|
||||
await asyncio.to_thread(self.graph.delete, memory_text, filters)
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up graph for memory {memory_id}: {e}")
|
||||
|
||||
await self._delete_memory(memory_id, existing_memory)
|
||||
await self._delete_memory(memory_id)
|
||||
return {"message": "Memory deleted successfully!"}
|
||||
|
||||
async def delete_all(self, user_id=None, agent_id=None, run_id=None):
|
||||
@@ -2294,27 +2229,24 @@ class AsyncMemory(MemoryBase):
|
||||
capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "async"})
|
||||
return await asyncio.to_thread(self.db.get_history, memory_id)
|
||||
|
||||
async def _create_memory(self, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None):
|
||||
async def _create_memory(self, data, existing_embeddings, metadata=None):
|
||||
logger.debug(f"Creating memory with {data=}")
|
||||
# existing_embeddings may be a dict (preferred) or a precomputed vector
|
||||
if isinstance(existing_embeddings, dict) and data in existing_embeddings:
|
||||
if data in existing_embeddings:
|
||||
embeddings = existing_embeddings[data]
|
||||
elif not isinstance(existing_embeddings, dict):
|
||||
embeddings = existing_embeddings
|
||||
else:
|
||||
embeddings = await asyncio.to_thread(self.embedding_model.embed, data, memory_action="add")
|
||||
|
||||
memory_id = str(uuid.uuid4())
|
||||
new_metadata = deepcopy(metadata) if metadata is not None else {}
|
||||
new_metadata["data"] = data
|
||||
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||
new_metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||
metadata = metadata or {}
|
||||
metadata["data"] = data
|
||||
metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
|
||||
metadata["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.vector_store.insert,
|
||||
vectors=[embeddings],
|
||||
ids=[memory_id],
|
||||
payloads=[new_metadata],
|
||||
payloads=[metadata],
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
@@ -2323,9 +2255,9 @@ class AsyncMemory(MemoryBase):
|
||||
None,
|
||||
data,
|
||||
"ADD",
|
||||
created_at=new_metadata.get("created_at"),
|
||||
actor_id=new_metadata.get("actor_id"),
|
||||
role=new_metadata.get("role"),
|
||||
created_at=metadata.get("created_at"),
|
||||
actor_id=metadata.get("actor_id"),
|
||||
role=metadata.get("role"),
|
||||
)
|
||||
|
||||
return memory_id
|
||||
@@ -2374,17 +2306,16 @@ class AsyncMemory(MemoryBase):
|
||||
if metadata is None:
|
||||
raise ValueError("Metadata cannot be done for procedural memory.")
|
||||
|
||||
new_metadata = deepcopy(metadata)
|
||||
new_metadata["memory_type"] = MemoryType.PROCEDURAL.value
|
||||
metadata["memory_type"] = MemoryType.PROCEDURAL.value
|
||||
embeddings = await asyncio.to_thread(self.embedding_model.embed, procedural_memory, memory_action="add")
|
||||
memory_id = await self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=new_metadata)
|
||||
memory_id = await self._create_memory(procedural_memory, {procedural_memory: embeddings}, metadata=metadata)
|
||||
capture_event("mem0._create_procedural_memory", self, {"memory_id": memory_id, "sync_type": "async"})
|
||||
|
||||
result = {"results": [{"id": memory_id, "memory": procedural_memory, "event": "ADD"}]}
|
||||
|
||||
return result
|
||||
|
||||
async def _update_memory(self, memory_id, data: str, existing_embeddings: Union[Dict[str, List[float]], List[float]], metadata=None):
|
||||
async def _update_memory(self, memory_id, data, existing_embeddings, metadata=None):
|
||||
logger.info(f"Updating memory with {data=}")
|
||||
|
||||
try:
|
||||
@@ -2418,10 +2349,8 @@ class AsyncMemory(MemoryBase):
|
||||
if "role" not in new_metadata and "role" in existing_memory.payload:
|
||||
new_metadata["role"] = existing_memory.payload["role"]
|
||||
|
||||
if isinstance(existing_embeddings, dict) and data in existing_embeddings:
|
||||
if data in existing_embeddings:
|
||||
embeddings = existing_embeddings[data]
|
||||
elif not isinstance(existing_embeddings, dict):
|
||||
embeddings = existing_embeddings
|
||||
else:
|
||||
embeddings = await asyncio.to_thread(self.embedding_model.embed, data, "update")
|
||||
|
||||
@@ -2446,12 +2375,11 @@ class AsyncMemory(MemoryBase):
|
||||
)
|
||||
return memory_id
|
||||
|
||||
async def _delete_memory(self, memory_id, existing_memory=None):
|
||||
async def _delete_memory(self, memory_id):
|
||||
logger.info(f"Deleting memory with {memory_id=}")
|
||||
existing_memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id)
|
||||
if existing_memory is None:
|
||||
existing_memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id)
|
||||
if existing_memory is None:
|
||||
raise ValueError(f"Memory with id {memory_id} not found")
|
||||
raise ValueError(f"Memory with id {memory_id} not found")
|
||||
prev_value = existing_memory.payload.get("data", "")
|
||||
|
||||
await asyncio.to_thread(self.vector_store.delete, vector_id=memory_id)
|
||||
|
||||
@@ -134,28 +134,6 @@ class MemoryGraph:
|
||||
|
||||
return search_results
|
||||
|
||||
def delete(self, data, filters):
|
||||
"""
|
||||
Delete graph entities associated with the given memory text.
|
||||
|
||||
Extracts entities and relationships from the memory text using the same
|
||||
pipeline as add(), then deletes the matching relationships in the graph.
|
||||
|
||||
Args:
|
||||
data (str): The memory text whose graph entities should be removed.
|
||||
filters (dict): Scope filters (user_id, agent_id).
|
||||
"""
|
||||
try:
|
||||
entity_type_map = self._retrieve_nodes_from_data(data, filters)
|
||||
if not entity_type_map:
|
||||
logger.debug("No entities found in memory text, skipping graph cleanup")
|
||||
return
|
||||
to_be_deleted = self._establish_nodes_relations_from_data(data, filters, entity_type_map)
|
||||
if to_be_deleted:
|
||||
self._delete_entities(to_be_deleted, filters)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during graph cleanup for memory delete: {e}")
|
||||
|
||||
def delete_all(self, filters):
|
||||
"""Delete all nodes and relationships for a user or specific agent."""
|
||||
if filters.get("agent_id"):
|
||||
|
||||
@@ -3,7 +3,6 @@ from typing import Dict, Optional, Union
|
||||
|
||||
from mem0.configs.embeddings.base import BaseEmbedderConfig
|
||||
from mem0.configs.llms.anthropic import AnthropicConfig
|
||||
from mem0.configs.llms.aws_bedrock import AWSBedrockConfig
|
||||
from mem0.configs.llms.azure import AzureOpenAIConfig
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.configs.llms.deepseek import DeepSeekConfig
|
||||
@@ -39,7 +38,7 @@ class LlmFactory:
|
||||
"openai": ("mem0.llms.openai.OpenAILLM", OpenAIConfig),
|
||||
"groq": ("mem0.llms.groq.GroqLLM", BaseLlmConfig),
|
||||
"together": ("mem0.llms.together.TogetherLLM", BaseLlmConfig),
|
||||
"aws_bedrock": ("mem0.llms.aws_bedrock.AWSBedrockLLM", AWSBedrockConfig),
|
||||
"aws_bedrock": ("mem0.llms.aws_bedrock.AWSBedrockLLM", BaseLlmConfig),
|
||||
"litellm": ("mem0.llms.litellm.LiteLLM", BaseLlmConfig),
|
||||
"azure_openai": ("mem0.llms.azure_openai.AzureOpenAILLM", AzureOpenAIConfig),
|
||||
"openai_structured": ("mem0.llms.openai_structured.OpenAIStructuredLLM", OpenAIConfig),
|
||||
@@ -189,7 +188,6 @@ class VectorStoreFactory:
|
||||
"baidu": "mem0.vector_stores.baidu.BaiduDB",
|
||||
"cassandra": "mem0.vector_stores.cassandra.CassandraDB",
|
||||
"neptune": "mem0.vector_stores.neptune_analytics.NeptuneAnalyticsVector",
|
||||
"turbopuffer": "mem0.vector_stores.turbopuffer.TurbopufferDB",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -1,88 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_default_vector_store_path(provider: str) -> str:
|
||||
"""Return a platform-appropriate default path for a vector store provider.
|
||||
|
||||
Preference order: MEM0_DATA_DIR → XDG_DATA_HOME (Linux) → APPDATA (Windows) → ~/.local/share
|
||||
"""
|
||||
mem0_data_dir = os.environ.get("MEM0_DATA_DIR")
|
||||
if mem0_data_dir:
|
||||
base = Path(mem0_data_dir)
|
||||
else:
|
||||
xdg = os.environ.get("XDG_DATA_HOME")
|
||||
app_data = os.environ.get("APPDATA")
|
||||
if xdg:
|
||||
base = Path(xdg)
|
||||
elif app_data:
|
||||
base = Path(app_data)
|
||||
else:
|
||||
base = Path.home() / ".local" / "share"
|
||||
base = base / "mem0"
|
||||
return str(base / provider)
|
||||
|
||||
|
||||
def _migrate_legacy_data(provider: str, new_path: str, legacy_path: Optional[str] = None):
|
||||
"""Auto-migrate data from legacy /tmp/{provider} to the new persistent path.
|
||||
|
||||
Skips migration if:
|
||||
- Legacy path doesn't exist, is empty, or is a symlink (security)
|
||||
- New path already contains data (no clobbering)
|
||||
- A .migration_complete sentinel exists at the new path (already migrated)
|
||||
|
||||
Falls back to a warning with manual instructions if the copy fails.
|
||||
Note: On Windows /tmp doesn't exist, so this is effectively a no-op.
|
||||
|
||||
Args:
|
||||
provider: Vector store provider name (e.g. 'qdrant').
|
||||
new_path: The new persistent path to migrate data to.
|
||||
legacy_path: Override the legacy path (default: /tmp/{provider}).
|
||||
Exposed for testing; production callers should omit this.
|
||||
"""
|
||||
legacy_path = Path(legacy_path if legacy_path is not None else f"/tmp/{provider}")
|
||||
if not legacy_path.exists() or legacy_path.is_symlink() or not any(legacy_path.iterdir()):
|
||||
return
|
||||
|
||||
new_path_obj = Path(new_path)
|
||||
|
||||
# Skip if already migrated in a previous run
|
||||
sentinel = new_path_obj / ".migration_complete"
|
||||
if sentinel.exists():
|
||||
return
|
||||
|
||||
if new_path_obj.exists() and any(new_path_obj.iterdir()):
|
||||
logger.info(
|
||||
"Both legacy path '%s' and new path '%s' contain data. "
|
||||
"Using new path. Legacy data at '%s' is untouched.",
|
||||
legacy_path, new_path, legacy_path,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
shutil.copytree(str(legacy_path), new_path, dirs_exist_ok=True)
|
||||
# Write sentinel to prevent repeated migration attempts on future startups
|
||||
sentinel.touch()
|
||||
logger.info(
|
||||
"Migrated vector store data from '%s' to '%s'. "
|
||||
"You may remove the old directory once verified.",
|
||||
legacy_path, new_path,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not auto-migrate data from '%s' to '%s': %s. "
|
||||
"Please manually copy/move the data, or set path='/tmp/%s' in your config "
|
||||
"to keep using the old location.",
|
||||
legacy_path, new_path, e, provider,
|
||||
)
|
||||
|
||||
|
||||
class VectorStoreConfig(BaseModel):
|
||||
provider: str = Field(
|
||||
@@ -115,7 +34,6 @@ class VectorStoreConfig(BaseModel):
|
||||
"faiss": "FAISSConfig",
|
||||
"langchain": "LangchainConfig",
|
||||
"s3_vectors": "S3VectorsConfig",
|
||||
"turbopuffer": "TurbopufferConfig",
|
||||
}
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -142,8 +60,7 @@ class VectorStoreConfig(BaseModel):
|
||||
|
||||
# also check if path in allowed kays for pydantic model, and whether config extra fields are allowed
|
||||
if "path" not in config and "path" in config_class.__annotations__:
|
||||
config["path"] = get_default_vector_store_path(provider)
|
||||
_migrate_legacy_data(provider, config["path"])
|
||||
config["path"] = f"/tmp/{provider}"
|
||||
|
||||
self.config = config_class(**config)
|
||||
return self
|
||||
|
||||
@@ -65,7 +65,7 @@ class Databricks(VectorStoreBase):
|
||||
catalog (str): Unity Catalog catalog name.
|
||||
schema (str): Unity Catalog schema name.
|
||||
table_name (str): Source Delta table name.
|
||||
collection_name (str, optional): Vector search index name (default: "mem0").
|
||||
index_name (str, optional): Vector search index name (default: "mem0").
|
||||
index_type (str, optional): Index type, either "DELTA_SYNC" or "DIRECT_ACCESS" (default: "DELTA_SYNC").
|
||||
embedding_model_endpoint_name (str, optional): Embedding model endpoint for Databricks-computed embeddings.
|
||||
embedding_dimension (int, optional): Vector embedding dimensions (default: 1536).
|
||||
@@ -85,7 +85,7 @@ class Databricks(VectorStoreBase):
|
||||
self.fully_qualified_index_name = f"{self.catalog}.{self.schema}.{self.index_name}"
|
||||
|
||||
# Configuration
|
||||
self.index_type = VectorIndexType(index_type) if isinstance(index_type, str) else index_type
|
||||
self.index_type = index_type
|
||||
self.embedding_model_endpoint_name = embedding_model_endpoint_name
|
||||
self.embedding_dimension = embedding_dimension
|
||||
self.endpoint_type = endpoint_type
|
||||
@@ -261,11 +261,11 @@ class Databricks(VectorStoreBase):
|
||||
)
|
||||
logger.info(f"Successfully created source table '{self.fully_qualified_table_name}'")
|
||||
self.client.table_constraints.create(
|
||||
full_name_arg=self.fully_qualified_table_name,
|
||||
full_name_arg="logistics_dev.ai.dev_memory",
|
||||
constraint=TableConstraint(
|
||||
primary_key_constraint=PrimaryKeyConstraint(
|
||||
name=f"pk_{self.table_name}",
|
||||
child_columns=["memory_id"],
|
||||
name="pk_dev_memory", # Name of the primary key constraint
|
||||
child_columns=["memory_id"], # Columns that make up the primary key
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -439,29 +439,29 @@ class Databricks(VectorStoreBase):
|
||||
try:
|
||||
filters_json = json.dumps(filters) if filters else None
|
||||
|
||||
# Choose query mode per Databricks SDK contract:
|
||||
# - query_text: for Delta Sync Index with model endpoint
|
||||
# - query_vector: for Direct Access Index and Delta Sync Index with self-managed vectors
|
||||
query_kwargs = {
|
||||
"index_name": self.fully_qualified_index_name,
|
||||
"columns": self.column_names,
|
||||
"num_results": limit,
|
||||
"query_type": self.query_type,
|
||||
"filters_json": filters_json,
|
||||
}
|
||||
uses_model_endpoint = (
|
||||
self.index_type == VectorIndexType.DELTA_SYNC and self.embedding_model_endpoint_name
|
||||
)
|
||||
if uses_model_endpoint:
|
||||
if not query:
|
||||
raise ValueError("Query text is required for Delta Sync Index with model endpoint.")
|
||||
query_kwargs["query_text"] = query
|
||||
elif vectors:
|
||||
query_kwargs["query_vector"] = vectors
|
||||
# Choose query type
|
||||
if self.index_type == VectorIndexType.DELTA_SYNC and query:
|
||||
# Text-based search
|
||||
sdk_results = self.client.vector_search_indexes.query_index(
|
||||
index_name=self.fully_qualified_index_name,
|
||||
columns=self.column_names,
|
||||
query_text=query,
|
||||
num_results=limit,
|
||||
query_type=self.query_type,
|
||||
filters_json=filters_json,
|
||||
)
|
||||
elif self.index_type == VectorIndexType.DIRECT_ACCESS and vectors:
|
||||
# Vector-based search
|
||||
sdk_results = self.client.vector_search_indexes.query_index(
|
||||
index_name=self.fully_qualified_index_name,
|
||||
columns=self.column_names,
|
||||
query_vector=vectors,
|
||||
num_results=limit,
|
||||
query_type=self.query_type,
|
||||
filters_json=filters_json,
|
||||
)
|
||||
else:
|
||||
raise ValueError("Must provide vectors for search.")
|
||||
|
||||
sdk_results = self.client.vector_search_indexes.query_index(**query_kwargs)
|
||||
raise ValueError("Must provide query text for DELTA_SYNC or vectors for DIRECT_ACCESS.")
|
||||
|
||||
# Parse results
|
||||
result_data = sdk_results.result if hasattr(sdk_results, "result") else sdk_results
|
||||
@@ -572,23 +572,14 @@ class Databricks(VectorStoreBase):
|
||||
filters = {"memory_id": vector_id}
|
||||
filters_json = json.dumps(filters)
|
||||
|
||||
# Use query_text for Delta Sync with model endpoint, query_vector otherwise
|
||||
query_kwargs = {
|
||||
"index_name": self.fully_qualified_index_name,
|
||||
"columns": self.column_names,
|
||||
"num_results": 1,
|
||||
"query_type": self.query_type,
|
||||
"filters_json": filters_json,
|
||||
}
|
||||
uses_model_endpoint = (
|
||||
self.index_type == VectorIndexType.DELTA_SYNC and self.embedding_model_endpoint_name
|
||||
results = self.client.vector_search_indexes.query_index(
|
||||
index_name=self.fully_qualified_index_name,
|
||||
columns=self.column_names,
|
||||
query_text=" ", # Empty query, rely on filters
|
||||
num_results=1,
|
||||
query_type=self.query_type,
|
||||
filters_json=filters_json,
|
||||
)
|
||||
if uses_model_endpoint:
|
||||
query_kwargs["query_text"] = " "
|
||||
else:
|
||||
query_kwargs["query_vector"] = [0.0] * self.embedding_dimension
|
||||
|
||||
results = self.client.vector_search_indexes.query_index(**query_kwargs)
|
||||
|
||||
# Process results
|
||||
result_data = results.result if hasattr(results, "result") else results
|
||||
@@ -598,7 +589,7 @@ class Databricks(VectorStoreBase):
|
||||
raise KeyError(f"Vector with ID {vector_id} not found")
|
||||
|
||||
result = data_array[0]
|
||||
columns = [col.name for col in results.manifest.columns] if results.manifest and results.manifest.columns else []
|
||||
columns = columns = [col.name for col in results.manifest.columns] if results.manifest and results.manifest.columns else []
|
||||
row_data = dict(zip(columns, result))
|
||||
|
||||
# Build payload following the standard schema
|
||||
@@ -695,23 +686,14 @@ class Databricks(VectorStoreBase):
|
||||
filters_json = json.dumps(filters) if filters else None
|
||||
num_results = limit or 100
|
||||
columns = self.column_names
|
||||
# Use query_text for Delta Sync with model endpoint, query_vector otherwise
|
||||
query_kwargs = {
|
||||
"index_name": self.fully_qualified_index_name,
|
||||
"columns": columns,
|
||||
"num_results": num_results,
|
||||
"query_type": self.query_type,
|
||||
"filters_json": filters_json,
|
||||
}
|
||||
uses_model_endpoint = (
|
||||
self.index_type == VectorIndexType.DELTA_SYNC and self.embedding_model_endpoint_name
|
||||
sdk_results = self.client.vector_search_indexes.query_index(
|
||||
index_name=self.fully_qualified_index_name,
|
||||
columns=columns,
|
||||
query_text=" ",
|
||||
num_results=num_results,
|
||||
query_type=self.query_type,
|
||||
filters_json=filters_json,
|
||||
)
|
||||
if uses_model_endpoint:
|
||||
query_kwargs["query_text"] = " "
|
||||
else:
|
||||
query_kwargs["query_vector"] = [0.0] * self.embedding_dimension
|
||||
|
||||
sdk_results = self.client.vector_search_indexes.query_index(**query_kwargs)
|
||||
result_data = sdk_results.result if hasattr(sdk_results, "result") else sdk_results
|
||||
data_array = result_data.data_array if hasattr(result_data, "data_array") else []
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ from typing import Dict, List, Optional
|
||||
import numpy as np
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mem0.vector_stores.configs import get_default_vector_store_path
|
||||
|
||||
import warnings
|
||||
|
||||
try:
|
||||
@@ -60,7 +58,7 @@ class FAISS(VectorStoreBase):
|
||||
Defaults to False.
|
||||
"""
|
||||
self.collection_name = collection_name
|
||||
self.path = path or f"{get_default_vector_store_path('faiss')}/{collection_name}"
|
||||
self.path = path or f"/tmp/faiss/{collection_name}"
|
||||
self.distance_strategy = distance_strategy
|
||||
self.normalize_L2 = normalize_L2
|
||||
self.embedding_model_dims = embedding_model_dims
|
||||
|
||||
@@ -26,7 +26,7 @@ class OutputData(BaseModel):
|
||||
|
||||
|
||||
class MongoDB(VectorStoreBase):
|
||||
VECTOR_TYPE = "vector"
|
||||
VECTOR_TYPE = "knnVector"
|
||||
SIMILARITY_METRIC = "cosine"
|
||||
|
||||
def __init__(self, db_name: str, collection_name: str, embedding_model_dims: int, mongo_uri: str):
|
||||
@@ -69,16 +69,17 @@ class MongoDB(VectorStoreBase):
|
||||
else:
|
||||
search_index_model = SearchIndexModel(
|
||||
name=self.index_name,
|
||||
type="vectorSearch",
|
||||
definition={
|
||||
"fields": [
|
||||
{
|
||||
"type": self.VECTOR_TYPE,
|
||||
"path": "embedding",
|
||||
"numDimensions": self.embedding_model_dims,
|
||||
"similarity": self.SIMILARITY_METRIC,
|
||||
}
|
||||
]
|
||||
"mappings": {
|
||||
"dynamic": False,
|
||||
"fields": {
|
||||
"embedding": {
|
||||
"type": self.VECTOR_TYPE,
|
||||
"dimensions": self.embedding_model_dims,
|
||||
"similarity": self.SIMILARITY_METRIC,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
collection.create_search_index(search_index_model)
|
||||
@@ -140,7 +141,7 @@ class MongoDB(VectorStoreBase):
|
||||
"$vectorSearch": {
|
||||
"index": self.index_name,
|
||||
"limit": limit,
|
||||
"numCandidates": min(limit * 20, 10000),
|
||||
"numCandidates": limit,
|
||||
"queryVector": vectors,
|
||||
"path": "embedding",
|
||||
}
|
||||
@@ -197,8 +198,7 @@ class MongoDB(VectorStoreBase):
|
||||
if vector is not None:
|
||||
update_fields["embedding"] = vector
|
||||
if payload is not None:
|
||||
for key, value in payload.items():
|
||||
update_fields[f"payload.{key}"] = value
|
||||
update_fields["payload"] = payload
|
||||
|
||||
if update_fields:
|
||||
try:
|
||||
|
||||
@@ -113,25 +113,6 @@ class OpenSearchDB(VectorStoreBase):
|
||||
if payloads is None:
|
||||
payloads = [{} for _ in range(len(vectors))]
|
||||
|
||||
for idx, vec in enumerate(vectors):
|
||||
if vec is None:
|
||||
raise ValueError(
|
||||
f"Vector at index {idx} is null. "
|
||||
f"This usually means the embedding model failed to generate an embedding. "
|
||||
f"Check that your embedding model is configured correctly and returning valid vectors."
|
||||
)
|
||||
if len(vec) == 0:
|
||||
raise ValueError(
|
||||
f"Vector at index {idx} is empty. "
|
||||
f"Expected a vector of dimension {self.embedding_model_dims}, got an empty vector."
|
||||
)
|
||||
if len(vec) != self.embedding_model_dims:
|
||||
raise ValueError(
|
||||
f"Vector at index {idx} has dimension {len(vec)}, "
|
||||
f"but the index '{self.collection_name}' expects dimension {self.embedding_model_dims}. "
|
||||
f"Ensure your embedding model's output dimensions match the vector store configuration."
|
||||
)
|
||||
|
||||
results = []
|
||||
for i, (vec, id_) in enumerate(zip(vectors, ids)):
|
||||
body = {
|
||||
@@ -143,16 +124,14 @@ class OpenSearchDB(VectorStoreBase):
|
||||
self.client.index(index=self.collection_name, body=body)
|
||||
# Force refresh to make documents immediately searchable for tests
|
||||
self.client.indices.refresh(index=self.collection_name)
|
||||
|
||||
results.append(
|
||||
OutputData(
|
||||
id=id_,
|
||||
score=1.0, # No score for inserts
|
||||
payload=payloads[i],
|
||||
)
|
||||
)
|
||||
|
||||
results.append(OutputData(
|
||||
id=id_,
|
||||
score=1.0, # No score for inserts
|
||||
payload=payloads[i]
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Error inserting vector {id_}: {e}", exc_info=True)
|
||||
logger.error(f"Error inserting vector {id_}: {e}")
|
||||
raise
|
||||
|
||||
return results
|
||||
@@ -200,7 +179,7 @@ class OpenSearchDB(VectorStoreBase):
|
||||
]
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error during search: {e}", exc_info=True)
|
||||
logger.error(f"Error during search: {e}")
|
||||
return []
|
||||
|
||||
def delete(self, vector_id: str) -> None:
|
||||
@@ -221,15 +200,6 @@ class OpenSearchDB(VectorStoreBase):
|
||||
|
||||
def update(self, vector_id: str, vector: Optional[List[float]] = None, payload: Optional[Dict] = None) -> None:
|
||||
"""Update a vector and its payload using the custom 'id' field."""
|
||||
if vector is not None:
|
||||
if len(vector) == 0:
|
||||
raise ValueError("Cannot update with an empty vector.")
|
||||
if len(vector) != self.embedding_model_dims:
|
||||
raise ValueError(
|
||||
f"Update vector has dimension {len(vector)}, "
|
||||
f"but the index '{self.collection_name}' expects dimension {self.embedding_model_dims}. "
|
||||
f"Ensure your embedding model's output dimensions match the vector store configuration."
|
||||
)
|
||||
|
||||
# First, find the document by custom ID
|
||||
search_query = {"query": {"term": {"id": vector_id}}}
|
||||
@@ -252,9 +222,8 @@ class OpenSearchDB(VectorStoreBase):
|
||||
if doc:
|
||||
try:
|
||||
response = self.client.update(index=self.collection_name, id=opensearch_id, body={"doc": doc})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating vector {vector_id}: {e}", exc_info=True)
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get(self, vector_id: str) -> Optional[OutputData]:
|
||||
"""Retrieve a vector by ID."""
|
||||
@@ -269,7 +238,7 @@ class OpenSearchDB(VectorStoreBase):
|
||||
|
||||
return OutputData(id=hits[0]["_source"].get("id"), score=1.0, payload=hits[0]["_source"].get("payload", {}))
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving vector {vector_id}: {str(e)}", exc_info=True)
|
||||
logger.error(f"Error retrieving vector {vector_id}: {str(e)}")
|
||||
return None
|
||||
|
||||
def list_cols(self) -> List[str]:
|
||||
@@ -312,8 +281,9 @@ class OpenSearchDB(VectorStoreBase):
|
||||
]
|
||||
return [results] # VectorStore expects tuple/list format
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing vectors: {e}", exc_info=True)
|
||||
logger.error(f"Error listing vectors: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def reset(self):
|
||||
"""Reset the index by deleting and recreating it."""
|
||||
|
||||
+14
-136
@@ -1,14 +1,12 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import (
|
||||
Distance,
|
||||
FieldCondition,
|
||||
Filter,
|
||||
MatchAny,
|
||||
MatchExcept,
|
||||
MatchText,
|
||||
MatchValue,
|
||||
PointIdsList,
|
||||
PointStruct,
|
||||
@@ -46,8 +44,7 @@ class Qdrant(VectorStoreBase):
|
||||
path (str, optional): Path for local Qdrant database. Defaults to None.
|
||||
url (str, optional): Full URL for Qdrant server. Defaults to None.
|
||||
api_key (str, optional): API key for Qdrant server. Defaults to None.
|
||||
on_disk (bool, optional): Enables persistent storage. Vectors are stored on disk (True) or in memory (False).
|
||||
Does not delete the local database path. Defaults to False.
|
||||
on_disk (bool, optional): Enables persistent storage. Defaults to False.
|
||||
"""
|
||||
if client:
|
||||
self.client = client
|
||||
@@ -65,6 +62,9 @@ class Qdrant(VectorStoreBase):
|
||||
if not params:
|
||||
params["path"] = path
|
||||
self.is_local = True
|
||||
if not on_disk:
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
self.is_local = False
|
||||
|
||||
@@ -138,148 +138,26 @@ class Qdrant(VectorStoreBase):
|
||||
]
|
||||
self.client.upsert(collection_name=self.collection_name, points=points)
|
||||
|
||||
def _build_field_condition(self, key: str, value) -> Optional[FieldCondition]:
|
||||
"""
|
||||
Build a single FieldCondition from a key-value filter pair.
|
||||
|
||||
Supports the enhanced filter syntax documented at
|
||||
https://docs.mem0.ai/open-source/features/metadata-filtering
|
||||
|
||||
Args:
|
||||
key (str): The payload field name.
|
||||
value: A scalar for simple equality, or a dict with one operator key.
|
||||
|
||||
Returns:
|
||||
Optional[FieldCondition]: The Qdrant field condition, or None if the
|
||||
value is the wildcard '*' (match any / field exists — skip filter).
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
if value == "*":
|
||||
# Wildcard: match any value. Qdrant has no direct "field exists"
|
||||
# condition via FieldCondition, so we skip this filter (match all).
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
# List shorthand: {"field": ["a", "b"]} treated as in-operator.
|
||||
return FieldCondition(key=key, match=MatchAny(any=value))
|
||||
# Simple equality: {"field": "value"}
|
||||
return FieldCondition(key=key, match=MatchValue(value=value))
|
||||
|
||||
ops = set(value.keys())
|
||||
range_ops = {"gt", "gte", "lt", "lte"}
|
||||
non_range_ops = ops - range_ops
|
||||
|
||||
if ops & range_ops:
|
||||
if non_range_ops:
|
||||
raise ValueError(
|
||||
f"Cannot mix range operators ({ops & range_ops}) with "
|
||||
f"non-range operators ({non_range_ops}) for field '{key}'. "
|
||||
f"Use AND to combine them as separate conditions."
|
||||
)
|
||||
range_kwargs = {op: value[op] for op in range_ops if op in value}
|
||||
return FieldCondition(key=key, range=Range(**range_kwargs))
|
||||
elif "eq" in value:
|
||||
return FieldCondition(key=key, match=MatchValue(value=value["eq"]))
|
||||
elif "ne" in value:
|
||||
return FieldCondition(key=key, match=MatchExcept(**{"except": [value["ne"]]}))
|
||||
elif "in" in value:
|
||||
return FieldCondition(key=key, match=MatchAny(any=value["in"]))
|
||||
elif "nin" in value:
|
||||
return FieldCondition(key=key, match=MatchExcept(**{"except": value["nin"]}))
|
||||
elif "contains" in value or "icontains" in value:
|
||||
# MatchText: with a full-text index, tokenized matching (all words must appear).
|
||||
# Without a full-text index, exact substring match.
|
||||
op = "icontains" if "icontains" in value else "contains"
|
||||
text = value[op]
|
||||
if op == "icontains":
|
||||
logger.debug(
|
||||
"icontains on field '%s': Qdrant MatchText case sensitivity depends on "
|
||||
"full-text index configuration. Without a full-text index this behaves "
|
||||
"as a case-sensitive substring match (same as 'contains').",
|
||||
key,
|
||||
)
|
||||
return FieldCondition(key=key, match=MatchText(text=text))
|
||||
else:
|
||||
supported = {"eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "contains", "icontains"}
|
||||
raise ValueError(
|
||||
f"Unsupported filter operator(s) for field '{key}': {ops}. "
|
||||
f"Supported operators: {supported}"
|
||||
)
|
||||
|
||||
def _create_filter(self, filters: dict) -> Optional[Filter]:
|
||||
def _create_filter(self, filters: dict) -> Filter:
|
||||
"""
|
||||
Create a Filter object from the provided filters.
|
||||
|
||||
Supports the enhanced filter syntax with comparison operators (eq, ne,
|
||||
gt, gte, lt, lte), list operators (in, nin), string operators (contains,
|
||||
icontains), and logical operators (AND, OR, NOT).
|
||||
|
||||
Args:
|
||||
filters (dict): Filters to apply.
|
||||
|
||||
Returns:
|
||||
Filter: The created Filter object, or None if filters is empty.
|
||||
Filter: The created Filter object.
|
||||
"""
|
||||
if not filters:
|
||||
return None
|
||||
|
||||
# Normalize $or/$not/$and → OR/NOT/AND and deduplicate.
|
||||
# Memory._process_metadata_filters() renames OR→$or and NOT→$not,
|
||||
# but effective_filters retains the original OR/NOT keys from
|
||||
# deepcopy(input_filters). Without dedup the same sub-conditions
|
||||
# would be evaluated twice.
|
||||
key_map = {"$or": "OR", "$not": "NOT", "$and": "AND"}
|
||||
normalized = {}
|
||||
|
||||
conditions = []
|
||||
for key, value in filters.items():
|
||||
norm_key = key_map.get(key, key)
|
||||
if norm_key not in normalized:
|
||||
normalized[norm_key] = value
|
||||
|
||||
must = []
|
||||
should = []
|
||||
must_not = []
|
||||
|
||||
for key, value in normalized.items():
|
||||
if key in ("AND", "OR", "NOT"):
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(
|
||||
f"{key} filter value must be a list of filter dicts, "
|
||||
f"got {type(value).__name__}"
|
||||
)
|
||||
for i, item in enumerate(value):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(
|
||||
f"{key} filter list item at index {i} must be a dict, "
|
||||
f"got {type(item).__name__}: {item!r}"
|
||||
)
|
||||
|
||||
if key == "AND":
|
||||
for sub in value:
|
||||
built = self._create_filter(sub)
|
||||
if built:
|
||||
must.append(built)
|
||||
elif key == "OR":
|
||||
for sub in value:
|
||||
built = self._create_filter(sub)
|
||||
if built:
|
||||
should.append(built)
|
||||
elif key == "NOT":
|
||||
for sub in value:
|
||||
built = self._create_filter(sub)
|
||||
if built:
|
||||
must_not.append(built)
|
||||
if isinstance(value, dict) and "gte" in value and "lte" in value:
|
||||
conditions.append(FieldCondition(key=key, range=Range(gte=value["gte"], lte=value["lte"])))
|
||||
else:
|
||||
condition = self._build_field_condition(key, value)
|
||||
if condition is not None:
|
||||
must.append(condition)
|
||||
|
||||
if not any([must, should, must_not]):
|
||||
return None
|
||||
|
||||
return Filter(
|
||||
must=must or None,
|
||||
should=should or None,
|
||||
must_not=must_not or None,
|
||||
)
|
||||
conditions.append(FieldCondition(key=key, match=MatchValue(value=value)))
|
||||
return Filter(must=conditions) if conditions else None
|
||||
|
||||
def search(self, query: str, vectors: list, limit: int = 5, filters: dict = None) -> list:
|
||||
"""
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
try:
|
||||
from turbopuffer import Turbopuffer as TurbopufferClient
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Turbopuffer requires extra dependencies. Install with `pip install turbopuffer`"
|
||||
) from None
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mem0.vector_stores.base import VectorStoreBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OutputData(BaseModel):
|
||||
id: Optional[str]
|
||||
score: Optional[float]
|
||||
payload: Optional[Dict]
|
||||
|
||||
|
||||
class TurbopufferDB(VectorStoreBase):
|
||||
def __init__(
|
||||
self,
|
||||
collection_name: str,
|
||||
embedding_model_dims: int,
|
||||
api_key: Optional[str] = None,
|
||||
region: str = "gcp-us-central1",
|
||||
distance_metric: str = "cosine_distance",
|
||||
batch_size: int = 100,
|
||||
extra_params: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the Turbopuffer vector store.
|
||||
|
||||
Args:
|
||||
collection_name (str): Name of the namespace/collection.
|
||||
embedding_model_dims (int): Dimensions of the embedding model.
|
||||
api_key (str, optional): API key for Turbopuffer. Defaults to None.
|
||||
region (str, optional): Turbopuffer region. Defaults to "gcp-us-central1".
|
||||
distance_metric (str, optional): Distance metric for vector similarity.
|
||||
Options: "cosine_distance" or "euclidean_squared". Defaults to "cosine_distance".
|
||||
batch_size (int, optional): Batch size for operations. Defaults to 100.
|
||||
extra_params (Dict, optional): Additional parameters for Turbopuffer client. Defaults to None.
|
||||
"""
|
||||
api_key = api_key or os.environ.get("TURBOPUFFER_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"Turbopuffer API key must be provided either as a parameter or via TURBOPUFFER_API_KEY environment variable"
|
||||
)
|
||||
|
||||
params = extra_params or {}
|
||||
params["region"] = region
|
||||
|
||||
self.client = TurbopufferClient(api_key=api_key, **params)
|
||||
self.collection_name = collection_name
|
||||
self.embedding_model_dims = embedding_model_dims
|
||||
self.distance_metric = distance_metric
|
||||
self.batch_size = batch_size
|
||||
|
||||
self.namespace = self.client.namespace(self.collection_name)
|
||||
|
||||
def create_col(self, name=None, vector_size=None, distance=None):
|
||||
"""
|
||||
Create a new namespace in Turbopuffer.
|
||||
Namespaces are created implicitly on first upsert, so this is a no-op.
|
||||
"""
|
||||
pass
|
||||
|
||||
def insert(
|
||||
self,
|
||||
vectors: List[List[float]],
|
||||
payloads: Optional[List[Dict]] = None,
|
||||
ids: Optional[List[Union[str, int]]] = None,
|
||||
):
|
||||
"""
|
||||
Insert vectors into the namespace.
|
||||
|
||||
Args:
|
||||
vectors (list): List of vectors to insert.
|
||||
payloads (list, optional): List of payloads corresponding to vectors. Defaults to None.
|
||||
ids (list, optional): List of IDs corresponding to vectors. Defaults to None.
|
||||
"""
|
||||
logger.info(f"Inserting {len(vectors)} vectors into namespace {self.collection_name}")
|
||||
|
||||
if ids is None:
|
||||
ids = [str(i) for i in range(len(vectors))]
|
||||
|
||||
for i in range(0, len(vectors), self.batch_size):
|
||||
batch_end = i + self.batch_size
|
||||
rows = []
|
||||
for j in range(i, min(batch_end, len(vectors))):
|
||||
row = {}
|
||||
if payloads and payloads[j]:
|
||||
row.update(payloads[j])
|
||||
row["id"] = str(ids[j])
|
||||
row["vector"] = vectors[j]
|
||||
rows.append(row)
|
||||
|
||||
self.namespace.write(
|
||||
upsert_rows=rows,
|
||||
distance_metric=self.distance_metric,
|
||||
)
|
||||
|
||||
def _parse_output(self, rows) -> List[OutputData]:
|
||||
"""
|
||||
Parse the output data from Turbopuffer query results.
|
||||
|
||||
Args:
|
||||
rows: List of Row objects from Turbopuffer query.
|
||||
|
||||
Returns:
|
||||
List[OutputData]: Parsed output data.
|
||||
"""
|
||||
results = []
|
||||
for row in rows:
|
||||
row_dict = row.model_dump()
|
||||
row_id = str(row_dict.pop("id"))
|
||||
dist = row_dict.pop("$dist", None)
|
||||
row_dict.pop("vector", None)
|
||||
|
||||
score = 1 - dist if dist is not None else None
|
||||
|
||||
results.append(OutputData(
|
||||
id=row_id,
|
||||
score=score,
|
||||
payload=row_dict,
|
||||
))
|
||||
return results
|
||||
|
||||
def _convert_filters(self, filters: Optional[Dict]):
|
||||
"""
|
||||
Convert mem0 filters to Turbopuffer filter format.
|
||||
|
||||
Turbopuffer filters use tuple format: ("And", (("field", "Op", value), ...))
|
||||
"""
|
||||
if not filters:
|
||||
return None
|
||||
|
||||
conditions = []
|
||||
for key, value in filters.items():
|
||||
if isinstance(value, dict):
|
||||
if "gte" in value:
|
||||
conditions.append((key, "Gte", value["gte"]))
|
||||
if "lte" in value:
|
||||
conditions.append((key, "Lte", value["lte"]))
|
||||
else:
|
||||
conditions.append((key, "Eq", value))
|
||||
|
||||
if not conditions:
|
||||
return None
|
||||
if len(conditions) == 1:
|
||||
return conditions[0]
|
||||
return ("And", tuple(conditions))
|
||||
|
||||
def search(
|
||||
self, query: str, vectors: List[float], limit: int = 5, filters: Optional[Dict] = None
|
||||
) -> List[OutputData]:
|
||||
"""
|
||||
Search for similar vectors.
|
||||
|
||||
Args:
|
||||
query (str): Query text (unused in vector search, kept for interface consistency).
|
||||
vectors (list): Query vector to search with.
|
||||
limit (int, optional): Number of results to return. Defaults to 5.
|
||||
filters (dict, optional): Filters to apply to the search. Defaults to None.
|
||||
|
||||
Returns:
|
||||
list: Search results.
|
||||
"""
|
||||
query_params = {
|
||||
"rank_by": ("vector", "ANN", vectors),
|
||||
"top_k": limit,
|
||||
"include_attributes": True,
|
||||
}
|
||||
|
||||
tpuf_filters = self._convert_filters(filters)
|
||||
if tpuf_filters is not None:
|
||||
query_params["filters"] = tpuf_filters
|
||||
|
||||
response = self.namespace.query(**query_params)
|
||||
return self._parse_output(response.rows or [])
|
||||
|
||||
def delete(self, vector_id: Union[str, int]):
|
||||
"""
|
||||
Delete a vector by ID.
|
||||
|
||||
Args:
|
||||
vector_id (Union[str, int]): ID of the vector to delete.
|
||||
"""
|
||||
self.namespace.write(deletes=[str(vector_id)])
|
||||
|
||||
def update(
|
||||
self,
|
||||
vector_id: Union[str, int],
|
||||
vector: Optional[List[float]] = None,
|
||||
payload: Optional[Dict] = None,
|
||||
):
|
||||
"""
|
||||
Update a vector and its payload.
|
||||
|
||||
Args:
|
||||
vector_id (Union[str, int]): ID of the vector to update.
|
||||
vector (list, optional): Updated vector. Defaults to None.
|
||||
payload (dict, optional): Updated payload. Defaults to None.
|
||||
"""
|
||||
if vector is not None:
|
||||
row = {}
|
||||
if payload:
|
||||
row.update(payload)
|
||||
row["id"] = str(vector_id)
|
||||
row["vector"] = vector
|
||||
self.namespace.write(
|
||||
upsert_rows=[row],
|
||||
distance_metric=self.distance_metric,
|
||||
)
|
||||
elif payload is not None:
|
||||
row = dict(payload)
|
||||
row["id"] = str(vector_id)
|
||||
self.namespace.write(patch_rows=[row])
|
||||
|
||||
def get(self, vector_id: Union[str, int]) -> Optional[OutputData]:
|
||||
"""
|
||||
Retrieve a vector by ID.
|
||||
|
||||
Args:
|
||||
vector_id (Union[str, int]): ID of the vector to retrieve.
|
||||
|
||||
Returns:
|
||||
OutputData: Retrieved vector data, or None if not found.
|
||||
"""
|
||||
try:
|
||||
response = self.namespace.query(
|
||||
top_k=1,
|
||||
rank_by=("vector", "ANN", [0.0] * self.embedding_model_dims),
|
||||
filters=("id", "Eq", str(vector_id)),
|
||||
include_attributes=True,
|
||||
)
|
||||
rows = response.rows or []
|
||||
if rows:
|
||||
return self._parse_output(rows)[0]
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving vector {vector_id}: {e}")
|
||||
return None
|
||||
|
||||
def list_cols(self) -> list:
|
||||
"""
|
||||
List all namespaces.
|
||||
|
||||
Returns:
|
||||
list: List of namespace summaries.
|
||||
"""
|
||||
try:
|
||||
result = []
|
||||
for ns in self.client.namespaces():
|
||||
result.append(ns)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing namespaces: {e}")
|
||||
return []
|
||||
|
||||
def delete_col(self):
|
||||
"""Delete the entire namespace."""
|
||||
try:
|
||||
self.namespace.delete_all()
|
||||
logger.info(f"Namespace {self.collection_name} deleted successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting namespace {self.collection_name}: {e}")
|
||||
|
||||
def col_info(self) -> Dict:
|
||||
"""
|
||||
Get information about the namespace.
|
||||
|
||||
Returns:
|
||||
dict: Namespace metadata.
|
||||
"""
|
||||
try:
|
||||
metadata = self.namespace.metadata()
|
||||
return {
|
||||
"name": self.collection_name,
|
||||
"approx_row_count": metadata.approx_row_count,
|
||||
"approx_logical_bytes": metadata.approx_logical_bytes,
|
||||
"created_at": str(metadata.created_at),
|
||||
"updated_at": str(metadata.updated_at),
|
||||
}
|
||||
except Exception:
|
||||
return {"name": self.collection_name}
|
||||
|
||||
def list(self, filters: Optional[Dict] = None, limit: int = 100) -> list:
|
||||
"""
|
||||
List vectors in the namespace with optional filtering.
|
||||
|
||||
Args:
|
||||
filters (dict, optional): Filters to apply. Defaults to None.
|
||||
limit (int, optional): Number of vectors to return. Defaults to 100.
|
||||
|
||||
Returns:
|
||||
list: Wrapped list of OutputData objects ([[results]]).
|
||||
"""
|
||||
query_params = {
|
||||
"rank_by": ("vector", "ANN", [0.0] * self.embedding_model_dims),
|
||||
"top_k": limit,
|
||||
"include_attributes": True,
|
||||
}
|
||||
|
||||
tpuf_filters = self._convert_filters(filters)
|
||||
if tpuf_filters is not None:
|
||||
query_params["filters"] = tpuf_filters
|
||||
|
||||
try:
|
||||
response = self.namespace.query(**query_params)
|
||||
results = self._parse_output(response.rows or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing vectors: {e}")
|
||||
results = []
|
||||
return [results]
|
||||
|
||||
def count(self) -> int:
|
||||
"""
|
||||
Get approximate count of vectors in the namespace.
|
||||
|
||||
Returns:
|
||||
int: Approximate number of vectors.
|
||||
"""
|
||||
try:
|
||||
metadata = self.namespace.metadata()
|
||||
return metadata.approx_row_count
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def reset(self):
|
||||
"""Reset the namespace by deleting all vectors."""
|
||||
self.delete_col()
|
||||
@@ -21,8 +21,6 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import Memory, MemoryAccessLog, MemoryState, MemoryStatusHistory
|
||||
from app.utils.db import get_user_and_app
|
||||
@@ -33,8 +31,6 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.routing import APIRouter
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||||
from starlette.responses import Response
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
@@ -61,8 +57,8 @@ mcp_router = APIRouter(prefix="/mcp")
|
||||
# Initialize SSE transport
|
||||
sse = SseServerTransport("/mcp/messages/")
|
||||
|
||||
@mcp.tool(description="Add a new memory. This method is called everytime the user informs anything about themselves, their preferences, or anything that has any relevant information which can be useful in the future conversation. This can also be called when the user asks you to remember something. Set infer to False to store the memory verbatim without LLM fact extraction.")
|
||||
async def add_memories(text: str, infer: bool = True) -> str:
|
||||
@mcp.tool(description="Add a new memory. This method is called everytime the user informs anything about themselves, their preferences, or anything that has any relevant information which can be useful in the future conversation. This can also be called when the user asks you to remember something.")
|
||||
async def add_memories(text: str) -> str:
|
||||
uid = user_id_var.get(None)
|
||||
client_name = client_name_var.get(None)
|
||||
|
||||
@@ -91,8 +87,7 @@ async def add_memories(text: str, infer: bool = True) -> str:
|
||||
metadata={
|
||||
"source_app": "openmemory",
|
||||
"mcp_client": client_name,
|
||||
},
|
||||
infer=infer)
|
||||
})
|
||||
|
||||
# Process the response and update database
|
||||
if isinstance(response, dict) and 'results' in response:
|
||||
@@ -189,7 +184,7 @@ async def search_memory(query: str) -> str:
|
||||
for h in hits:
|
||||
# All vector db search functions return OutputData class
|
||||
id, score, payload = h.id, h.score, h.payload
|
||||
if allowed and (h.id is None or h.id not in allowed):
|
||||
if allowed and h.id is None or h.id not in allowed:
|
||||
continue
|
||||
|
||||
results.append({
|
||||
@@ -442,10 +437,7 @@ async def handle_sse(request: Request):
|
||||
client_token = client_name_var.set(client_name or "")
|
||||
|
||||
try:
|
||||
# NOTE: request._send is the raw ASGI `send` callable. Starlette does not
|
||||
# expose it publicly, but the MCP SDK transports require the raw ASGI
|
||||
# interface (scope, receive, send). This is the standard pattern from the
|
||||
# MCP Python SDK examples.
|
||||
# Handle SSE connection
|
||||
async with sse.connect_sse(
|
||||
request.scope,
|
||||
request.receive,
|
||||
@@ -492,80 +484,6 @@ async def handle_post_message(request: Request):
|
||||
finally:
|
||||
pass
|
||||
|
||||
|
||||
@mcp_router.api_route("/{client_name}/http/{user_id}", methods=["POST", "GET", "DELETE"])
|
||||
async def handle_streamable_http(request: Request):
|
||||
"""Handle Streamable HTTP connections for a specific user and client.
|
||||
|
||||
Uses the Streamable HTTP transport (MCP spec 2025-03-26+) which replaces
|
||||
the deprecated SSE transport. Runs in stateless mode — each request is
|
||||
handled independently with no persistent session.
|
||||
|
||||
The transport writes its response directly to the ASGI ``send`` callable.
|
||||
We intercept it via ``capture_send`` so we can return a proper ``Response``
|
||||
to FastAPI — otherwise FastAPI would also try to send its own response,
|
||||
causing a "double-response" bug.
|
||||
"""
|
||||
uid = request.path_params.get("user_id")
|
||||
user_token = user_id_var.set(uid or "")
|
||||
client_name = request.path_params.get("client_name")
|
||||
client_token = client_name_var.set(client_name or "")
|
||||
|
||||
# Intercept the ASGI messages the transport sends so we can return them
|
||||
# as a single Response to FastAPI. Without this, FastAPI would attempt to
|
||||
# write its own response after the transport already wrote one.
|
||||
response_started = False
|
||||
response_status = 200
|
||||
response_headers: list[tuple[bytes, bytes]] = []
|
||||
response_body = bytearray()
|
||||
|
||||
async def capture_send(message):
|
||||
nonlocal response_started, response_status
|
||||
if message["type"] == "http.response.start":
|
||||
response_started = True
|
||||
response_status = message["status"]
|
||||
response_headers.extend(message.get("headers", []))
|
||||
elif message["type"] == "http.response.body":
|
||||
response_body.extend(message.get("body", b""))
|
||||
|
||||
try:
|
||||
transport = StreamableHTTPServerTransport(
|
||||
mcp_session_id=None,
|
||||
is_json_response_enabled=True,
|
||||
)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def run_server(*, task_status=anyio.TASK_STATUS_IGNORED):
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
task_status.started()
|
||||
await mcp._mcp_server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
mcp._mcp_server.create_initialization_options(),
|
||||
stateless=True,
|
||||
)
|
||||
|
||||
await tg.start(run_server)
|
||||
await transport.handle_request(request.scope, request.receive, capture_send)
|
||||
await transport.terminate()
|
||||
tg.cancel_scope.cancel()
|
||||
finally:
|
||||
user_id_var.reset(user_token)
|
||||
client_name_var.reset(client_token)
|
||||
|
||||
if not response_started:
|
||||
return Response(status_code=500, content=b"Transport did not produce a response")
|
||||
|
||||
# Header dict conversion is safe here: the MCP transport in stateless JSON
|
||||
# mode only emits single-valued headers (Content-Type, Content-Length).
|
||||
return Response(
|
||||
content=bytes(response_body),
|
||||
status_code=response_status,
|
||||
headers={k.decode(): v.decode() for k, v in response_headers},
|
||||
)
|
||||
|
||||
|
||||
def setup_mcp_server(app: FastAPI):
|
||||
"""Setup MCP server with the FastAPI application"""
|
||||
mcp._mcp_server.name = "mem0-mcp-server"
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
"""Tests for the MCP server endpoints (SSE and Streamable HTTP transports).
|
||||
|
||||
Covers the Streamable HTTP transport (MCP spec 2025-03-26+) and the legacy SSE
|
||||
transport. Tests exercise the full JSON-RPC flow — initialize, tools/list,
|
||||
tools/call — as well as error handling and context-variable isolation.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Set dummy keys before any imports that trigger client initialization
|
||||
os.environ.setdefault("OPENAI_API_KEY", "test-key")
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.mcp_server import client_name_var, mcp, mcp_router, user_id_var
|
||||
|
||||
# MCP Streamable HTTP requires the Accept header to include application/json.
|
||||
# Including text/event-stream as well satisfies GET (SSE) requests.
|
||||
MCP_HEADERS = {"Accept": "application/json, text/event-stream"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def test_app():
|
||||
"""Create a minimal FastAPI app with just the MCP router for testing."""
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(mcp_router)
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(test_app):
|
||||
"""Async HTTP client wired to the test app via ASGI transport."""
|
||||
transport = ASGITransport(app=test_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _jsonrpc(method: str, params: dict | None = None, req_id: int = 1) -> dict:
|
||||
"""Build a JSON-RPC 2.0 request envelope."""
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
|
||||
|
||||
def _initialize_payload(req_id: int = 1) -> dict:
|
||||
return _jsonrpc(
|
||||
"initialize",
|
||||
{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test-client", "version": "0.1.0"},
|
||||
},
|
||||
req_id=req_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streamable HTTP — route existence & basic protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamableHTTPBasic:
|
||||
"""Verify the Streamable HTTP route is registered and responds."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_initialize(self, client):
|
||||
"""POST initialize should return a valid JSON-RPC result."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["jsonrpc"] == "2.0"
|
||||
assert data["id"] == 1
|
||||
assert "result" in data
|
||||
result = data["result"]
|
||||
assert "serverInfo" in result
|
||||
assert "capabilities" in result
|
||||
assert result["protocolVersion"] == "2025-03-26"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_returns_method_not_allowed(self, client):
|
||||
"""DELETE in stateless mode should return 405 (no session to terminate)."""
|
||||
resp = await client.delete(
|
||||
"/mcp/testclient/http/user1",
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 405
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_accept_header_returns_406(self, client):
|
||||
"""POST without the required Accept header should return 406."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
)
|
||||
assert resp.status_code == 406
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_returns_400(self, client):
|
||||
"""POST with unparseable body should return 400."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
content=b"not json",
|
||||
headers={**MCP_HEADERS, "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_not_found_for_wrong_path(self, client):
|
||||
"""Requests to a non-existent path should 404."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/nonexistent/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streamable HTTP — full protocol flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamableHTTPProtocol:
|
||||
"""End-to-end JSON-RPC flows over Streamable HTTP."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_list(self, client):
|
||||
"""tools/list should return all registered MCP tools."""
|
||||
init_resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert init_resp.status_code == 200
|
||||
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("tools/list", req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "result" in data
|
||||
tool_names = {t["name"] for t in data["result"]["tools"]}
|
||||
expected = {"add_memories", "search_memory", "list_memories",
|
||||
"delete_memories", "delete_all_memories"}
|
||||
assert expected.issubset(tool_names), f"Missing tools: {expected - tool_names}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_list_has_descriptions(self, client):
|
||||
"""Every tool returned by tools/list should have a non-empty description."""
|
||||
await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("tools/list", req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
for tool in resp.json()["result"]["tools"]:
|
||||
assert tool.get("description"), f"Tool {tool['name']} has no description"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_list_has_input_schemas(self, client):
|
||||
"""Every tool should declare an inputSchema."""
|
||||
await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("tools/list", req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
for tool in resp.json()["result"]["tools"]:
|
||||
assert "inputSchema" in tool, f"Tool {tool['name']} missing inputSchema"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_unknown_tool_returns_error(self, client):
|
||||
"""Calling a non-existent tool should return a JSON-RPC error."""
|
||||
await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("tools/call", {"name": "no_such_tool", "arguments": {}}, req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "error" in data or (
|
||||
"result" in data and data["result"].get("isError")
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_jsonrpc_method(self, client):
|
||||
"""An unknown JSON-RPC method should return an error."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_jsonrpc("nonexistent/method"),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code in (200, 400)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_content_type_is_json(self, client):
|
||||
"""Responses should have Content-Type: application/json."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
ct = resp.headers.get("content-type", "")
|
||||
assert "application/json" in ct
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streamable HTTP — context variable handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamableHTTPContext:
|
||||
"""Verify that user_id and client_name context variables are set correctly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_vars_set_during_tool_call(self, client):
|
||||
"""Context vars should reflect the path parameters during tool execution."""
|
||||
captured = {}
|
||||
|
||||
@mcp.tool(name="__test_ctx", description="test only")
|
||||
async def _capture(query: str = "") -> str:
|
||||
captured["user_id"] = user_id_var.get(None)
|
||||
captured["client_name"] = client_name_var.get(None)
|
||||
return "ok"
|
||||
|
||||
try:
|
||||
await client.post(
|
||||
"/mcp/my-app/http/alice",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
resp = await client.post(
|
||||
"/mcp/my-app/http/alice",
|
||||
json=_jsonrpc("tools/call", {"name": "__test_ctx", "arguments": {}}, req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert captured.get("user_id") == "alice"
|
||||
assert captured.get("client_name") == "my-app"
|
||||
finally:
|
||||
mcp._tool_manager._tools.pop("__test_ctx", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_users_are_isolated(self, client):
|
||||
"""Sequential requests with different user_ids must not leak state."""
|
||||
results = []
|
||||
|
||||
@mcp.tool(name="__test_uid_iso", description="test only")
|
||||
async def _capture_uid(query: str = "") -> str:
|
||||
results.append(user_id_var.get(None))
|
||||
return "ok"
|
||||
|
||||
try:
|
||||
for uid in ("userA", "userB", "userC"):
|
||||
await client.post(
|
||||
f"/mcp/app1/http/{uid}",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
f"/mcp/app1/http/{uid}",
|
||||
json=_jsonrpc("tools/call", {"name": "__test_uid_iso", "arguments": {}}, req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
|
||||
assert results == ["userA", "userB", "userC"]
|
||||
finally:
|
||||
mcp._tool_manager._tools.pop("__test_uid_iso", None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_clients_are_isolated(self, client):
|
||||
"""Sequential requests with different client_names must not leak state."""
|
||||
results = []
|
||||
|
||||
@mcp.tool(name="__test_cn_iso", description="test only")
|
||||
async def _capture_cn(query: str = "") -> str:
|
||||
results.append(client_name_var.get(None))
|
||||
return "ok"
|
||||
|
||||
try:
|
||||
for cn in ("cursor", "windsurf", "claude"):
|
||||
await client.post(
|
||||
f"/mcp/{cn}/http/user1",
|
||||
json=_initialize_payload(),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
await client.post(
|
||||
f"/mcp/{cn}/http/user1",
|
||||
json=_jsonrpc("tools/call", {"name": "__test_cn_iso", "arguments": {}}, req_id=2),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
|
||||
assert results == ["cursor", "windsurf", "claude"]
|
||||
finally:
|
||||
mcp._tool_manager._tools.pop("__test_cn_iso", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streamable HTTP — response correctness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStreamableHTTPResponses:
|
||||
"""Verify that captured responses are returned correctly to the caller."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_status_codes_are_preserved(self, client):
|
||||
"""Transport error codes (e.g. 406) must be forwarded, not masked as 200."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(),
|
||||
)
|
||||
assert resp.status_code == 406
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_status_code_preserved(self, client):
|
||||
"""DELETE 405 from stateless transport must not be masked."""
|
||||
resp = await client.delete(
|
||||
"/mcp/testclient/http/user1",
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 405
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_sequential_requests(self, client):
|
||||
"""Multiple requests in sequence should each get independent responses."""
|
||||
for i in range(5):
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
json=_initialize_payload(req_id=i + 1),
|
||||
headers=MCP_HEADERS,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == i + 1
|
||||
assert "result" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_content_type_returns_error(self, client):
|
||||
"""POST with wrong Content-Type should return an error status."""
|
||||
resp = await client.post(
|
||||
"/mcp/testclient/http/user1",
|
||||
content=b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}',
|
||||
headers={**MCP_HEADERS, "Content-Type": "text/plain"},
|
||||
)
|
||||
assert resp.status_code in (400, 415)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRouteRegistration:
|
||||
"""Verify all expected routes are registered in the router."""
|
||||
|
||||
def test_sse_route_is_registered(self, test_app):
|
||||
routes = [r.path for r in test_app.routes if hasattr(r, "path")]
|
||||
assert "/mcp/{client_name}/sse/{user_id}" in routes
|
||||
|
||||
def test_sse_post_messages_route_is_registered(self, test_app):
|
||||
routes = [r.path for r in test_app.routes if hasattr(r, "path")]
|
||||
assert "/mcp/messages/" in routes or "/mcp/{client_name}/sse/{user_id}/messages/" in routes
|
||||
|
||||
def test_streamable_http_route_is_registered(self, test_app):
|
||||
routes = [r.path for r in test_app.routes if hasattr(r, "path")]
|
||||
assert "/mcp/{client_name}/http/{user_id}" in routes
|
||||
@@ -119,9 +119,6 @@ class MemoryCreate(BaseModel):
|
||||
agent_id: Optional[str] = None
|
||||
run_id: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
infer: Optional[bool] = Field(None, description="Whether to extract facts from messages. Defaults to True.")
|
||||
memory_type: Optional[str] = Field(None, description="Type of memory to store (e.g. 'core').")
|
||||
prompt: Optional[str] = Field(None, description="Custom prompt to use for fact extraction.")
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
@@ -130,8 +127,6 @@ class SearchRequest(BaseModel):
|
||||
run_id: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
limit: Optional[int] = Field(None, description="Maximum number of results to return.")
|
||||
threshold: Optional[float] = Field(None, description="Minimum similarity score for results.")
|
||||
|
||||
|
||||
@app.post("/configure", summary="Configure Mem0")
|
||||
|
||||
@@ -349,18 +349,23 @@ Use the `name` field in messages to identify speakers. Mem0 maps names to entity
|
||||
|
||||
## MCP Integration
|
||||
|
||||
Model Context Protocol integration enables AI clients (Claude, Claude Code, Cursor, Windsurf, VS Code, OpenCode) to manage Mem0 memory autonomously.
|
||||
Model Context Protocol integration enables AI clients (Claude Desktop, Cursor, custom agents) to manage Mem0 memory autonomously.
|
||||
|
||||
### Setup
|
||||
### Configuration
|
||||
|
||||
Add Mem0 MCP to your clients with a single command:
|
||||
|
||||
```bash
|
||||
npx mcp-add \
|
||||
--name mem0-mcp \
|
||||
--type http \
|
||||
--url "https://mcp.mem0.ai/mcp" \
|
||||
--clients "claude,claude code,cursor,windsurf,vscode,opencode"
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mem0": {
|
||||
"command": "uvx",
|
||||
"args": ["mem0-mcp-server"],
|
||||
"env": {
|
||||
"MEM0_API_KEY": "m0-your-api-key",
|
||||
"MEM0_DEFAULT_USER_ID": "your-user-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available MCP Tools
|
||||
@@ -372,7 +377,7 @@ The MCP server exposes 9 memory tools that AI agents can use autonomously:
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Add Mem0 MCP to your AI client using the setup command above
|
||||
1. Configure the MCP server in your AI client
|
||||
2. The agent autonomously decides when to store/retrieve memories
|
||||
3. No manual API calls needed — the agent manages memory as part of its reasoning
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ SECTION_MAP = {
|
||||
"/open-source/features/rest-api",
|
||||
"/open-source/configure-components",
|
||||
],
|
||||
"openmemory": [
|
||||
"/openmemory/overview",
|
||||
"/openmemory/quickstart",
|
||||
],
|
||||
"sdks": [
|
||||
"/sdks/python",
|
||||
"/sdks/js",
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("anthropic", reason="anthropic package not installed")
|
||||
|
||||
from mem0.configs.llms.anthropic import AnthropicConfig
|
||||
from mem0.configs.llms.base import BaseLlmConfig
|
||||
from mem0.llms.anthropic import AnthropicLLM
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_anthropic_client():
|
||||
with patch("mem0.llms.anthropic.anthropic") as mock_anthropic:
|
||||
mock_client = Mock()
|
||||
mock_anthropic.Anthropic.return_value = mock_client
|
||||
yield mock_client
|
||||
|
||||
|
||||
def test_default_config_omits_top_p(mock_anthropic_client):
|
||||
"""Default AnthropicConfig should not set top_p to avoid conflict with temperature."""
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
|
||||
assert config.top_p is None
|
||||
assert config.temperature == 0.1
|
||||
|
||||
|
||||
def test_generate_response_does_not_send_top_p_by_default(mock_anthropic_client):
|
||||
"""Anthropic API rejects temperature and top_p together; top_p must be omitted by default."""
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="Hello!")]
|
||||
mock_anthropic_client.messages.create.return_value = mock_response
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hi"},
|
||||
]
|
||||
|
||||
llm.generate_response(messages)
|
||||
|
||||
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
|
||||
assert "top_p" not in call_kwargs
|
||||
assert call_kwargs["temperature"] == 0.1
|
||||
|
||||
|
||||
def test_generate_response_sends_top_p_alone_when_no_temperature(mock_anthropic_client):
|
||||
"""When user sets only top_p (no temperature), top_p should be sent."""
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key", top_p=0.9, temperature=None)
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="Hello!")]
|
||||
mock_anthropic_client.messages.create.return_value = mock_response
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hi"},
|
||||
]
|
||||
|
||||
llm.generate_response(messages)
|
||||
|
||||
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
|
||||
assert call_kwargs["top_p"] == 0.9
|
||||
assert "temperature" not in call_kwargs
|
||||
|
||||
|
||||
def test_both_set_prefers_temperature_over_top_p(mock_anthropic_client):
|
||||
"""When both temperature and top_p are set, temperature wins and top_p is dropped."""
|
||||
config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key", top_p=0.9, temperature=0.5)
|
||||
llm = AnthropicLLM(config)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="Hello!")]
|
||||
mock_anthropic_client.messages.create.return_value = mock_response
|
||||
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
llm.generate_response(messages)
|
||||
|
||||
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
|
||||
assert call_kwargs["temperature"] == 0.5
|
||||
assert "top_p" not in call_kwargs
|
||||
|
||||
|
||||
def test_base_config_conversion_does_not_send_both(mock_anthropic_client):
|
||||
"""BaseLlmConfig defaults both temperature=0.1 and top_p=0.1; Anthropic must not send both."""
|
||||
base_config = BaseLlmConfig(model="claude-3-5-sonnet-20240620", api_key="test-key")
|
||||
llm = AnthropicLLM(base_config)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="Hello!")]
|
||||
mock_anthropic_client.messages.create.return_value = mock_response
|
||||
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
llm.generate_response(messages)
|
||||
|
||||
call_kwargs = mock_anthropic_client.messages.create.call_args[1]
|
||||
assert "temperature" in call_kwargs
|
||||
assert "top_p" not in call_kwargs
|
||||
@@ -1,330 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.configs.llms.aws_bedrock import AWSBedrockConfig
|
||||
from mem0.llms.aws_bedrock import AWSBedrockLLM, extract_provider
|
||||
from mem0.utils.factory import LlmFactory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def mock_boto3():
|
||||
"""Patch boto3 so no real AWS calls are made during unit tests."""
|
||||
with patch("mem0.llms.aws_bedrock.boto3") as mock_b3:
|
||||
runtime_client = MagicMock()
|
||||
bedrock_client = MagicMock()
|
||||
bedrock_client.list_foundation_models.return_value = {"modelSummaries": []}
|
||||
|
||||
def _client(service, **kwargs):
|
||||
if service == "bedrock-runtime":
|
||||
return runtime_client
|
||||
return bedrock_client
|
||||
|
||||
mock_b3.client.side_effect = _client
|
||||
yield runtime_client
|
||||
|
||||
|
||||
def _make_llm(model: str, mock_boto3, **kwargs) -> AWSBedrockLLM:
|
||||
"""Instantiate AWSBedrockLLM with a given model, all AWS calls mocked."""
|
||||
config = AWSBedrockConfig(model=model, **kwargs)
|
||||
return AWSBedrockLLM(config)
|
||||
|
||||
|
||||
def _converse_response(text: str = "ok") -> dict:
|
||||
"""Minimal Converse API response dict."""
|
||||
return {"output": {"message": {"content": [{"text": text}]}}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractProvider:
|
||||
def test_standard_anthropic_model(self):
|
||||
assert extract_provider("anthropic.claude-3-5-sonnet-20240620-v1:0") == "anthropic"
|
||||
|
||||
def test_inference_profile_us_prefix(self):
|
||||
# Cross-region inference profile IDs look like us.anthropic.<model>
|
||||
assert extract_provider("us.anthropic.claude-haiku-4-5-20251001-v1:0") == "anthropic"
|
||||
|
||||
def test_inference_profile_eu_prefix(self):
|
||||
assert extract_provider("eu.anthropic.claude-sonnet-4-5-20250929-v1:0") == "anthropic"
|
||||
|
||||
def test_inference_profile_ap_prefix(self):
|
||||
assert extract_provider("ap.anthropic.claude-3-opus-20240229-v1:0") == "anthropic"
|
||||
|
||||
def test_amazon_model(self):
|
||||
assert extract_provider("amazon.nova-3-mini-20241119-v1:0") == "amazon"
|
||||
|
||||
def test_meta_model(self):
|
||||
assert extract_provider("meta.llama3-8b-instruct-v1:0") == "meta"
|
||||
|
||||
def test_mistral_model(self):
|
||||
assert extract_provider("mistral.mistral-7b-instruct-v0:2") == "mistral"
|
||||
|
||||
def test_unknown_model_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
extract_provider("unknown-vendor.some-model-v1:0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AWSBedrockConfig
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAWSBedrockConfig:
|
||||
def test_top_p_defaults_to_none(self):
|
||||
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
assert config.top_p is None
|
||||
|
||||
def test_top_p_explicit_value_stored(self):
|
||||
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", top_p=0.8)
|
||||
assert config.top_p == 0.8
|
||||
|
||||
def test_get_model_config_excludes_top_p_by_default(self):
|
||||
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", temperature=0.5)
|
||||
model_cfg = config.get_model_config()
|
||||
assert "top_p" not in model_cfg
|
||||
|
||||
def test_get_model_config_includes_top_p_when_set(self):
|
||||
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", top_p=0.7)
|
||||
model_cfg = config.get_model_config()
|
||||
assert model_cfg["top_p"] == 0.7
|
||||
|
||||
def test_get_model_config_top_p_via_model_kwargs(self):
|
||||
"""model_kwargs can supply top_p after merge; same semantics as explicit top_p."""
|
||||
config = AWSBedrockConfig(
|
||||
model="anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
model_kwargs={"top_p": 0.88},
|
||||
)
|
||||
assert config.get_model_config()["top_p"] == 0.88
|
||||
|
||||
def test_get_model_config_always_includes_temperature(self):
|
||||
config = AWSBedrockConfig(model="anthropic.claude-3-5-sonnet-20240620-v1:0", temperature=0.3)
|
||||
model_cfg = config.get_model_config()
|
||||
assert model_cfg["temperature"] == 0.3
|
||||
|
||||
def test_aws_region_stored(self):
|
||||
config = AWSBedrockConfig(
|
||||
model="anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
aws_region="us-east-2",
|
||||
)
|
||||
assert config.aws_region == "us-east-2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LlmFactory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLlmFactory:
|
||||
def test_aws_bedrock_uses_aws_bedrock_config(self):
|
||||
_, config_class = LlmFactory.provider_to_class["aws_bedrock"]
|
||||
assert config_class is AWSBedrockConfig
|
||||
|
||||
def test_factory_create_accepts_aws_region(self, mock_boto3):
|
||||
"""LlmFactory.create must not crash when aws_region is in the config dict.
|
||||
|
||||
Before the fix, the factory mapped aws_bedrock to BaseLlmConfig which has
|
||||
no aws_region parameter, causing: TypeError: __init__() got an unexpected
|
||||
keyword argument 'aws_region'.
|
||||
"""
|
||||
user_dict = {
|
||||
"model": "anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
"aws_region": "us-east-2",
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000,
|
||||
}
|
||||
mock_boto3.converse.return_value = _converse_response()
|
||||
|
||||
# This must not raise TypeError
|
||||
llm = LlmFactory.create("aws_bedrock", user_dict)
|
||||
|
||||
assert isinstance(llm.config, AWSBedrockConfig)
|
||||
assert llm.config.aws_region == "us-east-2"
|
||||
assert llm.config.top_p is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_inference_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildInferenceConfig:
|
||||
"""
|
||||
Unit tests for the _build_inference_config helper.
|
||||
Validates the exact keys present in the returned dict.
|
||||
"""
|
||||
|
||||
def test_anthropic_only_temperature_by_default(self, mock_boto3):
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3, temperature=0.5)
|
||||
cfg = llm._build_inference_config()
|
||||
assert "temperature" in cfg
|
||||
assert cfg["temperature"] == 0.5
|
||||
assert "topP" not in cfg, "topP must be absent when top_p not configured"
|
||||
|
||||
def test_anthropic_top_p_explicitly_set_still_omits_top_p(self, mock_boto3):
|
||||
# Anthropic rejects both; topP must still be omitted even when set
|
||||
llm = _make_llm(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
mock_boto3,
|
||||
temperature=0.5,
|
||||
top_p=0.9,
|
||||
)
|
||||
cfg = llm._build_inference_config()
|
||||
assert "temperature" in cfg
|
||||
assert "topP" not in cfg
|
||||
|
||||
def test_anthropic_inference_profile_omits_top_p(self, mock_boto3):
|
||||
# Cross-region inference profiles (us.anthropic.*) follow the same rule
|
||||
llm = _make_llm("us.anthropic.claude-haiku-4-5-20251001-v1:0", mock_boto3, temperature=0.1)
|
||||
cfg = llm._build_inference_config()
|
||||
assert "topP" not in cfg
|
||||
|
||||
def test_amazon_includes_top_p_when_set(self, mock_boto3):
|
||||
llm = _make_llm(
|
||||
"amazon.nova-3-mini-20241119-v1:0",
|
||||
mock_boto3,
|
||||
temperature=0.5,
|
||||
top_p=0.85,
|
||||
)
|
||||
cfg = llm._build_inference_config()
|
||||
assert cfg["temperature"] == 0.5
|
||||
assert cfg["topP"] == 0.85
|
||||
|
||||
def test_amazon_omits_top_p_when_not_set(self, mock_boto3):
|
||||
llm = _make_llm("amazon.nova-3-mini-20241119-v1:0", mock_boto3, temperature=0.5)
|
||||
cfg = llm._build_inference_config()
|
||||
assert "topP" not in cfg
|
||||
|
||||
def test_max_tokens_present(self, mock_boto3):
|
||||
llm = _make_llm(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
mock_boto3,
|
||||
max_tokens=1024,
|
||||
)
|
||||
cfg = llm._build_inference_config()
|
||||
assert cfg["maxTokens"] == 1024
|
||||
|
||||
def test_nova_fallback_max_tokens_when_absent(self, mock_boto3):
|
||||
"""Legacy Nova Converse used 5000 when max_tokens was missing from the dict."""
|
||||
llm = _make_llm("amazon.nova-3-mini-20241119-v1:0", mock_boto3)
|
||||
llm.model_config.pop("max_tokens", None)
|
||||
cfg = llm._build_inference_config()
|
||||
assert cfg["maxTokens"] == 5000
|
||||
|
||||
def test_anthropic_fallback_max_tokens_when_absent(self, mock_boto3):
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
|
||||
llm.model_config.pop("max_tokens", None)
|
||||
cfg = llm._build_inference_config()
|
||||
assert cfg["maxTokens"] == 2000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_response — Converse API call assertions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MESSAGES = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "add_memory",
|
||||
"description": "Store a memory",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"data": {"type": "string"}},
|
||||
"required": ["data"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class TestGenerateResponseConverse:
|
||||
def test_standard_anthropic_no_top_p_in_converse_call(self, mock_boto3):
|
||||
mock_boto3.converse.return_value = _converse_response()
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3, temperature=0.2)
|
||||
|
||||
llm.generate_response(MESSAGES)
|
||||
|
||||
_, kwargs = mock_boto3.converse.call_args
|
||||
inference_cfg = kwargs["inferenceConfig"]
|
||||
assert "topP" not in inference_cfg
|
||||
assert inference_cfg["temperature"] == 0.2
|
||||
|
||||
def test_standard_anthropic_inference_profile_no_top_p(self, mock_boto3):
|
||||
mock_boto3.converse.return_value = _converse_response()
|
||||
llm = _make_llm("us.anthropic.claude-haiku-4-5-20251001-v1:0", mock_boto3, temperature=0.1)
|
||||
|
||||
llm.generate_response(MESSAGES)
|
||||
|
||||
_, kwargs = mock_boto3.converse.call_args
|
||||
assert "topP" not in kwargs["inferenceConfig"]
|
||||
|
||||
def test_with_tools_anthropic_no_top_p(self, mock_boto3):
|
||||
mock_boto3.converse.return_value = _converse_response()
|
||||
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3, temperature=0.2)
|
||||
|
||||
llm.generate_response(MESSAGES, tools=TOOLS)
|
||||
|
||||
_, kwargs = mock_boto3.converse.call_args
|
||||
assert "topP" not in kwargs["inferenceConfig"]
|
||||
|
||||
def test_with_tools_anthropic_top_p_set_still_omitted(self, mock_boto3):
|
||||
# Even if user explicitly sets top_p, Anthropic must not receive topP
|
||||
mock_boto3.converse.return_value = _converse_response()
|
||||
llm = _make_llm(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
mock_boto3,
|
||||
temperature=0.2,
|
||||
top_p=0.9,
|
||||
)
|
||||
|
||||
llm.generate_response(MESSAGES, tools=TOOLS)
|
||||
|
||||
_, kwargs = mock_boto3.converse.call_args
|
||||
assert "topP" not in kwargs["inferenceConfig"]
|
||||
|
||||
def test_nova_includes_top_p_when_explicitly_set(self, mock_boto3):
|
||||
mock_boto3.converse.return_value = _converse_response()
|
||||
llm = _make_llm(
|
||||
"amazon.nova-3-mini-20241119-v1:0",
|
||||
mock_boto3,
|
||||
temperature=0.5,
|
||||
top_p=0.85,
|
||||
)
|
||||
|
||||
llm.generate_response(MESSAGES)
|
||||
|
||||
_, kwargs = mock_boto3.converse.call_args
|
||||
assert kwargs["inferenceConfig"]["topP"] == 0.85
|
||||
|
||||
def test_nova_omits_top_p_when_not_set(self, mock_boto3):
|
||||
mock_boto3.converse.return_value = _converse_response()
|
||||
llm = _make_llm("amazon.nova-3-mini-20241119-v1:0", mock_boto3, temperature=0.5)
|
||||
|
||||
llm.generate_response(MESSAGES)
|
||||
|
||||
_, kwargs = mock_boto3.converse.call_args
|
||||
assert "topP" not in kwargs["inferenceConfig"]
|
||||
|
||||
def test_anthropic_model_kwargs_top_p_still_omits_top_p_in_converse(self, mock_boto3):
|
||||
"""top_p injected via model_kwargs must not add topP for Anthropic Converse."""
|
||||
mock_boto3.converse.return_value = _converse_response()
|
||||
llm = _make_llm(
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
mock_boto3,
|
||||
temperature=0.2,
|
||||
model_kwargs={"top_p": 0.88},
|
||||
)
|
||||
|
||||
llm.generate_response(MESSAGES)
|
||||
|
||||
_, kwargs = mock_boto3.converse.call_args
|
||||
assert "topP" not in kwargs["inferenceConfig"]
|
||||
@@ -117,73 +117,3 @@ def test_generate_response_with_tools(mock_gemini_client: Mock):
|
||||
assert len(response["tool_calls"]) == 1
|
||||
assert response["tool_calls"][0]["name"] == "add_memory"
|
||||
assert response["tool_calls"][0]["arguments"] == {"data": "Today is a sunny day."}
|
||||
|
||||
|
||||
def test_parse_response_none_content_no_tools(mock_gemini_client: Mock):
|
||||
"""Gemini can return content=None when response is blocked by safety filters."""
|
||||
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
|
||||
llm = GeminiLLM(config)
|
||||
|
||||
mock_candidate = Mock(content=None)
|
||||
mock_response = Mock(candidates=[mock_candidate])
|
||||
|
||||
result = llm._parse_response(mock_response, tools=None)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_parse_response_none_content_with_tools(mock_gemini_client: Mock):
|
||||
"""Gemini can return content=None when response is blocked by safety filters (tools path)."""
|
||||
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
|
||||
llm = GeminiLLM(config)
|
||||
|
||||
mock_candidate = Mock(content=None)
|
||||
mock_response = Mock(candidates=[mock_candidate])
|
||||
|
||||
result = llm._parse_response(mock_response, tools=[{"function": {"name": "test"}}])
|
||||
assert result == {"content": None, "tool_calls": []}
|
||||
|
||||
|
||||
def test_parse_response_empty_candidates(mock_gemini_client: Mock):
|
||||
"""Gemini can return an empty candidates list."""
|
||||
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
|
||||
llm = GeminiLLM(config)
|
||||
|
||||
mock_response = Mock(candidates=[])
|
||||
result = llm._parse_response(mock_response, tools=None)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_parse_response_none_candidates(mock_gemini_client: Mock):
|
||||
"""Gemini can return candidates=None."""
|
||||
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
|
||||
llm = GeminiLLM(config)
|
||||
|
||||
mock_response = Mock(candidates=None)
|
||||
result = llm._parse_response(mock_response, tools=None)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_parse_response_empty_parts_no_tools(mock_gemini_client: Mock):
|
||||
"""Gemini can return content with an empty parts list."""
|
||||
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
|
||||
llm = GeminiLLM(config)
|
||||
|
||||
mock_content = Mock(parts=[])
|
||||
mock_candidate = Mock(content=mock_content)
|
||||
mock_response = Mock(candidates=[mock_candidate])
|
||||
|
||||
result = llm._parse_response(mock_response, tools=None)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_parse_response_empty_parts_with_tools(mock_gemini_client: Mock):
|
||||
"""Gemini can return content with an empty parts list (tools path)."""
|
||||
config = BaseLlmConfig(model="gemini-2.0-flash", temperature=0.7, max_tokens=100, top_p=1.0)
|
||||
llm = GeminiLLM(config)
|
||||
|
||||
mock_content = Mock(parts=[])
|
||||
mock_candidate = Mock(content=mock_content)
|
||||
mock_response = Mock(candidates=[mock_candidate])
|
||||
|
||||
result = llm._parse_response(mock_response, tools=[{"function": {"name": "test"}}])
|
||||
assert result == {"content": None, "tool_calls": []}
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
"""Tests for graph memory soft-delete behavior.
|
||||
|
||||
Verifies that _delete_entities marks relationships as invalid (soft-delete)
|
||||
rather than permanently removing them, and that search/retrieval queries
|
||||
exclude soft-deleted relationships by default.
|
||||
|
||||
See: https://github.com/mem0ai/mem0/issues/4187
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
# Mock optional deps at module level so the import works across all Python
|
||||
# versions without triggering transitive C-extension reloads (numpy via
|
||||
# qdrant_client). This matches the pattern in test_memgraph_memory.py.
|
||||
_neo4j_mock = Mock()
|
||||
patch.dict("sys.modules", {
|
||||
"langchain_neo4j": _neo4j_mock,
|
||||
"rank_bm25": Mock(),
|
||||
}).start()
|
||||
|
||||
from mem0.memory.graph_memory import MemoryGraph # noqa: E402
|
||||
|
||||
|
||||
def _create_graph_memory():
|
||||
"""Create a MemoryGraph instance with mocked dependencies."""
|
||||
with patch.object(MemoryGraph, "__init__", lambda self, *a, **kw: None):
|
||||
mg = MemoryGraph.__new__(MemoryGraph)
|
||||
mg.graph = Mock()
|
||||
mg.graph.query = Mock(return_value=[])
|
||||
mg.embedding_model = Mock()
|
||||
mg.embedding_model.embed = Mock(return_value=[0.1] * 128)
|
||||
mg.llm = Mock()
|
||||
mg.node_label = ":Entity"
|
||||
mg.threshold = 0.7
|
||||
mg.llm_provider = "openai"
|
||||
return mg
|
||||
|
||||
|
||||
class TestSoftDelete:
|
||||
"""Verify _delete_entities uses SET r.valid = false, not DELETE r."""
|
||||
|
||||
def test_delete_entities_sends_soft_delete_cypher(self):
|
||||
mg = _create_graph_memory()
|
||||
mg.graph.query.return_value = [
|
||||
{"source": "Alice", "target": "Bob", "relationship": "KNOWS"}
|
||||
]
|
||||
|
||||
mg._delete_entities(
|
||||
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||
{"user_id": "user1"},
|
||||
)
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
assert "SET r.valid = false" in cypher
|
||||
assert "r.invalidated_at = datetime()" in cypher
|
||||
assert "DELETE r" not in cypher
|
||||
|
||||
def test_delete_entities_only_targets_valid_edges(self):
|
||||
mg = _create_graph_memory()
|
||||
mg._delete_entities(
|
||||
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||
{"user_id": "user1"},
|
||||
)
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
assert "r.valid IS NULL OR r.valid = true" in cypher
|
||||
|
||||
def test_delete_entities_is_idempotent(self):
|
||||
mg = _create_graph_memory()
|
||||
item = [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}]
|
||||
filters = {"user_id": "user1"}
|
||||
|
||||
mg.graph.query.return_value = [
|
||||
{"source": "Alice", "target": "Bob", "relationship": "KNOWS"}
|
||||
]
|
||||
mg._delete_entities(item, filters)
|
||||
|
||||
mg.graph.query.return_value = []
|
||||
mg._delete_entities(item, filters)
|
||||
|
||||
# Both calls should have the same WHERE filter
|
||||
for c in mg.graph.query.call_args_list:
|
||||
assert "r.valid IS NULL OR r.valid = true" in c[0][0]
|
||||
|
||||
|
||||
class TestSearchExcludesSoftDeleted:
|
||||
"""Verify search and get_all filter out soft-deleted relationships."""
|
||||
|
||||
def test_get_all_filters_soft_deleted(self):
|
||||
mg = _create_graph_memory()
|
||||
mg.get_all(filters={"user_id": "user1"}, limit=10)
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
assert "r.valid IS NULL OR r.valid = true" in cypher
|
||||
|
||||
def test_search_graph_db_filters_both_directions(self):
|
||||
"""_search_graph_db must filter soft-deleted edges in both outgoing and incoming queries."""
|
||||
mg = _create_graph_memory()
|
||||
mg.graph.query.return_value = []
|
||||
|
||||
mg._search_graph_db(node_list=["alice"], filters={"user_id": "user1"})
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
# The UNION query has two MATCH branches — both must filter
|
||||
occurrences = cypher.count("r.valid IS NULL OR r.valid = true")
|
||||
assert occurrences >= 2, (
|
||||
f"_search_graph_db has {occurrences} valid-filter(s) but needs >= 2 "
|
||||
"(one for outgoing, one for incoming relationships)"
|
||||
)
|
||||
|
||||
def test_delete_all_still_hard_deletes(self):
|
||||
mg = _create_graph_memory()
|
||||
mg.delete_all(filters={"user_id": "user1"})
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
assert "DETACH DELETE" in cypher
|
||||
|
||||
|
||||
class TestMergeResetsValidFlag:
|
||||
"""Verify MERGE in _add_entities sets r.valid = true.
|
||||
|
||||
Critical: after soft-delete, a MERGE that matches the existing
|
||||
(invalidated) edge must reset valid=true, or the edge becomes
|
||||
a zombie -- exists but invisible to queries.
|
||||
"""
|
||||
|
||||
def _run_add_entities(self, source_found, dest_found):
|
||||
"""Helper: call _add_entities with configurable node search results."""
|
||||
mg = _create_graph_memory()
|
||||
|
||||
source_result = (
|
||||
[{"elementId(source_candidate)": "src_id_1"}] if source_found else []
|
||||
)
|
||||
dest_result = (
|
||||
[{"elementId(destination_candidate)": "dst_id_1"}] if dest_found else []
|
||||
)
|
||||
|
||||
mg._search_source_node = Mock(return_value=source_result)
|
||||
mg._search_destination_node = Mock(return_value=dest_result)
|
||||
|
||||
mg._add_entities(
|
||||
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||
{"user_id": "user1"},
|
||||
entity_type_map={},
|
||||
)
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
return cypher
|
||||
|
||||
def test_merge_sets_valid_true_when_source_found(self):
|
||||
cypher = self._run_add_entities(source_found=True, dest_found=False)
|
||||
assert "r.valid = true" in cypher
|
||||
|
||||
def test_merge_sets_valid_true_when_dest_found(self):
|
||||
cypher = self._run_add_entities(source_found=False, dest_found=True)
|
||||
assert "r.valid = true" in cypher
|
||||
|
||||
def test_merge_sets_valid_true_when_both_found(self):
|
||||
cypher = self._run_add_entities(source_found=True, dest_found=True)
|
||||
assert "r.valid = true" in cypher
|
||||
|
||||
def test_merge_sets_valid_true_when_neither_found(self):
|
||||
cypher = self._run_add_entities(source_found=False, dest_found=False)
|
||||
assert "r.valid = true" in cypher
|
||||
|
||||
def test_merge_clears_invalidated_at_on_resurrection(self):
|
||||
"""When a soft-deleted edge is resurrected via MERGE, invalidated_at must be cleared.
|
||||
|
||||
Without this, a resurrected edge (valid=true) still carries stale
|
||||
invalidated_at metadata, which corrupts temporal reasoning queries.
|
||||
"""
|
||||
for label, src, dst in [
|
||||
("source found", True, False),
|
||||
("dest found", False, True),
|
||||
("both found", True, True),
|
||||
("neither found", False, False),
|
||||
]:
|
||||
cypher = self._run_add_entities(source_found=src, dest_found=dst)
|
||||
assert "r.invalidated_at = null" in cypher, (
|
||||
f"MERGE path '{label}': ON MATCH SET does not clear r.invalidated_at. "
|
||||
"Resurrected edges will have stale invalidation timestamps."
|
||||
)
|
||||
|
||||
|
||||
class TestCypherConsistency:
|
||||
"""Verify all MERGE blocks use consistent property names and variable aliases."""
|
||||
|
||||
def _get_merge_cypher(self, source_found, dest_found):
|
||||
mg = _create_graph_memory()
|
||||
mg._search_source_node = Mock(
|
||||
return_value=[{"elementId(source_candidate)": "id1"}] if source_found else []
|
||||
)
|
||||
mg._search_destination_node = Mock(
|
||||
return_value=[{"elementId(destination_candidate)": "id2"}] if dest_found else []
|
||||
)
|
||||
mg._add_entities(
|
||||
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||
{"user_id": "user1"},
|
||||
entity_type_map={},
|
||||
)
|
||||
return mg.graph.query.call_args[0][0]
|
||||
|
||||
def test_all_blocks_use_created_at_not_created(self):
|
||||
"""All MERGE blocks must use r.created_at, not r.created."""
|
||||
for label, src, dst in [
|
||||
("source found", True, False),
|
||||
("dest found", False, True),
|
||||
("both found", True, True),
|
||||
("neither found", False, False),
|
||||
]:
|
||||
cypher = self._get_merge_cypher(src, dst)
|
||||
assert "r.created_at" in cypher, (
|
||||
f"MERGE path '{label}': uses r.created instead of r.created_at"
|
||||
)
|
||||
|
||||
def test_all_blocks_use_r_not_rel(self):
|
||||
"""All MERGE blocks must use 'r' as the relationship variable, not 'rel'."""
|
||||
for label, src, dst in [
|
||||
("source found", True, False),
|
||||
("dest found", False, True),
|
||||
("both found", True, True),
|
||||
("neither found", False, False),
|
||||
]:
|
||||
cypher = self._get_merge_cypher(src, dst)
|
||||
assert "rel." not in cypher, (
|
||||
f"MERGE path '{label}': uses 'rel' variable instead of 'r'"
|
||||
)
|
||||
|
||||
def test_all_blocks_set_updated_at_on_create(self):
|
||||
"""All MERGE blocks must set r.updated_at on CREATE for consistent timestamps."""
|
||||
for label, src, dst in [
|
||||
("source found", True, False),
|
||||
("dest found", False, True),
|
||||
("both found", True, True),
|
||||
("neither found", False, False),
|
||||
]:
|
||||
cypher = self._get_merge_cypher(src, dst)
|
||||
assert "r.updated_at = timestamp()" in cypher, (
|
||||
f"MERGE path '{label}': missing r.updated_at on CREATE SET"
|
||||
)
|
||||
|
||||
|
||||
class TestSoftDeleteWithFilters:
|
||||
"""Verify soft-delete works correctly with agent_id and run_id filters."""
|
||||
|
||||
def test_delete_entities_with_agent_id(self):
|
||||
mg = _create_graph_memory()
|
||||
mg._delete_entities(
|
||||
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||
{"user_id": "user1", "agent_id": "agent1"},
|
||||
)
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
params = mg.graph.query.call_args[1]["params"]
|
||||
assert "SET r.valid = false" in cypher
|
||||
assert "agent_id: $agent_id" in cypher
|
||||
assert params["agent_id"] == "agent1"
|
||||
|
||||
def test_delete_entities_with_run_id(self):
|
||||
mg = _create_graph_memory()
|
||||
mg._delete_entities(
|
||||
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||
{"user_id": "user1", "run_id": "run1"},
|
||||
)
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
params = mg.graph.query.call_args[1]["params"]
|
||||
assert "SET r.valid = false" in cypher
|
||||
assert "run_id: $run_id" in cypher
|
||||
assert params["run_id"] == "run1"
|
||||
|
||||
def test_get_all_with_agent_id_filters_soft_deleted(self):
|
||||
mg = _create_graph_memory()
|
||||
mg.get_all(filters={"user_id": "user1", "agent_id": "agent1"}, limit=10)
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
assert "r.valid IS NULL OR r.valid = true" in cypher
|
||||
assert "agent_id: $agent_id" in cypher
|
||||
|
||||
def test_merge_with_agent_id_sets_valid_true(self):
|
||||
mg = _create_graph_memory()
|
||||
mg._search_source_node = Mock(
|
||||
return_value=[{"elementId(source_candidate)": "id1"}]
|
||||
)
|
||||
mg._search_destination_node = Mock(return_value=[])
|
||||
|
||||
mg._add_entities(
|
||||
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||
{"user_id": "user1", "agent_id": "agent1"},
|
||||
entity_type_map={},
|
||||
)
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
assert "r.valid = true" in cypher
|
||||
assert "agent_id: $agent_id" in cypher
|
||||
|
||||
|
||||
class TestResetAndCleanup:
|
||||
"""Verify reset and delete_all use hard-delete (DETACH DELETE)."""
|
||||
|
||||
def test_reset_uses_detach_delete(self):
|
||||
mg = _create_graph_memory()
|
||||
mg.reset()
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
assert "DETACH DELETE" in cypher
|
||||
assert "valid" not in cypher.lower()
|
||||
|
||||
def test_delete_all_does_not_soft_delete(self):
|
||||
mg = _create_graph_memory()
|
||||
mg.delete_all(filters={"user_id": "user1"})
|
||||
|
||||
cypher = mg.graph.query.call_args[0][0]
|
||||
assert "DETACH DELETE" in cypher
|
||||
assert "r.valid = false" not in cypher
|
||||
@@ -188,201 +188,6 @@ async def test_async_update_memory_uses_utc_timestamps(mocker):
|
||||
_assert_utc_timestamp(payload["updated_at"])
|
||||
|
||||
|
||||
class TestMetadataNotMutated:
|
||||
"""Tests that metadata dicts passed to memory methods are not mutated in-place (issue #2648)."""
|
||||
|
||||
def test_create_memory_does_not_mutate_metadata(self, mocker):
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
original_metadata = {"user_id": "test_user", "category": "sports"}
|
||||
metadata_copy = original_metadata.copy()
|
||||
|
||||
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
|
||||
|
||||
assert original_metadata == metadata_copy, (
|
||||
f"_create_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
|
||||
)
|
||||
|
||||
def test_create_memory_stores_correct_payload(self, mocker):
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
metadata = {"user_id": "test_user", "category": "sports"}
|
||||
|
||||
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=metadata)
|
||||
|
||||
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
|
||||
assert payload["data"] == "test data"
|
||||
assert payload["user_id"] == "test_user"
|
||||
assert payload["category"] == "sports"
|
||||
assert "hash" in payload
|
||||
assert "created_at" in payload
|
||||
|
||||
def test_create_memory_with_none_metadata(self, mocker):
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
|
||||
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=None)
|
||||
|
||||
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
|
||||
assert payload["data"] == "test data"
|
||||
assert "hash" in payload
|
||||
|
||||
def test_create_memory_shared_metadata_across_calls(self, mocker):
|
||||
"""Verify that sharing a metadata dict between multiple _create_memory calls is safe."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
shared_metadata = {"user_id": "test_user"}
|
||||
|
||||
memory._create_memory("first memory", {"first memory": [0.1, 0.2, 0.3]}, metadata=shared_metadata)
|
||||
memory._create_memory("second memory", {"second memory": [0.4, 0.5, 0.6]}, metadata=shared_metadata)
|
||||
|
||||
assert shared_metadata == {"user_id": "test_user"}, "shared metadata was mutated across calls"
|
||||
|
||||
# Verify each call got the correct data
|
||||
first_payload = memory.vector_store.insert.call_args_list[0].kwargs["payloads"][0]
|
||||
second_payload = memory.vector_store.insert.call_args_list[1].kwargs["payloads"][0]
|
||||
assert first_payload["data"] == "first memory"
|
||||
assert second_payload["data"] == "second memory"
|
||||
|
||||
def test_create_memory_preserves_role_and_actor_id_in_history(self, mocker):
|
||||
"""Verify that role and actor_id from metadata flow through to add_history after deepcopy."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
metadata = {"user_id": "test_user", "role": "assistant", "actor_id": "bot-1"}
|
||||
|
||||
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=metadata)
|
||||
|
||||
# Verify the payload stored in vector store has all fields
|
||||
payload = memory.vector_store.insert.call_args.kwargs["payloads"][0]
|
||||
assert payload["role"] == "assistant"
|
||||
assert payload["actor_id"] == "bot-1"
|
||||
assert payload["user_id"] == "test_user"
|
||||
assert payload["data"] == "test data"
|
||||
|
||||
# Verify add_history received the correct role and actor_id
|
||||
history_call = memory.db.add_history.call_args
|
||||
assert history_call.kwargs["role"] == "assistant"
|
||||
assert history_call.kwargs["actor_id"] == "bot-1"
|
||||
|
||||
# And the original metadata is still untouched
|
||||
assert metadata == {"user_id": "test_user", "role": "assistant", "actor_id": "bot-1"}
|
||||
|
||||
def test_create_memory_with_nested_metadata_not_mutated(self, mocker):
|
||||
"""Verify deepcopy protects nested structures in metadata."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
metadata = {"user_id": "test_user", "tags": ["important", "urgent"], "config": {"key": "val"}}
|
||||
import copy
|
||||
metadata_snapshot = copy.deepcopy(metadata)
|
||||
|
||||
memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=metadata)
|
||||
|
||||
assert metadata == metadata_snapshot, "Nested metadata structures were mutated"
|
||||
|
||||
def test_update_memory_does_not_mutate_metadata(self, mocker):
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
memory.vector_store.get.return_value = MagicMock(
|
||||
payload={"data": "old data", "user_id": "test_user", "created_at": "2026-01-01T00:00:00+00:00"}
|
||||
)
|
||||
original_metadata = {"category": "updated"}
|
||||
metadata_copy = original_metadata.copy()
|
||||
|
||||
memory._update_memory("mem-id", "new data", {"new data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
|
||||
|
||||
assert original_metadata == metadata_copy, (
|
||||
f"_update_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
|
||||
)
|
||||
|
||||
def test_add_to_vector_store_no_infer_does_not_mutate_metadata(self, mocker):
|
||||
"""Verify _add_to_vector_store with infer=False doesn't leak metadata between messages."""
|
||||
memory = _build_memory_instance(mocker, Memory)
|
||||
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
|
||||
|
||||
original_metadata = {"user_id": "test_user"}
|
||||
metadata_copy = original_metadata.copy()
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there", "name": "bot-1"},
|
||||
]
|
||||
|
||||
result = memory._add_to_vector_store(messages, original_metadata, filters={}, infer=False)
|
||||
|
||||
# Metadata should not be mutated
|
||||
assert original_metadata == metadata_copy, (
|
||||
f"_add_to_vector_store mutated the caller's metadata: {original_metadata}"
|
||||
)
|
||||
|
||||
# Should have created 2 memories
|
||||
assert len(result) == 2
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[1]["role"] == "assistant"
|
||||
assert result[1]["actor_id"] == "bot-1"
|
||||
|
||||
# Verify each insert got distinct payloads with correct roles
|
||||
insert_calls = memory.vector_store.insert.call_args_list
|
||||
first_payload = insert_calls[0].kwargs["payloads"][0]
|
||||
second_payload = insert_calls[1].kwargs["payloads"][0]
|
||||
assert first_payload["role"] == "user"
|
||||
assert "actor_id" not in first_payload # user message has no name
|
||||
assert second_payload["role"] == "assistant"
|
||||
assert second_payload["actor_id"] == "bot-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_create_memory_does_not_mutate_metadata(self, mocker):
|
||||
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||
original_metadata = {"user_id": "test_user", "category": "sports"}
|
||||
metadata_copy = original_metadata.copy()
|
||||
|
||||
await memory._create_memory("test data", {"test data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
|
||||
|
||||
assert original_metadata == metadata_copy, (
|
||||
f"async _create_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_create_memory_shared_metadata_across_calls(self, mocker):
|
||||
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||
shared_metadata = {"user_id": "test_user"}
|
||||
|
||||
await memory._create_memory("first memory", {"first memory": [0.1, 0.2, 0.3]}, metadata=shared_metadata)
|
||||
await memory._create_memory("second memory", {"second memory": [0.4, 0.5, 0.6]}, metadata=shared_metadata)
|
||||
|
||||
assert shared_metadata == {"user_id": "test_user"}, "shared metadata was mutated across async calls"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_add_to_vector_store_no_infer_does_not_mutate_metadata(self, mocker):
|
||||
"""Verify async _add_to_vector_store with infer=False doesn't leak metadata between messages."""
|
||||
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||
memory.embedding_model.embed.return_value = [0.1, 0.2, 0.3]
|
||||
|
||||
original_metadata = {"user_id": "test_user"}
|
||||
metadata_copy = original_metadata.copy()
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there", "name": "bot-1"},
|
||||
]
|
||||
|
||||
result = await memory._add_to_vector_store(messages, original_metadata, effective_filters={}, infer=False)
|
||||
|
||||
assert original_metadata == metadata_copy, (
|
||||
f"async _add_to_vector_store mutated the caller's metadata: {original_metadata}"
|
||||
)
|
||||
assert len(result) == 2
|
||||
assert result[0]["role"] == "user"
|
||||
assert result[1]["role"] == "assistant"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_update_memory_does_not_mutate_metadata(self, mocker):
|
||||
memory = _build_memory_instance(mocker, AsyncMemory)
|
||||
memory.vector_store.get.return_value = MagicMock(
|
||||
payload={"data": "old data", "user_id": "test_user", "created_at": "2026-01-01T00:00:00+00:00"}
|
||||
)
|
||||
original_metadata = {"category": "updated"}
|
||||
metadata_copy = original_metadata.copy()
|
||||
|
||||
await memory._update_memory("mem-id", "new data", {"new data": [0.1, 0.2, 0.3]}, metadata=original_metadata)
|
||||
|
||||
assert original_metadata == metadata_copy, (
|
||||
f"async _update_memory mutated the caller's metadata dict: {original_metadata} != {metadata_copy}"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_iso_timestamp_to_utc_preserves_naive_values():
|
||||
assert _normalize_iso_timestamp_to_utc("2026-03-18T00:00:00") == "2026-03-18T00:00:00"
|
||||
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
"""Tests for graph cleanup on memory deletion (issue #3245)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.configs.base import MemoryConfig
|
||||
|
||||
|
||||
class MockVectorMemory:
|
||||
def __init__(self, memory_id, payload, score=0.8):
|
||||
self.id = memory_id
|
||||
self.payload = payload
|
||||
self.score = score
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_delete_calls_graph_cleanup_when_graph_enabled(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""When graph is enabled, delete() should call graph.delete() with memory text and filters."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
# Enable graph with a mock
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
|
||||
# Set up vector store to return a memory with graph-relevant data
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1",
|
||||
{
|
||||
"data": "Alice likes Bob",
|
||||
"user_id": "user-1",
|
||||
"agent_id": "agent-1",
|
||||
"hash": "abc",
|
||||
},
|
||||
)
|
||||
|
||||
memory.delete("mem-1")
|
||||
|
||||
# graph.delete should have been called with the memory text and filters
|
||||
memory.graph.delete.assert_called_once_with(
|
||||
"Alice likes Bob", {"user_id": "user-1", "agent_id": "agent-1"}
|
||||
)
|
||||
|
||||
# _delete_memory should still have been called (vector store + history cleanup)
|
||||
mock_vector_store.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_delete_skips_graph_when_not_enabled(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""When graph is not enabled, delete() should not attempt graph cleanup."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
assert memory.enable_graph is False
|
||||
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1", {"data": "Alice likes Bob", "user_id": "user-1", "hash": "abc"}
|
||||
)
|
||||
|
||||
result = memory.delete("mem-1")
|
||||
|
||||
assert result == {"message": "Memory deleted successfully!"}
|
||||
mock_vector_store.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_delete_continues_if_graph_cleanup_fails(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""If graph cleanup raises an exception, delete() should still succeed."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
memory.graph.delete.side_effect = RuntimeError("Neo4j connection lost")
|
||||
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1", {"data": "Alice likes Bob", "user_id": "user-1", "hash": "abc"}
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
result = memory.delete("mem-1")
|
||||
assert result == {"message": "Memory deleted successfully!"}
|
||||
|
||||
# Vector store deletion should still proceed
|
||||
mock_vector_store.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_delete_skips_graph_when_no_user_id(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""Graph cleanup should be skipped if the memory has no user_id."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
|
||||
# Memory with no user_id
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1", {"data": "Some data", "hash": "abc"}
|
||||
)
|
||||
|
||||
memory.delete("mem-1")
|
||||
|
||||
# graph.delete should NOT have been called since there's no user_id
|
||||
memory.graph.delete.assert_not_called()
|
||||
mock_vector_store.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_delete_skips_graph_when_no_memory_text(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""Graph cleanup should be skipped if the memory has no text data."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1", {"user_id": "user-1", "hash": "abc"}
|
||||
)
|
||||
|
||||
memory.delete("mem-1")
|
||||
|
||||
memory.graph.delete.assert_not_called()
|
||||
mock_vector_store.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_delete_passes_all_filters_to_graph(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""Graph cleanup should include all available filters (user_id, agent_id, run_id)."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1",
|
||||
{
|
||||
"data": "Alice likes Bob",
|
||||
"user_id": "user-1",
|
||||
"agent_id": "agent-1",
|
||||
"run_id": "run-1",
|
||||
"hash": "abc",
|
||||
},
|
||||
)
|
||||
|
||||
memory.delete("mem-1")
|
||||
|
||||
memory.graph.delete.assert_called_once_with(
|
||||
"Alice likes Bob",
|
||||
{"user_id": "user-1", "agent_id": "agent-1", "run_id": "run-1"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
async def test_async_delete_calls_graph_cleanup(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""Async delete() should also perform graph cleanup."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import AsyncMemory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = AsyncMemory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1",
|
||||
{
|
||||
"data": "Alice likes Bob",
|
||||
"user_id": "user-1",
|
||||
"hash": "abc",
|
||||
},
|
||||
)
|
||||
|
||||
result = await memory.delete("mem-1")
|
||||
|
||||
assert result == {"message": "Memory deleted successfully!"}
|
||||
memory.graph.delete.assert_called_once_with("Alice likes Bob", {"user_id": "user-1"})
|
||||
mock_vector_store.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
async def test_async_delete_continues_if_graph_cleanup_fails(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""Async delete() should continue even if graph cleanup fails."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import AsyncMemory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = AsyncMemory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
memory.graph.delete.side_effect = RuntimeError("Graph error")
|
||||
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1", {"data": "Alice likes Bob", "user_id": "user-1", "hash": "abc"}
|
||||
)
|
||||
|
||||
result = await memory.delete("mem-1")
|
||||
assert result == {"message": "Memory deleted successfully!"}
|
||||
mock_vector_store.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_delete_raises_for_nonexistent_memory_with_graph_enabled(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""delete() should raise ValueError for non-existent memory even with graph enabled."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
|
||||
mock_vector_store.get.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Memory with id non-existent not found"):
|
||||
memory.delete("non-existent")
|
||||
|
||||
memory.graph.delete.assert_not_called()
|
||||
mock_vector_store.delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
async def test_async_delete_raises_for_nonexistent_memory_with_graph_enabled(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""Async delete() should raise ValueError for non-existent memory even with graph enabled."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_store.get.return_value = None
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import AsyncMemory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = AsyncMemory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
|
||||
with pytest.raises(ValueError, match="Memory with id non-existent not found"):
|
||||
await memory.delete("non-existent")
|
||||
|
||||
memory.graph.delete.assert_not_called()
|
||||
mock_vector_store.delete.assert_not_called()
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_delete_all_does_not_trigger_per_memory_graph_cleanup(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""delete_all() should use graph.delete_all(), not per-memory graph.delete()."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
|
||||
mem1 = MockVectorMemory("mem-1", {"data": "Alice likes Bob", "user_id": "user-1"})
|
||||
mem2 = MockVectorMemory("mem-2", {"data": "Bob likes Charlie", "user_id": "user-1"})
|
||||
mock_vector_store.list.return_value = ([mem1, mem2], 2)
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1", {"data": "Alice likes Bob", "user_id": "user-1"}
|
||||
)
|
||||
|
||||
memory.delete_all(user_id="user-1")
|
||||
|
||||
# graph.delete (per-memory) should NOT be called
|
||||
memory.graph.delete.assert_not_called()
|
||||
# graph.delete_all (bulk) SHOULD be called
|
||||
memory.graph.delete_all.assert_called_once_with({"user_id": "user-1"})
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_internal_delete_memory_does_not_trigger_graph_cleanup(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
"""_delete_memory() should NOT call graph.delete() — only the public delete() does.
|
||||
|
||||
This ensures that the DELETE branch inside _add_to_vector_store() (which calls
|
||||
_delete_memory directly) does not interfere with the parallel graph pipeline
|
||||
running in _add_to_graph().
|
||||
"""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
memory.enable_graph = True
|
||||
memory.graph = MagicMock()
|
||||
|
||||
mock_vector_store.get.return_value = MockVectorMemory(
|
||||
"mem-1", {"data": "Alice likes Bob", "user_id": "user-1", "hash": "abc"}
|
||||
)
|
||||
|
||||
# Call _delete_memory directly (as _add_to_vector_store does for DELETE events)
|
||||
memory._delete_memory("mem-1")
|
||||
|
||||
# graph.delete should NOT have been called — graph cleanup is only in delete()
|
||||
memory.graph.delete.assert_not_called()
|
||||
# But vector store deletion should proceed
|
||||
mock_vector_store.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
|
||||
def test_graph_memory_delete_calls_internal_methods():
|
||||
"""Test that MemoryGraph.delete() calls the expected internal pipeline methods."""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
# We need to mock the Neo4j import
|
||||
with _patch.dict("sys.modules", {"langchain_neo4j": MagicMock(), "rank_bm25": MagicMock()}):
|
||||
from mem0.memory.graph_memory import MemoryGraph
|
||||
|
||||
with _patch.object(MemoryGraph, "__init__", return_value=None):
|
||||
graph = MemoryGraph.__new__(MemoryGraph)
|
||||
|
||||
# Mock the internal methods
|
||||
graph._retrieve_nodes_from_data = MagicMock(
|
||||
return_value={"alice": "person", "bob": "person"}
|
||||
)
|
||||
graph._establish_nodes_relations_from_data = MagicMock(
|
||||
return_value=[
|
||||
{"source": "alice", "destination": "bob", "relationship": "likes"}
|
||||
]
|
||||
)
|
||||
graph._delete_entities = MagicMock(return_value=[])
|
||||
|
||||
filters = {"user_id": "user-1"}
|
||||
graph.delete("Alice likes Bob", filters)
|
||||
|
||||
graph._retrieve_nodes_from_data.assert_called_once_with("Alice likes Bob", filters)
|
||||
graph._establish_nodes_relations_from_data.assert_called_once_with(
|
||||
"Alice likes Bob", filters, {"alice": "person", "bob": "person"}
|
||||
)
|
||||
graph._delete_entities.assert_called_once_with(
|
||||
[{"source": "alice", "destination": "bob", "relationship": "likes"}],
|
||||
filters,
|
||||
)
|
||||
|
||||
|
||||
def test_graph_memory_delete_skips_when_no_entities():
|
||||
"""Test that MemoryGraph.delete() does nothing when no entities are extracted."""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
with _patch.dict("sys.modules", {"langchain_neo4j": MagicMock(), "rank_bm25": MagicMock()}):
|
||||
from mem0.memory.graph_memory import MemoryGraph
|
||||
|
||||
with _patch.object(MemoryGraph, "__init__", return_value=None):
|
||||
graph = MemoryGraph.__new__(MemoryGraph)
|
||||
|
||||
graph._retrieve_nodes_from_data = MagicMock(return_value={})
|
||||
graph._establish_nodes_relations_from_data = MagicMock()
|
||||
graph._delete_entities = MagicMock()
|
||||
|
||||
graph.delete("Some text", {"user_id": "user-1"})
|
||||
|
||||
graph._retrieve_nodes_from_data.assert_called_once()
|
||||
graph._establish_nodes_relations_from_data.assert_not_called()
|
||||
graph._delete_entities.assert_not_called()
|
||||
|
||||
|
||||
def test_graph_memory_delete_handles_exception():
|
||||
"""Test that MemoryGraph.delete() catches exceptions without raising."""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
with _patch.dict("sys.modules", {"langchain_neo4j": MagicMock(), "rank_bm25": MagicMock()}):
|
||||
from mem0.memory.graph_memory import MemoryGraph
|
||||
|
||||
with _patch.object(MemoryGraph, "__init__", return_value=None):
|
||||
graph = MemoryGraph.__new__(MemoryGraph)
|
||||
|
||||
graph._retrieve_nodes_from_data = MagicMock(
|
||||
side_effect=RuntimeError("LLM error")
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
graph.delete("Some text", {"user_id": "user-1"})
|
||||
@@ -1,821 +0,0 @@
|
||||
"""
|
||||
End-to-end tests for graph cleanup on memory deletion against real
|
||||
Neo4j, Memgraph, and Apache AGE instances running in Docker.
|
||||
|
||||
Requires:
|
||||
docker run -d --name mem0-neo4j-test -p 7687:7687 -e NEO4J_AUTH=neo4j/testpassword neo4j:5.23
|
||||
docker run -d --name mem0-memgraph-test -p 7688:7687 memgraph/memgraph:latest
|
||||
docker run -d --name mem0-age-test -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=testpassword -e POSTGRES_DB=testdb apache/age:latest
|
||||
|
||||
Tests are skipped automatically if the databases or required Python
|
||||
packages are not available.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
import warnings
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
EMBEDDING_DIMS = 64
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deterministic embedding helper (shared across backends)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_deterministic_embedder():
|
||||
cache = {}
|
||||
counter = [0]
|
||||
|
||||
def embed(text, *args, **kwargs):
|
||||
t = text.lower().strip()
|
||||
if t not in cache:
|
||||
vec = [0.0] * EMBEDDING_DIMS
|
||||
idx = counter[0] % EMBEDDING_DIMS
|
||||
vec[idx] = 1.0
|
||||
h = hashlib.sha256(t.encode()).digest()
|
||||
for i in range(EMBEDDING_DIMS):
|
||||
vec[i] += float(h[i % len(h)]) / 25500.0
|
||||
norm = sum(v * v for v in vec) ** 0.5
|
||||
cache[t] = [v / norm for v in vec]
|
||||
counter[0] += 1
|
||||
return cache[t]
|
||||
|
||||
mock = MagicMock()
|
||||
mock.embed.side_effect = embed
|
||||
mock.config.embedding_dims = EMBEDDING_DIMS
|
||||
return mock
|
||||
|
||||
|
||||
def _make_mock_llm(entities, relations):
|
||||
"""Create an LLM mock that returns specific entities and relations."""
|
||||
mock = MagicMock()
|
||||
|
||||
def generate_response(messages, tools):
|
||||
tool_names = []
|
||||
for t in tools:
|
||||
if isinstance(t, dict):
|
||||
fn = t.get("function", t)
|
||||
tool_names.append(fn.get("name", ""))
|
||||
else:
|
||||
tool_names.append(getattr(t, "name", str(t)))
|
||||
|
||||
if any("extract_entities" in n for n in tool_names):
|
||||
return {
|
||||
"tool_calls": [
|
||||
{"name": "extract_entities", "arguments": {"entities": entities}}
|
||||
]
|
||||
}
|
||||
elif any("establish" in n or "relation" in n for n in tool_names):
|
||||
return {
|
||||
"tool_calls": [
|
||||
{"name": "establish_nodes_relations", "arguments": {"entities": relations}}
|
||||
]
|
||||
}
|
||||
elif any("delete" in n for n in tool_names):
|
||||
return {"tool_calls": []}
|
||||
return {"tool_calls": []}
|
||||
|
||||
mock.generate_response.side_effect = generate_response
|
||||
return mock
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# NEO4J
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _port_open(host, port, timeout=1):
|
||||
"""Quick TCP check — avoids slow driver-level timeouts."""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _neo4j_available():
|
||||
if not _port_open("localhost", 7687):
|
||||
return False
|
||||
try:
|
||||
from langchain_neo4j import Neo4jGraph
|
||||
|
||||
g = Neo4jGraph(
|
||||
url="bolt://localhost:7687",
|
||||
username="neo4j",
|
||||
password="testpassword",
|
||||
refresh_schema=False,
|
||||
driver_config={"notifications_min_severity": "OFF"},
|
||||
)
|
||||
g.query("RETURN 1")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
requires_neo4j = pytest.mark.skipif(not _neo4j_available(), reason="Neo4j not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neo4j_graph():
|
||||
"""Create a Neo4j-backed MemoryGraph with mocked LLM/embedder."""
|
||||
from langchain_neo4j import Neo4jGraph
|
||||
from mem0.memory.graph_memory import MemoryGraph
|
||||
|
||||
mg = MemoryGraph.__new__(MemoryGraph)
|
||||
mg.graph = Neo4jGraph(
|
||||
url="bolt://localhost:7687",
|
||||
username="neo4j",
|
||||
password="testpassword",
|
||||
refresh_schema=False,
|
||||
driver_config={"notifications_min_severity": "OFF"},
|
||||
)
|
||||
mg.graph.query("MATCH (n) DETACH DELETE n")
|
||||
|
||||
mg.node_label = ":`__Entity__`"
|
||||
mg.llm_provider = "openai"
|
||||
mg.user_id = None
|
||||
mg.threshold = 0.99
|
||||
mg.embedding_model = _make_deterministic_embedder()
|
||||
mg.llm = MagicMock()
|
||||
mg.config = MagicMock()
|
||||
mg.config.graph_store.custom_prompt = None
|
||||
mg.config.graph_store.config.base_label = True
|
||||
|
||||
yield mg
|
||||
|
||||
mg.graph.query("MATCH (n) DETACH DELETE n")
|
||||
|
||||
|
||||
@requires_neo4j
|
||||
class TestNeo4jDeleteE2E:
|
||||
def _node_count(self, mg):
|
||||
return mg.graph.query("MATCH (n) RETURN count(n) AS cnt")[0]["cnt"]
|
||||
|
||||
def _valid_edge_count(self, mg):
|
||||
return mg.graph.query(
|
||||
"MATCH ()-[r]->() WHERE r.valid IS NULL OR r.valid = true RETURN count(r) AS cnt"
|
||||
)[0]["cnt"]
|
||||
|
||||
def _invalid_edge_count(self, mg):
|
||||
return mg.graph.query(
|
||||
"MATCH ()-[r]->() WHERE r.valid = false RETURN count(r) AS cnt"
|
||||
)[0]["cnt"]
|
||||
|
||||
def test_add_creates_graph_data(self, neo4j_graph):
|
||||
mg = neo4j_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._node_count(mg) == 2
|
||||
assert self._valid_edge_count(mg) == 1
|
||||
|
||||
def test_delete_soft_deletes_relationships(self, neo4j_graph):
|
||||
"""Neo4j delete() should set r.valid=false (soft-delete), not hard-delete."""
|
||||
mg = neo4j_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._valid_edge_count(mg) == 1
|
||||
assert self._invalid_edge_count(mg) == 0
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
assert self._valid_edge_count(mg) == 0
|
||||
assert self._invalid_edge_count(mg) == 1 # soft-deleted, not removed
|
||||
assert self._node_count(mg) == 2 # nodes preserved
|
||||
|
||||
def test_delete_preserves_other_relationships(self, neo4j_graph):
|
||||
mg = neo4j_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Charlie", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Charlie", "relationship": "knows"}],
|
||||
)
|
||||
mg.add("Alice knows Charlie", {"user_id": "u1"})
|
||||
assert self._valid_edge_count(mg) == 2
|
||||
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
assert self._valid_edge_count(mg) == 1
|
||||
assert self._invalid_edge_count(mg) == 1
|
||||
|
||||
def test_delete_user_isolation(self, neo4j_graph):
|
||||
mg = neo4j_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
mg.add("Alice likes Bob", {"user_id": "u2"})
|
||||
assert self._valid_edge_count(mg) == 2
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._valid_edge_count(mg) == 1
|
||||
|
||||
def test_delete_all_hard_deletes(self, neo4j_graph):
|
||||
mg = neo4j_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._node_count(mg) == 2
|
||||
|
||||
mg.delete_all({"user_id": "u1"})
|
||||
assert self._node_count(mg) == 0
|
||||
|
||||
def test_add_delete_add_cycle(self, neo4j_graph):
|
||||
mg = neo4j_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._valid_edge_count(mg) == 1
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._valid_edge_count(mg) == 0
|
||||
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._valid_edge_count(mg) == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MEMGRAPH
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _memgraph_available():
|
||||
if not _port_open("localhost", 7688):
|
||||
return False
|
||||
try:
|
||||
from langchain_memgraph.graphs.memgraph import Memgraph
|
||||
|
||||
g = Memgraph("bolt://localhost:7688", "memgraph", "memgraph")
|
||||
g.query("RETURN 1")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
requires_memgraph = pytest.mark.skipif(
|
||||
not _memgraph_available(), reason="Memgraph not available"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memgraph_graph():
|
||||
"""Create a Memgraph-backed MemoryGraph with mocked LLM/embedder."""
|
||||
from langchain_memgraph.graphs.memgraph import Memgraph
|
||||
from mem0.memory.memgraph_memory import MemoryGraph
|
||||
|
||||
mg = MemoryGraph.__new__(MemoryGraph)
|
||||
mg.graph = Memgraph("bolt://localhost:7688", "memgraph", "memgraph")
|
||||
mg.graph.query("MATCH (n) DETACH DELETE n")
|
||||
|
||||
try:
|
||||
mg.graph.query("DROP VECTOR INDEX memzero;")
|
||||
except Exception:
|
||||
pass
|
||||
mg.graph.query(
|
||||
f"CREATE VECTOR INDEX memzero ON :Entity(embedding) "
|
||||
f"WITH CONFIG {{'dimension': {EMBEDDING_DIMS}, 'capacity': 1000, 'metric': 'cos'}};"
|
||||
)
|
||||
try:
|
||||
mg.graph.query("CREATE INDEX ON :Entity(user_id);")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mg.graph.query("CREATE INDEX ON :Entity;")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mg.llm_provider = "openai"
|
||||
mg.user_id = None
|
||||
mg.threshold = 0.99
|
||||
mg.embedding_model = _make_deterministic_embedder()
|
||||
mg.llm = MagicMock()
|
||||
mg.config = MagicMock()
|
||||
mg.config.graph_store.custom_prompt = None
|
||||
mg.config.embedder.config = {"embedding_dims": EMBEDDING_DIMS}
|
||||
|
||||
yield mg
|
||||
|
||||
mg.graph.query("MATCH (n) DETACH DELETE n")
|
||||
|
||||
|
||||
@requires_memgraph
|
||||
class TestMemgraphDeleteE2E:
|
||||
def _node_count(self, mg):
|
||||
return mg.graph.query("MATCH (n:Entity) RETURN count(n) AS cnt")[0]["cnt"]
|
||||
|
||||
def _edge_count(self, mg):
|
||||
return mg.graph.query("MATCH ()-[r]->() RETURN count(r) AS cnt")[0]["cnt"]
|
||||
|
||||
def test_add_creates_graph_data(self, memgraph_graph):
|
||||
mg = memgraph_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._node_count(mg) == 2
|
||||
assert self._edge_count(mg) == 1
|
||||
|
||||
def test_delete_hard_deletes_relationships(self, memgraph_graph):
|
||||
"""Memgraph delete() should hard-delete the relationship (DELETE r)."""
|
||||
mg = memgraph_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._edge_count(mg) == 1
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
assert self._edge_count(mg) == 0
|
||||
assert self._node_count(mg) == 2
|
||||
|
||||
def test_delete_preserves_other_relationships(self, memgraph_graph):
|
||||
mg = memgraph_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Charlie", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Charlie", "relationship": "knows"}],
|
||||
)
|
||||
mg.add("Alice knows Charlie", {"user_id": "u1"})
|
||||
assert self._edge_count(mg) == 2
|
||||
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
assert self._edge_count(mg) == 1
|
||||
|
||||
def test_delete_user_isolation(self, memgraph_graph):
|
||||
mg = memgraph_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
mg.add("Alice likes Bob", {"user_id": "u2"})
|
||||
assert self._edge_count(mg) == 2
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._edge_count(mg) == 1
|
||||
|
||||
def test_delete_all_hard_deletes(self, memgraph_graph):
|
||||
mg = memgraph_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._node_count(mg) == 2
|
||||
|
||||
mg.delete_all({"user_id": "u1"})
|
||||
assert self._node_count(mg) == 0
|
||||
assert self._edge_count(mg) == 0
|
||||
|
||||
def test_add_delete_add_cycle(self, memgraph_graph):
|
||||
mg = memgraph_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._edge_count(mg) == 1
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._edge_count(mg) == 0
|
||||
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert self._edge_count(mg) == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# APACHE AGE
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _age_available():
|
||||
if not _port_open("localhost", 5432):
|
||||
return False
|
||||
try:
|
||||
import age
|
||||
|
||||
ag = age.connect(
|
||||
host="localhost",
|
||||
port=5432,
|
||||
dbname="testdb",
|
||||
user="postgres",
|
||||
password="testpassword",
|
||||
)
|
||||
with ag.connection.cursor() as cur:
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS age;")
|
||||
cur.execute("SET search_path = ag_catalog, '$user', public;")
|
||||
ag.connection.commit()
|
||||
ag.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
requires_age = pytest.mark.skipif(not _age_available(), reason="Apache AGE not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def age_graph():
|
||||
"""Create an Apache AGE-backed MemoryGraph with mocked LLM/embedder."""
|
||||
import age
|
||||
|
||||
from mem0.memory.apache_age_memory import MemoryGraph
|
||||
|
||||
graph_name = "mem0_test_delete"
|
||||
|
||||
ag = age.connect(
|
||||
graph=graph_name,
|
||||
host="localhost",
|
||||
port=5432,
|
||||
dbname="testdb",
|
||||
user="postgres",
|
||||
password="testpassword",
|
||||
)
|
||||
with ag.connection.cursor() as cur:
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS age;")
|
||||
cur.execute("SET search_path = ag_catalog, '$user', public;")
|
||||
ag.connection.commit()
|
||||
age.setUpAge(ag.connection, graph_name)
|
||||
ag.connection.commit()
|
||||
|
||||
mg = MemoryGraph.__new__(MemoryGraph)
|
||||
mg.ag = ag
|
||||
mg.graph_name = graph_name
|
||||
mg.llm_provider = "openai"
|
||||
mg.user_id = None
|
||||
mg.threshold = 0.99
|
||||
mg.embedding_model = _make_deterministic_embedder()
|
||||
mg.llm = MagicMock()
|
||||
mg.config = MagicMock()
|
||||
mg.config.graph_store.custom_prompt = None
|
||||
|
||||
try:
|
||||
ag.execCypher("MATCH (n) DETACH DELETE n")
|
||||
ag.commit()
|
||||
except Exception:
|
||||
ag.rollback()
|
||||
|
||||
yield mg
|
||||
|
||||
try:
|
||||
ag.execCypher("MATCH (n) DETACH DELETE n")
|
||||
ag.commit()
|
||||
except Exception:
|
||||
ag.rollback()
|
||||
ag.close()
|
||||
|
||||
|
||||
def _age_node_count(mg):
|
||||
cursor = mg.ag.execCypher("MATCH (n) RETURN count(n)", cols=["cnt"])
|
||||
rows = cursor.fetchall()
|
||||
return rows[0][0] if rows else 0
|
||||
|
||||
|
||||
def _age_edge_count(mg):
|
||||
cursor = mg.ag.execCypher("MATCH ()-[r]->() RETURN count(r)", cols=["cnt"])
|
||||
rows = cursor.fetchall()
|
||||
return rows[0][0] if rows else 0
|
||||
|
||||
|
||||
@requires_age
|
||||
class TestApacheAgeDeleteE2E:
|
||||
|
||||
def test_add_creates_graph_data(self, age_graph):
|
||||
mg = age_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _age_node_count(mg) == 2
|
||||
assert _age_edge_count(mg) == 1
|
||||
|
||||
def test_delete_hard_deletes_relationships(self, age_graph):
|
||||
"""Apache AGE delete() should hard-delete the relationship."""
|
||||
mg = age_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _age_edge_count(mg) == 1
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
assert _age_edge_count(mg) == 0
|
||||
assert _age_node_count(mg) == 2
|
||||
|
||||
def test_delete_preserves_other_relationships(self, age_graph):
|
||||
mg = age_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Charlie", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Charlie", "relationship": "knows"}],
|
||||
)
|
||||
mg.add("Alice knows Charlie", {"user_id": "u1"})
|
||||
assert _age_edge_count(mg) == 2
|
||||
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _age_edge_count(mg) == 1
|
||||
|
||||
def test_delete_user_isolation(self, age_graph):
|
||||
mg = age_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
mg.add("Alice likes Bob", {"user_id": "u2"})
|
||||
assert _age_edge_count(mg) == 2
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _age_edge_count(mg) == 1
|
||||
|
||||
def test_delete_all_hard_deletes(self, age_graph):
|
||||
mg = age_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _age_node_count(mg) == 2
|
||||
|
||||
mg.delete_all({"user_id": "u1"})
|
||||
assert _age_node_count(mg) == 0
|
||||
assert _age_edge_count(mg) == 0
|
||||
|
||||
def test_add_delete_add_cycle(self, age_graph):
|
||||
mg = age_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _age_edge_count(mg) == 1
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _age_edge_count(mg) == 0
|
||||
|
||||
mg.add("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _age_edge_count(mg) == 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# NEPTUNE (tested via Neo4j OpenCypher — same query language)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def _neptune_test_available():
|
||||
"""Neptune uses OpenCypher — we test NeptuneBase.delete() against Neo4j."""
|
||||
if not _port_open("localhost", 7687):
|
||||
return False
|
||||
try:
|
||||
# Mock langchain_aws so NeptuneBase can be imported without AWS deps
|
||||
sys.modules.setdefault("langchain_aws", MagicMock())
|
||||
sys.modules.setdefault("botocore", MagicMock())
|
||||
sys.modules.setdefault("botocore.config", MagicMock())
|
||||
|
||||
from mem0.graphs.neptune.base import NeptuneBase # noqa: F401
|
||||
from langchain_neo4j import Neo4jGraph
|
||||
|
||||
g = Neo4jGraph(
|
||||
url="bolt://localhost:7687",
|
||||
username="neo4j",
|
||||
password="testpassword",
|
||||
refresh_schema=False,
|
||||
driver_config={"notifications_min_severity": "OFF"},
|
||||
)
|
||||
g.query("RETURN 1")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
requires_neptune_test = pytest.mark.skipif(
|
||||
not _neptune_test_available(),
|
||||
reason="Neo4j not available (used as OpenCypher backend for Neptune tests)",
|
||||
)
|
||||
|
||||
|
||||
def _make_concrete_neptune_subclass():
|
||||
"""Create a concrete NeptuneBase subclass for testing, backed by Neo4j."""
|
||||
# Ensure mocks are in place for import
|
||||
sys.modules.setdefault("langchain_aws", MagicMock())
|
||||
sys.modules.setdefault("botocore", MagicMock())
|
||||
sys.modules.setdefault("botocore.config", MagicMock())
|
||||
|
||||
from mem0.graphs.neptune.base import NeptuneBase
|
||||
|
||||
class TestableNeptune(NeptuneBase):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def _delete_entities_cypher(self, source, destination, relationship, user_id):
|
||||
cypher = f"""
|
||||
MATCH (n:`__Entity__` {{name: $source_name, user_id: $user_id}})
|
||||
-[r:{relationship}]->
|
||||
(m:`__Entity__` {{name: $dest_name, user_id: $user_id}})
|
||||
DELETE r
|
||||
RETURN n.name AS source, m.name AS target, type(r) AS relationship
|
||||
"""
|
||||
return cypher, {"source_name": source, "dest_name": destination, "user_id": user_id}
|
||||
|
||||
def _delete_all_cypher(self, filters):
|
||||
return (
|
||||
"MATCH (n:`__Entity__` {user_id: $user_id}) DETACH DELETE n",
|
||||
{"user_id": filters["user_id"]},
|
||||
)
|
||||
|
||||
# Stubs for abstract methods not used in delete path
|
||||
def _add_entities_by_source_cypher(self, *a, **kw): pass
|
||||
def _add_entities_by_destination_cypher(self, *a, **kw): pass
|
||||
def _add_relationship_entities_cypher(self, *a, **kw): pass
|
||||
def _add_new_entities_cypher(self, *a, **kw): pass
|
||||
def _search_source_node_cypher(self, *a, **kw): pass
|
||||
def _search_destination_node_cypher(self, *a, **kw): pass
|
||||
def _get_all_cypher(self, *a, **kw): pass
|
||||
def _search_graph_db_cypher(self, *a, **kw): pass
|
||||
|
||||
return TestableNeptune
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def neptune_graph():
|
||||
"""NeptuneBase subclass backed by a real Neo4j container."""
|
||||
from langchain_neo4j import Neo4jGraph
|
||||
|
||||
cls = _make_concrete_neptune_subclass()
|
||||
mg = cls()
|
||||
mg.graph = Neo4jGraph(
|
||||
url="bolt://localhost:7687",
|
||||
username="neo4j",
|
||||
password="testpassword",
|
||||
refresh_schema=False,
|
||||
driver_config={"notifications_min_severity": "OFF"},
|
||||
)
|
||||
mg.graph.query("MATCH (n) DETACH DELETE n")
|
||||
|
||||
mg.node_label = ":`__Entity__`"
|
||||
mg.llm_provider = "openai"
|
||||
mg.user_id = None
|
||||
mg.threshold = 0.99
|
||||
mg.embedding_model = _make_deterministic_embedder()
|
||||
mg.llm = MagicMock()
|
||||
mg.config = MagicMock()
|
||||
mg.config.graph_store.custom_prompt = None
|
||||
|
||||
yield mg
|
||||
|
||||
mg.graph.query("MATCH (n) DETACH DELETE n")
|
||||
|
||||
|
||||
def _neptune_node_count(mg):
|
||||
return mg.graph.query("MATCH (n) RETURN count(n) AS cnt")[0]["cnt"]
|
||||
|
||||
|
||||
def _neptune_edge_count(mg):
|
||||
return mg.graph.query("MATCH ()-[r]->() RETURN count(r) AS cnt")[0]["cnt"]
|
||||
|
||||
|
||||
def _neptune_create_entities(mg, user_id):
|
||||
"""Create test entities directly via Cypher."""
|
||||
mg.graph.query(f"""
|
||||
CREATE (a:`__Entity__` {{name: 'alice', user_id: '{user_id}'}})
|
||||
CREATE (b:`__Entity__` {{name: 'bob', user_id: '{user_id}'}})
|
||||
CREATE (a)-[:likes]->(b)
|
||||
""")
|
||||
|
||||
|
||||
@requires_neptune_test
|
||||
class TestNeptuneDeleteE2E:
|
||||
"""Test NeptuneBase.delete() using Neo4j as the OpenCypher backend.
|
||||
|
||||
Neptune uses standard OpenCypher, the same query language as Neo4j.
|
||||
This validates that:
|
||||
- NeptuneBase.delete() correctly calls _delete_entities(to_be_deleted, user_id) with a string
|
||||
- The generated Cypher from _delete_entities_cypher runs correctly
|
||||
- User isolation works
|
||||
"""
|
||||
|
||||
def test_delete_removes_relationship(self, neptune_graph):
|
||||
mg = neptune_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
_neptune_create_entities(mg, "u1")
|
||||
assert _neptune_edge_count(mg) == 1
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
assert _neptune_edge_count(mg) == 0
|
||||
assert _neptune_node_count(mg) == 2
|
||||
|
||||
def test_delete_user_isolation(self, neptune_graph):
|
||||
mg = neptune_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
_neptune_create_entities(mg, "u1")
|
||||
_neptune_create_entities(mg, "u2")
|
||||
assert _neptune_edge_count(mg) == 2
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _neptune_edge_count(mg) == 1
|
||||
|
||||
def test_delete_passes_user_id_string_not_dict(self, neptune_graph):
|
||||
"""Verify NeptuneBase.delete() passes filters['user_id'] (string) to _delete_entities."""
|
||||
mg = neptune_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
|
||||
original = mg._delete_entities
|
||||
call_args = []
|
||||
|
||||
def spy(to_be_deleted, user_id):
|
||||
call_args.append(("to_be_deleted", to_be_deleted, "user_id", user_id))
|
||||
return original(to_be_deleted, user_id)
|
||||
|
||||
mg._delete_entities = spy
|
||||
|
||||
_neptune_create_entities(mg, "u1")
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
|
||||
assert len(call_args) == 1
|
||||
assert call_args[0][3] == "u1"
|
||||
assert isinstance(call_args[0][3], str)
|
||||
|
||||
def test_delete_all(self, neptune_graph):
|
||||
mg = neptune_graph
|
||||
_neptune_create_entities(mg, "u1")
|
||||
assert _neptune_node_count(mg) == 2
|
||||
|
||||
mg.delete_all({"user_id": "u1"})
|
||||
assert _neptune_node_count(mg) == 0
|
||||
|
||||
def test_add_delete_add_cycle(self, neptune_graph):
|
||||
mg = neptune_graph
|
||||
mg.llm = _make_mock_llm(
|
||||
[{"entity": "Alice", "entity_type": "person"}, {"entity": "Bob", "entity_type": "person"}],
|
||||
[{"source": "Alice", "destination": "Bob", "relationship": "likes"}],
|
||||
)
|
||||
_neptune_create_entities(mg, "u1")
|
||||
assert _neptune_edge_count(mg) == 1
|
||||
|
||||
mg.delete("Alice likes Bob", {"user_id": "u1"})
|
||||
assert _neptune_edge_count(mg) == 0
|
||||
|
||||
_neptune_create_entities(mg, "u1")
|
||||
assert _neptune_edge_count(mg) == 1
|
||||
@@ -1,736 +0,0 @@
|
||||
"""
|
||||
End-to-end tests for graph cleanup on memory deletion (issue #3245).
|
||||
|
||||
Uses a real Kuzu embedded database to verify that graph entities are
|
||||
correctly cleaned up when memories are deleted. LLM and embedding calls
|
||||
are mocked to provide deterministic entity extraction.
|
||||
|
||||
Tests are skipped automatically if kuzu is not installed.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.configs.base import MemoryConfig
|
||||
|
||||
try:
|
||||
import kuzu # noqa: F401
|
||||
_kuzu_available = True
|
||||
except ImportError:
|
||||
_kuzu_available = False
|
||||
|
||||
requires_kuzu = pytest.mark.skipif(not _kuzu_available, reason="kuzu is not installed")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _node_count(kuzu_graph):
|
||||
"""Return total node count in the Kuzu graph."""
|
||||
result = kuzu_graph.execute("MATCH (n:Entity) RETURN count(n) AS cnt")
|
||||
rows = list(result.rows_as_dict())
|
||||
return int(rows[0]["cnt"])
|
||||
|
||||
|
||||
def _edge_count(kuzu_graph):
|
||||
"""Return total edge count in the Kuzu graph."""
|
||||
result = kuzu_graph.execute("MATCH ()-[r:CONNECTED_TO]->() RETURN count(r) AS cnt")
|
||||
rows = list(result.rows_as_dict())
|
||||
return int(rows[0]["cnt"])
|
||||
|
||||
|
||||
def _get_edges(kuzu_graph):
|
||||
"""Return all edges as list of (source, relationship, destination) tuples."""
|
||||
result = kuzu_graph.execute(
|
||||
"MATCH (s:Entity)-[r:CONNECTED_TO]->(d:Entity) "
|
||||
"RETURN s.name AS src, r.name AS rel, d.name AS dst"
|
||||
)
|
||||
return [(row["src"], row["rel"], row["dst"]) for row in result.rows_as_dict()]
|
||||
|
||||
|
||||
def _get_nodes(kuzu_graph):
|
||||
"""Return all node names."""
|
||||
result = kuzu_graph.execute("MATCH (n:Entity) RETURN n.name AS name, n.user_id AS uid")
|
||||
return [(row["name"], row["uid"]) for row in result.rows_as_dict()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockVectorMemory:
|
||||
"""Mimics the object returned by vector_store.get()."""
|
||||
|
||||
def __init__(self, memory_id, payload, score=0.8):
|
||||
self.id = memory_id
|
||||
self.payload = payload
|
||||
self.score = score
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kuzu_graph_memory():
|
||||
"""
|
||||
Create a real Kuzu-backed MemoryGraph with mocked LLM and embedder.
|
||||
Yields (graph_memory_instance, kuzu_connection) then cleans up.
|
||||
"""
|
||||
import os
|
||||
|
||||
import kuzu
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
db_path = os.path.join(tmpdir, "test.kuzu")
|
||||
db = kuzu.Database(db_path)
|
||||
conn = kuzu.Connection(db)
|
||||
|
||||
# We'll construct the MemoryGraph by bypassing __init__ and setting up manually
|
||||
from mem0.memory.kuzu_memory import MemoryGraph
|
||||
|
||||
mg = MemoryGraph.__new__(MemoryGraph)
|
||||
|
||||
# Real Kuzu connection
|
||||
mg.db = db
|
||||
mg.graph = conn
|
||||
mg.node_label = ":Entity"
|
||||
mg.rel_label = ":CONNECTED_TO"
|
||||
mg.kuzu_create_schema()
|
||||
|
||||
# Deterministic embedding: use one-hot-style vectors per entity name
|
||||
# to avoid accidental cosine similarity matches between different entities
|
||||
embedding_dims = 64
|
||||
mg.embedding_dims = embedding_dims
|
||||
|
||||
_embed_cache = {}
|
||||
_embed_counter = [0]
|
||||
|
||||
def deterministic_embed(text):
|
||||
"""Generate a deterministic, near-orthogonal embedding for each unique text."""
|
||||
text_lower = text.lower().strip()
|
||||
if text_lower not in _embed_cache:
|
||||
# Create a sparse vector — set a unique dimension to 1.0
|
||||
vec = [0.0] * embedding_dims
|
||||
idx = _embed_counter[0] % embedding_dims
|
||||
vec[idx] = 1.0
|
||||
# Add small noise to other dims so it's not exactly zero
|
||||
import hashlib
|
||||
|
||||
h = hashlib.sha256(text_lower.encode()).digest()
|
||||
for i in range(embedding_dims):
|
||||
vec[i] += float(h[i % len(h)]) / 25500.0 # tiny noise
|
||||
norm = sum(v * v for v in vec) ** 0.5
|
||||
_embed_cache[text_lower] = [v / norm for v in vec]
|
||||
_embed_counter[0] += 1
|
||||
return _embed_cache[text_lower]
|
||||
|
||||
mock_embedder = MagicMock()
|
||||
mock_embedder.embed.side_effect = deterministic_embed
|
||||
mock_embedder.config.embedding_dims = embedding_dims
|
||||
mg.embedding_model = mock_embedder
|
||||
|
||||
# Mock LLM — configured per-test via mock_embedder
|
||||
mg.llm = MagicMock()
|
||||
mg.llm_provider = "openai"
|
||||
mg.user_id = None
|
||||
# High threshold so only identical entity names merge, not similar ones
|
||||
mg.threshold = 0.99
|
||||
mg.config = MagicMock()
|
||||
mg.config.graph_store.custom_prompt = None
|
||||
|
||||
yield mg, conn
|
||||
|
||||
# Cleanup
|
||||
conn.close()
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _setup_llm_for_entities(mg, entities, relations):
|
||||
"""
|
||||
Configure the mock LLM to return specific entities and relations.
|
||||
|
||||
entities: list of {"entity": str, "entity_type": str}
|
||||
relations: list of {"source": str, "destination": str, "relationship": str}
|
||||
"""
|
||||
|
||||
def generate_response(messages, tools):
|
||||
# Detect which tool is being called based on tool definition names
|
||||
tool_names = []
|
||||
for t in tools:
|
||||
if isinstance(t, dict):
|
||||
fn = t.get("function", t)
|
||||
tool_names.append(fn.get("name", ""))
|
||||
else:
|
||||
tool_names.append(getattr(t, "name", str(t)))
|
||||
|
||||
if any("extract_entities" in n for n in tool_names):
|
||||
return {
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "extract_entities",
|
||||
"arguments": {"entities": entities},
|
||||
}
|
||||
]
|
||||
}
|
||||
elif any("establish" in n or "relation" in n for n in tool_names):
|
||||
return {
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "establish_nodes_relations",
|
||||
"arguments": {"entities": relations},
|
||||
}
|
||||
]
|
||||
}
|
||||
elif any("delete" in n for n in tool_names):
|
||||
# For _get_delete_entities_from_search_output during add() — return nothing to delete
|
||||
return {"tool_calls": []}
|
||||
return {"tool_calls": []}
|
||||
|
||||
mg.llm.generate_response.side_effect = generate_response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@requires_kuzu
|
||||
class TestKuzuGraphDeleteE2E:
|
||||
"""End-to-end tests using a real Kuzu database."""
|
||||
|
||||
def test_add_creates_nodes_and_edges(self, kuzu_graph_memory):
|
||||
"""Baseline: verify add() actually creates graph data."""
|
||||
mg, conn = kuzu_graph_memory
|
||||
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
|
||||
filters = {"user_id": "test_user"}
|
||||
mg.add("Alice likes Bob", filters)
|
||||
|
||||
assert _node_count(conn) == 2
|
||||
assert _edge_count(conn) == 1
|
||||
edges = _get_edges(conn)
|
||||
assert ("alice", "likes", "bob") in edges
|
||||
|
||||
def test_delete_removes_edges_created_by_add(self, kuzu_graph_memory):
|
||||
"""Core test: delete() should remove the relationships that add() created."""
|
||||
mg, conn = kuzu_graph_memory
|
||||
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
|
||||
filters = {"user_id": "test_user"}
|
||||
mg.add("Alice likes Bob", filters)
|
||||
|
||||
assert _edge_count(conn) == 1
|
||||
|
||||
# Now delete using the same text — should remove the relationship
|
||||
mg.delete("Alice likes Bob", filters)
|
||||
|
||||
assert _edge_count(conn) == 0
|
||||
# Nodes remain (we don't delete nodes on single memory delete)
|
||||
assert _node_count(conn) == 2
|
||||
|
||||
def test_delete_only_removes_matching_edges(self, kuzu_graph_memory):
|
||||
"""delete() should only remove edges matching the extracted relationships."""
|
||||
mg, conn = kuzu_graph_memory
|
||||
|
||||
# First add: Alice likes Bob
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
filters = {"user_id": "test_user"}
|
||||
mg.add("Alice likes Bob", filters)
|
||||
|
||||
# Second add: Alice knows Charlie
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Charlie", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Charlie", "relationship": "knows"},
|
||||
],
|
||||
)
|
||||
mg.add("Alice knows Charlie", filters)
|
||||
|
||||
assert _edge_count(conn) == 2
|
||||
|
||||
# Delete only the "Alice likes Bob" memory
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
mg.delete("Alice likes Bob", filters)
|
||||
|
||||
assert _edge_count(conn) == 1
|
||||
edges = _get_edges(conn)
|
||||
assert ("alice", "knows", "charlie") in edges
|
||||
assert ("alice", "likes", "bob") not in edges
|
||||
|
||||
def test_delete_with_different_user_id_does_not_affect_other_users(self, kuzu_graph_memory):
|
||||
"""delete() scoped to user_id should not touch another user's graph data."""
|
||||
mg, conn = kuzu_graph_memory
|
||||
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
|
||||
# Add for user1
|
||||
mg.add("Alice likes Bob", {"user_id": "user1"})
|
||||
# Add same data for user2
|
||||
mg.add("Alice likes Bob", {"user_id": "user2"})
|
||||
|
||||
assert _edge_count(conn) == 2
|
||||
|
||||
# Delete only user1's data
|
||||
mg.delete("Alice likes Bob", {"user_id": "user1"})
|
||||
|
||||
assert _edge_count(conn) == 1
|
||||
# Remaining edge belongs to user2
|
||||
nodes = _get_nodes(conn)
|
||||
user2_nodes = [n for n in nodes if n[1] == "user2"]
|
||||
assert len(user2_nodes) == 2
|
||||
|
||||
def test_delete_nonexistent_relationship_is_safe(self, kuzu_graph_memory):
|
||||
"""delete() on data that doesn't exist in the graph should be a no-op."""
|
||||
mg, conn = kuzu_graph_memory
|
||||
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "hates"},
|
||||
],
|
||||
)
|
||||
|
||||
filters = {"user_id": "test_user"}
|
||||
|
||||
# Nothing in the graph yet
|
||||
assert _edge_count(conn) == 0
|
||||
assert _node_count(conn) == 0
|
||||
|
||||
# Should not raise
|
||||
mg.delete("Alice hates Bob", filters)
|
||||
|
||||
assert _edge_count(conn) == 0
|
||||
assert _node_count(conn) == 0
|
||||
|
||||
def test_delete_with_llm_failure_does_not_raise(self, kuzu_graph_memory):
|
||||
"""If LLM fails during entity extraction, delete() should not raise."""
|
||||
mg, conn = kuzu_graph_memory
|
||||
|
||||
# Make LLM raise
|
||||
mg.llm.generate_response.side_effect = RuntimeError("LLM service down")
|
||||
|
||||
filters = {"user_id": "test_user"}
|
||||
|
||||
# Should not raise
|
||||
mg.delete("Alice likes Bob", filters)
|
||||
|
||||
def test_delete_with_empty_entity_extraction(self, kuzu_graph_memory):
|
||||
"""If LLM returns no entities, delete() should be a no-op."""
|
||||
mg, conn = kuzu_graph_memory
|
||||
|
||||
# Add real data
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
filters = {"user_id": "test_user"}
|
||||
mg.add("Alice likes Bob", filters)
|
||||
assert _edge_count(conn) == 1
|
||||
|
||||
# Now delete but LLM returns no entities
|
||||
_setup_llm_for_entities(mg, entities=[], relations=[])
|
||||
mg.delete("some text", filters)
|
||||
|
||||
# Data should still be there
|
||||
assert _edge_count(conn) == 1
|
||||
|
||||
def test_delete_all_removes_everything_for_user(self, kuzu_graph_memory):
|
||||
"""delete_all() should remove all nodes/edges for a user (baseline behavior)."""
|
||||
mg, conn = kuzu_graph_memory
|
||||
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
filters = {"user_id": "test_user"}
|
||||
mg.add("Alice likes Bob", filters)
|
||||
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
{"entity": "Charlie", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Bob", "destination": "Charlie", "relationship": "knows"},
|
||||
],
|
||||
)
|
||||
mg.add("Bob knows Charlie", filters)
|
||||
|
||||
assert _node_count(conn) >= 3
|
||||
assert _edge_count(conn) == 2
|
||||
|
||||
mg.delete_all(filters)
|
||||
|
||||
assert _node_count(conn) == 0
|
||||
assert _edge_count(conn) == 0
|
||||
|
||||
def test_add_delete_add_cycle(self, kuzu_graph_memory):
|
||||
"""Verify that add → delete → re-add works correctly."""
|
||||
mg, conn = kuzu_graph_memory
|
||||
|
||||
_setup_llm_for_entities(
|
||||
mg,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
filters = {"user_id": "test_user"}
|
||||
|
||||
# Add
|
||||
mg.add("Alice likes Bob", filters)
|
||||
assert _edge_count(conn) == 1
|
||||
|
||||
# Delete
|
||||
mg.delete("Alice likes Bob", filters)
|
||||
assert _edge_count(conn) == 0
|
||||
|
||||
# Re-add
|
||||
mg.add("Alice likes Bob", filters)
|
||||
assert _edge_count(conn) == 1
|
||||
edges = _get_edges(conn)
|
||||
assert ("alice", "likes", "bob") in edges
|
||||
|
||||
|
||||
@requires_kuzu
|
||||
class TestMemoryDeleteWithGraphE2E:
|
||||
"""
|
||||
End-to-end tests for Memory.delete() with graph enabled.
|
||||
|
||||
Uses a real Kuzu database for the graph store and mocks for
|
||||
the vector store, LLM, and embedder.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_graph(self):
|
||||
"""Create a Memory instance with a real Kuzu graph backend."""
|
||||
import os
|
||||
|
||||
import kuzu
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
|
||||
with (
|
||||
patch("mem0.utils.factory.EmbedderFactory.create") as mock_embedder_factory,
|
||||
patch("mem0.utils.factory.VectorStoreFactory.create") as mock_vector_factory,
|
||||
patch("mem0.utils.factory.LlmFactory.create") as mock_llm_factory,
|
||||
patch("mem0.memory.storage.SQLiteManager") as mock_sqlite,
|
||||
):
|
||||
_mem_embed_cache = {}
|
||||
_mem_embed_counter = [0]
|
||||
|
||||
def _mem_deterministic_embed(text, *args, **kwargs):
|
||||
text_lower = text.lower().strip()
|
||||
if text_lower not in _mem_embed_cache:
|
||||
import hashlib
|
||||
|
||||
vec = [0.0] * 64
|
||||
idx = _mem_embed_counter[0] % 64
|
||||
vec[idx] = 1.0
|
||||
h = hashlib.sha256(text_lower.encode()).digest()
|
||||
for i in range(64):
|
||||
vec[i] += float(h[i % len(h)]) / 25500.0
|
||||
norm = sum(v * v for v in vec) ** 0.5
|
||||
_mem_embed_cache[text_lower] = [v / norm for v in vec]
|
||||
_mem_embed_counter[0] += 1
|
||||
return _mem_embed_cache[text_lower]
|
||||
|
||||
mock_embedder = MagicMock()
|
||||
mock_embedder.embed.side_effect = _mem_deterministic_embed
|
||||
mock_embedder.config.embedding_dims = 64
|
||||
mock_embedder_factory.return_value = mock_embedder
|
||||
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
|
||||
mock_llm = MagicMock()
|
||||
mock_llm_factory.return_value = mock_llm
|
||||
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory
|
||||
|
||||
config = MemoryConfig()
|
||||
memory = Memory(config)
|
||||
|
||||
# Now wire up a real Kuzu graph
|
||||
db_path = os.path.join(tmpdir, "test.kuzu")
|
||||
db = kuzu.Database(db_path)
|
||||
conn = kuzu.Connection(db)
|
||||
|
||||
from mem0.memory.kuzu_memory import MemoryGraph as KuzuMemoryGraph
|
||||
|
||||
graph = KuzuMemoryGraph.__new__(KuzuMemoryGraph)
|
||||
graph.db = db
|
||||
graph.graph = conn
|
||||
graph.node_label = ":Entity"
|
||||
graph.rel_label = ":CONNECTED_TO"
|
||||
graph.kuzu_create_schema()
|
||||
graph.embedding_dims = 64
|
||||
graph.embedding_model = mock_embedder
|
||||
graph.llm = mock_llm
|
||||
graph.llm_provider = "openai"
|
||||
graph.user_id = None
|
||||
graph.threshold = 0.99
|
||||
graph.config = MagicMock()
|
||||
graph.config.graph_store.custom_prompt = None
|
||||
|
||||
memory.graph = graph
|
||||
memory.enable_graph = True
|
||||
|
||||
yield memory, mock_vector_store, mock_llm, conn
|
||||
|
||||
conn.close()
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
def test_memory_delete_triggers_graph_cleanup(self, memory_with_graph):
|
||||
"""
|
||||
Full integration: Memory.delete() should clean up both vector store and graph.
|
||||
"""
|
||||
memory, mock_vs, mock_llm, conn = memory_with_graph
|
||||
|
||||
# 1. Manually add entities to the graph (simulating what add() would do)
|
||||
_setup_llm_for_memory_graph(
|
||||
mock_llm,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
memory.graph.add("Alice likes Bob", {"user_id": "user-1"})
|
||||
assert _edge_count(conn) == 1
|
||||
|
||||
# 2. Set up mock vector store to return this memory
|
||||
mock_vs.get.return_value = MockVectorMemory(
|
||||
"mem-1",
|
||||
{"data": "Alice likes Bob", "user_id": "user-1", "hash": "abc"},
|
||||
)
|
||||
|
||||
# 3. Delete the memory
|
||||
result = memory.delete("mem-1")
|
||||
|
||||
assert result == {"message": "Memory deleted successfully!"}
|
||||
|
||||
# 4. Verify graph was cleaned up
|
||||
assert _edge_count(conn) == 0
|
||||
|
||||
# 5. Verify vector store was also cleaned up
|
||||
mock_vs.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
def test_memory_delete_with_graph_preserves_other_users_data(self, memory_with_graph):
|
||||
"""Deleting user1's memory should not affect user2's graph data."""
|
||||
memory, mock_vs, mock_llm, conn = memory_with_graph
|
||||
|
||||
_setup_llm_for_memory_graph(
|
||||
mock_llm,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
|
||||
# Add data for two users
|
||||
memory.graph.add("Alice likes Bob", {"user_id": "user-1"})
|
||||
memory.graph.add("Alice likes Bob", {"user_id": "user-2"})
|
||||
assert _edge_count(conn) == 2
|
||||
|
||||
# Delete only user-1's memory
|
||||
mock_vs.get.return_value = MockVectorMemory(
|
||||
"mem-1",
|
||||
{"data": "Alice likes Bob", "user_id": "user-1", "hash": "abc"},
|
||||
)
|
||||
memory.delete("mem-1")
|
||||
|
||||
# user-2's data should be intact
|
||||
assert _edge_count(conn) == 1
|
||||
nodes = _get_nodes(conn)
|
||||
remaining_user_ids = set(uid for _, uid in nodes)
|
||||
assert "user-2" in remaining_user_ids
|
||||
|
||||
def test_memory_delete_graph_failure_still_deletes_vector(self, memory_with_graph):
|
||||
"""If graph cleanup fails, vector store deletion should still proceed."""
|
||||
memory, mock_vs, mock_llm, conn = memory_with_graph
|
||||
|
||||
# Make LLM raise during entity extraction (graph cleanup will fail)
|
||||
mock_llm.generate_response.side_effect = RuntimeError("LLM exploded")
|
||||
|
||||
mock_vs.get.return_value = MockVectorMemory(
|
||||
"mem-1",
|
||||
{"data": "Alice likes Bob", "user_id": "user-1", "hash": "abc"},
|
||||
)
|
||||
|
||||
result = memory.delete("mem-1")
|
||||
|
||||
assert result == {"message": "Memory deleted successfully!"}
|
||||
mock_vs.delete.assert_called_once_with(vector_id="mem-1")
|
||||
|
||||
def test_memory_delete_all_uses_bulk_not_per_memory(self, memory_with_graph):
|
||||
"""delete_all() should use delete_all() on graph, not per-memory delete()."""
|
||||
memory, mock_vs, mock_llm, conn = memory_with_graph
|
||||
|
||||
_setup_llm_for_memory_graph(
|
||||
mock_llm,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
memory.graph.add("Alice likes Bob", {"user_id": "user-1"})
|
||||
assert _edge_count(conn) == 1
|
||||
|
||||
# Set up vector store to return memories for deletion
|
||||
mem1 = MockVectorMemory("mem-1", {"data": "Alice likes Bob", "user_id": "user-1"})
|
||||
mock_vs.list.return_value = ([mem1], 1)
|
||||
mock_vs.get.return_value = mem1
|
||||
|
||||
memory.delete_all(user_id="user-1")
|
||||
|
||||
# After delete_all, graph should be empty (via graph.delete_all)
|
||||
assert _edge_count(conn) == 0
|
||||
assert _node_count(conn) == 0
|
||||
|
||||
def test_memory_delete_nonexistent_raises_without_graph_side_effects(self, memory_with_graph):
|
||||
"""Deleting a non-existent memory should raise ValueError without touching graph."""
|
||||
memory, mock_vs, mock_llm, conn = memory_with_graph
|
||||
|
||||
# Add some graph data that should NOT be affected
|
||||
_setup_llm_for_memory_graph(
|
||||
mock_llm,
|
||||
entities=[
|
||||
{"entity": "Alice", "entity_type": "person"},
|
||||
{"entity": "Bob", "entity_type": "person"},
|
||||
],
|
||||
relations=[
|
||||
{"source": "Alice", "destination": "Bob", "relationship": "likes"},
|
||||
],
|
||||
)
|
||||
memory.graph.add("Alice likes Bob", {"user_id": "user-1"})
|
||||
assert _edge_count(conn) == 1
|
||||
|
||||
# Memory doesn't exist in vector store
|
||||
mock_vs.get.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="Memory with id non-existent not found"):
|
||||
memory.delete("non-existent")
|
||||
|
||||
# Graph data should be untouched
|
||||
assert _edge_count(conn) == 1
|
||||
|
||||
|
||||
def _setup_llm_for_memory_graph(mock_llm, entities, relations):
|
||||
"""Configure mock LLM for the Memory-level graph operations."""
|
||||
|
||||
def generate_response(messages, tools):
|
||||
tool_names = []
|
||||
for t in tools:
|
||||
if isinstance(t, dict):
|
||||
fn = t.get("function", t)
|
||||
tool_names.append(fn.get("name", ""))
|
||||
else:
|
||||
tool_names.append(getattr(t, "name", str(t)))
|
||||
|
||||
if any("extract_entities" in n for n in tool_names):
|
||||
return {
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "extract_entities",
|
||||
"arguments": {"entities": entities},
|
||||
}
|
||||
]
|
||||
}
|
||||
elif any("establish" in n or "relation" in n for n in tool_names):
|
||||
return {
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "establish_nodes_relations",
|
||||
"arguments": {"entities": relations},
|
||||
}
|
||||
]
|
||||
}
|
||||
elif any("delete" in n for n in tool_names):
|
||||
return {"tool_calls": []}
|
||||
return {"tool_calls": []}
|
||||
|
||||
mock_llm.generate_response.side_effect = generate_response
|
||||
+1
-3
@@ -186,9 +186,7 @@ def test_delete(memory_instance):
|
||||
|
||||
result = memory_instance.delete("test_id")
|
||||
|
||||
# delete() now fetches the memory first and passes it to _delete_memory
|
||||
existing_memory = memory_instance.vector_store.get.return_value
|
||||
memory_instance._delete_memory.assert_called_once_with("test_id", existing_memory)
|
||||
memory_instance._delete_memory.assert_called_once_with("test_id")
|
||||
assert result["message"] == "Memory deleted successfully!"
|
||||
|
||||
|
||||
|
||||
@@ -438,137 +438,3 @@ async def test_async_update_nonexistent_memory_raises_error(mock_sqlite, mock_ll
|
||||
await memory._update_memory("non-existent-id", "new data", {"new data": [0.1, 0.2]})
|
||||
|
||||
mock_vector_store.update.assert_not_called()
|
||||
|
||||
|
||||
@patch('mem0.utils.factory.EmbedderFactory.create')
|
||||
@patch('mem0.utils.factory.VectorStoreFactory.create')
|
||||
@patch('mem0.utils.factory.LlmFactory.create')
|
||||
@patch('mem0.memory.storage.SQLiteManager')
|
||||
def test_add_infer_false_embeds_once(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
|
||||
"""
|
||||
Regression test for issue #3723: adding with infer=False should not trigger duplicate embedding calls.
|
||||
|
||||
Root cause: _create_memory expected a dict for existing_embeddings but received a raw list[float],
|
||||
causing the cache check `data in existing_embeddings` to always fail and trigger a redundant embed.
|
||||
"""
|
||||
embedder = MagicMock()
|
||||
embedder.embed.return_value = [0.1, 0.2, 0.3]
|
||||
embedder.config = MagicMock(embedding_dims=3)
|
||||
mock_embedder_factory.return_value = embedder
|
||||
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_store.search.return_value = []
|
||||
mock_vector_store.insert.return_value = None
|
||||
mock_vector_store.get.return_value = None
|
||||
telemetry_vector_store = MagicMock()
|
||||
mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store]
|
||||
|
||||
mock_llm_factory.return_value = MagicMock()
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory as MemoryClass
|
||||
memory = MemoryClass(MemoryConfig())
|
||||
|
||||
memory.add("foo", user_id="test_user", infer=False)
|
||||
|
||||
assert embedder.embed.call_count == 1
|
||||
mock_vector_store.insert.assert_called_once()
|
||||
|
||||
|
||||
@patch('mem0.utils.factory.EmbedderFactory.create')
|
||||
@patch('mem0.utils.factory.VectorStoreFactory.create')
|
||||
@patch('mem0.utils.factory.LlmFactory.create')
|
||||
@patch('mem0.memory.storage.SQLiteManager')
|
||||
def test_add_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
|
||||
"""
|
||||
Regression test for issue #3723 (infer=True path): when the LLM rewrites a fact during the
|
||||
ADD action, the embedding should be computed once and cached, not computed again inside _create_memory.
|
||||
"""
|
||||
embedder = MagicMock()
|
||||
embedder.embed.return_value = [0.1, 0.2, 0.3]
|
||||
embedder.config = MagicMock(embedding_dims=3)
|
||||
mock_embedder_factory.return_value = embedder
|
||||
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_store.search.return_value = []
|
||||
mock_vector_store.insert.return_value = None
|
||||
mock_vector_store.get.return_value = None
|
||||
telemetry_vector_store = MagicMock()
|
||||
mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store]
|
||||
|
||||
# LLM extracts fact "User likes Python", then ADD action rewrites to "The user enjoys Python"
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.generate_response.side_effect = [
|
||||
json.dumps({"facts": ["User likes Python"]}),
|
||||
json.dumps({"memory": [{"id": "0", "text": "The user enjoys Python", "event": "ADD", "old_memory": None}]}),
|
||||
]
|
||||
mock_llm_factory.return_value = mock_llm
|
||||
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory as MemoryClass
|
||||
memory = MemoryClass(MemoryConfig())
|
||||
|
||||
memory.add("I like Python", user_id="test_user", infer=True)
|
||||
|
||||
# embed should be called exactly twice:
|
||||
# 1. For the extracted fact "User likes Python" (search)
|
||||
# 2. For the rewritten text "The user enjoys Python" (pre-cached before _create_memory)
|
||||
# It should NOT be called a 3rd time inside _create_memory
|
||||
assert embedder.embed.call_count == 2
|
||||
mock_vector_store.insert.assert_called_once()
|
||||
|
||||
|
||||
@patch('mem0.utils.factory.EmbedderFactory.create')
|
||||
@patch('mem0.utils.factory.VectorStoreFactory.create')
|
||||
@patch('mem0.utils.factory.LlmFactory.create')
|
||||
@patch('mem0.memory.storage.SQLiteManager')
|
||||
def test_update_infer_true_caches_embedding_on_llm_rewrite(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
|
||||
"""
|
||||
Regression test for issue #3723 (infer=True UPDATE path): when the LLM rewrites a fact during
|
||||
an UPDATE action, the embedding should be computed once and cached, not computed again inside _update_memory.
|
||||
"""
|
||||
embedder = MagicMock()
|
||||
embedder.embed.return_value = [0.1, 0.2, 0.3]
|
||||
embedder.config = MagicMock(embedding_dims=3)
|
||||
mock_embedder_factory.return_value = embedder
|
||||
|
||||
# Existing memory that will be matched for update
|
||||
existing_memory = MockVectorMemory(
|
||||
memory_id="existing-mem-id",
|
||||
payload={
|
||||
"data": "User likes Python",
|
||||
"hash": "abc123",
|
||||
"created_at": "2025-01-01T00:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_store.search.return_value = [existing_memory]
|
||||
mock_vector_store.get.return_value = existing_memory
|
||||
mock_vector_store.insert.return_value = None
|
||||
mock_vector_store.update.return_value = None
|
||||
telemetry_vector_store = MagicMock()
|
||||
mock_vector_factory.side_effect = [mock_vector_store, telemetry_vector_store]
|
||||
|
||||
# LLM extracts fact "User loves Python now", then UPDATE action rewrites to "The user loves Python"
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.generate_response.side_effect = [
|
||||
json.dumps({"facts": ["User loves Python now"]}),
|
||||
json.dumps({"memory": [{"id": "0", "text": "The user loves Python", "event": "UPDATE", "old_memory": "User likes Python"}]}),
|
||||
]
|
||||
mock_llm_factory.return_value = mock_llm
|
||||
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import Memory as MemoryClass
|
||||
memory = MemoryClass(MemoryConfig())
|
||||
|
||||
memory.add("I love Python now", user_id="test_user", infer=True)
|
||||
|
||||
# embed should be called exactly twice:
|
||||
# 1. For the extracted fact "User loves Python now" (search)
|
||||
# 2. For the rewritten text "The user loves Python" (pre-cached before _update_memory)
|
||||
# It should NOT be called a 3rd time inside _update_memory
|
||||
assert embedder.embed.call_count == 2
|
||||
mock_vector_store.update.assert_called_once()
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
"""Tests for REST API parameter forwarding.
|
||||
|
||||
Verifies that the Pydantic request models in server/main.py correctly accept
|
||||
and forward all parameters supported by the underlying Memory class methods,
|
||||
including limit, threshold, infer, memory_type, and prompt — which were
|
||||
previously silently dropped by Pydantic v2's default extra='ignore' behavior.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi", reason="fastapi not installed")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_memory():
|
||||
"""Patch Memory.from_config so the server imports without a real backend."""
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.add.return_value = {"results": [{"id": "mem-1", "event": "ADD", "memory": "test"}]}
|
||||
mock_instance.search.return_value = [{"id": "mem-1", "memory": "test", "score": 0.9}]
|
||||
mock_instance.get.return_value = {"id": "mem-1", "memory": "test memory"}
|
||||
mock_instance.get_all.return_value = [{"id": "mem-1", "memory": "test memory"}]
|
||||
mock_instance.update.return_value = {"message": "Memory updated"}
|
||||
mock_instance.history.return_value = [{"id": "mem-1", "old_memory": "a", "new_memory": "b"}]
|
||||
mock_instance.delete.return_value = None
|
||||
mock_instance.delete_all.return_value = {"message": "Memories deleted"}
|
||||
mock_instance.reset.return_value = None
|
||||
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "fake-key", "ADMIN_API_KEY": ""}):
|
||||
with patch("mem0.Memory.from_config", return_value=mock_instance):
|
||||
yield mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(_mock_memory):
|
||||
"""Return a TestClient wired to the server app with mocked Memory."""
|
||||
import server.main as server_main
|
||||
with patch.dict(os.environ, {"ADMIN_API_KEY": ""}):
|
||||
importlib.reload(server_main)
|
||||
return TestClient(server_main.app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory(_mock_memory):
|
||||
return _mock_memory
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SearchRequest: limit parameter
|
||||
# ===========================================================================
|
||||
|
||||
class TestSearchLimit:
|
||||
"""Verify that the limit parameter is accepted and forwarded to Memory.search()."""
|
||||
|
||||
def test_limit_forwarded(self, client, mock_memory):
|
||||
resp = client.post("/search", json={"query": "food", "user_id": "u1", "limit": 5})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert kwargs["limit"] == 5
|
||||
|
||||
def test_limit_one(self, client, mock_memory):
|
||||
resp = client.post("/search", json={"query": "food", "user_id": "u1", "limit": 1})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert kwargs["limit"] == 1
|
||||
|
||||
def test_limit_omitted_uses_memory_default(self, client, mock_memory):
|
||||
"""When limit is not sent, it should not appear in the kwargs,
|
||||
allowing Memory.search() to use its own default (100)."""
|
||||
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert "limit" not in kwargs
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SearchRequest: threshold parameter
|
||||
# ===========================================================================
|
||||
|
||||
class TestSearchThreshold:
|
||||
"""Verify that the threshold parameter is accepted and forwarded."""
|
||||
|
||||
def test_threshold_forwarded(self, client, mock_memory):
|
||||
resp = client.post("/search", json={"query": "food", "user_id": "u1", "threshold": 0.8})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert kwargs["threshold"] == 0.8
|
||||
|
||||
def test_threshold_zero(self, client, mock_memory):
|
||||
"""threshold=0.0 is a valid falsy value that must not be filtered out."""
|
||||
resp = client.post("/search", json={"query": "food", "user_id": "u1", "threshold": 0.0})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert kwargs["threshold"] == 0.0
|
||||
|
||||
def test_threshold_omitted_uses_memory_default(self, client, mock_memory):
|
||||
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert "threshold" not in kwargs
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SearchRequest: limit + threshold together
|
||||
# ===========================================================================
|
||||
|
||||
class TestSearchLimitAndThreshold:
|
||||
|
||||
def test_both_forwarded(self, client, mock_memory):
|
||||
resp = client.post("/search", json={
|
||||
"query": "food", "user_id": "u1", "limit": 10, "threshold": 0.5
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert kwargs["limit"] == 10
|
||||
assert kwargs["threshold"] == 0.5
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MemoryCreate: infer parameter
|
||||
# ===========================================================================
|
||||
|
||||
class TestAddInfer:
|
||||
"""Verify that the infer parameter is accepted and forwarded to Memory.add()."""
|
||||
|
||||
def test_infer_false_forwarded(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "Store this exactly"}],
|
||||
"user_id": "u1",
|
||||
"infer": False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert kwargs["infer"] is False
|
||||
|
||||
def test_infer_true_forwarded(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "I like pizza"}],
|
||||
"user_id": "u1",
|
||||
"infer": True,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert kwargs["infer"] is True
|
||||
|
||||
def test_infer_omitted_uses_memory_default(self, client, mock_memory):
|
||||
"""When infer is not sent, it should not appear in kwargs,
|
||||
allowing Memory.add() to use its own default (True)."""
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"user_id": "u1",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert "infer" not in kwargs
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MemoryCreate: memory_type parameter
|
||||
# ===========================================================================
|
||||
|
||||
class TestAddMemoryType:
|
||||
"""Verify that the memory_type parameter is accepted and forwarded."""
|
||||
|
||||
def test_memory_type_forwarded(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "I like pizza"}],
|
||||
"user_id": "u1",
|
||||
"memory_type": "core",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert kwargs["memory_type"] == "core"
|
||||
|
||||
def test_memory_type_omitted(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"user_id": "u1",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert "memory_type" not in kwargs
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MemoryCreate: prompt parameter
|
||||
# ===========================================================================
|
||||
|
||||
class TestAddPrompt:
|
||||
"""Verify that the prompt parameter is accepted and forwarded."""
|
||||
|
||||
def test_prompt_forwarded(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "I like pizza"}],
|
||||
"user_id": "u1",
|
||||
"prompt": "Extract food preferences only.",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert kwargs["prompt"] == "Extract food preferences only."
|
||||
|
||||
def test_prompt_omitted(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"user_id": "u1",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert "prompt" not in kwargs
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MemoryCreate: all new params together
|
||||
# ===========================================================================
|
||||
|
||||
class TestAddAllNewParams:
|
||||
|
||||
def test_infer_memory_type_and_prompt_together(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "I like pizza"}],
|
||||
"user_id": "u1",
|
||||
"infer": False,
|
||||
"memory_type": "core",
|
||||
"prompt": "Custom extraction prompt.",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert kwargs["infer"] is False
|
||||
assert kwargs["memory_type"] == "core"
|
||||
assert kwargs["prompt"] == "Custom extraction prompt."
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Edge cases: falsy-but-valid values must not be filtered out
|
||||
# ===========================================================================
|
||||
|
||||
class TestFalsyValues:
|
||||
"""The handler filters with `v is not None`. Falsy values like False, 0,
|
||||
0.0, and empty string must still be forwarded."""
|
||||
|
||||
def test_infer_false_not_filtered(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"user_id": "u1",
|
||||
"infer": False,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert kwargs["infer"] is False
|
||||
|
||||
def test_threshold_zero_not_filtered(self, client, mock_memory):
|
||||
resp = client.post("/search", json={
|
||||
"query": "food", "user_id": "u1", "threshold": 0.0,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert kwargs["threshold"] == 0.0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Extra/unknown fields are still silently ignored (existing Pydantic behavior)
|
||||
# ===========================================================================
|
||||
|
||||
class TestUnknownFieldsIgnored:
|
||||
|
||||
def test_unknown_search_field_ignored(self, client, mock_memory):
|
||||
resp = client.post("/search", json={
|
||||
"query": "food", "user_id": "u1", "bogus_field": "xyz",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert "bogus_field" not in kwargs
|
||||
|
||||
def test_unknown_add_field_ignored(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"user_id": "u1",
|
||||
"unknown_param": 42,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert "unknown_param" not in kwargs
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Backward compatibility: existing params still work
|
||||
# ===========================================================================
|
||||
|
||||
class TestExistingParamsUnchanged:
|
||||
|
||||
def test_search_filters_still_forwarded(self, client, mock_memory):
|
||||
resp = client.post("/search", json={
|
||||
"query": "food",
|
||||
"user_id": "u1",
|
||||
"agent_id": "a1",
|
||||
"filters": {"category": "food"},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert kwargs["user_id"] == "u1"
|
||||
assert kwargs["agent_id"] == "a1"
|
||||
assert kwargs["filters"] == {"category": "food"}
|
||||
|
||||
def test_add_metadata_still_forwarded(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"user_id": "u1",
|
||||
"agent_id": "a1",
|
||||
"metadata": {"source": "test"},
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert kwargs["user_id"] == "u1"
|
||||
assert kwargs["agent_id"] == "a1"
|
||||
assert kwargs["metadata"] == {"source": "test"}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# OpenAPI schema: new fields are documented
|
||||
# ===========================================================================
|
||||
|
||||
class TestOpenAPISchema:
|
||||
"""Verify the new fields appear in the auto-generated OpenAPI schema."""
|
||||
|
||||
def test_search_schema_includes_limit(self, client):
|
||||
schema = client.get("/openapi.json").json()
|
||||
search_props = schema["components"]["schemas"]["SearchRequest"]["properties"]
|
||||
assert "limit" in search_props
|
||||
assert search_props["limit"]["description"] == "Maximum number of results to return."
|
||||
|
||||
def test_search_schema_includes_threshold(self, client):
|
||||
schema = client.get("/openapi.json").json()
|
||||
search_props = schema["components"]["schemas"]["SearchRequest"]["properties"]
|
||||
assert "threshold" in search_props
|
||||
|
||||
def test_add_schema_includes_infer(self, client):
|
||||
schema = client.get("/openapi.json").json()
|
||||
add_props = schema["components"]["schemas"]["MemoryCreate"]["properties"]
|
||||
assert "infer" in add_props
|
||||
|
||||
def test_add_schema_includes_memory_type(self, client):
|
||||
schema = client.get("/openapi.json").json()
|
||||
add_props = schema["components"]["schemas"]["MemoryCreate"]["properties"]
|
||||
assert "memory_type" in add_props
|
||||
|
||||
def test_add_schema_includes_prompt(self, client):
|
||||
schema = client.get("/openapi.json").json()
|
||||
add_props = schema["components"]["schemas"]["MemoryCreate"]["properties"]
|
||||
assert "prompt" in add_props
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Pydantic type validation: invalid types return 422
|
||||
# ===========================================================================
|
||||
|
||||
class TestTypeValidation:
|
||||
"""Verify FastAPI/Pydantic rejects invalid types with 422."""
|
||||
|
||||
def test_limit_string_rejected(self, client):
|
||||
resp = client.post("/search", json={
|
||||
"query": "food", "user_id": "u1", "limit": "not_a_number",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_threshold_string_rejected(self, client):
|
||||
resp = client.post("/search", json={
|
||||
"query": "food", "user_id": "u1", "threshold": "high",
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_infer_string_coerced_by_pydantic(self, client, mock_memory):
|
||||
"""Pydantic v2 coerces truthy strings like 'yes' to True for bool fields."""
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"user_id": "u1",
|
||||
"infer": "yes",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert kwargs["infer"] is True
|
||||
|
||||
def test_infer_invalid_value_rejected(self, client):
|
||||
"""A value that cannot be coerced to bool should be rejected."""
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"user_id": "u1",
|
||||
"infer": [1, 2, 3],
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_limit_float_rejected(self, client):
|
||||
resp = client.post("/search", json={
|
||||
"query": "food", "user_id": "u1", "limit": 5.7,
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_memory_type_int_rejected(self, client):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"user_id": "u1",
|
||||
"memory_type": 123,
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Explicit null values: treated as omitted (filtered by `is not None`)
|
||||
# ===========================================================================
|
||||
|
||||
class TestExplicitNull:
|
||||
"""When a client sends null for an optional field, it should be treated
|
||||
as omitted — the Memory class default should be used."""
|
||||
|
||||
def test_limit_null_uses_memory_default(self, client, mock_memory):
|
||||
resp = client.post("/search", json={
|
||||
"query": "food", "user_id": "u1", "limit": None,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert "limit" not in kwargs
|
||||
|
||||
def test_infer_null_uses_memory_default(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"user_id": "u1",
|
||||
"infer": None,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert "infer" not in kwargs
|
||||
|
||||
def test_prompt_null_uses_memory_default(self, client, mock_memory):
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
"user_id": "u1",
|
||||
"prompt": None,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
assert "prompt" not in kwargs
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Verify exact call signatures match Memory method params
|
||||
# ===========================================================================
|
||||
|
||||
class TestCallSignatureMatch:
|
||||
"""Ensure forwarded params exactly match Memory.add() and Memory.search()
|
||||
keyword argument names — a typo here would cause a TypeError at runtime."""
|
||||
|
||||
def test_search_kwargs_are_valid(self, client, mock_memory):
|
||||
"""All kwargs forwarded to Memory.search() must be in its signature."""
|
||||
resp = client.post("/search", json={
|
||||
"query": "food", "user_id": "u1", "agent_id": "a1",
|
||||
"run_id": "r1", "filters": {"k": "v"},
|
||||
"limit": 10, "threshold": 0.5,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
# The handler passes query= as a keyword arg, so it appears in kwargs too
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
valid_params = {"query", "user_id", "agent_id", "run_id", "limit", "filters", "threshold", "rerank"}
|
||||
for key in kwargs:
|
||||
assert key in valid_params, f"Unexpected kwarg '{key}' forwarded to Memory.search()"
|
||||
|
||||
def test_add_kwargs_are_valid(self, client, mock_memory):
|
||||
"""All kwargs forwarded to Memory.add() must be in its signature."""
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"user_id": "u1", "agent_id": "a1", "run_id": "r1",
|
||||
"metadata": {"k": "v"},
|
||||
"infer": False, "memory_type": "core", "prompt": "custom",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
# The handler passes messages= as a keyword arg, so it appears in kwargs too
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
valid_params = {"messages", "user_id", "agent_id", "run_id", "metadata", "infer", "memory_type", "prompt"}
|
||||
for key in kwargs:
|
||||
assert key in valid_params, f"Unexpected kwarg '{key}' forwarded to Memory.add()"
|
||||
|
||||
def test_messages_excluded_from_params_dict(self, client, mock_memory):
|
||||
"""messages is passed separately via messages= kwarg, not duplicated from model_dump."""
|
||||
resp = client.post("/memories", json={
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"user_id": "u1",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.add.call_args
|
||||
# messages should be present (passed explicitly) and be a list of dicts
|
||||
assert "messages" in kwargs
|
||||
assert isinstance(kwargs["messages"], list)
|
||||
assert kwargs["messages"][0] == {"role": "user", "content": "hi"}
|
||||
|
||||
def test_query_passed_explicitly(self, client, mock_memory):
|
||||
"""query is passed as an explicit keyword arg to Memory.search()."""
|
||||
resp = client.post("/search", json={"query": "food", "user_id": "u1"})
|
||||
assert resp.status_code == 200
|
||||
_, kwargs = mock_memory.search.call_args
|
||||
assert kwargs["query"] == "food"
|
||||
@@ -3,7 +3,6 @@ from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
|
||||
from mem0.vector_stores.chroma import ChromaDB
|
||||
from mem0.configs.vector_stores.chroma import ChromaDbConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -250,15 +249,3 @@ def test_generate_where_clause_non_string_values():
|
||||
# ChromaDB accepts non-string values in filters
|
||||
expected = {"$and": [{"user_id": {"$eq": "alice"}}, {"count": {"$eq": 5}}, {"active": {"$eq": True}}]}
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_chroma_config_accepts_default_tmp_path():
|
||||
"""Test that ChromaDbConfig accepts the default /tmp/chroma path."""
|
||||
config = ChromaDbConfig(path="/tmp/chroma")
|
||||
assert config.path == "/tmp/chroma"
|
||||
|
||||
|
||||
def test_chroma_config_rejects_no_config():
|
||||
"""Test that ChromaDbConfig rejects when no connection config is provided."""
|
||||
with pytest.raises(ValueError):
|
||||
ChromaDbConfig()
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("databricks", reason="databricks-sdk package not installed")
|
||||
|
||||
from databricks.sdk.service.vectorsearch import VectorIndexType, QueryVectorIndexResponse, ResultManifest, ResultData, ColumnInfo
|
||||
from mem0.vector_stores.databricks import Databricks
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------- Fixtures ---------------------- #
|
||||
@@ -209,34 +205,9 @@ def test_search_direct_access_vector(db_instance_direct, mock_workspace_client):
|
||||
assert results[0].score == 0.77
|
||||
|
||||
|
||||
def test_search_delta_sync_self_managed_vectors(mock_workspace_client):
|
||||
"""DELTA_SYNC without embedding model endpoint should use query_vector, not query_text."""
|
||||
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
|
||||
# DELTA_SYNC without embedding_model_endpoint_name = self-managed vectors
|
||||
inst = Databricks(
|
||||
workspace_url="https://test",
|
||||
access_token="tok",
|
||||
endpoint_name="vs-endpoint",
|
||||
catalog="catalog",
|
||||
schema="schema",
|
||||
table_name="table",
|
||||
warehouse_name="test-warehouse",
|
||||
index_type=VectorIndexType.DELTA_SYNC,
|
||||
embedding_dimension=4,
|
||||
# NOTE: no embedding_model_endpoint_name
|
||||
)
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
|
||||
result=SimpleNamespace(data_array=[])
|
||||
)
|
||||
inst.search(query="ignored", vectors=[0.1, 0.2, 0.3, 0.4], limit=5)
|
||||
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_vector" in call_kwargs
|
||||
assert "query_text" not in call_kwargs
|
||||
|
||||
|
||||
def test_search_missing_params_raises(db_instance_delta):
|
||||
with pytest.raises(ValueError):
|
||||
db_instance_delta.search(query="", vectors=[0.1, 0.2]) # DELTA_SYNC with model endpoint requires query text
|
||||
db_instance_delta.search(query="", vectors=[0.1, 0.2]) # DELTA_SYNC requires query text
|
||||
|
||||
|
||||
# ---------------------- Delete Tests ---------------------- #
|
||||
@@ -304,54 +275,6 @@ def test_get_vector(db_instance_delta, mock_workspace_client):
|
||||
assert res.id == "id-get"
|
||||
assert res.payload["data"] == "some memory"
|
||||
assert res.payload["tag"] == "x"
|
||||
# DELTA_SYNC should use query_text, not query_vector
|
||||
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_text" in call_kwargs
|
||||
assert "query_vector" not in call_kwargs
|
||||
|
||||
|
||||
def test_get_vector_direct_access(db_instance_direct, mock_workspace_client):
|
||||
"""get() on a DIRECT_ACCESS index must use query_vector instead of query_text."""
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = QueryVectorIndexResponse(
|
||||
manifest=ResultManifest(columns=[
|
||||
ColumnInfo(name="memory_id"),
|
||||
ColumnInfo(name="hash"),
|
||||
ColumnInfo(name="agent_id"),
|
||||
ColumnInfo(name="run_id"),
|
||||
ColumnInfo(name="user_id"),
|
||||
ColumnInfo(name="memory"),
|
||||
ColumnInfo(name="metadata"),
|
||||
ColumnInfo(name="created_at"),
|
||||
ColumnInfo(name="updated_at"),
|
||||
ColumnInfo(name="embedding"),
|
||||
ColumnInfo(name="score"),
|
||||
]),
|
||||
result=ResultData(
|
||||
data_array=[
|
||||
[
|
||||
"id-get-da",
|
||||
"h",
|
||||
"a",
|
||||
"r",
|
||||
"u",
|
||||
"direct access memory",
|
||||
'{"tag":"da"}',
|
||||
"2024-01-01T00:00:00",
|
||||
"2024-01-01T00:00:00",
|
||||
[0.1, 0.2, 0.3, 0.4],
|
||||
"0.88",
|
||||
]
|
||||
]
|
||||
)
|
||||
)
|
||||
res = db_instance_direct.get("id-get-da")
|
||||
assert res.id == "id-get-da"
|
||||
assert res.payload["data"] == "direct access memory"
|
||||
# DIRECT_ACCESS should use query_vector, not query_text
|
||||
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_vector" in call_kwargs
|
||||
assert "query_text" not in call_kwargs
|
||||
assert call_kwargs["query_vector"] == [0.0] * 4 # embedding_dimension=4
|
||||
|
||||
|
||||
# ---------------------- Collection Info / Listing Tests ---------------------- #
|
||||
@@ -407,185 +330,6 @@ def test_list_memories(db_instance_delta, mock_workspace_client):
|
||||
assert isinstance(res, list)
|
||||
assert len(res[0]) == 1
|
||||
assert res[0][0].id == "id-get"
|
||||
# DELTA_SYNC should use query_text
|
||||
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_text" in call_kwargs
|
||||
assert "query_vector" not in call_kwargs
|
||||
|
||||
|
||||
def test_list_memories_direct_access(db_instance_direct, mock_workspace_client):
|
||||
"""list() on a DIRECT_ACCESS index must use query_vector instead of query_text."""
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
|
||||
result=SimpleNamespace(
|
||||
data_array=[
|
||||
[
|
||||
"id-da-list",
|
||||
"h",
|
||||
"a",
|
||||
"r",
|
||||
"u",
|
||||
"direct memory",
|
||||
None,
|
||||
"2024-01-01T00:00:00",
|
||||
"2024-01-01T00:00:00",
|
||||
[0.1, 0.2, 0.3, 0.4],
|
||||
]
|
||||
]
|
||||
)
|
||||
)
|
||||
res = db_instance_direct.list(limit=5)
|
||||
assert isinstance(res, list)
|
||||
assert len(res[0]) == 1
|
||||
assert res[0][0].id == "id-da-list"
|
||||
assert res[0][0].payload["data"] == "direct memory"
|
||||
# DIRECT_ACCESS should use query_vector
|
||||
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_vector" in call_kwargs
|
||||
assert "query_text" not in call_kwargs
|
||||
assert call_kwargs["query_vector"] == [0.0] * 4
|
||||
|
||||
|
||||
def test_get_vector_delta_sync_self_managed(mock_workspace_client):
|
||||
"""get() on DELTA_SYNC without model endpoint should use query_vector."""
|
||||
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
|
||||
inst = Databricks(
|
||||
workspace_url="https://test",
|
||||
access_token="tok",
|
||||
endpoint_name="vs-endpoint",
|
||||
catalog="catalog",
|
||||
schema="schema",
|
||||
table_name="table",
|
||||
warehouse_name="test-warehouse",
|
||||
index_type=VectorIndexType.DELTA_SYNC,
|
||||
embedding_dimension=4,
|
||||
# NOTE: no embedding_model_endpoint_name
|
||||
)
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = QueryVectorIndexResponse(
|
||||
manifest=ResultManifest(columns=[
|
||||
ColumnInfo(name="memory_id"), ColumnInfo(name="hash"),
|
||||
ColumnInfo(name="agent_id"), ColumnInfo(name="run_id"),
|
||||
ColumnInfo(name="user_id"), ColumnInfo(name="memory"),
|
||||
ColumnInfo(name="metadata"), ColumnInfo(name="created_at"),
|
||||
ColumnInfo(name="updated_at"),
|
||||
]),
|
||||
result=ResultData(data_array=[["id-sm", "h", None, None, None, "self-managed mem", None, None, None]]),
|
||||
)
|
||||
res = inst.get("id-sm")
|
||||
assert res.id == "id-sm"
|
||||
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_vector" in call_kwargs
|
||||
assert "query_text" not in call_kwargs
|
||||
assert call_kwargs["query_vector"] == [0.0] * 4
|
||||
|
||||
|
||||
def test_list_memories_delta_sync_self_managed(mock_workspace_client):
|
||||
"""list() on DELTA_SYNC without model endpoint should use query_vector."""
|
||||
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
|
||||
inst = Databricks(
|
||||
workspace_url="https://test",
|
||||
access_token="tok",
|
||||
endpoint_name="vs-endpoint",
|
||||
catalog="catalog",
|
||||
schema="schema",
|
||||
table_name="table",
|
||||
warehouse_name="test-warehouse",
|
||||
index_type=VectorIndexType.DELTA_SYNC,
|
||||
embedding_dimension=4,
|
||||
# NOTE: no embedding_model_endpoint_name
|
||||
)
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
|
||||
result=SimpleNamespace(data_array=[])
|
||||
)
|
||||
inst.list(limit=5)
|
||||
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_vector" in call_kwargs
|
||||
assert "query_text" not in call_kwargs
|
||||
|
||||
|
||||
def test_list_memories_default_limit(db_instance_delta, mock_workspace_client):
|
||||
"""list() with no limit should default to 100."""
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
|
||||
result=SimpleNamespace(data_array=[])
|
||||
)
|
||||
db_instance_delta.list(limit=None)
|
||||
call_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert call_kwargs["num_results"] == 100
|
||||
|
||||
|
||||
# ---------------------- Table Creation Tests ---------------------- #
|
||||
|
||||
|
||||
def test_ensure_source_table_uses_dynamic_names(mock_workspace_client):
|
||||
"""Verify _ensure_source_table_exists uses self.fully_qualified_table_name and
|
||||
self.table_name for the PK constraint, not hardcoded values."""
|
||||
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=False)
|
||||
Databricks(
|
||||
workspace_url="https://test",
|
||||
access_token="tok",
|
||||
endpoint_name="vs-endpoint",
|
||||
catalog="my_catalog",
|
||||
schema="my_schema",
|
||||
table_name="my_memories",
|
||||
collection_name="my_index",
|
||||
warehouse_name="test-warehouse",
|
||||
index_type=VectorIndexType.DELTA_SYNC,
|
||||
embedding_model_endpoint_name="embedding-endpoint",
|
||||
)
|
||||
# _ensure_source_table_exists was called during __init__ via create_col
|
||||
constraint_call = mock_workspace_client.table_constraints.create.call_args
|
||||
assert constraint_call.kwargs["full_name_arg"] == "my_catalog.my_schema.my_memories"
|
||||
pk_name = constraint_call.kwargs["constraint"].primary_key_constraint.name
|
||||
assert pk_name == "pk_my_memories"
|
||||
|
||||
|
||||
# ---------------------- Config Validation Tests ---------------------- #
|
||||
|
||||
|
||||
def test_config_rejects_old_doc_params():
|
||||
"""Config should reject the old documentation parameter names like index_name and source_table_name."""
|
||||
from mem0.configs.vector_stores.databricks import DatabricksConfig
|
||||
with pytest.raises(ValueError, match="Extra fields not allowed"):
|
||||
DatabricksConfig(
|
||||
workspace_url="https://test",
|
||||
access_token="tok",
|
||||
endpoint_name="ep",
|
||||
catalog="cat",
|
||||
schema="sch",
|
||||
table_name="tbl",
|
||||
index_name="catalog.schema.index", # old param from docs
|
||||
)
|
||||
|
||||
|
||||
def test_config_rejects_source_table_name():
|
||||
"""Config should reject source_table_name which was in old docs."""
|
||||
from mem0.configs.vector_stores.databricks import DatabricksConfig
|
||||
with pytest.raises(ValueError, match="Extra fields not allowed"):
|
||||
DatabricksConfig(
|
||||
workspace_url="https://test",
|
||||
access_token="tok",
|
||||
endpoint_name="ep",
|
||||
catalog="cat",
|
||||
schema="sch",
|
||||
table_name="tbl",
|
||||
source_table_name="catalog.schema.table", # old param from docs
|
||||
)
|
||||
|
||||
|
||||
def test_config_accepts_correct_params():
|
||||
"""Config should accept all the correct parameter names."""
|
||||
from mem0.configs.vector_stores.databricks import DatabricksConfig
|
||||
config = DatabricksConfig(
|
||||
workspace_url="https://test",
|
||||
access_token="tok",
|
||||
endpoint_name="ep",
|
||||
catalog="cat",
|
||||
schema="sch",
|
||||
table_name="tbl",
|
||||
collection_name="my_index",
|
||||
embedding_dimension=768,
|
||||
)
|
||||
assert config.collection_name == "my_index"
|
||||
assert config.embedding_dimension == 768
|
||||
|
||||
|
||||
# ---------------------- Reset Tests ---------------------- #
|
||||
@@ -597,244 +341,3 @@ def test_reset(db_instance_delta, mock_workspace_client):
|
||||
with patch.object(db_instance_delta, "create_col", wraps=db_instance_delta.create_col) as create_spy:
|
||||
db_instance_delta.reset()
|
||||
assert create_spy.called
|
||||
|
||||
|
||||
# ---------------------- End-to-End Config → Factory → CRUD Tests ---------------------- #
|
||||
|
||||
|
||||
def test_e2e_config_to_factory_delta_sync(mock_workspace_client):
|
||||
"""End-to-end: VectorStoreConfig validates docs-correct params, factory creates Databricks instance."""
|
||||
from mem0.vector_stores.configs import VectorStoreConfig
|
||||
from mem0.utils.factory import VectorStoreFactory
|
||||
|
||||
# Step 1: Config validation (simulates what Memory.from_config does)
|
||||
vs_config = VectorStoreConfig(
|
||||
provider="databricks",
|
||||
config={
|
||||
"workspace_url": "https://my-workspace.databricks.com",
|
||||
"access_token": "my-token",
|
||||
"endpoint_name": "my-endpoint",
|
||||
"catalog": "prod_catalog",
|
||||
"schema": "ai_schema",
|
||||
"table_name": "memories_table",
|
||||
"collection_name": "my_index",
|
||||
"embedding_dimension": 768,
|
||||
"warehouse_name": "test-warehouse",
|
||||
},
|
||||
)
|
||||
assert vs_config.config.collection_name == "my_index"
|
||||
assert vs_config.config.catalog == "prod_catalog"
|
||||
|
||||
# Step 2: Factory instantiation (same as MemoryBase.__init__)
|
||||
instance = VectorStoreFactory.create("databricks", vs_config.config)
|
||||
assert isinstance(instance, Databricks)
|
||||
assert instance.fully_qualified_table_name == "prod_catalog.ai_schema.memories_table"
|
||||
assert instance.fully_qualified_index_name == "prod_catalog.ai_schema.my_index"
|
||||
assert instance.embedding_dimension == 768
|
||||
|
||||
|
||||
def test_e2e_config_to_factory_direct_access(mock_workspace_client):
|
||||
"""End-to-end: DIRECT_ACCESS via config → factory creates correct instance."""
|
||||
from mem0.vector_stores.configs import VectorStoreConfig
|
||||
from mem0.utils.factory import VectorStoreFactory
|
||||
|
||||
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
|
||||
|
||||
vs_config = VectorStoreConfig(
|
||||
provider="databricks",
|
||||
config={
|
||||
"workspace_url": "https://my-workspace.databricks.com",
|
||||
"access_token": "my-token",
|
||||
"endpoint_name": "my-endpoint",
|
||||
"catalog": "cat",
|
||||
"schema": "sch",
|
||||
"table_name": "tbl",
|
||||
"index_type": "DIRECT_ACCESS",
|
||||
"embedding_dimension": 4,
|
||||
"warehouse_name": "test-warehouse",
|
||||
},
|
||||
)
|
||||
instance = VectorStoreFactory.create("databricks", vs_config.config)
|
||||
assert isinstance(instance, Databricks)
|
||||
assert "embedding" in instance.column_names
|
||||
|
||||
|
||||
def test_e2e_old_docs_config_rejected():
|
||||
"""End-to-end: Config from old docs (with index_name, source_table_name) is rejected at validation."""
|
||||
from mem0.vector_stores.configs import VectorStoreConfig
|
||||
|
||||
with pytest.raises(ValueError, match="Extra fields not allowed"):
|
||||
VectorStoreConfig(
|
||||
provider="databricks",
|
||||
config={
|
||||
"workspace_url": "https://my-workspace.databricks.com",
|
||||
"access_token": "my-token",
|
||||
"endpoint_name": "my-endpoint",
|
||||
"index_name": "catalog.schema.index_name",
|
||||
"source_table_name": "catalog.schema.source_table",
|
||||
"embedding_dimension": 1536,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_crud_lifecycle_delta_sync(mock_workspace_client):
|
||||
"""End-to-end CRUD lifecycle: insert → search → get → list → update → delete."""
|
||||
from mem0.vector_stores.configs import VectorStoreConfig
|
||||
from mem0.utils.factory import VectorStoreFactory
|
||||
|
||||
vs_config = VectorStoreConfig(
|
||||
provider="databricks",
|
||||
config={
|
||||
"workspace_url": "https://test",
|
||||
"access_token": "tok",
|
||||
"endpoint_name": "ep",
|
||||
"catalog": "cat",
|
||||
"schema": "sch",
|
||||
"table_name": "tbl",
|
||||
"warehouse_name": "test-warehouse",
|
||||
"embedding_model_endpoint_name": "emb-ep",
|
||||
},
|
||||
)
|
||||
db = VectorStoreFactory.create("databricks", vs_config.config)
|
||||
|
||||
# INSERT
|
||||
db.insert(
|
||||
vectors=[[0.1, 0.2]],
|
||||
payloads=[{"data": "test memory", "user_id": "u1", "hash": "h1"}],
|
||||
ids=["mem-001"],
|
||||
)
|
||||
insert_sql = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs["statement"]
|
||||
assert "INSERT INTO cat.sch.tbl" in insert_sql
|
||||
assert "mem-001" in insert_sql
|
||||
|
||||
# SEARCH
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
|
||||
result=SimpleNamespace(
|
||||
data_array=[["mem-001", "h1", None, None, "u1", "test memory", None, None, None, 0.95]]
|
||||
)
|
||||
)
|
||||
results = db.search(query="test", vectors=None, limit=5)
|
||||
assert len(results) == 1
|
||||
assert results[0].id == "mem-001"
|
||||
assert results[0].payload["data"] == "test memory"
|
||||
search_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert search_kwargs["query_text"] == "test"
|
||||
|
||||
# GET
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = QueryVectorIndexResponse(
|
||||
manifest=ResultManifest(columns=[
|
||||
ColumnInfo(name="memory_id"), ColumnInfo(name="hash"),
|
||||
ColumnInfo(name="agent_id"), ColumnInfo(name="run_id"),
|
||||
ColumnInfo(name="user_id"), ColumnInfo(name="memory"),
|
||||
ColumnInfo(name="metadata"), ColumnInfo(name="created_at"),
|
||||
ColumnInfo(name="updated_at"),
|
||||
]),
|
||||
result=ResultData(data_array=[["mem-001", "h1", None, None, "u1", "test memory", None, None, None]]),
|
||||
)
|
||||
got = db.get("mem-001")
|
||||
assert got.id == "mem-001"
|
||||
assert got.payload["data"] == "test memory"
|
||||
get_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_text" in get_kwargs
|
||||
assert "query_vector" not in get_kwargs
|
||||
|
||||
# LIST
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
|
||||
result=SimpleNamespace(
|
||||
data_array=[["mem-001", "h1", None, None, "u1", "test memory", None, None, None]]
|
||||
)
|
||||
)
|
||||
listed = db.list(filters={"user_id": "u1"}, limit=10)
|
||||
assert len(listed[0]) == 1
|
||||
assert listed[0][0].id == "mem-001"
|
||||
list_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_text" in list_kwargs
|
||||
assert list_kwargs["num_results"] == 10
|
||||
|
||||
# UPDATE
|
||||
db.update(vector_id="mem-001", payload={"memory": "updated memory"})
|
||||
update_sql = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs["statement"]
|
||||
assert "UPDATE cat.sch.tbl" in update_sql
|
||||
assert "mem-001" in update_sql
|
||||
assert "updated memory" in update_sql
|
||||
|
||||
# DELETE
|
||||
db.delete("mem-001")
|
||||
delete_sql = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs["statement"]
|
||||
assert "DELETE FROM cat.sch.tbl" in delete_sql
|
||||
assert "mem-001" in delete_sql
|
||||
|
||||
|
||||
def test_e2e_crud_lifecycle_direct_access(mock_workspace_client):
|
||||
"""End-to-end CRUD lifecycle for DIRECT_ACCESS: insert → search → get → list."""
|
||||
from mem0.vector_stores.configs import VectorStoreConfig
|
||||
from mem0.utils.factory import VectorStoreFactory
|
||||
|
||||
mock_workspace_client.tables.exists.return_value = SimpleNamespace(table_exists=True)
|
||||
|
||||
vs_config = VectorStoreConfig(
|
||||
provider="databricks",
|
||||
config={
|
||||
"workspace_url": "https://test",
|
||||
"access_token": "tok",
|
||||
"endpoint_name": "ep",
|
||||
"catalog": "cat",
|
||||
"schema": "sch",
|
||||
"table_name": "tbl",
|
||||
"index_type": "DIRECT_ACCESS",
|
||||
"embedding_dimension": 4,
|
||||
"warehouse_name": "test-warehouse",
|
||||
"embedding_model_endpoint_name": "emb-ep",
|
||||
},
|
||||
)
|
||||
db = VectorStoreFactory.create("databricks", vs_config.config)
|
||||
assert "embedding" in db.column_names
|
||||
|
||||
# INSERT with vector
|
||||
db.insert(
|
||||
vectors=[[0.1, 0.2, 0.3, 0.4]],
|
||||
payloads=[{"data": "direct memory", "user_id": "u1", "hash": "h1"}],
|
||||
ids=["mem-da-001"],
|
||||
)
|
||||
insert_sql = mock_workspace_client.statement_execution.execute_statement.call_args.kwargs["statement"]
|
||||
assert "array(0.1, 0.2, 0.3, 0.4)" in insert_sql
|
||||
|
||||
# SEARCH with vector
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
|
||||
result=SimpleNamespace(
|
||||
data_array=[["mem-da-001", "h1", None, None, "u1", "direct memory", None, None, None, [0.1, 0.2, 0.3, 0.4], 0.9]]
|
||||
)
|
||||
)
|
||||
results = db.search(query="", vectors=[0.1, 0.2, 0.3, 0.4], limit=5)
|
||||
assert len(results) == 1
|
||||
search_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_vector" in search_kwargs
|
||||
assert "query_text" not in search_kwargs
|
||||
|
||||
# GET — must use query_vector for DIRECT_ACCESS
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = QueryVectorIndexResponse(
|
||||
manifest=ResultManifest(columns=[
|
||||
ColumnInfo(name="memory_id"), ColumnInfo(name="hash"),
|
||||
ColumnInfo(name="agent_id"), ColumnInfo(name="run_id"),
|
||||
ColumnInfo(name="user_id"), ColumnInfo(name="memory"),
|
||||
ColumnInfo(name="metadata"), ColumnInfo(name="created_at"),
|
||||
ColumnInfo(name="updated_at"), ColumnInfo(name="embedding"),
|
||||
]),
|
||||
result=ResultData(data_array=[["mem-da-001", "h1", None, None, "u1", "direct memory", None, None, None, [0.1, 0.2, 0.3, 0.4]]]),
|
||||
)
|
||||
got = db.get("mem-da-001")
|
||||
assert got.id == "mem-da-001"
|
||||
get_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_vector" in get_kwargs
|
||||
assert get_kwargs["query_vector"] == [0.0] * 4
|
||||
|
||||
# LIST — must use query_vector for DIRECT_ACCESS
|
||||
mock_workspace_client.vector_search_indexes.query_index.return_value = SimpleNamespace(
|
||||
result=SimpleNamespace(
|
||||
data_array=[["mem-da-001", "h1", None, None, "u1", "direct memory", None, None, None, [0.1, 0.2, 0.3, 0.4]]]
|
||||
)
|
||||
)
|
||||
db.list(limit=5)
|
||||
list_kwargs = mock_workspace_client.vector_search_indexes.query_index.call_args.kwargs
|
||||
assert "query_vector" in list_kwargs
|
||||
assert "query_text" not in list_kwargs
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
"""Tests for the vector store default path fix (issue #4279).
|
||||
|
||||
Verifies that vector store defaults use platform-appropriate persistent directories
|
||||
instead of /tmp, which is ephemeral and may be inaccessible in restricted environments.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mem0.vector_stores.configs import (
|
||||
VectorStoreConfig,
|
||||
_migrate_legacy_data,
|
||||
get_default_vector_store_path,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_default_vector_store_path unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetDefaultVectorStorePath:
|
||||
"""Tests for the get_default_vector_store_path helper function."""
|
||||
|
||||
def _clean_env(self, monkeypatch):
|
||||
"""Remove all relevant env vars to get a clean baseline."""
|
||||
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
def test_default_falls_back_to_local_share(self, monkeypatch):
|
||||
"""With no env vars set, path should be ~/.local/share/mem0/{provider}."""
|
||||
self._clean_env(monkeypatch)
|
||||
result = get_default_vector_store_path("qdrant")
|
||||
expected = str(Path.home() / ".local" / "share" / "mem0" / "qdrant")
|
||||
assert result == expected
|
||||
|
||||
def test_xdg_data_home_respected(self, monkeypatch):
|
||||
"""XDG_DATA_HOME should be used as the base when set."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("XDG_DATA_HOME", "/custom/xdg/data")
|
||||
result = get_default_vector_store_path("chroma")
|
||||
assert result == "/custom/xdg/data/mem0/chroma"
|
||||
|
||||
def test_appdata_respected_on_windows(self, monkeypatch):
|
||||
"""APPDATA should be used when XDG_DATA_HOME is not set (Windows)."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming")
|
||||
result = get_default_vector_store_path("faiss")
|
||||
expected = str(Path("C:\\Users\\test\\AppData\\Roaming") / "mem0" / "faiss")
|
||||
assert result == expected
|
||||
|
||||
def test_xdg_takes_precedence_over_appdata(self, monkeypatch):
|
||||
"""XDG_DATA_HOME should take precedence over APPDATA."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("XDG_DATA_HOME", "/xdg/path")
|
||||
monkeypatch.setenv("APPDATA", "C:\\windows\\path")
|
||||
result = get_default_vector_store_path("qdrant")
|
||||
assert result == "/xdg/path/mem0/qdrant"
|
||||
|
||||
def test_mem0_data_dir_takes_highest_precedence(self, monkeypatch):
|
||||
"""MEM0_DATA_DIR should override all other env vars."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("MEM0_DATA_DIR", "/custom/mem0/data")
|
||||
monkeypatch.setenv("XDG_DATA_HOME", "/should/be/ignored")
|
||||
monkeypatch.setenv("APPDATA", "C:\\also\\ignored")
|
||||
result = get_default_vector_store_path("qdrant")
|
||||
assert result == "/custom/mem0/data/qdrant"
|
||||
|
||||
def test_mem0_data_dir_no_extra_mem0_subdir(self, monkeypatch):
|
||||
"""MEM0_DATA_DIR is already mem0-specific, so no extra 'mem0' subdir."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("MEM0_DATA_DIR", "/data/mem0")
|
||||
result = get_default_vector_store_path("qdrant")
|
||||
# Should be /data/mem0/qdrant, NOT /data/mem0/mem0/qdrant
|
||||
assert result == "/data/mem0/qdrant"
|
||||
|
||||
def test_different_providers_get_different_paths(self, monkeypatch):
|
||||
"""Each provider should get its own subdirectory."""
|
||||
self._clean_env(monkeypatch)
|
||||
qdrant_path = get_default_vector_store_path("qdrant")
|
||||
chroma_path = get_default_vector_store_path("chroma")
|
||||
faiss_path = get_default_vector_store_path("faiss")
|
||||
assert qdrant_path != chroma_path != faiss_path
|
||||
assert qdrant_path.endswith("/qdrant")
|
||||
assert chroma_path.endswith("/chroma")
|
||||
assert faiss_path.endswith("/faiss")
|
||||
|
||||
def test_path_never_contains_tmp(self, monkeypatch):
|
||||
"""Default path should never be under /tmp (the whole point of #4279)."""
|
||||
self._clean_env(monkeypatch)
|
||||
for provider in ("qdrant", "chroma", "faiss"):
|
||||
result = get_default_vector_store_path(provider)
|
||||
assert not result.startswith("/tmp"), f"Provider {provider} still defaults to /tmp: {result}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VectorStoreConfig integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVectorStoreConfigPathInjection:
|
||||
"""Tests that VectorStoreConfig correctly injects the new default path."""
|
||||
|
||||
def _clean_env(self, monkeypatch):
|
||||
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
def test_qdrant_gets_persistent_default_path(self, monkeypatch):
|
||||
"""Qdrant should get a persistent default path, not /tmp."""
|
||||
self._clean_env(monkeypatch)
|
||||
vc = VectorStoreConfig(provider="qdrant", config={})
|
||||
assert "/tmp" not in vc.config.path
|
||||
assert "mem0" in vc.config.path
|
||||
assert vc.config.path.endswith("/qdrant")
|
||||
|
||||
def test_explicit_path_not_overridden(self, monkeypatch):
|
||||
"""An explicitly provided path should be used as-is."""
|
||||
self._clean_env(monkeypatch)
|
||||
custom_path = "/my/custom/qdrant/path"
|
||||
vc = VectorStoreConfig(provider="qdrant", config={"path": custom_path})
|
||||
assert vc.config.path == custom_path
|
||||
|
||||
def test_explicit_tmp_path_still_works(self, monkeypatch):
|
||||
"""Users who explicitly want /tmp should still be able to use it."""
|
||||
self._clean_env(monkeypatch)
|
||||
vc = VectorStoreConfig(provider="qdrant", config={"path": "/tmp/qdrant"})
|
||||
assert vc.config.path == "/tmp/qdrant"
|
||||
|
||||
def test_mem0_data_dir_flows_through_config(self, monkeypatch):
|
||||
"""MEM0_DATA_DIR should be respected through VectorStoreConfig."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("MEM0_DATA_DIR", "/custom/data")
|
||||
vc = VectorStoreConfig(provider="qdrant", config={})
|
||||
assert vc.config.path == "/custom/data/qdrant"
|
||||
|
||||
def test_xdg_data_home_flows_through_config(self, monkeypatch):
|
||||
"""XDG_DATA_HOME should be respected through VectorStoreConfig."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("XDG_DATA_HOME", "/xdg/data")
|
||||
vc = VectorStoreConfig(provider="qdrant", config={})
|
||||
assert vc.config.path == "/xdg/data/mem0/qdrant"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Qdrant-specific config tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQdrantConfigDefault:
|
||||
"""Tests that QdrantConfig's own default_factory uses the new path."""
|
||||
|
||||
def _clean_env(self, monkeypatch):
|
||||
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
def test_qdrant_field_default_is_persistent(self, monkeypatch):
|
||||
"""QdrantConfig's path Field default should not be /tmp."""
|
||||
self._clean_env(monkeypatch)
|
||||
from mem0.configs.vector_stores.qdrant import QdrantConfig
|
||||
|
||||
# When path is explicitly provided, the before-validator sees it
|
||||
config = QdrantConfig(path=get_default_vector_store_path("qdrant"))
|
||||
assert "/tmp" not in config.path
|
||||
assert config.path.endswith("/qdrant")
|
||||
|
||||
def test_qdrant_via_vector_store_config_no_path(self, monkeypatch):
|
||||
"""Creating QdrantConfig via VectorStoreConfig with empty config should work."""
|
||||
self._clean_env(monkeypatch)
|
||||
vc = VectorStoreConfig(provider="qdrant", config={})
|
||||
assert vc.config.path is not None
|
||||
assert "/tmp" not in vc.config.path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChromaDB-specific config tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestChromaDbConfigDefault:
|
||||
"""Tests that ChromaDB's sentinel check works with the new default path."""
|
||||
|
||||
def _clean_env(self, monkeypatch):
|
||||
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
def test_chroma_default_path_via_vector_store_config(self, monkeypatch):
|
||||
"""ChromaDB via VectorStoreConfig should get a non-/tmp default path."""
|
||||
self._clean_env(monkeypatch)
|
||||
vc = VectorStoreConfig(provider="chroma", config={})
|
||||
assert "/tmp" not in vc.config.path
|
||||
assert vc.config.path.endswith("/chroma")
|
||||
|
||||
def test_chroma_cloud_config_strips_default_path(self, monkeypatch):
|
||||
"""ChromaDB cloud config should strip the injected default path."""
|
||||
self._clean_env(monkeypatch)
|
||||
default_path = get_default_vector_store_path("chroma")
|
||||
from mem0.configs.vector_stores.chroma import ChromaDbConfig
|
||||
|
||||
# Simulate what VectorStoreConfig does: inject default path, then create config
|
||||
config = ChromaDbConfig(
|
||||
path=default_path,
|
||||
api_key="test-key",
|
||||
tenant="test-tenant",
|
||||
)
|
||||
# When cloud config is provided and path matches default, path should be stripped
|
||||
assert config.path is None
|
||||
|
||||
def test_chroma_explicit_path_with_cloud_raises(self, monkeypatch):
|
||||
"""ChromaDB with both explicit custom path and cloud config should raise."""
|
||||
self._clean_env(monkeypatch)
|
||||
from mem0.configs.vector_stores.chroma import ChromaDbConfig
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot specify both"):
|
||||
ChromaDbConfig(
|
||||
path="/my/custom/path",
|
||||
api_key="test-key",
|
||||
tenant="test-tenant",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FAISS-specific tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFaissDefaultPath:
|
||||
"""Tests that FAISS uses the new default path."""
|
||||
|
||||
def _clean_env(self, monkeypatch):
|
||||
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
def test_faiss_default_path_via_vector_store_config(self, monkeypatch):
|
||||
"""FAISS via VectorStoreConfig should get a non-/tmp default path."""
|
||||
self._clean_env(monkeypatch)
|
||||
vc = VectorStoreConfig(provider="faiss", config={})
|
||||
assert "/tmp" not in vc.config.path
|
||||
assert vc.config.path.endswith("/faiss")
|
||||
|
||||
def test_faiss_implementation_uses_new_default(self, monkeypatch):
|
||||
"""FAISS implementation's own fallback should not use /tmp."""
|
||||
self._clean_env(monkeypatch)
|
||||
with patch("faiss.IndexFlatL2") as mock_index:
|
||||
mock_index.return_value = MagicMock()
|
||||
with patch("faiss.write_index"):
|
||||
from mem0.vector_stores.faiss import FAISS
|
||||
store = FAISS(collection_name="test")
|
||||
assert "/tmp" not in store.path
|
||||
assert "mem0" in store.path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy path migration warning tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLegacyPathMigration:
|
||||
"""Tests that legacy /tmp data is automatically migrated to the new path.
|
||||
|
||||
Uses the ``legacy_path`` parameter of ``_migrate_legacy_data`` so that every
|
||||
test exercises real filesystem operations (via pytest ``tmp_path``) instead of
|
||||
mocking ``Path`` and ``shutil.copytree``. This catches real issues like
|
||||
permission errors, path resolution, and symlink handling that mocks silently
|
||||
skip over.
|
||||
"""
|
||||
|
||||
def test_auto_migration_copies_legacy_data(self, tmp_path, caplog):
|
||||
"""When legacy dir has data and new path doesn't exist, data is copied."""
|
||||
legacy = tmp_path / "legacy_qdrant"
|
||||
legacy.mkdir()
|
||||
(legacy / "collection").mkdir()
|
||||
(legacy / "collection" / "data.bin").write_bytes(b"\x00real-vector-data")
|
||||
|
||||
new = tmp_path / "new_qdrant"
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="mem0.vector_stores.configs"):
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||
|
||||
# Data was actually copied
|
||||
assert (new / "collection" / "data.bin").exists()
|
||||
assert (new / "collection" / "data.bin").read_bytes() == b"\x00real-vector-data"
|
||||
# Sentinel was written
|
||||
assert (new / ".migration_complete").exists()
|
||||
# Legacy data is untouched (copy, not move)
|
||||
assert (legacy / "collection" / "data.bin").exists()
|
||||
assert "migrated" in caplog.text.lower()
|
||||
|
||||
def test_no_migration_when_legacy_path_missing(self, tmp_path):
|
||||
"""No-op when the legacy directory doesn't exist at all."""
|
||||
nonexistent = tmp_path / "does_not_exist"
|
||||
new = tmp_path / "new"
|
||||
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(nonexistent))
|
||||
|
||||
assert not new.exists()
|
||||
|
||||
def test_no_migration_when_legacy_path_empty_dir(self, tmp_path):
|
||||
"""No-op when the legacy directory exists but is empty."""
|
||||
legacy = tmp_path / "empty_qdrant"
|
||||
legacy.mkdir()
|
||||
|
||||
new = tmp_path / "new"
|
||||
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||
|
||||
assert not new.exists()
|
||||
|
||||
def test_no_migration_when_legacy_path_is_symlink(self, tmp_path):
|
||||
"""Refuse to migrate if legacy path is a symlink (prevents symlink attacks)."""
|
||||
real_dir = tmp_path / "real_data"
|
||||
real_dir.mkdir()
|
||||
(real_dir / "secret.key").write_text("sensitive")
|
||||
|
||||
symlink = tmp_path / "symlink_qdrant"
|
||||
symlink.symlink_to(real_dir)
|
||||
|
||||
new = tmp_path / "new"
|
||||
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(symlink))
|
||||
|
||||
assert not new.exists()
|
||||
|
||||
def test_no_migration_when_new_path_already_has_data(self, tmp_path, caplog):
|
||||
"""Don't clobber existing data at the new path."""
|
||||
legacy = tmp_path / "legacy"
|
||||
legacy.mkdir()
|
||||
(legacy / "old.bin").write_text("legacy data")
|
||||
|
||||
new = tmp_path / "new"
|
||||
new.mkdir()
|
||||
(new / "existing.bin").write_text("keep this")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="mem0.vector_stores.configs"):
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||
|
||||
# Existing data preserved, legacy data NOT copied
|
||||
assert (new / "existing.bin").read_text() == "keep this"
|
||||
assert not (new / "old.bin").exists()
|
||||
assert "both legacy path" in caplog.text.lower()
|
||||
|
||||
def test_no_migration_when_sentinel_exists(self, tmp_path):
|
||||
"""Skip migration when .migration_complete sentinel already exists."""
|
||||
legacy = tmp_path / "legacy"
|
||||
legacy.mkdir()
|
||||
(legacy / "data.bin").write_text("legacy data")
|
||||
|
||||
new = tmp_path / "new"
|
||||
new.mkdir()
|
||||
(new / ".migration_complete").touch()
|
||||
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||
|
||||
# Legacy data was NOT copied (sentinel prevented it)
|
||||
assert not (new / "data.bin").exists()
|
||||
|
||||
def test_migration_failure_falls_back_to_warning(self, tmp_path, caplog):
|
||||
"""If copytree fails, log a warning with manual instructions."""
|
||||
legacy = tmp_path / "legacy"
|
||||
legacy.mkdir()
|
||||
(legacy / "data.bin").write_text("legacy data")
|
||||
|
||||
new = tmp_path / "new"
|
||||
|
||||
with patch("shutil.copytree", side_effect=PermissionError("denied")):
|
||||
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.configs"):
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||
|
||||
# No sentinel on failure
|
||||
assert not (new / ".migration_complete").exists()
|
||||
assert "denied" in caplog.text
|
||||
|
||||
def test_migration_is_idempotent(self, tmp_path):
|
||||
"""Running migration twice doesn't duplicate data (sentinel prevents re-run)."""
|
||||
legacy = tmp_path / "legacy"
|
||||
legacy.mkdir()
|
||||
(legacy / "data.bin").write_text("original")
|
||||
|
||||
new = tmp_path / "new"
|
||||
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||
assert (new / "data.bin").read_text() == "original"
|
||||
|
||||
# Modify legacy data after first migration
|
||||
(legacy / "data.bin").write_text("modified")
|
||||
|
||||
# Second call is a no-op because sentinel exists
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||
assert (new / "data.bin").read_text() == "original"
|
||||
|
||||
def test_migration_preserves_nested_directory_structure(self, tmp_path):
|
||||
"""Deep directory trees are faithfully copied."""
|
||||
legacy = tmp_path / "legacy"
|
||||
(legacy / "collections" / "default" / "segments").mkdir(parents=True)
|
||||
(legacy / "collections" / "default" / "segments" / "index.bin").write_bytes(b"\x01\x02")
|
||||
(legacy / "meta.json").write_text('{"version": 1}')
|
||||
|
||||
new = tmp_path / "new"
|
||||
|
||||
_migrate_legacy_data("qdrant", str(new), legacy_path=str(legacy))
|
||||
|
||||
assert (new / "meta.json").read_text() == '{"version": 1}'
|
||||
assert (new / "collections" / "default" / "segments" / "index.bin").read_bytes() == b"\x01\x02"
|
||||
|
||||
def test_no_migration_when_explicit_path_provided(self, monkeypatch, caplog):
|
||||
"""VectorStoreConfig with explicit path should not trigger migration."""
|
||||
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
with caplog.at_level(logging.WARNING, logger="mem0.vector_stores.configs"):
|
||||
VectorStoreConfig(provider="qdrant", config={"path": "/my/path"})
|
||||
|
||||
assert "migrat" not in caplog.text.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-platform / issue #4279 scenario tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIssue4279Scenarios:
|
||||
"""End-to-end tests for the specific scenarios described in issue #4279."""
|
||||
|
||||
def _clean_env(self, monkeypatch):
|
||||
for var in ("MEM0_DATA_DIR", "XDG_DATA_HOME", "APPDATA"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
def test_windows_no_tmp(self, monkeypatch):
|
||||
"""Windows doesn't have /tmp — APPDATA should be used."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming")
|
||||
for provider in ("qdrant", "chroma", "faiss"):
|
||||
path = get_default_vector_store_path(provider)
|
||||
assert not path.startswith("/tmp"), f"Windows path for {provider} still uses /tmp"
|
||||
assert "AppData" in path
|
||||
|
||||
def test_linux_systemd_with_xdg(self, monkeypatch):
|
||||
"""Linux systemd services should respect XDG_DATA_HOME."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("XDG_DATA_HOME", "/var/lib/myservice")
|
||||
path = get_default_vector_store_path("qdrant")
|
||||
assert path == "/var/lib/myservice/mem0/qdrant"
|
||||
|
||||
def test_docker_with_custom_data_dir(self, monkeypatch):
|
||||
"""Docker users can mount a volume and point MEM0_DATA_DIR to it."""
|
||||
self._clean_env(monkeypatch)
|
||||
monkeypatch.setenv("MEM0_DATA_DIR", "/data/mem0")
|
||||
path = get_default_vector_store_path("qdrant")
|
||||
assert path == "/data/mem0/qdrant"
|
||||
|
||||
def test_macos_default_is_persistent(self, monkeypatch):
|
||||
"""macOS default should be under home directory, not /tmp."""
|
||||
self._clean_env(monkeypatch)
|
||||
path = get_default_vector_store_path("qdrant")
|
||||
assert path.startswith(str(Path.home()))
|
||||
assert "/tmp" not in path
|
||||
|
||||
def test_all_affected_providers_fixed(self, monkeypatch):
|
||||
"""All providers with a path field (qdrant, chroma, faiss) should be fixed."""
|
||||
self._clean_env(monkeypatch)
|
||||
for provider in ("qdrant", "chroma", "faiss"):
|
||||
vc = VectorStoreConfig(provider=provider, config={})
|
||||
assert "/tmp" not in vc.config.path, (
|
||||
f"Provider '{provider}' still defaults to /tmp: {vc.config.path}"
|
||||
)
|
||||
@@ -48,16 +48,17 @@ def test_initalize_create_col(mongo_vector_fixture):
|
||||
search_index_model = args[0].document
|
||||
assert search_index_model == {
|
||||
"name": "test_collection_vector_index",
|
||||
"type": "vectorSearch",
|
||||
"definition": {
|
||||
"fields": [
|
||||
{
|
||||
"type": "vector",
|
||||
"path": "embedding",
|
||||
"numDimensions": 1536,
|
||||
"similarity": "cosine",
|
||||
}
|
||||
]
|
||||
"mappings": {
|
||||
"dynamic": False,
|
||||
"fields": {
|
||||
"embedding": {
|
||||
"type": "knnVector",
|
||||
"dimensions": 1536,
|
||||
"similarity": "cosine",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
assert mongo_vector.collection == mock_collection
|
||||
@@ -94,7 +95,7 @@ def test_search(mongo_vector_fixture):
|
||||
"$vectorSearch": {
|
||||
"index": "test_collection_vector_index",
|
||||
"limit": 2,
|
||||
"numCandidates": 40,
|
||||
"numCandidates": 2,
|
||||
"queryVector": query_vector,
|
||||
"path": "embedding",
|
||||
},
|
||||
@@ -203,10 +204,6 @@ def test_delete(mongo_vector_fixture):
|
||||
|
||||
|
||||
def test_update(mongo_vector_fixture):
|
||||
"""
|
||||
Test that update() uses dot notation for payload fields instead of replacing
|
||||
the entire payload document.
|
||||
"""
|
||||
mongo_vector, mock_collection, _ = mongo_vector_fixture
|
||||
vector_id = "id1"
|
||||
updated_vector = [0.3] * 1536
|
||||
@@ -216,51 +213,11 @@ def test_update(mongo_vector_fixture):
|
||||
|
||||
mongo_vector.update(vector_id=vector_id, vector=updated_vector, payload=updated_payload)
|
||||
|
||||
# Should use dot notation (payload.name) instead of full replacement (payload)
|
||||
mock_collection.update_one.assert_called_once_with(
|
||||
{"_id": vector_id}, {"$set": {"embedding": updated_vector, "payload.name": "updated_vector"}}
|
||||
{"_id": vector_id}, {"$set": {"embedding": updated_vector, "payload": updated_payload}}
|
||||
)
|
||||
|
||||
|
||||
def test_update_payload_only_uses_dot_notation(mongo_vector_fixture):
|
||||
"""
|
||||
Test that updating only the payload uses dot notation for each field,
|
||||
preserving existing metadata fields not included in the update.
|
||||
"""
|
||||
mongo_vector, mock_collection, _ = mongo_vector_fixture
|
||||
|
||||
mock_collection.update_one.return_value = MagicMock(matched_count=1)
|
||||
|
||||
mongo_vector.update(
|
||||
vector_id="id1",
|
||||
payload={"data": "updated text", "hash": "def456", "updated_at": "2025-06-01"},
|
||||
)
|
||||
|
||||
set_arg = mock_collection.update_one.call_args[0][1]["$set"]
|
||||
|
||||
# Only the specified fields should be in $set, using dot notation
|
||||
assert set_arg == {
|
||||
"payload.data": "updated text",
|
||||
"payload.hash": "def456",
|
||||
"payload.updated_at": "2025-06-01",
|
||||
}
|
||||
# "payload" key itself should NOT appear (that would replace the whole document)
|
||||
assert "payload" not in set_arg
|
||||
|
||||
|
||||
def test_update_vector_only_does_not_touch_payload(mongo_vector_fixture):
|
||||
"""Test that updating only the vector does not touch any payload fields."""
|
||||
mongo_vector, mock_collection, _ = mongo_vector_fixture
|
||||
|
||||
mock_collection.update_one.return_value = MagicMock(matched_count=1)
|
||||
|
||||
mongo_vector.update(vector_id="id1", vector=[0.7, 0.8, 0.9])
|
||||
|
||||
set_arg = mock_collection.update_one.call_args[0][1]["$set"]
|
||||
assert set_arg == {"embedding": [0.7, 0.8, 0.9]}
|
||||
assert not any(k.startswith("payload.") for k in set_arg)
|
||||
|
||||
|
||||
def test_get(mongo_vector_fixture):
|
||||
mongo_vector, mock_collection, _ = mongo_vector_fixture
|
||||
vector_id = "id1"
|
||||
|
||||
@@ -18,25 +18,25 @@ from mem0.vector_stores.opensearch import OpenSearchDB
|
||||
# Mock classes for testing OpenSearch with AWS authentication
|
||||
class MockFieldInfo:
|
||||
"""Mock pydantic field info."""
|
||||
|
||||
def __init__(self, default=None):
|
||||
self.default = default
|
||||
|
||||
|
||||
class MockOpenSearchConfig:
|
||||
|
||||
model_fields = {
|
||||
"collection_name": MockFieldInfo(default="default_collection"),
|
||||
"host": MockFieldInfo(default="localhost"),
|
||||
"port": MockFieldInfo(default=9200),
|
||||
"embedding_model_dims": MockFieldInfo(default=1536),
|
||||
"http_auth": MockFieldInfo(default=None),
|
||||
"auth": MockFieldInfo(default=None),
|
||||
"credentials": MockFieldInfo(default=None),
|
||||
"connection_class": MockFieldInfo(default=None),
|
||||
"use_ssl": MockFieldInfo(default=False),
|
||||
"verify_certs": MockFieldInfo(default=False),
|
||||
'collection_name': MockFieldInfo(default="default_collection"),
|
||||
'host': MockFieldInfo(default="localhost"),
|
||||
'port': MockFieldInfo(default=9200),
|
||||
'embedding_model_dims': MockFieldInfo(default=1536),
|
||||
'http_auth': MockFieldInfo(default=None),
|
||||
'auth': MockFieldInfo(default=None),
|
||||
'credentials': MockFieldInfo(default=None),
|
||||
'connection_class': MockFieldInfo(default=None),
|
||||
'use_ssl': MockFieldInfo(default=False),
|
||||
'verify_certs': MockFieldInfo(default=False),
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, collection_name="test_collection", include_auth=True, **kwargs):
|
||||
self.collection_name = collection_name
|
||||
self.host = kwargs.get("host", "localhost")
|
||||
@@ -44,7 +44,7 @@ class MockOpenSearchConfig:
|
||||
self.embedding_model_dims = kwargs.get("embedding_model_dims", 1536)
|
||||
self.use_ssl = kwargs.get("use_ssl", True)
|
||||
self.verify_certs = kwargs.get("verify_certs", True)
|
||||
|
||||
|
||||
if any(field in kwargs for field in ["http_auth", "auth", "credentials", "connection_class"]):
|
||||
self.http_auth = kwargs.get("http_auth")
|
||||
self.auth = kwargs.get("auth")
|
||||
@@ -63,18 +63,20 @@ class MockOpenSearchConfig:
|
||||
|
||||
|
||||
class MockAWSAuth:
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self.region = "us-east-1"
|
||||
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
raise TypeError("cannot pickle '_thread.lock' object")
|
||||
|
||||
|
||||
class MockConnectionClass:
|
||||
|
||||
def __init__(self):
|
||||
self._state = {"connected": False}
|
||||
|
||||
|
||||
def __deepcopy__(self, memo):
|
||||
raise TypeError("cannot pickle connection state")
|
||||
|
||||
@@ -253,104 +255,6 @@ class TestOpenSearchDB(unittest.TestCase):
|
||||
self.os_db.delete_col()
|
||||
self.client_mock.indices.delete.assert_called_once_with(index="test_collection")
|
||||
|
||||
def test_insert_rejects_null_vectors(self):
|
||||
"""Vectors that are None should raise ValueError before hitting OpenSearch."""
|
||||
vectors = [None]
|
||||
payloads = [{"key": "value"}]
|
||||
ids = ["id1"]
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.os_db.insert(vectors=vectors, payloads=payloads, ids=ids)
|
||||
|
||||
self.assertIn("null", str(ctx.exception).lower())
|
||||
self.client_mock.index.assert_not_called()
|
||||
|
||||
def test_insert_rejects_empty_vectors(self):
|
||||
"""Empty vector lists should raise ValueError."""
|
||||
vectors = [[]]
|
||||
payloads = [{"key": "value"}]
|
||||
ids = ["id1"]
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.os_db.insert(vectors=vectors, payloads=payloads, ids=ids)
|
||||
|
||||
self.assertIn("empty", str(ctx.exception).lower())
|
||||
self.client_mock.index.assert_not_called()
|
||||
|
||||
def test_insert_rejects_dimension_mismatch(self):
|
||||
"""Vectors with wrong dimensions should raise ValueError with a clear message."""
|
||||
vectors = [[0.1] * 768]
|
||||
payloads = [{"key": "value"}]
|
||||
ids = ["id1"]
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.os_db.insert(vectors=vectors, payloads=payloads, ids=ids)
|
||||
|
||||
error_msg = str(ctx.exception)
|
||||
self.assertIn("768", error_msg)
|
||||
self.assertIn("1536", error_msg)
|
||||
self.client_mock.index.assert_not_called()
|
||||
|
||||
def test_update_rejects_empty_vector(self):
|
||||
"""Update with an empty vector should raise ValueError."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.os_db.update("id1", vector=[], payload={"key": "value"})
|
||||
|
||||
self.assertIn("empty", str(ctx.exception).lower())
|
||||
self.client_mock.search.assert_not_called()
|
||||
|
||||
def test_update_rejects_dimension_mismatch(self):
|
||||
"""Update with wrong vector dimensions should raise ValueError."""
|
||||
vector = [0.1] * 768
|
||||
payload = {"key": "value"}
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.os_db.update("id1", vector=vector, payload=payload)
|
||||
|
||||
error_msg = str(ctx.exception)
|
||||
self.assertIn("768", error_msg)
|
||||
self.assertIn("1536", error_msg)
|
||||
self.client_mock.search.assert_not_called()
|
||||
|
||||
@patch("mem0.vector_stores.opensearch.logger")
|
||||
def test_update_error_logs_with_exc_info(self, mock_logger):
|
||||
"""Update errors should log with exc_info and re-raise."""
|
||||
mock_search_response = {"hits": {"hits": [{"_id": "doc1", "_source": {"id": "id1"}}]}}
|
||||
self.client_mock.search.return_value = mock_search_response
|
||||
self.client_mock.update.side_effect = Exception("Update failed")
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
self.os_db.update("id1", vector=[0.1] * 1536, payload={"key": "value"})
|
||||
|
||||
mock_logger.error.assert_called_once()
|
||||
call_kwargs = mock_logger.error.call_args
|
||||
self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True")
|
||||
|
||||
@patch("mem0.vector_stores.opensearch.logger")
|
||||
def test_insert_error_logs_with_exc_info(self, mock_logger):
|
||||
"""Error logging should include exc_info for full stack trace."""
|
||||
vectors = [[0.1] * 1536]
|
||||
payloads = [{"key": "value"}]
|
||||
ids = ["id1"]
|
||||
self.client_mock.index.side_effect = Exception("Connection refused")
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
self.os_db.insert(vectors=vectors, payloads=payloads, ids=ids)
|
||||
|
||||
mock_logger.error.assert_called_once()
|
||||
call_kwargs = mock_logger.error.call_args
|
||||
self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True")
|
||||
|
||||
@patch("mem0.vector_stores.opensearch.logger")
|
||||
def test_search_error_logs_with_exc_info(self, mock_logger):
|
||||
"""Search error logging should include exc_info for full stack trace."""
|
||||
self.client_mock.search.side_effect = Exception("Search failed")
|
||||
results = self.os_db.search(query="", vectors=[[0.1] * 1536], limit=5)
|
||||
self.assertEqual(results, [])
|
||||
mock_logger.error.assert_called_once()
|
||||
call_kwargs = mock_logger.error.call_args
|
||||
self.assertTrue(call_kwargs[1].get("exc_info"), "logger.error must be called with exc_info=True")
|
||||
|
||||
def test_init_with_http_auth(self):
|
||||
mock_credentials = MagicMock()
|
||||
mock_signer = AWSV4SignerAuth(mock_credentials, "us-east-1", "es")
|
||||
@@ -378,13 +282,11 @@ class TestOpenSearchDB(unittest.TestCase):
|
||||
|
||||
|
||||
# Tests for OpenSearch config deepcopy with AWS authentication (Issue #3464)
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_safe_deepcopy_config_handles_opensearch_auth(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
@patch('mem0.utils.factory.EmbedderFactory.create')
|
||||
@patch('mem0.utils.factory.VectorStoreFactory.create')
|
||||
@patch('mem0.utils.factory.LlmFactory.create')
|
||||
@patch('mem0.memory.storage.SQLiteManager')
|
||||
def test_safe_deepcopy_config_handles_opensearch_auth(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
|
||||
"""Test that _safe_deepcopy_config handles OpenSearch configs with AWS auth objects gracefully."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
@@ -393,9 +295,9 @@ def test_safe_deepcopy_config_handles_opensearch_auth(
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import _safe_deepcopy_config
|
||||
|
||||
|
||||
config_with_auth = MockOpenSearchConfig(collection_name="opensearch_test", include_auth=True)
|
||||
|
||||
|
||||
safe_config = _safe_deepcopy_config(config_with_auth)
|
||||
|
||||
# Runtime auth objects must be preserved (Issue #3580)
|
||||
@@ -404,7 +306,7 @@ def test_safe_deepcopy_config_handles_opensearch_auth(
|
||||
assert safe_config.connection_class is not None
|
||||
# Credentials dict is a sensitive secret and should be redacted
|
||||
assert safe_config.credentials is None
|
||||
|
||||
|
||||
assert safe_config.collection_name == "opensearch_test"
|
||||
assert safe_config.host == "localhost"
|
||||
assert safe_config.port == 9200
|
||||
@@ -413,10 +315,10 @@ def test_safe_deepcopy_config_handles_opensearch_auth(
|
||||
assert safe_config.verify_certs is True
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
@patch('mem0.utils.factory.EmbedderFactory.create')
|
||||
@patch('mem0.utils.factory.VectorStoreFactory.create')
|
||||
@patch('mem0.utils.factory.LlmFactory.create')
|
||||
@patch('mem0.memory.storage.SQLiteManager')
|
||||
def test_safe_deepcopy_config_normal_configs(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
|
||||
"""Test that _safe_deepcopy_config handles normal OpenSearch configs without auth."""
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
@@ -426,12 +328,12 @@ def test_safe_deepcopy_config_normal_configs(mock_sqlite, mock_llm_factory, mock
|
||||
mock_sqlite.return_value = MagicMock()
|
||||
|
||||
from mem0.memory.main import _safe_deepcopy_config
|
||||
|
||||
|
||||
config_without_auth = MockOpenSearchConfig(collection_name="normal_test", include_auth=False)
|
||||
|
||||
|
||||
safe_config = _safe_deepcopy_config(config_without_auth)
|
||||
|
||||
assert safe_config.collection_name == "normal_test"
|
||||
|
||||
assert safe_config.collection_name == "normal_test"
|
||||
assert safe_config.host == "localhost"
|
||||
assert safe_config.port == 9200
|
||||
assert safe_config.embedding_model_dims == 1536
|
||||
@@ -439,15 +341,13 @@ def test_safe_deepcopy_config_normal_configs(mock_sqlite, mock_llm_factory, mock
|
||||
assert safe_config.verify_certs is True
|
||||
|
||||
|
||||
@patch("mem0.utils.factory.EmbedderFactory.create")
|
||||
@patch("mem0.utils.factory.VectorStoreFactory.create")
|
||||
@patch("mem0.utils.factory.LlmFactory.create")
|
||||
@patch("mem0.memory.storage.SQLiteManager")
|
||||
def test_memory_initialization_opensearch_aws_auth(
|
||||
mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory
|
||||
):
|
||||
@patch('mem0.utils.factory.EmbedderFactory.create')
|
||||
@patch('mem0.utils.factory.VectorStoreFactory.create')
|
||||
@patch('mem0.utils.factory.LlmFactory.create')
|
||||
@patch('mem0.memory.storage.SQLiteManager')
|
||||
def test_memory_initialization_opensearch_aws_auth(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory):
|
||||
"""Test that Memory initialization works with OpenSearch configs containing AWS auth."""
|
||||
|
||||
|
||||
mock_embedder_factory.return_value = MagicMock()
|
||||
mock_vector_store = MagicMock()
|
||||
mock_vector_factory.return_value = mock_vector_store
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import (
|
||||
Distance,
|
||||
FieldCondition,
|
||||
Filter,
|
||||
MatchAny,
|
||||
MatchExcept,
|
||||
MatchText,
|
||||
MatchValue,
|
||||
PointIdsList,
|
||||
PointStruct,
|
||||
Range,
|
||||
VectorParams,
|
||||
)
|
||||
|
||||
@@ -33,23 +25,6 @@ class TestQdrant(unittest.TestCase):
|
||||
on_disk=True,
|
||||
)
|
||||
|
||||
def test_local_path_on_disk_false_preserves_existing_directory(self):
|
||||
"""#4473: local path must not be removed when on_disk is False."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
sentinel = os.path.join(tmp, "sentinel")
|
||||
with open(sentinel, "w", encoding="utf-8") as f:
|
||||
f.write("keep")
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_collections.return_value = MagicMock(collections=[])
|
||||
with patch("mem0.vector_stores.qdrant.QdrantClient", return_value=mock_client):
|
||||
Qdrant(
|
||||
collection_name="c",
|
||||
embedding_model_dims=128,
|
||||
path=tmp,
|
||||
on_disk=False,
|
||||
)
|
||||
self.assertTrue(os.path.isfile(sentinel))
|
||||
|
||||
def test_create_col(self):
|
||||
self.client_mock.get_collections.return_value = MagicMock(collections=[])
|
||||
|
||||
@@ -323,456 +298,3 @@ class TestQdrant(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
del self.qdrant
|
||||
|
||||
|
||||
class TestQdrantEnhancedFilters(unittest.TestCase):
|
||||
"""Tests for enhanced metadata filtering operators (issue #3975)."""
|
||||
|
||||
def setUp(self):
|
||||
self.client_mock = MagicMock(spec=QdrantClient)
|
||||
self.qdrant = Qdrant(
|
||||
collection_name="test_collection",
|
||||
embedding_model_dims=128,
|
||||
client=self.client_mock,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _build_field_condition #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_simple_equality(self):
|
||||
"""Plain value maps to MatchValue."""
|
||||
cond = self.qdrant._build_field_condition("category", "programming")
|
||||
self.assertIsInstance(cond, FieldCondition)
|
||||
self.assertEqual(cond.key, "category")
|
||||
self.assertIsInstance(cond.match, MatchValue)
|
||||
self.assertEqual(cond.match.value, "programming")
|
||||
|
||||
def test_eq_operator(self):
|
||||
"""{\"eq\": v} maps to MatchValue."""
|
||||
cond = self.qdrant._build_field_condition("category", {"eq": "programming"})
|
||||
self.assertIsInstance(cond.match, MatchValue)
|
||||
self.assertEqual(cond.match.value, "programming")
|
||||
|
||||
def test_ne_operator(self):
|
||||
"""{\"ne\": v} maps to MatchExcept with a single-element list."""
|
||||
cond = self.qdrant._build_field_condition("category", {"ne": "spam"})
|
||||
self.assertIsInstance(cond.match, MatchExcept)
|
||||
self.assertIn("spam", getattr(cond.match, "except_", None) or getattr(cond.match, "except"))
|
||||
|
||||
def test_in_operator(self):
|
||||
"""{\"in\": [...]} maps to MatchAny."""
|
||||
cond = self.qdrant._build_field_condition("category", {"in": ["a", "b", "c"]})
|
||||
self.assertIsInstance(cond.match, MatchAny)
|
||||
self.assertEqual(cond.match.any, ["a", "b", "c"])
|
||||
|
||||
def test_nin_operator(self):
|
||||
"""{\"nin\": [...]} maps to MatchExcept."""
|
||||
cond = self.qdrant._build_field_condition("status", {"nin": ["deleted", "banned"]})
|
||||
self.assertIsInstance(cond.match, MatchExcept)
|
||||
excluded = getattr(cond.match, "except_", None) or getattr(cond.match, "except")
|
||||
self.assertIn("deleted", excluded)
|
||||
self.assertIn("banned", excluded)
|
||||
|
||||
def test_contains_operator(self):
|
||||
"""{\"contains\": x} maps to MatchText."""
|
||||
cond = self.qdrant._build_field_condition("bio", {"contains": "python"})
|
||||
self.assertIsInstance(cond.match, MatchText)
|
||||
self.assertEqual(cond.match.text, "python")
|
||||
|
||||
def test_icontains_operator(self):
|
||||
"""{\"icontains\": x} maps to MatchText (same as contains for Qdrant)."""
|
||||
cond = self.qdrant._build_field_condition("bio", {"icontains": "Python"})
|
||||
self.assertIsInstance(cond.match, MatchText)
|
||||
self.assertEqual(cond.match.text, "Python")
|
||||
|
||||
def test_gt_operator(self):
|
||||
"""{\"gt\": v} maps to Range with gt only."""
|
||||
cond = self.qdrant._build_field_condition("priority", {"gt": 5})
|
||||
self.assertIsInstance(cond.range, Range)
|
||||
self.assertEqual(cond.range.gt, 5)
|
||||
self.assertIsNone(cond.range.gte)
|
||||
self.assertIsNone(cond.range.lt)
|
||||
self.assertIsNone(cond.range.lte)
|
||||
|
||||
def test_gte_operator(self):
|
||||
"""{\"gte\": v} maps to Range with gte only."""
|
||||
cond = self.qdrant._build_field_condition("priority", {"gte": 5})
|
||||
self.assertIsInstance(cond.range, Range)
|
||||
self.assertEqual(cond.range.gte, 5)
|
||||
self.assertIsNone(cond.range.gt)
|
||||
|
||||
def test_lt_operator(self):
|
||||
"""{\"lt\": v} maps to Range with lt only."""
|
||||
cond = self.qdrant._build_field_condition("priority", {"lt": 10})
|
||||
self.assertIsInstance(cond.range, Range)
|
||||
self.assertEqual(cond.range.lt, 10)
|
||||
|
||||
def test_lte_operator(self):
|
||||
"""{\"lte\": v} maps to Range with lte only."""
|
||||
cond = self.qdrant._build_field_condition("priority", {"lte": 10})
|
||||
self.assertIsInstance(cond.range, Range)
|
||||
self.assertEqual(cond.range.lte, 10)
|
||||
|
||||
def test_range_gte_lte(self):
|
||||
"""Combined {\"gte\": x, \"lte\": y} maps to Range with both bounds."""
|
||||
cond = self.qdrant._build_field_condition("priority", {"gte": 5, "lte": 10})
|
||||
self.assertIsInstance(cond.range, Range)
|
||||
self.assertEqual(cond.range.gte, 5)
|
||||
self.assertEqual(cond.range.lte, 10)
|
||||
|
||||
def test_range_gt_lt(self):
|
||||
"""Open interval {\"gt\": x, \"lt\": y} maps to Range with both bounds."""
|
||||
cond = self.qdrant._build_field_condition("score", {"gt": 0.5, "lt": 0.9})
|
||||
self.assertIsInstance(cond.range, Range)
|
||||
self.assertEqual(cond.range.gt, 0.5)
|
||||
self.assertEqual(cond.range.lt, 0.9)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _create_filter — comparison and list operators #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_create_filter_eq(self):
|
||||
result = self.qdrant._create_filter({"category": {"eq": "programming"}})
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertEqual(len(result.must), 1)
|
||||
self.assertIsInstance(result.must[0].match, MatchValue)
|
||||
|
||||
def test_create_filter_ne(self):
|
||||
result = self.qdrant._create_filter({"category": {"ne": "spam"}})
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsInstance(result.must[0].match, MatchExcept)
|
||||
|
||||
def test_create_filter_in(self):
|
||||
result = self.qdrant._create_filter({"category": {"in": ["prog", "data"]}})
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsInstance(result.must[0].match, MatchAny)
|
||||
self.assertEqual(result.must[0].match.any, ["prog", "data"])
|
||||
|
||||
def test_create_filter_nin(self):
|
||||
result = self.qdrant._create_filter({"status": {"nin": ["deleted", "banned"]}})
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsInstance(result.must[0].match, MatchExcept)
|
||||
|
||||
def test_create_filter_gt(self):
|
||||
result = self.qdrant._create_filter({"priority": {"gt": 7}})
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsInstance(result.must[0].range, Range)
|
||||
self.assertEqual(result.must[0].range.gt, 7)
|
||||
|
||||
def test_create_filter_gte_lte_range(self):
|
||||
result = self.qdrant._create_filter({"priority": {"gte": 5, "lte": 9}})
|
||||
self.assertIsInstance(result, Filter)
|
||||
r = result.must[0].range
|
||||
self.assertEqual(r.gte, 5)
|
||||
self.assertEqual(r.lte, 9)
|
||||
|
||||
def test_create_filter_contains(self):
|
||||
result = self.qdrant._create_filter({"bio": {"contains": "python"}})
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsInstance(result.must[0].match, MatchText)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _create_filter — logical operators #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_and_operator(self):
|
||||
"""AND populates Filter.must with nested Filter objects."""
|
||||
filters = {"AND": [{"category": "programming"}, {"priority": 10}]}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertEqual(len(result.must), 2)
|
||||
# Each item is a Filter that wraps a single FieldCondition
|
||||
for item in result.must:
|
||||
self.assertIsInstance(item, Filter)
|
||||
|
||||
def test_or_operator(self):
|
||||
"""OR populates Filter.should with nested Filter objects."""
|
||||
filters = {"OR": [{"category": "programming"}, {"category": "data"}]}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertEqual(len(result.should), 2)
|
||||
self.assertIsNone(result.must)
|
||||
|
||||
def test_not_operator(self):
|
||||
"""NOT populates Filter.must_not with nested Filter objects."""
|
||||
filters = {"NOT": [{"category": "spam"}]}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertEqual(len(result.must_not), 1)
|
||||
self.assertIsNone(result.must)
|
||||
self.assertIsNone(result.should)
|
||||
|
||||
def test_mixed_field_and_and(self):
|
||||
"""Top-level field conditions and AND can coexist in must."""
|
||||
filters = {
|
||||
"user_id": "alice",
|
||||
"AND": [{"priority": {"gte": 5}}, {"category": "programming"}],
|
||||
}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
# user_id FieldCondition + 2 nested AND Filters = 3 items in must
|
||||
self.assertEqual(len(result.must), 3)
|
||||
|
||||
def test_nested_and_or(self):
|
||||
"""AND containing an OR sub-condition produces correct nesting."""
|
||||
filters = {
|
||||
"AND": [
|
||||
{"OR": [{"category": "prog"}, {"category": "data"}]},
|
||||
{"priority": {"gt": 3}},
|
||||
]
|
||||
}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertEqual(len(result.must), 2)
|
||||
# First item is a Filter with should (the OR)
|
||||
or_filter = result.must[0]
|
||||
self.assertIsInstance(or_filter, Filter)
|
||||
self.assertEqual(len(or_filter.should), 2)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Edge cases #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_empty_filter_returns_none(self):
|
||||
self.assertIsNone(self.qdrant._create_filter({}))
|
||||
self.assertIsNone(self.qdrant._create_filter(None))
|
||||
|
||||
def test_backward_compat_simple_equality(self):
|
||||
"""Plain scalar equality still works as before."""
|
||||
result = self.qdrant._create_filter({"user_id": "alice", "run_id": "r1"})
|
||||
self.assertIsInstance(result, Filter)
|
||||
keys = [c.key for c in result.must]
|
||||
self.assertIn("user_id", keys)
|
||||
self.assertIn("run_id", keys)
|
||||
|
||||
def test_backward_compat_gte_lte_range(self):
|
||||
"""Original gte+lte range filter still produces a Range condition."""
|
||||
result = self.qdrant._create_filter({"count": {"gte": 5, "lte": 10}})
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsInstance(result.must[0].range, Range)
|
||||
self.assertEqual(result.must[0].range.gte, 5)
|
||||
self.assertEqual(result.must[0].range.lte, 10)
|
||||
|
||||
def test_unknown_operator_raises_error(self):
|
||||
"""Unknown operator dict should raise ValueError, not ValidationError."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.qdrant._build_field_condition("field", {"unknown_op": "foo"})
|
||||
self.assertIn("Unsupported", str(ctx.exception))
|
||||
|
||||
def test_mixed_range_and_non_range_raises_error(self):
|
||||
"""Mixing range ops with non-range ops should raise ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.qdrant._build_field_condition("priority", {"gte": 5, "ne": 10})
|
||||
|
||||
def test_mixed_range_and_eq_raises_error(self):
|
||||
"""Mixing range ops with eq should raise ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.qdrant._build_field_condition("score", {"gt": 0.5, "eq": 1.0})
|
||||
|
||||
def test_wildcard_returns_none(self):
|
||||
"""Wildcard '*' should return None (skip filter — match any)."""
|
||||
result = self.qdrant._build_field_condition("category", "*")
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_create_filter_with_wildcard_skips_it(self):
|
||||
"""Wildcard fields should be skipped in the final filter."""
|
||||
result = self.qdrant._create_filter({"category": "*", "user_id": "alice"})
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertEqual(len(result.must), 1)
|
||||
self.assertEqual(result.must[0].key, "user_id")
|
||||
|
||||
def test_create_filter_only_wildcard_returns_none(self):
|
||||
"""Filter with only wildcard should return None."""
|
||||
result = self.qdrant._create_filter({"category": "*"})
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_and_with_non_list_raises_error(self):
|
||||
"""AND with non-list value should raise ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.qdrant._create_filter({"AND": "not_a_list"})
|
||||
|
||||
def test_or_with_non_list_raises_error(self):
|
||||
"""OR with non-list value should raise ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.qdrant._create_filter({"OR": {"category": "work"}})
|
||||
|
||||
def test_not_with_non_list_raises_error(self):
|
||||
"""NOT with non-list value should raise ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.qdrant._create_filter({"NOT": "invalid"})
|
||||
|
||||
def test_empty_and_list(self):
|
||||
"""AND with empty list should return None."""
|
||||
result = self.qdrant._create_filter({"AND": []})
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_empty_or_list(self):
|
||||
"""OR with empty list should return None."""
|
||||
result = self.qdrant._create_filter({"OR": []})
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_empty_not_list(self):
|
||||
"""NOT with empty list should return None."""
|
||||
result = self.qdrant._create_filter({"NOT": []})
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_deeply_nested_logical(self):
|
||||
"""3-level nesting: AND > OR > NOT."""
|
||||
filters = {
|
||||
"AND": [
|
||||
{
|
||||
"OR": [
|
||||
{"category": "work"},
|
||||
{"category": "personal"}
|
||||
]
|
||||
},
|
||||
{"priority": {"gte": 5}},
|
||||
{
|
||||
"NOT": [
|
||||
{"status": "archived"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertEqual(len(result.must), 3)
|
||||
|
||||
def test_boolean_equality(self):
|
||||
"""Boolean values should work with MatchValue."""
|
||||
cond = self.qdrant._build_field_condition("active", True)
|
||||
self.assertIsInstance(cond.match, MatchValue)
|
||||
self.assertEqual(cond.match.value, True)
|
||||
|
||||
def test_integer_equality(self):
|
||||
"""Integer values should work with MatchValue."""
|
||||
cond = self.qdrant._build_field_condition("count", 42)
|
||||
self.assertIsInstance(cond.match, MatchValue)
|
||||
self.assertEqual(cond.match.value, 42)
|
||||
|
||||
def test_eq_with_boolean(self):
|
||||
"""eq operator with boolean should work."""
|
||||
cond = self.qdrant._build_field_condition("active", {"eq": False})
|
||||
self.assertIsInstance(cond.match, MatchValue)
|
||||
self.assertEqual(cond.match.value, False)
|
||||
|
||||
def test_in_with_integers(self):
|
||||
"""in operator with integer list should use MatchAny."""
|
||||
cond = self.qdrant._build_field_condition("priority", {"in": [1, 2, 3]})
|
||||
self.assertIsInstance(cond.match, MatchAny)
|
||||
self.assertEqual(cond.match.any, [1, 2, 3])
|
||||
|
||||
def test_wildcard_inside_and(self):
|
||||
"""Wildcard inside AND should be skipped, other conditions preserved."""
|
||||
result = self.qdrant._create_filter({
|
||||
"AND": [{"category": "*"}, {"user_id": "alice"}]
|
||||
})
|
||||
self.assertIsInstance(result, Filter)
|
||||
# AND produces nested Filters; the wildcard sub-filter returns None and is skipped
|
||||
# Only the user_id sub-filter remains
|
||||
self.assertEqual(len(result.must), 1)
|
||||
|
||||
def test_list_value_treated_as_match_any(self):
|
||||
"""List value shorthand should be treated as in-operator (MatchAny)."""
|
||||
cond = self.qdrant._build_field_condition("tags", ["a", "b", "c"])
|
||||
self.assertIsInstance(cond.match, MatchAny)
|
||||
self.assertEqual(cond.match.any, ["a", "b", "c"])
|
||||
|
||||
def test_list_value_in_create_filter(self):
|
||||
"""List value shorthand should work through _create_filter too."""
|
||||
result = self.qdrant._create_filter({"tags": ["python", "rust"]})
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertEqual(len(result.must), 1)
|
||||
self.assertIsInstance(result.must[0].match, MatchAny)
|
||||
|
||||
def test_non_dict_item_in_and_raises_error(self):
|
||||
"""Non-dict item inside AND list should raise clear ValueError."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.qdrant._create_filter({"AND": ["not_a_dict", {"field": "val"}]})
|
||||
self.assertIn("index 0", str(ctx.exception))
|
||||
self.assertIn("must be a dict", str(ctx.exception))
|
||||
|
||||
def test_non_dict_item_in_or_raises_error(self):
|
||||
"""Non-dict item inside OR list should raise clear ValueError."""
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.qdrant._create_filter({"OR": [{"field": "val"}, 42]})
|
||||
self.assertIn("index 1", str(ctx.exception))
|
||||
|
||||
def test_empty_dict_value_raises_error(self):
|
||||
"""Empty dict as filter value should raise ValueError."""
|
||||
with self.assertRaises(ValueError):
|
||||
self.qdrant._build_field_condition("field", {})
|
||||
|
||||
def test_ne_alone_does_not_trigger_mixed_error(self):
|
||||
"""ne as sole operator should NOT trigger mixed-operator error (regression guard)."""
|
||||
cond = self.qdrant._build_field_condition("status", {"ne": "deleted"})
|
||||
self.assertIsInstance(cond.match, MatchExcept)
|
||||
|
||||
def test_eq_with_literal_star(self):
|
||||
"""eq operator with literal '*' should match the string '*', not wildcard."""
|
||||
cond = self.qdrant._build_field_condition("category", {"eq": "*"})
|
||||
self.assertIsInstance(cond.match, MatchValue)
|
||||
self.assertEqual(cond.match.value, "*")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# $or / $not normalization (Memory._process_metadata_filters injects #
|
||||
# these keys alongside the original OR/NOT) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_dollar_or_handled_as_or(self):
|
||||
"""$or injected by Memory middleware should be treated as OR."""
|
||||
filters = {
|
||||
"user_id": "alice",
|
||||
"$or": [{"category": "programming"}, {"category": "data"}],
|
||||
}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsNotNone(result.should)
|
||||
self.assertEqual(len(result.should), 2)
|
||||
|
||||
def test_dollar_not_handled_as_not(self):
|
||||
"""$not injected by Memory middleware should be treated as NOT."""
|
||||
filters = {
|
||||
"user_id": "alice",
|
||||
"$not": [{"category": "spam"}],
|
||||
}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsNotNone(result.must_not)
|
||||
self.assertEqual(len(result.must_not), 1)
|
||||
|
||||
def test_memory_search_or_shape(self):
|
||||
"""Simulate exact shape Memory.search() sends for OR filters.
|
||||
|
||||
effective_filters keeps the original OR key (via deepcopy of
|
||||
input_filters) and _process_metadata_filters adds $or with the
|
||||
same content. _create_filter should deduplicate so only the
|
||||
first occurrence (OR) is used — exactly 2 should entries.
|
||||
"""
|
||||
filters = {
|
||||
"OR": [{"category": "programming"}, {"category": "data"}],
|
||||
"user_id": "test_user",
|
||||
"$or": [{"category": "programming"}, {"category": "data"}],
|
||||
}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsNotNone(result.should)
|
||||
# Deduplicated: OR wins, $or is skipped — exactly 2 entries
|
||||
self.assertEqual(len(result.should), 2)
|
||||
|
||||
def test_memory_search_not_shape(self):
|
||||
"""Simulate exact shape Memory.search() sends for NOT filters.
|
||||
|
||||
Same deduplication as OR: NOT wins, $not is skipped.
|
||||
"""
|
||||
filters = {
|
||||
"NOT": [{"category": "spam"}],
|
||||
"user_id": "test_user",
|
||||
"$not": [{"category": "spam"}],
|
||||
}
|
||||
result = self.qdrant._create_filter(filters)
|
||||
self.assertIsInstance(result, Filter)
|
||||
self.assertIsNotNone(result.must_not)
|
||||
# Deduplicated: NOT wins, $not is skipped — exactly 1 entry
|
||||
self.assertEqual(len(result.must_not), 1)
|
||||
|
||||
@@ -1,725 +0,0 @@
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("turbopuffer", reason="turbopuffer not installed")
|
||||
|
||||
from turbopuffer.types import Row
|
||||
|
||||
from mem0.vector_stores.turbopuffer import OutputData, TurbopufferDB
|
||||
|
||||
|
||||
def _make_row(id, dist=None, vector=None, **attributes):
|
||||
"""Helper to create a turbopuffer Row with extra attributes."""
|
||||
data = {"id": id, "vector": vector}
|
||||
if dist is not None:
|
||||
data["$dist"] = dist
|
||||
data.update(attributes)
|
||||
return Row.model_validate(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
with patch("mem0.vector_stores.turbopuffer.TurbopufferClient") as MockClient:
|
||||
client_instance = MagicMock()
|
||||
namespace_instance = MagicMock()
|
||||
client_instance.namespace.return_value = namespace_instance
|
||||
MockClient.return_value = client_instance
|
||||
yield client_instance, namespace_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(mock_client):
|
||||
_, _ = mock_client
|
||||
return TurbopufferDB(
|
||||
collection_name="test_ns",
|
||||
embedding_model_dims=4,
|
||||
api_key="tpuf_test_key",
|
||||
region="gcp-us-central1",
|
||||
distance_metric="cosine_distance",
|
||||
batch_size=2,
|
||||
)
|
||||
|
||||
|
||||
# ── Initialization ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_init_with_api_key(self, mock_client):
|
||||
client_instance, namespace_instance = mock_client
|
||||
db = TurbopufferDB(
|
||||
collection_name="my_ns",
|
||||
embedding_model_dims=128,
|
||||
api_key="tpuf_key",
|
||||
region="gcp-us-central1",
|
||||
)
|
||||
assert db.collection_name == "my_ns"
|
||||
assert db.embedding_model_dims == 128
|
||||
assert db.distance_metric == "cosine_distance"
|
||||
assert db.batch_size == 100
|
||||
client_instance.namespace.assert_called_with("my_ns")
|
||||
|
||||
def test_init_with_env_var(self, mock_client):
|
||||
with patch.dict("os.environ", {"TURBOPUFFER_API_KEY": "tpuf_env_key"}):
|
||||
db = TurbopufferDB(
|
||||
collection_name="test",
|
||||
embedding_model_dims=4,
|
||||
region="gcp-us-central1",
|
||||
)
|
||||
assert db.collection_name == "test"
|
||||
|
||||
def test_init_without_api_key_raises(self, mock_client):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
# Remove TURBOPUFFER_API_KEY if it exists
|
||||
import os
|
||||
os.environ.pop("TURBOPUFFER_API_KEY", None)
|
||||
with pytest.raises(ValueError, match="API key must be provided"):
|
||||
TurbopufferDB(
|
||||
collection_name="test",
|
||||
embedding_model_dims=4,
|
||||
region="gcp-us-central1",
|
||||
)
|
||||
|
||||
def test_init_with_extra_params(self, mock_client):
|
||||
client_instance, _ = mock_client
|
||||
with patch("mem0.vector_stores.turbopuffer.TurbopufferClient") as MockClient:
|
||||
MockClient.return_value = client_instance
|
||||
TurbopufferDB(
|
||||
collection_name="test",
|
||||
embedding_model_dims=4,
|
||||
api_key="key",
|
||||
region="aws-us-west-2",
|
||||
extra_params={"compression": True},
|
||||
)
|
||||
MockClient.assert_called_with(
|
||||
api_key="key",
|
||||
region="aws-us-west-2",
|
||||
compression=True,
|
||||
)
|
||||
|
||||
def test_init_default_region(self, mock_client):
|
||||
client_instance, _ = mock_client
|
||||
with patch("mem0.vector_stores.turbopuffer.TurbopufferClient") as MockClient:
|
||||
MockClient.return_value = client_instance
|
||||
TurbopufferDB(
|
||||
collection_name="test",
|
||||
embedding_model_dims=4,
|
||||
api_key="key",
|
||||
)
|
||||
MockClient.assert_called_with(
|
||||
api_key="key",
|
||||
region="gcp-us-central1",
|
||||
)
|
||||
|
||||
|
||||
# ── create_col ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreateCol:
|
||||
def test_create_col_is_noop(self, db):
|
||||
# Should not raise or call anything
|
||||
result = db.create_col()
|
||||
assert result is None
|
||||
|
||||
def test_create_col_with_args_is_noop(self, db):
|
||||
result = db.create_col(name="x", vector_size=128, distance="cosine")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── insert ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestInsert:
|
||||
def test_insert_with_ids_and_payloads(self, db):
|
||||
vectors = [[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]]
|
||||
payloads = [{"data": "hello", "user_id": "u1"}, {"data": "world", "user_id": "u2"}]
|
||||
ids = ["id1", "id2"]
|
||||
|
||||
db.insert(vectors, payloads, ids)
|
||||
|
||||
db.namespace.write.assert_called_once()
|
||||
call_kwargs = db.namespace.write.call_args[1]
|
||||
rows = call_kwargs["upsert_rows"]
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["id"] == "id1"
|
||||
assert rows[0]["vector"] == [0.1, 0.2, 0.3, 0.4]
|
||||
assert rows[0]["data"] == "hello"
|
||||
assert rows[0]["user_id"] == "u1"
|
||||
assert rows[1]["id"] == "id2"
|
||||
assert call_kwargs["distance_metric"] == "cosine_distance"
|
||||
|
||||
def test_insert_without_ids_generates_ids(self, db):
|
||||
vectors = [[0.1, 0.2, 0.3, 0.4]]
|
||||
db.insert(vectors)
|
||||
|
||||
db.namespace.write.assert_called_once()
|
||||
call_kwargs = db.namespace.write.call_args[1]
|
||||
assert call_kwargs["upsert_rows"][0]["id"] == "0"
|
||||
|
||||
def test_insert_without_payloads(self, db):
|
||||
vectors = [[0.1, 0.2, 0.3, 0.4]]
|
||||
ids = ["id1"]
|
||||
db.insert(vectors, ids=ids)
|
||||
|
||||
call_kwargs = db.namespace.write.call_args[1]
|
||||
row = call_kwargs["upsert_rows"][0]
|
||||
assert row["id"] == "id1"
|
||||
assert row["vector"] == [0.1, 0.2, 0.3, 0.4]
|
||||
assert len(row) == 2 # only id and vector
|
||||
|
||||
def test_insert_payload_does_not_overwrite_id_or_vector(self, db):
|
||||
"""Payload with 'id' or 'vector' keys must not overwrite the actual values."""
|
||||
vectors = [[0.1, 0.2, 0.3, 0.4]]
|
||||
payloads = [{"id": "fake_id", "vector": "fake_vector", "data": "hello"}]
|
||||
ids = ["real_id"]
|
||||
db.insert(vectors, payloads, ids)
|
||||
|
||||
call_kwargs = db.namespace.write.call_args[1]
|
||||
row = call_kwargs["upsert_rows"][0]
|
||||
assert row["id"] == "real_id"
|
||||
assert row["vector"] == [0.1, 0.2, 0.3, 0.4]
|
||||
assert row["data"] == "hello"
|
||||
|
||||
def test_insert_batching(self, db):
|
||||
"""batch_size=2, so 3 vectors should produce 2 write calls."""
|
||||
vectors = [[0.1] * 4, [0.2] * 4, [0.3] * 4]
|
||||
ids = ["a", "b", "c"]
|
||||
db.insert(vectors, ids=ids)
|
||||
|
||||
assert db.namespace.write.call_count == 2
|
||||
first_call = db.namespace.write.call_args_list[0][1]
|
||||
second_call = db.namespace.write.call_args_list[1][1]
|
||||
assert len(first_call["upsert_rows"]) == 2
|
||||
assert len(second_call["upsert_rows"]) == 1
|
||||
|
||||
|
||||
# ── _parse_output ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestParseOutput:
|
||||
def test_parse_rows_with_dist_and_attributes(self, db):
|
||||
rows = [
|
||||
_make_row("id1", dist=0.1, data="hello", user_id="u1"),
|
||||
_make_row("id2", dist=0.3, data="world", user_id="u2"),
|
||||
]
|
||||
results = db._parse_output(rows)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].id == "id1"
|
||||
assert results[0].score == pytest.approx(0.9)
|
||||
assert results[0].payload == {"data": "hello", "user_id": "u1"}
|
||||
assert results[1].id == "id2"
|
||||
assert results[1].score == pytest.approx(0.7)
|
||||
assert results[1].payload == {"data": "world", "user_id": "u2"}
|
||||
|
||||
def test_parse_rows_without_dist(self, db):
|
||||
rows = [_make_row("id1", data="hello")]
|
||||
results = db._parse_output(rows)
|
||||
|
||||
assert results[0].score is None
|
||||
assert results[0].payload == {"data": "hello"}
|
||||
|
||||
def test_parse_rows_strips_vector_and_id(self, db):
|
||||
rows = [_make_row("id1", dist=0.0, vector=[1.0, 2.0, 3.0, 4.0], data="test")]
|
||||
results = db._parse_output(rows)
|
||||
|
||||
assert "vector" not in results[0].payload
|
||||
assert "id" not in results[0].payload
|
||||
assert "$dist" not in results[0].payload
|
||||
assert results[0].id == "id1"
|
||||
|
||||
def test_parse_empty_rows(self, db):
|
||||
assert db._parse_output([]) == []
|
||||
|
||||
|
||||
# ── _convert_filters ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConvertFilters:
|
||||
def test_none_filters(self, db):
|
||||
assert db._convert_filters(None) is None
|
||||
|
||||
def test_empty_filters(self, db):
|
||||
assert db._convert_filters({}) is None
|
||||
|
||||
def test_single_eq_filter(self, db):
|
||||
result = db._convert_filters({"user_id": "u1"})
|
||||
assert result == ("user_id", "Eq", "u1")
|
||||
|
||||
def test_multiple_eq_filters(self, db):
|
||||
result = db._convert_filters({"user_id": "u1", "agent_id": "a1"})
|
||||
assert result[0] == "And"
|
||||
conditions = result[1]
|
||||
assert ("user_id", "Eq", "u1") in conditions
|
||||
assert ("agent_id", "Eq", "a1") in conditions
|
||||
|
||||
def test_range_filter_gte_lte(self, db):
|
||||
result = db._convert_filters({"score": {"gte": 0.5, "lte": 1.0}})
|
||||
assert result[0] == "And"
|
||||
conditions = result[1]
|
||||
assert ("score", "Gte", 0.5) in conditions
|
||||
assert ("score", "Lte", 1.0) in conditions
|
||||
|
||||
def test_range_filter_gte_only(self, db):
|
||||
result = db._convert_filters({"score": {"gte": 0.5}})
|
||||
assert result == ("score", "Gte", 0.5)
|
||||
|
||||
def test_range_filter_lte_only(self, db):
|
||||
result = db._convert_filters({"score": {"lte": 1.0}})
|
||||
assert result == ("score", "Lte", 1.0)
|
||||
|
||||
def test_mixed_eq_and_range_filters(self, db):
|
||||
result = db._convert_filters({"user_id": "u1", "score": {"gte": 0.5}})
|
||||
assert result[0] == "And"
|
||||
conditions = result[1]
|
||||
assert ("user_id", "Eq", "u1") in conditions
|
||||
assert ("score", "Gte", 0.5) in conditions
|
||||
|
||||
|
||||
# ── search ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSearch:
|
||||
def test_search_basic(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = [
|
||||
_make_row("id1", dist=0.1, data="hello"),
|
||||
_make_row("id2", dist=0.2, data="world"),
|
||||
]
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
results = db.search("test query", [0.1, 0.2, 0.3, 0.4], limit=2)
|
||||
|
||||
db.namespace.query.assert_called_once_with(
|
||||
rank_by=("vector", "ANN", [0.1, 0.2, 0.3, 0.4]),
|
||||
top_k=2,
|
||||
include_attributes=True,
|
||||
)
|
||||
assert len(results) == 2
|
||||
assert results[0].id == "id1"
|
||||
assert results[0].score == pytest.approx(0.9)
|
||||
|
||||
def test_search_with_filters(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = [_make_row("id1", dist=0.1, data="hello")]
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
results = db.search(
|
||||
"query", [0.1, 0.2, 0.3, 0.4], limit=5, filters={"user_id": "u1"}
|
||||
)
|
||||
|
||||
call_kwargs = db.namespace.query.call_args[1]
|
||||
assert call_kwargs["filters"] == ("user_id", "Eq", "u1")
|
||||
assert len(results) == 1
|
||||
|
||||
def test_search_no_results(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = None
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
results = db.search("query", [0.1, 0.2, 0.3, 0.4])
|
||||
assert results == []
|
||||
|
||||
def test_search_empty_rows(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = []
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
results = db.search("query", [0.1, 0.2, 0.3, 0.4])
|
||||
assert results == []
|
||||
|
||||
|
||||
# ── delete ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_by_string_id(self, db):
|
||||
db.delete("id1")
|
||||
db.namespace.write.assert_called_once_with(deletes=["id1"])
|
||||
|
||||
def test_delete_by_int_id(self, db):
|
||||
db.delete(123)
|
||||
db.namespace.write.assert_called_once_with(deletes=["123"])
|
||||
|
||||
|
||||
# ── update ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestUpdate:
|
||||
def test_update_with_vector_and_payload(self, db):
|
||||
db.update("id1", vector=[0.5, 0.6, 0.7, 0.8], payload={"data": "updated"})
|
||||
|
||||
db.namespace.write.assert_called_once()
|
||||
call_kwargs = db.namespace.write.call_args[1]
|
||||
row = call_kwargs["upsert_rows"][0]
|
||||
assert row["id"] == "id1"
|
||||
assert row["vector"] == [0.5, 0.6, 0.7, 0.8]
|
||||
assert row["data"] == "updated"
|
||||
assert call_kwargs["distance_metric"] == "cosine_distance"
|
||||
|
||||
def test_update_vector_only(self, db):
|
||||
db.update("id1", vector=[0.5, 0.6, 0.7, 0.8])
|
||||
|
||||
call_kwargs = db.namespace.write.call_args[1]
|
||||
row = call_kwargs["upsert_rows"][0]
|
||||
assert row["id"] == "id1"
|
||||
assert row["vector"] == [0.5, 0.6, 0.7, 0.8]
|
||||
assert "data" not in row
|
||||
|
||||
def test_update_payload_only_uses_patch(self, db):
|
||||
"""Payload-only updates should use patch_rows, not upsert_rows."""
|
||||
db.update("id1", vector=None, payload={"data": "patched", "user_id": "u1"})
|
||||
|
||||
call_kwargs = db.namespace.write.call_args[1]
|
||||
row = call_kwargs["patch_rows"][0]
|
||||
assert row["id"] == "id1"
|
||||
assert row["data"] == "patched"
|
||||
assert row["user_id"] == "u1"
|
||||
assert "upsert_rows" not in call_kwargs
|
||||
|
||||
def test_update_payload_does_not_overwrite_id(self, db):
|
||||
"""Payload with an 'id' key must not overwrite the actual vector ID."""
|
||||
db.update("real_id", vector=None, payload={"id": "fake_id", "data": "test"})
|
||||
call_kwargs = db.namespace.write.call_args[1]
|
||||
row = call_kwargs["patch_rows"][0]
|
||||
assert row["id"] == "real_id"
|
||||
|
||||
def test_update_nothing(self, db):
|
||||
"""Neither vector nor payload: should not call write."""
|
||||
db.update("id1")
|
||||
db.namespace.write.assert_not_called()
|
||||
|
||||
|
||||
# ── get ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGet:
|
||||
def test_get_found(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = [_make_row("id1", dist=0.0, data="hello", user_id="u1")]
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
result = db.get("id1")
|
||||
|
||||
db.namespace.query.assert_called_once_with(
|
||||
top_k=1,
|
||||
rank_by=("vector", "ANN", [0.0, 0.0, 0.0, 0.0]),
|
||||
filters=("id", "Eq", "id1"),
|
||||
include_attributes=True,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.id == "id1"
|
||||
assert result.payload["data"] == "hello"
|
||||
assert result.payload["user_id"] == "u1"
|
||||
|
||||
def test_get_not_found(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = []
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
result = db.get("nonexistent")
|
||||
assert result is None
|
||||
|
||||
def test_get_none_rows(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = None
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
result = db.get("id1")
|
||||
assert result is None
|
||||
|
||||
def test_get_handles_exception(self, db):
|
||||
db.namespace.query.side_effect = Exception("API error")
|
||||
result = db.get("id1")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── list_cols ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListCols:
|
||||
def test_list_cols(self, db):
|
||||
ns1 = MagicMock()
|
||||
ns1.id = "ns1"
|
||||
ns2 = MagicMock()
|
||||
ns2.id = "ns2"
|
||||
db.client.namespaces.return_value = [ns1, ns2]
|
||||
|
||||
result = db.list_cols()
|
||||
assert len(result) == 2
|
||||
db.client.namespaces.assert_called_once()
|
||||
|
||||
|
||||
# ── delete_col ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeleteCol:
|
||||
def test_delete_col(self, db):
|
||||
db.delete_col()
|
||||
db.namespace.delete_all.assert_called_once()
|
||||
|
||||
def test_delete_col_handles_error(self, db):
|
||||
db.namespace.delete_all.side_effect = Exception("API error")
|
||||
# Should not raise
|
||||
db.delete_col()
|
||||
|
||||
|
||||
# ── col_info ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestColInfo:
|
||||
def test_col_info(self, db):
|
||||
mock_metadata = MagicMock()
|
||||
mock_metadata.approx_row_count = 42
|
||||
mock_metadata.approx_logical_bytes = 1024
|
||||
mock_metadata.created_at = datetime(2025, 1, 1)
|
||||
mock_metadata.updated_at = datetime(2025, 6, 1)
|
||||
db.namespace.metadata.return_value = mock_metadata
|
||||
|
||||
info = db.col_info()
|
||||
assert info["name"] == "test_ns"
|
||||
assert info["approx_row_count"] == 42
|
||||
assert info["approx_logical_bytes"] == 1024
|
||||
|
||||
def test_col_info_handles_error(self, db):
|
||||
db.namespace.metadata.side_effect = Exception("not found")
|
||||
info = db.col_info()
|
||||
assert info == {"name": "test_ns"}
|
||||
|
||||
|
||||
# ── list ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_list_returns_wrapped_format(self, db):
|
||||
"""list() must return [[results]] for compatibility with main.py."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = [
|
||||
_make_row("id1", dist=0.1, data="hello"),
|
||||
_make_row("id2", dist=0.2, data="world"),
|
||||
]
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
result = db.list()
|
||||
|
||||
# Must be wrapped: result[0] is the actual list
|
||||
assert isinstance(result, list)
|
||||
assert isinstance(result[0], list)
|
||||
assert len(result[0]) == 2
|
||||
assert result[0][0].id == "id1"
|
||||
assert result[0][1].id == "id2"
|
||||
|
||||
def test_list_with_filters(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = [_make_row("id1", dist=0.1, data="hello")]
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
db.list(filters={"user_id": "u1"}, limit=50)
|
||||
|
||||
call_kwargs = db.namespace.query.call_args[1]
|
||||
assert call_kwargs["filters"] == ("user_id", "Eq", "u1")
|
||||
assert call_kwargs["top_k"] == 50
|
||||
|
||||
def test_list_empty(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = None
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
result = db.list()
|
||||
assert result == [[]]
|
||||
|
||||
def test_list_uses_zero_vector(self, db):
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = []
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
db.list()
|
||||
call_kwargs = db.namespace.query.call_args[1]
|
||||
assert call_kwargs["rank_by"] == ("vector", "ANN", [0.0, 0.0, 0.0, 0.0])
|
||||
|
||||
def test_list_compatible_with_main_py_get_all(self, db):
|
||||
"""Simulate how main.py _get_all_from_vector_store unwraps list()."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = [_make_row("id1", dist=0.1, data="hello")]
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
memories_result = db.list(filters={"user_id": "u1"})
|
||||
|
||||
# Reproduce main.py unwrapping logic
|
||||
first_element = memories_result[0]
|
||||
if isinstance(first_element, (list, tuple)):
|
||||
actual_memories = first_element
|
||||
else:
|
||||
actual_memories = memories_result
|
||||
|
||||
assert isinstance(actual_memories, list)
|
||||
assert len(actual_memories) == 1
|
||||
assert actual_memories[0].id == "id1"
|
||||
assert actual_memories[0].payload["data"] == "hello"
|
||||
|
||||
def test_list_compatible_with_main_py_delete_all(self, db):
|
||||
"""Simulate how main.py delete_all uses list()[0]."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.rows = [
|
||||
_make_row("id1", dist=0.1, data="hello"),
|
||||
_make_row("id2", dist=0.2, data="world"),
|
||||
]
|
||||
db.namespace.query.return_value = mock_response
|
||||
|
||||
result = db.list(filters={"user_id": "u1"})
|
||||
memories = result[0]
|
||||
|
||||
assert isinstance(memories, list)
|
||||
for mem in memories:
|
||||
assert hasattr(mem, "id")
|
||||
assert hasattr(mem, "payload")
|
||||
|
||||
|
||||
# ── count ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCount:
|
||||
def test_count(self, db):
|
||||
mock_metadata = MagicMock()
|
||||
mock_metadata.approx_row_count = 100
|
||||
db.namespace.metadata.return_value = mock_metadata
|
||||
|
||||
assert db.count() == 100
|
||||
|
||||
def test_count_handles_error(self, db):
|
||||
db.namespace.metadata.side_effect = Exception("error")
|
||||
assert db.count() == 0
|
||||
|
||||
|
||||
# ── reset ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestReset:
|
||||
def test_reset_calls_delete_all(self, db):
|
||||
db.reset()
|
||||
db.namespace.delete_all.assert_called_once()
|
||||
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_config_valid(self):
|
||||
with patch.dict("os.environ", {"TURBOPUFFER_API_KEY": "key"}):
|
||||
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
|
||||
|
||||
config = TurbopufferConfig()
|
||||
assert config.collection_name == "mem0"
|
||||
assert config.embedding_model_dims == 1536
|
||||
assert config.distance_metric == "cosine_distance"
|
||||
assert config.batch_size == 100
|
||||
assert config.region == "gcp-us-central1"
|
||||
|
||||
def test_config_custom_values(self):
|
||||
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
|
||||
|
||||
config = TurbopufferConfig(
|
||||
collection_name="custom",
|
||||
embedding_model_dims=768,
|
||||
api_key="tpuf_key",
|
||||
region="aws-us-west-2",
|
||||
distance_metric="euclidean_squared",
|
||||
batch_size=50,
|
||||
)
|
||||
assert config.collection_name == "custom"
|
||||
assert config.embedding_model_dims == 768
|
||||
assert config.api_key == "tpuf_key"
|
||||
assert config.region == "aws-us-west-2"
|
||||
assert config.distance_metric == "euclidean_squared"
|
||||
assert config.batch_size == 50
|
||||
|
||||
def test_config_rejects_extra_fields(self):
|
||||
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
|
||||
|
||||
with pytest.raises(ValueError, match="Extra fields not allowed"):
|
||||
TurbopufferConfig(api_key="key", unknown_field="value")
|
||||
|
||||
def test_config_requires_api_key(self):
|
||||
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
import os
|
||||
os.environ.pop("TURBOPUFFER_API_KEY", None)
|
||||
with pytest.raises(ValueError, match="api_key"):
|
||||
TurbopufferConfig()
|
||||
|
||||
def test_config_accepts_env_var(self):
|
||||
from mem0.configs.vector_stores.turbopuffer import TurbopufferConfig
|
||||
|
||||
with patch.dict("os.environ", {"TURBOPUFFER_API_KEY": "env_key"}):
|
||||
config = TurbopufferConfig()
|
||||
assert config.api_key is None # not set explicitly, but env var is present
|
||||
|
||||
|
||||
# ── Factory Registration ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFactoryRegistration:
|
||||
def test_vector_store_factory_has_turbopuffer(self):
|
||||
from mem0.utils.factory import VectorStoreFactory
|
||||
|
||||
assert "turbopuffer" in VectorStoreFactory.provider_to_class
|
||||
assert VectorStoreFactory.provider_to_class["turbopuffer"] == "mem0.vector_stores.turbopuffer.TurbopufferDB"
|
||||
|
||||
def test_vector_store_config_has_turbopuffer(self):
|
||||
from mem0.vector_stores.configs import VectorStoreConfig
|
||||
|
||||
config = VectorStoreConfig.__private_attributes__["_provider_configs"].default
|
||||
assert "turbopuffer" in config
|
||||
assert config["turbopuffer"] == "TurbopufferConfig"
|
||||
|
||||
def test_config_validation_pipeline(self):
|
||||
"""Test that VectorStoreConfig correctly resolves turbopuffer config."""
|
||||
from mem0.vector_stores.configs import VectorStoreConfig
|
||||
|
||||
with patch.dict("os.environ", {"TURBOPUFFER_API_KEY": "key"}):
|
||||
config = VectorStoreConfig(
|
||||
provider="turbopuffer",
|
||||
config={"collection_name": "test", "region": "gcp-us-central1"},
|
||||
)
|
||||
assert config.config.collection_name == "test"
|
||||
assert config.config.region == "gcp-us-central1"
|
||||
assert config.config.embedding_model_dims == 1536
|
||||
|
||||
|
||||
# ── OutputData ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestOutputData:
|
||||
def test_output_data_has_required_fields(self):
|
||||
od = OutputData(id="test", score=0.9, payload={"data": "hello"})
|
||||
assert od.id == "test"
|
||||
assert od.score == 0.9
|
||||
assert od.payload == {"data": "hello"}
|
||||
|
||||
def test_output_data_nullable_fields(self):
|
||||
od = OutputData(id=None, score=None, payload=None)
|
||||
assert od.id is None
|
||||
assert od.score is None
|
||||
assert od.payload is None
|
||||
|
||||
def test_output_data_payload_access_pattern(self):
|
||||
"""Test the exact access pattern used by main.py."""
|
||||
od = OutputData(
|
||||
id="mem1",
|
||||
score=0.95,
|
||||
payload={
|
||||
"data": "User likes sci-fi movies",
|
||||
"hash": "abc123",
|
||||
"created_at": "2025-01-01",
|
||||
"updated_at": "2025-06-01",
|
||||
"user_id": "alice",
|
||||
"agent_id": "agent1",
|
||||
},
|
||||
)
|
||||
assert od.payload.get("data", "") == "User likes sci-fi movies"
|
||||
assert od.payload.get("hash") == "abc123"
|
||||
assert od.payload.get("created_at") == "2025-01-01"
|
||||
assert od.payload.get("user_id") == "alice"
|
||||
assert od.payload.get("run_id") is None # not present, should return None
|
||||
Reference in New Issue
Block a user