From fa9abc77a6fcbe8b6afdda1de997bc6a52aa99f3 Mon Sep 17 00:00:00 2001 From: Bartok Date: Tue, 23 Jun 2026 06:55:51 -0400 Subject: [PATCH 01/11] fix(ts-oss): honor configured baseURL in AnthropicLLM (#5740) --- mem0-ts/src/oss/src/llms/anthropic.ts | 8 ++++- mem0-ts/src/oss/tests/anthropic-llm.test.ts | 37 ++++++++++++++++++--- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/mem0-ts/src/oss/src/llms/anthropic.ts b/mem0-ts/src/oss/src/llms/anthropic.ts index b5832b0ab0..ea91f8ca4b 100644 --- a/mem0-ts/src/oss/src/llms/anthropic.ts +++ b/mem0-ts/src/oss/src/llms/anthropic.ts @@ -14,7 +14,13 @@ export class AnthropicLLM implements LLM { if (!apiKey) { throw new Error("Anthropic API key is required"); } - this.client = new Anthropic({ apiKey }); + // Forward baseURL to the client when set so proxy/gateway users are + // honored (parity with the OpenAI provider and the Python fix in #5626). + const clientArgs: { apiKey: string; baseURL?: string } = { apiKey }; + if (config.baseURL) { + clientArgs.baseURL = config.baseURL; + } + this.client = new Anthropic(clientArgs); this.model = config.model || "claude-sonnet-4-6"; // Defaults mirror the Python provider's AnthropicConfig // (max_tokens=2000, temperature=0.1, top_p omitted). diff --git a/mem0-ts/src/oss/tests/anthropic-llm.test.ts b/mem0-ts/src/oss/tests/anthropic-llm.test.ts index c279037d60..a8d5d32767 100644 --- a/mem0-ts/src/oss/tests/anthropic-llm.test.ts +++ b/mem0-ts/src/oss/tests/anthropic-llm.test.ts @@ -4,17 +4,46 @@ */ const mockCreate = jest.fn(); +const mockConstructor = jest.fn(); jest.mock("@anthropic-ai/sdk", () => { - return jest.fn().mockImplementation(() => ({ - messages: { create: mockCreate }, - })); + return jest.fn().mockImplementation((args) => { + mockConstructor(args); + return { messages: { create: mockCreate } }; + }); }); import { AnthropicLLM } from "../src/llms/anthropic"; describe("AnthropicLLM (unit)", () => { - beforeEach(() => mockCreate.mockClear()); + beforeEach(() => { + mockCreate.mockClear(); + mockConstructor.mockClear(); + }); + + // Regression #5665: a configured baseURL must reach the Anthropic client so + // proxy/gateway users are not silently bypassed (TS parity with #5626). + it("forwards baseURL to the Anthropic client when set", () => { + new AnthropicLLM({ + apiKey: "test-key", + baseURL: "https://proxy.example/v1", + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + const ctorArgs = mockConstructor.mock.calls[0][0]; + expect(ctorArgs.apiKey).toBe("test-key"); + expect(ctorArgs.baseURL).toBe("https://proxy.example/v1"); + }); + + // When no baseURL is configured the client must not receive a baseURL key + // (so the SDK default endpoint is used). + it("does NOT set baseURL when none is configured", () => { + new AnthropicLLM({ apiKey: "test-key" }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + const ctorArgs = mockConstructor.mock.calls[0][0]; + expect(ctorArgs.baseURL).toBeUndefined(); + }); it("returns text when no tools are provided and model returns a text block", async () => { mockCreate.mockResolvedValueOnce({ From 15a930dac22840fef6256b35db4fc741e7dad6cc Mon Sep 17 00:00:00 2001 From: Yash Singh <123385188+yashs33244@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:26:22 +0530 Subject: [PATCH 02/11] fix(llms): pass configured anthropic_base_url to the Anthropic client (#5626) --- mem0/llms/anthropic.py | 6 +++++- tests/llms/test_anthropic.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/mem0/llms/anthropic.py b/mem0/llms/anthropic.py index f5e8dbce4a..179f1b4ff9 100644 --- a/mem0/llms/anthropic.py +++ b/mem0/llms/anthropic.py @@ -38,7 +38,11 @@ def __init__(self, config: Optional[Union[BaseLlmConfig, AnthropicConfig, Dict]] self.config.model = "claude-sonnet-4-6" api_key = self.config.api_key or os.getenv("ANTHROPIC_API_KEY") - self.client = anthropic.Anthropic(api_key=api_key) + base_url = self.config.anthropic_base_url or os.getenv("ANTHROPIC_BASE_URL") + client_kwargs = {"api_key": api_key} + if base_url: + client_kwargs["base_url"] = base_url + self.client = anthropic.Anthropic(**client_kwargs) def _get_common_params(self, **kwargs) -> Dict: """Get common parameters, avoiding sending both temperature and top_p together. diff --git a/tests/llms/test_anthropic.py b/tests/llms/test_anthropic.py index ae8e67d45e..a0d103c2b8 100644 --- a/tests/llms/test_anthropic.py +++ b/tests/llms/test_anthropic.py @@ -83,6 +83,39 @@ def test_both_set_prefers_temperature_over_top_p(mock_anthropic_client): assert "top_p" not in call_kwargs +def test_configured_base_url_is_passed_to_client(): + """A configured anthropic_base_url must reach the Anthropic client constructor.""" + with patch("mem0.llms.anthropic.anthropic") as mock_anthropic: + config = AnthropicConfig( + model="claude-3-5-sonnet-20240620", + api_key="test-key", + anthropic_base_url="https://proxy.example.com", + ) + AnthropicLLM(config) + + assert mock_anthropic.Anthropic.call_args[1]["base_url"] == "https://proxy.example.com" + + +def test_base_url_falls_back_to_env(monkeypatch): + """When no base_url is configured, ANTHROPIC_BASE_URL is used.""" + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://env.example.com") + with patch("mem0.llms.anthropic.anthropic") as mock_anthropic: + config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key") + AnthropicLLM(config) + + assert mock_anthropic.Anthropic.call_args[1]["base_url"] == "https://env.example.com" + + +def test_base_url_omitted_when_unset(monkeypatch): + """With no base_url anywhere, base_url must not be forced to None on the client.""" + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + with patch("mem0.llms.anthropic.anthropic") as mock_anthropic: + config = AnthropicConfig(model="claude-3-5-sonnet-20240620", api_key="test-key") + AnthropicLLM(config) + + assert "base_url" not in mock_anthropic.Anthropic.call_args[1] + + 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") From 87bd2d91e0c260dc8962d25b2293ea1e014a0818 Mon Sep 17 00:00:00 2001 From: Yash Singh <123385188+yashs33244@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:27:43 +0530 Subject: [PATCH 03/11] fix(llms): preserve reasoning fields in base-to-provider config conversion (#5638) --- mem0/utils/factory.py | 9 +++++++ tests/utils/test_factory.py | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 tests/utils/test_factory.py diff --git a/mem0/utils/factory.py b/mem0/utils/factory.py index 451726bcf9..cf01ccafc9 100644 --- a/mem0/utils/factory.py +++ b/mem0/utils/factory.py @@ -1,4 +1,5 @@ import importlib +import inspect from typing import Dict, Optional, Union from mem0.configs.embeddings.base import BaseEmbedderConfig @@ -102,6 +103,14 @@ def create(cls, provider_name: str, config: Optional[Union[BaseLlmConfig, Dict]] "vision_details": config.vision_details, "http_client_proxies": config.http_client_proxies, } + # Only forward reasoning fields to provider configs that accept them + # (explicitly or via **kwargs); others would raise on unexpected kwargs. + params = inspect.signature(config_class).parameters + accepts_kwargs = any(p.kind == p.VAR_KEYWORD for p in params.values()) + if accepts_kwargs or "reasoning_effort" in params: + config_dict["reasoning_effort"] = config.reasoning_effort + if accepts_kwargs or "is_reasoning_model" in params: + config_dict["is_reasoning_model"] = config.is_reasoning_model config_dict.update(kwargs) config = config_class(**config_dict) else: diff --git a/tests/utils/test_factory.py b/tests/utils/test_factory.py new file mode 100644 index 0000000000..0f07bd2d5b --- /dev/null +++ b/tests/utils/test_factory.py @@ -0,0 +1,53 @@ +from unittest.mock import Mock, patch + +from mem0.configs.llms.anthropic import AnthropicConfig +from mem0.configs.llms.aws_bedrock import AWSBedrockConfig +from mem0.configs.llms.base import BaseLlmConfig +from mem0.configs.llms.openai import OpenAIConfig +from mem0.utils.factory import LlmFactory + + +def _capture_config(provider_name, config): + """Build an LLM via the factory and return the config it was constructed with.""" + captured = {} + + def fake_llm_class(built_config): + captured["config"] = built_config + return Mock() + + with patch("mem0.utils.factory.load_class", return_value=fake_llm_class): + LlmFactory.create(provider_name, config) + + return captured["config"] + + +def test_base_to_openai_preserves_reasoning_fields(): + base_config = BaseLlmConfig(model="o3", reasoning_effort="high", is_reasoning_model=True) + + built = _capture_config("openai", base_config) + + assert isinstance(built, OpenAIConfig) + assert built.reasoning_effort == "high" + assert built.is_reasoning_model is True + + +def test_base_to_kwargs_provider_preserves_reasoning_fields(): + # AWSBedrockConfig accepts the reasoning fields via **kwargs, so they must survive. + base_config = BaseLlmConfig(model="amazon.nova", reasoning_effort="medium", is_reasoning_model=True) + + built = _capture_config("aws_bedrock", base_config) + + assert isinstance(built, AWSBedrockConfig) + assert built.reasoning_effort == "medium" + assert built.is_reasoning_model is True + + +def test_base_to_provider_without_reasoning_fields_still_builds(): + # Anthropic config does not accept reasoning_effort/is_reasoning_model; + # the conversion must not forward unsupported kwargs to it. + base_config = BaseLlmConfig(model="claude-3-5-sonnet-20240620") + + built = _capture_config("anthropic", base_config) + + assert isinstance(built, AnthropicConfig) + assert built.model == "claude-3-5-sonnet-20240620" From 7fa996261d0a18d292a010557450a2146d6895c6 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Tue, 23 Jun 2026 18:59:16 +0800 Subject: [PATCH 04/11] perf: batch BM25 sparse encoding in Qdrant insert (#5592) --- mem0/vector_stores/qdrant.py | 46 +++++++++++++-- tests/vector_stores/test_qdrant.py | 93 ++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/mem0/vector_stores/qdrant.py b/mem0/vector_stores/qdrant.py index 25a6880583..3f29edc4b1 100644 --- a/mem0/vector_stores/qdrant.py +++ b/mem0/vector_stores/qdrant.py @@ -191,6 +191,43 @@ def insert(self, vectors: list, payloads: list = None, ids: list = None): ids (list, optional): List of IDs corresponding to vectors. Defaults to None. """ logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}") + + # Pre-compute BM25 sparse vectors in a single batch call. fastembed's + # embed() accepts a list of texts, so batching avoids per-row encoder + # overhead (model dispatch, tokenizer setup, etc.). + bm25_sparse_vectors: list[Optional[SparseVector]] = [None] * len(vectors) + if self._has_bm25_slot and payloads: + texts_for_bm25: list[str] = [] + indices_for_bm25: list[int] = [] + for idx, payload in enumerate(payloads): + text = payload.get("text_lemmatized") or payload.get("data", "") + if text: + texts_for_bm25.append(text) + indices_for_bm25.append(idx) + + if texts_for_bm25: + encoder = self._get_bm25_encoder() + if encoder is not None: + try: + sparse_results = list(encoder.embed(texts_for_bm25)) + if len(sparse_results) != len(texts_for_bm25): + logger.warning( + f"BM25 batch returned {len(sparse_results)} results for " + f"{len(texts_for_bm25)} texts; falling back to per-row encoding" + ) + raise ValueError("count mismatch") + for i, sparse in enumerate(sparse_results): + bm25_sparse_vectors[indices_for_bm25[i]] = SparseVector( + indices=sparse.indices.tolist(), + values=sparse.values.tolist(), + ) + except Exception as e: + # Fall back to per-row encoding so a single bad input + # doesn't drop BM25 for the whole batch. + logger.debug(f"Batch BM25 encoding failed, falling back to per-row: {e}") + for i, text in enumerate(texts_for_bm25): + bm25_sparse_vectors[indices_for_bm25[i]] = self._encode_bm25(text) + points = [] for idx, vector in enumerate(vectors): payload = payloads[idx] if payloads else {} @@ -198,12 +235,8 @@ def insert(self, vectors: list, payloads: list = None, ids: list = None): # Build named vectors: dense + optional BM25 sparse (only if collection has the slot). named_vectors = {"": vector} - if self._has_bm25_slot: - text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "") - if text_for_bm25: - sparse = self._encode_bm25(text_for_bm25) - if sparse is not None: - named_vectors["bm25"] = sparse + if self._has_bm25_slot and bm25_sparse_vectors[idx] is not None: + named_vectors["bm25"] = bm25_sparse_vectors[idx] points.append(PointStruct(id=point_id, vector=named_vectors, payload=payload)) @@ -476,6 +509,7 @@ def update(self, vector_id: int, vector: list = None, payload: dict = None): if self._has_bm25_slot: text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "") if text_for_bm25: + # Single-item update: per-row encoding is correct here; see insert() for the batch path. sparse = self._encode_bm25(text_for_bm25) if sparse is not None: named_vectors["bm25"] = sparse diff --git a/tests/vector_stores/test_qdrant.py b/tests/vector_stores/test_qdrant.py index 83de59cd39..3bee290528 100644 --- a/tests/vector_stores/test_qdrant.py +++ b/tests/vector_stores/test_qdrant.py @@ -18,6 +18,7 @@ PointStruct, PointVectors, Range, + SparseVector, SparseVectorParams, VectorParams, ) @@ -86,6 +87,98 @@ def test_insert(self): self.assertEqual(points[0].payload, payloads[0]) + def test_insert_batches_bm25_encoding(self): + """BM25 encoding must be done in a single batch call, not per-row. + + Regression test for the optimization that switched from looping over + `_encode_bm25(text)` to a single `encoder.embed(all_texts)` call. + """ + # Pretend the collection has the bm25 sparse slot (set up by create_col + # on a v3 collection) and stub the encoder to return predictable sparse + # vectors mimicking fastembed's output (objects with .indices/.values). + self.qdrant._has_bm25_slot = True + + encoder_mock = MagicMock() + encoder_mock.embed.return_value = iter( + [ + MagicMock(indices=MagicMock(tolist=lambda: [1, 2]), values=MagicMock(tolist=lambda: [0.5, 0.3])), + MagicMock(indices=MagicMock(tolist=lambda: [3]), values=MagicMock(tolist=lambda: [0.8])), + ] + ) + self.qdrant._bm25_encoder = encoder_mock + + vectors = [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]] + payloads = [ + {"data": "hello world"}, + {"text_lemmatized": "foo bar"}, + {"key": "no text — should skip BM25"}, + ] + ids = [str(uuid.uuid4()) for _ in range(3)] + + self.qdrant.insert(vectors=vectors, payloads=payloads, ids=ids) + + # 1. encoder.embed must be called exactly once (batched), not per-row. + encoder_mock.embed.assert_called_once() + called_texts = encoder_mock.embed.call_args[0][0] + self.assertEqual(called_texts, ["hello world", "foo bar"]) + + # 2. Resulting points carry the right named vectors: + # - Rows with text get both "" (dense) and "bm25" sparse. + # - Row without text gets dense only. + points = self.client_mock.upsert.call_args[1]["points"] + self.assertEqual(len(points), 3) + self.assertIn("bm25", points[0].vector) + self.assertEqual(points[0].vector["bm25"].indices, [1, 2]) + self.assertEqual(points[0].vector["bm25"].values, [0.5, 0.3]) + self.assertIn("bm25", points[1].vector) + self.assertEqual(points[1].vector["bm25"].indices, [3]) + self.assertNotIn("bm25", points[2].vector) + + def test_insert_skips_bm25_when_slot_missing(self): + """Pre-v3 collections (no bm25 slot) must not invoke the encoder at all.""" + self.qdrant._has_bm25_slot = False + encoder_mock = MagicMock() + self.qdrant._bm25_encoder = encoder_mock + + self.qdrant.insert( + vectors=[[0.1, 0.2]], + payloads=[{"data": "hello"}], + ids=[str(uuid.uuid4())], + ) + + encoder_mock.embed.assert_not_called() + points = self.client_mock.upsert.call_args[1]["points"] + self.assertNotIn("bm25", points[0].vector) + + def test_insert_falls_back_to_per_row_on_batch_failure(self): + """If batch encoding raises, each row should be re-encoded individually + so a single bad input doesn't drop BM25 for the whole batch.""" + self.qdrant._has_bm25_slot = True + + # Batch call raises; per-row _encode_bm25 should be used as fallback. + encoder_mock = MagicMock() + encoder_mock.embed.side_effect = RuntimeError("batch failed") + self.qdrant._bm25_encoder = encoder_mock + + # Use a real SparseVector — PointStruct validates the vector dict via + # Pydantic, which would coerce a bare MagicMock into [] (MagicMock is + # iterable). _encode_bm25's real return type is SparseVector. + fallback_sparse = SparseVector(indices=[7], values=[0.9]) + with patch.object(self.qdrant, "_encode_bm25", return_value=fallback_sparse) as fallback: + self.qdrant.insert( + vectors=[[0.1, 0.2], [0.3, 0.4]], + payloads=[{"data": "a"}, {"data": "b"}], + ids=[str(uuid.uuid4()), str(uuid.uuid4())], + ) + + encoder_mock.embed.assert_called_once() + self.assertEqual(fallback.call_count, 2) + self.assertEqual([c.args[0] for c in fallback.call_args_list], ["a", "b"]) + points = self.client_mock.upsert.call_args[1]["points"] + for point in points: + self.assertEqual(point.vector["bm25"].indices, [7]) + self.assertEqual(point.vector["bm25"].values, [0.9]) + def test_search(self): vectors = [[0.1, 0.2]] mock_point = MagicMock(id=str(uuid.uuid4()), score=0.95, payload={"key": "value"}) From 716f021df845d18330b832d18a9454ffbb1c415a Mon Sep 17 00:00:00 2001 From: Hrushikesh Yadav <136978914+HrushiYadav@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:38:28 +0530 Subject: [PATCH 05/11] fix(pinecone): map all comparison operators in _create_filter() (#5707) --- mem0/vector_stores/pinecone.py | 22 ++++++++++++++++++++-- tests/vector_stores/test_pinecone.py | 25 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/mem0/vector_stores/pinecone.py b/mem0/vector_stores/pinecone.py index 733db6ca8c..8c6ded7d7a 100644 --- a/mem0/vector_stores/pinecone.py +++ b/mem0/vector_stores/pinecone.py @@ -186,6 +186,17 @@ def _parse_output(self, data: Dict) -> List[OutputData]: return result + OPERATOR_MAP = { + "eq": "$eq", + "ne": "$ne", + "gt": "$gt", + "gte": "$gte", + "lt": "$lt", + "lte": "$lte", + "in": "$in", + "nin": "$nin", + } + def _create_filter(self, filters: Optional[Dict]) -> Dict: """ Create a filter dictionary from the provided filters. @@ -196,8 +207,15 @@ def _create_filter(self, filters: Optional[Dict]) -> Dict: pinecone_filter = {} for key, value in filters.items(): - if isinstance(value, dict) and "gte" in value and "lte" in value: - pinecone_filter[key] = {"$gte": value["gte"], "$lte": value["lte"]} + if isinstance(value, dict): + condition = {} + for op, operand in value.items(): + pc_op = self.OPERATOR_MAP.get(op) + if pc_op: + condition[pc_op] = operand + else: + condition[f"${op}"] = operand + pinecone_filter[key] = condition else: pinecone_filter[key] = {"$eq": value} diff --git a/tests/vector_stores/test_pinecone.py b/tests/vector_stores/test_pinecone.py index 06980fe822..1591bf0988 100644 --- a/tests/vector_stores/test_pinecone.py +++ b/tests/vector_stores/test_pinecone.py @@ -196,3 +196,28 @@ def test_list_error_returns_list_not_dict(pinecone_db): result = pinecone_db.list(filters={"user_id": "alice"}, top_k=10) assert isinstance(result, list) assert result == [[]] + + +def test_create_filter_plain_value(pinecone_db): + result = pinecone_db._create_filter({"user_id": "alice"}) + assert result == {"user_id": {"$eq": "alice"}} + + +def test_create_filter_range(pinecone_db): + result = pinecone_db._create_filter({"age": {"gte": 18, "lte": 65}}) + assert result == {"age": {"$gte": 18, "$lte": 65}} + + +def test_create_filter_gt_operator(pinecone_db): + result = pinecone_db._create_filter({"score": {"gt": 0.5}}) + assert result == {"score": {"$gt": 0.5}} + + +def test_create_filter_in_operator(pinecone_db): + result = pinecone_db._create_filter({"status": {"in": ["active", "pending"]}}) + assert result == {"status": {"$in": ["active", "pending"]}} + + +def test_create_filter_ne_operator(pinecone_db): + result = pinecone_db._create_filter({"status": {"ne": "deleted"}}) + assert result == {"status": {"$ne": "deleted"}} From c2e723352e2317328dec9982bc169be7d867a971 Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Tue, 23 Jun 2026 16:42:57 +0530 Subject: [PATCH 06/11] fix(ts-oss): return attributedTo from get/search/getAll (#5675) --- mem0-ts/src/oss/src/memory/index.ts | 12 ++++++- mem0-ts/src/oss/src/types/index.ts | 1 + mem0-ts/src/oss/tests/memory.crud.test.ts | 39 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index f41d161ac1..ee653da466 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -1189,7 +1189,13 @@ export class Memory { } } - const result = { ...memoryItem, ...filters }; + const result = { + ...memoryItem, + ...filters, + ...(memory.payload.attributedTo && { + attributedTo: memory.payload.attributedTo, + }), + }; await this._displayFirstRunNotice("get"); return result; } @@ -1453,6 +1459,7 @@ export class Memory { ...(payload.user_id && { user_id: payload.user_id }), ...(payload.agent_id && { agent_id: payload.agent_id }), ...(payload.run_id && { run_id: payload.run_id }), + ...(payload.attributedTo && { attributedTo: payload.attributedTo }), ...(scored.scoreDetails && { score_details: scored.scoreDetails }), }; }); @@ -1687,6 +1694,9 @@ export class Memory { ...(mem.payload.user_id && { user_id: mem.payload.user_id }), ...(mem.payload.agent_id && { agent_id: mem.payload.agent_id }), ...(mem.payload.run_id && { run_id: mem.payload.run_id }), + ...(mem.payload.attributedTo && { + attributedTo: mem.payload.attributedTo, + }), })); const result = { results }; diff --git a/mem0-ts/src/oss/src/types/index.ts b/mem0-ts/src/oss/src/types/index.ts index f52bbdb979..422f208e13 100644 --- a/mem0-ts/src/oss/src/types/index.ts +++ b/mem0-ts/src/oss/src/types/index.ts @@ -82,6 +82,7 @@ export interface MemoryItem { updatedAt?: string; score?: number; metadata?: Record; + attributedTo?: string; } export interface SearchFilters { diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts index 7fb8d01d48..55db5d5f42 100644 --- a/mem0-ts/src/oss/tests/memory.crud.test.ts +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -361,6 +361,45 @@ describe("Memory - search()", () => { }); }); +// ─── attributedTo (#5666) ──────────────────────────────── + +describe("Memory - attributedTo round-trip (#5666)", () => { + let memory: Memory; + const userId = `attributed_test_${Date.now()}`; + let id: string; + + beforeAll(async () => { + memory = createMemory(); + // The mocked LLM tags every extracted fact with attributed_to: "user". + const addResult: SearchResult = await memory.add("I love AI", { userId }); + id = addResult.results[0].id; + }); + + afterAll(async () => { + await memory.reset(); + }); + + test("get() surfaces attributedTo", async () => { + const item: MemoryItem | null = await memory.get(id); + expect(item!.attributedTo).toBe("user"); + }); + + test("getAll() surfaces attributedTo", async () => { + const result: SearchResult = await memory.getAll({ + filters: { user_id: userId }, + }); + expect(result.results[0].attributedTo).toBe("user"); + }); + + test("search() surfaces attributedTo", async () => { + const result: SearchResult = await memory.search("AI", { + filters: { user_id: userId }, + }); + expect(result.results.length).toBeGreaterThan(0); + expect(result.results[0].attributedTo).toBe("user"); + }); +}); + // ─── history() ─────────────────────────────────────────── describe("Memory - history()", () => { From 879c68555c94152ca3aefeac5fcda28cbc791d2b Mon Sep 17 00:00:00 2001 From: Yash Singh <123385188+yashs33244@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:43:30 +0530 Subject: [PATCH 07/11] fix(memory): return attributed_to from get/get_all/search (#5629) --- mem0/memory/main.py | 6 ++++ tests/test_memory.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 45511a973f..b0335e501a 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -1102,6 +1102,7 @@ def get(self, memory_id): "run_id", "actor_id", "role", + "attributed_to", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} @@ -1215,6 +1216,7 @@ def _get_all_from_vector_store(self, filters, limit): "run_id", "actor_id", "role", + "attributed_to", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} @@ -1550,6 +1552,7 @@ def _search_vector_store(self, query, filters, limit, threshold=0.1, explain=Fal "run_id", "actor_id", "role", + "attributed_to", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} @@ -2638,6 +2641,7 @@ async def get(self, memory_id): "run_id", "actor_id", "role", + "attributed_to", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} @@ -2751,6 +2755,7 @@ async def _get_all_from_vector_store(self, filters, limit): "run_id", "actor_id", "role", + "attributed_to", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} @@ -3092,6 +3097,7 @@ async def _search_vector_store(self, query, filters, limit, threshold=0.1, expla "run_id", "actor_id", "role", + "attributed_to", ] core_and_promoted_keys = {"data", "hash", "created_at", "updated_at", "id", "text_lemmatized", "attributed_to", *promoted_payload_keys} diff --git a/tests/test_memory.py b/tests/test_memory.py index da00953ef0..5c277f50bb 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -345,6 +345,91 @@ def test_get_all_handles_flat_list_from_postgres(mock_sqlite, mock_llm_factory, assert result[1]["memory"] == "Memory 2" +@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_read_apis_surface_attributed_to(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): + """ + attributed_to is written to the payload on add (and the extraction prompt marks it + required), so get/get_all/search must return it instead of dropping it. It must be a + top-level field, not buried inside metadata. + """ + mock_embedder = MagicMock() + mock_embedder.embed.return_value = [0.1, 0.2, 0.3] + mock_embedder_factory.return_value = mock_embedder + 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 as MemoryClass + memory = MemoryClass(MemoryConfig()) + memory.embedding_model = mock_embedder + + payload = {"data": "User likes Python", "attributed_to": "user", "user_id": "u1"} + + # get + mock_vector_store.get.return_value = MockVectorMemory("mem_1", payload) + got = memory.get("mem_1") + assert got["attributed_to"] == "user" + assert "attributed_to" not in (got.get("metadata") or {}) + + # get_all + mock_vector_store.list.return_value = [MockVectorMemory("mem_1", payload)] + listed = memory._get_all_from_vector_store({"user_id": "u1"}, 100) + assert listed[0]["attributed_to"] == "user" + assert "attributed_to" not in (listed[0].get("metadata") or {}) + + # search + mock_vector_store.search.return_value = [MockVectorMemory("mem_1", payload, score=0.9)] + mock_vector_store.keyword_search.return_value = [] + searched = memory._search_vector_store("python", {"user_id": "u1"}, 10) + assert searched[0]["attributed_to"] == "user" + assert "attributed_to" not in (searched[0].get("metadata") or {}) + + +@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_read_apis_surface_attributed_to(mock_sqlite, mock_llm_factory, mock_vector_factory, mock_embedder_factory): + """AsyncMemory get/get_all/search must surface attributed_to, same as the sync path.""" + mock_embedder = MagicMock() + mock_embedder.embed.return_value = [0.1, 0.2, 0.3] + mock_embedder_factory.return_value = mock_embedder + 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 + memory = AsyncMemory(MemoryConfig()) + memory.embedding_model = mock_embedder + + payload = {"data": "User likes Python", "attributed_to": "user", "user_id": "u1"} + + # get + mock_vector_store.get.return_value = MockVectorMemory("mem_1", payload) + got = await memory.get("mem_1") + assert got["attributed_to"] == "user" + assert "attributed_to" not in (got.get("metadata") or {}) + + # get_all + mock_vector_store.list.return_value = [MockVectorMemory("mem_1", payload)] + listed = await memory._get_all_from_vector_store({"user_id": "u1"}, 100) + assert listed[0]["attributed_to"] == "user" + assert "attributed_to" not in (listed[0].get("metadata") or {}) + + # search + mock_vector_store.search.return_value = [MockVectorMemory("mem_1", payload, score=0.9)] + mock_vector_store.keyword_search.return_value = [] + searched = await memory._search_vector_store("python", {"user_id": "u1"}, 10) + assert searched[0]["attributed_to"] == "user" + assert "attributed_to" not in (searched[0].get("metadata") or {}) + + @patch('mem0.utils.factory.EmbedderFactory.create') @patch('mem0.utils.factory.VectorStoreFactory.create') @patch('mem0.utils.factory.LlmFactory.create') From c0ac9f81fa54a34b982b05654fa45ec43717c9dc Mon Sep 17 00:00:00 2001 From: Hrushikesh Yadav <136978914+HrushiYadav@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:48:47 +0530 Subject: [PATCH 08/11] fix(milvus): wrap scalar vector_id in list for delete() (#5704) --- mem0/vector_stores/milvus.py | 2 +- tests/vector_stores/test_milvus.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mem0/vector_stores/milvus.py b/mem0/vector_stores/milvus.py index 80a4d0ffaf..6c8ae885f8 100644 --- a/mem0/vector_stores/milvus.py +++ b/mem0/vector_stores/milvus.py @@ -262,7 +262,7 @@ def delete(self, vector_id): Args: vector_id (str): ID of the vector to delete. """ - self.client.delete(collection_name=self.collection_name, ids=vector_id) + self.client.delete(collection_name=self.collection_name, ids=[vector_id]) def update(self, vector_id=None, vector=None, payload=None): """ diff --git a/tests/vector_stores/test_milvus.py b/tests/vector_stores/test_milvus.py index b0109ca56a..9acd841400 100644 --- a/tests/vector_stores/test_milvus.py +++ b/tests/vector_stores/test_milvus.py @@ -178,7 +178,7 @@ def test_delete(self, milvus_db, mock_milvus_client): mock_milvus_client.delete.assert_called_once_with( collection_name="test_collection", - ids=vector_id + ids=[vector_id] ) def test_get(self, milvus_db, mock_milvus_client): From 565db27121e4a264c55330896d4c29f4888c5b60 Mon Sep 17 00:00:00 2001 From: Hrushikesh Yadav <136978914+HrushiYadav@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:51:05 +0530 Subject: [PATCH 09/11] fix(milvus,baidu): sanitize filter values to prevent expression injection (#5746) --- mem0/vector_stores/baidu.py | 15 ++++++++++++-- mem0/vector_stores/milvus.py | 15 ++++++++++++-- tests/vector_stores/test_baidu.py | 32 ++++++++++++++++++++++++++++++ tests/vector_stores/test_milvus.py | 27 +++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/mem0/vector_stores/baidu.py b/mem0/vector_stores/baidu.py index a35e5b10fe..e9437232dd 100644 --- a/mem0/vector_stores/baidu.py +++ b/mem0/vector_stores/baidu.py @@ -1,4 +1,5 @@ import logging +import re import time from typing import Dict, Optional @@ -47,6 +48,8 @@ class OutputData(BaseModel): class BaiduDB(VectorStoreBase): + _SAFE_FILTER_KEY = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + def __init__( self, endpoint: str, @@ -404,8 +407,16 @@ def _create_filter(self, filters: dict) -> str: """ conditions = [] for key, value in filters.items(): + if not self._SAFE_FILTER_KEY.match(key): + raise ValueError(f"Invalid filter key: {key!r}") if isinstance(value, str): - conditions.append(f'metadata["{key}"] = "{value}"') - else: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + conditions.append(f'metadata["{key}"] = "{escaped}"') + elif isinstance(value, (int, float, bool)): conditions.append(f'metadata["{key}"] = {value}') + else: + raise ValueError( + f"Filter value for {key!r} must be str, int, float, or bool, " + f"got {type(value).__name__}" + ) return " AND ".join(conditions) diff --git a/mem0/vector_stores/milvus.py b/mem0/vector_stores/milvus.py index 6c8ae885f8..748553ccc6 100644 --- a/mem0/vector_stores/milvus.py +++ b/mem0/vector_stores/milvus.py @@ -1,4 +1,5 @@ import logging +import re from typing import Dict, Optional from pydantic import BaseModel @@ -143,6 +144,8 @@ def _build_record(idx, embedding, metadata): data = [_build_record(idx, embedding, metadata) for idx, embedding, metadata in zip(ids, vectors, payloads)] self.client.insert(collection_name=self.collection_name, data=data, **kwargs) + _SAFE_FILTER_KEY = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + def _create_filter(self, filters: dict): """Prepare filters for efficient query. @@ -154,10 +157,18 @@ def _create_filter(self, filters: dict): """ operands = [] for key, value in filters.items(): + if not self._SAFE_FILTER_KEY.match(key): + raise ValueError(f"Invalid filter key: {key!r}") if isinstance(value, str): - operands.append(f'(metadata["{key}"] == "{value}")') - else: + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + operands.append(f'(metadata["{key}"] == "{escaped}")') + elif isinstance(value, (int, float, bool)): operands.append(f'(metadata["{key}"] == {value})') + else: + raise ValueError( + f"Filter value for {key!r} must be str, int, float, or bool, " + f"got {type(value).__name__}" + ) return " and ".join(operands) diff --git a/tests/vector_stores/test_baidu.py b/tests/vector_stores/test_baidu.py index cd615feb77..a3777a1d89 100644 --- a/tests/vector_stores/test_baidu.py +++ b/tests/vector_stores/test_baidu.py @@ -235,3 +235,35 @@ def test_col_info(mochow_instance, mock_mochow_client): result = mochow_instance.col_info() assert result == mock_table_info + + +def test_create_filter_rejects_dict_value(mochow_instance): + """Dict filter values could contain expression injection payloads.""" + with pytest.raises(ValueError, match="must be str, int, float, or bool"): + mochow_instance._create_filter({"user_id": {"$ne": ""}}) + + +def test_create_filter_rejects_malicious_key(mochow_instance): + """Keys with special characters must be rejected.""" + with pytest.raises(ValueError, match="Invalid filter key"): + mochow_instance._create_filter({'"] = "") or true or ("': "x"}) + + +def test_create_filter_escapes_quotes_in_value(mochow_instance): + """Double-quotes inside string values must be escaped.""" + result = mochow_instance._create_filter({"user_id": 'alice"}'}) + assert '\\"' in result + assert 'alice\\"' in result + + +def test_create_filter_escapes_backslash_and_quote(mochow_instance): + """Backslashes and double-quotes in the same value must both be escaped.""" + result = mochow_instance._create_filter({"user_id": r'alice\path"beta'}) + assert result == r'metadata["user_id"] = "alice\\path\"beta"' + + +def test_create_filter_renders_boolean(mochow_instance): + """Boolean values must be rendered unquoted in the backend's expected format.""" + result = mochow_instance._create_filter({"active": True, "deleted": False}) + assert 'metadata["active"] = True' in result + assert 'metadata["deleted"] = False' in result diff --git a/tests/vector_stores/test_milvus.py b/tests/vector_stores/test_milvus.py index 9acd841400..e17aa3b670 100644 --- a/tests/vector_stores/test_milvus.py +++ b/tests/vector_stores/test_milvus.py @@ -310,6 +310,33 @@ def test_update_with_none_vector_raises_on_missing_vector_data(self, milvus_db, with pytest.raises(ValueError, match="no vector data"): milvus_db.update(vector_id="test_id", vector=None, payload={"data": "test"}) + def test_create_filter_rejects_expression_injection(self, milvus_db): + """Crafted string value must not break out of the quoted expression.""" + with pytest.raises(ValueError, match="must be str, int, float, or bool"): + milvus_db._create_filter({"user_id": {"$ne": ""}}) + + def test_create_filter_rejects_malicious_key(self, milvus_db): + """Keys with special characters must be rejected.""" + with pytest.raises(ValueError, match="Invalid filter key"): + milvus_db._create_filter({'"] == "") or true or ("': "x"}) + + def test_create_filter_escapes_quotes_in_value(self, milvus_db): + """Double-quotes inside string values must be escaped.""" + result = milvus_db._create_filter({"user_id": 'alice"}'}) + assert '\\"' in result + assert 'alice\\"' in result + + def test_create_filter_escapes_backslash_and_quote(self, milvus_db): + """Backslashes and double-quotes in the same value must both be escaped.""" + result = milvus_db._create_filter({"user_id": r'alice\path"beta'}) + assert result == r'(metadata["user_id"] == "alice\\path\"beta")' + + def test_create_filter_renders_boolean(self, milvus_db): + """Boolean values must be rendered unquoted in the backend's expected format.""" + result = milvus_db._create_filter({"active": True, "deleted": False}) + assert '(metadata["active"] == True)' in result + assert '(metadata["deleted"] == False)' in result + def test_collection_already_exists(self, mock_milvus_client): """Test that existing collection is not recreated.""" mock_milvus_client.has_collection.return_value = True From ced4af681fefa1a88110bb79458d5451bf1655af Mon Sep 17 00:00:00 2001 From: Bartok Date: Tue, 23 Jun 2026 07:22:35 -0400 Subject: [PATCH 10/11] fix(claude-plugin): rerank auto-injected memory context by default (#5690) --- integrations/mem0-plugin/scripts/_search.py | 19 ++++++ .../mem0-plugin/scripts/file_context.py | 3 +- .../mem0-plugin/scripts/on_bash_output.sh | 7 ++- .../mem0-plugin/scripts/on_user_prompt.sh | 7 ++- integrations/mem0-plugin/tests/test_search.py | 61 +++++++++++++++++++ 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/integrations/mem0-plugin/scripts/_search.py b/integrations/mem0-plugin/scripts/_search.py index 8f32862f22..5a19cfd41f 100644 --- a/integrations/mem0-plugin/scripts/_search.py +++ b/integrations/mem0-plugin/scripts/_search.py @@ -7,12 +7,31 @@ from __future__ import annotations import json +import os import urllib.request SEARCH_URL = "https://api.mem0.ai/v3/memories/search/" SEARCH_TIMEOUT = 5 +def should_rerank() -> bool: + """Whether auto-injection searches should request Platform reranking. + + The REST search endpoint does not rerank when ``rerank`` is omitted, so + auto-injected context is ordered by raw vector similarity and the single + most relevant memory can fall outside the injected top_k window. We default + reranking ON for the hook-driven injection path (the extra ~150-200ms is + well within the hook's curl budget) and let users opt out via MEM0_RERANK. + + MEM0_RERANK is read case-insensitively; ``0``, ``false``, ``no``, and + ``off`` disable reranking. Anything else (including unset) enables it. + """ + raw = os.environ.get("MEM0_RERANK") + if raw is None: + return True + return raw.strip().lower() not in ("0", "false", "no", "off", "") + + def _do_search(api_key: str, payload: dict) -> list[dict]: body = json.dumps(payload).encode() req = urllib.request.Request( diff --git a/integrations/mem0-plugin/scripts/file_context.py b/integrations/mem0-plugin/scripts/file_context.py index 8256ae6fae..a1ebf27354 100644 --- a/integrations/mem0-plugin/scripts/file_context.py +++ b/integrations/mem0-plugin/scripts/file_context.py @@ -23,7 +23,7 @@ from _formatting import TYPE_ICONS, format_age from _identity import resolve_api_key, resolve_user_id from _project import resolve_project_id -from _search import search_memories +from _search import search_memories, should_rerank FILE_READ_GATE_MIN_BYTES = 1500 MAX_RESULTS = 5 @@ -93,6 +93,7 @@ def search_file_context( api_key, user_id, project_id, query, top_k=MAX_RESULTS, threshold=0.3, global_search=global_search, + rerank=should_rerank(), ) results = results[:MAX_RESULTS] diff --git a/integrations/mem0-plugin/scripts/on_bash_output.sh b/integrations/mem0-plugin/scripts/on_bash_output.sh index 698729f738..dc8621fc9e 100755 --- a/integrations/mem0-plugin/scripts/on_bash_output.sh +++ b/integrations/mem0-plugin/scripts/on_bash_output.sh @@ -77,15 +77,16 @@ RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_QUERY="$ERROR_QUERY" MEM0_SEARCH_ python3 -c " import os, sys sys.path.insert(0, os.environ.get('PYTHONPATH', '.')) -from _search import search_memories, format_results_for_context +from _search import search_memories, format_results_for_context, should_rerank api_key = os.environ.get('MEM0_API_KEY', '') user_id = os.environ.get('MEM0_SEARCH_USER', 'default') project_id = os.environ.get('MEM0_PROJECT_ID', 'unknown') query = os.environ.get('MEM0_SEARCH_QUERY', '') +rerank = should_rerank() -r1 = search_memories(api_key, user_id, project_id, query, metadata_type='anti_pattern', top_k=3) -r2 = search_memories(api_key, user_id, project_id, query, metadata_type='bug_fix', top_k=3) +r1 = search_memories(api_key, user_id, project_id, query, metadata_type='anti_pattern', top_k=3, rerank=rerank) +r2 = search_memories(api_key, user_id, project_id, query, metadata_type='bug_fix', top_k=3, rerank=rerank) seen = set() combined = [] diff --git a/integrations/mem0-plugin/scripts/on_user_prompt.sh b/integrations/mem0-plugin/scripts/on_user_prompt.sh index a65af96c4c..3f48fc01f3 100755 --- a/integrations/mem0-plugin/scripts/on_user_prompt.sh +++ b/integrations/mem0-plugin/scripts/on_user_prompt.sh @@ -120,14 +120,15 @@ if [ -n "$HAS_RESUME" ]; then RESUME_RESULTS=$(PYTHONPATH="$SCRIPT_DIR" MEM0_SEARCH_USER="$USER_ID" python3 -c " import os, sys sys.path.insert(0, os.environ.get('PYTHONPATH', '.')) -from _search import search_memories, format_results_for_context +from _search import search_memories, format_results_for_context, should_rerank api_key = os.environ.get('MEM0_API_KEY', '') user_id = os.environ.get('MEM0_SEARCH_USER', 'default') project_id = os.environ.get('MEM0_PROJECT_ID', 'unknown') +rerank = should_rerank() -state = search_memories(api_key, user_id, project_id, 'session state current task', metadata_type='session_state', top_k=3) -decisions = search_memories(api_key, user_id, project_id, 'recent decisions and learnings', metadata_type='decision', top_k=3) +state = search_memories(api_key, user_id, project_id, 'session state current task', metadata_type='session_state', top_k=3, rerank=rerank) +decisions = search_memories(api_key, user_id, project_id, 'recent decisions and learnings', metadata_type='decision', top_k=3, rerank=rerank) all_r = state + decisions seen = set() diff --git a/integrations/mem0-plugin/tests/test_search.py b/integrations/mem0-plugin/tests/test_search.py index fd9f91de7d..ccf1437d30 100644 --- a/integrations/mem0-plugin/tests/test_search.py +++ b/integrations/mem0-plugin/tests/test_search.py @@ -101,6 +101,67 @@ def test_search_memories_no_api_key_returns_empty(): assert results == [] +def test_search_memories_omits_rerank_by_default(): + """Regression for #5684: rerank must not be sent unless requested.""" + from _search import search_memories + + captured_body = {} + + def mock_urlopen(req, timeout=None): + captured_body.update(json.loads(req.data.decode())) + resp = MagicMock() + resp.read.return_value = json.dumps({"results": []}).encode() + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + search_memories("key", "user", "proj", "query") + + assert "rerank" not in captured_body + + +def test_search_memories_forwards_rerank_true(): + """Regression for #5684: rerank=True must reach the request body so the + REST endpoint actually reranks (it does not rerank when omitted).""" + from _search import search_memories + + captured_body = {} + + def mock_urlopen(req, timeout=None): + captured_body.update(json.loads(req.data.decode())) + resp = MagicMock() + resp.read.return_value = json.dumps({"results": []}).encode() + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + with patch("urllib.request.urlopen", side_effect=mock_urlopen): + search_memories("key", "user", "proj", "query", rerank=True) + + assert captured_body.get("rerank") is True + + +def test_should_rerank_defaults_true(monkeypatch): + """Regression for #5684: auto-injection reranks by default.""" + from _search import should_rerank + + monkeypatch.delenv("MEM0_RERANK", raising=False) + assert should_rerank() is True + + +def test_should_rerank_opt_out_values(monkeypatch): + from _search import should_rerank + + for falsey in ("0", "false", "False", "NO", "off", ""): + monkeypatch.setenv("MEM0_RERANK", falsey) + assert should_rerank() is False, falsey + + for truthy in ("1", "true", "yes", "on"): + monkeypatch.setenv("MEM0_RERANK", truthy) + assert should_rerank() is True, truthy + + def test_format_results_for_context(): from _search import format_results_for_context From 1678e682ee70bae5c3302de5c0e39b71dfc919e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=B0=AC=ED=9D=AC?= Date: Tue, 23 Jun 2026 20:48:00 +0900 Subject: [PATCH 11/11] fix(ts-sdk): check message.role instead of content for system messages (#3921) --- mem0-ts/src/oss/src/memory/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index ee653da466..947e8c77fb 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -717,7 +717,7 @@ export class Memory { if (!infer) { const returnedMemories: MemoryItem[] = []; for (const message of messages) { - if (message.content === "system") { + if (message.role === "system") { continue; } const memoryId = await this.createMemory(