diff --git a/mem0/client/main.py b/mem0/client/main.py index d4633709f..01798bd7a 100644 --- a/mem0/client/main.py +++ b/mem0/client/main.py @@ -33,6 +33,15 @@ setup_config() ENTITY_PARAMS = frozenset({"user_id", "agent_id", "app_id", "run_id"}) +def _validate_and_trim_search_query(query: str) -> str: + if not isinstance(query, str): + raise ValueError("Invalid query: must be a non-empty string.") + trimmed = query.strip() + if not trimmed: + raise ValueError("Invalid query: cannot be empty or whitespace-only.") + return trimmed + + def _maybe_alias_anon_to_email(user_email): """Fire $identify per prior anon ID so PostHog merges them into email. @@ -306,6 +315,7 @@ class MemoryClient: kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} params = self._prepare_params(kwargs) + query = _validate_and_trim_search_query(query) payload = {"query": query, **params} response = self.client.post("/v3/memories/search/", json=payload) @@ -1221,6 +1231,7 @@ class AsyncMemoryClient: kwargs = {**(options.model_dump(exclude_unset=True) if options else {}), **kwargs} params = self._prepare_params(kwargs) + query = _validate_and_trim_search_query(query) payload = {"query": query, **params} response = await self.async_client.post("/v3/memories/search/", json=payload) diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 0caccbd96..309583c85 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -169,6 +169,21 @@ def _validate_search_params(threshold: Optional[float] = None, top_k: Optional[i ) +def _validate_and_trim_search_query(query: str) -> str: + """ + Validates and normalizes a search query before embedding/vector search. + + Raises: + ValueError: If query is not a string or is empty/whitespace-only. + """ + if not isinstance(query, str): + raise ValueError("Invalid query: must be a non-empty string.") + trimmed = query.strip() + if not trimmed: + raise ValueError("Invalid query: cannot be empty or whitespace-only.") + return trimmed + + def _is_sensitive_field(field_name: str) -> bool: """Check if a field should be redacted for telemetry safety. @@ -1178,6 +1193,7 @@ class Memory(MemoryBase): # Validate search parameters (before applying defaults) _validate_search_params(threshold=threshold, top_k=top_k) + query = _validate_and_trim_search_query(query) # Validate and trim entity IDs in filters effective_filters = filters.copy() if filters else {} @@ -2620,6 +2636,7 @@ class AsyncMemory(MemoryBase): # Validate search parameters (before applying defaults) _validate_search_params(threshold=threshold, top_k=top_k) + query = _validate_and_trim_search_query(query) # Validate and trim entity IDs in filters effective_filters = filters.copy() if filters else {} diff --git a/tests/test_client.py b/tests/test_client.py index 66d01a4c0..c776ecb05 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -26,6 +26,28 @@ def mock_memory_client(): class TestSearchEntityParamRejection: """Tests that top-level entity params are rejected in search().""" + @pytest.mark.parametrize("query", ["", " ", "\n\t"]) + def test_search_rejects_empty_query(self, mock_memory_client, query): + """search() should reject empty or whitespace-only queries before API calls.""" + with pytest.raises(ValueError, match="Invalid query.*empty or whitespace-only"): + mock_memory_client.search(query, filters={"user_id": "u1"}) + + mock_memory_client.client.post.assert_not_called() + + def test_search_trims_query_before_api_call(self, mock_memory_client): + """search() should send the normalized query to the API.""" + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_memory_client.client.post.return_value = mock_response + + mock_memory_client.search(" test query ", filters={"user_id": "u1"}) + + mock_memory_client.client.post.assert_called_once_with( + "/v3/memories/search/", + json={"query": "test query", "filters": {"user_id": "u1"}}, + ) + def test_search_rejects_user_id_kwarg(self, mock_memory_client): """search() should reject user_id as top-level kwarg.""" with pytest.raises(ValueError, match=r"user_id"): diff --git a/tests/test_main.py b/tests/test_main.py index ae105b9de..e4396ebee 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -290,6 +290,29 @@ class TestEntityIdValidation: class TestSearchParamValidation: """Tests for search parameter validation (threshold and top_k).""" + @pytest.mark.parametrize("query", ["", " ", "\n\t"]) + def test_search_rejects_empty_query(self, memory_instance, query): + """Search should reject empty or whitespace-only queries before retrieval work.""" + memory_instance.embedding_model.embed = Mock() + + with pytest.raises(ValueError, match="Invalid query.*empty or whitespace-only"): + memory_instance.search(query, filters={"user_id": "test"}) + + memory_instance.embedding_model.embed.assert_not_called() + + def test_search_trims_query_before_embedding(self, memory_instance): + """Search should normalize leading/trailing whitespace before embedding.""" + mock_memories = [] + memory_instance.vector_store.search = Mock(return_value=mock_memories) + memory_instance.vector_store.keyword_search = Mock(return_value=None) + memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) + + with patch("mem0.memory.main.lemmatize_for_bm25", return_value="test"), \ + patch("mem0.memory.main.extract_entities", return_value=[]): + memory_instance.search(" test ", filters={"user_id": "test"}) + + memory_instance.embedding_model.embed.assert_called_once_with("test", "search") + def test_search_rejects_threshold_above_1(self, memory_instance): """Search should reject threshold > 1.""" with pytest.raises(ValueError, match="Invalid threshold.*Must be between 0 and 1"):