Skip to content

Commit 878744d

Browse files
feat(memory): add RedisMemoryService
1 parent aa7395e commit 878744d

3 files changed

Lines changed: 722 additions & 1 deletion

File tree

src/google/adk_community/memory/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616

1717
from .open_memory_service import OpenMemoryService
1818
from .open_memory_service import OpenMemoryServiceConfig
19+
from .redis_memory_service import RedisMemoryService
1920

2021
__all__ = [
2122
"OpenMemoryService",
2223
"OpenMemoryServiceConfig",
24+
"RedisMemoryService",
2325
]
24-
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from collections.abc import Mapping
18+
from collections.abc import Sequence
19+
import hashlib
20+
import json
21+
import re
22+
from typing import Any
23+
from typing import TYPE_CHECKING
24+
from urllib.parse import quote
25+
26+
from google.adk.memory import _utils
27+
from google.adk.memory.base_memory_service import BaseMemoryService
28+
from google.adk.memory.base_memory_service import SearchMemoryResponse
29+
from google.adk.memory.memory_entry import MemoryEntry
30+
from google.genai import types
31+
import redis.asyncio as redis
32+
from typing_extensions import override
33+
34+
from .utils import extract_text_from_event
35+
36+
if TYPE_CHECKING:
37+
from google.adk.events.event import Event
38+
from google.adk.sessions.session import Session
39+
40+
_UNKNOWN_SESSION_ID = '__unknown_session_id__'
41+
42+
43+
def _key_part(value: str) -> str:
44+
return quote(value, safe='')
45+
46+
47+
def _decode(value: Any) -> str:
48+
if isinstance(value, bytes):
49+
return value.decode('utf-8')
50+
return str(value)
51+
52+
53+
def _extract_words_lower(text: str) -> set[str]:
54+
return set(word.lower() for word in re.findall(r'[A-Za-z]+', text))
55+
56+
57+
def _content_from_text(text: str) -> types.Content:
58+
return types.Content(parts=[types.Part(text=text)])
59+
60+
61+
def _event_id(event: Event, content_text: str) -> str:
62+
if event.id:
63+
return event.id
64+
digest = hashlib.sha256(
65+
f'{event.author}:{event.timestamp}:{content_text}'.encode('utf-8')
66+
).hexdigest()
67+
return f'generated-{digest}'
68+
69+
70+
def _event_to_payload(
71+
event: Event,
72+
*,
73+
session_id: str,
74+
content_text: str,
75+
custom_metadata: Mapping[str, object] | None = None,
76+
) -> dict[str, Any]:
77+
metadata = dict(custom_metadata or {})
78+
metadata.setdefault('session_id', session_id)
79+
return {
80+
'id': _event_id(event, content_text),
81+
'author': event.author,
82+
'timestamp': (
83+
_utils.format_timestamp(event.timestamp)
84+
if event.timestamp is not None
85+
else None
86+
),
87+
'content': (
88+
_content_from_text(content_text).model_dump(
89+
mode='json', by_alias=True, exclude_none=True
90+
)
91+
),
92+
'text': content_text,
93+
'custom_metadata': metadata,
94+
}
95+
96+
97+
class RedisMemoryService(BaseMemoryService):
98+
"""Redis-backed memory service for ADK community integrations.
99+
100+
This service mirrors InMemoryMemoryService's keyword search behavior while
101+
keeping memory entries in Redis so they survive process restarts.
102+
"""
103+
104+
def __init__(
105+
self,
106+
host: str = 'localhost',
107+
port: int = 6379,
108+
db: int = 0,
109+
uri: str | None = None,
110+
cluster_uri: str | None = None,
111+
*,
112+
key_prefix: str = 'adk:memory:',
113+
client: Any | None = None,
114+
**kwargs: Any,
115+
):
116+
"""Initializes the Redis memory service.
117+
118+
Args:
119+
host: Redis host used when uri, cluster_uri, and client are not supplied.
120+
port: Redis port used when uri, cluster_uri, and client are not supplied.
121+
db: Redis database used when uri, cluster_uri, and client are not supplied.
122+
uri: Redis URL used to create a standalone Redis client.
123+
cluster_uri: Redis Cluster URL used to create a Redis Cluster client.
124+
key_prefix: Prefix for all Redis keys written by this service.
125+
client: Optional async Redis-compatible client, mainly for tests.
126+
**kwargs: Extra keyword arguments forwarded to the Redis client factory.
127+
"""
128+
if client is not None:
129+
self.cache = client
130+
elif cluster_uri:
131+
self.cache = redis.RedisCluster.from_url(cluster_uri, **kwargs)
132+
elif uri:
133+
self.cache = redis.Redis.from_url(uri, **kwargs)
134+
else:
135+
self.cache = redis.Redis(host=host, port=port, db=db, **kwargs)
136+
137+
self._key_prefix = key_prefix
138+
139+
def _scope_prefix(self, app_name: str, user_id: str) -> str:
140+
return f'{self._key_prefix}{_key_part(app_name)}:{_key_part(user_id)}'
141+
142+
def _sessions_key(self, app_name: str, user_id: str) -> str:
143+
return f'{self._scope_prefix(app_name, user_id)}:sessions'
144+
145+
def _session_keys(
146+
self, app_name: str, user_id: str, session_id: str
147+
) -> tuple[str, str]:
148+
session_prefix = (
149+
f'{self._scope_prefix(app_name, user_id)}:{_key_part(session_id)}'
150+
)
151+
return f'{session_prefix}:order', f'{session_prefix}:entries'
152+
153+
async def _append_events(
154+
self,
155+
*,
156+
app_name: str,
157+
user_id: str,
158+
session_id: str,
159+
events: Sequence[Event],
160+
custom_metadata: Mapping[str, object] | None = None,
161+
) -> None:
162+
await self.cache.sadd(self._sessions_key(app_name, user_id), session_id)
163+
order_key, entries_key = self._session_keys(app_name, user_id, session_id)
164+
165+
for event in events:
166+
content_text = extract_text_from_event(event)
167+
if not content_text:
168+
continue
169+
170+
event_id = _event_id(event, content_text)
171+
payload = _event_to_payload(
172+
event,
173+
session_id=session_id,
174+
content_text=content_text,
175+
custom_metadata=custom_metadata,
176+
)
177+
was_added = await self.cache.hsetnx(
178+
entries_key, event_id, json.dumps(payload)
179+
)
180+
if was_added:
181+
await self.cache.rpush(order_key, event_id)
182+
183+
@override
184+
async def add_session_to_memory(self, session: Session) -> None:
185+
session_id = session.id or _UNKNOWN_SESSION_ID
186+
order_key, entries_key = self._session_keys(
187+
session.app_name, session.user_id, session_id
188+
)
189+
await self.cache.delete(order_key)
190+
await self.cache.delete(entries_key)
191+
await self._append_events(
192+
app_name=session.app_name,
193+
user_id=session.user_id,
194+
session_id=session_id,
195+
events=session.events,
196+
)
197+
198+
@override
199+
async def add_events_to_memory(
200+
self,
201+
*,
202+
app_name: str,
203+
user_id: str,
204+
events: Sequence[Event],
205+
session_id: str | None = None,
206+
custom_metadata: Mapping[str, object] | None = None,
207+
) -> None:
208+
await self._append_events(
209+
app_name=app_name,
210+
user_id=user_id,
211+
session_id=session_id or _UNKNOWN_SESSION_ID,
212+
events=events,
213+
custom_metadata=custom_metadata,
214+
)
215+
216+
@override
217+
async def search_memory(
218+
self, *, app_name: str, user_id: str, query: str
219+
) -> SearchMemoryResponse:
220+
sessions_key = self._sessions_key(app_name, user_id)
221+
session_ids = sorted(
222+
[_decode(value) for value in await self.cache.smembers(sessions_key)]
223+
)
224+
words_in_query = _extract_words_lower(query)
225+
response = SearchMemoryResponse()
226+
227+
for session_id in session_ids:
228+
order_key, entries_key = self._session_keys(app_name, user_id, session_id)
229+
event_ids = [
230+
_decode(value) for value in await self.cache.lrange(order_key, 0, -1)
231+
]
232+
for event_id in event_ids:
233+
raw_payload = await self.cache.hget(entries_key, event_id)
234+
if raw_payload is None:
235+
continue
236+
payload = json.loads(_decode(raw_payload))
237+
words_in_memory = _extract_words_lower(payload.get('text', ''))
238+
if not words_in_memory:
239+
continue
240+
if any(query_word in words_in_memory for query_word in words_in_query):
241+
response.memories.append(
242+
MemoryEntry(
243+
id=payload['id'],
244+
content=types.Content.model_validate(payload['content']),
245+
author=payload.get('author'),
246+
timestamp=payload.get('timestamp'),
247+
custom_metadata=payload.get('custom_metadata') or {},
248+
)
249+
)
250+
251+
return response
252+
253+
async def close(self) -> None:
254+
"""Closes the Redis client if it exposes a close method."""
255+
close = getattr(self.cache, 'aclose', None)
256+
if close is None:
257+
close = getattr(self.cache, 'close', None)
258+
if close is not None:
259+
result = close()
260+
if hasattr(result, '__await__'):
261+
await result
262+
263+
async def __aenter__(self) -> RedisMemoryService:
264+
return self
265+
266+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
267+
await self.close()

0 commit comments

Comments
 (0)