Compare commits

..
Author SHA1 Message Date
KartikandGitHub 98dbf90864 chore: update changelog, bump SDK versions to Python 2.0.8 and TypeScript 3.0.10 (#5825) 2026-06-24 20:21:06 +05:30
David ShraderandGitHub 6ddf1669f4 fix(vector_stores): point fastembed-missing warning at mem0ai[extras] (#5622) 2026-06-24 14:47:02 +05:30
David ShraderandGitHub d3d2e89fd5 fix(llms): skip JSON response_format for Groq compound models (#5513) 2026-06-24 14:42:22 +05:30
XiaojuandGitHub 43175d85f2 fix: preserve empty Azure AI Search update values (#5524)
Signed-off-by: Xiaoju <xiaojuchh@gmail.com>
2026-06-24 14:41:55 +05:30
KartikandGitHub 661ecb9f0f docs(hermes): update for v3 API, OSS self-hosted mode, and new tools (#5807) 2026-06-24 11:59:13 +05:30
冯基魁andGitHub 09f181c577 fix(server): fetch filtered dashboard memories beyond default page (#5753) 2026-06-24 11:49:06 +05:30
3497f26a00 fix: add auto_refresh option for OpenSearch Serverless compatibility (#3893)
Co-authored-by: Zaid Malhis <Malhis@users.noreply.github.com>
2026-06-24 11:48:52 +05:30
BartokandGitHub e9c0547423 fix(ts-sdk): preserve customCategories names through key conversion (#5741) 2026-06-24 11:15:19 +05:30
Yash SinghandGitHub 25bc1b7426 fix(memory): guard against malformed image_url in parse_vision_messages (#5631) 2026-06-24 11:10:36 +05:30
Hrushikesh YadavandGitHub fee344db85 fix(chroma): wrap scalar vector_id in list for delete() (#5703) 2026-06-24 10:54:36 +05:30
BartokandGitHub b611f69381 fix(chroma): wrap update() ids/embeddings/metadatas in lists (#5757) 2026-06-24 10:54:15 +05:30
Muhammad FurqanandGitHub c2862831db fix(reranker): log reranking failures instead of swallowing them silently (#5717) 2026-06-24 10:44:37 +05:30
34 changed files with 710 additions and 235 deletions
+70
View File
@@ -7,6 +7,58 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-06-24" description="v2.0.8">
**New Features:**
- **Embeddings:** Add native `embed_batch` to five embedders — LM Studio, Together, HuggingFace, Vertex AI, and Google GenAI — for batched embedding requests ([#5609](https://github.com/mem0ai/mem0/pull/5609))
**Bug Fixes:**
- **Core:** Guard against malformed `image_url` entries in `parse_vision_messages` to prevent crashes ([#5631](https://github.com/mem0ai/mem0/pull/5631))
- **Core:** Return `attributed_to` from `get()`, `get_all()`, and `search()` ([#5629](https://github.com/mem0ai/mem0/pull/5629))
- **Core:** Fix `reset()` only dropping the history table and leaving stale messages behind ([#5541](https://github.com/mem0ai/mem0/pull/5541))
- **Core:** Guard against an entity `embed_batch` count mismatch in the v3 add pipeline ([#5604](https://github.com/mem0ai/mem0/pull/5604))
- **Core:** Fix an async `delete_all` race condition that corrupted the entity store's `linked_memory_ids` ([#5553](https://github.com/mem0ai/mem0/pull/5553))
- **LLMs:** Skip the JSON `response_format` for Groq compound models that reject it ([#5513](https://github.com/mem0ai/mem0/pull/5513))
- **LLMs:** Preserve reasoning fields during base-to-provider config conversion ([#5638](https://github.com/mem0ai/mem0/pull/5638))
- **LLMs:** Pass the configured `anthropic_base_url` to the Anthropic client ([#5626](https://github.com/mem0ai/mem0/pull/5626))
- **LLMs:** Stop the Azure provider from mutating and corrupting caller messages during content rewrite ([#5731](https://github.com/mem0ai/mem0/pull/5731))
- **LLMs & Embeddings:** Repair HTTP proxy support for `httpx>=0.28` and preserve `proxies` in `LlmFactory` ([#5447](https://github.com/mem0ai/mem0/pull/5447))
- **Embeddings:** Forward `embedding_dims` to Titan V2 in the AWS Bedrock embedder ([#5671](https://github.com/mem0ai/mem0/pull/5671))
- **Rerankers:** Log reranking failures instead of swallowing them silently ([#5717](https://github.com/mem0ai/mem0/pull/5717))
- **Rerankers:** Clamp out-of-range LLM scores instead of mis-parsing them ([#5635](https://github.com/mem0ai/mem0/pull/5635))
- **Rerankers:** Export all five rerankers from the package root ([#5636](https://github.com/mem0ai/mem0/pull/5636))
- **Vector Stores:** Point the FastEmbed-missing warning at `mem0ai[extras]` ([#5622](https://github.com/mem0ai/mem0/pull/5622))
- **Vector Stores:** Preserve empty Azure AI Search update values ([#5524](https://github.com/mem0ai/mem0/pull/5524))
- **Vector Stores:** Add an `auto_refresh` option for OpenSearch Serverless compatibility ([#3893](https://github.com/mem0ai/mem0/pull/3893))
- **Vector Stores:** Wrap a scalar `vector_id` in a list for Chroma `delete()` ([#5703](https://github.com/mem0ai/mem0/pull/5703))
- **Vector Stores:** Wrap Chroma `update()` ids, embeddings, and metadatas in lists ([#5757](https://github.com/mem0ai/mem0/pull/5757))
- **Vector Stores:** Wrap a scalar `vector_id` in a list for Milvus `delete()` ([#5704](https://github.com/mem0ai/mem0/pull/5704))
- **Vector Stores:** Map all comparison operators in the Pinecone `_create_filter()` ([#5707](https://github.com/mem0ai/mem0/pull/5707))
- **Vector Stores:** Return `None` instead of `{}` from Chroma `_generate_where_clause` for empty filters ([#5713](https://github.com/mem0ai/mem0/pull/5713))
- **Vector Stores:** Return `[[]]` from the OpenSearch `list()` error path to honor the `list()` contract ([#5727](https://github.com/mem0ai/mem0/pull/5727))
- **Vector Stores:** Return `[[]]` from the Pinecone `list()` error path instead of a dict ([#5706](https://github.com/mem0ai/mem0/pull/5706))
- **Vector Stores:** Return `[[]]` for an uninitialized FAISS index to honor the `list()` contract ([#5725](https://github.com/mem0ai/mem0/pull/5725))
- **Vector Stores:** Wrap the MongoDB `list()` return in an outer list to match the interface contract ([#5729](https://github.com/mem0ai/mem0/pull/5729))
- **Vector Stores:** Deep-copy Redis `DEFAULT_FIELDS` so instances keep distinct dims ([#5633](https://github.com/mem0ai/mem0/pull/5633))
- **Vector Stores:** Pass the required `vectors` arg in Vertex AI `list()` and similarity search ([#5627](https://github.com/mem0ai/mem0/pull/5627))
- **Vector Stores:** Return `None` from Redis `get()` for missing IDs ([#5625](https://github.com/mem0ai/mem0/pull/5625))
- **Vector Stores:** Drop a stray `print` in Weaviate `list_cols` ([#5637](https://github.com/mem0ai/mem0/pull/5637))
- **Graph:** Keep distinct entities that share a substring prefix ([#5630](https://github.com/mem0ai/mem0/pull/5630))
- **Client:** Check the HTTP status before parsing the ping response in `_validate_api_key` ([#5639](https://github.com/mem0ai/mem0/pull/5639))
- **Server:** Fetch filtered dashboard memories beyond the default page ([#5753](https://github.com/mem0ai/mem0/pull/5753))
- **Server:** Return 404/400 instead of 502 for not-found and invalid input ([#5634](https://github.com/mem0ai/mem0/pull/5634))
- **Server:** Return 404 instead of 500 for a malformed API key id on revoke ([#5640](https://github.com/mem0ai/mem0/pull/5640))
- **Server:** Use `127.0.0.1` in the dashboard healthcheck to avoid IPv6 localhost resolution ([#5612](https://github.com/mem0ai/mem0/pull/5612))
**Improvements:**
- **Vector Stores:** Batch BM25 sparse encoding in Qdrant insert ([#5592](https://github.com/mem0ai/mem0/pull/5592))
**Security:**
- **Vector Stores:** Sanitize Milvus and Baidu filter values to prevent expression injection ([#5746](https://github.com/mem0ai/mem0/pull/5746))
- **Vector Stores:** Reject dict filter values in MongoDB to prevent NoSQL operator injection ([#5748](https://github.com/mem0ai/mem0/pull/5748))
</Update>
<Update label="2026-06-17" description="v2.0.7">
**New Features:**
@@ -1011,6 +1063,24 @@ See the [OSS v1 to v2 migration guide](https://docs.mem0.ai/migration/oss-v1-to-
<Tab title="TypeScript">
<Update label="2026-06-24" description="v3.0.10">
**Bug Fixes:**
- **Memory (OSS):** Guard against malformed `image_url` entries in `parseVisionMessages` to prevent crashes ([#5631](https://github.com/mem0ai/mem0/pull/5631))
- **Memory (OSS):** Return `attributedTo` from `get()`, `search()`, and `getAll()` ([#5675](https://github.com/mem0ai/mem0/pull/5675))
- **Memory (OSS):** Preserve message roles in the extraction input so assistant facts aren't attributed to the user ([#5643](https://github.com/mem0ai/mem0/pull/5643))
- **Memory (OSS):** Reject empty or blank messages in `Memory.add()` to prevent hallucinated memories ([#5545](https://github.com/mem0ai/mem0/pull/5545))
- **Memory (OSS):** Check `message.role` instead of `content` when detecting system messages ([#3921](https://github.com/mem0ai/mem0/pull/3921))
- **LLMs:** Honor the configured `baseURL` in `AnthropicLLM` ([#5740](https://github.com/mem0ai/mem0/pull/5740))
- **Client:** Preserve `customCategories` names through key conversion ([#5741](https://github.com/mem0ai/mem0/pull/5741))
- **Client:** Prevent hallucinated memories on an empty messages payload ([#5613](https://github.com/mem0ai/mem0/pull/5613))
- **Client:** Preserve user metadata keys across the case-conversion round-trip ([#5515](https://github.com/mem0ai/mem0/pull/5515))
**Security:**
- **Dependencies:** Upgrade `form-data` to `>=4.0.6` across pnpm workspaces to remediate CVE-2026-12143 ([#5618](https://github.com/mem0ai/mem0/pull/5618))
</Update>
<Update label="2026-06-17" description="v3.0.9">
**Bug Fixes:**
@@ -56,6 +56,30 @@ config = {
}
```
### Configuration Options
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `collection_name` | string | required | Name of the OpenSearch index |
| `host` | string | required | OpenSearch endpoint URL |
| `port` | int | 9200 | Port number |
| `http_auth` | object | None | Authentication credentials (e.g., AWSV4SignerAuth) |
| `embedding_model_dims` | int | 1536 | Dimension of embedding vectors |
| `use_ssl` | bool | False | Enable SSL/TLS connection |
| `verify_certs` | bool | False | Verify SSL certificates |
| `auto_refresh` | bool | False | Automatically refresh index after insert. OpenSearch refreshes every ~1 second by default, so this is rarely needed. |
<Note>
The defaults above match a local OpenSearch instance. The AWS OpenSearch Serverless
example earlier on this page intentionally overrides them with `port=443`, `use_ssl=True`,
and `verify_certs=True`, which are required when connecting to a Serverless collection.
</Note>
<Note>
For **AWS OpenSearch Serverless**, keep `auto_refresh=False` (the default).
The `indices.refresh()` API is not supported on Serverless collections.
</Note>
### Add Memories
```python
+164 -37
View File
@@ -1,35 +1,42 @@
---
title: Hermes Agent
description: "Add long-term memory to Hermes agents using Mem0 as a pluggable memory provider with automatic background sync and zero-latency prefetch."
description: "Add long-term memory to Hermes agents with Mem0, on managed Mem0 Cloud or fully self-hosted (OSS), with automatic background sync and zero-latency prefetch."
---
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent) — a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 automatically learns facts from your conversations and surfaces relevant ones before each turn — all without slowing down the chat.
Add long-term memory to [Hermes Agent](https://github.com/NousResearch/hermes-agent), a self-improving AI agent CLI by Nous Research. Hermes has a pluggable memory system, and Mem0 is one of the supported providers. Once enabled, Mem0 learns facts from your conversations and surfaces relevant ones before each turn, without slowing down the chat.
## Overview
You can run Mem0 in two ways:
Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at three key moments in every conversation turn:
- **Platform mode** (default): managed Mem0 Cloud. Add your API key and you are ready.
- **OSS mode**: fully self-hosted with your own LLM, embedder, and vector store. No data leaves your machine.
### 1. Before the Agent Responds (Prefetch)
## How It Works
When you send a message, Hermes checks if it already has cached Mem0 search results from the previous turn. If so, those memories are injected into the system prompt so the LLM can see them. This is **zero-latency** — no waiting for an API call.
Hermes runs a built-in memory system (file-based `MEMORY.md` and `USER.md`) alongside one external provider. When Mem0 is active, it works additively with the built-in system at three points in every conversation turn.
### 2. After the Agent Responds (Sync)
### 1. Before the agent responds (prefetch)
Once the LLM finishes responding, Hermes sends the `(user message, assistant response)` pair to Mem0's API in a **background thread**. Mem0's server-side LLM automatically extracts facts (e.g., "user prefers Python", "user works at Acme Corp") — you don't have to tell it what to remember.
When you send a message, Hermes checks for cached Mem0 search results from the previous turn. If they exist, those memories are injected into the system prompt so the model can see them. This is zero-latency, with no waiting on an API call.
### 3. Background Prefetch for Next Turn
### 2. After the agent responds (sync)
At the same time as sync, Hermes kicks off a background search on Mem0 to pre-load relevant memories for the next turn. By the time you type your next message, the memories are already cached.
Once the model finishes, Hermes sends the `(user message, assistant response)` pair to Mem0 in a background thread. Mem0 extracts facts automatically (for example, "user prefers Python" or "user works at Acme Corp"), so you never have to tell it what to remember. Each write is tagged with the gateway channel it came from.
### 3. Background prefetch for the next turn
At the same time, Hermes runs a background search to pre-load relevant memories for your next message. By the time you type, the results are already cached.
## Agent Tools
When Mem0 is active, the LLM gets three extra tools it can call during conversations:
When Mem0 is active, the model gets five tools it can call during a conversation:
| Tool | Description |
|------|-------------|
| `mem0_profile` | Fetch all stored memories about the user |
| `mem0_search` | Semantic search through memories (supports optional reranking via `rerank` and `top_k` parameters) |
| `mem0_conclude` | Store a specific fact verbatim — uses `infer=False` so no server-side LLM extraction happens |
| Tool | Description | Parameters |
|------|-------------|------------|
| `mem0_list` | List all stored memories, for a full overview | `page`, `page_size` (default 100, max 200) |
| `mem0_search` | Semantic search by meaning, ranked by relevance | `query` (required), `top_k` (default 10, max 50), `rerank` (default `true`, Platform mode only) |
| `mem0_add` | Store a fact verbatim, with no LLM extraction | `content` (required) |
| `mem0_update` | Update a memory's text by ID | `memory_id`, `text` (both required) |
| `mem0_delete` | Delete a memory by ID | `memory_id` (required) |
## Installation
@@ -40,17 +47,19 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri
source ~/.bashrc
```
The `mem0ai` Python package is automatically installed when you enable the Mem0 provider — no manual pip install needed.
The `mem0ai` package is installed automatically when you enable the Mem0 provider, so there is no manual pip step. OSS providers may need extra packages (for example `qdrant-client`, `psycopg2-binary`, or `ollama`), which the setup flow installs for you when you pick them.
## Setup
## Platform Setup
### Option 1: Interactive Setup Wizard (Recommended)
Platform mode uses managed Mem0 Cloud and is the fastest way to start.
### Option 1: Interactive wizard (recommended)
```bash
hermes memory setup
```
Select **mem0** as the provider and enter your Mem0 API key when prompted. The wizard writes your config to `~/.hermes/mem0.json`.
Select **mem0**, choose **Platform**, and paste your API key when prompted. The wizard writes the non-secret settings to `~/.hermes/mem0.json` and keeps the key in `~/.hermes/.env`.
<Note>Get your API key from <a href="https://app.mem0.ai?utm_source=oss&utm_medium=integration-hermes" rel="nofollow">app.mem0.ai</a>.</Note>
@@ -68,33 +77,151 @@ memory:
provider: mem0
```
That's it — Mem0 runs automatically from this point.
That's it. Mem0 runs automatically from here.
## Configuration Options
## OSS (Self-Hosted) Setup
Configuration is stored in `~/.hermes/mem0.json`. Values can also be set via environment variables.
OSS mode runs Mem0 entirely on your own infrastructure: your LLM, your embedder, and your vector store. No data is sent to Mem0 Cloud, and no Mem0 API key is required.
| Key | Env Variable | Default | Description |
|-----|-------------|---------|-------------|
| `api_key` | `MEM0_API_KEY` | — | **Required.** Mem0 Platform API key |
| `user_id` | `MEM0_USER_ID` | `hermes-user` | User identifier for scoping memories |
| `agent_id` | `MEM0_AGENT_ID` | `hermes` | Agent identifier |
| `rerank` | — | `true` | Enable reranking for memory recall |
### Interactive
```bash
hermes memory setup
# Select "mem0", then "Open Source (self-hosted)"
# Follow the prompts for LLM, embedder, and vector store
```
### With flags
```bash
hermes memory setup mem0 --mode oss \
--oss-llm openai --oss-llm-key sk-... \
--oss-vector qdrant
```
### Supported providers
| Component | Providers |
|-----------|-----------|
| LLM | `openai` (default model `gpt-5-mini`), `ollama` (local, default `llama3.1:8b`) |
| Embedder | `openai` (default `text-embedding-3-small`), `ollama` (local, default `nomic-embed-text`) |
| Vector store | `qdrant` (local path or server), `pgvector` |
### Flag reference
| Flag | Description |
|------|-------------|
| `--mode` | `platform` or `oss` |
| `--oss-llm` | LLM provider (`openai` or `ollama`, default `openai`) |
| `--oss-llm-key` | LLM API key (for `openai`) |
| `--oss-llm-model` | Override the LLM model |
| `--oss-llm-url` | LLM base URL (for `ollama` or a custom endpoint) |
| `--oss-embedder` | Embedder provider (default `openai`) |
| `--oss-embedder-key` | Embedder API key |
| `--oss-vector` | Vector store (`qdrant` or `pgvector`, default `qdrant`) |
| `--oss-vector-path` | Local Qdrant storage path |
| `--oss-vector-host`, `--oss-vector-port` | PGVector or remote Qdrant host and port |
| `--oss-vector-user`, `--oss-vector-password`, `--oss-vector-dbname` | PGVector connection details |
| `--user-id` | Canonical user identifier |
| `--dry-run` | Preview the resolved config without writing it |
## Switching Modes
You can move between Platform and OSS at any time. Run the setup command again, or edit `~/.hermes/mem0.json` directly.
```bash
# Platform to OSS
hermes memory setup mem0 --mode oss --oss-llm-key sk-...
# OSS to Platform
hermes memory setup mem0 --mode platform --api-key sk-...
# Preview without writing anything
hermes memory setup mem0 --mode oss --oss-llm-key sk-... --dry-run
```
A self-hosted `~/.hermes/mem0.json` looks like this:
```json
{
"mode": "oss",
"oss": {
"llm": {"provider": "openai", "config": {"model": "gpt-5-mini"}},
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
"vector_store": {"provider": "qdrant", "config": {"path": "~/.hermes/mem0_qdrant"}}
}
}
```
## Configuration
Behavioral settings live in `~/.hermes/mem0.json` and are written for you by `hermes memory setup`. Only the secret `MEM0_API_KEY` belongs in `~/.hermes/.env`.
| Key | Default | Description |
|-----|---------|-------------|
| `mode` | `platform` | `platform` (Mem0 Cloud) or `oss` (self-hosted) |
| `api_key` | none | Mem0 Platform API key, required in Platform mode. Stored in `.env` as `MEM0_API_KEY` |
| `user_id` | `hermes-user` | Identifier that scopes memories. See cross-channel behavior below |
| `agent_id` | `hermes` | Agent identifier attached to writes |
| `rerank` | `true` | Rerank search results for relevance (Platform mode only) |
### Cross-channel memories
Hermes can run from the CLI and from gateways like Telegram, Slack, and Discord. The `user_id` setting controls how memories are scoped across them:
- **Set a `user_id`** and it applies to every gateway, so one person gets a single merged memory store no matter where they talk to the agent.
- **Leave it unset** (or at the default `hermes-user`) and each gateway uses its own native id, keeping per-platform memories separate.
Either way, every write is tagged with `metadata.channel` (for example `telegram` or `cli`), so per-channel views are still possible at query time.
## Reliability
- **Circuit Breaker** — If Mem0's API fails 5 times in a row, Hermes stops calling it for 2 minutes, then retries. The agent keeps working fine without memory during that time.
- **Non-blocking** — All Mem0 API calls happen in background daemon threads. A slow or failed API call never blocks your conversation.
- **Thread-safe** — The Mem0 client uses lazy initialization with locking, safe for concurrent access.
- **Circuit breaker**: if Mem0 fails five times in a row, Hermes pauses calls for two minutes, then retries. The agent keeps working without memory during that window. Expected client errors, like a 404 on a missing memory id, do not count toward tripping the breaker.
- **Non-blocking**: every Mem0 call runs in a background daemon thread, so a slow or failed call never blocks your conversation.
- **Thread-safe**: the client uses lazy initialization with locking, and the background sync and prefetch threads are guarded so concurrent gateway messages cannot produce duplicate memories.
## Troubleshooting
### "Mem0 temporarily unavailable"
The circuit breaker tripped after five consecutive failures and resets after two minutes.
- **Platform mode**: check your API key and internet connection.
- **OSS mode**: make sure your vector store (Qdrant or PGVector) is running and reachable.
### OSS: vector store connection refused
```bash
# Local Qdrant: confirm the storage path is writable
ls -la ~/.hermes/mem0_qdrant
# Qdrant server: confirm it is reachable
curl http://localhost:6333/healthz
# PGVector: confirm PostgreSQL is accepting connections
pg_isready -h localhost -p 5432
```
### OSS: Ollama not reachable
```bash
curl http://localhost:11434/api/tags
```
### Memories not appearing
- `mem0_add` stores text verbatim with no extraction. Ordinary conversation turns are extracted automatically by the background sync.
- Search is semantic, so try a broader query.
- Confirm `user_id` is the same across sessions (check `~/.hermes/mem0.json`).
## Key Features
1. **Zero-Latency Recall** — Memories are prefetched in the background and cached, ready before you type
2. **Server-side Extraction** — Mem0's API automatically extracts and deduplicates facts from each exchange
3. **Non-blocking** — All API calls run in background daemon threads
4. **Fault Tolerant** — Circuit breaker ensures the agent works even if Mem0 is temporarily unreachable
5. **Additive Memory** — Works alongside Hermes' built-in file-based memory system (MEMORY.md, USER.md)
1. **Two ways to run**: managed Platform or fully self-hosted OSS, switchable at any time.
2. **Zero-latency recall**: memories are prefetched in the background and cached before you type.
3. **Automatic extraction**: Mem0 extracts and deduplicates facts from each exchange for you.
4. **Non-blocking and fault tolerant**: background threads plus a circuit breaker keep the agent responsive even when Mem0 is unreachable.
5. **Additive memory**: works alongside Hermes' built-in file memory (`MEMORY.md`, `USER.md`).
<CardGroup cols={2}>
<Card title="OpenClaw Integration" icon={<svg width="24" height="24" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="m153.5 173.5q24.62 1.46 46 13.5 12.11 8.1 17.5 21.5 0.74 2.45 0.5 5 0.09 0.81 1 1 1.48-4.9 1-10 5.04 10.48 1.5 22-9.81 27.86-35.5 42.5-26.17 14.97-56 19.5-2.77-0.4-2 1 2.86 1.27 6 1 25.64 1.53 48.5-10 0.34 10.08 2 20 1.08 5.76 5 10 1 1.5 0 3-31.11 20.84-68.5 17.5-23.7-5.7-32.5-28.5-4.39-9.18-3.5-19 15.41 6.23 32 4.5-20.68-6.39-39-18-34.81-27.22-12.5-65.5 11.84-14.83 29-23 4.21 7.66 11.5 12.5 3 1 6 0-26.04-34.62-29-78-0.13-8.46 2-16.5 1 6.5 2 13 3.43 39.53 24.5 73 2.03 2.28 4.5 4 0.5-1.25 1-2.5-1.27-6.54-5-12 0.5-0.75 1-1.5 9.72-3.43 20-4 0.55 10.34 8 17.5 1.94 0.74 4 0.5-17.8-64.6 16.5-122 0.98-1.79 1.5 0-28.21 56.64-13.5 118 1.08 1.43 2.5 0.5 2.21-4.98 2-10.5z" fill="currentColor"/><path fill-rule="evenodd" d="m454.5 97.5q-1.33 11.18-8.5 20-21.81 26.28-55.5 32-1.11-0.2-2 0.5 2.31 2.82 5.5 4.5 1 2 0 4-9.56 11.3-19.5 20 19.71-8.72 31-27 2.68-0.43 5 1-14.24 30.97-48 36.5-9.93 1.71-20 1.5-6.8-0.48-13 1 5.81 6.92 14 11-10.78 16.03-27 26.5 27.16-7.4 38-33.5 4.34 1.35 9 1-9.08 23.84-33 33.5-18.45 6.41-38 7 22.59 8.92 45-1 12.05-5.52 24-11 9.01-1.79 17 2.5 5.28-4.38 11-8 12.8-6.07 27-5 0 0.5 0 1-19.34 2.69-34 15.5 0.5 0.25 1 0.5 17.79-8.09 36-15 2.71-0.79 5-2 2.5-1 5-2 5.53-4.04 11-8 11.7-4.18 24-6.5 7.78-1.36 15 1.5-2.97 18.45-13.5 34-34.92 49.37-94.5 62.5-59.27 12.45-108-23-15.53-12.52-21.5-31.5-2.47-14.26 4-27-3.15 24.41 14 42-4.92-10.28-7-22-1.97-17.63 7-33 47.28-69.5 125.5-100 15.86-3.42 32-5.5 18.63-1.47 37 1.5z" fill="currentColor"/><path fill-rule="evenodd" d="m231.5 238.5q1.31-0.2 2 1-3.13 28.62 15 51-16.25 6.75-27-7.5-1-1-2 0 14.73 29.34 46 18.5 1.79 0.52 0 1.5-37.63 16.82-50.5-22.5-5.1-26.48 16.5-42z" fill="currentColor"/><path fill-rule="evenodd" d="m203.5 266.5q1.31-0.2 2 1-2.48 22.08 12 39-6.99 1.35-14 0.5 4.59 4.08 10 7-8.71 0.28-14.5-6.5-16.98-22.76 4.5-41z" fill="currentColor"/><path fill-rule="evenodd" d="m58.5 284.5q9.6-2.17 14.5 6 5.15 14.18-1 28-11.05-13.14-27.5-17.5 5.15-9.9 14-16.5z" fill="currentColor"/><path fill-rule="evenodd" d="m56.5 313.5q3.43 5.43 8 10-4.88 0.44-8 4-1.11-0.2-2 0.5 28.91 1.65 38 28.5 0.45 3.16-1 6-11.02-7.01-23-12.5-4.75-3.75-9.5-7.5 1.47 7.42 7 13 8.34 27.18 32 43 0.99 2.41-1.5 3.5-40.25 5.58-66.5-25.5-15.67-22.01-8-48 10.46-23.87 34.5-15z" fill="currentColor"/><path fill-rule="evenodd" d="m198.5 319.5q1.44 0.68 2.5 2 2.41 8.23 6 16 1.2 2.64-0.5 5-30.65 21.41-68 18.5-25.16-6.17-32.5-30.5 6.96 4.99 15.5 6.5 8.99 0.75 18 0.5 16.25 2.38 32-2.5 15.9-3.94 27-15.5z" fill="currentColor"/><path fill-rule="evenodd" d="m239.5 342.5q7.02-0.25 14 0.5 4.46 1.06 8 3.5-5.2 2.35-10 5.5-3.88 4.65-9 7.5-9.89-3.09-9.5-13 2.36-3.63 6.5-4z" fill="currentColor"/><path fill-rule="evenodd" d="m214.5 349.5q5.96 7.2 13.5 13 1 1 0 2-28.58 23.34-65.5 20.5-18.15-4.24-27.5-19.5 1.13 0.94 2.5 1.5 14.7 1.42 29-1.5 26.57-0.52 48-16z" fill="currentColor"/><path fill-rule="evenodd" d="m302.5 373.5q0.21 2.44-2 3.5-28.69 7.6-50.5-12.5-0.06-6.71 6.5-9 4.45-0.75 9-1 22.26 2.27 37 19z" fill="currentColor"/><path fill-rule="evenodd" d="m232.5 365.5q17.6 6.19 10.5 23-10.6 10.42-25.5 11.5-25.94 3.21-49-9 36.75-1.65 64-25.5z" fill="currentColor"/><path fill-rule="evenodd" d="m113.5 367.5q7.7-0.01 9.5 7-9.69 7.19-18.5 15.5-7.23 5.76-5.5-3.5 3.12-12.84 14.5-19z" fill="currentColor"/><path fill-rule="evenodd" d="m126.5 380.5q7.88-0.4 12 6.5-8.5 7.25-17 14.5-5.62-12.55 5-21z" fill="currentColor"/><path fill-rule="evenodd" d="m283.5 385.5q3.22 2.95 7 5.5 2.8 4.03 6 7.5 0.42 2.77-2 4-15.5-9.75-31-19.5-1.79-0.98 0-1.5 9.96 2.49 20 4z" fill="currentColor"/></svg>} href="/integrations/openclaw">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.0.9",
"version": "3.0.10",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+3 -1
View File
@@ -169,7 +169,9 @@ export interface PaginatedMemories {
export interface ProjectResponse {
customInstructions?: string;
customCategories?: string[];
// The API returns category objects (`[{ "<name>": "<description>" }]`),
// not bare strings (see issue #5738).
customCategories?: custom_categories[];
[key: string]: any;
}
+46
View File
@@ -99,4 +99,50 @@ describe("camelToSnakeKeys / snakeToCamelKeys", () => {
});
});
});
describe("user-controlled customCategories names (issue #5738)", () => {
it("converts the outer key but leaves multi-word category names on write", () => {
expect(
camelToSnakeKeys({
customCategories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
}),
).toEqual({
// outer SDK key is snake_cased, user-defined category names are not
custom_categories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
});
});
it("converts the outer key but leaves category names verbatim on read", () => {
expect(
snakeToCamelKeys({
custom_categories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
}),
).toEqual({
customCategories: [
{ work_life_balance: "desc" },
{ AIResearch: "desc" },
],
});
});
it("round-trips category names losslessly (write then read)", () => {
const customCategories = [
{ work_life_balance: "balance between work and life" },
{ AIResearch: "artificial intelligence research" },
];
const roundTripped = snakeToCamelKeys(
camelToSnakeKeys({ customCategories }),
);
expect(roundTripped.customCategories).toEqual(customCategories);
});
});
});
+5
View File
@@ -29,6 +29,11 @@ const OPAQUE_VALUE_KEYS = new Set([
"metadata",
"structuredDataSchema",
"structured_data_schema",
// Custom-category names are user-controlled keys (`[{ "<name>": "<desc>" }]`).
// Listed in both casings so they round-trip verbatim in both directions
// (see issue #5738; same class as `metadata`/`structuredDataSchema`).
"customCategories",
"custom_categories",
]);
/**
-43
View File
@@ -1,43 +0,0 @@
import { OpenAILLM } from "./openai";
import { LLMConfig, Message } from "../types";
import { LLMResponse } from "./base";
export class TogetherLLM extends OpenAILLM {
constructor(config: LLMConfig) {
const apiKey = config.apiKey || process.env.TOGETHER_API_KEY;
if (!apiKey) {
throw new Error("Together API key is required");
}
super({
...config,
apiKey,
baseURL:
config.baseURL ||
process.env.TOGETHER_API_BASE ||
"https://api.together.xyz/v1",
model: config.model || "mistralai/Mixtral-8x7B-Instruct-v0.1",
});
}
async generateResponse(
messages: Message[],
responseFormat?: { type: string },
tools?: any[],
): Promise<string | LLMResponse> {
try {
return await super.generateResponse(messages, responseFormat, tools);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Together LLM failed: ${message}`);
}
}
async generateChat(messages: Message[]): Promise<LLMResponse> {
try {
return await super.generateChat(messages);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Together LLM failed: ${message}`);
}
}
}
-3
View File
@@ -22,7 +22,6 @@ import { RedisDB } from "../vector_stores/redis";
import { OllamaLLM } from "../llms/ollama";
import { LMStudioLLM } from "../llms/lmstudio";
import { DeepSeekLLM } from "../llms/deepseek";
import { TogetherLLM } from "../llms/together";
import { SupabaseDB } from "../vector_stores/supabase";
import { SQLiteManager } from "../storage/SQLiteManager";
import { MemoryHistoryManager } from "../storage/MemoryHistoryManager";
@@ -86,8 +85,6 @@ export class LLMFactory {
return new LangchainLLM(config);
case "deepseek":
return new DeepSeekLLM(config);
case "together":
return new TogetherLLM(config);
default:
throw new Error(`Unsupported LLM provider: ${provider}`);
}
+5 -3
View File
@@ -31,9 +31,11 @@ const parse_vision_messages = async (messages: Message[]) => {
typeof message.content === "object" &&
message.content.type === "image_url"
) {
const description = await get_image_description(
message.content.image_url.url,
);
const imageUrl = message.content.image_url?.url;
if (!imageUrl) {
throw new Error("image_url content part is missing image_url.url");
}
const description = await get_image_description(imageUrl);
new_message.content =
typeof description === "string"
? description
-124
View File
@@ -1,124 +0,0 @@
/// <reference types="jest" />
/**
* Together LLM — unit tests (mocked OpenAI).
*/
import { TogetherLLM } from "../src/llms/together";
const mockCreate = jest.fn();
jest.mock("openai", () => {
return jest.fn().mockImplementation(() => ({
chat: { completions: { create: mockCreate } },
}));
});
describe("TogetherLLM (unit)", () => {
beforeEach(() => mockCreate.mockClear());
it("throws when no API key is provided", () => {
const original = process.env.TOGETHER_API_KEY;
delete process.env.TOGETHER_API_KEY;
try {
expect(() => new TogetherLLM({})).toThrow("Together API key is required");
} finally {
if (original !== undefined) process.env.TOGETHER_API_KEY = original;
}
});
it("generateResponse() returns a text response", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: {
content: "Hello, world!",
role: "assistant",
tool_calls: null,
},
},
],
});
const llm = new TogetherLLM({ apiKey: "test-key" });
const result = await llm.generateResponse([
{ role: "user", content: "Hi" },
]);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(result).toBe("Hello, world!");
});
it("generateResponse() handles tool calls", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: {
content: "",
role: "assistant",
tool_calls: [
{
function: {
name: "get_weather",
arguments: '{"city": "London"}',
},
},
],
},
},
],
});
const llm = new TogetherLLM({ apiKey: "test-key" });
const result = await llm.generateResponse(
[{ role: "user", content: "What is the weather?" }],
undefined,
[{ type: "function", function: { name: "get_weather" } }],
);
expect(result).toEqual({
content: "",
role: "assistant",
toolCalls: [{ name: "get_weather", arguments: '{"city": "London"}' }],
});
});
it("generateResponse() wraps API errors with a clear message", async () => {
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
const llm = new TogetherLLM({ apiKey: "test-key" });
await expect(
llm.generateResponse([{ role: "user", content: "Hi" }]),
).rejects.toThrow("Together LLM failed: Connection refused");
});
it("generateChat() returns LLMResponse shape", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: { content: "I can help with that.", role: "assistant" },
},
],
});
const llm = new TogetherLLM({ apiKey: "test-key" });
const result = await llm.generateChat([
{ role: "user", content: "Help me" },
]);
expect(result).toEqual({
content: "I can help with that.",
role: "assistant",
});
});
it("generateChat() wraps API errors with a clear message", async () => {
mockCreate.mockRejectedValueOnce(new Error("Timeout"));
const llm = new TogetherLLM({ apiKey: "test-key" });
await expect(
llm.generateChat([{ role: "user", content: "Hi" }]),
).rejects.toThrow("Together LLM failed: Timeout");
});
});
+6
View File
@@ -18,6 +18,12 @@ class OpenSearchConfig(BaseModel):
"RequestsHttpConnection", description="Connection class for OpenSearch"
)
pool_maxsize: int = Field(20, description="Maximum number of connections in the pool")
auto_refresh: bool = Field(
False,
description="Automatically refresh index after insert operations to make documents "
"immediately searchable. Disabled by default for OpenSearch Serverless compatibility. "
"OpenSearch automatically refreshes indices every ~1 second, so most users don't need this.",
)
@model_validator(mode="before")
@classmethod
+33 -2
View File
@@ -1,6 +1,7 @@
import json
import logging
import os
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Union
try:
from groq import Groq
@@ -11,6 +12,8 @@ from mem0.configs.llms.base import BaseLlmConfig
from mem0.llms.base import LLMBase
from mem0.memory.utils import extract_json
logger = logging.getLogger(__name__)
class GroqLLM(LLMBase):
def __init__(self, config: Optional[BaseLlmConfig] = None):
@@ -22,6 +25,24 @@ class GroqLLM(LLMBase):
api_key = self.config.api_key or os.getenv("GROQ_API_KEY")
self.client = Groq(api_key=api_key)
@staticmethod
def _supports_json_mode(model: Optional[Union[str, Dict]]) -> bool:
"""
Groq's compound agentic systems (e.g. ``groq/compound``, ``groq/compound-mini``)
do not support the JSON ``response_format`` and return empty or non-JSON content
when it is requested. See https://console.groq.com/docs/structured-outputs.
Non-string models (the config allows a dict) are assumed to support JSON mode,
preserving prior behavior.
"""
if not isinstance(model, str):
return True
# Strip provider prefixes (e.g. "groq/compound-mini" -> "compound-mini"),
# mirroring the _is_reasoning_model heuristic in LLMBase, so the match
# targets the compound family rather than any name containing the substring.
base_model = model.lower().rsplit("/", 1)[-1]
return not base_model.startswith("compound")
def _parse_response(self, response, tools):
"""
Process the response based on whether tools are used or not.
@@ -79,7 +100,17 @@ class GroqLLM(LLMBase):
"top_p": self.config.top_p,
}
if response_format:
params["response_format"] = response_format
requests_json = isinstance(response_format, dict) and response_format.get("type") in (
"json_object",
"json_schema",
)
if requests_json and not self._supports_json_mode(self.config.model):
logger.debug(
f"Model '{self.config.model}' does not support JSON response_format; "
"sending the request without it."
)
else:
params["response_format"] = response_format
if tools:
params["tools"] = tools
params["tool_choice"] = tool_choice
+4 -1
View File
@@ -206,7 +206,10 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"):
elif isinstance(content, dict) and content.get("type") == "image_url":
if llm is None:
continue
image_url = content["image_url"]["url"]
image_url_obj = content.get("image_url")
image_url = image_url_obj.get("url") if isinstance(image_url_obj, dict) else None
if not image_url:
raise ValueError("image_url content part is missing image_url.url")
try:
description = get_image_description(image_url, llm, vision_details)
returned_messages.append({"role": role, "content": description})
+5 -1
View File
@@ -1,3 +1,4 @@
import logging
import os
from typing import List, Dict, Any
@@ -9,6 +10,8 @@ try:
except ImportError:
COHERE_AVAILABLE = False
logger = logging.getLogger(__name__)
class CohereReranker(BaseReranker):
"""Cohere-based reranker implementation."""
@@ -78,8 +81,9 @@ class CohereReranker(BaseReranker):
return reranked_docs
except Exception:
except Exception as e:
# Fallback to original order if reranking fails
logger.warning("Cohere reranking failed, falling back to original order: %s", e)
for doc in documents:
doc['rerank_score'] = 0.0
final_top_k = top_k or self.config.top_k
+5 -1
View File
@@ -1,3 +1,4 @@
import logging
from typing import List, Dict, Any, Union
import numpy as np
@@ -12,6 +13,8 @@ try:
except ImportError:
TRANSFORMERS_AVAILABLE = False
logger = logging.getLogger(__name__)
class HuggingFaceReranker(BaseReranker):
"""HuggingFace Transformers based reranker implementation."""
@@ -139,8 +142,9 @@ class HuggingFaceReranker(BaseReranker):
return reranked_docs
except Exception:
except Exception as e:
# Fallback to original order if reranking fails
logger.warning("HuggingFace reranking failed, falling back to original order: %s", e)
for doc in documents:
doc['rerank_score'] = 0.0
final_top_k = top_k or self.config.top_k
+5 -1
View File
@@ -1,3 +1,4 @@
import logging
import re
from typing import Any, Dict, List, Union
@@ -6,6 +7,8 @@ from mem0.configs.rerankers.llm import LLMRerankerConfig
from mem0.reranker.base import BaseReranker
from mem0.utils.factory import LlmFactory
logger = logging.getLogger(__name__)
class LLMReranker(BaseReranker):
"""LLM-based reranker implementation."""
@@ -151,8 +154,9 @@ class LLMReranker(BaseReranker):
scored_doc['rerank_score'] = score
scored_docs.append(scored_doc)
except Exception:
except Exception as e:
# Fallback: assign neutral score if scoring fails
logger.warning("LLM reranking failed for a document, assigning neutral score: %s", e)
scored_doc = doc.copy()
scored_doc['rerank_score'] = 0.5
scored_docs.append(scored_doc)
@@ -1,3 +1,4 @@
import logging
from typing import List, Dict, Any, Union
import numpy as np
@@ -11,6 +12,8 @@ try:
except ImportError:
SENTENCE_TRANSFORMERS_AVAILABLE = False
logger = logging.getLogger(__name__)
class SentenceTransformerReranker(BaseReranker):
"""Sentence Transformer based reranker implementation."""
@@ -102,8 +105,9 @@ class SentenceTransformerReranker(BaseReranker):
return reranked_docs
except Exception:
except Exception as e:
# Fallback to original order if reranking fails
logger.warning("SentenceTransformer reranking failed, falling back to original order: %s", e)
for doc in documents:
doc['rerank_score'] = 0.0
final_top_k = top_k or self.config.top_k
+5 -1
View File
@@ -1,3 +1,4 @@
import logging
import os
from typing import List, Dict, Any
@@ -9,6 +10,8 @@ try:
except ImportError:
ZERO_ENTROPY_AVAILABLE = False
logger = logging.getLogger(__name__)
class ZeroEntropyReranker(BaseReranker):
"""Zero Entropy-based reranker implementation."""
@@ -89,8 +92,9 @@ class ZeroEntropyReranker(BaseReranker):
return reranked_docs
except Exception:
except Exception as e:
# Fallback to original order if reranking fails
logger.warning("Zero Entropy reranking failed, falling back to original order: %s", e)
for doc in documents:
doc['rerank_score'] = 0.0
final_top_k = top_k or self.config.top_k
+2 -2
View File
@@ -298,9 +298,9 @@ class AzureAISearch(VectorStoreBase):
payload (Dict, optional): Updated payload.
"""
document = {"id": vector_id}
if vector:
if vector is not None:
document["vector"] = vector
if payload:
if payload is not None:
json_payload = json.dumps(payload)
document["payload"] = json_payload
for field in ["user_id", "run_id", "agent_id"]:
+6 -2
View File
@@ -169,7 +169,7 @@ class ChromaDB(VectorStoreBase):
Args:
vector_id (str): ID of the vector to delete.
"""
self.collection.delete(ids=vector_id)
self.collection.delete(ids=[vector_id])
def update(
self,
@@ -185,7 +185,11 @@ class ChromaDB(VectorStoreBase):
vector (Optional[List[float]], optional): Updated vector. Defaults to None.
payload (Optional[Dict], optional): Updated payload. Defaults to None.
"""
self.collection.update(ids=vector_id, embeddings=vector, metadatas=payload)
self.collection.update(
ids=[vector_id],
embeddings=[vector] if vector is not None else None,
metadatas=[payload] if payload is not None else None,
)
def get(self, vector_id: str) -> Optional[OutputData]:
"""
+10 -2
View File
@@ -39,6 +39,8 @@ class OpenSearchDB(VectorStoreBase):
self.collection_name = config.collection_name
self.embedding_model_dims = config.embedding_model_dims
self.auto_refresh = config.auto_refresh
self.create_col(self.collection_name, self.embedding_model_dims)
def create_index(self) -> None:
@@ -148,8 +150,6 @@ class OpenSearchDB(VectorStoreBase):
}
try:
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(
@@ -162,6 +162,14 @@ class OpenSearchDB(VectorStoreBase):
logger.error(f"Error inserting vector {id_}: {e}", exc_info=True)
raise
# Refresh once after the full batch (not per document) if explicitly enabled.
# Disabled by default for Serverless compatibility: OpenSearch Serverless does not
# support the indices.refresh() API, and refreshing per document would cause a
# cluster-level I/O stall on every insert.
# See: https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless-genref.html
if self.auto_refresh:
self.client.indices.refresh(index=self.collection_name)
return results
def search(
+4 -1
View File
@@ -98,7 +98,10 @@ class Qdrant(VectorStoreBase):
self._bm25_encoder = SparseTextEmbedding(model_name="Qdrant/bm25")
logger.info("BM25 encoder loaded (fastembed Qdrant/bm25)")
except ImportError:
logger.warning("fastembed not installed — BM25 keyword search disabled. Install with: pip install fastembed")
logger.warning(
"fastembed not installed - BM25 keyword search disabled. "
'Install it with: pip install "mem0ai[extras]"'
)
self._bm25_encoder = False # sentinel: tried and failed
except Exception as e:
logger.warning(f"Failed to load BM25 encoder: {e}")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "2.0.7"
version = "2.0.8"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "support@mem0.ai" }
@@ -27,6 +27,8 @@ import { useApiQuery } from "@/hooks/use-api-query";
import { Memory } from "@/types/api";
const PAGE_SIZE = 20;
// Keep in sync with ALL_MEMORIES_LIMIT in server/main.py.
const MEMORY_FETCH_LIMIT = 1000;
export default function MemoriesPage() {
const [userId, setUserId] = useState("");
@@ -41,7 +43,9 @@ export default function MemoriesPage() {
refetch,
} = useApiQuery<Memory[]>(
async () => {
const params = userId.trim() ? { user_id: userId.trim() } : undefined;
const params = userId.trim()
? { user_id: userId.trim(), top_k: MEMORY_FETCH_LIMIT }
: { top_k: MEMORY_FETCH_LIMIT };
const res = await api.get(MEMORY_ENDPOINTS.BASE, { params });
const raw = res.data?.results ?? res.data ?? [];
return Array.isArray(raw) ? raw : [];
@@ -96,7 +100,7 @@ export default function MemoriesPage() {
<div className="space-y-4">
<h1 className="text-xl font-semibold font-fustat">Memories</h1>
{memories.length >= 1000 && (
{memories.length >= MEMORY_FETCH_LIMIT && (
<UpgradeBanner
id="memories-1k"
message="1,000+ memories stored. Categories can help organize them."
+7 -3
View File
@@ -16,7 +16,7 @@ from errors import (
upstream_error,
upstream_error_handler,
)
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from mem0.exceptions import ValidationError as Mem0ValidationError
@@ -409,6 +409,7 @@ def get_all_memories(
user_id: Optional[str] = None,
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
top_k: Optional[int] = Query(None, ge=0, le=ALL_MEMORIES_LIMIT),
_auth=Depends(verify_auth),
):
"""Retrieve stored memories. Lists all memories when no identifier is provided (admin only)."""
@@ -417,11 +418,14 @@ def get_all_memories(
auth_type = getattr(request.state, "auth_type", "none")
if _auth is not None and _auth.role != "admin" and auth_type not in {"admin_api_key", "disabled"}:
raise HTTPException(status_code=403, detail="Admin role required to list all memories.")
return _list_all_memories()
return _list_all_memories(limit=top_k if top_k is not None else ALL_MEMORIES_LIMIT)
filters = {
k: v for k, v in {"user_id": user_id, "run_id": run_id, "agent_id": agent_id}.items() if v is not None
}
return get_memory_instance().get_all(filters=filters)
params = {"filters": filters}
if top_k is not None:
params["top_k"] = top_k
return get_memory_instance().get_all(**params)
except HTTPException:
raise
except Exception:
+98
View File
@@ -84,3 +84,101 @@ def test_generate_response_with_tools(mock_groq_client):
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."}
@pytest.mark.parametrize("model", ["groq/compound", "groq/compound-mini"])
def test_generate_response_skips_json_mode_for_compound_models(mock_groq_client, model):
config = BaseLlmConfig(model=model, temperature=0.7, max_tokens=100, top_p=1.0)
llm = GroqLLM(config)
messages = [{"role": "user", "content": "Hi, I'm Alice and I love hiking."}]
# Compound models answer JSON-mode requests with empty or non-JSON content;
# the mock mirrors that plain-text reply. These tests pin request
# construction (response_format omitted), not end-to-end extraction.
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Alice introduced herself and mentioned she loves hiking."))]
mock_groq_client.chat.completions.create.return_value = mock_response
llm.generate_response(messages, response_format={"type": "json_object"})
_, kwargs = mock_groq_client.chat.completions.create.call_args
assert "response_format" not in kwargs
def test_generate_response_keeps_json_mode_for_standard_model(mock_groq_client):
config = BaseLlmConfig(model="llama-3.3-70b-versatile", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GroqLLM(config)
messages = [{"role": "user", "content": "Hi, I'm Alice and I love hiking."}]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"memory": ["Name is Alice", "Loves hiking"]}'))]
mock_groq_client.chat.completions.create.return_value = mock_response
llm.generate_response(messages, response_format={"type": "json_object"})
_, kwargs = mock_groq_client.chat.completions.create.call_args
assert kwargs["response_format"] == {"type": "json_object"}
def test_generate_response_keeps_non_json_response_format_for_compound_model(mock_groq_client):
config = BaseLlmConfig(model="groq/compound", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GroqLLM(config)
messages = [{"role": "user", "content": "Hi, I'm Alice and I love hiking."}]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="Alice loves hiking."))]
mock_groq_client.chat.completions.create.return_value = mock_response
llm.generate_response(messages, response_format={"type": "text"})
_, kwargs = mock_groq_client.chat.completions.create.call_args
assert kwargs["response_format"] == {"type": "text"}
def test_generate_response_keeps_tools_when_skipping_json_mode(mock_groq_client):
config = BaseLlmConfig(model="groq/compound", temperature=0.7, max_tokens=100, top_p=1.0)
llm = GroqLLM(config)
messages = [{"role": "user", "content": "Add a new memory: Today is a sunny day."}]
tools = [
{
"type": "function",
"function": {
"name": "add_memory",
"description": "Add a memory",
"parameters": {
"type": "object",
"properties": {"data": {"type": "string", "description": "Data to add to memory"}},
"required": ["data"],
},
},
}
]
mock_response = Mock()
mock_message = Mock()
mock_message.content = "Done."
mock_message.tool_calls = None
mock_response.choices = [Mock(message=mock_message)]
mock_groq_client.chat.completions.create.return_value = mock_response
llm.generate_response(messages, response_format={"type": "json_object"}, tools=tools)
_, kwargs = mock_groq_client.chat.completions.create.call_args
assert "response_format" not in kwargs
assert kwargs["tools"] == tools
assert kwargs["tool_choice"] == "auto"
def test_generate_response_handles_non_string_model(mock_groq_client):
config = BaseLlmConfig(model={"name": "custom-model"}, temperature=0.7, max_tokens=100, top_p=1.0)
llm = GroqLLM(config)
messages = [{"role": "user", "content": "Hi, I'm Alice and I love hiking."}]
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content='{"memory": []}'))]
mock_groq_client.chat.completions.create.return_value = mock_response
llm.generate_response(messages, response_format={"type": "json_object"})
_, kwargs = mock_groq_client.chat.completions.create.call_args
assert kwargs["response_format"] == {"type": "json_object"}
+18
View File
@@ -96,6 +96,24 @@ class TestParseVisionMessages:
result = parse_vision_messages(messages, llm=None)
assert result == messages
def test_malformed_image_dict_raises_value_error(self):
# A malformed image part (missing the nested url) used to raise an
# uncaught KeyError that aborted add(); it should raise a clear ValueError.
mock_llm = Mock()
messages = [{"role": "user", "content": {"type": "image_url", "image_url": {}}}]
with pytest.raises(ValueError, match=r"missing image_url\.url"):
parse_vision_messages(messages, llm=mock_llm)
mock_llm.generate_response.assert_not_called()
def test_none_image_url_raises_value_error(self):
# image_url present but None (or any non-dict) must also raise the clear
# ValueError, not an AttributeError from calling .get() on None.
mock_llm = Mock()
messages = [{"role": "user", "content": {"type": "image_url", "image_url": None}}]
with pytest.raises(ValueError, match=r"missing image_url\.url"):
parse_vision_messages(messages, llm=mock_llm)
mock_llm.generate_response.assert_not_called()
class TestRemoveSpacesFromEntities:
"""
@@ -0,0 +1,32 @@
"""Reranker failures must be logged, not silently swallowed.
Uses the LLMReranker because it is constructible without heavy ML deps (the
``mock_llm`` fixture stubs the LLM factory). The fix under test is shared by all
reranker providers: the ``except`` fallback now emits a ``logger.warning`` before
degrading to the original order / a neutral score.
"""
import logging
from mem0.reranker.llm_reranker import LLMReranker
class TestRerankerFailureLogging:
def test_llm_failure_is_logged_and_falls_back(self, mock_llm, caplog):
_factory, llm_instance = mock_llm
llm_instance.generate_response.side_effect = RuntimeError("upstream 500")
reranker = LLMReranker({"provider": "openai"})
docs = [{"memory": "alpha"}, {"memory": "beta"}]
with caplog.at_level(logging.WARNING, logger="mem0.reranker.llm_reranker"):
result = reranker.rerank("q", docs)
# Graceful degradation preserved: every doc still comes back, scored neutral.
assert len(result) == 2
assert all(d["rerank_score"] == 0.5 for d in result)
# The failure is no longer silent.
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert warnings, "expected a warning to be logged on reranking failure"
assert "upstream 500" in caplog.text
+25
View File
@@ -618,6 +618,31 @@ class TestGetMemories:
# 3. Verify the core logic: the param was mapped to the filters dict!
_, kwargs = mock_memory.get_all.call_args
assert kwargs["filters"] == {"user_id": "test_routing_user"}
assert "top_k" not in kwargs
def test_get_memories_entity_filters_forward_top_k(self, client, mock_memory):
response = client.get("/memories?user_id=test_routing_user&top_k=1000")
assert response.status_code == 200
_, kwargs = mock_memory.get_all.call_args
assert kwargs["filters"] == {"user_id": "test_routing_user"}
assert kwargs["top_k"] == 1000
def test_get_memories_admin_top_k_zero_not_defaulted(self, client, mock_memory):
mock_memory.vector_store.list.return_value = []
response = client.get("/memories?top_k=0")
assert response.status_code == 200
_, kwargs = mock_memory.vector_store.list.call_args
assert kwargs["top_k"] == 0
def test_get_memories_rejects_top_k_above_limit(self, client, mock_memory):
response = client.get("/memories?user_id=test_routing_user&top_k=1001")
assert response.status_code == 422
mock_memory.get_all.assert_not_called()
# ===========================================================================
@@ -511,6 +511,30 @@ def test_insert_with_http_error(azure_ai_search_instance):
assert "Azure service error" in str(exc_info.value)
def test_update_allows_empty_payload(azure_ai_search_instance):
"""Test updating with an empty payload explicitly clears stored payload data."""
instance, mock_search_client, _ = azure_ai_search_instance
mock_search_client.merge_or_upload_documents.return_value = [{"status": True, "id": "doc1", "status_code": 200}]
instance.update("doc1", payload={})
mock_search_client.merge_or_upload_documents.assert_called_once_with(
documents=[{"id": "doc1", "payload": "{}", "user_id": None, "run_id": None, "agent_id": None}]
)
def test_update_allows_empty_vector(azure_ai_search_instance):
"""Test updating with an empty vector still sends the vector field to Azure."""
instance, mock_search_client, _ = azure_ai_search_instance
mock_search_client.merge_or_upload_documents.return_value = [{"status": True, "id": "doc1", "status_code": 200}]
instance.update("doc1", vector=[])
mock_search_client.merge_or_upload_documents.assert_called_once_with(documents=[{"id": "doc1", "vector": []}])
# --- Tests for search method ---
+26 -2
View File
@@ -122,7 +122,7 @@ def test_delete_vector(chromadb_instance):
chromadb_instance.delete(vector_id=vector_id)
chromadb_instance.collection.delete.assert_called_once_with(ids=vector_id)
chromadb_instance.collection.delete.assert_called_once_with(ids=[vector_id])
def test_update_vector(chromadb_instance):
@@ -133,7 +133,31 @@ def test_update_vector(chromadb_instance):
chromadb_instance.update(vector_id=vector_id, vector=new_vector, payload=new_payload)
chromadb_instance.collection.update.assert_called_once_with(
ids=vector_id, embeddings=new_vector, metadatas=new_payload
ids=[vector_id], embeddings=[new_vector], metadatas=[new_payload]
)
def test_update_vector_metadata_only(chromadb_instance):
# Metadata-only update (vector=None) must not wrap None in a list.
vector_id = "id1"
new_payload = {"name": "updated_vector"}
chromadb_instance.update(vector_id=vector_id, vector=None, payload=new_payload)
chromadb_instance.collection.update.assert_called_once_with(
ids=[vector_id], embeddings=None, metadatas=[new_payload]
)
def test_update_vector_embedding_only(chromadb_instance):
# Vector-only update (payload=None) must not wrap None in a list.
vector_id = "id1"
new_vector = [0.7, 0.8, 0.9]
chromadb_instance.update(vector_id=vector_id, vector=new_vector, payload=None)
chromadb_instance.collection.update.assert_called_once_with(
ids=[vector_id], embeddings=[new_vector], metadatas=None
)
+49
View File
@@ -151,6 +151,55 @@ class TestOpenSearchDB(unittest.TestCase):
self.os_db.create_index()
self.client_mock.indices.create.assert_not_called()
def test_auto_refresh_disabled_by_default(self):
"""Test that auto_refresh is disabled by default (Issue #3739).
This ensures OpenSearch Serverless compatibility out-of-the-box since
the indices.refresh() API is not supported in serverless mode.
"""
# Default instance should have auto_refresh=False
self.assertFalse(self.os_db.auto_refresh)
self.client_mock.reset_mock()
vectors = [[0.1] * 1536]
payloads = [{"key1": "value1"}]
ids = ["id1"]
self.os_db.insert(vectors=vectors, payloads=payloads, ids=ids)
# Verify index was called but refresh was NOT called (default behavior)
self.assertEqual(self.client_mock.index.call_count, 1)
self.client_mock.indices.refresh.assert_not_called()
def test_auto_refresh_enabled(self):
"""Test that refresh is called once per batch (not per document) when auto_refresh=True."""
with patch("mem0.vector_stores.opensearch.OpenSearch", return_value=self.client_mock):
auto_refresh_db = OpenSearchDB(
host="localhost",
port=9200,
collection_name="test_auto_refresh",
embedding_model_dims=1536,
auto_refresh=True, # Enable auto-refresh
)
self.assertTrue(auto_refresh_db.auto_refresh)
# auto_refresh_db reuses self.client_mock (patched above), so reset to drop
# the index calls made during construction before asserting on insert().
self.client_mock.reset_mock()
# Insert a batch of 3 vectors to verify the refresh is hoisted out of the
# per-document loop: index() is called once per document, but refresh()
# must fire exactly once for the whole batch.
vectors = [[0.1] * 1536, [0.2] * 1536, [0.3] * 1536]
payloads = [{"key1": "value1"}, {"key2": "value2"}, {"key3": "value3"}]
ids = ["id1", "id2", "id3"]
auto_refresh_db.insert(vectors=vectors, payloads=payloads, ids=ids)
# index() once per document, but refresh() only once for the batch
self.assertEqual(self.client_mock.index.call_count, 3)
self.assertEqual(self.client_mock.indices.refresh.call_count, 1)
def test_insert(self):
vectors = [[0.1] * 1536, [0.2] * 1536]
payloads = [{"key1": "value1"}, {"key2": "value2"}]
+16
View File
@@ -1025,3 +1025,19 @@ class TestQdrantDatetimeRangeFilters(unittest.TestCase):
types = {type(c.range) for c in result.must}
self.assertIn(DatetimeRange, types)
self.assertIn(Range, types)
def test_missing_fastembed_warns_with_extras_install_hint(self):
"""When fastembed is missing, the BM25 warning must point at the mem0 optional group
that actually provides it (`mem0ai[extras]`), not the bare `fastembed` package -
fastembed is declared under [extras], so that is the discoverable install path."""
self.qdrant._bm25_encoder = None
# Setting the module to None in sys.modules makes `from fastembed import ...` raise ImportError.
with patch.dict("sys.modules", {"fastembed": None}):
with self.assertLogs("mem0.vector_stores.qdrant", level="WARNING") as cm:
result = self.qdrant._get_bm25_encoder()
self.assertIsNone(result) # encoder unavailable -> None
self.assertIs(self.qdrant._bm25_encoder, False) # sentinel: tried and failed, do not retry
joined = "\n".join(cm.output)
self.assertIn("mem0ai[extras]", joined) # points at the right install
self.assertNotIn("pip install fastembed", joined) # not the bare package
self.assertNotIn("\u2014", joined) # no em-dash