From b0a4a3bf40fca0dde1696c42f2c38f49b6e47716 Mon Sep 17 00:00:00 2001 From: rakshith-git Date: Thu, 6 Nov 2025 01:28:31 +0530 Subject: [PATCH 1/8] Add OpenMemory service integration for ADK community - Implement OpenMemoryService with required API key authentication - Add comprehensive error handling and logging - Include metadata tags for app-level filtering - Add sample agent with auto-save callback for testing - Update default port to 8080 to match OpenMemory standard - Add unit tests for service functionality --- pyproject.toml | 1 + samples/open_memory/README.md | 126 +++++++ samples/open_memory/__init__.py | 16 + samples/open_memory/agent.py | 52 +++ src/google/adk_community/__init__.py | 2 + src/google/adk_community/memory/__init__.py | 24 ++ .../memory/open_memory_service.py | 313 +++++++++++++++ src/google/adk_community/memory/utils.py | 41 ++ tests/unittests/memory/__init__.py | 14 + .../memory/test_open_memory_service.py | 356 ++++++++++++++++++ 10 files changed, 945 insertions(+) create mode 100644 samples/open_memory/README.md create mode 100644 samples/open_memory/__init__.py create mode 100644 samples/open_memory/agent.py create mode 100644 src/google/adk_community/memory/__init__.py create mode 100644 src/google/adk_community/memory/open_memory_service.py create mode 100644 src/google/adk_community/memory/utils.py create mode 100644 tests/unittests/memory/__init__.py create mode 100644 tests/unittests/memory/test_open_memory_service.py diff --git a/pyproject.toml b/pyproject.toml index aba7a14..11afcd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ # go/keep-sorted start "google-genai>=1.21.1, <2.0.0", # Google GenAI SDK "google-adk", # Google ADK + "httpx>=0.27.0, <1.0.0", # For OpenMemory service "redis>=5.0.0, <6.0.0", # Redis for session storage # go/keep-sorted end "orjson>=3.11.3", diff --git a/samples/open_memory/README.md b/samples/open_memory/README.md new file mode 100644 index 0000000..329e7e7 --- /dev/null +++ b/samples/open_memory/README.md @@ -0,0 +1,126 @@ +# OpenMemory Sample + +This sample demonstrates how to use OpenMemory as a self-hosted memory backend +for ADK agents using the community package. + +## Prerequisites + +- Python 3.9+ (Python 3.11+ recommended) +- Docker (for running OpenMemory) +- ADK and ADK Community installed + +## Setup + +### 1. Install Dependencies + +```bash +pip install google-adk google-adk-community +``` + +### 2. Set Up OpenMemory Server + +Follow the [OpenMemory Quick Start Guide](https://openmemory.cavira.app/docs/quick-start) to install and configure your OpenMemory server. + +Once OpenMemory is running, you'll need: +- The OpenMemory server URL (default: `http://localhost:8080`) +- An API key for authentication by setting OM_API_KEY= (configured in your OpenMemory server) + +### 3. Configure Environment Variables for adk + +Create a `.env` file in this directory : + +```bash +# Required: Google API key for the agent +GOOGLE_API_KEY=your-google-api-key + +# Required: OpenMemory API key for authentication +OPENMEMORY_API_KEY=your-openmemory-api-key + +# Optional: OpenMemory base URL (defaults to http://localhost:8080) +OPENMEMORY_BASE_URL=http://localhost:8080 +``` + +**Note:** `OPENMEMORY_API_KEY` is required for OpenMemory authentication. + +## Usage + +The sample provides an agent definition that you can use programmatically. The agent includes memory tools and auto-save functionality. + +```python +from google.adk_community.memory import OpenMemoryService, OpenMemoryServiceConfig +from google.adk.runners import Runner + +# Create OpenMemory service with API key (required) +memory_service = OpenMemoryService( + base_url="http://localhost:8080", # Adjust to match your OpenMemory server URL + api_key="your-api-key" # Required - get this from your OpenMemory server configuration +) + +# Use with runner +runner = Runner( + app_name="my_app", + agent=my_agent, + memory_service=memory_service +) +``` + +### Advanced Configuration + +```python +from google.adk_community.memory import OpenMemoryService, OpenMemoryServiceConfig + +# Custom configuration +config = OpenMemoryServiceConfig( + search_top_k=20, # Retrieve more memories per query + timeout=10.0, # Faster timeout for production + user_content_salience=0.9, # Higher importance for user messages + model_content_salience=0.75, # Medium importance for model responses + enable_metadata_tags=True # Add tags (session, app, author) for filtering + # When enabled, memories are tagged with session ID, app name, + # and author, allowing search queries to filter by app name +) + +memory_service = OpenMemoryService( + base_url="http://localhost:8080", # Adjust to match your OpenMemory server URL + api_key="your-api-key", # Required - get this from your OpenMemory server configuration + config=config +) +``` + +## Sample Agent + +The sample agent (`agent.py`) includes: +- Memory tools (`load_memory_tool`, `preload_memory_tool`) for retrieving past conversations +- Auto-save callback that saves sessions to memory after each agent turn +- Time context for the agent to use current time in responses + +## Configuration Options + +### OpenMemoryServiceConfig + +- `search_top_k` (int, default: 10): Maximum memories to retrieve per search +- `timeout` (float, default: 30.0): HTTP request timeout in seconds +- `user_content_salience` (float, default: 0.8): Importance for user messages +- `model_content_salience` (float, default: 0.7): Importance for model responses +- `default_salience` (float, default: 0.6): Fallback importance value +- `enable_metadata_tags` (bool, default: True): Include tags for filtering. When enabled, + memories are tagged with `session:{session_id}`, `app:{app_name}`, and `author:{author}`. + These tags are used to filter search results by app name, improving isolation between + different applications using the same OpenMemory instance. + +## Features + +OpenMemory provides: + +- **Multi-sector embeddings**: Factual, emotional, temporal, relational memory +- **Graceful decay curves**: Automatic reinforcement keeps relevant context sharp +- **Self-hosted**: Full data ownership, no vendor lock-in +- **High performance**: 2-3× faster than hosted alternatives +- **Cost-effective**: 6-10× cheaper than SaaS memory APIs + +## Learn More + +- [OpenMemory Documentation](https://openmemory.cavira.app/) +- [OpenMemory API Reference](https://openmemory.cavira.app/docs/api/add-memory) +- [ADK Memory Documentation](https://google.github.io/adk-docs) + diff --git a/samples/open_memory/__init__.py b/samples/open_memory/__init__.py new file mode 100644 index 0000000..8ce90a2 --- /dev/null +++ b/samples/open_memory/__init__.py @@ -0,0 +1,16 @@ +# 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 . import agent + diff --git a/samples/open_memory/agent.py b/samples/open_memory/agent.py new file mode 100644 index 0000000..90a974f --- /dev/null +++ b/samples/open_memory/agent.py @@ -0,0 +1,52 @@ +# 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 datetime import datetime + +from google.adk import Agent +from google.adk.agents.callback_context import CallbackContext +from google.adk.tools.load_memory_tool import load_memory_tool +from google.adk.tools.preload_memory_tool import preload_memory_tool + + +def update_current_time(callback_context: CallbackContext): + callback_context.state['_time'] = datetime.now().isoformat() + + +async def auto_save_session_to_memory_callback(callback_context: CallbackContext): + """Auto-saves the current session to memory after each agent turn. + + Since there's no automatic save (saves only happen when the PATCH /memory endpoint + is called), this callback is a simple workaround for testing that saves memories + after each agent turn. + """ + if callback_context._invocation_context.memory_service: + await callback_context._invocation_context.memory_service.add_session_to_memory( + callback_context._invocation_context.session) + + +root_agent = Agent( + model='gemini-2.5-flash', + name='open_memory_agent', + description='agent that has access to memory tools with OpenMemory.', + before_agent_callback=update_current_time, + after_agent_callback=auto_save_session_to_memory_callback, + instruction=( + 'You are an agent that helps user answer questions. You have access to memory tools.\n' + 'You can use the memory tools to look up the information in the memory. Current time: {_time}' + ), + tools=[load_memory_tool, preload_memory_tool], +) + diff --git a/src/google/adk_community/__init__.py b/src/google/adk_community/__init__.py index 4d75708..9a1dc35 100644 --- a/src/google/adk_community/__init__.py +++ b/src/google/adk_community/__init__.py @@ -12,5 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from . import memory +from . import sessions from . import version __version__ = version.__version__ diff --git a/src/google/adk_community/memory/__init__.py b/src/google/adk_community/memory/__init__.py new file mode 100644 index 0000000..1f3442c --- /dev/null +++ b/src/google/adk_community/memory/__init__.py @@ -0,0 +1,24 @@ +# 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. + +"""Community memory services for ADK.""" + +from .open_memory_service import OpenMemoryService +from .open_memory_service import OpenMemoryServiceConfig + +__all__ = [ + "OpenMemoryService", + "OpenMemoryServiceConfig", +] + diff --git a/src/google/adk_community/memory/open_memory_service.py b/src/google/adk_community/memory/open_memory_service.py new file mode 100644 index 0000000..92659dc --- /dev/null +++ b/src/google/adk_community/memory/open_memory_service.py @@ -0,0 +1,313 @@ +# 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 re +from typing import Optional +from typing import TYPE_CHECKING + +import httpx +from google.genai import types +from pydantic import BaseModel +from pydantic import Field +from typing_extensions import override + +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 .utils import extract_text_from_event + +if TYPE_CHECKING: + from google.adk.sessions.session import Session + +logger = logging.getLogger('google_adk.' + __name__) + +class OpenMemoryService(BaseMemoryService): + """Memory service implementation using OpenMemory. + + See https://openmemory.cavira.app/ for more information. + """ + + def __init__( + self, + base_url: str = "http://localhost:3000", + api_key: str = "", # Required parameter (empty string triggers validation) + config: Optional[OpenMemoryServiceConfig] = None, + ): + """Initializes the OpenMemory service. + + Args: + base_url: Base URL of the OpenMemory instance (default: http://localhost:3000). + api_key: API key for authentication. **Required** - must be provided. + config: OpenMemoryServiceConfig instance. If None, uses defaults. + + Raises: + ValueError: If api_key is not provided or is empty. + """ + if not api_key: + raise ValueError( + "api_key is required for OpenMemory. " + "Provide an API key when initializing OpenMemoryService." + ) + self._base_url = base_url.rstrip('/') + self._api_key = api_key + self._config = config or OpenMemoryServiceConfig() + + def _determine_salience(self, author: Optional[str]) -> float: + """Determine salience value based on content author.""" + if not author: + return self._config.default_salience + + author_lower = author.lower() + if author_lower == "user": + return self._config.user_content_salience + elif author_lower == "model": + return self._config.model_content_salience + else: + return self._config.default_salience + + def _prepare_memory_data( + self, event, content_text: str, session + ) -> dict: + """Prepare memory data structure for OpenMemory API.""" + timestamp_str = None + if event.timestamp: + timestamp_str = _utils.format_timestamp(event.timestamp) + + # Embed author and timestamp in content for search retrieval + # 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 + + 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" + } + + memory_data = { + "content": enriched_content, + "metadata": metadata, + "salience": self._determine_salience(event.author) + } + + 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 OpenMemory.""" + 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 + + memory_data = self._prepare_memory_data(event, content_text, session) + + try: + # user_id is passed as top-level field for server-side filtering + payload = { + "content": memory_data["content"], + "tags": memory_data.get("tags", []), + "metadata": memory_data.get("metadata", {}), + "salience": memory_data.get("salience", 0.5), + "user_id": session.user_id + } + + response = await http_client.post( + f"{self._base_url}/memory/add", + 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_search_payload( + self, app_name: str, user_id: str, query: str + ) -> dict: + """Build search payload for OpenMemory query API.""" + payload = { + "query": query, + "k": self._config.search_top_k, + "filter": {} + } + + payload["filter"]["user_id"] = user_id + + if self._config.enable_metadata_tags: + payload["filter"]["tags"] = [f"app:{app_name}"] + + return payload + + def _convert_to_memory_entry(self, result: dict) -> Optional[MemoryEntry]: + """Convert OpenMemory result to MemoryEntry. + + Extracts author and timestamp from enriched content format: + [Author: user, Time: 2025-11-04T10:32:01] Content text + """ + try: + raw_content = result["content"] + author = None + timestamp = None + clean_content = raw_content + + # Parse enriched content format to extract metadata + 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 for memories using OpenMemory's query API.""" + try: + search_payload = self._build_search_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("Query payload: %s", search_payload) + + response = await http_client.post( + f"{self._base_url}/memory/query", + json=search_payload, + headers=headers + ) + response.raise_for_status() + result = response.json() + + logger.debug("Query returned %d matches", len(result.get("matches", []))) + + for match in result.get("matches", []): + memory_entry = self._convert_to_memory_entry(match) + 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 OpenMemoryServiceConfig(BaseModel): + """Configuration for OpenMemory service behavior. + + Attributes: + search_top_k: Maximum number of memories to retrieve per search. + timeout: Request timeout in seconds. + user_content_salience: Salience for user-authored content (0.0-1.0). + model_content_salience: Salience for model-generated content (0.0-1.0). + default_salience: Default salience value for memories (0.0-1.0). + enable_metadata_tags: Include session/app tags in memories. + """ + + search_top_k: int = Field(default=10, ge=1, le=100) + timeout: float = Field(default=30.0, gt=0.0) + user_content_salience: float = Field(default=0.8, ge=0.0, le=1.0) + model_content_salience: float = Field(default=0.7, ge=0.0, le=1.0) + default_salience: float = Field(default=0.6, ge=0.0, le=1.0) + enable_metadata_tags: bool = Field(default=True) diff --git a/src/google/adk_community/memory/utils.py b/src/google/adk_community/memory/utils.py new file mode 100644 index 0000000..0b78206 --- /dev/null +++ b/src/google/adk_community/memory/utils.py @@ -0,0 +1,41 @@ +# 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 + + +def extract_text_from_event(event) -> str: + """Extracts text content from an event's content parts. + + Filters out thought parts and only extracts actual text content. + This ensures metadata like thoughtSignature is not stored in memories. + + Args: + event: The event to extract text from. + + Returns: + Combined text from all text parts (excluding thoughts), or empty string if none found. + """ + if not event.content or not event.content.parts: + return '' + + # Filter out thought parts and only extract text + # This prevents metadata like thoughtSignature from being stored + text_parts = [ + part.text + for part in event.content.parts + if part.text and not part.thought + ] + return ' '.join(text_parts) + diff --git a/tests/unittests/memory/__init__.py b/tests/unittests/memory/__init__.py new file mode 100644 index 0000000..36a1e8d --- /dev/null +++ b/tests/unittests/memory/__init__.py @@ -0,0 +1,14 @@ +# 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. + diff --git a/tests/unittests/memory/test_open_memory_service.py b/tests/unittests/memory/test_open_memory_service.py new file mode 100644 index 0000000..74cb05e --- /dev/null +++ b/tests/unittests/memory/test_open_memory_service.py @@ -0,0 +1,356 @@ +# 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, MagicMock +from unittest.mock import patch + +from google.adk.events.event import Event +from google.adk_community.memory.open_memory_service import ( + OpenMemoryService, + OpenMemoryServiceConfig, +) +from google.adk.sessions.session import Session +from google.genai import types +import pytest + +MOCK_APP_NAME = 'test-app' +MOCK_USER_ID = 'test-user' +MOCK_SESSION_ID = 'session-1' + +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.open_memory_service.httpx.AsyncClient') as mock_client_class: + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = {"matches": []} + 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 OpenMemoryService instance for testing.""" + return OpenMemoryService(base_url='http://localhost:3000', api_key='test-key') + + +@pytest.fixture +def memory_service_with_config(mock_httpx_client): + """Create OpenMemoryService with custom config.""" + config = OpenMemoryServiceConfig( + search_top_k=5, + user_content_salience=0.9, + model_content_salience=0.6 + ) + return OpenMemoryService( + base_url='http://localhost:3000', + api_key='test-key', + config=config + ) + + +class TestOpenMemoryServiceConfig: + """Tests for OpenMemoryServiceConfig.""" + + def test_default_config(self): + """Test default configuration values.""" + config = OpenMemoryServiceConfig() + assert config.search_top_k == 10 + assert config.timeout == 30.0 + assert config.user_content_salience == 0.8 + assert config.model_content_salience == 0.7 + assert config.default_salience == 0.6 + assert config.enable_metadata_tags is True + + def test_custom_config(self): + """Test custom configuration values.""" + config = OpenMemoryServiceConfig( + search_top_k=20, + timeout=10.0, + user_content_salience=0.9, + model_content_salience=0.75, + default_salience=0.5, + enable_metadata_tags=False + ) + assert config.search_top_k == 20 + assert config.timeout == 10.0 + assert config.user_content_salience == 0.9 + assert config.model_content_salience == 0.75 + assert config.default_salience == 0.5 + 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 + OpenMemoryServiceConfig(search_top_k=0) + + with pytest.raises(Exception): + OpenMemoryServiceConfig(search_top_k=101) + + def test_api_key_required(self): + """Test that API key is required.""" + with pytest.raises(ValueError, match="api_key is required"): + OpenMemoryService(base_url="http://localhost:3000", api_key="") + + with pytest.raises(ValueError, match="api_key is required"): + OpenMemoryService(base_url="http://localhost:3000") + + +class TestOpenMemoryService: + """Tests for OpenMemoryService.""" + + @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 + + # Check first call (user event) + call_args = mock_httpx_client.post.call_args_list[0] + request_data = call_args.kwargs['json'] + assert '[Author: user' in request_data['content'] + assert 'Hello, I like Python.' in request_data['content'] + assert 'session:session-1' in request_data['tags'] + assert request_data['metadata']['author'] == 'user' + assert request_data['salience'] == 0.8 # User content salience + + # Check 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['salience'] == 0.7 # Model content salience + + @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) + + # Should make 0 POST calls (no valid events) + assert mock_httpx_client.post.call_count == 0 + + @pytest.mark.asyncio + async def test_add_session_uses_config_salience( + self, memory_service_with_config, mock_httpx_client + ): + """Test that salience values from config are used.""" + await memory_service_with_config.add_session_to_memory(MOCK_SESSION) + + # Check that custom salience values are used + call_args = mock_httpx_client.post.call_args_list[0] + request_data = call_args.kwargs['json'] + assert request_data['salience'] == 0.9 # Custom user salience + + call_args = mock_httpx_client.post.call_args_list[1] + request_data = call_args.kwargs['json'] + assert request_data['salience'] == 0.6 # Custom model salience + + @pytest.mark.asyncio + async def test_add_session_without_metadata_tags( + self, mock_httpx_client + ): + """Test adding memories without metadata tags.""" + config = OpenMemoryServiceConfig(enable_metadata_tags=False) + memory_service = OpenMemoryService( + base_url='http://localhost:3000', api_key='test-key', 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 exception, just log error + await memory_service.add_session_to_memory(MOCK_SESSION) + + # Should still attempt to make POST calls + 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 search.""" + # Mock response with enriched content format + mock_response = MagicMock() + mock_response.json.return_value = { + 'matches': [ + { + 'content': '[Author: user, Time: 2025-01-01T00:00:00] Python is great', + }, + { + 'content': '[Author: model, Time: 2025-01-01T00:01:00] I like programming', + } + ] + } + 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 API call + call_args = mock_httpx_client.post.call_args + request_data = call_args.kwargs['json'] + assert request_data['query'] == 'Python programming' + assert request_data['k'] == 10 + assert request_data['filter']['user_id'] == MOCK_USER_ID + assert f"app:{MOCK_APP_NAME}" in request_data['filter']['tags'] + + # Verify results (content should be cleaned of 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_applies_filters( + self, memory_service, mock_httpx_client + ): + """Test that app_name/user_id filters are applied.""" + # Mock response - server-side filtering ensures only matching results + mock_response = MagicMock() + mock_response.json.return_value = { + 'matches': [ + { + 'content': '[Author: model, Time: 2025-01-01T00:01:00] I like programming', + } + ] + } + 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' + ) + + # Verify filters were passed correctly + call_args = mock_httpx_client.post.call_args + request_data = call_args.kwargs['json'] + assert request_data['filter']['user_id'] == MOCK_USER_ID + assert f"app:{MOCK_APP_NAME}" in request_data['filter']['tags'] + + # Should return filtered results + assert len(result.memories) == 1 + assert result.memories[0].content.parts[0].text == 'I like programming' + + @pytest.mark.asyncio + async def test_search_memory_respects_top_k( + self, memory_service_with_config, mock_httpx_client + ): + """Test that config.search_top_k is used.""" + mock_response = MagicMock() + mock_response.json.return_value = {'matches': []} + mock_response.raise_for_status = MagicMock() + mock_httpx_client.post = AsyncMock(return_value=mock_response) + + await memory_service_with_config.search_memory( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + query='test query' + ) + + call_args = mock_httpx_client.post.call_args + request_data = call_args.kwargs['json'] + assert request_data['k'] == 5 # Custom config value + + @pytest.mark.asyncio + async def test_search_memory_error_handling( + self, memory_service, mock_httpx_client + ): + """Test graceful error handling during memory search.""" + 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='test query' + ) + + # Should return empty results on error + assert len(result.memories) == 0 + From 1c31107bfeca4d6c830ddf5c7edb6d7739afd745 Mon Sep 17 00:00:00 2001 From: Rakshith G <62304358+rakshith-git@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:24:16 +0530 Subject: [PATCH 2/8] Update samples/open_memory/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- samples/open_memory/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/open_memory/README.md b/samples/open_memory/README.md index 329e7e7..978c538 100644 --- a/samples/open_memory/README.md +++ b/samples/open_memory/README.md @@ -59,7 +59,7 @@ memory_service = OpenMemoryService( # Use with runner runner = Runner( app_name="my_app", - agent=my_agent, + agent=root_agent, memory_service=memory_service ) ``` From 29dbe6b9dab15e8b2d10ee0440f222e26b28ac57 Mon Sep 17 00:00:00 2001 From: Rakshith G <62304358+rakshith-git@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:25:03 +0530 Subject: [PATCH 3/8] Update src/google/adk_community/memory/open_memory_service.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/google/adk_community/memory/open_memory_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/google/adk_community/memory/open_memory_service.py b/src/google/adk_community/memory/open_memory_service.py index 92659dc..92c1ae6 100644 --- a/src/google/adk_community/memory/open_memory_service.py +++ b/src/google/adk_community/memory/open_memory_service.py @@ -45,7 +45,7 @@ class OpenMemoryService(BaseMemoryService): def __init__( self, - base_url: str = "http://localhost:3000", + base_url: str = "http://localhost:8080", api_key: str = "", # Required parameter (empty string triggers validation) config: Optional[OpenMemoryServiceConfig] = None, ): From 5d6cbc45292ce8fccbc8602313d579740d52b631 Mon Sep 17 00:00:00 2001 From: Hangfei Lin Date: Sun, 9 Nov 2025 12:51:44 -0800 Subject: [PATCH 4/8] Create readme.md --- contributing/readme.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributing/readme.md diff --git a/contributing/readme.md b/contributing/readme.md new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/contributing/readme.md @@ -0,0 +1 @@ + From 203736f0494991cf1ce6a059f6227ba703aa607f Mon Sep 17 00:00:00 2001 From: Hangfei Lin Date: Sun, 9 Nov 2025 12:55:09 -0800 Subject: [PATCH 5/8] Rename samples/open_memory/README.md to contributing/samples/open_memory/README.md --- {samples => contributing/samples}/open_memory/README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {samples => contributing/samples}/open_memory/README.md (100%) diff --git a/samples/open_memory/README.md b/contributing/samples/open_memory/README.md similarity index 100% rename from samples/open_memory/README.md rename to contributing/samples/open_memory/README.md From 662e3f01469416005253a510dfda2dab73e7cef6 Mon Sep 17 00:00:00 2001 From: Hangfei Lin Date: Sun, 9 Nov 2025 12:55:37 -0800 Subject: [PATCH 6/8] Rename samples/open_memory/__init__.py to contributing/samples/open_memory/__init__.py --- {samples => contributing/samples}/open_memory/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {samples => contributing/samples}/open_memory/__init__.py (100%) diff --git a/samples/open_memory/__init__.py b/contributing/samples/open_memory/__init__.py similarity index 100% rename from samples/open_memory/__init__.py rename to contributing/samples/open_memory/__init__.py From 15b3086ca8beb3f8c499604822f16cf3bfe18408 Mon Sep 17 00:00:00 2001 From: Hangfei Lin Date: Sun, 9 Nov 2025 12:56:05 -0800 Subject: [PATCH 7/8] Rename samples/open_memory/agent.py to contributing/samples/open_memory/agent.py --- {samples => contributing/samples}/open_memory/agent.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {samples => contributing/samples}/open_memory/agent.py (100%) diff --git a/samples/open_memory/agent.py b/contributing/samples/open_memory/agent.py similarity index 100% rename from samples/open_memory/agent.py rename to contributing/samples/open_memory/agent.py From 4c8bb4704958abecdb95bec4149632623012fa2d Mon Sep 17 00:00:00 2001 From: Hangfei Lin Date: Sun, 9 Nov 2025 12:58:48 -0800 Subject: [PATCH 8/8] Update README.md --- contributing/samples/open_memory/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/contributing/samples/open_memory/README.md b/contributing/samples/open_memory/README.md index 978c538..35509bb 100644 --- a/contributing/samples/open_memory/README.md +++ b/contributing/samples/open_memory/README.md @@ -94,6 +94,14 @@ The sample agent (`agent.py`) includes: - Auto-save callback that saves sessions to memory after each agent turn - Time context for the agent to use current time in responses +## Sample Query +- hello my name is Amy and i love gaming +- I really love fps games like GameA and GameB, but my favourite is GameC. my favourite snack is doritos. + +Then in a new session +- tell me everything you know about me +(Agent recalled prior details ( my name, love for gaming and mentioned games ,snacks etc)) + ## Configuration Options ### OpenMemoryServiceConfig