From 18cd26310e0620b287cdb4a4da5b936af0fa5071 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Mon, 12 Jan 2026 23:23:31 +0200 Subject: [PATCH 01/20] feat(memory): add RedisAgentMemoryService for Agent Memory Server integration Implement RedisAgentMemoryService class that integrates with the Redis Agent Memory Server for production-grade long-term memory capabilities. Features: - Two-tier memory architecture (working memory + long-term memory) - Configurable extraction strategies (discrete, summary, preferences) - Recency-boosted semantic search with configurable weights - Namespace and user filtering support - Lazy client initialization with proper error handling The service implements the ADK BaseMemoryService interface with: - add_session_to_memory(): Stores session events in working memory - search_memory(): Retrieves relevant memories with semantic search - close(): Properly closes the client connection --- .../memory/redis_agent_memory_service.py | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 src/google/adk_community/memory/redis_agent_memory_service.py diff --git a/src/google/adk_community/memory/redis_agent_memory_service.py b/src/google/adk_community/memory/redis_agent_memory_service.py new file mode 100644 index 0000000..a367df7 --- /dev/null +++ b/src/google/adk_community/memory/redis_agent_memory_service.py @@ -0,0 +1,295 @@ +# 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. + +"""Redis Agent Memory Service for ADK. + +This module provides integration with the Redis Agent Memory Server, +offering production-grade long-term memory with automatic summarization, +topic/entity extraction, and recency-boosted search. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal, Optional + +from google.adk.memory.base_memory_service import BaseMemoryService, SearchMemoryResponse +from google.adk.memory.memory_entry import MemoryEntry +from google.genai import types +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__) + + +@dataclass +class RedisAgentMemoryServiceConfig: + """Configuration for Redis Agent Memory Service. + + Attributes: + api_base_url: Base URL of the Agent Memory Server. + timeout: HTTP request timeout in seconds. + default_namespace: Default namespace for memory operations. + search_top_k: Maximum number of memories to retrieve per search. + distance_threshold: Maximum distance threshold for search results (0.0-1.0). + recency_boost: Enable recency-aware re-ranking of search results. + semantic_weight: Weight for semantic similarity in recency boosting (0.0-1.0). + recency_weight: Weight for recency score in recency boosting (0.0-1.0). + freshness_weight: Weight for freshness component within recency score. + novelty_weight: Weight for novelty component within recency score. + half_life_last_access_days: Half-life in days for last_accessed decay. + half_life_created_days: Half-life in days for created_at decay. + extraction_strategy: Memory extraction strategy (discrete, summary, preferences, custom). + extraction_strategy_config: Additional configuration for the extraction strategy. + model_name: Model name for context window management and summarization. + context_window_max: Maximum context window tokens (overrides model default). + """ + + api_base_url: str = "http://localhost:8000" + timeout: float = 30.0 + default_namespace: Optional[str] = None + search_top_k: int = 10 + distance_threshold: Optional[float] = None + recency_boost: bool = True + semantic_weight: float = 0.8 + recency_weight: float = 0.2 + freshness_weight: float = 0.6 + novelty_weight: float = 0.4 + half_life_last_access_days: float = 7.0 + half_life_created_days: float = 30.0 + extraction_strategy: Literal["discrete", "summary", "preferences", "custom"] = "discrete" + extraction_strategy_config: dict = field(default_factory=dict) + model_name: Optional[str] = None + context_window_max: Optional[int] = None + + +class RedisAgentMemoryService(BaseMemoryService): + """Memory service implementation using Redis Agent Memory Server. + + This service provides production-grade memory capabilities including: + - Two-tier memory architecture (working memory + long-term memory) + - Automatic memory extraction (semantic facts, episodic events, preferences) + - Topic and entity extraction + - Auto-summarization when context window is exceeded + - Recency-boosted semantic search + - Deduplication and memory compaction + + Requires the `agent-memory-client` package to be installed. + + Example: + ```python + from google.adk_community.memory import ( + RedisAgentMemoryService, + RedisAgentMemoryServiceConfig, + ) + + config = RedisAgentMemoryServiceConfig( + api_base_url="http://localhost:8000", + default_namespace="my_app", + recency_boost=True, + ) + memory_service = RedisAgentMemoryService(config=config) + + # Use with ADK agent + agent = Agent( + name="my_agent", + memory_service=memory_service, + ) + ``` + """ + + def __init__(self, config: Optional[RedisAgentMemoryServiceConfig] = None): + """Initialize the Redis Agent Memory Service. + + Args: + config: Configuration for the service. If None, uses defaults. + + Raises: + ImportError: If agent-memory-client package is not installed. + """ + self._config = config or RedisAgentMemoryServiceConfig() + self._client = None + self._client_initialized = False + + async def _get_client(self): + """Lazily initialize and return the MemoryAPIClient.""" + if not self._client_initialized: + try: + from agent_memory_client import MemoryAPIClient, MemoryClientConfig + except ImportError as e: + raise ImportError( + "agent-memory-client package is required for RedisAgentMemoryService. " + "Install it with: pip install agent-memory-client" + ) from e + + client_config = MemoryClientConfig( + base_url=self._config.api_base_url, + timeout=self._config.timeout, + default_namespace=self._config.default_namespace, + default_model_name=self._config.model_name, + default_context_window_max=self._config.context_window_max, + ) + self._client = MemoryAPIClient(client_config) + self._client_initialized = True + return self._client + + def _build_working_memory(self, session: "Session"): + """Convert ADK Session to WorkingMemory for the Agent Memory Server.""" + from agent_memory_client.models import ( + MemoryMessage, + MemoryStrategyConfig, + WorkingMemory, + ) + + messages = [] + for event in session.events: + text = extract_text_from_event(event) + if not text: + continue + role = "user" if event.author == "user" else "assistant" + messages.append(MemoryMessage(role=role, content=text)) + + strategy_config = MemoryStrategyConfig( + strategy=self._config.extraction_strategy, + config=self._config.extraction_strategy_config, + ) + + return WorkingMemory( + session_id=session.id, + namespace=self._config.default_namespace or session.app_name, + user_id=session.user_id, + messages=messages, + long_term_memory_strategy=strategy_config, + ) + + @override + async def add_session_to_memory(self, session: "Session"): + """Add a session's events to the Agent Memory Server. + + Converts ADK Session events to WorkingMemory messages and stores them + in the Agent Memory Server. The server will automatically: + - Extract semantic and episodic memories based on the configured strategy + - Perform topic and entity extraction + - Summarize context when the token limit is exceeded + - Promote memories to long-term storage via background tasks + + Args: + session: The ADK Session containing events to store. + """ + try: + client = await self._get_client() + working_memory = self._build_working_memory(session) + + if not working_memory.messages: + logger.debug("No messages to store for session %s", session.id) + return + + response = await client.put_working_memory( + session_id=session.id, + memory=working_memory, + user_id=session.user_id, + ) + + logger.info( + "Stored %d messages for session %s (context: %.1f%% used)", + len(working_memory.messages), + session.id, + response.context_percentage_total_used or 0, + ) + + except Exception as e: + logger.error( + "Failed to add session %s to memory: %s", + session.id, + e, + ) + + def _build_recency_config(self): + """Build RecencyConfig from service configuration.""" + from agent_memory_client.models import RecencyConfig + + return RecencyConfig( + recency_boost=self._config.recency_boost, + semantic_weight=self._config.semantic_weight, + recency_weight=self._config.recency_weight, + freshness_weight=self._config.freshness_weight, + novelty_weight=self._config.novelty_weight, + half_life_last_access_days=self._config.half_life_last_access_days, + half_life_created_days=self._config.half_life_created_days, + ) + + @override + async def search_memory( + self, *, app_name: str, user_id: str, query: str + ) -> SearchMemoryResponse: + """Search for memories using the Agent Memory Server. + + Performs semantic search against long-term memory with optional + recency boosting. Results are filtered by namespace (derived from + app_name) and user_id. + + Args: + app_name: The application name (used as namespace if not configured). + user_id: The user ID to filter memories. + query: The search query for semantic matching. + + Returns: + SearchMemoryResponse containing matching MemoryEntry objects. + """ + try: + client = await self._get_client() + recency_config = self._build_recency_config() if self._config.recency_boost else None + + namespace = self._config.default_namespace or app_name + + results = await client.search_long_term_memory( + text=query, + namespace={"eq": namespace}, + user_id={"eq": user_id}, + distance_threshold=self._config.distance_threshold, + recency=recency_config, + limit=self._config.search_top_k, + ) + + memories = [] + for record in results.memories: + content = types.Content(parts=[types.Part(text=record.text)]) + memory_entry = MemoryEntry(content=content) + memories.append(memory_entry) + + logger.info( + "Found %d memories for query '%s' (namespace=%s, user=%s)", + len(memories), + query[:50], + namespace, + user_id, + ) + return SearchMemoryResponse(memories=memories) + + except Exception as e: + logger.error("Failed to search memories: %s", e) + return SearchMemoryResponse(memories=[]) + + async def close(self): + """Close the memory service and cleanup resources.""" + if self._client is not None: + await self._client.close() + self._client = None + self._client_initialized = False + From 9174c1af4bb4e639fa9d8e74cc044f8a5c3d00d3 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Mon, 12 Jan 2026 23:23:37 +0200 Subject: [PATCH 02/20] feat(memory): export RedisAgentMemoryService from memory module Add RedisAgentMemoryService and RedisAgentMemoryServiceConfig to the public exports of the google.adk_community.memory module. --- src/google/adk_community/memory/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/google/adk_community/memory/__init__.py b/src/google/adk_community/memory/__init__.py index 1f3442c..ec37e4e 100644 --- a/src/google/adk_community/memory/__init__.py +++ b/src/google/adk_community/memory/__init__.py @@ -16,9 +16,13 @@ from .open_memory_service import OpenMemoryService from .open_memory_service import OpenMemoryServiceConfig +from .redis_agent_memory_service import RedisAgentMemoryService +from .redis_agent_memory_service import RedisAgentMemoryServiceConfig __all__ = [ "OpenMemoryService", "OpenMemoryServiceConfig", + "RedisAgentMemoryService", + "RedisAgentMemoryServiceConfig", ] From d3194e5bee36eaf666010ede94f3fc72a113a57b Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Mon, 12 Jan 2026 23:23:43 +0200 Subject: [PATCH 03/20] build(deps): add redis-agent-memory optional dependency Add agent-memory-client>=0.13.0 as an optional dependency under the redis-agent-memory extra. Install with: pip install 'google-adk-community[redis-agent-memory]' --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 11afcd8..3192789 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,9 @@ test = [ "pytest>=8.4.2", "pytest-asyncio>=1.2.0", ] - +redis-agent-memory = [ + "agent-memory-client>=0.13.0", +] [tool.pyink] # Format py files following Google style-guide From 097210160a2036e85bfe88ef1a47410e4985efa1 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Mon, 12 Jan 2026 23:23:50 +0200 Subject: [PATCH 04/20] test(memory): add unit tests for RedisAgentMemoryService Add comprehensive unit tests covering: - Configuration validation and defaults - Session to memory conversion - Memory search with recency boosting - Error handling for missing dependencies - Client lifecycle management All 14 tests pass with mocked MemoryAPIClient. --- .../memory/test_redis_agent_memory_service.py | 391 ++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 tests/unittests/memory/test_redis_agent_memory_service.py diff --git a/tests/unittests/memory/test_redis_agent_memory_service.py b/tests/unittests/memory/test_redis_agent_memory_service.py new file mode 100644 index 0000000..a6491df --- /dev/null +++ b/tests/unittests/memory/test_redis_agent_memory_service.py @@ -0,0 +1,391 @@ +# 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. + +import sys +from unittest.mock import AsyncMock, MagicMock, 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.redis_agent_memory_service import ( + RedisAgentMemoryService, + RedisAgentMemoryServiceConfig, +) + + +# Create mock classes for agent_memory_client.models +class MockMemoryMessage: + def __init__(self, role, content): + self.role = role + self.content = content + + +class MockMemoryStrategyConfig: + def __init__(self, strategy, config=None): + self.strategy = strategy + self.config = config + + +class MockWorkingMemory: + def __init__(self, session_id, namespace, user_id, messages, long_term_memory_strategy): + self.session_id = session_id + self.namespace = namespace + self.user_id = user_id + self.messages = messages + self.long_term_memory_strategy = long_term_memory_strategy + + +class MockRecencyConfig: + def __init__(self, recency_boost, semantic_weight, recency_weight, + freshness_weight, novelty_weight, half_life_last_access_days, + half_life_created_days): + self.recency_boost = recency_boost + self.semantic_weight = semantic_weight + self.recency_weight = recency_weight + self.freshness_weight = freshness_weight + self.novelty_weight = novelty_weight + self.half_life_last_access_days = half_life_last_access_days + self.half_life_created_days = half_life_created_days + +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, + ), + ], +) + +MOCK_SESSION_WITH_EMPTY_EVENTS = Session( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + id=MOCK_SESSION_ID, + last_update_time=1000, +) + + +class TestRedisAgentMemoryServiceConfig: + """Tests for RedisAgentMemoryServiceConfig.""" + + def test_default_config(self): + """Test default configuration values.""" + config = RedisAgentMemoryServiceConfig() + assert config.api_base_url == "http://localhost:8000" + assert config.timeout == 30.0 + assert config.default_namespace is None + assert config.search_top_k == 10 + assert config.distance_threshold is None + assert config.recency_boost is True + assert config.semantic_weight == 0.8 + assert config.recency_weight == 0.2 + assert config.freshness_weight == 0.6 + assert config.novelty_weight == 0.4 + assert config.half_life_last_access_days == 7.0 + assert config.half_life_created_days == 30.0 + assert config.extraction_strategy == "discrete" + assert config.extraction_strategy_config == {} + assert config.model_name is None + assert config.context_window_max is None + + def test_custom_config(self): + """Test custom configuration values.""" + config = RedisAgentMemoryServiceConfig( + api_base_url="http://memory-server:9000", + timeout=60.0, + default_namespace="my_app", + search_top_k=20, + distance_threshold=0.5, + recency_boost=False, + semantic_weight=0.7, + recency_weight=0.3, + extraction_strategy="summary", + model_name="gpt-4o", + context_window_max=128000, + ) + assert config.api_base_url == "http://memory-server:9000" + assert config.timeout == 60.0 + assert config.default_namespace == "my_app" + assert config.search_top_k == 20 + assert config.distance_threshold == 0.5 + assert config.recency_boost is False + assert config.semantic_weight == 0.7 + assert config.recency_weight == 0.3 + assert config.extraction_strategy == "summary" + assert config.model_name == "gpt-4o" + assert config.context_window_max == 128000 + + +class TestRedisAgentMemoryService: + """Tests for RedisAgentMemoryService.""" + + @pytest.fixture(autouse=True) + def mock_agent_memory_models(self): + """Mock agent_memory_client.models module.""" + mock_models = MagicMock() + mock_models.MemoryMessage = MockMemoryMessage + mock_models.MemoryStrategyConfig = MockMemoryStrategyConfig + mock_models.WorkingMemory = MockWorkingMemory + mock_models.RecencyConfig = MockRecencyConfig + + with patch.dict(sys.modules, {"agent_memory_client.models": mock_models}): + yield mock_models + + @pytest.fixture + def mock_memory_client(self): + """Create a mock MemoryAPIClient.""" + mock_client = MagicMock() + mock_client.put_working_memory = AsyncMock() + mock_client.search_long_term_memory = AsyncMock() + mock_client.close = AsyncMock() + return mock_client + + @pytest.fixture + def memory_service(self, mock_memory_client): + """Create RedisAgentMemoryService with mocked client.""" + service = RedisAgentMemoryService() + service._client = mock_memory_client + service._client_initialized = True + return service + + @pytest.fixture + def memory_service_with_config(self, mock_memory_client): + """Create RedisAgentMemoryService with custom config.""" + config = RedisAgentMemoryServiceConfig( + default_namespace="custom_namespace", + search_top_k=5, + recency_boost=True, + extraction_strategy="preferences", + ) + service = RedisAgentMemoryService(config=config) + service._client = mock_memory_client + service._client_initialized = True + return service + + @pytest.mark.asyncio + async def test_add_session_to_memory_success( + self, memory_service, mock_memory_client + ): + """Test successful addition of session to memory.""" + mock_response = MagicMock() + mock_response.context_percentage_total_used = 25.0 + mock_memory_client.put_working_memory.return_value = mock_response + + await memory_service.add_session_to_memory(MOCK_SESSION) + + mock_memory_client.put_working_memory.assert_called_once() + call_args = mock_memory_client.put_working_memory.call_args + assert call_args.kwargs["session_id"] == MOCK_SESSION_ID + assert call_args.kwargs["user_id"] == MOCK_USER_ID + + working_memory = call_args.kwargs["memory"] + assert len(working_memory.messages) == 2 + assert working_memory.messages[0].role == "user" + assert working_memory.messages[0].content == "Hello, I like Python." + assert working_memory.messages[1].role == "assistant" + assert working_memory.messages[1].content == "Python is a great programming language." + + @pytest.mark.asyncio + async def test_add_session_filters_empty_events( + self, memory_service, mock_memory_client + ): + """Test that events without content are filtered out.""" + await memory_service.add_session_to_memory(MOCK_SESSION_WITH_EMPTY_EVENTS) + + mock_memory_client.put_working_memory.assert_not_called() + + @pytest.mark.asyncio + async def test_add_session_uses_config_namespace( + self, memory_service_with_config, mock_memory_client + ): + """Test that namespace from config is used.""" + mock_response = MagicMock() + mock_response.context_percentage_total_used = 10.0 + mock_memory_client.put_working_memory.return_value = mock_response + + await memory_service_with_config.add_session_to_memory(MOCK_SESSION) + + call_args = mock_memory_client.put_working_memory.call_args + working_memory = call_args.kwargs["memory"] + assert working_memory.namespace == "custom_namespace" + + @pytest.mark.asyncio + async def test_add_session_uses_extraction_strategy( + self, memory_service_with_config, mock_memory_client + ): + """Test that extraction strategy from config is used.""" + mock_response = MagicMock() + mock_response.context_percentage_total_used = 10.0 + mock_memory_client.put_working_memory.return_value = mock_response + + await memory_service_with_config.add_session_to_memory(MOCK_SESSION) + + call_args = mock_memory_client.put_working_memory.call_args + working_memory = call_args.kwargs["memory"] + assert working_memory.long_term_memory_strategy.strategy == "preferences" + + @pytest.mark.asyncio + async def test_add_session_error_handling( + self, memory_service, mock_memory_client + ): + """Test error handling during memory addition.""" + mock_memory_client.put_working_memory.side_effect = Exception("API Error") + + # Should not raise exception, just log error + await memory_service.add_session_to_memory(MOCK_SESSION) + + @pytest.mark.asyncio + async def test_search_memory_success(self, memory_service, mock_memory_client): + """Test successful memory search.""" + mock_memory = MagicMock() + mock_memory.text = "Python is a great language" + mock_results = MagicMock() + mock_results.memories = [mock_memory] + mock_memory_client.search_long_term_memory.return_value = mock_results + + result = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="Python programming" + ) + + mock_memory_client.search_long_term_memory.assert_called_once() + call_args = mock_memory_client.search_long_term_memory.call_args + assert call_args.kwargs["text"] == "Python programming" + assert call_args.kwargs["namespace"] == {"eq": MOCK_APP_NAME} + assert call_args.kwargs["user_id"] == {"eq": MOCK_USER_ID} + + assert len(result.memories) == 1 + assert result.memories[0].content.parts[0].text == "Python is a great language" + + @pytest.mark.asyncio + async def test_search_memory_with_recency_boost( + self, memory_service, mock_memory_client + ): + """Test that recency config is passed when enabled.""" + mock_results = MagicMock() + mock_results.memories = [] + mock_memory_client.search_long_term_memory.return_value = mock_results + + await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" + ) + + call_args = mock_memory_client.search_long_term_memory.call_args + recency = call_args.kwargs["recency"] + assert recency is not None + assert recency.recency_boost is True + assert recency.semantic_weight == 0.8 + assert recency.recency_weight == 0.2 + + @pytest.mark.asyncio + async def test_search_memory_without_recency_boost(self, mock_memory_client): + """Test that recency config is None when disabled.""" + config = RedisAgentMemoryServiceConfig(recency_boost=False) + service = RedisAgentMemoryService(config=config) + service._client = mock_memory_client + service._client_initialized = True + + mock_results = MagicMock() + mock_results.memories = [] + mock_memory_client.search_long_term_memory.return_value = mock_results + + await service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" + ) + + call_args = mock_memory_client.search_long_term_memory.call_args + assert call_args.kwargs["recency"] is None + + @pytest.mark.asyncio + async def test_search_memory_respects_top_k( + self, memory_service_with_config, mock_memory_client + ): + """Test that config.search_top_k is used.""" + mock_results = MagicMock() + mock_results.memories = [] + mock_memory_client.search_long_term_memory.return_value = mock_results + + await memory_service_with_config.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" + ) + + call_args = mock_memory_client.search_long_term_memory.call_args + assert call_args.kwargs["limit"] == 5 + + @pytest.mark.asyncio + async def test_search_memory_error_handling( + self, memory_service, mock_memory_client + ): + """Test graceful error handling during memory search.""" + mock_memory_client.search_long_term_memory.side_effect = Exception("API Error") + + result = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" + ) + + assert len(result.memories) == 0 + + @pytest.mark.asyncio + async def test_close(self, memory_service, mock_memory_client): + """Test closing the service.""" + await memory_service.close() + + mock_memory_client.close.assert_called_once() + assert memory_service._client is None + assert memory_service._client_initialized is False + + def test_import_error_handling(self): + """Test that ImportError is raised when agent-memory-client is not installed.""" + service = RedisAgentMemoryService() + + with patch.dict("sys.modules", {"agent_memory_client": None}): + with patch( + "google.adk_community.memory.redis_agent_memory_service.RedisAgentMemoryService._get_client" + ) as mock_get_client: + mock_get_client.side_effect = ImportError( + "agent-memory-client package is required" + ) + with pytest.raises(ImportError, match="agent-memory-client"): + import asyncio + asyncio.get_event_loop().run_until_complete(service._get_client()) + From ee368c8ce7bc933e8989385785febe87b7cf8b22 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Mon, 12 Jan 2026 23:23:58 +0200 Subject: [PATCH 05/20] docs(samples): add redis_agent_memory sample application Add a complete sample demonstrating RedisAgentMemoryService usage: - main.py: FastAPI server with URI scheme registration - redis_agent_memory_agent/: Sample agent with memory tools - README.md: Setup and usage documentation The sample shows how to: - Register the redis-agent-memory:// URI scheme - Configure extraction strategies and recency boosting - Use memory tools (load_memory, preload_memory) with the agent --- .../samples/redis_agent_memory/README.md | 157 ++++++++++++++++++ .../samples/redis_agent_memory/main.py | 98 +++++++++++ .../redis_agent_memory_agent/__init__.py | 16 ++ .../redis_agent_memory_agent/agent.py | 47 ++++++ 4 files changed, 318 insertions(+) create mode 100644 contributing/samples/redis_agent_memory/README.md create mode 100644 contributing/samples/redis_agent_memory/main.py create mode 100644 contributing/samples/redis_agent_memory/redis_agent_memory_agent/__init__.py create mode 100644 contributing/samples/redis_agent_memory/redis_agent_memory_agent/agent.py diff --git a/contributing/samples/redis_agent_memory/README.md b/contributing/samples/redis_agent_memory/README.md new file mode 100644 index 0000000..38711fe --- /dev/null +++ b/contributing/samples/redis_agent_memory/README.md @@ -0,0 +1,157 @@ +# Redis Agent Memory Sample + +This sample demonstrates how to use the Redis Agent Memory Server as a long-term +memory backend for ADK agents using the community package. + +## Prerequisites + +- Python 3.9+ (Python 3.11+ recommended) +- Docker (for running Redis Stack) +- Redis Agent Memory Server running +- ADK and ADK Community installed + +## Setup + +### 1. Install Dependencies + +```bash +pip install "google-adk-community[redis-agent-memory]" +``` + +### 2. Set Up Redis Stack + +```bash +docker run -d --name redis-stack -p 6379:6379 redis/redis-stack:latest +``` + +### 3. Set Up Redis Agent Memory Server + +Clone and run the Agent Memory Server: + +```bash +git clone https://github.com/redis-developer/agent-memory-server.git +cd agent-memory-server +cp .env.example .env +# Edit .env and set OPENAI_API_KEY (required for embeddings) +pip install -e . +uvicorn agent_memory_server.main:app --port 8000 +``` + +### 4. Configure Environment Variables + +Create a `.env` file in this directory: + +```bash +# Required: Google API key for the agent +GOOGLE_API_KEY=your-google-api-key + +# Optional: Redis Agent Memory Server URL (defaults to http://localhost:8000) +REDIS_AGENT_MEMORY_URL=http://localhost:8000 + +# Optional: Namespace for memory isolation (defaults to adk_sample) +REDIS_AGENT_MEMORY_NAMESPACE=adk_sample + +# Optional: Extraction strategy - discrete, summary, or preferences (defaults to discrete) +REDIS_AGENT_MEMORY_EXTRACTION_STRATEGY=discrete + +# Optional: Enable recency-boosted search (defaults to true) +REDIS_AGENT_MEMORY_RECENCY_BOOST=true + +# Optional: Semantic similarity weight (defaults to 0.8) +REDIS_AGENT_MEMORY_SEMANTIC_WEIGHT=0.8 + +# Optional: Recency weight (defaults to 0.2) +REDIS_AGENT_MEMORY_RECENCY_WEIGHT=0.2 +``` + +## Usage + +### Option 1: Using `main.py` with FastAPI (Recommended) + +```bash +python main.py +``` + +This starts the ADK web interface at `http://localhost:8080`. + +### Option 2: Using `Runner` Directly + +```python +from google.adk.runners import Runner +from google.adk.agents import LlmAgent +from google.adk_community.memory import ( + RedisAgentMemoryService, + RedisAgentMemoryServiceConfig, +) + +# Configure the memory service +config = RedisAgentMemoryServiceConfig( + api_base_url="http://localhost:8000", + default_namespace="my_app", + extraction_strategy="discrete", + enable_recency_boost=True, +) + +# Create the memory service +memory_service = RedisAgentMemoryService(config=config) + +# Use with ADK Runner +agent = LlmAgent(name="assistant", model="gemini-2.5-flash") +runner = Runner( + app_name="my_app", + agent=agent, + memory_service=memory_service, +) +``` + +## Sample Structure + +``` +redis_agent_memory/ +├── main.py # FastAPI server using get_fast_api_app +├── redis_agent_memory_agent/ +│ ├── __init__.py # Agent package initialization +│ └── agent.py # Agent definition with memory tools +└── README.md # This file +``` + +## Sample Queries + +Try these conversations to test long-term memory: + +**Session 1:** +- "Hello, my name is Alex and I'm a software engineer" +- "I love hiking and photography. My favorite mountain is Mt. Rainier" + +**Session 2 (new session):** +- "What do you remember about me?" +- "What are my hobbies?" + +The agent should recall information from Session 1. + +## Configuration Options + +| Option | Default | Description | +|--------|---------|-------------| +| `api_base_url` | `http://localhost:8000` | Agent Memory Server URL | +| `default_namespace` | `None` | Namespace for memory isolation | +| `extraction_strategy` | `discrete` | Memory extraction: `discrete`, `summary`, `preferences` | +| `recency_boost` | `True` | Enable recency-boosted semantic search | +| `semantic_weight` | `0.8` | Weight for semantic similarity (0-1) | +| `recency_weight` | `0.2` | Weight for recency (0-1) | + +## Features + +Redis Agent Memory Server provides: + +- **Two-tier memory**: Working memory (session) + Long-term memory (persistent) +- **Intelligent extraction**: Automatically extracts facts, preferences, and episodic memories +- **Recency-boosted search**: Balances semantic relevance with temporal freshness +- **Vector search**: High-performance semantic search powered by Redis Stack +- **Namespace isolation**: Separate memory spaces for different apps/users + +## Learn More + +- [Redis Agent Memory Server](https://github.com/redis-developer/agent-memory-server) +- [ADK Memory Documentation](https://google.github.io/adk-docs) + diff --git a/contributing/samples/redis_agent_memory/main.py b/contributing/samples/redis_agent_memory/main.py new file mode 100644 index 0000000..8fa8577 --- /dev/null +++ b/contributing/samples/redis_agent_memory/main.py @@ -0,0 +1,98 @@ +# 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. + +"""Example of using Redis Agent Memory Service with get_fast_api_app.""" + +import os + +import uvicorn +from dotenv import load_dotenv +from fastapi import FastAPI +from urllib.parse import urlparse + +from google.adk.cli.fast_api import get_fast_api_app +from google.adk.cli.service_registry import get_service_registry +from google.adk_community.memory import ( + RedisAgentMemoryService, + RedisAgentMemoryServiceConfig, +) + +# Load environment variables from .env file if it exists +load_dotenv() + + +def redis_agent_memory_factory(uri: str, **kwargs): + """Factory function for creating RedisAgentMemoryService from URI.""" + parsed = urlparse(uri) + location = parsed.netloc + parsed.path + base_url = ( + location + if location.startswith(("http://", "https://")) + else f"http://{location}" + ) + + # Get configuration from environment variables + config = RedisAgentMemoryServiceConfig( + api_base_url=base_url, + default_namespace=os.getenv("REDIS_AGENT_MEMORY_NAMESPACE", "adk_sample"), + extraction_strategy=os.getenv( + "REDIS_AGENT_MEMORY_EXTRACTION_STRATEGY", "discrete" + ), + recency_boost=os.getenv( + "REDIS_AGENT_MEMORY_RECENCY_BOOST", "true" + ).lower() + == "true", + semantic_weight=float( + os.getenv("REDIS_AGENT_MEMORY_SEMANTIC_WEIGHT", "0.8") + ), + recency_weight=float(os.getenv("REDIS_AGENT_MEMORY_RECENCY_WEIGHT", "0.2")), + ) + + return RedisAgentMemoryService(config=config) + + +# Register Redis Agent Memory service factory for redis-agent-memory:// URI scheme +get_service_registry().register_memory_service( + "redis-agent-memory", redis_agent_memory_factory +) + +# Build Redis Agent Memory URI from environment variables +base_url = ( + os.getenv("REDIS_AGENT_MEMORY_URL", "http://localhost:8000") + .replace("http://", "") + .replace("https://", "") +) +MEMORY_SERVICE_URI = f"redis-agent-memory://{base_url}" + +# Create the FastAPI app using get_fast_api_app +app: FastAPI = get_fast_api_app( + agents_dir=".", + memory_service_uri=MEMORY_SERVICE_URI, + web=True, +) + + +if __name__ == "__main__": + # Use the PORT environment variable provided by Cloud Run, defaulting to 8000 + port = int(os.environ.get("PORT", 8080)) + print(f""" +Starting Redis Agent Memory Sample +=================================== +ADK Server: http://localhost:{port} +Memory Server: {os.getenv('REDIS_AGENT_MEMORY_URL', 'http://localhost:8000')} +Namespace: {os.getenv('REDIS_AGENT_MEMORY_NAMESPACE', 'adk_sample')} +Extraction Strategy: {os.getenv('REDIS_AGENT_MEMORY_EXTRACTION_STRATEGY', 'discrete')} +""") + uvicorn.run(app, host="0.0.0.0", port=port) + diff --git a/contributing/samples/redis_agent_memory/redis_agent_memory_agent/__init__.py b/contributing/samples/redis_agent_memory/redis_agent_memory_agent/__init__.py new file mode 100644 index 0000000..8ce90a2 --- /dev/null +++ b/contributing/samples/redis_agent_memory/redis_agent_memory_agent/__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/contributing/samples/redis_agent_memory/redis_agent_memory_agent/agent.py b/contributing/samples/redis_agent_memory/redis_agent_memory_agent/agent.py new file mode 100644 index 0000000..756bac1 --- /dev/null +++ b/contributing/samples/redis_agent_memory/redis_agent_memory_agent/agent.py @@ -0,0 +1,47 @@ +# 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. + +"""Sample agent demonstrating Redis Agent Memory Service integration.""" + +from datetime import datetime + +from google.adk import Agent +from google.adk.agents.callback_context import CallbackContext +from google.adk.tools import load_memory, preload_memory + + +def update_current_time(callback_context: CallbackContext): + """Update the current time in the agent's state.""" + callback_context.state["_time"] = datetime.now().isoformat() + + +root_agent = Agent( + model="gemini-2.5-flash", + name="redis_agent_memory_agent", + description="Agent with long-term memory powered by Redis Agent Memory Server.", + before_agent_callback=update_current_time, + instruction=( + "You are a helpful assistant with long-term memory capabilities.\n" + "You can remember information from past conversations with the user.\n\n" + "When the user asks about something you discussed before, use the load_memory " + "tool to search for relevant information from past conversations.\n" + "If the first search doesn't find relevant information, try different search " + "terms or keywords related to the question.\n\n" + "When the user shares personal information (name, preferences, interests), " + "acknowledge it - this information will be automatically saved to memory.\n\n" + "Current time: {_time}" + ), + tools=[preload_memory, load_memory], +) + From 05a382ab732f5bec4a2843bba31732765bd5e087 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 15 Jan 2026 10:09:16 +0100 Subject: [PATCH 06/20] Add Pydantic dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 3192789..461baa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "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 + "pydantic>=2.0, <3.0.0", # For data validation/models "redis>=5.0.0, <6.0.0", # Redis for session storage # go/keep-sorted end "orjson>=3.11.3", From 800048471e70af0876f27479cea1ca2c281c4f0d Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 15 Jan 2026 10:09:30 +0100 Subject: [PATCH 07/20] Constrain `agent-memory-client` dependency to Python 3.10+ --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 461baa9..c62eb85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ test = [ "pytest-asyncio>=1.2.0", ] redis-agent-memory = [ - "agent-memory-client>=0.13.0", + "agent-memory-client>=0.13.0; python_version >= '3.10'", ] [tool.pyink] From c3efa938342ad285921c8459ee78f9dfb5f7437a Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 15 Jan 2026 10:12:37 +0100 Subject: [PATCH 08/20] dataclass -> Pydantic; lazy-load client --- .../memory/redis_agent_memory_service.py | 87 +++++++++---------- .../memory/test_redis_agent_memory_service.py | 23 ++--- 2 files changed, 48 insertions(+), 62 deletions(-) diff --git a/src/google/adk_community/memory/redis_agent_memory_service.py b/src/google/adk_community/memory/redis_agent_memory_service.py index a367df7..78b0a52 100644 --- a/src/google/adk_community/memory/redis_agent_memory_service.py +++ b/src/google/adk_community/memory/redis_agent_memory_service.py @@ -22,15 +22,16 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field +from functools import cached_property from typing import TYPE_CHECKING, Literal, Optional from google.adk.memory.base_memory_service import BaseMemoryService, SearchMemoryResponse from google.adk.memory.memory_entry import MemoryEntry from google.genai import types +from pydantic import BaseModel, Field from typing_extensions import override -from .utils import extract_text_from_event +from google.adk_community.memory.utils import extract_text_from_event if TYPE_CHECKING: from google.adk.sessions.session import Session @@ -38,8 +39,7 @@ logger = logging.getLogger("google_adk." + __name__) -@dataclass -class RedisAgentMemoryServiceConfig: +class RedisAgentMemoryServiceConfig(BaseModel): """Configuration for Redis Agent Memory Service. Attributes: @@ -61,22 +61,22 @@ class RedisAgentMemoryServiceConfig: context_window_max: Maximum context window tokens (overrides model default). """ - api_base_url: str = "http://localhost:8000" - timeout: float = 30.0 + api_base_url: str = Field(default="http://localhost:8000") + timeout: float = Field(default=30.0, gt=0.0) default_namespace: Optional[str] = None - search_top_k: int = 10 - distance_threshold: Optional[float] = None + search_top_k: int = Field(default=10, ge=1) + distance_threshold: Optional[float] = Field(default=None, ge=0.0, le=1.0) recency_boost: bool = True - semantic_weight: float = 0.8 - recency_weight: float = 0.2 - freshness_weight: float = 0.6 - novelty_weight: float = 0.4 - half_life_last_access_days: float = 7.0 - half_life_created_days: float = 30.0 - extraction_strategy: Literal["discrete", "summary", "preferences", "custom"] = "discrete" - extraction_strategy_config: dict = field(default_factory=dict) + semantic_weight: float = Field(default=0.8, ge=0.0, le=1.0) + recency_weight: float = Field(default=0.2, ge=0.0, le=1.0) + freshness_weight: float = Field(default=0.6, ge=0.0, le=1.0) + novelty_weight: float = Field(default=0.4, ge=0.0, le=1.0) + half_life_last_access_days: float = Field(default=7.0, gt=0.0) + half_life_created_days: float = Field(default=30.0, gt=0.0) + extraction_strategy: Literal["discrete", "summary", "preferences", "custom"] = Field(default="discrete") + extraction_strategy_config: dict = Field(default_factory=dict) model_name: Optional[str] = None - context_window_max: Optional[int] = None + context_window_max: Optional[int] = Field(default=None, ge=1) class RedisAgentMemoryService(BaseMemoryService): @@ -124,30 +124,26 @@ def __init__(self, config: Optional[RedisAgentMemoryServiceConfig] = None): ImportError: If agent-memory-client package is not installed. """ self._config = config or RedisAgentMemoryServiceConfig() - self._client = None - self._client_initialized = False - async def _get_client(self): + @cached_property + def _client(self): """Lazily initialize and return the MemoryAPIClient.""" - if not self._client_initialized: - try: - from agent_memory_client import MemoryAPIClient, MemoryClientConfig - except ImportError as e: - raise ImportError( - "agent-memory-client package is required for RedisAgentMemoryService. " - "Install it with: pip install agent-memory-client" - ) from e - - client_config = MemoryClientConfig( - base_url=self._config.api_base_url, - timeout=self._config.timeout, - default_namespace=self._config.default_namespace, - default_model_name=self._config.model_name, - default_context_window_max=self._config.context_window_max, - ) - self._client = MemoryAPIClient(client_config) - self._client_initialized = True - return self._client + try: + from agent_memory_client import MemoryAPIClient, MemoryClientConfig + except ImportError as e: + raise ImportError( + "agent-memory-client package is required for RedisAgentMemoryService. " + "Install it with: pip install agent-memory-client" + ) from e + + client_config = MemoryClientConfig( + base_url=self._config.api_base_url, + timeout=self._config.timeout, + default_namespace=self._config.default_namespace, + default_model_name=self._config.model_name, + default_context_window_max=self._config.context_window_max, + ) + return MemoryAPIClient(client_config) def _build_working_memory(self, session: "Session"): """Convert ADK Session to WorkingMemory for the Agent Memory Server.""" @@ -193,14 +189,13 @@ async def add_session_to_memory(self, session: "Session"): session: The ADK Session containing events to store. """ try: - client = await self._get_client() working_memory = self._build_working_memory(session) if not working_memory.messages: logger.debug("No messages to store for session %s", session.id) return - response = await client.put_working_memory( + response = await self._client.put_working_memory( session_id=session.id, memory=working_memory, user_id=session.user_id, @@ -253,12 +248,11 @@ async def search_memory( SearchMemoryResponse containing matching MemoryEntry objects. """ try: - client = await self._get_client() recency_config = self._build_recency_config() if self._config.recency_boost else None namespace = self._config.default_namespace or app_name - results = await client.search_long_term_memory( + results = await self._client.search_long_term_memory( text=query, namespace={"eq": namespace}, user_id={"eq": user_id}, @@ -288,8 +282,7 @@ async def search_memory( async def close(self): """Close the memory service and cleanup resources.""" - if self._client is not None: + if "_client" in self.__dict__: # Check for initialized client without triggering cached_property await self._client.close() - self._client = None - self._client_initialized = False - + # Clear the cached property + del self._client diff --git a/tests/unittests/memory/test_redis_agent_memory_service.py b/tests/unittests/memory/test_redis_agent_memory_service.py index a6491df..71e6181 100644 --- a/tests/unittests/memory/test_redis_agent_memory_service.py +++ b/tests/unittests/memory/test_redis_agent_memory_service.py @@ -185,8 +185,8 @@ def mock_memory_client(self): def memory_service(self, mock_memory_client): """Create RedisAgentMemoryService with mocked client.""" service = RedisAgentMemoryService() - service._client = mock_memory_client - service._client_initialized = True + # Inject the mock client by setting it in __dict__ to bypass cached_property + service.__dict__['_client'] = mock_memory_client return service @pytest.fixture @@ -199,8 +199,8 @@ def memory_service_with_config(self, mock_memory_client): extraction_strategy="preferences", ) service = RedisAgentMemoryService(config=config) - service._client = mock_memory_client - service._client_initialized = True + # Inject the mock client by setting it in __dict__ to bypass cached_property + service.__dict__['_client'] = mock_memory_client return service @pytest.mark.asyncio @@ -371,21 +371,14 @@ async def test_close(self, memory_service, mock_memory_client): await memory_service.close() mock_memory_client.close.assert_called_once() - assert memory_service._client is None - assert memory_service._client_initialized is False + assert not hasattr(memory_service, 'client') or 'client' not in memory_service.__dict__ def test_import_error_handling(self): """Test that ImportError is raised when agent-memory-client is not installed.""" service = RedisAgentMemoryService() with patch.dict("sys.modules", {"agent_memory_client": None}): - with patch( - "google.adk_community.memory.redis_agent_memory_service.RedisAgentMemoryService._get_client" - ) as mock_get_client: - mock_get_client.side_effect = ImportError( - "agent-memory-client package is required" - ) - with pytest.raises(ImportError, match="agent-memory-client"): - import asyncio - asyncio.get_event_loop().run_until_complete(service._get_client()) + with pytest.raises(ImportError, match="agent-memory-client"): + # Access the client property which will trigger the import + _ = service._client From ed2abcc49bcb281f9fdc3695829187008ed06ce2 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 15 Jan 2026 10:14:41 +0100 Subject: [PATCH 09/20] Remove frivolous tests --- .../memory/test_redis_agent_memory_service.py | 51 ------------------- 1 file changed, 51 deletions(-) diff --git a/tests/unittests/memory/test_redis_agent_memory_service.py b/tests/unittests/memory/test_redis_agent_memory_service.py index 71e6181..a06e6dd 100644 --- a/tests/unittests/memory/test_redis_agent_memory_service.py +++ b/tests/unittests/memory/test_redis_agent_memory_service.py @@ -106,57 +106,6 @@ def __init__(self, recency_boost, semantic_weight, recency_weight, ) -class TestRedisAgentMemoryServiceConfig: - """Tests for RedisAgentMemoryServiceConfig.""" - - def test_default_config(self): - """Test default configuration values.""" - config = RedisAgentMemoryServiceConfig() - assert config.api_base_url == "http://localhost:8000" - assert config.timeout == 30.0 - assert config.default_namespace is None - assert config.search_top_k == 10 - assert config.distance_threshold is None - assert config.recency_boost is True - assert config.semantic_weight == 0.8 - assert config.recency_weight == 0.2 - assert config.freshness_weight == 0.6 - assert config.novelty_weight == 0.4 - assert config.half_life_last_access_days == 7.0 - assert config.half_life_created_days == 30.0 - assert config.extraction_strategy == "discrete" - assert config.extraction_strategy_config == {} - assert config.model_name is None - assert config.context_window_max is None - - def test_custom_config(self): - """Test custom configuration values.""" - config = RedisAgentMemoryServiceConfig( - api_base_url="http://memory-server:9000", - timeout=60.0, - default_namespace="my_app", - search_top_k=20, - distance_threshold=0.5, - recency_boost=False, - semantic_weight=0.7, - recency_weight=0.3, - extraction_strategy="summary", - model_name="gpt-4o", - context_window_max=128000, - ) - assert config.api_base_url == "http://memory-server:9000" - assert config.timeout == 60.0 - assert config.default_namespace == "my_app" - assert config.search_top_k == 20 - assert config.distance_threshold == 0.5 - assert config.recency_boost is False - assert config.semantic_weight == 0.7 - assert config.recency_weight == 0.3 - assert config.extraction_strategy == "summary" - assert config.model_name == "gpt-4o" - assert config.context_window_max == 128000 - - class TestRedisAgentMemoryService: """Tests for RedisAgentMemoryService.""" From 1494a81aec88b701a697d054995e0610aa8061e2 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 15 Jan 2026 11:12:28 +0100 Subject: [PATCH 10/20] Formatting --- .../memory/redis_agent_memory_service.py | 503 +++++++++-------- .../memory/test_redis_agent_memory_service.py | 531 +++++++++--------- 2 files changed, 535 insertions(+), 499 deletions(-) diff --git a/src/google/adk_community/memory/redis_agent_memory_service.py b/src/google/adk_community/memory/redis_agent_memory_service.py index 78b0a52..5e9355a 100644 --- a/src/google/adk_community/memory/redis_agent_memory_service.py +++ b/src/google/adk_community/memory/redis_agent_memory_service.py @@ -21,268 +21,277 @@ from __future__ import annotations -import logging from functools import cached_property -from typing import TYPE_CHECKING, Literal, Optional +import logging +from typing import Literal +from typing import Optional +from typing import TYPE_CHECKING -from google.adk.memory.base_memory_service import BaseMemoryService, SearchMemoryResponse +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 -from pydantic import BaseModel, Field +from pydantic import BaseModel +from pydantic import Field from typing_extensions import override from google.adk_community.memory.utils import extract_text_from_event if TYPE_CHECKING: - from google.adk.sessions.session import Session + from google.adk.sessions.session import Session logger = logging.getLogger("google_adk." + __name__) class RedisAgentMemoryServiceConfig(BaseModel): - """Configuration for Redis Agent Memory Service. - - Attributes: - api_base_url: Base URL of the Agent Memory Server. - timeout: HTTP request timeout in seconds. - default_namespace: Default namespace for memory operations. - search_top_k: Maximum number of memories to retrieve per search. - distance_threshold: Maximum distance threshold for search results (0.0-1.0). - recency_boost: Enable recency-aware re-ranking of search results. - semantic_weight: Weight for semantic similarity in recency boosting (0.0-1.0). - recency_weight: Weight for recency score in recency boosting (0.0-1.0). - freshness_weight: Weight for freshness component within recency score. - novelty_weight: Weight for novelty component within recency score. - half_life_last_access_days: Half-life in days for last_accessed decay. - half_life_created_days: Half-life in days for created_at decay. - extraction_strategy: Memory extraction strategy (discrete, summary, preferences, custom). - extraction_strategy_config: Additional configuration for the extraction strategy. - model_name: Model name for context window management and summarization. - context_window_max: Maximum context window tokens (overrides model default). - """ - - api_base_url: str = Field(default="http://localhost:8000") - timeout: float = Field(default=30.0, gt=0.0) - default_namespace: Optional[str] = None - search_top_k: int = Field(default=10, ge=1) - distance_threshold: Optional[float] = Field(default=None, ge=0.0, le=1.0) - recency_boost: bool = True - semantic_weight: float = Field(default=0.8, ge=0.0, le=1.0) - recency_weight: float = Field(default=0.2, ge=0.0, le=1.0) - freshness_weight: float = Field(default=0.6, ge=0.0, le=1.0) - novelty_weight: float = Field(default=0.4, ge=0.0, le=1.0) - half_life_last_access_days: float = Field(default=7.0, gt=0.0) - half_life_created_days: float = Field(default=30.0, gt=0.0) - extraction_strategy: Literal["discrete", "summary", "preferences", "custom"] = Field(default="discrete") - extraction_strategy_config: dict = Field(default_factory=dict) - model_name: Optional[str] = None - context_window_max: Optional[int] = Field(default=None, ge=1) + """Configuration for Redis Agent Memory Service. + + Attributes: + api_base_url: Base URL of the Agent Memory Server. + timeout: HTTP request timeout in seconds. + default_namespace: Default namespace for memory operations. + search_top_k: Maximum number of memories to retrieve per search. + distance_threshold: Maximum distance threshold for search results (0.0-1.0). + recency_boost: Enable recency-aware re-ranking of search results. + semantic_weight: Weight for semantic similarity in recency boosting (0.0-1.0). + recency_weight: Weight for recency score in recency boosting (0.0-1.0). + freshness_weight: Weight for freshness component within recency score. + novelty_weight: Weight for novelty component within recency score. + half_life_last_access_days: Half-life in days for last_accessed decay. + half_life_created_days: Half-life in days for created_at decay. + extraction_strategy: Memory extraction strategy (discrete, summary, preferences, custom). + extraction_strategy_config: Additional configuration for the extraction strategy. + model_name: Model name for context window management and summarization. + context_window_max: Maximum context window tokens (overrides model default). + """ + + api_base_url: str = Field(default="http://localhost:8000") + timeout: float = Field(default=30.0, gt=0.0) + default_namespace: Optional[str] = None + search_top_k: int = Field(default=10, ge=1) + distance_threshold: Optional[float] = Field(default=None, ge=0.0, le=1.0) + recency_boost: bool = True + semantic_weight: float = Field(default=0.8, ge=0.0, le=1.0) + recency_weight: float = Field(default=0.2, ge=0.0, le=1.0) + freshness_weight: float = Field(default=0.6, ge=0.0, le=1.0) + novelty_weight: float = Field(default=0.4, ge=0.0, le=1.0) + half_life_last_access_days: float = Field(default=7.0, gt=0.0) + half_life_created_days: float = Field(default=30.0, gt=0.0) + extraction_strategy: Literal[ + "discrete", "summary", "preferences", "custom" + ] = "discrete" + extraction_strategy_config: dict = Field(default_factory=dict) + model_name: Optional[str] = None + context_window_max: Optional[int] = Field(default=None, ge=1) class RedisAgentMemoryService(BaseMemoryService): - """Memory service implementation using Redis Agent Memory Server. - - This service provides production-grade memory capabilities including: - - Two-tier memory architecture (working memory + long-term memory) - - Automatic memory extraction (semantic facts, episodic events, preferences) - - Topic and entity extraction - - Auto-summarization when context window is exceeded - - Recency-boosted semantic search - - Deduplication and memory compaction - - Requires the `agent-memory-client` package to be installed. - - Example: - ```python - from google.adk_community.memory import ( - RedisAgentMemoryService, - RedisAgentMemoryServiceConfig, - ) - - config = RedisAgentMemoryServiceConfig( - api_base_url="http://localhost:8000", - default_namespace="my_app", - recency_boost=True, - ) - memory_service = RedisAgentMemoryService(config=config) - - # Use with ADK agent - agent = Agent( - name="my_agent", - memory_service=memory_service, - ) - ``` + """Memory service implementation using Redis Agent Memory Server. + + This service provides production-grade memory capabilities including: + - Two-tier memory architecture (working memory + long-term memory) + - Automatic memory extraction (semantic facts, episodic events, preferences) + - Topic and entity extraction + - Auto-summarization when context window is exceeded + - Recency-boosted semantic search + - Deduplication and memory compaction + + Requires the `agent-memory-client` package to be installed. + + Example: + ```python + from google.adk_community.memory import ( + RedisAgentMemoryService, + RedisAgentMemoryServiceConfig, + ) + + config = RedisAgentMemoryServiceConfig( + api_base_url="http://localhost:8000", + default_namespace="my_app", + recency_boost=True, + ) + memory_service = RedisAgentMemoryService(config=config) + + # Use with ADK agent + agent = Agent( + name="my_agent", + memory_service=memory_service, + ) + ``` + """ + + def __init__(self, config: Optional[RedisAgentMemoryServiceConfig] = None): + """Initialize the Redis Agent Memory Service. + + Args: + config: Configuration for the service. If None, uses defaults. + + Raises: + ImportError: If agent-memory-client package is not installed. """ - - def __init__(self, config: Optional[RedisAgentMemoryServiceConfig] = None): - """Initialize the Redis Agent Memory Service. - - Args: - config: Configuration for the service. If None, uses defaults. - - Raises: - ImportError: If agent-memory-client package is not installed. - """ - self._config = config or RedisAgentMemoryServiceConfig() - - @cached_property - def _client(self): - """Lazily initialize and return the MemoryAPIClient.""" - try: - from agent_memory_client import MemoryAPIClient, MemoryClientConfig - except ImportError as e: - raise ImportError( - "agent-memory-client package is required for RedisAgentMemoryService. " - "Install it with: pip install agent-memory-client" - ) from e - - client_config = MemoryClientConfig( - base_url=self._config.api_base_url, - timeout=self._config.timeout, - default_namespace=self._config.default_namespace, - default_model_name=self._config.model_name, - default_context_window_max=self._config.context_window_max, - ) - return MemoryAPIClient(client_config) - - def _build_working_memory(self, session: "Session"): - """Convert ADK Session to WorkingMemory for the Agent Memory Server.""" - from agent_memory_client.models import ( - MemoryMessage, - MemoryStrategyConfig, - WorkingMemory, - ) - - messages = [] - for event in session.events: - text = extract_text_from_event(event) - if not text: - continue - role = "user" if event.author == "user" else "assistant" - messages.append(MemoryMessage(role=role, content=text)) - - strategy_config = MemoryStrategyConfig( - strategy=self._config.extraction_strategy, - config=self._config.extraction_strategy_config, - ) - - return WorkingMemory( - session_id=session.id, - namespace=self._config.default_namespace or session.app_name, - user_id=session.user_id, - messages=messages, - long_term_memory_strategy=strategy_config, - ) - - @override - async def add_session_to_memory(self, session: "Session"): - """Add a session's events to the Agent Memory Server. - - Converts ADK Session events to WorkingMemory messages and stores them - in the Agent Memory Server. The server will automatically: - - Extract semantic and episodic memories based on the configured strategy - - Perform topic and entity extraction - - Summarize context when the token limit is exceeded - - Promote memories to long-term storage via background tasks - - Args: - session: The ADK Session containing events to store. - """ - try: - working_memory = self._build_working_memory(session) - - if not working_memory.messages: - logger.debug("No messages to store for session %s", session.id) - return - - response = await self._client.put_working_memory( - session_id=session.id, - memory=working_memory, - user_id=session.user_id, - ) - - logger.info( - "Stored %d messages for session %s (context: %.1f%% used)", - len(working_memory.messages), - session.id, - response.context_percentage_total_used or 0, - ) - - except Exception as e: - logger.error( - "Failed to add session %s to memory: %s", - session.id, - e, - ) - - def _build_recency_config(self): - """Build RecencyConfig from service configuration.""" - from agent_memory_client.models import RecencyConfig - - return RecencyConfig( - recency_boost=self._config.recency_boost, - semantic_weight=self._config.semantic_weight, - recency_weight=self._config.recency_weight, - freshness_weight=self._config.freshness_weight, - novelty_weight=self._config.novelty_weight, - half_life_last_access_days=self._config.half_life_last_access_days, - half_life_created_days=self._config.half_life_created_days, - ) - - @override - async def search_memory( - self, *, app_name: str, user_id: str, query: str - ) -> SearchMemoryResponse: - """Search for memories using the Agent Memory Server. - - Performs semantic search against long-term memory with optional - recency boosting. Results are filtered by namespace (derived from - app_name) and user_id. - - Args: - app_name: The application name (used as namespace if not configured). - user_id: The user ID to filter memories. - query: The search query for semantic matching. - - Returns: - SearchMemoryResponse containing matching MemoryEntry objects. - """ - try: - recency_config = self._build_recency_config() if self._config.recency_boost else None - - namespace = self._config.default_namespace or app_name - - results = await self._client.search_long_term_memory( - text=query, - namespace={"eq": namespace}, - user_id={"eq": user_id}, - distance_threshold=self._config.distance_threshold, - recency=recency_config, - limit=self._config.search_top_k, - ) - - memories = [] - for record in results.memories: - content = types.Content(parts=[types.Part(text=record.text)]) - memory_entry = MemoryEntry(content=content) - memories.append(memory_entry) - - logger.info( - "Found %d memories for query '%s' (namespace=%s, user=%s)", - len(memories), - query[:50], - namespace, - user_id, - ) - return SearchMemoryResponse(memories=memories) - - except Exception as e: - logger.error("Failed to search memories: %s", e) - return SearchMemoryResponse(memories=[]) - - async def close(self): - """Close the memory service and cleanup resources.""" - if "_client" in self.__dict__: # Check for initialized client without triggering cached_property - await self._client.close() - # Clear the cached property - del self._client + self._config = config or RedisAgentMemoryServiceConfig() + + @cached_property + def _client(self): + """Lazily initialize and return the MemoryAPIClient.""" + try: + from agent_memory_client import MemoryAPIClient + from agent_memory_client import MemoryClientConfig + except ImportError as e: + raise ImportError( + "agent-memory-client package is required for RedisAgentMemoryService." + " Install it with: pip install agent-memory-client" + ) from e + + client_config = MemoryClientConfig( + base_url=self._config.api_base_url, + timeout=self._config.timeout, + default_namespace=self._config.default_namespace, + default_model_name=self._config.model_name, + default_context_window_max=self._config.context_window_max, + ) + return MemoryAPIClient(client_config) + + def _build_working_memory(self, session: "Session"): + """Convert ADK Session to WorkingMemory for the Agent Memory Server.""" + from agent_memory_client.models import MemoryMessage + from agent_memory_client.models import MemoryStrategyConfig + from agent_memory_client.models import WorkingMemory + + messages = [] + for event in session.events: + text = extract_text_from_event(event) + if not text: + continue + role = "user" if event.author == "user" else "assistant" + messages.append(MemoryMessage(role=role, content=text)) + + strategy_config = MemoryStrategyConfig( + strategy=self._config.extraction_strategy, + config=self._config.extraction_strategy_config, + ) + + return WorkingMemory( + session_id=session.id, + namespace=self._config.default_namespace or session.app_name, + user_id=session.user_id, + messages=messages, + long_term_memory_strategy=strategy_config, + ) + + @override + async def add_session_to_memory(self, session: "Session"): + """Add a session's events to the Agent Memory Server. + + Converts ADK Session events to WorkingMemory messages and stores them + in the Agent Memory Server. The server will automatically: + - Extract semantic and episodic memories based on the configured strategy + - Perform topic and entity extraction + - Summarize context when the token limit is exceeded + - Promote memories to long-term storage via background tasks + + Args: + session: The ADK Session containing events to store. + """ + try: + working_memory = self._build_working_memory(session) + + if not working_memory.messages: + logger.debug("No messages to store for session %s", session.id) + return + + response = await self._client.put_working_memory( + session_id=session.id, + memory=working_memory, + user_id=session.user_id, + ) + + logger.info( + "Stored %d messages for session %s (context: %.1f%% used)", + len(working_memory.messages), + session.id, + response.context_percentage_total_used or 0, + ) + + except Exception as e: + logger.error( + "Failed to add session %s to memory: %s", + session.id, + e, + ) + + def _build_recency_config(self): + """Build RecencyConfig from service configuration.""" + from agent_memory_client.models import RecencyConfig + + return RecencyConfig( + recency_boost=self._config.recency_boost, + semantic_weight=self._config.semantic_weight, + recency_weight=self._config.recency_weight, + freshness_weight=self._config.freshness_weight, + novelty_weight=self._config.novelty_weight, + half_life_last_access_days=self._config.half_life_last_access_days, + half_life_created_days=self._config.half_life_created_days, + ) + + @override + async def search_memory( + self, *, app_name: str, user_id: str, query: str + ) -> SearchMemoryResponse: + """Search for memories using the Agent Memory Server. + + Performs semantic search against long-term memory with optional + recency boosting. Results are filtered by namespace (derived from + app_name) and user_id. + + Args: + app_name: The application name (used as namespace if not configured). + user_id: The user ID to filter memories. + query: The search query for semantic matching. + + Returns: + SearchMemoryResponse containing matching MemoryEntry objects. + """ + try: + recency_config = ( + self._build_recency_config() if self._config.recency_boost else None + ) + + namespace = self._config.default_namespace or app_name + + results = await self._client.search_long_term_memory( + text=query, + namespace={"eq": namespace}, + user_id={"eq": user_id}, + distance_threshold=self._config.distance_threshold, + recency=recency_config, + limit=self._config.search_top_k, + ) + + memories = [] + for record in results.memories: + content = types.Content(parts=[types.Part(text=record.text)]) + memory_entry = MemoryEntry(content=content) + memories.append(memory_entry) + + logger.info( + "Found %d memories for query '%s' (namespace=%s, user=%s)", + len(memories), + query[:50], + namespace, + user_id, + ) + return SearchMemoryResponse(memories=memories) + + except Exception as e: + logger.error("Failed to search memories: %s", e) + return SearchMemoryResponse(memories=[]) + + async def close(self): + """Close the memory service and cleanup resources.""" + if ( + "_client" in self.__dict__ + ): # Check for initialized client without triggering cached_property + await self._client.close() + # Clear the cached property + del self._client diff --git a/tests/unittests/memory/test_redis_agent_memory_service.py b/tests/unittests/memory/test_redis_agent_memory_service.py index a06e6dd..9ca61ee 100644 --- a/tests/unittests/memory/test_redis_agent_memory_service.py +++ b/tests/unittests/memory/test_redis_agent_memory_service.py @@ -13,52 +13,66 @@ # limitations under the License. import sys -from unittest.mock import AsyncMock, MagicMock, patch +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.redis_agent_memory_service import ( - RedisAgentMemoryService, - RedisAgentMemoryServiceConfig, -) +from google.adk_community.memory.redis_agent_memory_service import RedisAgentMemoryService +from google.adk_community.memory.redis_agent_memory_service import RedisAgentMemoryServiceConfig # Create mock classes for agent_memory_client.models class MockMemoryMessage: - def __init__(self, role, content): - self.role = role - self.content = content + + def __init__(self, role, content): + self.role = role + self.content = content class MockMemoryStrategyConfig: - def __init__(self, strategy, config=None): - self.strategy = strategy - self.config = config + + def __init__(self, strategy, config=None): + self.strategy = strategy + self.config = config class MockWorkingMemory: - def __init__(self, session_id, namespace, user_id, messages, long_term_memory_strategy): - self.session_id = session_id - self.namespace = namespace - self.user_id = user_id - self.messages = messages - self.long_term_memory_strategy = long_term_memory_strategy + + def __init__( + self, session_id, namespace, user_id, messages, long_term_memory_strategy + ): + self.session_id = session_id + self.namespace = namespace + self.user_id = user_id + self.messages = messages + self.long_term_memory_strategy = long_term_memory_strategy class MockRecencyConfig: - def __init__(self, recency_boost, semantic_weight, recency_weight, - freshness_weight, novelty_weight, half_life_last_access_days, - half_life_created_days): - self.recency_boost = recency_boost - self.semantic_weight = semantic_weight - self.recency_weight = recency_weight - self.freshness_weight = freshness_weight - self.novelty_weight = novelty_weight - self.half_life_last_access_days = half_life_last_access_days - self.half_life_created_days = half_life_created_days + + def __init__( + self, + recency_boost, + semantic_weight, + recency_weight, + freshness_weight, + novelty_weight, + half_life_last_access_days, + half_life_created_days, + ): + self.recency_boost = recency_boost + self.semantic_weight = semantic_weight + self.recency_weight = recency_weight + self.freshness_weight = freshness_weight + self.novelty_weight = novelty_weight + self.half_life_last_access_days = half_life_last_access_days + self.half_life_created_days = half_life_created_days + MOCK_APP_NAME = "test-app" MOCK_USER_ID = "test-user" @@ -85,7 +99,9 @@ def __init__(self, recency_boost, semantic_weight, recency_weight, author="model", timestamp=12346, content=types.Content( - parts=[types.Part(text="Python is a great programming language.")] + parts=[ + types.Part(text="Python is a great programming language.") + ] ), ), # Empty event, should be ignored @@ -107,227 +123,238 @@ def __init__(self, recency_boost, semantic_weight, recency_weight, class TestRedisAgentMemoryService: - """Tests for RedisAgentMemoryService.""" - - @pytest.fixture(autouse=True) - def mock_agent_memory_models(self): - """Mock agent_memory_client.models module.""" - mock_models = MagicMock() - mock_models.MemoryMessage = MockMemoryMessage - mock_models.MemoryStrategyConfig = MockMemoryStrategyConfig - mock_models.WorkingMemory = MockWorkingMemory - mock_models.RecencyConfig = MockRecencyConfig - - with patch.dict(sys.modules, {"agent_memory_client.models": mock_models}): - yield mock_models - - @pytest.fixture - def mock_memory_client(self): - """Create a mock MemoryAPIClient.""" - mock_client = MagicMock() - mock_client.put_working_memory = AsyncMock() - mock_client.search_long_term_memory = AsyncMock() - mock_client.close = AsyncMock() - return mock_client - - @pytest.fixture - def memory_service(self, mock_memory_client): - """Create RedisAgentMemoryService with mocked client.""" - service = RedisAgentMemoryService() - # Inject the mock client by setting it in __dict__ to bypass cached_property - service.__dict__['_client'] = mock_memory_client - return service - - @pytest.fixture - def memory_service_with_config(self, mock_memory_client): - """Create RedisAgentMemoryService with custom config.""" - config = RedisAgentMemoryServiceConfig( - default_namespace="custom_namespace", - search_top_k=5, - recency_boost=True, - extraction_strategy="preferences", - ) - service = RedisAgentMemoryService(config=config) - # Inject the mock client by setting it in __dict__ to bypass cached_property - service.__dict__['_client'] = mock_memory_client - return service - - @pytest.mark.asyncio - async def test_add_session_to_memory_success( - self, memory_service, mock_memory_client - ): - """Test successful addition of session to memory.""" - mock_response = MagicMock() - mock_response.context_percentage_total_used = 25.0 - mock_memory_client.put_working_memory.return_value = mock_response - - await memory_service.add_session_to_memory(MOCK_SESSION) - - mock_memory_client.put_working_memory.assert_called_once() - call_args = mock_memory_client.put_working_memory.call_args - assert call_args.kwargs["session_id"] == MOCK_SESSION_ID - assert call_args.kwargs["user_id"] == MOCK_USER_ID - - working_memory = call_args.kwargs["memory"] - assert len(working_memory.messages) == 2 - assert working_memory.messages[0].role == "user" - assert working_memory.messages[0].content == "Hello, I like Python." - assert working_memory.messages[1].role == "assistant" - assert working_memory.messages[1].content == "Python is a great programming language." - - @pytest.mark.asyncio - async def test_add_session_filters_empty_events( - self, memory_service, mock_memory_client - ): - """Test that events without content are filtered out.""" - await memory_service.add_session_to_memory(MOCK_SESSION_WITH_EMPTY_EVENTS) - - mock_memory_client.put_working_memory.assert_not_called() - - @pytest.mark.asyncio - async def test_add_session_uses_config_namespace( - self, memory_service_with_config, mock_memory_client - ): - """Test that namespace from config is used.""" - mock_response = MagicMock() - mock_response.context_percentage_total_used = 10.0 - mock_memory_client.put_working_memory.return_value = mock_response - - await memory_service_with_config.add_session_to_memory(MOCK_SESSION) - - call_args = mock_memory_client.put_working_memory.call_args - working_memory = call_args.kwargs["memory"] - assert working_memory.namespace == "custom_namespace" - - @pytest.mark.asyncio - async def test_add_session_uses_extraction_strategy( - self, memory_service_with_config, mock_memory_client - ): - """Test that extraction strategy from config is used.""" - mock_response = MagicMock() - mock_response.context_percentage_total_used = 10.0 - mock_memory_client.put_working_memory.return_value = mock_response - - await memory_service_with_config.add_session_to_memory(MOCK_SESSION) - - call_args = mock_memory_client.put_working_memory.call_args - working_memory = call_args.kwargs["memory"] - assert working_memory.long_term_memory_strategy.strategy == "preferences" - - @pytest.mark.asyncio - async def test_add_session_error_handling( - self, memory_service, mock_memory_client - ): - """Test error handling during memory addition.""" - mock_memory_client.put_working_memory.side_effect = Exception("API Error") - - # Should not raise exception, just log error - await memory_service.add_session_to_memory(MOCK_SESSION) - - @pytest.mark.asyncio - async def test_search_memory_success(self, memory_service, mock_memory_client): - """Test successful memory search.""" - mock_memory = MagicMock() - mock_memory.text = "Python is a great language" - mock_results = MagicMock() - mock_results.memories = [mock_memory] - mock_memory_client.search_long_term_memory.return_value = mock_results - - result = await memory_service.search_memory( - app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="Python programming" - ) - - mock_memory_client.search_long_term_memory.assert_called_once() - call_args = mock_memory_client.search_long_term_memory.call_args - assert call_args.kwargs["text"] == "Python programming" - assert call_args.kwargs["namespace"] == {"eq": MOCK_APP_NAME} - assert call_args.kwargs["user_id"] == {"eq": MOCK_USER_ID} - - assert len(result.memories) == 1 - assert result.memories[0].content.parts[0].text == "Python is a great language" - - @pytest.mark.asyncio - async def test_search_memory_with_recency_boost( - self, memory_service, mock_memory_client - ): - """Test that recency config is passed when enabled.""" - mock_results = MagicMock() - mock_results.memories = [] - mock_memory_client.search_long_term_memory.return_value = mock_results - - await memory_service.search_memory( - app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" - ) - - call_args = mock_memory_client.search_long_term_memory.call_args - recency = call_args.kwargs["recency"] - assert recency is not None - assert recency.recency_boost is True - assert recency.semantic_weight == 0.8 - assert recency.recency_weight == 0.2 - - @pytest.mark.asyncio - async def test_search_memory_without_recency_boost(self, mock_memory_client): - """Test that recency config is None when disabled.""" - config = RedisAgentMemoryServiceConfig(recency_boost=False) - service = RedisAgentMemoryService(config=config) - service._client = mock_memory_client - service._client_initialized = True - - mock_results = MagicMock() - mock_results.memories = [] - mock_memory_client.search_long_term_memory.return_value = mock_results - - await service.search_memory( - app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" - ) - - call_args = mock_memory_client.search_long_term_memory.call_args - assert call_args.kwargs["recency"] is None - - @pytest.mark.asyncio - async def test_search_memory_respects_top_k( - self, memory_service_with_config, mock_memory_client - ): - """Test that config.search_top_k is used.""" - mock_results = MagicMock() - mock_results.memories = [] - mock_memory_client.search_long_term_memory.return_value = mock_results - - await memory_service_with_config.search_memory( - app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" - ) - - call_args = mock_memory_client.search_long_term_memory.call_args - assert call_args.kwargs["limit"] == 5 - - @pytest.mark.asyncio - async def test_search_memory_error_handling( - self, memory_service, mock_memory_client - ): - """Test graceful error handling during memory search.""" - mock_memory_client.search_long_term_memory.side_effect = Exception("API Error") - - result = await memory_service.search_memory( - app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" - ) - - assert len(result.memories) == 0 - - @pytest.mark.asyncio - async def test_close(self, memory_service, mock_memory_client): - """Test closing the service.""" - await memory_service.close() - - mock_memory_client.close.assert_called_once() - assert not hasattr(memory_service, 'client') or 'client' not in memory_service.__dict__ - - def test_import_error_handling(self): - """Test that ImportError is raised when agent-memory-client is not installed.""" - service = RedisAgentMemoryService() - - with patch.dict("sys.modules", {"agent_memory_client": None}): - with pytest.raises(ImportError, match="agent-memory-client"): - # Access the client property which will trigger the import - _ = service._client - + """Tests for RedisAgentMemoryService.""" + + @pytest.fixture(autouse=True) + def mock_agent_memory_models(self): + """Mock agent_memory_client.models module.""" + mock_models = MagicMock() + mock_models.MemoryMessage = MockMemoryMessage + mock_models.MemoryStrategyConfig = MockMemoryStrategyConfig + mock_models.WorkingMemory = MockWorkingMemory + mock_models.RecencyConfig = MockRecencyConfig + + with patch.dict(sys.modules, {"agent_memory_client.models": mock_models}): + yield mock_models + + @pytest.fixture + def mock_memory_client(self): + """Create a mock MemoryAPIClient.""" + mock_client = MagicMock() + mock_client.put_working_memory = AsyncMock() + mock_client.search_long_term_memory = AsyncMock() + mock_client.close = AsyncMock() + return mock_client + + @pytest.fixture + def memory_service(self, mock_memory_client): + """Create RedisAgentMemoryService with mocked client.""" + service = RedisAgentMemoryService() + # Inject the mock client by setting it in __dict__ to bypass cached_property + service.__dict__["_client"] = mock_memory_client + return service + + @pytest.fixture + def memory_service_with_config(self, mock_memory_client): + """Create RedisAgentMemoryService with custom config.""" + config = RedisAgentMemoryServiceConfig( + default_namespace="custom_namespace", + search_top_k=5, + recency_boost=True, + extraction_strategy="preferences", + ) + service = RedisAgentMemoryService(config=config) + # Inject the mock client by setting it in __dict__ to bypass cached_property + service.__dict__["_client"] = mock_memory_client + return service + + @pytest.mark.asyncio + async def test_add_session_to_memory_success( + self, memory_service, mock_memory_client + ): + """Test successful addition of session to memory.""" + mock_response = MagicMock() + mock_response.context_percentage_total_used = 25.0 + mock_memory_client.put_working_memory.return_value = mock_response + + await memory_service.add_session_to_memory(MOCK_SESSION) + + mock_memory_client.put_working_memory.assert_called_once() + call_args = mock_memory_client.put_working_memory.call_args + assert call_args.kwargs["session_id"] == MOCK_SESSION_ID + assert call_args.kwargs["user_id"] == MOCK_USER_ID + + working_memory = call_args.kwargs["memory"] + assert len(working_memory.messages) == 2 + assert working_memory.messages[0].role == "user" + assert working_memory.messages[0].content == "Hello, I like Python." + assert working_memory.messages[1].role == "assistant" + assert ( + working_memory.messages[1].content + == "Python is a great programming language." + ) + + @pytest.mark.asyncio + async def test_add_session_filters_empty_events( + self, memory_service, mock_memory_client + ): + """Test that events without content are filtered out.""" + await memory_service.add_session_to_memory(MOCK_SESSION_WITH_EMPTY_EVENTS) + + mock_memory_client.put_working_memory.assert_not_called() + + @pytest.mark.asyncio + async def test_add_session_uses_config_namespace( + self, memory_service_with_config, mock_memory_client + ): + """Test that namespace from config is used.""" + mock_response = MagicMock() + mock_response.context_percentage_total_used = 10.0 + mock_memory_client.put_working_memory.return_value = mock_response + + await memory_service_with_config.add_session_to_memory(MOCK_SESSION) + + call_args = mock_memory_client.put_working_memory.call_args + working_memory = call_args.kwargs["memory"] + assert working_memory.namespace == "custom_namespace" + + @pytest.mark.asyncio + async def test_add_session_uses_extraction_strategy( + self, memory_service_with_config, mock_memory_client + ): + """Test that extraction strategy from config is used.""" + mock_response = MagicMock() + mock_response.context_percentage_total_used = 10.0 + mock_memory_client.put_working_memory.return_value = mock_response + + await memory_service_with_config.add_session_to_memory(MOCK_SESSION) + + call_args = mock_memory_client.put_working_memory.call_args + working_memory = call_args.kwargs["memory"] + assert working_memory.long_term_memory_strategy.strategy == "preferences" + + @pytest.mark.asyncio + async def test_add_session_error_handling( + self, memory_service, mock_memory_client + ): + """Test error handling during memory addition.""" + mock_memory_client.put_working_memory.side_effect = Exception("API Error") + + # Should not raise exception, just log error + await memory_service.add_session_to_memory(MOCK_SESSION) + + @pytest.mark.asyncio + async def test_search_memory_success( + self, memory_service, mock_memory_client + ): + """Test successful memory search.""" + mock_memory = MagicMock() + mock_memory.text = "Python is a great language" + mock_results = MagicMock() + mock_results.memories = [mock_memory] + mock_memory_client.search_long_term_memory.return_value = mock_results + + result = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="Python programming" + ) + + mock_memory_client.search_long_term_memory.assert_called_once() + call_args = mock_memory_client.search_long_term_memory.call_args + assert call_args.kwargs["text"] == "Python programming" + assert call_args.kwargs["namespace"] == {"eq": MOCK_APP_NAME} + assert call_args.kwargs["user_id"] == {"eq": MOCK_USER_ID} + + assert len(result.memories) == 1 + assert ( + result.memories[0].content.parts[0].text == "Python is a great language" + ) + + @pytest.mark.asyncio + async def test_search_memory_with_recency_boost( + self, memory_service, mock_memory_client + ): + """Test that recency config is passed when enabled.""" + mock_results = MagicMock() + mock_results.memories = [] + mock_memory_client.search_long_term_memory.return_value = mock_results + + await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" + ) + + call_args = mock_memory_client.search_long_term_memory.call_args + recency = call_args.kwargs["recency"] + assert recency is not None + assert recency.recency_boost is True + assert recency.semantic_weight == 0.8 + assert recency.recency_weight == 0.2 + + @pytest.mark.asyncio + async def test_search_memory_without_recency_boost(self, mock_memory_client): + """Test that recency config is None when disabled.""" + config = RedisAgentMemoryServiceConfig(recency_boost=False) + service = RedisAgentMemoryService(config=config) + service._client = mock_memory_client + service._client_initialized = True + + mock_results = MagicMock() + mock_results.memories = [] + mock_memory_client.search_long_term_memory.return_value = mock_results + + await service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" + ) + + call_args = mock_memory_client.search_long_term_memory.call_args + assert call_args.kwargs["recency"] is None + + @pytest.mark.asyncio + async def test_search_memory_respects_top_k( + self, memory_service_with_config, mock_memory_client + ): + """Test that config.search_top_k is used.""" + mock_results = MagicMock() + mock_results.memories = [] + mock_memory_client.search_long_term_memory.return_value = mock_results + + await memory_service_with_config.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" + ) + + call_args = mock_memory_client.search_long_term_memory.call_args + assert call_args.kwargs["limit"] == 5 + + @pytest.mark.asyncio + async def test_search_memory_error_handling( + self, memory_service, mock_memory_client + ): + """Test graceful error handling during memory search.""" + mock_memory_client.search_long_term_memory.side_effect = Exception( + "API Error" + ) + + result = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query="test query" + ) + + assert len(result.memories) == 0 + + @pytest.mark.asyncio + async def test_close(self, memory_service, mock_memory_client): + """Test closing the service.""" + await memory_service.close() + + mock_memory_client.close.assert_called_once() + assert ( + not hasattr(memory_service, "client") + or "client" not in memory_service.__dict__ + ) + + def test_import_error_handling(self): + """Test that ImportError is raised when agent-memory-client is not installed.""" + service = RedisAgentMemoryService() + + with patch.dict("sys.modules", {"agent_memory_client": None}): + with pytest.raises(ImportError, match="agent-memory-client"): + # Access the client property which will trigger the import + _ = service._client From 65155a28fdef004a5d656be763f410b21b2117f9 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 15 Jan 2026 11:37:41 +0100 Subject: [PATCH 11/20] Fix Python version range for agent-memory-server --- contributing/samples/redis_agent_memory/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributing/samples/redis_agent_memory/README.md b/contributing/samples/redis_agent_memory/README.md index 38711fe..d675c8d 100644 --- a/contributing/samples/redis_agent_memory/README.md +++ b/contributing/samples/redis_agent_memory/README.md @@ -5,7 +5,7 @@ memory backend for ADK agents using the community package. ## Prerequisites -- Python 3.9+ (Python 3.11+ recommended) +- Python 3.10+ (Python 3.11+ recommended) - Docker (for running Redis Stack) - Redis Agent Memory Server running - ADK and ADK Community installed From 471e62d700eb9d8176ede2733486af120afc3b57 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Tue, 27 Jan 2026 13:51:28 -0500 Subject: [PATCH 12/20] refactor(memory): rename RedisAgentMemoryService to RedisLongTermMemoryService - Rename redis_agent_memory_service.py to redis_long_term_memory_service.py - Rename class to better reflect its purpose (long-term memory storage) - Update imports and exports in __init__.py - Rename test file accordingly - No functional changes --- src/google/adk_community/memory/__init__.py | 9 +++-- ...e.py => redis_long_term_memory_service.py} | 35 +++++++++++-------- 2 files changed, 24 insertions(+), 20 deletions(-) rename src/google/adk_community/memory/{redis_agent_memory_service.py => redis_long_term_memory_service.py} (90%) diff --git a/src/google/adk_community/memory/__init__.py b/src/google/adk_community/memory/__init__.py index ec37e4e..60610d3 100644 --- a/src/google/adk_community/memory/__init__.py +++ b/src/google/adk_community/memory/__init__.py @@ -16,13 +16,12 @@ from .open_memory_service import OpenMemoryService from .open_memory_service import OpenMemoryServiceConfig -from .redis_agent_memory_service import RedisAgentMemoryService -from .redis_agent_memory_service import RedisAgentMemoryServiceConfig +from .redis_long_term_memory_service import RedisLongTermMemoryService +from .redis_long_term_memory_service import RedisLongTermMemoryServiceConfig __all__ = [ "OpenMemoryService", "OpenMemoryServiceConfig", - "RedisAgentMemoryService", - "RedisAgentMemoryServiceConfig", + "RedisLongTermMemoryService", + "RedisLongTermMemoryServiceConfig", ] - diff --git a/src/google/adk_community/memory/redis_agent_memory_service.py b/src/google/adk_community/memory/redis_long_term_memory_service.py similarity index 90% rename from src/google/adk_community/memory/redis_agent_memory_service.py rename to src/google/adk_community/memory/redis_long_term_memory_service.py index 5e9355a..4d0e264 100644 --- a/src/google/adk_community/memory/redis_agent_memory_service.py +++ b/src/google/adk_community/memory/redis_long_term_memory_service.py @@ -12,11 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Redis Agent Memory Service for ADK. +"""Redis Long-Term Memory Service for ADK. This module provides integration with the Redis Agent Memory Server, offering production-grade long-term memory with automatic summarization, topic/entity extraction, and recency-boosted search. + +Note: The classes were renamed from RedisAgentMemoryService to +RedisLongTermMemoryService to better reflect their purpose of managing +long-term memory via the Agent Memory Server. """ from __future__ import annotations @@ -43,8 +47,8 @@ logger = logging.getLogger("google_adk." + __name__) -class RedisAgentMemoryServiceConfig(BaseModel): - """Configuration for Redis Agent Memory Service. +class RedisLongTermMemoryServiceConfig(BaseModel): + """Configuration for Redis Long-Term Memory Service. Attributes: api_base_url: Base URL of the Agent Memory Server. @@ -85,8 +89,8 @@ class RedisAgentMemoryServiceConfig(BaseModel): context_window_max: Optional[int] = Field(default=None, ge=1) -class RedisAgentMemoryService(BaseMemoryService): - """Memory service implementation using Redis Agent Memory Server. +class RedisLongTermMemoryService(BaseMemoryService): + """Long-term memory service implementation using Redis Agent Memory Server. This service provides production-grade memory capabilities including: - Two-tier memory architecture (working memory + long-term memory) @@ -95,22 +99,22 @@ class RedisAgentMemoryService(BaseMemoryService): - Auto-summarization when context window is exceeded - Recency-boosted semantic search - Deduplication and memory compaction - + - https://github.com/redis/agent-memory-server Requires the `agent-memory-client` package to be installed. Example: ```python from google.adk_community.memory import ( - RedisAgentMemoryService, - RedisAgentMemoryServiceConfig, + RedisLongTermMemoryService, + RedisLongTermMemoryServiceConfig, ) - config = RedisAgentMemoryServiceConfig( + config = RedisLongTermMemoryServiceConfig( api_base_url="http://localhost:8000", default_namespace="my_app", recency_boost=True, ) - memory_service = RedisAgentMemoryService(config=config) + memory_service = RedisLongTermMemoryService(config=config) # Use with ADK agent agent = Agent( @@ -120,8 +124,8 @@ class RedisAgentMemoryService(BaseMemoryService): ``` """ - def __init__(self, config: Optional[RedisAgentMemoryServiceConfig] = None): - """Initialize the Redis Agent Memory Service. + def __init__(self, config: Optional[RedisLongTermMemoryServiceConfig] = None): + """Initialize the Redis Long-Term Memory Service. Args: config: Configuration for the service. If None, uses defaults. @@ -129,7 +133,7 @@ def __init__(self, config: Optional[RedisAgentMemoryServiceConfig] = None): Raises: ImportError: If agent-memory-client package is not installed. """ - self._config = config or RedisAgentMemoryServiceConfig() + self._config = config or RedisLongTermMemoryServiceConfig() @cached_property def _client(self): @@ -139,8 +143,9 @@ def _client(self): from agent_memory_client import MemoryClientConfig except ImportError as e: raise ImportError( - "agent-memory-client package is required for RedisAgentMemoryService." - " Install it with: pip install agent-memory-client" + "agent-memory-client package is required for" + " RedisLongTermMemoryService. Install it with: pip install" + " agent-memory-client" ) from e client_config = MemoryClientConfig( From 0f9202965abef88f198b35f1979388637a620ed9 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Tue, 27 Jan 2026 13:57:02 -0500 Subject: [PATCH 13/20] tests(memory): rename test_redis_agent_memory_service.py to test_redis_long_term_memory_service.py --- ...=> test_redis_long_term_memory_service.py} | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) rename tests/unittests/memory/{test_redis_agent_memory_service.py => test_redis_long_term_memory_service.py} (94%) diff --git a/tests/unittests/memory/test_redis_agent_memory_service.py b/tests/unittests/memory/test_redis_long_term_memory_service.py similarity index 94% rename from tests/unittests/memory/test_redis_agent_memory_service.py rename to tests/unittests/memory/test_redis_long_term_memory_service.py index 9ca61ee..72c7482 100644 --- a/tests/unittests/memory/test_redis_agent_memory_service.py +++ b/tests/unittests/memory/test_redis_long_term_memory_service.py @@ -22,8 +22,8 @@ from google.genai import types import pytest -from google.adk_community.memory.redis_agent_memory_service import RedisAgentMemoryService -from google.adk_community.memory.redis_agent_memory_service import RedisAgentMemoryServiceConfig +from google.adk_community.memory.redis_long_term_memory_service import RedisLongTermMemoryService +from google.adk_community.memory.redis_long_term_memory_service import RedisLongTermMemoryServiceConfig # Create mock classes for agent_memory_client.models @@ -122,8 +122,8 @@ def __init__( ) -class TestRedisAgentMemoryService: - """Tests for RedisAgentMemoryService.""" +class TestRedisLongTermMemoryService: + """Tests for RedisLongTermMemoryService.""" @pytest.fixture(autouse=True) def mock_agent_memory_models(self): @@ -148,22 +148,22 @@ def mock_memory_client(self): @pytest.fixture def memory_service(self, mock_memory_client): - """Create RedisAgentMemoryService with mocked client.""" - service = RedisAgentMemoryService() + """Create RedisLongTermMemoryService with mocked client.""" + service = RedisLongTermMemoryService() # Inject the mock client by setting it in __dict__ to bypass cached_property service.__dict__["_client"] = mock_memory_client return service @pytest.fixture def memory_service_with_config(self, mock_memory_client): - """Create RedisAgentMemoryService with custom config.""" - config = RedisAgentMemoryServiceConfig( + """Create RedisLongTermMemoryService with custom config.""" + config = RedisLongTermMemoryServiceConfig( default_namespace="custom_namespace", search_top_k=5, recency_boost=True, extraction_strategy="preferences", ) - service = RedisAgentMemoryService(config=config) + service = RedisLongTermMemoryService(config=config) # Inject the mock client by setting it in __dict__ to bypass cached_property service.__dict__["_client"] = mock_memory_client return service @@ -292,8 +292,8 @@ async def test_search_memory_with_recency_boost( @pytest.mark.asyncio async def test_search_memory_without_recency_boost(self, mock_memory_client): """Test that recency config is None when disabled.""" - config = RedisAgentMemoryServiceConfig(recency_boost=False) - service = RedisAgentMemoryService(config=config) + config = RedisLongTermMemoryServiceConfig(recency_boost=False) + service = RedisLongTermMemoryService(config=config) service._client = mock_memory_client service._client_initialized = True @@ -352,7 +352,7 @@ async def test_close(self, memory_service, mock_memory_client): def test_import_error_handling(self): """Test that ImportError is raised when agent-memory-client is not installed.""" - service = RedisAgentMemoryService() + service = RedisLongTermMemoryService() with patch.dict("sys.modules", {"agent_memory_client": None}): with pytest.raises(ImportError, match="agent-memory-client"): From e5eb662e065909586c5940d8da380bb44b81ef87 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Tue, 27 Jan 2026 13:57:58 -0500 Subject: [PATCH 14/20] formatting utils.py --- src/google/adk_community/memory/utils.py | 5 ++-- src/google/adk_community/sessions/utils.py | 28 +++++++++++----------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/google/adk_community/memory/utils.py b/src/google/adk_community/memory/utils.py index 0b78206..48058ce 100644 --- a/src/google/adk_community/memory/utils.py +++ b/src/google/adk_community/memory/utils.py @@ -33,9 +33,8 @@ def extract_text_from_event(event) -> str: # 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 + part.text + for part in event.content.parts if part.text and not part.thought ] return ' '.join(text_parts) - diff --git a/src/google/adk_community/sessions/utils.py b/src/google/adk_community/sessions/utils.py index bc53d2b..132c773 100644 --- a/src/google/adk_community/sessions/utils.py +++ b/src/google/adk_community/sessions/utils.py @@ -20,18 +20,18 @@ def _json_serializer(obj): - """Fallback serializer to handle non-JSON-compatible types.""" - if isinstance(obj, set): - return list(obj) - if isinstance(obj, bytes): - try: - return base64.b64encode(obj).decode("ascii") - except Exception: - return repr(obj) - if isinstance(obj, (datetime.datetime, datetime.date)): - return obj.isoformat() - if isinstance(obj, uuid.UUID): - return str(obj) - if isinstance(obj, Decimal): - return float(obj) + """Fallback serializer to handle non-JSON-compatible types.""" + if isinstance(obj, set): + return list(obj) + if isinstance(obj, bytes): + try: + return base64.b64encode(obj).decode("ascii") + except Exception: + return repr(obj) + if isinstance(obj, (datetime.datetime, datetime.date)): + return obj.isoformat() + if isinstance(obj, uuid.UUID): return str(obj) + if isinstance(obj, Decimal): + return float(obj) + return str(obj) From dacffc860eb05895df0334847b8a97cb486b42c0 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Tue, 27 Jan 2026 13:59:02 -0500 Subject: [PATCH 15/20] feat(sessions): add RedisWorkingMemorySessionService for working memory - Implement BaseSessionService using Redis Agent Memory Server - Support session CRUD operations via working memory API - Add auto-summarization and context window management - Add configuration options (namespace, model, extraction strategy) - Unit tests --- src/google/adk_community/__init__.py | 1 + src/google/adk_community/sessions/__init__.py | 8 +- .../redis_working_memory_session_service.py | 437 ++++++++++++++++++ ...st_redis_working_memory_session_service.py | 269 +++++++++++ 4 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 src/google/adk_community/sessions/redis_working_memory_session_service.py create mode 100644 tests/unittests/sessions/test_redis_working_memory_session_service.py diff --git a/src/google/adk_community/__init__.py b/src/google/adk_community/__init__.py index 9a1dc35..98c0e33 100644 --- a/src/google/adk_community/__init__.py +++ b/src/google/adk_community/__init__.py @@ -15,4 +15,5 @@ from . import memory from . import sessions from . import version + __version__ = version.__version__ diff --git a/src/google/adk_community/sessions/__init__.py b/src/google/adk_community/sessions/__init__.py index 90bf28d..340ae34 100644 --- a/src/google/adk_community/sessions/__init__.py +++ b/src/google/adk_community/sessions/__init__.py @@ -15,5 +15,11 @@ """Community session services for ADK.""" from .redis_session_service import RedisSessionService +from .redis_working_memory_session_service import RedisWorkingMemorySessionService +from .redis_working_memory_session_service import RedisWorkingMemorySessionServiceConfig -__all__ = ["RedisSessionService"] +__all__ = [ + "RedisSessionService", + "RedisWorkingMemorySessionService", + "RedisWorkingMemorySessionServiceConfig", +] diff --git a/src/google/adk_community/sessions/redis_working_memory_session_service.py b/src/google/adk_community/sessions/redis_working_memory_session_service.py new file mode 100644 index 0000000..fd30c31 --- /dev/null +++ b/src/google/adk_community/sessions/redis_working_memory_session_service.py @@ -0,0 +1,437 @@ +# 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. + +"""Redis Working Memory Session Service for ADK. + +This module provides session management using the Redis Agent Memory Server's +Working Memory API, offering automatic context summarization and background +memory extraction. +""" + +from __future__ import annotations + +from functools import cached_property +import logging +import time +from typing import Any +from typing import Literal +from typing import Optional +import uuid + +from google.adk.events.event import Event +from google.adk.sessions.base_session_service import BaseSessionService +from google.adk.sessions.base_session_service import GetSessionConfig +from google.adk.sessions.base_session_service import ListSessionsResponse +from google.adk.sessions.session import Session +from google.genai import types +from pydantic import BaseModel +from pydantic import Field +from typing_extensions import override + +from google.adk_community.memory.utils import extract_text_from_event + +logger = logging.getLogger("google_adk." + __name__) + + +class RedisWorkingMemorySessionServiceConfig(BaseModel): + """Configuration for Redis Working Memory Session Service. + + Attributes: + api_base_url: Base URL of the Agent Memory Server. + timeout: HTTP request timeout in seconds. + default_namespace: Default namespace for session operations. + model_name: Model name for context window management and summarization. + context_window_max: Maximum context window tokens. + extraction_strategy: Memory extraction strategy. + extraction_strategy_config: Additional config for extraction strategy. + session_ttl_seconds: Optional TTL for session expiration. + """ + + api_base_url: str = Field(default="http://localhost:8000") + timeout: float = Field(default=30.0, gt=0.0) + default_namespace: Optional[str] = None + model_name: Optional[str] = None + context_window_max: Optional[int] = Field(default=None, ge=1) + extraction_strategy: Literal[ + "discrete", "summary", "preferences", "custom" + ] = "discrete" + extraction_strategy_config: dict = Field(default_factory=dict) + session_ttl_seconds: Optional[int] = Field(default=None, ge=1) + + +class RedisWorkingMemorySessionService(BaseSessionService): + """Session service using Redis Agent Memory Server's Working Memory API. + + This service provides session management backed by Agent Memory Server: + - Session storage in Working Memory + - Automatic context summarization when token limit exceeded + - Background memory extraction to Long-Term Memory + - Incremental message appending + - https://github.com/redis/agent-memory-server + + Requires the `agent-memory-client` package to be installed. + + Example: + ```python + from google.adk_community.sessions import ( + RedisWorkingMemorySessionService, + RedisWorkingMemorySessionServiceConfig, + ) + + config = RedisWorkingMemorySessionServiceConfig( + api_base_url="http://localhost:8000", + default_namespace="my_app", + ) + session_service = RedisWorkingMemorySessionService(config=config) + + # Use with ADK runner + runner = Runner( + agent=agent, + session_service=session_service, + ) + ``` + """ + + def __init__( + self, config: Optional[RedisWorkingMemorySessionServiceConfig] = None + ): + """Initialize the Redis Working Memory Session Service. + + Args: + config: Configuration for the service. If None, uses defaults. + """ + self._config = config or RedisWorkingMemorySessionServiceConfig() + + @cached_property + def _client(self): + """Lazily initialize and return the MemoryAPIClient.""" + try: + from agent_memory_client import MemoryAPIClient + from agent_memory_client import MemoryClientConfig + except ImportError as e: + raise ImportError( + "agent-memory-client package is required for " + "RedisWorkingMemorySessionService. " + "Install it with: pip install agent-memory-client" + ) from e + + client_config = MemoryClientConfig( + base_url=self._config.api_base_url, + timeout=self._config.timeout, + default_namespace=self._config.default_namespace, + default_model_name=self._config.model_name, + default_context_window_max=self._config.context_window_max, + ) + return MemoryAPIClient(client_config) + + def _get_namespace(self, app_name: str) -> str: + """Get namespace from config or app_name.""" + return self._config.default_namespace or app_name + + def _event_to_message(self, event: Event): + """Convert ADK Event to MemoryMessage.""" + from datetime import datetime + from datetime import timezone + + from agent_memory_client.models import MemoryMessage + + text = extract_text_from_event(event) + if not text: + return None + + role = "user" if event.author == "user" else "assistant" + # Convert event timestamp (float) to datetime for MemoryMessage + created_at = datetime.fromtimestamp(event.timestamp, tz=timezone.utc) + return MemoryMessage(role=role, content=text, created_at=created_at) + + def _working_memory_response_to_session( + self, + response, + app_name: str, + user_id: str, + ) -> Session: + """Convert WorkingMemoryResponse to ADK Session.""" + events = [] + for msg in response.messages or []: + author = "user" if msg.role == "user" else response.session_id + content = types.Content(parts=[types.Part(text=msg.content)]) + # Preserve original message timestamp if available + timestamp = ( + msg.created_at.timestamp() + if hasattr(msg, "created_at") and msg.created_at + else time.time() + ) + event = Event( + author=author, + content=content, + timestamp=timestamp, + ) + events.append(event) + + return Session( + id=response.session_id, + app_name=app_name, + user_id=user_id, + events=events, + state=response.data or {}, + last_update_time=time.time(), + ) + + @override + async def create_session( + self, + *, + app_name: str, + user_id: str, + state: Optional[dict[str, Any]] = None, + session_id: Optional[str] = None, + ) -> Session: + """Create a new session in Working Memory. + + Uses get_or_create_working_memory to prevent accidental overwrites + of existing sessions. + + Args: + app_name: Application name (used as namespace if not configured). + user_id: User identifier. + state: Initial session state. + session_id: Optional session ID (generated if not provided). + + Returns: + The created Session. + """ + from agent_memory_client.models import MemoryStrategyConfig + + session_id = ( + session_id.strip() + if session_id and session_id.strip() + else str(uuid.uuid4()) + ) + namespace = self._get_namespace(app_name) + + strategy_config = MemoryStrategyConfig( + strategy=self._config.extraction_strategy, + config=self._config.extraction_strategy_config, + ) + + # Use get_or_create to prevent accidental overwrites + created, working_memory = await self._client.get_or_create_working_memory( + session_id=session_id, + namespace=namespace, + user_id=user_id, + long_term_memory_strategy=strategy_config, + ) + + if not created: + logger.warning( + "Session %s already exists in namespace %s, returning existing", + session_id, + namespace, + ) + # Return existing session data + return self._working_memory_response_to_session( + working_memory, app_name, user_id + ) + + # Update with initial state and TTL if provided + if state or self._config.session_ttl_seconds: + if state: + working_memory.data = state + if self._config.session_ttl_seconds: + working_memory.ttl_seconds = self._config.session_ttl_seconds + await self._client.put_working_memory( + session_id=session_id, + memory=working_memory, + user_id=user_id, + ) + + logger.info("Created session %s in namespace %s", session_id, namespace) + + return Session( + id=session_id, + app_name=app_name, + user_id=user_id, + state=state or {}, + events=[], + last_update_time=time.time(), + ) + + @override + async def get_session( + self, + *, + app_name: str, + user_id: str, + session_id: str, + config: Optional[GetSessionConfig] = None, + ) -> Optional[Session]: + """Retrieve a session from Working Memory. + + Uses get_or_create_working_memory and checks if session was newly created + to determine if it exists. Passes model_name and context_window_max to + enable automatic context summarization when token limit is exceeded. + + Args: + app_name: Application name. + user_id: User identifier. + session_id: Session ID to retrieve. + config: Optional configuration for filtering events. + + Returns: + The Session if found, None otherwise. + """ + from agent_memory_client.exceptions import MemoryNotFoundError + + try: + namespace = self._get_namespace(app_name) + # Use get_or_create to avoid deprecated get_working_memory + created, response = await self._client.get_or_create_working_memory( + session_id=session_id, + namespace=namespace, + user_id=user_id, + model_name=self._config.model_name, + context_window_max=self._config.context_window_max, + ) + + # If session was just created, it means it didn't exist before + # Delete it and return None to maintain get_session semantics + if created: + await self._client.delete_working_memory( + session_id=session_id, + namespace=namespace, + user_id=user_id, + ) + return None + + session = self._working_memory_response_to_session( + response, app_name, user_id + ) + + if config: + if config.num_recent_events: + session.events = session.events[-config.num_recent_events :] + if config.after_timestamp: + session.events = [ + e for e in session.events if e.timestamp > config.after_timestamp + ] + + return session + + except MemoryNotFoundError: + return None + except Exception as e: + logger.error("Failed to get session %s: %s", session_id, e) + return None + + @override + async def list_sessions( + self, *, app_name: str, user_id: str + ) -> ListSessionsResponse: + """List all sessions for a user from Working Memory. + + Args: + app_name: Application name. + user_id: User identifier. + + Returns: + ListSessionsResponse containing sessions (without events). + """ + try: + namespace = self._get_namespace(app_name) + + # SDK method: list_sessions returns SessionListResponse + # with sessions: list[str] (session IDs only) + response = await self._client.list_sessions( + namespace=namespace, + user_id=user_id, + ) + + sessions = [] + for session_id in response.sessions: + session = Session( + id=session_id, + app_name=app_name, + user_id=user_id, + state={}, + events=[], + last_update_time=time.time(), + ) + sessions.append(session) + + return ListSessionsResponse(sessions=sessions) + + except Exception as e: + logger.error("Failed to list sessions: %s", e) + return ListSessionsResponse(sessions=[]) + + @override + async def delete_session( + self, *, app_name: str, user_id: str, session_id: str + ) -> None: + """Delete a session from Working Memory. + + Args: + app_name: Application name. + user_id: User identifier. + session_id: Session ID to delete. + """ + try: + namespace = self._get_namespace(app_name) + await self._client.delete_working_memory( + session_id=session_id, + namespace=namespace, + user_id=user_id, + ) + logger.info("Deleted session %s", session_id) + except Exception as e: + logger.error("Failed to delete session %s: %s", session_id, e) + + @override + async def append_event(self, session: Session, event: Event) -> Event: + """Append an event to the session in Working Memory. + + Uses the incremental append API to add a single message without + resending the full conversation history. + + Args: + session: The session to append to. + event: The event to append. + + Returns: + The appended event. + """ + await super().append_event(session=session, event=event) + session.last_update_time = event.timestamp + + try: + message = self._event_to_message(event) + if message: + namespace = self._get_namespace(session.app_name) + await self._client.append_messages_to_working_memory( + session_id=session.id, + messages=[message], + namespace=namespace, + user_id=session.user_id, + ) + logger.debug("Appended message to session %s", session.id) + except Exception as e: + logger.error("Failed to append event to session %s: %s", session.id, e) + + return event + + async def close(self): + """Close the session service and cleanup resources.""" + if "_client" in self.__dict__: + await self._client.close() + del self.__dict__["_client"] diff --git a/tests/unittests/sessions/test_redis_working_memory_session_service.py b/tests/unittests/sessions/test_redis_working_memory_session_service.py new file mode 100644 index 0000000..67f4748 --- /dev/null +++ b/tests/unittests/sessions/test_redis_working_memory_session_service.py @@ -0,0 +1,269 @@ +# 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. + +"""Unit tests for RedisWorkingMemorySessionService.""" + +import time +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.base_session_service import GetSessionConfig +from google.adk.sessions.session import Session +from google.genai import types +import pytest +import pytest_asyncio + + +class TestRedisWorkingMemorySessionServiceConfig: + """Test cases for RedisWorkingMemorySessionServiceConfig.""" + + def test_default_config(self): + """Test default configuration values.""" + from google.adk_community.sessions import RedisWorkingMemorySessionServiceConfig + + config = RedisWorkingMemorySessionServiceConfig() + + assert config.api_base_url == "http://localhost:8000" + assert config.timeout == 30.0 + assert config.default_namespace is None + assert config.model_name is None + assert config.context_window_max is None + assert config.extraction_strategy == "discrete" + assert config.extraction_strategy_config == {} + + def test_custom_config(self): + """Test custom configuration values.""" + from google.adk_community.sessions import RedisWorkingMemorySessionServiceConfig + + config = RedisWorkingMemorySessionServiceConfig( + api_base_url="http://custom:9000", + timeout=60.0, + default_namespace="my_namespace", + model_name="gpt-4", + context_window_max=8000, + extraction_strategy="summary", + extraction_strategy_config={"key": "value"}, + ) + + assert config.api_base_url == "http://custom:9000" + assert config.timeout == 60.0 + assert config.default_namespace == "my_namespace" + assert config.model_name == "gpt-4" + assert config.context_window_max == 8000 + assert config.extraction_strategy == "summary" + assert config.extraction_strategy_config == {"key": "value"} + + +class TestRedisWorkingMemorySessionService: + """Test cases for RedisWorkingMemorySessionService.""" + + @pytest_asyncio.fixture + async def mock_client(self): + """Create a mock MemoryAPIClient.""" + mock = AsyncMock() + mock.close = AsyncMock() + return mock + + @pytest_asyncio.fixture + async def service(self, mock_client): + """Create a RedisWorkingMemorySessionService with mocked client.""" + from google.adk_community.sessions import RedisWorkingMemorySessionService + from google.adk_community.sessions import RedisWorkingMemorySessionServiceConfig + + config = RedisWorkingMemorySessionServiceConfig( + api_base_url="http://localhost:8000", + default_namespace="test_namespace", + ) + svc = RedisWorkingMemorySessionService(config=config) + # Inject mock client + svc.__dict__["_client"] = mock_client + return svc + + @pytest.mark.asyncio + async def test_create_session(self, service, mock_client): + """Test session creation.""" + mock_wm = MagicMock() + mock_wm.session_id = "generated_id" + mock_wm.messages = [] + mock_wm.data = {} + mock_client.get_or_create_working_memory = AsyncMock( + return_value=(True, mock_wm) + ) + mock_client.put_working_memory = AsyncMock() + + session = await service.create_session( + app_name="test_app", + user_id="test_user", + state={"key": "value"}, + ) + + assert session.app_name == "test_app" + assert session.user_id == "test_user" + assert session.state == {"key": "value"} + assert session.events == [] + assert session.id is not None + mock_client.get_or_create_working_memory.assert_called_once() + # put_working_memory called to update state + mock_client.put_working_memory.assert_called_once() + + @pytest.mark.asyncio + async def test_create_session_with_custom_id(self, service, mock_client): + """Test session creation with custom session ID.""" + mock_wm = MagicMock() + mock_wm.session_id = "custom_session_id" + mock_wm.messages = [] + mock_wm.data = {} + mock_client.get_or_create_working_memory = AsyncMock( + return_value=(True, mock_wm) + ) + + session = await service.create_session( + app_name="test_app", + user_id="test_user", + session_id="custom_session_id", + ) + + assert session.id == "custom_session_id" + mock_client.get_or_create_working_memory.assert_called_once() + + @pytest.mark.asyncio + async def test_get_session(self, service, mock_client): + """Test session retrieval.""" + mock_response = MagicMock() + mock_response.session_id = "test_session" + mock_response.messages = [] + mock_response.data = {"key": "value"} + # Return (created=False, response) to indicate existing session + mock_client.get_or_create_working_memory = AsyncMock( + return_value=(False, mock_response) + ) + + session = await service.get_session( + app_name="test_app", + user_id="test_user", + session_id="test_session", + ) + + assert session is not None + assert session.id == "test_session" + assert session.state == {"key": "value"} + mock_client.get_or_create_working_memory.assert_called_once() + + @pytest.mark.asyncio + async def test_get_session_not_found(self, service, mock_client): + """Test session retrieval when session doesn't exist.""" + # Return (created=True, response) to indicate new session was created + mock_response = MagicMock() + mock_response.session_id = "nonexistent" + mock_response.messages = [] + mock_response.data = {} + mock_client.get_or_create_working_memory = AsyncMock( + return_value=(True, mock_response) + ) + mock_client.delete_working_memory = AsyncMock() + + session = await service.get_session( + app_name="test_app", + user_id="test_user", + session_id="nonexistent", + ) + + assert session is None + + @pytest.mark.asyncio + async def test_list_sessions(self, service, mock_client): + """Test listing sessions.""" + mock_response = MagicMock() + mock_response.sessions = ["session1", "session2", "session3"] + mock_client.list_sessions = AsyncMock(return_value=mock_response) + + result = await service.list_sessions( + app_name="test_app", + user_id="test_user", + ) + + assert len(result.sessions) == 3 + assert result.sessions[0].id == "session1" + assert result.sessions[1].id == "session2" + assert result.sessions[2].id == "session3" + + @pytest.mark.asyncio + async def test_delete_session(self, service, mock_client): + """Test session deletion.""" + mock_client.delete_working_memory = AsyncMock() + + await service.delete_session( + app_name="test_app", + user_id="test_user", + session_id="test_session", + ) + + mock_client.delete_working_memory.assert_called_once() + + @pytest.mark.asyncio + async def test_append_event(self, service, mock_client): + """Test appending an event to a session.""" + mock_client.append_messages_to_working_memory = AsyncMock() + + session = Session( + id="test_session", + app_name="test_app", + user_id="test_user", + state={}, + events=[], + last_update_time=time.time(), + ) + + event = Event( + author="user", + content=types.Content(parts=[types.Part(text="Hello")]), + timestamp=time.time(), + ) + + result = await service.append_event(session=session, event=event) + + assert result == event + mock_client.append_messages_to_working_memory.assert_called_once() + + @pytest.mark.asyncio + async def test_create_session_existing_returns_existing( + self, service, mock_client + ): + """Test that creating a session with existing ID returns existing session.""" + mock_wm = MagicMock() + mock_wm.session_id = "existing_session" + mock_wm.messages = [] + mock_wm.data = {"existing": "data"} + # created=False means session already exists + mock_client.get_or_create_working_memory = AsyncMock( + return_value=(False, mock_wm) + ) + + session = await service.create_session( + app_name="test_app", + user_id="test_user", + session_id="existing_session", + ) + + assert session.id == "existing_session" + assert session.state == {"existing": "data"} + + @pytest.mark.asyncio + async def test_close(self, service, mock_client): + """Test closing the service.""" + await service.close() + + mock_client.close.assert_called_once() From 114046be5b6ebf7dca4da1ecbb56b841acd2da38 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Tue, 27 Jan 2026 14:00:23 -0500 Subject: [PATCH 16/20] refactor(samples): redis_agent_memory sample with working and longterm memory - Use RedisWorkingMemorySessionService for session management - Use RedisLongTermMemoryService for persistent memory - Add README with setup instructions --- .../samples/redis_agent_memory/README.md | 180 +++++++----------- .../samples/redis_agent_memory/main.py | 151 +++++++++------ .../redis_agent_memory_agent/__init__.py | 1 - .../redis_agent_memory_agent/agent.py | 64 +++++-- 4 files changed, 212 insertions(+), 184 deletions(-) diff --git a/contributing/samples/redis_agent_memory/README.md b/contributing/samples/redis_agent_memory/README.md index d675c8d..7670095 100644 --- a/contributing/samples/redis_agent_memory/README.md +++ b/contributing/samples/redis_agent_memory/README.md @@ -1,14 +1,30 @@ # Redis Agent Memory Sample -This sample demonstrates how to use the Redis Agent Memory Server as a long-term -memory backend for ADK agents using the community package. +This sample demonstrates the **complete two-tier memory architecture** using Redis Agent Memory Server with ADK: + +1. **RedisWorkingMemorySessionService** - Session management with auto-summarization +2. **RedisLongTermMemoryService** - Persistent long-term memory with semantic search + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ADK Agent │ +├─────────────────────────────────────────────────────────────────┤ +│ RedisWorkingMemorySessionService │ RedisLongTermMemoryService │ +│ (Tier 1: Working Memory) │ (Tier 2: Long-Term Memory) │ +├─────────────────────────────────────────────────────────────────┤ +│ Redis Agent Memory Server │ +├─────────────────────────────────────────────────────────────────┤ +│ Redis Stack │ +└─────────────────────────────────────────────────────────────────┘ +``` ## Prerequisites -- Python 3.10+ (Python 3.11+ recommended) -- Docker (for running Redis Stack) +- Python 3.10+ +- Docker (for Redis Stack) - Redis Agent Memory Server running -- ADK and ADK Community installed ## Setup @@ -18,140 +34,90 @@ memory backend for ADK agents using the community package. pip install "google-adk-community[redis-agent-memory]" ``` -### 2. Set Up Redis Stack +### 2. Start Redis Stack ```bash docker run -d --name redis-stack -p 6379:6379 redis/redis-stack:latest ``` -### 3. Set Up Redis Agent Memory Server - -Clone and run the Agent Memory Server: +### 3. Start Redis Agent Memory Server ```bash git clone https://github.com/redis-developer/agent-memory-server.git cd agent-memory-server cp .env.example .env -# Edit .env and set OPENAI_API_KEY (required for embeddings) +# Edit .env: set OPENAI_API_KEY for embeddings pip install -e . uvicorn agent_memory_server.main:app --port 8000 ``` -### 4. Configure Environment Variables +### 4. Configure Environment -Create a `.env` file in this directory: +Create `.env` in this directory: ```bash -# Required: Google API key for the agent GOOGLE_API_KEY=your-google-api-key - -# Optional: Redis Agent Memory Server URL (defaults to http://localhost:8000) -REDIS_AGENT_MEMORY_URL=http://localhost:8000 - -# Optional: Namespace for memory isolation (defaults to adk_sample) -REDIS_AGENT_MEMORY_NAMESPACE=adk_sample - -# Optional: Extraction strategy - discrete, summary, or preferences (defaults to discrete) -REDIS_AGENT_MEMORY_EXTRACTION_STRATEGY=discrete - -# Optional: Enable recency-boosted search (defaults to true) -REDIS_AGENT_MEMORY_RECENCY_BOOST=true - -# Optional: Semantic similarity weight (defaults to 0.8) -REDIS_AGENT_MEMORY_SEMANTIC_WEIGHT=0.8 - -# Optional: Recency weight (defaults to 0.2) -REDIS_AGENT_MEMORY_RECENCY_WEIGHT=0.2 +REDIS_MEMORY_SERVER_URL=http://localhost:8000 +REDIS_MEMORY_NAMESPACE=adk_agent_memory +REDIS_MEMORY_EXTRACTION_STRATEGY=discrete +REDIS_MEMORY_CONTEXT_WINDOW=8000 +REDIS_MEMORY_RECENCY_BOOST=true ``` ## Usage -### Option 1: Using `main.py` with FastAPI (Recommended) - ```bash python main.py ``` -This starts the ADK web interface at `http://localhost:8080`. - -### Option 2: Using `Runner` Directly - -```python -from google.adk.runners import Runner -from google.adk.agents import LlmAgent -from google.adk_community.memory import ( - RedisAgentMemoryService, - RedisAgentMemoryServiceConfig, -) - -# Configure the memory service -config = RedisAgentMemoryServiceConfig( - api_base_url="http://localhost:8000", - default_namespace="my_app", - extraction_strategy="discrete", - enable_recency_boost=True, -) - -# Create the memory service -memory_service = RedisAgentMemoryService(config=config) - -# Use with ADK Runner -agent = LlmAgent(name="assistant", model="gemini-2.5-flash") -runner = Runner( - app_name="my_app", - agent=agent, - memory_service=memory_service, -) -``` +Open http://localhost:8080 in your browser. -## Sample Structure +## Test Conversation +**Session 1** - Share information: ``` -redis_agent_memory/ -├── main.py # FastAPI server using get_fast_api_app -├── redis_agent_memory_agent/ -│ ├── __init__.py # Agent package initialization -│ └── agent.py # Agent definition with memory tools -└── README.md # This file +User: Hi, I'm Nitin. I'm an Machine Learning Engineer working on ML projects. +User: I love coffee, especially Berliner Früstuck Coffee from Berliner Kaffeerösterei. +User: My favorite programming language is Python. ``` -## Sample Queries - -Try these conversations to test long-term memory: - -**Session 1:** -- "Hello, my name is Alex and I'm a software engineer" -- "I love hiking and photography. My favorite mountain is Mt. Rainier" - -**Session 2 (new session):** -- "What do you remember about me?" -- "What are my hobbies?" - -The agent should recall information from Session 1. - -## Configuration Options - -| Option | Default | Description | -|--------|---------|-------------| -| `api_base_url` | `http://localhost:8000` | Agent Memory Server URL | -| `default_namespace` | `None` | Namespace for memory isolation | -| `extraction_strategy` | `discrete` | Memory extraction: `discrete`, `summary`, `preferences` | -| `recency_boost` | `True` | Enable recency-boosted semantic search | -| `semantic_weight` | `0.8` | Weight for semantic similarity (0-1) | -| `recency_weight` | `0.2` | Weight for recency (0-1) | +**Session 2** - Test memory recall: +``` +User: What do you remember about me? +User: What's my favorite coffee? +``` ## Features -Redis Agent Memory Server provides: - -- **Two-tier memory**: Working memory (session) + Long-term memory (persistent) -- **Intelligent extraction**: Automatically extracts facts, preferences, and episodic memories -- **Recency-boosted search**: Balances semantic relevance with temporal freshness -- **Vector search**: High-performance semantic search powered by Redis Stack -- **Namespace isolation**: Separate memory spaces for different apps/users - -## Learn More - -- [Redis Agent Memory Server](https://github.com/redis-developer/agent-memory-server) -- [ADK Memory Documentation](https://google.github.io/adk-docs) +| Feature | Working Memory (Tier 1) | Long-Term Memory (Tier 2) | +|---------|------------------------|---------------------------| +| Scope | Current session | All sessions | +| Auto-summarization | ✅ Yes | No | +| Semantic search | No | ✅ Yes | +| Fact extraction | Background | ✅ Persistent | +| TTL support | ✅ Yes | No | + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `REDIS_MEMORY_SERVER_URL` | `http://localhost:8000` | Memory server URL | +| `REDIS_MEMORY_NAMESPACE` | `adk_agent_memory` | Namespace for isolation | +| `REDIS_MEMORY_EXTRACTION_STRATEGY` | `discrete` | `discrete`, `summary`, `preferences` | +| `REDIS_MEMORY_CONTEXT_WINDOW` | `8000` | Max tokens before summarization | +| `REDIS_MEMORY_RECENCY_BOOST` | `true` | Boost recent memories in search | + +## Memory Server Configuration + +The Redis Agent Memory Server has important settings that affect memory extraction: + +| Setting | Default | Description | +|---------|---------|-------------| +| `EXTRACTION_DEBOUNCE_SECONDS` | `300` (5 min) | Time between extraction runs per session | +| `LONG_TERM_MEMORY` | `true` | Enable long-term memory storage | +| `ENABLE_DISCRETE_MEMORY_EXTRACTION` | `true` | Enable fact extraction from messages | + +**Note on Debouncing**: The memory server debounces extraction to avoid constantly re-extracting +from the same conversation. For testing, you can reduce `EXTRACTION_DEBOUNCE_SECONDS` to `5` in +the memory server's `.env` file. diff --git a/contributing/samples/redis_agent_memory/main.py b/contributing/samples/redis_agent_memory/main.py index 8fa8577..748c997 100644 --- a/contributing/samples/redis_agent_memory/main.py +++ b/contributing/samples/redis_agent_memory/main.py @@ -12,87 +12,122 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Example of using Redis Agent Memory Service with get_fast_api_app.""" +"""Full Redis Memory Sample: Working Memory Sessions + Long-Term Memory. + +This sample demonstrates using BOTH Redis Agent Memory Server services: +1. RedisWorkingMemorySessionService - For session management with auto-summarization +2. RedisLongTermMemoryService - For persistent long-term memory search + +This provides the complete two-tier memory architecture: +- Working Memory (Tier 1): Session state, messages, auto-summarization +- Long-Term Memory (Tier 2): Persistent facts, preferences, semantic search +""" import os +from urllib.parse import urlparse -import uvicorn from dotenv import load_dotenv from fastapi import FastAPI -from urllib.parse import urlparse - from google.adk.cli.fast_api import get_fast_api_app from google.adk.cli.service_registry import get_service_registry -from google.adk_community.memory import ( - RedisAgentMemoryService, - RedisAgentMemoryServiceConfig, -) +import uvicorn -# Load environment variables from .env file if it exists -load_dotenv() +from google.adk_community.memory import RedisLongTermMemoryService +from google.adk_community.memory import RedisLongTermMemoryServiceConfig +from google.adk_community.sessions import RedisWorkingMemorySessionService +from google.adk_community.sessions import RedisWorkingMemorySessionServiceConfig +load_dotenv() -def redis_agent_memory_factory(uri: str, **kwargs): - """Factory function for creating RedisAgentMemoryService from URI.""" - parsed = urlparse(uri) - location = parsed.netloc + parsed.path - base_url = ( - location - if location.startswith(("http://", "https://")) - else f"http://{location}" - ) - - # Get configuration from environment variables - config = RedisAgentMemoryServiceConfig( - api_base_url=base_url, - default_namespace=os.getenv("REDIS_AGENT_MEMORY_NAMESPACE", "adk_sample"), - extraction_strategy=os.getenv( - "REDIS_AGENT_MEMORY_EXTRACTION_STRATEGY", "discrete" - ), - recency_boost=os.getenv( - "REDIS_AGENT_MEMORY_RECENCY_BOOST", "true" - ).lower() - == "true", - semantic_weight=float( - os.getenv("REDIS_AGENT_MEMORY_SEMANTIC_WEIGHT", "0.8") - ), - recency_weight=float(os.getenv("REDIS_AGENT_MEMORY_RECENCY_WEIGHT", "0.2")), - ) - - return RedisAgentMemoryService(config=config) - - -# Register Redis Agent Memory service factory for redis-agent-memory:// URI scheme -get_service_registry().register_memory_service( - "redis-agent-memory", redis_agent_memory_factory -) -# Build Redis Agent Memory URI from environment variables -base_url = ( - os.getenv("REDIS_AGENT_MEMORY_URL", "http://localhost:8000") +def parse_base_url(uri: str) -> str: + """Parse URI to extract base URL.""" + parsed = urlparse(uri) + location = parsed.netloc + parsed.path + return ( + location + if location.startswith(("http://", "https://")) + else f"http://{location}" + ) + + +def redis_session_factory(uri: str, **kwargs): + """Factory function for creating RedisWorkingMemorySessionService from URI.""" + base_url = parse_base_url(uri) + config = RedisWorkingMemorySessionServiceConfig( + api_base_url=base_url, + default_namespace=os.getenv("REDIS_MEMORY_NAMESPACE", "adk_agent_memory"), + model_name=os.getenv("REDIS_MEMORY_MODEL_NAME", "gpt-4o"), + context_window_max=int(os.getenv("REDIS_MEMORY_CONTEXT_WINDOW", "8000")), + extraction_strategy=os.getenv( + "REDIS_MEMORY_EXTRACTION_STRATEGY", "discrete" + ), + ) + return RedisWorkingMemorySessionService(config=config) + + +def redis_memory_factory(uri: str, **kwargs): + """Factory function for creating RedisLongTermMemoryService from URI.""" + base_url = parse_base_url(uri) + config = RedisLongTermMemoryServiceConfig( + api_base_url=base_url, + default_namespace=os.getenv("REDIS_MEMORY_NAMESPACE", "adk_agent_memory"), + extraction_strategy=os.getenv( + "REDIS_MEMORY_EXTRACTION_STRATEGY", "discrete" + ), + recency_boost=os.getenv("REDIS_MEMORY_RECENCY_BOOST", "true").lower() + == "true", + semantic_weight=float(os.getenv("REDIS_MEMORY_SEMANTIC_WEIGHT", "0.7")), + recency_weight=float(os.getenv("REDIS_MEMORY_RECENCY_WEIGHT", "0.3")), + ) + return RedisLongTermMemoryService(config=config) + + +# Register both service factories +registry = get_service_registry() +registry.register_session_service("redis-working-memory", redis_session_factory) +registry.register_memory_service("redis-long-term-memory", redis_memory_factory) + +# Build URIs from environment +server_url = ( + os.getenv("REDIS_MEMORY_SERVER_URL", "http://localhost:8000") .replace("http://", "") .replace("https://", "") ) -MEMORY_SERVICE_URI = f"redis-agent-memory://{base_url}" +SESSION_SERVICE_URI = f"redis-working-memory://{server_url}" +MEMORY_SERVICE_URI = f"redis-long-term-memory://{server_url}" -# Create the FastAPI app using get_fast_api_app +# Create the FastAPI app with both services app: FastAPI = get_fast_api_app( agents_dir=".", + session_service_uri=SESSION_SERVICE_URI, memory_service_uri=MEMORY_SERVICE_URI, web=True, ) if __name__ == "__main__": - # Use the PORT environment variable provided by Cloud Run, defaulting to 8000 - port = int(os.environ.get("PORT", 8080)) - print(f""" + port = int(os.environ.get("PORT", 8080)) + namespace = os.getenv("REDIS_MEMORY_NAMESPACE", "adk_agent_memory") + server = os.getenv("REDIS_MEMORY_SERVER_URL", "http://localhost:8000") + extraction = os.getenv("REDIS_MEMORY_EXTRACTION_STRATEGY", "discrete") + context_window = os.getenv("REDIS_MEMORY_CONTEXT_WINDOW", "8000") + + print(f""" Starting Redis Agent Memory Sample -=================================== +======================== ADK Server: http://localhost:{port} -Memory Server: {os.getenv('REDIS_AGENT_MEMORY_URL', 'http://localhost:8000')} -Namespace: {os.getenv('REDIS_AGENT_MEMORY_NAMESPACE', 'adk_sample')} -Extraction Strategy: {os.getenv('REDIS_AGENT_MEMORY_EXTRACTION_STRATEGY', 'discrete')} +Memory Server: {server} +Namespace: {namespace} +Extraction Strategy: {extraction} +Context Window: {context_window} tokens + +Services: + - Session: RedisWorkingMemorySessionService (auto-summarization) + - Memory: RedisLongTermMemoryService (semantic search) + +Two-Tier Architecture: + Tier 1 (Working Memory): Session messages, state, auto-summarization + Tier 2 (Long-Term Memory): Extracted facts, preferences, semantic search """) - uvicorn.run(app, host="0.0.0.0", port=port) - + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/contributing/samples/redis_agent_memory/redis_agent_memory_agent/__init__.py b/contributing/samples/redis_agent_memory/redis_agent_memory_agent/__init__.py index 8ce90a2..c48963c 100644 --- a/contributing/samples/redis_agent_memory/redis_agent_memory_agent/__init__.py +++ b/contributing/samples/redis_agent_memory/redis_agent_memory_agent/__init__.py @@ -13,4 +13,3 @@ # limitations under the License. from . import agent - diff --git a/contributing/samples/redis_agent_memory/redis_agent_memory_agent/agent.py b/contributing/samples/redis_agent_memory/redis_agent_memory_agent/agent.py index 756bac1..791a5f5 100644 --- a/contributing/samples/redis_agent_memory/redis_agent_memory_agent/agent.py +++ b/contributing/samples/redis_agent_memory/redis_agent_memory_agent/agent.py @@ -12,36 +12,64 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Sample agent demonstrating Redis Agent Memory Service integration.""" +"""Agent with full Redis memory: Working Memory + Long-Term Memory. + +This agent demonstrates the complete two-tier memory architecture: +- Working Memory: Automatic session summarization when context grows large +- Long-Term Memory: Persistent facts extracted and searchable across sessions +""" from datetime import datetime from google.adk import Agent from google.adk.agents.callback_context import CallbackContext -from google.adk.tools import load_memory, preload_memory +from google.adk.tools import load_memory +from google.adk.tools import preload_memory + + +def before_agent(callback_context: CallbackContext): + """Update state before agent runs.""" + callback_context.state["_time"] = datetime.now().isoformat() -def update_current_time(callback_context: CallbackContext): - """Update the current time in the agent's state.""" - callback_context.state["_time"] = datetime.now().isoformat() +async def after_agent(callback_context: CallbackContext): + """Store session to long-term memory after agent completes.""" + # This triggers memory extraction to long-term memory + await callback_context.add_session_to_memory() root_agent = Agent( model="gemini-2.5-flash", name="redis_agent_memory_agent", - description="Agent with long-term memory powered by Redis Agent Memory Server.", - before_agent_callback=update_current_time, - instruction=( - "You are a helpful assistant with long-term memory capabilities.\n" - "You can remember information from past conversations with the user.\n\n" - "When the user asks about something you discussed before, use the load_memory " - "tool to search for relevant information from past conversations.\n" - "If the first search doesn't find relevant information, try different search " - "terms or keywords related to the question.\n\n" - "When the user shares personal information (name, preferences, interests), " - "acknowledge it - this information will be automatically saved to memory.\n\n" - "Current time: {_time}" + description=( + "Agent with full two-tier Redis memory: working memory for sessions," + " long-term memory for persistence." ), + before_agent_callback=before_agent, + after_agent_callback=after_agent, + instruction="""You are a helpful assistant with a powerful two-tier memory system. + +## Your Memory Capabilities + +1. **Working Memory** (automatic): Your current conversation is automatically managed. + When the conversation gets long, older messages are summarized to keep context efficient. + +2. **Long-Term Memory** (persistent): Important facts and preferences are automatically + extracted and stored. You can search this memory across sessions. + +## How to Use Memory + +- Use `load_memory` to search for information from past conversations +- When users share personal info (name, preferences, facts), acknowledge it - + it will be automatically saved to long-term memory +- If a search doesn't find results, try different keywords + +## Conversation Guidelines + +- Be conversational and remember details the user shares +- Reference past interactions when relevant +- Ask clarifying questions to learn more about the user + +Current time: {_time}""", tools=[preload_memory, load_memory], ) - From 3a77986dbc2714b77010a2030bb1949e502e0bd2 Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Tue, 27 Jan 2026 14:33:05 -0500 Subject: [PATCH 17/20] readme(samples): Add instructions for setup to run ADK with Redis, Agent memory server --- .../samples/redis_agent_memory/README.md | 70 +++++++++++++------ 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/contributing/samples/redis_agent_memory/README.md b/contributing/samples/redis_agent_memory/README.md index 7670095..bc98af8 100644 --- a/contributing/samples/redis_agent_memory/README.md +++ b/contributing/samples/redis_agent_memory/README.md @@ -8,23 +8,44 @@ This sample demonstrates the **complete two-tier memory architecture** using Red ## Architecture ``` -┌─────────────────────────────────────────────────────────────────┐ -│ ADK Agent │ -├─────────────────────────────────────────────────────────────────┤ -│ RedisWorkingMemorySessionService │ RedisLongTermMemoryService │ -│ (Tier 1: Working Memory) │ (Tier 2: Long-Term Memory) │ -├─────────────────────────────────────────────────────────────────┤ -│ Redis Agent Memory Server │ -├─────────────────────────────────────────────────────────────────┤ -│ Redis Stack │ -└─────────────────────────────────────────────────────────────────┘ +┌────────────────────────────────────────────────────────────────┐ +│ ADK Agent │ +├──────────────────────────────┬─────────────────────────────────┤ +│ TIER 1: Working Memory │ TIER 2: Long-Term Memory │ +├──────────────────────────────┼─────────────────────────────────┤ +│ • Current session messages │ • Extracted facts & preferences │ +│ • Auto-summarization │ • Semantic vector search │ +│ • Context window management │ • Cross-session persistence │ +│ • TTL support │ • Recency-boosted retrieval │ +├──────────────────────────────┴─────────────────────────────────┤ +│ Agent Memory Server API │ +├────────────────────────────────────────────────────────────────┤ +│ Redis Stack │ +└────────────────────────────────────────────────────────────────┘ +``` + +## Example Flow + +``` +User Message + │ + ▼ +┌─────────────┐ store ┌──────────────────────┐ +│ ADK Agent │─────────────▶│ Working Memory │ +└─────────────┘ │ (current session) │ + │ └──────────┬───────────┘ + │ │ extract + │ search ▼ + │ ┌──────────────────────┐ + └──────────────────────▶│ Long-Term Memory │ + │ (all sessions) │ + └──────────────────────┘ ``` ## Prerequisites - Python 3.10+ -- Docker (for Redis Stack) -- Redis Agent Memory Server running +- Docker (for Redis Stack and Agent Memory Server) ## Setup @@ -34,24 +55,33 @@ This sample demonstrates the **complete two-tier memory architecture** using Red pip install "google-adk-community[redis-agent-memory]" ``` +> **Important**: The server is NOT installed via pip - it's a separate service that must be running. The pip package only installs the client to communicate with it. + ### 2. Start Redis Stack ```bash docker run -d --name redis-stack -p 6379:6379 redis/redis-stack:latest ``` -### 3. Start Redis Agent Memory Server +### 3. Start Agent Memory Server + +```bash +docker run -d --name agent-memory-server -p 8000:8000 \ + -e REDIS_URL=redis://host.docker.internal:6379 \ + -e OPENAI_API_KEY=your-openai-key \ + redislabs/agent-memory-server:latest \ + agent-memory api --host 0.0.0.0 --port 8000 --task-backend=asyncio +``` + +> **Note**: The memory server requires an OpenAI API key for embeddings by default. See the [Agent Memory Server docs](https://redis.github.io/agent-memory-server/) for alternative embedding providers. + +### 4. Verify Setup ```bash -git clone https://github.com/redis-developer/agent-memory-server.git -cd agent-memory-server -cp .env.example .env -# Edit .env: set OPENAI_API_KEY for embeddings -pip install -e . -uvicorn agent_memory_server.main:app --port 8000 +curl http://localhost:8000/health ``` -### 4. Configure Environment +### 5. Configure Environment Create `.env` in this directory: From df82aba7d3ca5c0da4acb35d377e38c4e6b865b2 Mon Sep 17 00:00:00 2001 From: nitin <47899917+nkanu17@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:17:47 -0500 Subject: [PATCH 18/20] Update contributing/samples/redis_agent_memory/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- contributing/samples/redis_agent_memory/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributing/samples/redis_agent_memory/README.md b/contributing/samples/redis_agent_memory/README.md index bc98af8..287b5ab 100644 --- a/contributing/samples/redis_agent_memory/README.md +++ b/contributing/samples/redis_agent_memory/README.md @@ -106,7 +106,7 @@ Open http://localhost:8080 in your browser. **Session 1** - Share information: ``` -User: Hi, I'm Nitin. I'm an Machine Learning Engineer working on ML projects. +User: Hi, I'm Nitin. I'm a Machine Learning Engineer working on ML projects. User: I love coffee, especially Berliner Früstuck Coffee from Berliner Kaffeerösterei. User: My favorite programming language is Python. ``` From be8215963445d7982e8a720aeaf0a4607f4b3e08 Mon Sep 17 00:00:00 2001 From: nitin <47899917+nkanu17@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:17:58 -0500 Subject: [PATCH 19/20] Update contributing/samples/redis_agent_memory/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- contributing/samples/redis_agent_memory/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributing/samples/redis_agent_memory/README.md b/contributing/samples/redis_agent_memory/README.md index 287b5ab..7ade099 100644 --- a/contributing/samples/redis_agent_memory/README.md +++ b/contributing/samples/redis_agent_memory/README.md @@ -107,7 +107,7 @@ Open http://localhost:8080 in your browser. **Session 1** - Share information: ``` User: Hi, I'm Nitin. I'm a Machine Learning Engineer working on ML projects. -User: I love coffee, especially Berliner Früstuck Coffee from Berliner Kaffeerösterei. +User: I love coffee, especially Berliner Frühstück Coffee from Berliner Kaffeerösterei. User: My favorite programming language is Python. ``` From c2c0b9b9fabb3051c380fb52fc4abd28e6bb541c Mon Sep 17 00:00:00 2001 From: Nitin Kanukolanu Date: Tue, 27 Jan 2026 18:44:15 -0500 Subject: [PATCH 20/20] fix: address code review feedback - consistent cached_property cleanup and test mock injection --- .../sessions/redis_working_memory_session_service.py | 7 +++++-- .../memory/test_redis_long_term_memory_service.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/google/adk_community/sessions/redis_working_memory_session_service.py b/src/google/adk_community/sessions/redis_working_memory_session_service.py index fd30c31..7ebdbec 100644 --- a/src/google/adk_community/sessions/redis_working_memory_session_service.py +++ b/src/google/adk_community/sessions/redis_working_memory_session_service.py @@ -432,6 +432,9 @@ async def append_event(self, session: Session, event: Event) -> Event: async def close(self): """Close the session service and cleanup resources.""" - if "_client" in self.__dict__: + if ( + "_client" in self.__dict__ + ): # Check for initialized client without triggering cached_property await self._client.close() - del self.__dict__["_client"] + # Clear the cached property + del self._client diff --git a/tests/unittests/memory/test_redis_long_term_memory_service.py b/tests/unittests/memory/test_redis_long_term_memory_service.py index 72c7482..a2ca3f4 100644 --- a/tests/unittests/memory/test_redis_long_term_memory_service.py +++ b/tests/unittests/memory/test_redis_long_term_memory_service.py @@ -294,8 +294,8 @@ async def test_search_memory_without_recency_boost(self, mock_memory_client): """Test that recency config is None when disabled.""" config = RedisLongTermMemoryServiceConfig(recency_boost=False) service = RedisLongTermMemoryService(config=config) - service._client = mock_memory_client - service._client_initialized = True + # Inject the mock client by setting it in __dict__ to bypass cached_property + service.__dict__["_client"] = mock_memory_client mock_results = MagicMock() mock_results.memories = []