diff --git a/src/google/adk_community/memory/__init__.py b/src/google/adk_community/memory/__init__.py index 1f3442c0..f502f6e1 100644 --- a/src/google/adk_community/memory/__init__.py +++ b/src/google/adk_community/memory/__init__.py @@ -14,11 +14,14 @@ """Community memory services for ADK.""" +from .dakera_memory_service import DakeraMemoryService +from .dakera_memory_service import DakeraMemoryServiceConfig from .open_memory_service import OpenMemoryService from .open_memory_service import OpenMemoryServiceConfig __all__ = [ + "DakeraMemoryService", + "DakeraMemoryServiceConfig", "OpenMemoryService", "OpenMemoryServiceConfig", ] - diff --git a/src/google/adk_community/memory/dakera_memory_service.py b/src/google/adk_community/memory/dakera_memory_service.py new file mode 100644 index 00000000..e3ea4e05 --- /dev/null +++ b/src/google/adk_community/memory/dakera_memory_service.py @@ -0,0 +1,347 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import logging +import os +import re +from typing import Optional +from typing import TYPE_CHECKING + +from google.adk.memory import _utils +from google.adk.memory.base_memory_service import BaseMemoryService +from google.adk.memory.base_memory_service import SearchMemoryResponse +from google.adk.memory.memory_entry import MemoryEntry +from google.genai import types +import httpx +from pydantic import BaseModel +from pydantic import Field +from typing_extensions import override + +from .utils import extract_text_from_event + +if TYPE_CHECKING: + from google.adk.sessions.session import Session + +logger = logging.getLogger('google_adk.' + __name__) + + +class DakeraMemoryService(BaseMemoryService): + """Memory service implementation using Dakera. + + Dakera (https://dakera.ai) is a self-hosted memory server that persists agent + memories across sessions and ranks recall by an access-weighted importance + decay model, so frequently-used memories survive while stale ones fade. + + Session events are stored to ``POST /v1/memory/store`` and retrieved with + semantic recall via ``POST /v1/memory/recall``. Memories are namespaced by a + Dakera ``agent_id`` derived from the ADK ``app_name`` and ``user_id`` so each + app/user keeps an isolated memory space; ``session_id`` is preserved for + provenance. + + Self-host the server (with its object store) using the public compose file:: + + git clone https://github.com/dakera-ai/dakera-deploy + cd dakera-deploy && docker compose up -d # server on :3000 + MinIO + + Then initialise the service with your server URL and API key:: + + from google.adk_community.memory import DakeraMemoryService + + memory_service = DakeraMemoryService( + base_url="http://localhost:3000", # or set DAKERA_API_URL + api_key="dk-...", # or set DAKERA_API_KEY + ) + """ + + def __init__( + self, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + config: Optional[DakeraMemoryServiceConfig] = None, + ): + """Initializes the Dakera memory service. + + Args: + base_url: Base URL of the Dakera server. Defaults to the + ``DAKERA_API_URL`` environment variable, falling back to + ``http://localhost:3000``. + api_key: API key for authentication (a ``dk-...`` token). Defaults to + the ``DAKERA_API_KEY`` environment variable. **Required** — must be + provided directly or via the environment. + config: DakeraMemoryServiceConfig instance. If None, uses defaults. + + Raises: + ValueError: If no API key is provided or resolvable from the + environment. + """ + resolved_key = api_key or os.environ.get('DAKERA_API_KEY', '') + if not resolved_key: + raise ValueError( + 'api_key is required for Dakera. Provide an API key when ' + 'initializing DakeraMemoryService or set DAKERA_API_KEY.' + ) + resolved_url = base_url or os.environ.get( + 'DAKERA_API_URL', 'http://localhost:3000' + ) + self._base_url = resolved_url.rstrip('/') + self._api_key = resolved_key + self._config = config or DakeraMemoryServiceConfig() + + def _namespace(self, app_name: str, user_id: str) -> str: + """Derive the Dakera agent_id (namespace) for an app/user pair.""" + return f'{app_name}:{user_id}' + + def _determine_importance(self, author: Optional[str]) -> float: + """Determine importance based on the content author. + + User-authored content is weighted highest, then model output, so Dakera's + decay engine retains the most task-relevant memories longest. + """ + if not author: + return self._config.default_importance + + author_lower = author.lower() + if author_lower == 'user': + return self._config.user_content_importance + elif author_lower == 'model': + return self._config.model_content_importance + else: + return self._config.default_importance + + def _prepare_memory_data(self, event, content_text: str, session) -> dict: + """Prepare the ``/v1/memory/store`` request payload for one event.""" + timestamp_str = None + if event.timestamp: + timestamp_str = _utils.format_timestamp(event.timestamp) + + # Embed author and timestamp in the content so they can be recovered on + # recall (Dakera returns stored content verbatim). + # Format: [Author: user, Time: 2025-11-04T10:32:01] Content text + enriched_content = content_text + metadata_parts = [] + if event.author: + metadata_parts.append(f'Author: {event.author}') + if timestamp_str: + metadata_parts.append(f'Time: {timestamp_str}') + + if metadata_parts: + metadata_prefix = '[' + ', '.join(metadata_parts) + '] ' + enriched_content = metadata_prefix + content_text + + memory_data = { + 'content': enriched_content, + 'agent_id': self._namespace(session.app_name, session.user_id), + 'memory_type': self._config.memory_type, + 'session_id': session.id, + 'importance': self._determine_importance(event.author), + 'metadata': { + 'app_name': session.app_name, + 'user_id': session.user_id, + 'session_id': session.id, + 'event_id': event.id, + 'invocation_id': event.invocation_id, + 'author': event.author, + 'timestamp': event.timestamp, + 'source': 'adk_session', + }, + } + + if self._config.enable_metadata_tags: + tags = [ + f'session:{session.id}', + f'app:{session.app_name}', + ] + if event.author: + tags.append(f'author:{event.author}') + memory_data['tags'] = tags + + return memory_data + + @override + async def add_session_to_memory(self, session: Session): + """Add a session's events to Dakera memory.""" + memories_added = 0 + + async with httpx.AsyncClient(timeout=self._config.timeout) as http_client: + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self._api_key}', + } + + for event in session.events: + content_text = extract_text_from_event(event) + if not content_text: + continue + + payload = self._prepare_memory_data(event, content_text, session) + + try: + response = await http_client.post( + f'{self._base_url}/v1/memory/store', + json=payload, + headers=headers, + ) + response.raise_for_status() + memories_added += 1 + logger.debug('Added memory for event %s', event.id) + except httpx.HTTPStatusError as e: + logger.error( + 'Failed to add memory for event %s due to HTTP error: %s - %s', + event.id, + e.response.status_code, + e.response.text, + ) + except httpx.RequestError as e: + logger.error( + 'Failed to add memory for event %s due to request error: %s', + event.id, + e, + ) + except Exception as e: + logger.error( + 'Failed to add memory for event %s due to unexpected error: %s', + event.id, + e, + ) + + logger.info('Added %d memories from session %s', memories_added, session.id) + + def _build_recall_payload( + self, app_name: str, user_id: str, query: str + ) -> dict: + """Build the ``/v1/memory/recall`` request payload.""" + payload = { + 'query': query, + 'agent_id': self._namespace(app_name, user_id), + 'top_k': self._config.search_top_k, + } + if self._config.min_importance is not None: + payload['min_importance'] = self._config.min_importance + return payload + + def _convert_to_memory_entry(self, memory: dict) -> Optional[MemoryEntry]: + """Convert a Dakera memory record to a ``MemoryEntry``. + + Extracts author and timestamp from the enriched content prefix + ``[Author: user, Time: 2025-11-04T10:32:01] Content text``. + """ + try: + raw_content = memory['content'] + author = None + timestamp = None + clean_content = raw_content + + match = re.match(r'^\[([^\]]+)\]\s+(.*)', raw_content, re.DOTALL) + if match: + metadata_str = match.group(1) + clean_content = match.group(2) + + author_match = re.search(r'Author:\s*([^,\]]+)', metadata_str) + if author_match: + author = author_match.group(1).strip() + + time_match = re.search(r'Time:\s*([^,\]]+)', metadata_str) + if time_match: + timestamp = time_match.group(1).strip() + + content = types.Content(parts=[types.Part(text=clean_content)]) + return MemoryEntry(content=content, author=author, timestamp=timestamp) + except (KeyError, ValueError) as e: + logger.debug('Failed to convert result to MemoryEntry: %s', e) + return None + + @override + async def search_memory( + self, *, app_name: str, user_id: str, query: str + ) -> SearchMemoryResponse: + """Search Dakera for memories relevant to *query* within the namespace.""" + try: + recall_payload = self._build_recall_payload(app_name, user_id, query) + memories = [] + + async with httpx.AsyncClient(timeout=self._config.timeout) as http_client: + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self._api_key}', + } + + logger.debug('Recall payload: %s', recall_payload) + + response = await http_client.post( + f'{self._base_url}/v1/memory/recall', + json=recall_payload, + headers=headers, + ) + response.raise_for_status() + result = response.json() + + results = result.get('memories', []) + logger.debug('Recall returned %d matches', len(results)) + + for item in results: + # Each result wraps the stored record under "memory". + memory = item.get('memory') if isinstance(item, dict) else None + if not memory: + continue + memory_entry = self._convert_to_memory_entry(memory) + if memory_entry: + memories.append(memory_entry) + + logger.info("Found %d memories for query: '%s'", len(memories), query) + return SearchMemoryResponse(memories=memories) + + except httpx.HTTPStatusError as e: + logger.error( + 'Failed to search memories due to HTTP error: %s - %s', + e.response.status_code, + e.response.text, + ) + return SearchMemoryResponse(memories=[]) + except httpx.RequestError as e: + logger.error('Failed to search memories due to request error: %s', e) + return SearchMemoryResponse(memories=[]) + except Exception as e: + logger.error('Failed to search memories due to unexpected error: %s', e) + return SearchMemoryResponse(memories=[]) + + async def close(self): + """Close the memory service and cleanup resources.""" + pass + + +class DakeraMemoryServiceConfig(BaseModel): + """Configuration for Dakera memory service behavior. + + Attributes: + search_top_k: Maximum number of memories to retrieve per recall. + timeout: Request timeout in seconds. + user_content_importance: Importance for user-authored content (0.0-1.0). + model_content_importance: Importance for model-generated content + (0.0-1.0). + default_importance: Default importance for other authors (0.0-1.0). + min_importance: Optional lower bound on importance for recall results. + memory_type: Dakera memory type for stored session events. + enable_metadata_tags: Include session/app/author tags on stored memories. + """ + + search_top_k: int = Field(default=10, ge=1, le=100) + timeout: float = Field(default=30.0, gt=0.0) + user_content_importance: float = Field(default=0.8, ge=0.0, le=1.0) + model_content_importance: float = Field(default=0.7, ge=0.0, le=1.0) + default_importance: float = Field(default=0.6, ge=0.0, le=1.0) + min_importance: Optional[float] = Field(default=None, ge=0.0, le=1.0) + memory_type: str = Field(default='episodic') + enable_metadata_tags: bool = Field(default=True) diff --git a/tests/unittests/memory/test_dakera_memory_service.py b/tests/unittests/memory/test_dakera_memory_service.py new file mode 100644 index 00000000..551e487c --- /dev/null +++ b/tests/unittests/memory/test_dakera_memory_service.py @@ -0,0 +1,397 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +from google.adk.events.event import Event +from google.adk.sessions.session import Session +from google.genai import types +import pytest + +from google.adk_community.memory.dakera_memory_service import DakeraMemoryService +from google.adk_community.memory.dakera_memory_service import DakeraMemoryServiceConfig + +MOCK_APP_NAME = 'test-app' +MOCK_USER_ID = 'test-user' +MOCK_SESSION_ID = 'session-1' +MOCK_NAMESPACE = f'{MOCK_APP_NAME}:{MOCK_USER_ID}' + +MOCK_SESSION = Session( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + id=MOCK_SESSION_ID, + last_update_time=1000, + events=[ + Event( + id='event-1', + invocation_id='inv-1', + author='user', + timestamp=12345, + content=types.Content( + parts=[types.Part(text='Hello, I like Python.')] + ), + ), + Event( + id='event-2', + invocation_id='inv-2', + author='model', + timestamp=12346, + content=types.Content( + parts=[ + types.Part(text='Python is a great programming language.') + ] + ), + ), + # Empty event, should be ignored + Event( + id='event-3', + invocation_id='inv-3', + author='user', + timestamp=12347, + ), + # Function call event, should be ignored + Event( + id='event-4', + invocation_id='inv-4', + author='agent', + timestamp=12348, + content=types.Content( + parts=[ + types.Part( + function_call=types.FunctionCall(name='test_function') + ) + ] + ), + ), + ], +) + +MOCK_SESSION_WITH_EMPTY_EVENTS = Session( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + id=MOCK_SESSION_ID, + last_update_time=1000, +) + + +@pytest.fixture +def mock_httpx_client(): + """Mock httpx.AsyncClient for testing.""" + with patch( + 'google.adk_community.memory.dakera_memory_service.httpx.AsyncClient' + ) as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = {'memories': []} + mock_response.raise_for_status = MagicMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client_class.return_value = mock_client + yield mock_client + + +@pytest.fixture +def memory_service(mock_httpx_client): + """Create DakeraMemoryService instance for testing.""" + return DakeraMemoryService( + base_url='http://localhost:3000', api_key='dk-test' + ) + + +@pytest.fixture +def memory_service_with_config(mock_httpx_client): + """Create DakeraMemoryService with custom config.""" + config = DakeraMemoryServiceConfig( + search_top_k=5, + user_content_importance=0.9, + model_content_importance=0.6, + ) + return DakeraMemoryService( + base_url='http://localhost:3000', api_key='dk-test', config=config + ) + + +class TestDakeraMemoryServiceConfig: + """Tests for DakeraMemoryServiceConfig.""" + + def test_default_config(self): + """Test default configuration values.""" + config = DakeraMemoryServiceConfig() + assert config.search_top_k == 10 + assert config.timeout == 30.0 + assert config.user_content_importance == 0.8 + assert config.model_content_importance == 0.7 + assert config.default_importance == 0.6 + assert config.min_importance is None + assert config.memory_type == 'episodic' + assert config.enable_metadata_tags is True + + def test_custom_config(self): + """Test custom configuration values.""" + config = DakeraMemoryServiceConfig( + search_top_k=20, + timeout=10.0, + user_content_importance=0.9, + model_content_importance=0.75, + default_importance=0.5, + min_importance=0.3, + memory_type='semantic', + enable_metadata_tags=False, + ) + assert config.search_top_k == 20 + assert config.timeout == 10.0 + assert config.user_content_importance == 0.9 + assert config.model_content_importance == 0.75 + assert config.default_importance == 0.5 + assert config.min_importance == 0.3 + assert config.memory_type == 'semantic' + assert config.enable_metadata_tags is False + + def test_config_validation_search_top_k(self): + """Test search_top_k validation.""" + with pytest.raises(Exception): # Pydantic validation error + DakeraMemoryServiceConfig(search_top_k=0) + + with pytest.raises(Exception): + DakeraMemoryServiceConfig(search_top_k=101) + + def test_config_validation_importance_bounds(self): + """Test importance values are clamped to [0.0, 1.0].""" + with pytest.raises(Exception): + DakeraMemoryServiceConfig(default_importance=1.5) + + with pytest.raises(Exception): + DakeraMemoryServiceConfig(min_importance=-0.1) + + +class TestDakeraMemoryServiceInit: + """Tests for DakeraMemoryService initialization.""" + + def test_api_key_required(self, monkeypatch): + """Test that an API key is required.""" + monkeypatch.delenv('DAKERA_API_KEY', raising=False) + with pytest.raises(ValueError, match='api_key is required'): + DakeraMemoryService(base_url='http://localhost:3000', api_key='') + + def test_api_key_required_no_env(self, monkeypatch): + """Test that a missing API key with no env var raises.""" + monkeypatch.delenv('DAKERA_API_KEY', raising=False) + with pytest.raises(ValueError, match='api_key is required'): + DakeraMemoryService(base_url='http://localhost:3000') + + def test_env_var_fallback(self, monkeypatch): + """Test base_url and api_key fall back to environment variables.""" + monkeypatch.setenv('DAKERA_API_URL', 'http://dakera.internal:3000/') + monkeypatch.setenv('DAKERA_API_KEY', 'dk-env') + service = DakeraMemoryService() + assert ( + service._base_url == 'http://dakera.internal:3000' + ) # trailing / stripped + assert service._api_key == 'dk-env' + + def test_default_base_url(self, monkeypatch): + """Test the default base URL is the Dakera port 3000.""" + monkeypatch.delenv('DAKERA_API_URL', raising=False) + service = DakeraMemoryService(api_key='dk-test') + assert service._base_url == 'http://localhost:3000' + + +class TestDakeraMemoryService: + """Tests for DakeraMemoryService.""" + + @pytest.mark.asyncio + async def test_add_session_to_memory_success( + self, memory_service, mock_httpx_client + ): + """Test successful addition of session memories.""" + await memory_service.add_session_to_memory(MOCK_SESSION) + + # Should make 2 POST calls (one per valid event) + assert mock_httpx_client.post.call_count == 2 + + # First call (user event) hits the store endpoint with correct fields. + call_args = mock_httpx_client.post.call_args_list[0] + assert call_args.args[0].endswith('/v1/memory/store') + request_data = call_args.kwargs['json'] + assert '[Author: user' in request_data['content'] + assert 'Hello, I like Python.' in request_data['content'] + assert request_data['agent_id'] == MOCK_NAMESPACE + assert request_data['session_id'] == MOCK_SESSION_ID + assert request_data['memory_type'] == 'episodic' + assert 'session:session-1' in request_data['tags'] + assert request_data['metadata']['author'] == 'user' + assert request_data['importance'] == 0.8 # User content importance + + # Second call (model event). + call_args = mock_httpx_client.post.call_args_list[1] + request_data = call_args.kwargs['json'] + assert '[Author: model' in request_data['content'] + assert 'Python is a great programming language.' in request_data['content'] + assert request_data['metadata']['author'] == 'model' + assert request_data['importance'] == 0.7 # Model content importance + + @pytest.mark.asyncio + async def test_add_session_filters_empty_events( + self, memory_service, mock_httpx_client + ): + """Test that events without content are filtered out.""" + await memory_service.add_session_to_memory(MOCK_SESSION_WITH_EMPTY_EVENTS) + assert mock_httpx_client.post.call_count == 0 + + @pytest.mark.asyncio + async def test_add_session_uses_config_importance( + self, memory_service_with_config, mock_httpx_client + ): + """Test that importance values from config are used.""" + await memory_service_with_config.add_session_to_memory(MOCK_SESSION) + + call_args = mock_httpx_client.post.call_args_list[0] + assert call_args.kwargs['json']['importance'] == 0.9 # Custom user value + + call_args = mock_httpx_client.post.call_args_list[1] + assert call_args.kwargs['json']['importance'] == 0.6 # Custom model value + + @pytest.mark.asyncio + async def test_add_session_without_metadata_tags(self, mock_httpx_client): + """Test adding memories without metadata tags.""" + config = DakeraMemoryServiceConfig(enable_metadata_tags=False) + memory_service = DakeraMemoryService( + base_url='http://localhost:3000', api_key='dk-test', config=config + ) + + await memory_service.add_session_to_memory(MOCK_SESSION) + + call_args = mock_httpx_client.post.call_args_list[0] + request_data = call_args.kwargs['json'] + assert request_data.get('tags', []) == [] + + @pytest.mark.asyncio + async def test_add_session_error_handling( + self, memory_service, mock_httpx_client + ): + """Test error handling during memory addition.""" + mock_httpx_client.post.side_effect = Exception('API Error') + + # Should not raise, just log the error. + await memory_service.add_session_to_memory(MOCK_SESSION) + assert mock_httpx_client.post.call_count == 2 + + @pytest.mark.asyncio + async def test_search_memory_success(self, memory_service, mock_httpx_client): + """Test successful memory recall.""" + mock_response = MagicMock() + mock_response.json.return_value = { + 'memories': [ + { + 'memory': { + 'id': 'mem-1', + 'content': ( + '[Author: user, Time: 2025-01-01T00:00:00] Python is' + ' great' + ), + }, + 'score': 0.9, + }, + { + 'memory': { + 'id': 'mem-2', + 'content': ( + '[Author: model, Time: 2025-01-01T00:01:00] I like' + ' programming' + ), + }, + 'score': 0.8, + }, + ] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post = AsyncMock(return_value=mock_response) + + result = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='Python programming' + ) + + # Verify the recall API call. + call_args = mock_httpx_client.post.call_args + assert call_args.args[0].endswith('/v1/memory/recall') + request_data = call_args.kwargs['json'] + assert request_data['query'] == 'Python programming' + assert request_data['top_k'] == 10 + assert request_data['agent_id'] == MOCK_NAMESPACE + + # Verify results (content cleaned of the metadata prefix). + assert len(result.memories) == 2 + assert result.memories[0].content.parts[0].text == 'Python is great' + assert result.memories[0].author == 'user' + assert result.memories[1].content.parts[0].text == 'I like programming' + assert result.memories[1].author == 'model' + + @pytest.mark.asyncio + async def test_search_memory_scopes_to_namespace( + self, memory_service, mock_httpx_client + ): + """Test that recall is scoped to the app/user namespace.""" + mock_response = MagicMock() + mock_response.json.return_value = { + 'memories': [{ + 'memory': {'content': 'plain content without prefix'}, + 'score': 1.0, + }] + } + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post = AsyncMock(return_value=mock_response) + + result = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='test query' + ) + + request_data = mock_httpx_client.post.call_args.kwargs['json'] + assert request_data['agent_id'] == MOCK_NAMESPACE + # Content with no enriched prefix passes through unchanged. + assert len(result.memories) == 1 + assert ( + result.memories[0].content.parts[0].text + == 'plain content without prefix' + ) + assert result.memories[0].author is None + + @pytest.mark.asyncio + async def test_search_memory_applies_min_importance(self, mock_httpx_client): + """Test that min_importance is forwarded to recall when configured.""" + config = DakeraMemoryServiceConfig(min_importance=0.4) + memory_service = DakeraMemoryService( + base_url='http://localhost:3000', api_key='dk-test', config=config + ) + + await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='q' + ) + + request_data = mock_httpx_client.post.call_args.kwargs['json'] + assert request_data['min_importance'] == 0.4 + + @pytest.mark.asyncio + async def test_search_memory_error_returns_empty( + self, memory_service, mock_httpx_client + ): + """Test that recall errors return an empty response rather than raising.""" + mock_httpx_client.post.side_effect = Exception('API Error') + + result = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='q' + ) + assert result.memories == []