diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f335337..7f120d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,17 +27,17 @@ jobs: - name: Install dependencies run: uv sync --all-extras --dev - - name: Run linting - run: | - uv run ruff check src tests examples - uv run isort --check-only src tests examples - uv run pyink --check src tests examples + - name: Format check + run: make format-check - - name: Run type checking - run: uv run mypy src + - name: Lint + run: make lint - - name: Run tests - run: uv run pytest tests -v --cov=adk_redis --cov-report=xml + - name: Type check + run: make type-check + + - name: Tests with coverage + run: uv run pytest tests --cov=adk_redis --cov-report=xml - name: Upload coverage uses: codecov/codecov-action@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f8a295..d18c0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.0.7] - 2026-06-02 + +### Added + +- Selectable memory backend support across all ADK memory surfaces. + `RedisLongTermMemoryService`, `RedisWorkingMemorySessionService`, + and the six memory tools (`SearchMemoryTool`, `CreateMemoryTool`, + `GetMemoryTool`, `UpdateMemoryTool`, `DeleteMemoryTool`, + `MemoryPromptTool`) now accept a `backend` field: + - `backend="redis-agent-memory"` (default) routes through the managed + `redis-agent-memory` SDK. + - `backend="opensource-agent-memory"` keeps the self-hosted Agent + Memory Server path through `agent-memory-client`. +- `RedisLongTermMemoryService` now implements the newer ADK write hooks + `add_events_to_memory()` and `add_memory()`, verified against upstream + `google/adk-python@ae95a97`. Older ADK versions that only call + `add_session_to_memory()` and `search_memory()` keep working. +- New spec at `docs/specs/redis-agent-memory-default.md` describing the + dual-backend design, config surface, and test scope. +- Examples (`simple_redis_memory`, `travel_agent_memory_hybrid`, + `travel_agent_memory_tools`) surface a `REDIS_MEMORY_BACKEND` env + var in `.env.example`, README, and agent wiring so users can switch + backends without code changes. +- `tests/integration/test_memory_backends_end_to_end.py`: live + round-trip coverage for both backends. Skips when + `REDIS_AGENT_MEMORY_API_BASE_URL` / `REDIS_AGENT_MEMORY_API_KEY` / + `REDIS_AGENT_MEMORY_STORE_ID` or `AGENT_MEMORY_SERVER_URL` are not + set. + +### Changed + +- `memory` extra now installs both `agent-memory-client>=0.14.0` and + `redis-agent-memory>=0.0.4`. +- CI workflow (`.github/workflows/ci.yml`) now invokes the + `format-check`, `lint`, and `type-check` Make targets so the + Makefile is the single source of truth for local and CI checks. + +### Docs + +- Updated `docs/concepts/memory.md`, `docs/concepts/sessions.md`, and + the `docs/user_guide/` how-to guides for `memory_service`, + `session_service`, `memory_server_setup`, and `redis_setup` to + reflect the backend choice. + ## [0.0.6] - 2026-05-20 ### Breaking diff --git a/README.md b/README.md index ecabf14..046d064 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,13 @@ --- -`adk-redis` is the Redis layer for [Google ADK](https://github.com/google/adk-python) agents. It implements ADK's `BaseMemoryService`, `BaseSessionService`, and `BaseTool` interfaces against Redis, [RedisVL](https://docs.redisvl.com), and the [Redis Agent Memory Server](https://github.com/redis/agent-memory-server). It also ships MCP toolset helpers and semantic-cache providers. +`adk-redis` is the Redis layer for [Google ADK](https://github.com/google/adk-python) agents. It implements ADK's `BaseMemoryService`, `BaseSessionService`, and `BaseTool` interfaces against Redis, [RedisVL](https://docs.redisvl.com), Redis Agent Memory, and the [Redis Agent Memory Server](https://github.com/redis/agent-memory-server). It also ships MCP toolset helpers and semantic-cache providers. | Surface | What you get | Backed by | |---|---|---| -| **Sessions** | `BaseSessionService` with auto-summarization and context-window management | Agent Memory Server | -| **Long-term memory** | `BaseMemoryService` with semantic search and recency boosting | Agent Memory Server | -| **Memory tools** | LLM-controlled memory CRUD operations | Agent Memory Server | +| **Sessions** | `BaseSessionService` with durable conversation state | Redis Agent Memory or Agent Memory Server | +| **Long-term memory** | `BaseMemoryService` with semantic search | Redis Agent Memory or Agent Memory Server | +| **Memory tools** | LLM-controlled memory CRUD operations | Redis Agent Memory or Agent Memory Server | | **Search tools** | Vector, hybrid, range, text, and SQL search as `BaseTool` subclasses | RedisVL | | **MCP search** | `search-records` / `upsert-records` via ADK's native `McpToolset` | `rvl mcp` server | | **Semantic cache** | Skip repeat LLM calls by semantic similarity | RedisVL or [Redis LangCache](https://redis.io/langcache) | @@ -52,11 +52,18 @@ pip install 'adk-redis[langcache]' # managed semantic cache provider pip install 'adk-redis[all]' # everything above ``` +Memory backends are selected with `backend`: + +| Backend | Use when | Client | +|---|---|---| +| `redis-agent-memory` | You want Redis Agent Memory managed by Redis or the Redis Agent Memory data plane | `redis-agent-memory` | +| `opensource-agent-memory` | You want the open source self-hosted Agent Memory Server | `agent-memory-client` | + --- ## Quick start -**Prerequisites:** Python 3.10+, Redis 8.4+, and (for memory features) a running [Agent Memory Server](https://github.com/redis/agent-memory-server). See the [Quickstart](https://redis-developer.github.io/adk-redis/user_guide/01_integration/) for full setup steps. +**Prerequisites:** Python 3.10+, Redis 8.4+, and one memory backend. See the [Quickstart](https://redis-developer.github.io/adk-redis/user_guide/01_integration/) for full setup steps. ```python from google.adk import Agent @@ -71,13 +78,19 @@ from adk_redis import ( session_service = RedisWorkingMemorySessionService( config=RedisWorkingMemorySessionServiceConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8088", + api_key="...", + store_id="...", default_namespace="my_app", ), ) memory_service = RedisLongTermMemoryService( config=RedisLongTermMemoryServiceConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8088", + api_key="...", + store_id="...", default_namespace="my_app", ), ) @@ -96,6 +109,10 @@ runner = Runner( ) ``` +For the open source self-hosted Agent Memory Server, set +`backend="opensource-agent-memory"` and omit `api_key` and `store_id` unless your +server requires them. + → *More examples: [search tools](https://redis-developer.github.io/adk-redis/user_guide/how_to_guides/search_tools/), [MCP search](https://redis-developer.github.io/adk-redis/concepts/search/), [semantic caching](https://redis-developer.github.io/adk-redis/user_guide/how_to_guides/semantic_cache/)* --- diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 611a3aa..6a4033a 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -1,6 +1,9 @@ # Sessions + Memory with MCP + Tools -Use ADK's native `McpToolset` to connect your agent to the Agent Memory Server's MCP endpoint, or use the REST-based memory tools directly. The LLM decides when to search, create, update, or delete memories. +Use ADK's native `McpToolset` to connect your agent to the Agent Memory +Server's MCP endpoint, or use the Python memory tools directly. The Python +tools can target Redis Agent Memory or the self-hosted Agent Memory Server. +The LLM decides when to search, create, update, or delete memories. ## Quick Reference @@ -80,9 +83,11 @@ agent = Agent( | `memory_prompt` | Enrich a prompt with relevant memories | | `set_working_memory` | Write to the current session's working memory | -## Option 2: REST-Based Tools +## Option 2: SDK-Based Tools -Use the Python memory tool classes for direct REST access. No MCP server needed; the tools call the Agent Memory Server REST API. +Use the Python memory tool classes for direct SDK access. Tools can call Redis +Agent Memory through `redis-agent-memory`, or the self-hosted Agent Memory +Server through `agent-memory-client`. ```python from google.adk import Agent @@ -97,9 +102,11 @@ from adk_redis import ( ) config = MemoryToolConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8000", + api_key="...", + store_id="...", default_namespace="my_app", - recency_boost=True, ) agent = Agent( @@ -115,36 +122,38 @@ agent = Agent( ) ``` -### Available REST Tools +### Available SDK Tools | Tool | Description | |------|-------------| -| `SearchMemoryTool` | Semantic search with optional recency boost | +| `SearchMemoryTool` | Semantic search over long-term memories | | `CreateMemoryTool` | Store a new memory (semantic, episodic, or message) | | `GetMemoryTool` | Retrieve a memory by ID | | `UpdateMemoryTool` | Update content, topics, or metadata | | `DeleteMemoryTool` | Remove memories by ID | | `MemoryPromptTool` | Enrich a system prompt with relevant memories | -## MCP vs REST Decision +## MCP vs SDK Decision -| | MCP | REST Tools | +| | MCP | SDK Tools | |---|---|---| | **Multi-language** | Yes (Python, TypeScript, any MCP client) | Python only | -| **Shared server** | Yes, multiple agents connect to one MCP endpoint | Each agent connects directly to REST API | +| **Shared server** | Yes, multiple agents connect to one MCP endpoint | Each agent connects through the SDK | | **Extra service** | Requires MCP server running | No extra service (direct HTTP) | | **Tool filtering** | `tool_filter` on `McpToolset` | Choose which tool classes to instantiate | -## Configuration (REST Tools) +## Configuration (SDK Tools) | Option | Default | Description | |--------|---------|-------------| -| `api_base_url` | `http://localhost:8000` | Agent Memory Server URL | +| `backend` | `redis-agent-memory` | `redis-agent-memory` or `opensource-agent-memory` | +| `api_base_url` | `http://localhost:8000` | Memory backend URL | +| `api_key` | `None` | Redis Agent Memory API key | +| `store_id` | `None` | Redis Agent Memory store ID | | `timeout` | `30` | HTTP timeout in seconds | | `default_namespace` | `default` | Namespace for memory isolation | | `search_top_k` | `10` | Default max search results | -| `recency_boost` | `True` | Bias scoring toward newer memories | -| `distance_threshold` | `None` | Max vector distance for search results | +| `distance_threshold` | `None` | Compatibility alias for search threshold | | `deduplicate` | `True` | Deduplicate when creating memories | Launch with the [ADK web UI](https://google.github.io/adk-docs/runtime/) for interactive testing: diff --git a/docs/concepts/sessions.md b/docs/concepts/sessions.md index cd59dd7..7f4f84e 100644 --- a/docs/concepts/sessions.md +++ b/docs/concepts/sessions.md @@ -6,11 +6,11 @@ Use `RedisWorkingMemorySessionService` and `RedisLongTermMemoryService` when you | Feature | Details | |---------|---------| -| **Session storage** | Agent Memory Server working memory (Redis JSON) | -| **Long-term memory** | Agent Memory Server with vector + full-text indexes | -| **Auto-summarization** | Old messages are summarized when context window fills | -| **Memory extraction** | Background promotion of facts to long-term storage | -| **Search** | Semantic, keyword, and hybrid search across sessions | +| **Session storage** | Redis Agent Memory session events or Agent Memory Server working memory | +| **Long-term memory** | Redis Agent Memory records or Agent Memory Server long-term memory | +| **Direct memory writes** | `add_memory()` stores durable semantic or episodic facts | +| **Event memory writes** | `add_session_to_memory()` stores ADK events as `message` memories | +| **Search** | Backend long-term search across scoped records | | **Multi-process** | Safe for horizontal scaling; all state lives in Redis | ## How It Works @@ -18,14 +18,16 @@ Use `RedisWorkingMemorySessionService` and `RedisLongTermMemoryService` when you ```mermaid flowchart TD U([User message]) --> R[ADK Runner] - R -->|append_event| WM[Working Memory
messages · context · data] - WM -->|auto-summarize| WM - WM -->|background extraction| LTM[Long-Term Memory
vector + full-text index] + R -->|append_event| WM[Session Events] + R -->|add_memory| LTM[Long-Term Memory] + R -->|add_session_to_memory| MSG[Message Memories] + MSG --> LTM LTM -->|search_memory| R R --> A([Agent response]) - subgraph AMS [Agent Memory Server] + subgraph RAM [Configured Memory Backend] WM + MSG LTM end @@ -35,13 +37,13 @@ flowchart TD FT[(Full-text index)] end - AMS --- Redis + RAM --- Redis ``` -1. The ADK `Runner` calls `append_event()` after every turn, forwarding the message to the Agent Memory Server. -2. When the conversation exceeds `context_window_max` tokens, the server summarizes older messages and stores the summary in a `context` field. -3. A background task extracts structured memories (facts, preferences, events) and promotes them to long-term storage. -4. On future sessions, `search_memory()` retrieves relevant memories via hybrid search. +1. The ADK `Runner` calls `append_event()` after every turn, forwarding the message to the configured memory backend. +2. ADK callbacks or API routes can call `add_session_to_memory()` to store session events as long-term `message` memories. +3. Agents and callbacks can call `add_memory()` or memory tools to store durable `semantic` or `episodic` records. +4. On future sessions, `search_memory()` retrieves relevant memories from the configured backend. ## Usage @@ -58,18 +60,21 @@ from adk_redis import ( session_service = RedisWorkingMemorySessionService( config=RedisWorkingMemorySessionServiceConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8000", + api_key="...", + store_id="...", default_namespace="my_app", - model_name="gpt-4o", - context_window_max=8000, ), ) memory_service = RedisLongTermMemoryService( config=RedisLongTermMemoryServiceConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8000", + api_key="...", + store_id="...", default_namespace="my_app", - recency_boost=True, ), ) @@ -86,6 +91,10 @@ runner = Runner( ) ``` +To use the open source self-hosted Agent Memory Server instead, set +`backend="opensource-agent-memory"` on both configs. Redis Agent Memory is the +default backend. + Launch with the [ADK web UI](https://google.github.io/adk-docs/runtime/) for interactive testing: ```bash @@ -98,57 +107,40 @@ adk web . | Option | Default | Description | |--------|---------|-------------| -| `api_base_url` | `http://localhost:8000` | Agent Memory Server URL | +| `backend` | `redis-agent-memory` | `redis-agent-memory` or `opensource-agent-memory` | +| `api_base_url` | `http://localhost:8000` | Memory backend URL | +| `api_key` | `None` | Redis Agent Memory API key | +| `store_id` | `None` | Redis Agent Memory store ID | | `timeout` | `30.0` | HTTP request timeout in seconds | | `default_namespace` | `None` | Logical grouping for multi-tenant isolation | -| `model_name` | `None` | Model name used for context window sizing and summarization | -| `context_window_max` | `None` | Token limit that triggers auto-summarization | -| `extraction_strategy` | `discrete` | How memories are extracted (`discrete`, `summary`, `preferences`, `custom`) | -| `session_ttl_seconds` | `None` | Optional TTL; expired sessions are cleaned up by Redis | ### Memory Service (`RedisLongTermMemoryServiceConfig`) | Option | Default | Description | |--------|---------|-------------| -| `api_base_url` | `http://localhost:8000` | Agent Memory Server URL | +| `backend` | `redis-agent-memory` | `redis-agent-memory` or `opensource-agent-memory` | +| `api_base_url` | `http://localhost:8000` | Memory backend URL | +| `api_key` | `None` | Redis Agent Memory API key | +| `store_id` | `None` | Redis Agent Memory store ID | | `timeout` | `30.0` | HTTP request timeout in seconds | | `default_namespace` | `None` | Namespace for memory isolation | | `search_top_k` | `10` | Max results returned from `search_memory()` | -| `distance_threshold` | `None` | Max vector distance for search results (0.0-1.0) | -| `recency_boost` | `True` | Bias search scoring toward newer memories | -| `semantic_weight` | `0.8` | Weight for semantic similarity (0.0-1.0) | -| `recency_weight` | `0.2` | Weight for recency score (0.0-1.0) | -| `extraction_strategy` | `discrete` | How memories are extracted (`discrete`, `summary`, `preferences`, `custom`) | - -## Automatic Summarization - -When conversation messages exceed `context_window_max` tokens, the server: - -1. Summarizes older messages into a compact paragraph. -2. Stores the summary in the `context` field of working memory. -3. Removes the summarized messages to free space. -4. Keeps recent messages intact. - -```mermaid -flowchart LR - M["msg1 msg2 ... msg10"] -->|exceeds threshold| S[Summarize] - S --> C["context: 'User discussed trip planning...'"] - S --> K["msg8 msg9 msg10
(recent kept)"] -``` +| `similarity_threshold` | `None` | Min similarity for search results (0.0-1.0) | +| `store_events_as_messages` | `True` | Store ADK events as `message` memories | ## Memory Types -The server extracts three types of memories from conversations: +Redis Agent Memory and Agent Memory Server support three memory types: | Type | Description | Example | |------|-------------|---------| | **Semantic** | Facts, preferences, general knowledge | "User prefers window seats" | | **Episodic** | Events with temporal context | "User visited Paris in March 2024" | -| **Message** | Conversation records (auto-generated) | Stored from working memory messages | +| **Message** | Conversation records | "user: I prefer window seats" | ## Cross-Process Scaling -Because all state lives in the Agent Memory Server (backed by Redis), multiple processes can share sessions: +Because all state lives in Redis, multiple processes can share sessions: - **Horizontal scaling**: deploy multiple agent replicas behind a load balancer. - **Seamless failover**: if one instance goes down, another picks up the session. diff --git a/docs/llms.txt b/docs/llms.txt index fd6f8ae..51ac925 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -2,9 +2,8 @@ > Redis integrations for Google's Agent Development Kit. Provides ADK > `BaseSessionService` and `BaseMemoryService` implementations, five -> RedisVL-backed search tools, MCP toolsets for RedisVL and Agent Memory -> Server, and semantic cache providers (self-hosted RedisVL or managed -> Redis LangCache). +> RedisVL-backed search tools, selectable Redis Agent Memory or Agent Memory +> Server memory backends, MCP toolsets, and semantic cache providers. For agents using the library, read [AGENTS.md](../AGENTS.md) first. For the full API surface, browse [the API reference](api/python/index.md). @@ -14,8 +13,8 @@ For task-oriented recipes, browse the ## Concepts - [ADK overview](concepts/adk_overview.md): how ADK abstractions map onto Redis. -- [Sessions](concepts/sessions.md): working memory backed by Agent Memory Server. -- [Memory](concepts/memory.md): long-term semantic memory with recency boosting. +- [Sessions](concepts/sessions.md): working memory backed by Redis Agent Memory or Agent Memory Server. +- [Memory](concepts/memory.md): long-term semantic memory through Redis Agent Memory or Agent Memory Server. - [Search](concepts/search.md): vector, hybrid, range, text, and SQL search over RedisVL. - [Caching](concepts/caching.md): semantic caching with RedisVL or LangCache. @@ -58,6 +57,8 @@ When generating code that uses adk-redis: (e.g., `from adk_redis import RedisVectorSearchTool`). - Construct services with their `Config` dataclasses (`RedisLongTermMemoryServiceConfig`, etc.) so options are explicit. +- Set `backend="redis-agent-memory"` for Redis Agent Memory, or + `backend="opensource-agent-memory"` for the open source self-hosted server. - For the RedisVL MCP path, use ADK's native `McpToolset` with the appropriate `*ConnectionParams` class. Set `tool_filter=["search-records"]` to suppress writes, or pass @@ -71,22 +72,20 @@ When generating code that uses adk-redis, never: - Pass `epsilon=` to `RedisVectorQueryConfig`; it is `VECTOR_RANGE`-only and lives on `RedisRangeQueryConfig`. - Mix sync and async RedisVL `SearchIndex` instances in the same tool. -- Cache the `MemoryAPIClient` across ADK `Runner.run` invocations; the - session service builds a new client per call on purpose. +- Cache Redis Agent Memory SDK clients across ADK `Runner.run` invocations; + the session and memory services build short-lived clients on purpose. When the user asks for something this library does not cover: - For LangChain agents over Redis, use [`langchain-redis`](https://github.com/redis-developer/langchain-redis). - For LangGraph state and checkpointers, use [`langgraph-redis`](https://github.com/redis-developer/langgraph-redis). - For raw RedisVL search without ADK, use [`redisvl`](https://docs.redisvl.com). -- For the Redis Agent Memory Server itself, use - [`agent-memory-server`](https://github.com/redis/agent-memory-server) - and its `agent-memory-client` SDK. +- For Redis Agent Memory itself, use the `redis-agent-memory` SDK. When the user is comparing adk-redis to another library: - adk-redis is the ADK-native surface for Redis. It does not replace - RedisVL or Agent Memory Server; it adapts them to ADK's `BaseTool`, + RedisVL or Redis Agent Memory; it adapts them to ADK's `BaseTool`, `BaseSessionService`, `BaseMemoryService`, and MCP interfaces. - Pick this library when the agent runs on Google ADK and the data layer is Redis. For other agent frameworks, the corresponding `*-redis` diff --git a/docs/specs/examples-memory-coverage-handover.md b/docs/specs/examples-memory-coverage-handover.md new file mode 100644 index 0000000..6e7c849 --- /dev/null +++ b/docs/specs/examples-memory-coverage-handover.md @@ -0,0 +1,232 @@ +# Handover: memory example coverage and runner alignment + +Status: open follow-up after PR #16 (selectable memory backends, release 0.0.7). + +## Background + +PR #16 made the memory stack backend-pluggable through a single config field: + +- `backend="redis-agent-memory"` (managed Redis Agent Memory, library default). +- `backend="opensource-agent-memory"` (self-hosted Agent Memory Server via + `agent-memory-client`). + +See [`redis-agent-memory-default.md`](redis-agent-memory-default.md) for the +backend selection design. + +The work below is open because the two backends do not have identical +capabilities, and the shipped examples were originally written against the +opensource backend. Before changing examples, the next person needs to +confirm what the managed backend can and cannot do today, then decide what +the example surface should look like. + +## Goal + +Decide what `examples/` should look like now that two memory backends are +supported. Produce a concrete plan (and ideally PRs) that aligns examples +with the library default, covers both backends honestly, and keeps the +"clone, run, done" experience for new users. + +## How to approach this + +Work in two phases. Do not skip phase 1: the example decisions in phase 2 +are downstream of what you find in phase 1. + +### Phase 1: assess implementation coverage of the managed backend + +Goal: a written answer to "does every ADK memory surface we ship work +end-to-end against `redis-agent-memory`, and if not, what is missing?" + +1. Read the current backend dispatch: + - `src/adk_redis/memory/long_term_memory.py` + - `src/adk_redis/sessions/working_memory.py` + - `src/adk_redis/tools/memory/` (all six tools plus `_base.py` and + `_config.py`) + - `src/adk_redis/memory/_backends.py` +2. Read the `redis-agent-memory` SDK surface that we actually call. The + installed package lives under + `.venv/lib/python3.13/site-packages/redis_agent_memory/`. Start at + `sdk.py` (the `AgentMemory` class) and the `models/` directory. +3. Read the `agent-memory-client` surface we call for the opensource path, + and the upstream `redis/agent-memory-server` README, so you know what + the opensource backend offers that the managed SDK may not. +4. For each ADK surface we expose + (`RedisLongTermMemoryService.add_session_to_memory` / `add_memory` / + `add_events_to_memory` / `search_memory`, + `RedisWorkingMemorySessionService.create_session` / `get_session` / + `list_sessions` / `append_event` / `delete_session`, and each memory + tool), record: + - Does the managed SDK have a primitive we can call? + - If yes, is the behavior equivalent, or is there a semantic gap + (for example: explicit writes vs. server-side extraction, + summarization, recency, MCP, namespace/scope semantics, owner + IDs)? + - If no, is it a hard gap (no managed equivalent exists) or a soft + gap (managed has a different primitive that we have not wired + yet)? +5. Cross-check against the existing live integration tests in + `tests/integration/test_memory_backends_end_to_end.py`. Anything not + exercised there is by definition not verified end-to-end on managed. + +Deliverable: a coverage table (ADK surface x backend) checked into +`docs/specs/` or attached to the follow-up PR, plus a list of hard +gaps that need either upstream work in `redis-agent-memory` or +documentation in `adk-redis` so users are not surprised. + +### Phase 2: assess example coverage given the phase 1 findings + +Only start once phase 1 has a written conclusion. The example decisions +are different depending on whether managed has full parity, partial +parity, or a known feature gap. + +1. Enumerate the current examples and what each one actually exercises: + - Which use `RedisLongTermMemoryService` / `RedisWorkingMemorySessionService`? + - Which use the memory tools directly? + - Which use the Agent Memory Server MCP endpoint? + - Which use the `adk web` runner vs. a custom `main.py` via + `get_fast_api_app`? + - Which default `REDIS_MEMORY_BACKEND` is set in `.env.example`, + README, and agent wiring? +2. For each example, answer: + - Can it run unchanged on managed today, given phase 1 findings? + - If not, is the blocker an example-level wiring issue (env vars, + defaults), a runner constraint (services need `get_fast_api_app`, + `adk web` cannot register them), or a real feature gap in managed? + - Should its default backend stay opensource, flip to managed, or be + duplicated so both backends are covered? +3. Decide on the target example surface: + - Do we need a new managed-default example? If yes, what runner and + what feature set (services vs. tools-only)? + - Do any existing examples need to be split, renamed, or removed? + - Is there a missing top-level `examples/README.md` matrix that would + orient users to the runner/backend/infra trade-offs? + - Is `examples/simple_redis_memory/.env.example` (currently empty) + intentional or an oversight? + +Deliverable: a short plan ("add X, change Y, leave Z alone, document W") +and the corresponding PRs. + +## Known starting observations + +These are observations from the PR #16 review and a read of the public +Redis Agent Memory docs as of 2026-06-02. Treat them as inputs to phase +1 / phase 2, not as conclusions. Verify each one against the current +SDK and server behavior before acting on it: managed Redis Agent Memory +is explicitly in **preview** ("Features and behavior are subject to +change"). + +### Example wiring + +- Every memory example currently defaults to + `REDIS_MEMORY_BACKEND=opensource-agent-memory`, which is the opposite + of the library default. +- Only one memory example (`travel_agent_memory_tools`) uses the + `adk web` runner. The other two register services through + `google.adk.cli.service_registry.get_service_registry()`, which only + `get_fast_api_app` consults, so they ship a custom `main.py`. Whether + this is a permanent constraint or something that should be lifted + upstream in ADK is worth checking during phase 1. +- `examples/simple_redis_memory/.env.example` is empty. + +### Capability differences from public docs + +Sources: , +, +and . Numbers are starting +points; phase 1 needs to confirm each against the installed SDK and the +current server image. + +- **Auto-promotion is present on managed, but the policy is opaque.** + The public managed docs state "the Agent Memory model will + automatically promote relevant short-term memories to long-term + memory" when events are added through the session-memory endpoint. + There is no documented control surface (no extraction strategy + selector, no debounce). Opensource exposes `extraction_strategy` + (`discrete` / `summary` / `preferences`) and + `EXTRACTION_DEBOUNCE_SECONDS`. Phase 1 needs to check whether our + current managed dispatch routes session events through the endpoint + that triggers promotion, and what `RedisLongTermMemoryServiceConfig` + knobs become no-ops on managed. +- **Auto-summarization of working memory is opensource only.** + Opensource summarizes older turns when token count crosses + `context_window_max` using the configured `model_name`. The managed + docs do not describe an equivalent. Confirm whether the managed + session-memory endpoint has any size-based behavior, and document any + `RedisWorkingMemorySessionServiceConfig` fields that become no-ops on + managed. +- **Recency-boosted search is opensource only (as documented).** + Opensource search exposes `semantic_weight` / `recency_weight` / + `recency_boost`. Managed search exposes `similarityThreshold` plus + rich `filter` operators (`eq`, `ne`, `in`, `all`, `gt`, `lt`, `gte`, + `lte`) over `sessionId`, `ownerId`, `namespace`, `topics`, + `memoryType`, `createdAt`. Phase 1 needs to confirm how our + `recency_boost` / weight config degrades on managed. +- **MCP server endpoint is opensource only.** Opensource ships + `agent-memory mcp --mode sse` and our `create_memory_mcp_toolset` + helper targets it. Managed has no documented MCP endpoint. Until + that changes, `fitness_coach_mcp` and any future MCP example stays + on opensource, and the docs need to say so. +- **`MemoryPromptTool` parity.** Opensource MCP exposes a + `memory_prompt` tool. Confirm whether our managed dispatch in + `src/adk_redis/tools/memory/memory_prompt_tool.py` returns equivalent + data via the managed search endpoint, and document any behavior + difference. +- **Identifier naming.** Managed uses `ownerId` and `storeId`; + opensource uses `user_id` and namespaces only. Our config field is + `default_owner_id`. Phase 1 should confirm the mapping is consistent + across all six tools, both services, and the integration tests, and + that ADK's `user_id` flows through to `ownerId` correctly. +- **Session state.** ADK `Session.state` is a free-form dict. Managed + session events have `actorId`, `role`, `content[]`, `createdAt`, and + per-event `metadata`, but no documented session-level state field. + Phase 1 should check whether session state survives a + `create_session` / `get_session` round trip on managed and whether + this needs to be called out as a known gap. +- **Event payload fidelity.** ADK events carry function calls, tool + responses, partials, and other fields beyond plain text. Managed + session events are modeled around text content with optional + metadata. Confirm what our `append_event` dispatch on managed does + with non-text payloads and whether anything is dropped. +- **Bulk operations.** The managed SDK exposes + `bulk_create_long_term_memories` and `bulk_delete_long_term_memories`. + Check that we use bulk where it makes sense (`add_memory` already + does) and whether `DeleteMemoryTool` on managed could be made + bulk-aware. +- **Documentation alignment.** The public ADK integration page + (`redis.io/docs/.../integrate/google-adk/redis-agent-memory/`) is + written as if the only backend is the Agent Memory Server. It does + not yet describe the managed backend selector introduced by PR #16. + Coordinate with whoever owns that page so it matches the library + default once phase 2 lands. + +## Out of scope for this handover + +- Changing the library default backend. +- Live CI coverage for managed integration tests (covered separately by + the integration tests added in PR #16; gating them in CI is its own + follow-up). +- Updates to the external `redis.io` ADK integration page (flagged + above for coordination, but not owned by this repo). + +## Out of scope for this handover + +- Changing the library default backend. +- Live CI coverage for managed integration tests (covered separately by + the integration tests added in PR #16; gating them in CI is its own + follow-up). + +## Pointers + +- Backend selection spec: [`redis-agent-memory-default.md`](redis-agent-memory-default.md) +- Backend dispatch: + - `src/adk_redis/memory/long_term_memory.py` + - `src/adk_redis/sessions/working_memory.py` + - `src/adk_redis/tools/memory/_config.py` + - `src/adk_redis/tools/memory/_base.py` +- Live integration coverage: `tests/integration/test_memory_backends_end_to_end.py` +- Managed SDK (installed): `.venv/.../site-packages/redis_agent_memory/sdk.py` +- Opensource server: +- Example wiring references: + - Tools-only + `adk web`: `examples/travel_agent_memory_tools/` + - Services + custom runner: `examples/travel_agent_memory_hybrid/main.py`, + `examples/simple_redis_memory/main.py` + - MCP via opensource: `examples/fitness_coach_mcp/` diff --git a/docs/specs/redis-agent-memory-default.md b/docs/specs/redis-agent-memory-default.md new file mode 100644 index 0000000..63b0027 --- /dev/null +++ b/docs/specs/redis-agent-memory-default.md @@ -0,0 +1,138 @@ +# Redis Agent Memory Dual Backend Spec + +## Goal + +Add Redis Agent Memory support to `adk-redis` while keeping the open source, +self-hosted Agent Memory Server path available. Users choose the backend with a +single config field. + +## Backend Choice + +All ADK memory surfaces accept: + +```python +backend="redis-agent-memory" # default, uses redis-agent-memory +backend="opensource-agent-memory" # self-hosted, uses agent-memory-client +``` + +The public class names stay unchanged: + +- `RedisWorkingMemorySessionService` +- `RedisLongTermMemoryService` +- `MemoryToolConfig` +- `SearchMemoryTool`, `CreateMemoryTool`, `GetMemoryTool`, + `UpdateMemoryTool`, `DeleteMemoryTool`, `MemoryPromptTool` + +## ADK Upstream Check + +The latest `google/adk-python` source was checked from a fresh shallow clone at +commit `ae95a97`. The memory service contract still includes +`add_session_to_memory()` and `search_memory()`, and now also includes optional +write paths: + +- `add_events_to_memory()` +- `add_memory()` + +The implementation supports these newer hooks while remaining compatible with +installed ADK versions that do not call them yet. + +## Redis Agent Memory Backend + +The default backend uses `redis_agent_memory.AgentMemory`. + +Session service: + +- `append_event()` writes Redis Agent Memory session events. +- `get_session()` reconstructs ADK sessions from stored events. +- `list_sessions()` stores ADK scope in deterministic internal session IDs. + +Long-term memory service: + +- `search_memory()` calls `search_long_term_memory_async()`. +- Searches filter by `ownerId == user_id` and namespace. +- `add_memory()` writes durable `semantic`, `episodic`, or `message` records. +- `add_events_to_memory()` and `add_session_to_memory()` write ADK events as + long-term `message` memories for ADK compatibility. + +Tools: + +- Use the async Redis Agent Memory SDK methods for search, create, get, update, + delete, and prompt enrichment. +- `default_owner_id` is available for tools that run outside an ADK user + context. + +## Agent Memory Server Backend + +The self-hosted backend keeps the previous `agent-memory-client` behavior. + +Session service: + +- Uses Working Memory APIs. +- Preserves automatic summarization and extraction strategy settings. +- Keeps incremental `append_messages_to_working_memory()` writes. + +Long-term memory service: + +- `add_session_to_memory()` stores session working memory so the server can + extract long-term memories. +- `add_memory()` uses `add_memory_tool()` for explicit durable memory writes. +- `search_memory()` uses `search_long_term_memory()` with namespace and user + filters, plus optional recency config. + +Tools: + +- Use the previous REST client methods. +- Preserve recency settings and memory prompt behavior. + +## Configuration + +Shared options: + +- `backend` +- `api_base_url` +- `default_namespace` +- `search_top_k` +- `distance_threshold` +- `timeout` + +Redis Agent Memory options: + +- `api_key` +- `store_id` +- `timeout_ms` +- `similarity_threshold` +- `default_owner_id` for tools + +Agent Memory Server options: + +- `recency_boost` +- `semantic_weight` +- `recency_weight` +- `freshness_weight` +- `novelty_weight` +- `half_life_last_access_days` +- `half_life_created_days` +- `extraction_strategy` +- `extraction_strategy_config` +- `model_name` +- `context_window_max` + +## Dependency + +The `memory` extra installs both backend clients: + +- `agent-memory-client>=0.14.0` +- `redis-agent-memory>=0.0.4` + +## Tests + +Unit tests mock both clients and cover: + +- Config defaults. +- Redis Agent Memory owner and namespace filters. +- Self-hosted Agent Memory Server backend dispatch. +- Redis Agent Memory session event writes. +- Session reconstruction. +- Tool create, search, update, and delete payloads. + +Normal unit tests do not require a live memory server. diff --git a/docs/user_guide/01_integration.md b/docs/user_guide/01_integration.md index 7e6c25b..0db02e6 100644 --- a/docs/user_guide/01_integration.md +++ b/docs/user_guide/01_integration.md @@ -8,33 +8,18 @@ three steps. For the concepts behind each feature, see the ## 1. Start infrastructure -Follow the [Redis setup](how_to_guides/redis_setup.md) and -[Agent Memory Server setup](how_to_guides/memory_server_setup.md) how-to guides, -or use this minimal start: +Provide Redis Agent Memory connection settings: ```bash -# Redis 8.4 -docker run -d --name redis -p 6379:6379 redis:8.4-alpine - -# Agent Memory Server (dev mode) -docker run -d --name agent-memory-server \ - -p 8000:8000 \ - -e REDIS_URL=redis://host.docker.internal:6379 \ - -e GEMINI_API_KEY=your-gemini-api-key \ - -e GENERATION_MODEL=gemini/gemini-2.0-flash-exp \ - -e EMBEDDING_MODEL=gemini/text-embedding-004 \ - -e EXTRACTION_DEBOUNCE_SECONDS=5 \ - -e DISABLE_AUTH=true \ - redislabs/agent-memory-server:0.13.2 \ - agent-memory api --host 0.0.0.0 --port 8000 --task-backend=asyncio - -# Verify -curl http://localhost:8000/v1/health +export REDIS_MEMORY_BACKEND="redis-agent-memory" +export AGENT_MEMORY_SERVER_URL="https://..." +export AGENT_MEMORY_STORE_ID="..." +export AGENT_MEMORY_API_KEY="..." ``` -!!! note - On Linux, replace `host.docker.internal` with `172.17.0.1` or use - `--network host`. +For the open source self-hosted Agent Memory Server, use +`REDIS_MEMORY_BACKEND="opensource-agent-memory"` and point +`AGENT_MEMORY_SERVER_URL` at your server. ## 2. Install dependencies @@ -45,8 +30,13 @@ pip install google-adk "adk-redis[memory]" ## 3. Wire services into an agent ```python +import os + from google.adk import Agent +from google.adk.agents.callback_context import CallbackContext from google.adk.runners import Runner +from google.adk.tools import load_memory +from google.adk.tools import preload_memory from adk_redis import ( RedisWorkingMemorySessionService, RedisWorkingMemorySessionServiceConfig, @@ -56,22 +46,35 @@ from adk_redis import ( session_service = RedisWorkingMemorySessionService( config=RedisWorkingMemorySessionServiceConfig( - api_base_url="http://localhost:8000", + backend=os.getenv("REDIS_MEMORY_BACKEND", "redis-agent-memory"), + api_base_url=os.environ["AGENT_MEMORY_SERVER_URL"], + api_key=os.environ.get("AGENT_MEMORY_API_KEY"), + store_id=os.environ.get("AGENT_MEMORY_STORE_ID"), default_namespace="my_app", ) ) memory_service = RedisLongTermMemoryService( config=RedisLongTermMemoryServiceConfig( - api_base_url="http://localhost:8000", + backend=os.getenv("REDIS_MEMORY_BACKEND", "redis-agent-memory"), + api_base_url=os.environ["AGENT_MEMORY_SERVER_URL"], + api_key=os.environ.get("AGENT_MEMORY_API_KEY"), + store_id=os.environ.get("AGENT_MEMORY_STORE_ID"), default_namespace="my_app", ) ) + +async def after_agent(callback_context: CallbackContext): + await callback_context.add_session_to_memory() + + agent = Agent( name="memory_agent", model="gemini-2.0-flash", instruction="You are a helpful assistant with long-term memory.", + tools=[preload_memory, load_memory], + after_agent_callback=after_agent, ) runner = Runner( @@ -92,8 +95,7 @@ adk web my_app **Try it out:** 1. "Hi, I'm Alice. I love pizza and Python." -2. Wait 5 seconds for background memory extraction. -3. Start a new session: "What do you remember about me?" +2. Start a new session: "What do you remember about me?" ## What next? diff --git a/docs/user_guide/how_to_guides/memory_service.md b/docs/user_guide/how_to_guides/memory_service.md index 196ec0f..b89cca9 100644 --- a/docs/user_guide/how_to_guides/memory_service.md +++ b/docs/user_guide/how_to_guides/memory_service.md @@ -1,16 +1,16 @@ # Memory Service This guide shows how to wire `RedisLongTermMemoryService` into a Google ADK -agent for persistent long-term memory backed by the -[Agent Memory Server](memory_server_setup.md). +agent for persistent long-term memory backed by Redis Agent Memory or the +self-hosted Agent Memory Server. For the concepts behind long-term memory, see [Sessions + Memory Services](../../concepts/sessions.md). ## Prerequisites -- Agent Memory Server running on `localhost:8000` - (see [Memory server setup](memory_server_setup.md)). +- Redis Agent Memory endpoint, store ID, and API key, or a self-hosted Agent + Memory Server endpoint. - `adk-redis` with the memory extra: `pip install "adk-redis[memory]"`. ## Basic usage @@ -27,14 +27,20 @@ from adk_redis import ( session_service = RedisWorkingMemorySessionService( config=RedisWorkingMemorySessionServiceConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8000", + api_key="...", + store_id="...", default_namespace="my_app", ) ) memory_service = RedisLongTermMemoryService( config=RedisLongTermMemoryServiceConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8000", + api_key="...", + store_id="...", default_namespace="my_app", recency_boost=True, ) @@ -54,11 +60,14 @@ runner = Runner( ) ``` +For self-hosted Agent Memory Server, use `backend="opensource-agent-memory"` and +omit `api_key` and `store_id` unless your deployment requires them. + ## How memories flow 1. The agent converses with the user in a session. -2. The Agent Memory Server extracts key facts in the background. -3. Facts are stored as long-term memories with vector embeddings. +2. `add_session_to_memory()` stores event text as long-term `message` memory. +3. Use `add_memory()` or memory tools to write durable facts and preferences. 4. On future sessions, the runner calls `search_memory()` and injects relevant memories into the prompt automatically. @@ -66,12 +75,12 @@ runner = Runner( | Option | Default | Description | |--------|---------|-------------| -| `api_base_url` | `http://localhost:8000` | Agent Memory Server URL | +| `backend` | `redis-agent-memory` | `redis-agent-memory` or `opensource-agent-memory` | +| `api_base_url` | `http://localhost:8000` | Memory backend URL | +| `api_key` | `None` | Redis Agent Memory API key | +| `store_id` | `None` | Redis Agent Memory store ID | | `default_namespace` | `None` | Namespace for memory isolation | | `search_top_k` | `10` | Max memories per search | -| `distance_threshold` | `None` | Max distance for results (0.0-1.0) | -| `recency_boost` | `True` | Enable recency-aware ranking | -| `semantic_weight` | `0.8` | Weight for semantic similarity | -| `recency_weight` | `0.2` | Weight for recency score | -| `extraction_strategy` | `discrete` | `discrete`, `summary`, `preferences`, `custom` | +| `similarity_threshold` | `None` | Min similarity for results (0.0-1.0) | +| `store_events_as_messages` | `True` | Store ADK events as `message` memories | | `timeout` | `30.0` | HTTP request timeout | diff --git a/docs/user_guide/how_to_guides/session_service.md b/docs/user_guide/how_to_guides/session_service.md index c7ce0d4..954ccb2 100644 --- a/docs/user_guide/how_to_guides/session_service.md +++ b/docs/user_guide/how_to_guides/session_service.md @@ -1,16 +1,16 @@ # Session Service This guide shows how to wire `RedisWorkingMemorySessionService` into a Google -ADK agent for durable, auto-summarizing session state backed by the -[Agent Memory Server](memory_server_setup.md). +ADK agent for durable session state backed by Redis Agent Memory session +events or self-hosted Agent Memory Server working memory. For the concepts behind sessions and working memory, see [Sessions + Memory Services](../../concepts/sessions.md). ## Prerequisites -- Agent Memory Server running on `localhost:8000` - (see [Memory server setup](memory_server_setup.md)). +- Redis Agent Memory endpoint, store ID, and API key, or a self-hosted Agent + Memory Server endpoint. - `adk-redis` with the memory extra: `pip install "adk-redis[memory]"`. ## Basic usage @@ -25,7 +25,10 @@ from adk_redis import ( session_service = RedisWorkingMemorySessionService( config=RedisWorkingMemorySessionServiceConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8000", + api_key="...", + store_id="...", default_namespace="my_app", ) ) @@ -48,17 +51,19 @@ session = await session_service.create_session( user_id="alice", ) -# The session is persisted in Agent Memory Server and survives restarts +# Events appended to the session persist in the configured backend ``` +For self-hosted Agent Memory Server, use `backend="opensource-agent-memory"` and +omit `api_key` and `store_id` unless your deployment requires them. + ## Configuration options | Option | Default | Description | |--------|---------|-------------| -| `api_base_url` | `http://localhost:8000` | Agent Memory Server URL | +| `backend` | `redis-agent-memory` | `redis-agent-memory` or `opensource-agent-memory` | +| `api_base_url` | `http://localhost:8000` | Memory backend URL | +| `api_key` | `None` | Redis Agent Memory API key | +| `store_id` | `None` | Redis Agent Memory store ID | | `default_namespace` | `None` | Namespace for session isolation | -| `model_name` | `None` | LLM model for summarization | -| `context_window_max` | `None` | Max tokens before auto-summarization | -| `extraction_strategy` | `discrete` | `discrete`, `summary`, `preferences`, `custom` | -| `session_ttl_seconds` | `None` | Session expiration time | | `timeout` | `30.0` | HTTP request timeout | diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..eb3366f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,48 @@ +# Examples + +Runnable agents built with [Google ADK](https://github.com/google/adk-python) +and `adk-redis`. Each example ships with a README and `.env.example`. + +## Memory and sessions + +| Example | Backend default | Runner | What it exercises | +|---------|-----------------|--------|-------------------| +| [`managed_memory_quickstart`](managed_memory_quickstart/) | `redis-agent-memory` (managed) | `python main.py` | Minimal session + long-term memory services against managed Redis Agent Memory. No Docker. | +| [`simple_redis_memory`](simple_redis_memory/) | `opensource-agent-memory` | `python main.py` | Full two-tier memory with auto-summarization and extraction via Agent Memory Server. | +| [`travel_agent_memory_hybrid`](travel_agent_memory_hybrid/) | `opensource-agent-memory` | `python main.py` | Services + explicit memory tools + travel domain tools. | +| [`travel_agent_memory_tools`](travel_agent_memory_tools/) | `opensource-agent-memory` | `adk web .` | Memory tools only (no framework services). Switch backend via `REDIS_MEMORY_BACKEND`. | +| [`fitness_coach_mcp`](fitness_coach_mcp/) | `opensource-agent-memory` only | `adk web .` | MCP memory tools via Agent Memory Server SSE. Managed backend has no MCP endpoint. | + +### Runner notes + +- **`python main.py`** — Required when registering `RedisWorkingMemorySessionService` + and/or `RedisLongTermMemoryService` through `get_service_registry()`. ADK's + `get_fast_api_app` reads those registrations; `adk web` does not. +- **`adk web .`** — Works when memory is wired as agent tools in `agent.py` + (no custom session/memory services). + +### Backend notes + +- **`redis-agent-memory`** — Managed Redis Agent Memory (library default). Needs + `REDIS_AGENT_MEMORY_API_BASE_URL`, `REDIS_AGENT_MEMORY_API_KEY`, and + `REDIS_AGENT_MEMORY_STORE_ID`. No local Agent Memory Server. +- **`opensource-agent-memory`** — Self-hosted + [Agent Memory Server](https://github.com/redis/agent-memory-server). Supports + auto-summarization, extraction strategies, recency-boosted search, and MCP. + +Set `REDIS_MEMORY_BACKEND` in examples that support both backends. + +## Search + +| Example | Runner | What it exercises | +|---------|--------|-------------------| +| [`redis_search_tools`](redis_search_tools/) | `adk web .` | Vector, text, and range search tools | +| [`redis_sql_search`](redis_sql_search/) | `adk web .` | `RedisSQLSearchTool` | +| [`redisvl_mcp_search`](redisvl_mcp_search/) | `adk web .` | RedisVL data via `rvl mcp` | + +## Caching + +| Example | Runner | What it exercises | +|---------|--------|-------------------| +| [`semantic_cache`](semantic_cache/) | `python main.py` | `RedisVLCacheProvider` semantic cache | +| [`langcache_cache`](langcache_cache/) | `python main.py` | Managed `LangCacheProvider` | diff --git a/examples/managed_memory_quickstart/.env.example b/examples/managed_memory_quickstart/.env.example new file mode 100644 index 0000000..19be318 --- /dev/null +++ b/examples/managed_memory_quickstart/.env.example @@ -0,0 +1,29 @@ +# ============================================================================= +# MANAGED REDIS AGENT MEMORY QUICKSTART +# ============================================================================= +# Copy this file to .env and fill in your actual values. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Google Gemini API Key (REQUIRED) +# ----------------------------------------------------------------------------- +GOOGLE_API_KEY=your-google-api-key-here + +# ----------------------------------------------------------------------------- +# Redis Agent Memory credentials (REQUIRED) +# ----------------------------------------------------------------------------- +# Same variable names used by tests/integration/test_memory_backends_end_to_end.py +REDIS_AGENT_MEMORY_API_BASE_URL=https://your-api-base-url +REDIS_AGENT_MEMORY_API_KEY=your-api-key +REDIS_AGENT_MEMORY_STORE_ID=your-store-id + +# ----------------------------------------------------------------------------- +# Namespace (OPTIONAL) +# ----------------------------------------------------------------------------- +# Underscores are auto-converted to hyphens for the managed API. +REDIS_MEMORY_NAMESPACE=managed-memory-quickstart + +# ----------------------------------------------------------------------------- +# Search tuning (OPTIONAL) +# ----------------------------------------------------------------------------- +REDIS_MEMORY_SEARCH_TOP_K=5 diff --git a/examples/managed_memory_quickstart/README.md b/examples/managed_memory_quickstart/README.md new file mode 100644 index 0000000..72e94c5 --- /dev/null +++ b/examples/managed_memory_quickstart/README.md @@ -0,0 +1,108 @@ +# Managed Redis Agent Memory Quickstart + +Minimal counterpart to [`simple_redis_memory`](../simple_redis_memory/). This +example uses the **managed** `redis-agent-memory` backend (the library default) +with no self-hosted Agent Memory Server or Docker setup. + +## What it demonstrates + +- `RedisWorkingMemorySessionService` for session persistence +- `RedisLongTermMemoryService` for semantic memory search +- ADK built-in `preload_memory` and `load_memory` tools + +This example intentionally avoids opensource-only features such as +auto-summarization, extraction strategies, recency-boosted search, and MCP. + +## Prerequisites + +- Python 3.10+ +- A Redis Agent Memory store (`REDIS_AGENT_MEMORY_STORE_ID` + API key) +- Google API key for Gemini + +## Setup + +### 1. Install dependencies + +From the repository root (editable install for local PR testing): + +```bash +uv venv +uv pip install -e ".[all,examples]" +``` + +Or use `make dev` if you also want test/lint tooling. + +> **Note:** Even memory-only examples import `adk_redis`, whose top-level +> `__init__.py` currently pulls in search tools that require `redisvl`. The +> `[all]` extra installs that dependency. `[memory]` alone is not enough today. + +### 2. Configure environment + +```bash +cd examples/managed_memory_quickstart +cp .env.example .env +# Edit .env with your credentials +``` + +### 3. Start the agent + +```bash +uv run python main.py +``` + +Open http://localhost:8080 in your browser. + +## Try it + +**Session 1** — share a few facts: + +``` +Hi! My name is Alex. +My favorite color is teal. +I have a cat named Sammy. +``` + +Wait for a reply after each message. + +**Session 2** — click **New Session** (keep the same `userId`, e.g. `user`) and +ask: + +``` +What do you remember about me? +What is my favorite color? +``` + +Cross-session recall uses long-term memory (`preload_memory` / `load_memory`). +Facts are stored automatically after each turn via the agent's `after_agent` +callback — there is no separate seed step. + +## Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `GOOGLE_API_KEY` | Yes | — | Gemini API key | +| `REDIS_AGENT_MEMORY_API_BASE_URL` | Yes | — | Managed API base URL | +| `REDIS_AGENT_MEMORY_API_KEY` | Yes | — | Managed API key | +| `REDIS_AGENT_MEMORY_STORE_ID` | Yes | — | Memory store ID | +| `REDIS_MEMORY_NAMESPACE` | No | `managed_memory_quickstart` | Namespace for isolation | +| `REDIS_MEMORY_SEARCH_TOP_K` | No | `5` | Results returned by `search_memory` | +| `PORT` | No | `8080` | ADK web server port | + +## How to run + +This example uses a custom `main.py` with `get_fast_api_app` because ADK +session and memory services must be registered through +`get_service_registry()`. The `adk web` runner cannot register those services. + +For a tools-only example that runs with `adk web`, see +[`travel_agent_memory_tools`](../travel_agent_memory_tools/). + +## Related examples + +| Example | Backend | Runner | +|---------|---------|--------| +| **This quickstart** | Managed (`redis-agent-memory`) | `python main.py` | +| [`simple_redis_memory`](../simple_redis_memory/) | Self-hosted (default) | `python main.py` | +| [`travel_agent_memory_tools`](../travel_agent_memory_tools/) | Either (env switch) | `adk web .` | + +See [`examples/README.md`](../README.md) for the full matrix. diff --git a/examples/managed_memory_quickstart/main.py b/examples/managed_memory_quickstart/main.py new file mode 100644 index 0000000..7f07ede --- /dev/null +++ b/examples/managed_memory_quickstart/main.py @@ -0,0 +1,123 @@ +# Copyright 2025 Redis, Inc. +# +# 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. + +"""Managed Redis Agent Memory quickstart. + +Registers RedisWorkingMemorySessionService and RedisLongTermMemoryService +against the managed redis-agent-memory backend, then serves the agent via ADK's +FastAPI runner. +""" + +import os +from urllib.parse import urlparse + +from dotenv import load_dotenv +from fastapi import FastAPI +from google.adk.cli.fast_api import get_fast_api_app +from google.adk.cli.service_registry import get_service_registry +import uvicorn + +from adk_redis.memory import RedisLongTermMemoryService +from adk_redis.memory import RedisLongTermMemoryServiceConfig +from adk_redis.sessions import RedisWorkingMemorySessionService +from adk_redis.sessions import RedisWorkingMemorySessionServiceConfig + +load_dotenv() + +_BACKEND = "redis-agent-memory" + + +def _api_base_url() -> str: + return os.environ["REDIS_AGENT_MEMORY_API_BASE_URL"] + + +def parse_base_url(uri: str) -> str: + """Parse a service URI to extract the 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 for RedisWorkingMemorySessionService.""" + base_url = parse_base_url(uri) + config = RedisWorkingMemorySessionServiceConfig( + backend=_BACKEND, + api_base_url=base_url, + api_key=os.environ["REDIS_AGENT_MEMORY_API_KEY"], + store_id=os.environ["REDIS_AGENT_MEMORY_STORE_ID"], + default_namespace=os.getenv( + "REDIS_MEMORY_NAMESPACE", "managed_memory_quickstart" + ), + ) + return RedisWorkingMemorySessionService(config=config) + + +def redis_memory_factory(uri: str, **kwargs): + """Factory for RedisLongTermMemoryService.""" + base_url = parse_base_url(uri) + config = RedisLongTermMemoryServiceConfig( + backend=_BACKEND, + api_base_url=base_url, + api_key=os.environ["REDIS_AGENT_MEMORY_API_KEY"], + store_id=os.environ["REDIS_AGENT_MEMORY_STORE_ID"], + default_namespace=os.getenv( + "REDIS_MEMORY_NAMESPACE", "managed_memory_quickstart" + ), + search_top_k=int(os.getenv("REDIS_MEMORY_SEARCH_TOP_K", "5")), + ) + return RedisLongTermMemoryService(config=config) + + +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) + +api_host = _api_base_url().replace("http://", "").replace("https://", "") +SESSION_SERVICE_URI = f"redis-working-memory://{api_host}" +MEMORY_SERVICE_URI = f"redis-long-term-memory://{api_host}" + +app: FastAPI = get_fast_api_app( + agents_dir=".", + session_service_uri=SESSION_SERVICE_URI, + memory_service_uri=MEMORY_SERVICE_URI, + web=True, + auto_create_session=True, +) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 8080)) + namespace = os.getenv("REDIS_MEMORY_NAMESPACE", "managed_memory_quickstart") + + print( + f""" +Starting Managed Redis Agent Memory Quickstart (adk-redis) +========================================================== +ADK Server: http://localhost:{port} +Memory Backend: {_BACKEND} +API Base URL: {_api_base_url()} +Store ID: {os.environ["REDIS_AGENT_MEMORY_STORE_ID"]} +Namespace: {namespace} + +Services: + - Session: RedisWorkingMemorySessionService + - Memory: RedisLongTermMemoryService +""" + ) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/examples/managed_memory_quickstart/managed_memory_agent/__init__.py b/examples/managed_memory_quickstart/managed_memory_agent/__init__.py new file mode 100644 index 0000000..0a24b26 --- /dev/null +++ b/examples/managed_memory_quickstart/managed_memory_agent/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Redis, Inc. +# +# 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 as agent diff --git a/examples/managed_memory_quickstart/managed_memory_agent/agent.py b/examples/managed_memory_quickstart/managed_memory_agent/agent.py new file mode 100644 index 0000000..a71dbbd --- /dev/null +++ b/examples/managed_memory_quickstart/managed_memory_agent/agent.py @@ -0,0 +1,60 @@ +# Copyright 2025 Redis, Inc. +# +# 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. + +"""Minimal agent using managed Redis Agent Memory services.""" + +from datetime import datetime + +from google.adk import Agent +from google.adk.agents.callback_context import CallbackContext +from google.adk.tools import load_memory +from google.adk.tools import preload_memory + + +def before_agent(callback_context: CallbackContext): + """Update state before the agent runs.""" + callback_context.state["_time"] = datetime.now().isoformat() + + +async def after_agent(callback_context: CallbackContext): + """Persist session events to long-term memory after each turn.""" + await callback_context.add_session_to_memory() + + +root_agent = Agent( + model="gemini-2.5-flash", + name="managed_memory_agent", + description=( + "Minimal agent with managed Redis Agent Memory session and" + " long-term memory services." + ), + before_agent_callback=before_agent, + after_agent_callback=after_agent, + instruction="""You are a helpful assistant with Redis Agent Memory. + +## Memory + +1. **Working memory**: The current conversation is stored in your session. +2. **Long-term memory**: Use `load_memory` to search facts from past sessions. + `preload_memory` may surface relevant memories automatically. + +## Guidelines + +- Be conversational and remember details the user shares. +- When users share personal info, acknowledge it clearly. +- If a memory search returns nothing, say so and ask a clarifying question. + +Current time: {_time}""", + tools=[preload_memory, load_memory], +) diff --git a/examples/simple_redis_memory/README.md b/examples/simple_redis_memory/README.md index 2280ef2..46d3af8 100644 --- a/examples/simple_redis_memory/README.md +++ b/examples/simple_redis_memory/README.md @@ -109,11 +109,15 @@ Create `.env` in this directory: ```bash GOOGLE_API_KEY=your-google-api-key +REDIS_MEMORY_BACKEND=opensource-agent-memory REDIS_MEMORY_SERVER_URL=http://localhost:8088 REDIS_MEMORY_NAMESPACE=adk_agent_memory REDIS_MEMORY_EXTRACTION_STRATEGY=discrete REDIS_MEMORY_CONTEXT_WINDOW=8000 REDIS_MEMORY_RECENCY_BOOST=true +# Required only when REDIS_MEMORY_BACKEND=redis-agent-memory +AGENT_MEMORY_STORE_ID= +AGENT_MEMORY_API_KEY= ``` ## Usage @@ -171,7 +175,10 @@ User: What's my favorite coffee? | Variable | Default | Description | |----------|---------|-------------| +| `REDIS_MEMORY_BACKEND` | `opensource-agent-memory` | `opensource-agent-memory` or `redis-agent-memory` | | `REDIS_MEMORY_SERVER_URL` | `http://localhost:8088` | Memory server URL | +| `AGENT_MEMORY_STORE_ID` | empty | Redis Agent Memory store ID, used only for `redis-agent-memory` | +| `AGENT_MEMORY_API_KEY` | empty | Redis Agent Memory API key, used only for `redis-agent-memory` | | `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 | diff --git a/examples/simple_redis_memory/main.py b/examples/simple_redis_memory/main.py index 5866629..93a9d66 100644 --- a/examples/simple_redis_memory/main.py +++ b/examples/simple_redis_memory/main.py @@ -55,7 +55,10 @@ def redis_session_factory(uri: str, **kwargs): """Factory function for creating RedisWorkingMemorySessionService from URI.""" base_url = parse_base_url(uri) config = RedisWorkingMemorySessionServiceConfig( + backend=os.getenv("REDIS_MEMORY_BACKEND", "opensource-agent-memory"), api_base_url=base_url, + api_key=os.getenv("AGENT_MEMORY_API_KEY"), + store_id=os.getenv("AGENT_MEMORY_STORE_ID"), 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")), @@ -70,7 +73,10 @@ def redis_memory_factory(uri: str, **kwargs): """Factory function for creating RedisLongTermMemoryService from URI.""" base_url = parse_base_url(uri) config = RedisLongTermMemoryServiceConfig( + backend=os.getenv("REDIS_MEMORY_BACKEND", "opensource-agent-memory"), api_base_url=base_url, + api_key=os.getenv("AGENT_MEMORY_API_KEY"), + store_id=os.getenv("AGENT_MEMORY_STORE_ID"), default_namespace=os.getenv("REDIS_MEMORY_NAMESPACE", "adk_agent_memory"), extraction_strategy=os.getenv( "REDIS_MEMORY_EXTRACTION_STRATEGY", "discrete" @@ -110,6 +116,7 @@ def redis_memory_factory(uri: str, **kwargs): 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") + backend = os.getenv("REDIS_MEMORY_BACKEND", "opensource-agent-memory") extraction = os.getenv("REDIS_MEMORY_EXTRACTION_STRATEGY", "discrete") context_window = os.getenv("REDIS_MEMORY_CONTEXT_WINDOW", "8000") @@ -119,6 +126,7 @@ def redis_memory_factory(uri: str, **kwargs): ========================================= ADK Server: http://localhost:{port} Memory Server: {server} +Memory Backend: {backend} Namespace: {namespace} Extraction Strategy: {extraction} Context Window: {context_window} tokens diff --git a/examples/travel_agent_memory_hybrid/.env.example b/examples/travel_agent_memory_hybrid/.env.example index af71651..c54cab5 100644 --- a/examples/travel_agent_memory_hybrid/.env.example +++ b/examples/travel_agent_memory_hybrid/.env.example @@ -19,15 +19,29 @@ GOOGLE_API_KEY=your-google-api-key-here TAVILY_API_KEY=your-tavily-api-key-here # ----------------------------------------------------------------------------- -# Agent Memory Server URL (REQUIRED) +# Memory Backend (OPTIONAL) # ----------------------------------------------------------------------------- -# URL of the Redis Agent Memory Server +# Use opensource-agent-memory for the self-hosted Agent Memory Server. +# Use redis-agent-memory for Redis Agent Memory. +REDIS_MEMORY_BACKEND=opensource-agent-memory + +# ----------------------------------------------------------------------------- +# Memory Backend URL (REQUIRED) +# ----------------------------------------------------------------------------- +# URL of the selected memory backend # Default: http://localhost:8088 # # To start Agent Memory Server: # docker run -p 8088:8088 -p 6379:6379 redis/agent-memory-server:latest MEMORY_SERVER_URL=http://localhost:8088 +# ----------------------------------------------------------------------------- +# Redis Agent Memory Credentials (OPTIONAL) +# ----------------------------------------------------------------------------- +# Required only when REDIS_MEMORY_BACKEND=redis-agent-memory +AGENT_MEMORY_STORE_ID= +AGENT_MEMORY_API_KEY= + # ----------------------------------------------------------------------------- # Redis URL (REQUIRED) # ----------------------------------------------------------------------------- @@ -44,4 +58,3 @@ REDIS_URL=redis://localhost:6379 # Memory namespace for isolating different agent instances # Default: travel_agent NAMESPACE=travel_agent - diff --git a/examples/travel_agent_memory_hybrid/README.md b/examples/travel_agent_memory_hybrid/README.md index 9e0f306..ebbf92d 100644 --- a/examples/travel_agent_memory_hybrid/README.md +++ b/examples/travel_agent_memory_hybrid/README.md @@ -104,8 +104,12 @@ cd examples/travel_agent_memory_hybrid cat > .env << EOF GOOGLE_API_KEY=your-google-api-key TAVILY_API_KEY=your-tavily-api-key +REDIS_MEMORY_BACKEND=opensource-agent-memory MEMORY_SERVER_URL=http://localhost:8088 NAMESPACE=travel_agent_hybrid +# Required only when REDIS_MEMORY_BACKEND=redis-agent-memory +AGENT_MEMORY_STORE_ID= +AGENT_MEMORY_API_KEY= EOF ``` @@ -578,4 +582,3 @@ uv run adk web . Copyright 2025 Google LLC and Redis, Inc. Licensed under the Apache License, Version 2.0. See [LICENSE](../../LICENSE) for details. - diff --git a/examples/travel_agent_memory_hybrid/main.py b/examples/travel_agent_memory_hybrid/main.py index 44d7f5c..f190aa8 100644 --- a/examples/travel_agent_memory_hybrid/main.py +++ b/examples/travel_agent_memory_hybrid/main.py @@ -56,7 +56,10 @@ def redis_session_factory(uri: str, **kwargs): """Factory function for creating RedisWorkingMemorySessionService from URI.""" base_url = parse_base_url(uri) config = RedisWorkingMemorySessionServiceConfig( + backend=os.getenv("REDIS_MEMORY_BACKEND", "opensource-agent-memory"), api_base_url=base_url, + api_key=os.getenv("AGENT_MEMORY_API_KEY"), + store_id=os.getenv("AGENT_MEMORY_STORE_ID"), default_namespace=os.getenv("NAMESPACE", "travel_agent_memory_hybrid"), model_name=os.getenv("REDIS_MEMORY_MODEL_NAME", "gpt-4o"), context_window_max=int(os.getenv("REDIS_MEMORY_CONTEXT_WINDOW", "8000")), @@ -71,7 +74,10 @@ def redis_memory_factory(uri: str, **kwargs): """Factory function for creating RedisLongTermMemoryService from URI.""" base_url = parse_base_url(uri) config = RedisLongTermMemoryServiceConfig( + backend=os.getenv("REDIS_MEMORY_BACKEND", "opensource-agent-memory"), api_base_url=base_url, + api_key=os.getenv("AGENT_MEMORY_API_KEY"), + store_id=os.getenv("AGENT_MEMORY_STORE_ID"), default_namespace=os.getenv("NAMESPACE", "travel_agent_memory_hybrid"), extraction_strategy=os.getenv( "REDIS_MEMORY_EXTRACTION_STRATEGY", "discrete" @@ -111,6 +117,7 @@ def redis_memory_factory(uri: str, **kwargs): port = int(os.environ.get("PORT", 8080)) namespace = os.getenv("NAMESPACE", "travel_agent_memory_hybrid") server = os.getenv("MEMORY_SERVER_URL", "http://localhost:8088") + backend = os.getenv("REDIS_MEMORY_BACKEND", "opensource-agent-memory") extraction = os.getenv("REDIS_MEMORY_EXTRACTION_STRATEGY", "discrete") context_window = os.getenv("REDIS_MEMORY_CONTEXT_WINDOW", "8000") @@ -120,6 +127,7 @@ def redis_memory_factory(uri: str, **kwargs): =============================== ADK Server: http://localhost:{port} Memory Server: {server} +Memory Backend: {backend} Namespace: {namespace} Extraction Strategy: {extraction} Context Window: {context_window} tokens diff --git a/examples/travel_agent_memory_hybrid/travel_agent/agent.py b/examples/travel_agent_memory_hybrid/travel_agent/agent.py index 637c86c..6db51dc 100644 --- a/examples/travel_agent_memory_hybrid/travel_agent/agent.py +++ b/examples/travel_agent_memory_hybrid/travel_agent/agent.py @@ -45,6 +45,7 @@ # Configuration from environment MEMORY_SERVER_URL = os.getenv("MEMORY_SERVER_URL", "http://localhost:8088") +MEMORY_BACKEND = os.getenv("REDIS_MEMORY_BACKEND", "opensource-agent-memory") NAMESPACE = os.getenv("NAMESPACE", "travel_agent_memory_hybrid") @@ -59,7 +60,10 @@ async def after_agent(callback_context: CallbackContext): # Configure memory tools memory_config = MemoryToolConfig( + backend=MEMORY_BACKEND, api_base_url=MEMORY_SERVER_URL, + api_key=os.getenv("AGENT_MEMORY_API_KEY"), + store_id=os.getenv("AGENT_MEMORY_STORE_ID"), default_namespace=NAMESPACE, recency_boost=True, search_top_k=10, diff --git a/examples/travel_agent_memory_tools/.env.example b/examples/travel_agent_memory_tools/.env.example index af71651..c54cab5 100644 --- a/examples/travel_agent_memory_tools/.env.example +++ b/examples/travel_agent_memory_tools/.env.example @@ -19,15 +19,29 @@ GOOGLE_API_KEY=your-google-api-key-here TAVILY_API_KEY=your-tavily-api-key-here # ----------------------------------------------------------------------------- -# Agent Memory Server URL (REQUIRED) +# Memory Backend (OPTIONAL) # ----------------------------------------------------------------------------- -# URL of the Redis Agent Memory Server +# Use opensource-agent-memory for the self-hosted Agent Memory Server. +# Use redis-agent-memory for Redis Agent Memory. +REDIS_MEMORY_BACKEND=opensource-agent-memory + +# ----------------------------------------------------------------------------- +# Memory Backend URL (REQUIRED) +# ----------------------------------------------------------------------------- +# URL of the selected memory backend # Default: http://localhost:8088 # # To start Agent Memory Server: # docker run -p 8088:8088 -p 6379:6379 redis/agent-memory-server:latest MEMORY_SERVER_URL=http://localhost:8088 +# ----------------------------------------------------------------------------- +# Redis Agent Memory Credentials (OPTIONAL) +# ----------------------------------------------------------------------------- +# Required only when REDIS_MEMORY_BACKEND=redis-agent-memory +AGENT_MEMORY_STORE_ID= +AGENT_MEMORY_API_KEY= + # ----------------------------------------------------------------------------- # Redis URL (REQUIRED) # ----------------------------------------------------------------------------- @@ -44,4 +58,3 @@ REDIS_URL=redis://localhost:6379 # Memory namespace for isolating different agent instances # Default: travel_agent NAMESPACE=travel_agent - diff --git a/examples/travel_agent_memory_tools/README.md b/examples/travel_agent_memory_tools/README.md index 5d5db60..3f4d6b8 100644 --- a/examples/travel_agent_memory_tools/README.md +++ b/examples/travel_agent_memory_tools/README.md @@ -75,7 +75,8 @@ docker run -d --name agent-memory-server -p 8088:8088 \ ```bash cd examples/travel_agent_memory_tools cp .env.example .env -# Edit .env and add your GOOGLE_API_KEY and TAVILY_API_KEY +# Edit .env and add your GOOGLE_API_KEY and TAVILY_API_KEY. +# REDIS_MEMORY_BACKEND defaults to opensource-agent-memory. ``` ### (Optional) Seed Demo User Profiles @@ -537,4 +538,3 @@ uv run adk web . Copyright 2025 Google LLC and Redis, Inc. Licensed under the Apache License, Version 2.0. See [LICENSE](../../LICENSE) for details. - diff --git a/examples/travel_agent_memory_tools/travel_agent/agent.py b/examples/travel_agent_memory_tools/travel_agent/agent.py index e02171a..e80d359 100644 --- a/examples/travel_agent_memory_tools/travel_agent/agent.py +++ b/examples/travel_agent_memory_tools/travel_agent/agent.py @@ -40,6 +40,7 @@ # Configuration from environment MEMORY_SERVER_URL = os.getenv("MEMORY_SERVER_URL", "http://localhost:8088") +MEMORY_BACKEND = os.getenv("REDIS_MEMORY_BACKEND", "opensource-agent-memory") NAMESPACE = os.getenv("NAMESPACE", "travel_agent_memory_tools") @@ -54,7 +55,10 @@ async def after_agent(callback_context: CallbackContext): # Configure memory tools memory_config = MemoryToolConfig( + backend=MEMORY_BACKEND, api_base_url=MEMORY_SERVER_URL, + api_key=os.getenv("AGENT_MEMORY_API_KEY"), + store_id=os.getenv("AGENT_MEMORY_STORE_ID"), default_namespace=NAMESPACE, recency_boost=True, search_top_k=10, diff --git a/pyproject.toml b/pyproject.toml index f72de45..40eded4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "adk-redis" -version = "0.0.6" +version = "0.0.7" description = "Redis integrations for Google's Agent Development Kit (ADK)" readme = "README.md" license = "Apache-2.0" @@ -40,9 +40,10 @@ dependencies = [ ] [project.optional-dependencies] -# Redis Agent Memory Server integration (long-term memory + working memory sessions) +# Redis Agent Memory integrations (long-term memory + working memory sessions) memory = [ "agent-memory-client>=0.14.0", + "redis-agent-memory>=0.0.4", ] # RedisVL-based search tools diff --git a/src/adk_redis/__init__.py b/src/adk_redis/__init__.py index cd1bc56..b29c31d 100644 --- a/src/adk_redis/__init__.py +++ b/src/adk_redis/__init__.py @@ -54,13 +54,19 @@ # Configure memory service memory_config = RedisLongTermMemoryServiceConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8000", + api_key="...", + store_id="...", ) memory_service = RedisLongTermMemoryService(config=memory_config) # Configure session service session_config = RedisWorkingMemorySessionServiceConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8000", + api_key="...", + store_id="...", ) session_service = RedisWorkingMemorySessionService(config=session_config) ``` diff --git a/src/adk_redis/memory/_backends.py b/src/adk_redis/memory/_backends.py new file mode 100644 index 0000000..158cba2 --- /dev/null +++ b/src/adk_redis/memory/_backends.py @@ -0,0 +1,27 @@ +# Copyright 2025 Redis, Inc. +# +# 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. + +"""Memory backend names.""" + +from __future__ import annotations + +from typing import Literal + +REDIS_AGENT_MEMORY_BACKEND = "redis-agent-memory" +OPENSOURCE_AGENT_MEMORY_BACKEND = "opensource-agent-memory" + +MemoryBackendName = Literal[ + "redis-agent-memory", + "opensource-agent-memory", +] diff --git a/src/adk_redis/memory/_utils.py b/src/adk_redis/memory/_utils.py index 48da723..48f55a1 100644 --- a/src/adk_redis/memory/_utils.py +++ b/src/adk_redis/memory/_utils.py @@ -16,11 +16,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from datetime import datetime +from datetime import timezone +import hashlib +import re +from typing import Any, TYPE_CHECKING + +from google.genai import types if TYPE_CHECKING: from google.adk.events.event import Event +_MANAGED_IDENTIFIER_RE = re.compile(r"[^A-Za-z0-9-]+") + def extract_text_from_event(event: "Event") -> str: """Extracts text content from an event's content parts. @@ -45,3 +53,99 @@ def extract_text_from_event(event: "Event") -> str: if part.text and not part.thought ] return " ".join(text_parts) + + +def extract_text_from_content(content: types.Content | None) -> str: + """Extract text content from a GenAI content object. + + Args: + content: Content object to read. + + Returns: + Combined non-empty text parts. + """ + if not content or not content.parts: + return "" + + text_parts = [ + part.text.strip() + for part in content.parts + if part.text and part.text.strip() + ] + return "\n".join(text_parts) + + +def timestamp_to_datetime(timestamp: float) -> datetime: + """Convert a Unix timestamp to a UTC datetime. + + Args: + timestamp: Unix timestamp in seconds. + + Returns: + Timezone-aware UTC datetime. + """ + return datetime.fromtimestamp(timestamp, tz=timezone.utc) + + +def stable_memory_id(*parts: object) -> str: + """Build a deterministic memory ID from stable input parts. + + Args: + *parts: Values that uniquely identify the memory. + + Returns: + A deterministic Redis Agent Memory ID. + """ + digest = hashlib.sha256( + ":".join(str(part) for part in parts).encode("utf-8") + ).hexdigest() + return f"adk-{digest[:32]}" + + +def read_field(value: object, name: str, default: Any = None) -> Any: + """Read a field from either a dict or an object. + + Args: + value: Mapping or object to inspect. + name: Field or attribute name. + default: Value returned when the field is absent. + + Returns: + Field value or default. + """ + if isinstance(value, dict): + return value.get(name, default) + return getattr(value, name, default) + + +def sanitize_managed_identifier(value: str) -> str: + """Normalize a value for managed Redis Agent Memory identifiers. + + Managed APIs accept only alphanumeric characters and hyphens in fields + such as namespace, sessionId, and actorId. + + Args: + value: Raw identifier from ADK app names, namespaces, or authors. + + Returns: + Sanitized identifier with unsupported characters replaced by hyphens. + """ + sanitized = _MANAGED_IDENTIFIER_RE.sub("-", value.strip()).strip("-") + if not sanitized: + raise ValueError("Managed identifier must not be empty after sanitization") + return sanitized + + +def is_not_found_error(exc: Exception) -> bool: + """Return True when a Redis Agent Memory exception represents a 404. + + Args: + exc: Exception raised by the Redis Agent Memory SDK. + + Returns: + True when the SDK reported a missing resource. + """ + return ( + getattr(exc, "status_code", None) == 404 + or exc.__class__.__name__ == "NotFoundErrorResponseContent" + ) diff --git a/src/adk_redis/memory/long_term_memory.py b/src/adk_redis/memory/long_term_memory.py index 0a8d9aa..6e89c4f 100644 --- a/src/adk_redis/memory/long_term_memory.py +++ b/src/adk_redis/memory/long_term_memory.py @@ -12,66 +12,124 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""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. -""" +"""Redis long-term memory service for ADK.""" from __future__ import annotations +from collections.abc import Mapping +from collections.abc import Sequence +from contextlib import asynccontextmanager from functools import cached_property +from importlib import import_module import logging -from typing import Any, Literal, TYPE_CHECKING +from typing import Any, AsyncIterator, Literal +from google.adk.events.event import Event 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.adk.sessions.session import Session from google.genai import types from pydantic import BaseModel from pydantic import Field from typing_extensions import override +from adk_redis.memory._backends import MemoryBackendName +from adk_redis.memory._backends import OPENSOURCE_AGENT_MEMORY_BACKEND +from adk_redis.memory._backends import REDIS_AGENT_MEMORY_BACKEND +from adk_redis.memory._utils import extract_text_from_content from adk_redis.memory._utils import extract_text_from_event - -if TYPE_CHECKING: - from google.adk.sessions.session import Session +from adk_redis.memory._utils import read_field +from adk_redis.memory._utils import sanitize_managed_identifier +from adk_redis.memory._utils import stable_memory_id logger = logging.getLogger("adk_redis." + __name__) +try: + _agent_memory_client_module = import_module("agent_memory_client") + _agent_memory_models_module = import_module("agent_memory_client.models") +except ImportError: + _MemoryAPIClient: Any = None + _MemoryClientConfig: Any = None + _MemoryMessage: Any = None + _MemoryStrategyConfig: Any = None + _RecencyConfig: Any = None + _WorkingMemory: Any = None +else: + _MemoryAPIClient = _agent_memory_client_module.MemoryAPIClient + _MemoryClientConfig = _agent_memory_client_module.MemoryClientConfig + _MemoryMessage = _agent_memory_models_module.MemoryMessage + _MemoryStrategyConfig = _agent_memory_models_module.MemoryStrategyConfig + _RecencyConfig = _agent_memory_models_module.RecencyConfig + _WorkingMemory = _agent_memory_models_module.WorkingMemory + +try: + _redis_agent_memory_module = import_module("redis_agent_memory") +except ImportError: + _AgentMemory: Any = None +else: + _AgentMemory = _redis_agent_memory_module.AgentMemory + +MemoryTypeName = Literal["semantic", "episodic", "message"] + class RedisLongTermMemoryServiceConfig(BaseModel): - """Configuration for Redis Long-Term Memory Service. + """Configuration for Redis long-term memory. Attributes: - api_base_url: Base URL of the Agent Memory Server. - timeout: HTTP request timeout in seconds. + backend: Memory backend to use. + api_base_url: Memory API base URL. + api_key: Redis Agent Memory API key. + store_id: Redis Agent Memory store ID. + timeout: HTTP timeout in seconds. + timeout_ms: Optional SDK timeout in milliseconds. 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). + similarity_threshold: Minimum similarity threshold for search results. + distance_threshold: Backward-compatible alias for similarity_threshold. + store_events_as_messages: Store ADK events as long-term message memories + when add_session_to_memory or add_events_to_memory is called. + default_memory_type: Default memory type for explicit add_memory writes. + default_topics: Topics attached to created memory records. + source: Source label used when building deterministic IDs. + recency_boost: Backward-compatible option retained from the previous + Agent Memory Server client. + semantic_weight: Backward-compatible option retained from the previous + Agent Memory Server client. + recency_weight: Backward-compatible option retained from the previous + Agent Memory Server client. + freshness_weight: Backward-compatible option retained from the previous + Agent Memory Server client. + novelty_weight: Backward-compatible option retained from the previous + Agent Memory Server client. + half_life_last_access_days: Backward-compatible option retained from the + previous Agent Memory Server client. + half_life_created_days: Backward-compatible option retained from the + previous Agent Memory Server client. + extraction_strategy: Backward-compatible option retained from the + previous Agent Memory Server client. + extraction_strategy_config: Backward-compatible option retained from the + previous Agent Memory Server client. + model_name: Backward-compatible option retained from the previous Agent + Memory Server client. + context_window_max: Backward-compatible option retained from the + previous Agent Memory Server client. """ + backend: MemoryBackendName = "redis-agent-memory" api_base_url: str = Field(default="http://localhost:8000") + api_key: str | None = None + store_id: str | None = None timeout: float = Field(default=30.0, gt=0.0) + timeout_ms: int | None = Field(default=None, ge=1) default_namespace: str | None = None search_top_k: int = Field(default=10, ge=1) + similarity_threshold: float | None = Field(default=None, ge=0.0, le=1.0) distance_threshold: float | None = Field(default=None, ge=0.0, le=1.0) + store_events_as_messages: bool = True + default_memory_type: MemoryTypeName = "semantic" + default_topics: list[str] = Field(default_factory=list) + source: str = "adk-redis" 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) @@ -88,79 +146,208 @@ class RedisLongTermMemoryServiceConfig(BaseModel): 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) - - 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 - - https://github.com/redis/agent-memory-server - - Requires the `agent-memory-client` package to be installed. - - Example: - ```python - from adk_redis import ( - RedisLongTermMemoryService, - RedisLongTermMemoryServiceConfig, - ) + """Long-term memory service backed by Redis memory backends. - config = RedisLongTermMemoryServiceConfig( - api_base_url="http://localhost:8000", - default_namespace="my_app", - recency_boost=True, - ) - memory_service = RedisLongTermMemoryService(config=config) + The service implements ADK's memory interface using either Redis Agent + Memory or the self-hosted Agent Memory Server. Searches are scoped by user + ID and namespace for both backends. - # Use with ADK agent - agent = Agent( - name="my_agent", - memory_service=memory_service, - ) - ``` + Latest ADK versions add explicit `add_events_to_memory` and `add_memory` + hooks. This service implements them while staying compatible with older ADK + versions that only call `add_session_to_memory` and `search_memory`. """ def __init__(self, config: RedisLongTermMemoryServiceConfig | None = None): - """Initialize the Redis Long-Term Memory Service. + """Initialize the Redis long-term memory service. Args: - config: Configuration for the service. If None, uses defaults. - - Raises: - ImportError: If agent-memory-client package is not installed. + config: Configuration for the service. If None, defaults are used. """ self._config = config or RedisLongTermMemoryServiceConfig() + def _timeout_ms(self) -> int: + """Return the configured SDK timeout in milliseconds.""" + return self._config.timeout_ms or int(self._config.timeout * 1000) + + def _get_client(self) -> Any: + """Get a backend client for compatibility with existing tests.""" + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + return self._get_agent_memory_server_client() + return self._get_redis_agent_memory_client() + + def _get_redis_agent_memory_client(self) -> Any: + """Get a Redis Agent Memory SDK client.""" + if _AgentMemory is None: + raise ImportError( + "redis-agent-memory package is required for " + "RedisLongTermMemoryService. " + "Install it with: pip install adk-redis[memory]" + ) + + return _AgentMemory( + self._config.api_base_url, + api_key=self._config.api_key, + store_id=self._config.store_id, + timeout_ms=self._timeout_ms(), + ) + @cached_property - def _client(self) -> Any: - """Lazily initialize and return the MemoryAPIClient.""" - try: - from agent_memory_client import MemoryAPIClient - from agent_memory_client import MemoryClientConfig - except ImportError as e: + def _agent_memory_server_client(self) -> Any: + """Lazily initialize and return the self-hosted memory client.""" + if _MemoryAPIClient is None or _MemoryClientConfig is None: raise ImportError( - "agent-memory-client package is required for" - " RedisLongTermMemoryService. Install it with: pip install" - " adk-redis[memory]" - ) from e + "agent-memory-client package is required for the " + "opensource-agent-memory backend. Install it with: " + "pip install adk-redis[memory]" + ) - client_config = MemoryClientConfig( + 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) + return _MemoryAPIClient(client_config) + + def _get_agent_memory_server_client(self) -> Any: + """Get the self-hosted Agent Memory Server client.""" + return self._agent_memory_server_client + + @asynccontextmanager + async def _agent_memory(self) -> AsyncIterator[Any]: + """Yield a Redis Agent Memory client and close SDK resources.""" + client = self._get_client() + if hasattr(client, "__aenter__"): + async with client as agent_memory: + yield agent_memory + else: + yield client + + def _get_namespace( + self, + app_name: str, + custom_metadata: Mapping[str, object] | None = None, + ) -> str: + """Get namespace from metadata, config, or app_name.""" + if custom_metadata: + namespace = custom_metadata.get("namespace") + if isinstance(namespace, str) and namespace: + resolved = namespace + else: + resolved = self._config.default_namespace or app_name + else: + resolved = self._config.default_namespace or app_name + + if self._config.backend == REDIS_AGENT_MEMORY_BACKEND: + return sanitize_managed_identifier(resolved) + return resolved + + def _search_threshold(self) -> float | None: + """Return the configured search threshold.""" + return ( + self._config.similarity_threshold + if self._config.similarity_threshold is not None + else self._config.distance_threshold + ) + + def _coerce_topics( + self, + *, + app_name: str, + custom_metadata: Mapping[str, object] | None = None, + memory: MemoryEntry | None = None, + ) -> list[str] | None: + """Build topics for a memory record.""" + topics: list[str] = list(self._config.default_topics) + metadata_values = [] + if custom_metadata: + metadata_values.append(custom_metadata.get("topics")) + if memory and memory.custom_metadata: + metadata_values.append(memory.custom_metadata.get("topics")) + + for value in metadata_values: + if isinstance(value, str): + topics.append(value) + elif isinstance(value, Sequence) and not isinstance(value, str): + topics.extend(str(item) for item in value if item) + + if app_name not in topics: + topics.append(app_name) + return topics or None + + def _coerce_memory_type( + self, + value: object | None = None, + *, + default: MemoryTypeName | None = None, + ) -> MemoryTypeName: + """Normalize memory type aliases.""" + raw = str(value or default or self._config.default_memory_type).lower() + memory_type_map = { + "preference": "semantic", + "fact": "semantic", + "event": "episodic", + "experience": "episodic", + "conversation": "message", + } + memory_type = memory_type_map.get(raw, raw) + if memory_type not in ("semantic", "episodic", "message"): + return self._config.default_memory_type + return memory_type # type: ignore[return-value] + + def _memory_type_from_metadata( + self, + custom_metadata: Mapping[str, object] | None, + memory: MemoryEntry | None = None, + *, + default: MemoryTypeName | None = None, + ) -> MemoryTypeName: + """Read memory type from metadata.""" + candidates = [] + if memory and memory.custom_metadata: + candidates.append(memory.custom_metadata.get("memory_type")) + if custom_metadata: + candidates.append(custom_metadata.get("memory_type")) + + for candidate in candidates: + if candidate: + return self._coerce_memory_type(candidate, default=default) + return self._coerce_memory_type(default=default) + + def _build_recency_config(self) -> Any: + """Build RecencyConfig for the self-hosted memory backend.""" + if _RecencyConfig is None: + raise ImportError( + "agent-memory-client package is required for recency search. " + "Install it with: pip install adk-redis[memory]" + ) + + 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, + ) - def _build_working_memory(self, session: "Session") -> Any: - """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 + def _build_agent_memory_server_working_memory( + self, + session: Session, + custom_metadata: Mapping[str, object] | None = None, + ) -> Any: + """Convert an ADK session to self-hosted WorkingMemory.""" + if ( + _MemoryMessage is None + or _MemoryStrategyConfig is None + or _WorkingMemory is None + ): + raise ImportError( + "agent-memory-client package is required for working memory. " + "Install it with: pip install adk-redis[memory]" + ) messages = [] for event in session.events: @@ -168,115 +355,454 @@ def _build_working_memory(self, session: "Session") -> Any: if not text: continue role = "user" if event.author == "user" else "assistant" - messages.append(MemoryMessage(role=role, content=text)) + messages.append(_MemoryMessage(role=role, content=text)) - strategy_config = MemoryStrategyConfig( + strategy_config = _MemoryStrategyConfig( strategy=self._config.extraction_strategy, config=self._config.extraction_strategy_config, ) - return WorkingMemory( + return _WorkingMemory( session_id=session.id, - namespace=self._config.default_namespace or session.app_name, + namespace=self._get_namespace(session.app_name, custom_metadata), user_id=session.user_id, messages=messages, long_term_memory_strategy=strategy_config, ) - @override - async def add_session_to_memory(self, session: "Session") -> None: - """Add a session's events to the Agent Memory Server. + async def _add_session_to_agent_memory_server( + self, + session: Session, + custom_metadata: Mapping[str, object] | None = None, + ) -> None: + """Store a session in the self-hosted Agent Memory Server.""" + working_memory = self._build_agent_memory_server_working_memory( + session, + custom_metadata, + ) + if not working_memory.messages: + logger.debug("No messages to store for session %s", session.id) + return + + response = await self._get_agent_memory_server_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, + getattr(response, "context_percentage_total_used", None) or 0, + ) + + async def _add_memory_to_agent_memory_server( + self, + *, + app_name: str, + user_id: str, + memories: Sequence[MemoryEntry], + custom_metadata: Mapping[str, object] | None, + ) -> None: + """Store explicit memories through the self-hosted memory API.""" + namespace = self._get_namespace(app_name, custom_metadata) + client = self._get_agent_memory_server_client() + + for index, memory in enumerate(memories): + text = extract_text_from_content(memory.content).strip() + if not text: + raise ValueError(f"memories[{index}] must include non-empty text") + + merged_metadata: dict[str, object] = {} + if custom_metadata: + merged_metadata.update(custom_metadata) + if memory.custom_metadata: + merged_metadata.update(memory.custom_metadata) + + session_id = str( + merged_metadata.get("session_id") + or stable_memory_id( + self._config.source, + "explicit", + namespace, + user_id, + text, + ) + ) + await client.add_memory_tool( + session_id=session_id, + text=text, + memory_type=self._memory_type_from_metadata( + custom_metadata, + memory, + ), + topics=self._coerce_topics( + app_name=app_name, + custom_metadata=custom_metadata, + memory=memory, + ), + namespace=namespace, + user_id=user_id, + ) + + def _event_record_text(self, event: Event) -> str: + """Build long-term message memory text for an ADK event.""" + text = extract_text_from_event(event).strip() + if not text: + return "" + author = event.author or "agent" + return f"{author}: {text}" + + def _build_event_records( + self, + *, + app_name: str, + user_id: str, + events: Sequence[Event], + session_id: str | None, + custom_metadata: Mapping[str, object] | None, + ) -> list[dict[str, Any]]: + """Convert ADK events into Redis Agent Memory records.""" + namespace = self._get_namespace(app_name, custom_metadata) + memory_type = self._memory_type_from_metadata( + custom_metadata, + default="message", + ) + records = [] + + for event in events: + text = self._event_record_text(event) + if not text: + continue + event_id = event.id or event.timestamp + records.append( + { + "id": stable_memory_id( + self._config.source, + "event", + namespace, + user_id, + session_id or "", + event_id, + text, + ), + "text": text, + "ownerId": user_id, + "namespace": namespace, + "sessionId": session_id, + "topics": self._coerce_topics( + app_name=app_name, + custom_metadata=custom_metadata, + ), + "memoryType": memory_type, + } + ) + + return records + + def _build_memory_records( + self, + *, + app_name: str, + user_id: str, + memories: Sequence[MemoryEntry], + custom_metadata: Mapping[str, object] | None, + ) -> list[dict[str, Any]]: + """Convert ADK MemoryEntry objects into Redis Agent Memory records.""" + namespace = self._get_namespace(app_name, custom_metadata) + records = [] + + for index, memory in enumerate(memories): + text = extract_text_from_content(memory.content).strip() + if not text: + raise ValueError(f"memories[{index}] must include non-empty text") + + merged_metadata: dict[str, object] = {} + if custom_metadata: + merged_metadata.update(custom_metadata) + if memory.custom_metadata: + merged_metadata.update(memory.custom_metadata) + + session_id = merged_metadata.get("session_id") + records.append( + { + "id": memory.id + or stable_memory_id( + self._config.source, + "memory", + namespace, + user_id, + text, + ), + "text": text, + "ownerId": user_id, + "namespace": namespace, + "sessionId": str(session_id) if session_id else None, + "topics": self._coerce_topics( + app_name=app_name, + custom_metadata=custom_metadata, + memory=memory, + ), + "memoryType": self._memory_type_from_metadata( + custom_metadata, + memory, + ), + } + ) - 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 + return records + + async def _bulk_create(self, records: list[dict[str, Any]]) -> None: + """Create long-term memory records in Redis Agent Memory.""" + if not records: + return + async with self._agent_memory() as agent_memory: + response = await agent_memory.bulk_create_long_term_memories_async( + memories=records + ) + errors = read_field(response, "errors") + if errors: + logger.warning("Redis Agent Memory reported write errors: %s", errors) + + @override + async def add_session_to_memory(self, session: Session) -> None: + """Add an ADK session to the configured long-term memory backend. Args: session: The ADK Session containing events to store. """ - try: - working_memory = self._build_working_memory(session) + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + try: + await self._add_session_to_agent_memory_server(session) + except Exception as e: + logger.error( + "Failed to add session %s to memory: %s", + session.id, + e, + ) + return + + await self.add_events_to_memory( + app_name=session.app_name, + user_id=session.user_id, + events=session.events, + session_id=session.id, + ) + + async def add_events_to_memory( + self, + *, + app_name: str, + user_id: str, + events: Sequence[Event], + session_id: str | None = None, + custom_metadata: Mapping[str, object] | None = None, + ) -> None: + """Add ADK events to the configured memory backend.""" + if not self._config.store_events_as_messages: + logger.debug("Event to memory storage is disabled") + return - if not working_memory.messages: - logger.debug("No messages to store for session %s", session.id) + try: + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + session = Session( + id=session_id + or stable_memory_id( + self._config.source, + "events", + app_name, + user_id, + len(events), + ), + app_name=app_name, + user_id=user_id, + events=list(events), + ) + await self._add_session_to_agent_memory_server( + session, + custom_metadata, + ) return - response = await self._client.put_working_memory( - session_id=session.id, - memory=working_memory, - user_id=session.user_id, + records = self._build_event_records( + app_name=app_name, + user_id=user_id, + events=events, + session_id=session_id, + custom_metadata=custom_metadata, ) + await self._bulk_create(records) + logger.info("Stored %d event memory records", len(records)) + except Exception as e: + logger.error("Failed to add events to memory: %s", e) + + async def add_memory( + self, + *, + app_name: str, + user_id: str, + memories: Sequence[MemoryEntry], + custom_metadata: Mapping[str, object] | None = None, + ) -> None: + """Add explicit durable memories to the configured backend.""" + try: + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + await self._add_memory_to_agent_memory_server( + app_name=app_name, + user_id=user_id, + memories=memories, + custom_metadata=custom_metadata, + ) + logger.info("Stored %d explicit memory records", len(memories)) + return - logger.info( - "Stored %d messages for session %s (context: %.1f%% used)", - len(working_memory.messages), - session.id, - response.context_percentage_total_used or 0, + records = self._build_memory_records( + app_name=app_name, + user_id=user_id, + memories=memories, + custom_metadata=custom_metadata, ) - + await self._bulk_create(records) + logger.info("Stored %d explicit memory records", len(records)) except Exception as e: - logger.error( - "Failed to add session %s to memory: %s", - session.id, - e, - ) + logger.error("Failed to add explicit memories: %s", e) - def _build_recency_config(self) -> Any: - """Build RecencyConfig from service configuration.""" - from agent_memory_client.models import RecencyConfig + def _coerce_memories(self, response: object) -> list[object]: + """Coerce SDK search responses into a list of memory records.""" + if isinstance(response, dict): + return list(response.get("items", response.get("memories", [])) or []) - 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, + items = getattr(response, "items", None) + if items is not None: + return list(items) + + memories = getattr(response, "memories", None) + return list(memories or []) + + def _memory_type_value(self, memory_type: object) -> str | None: + """Return a JSON-friendly memory type.""" + if memory_type is None: + return None + return str(getattr(memory_type, "value", memory_type)) + + async def _search_agent_memory_server( + self, *, app_name: str, user_id: str, query: str + ) -> SearchMemoryResponse: + """Search memories through the self-hosted Agent Memory Server.""" + recency_config = ( + self._build_recency_config() if self._config.recency_boost else None + ) + namespace = self._get_namespace(app_name) + + results = ( + await self._get_agent_memory_server_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 getattr(results, "memories", []) or []: + text = str(getattr(record, "text", "") or "") + if not text: + continue + + created_at = getattr(record, "created_at", None) + timestamp = created_at.isoformat() if created_at else None + content = types.Content(parts=[types.Part(text=text)]) + memories.append( + MemoryEntry( + id=getattr(record, "id", None), + timestamp=timestamp, + content=content, + custom_metadata={ + "namespace": getattr(record, "namespace", namespace), + "user_id": getattr(record, "user_id", user_id), + "session_id": getattr(record, "session_id", None), + "topics": getattr(record, "topics", []) or [], + "memory_type": getattr(record, "memory_type", None), + }, + ) + ) + + logger.info( + "Found %d memories for query '%s' (namespace=%s, user=%s)", + len(memories), + query[:50], + namespace, + user_id, + ) + return SearchMemoryResponse(memories=memories) + @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. + """Search for memories using the configured memory backend. 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. + app_name: Application name, used as namespace if no default is set. + user_id: Owner ID used to isolate memories. + query: 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, - ) + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + return await self._search_agent_memory_server( + app_name=app_name, + user_id=user_id, + query=query, + ) + + namespace = self._get_namespace(app_name) + request: dict[str, Any] = { + "text": query, + "limit": self._config.search_top_k, + "filter": { + "ownerId": {"eq": user_id}, + "namespace": {"eq": namespace}, + }, + "filterOp": "all", + } + threshold = self._search_threshold() + if threshold is not None: + request["similarityThreshold"] = threshold + + async with self._agent_memory() as agent_memory: + results = await agent_memory.search_long_term_memory_async( + request=request + ) memories = [] - for record in results.memories: - content = types.Content(parts=[types.Part(text=record.text)]) - memory_entry = MemoryEntry(content=content) - memories.append(memory_entry) + for record in self._coerce_memories(results): + text = str(read_field(record, "text", "") or "") + if not text: + continue + + created_at = read_field(record, "created_at") + timestamp = created_at.isoformat() if created_at else None + memory_type = self._memory_type_value(read_field(record, "memory_type")) + content = types.Content(parts=[types.Part(text=text)]) + memories.append( + MemoryEntry( + id=read_field(record, "id"), + timestamp=timestamp, + content=content, + custom_metadata={ + "namespace": read_field(record, "namespace"), + "owner_id": read_field(record, "owner_id"), + "session_id": read_field(record, "session_id"), + "topics": read_field(record, "topics", []) or [], + "memory_type": memory_type, + }, + ) + ) logger.info( "Found %d memories for query '%s' (namespace=%s, user=%s)", @@ -293,8 +819,9 @@ async def search_memory( async def close(self) -> None: """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 + if "_agent_memory_server_client" in self.__dict__: + client = self.__dict__["_agent_memory_server_client"] + close = getattr(client, "close", None) + if close: + await close() + del self.__dict__["_agent_memory_server_client"] diff --git a/src/adk_redis/sessions/working_memory.py b/src/adk_redis/sessions/working_memory.py index 732986e..559f29d 100644 --- a/src/adk_redis/sessions/working_memory.py +++ b/src/adk_redis/sessions/working_memory.py @@ -12,18 +12,17 @@ # 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. -""" +"""Redis working memory session service for ADK.""" from __future__ import annotations +import base64 +from contextlib import asynccontextmanager +import hashlib +from importlib import import_module import logging import time -from typing import Any, Literal +from typing import Any, AsyncIterator, Literal import uuid from google.adk.events.event import Event @@ -36,27 +35,79 @@ from pydantic import Field from typing_extensions import override +from adk_redis.memory._backends import MemoryBackendName +from adk_redis.memory._backends import OPENSOURCE_AGENT_MEMORY_BACKEND +from adk_redis.memory._backends import REDIS_AGENT_MEMORY_BACKEND from adk_redis.memory._utils import extract_text_from_event +from adk_redis.memory._utils import is_not_found_error +from adk_redis.memory._utils import read_field +from adk_redis.memory._utils import sanitize_managed_identifier +from adk_redis.memory._utils import timestamp_to_datetime logger = logging.getLogger("adk_redis." + __name__) +_SESSION_ID_PREFIX = "adkredis" +_MANAGED_SESSION_ID_LEN = 64 + +try: + _agent_memory_client_module = import_module("agent_memory_client") + _agent_memory_exceptions_module = import_module( + "agent_memory_client.exceptions" + ) + _agent_memory_models_module = import_module("agent_memory_client.models") +except ImportError: + _MemoryAPIClient: Any = None + _MemoryClientConfig: Any = None + + class _FallbackMemoryNotFoundError(Exception): + """Fallback used when agent-memory-client is not installed.""" + + _MemoryNotFoundError: Any = _FallbackMemoryNotFoundError + _MemoryMessage: Any = None + _MemoryStrategyConfig: Any = None +else: + _MemoryAPIClient = _agent_memory_client_module.MemoryAPIClient + _MemoryClientConfig = _agent_memory_client_module.MemoryClientConfig + _MemoryNotFoundError = _agent_memory_exceptions_module.MemoryNotFoundError + _MemoryMessage = _agent_memory_models_module.MemoryMessage + _MemoryStrategyConfig = _agent_memory_models_module.MemoryStrategyConfig + +try: + _redis_agent_memory_module = import_module("redis_agent_memory") +except ImportError: + _AgentMemory: Any = None +else: + _AgentMemory = _redis_agent_memory_module.AgentMemory + class RedisWorkingMemorySessionServiceConfig(BaseModel): - """Configuration for Redis Working Memory Session Service. + """Configuration for Redis working memory session service. Attributes: - api_base_url: Base URL of the Agent Memory Server. + backend: Memory backend to use. + api_base_url: Memory API base URL. + api_key: Redis Agent Memory API key. + store_id: Redis Agent Memory store ID. 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. + timeout_ms: Optional SDK timeout in milliseconds. + default_namespace: Default namespace for session isolation. + model_name: Backward-compatible option retained from the previous client. + context_window_max: Backward-compatible option retained from the + previous client. + extraction_strategy: Backward-compatible option retained from the + previous client. + extraction_strategy_config: Backward-compatible option retained from the + previous client. + session_ttl_seconds: Backward-compatible option retained from the + previous client. """ + backend: MemoryBackendName = "redis-agent-memory" api_base_url: str = Field(default="http://localhost:8000") + api_key: str | None = None + store_id: str | None = None timeout: float = Field(default=30.0, gt=0.0) + timeout_ms: int | None = Field(default=None, ge=1) default_namespace: str | None = None model_name: str | None = None context_window_max: int | None = Field(default=None, ge=1) @@ -68,177 +119,276 @@ class RedisWorkingMemorySessionServiceConfig(BaseModel): 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 adk_redis import ( - RedisWorkingMemorySessionService, - RedisWorkingMemorySessionServiceConfig, - ) + """Session service backed by Redis memory backends. - 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, - ) - ``` + Redis Agent Memory stores session events by session ID. The self-hosted + Agent Memory Server stores sessions in Working Memory. Returned ADK + sessions keep the original caller-facing session ID for both backends. """ def __init__( self, config: RedisWorkingMemorySessionServiceConfig | None = None ): - """Initialize the Redis Working Memory Session Service. + """Initialize the Redis Agent Memory session service. Args: - config: Configuration for the service. If None, uses defaults. + config: Configuration for the service. If None, defaults are used. """ self._config = config or RedisWorkingMemorySessionServiceConfig() - def _get_client(self) -> Any: - """Get a MemoryAPIClient instance. + def _timeout_ms(self) -> int: + """Return the configured SDK timeout in milliseconds.""" + return self._config.timeout_ms or int(self._config.timeout * 1000) - Note: We create a new client for each call instead of caching it because - the ADK Runner creates a new event loop for each run() call, and cached - async clients get tied to the first event loop and fail when it closes. - """ - try: - from agent_memory_client import MemoryAPIClient - from agent_memory_client import MemoryClientConfig - except ImportError as e: + def _get_client(self) -> Any: + """Get a backend client for compatibility with existing tests.""" + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + return self._get_agent_memory_server_client() + return self._get_redis_agent_memory_client() + + def _get_redis_agent_memory_client(self) -> Any: + """Get a Redis Agent Memory SDK client.""" + if _AgentMemory is None: raise ImportError( - "agent-memory-client package is required for " + "redis-agent-memory package is required for " "RedisWorkingMemorySessionService. " "Install it with: pip install adk-redis[memory]" - ) from e + ) + + return _AgentMemory( + self._config.api_base_url, + api_key=self._config.api_key, + store_id=self._config.store_id, + timeout_ms=self._timeout_ms(), + ) + + def _get_agent_memory_server_client(self) -> Any: + """Get a self-hosted Agent Memory Server client.""" + if _MemoryAPIClient is None or _MemoryClientConfig is None: + raise ImportError( + "agent-memory-client package is required for the " + "opensource-agent-memory backend. Install it with: " + "pip install adk-redis[memory]" + ) - client_config = MemoryClientConfig( + 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) + return _MemoryAPIClient(client_config) + + @asynccontextmanager + async def _agent_memory(self) -> AsyncIterator[Any]: + """Yield a Redis Agent Memory client and close SDK resources.""" + client = self._get_client() + if hasattr(client, "__aenter__"): + async with client as agent_memory: + yield agent_memory + else: + yield client def _get_namespace(self, app_name: str) -> str: """Get namespace from config or app_name.""" - return self._config.default_namespace or app_name + namespace = self._config.default_namespace or app_name + if self._config.backend == REDIS_AGENT_MEMORY_BACKEND: + return sanitize_managed_identifier(namespace) + return namespace + + def _encode_segment(self, value: str) -> str: + """Encode one storage session ID segment.""" + encoded = base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii") + return encoded.rstrip("=") + + def _decode_segment(self, value: str) -> str: + """Decode one storage session ID segment.""" + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(f"{value}{padding}").decode("utf-8") + + def _storage_session_id( + self, *, app_name: str, user_id: str, session_id: str + ) -> str: + """Build a managed Redis Agent Memory session ID. + + Managed session IDs must be 1-64 chars and contain only alphanumeric + characters and hyphens. Encode ADK scope as a deterministic SHA-256 hex + digest so UUID session IDs and namespaces remain isolated per store. + """ + namespace = self._get_namespace(app_name) + return hashlib.sha256( + f"{namespace}\0{user_id}\0{session_id}".encode("utf-8") + ).hexdigest()[:_MANAGED_SESSION_ID_LEN] + + def _session_scope_from_response( + self, response: object + ) -> tuple[str, str, str] | None: + """Extract ADK session scope from a Redis Agent Memory session response.""" + for redis_event in read_field(response, "events", []) or []: + metadata = read_field(redis_event, "metadata", {}) or {} + namespace = metadata.get("namespace") + stored_user_id = metadata.get("user_id") + adk_session_id = metadata.get("adk_session_id") + if namespace and stored_user_id and adk_session_id: + return ( + str(namespace), + str(stored_user_id), + str(adk_session_id), + ) + return None + + def _parse_storage_session_id( + self, storage_session_id: str + ) -> tuple[str, str, str] | None: + """Parse an internal storage session ID. + + Returns: + Tuple of namespace, user_id, and ADK session_id, or None. + """ + parts = storage_session_id.split(":") + if len(parts) != 4 or parts[0] != _SESSION_ID_PREFIX: + return None - def _event_to_message(self, event: Event) -> Any: - """Convert ADK Event to MemoryMessage.""" - from datetime import datetime - from datetime import timezone + try: + return ( + self._decode_segment(parts[1]), + self._decode_segment(parts[2]), + self._decode_segment(parts[3]), + ) + except Exception: + return None + + def _event_role(self, event: Event) -> Any: + """Map an ADK event to a Redis Agent Memory role.""" + content_role = event.content.role if event.content else None + if event.author == "user" or content_role == "user": + return "USER" + if content_role == "system": + return "SYSTEM" + return "ASSISTANT" + + def _event_metadata( + self, + *, + event: Event, + app_name: str, + user_id: str, + session_id: str, + ) -> dict[str, Any]: + """Build portable metadata for an ADK event.""" + metadata: dict[str, Any] = { + "source": "adk-redis", + "namespace": self._get_namespace(app_name), + "app_name": app_name, + "user_id": user_id, + "adk_session_id": session_id, + "adk_event_id": event.id, + "adk_author": event.author, + } + if event.actions and event.actions.state_delta: + metadata["state_delta"] = dict(event.actions.state_delta) + return metadata + + def _event_actor_id(self, session: Session, event: Event) -> str: + """Return the Redis actor ID for an ADK event.""" + if event.author == "user": + actor_id = session.user_id + else: + actor_id = event.author or session.app_name + if self._config.backend == REDIS_AGENT_MEMORY_BACKEND: + return sanitize_managed_identifier(actor_id) + return actor_id + + def _build_agent_memory_server_strategy(self) -> Any: + """Build the self-hosted memory extraction strategy config.""" + if _MemoryStrategyConfig is None: + raise ImportError( + "agent-memory-client package is required for memory strategies. " + "Install it with: pip install adk-redis[memory]" + ) + + return _MemoryStrategyConfig( + strategy=self._config.extraction_strategy, + config=self._config.extraction_strategy_config, + ) - from agent_memory_client.models import MemoryMessage + def _event_to_agent_memory_server_message(self, event: Event) -> Any: + """Convert an ADK event to a self-hosted memory message.""" + if _MemoryMessage is None: + raise ImportError( + "agent-memory-client package is required for memory messages. " + "Install it with: pip install adk-redis[memory]" + ) 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) + return _MemoryMessage( + role=role, + content=text, + created_at=timestamp_to_datetime(event.timestamp), + ) - def _working_memory_response_to_session( + def _agent_memory_server_response_to_session( self, response: Any, app_name: str, user_id: str, ) -> Session: - """Convert WorkingMemoryResponse to ADK Session.""" + """Convert a self-hosted WorkingMemory response to an ADK Session.""" events = [] - for msg in response.messages or []: - # For assistant messages, use app_name as the author since that matches - # the agent name in ADK. Using session_id causes "Event from an unknown - # agent" warnings and breaks conversation history handling. - author = "user" if msg.role == "user" else app_name - # Set the role on Content - "user" for user messages, "model" for assistant - # This is required for ADK's content processor to include events in LLM context - role = "user" if msg.role == "user" else "model" - content = types.Content(role=role, 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() + for message in getattr(response, "messages", None) or []: + role_raw = getattr(message, "role", "") + author = "user" if role_raw == "user" else app_name + content_role = "user" if role_raw == "user" else "model" + content = types.Content( + role=content_role, + parts=[types.Part(text=getattr(message, "content", ""))], ) - event = Event( - author=author, - content=content, - timestamp=timestamp, + created_at = getattr(message, "created_at", None) + timestamp = created_at.timestamp() if created_at else time.time() + events.append( + Event( + author=author, + content=content, + timestamp=timestamp, + ) ) - events.append(event) return Session( - id=response.session_id, + id=getattr(response, "session_id"), app_name=app_name, user_id=user_id, events=events, - state=response.data or {}, + state=getattr(response, "data", None) or {}, last_update_time=time.time(), ) - @override - async def create_session( + async def _create_session_agent_memory_server( self, *, app_name: str, user_id: str, - state: dict[str, Any] | None = None, - session_id: str | None = None, + state: dict[str, Any] | None, + session_id: 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 - + """Create a session in the self-hosted Agent Memory Server.""" session_id = ( session_id.strip() if session_id and session_id.strip() else str(uuid.uuid4()) ) namespace = self._get_namespace(app_name) + client = self._get_agent_memory_server_client() - strategy_config = MemoryStrategyConfig( - strategy=self._config.extraction_strategy, - config=self._config.extraction_strategy_config, - ) - - # Use get_or_create to prevent accidental overwrites - client = self._get_client() created, working_memory = await client.get_or_create_working_memory( session_id=session_id, namespace=namespace, user_id=user_id, - long_term_memory_strategy=strategy_config, + long_term_memory_strategy=self._build_agent_memory_server_strategy(), ) if not created: @@ -247,12 +397,12 @@ async def create_session( session_id, namespace, ) - # Return existing session data - return self._working_memory_response_to_session( - working_memory, app_name, user_id + return self._agent_memory_server_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 @@ -265,7 +415,6 @@ async def create_session( ) logger.info("Created session %s in namespace %s", session_id, namespace) - return Session( id=session_id, app_name=app_name, @@ -275,105 +424,271 @@ async def create_session( last_update_time=time.time(), ) - @override - async def get_session( + async def _get_session_agent_memory_server( self, *, app_name: str, user_id: str, session_id: str, - config: GetSessionConfig | None = None, + config: GetSessionConfig | None, ) -> Session | None: - """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. - - NOTE: For ADK Runner compatibility, this method now returns the session - even if it was just created. The Runner expects get_session to either - return an existing session OR return a newly created empty session. - Returning None causes the Runner to fail with "Session not found". - - Args: - app_name: Application name. - user_id: User identifier. - session_id: Session ID to retrieve. - config: Optional configuration for filtering events. - - Returns: - The Session (existing or newly created). - """ - from agent_memory_client.exceptions import MemoryNotFoundError - + """Retrieve a session from the self-hosted Agent Memory Server.""" try: namespace = self._get_namespace(app_name) - # Use get_or_create to avoid deprecated get_working_memory - client = self._get_client() - created, response = await client.get_or_create_working_memory( + client = self._get_agent_memory_server_client() + _, response = await 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, ) - - # Return the session whether it was just created or already existed - # This is required for ADK Runner compatibility - session = self._working_memory_response_to_session( - response, app_name, user_id + session = self._agent_memory_server_response_to_session( + response, + app_name, + user_id, ) if config: - if config.num_recent_events: + if config.num_recent_events is not None: session.events = session.events[-config.num_recent_events :] - if config.after_timestamp: + if config.after_timestamp is not None: session.events = [ - e for e in session.events if e.timestamp > config.after_timestamp + event + for event in session.events + if event.timestamp >= config.after_timestamp ] return session - except MemoryNotFoundError: + 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 | None = None + async def _list_sessions_agent_memory_server( + self, *, app_name: str, user_id: str | None ) -> ListSessionsResponse: - """List all sessions for a user from Working Memory. - - Args: - app_name: Application name. - user_id: User identifier (required for this implementation). - - Returns: - ListSessionsResponse containing sessions (without events). - - Raises: - ValueError: If user_id is not provided. - """ + """List sessions from the self-hosted Agent Memory Server.""" if user_id is None: raise ValueError( "user_id is required for RedisWorkingMemorySessionService" ) + try: namespace = self._get_namespace(app_name) - - # SDK method: list_sessions returns SessionListResponse - # with sessions: list[str] (session IDs only) - client = self._get_client() - response = await client.list_sessions( + response = await self._get_agent_memory_server_client().list_sessions( namespace=namespace, user_id=user_id, ) + sessions = [ + Session( + id=session_id, + app_name=app_name, + user_id=user_id, + state={}, + events=[], + last_update_time=time.time(), + ) + for session_id in getattr(response, "sessions", []) or [] + ] + return ListSessionsResponse(sessions=sessions) + + except Exception as e: + logger.error("Failed to list sessions: %s", e) + return ListSessionsResponse(sessions=[]) + + async def _delete_session_agent_memory_server( + self, *, app_name: str, user_id: str, session_id: str + ) -> None: + """Delete a session from the self-hosted Agent Memory Server.""" + try: + await self._get_agent_memory_server_client().delete_working_memory( + session_id=session_id, + namespace=self._get_namespace(app_name), + 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) + + async def _append_event_to_agent_memory_server( + self, session: Session, event: Event + ) -> None: + """Append one ADK event to the self-hosted Agent Memory Server.""" + message = self._event_to_agent_memory_server_message(event) + if not message: + return + + await self._get_agent_memory_server_client().append_messages_to_working_memory( + session_id=session.id, + messages=[message], + namespace=self._get_namespace(session.app_name), + user_id=session.user_id, + ) - sessions = [] - for session_id in response.sessions: - session = Session( + async def _append_event_to_redis( + self, session: Session, event: Event + ) -> None: + """Append one ADK event to Redis Agent Memory.""" + text = extract_text_from_event(event).strip() + if not text: + return + + storage_session_id = self._storage_session_id( + app_name=session.app_name, + user_id=session.user_id, + session_id=session.id, + ) + async with self._agent_memory() as agent_memory: + await agent_memory.add_session_event_async( + session_id=storage_session_id, + actor_id=self._event_actor_id(session, event), + role=self._event_role(event), + content=[{"text": text}], + created_at=timestamp_to_datetime(event.timestamp), + metadata=self._event_metadata( + event=event, + app_name=session.app_name, + user_id=session.user_id, + session_id=session.id, + ), + ) + + def _event_text(self, event: object) -> str: + """Extract text from a Redis Agent Memory session event.""" + content = read_field(event, "content", []) + parts = [] + for item in content or []: + text = read_field(item, "text", "") + if text: + parts.append(str(text)) + return "\n".join(parts) + + def _response_to_session( + self, + response: object, + *, + app_name: str, + user_id: str, + session_id: str, + ) -> Session: + """Convert a Redis Agent Memory session response to an ADK Session.""" + events = [] + state: dict[str, Any] = {} + response_events = read_field(response, "events", []) or [] + + for redis_event in response_events: + text = self._event_text(redis_event).strip() + if not text: + continue + + role_raw = read_field(redis_event, "role", "") + role = str(getattr(role_raw, "value", role_raw)).lower() + author = "user" if role == "user" else app_name + content_role = "user" if role == "user" else "model" + content = types.Content( + role=content_role, + parts=[types.Part(text=text)], + ) + created_at = read_field(redis_event, "created_at") + timestamp = created_at.timestamp() if created_at else time.time() + metadata = read_field(redis_event, "metadata", {}) or {} + state_delta = metadata.get("state_delta") + if isinstance(state_delta, dict): + state.update(state_delta) + + events.append( + Event( + id=metadata.get("adk_event_id") + or read_field(redis_event, "event_id"), + author=metadata.get("adk_author") or author, + content=content, + timestamp=timestamp, + ) + ) + + return Session( + id=session_id, + app_name=app_name, + user_id=user_id, + events=events, + state=state, + last_update_time=time.time(), + ) + + @override + async def create_session( + self, + *, + app_name: str, + user_id: str, + state: dict[str, Any] | None = None, + session_id: str | None = None, + ) -> Session: + """Create a new ADK session object. + + Redis Agent Memory creates a stored session when the first event is + appended, so this method returns the ADK session without writing a marker + event. + """ + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + return await self._create_session_agent_memory_server( + app_name=app_name, + user_id=user_id, + state=state, + session_id=session_id, + ) + + session_id = ( + session_id.strip() + if session_id and session_id.strip() + else str(uuid.uuid4()) + ) + 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: GetSessionConfig | None = None, + ) -> Session | None: + """Retrieve an ADK session from Redis Agent Memory.""" + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + return await self._get_session_agent_memory_server( + app_name=app_name, + user_id=user_id, + session_id=session_id, + config=config, + ) + + storage_session_id = self._storage_session_id( + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + + try: + async with self._agent_memory() as agent_memory: + response = await agent_memory.get_session_memory_async( + session_id=storage_session_id + ) + except Exception as e: + if is_not_found_error(e): + # Managed sessions are materialized on first append_event. Return an + # empty ADK session so runners can start a new conversation. + return Session( id=session_id, app_name=app_name, user_id=user_id, @@ -381,7 +696,83 @@ async def list_sessions( events=[], last_update_time=time.time(), ) - sessions.append(session) + logger.error("Failed to get session %s: %s", session_id, e) + return None + + session = self._response_to_session( + response, + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + + if config: + if config.num_recent_events is not None: + session.events = session.events[-config.num_recent_events :] + if config.after_timestamp is not None: + session.events = [ + event + for event in session.events + if event.timestamp >= config.after_timestamp + ] + + return session + + @override + async def list_sessions( + self, *, app_name: str, user_id: str | None = None + ) -> ListSessionsResponse: + """List stored sessions from Redis Agent Memory.""" + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + return await self._list_sessions_agent_memory_server( + app_name=app_name, + user_id=user_id, + ) + + namespace = self._get_namespace(app_name) + sessions = [] + page_token = None + + try: + async with self._agent_memory() as agent_memory: + while True: + response = await agent_memory.list_sessions_async( + limit=100, + page_token=page_token, + ) + for storage_session_id in read_field(response, "items", []) or []: + parsed = self._parse_storage_session_id(storage_session_id) + if parsed: + stored_namespace, stored_user_id, session_id = parsed + else: + try: + stored = await agent_memory.get_session_memory_async( + session_id=storage_session_id + ) + except Exception: + continue + scope = self._session_scope_from_response(stored) + if not scope: + continue + stored_namespace, stored_user_id, session_id = scope + if stored_namespace != namespace: + continue + if user_id is not None and stored_user_id != user_id: + continue + sessions.append( + Session( + id=session_id, + app_name=app_name, + user_id=stored_user_id, + state={}, + events=[], + last_update_time=time.time(), + ) + ) + + page_token = read_field(response, "next_page_token") + if not page_token: + break return ListSessionsResponse(sessions=sessions) @@ -393,60 +784,52 @@ async def list_sessions( 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) - client = self._get_client() - await client.delete_working_memory( - session_id=session_id, - namespace=namespace, + """Delete a session from Redis Agent Memory.""" + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + await self._delete_session_agent_memory_server( + app_name=app_name, user_id=user_id, + session_id=session_id, ) + return + + storage_session_id = self._storage_session_id( + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + try: + async with self._agent_memory() as agent_memory: + await agent_memory.delete_session_memory_async( + session_id=storage_session_id + ) logger.info("Deleted session %s", session_id) except Exception as e: - logger.error("Failed to delete session %s: %s", session_id, e) + if not is_not_found_error(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 + """Append an event to the ADK session and configured backend.""" + appended_event = await super().append_event(session=session, event=event) + if appended_event.partial: + return appended_event + session.last_update_time = appended_event.timestamp try: - message = self._event_to_message(event) - if message: - namespace = self._get_namespace(session.app_name) - client = self._get_client() - await client.append_messages_to_working_memory( - session_id=session.id, - messages=[message], - namespace=namespace, - user_id=session.user_id, + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + await self._append_event_to_agent_memory_server( + session, + appended_event, ) - logger.debug("Appended message to session %s", session.id) + else: + await self._append_event_to_redis(session, appended_event) + 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 + return appended_event async def close(self) -> None: """Close the session service and cleanup resources.""" - # No longer caching client, so nothing to close pass diff --git a/src/adk_redis/tools/memory/__init__.py b/src/adk_redis/tools/memory/__init__.py index 3868183..8c6f821 100644 --- a/src/adk_redis/tools/memory/__init__.py +++ b/src/adk_redis/tools/memory/__init__.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Redis Agent Memory tools for ADK. +"""Redis memory tools for ADK. This module provides tools for explicit LLM-controlled memory operations -with the Redis Agent Memory Server. These tools complement the automatic -memory services by allowing the LLM to directly manage long-term memories. +with Redis Agent Memory or the self-hosted Agent Memory Server. These tools +complement the automatic memory services by allowing the LLM to directly manage +long-term memories. Available Tools: - MemoryPromptTool: Enrich prompts with relevant memories @@ -38,6 +39,7 @@ # Configure all tools config = MemoryToolConfig( + backend="redis-agent-memory", api_base_url="http://localhost:8000", default_namespace="my_app", recency_boost=True, diff --git a/src/adk_redis/tools/memory/_base.py b/src/adk_redis/tools/memory/_base.py index ce9db89..74da8a1 100644 --- a/src/adk_redis/tools/memory/_base.py +++ b/src/adk_redis/tools/memory/_base.py @@ -12,30 +12,54 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Base class for Redis Agent Memory tools.""" +"""Base class for Redis memory tools.""" from __future__ import annotations +from contextlib import asynccontextmanager +from importlib import import_module import logging -from typing import Any +from typing import Any, AsyncIterator from google.adk.tools.base_tool import BaseTool +from adk_redis.memory._backends import OPENSOURCE_AGENT_MEMORY_BACKEND +from adk_redis.memory._backends import REDIS_AGENT_MEMORY_BACKEND +from adk_redis.memory._utils import sanitize_managed_identifier from adk_redis.tools.memory._config import MemoryToolConfig logger = logging.getLogger("adk_redis." + __name__) +try: + _agent_memory_client_module = import_module("agent_memory_client") + _agent_memory_models_module = import_module("agent_memory_client.models") +except ImportError: + _MemoryAPIClient: Any = None + _MemoryClientConfig: Any = None + _RecencyConfig: Any = None +else: + _MemoryAPIClient = _agent_memory_client_module.MemoryAPIClient + _MemoryClientConfig = _agent_memory_client_module.MemoryClientConfig + _RecencyConfig = _agent_memory_models_module.RecencyConfig + +try: + _redis_agent_memory_module = import_module("redis_agent_memory") +except ImportError: + _AgentMemory: Any = None +else: + _AgentMemory = _redis_agent_memory_module.AgentMemory + class BaseMemoryTool(BaseTool): - """Base class for all Redis Agent Memory tools. + """Base class for all Redis memory tools. This class provides common functionality for memory tools: - - Lazy initialization of the MemoryAPIClient + - Lazy initialization of memory backend clients - Shared configuration management - Standard error handling Subclasses should implement their specific tool logic using - the `_client` property to access the Agent Memory Server. + the configured backend helpers. """ def __init__( @@ -53,49 +77,90 @@ def __init__( description: The description of the tool (exposed to LLM). Raises: - ImportError: If agent-memory-client package is not installed. + ImportError: If the configured backend package is not installed. """ super().__init__(name=name, description=description) self._config = config def _get_client(self) -> Any: - """Get a MemoryAPIClient instance. + """Get a backend client for compatibility with existing tests.""" + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + return self._get_agent_memory_server_client() + return self._get_redis_agent_memory_client() + + def _get_redis_agent_memory_client(self) -> Any: + """Get a Redis Agent Memory SDK client. Note: We create a new client for each call instead of caching it because the ADK Runner creates a new event loop for each run() call, and cached async clients get tied to the first event loop and fail when it closes. Returns: - An initialized MemoryAPIClient instance. + An initialized Redis Agent Memory SDK client. Raises: - ImportError: If agent-memory-client package is not installed. + ImportError: If redis-agent-memory package is not installed. """ - try: - from agent_memory_client import MemoryAPIClient - from agent_memory_client import MemoryClientConfig - except ImportError as e: + if _AgentMemory is None: raise ImportError( - "agent-memory-client package is required for memory tools. " + "redis-agent-memory package is required for memory tools. " "Install it with: pip install adk-redis[memory]" - ) from e + ) + + return _AgentMemory( + self._config.api_base_url, + api_key=self._config.api_key, + store_id=self._config.store_id, + timeout_ms=self._timeout_ms(), + ) + + def _get_agent_memory_server_client(self) -> Any: + """Get a self-hosted Agent Memory Server client.""" + if _MemoryAPIClient is None or _MemoryClientConfig is None: + raise ImportError( + "agent-memory-client package is required for memory tools with " + "backend='opensource-agent-memory'. Install it with: " + "pip install adk-redis[memory]" + ) - client_config = MemoryClientConfig( + client_config = _MemoryClientConfig( base_url=self._config.api_base_url, timeout=self._config.timeout, default_namespace=self._config.default_namespace, ) - return MemoryAPIClient(client_config) + return _MemoryAPIClient(client_config) + + def _timeout_ms(self) -> int: + """Return the configured SDK timeout in milliseconds.""" + return self._config.timeout_ms or int(self._config.timeout * 1000) + + @asynccontextmanager + async def _agent_memory(self) -> AsyncIterator[Any]: + """Yield a Redis Agent Memory client and close SDK resources.""" + client = self._get_client() + if hasattr(client, "__aenter__"): + async with client as agent_memory: + yield agent_memory + else: + yield client def _build_recency_config(self) -> Any: """Build RecencyConfig from tool configuration. Returns: - A RecencyConfig object for use with search operations. + A RecencyConfig object for the self-hosted backend, or None for + Redis Agent Memory. """ - from agent_memory_client.models import RecencyConfig + if self._config.backend != OPENSOURCE_AGENT_MEMORY_BACKEND: + return None - return RecencyConfig( + if _RecencyConfig is None: + raise ImportError( + "agent-memory-client package is required for recency search. " + "Install it with: pip install adk-redis[memory]" + ) + + return _RecencyConfig( recency_boost=self._config.recency_boost, semantic_weight=self._config.semantic_weight, recency_weight=self._config.recency_weight, @@ -114,7 +179,10 @@ def _get_namespace(self, namespace: str | None = None) -> str: Returns: The namespace to use (override or default). """ - return namespace or self._config.default_namespace + resolved = namespace or self._config.default_namespace + if self._config.backend == REDIS_AGENT_MEMORY_BACKEND: + return sanitize_managed_identifier(resolved) + return resolved def _get_user_id(self, user_id: str | None = None) -> str | None: """Get the user ID to use for operations. @@ -125,4 +193,6 @@ def _get_user_id(self, user_id: str | None = None) -> str | None: Returns: The user ID to use (override or default). """ - return user_id or self._config.default_user_id + return ( + user_id or self._config.default_owner_id or self._config.default_user_id + ) diff --git a/src/adk_redis/tools/memory/_config.py b/src/adk_redis/tools/memory/_config.py index f195945..442b44e 100644 --- a/src/adk_redis/tools/memory/_config.py +++ b/src/adk_redis/tools/memory/_config.py @@ -19,18 +19,25 @@ from pydantic import BaseModel from pydantic import Field +from adk_redis.memory._backends import MemoryBackendName + class MemoryToolConfig(BaseModel): - """Shared configuration for all Redis Agent Memory tools. + """Shared configuration for all Redis memory tools. This configuration is used by all memory tools to connect to the - Agent Memory Server and manage memory operations. + configured memory backend and manage memory operations. Attributes: - api_base_url: Base URL of the Agent Memory Server. + backend: Memory backend to use. + api_base_url: Memory API base URL. + api_key: Redis Agent Memory API key. + store_id: Redis Agent Memory store ID. timeout: HTTP request timeout in seconds. + timeout_ms: Optional SDK timeout in milliseconds. default_namespace: Default namespace for memory operations. default_user_id: Default user ID for memory operations (optional). + default_owner_id: Default owner ID for memory operations (optional). search_top_k: Default maximum number of memories to retrieve. distance_threshold: Maximum distance threshold for search (0.0-1.0). recency_boost: Enable recency-aware re-ranking of search results. @@ -54,10 +61,15 @@ class MemoryToolConfig(BaseModel): ``` """ + backend: MemoryBackendName = "redis-agent-memory" api_base_url: str = Field(default="http://localhost:8000") + api_key: str | None = None + store_id: str | None = None timeout: float = Field(default=30.0, gt=0.0) + timeout_ms: int | None = Field(default=None, ge=1) default_namespace: str = Field(default="default") default_user_id: str | None = None + default_owner_id: str | None = None search_top_k: int = Field(default=10, ge=1) distance_threshold: float | None = Field(default=None, ge=0.0, le=1.0) recency_boost: bool = True diff --git a/src/adk_redis/tools/memory/create.py b/src/adk_redis/tools/memory/create.py index 3f07f73..8ffc4f0 100644 --- a/src/adk_redis/tools/memory/create.py +++ b/src/adk_redis/tools/memory/create.py @@ -18,9 +18,13 @@ import logging from typing import Any +import uuid from google.genai import types +from adk_redis.memory._backends import OPENSOURCE_AGENT_MEMORY_BACKEND +from adk_redis.memory._utils import read_field +from adk_redis.memory._utils import stable_memory_id from adk_redis.tools.memory._base import BaseMemoryTool from adk_redis.tools.memory._config import MemoryToolConfig @@ -150,37 +154,66 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]: memory_type = "semantic" # Default fallback try: - # Use add_memory_tool which creates a memory in a session context - # We'll use a temporary session ID for standalone memory creation - import uuid - - session_id = f"standalone_{uuid.uuid4().hex[:8]}" - - client = self._get_client() - response = await client.add_memory_tool( - session_id=session_id, - text=content, - memory_type=memory_type, - topics=topics if topics else None, - namespace=namespace, - user_id=user_id, - ) - - # Response is a dict with 'success' key and summary - if response.get("success"): - # Extract memory ID from summary if available, or use session_id as fallback - memory_id = response.get("memory_id", session_id) + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + session_id = f"standalone_{uuid.uuid4().hex[:8]}" + response = await self._get_agent_memory_server_client().add_memory_tool( + session_id=session_id, + text=content, + memory_type=memory_type, + topics=topics if topics else None, + namespace=namespace, + user_id=user_id, + ) + + if response.get("success"): + return { + "status": "success", + "memory_id": response.get("memory_id", session_id), + "message": response.get( + "summary", + "Memory created successfully", + ), + } return { - "status": "success", - "memory_id": memory_id, - "message": response.get("summary", "Memory created successfully"), + "status": "error", + "message": response.get("summary", "Failed to create memory"), } - else: + + memory_id = stable_memory_id( + "tool", + namespace, + user_id or "", + memory_type, + content, + ) + record = { + "id": memory_id, + "text": content, + "ownerId": user_id, + "namespace": namespace, + "topics": topics if topics else None, + "memoryType": memory_type, + } + + async with self._agent_memory() as agent_memory: + response = await agent_memory.bulk_create_long_term_memories_async( + memories=[record] + ) + + errors = read_field(response, "errors") + if errors: return { "status": "error", - "message": response.get("summary", "Failed to create memory"), + "message": f"Failed to create memory: {errors}", } + created = read_field(response, "created", []) or [memory_id] + return { + "status": "success", + "memory_id": created[0], + "message": "Memory created successfully", + } + except Exception as e: logger.error("Failed to create memory: %s", e) return { diff --git a/src/adk_redis/tools/memory/delete.py b/src/adk_redis/tools/memory/delete.py index a5384f8..fc443c9 100644 --- a/src/adk_redis/tools/memory/delete.py +++ b/src/adk_redis/tools/memory/delete.py @@ -17,10 +17,13 @@ from __future__ import annotations import logging +import re from typing import Any from google.genai import types +from adk_redis.memory._backends import OPENSOURCE_AGENT_MEMORY_BACKEND +from adk_redis.memory._utils import read_field from adk_redis.tools.memory._base import BaseMemoryTool from adk_redis.tools.memory._config import MemoryToolConfig @@ -122,27 +125,32 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]: return {"status": "error", "message": "memory_ids is required"} try: - # delete_long_term_memories only takes memory_ids - client = self._get_client() - response = await client.delete_long_term_memories( - memory_ids=memory_ids, - ) - - # Response is AckResponse with 'status' field containing message like "ok, deleted 2 memories" - status_msg = response.status - - # Parse the deleted count from the status message - import re - - match = re.search(r"deleted (\d+)", status_msg) - deleted_count = int(match.group(1)) if match else 0 - - is_success = "ok" in status_msg.lower() + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + response = await self._get_agent_memory_server_client().delete_long_term_memories( + memory_ids=memory_ids, + ) + status_msg = response.status + match = re.search(r"deleted (\d+)", status_msg) + deleted_count = int(match.group(1)) if match else 0 + is_success = "ok" in status_msg.lower() + + return { + "status": "success" if is_success else "error", + "deleted_count": deleted_count, + "message": status_msg, + } + + async with self._agent_memory() as agent_memory: + response = await agent_memory.bulk_delete_long_term_memories_async( + memory_ids=memory_ids, + ) + deleted = read_field(response, "deleted", []) or [] + errors = read_field(response, "errors") return { - "status": "success" if is_success else "error", - "deleted_count": deleted_count, - "message": status_msg, + "status": "error" if errors else "success", + "deleted_count": len(deleted), + "message": "Deleted memories" if not errors else str(errors), } except Exception as e: diff --git a/src/adk_redis/tools/memory/get.py b/src/adk_redis/tools/memory/get.py index 6fa359a..9745015 100644 --- a/src/adk_redis/tools/memory/get.py +++ b/src/adk_redis/tools/memory/get.py @@ -21,6 +21,8 @@ from google.genai import types +from adk_redis.memory._backends import OPENSOURCE_AGENT_MEMORY_BACKEND +from adk_redis.memory._utils import read_field from adk_redis.tools.memory._base import BaseMemoryTool from adk_redis.tools.memory._config import MemoryToolConfig @@ -110,26 +112,57 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]: return {"status": "error", "message": "memory_id is required"} try: - client = self._get_client() - memory = await client.get_long_term_memory(memory_id=memory_id) + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + memory = ( + await ( + self._get_agent_memory_server_client().get_long_term_memory( + memory_id=memory_id + ) + ) + ) + + return { + "status": "success", + "memory": { + "id": memory.id, + "content": memory.text, + "topics": memory.topics or [], + "entities": memory.entities or [], + "memory_type": memory.memory_type, + "namespace": memory.namespace, + "user_id": memory.user_id, + "session_id": memory.session_id, + "created_at": str(memory.created_at) + if memory.created_at + else None, + "last_accessed": str(memory.last_accessed) + if memory.last_accessed + else None, + }, + } + + async with self._agent_memory() as agent_memory: + memory = await agent_memory.get_long_term_memory_async( + memory_id=memory_id + ) + memory_type = read_field(memory, "memory_type") + created_at = read_field(memory, "created_at") + updated_at = read_field(memory, "updated_at") return { "status": "success", "memory": { - "id": memory.id, - "content": memory.text, - "topics": memory.topics or [], - "entities": memory.entities or [], - "memory_type": memory.memory_type, - "namespace": memory.namespace, - "user_id": memory.user_id, - "session_id": memory.session_id, - "created_at": str(memory.created_at) - if memory.created_at - else None, - "last_accessed": str(memory.last_accessed) - if memory.last_accessed + "id": read_field(memory, "id"), + "content": read_field(memory, "text"), + "topics": read_field(memory, "topics", []) or [], + "memory_type": str(getattr(memory_type, "value", memory_type)) + if memory_type else None, + "namespace": read_field(memory, "namespace"), + "user_id": read_field(memory, "owner_id"), + "session_id": read_field(memory, "session_id"), + "created_at": str(created_at) if created_at else None, + "updated_at": str(updated_at) if updated_at else None, }, } diff --git a/src/adk_redis/tools/memory/prompt.py b/src/adk_redis/tools/memory/prompt.py index 62a1a15..ace4a5f 100644 --- a/src/adk_redis/tools/memory/prompt.py +++ b/src/adk_redis/tools/memory/prompt.py @@ -21,6 +21,8 @@ from google.genai import types +from adk_redis.memory._backends import OPENSOURCE_AGENT_MEMORY_BACKEND +from adk_redis.memory._utils import read_field from adk_redis.tools.memory._base import BaseMemoryTool from adk_redis.tools.memory._config import MemoryToolConfig @@ -130,26 +132,56 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]: return {"status": "error", "message": "query is required"} try: - # memory_prompt requires either session_id or long_term_search - # We'll use long_term_search to search long-term memories - long_term_search = { + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + response = await self._get_agent_memory_server_client().memory_prompt( + query=query, + user_id=user_id, + namespace=namespace, + long_term_search={"limit": self._config.search_top_k}, + ) + enriched_prompt = response.get("prompt", query) + if system_prompt: + enriched_prompt = f"{system_prompt}\n\n{enriched_prompt}" + + return { + "status": "success", + "enriched_prompt": enriched_prompt, + "memories_used": len(response.get("memories", [])), + } + + filters: dict[str, Any] = {"namespace": {"eq": namespace}} + if user_id: + filters["ownerId"] = {"eq": user_id} + request = { + "text": query, "limit": self._config.search_top_k, + "filter": filters, + "filterOp": "all", } - client = self._get_client() - response = await client.memory_prompt( - query=query, - user_id=user_id, - namespace=namespace, - long_term_search=long_term_search, - ) + async with self._agent_memory() as agent_memory: + response = await agent_memory.search_long_term_memory_async( + request=request + ) + + memory_lines = [ + f"- {read_field(memory, 'text')}" + for memory in read_field(response, "items", []) or [] + if read_field(memory, "text") + ] + if memory_lines: + memory_context = "\n".join(memory_lines) + enriched_prompt = ( + "Relevant long-term memories:\n" + f"{memory_context}\n\nCurrent query:\n{query}" + ) + else: + enriched_prompt = query - # Extract the enriched prompt and combine with system prompt if provided - enriched_prompt = response.get("prompt", query) if system_prompt: enriched_prompt = f"{system_prompt}\n\n{enriched_prompt}" - memories_used = len(response.get("memories", [])) + memories_used = len(memory_lines) return { "status": "success", diff --git a/src/adk_redis/tools/memory/search.py b/src/adk_redis/tools/memory/search.py index 88fcc4f..421e512 100644 --- a/src/adk_redis/tools/memory/search.py +++ b/src/adk_redis/tools/memory/search.py @@ -16,16 +16,28 @@ from __future__ import annotations +from importlib import import_module import logging from typing import Any from google.genai import types +from adk_redis.memory._backends import OPENSOURCE_AGENT_MEMORY_BACKEND +from adk_redis.memory._utils import read_field from adk_redis.tools.memory._base import BaseMemoryTool from adk_redis.tools.memory._config import MemoryToolConfig logger = logging.getLogger("adk_redis." + __name__) +try: + _agent_memory_filters_module = import_module("agent_memory_client.filters") +except ImportError: + _Namespace: Any = None + _UserId: Any = None +else: + _Namespace = _agent_memory_filters_module.Namespace + _UserId = _agent_memory_filters_module.UserId + class SearchMemoryTool(BaseMemoryTool): """Tool for searching long-term memories. @@ -129,34 +141,82 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]: return {"status": "error", "message": "query is required"} try: - # Use search_long_term_memory which supports namespace filtering - client = self._get_client() - from agent_memory_client.filters import Namespace - from agent_memory_client.filters import UserId - - ns = Namespace(eq=namespace) - uid = UserId(eq=user_id) if user_id else None - response = await client.search_long_term_memory( - text=query, - namespace=ns, - user_id=uid, - distance_threshold=self._config.distance_threshold, - limit=limit, - ) + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + if _Namespace is None or _UserId is None: + raise ImportError( + "agent-memory-client package is required for memory search. " + "Install it with: pip install adk-redis[memory]" + ) + + namespace_filter = _Namespace(eq=namespace) + user_filter = _UserId(eq=user_id) if user_id else None + response = ( + await ( + self._get_agent_memory_server_client().search_long_term_memory( + text=query, + namespace=namespace_filter, + user_id=user_filter, + distance_threshold=self._config.distance_threshold, + limit=limit, + recency=self._build_recency_config() + if self._config.recency_boost + else None, + ) + ) + ) + + memories = [] + for memory in response.memories: + memories.append( + { + "id": memory.id, + "content": memory.text, + "score": getattr(memory, "dist", 0.0), + "topics": memory.topics or [], + "memory_type": memory.memory_type, + "created_at": str(memory.created_at) + if memory.created_at + else None, + } + ) + + return { + "status": "success", + "memories": memories, + "count": len(memories), + } + + filters: dict[str, Any] = {"namespace": {"eq": namespace}} + if user_id: + filters["ownerId"] = {"eq": user_id} + request: dict[str, Any] = { + "text": query, + "limit": limit, + "filter": filters, + "filterOp": "all", + } + if self._config.distance_threshold is not None: + request["similarityThreshold"] = self._config.distance_threshold + + async with self._agent_memory() as agent_memory: + response = await agent_memory.search_long_term_memory_async( + request=request + ) - # Response is a MemoryRecordResults object with .memories attribute memories = [] - for memory in response.memories: + for memory in read_field(response, "items", []) or []: + memory_type = read_field(memory, "memory_type") + created_at = read_field(memory, "created_at") memories.append( { - "id": memory.id, - "content": memory.text, - "score": getattr(memory, "dist", 0.0), - "topics": memory.topics or [], - "memory_type": memory.memory_type, - "created_at": str(memory.created_at) - if memory.created_at + "id": read_field(memory, "id"), + "content": read_field(memory, "text"), + "score": read_field(memory, "score", 0.0), + "topics": read_field(memory, "topics", []) or [], + "memory_type": str(getattr(memory_type, "value", memory_type)) + if memory_type else None, + "created_at": str(created_at) if created_at else None, } ) diff --git a/src/adk_redis/tools/memory/update.py b/src/adk_redis/tools/memory/update.py index 1cdd6cc..b909d52 100644 --- a/src/adk_redis/tools/memory/update.py +++ b/src/adk_redis/tools/memory/update.py @@ -21,6 +21,8 @@ from google.genai import types +from adk_redis.memory._backends import OPENSOURCE_AGENT_MEMORY_BACKEND +from adk_redis.memory._utils import read_field from adk_redis.tools.memory._base import BaseMemoryTool from adk_redis.tools.memory._config import MemoryToolConfig @@ -127,8 +129,8 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]: memory_id = args.get("memory_id") content = args.get("content") topics = args.get("topics") - self._get_namespace(args.get("namespace")) - self._get_user_id(args.get("user_id")) + namespace = args.get("namespace") + user_id = args.get("user_id") if not memory_id: return {"status": "error", "message": "memory_id is required"} @@ -140,24 +142,49 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]: } try: - # Build updates dict - use 'text' instead of 'content' - updates = {} + if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND: + updates: dict[str, Any] = {} + if content: + updates["text"] = content + if topics is not None: + updates["topics"] = topics + + response = ( + await ( + self._get_agent_memory_server_client().edit_long_term_memory( + memory_id=memory_id, + updates=updates, + ) + ) + ) + return { + "status": "success", + "memory_id": response.id, + "message": f"Memory {response.id} updated successfully", + } + + update_kwargs: dict[str, Any] = {} if content: - updates["text"] = content + update_kwargs["text"] = content if topics is not None: - updates["topics"] = topics - - # edit_long_term_memory only takes memory_id and updates dict - client = self._get_client() - response = await client.edit_long_term_memory( - memory_id=memory_id, - updates=updates, - ) + update_kwargs["topics"] = topics + if namespace: + update_kwargs["namespace"] = namespace + if user_id: + update_kwargs["owner_id"] = user_id + + async with self._agent_memory() as agent_memory: + response = await agent_memory.update_long_term_memory_async( + memory_id=memory_id, + **update_kwargs, + ) return { "status": "success", - "memory_id": response.id, - "message": f"Memory {response.id} updated successfully", + "memory_id": read_field(response, "id"), + "message": ( + f"Memory {read_field(response, 'id')} updated successfully" + ), } except Exception as e: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1d532bd..c2c6437 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -55,6 +55,35 @@ def _redis_reachable(url: str) -> bool: ) +REDIS_AGENT_MEMORY_URL = os.environ.get("REDIS_AGENT_MEMORY_API_BASE_URL") +REDIS_AGENT_MEMORY_API_KEY = os.environ.get("REDIS_AGENT_MEMORY_API_KEY") +REDIS_AGENT_MEMORY_STORE_ID = os.environ.get("REDIS_AGENT_MEMORY_STORE_ID") + +REQUIRES_REDIS_AGENT_MEMORY = pytest.mark.skipif( + not ( + REDIS_AGENT_MEMORY_URL + and REDIS_AGENT_MEMORY_API_KEY + and REDIS_AGENT_MEMORY_STORE_ID + ), + reason=( + "Redis Agent Memory env vars not set. Set " + "REDIS_AGENT_MEMORY_API_BASE_URL, REDIS_AGENT_MEMORY_API_KEY, and " + "REDIS_AGENT_MEMORY_STORE_ID to enable." + ), +) + + +AGENT_MEMORY_SERVER_URL = os.environ.get("AGENT_MEMORY_SERVER_URL") + +REQUIRES_AGENT_MEMORY_SERVER = pytest.mark.skipif( + not AGENT_MEMORY_SERVER_URL, + reason=( + "Agent Memory Server not configured. Set AGENT_MEMORY_SERVER_URL " + "(for example http://localhost:8000) to enable." + ), +) + + @pytest.fixture(scope="session") def redis_url() -> str: """Redis URL for integration tests.""" @@ -65,3 +94,15 @@ def redis_url() -> str: def unique_index_name() -> str: """Unique index name to isolate test runs.""" return f"adk_redis_it_{uuid.uuid4().hex[:8]}" + + +@pytest.fixture +def unique_namespace() -> str: + """Unique namespace to isolate memory-backend test runs.""" + return f"adk_redis_it_{uuid.uuid4().hex[:8]}" + + +@pytest.fixture +def unique_user_id() -> str: + """Unique owner/user ID to isolate memory-backend test runs.""" + return f"user_{uuid.uuid4().hex[:8]}" diff --git a/tests/integration/test_memory_backends_end_to_end.py b/tests/integration/test_memory_backends_end_to_end.py new file mode 100644 index 0000000..e7b8258 --- /dev/null +++ b/tests/integration/test_memory_backends_end_to_end.py @@ -0,0 +1,159 @@ +# Copyright 2025 Redis, Inc. +# +# 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. + +"""End-to-end integration tests for the selectable memory backends. + +Round-trips RedisLongTermMemoryService and RedisWorkingMemorySessionService +against real backends. Tests skip when env vars are not set: + +- redis-agent-memory (managed): REDIS_AGENT_MEMORY_API_BASE_URL, + REDIS_AGENT_MEMORY_API_KEY, REDIS_AGENT_MEMORY_STORE_ID +- opensource-agent-memory (self-hosted): AGENT_MEMORY_SERVER_URL +""" + +from __future__ import annotations + +import uuid + +from google.adk.events.event import Event +from google.adk.memory.memory_entry import MemoryEntry +from google.genai import types +import pytest + +from adk_redis import RedisLongTermMemoryService +from adk_redis import RedisLongTermMemoryServiceConfig +from adk_redis import RedisWorkingMemorySessionService +from adk_redis import RedisWorkingMemorySessionServiceConfig +from tests.integration.conftest import AGENT_MEMORY_SERVER_URL +from tests.integration.conftest import REDIS_AGENT_MEMORY_API_KEY +from tests.integration.conftest import REDIS_AGENT_MEMORY_STORE_ID +from tests.integration.conftest import REDIS_AGENT_MEMORY_URL +from tests.integration.conftest import REQUIRES_AGENT_MEMORY_SERVER +from tests.integration.conftest import REQUIRES_REDIS_AGENT_MEMORY + +pytest.importorskip("agent_memory_client") +pytest.importorskip("redis_agent_memory") + +_APP_NAME = "adk_redis_it" +_QUERY = "what color does the user like?" +_MEMORY_TEXT = "The user prefers the color teal." + + +def _text_event(author: str, text: str) -> Event: + return Event( + author=author, + content=types.Content(role=author, parts=[types.Part(text=text)]), + ) + + +def _memory_entry(text: str) -> MemoryEntry: + return MemoryEntry( + content=types.Content(parts=[types.Part(text=text)]), + custom_metadata={"memory_type": "semantic"}, + ) + + +def _text_of(parts_owner) -> str: + if parts_owner and parts_owner.content and parts_owner.content.parts: + return (parts_owner.content.parts[0].text or "").lower() + return "" + + +@REQUIRES_REDIS_AGENT_MEMORY +class TestRedisAgentMemoryBackendEndToEnd: + """Round-trip the managed Redis Agent Memory backend.""" + + def _long_term(self, namespace: str) -> RedisLongTermMemoryService: + return RedisLongTermMemoryService( + config=RedisLongTermMemoryServiceConfig( + backend="redis-agent-memory", + api_base_url=REDIS_AGENT_MEMORY_URL, + api_key=REDIS_AGENT_MEMORY_API_KEY, + store_id=REDIS_AGENT_MEMORY_STORE_ID, + default_namespace=namespace, + search_top_k=5, + ) + ) + + async def test_add_memory_then_search( + self, unique_namespace: str, unique_user_id: str + ) -> None: + service = self._long_term(unique_namespace) + await service.add_memory( + app_name=_APP_NAME, + user_id=unique_user_id, + memories=[_memory_entry(_MEMORY_TEXT)], + ) + response = await service.search_memory( + app_name=_APP_NAME, user_id=unique_user_id, query=_QUERY + ) + assert any("teal" in _text_of(m) for m in response.memories) + + async def test_session_append_and_get( + self, unique_namespace: str, unique_user_id: str + ) -> None: + sessions = RedisWorkingMemorySessionService( + config=RedisWorkingMemorySessionServiceConfig( + backend="redis-agent-memory", + api_base_url=REDIS_AGENT_MEMORY_URL, + api_key=REDIS_AGENT_MEMORY_API_KEY, + store_id=REDIS_AGENT_MEMORY_STORE_ID, + default_namespace=unique_namespace, + ) + ) + session_id = f"sess_{uuid.uuid4().hex[:8]}" + session = await sessions.create_session( + app_name=_APP_NAME, user_id=unique_user_id, session_id=session_id + ) + await sessions.append_event( + session=session, event=_text_event("user", "hi from integration test") + ) + fetched = await sessions.get_session( + app_name=_APP_NAME, user_id=unique_user_id, session_id=session_id + ) + assert fetched is not None and fetched.id == session_id + assert any("integration test" in _text_of(e) for e in fetched.events) + await sessions.delete_session( + app_name=_APP_NAME, user_id=unique_user_id, session_id=session_id + ) + + +@REQUIRES_AGENT_MEMORY_SERVER +class TestAgentMemoryServerBackendEndToEnd: + """Round-trip the self-hosted Agent Memory Server backend.""" + + async def test_add_memory_then_search( + self, unique_namespace: str, unique_user_id: str + ) -> None: + service = RedisLongTermMemoryService( + config=RedisLongTermMemoryServiceConfig( + backend="opensource-agent-memory", + api_base_url=AGENT_MEMORY_SERVER_URL, + default_namespace=unique_namespace, + search_top_k=5, + recency_boost=False, + ) + ) + try: + await service.add_memory( + app_name=_APP_NAME, + user_id=unique_user_id, + memories=[_memory_entry(_MEMORY_TEXT)], + ) + response = await service.search_memory( + app_name=_APP_NAME, user_id=unique_user_id, query=_QUERY + ) + assert isinstance(response.memories, list) + finally: + await service.close() diff --git a/tests/memory/test_long_term_memory.py b/tests/memory/test_long_term_memory.py index be248d1..58622cc 100644 --- a/tests/memory/test_long_term_memory.py +++ b/tests/memory/test_long_term_memory.py @@ -14,23 +14,68 @@ """Tests for RedisLongTermMemoryService.""" -from unittest.mock import AsyncMock -from unittest.mock import MagicMock +from datetime import datetime +from datetime import timezone +from types import SimpleNamespace from unittest.mock import patch +from google.adk.memory.memory_entry import MemoryEntry +from google.adk.sessions.session import Session +from google.genai import types import pytest from adk_redis.memory import RedisLongTermMemoryService from adk_redis.memory import RedisLongTermMemoryServiceConfig +class FakeAgentMemory: + """Fake async Redis Agent Memory client.""" + + def __init__(self): + self.created_records = [] + self.search_request = None + self.search_response = SimpleNamespace(items=[]) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + async def bulk_create_long_term_memories_async(self, *, memories): + self.created_records.extend(memories) + return SimpleNamespace( + created=[memory["id"] for memory in memories], + errors=None, + ) + + async def search_long_term_memory_async(self, *, request): + self.search_request = request + return self.search_response + + +class FakeAgentMemoryServerClient: + """Fake self-hosted Agent Memory Server client.""" + + def __init__(self): + self.search_kwargs = None + self.search_response = SimpleNamespace(memories=[]) + + async def search_long_term_memory(self, **kwargs): + self.search_kwargs = kwargs + return self.search_response + + class TestRedisLongTermMemoryServiceConfig: """Tests for RedisLongTermMemoryServiceConfig.""" def test_default_values(self): """Test default configuration values.""" config = RedisLongTermMemoryServiceConfig() + assert config.backend == "redis-agent-memory" assert config.api_base_url == "http://localhost:8000" + assert config.api_key is None + assert config.store_id is None assert config.timeout == 30.0 assert config.default_namespace is None assert config.recency_boost is True @@ -38,11 +83,14 @@ def test_default_values(self): assert config.semantic_weight == 0.8 assert config.extraction_strategy == "discrete" assert config.extraction_strategy_config == {} + assert config.store_events_as_messages is True def test_custom_values(self): """Test custom configuration values.""" config = RedisLongTermMemoryServiceConfig( api_base_url="http://custom:9000", + api_key="key", + store_id="store", timeout=60.0, default_namespace="test_ns", recency_weight=0.5, @@ -51,6 +99,8 @@ def test_custom_values(self): extraction_strategy_config={"max_length": 100}, ) assert config.api_base_url == "http://custom:9000" + assert config.api_key == "key" + assert config.store_id == "store" assert config.timeout == 60.0 assert config.default_namespace == "test_ns" assert config.recency_weight == 0.5 @@ -58,6 +108,11 @@ def test_custom_values(self): assert config.extraction_strategy == "summary" assert config.extraction_strategy_config == {"max_length": 100} + def test_opensource_backend_value(self): + """Test the self-hosted backend value is accepted.""" + config = RedisLongTermMemoryServiceConfig(backend="opensource-agent-memory") + assert config.backend == "opensource-agent-memory" + class TestRedisLongTermMemoryServiceInit: """Tests for RedisLongTermMemoryService initialization.""" @@ -80,40 +135,139 @@ class TestRedisLongTermMemoryServiceMethods: """Tests for RedisLongTermMemoryService methods.""" @pytest.fixture - def service(self): + def fake_client(self): + """Create a fake Redis Agent Memory client.""" + return FakeAgentMemory() + + @pytest.fixture + def service(self, fake_client): """Create a service instance for testing.""" - return RedisLongTermMemoryService() + service = RedisLongTermMemoryService( + RedisLongTermMemoryServiceConfig(default_namespace="test_ns") + ) + with patch.object(service, "_get_client", return_value=fake_client): + yield service @pytest.mark.asyncio - async def test_search_memory_returns_empty_on_error(self, service): - """Test search_memory returns empty response on error.""" - with patch.object( - service, - "_client", - create=True, - new_callable=lambda: MagicMock( - search_long_term_memory=AsyncMock( - side_effect=Exception("Test error") + async def test_search_memory_uses_owner_and_namespace_filter( + self, service, fake_client + ): + """Test search_memory scopes requests by owner and namespace.""" + fake_client.search_response = SimpleNamespace( + items=[ + SimpleNamespace( + id="memory-1", + text="The user prefers window seats.", + owner_id="alice", + namespace="test_ns", + session_id="session-1", + topics=["travel"], + memory_type="semantic", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), ) + ] + ) + + result = await service.search_memory( + app_name="app", + user_id="alice", + query="seat preference", + ) + + assert fake_client.search_request["filter"] == { + "ownerId": {"eq": "alice"}, + "namespace": {"eq": "test-ns"}, + } + assert result.memories[0].id == "memory-1" + assert ( + result.memories[0].content.parts[0].text + == "The user prefers window seats." + ) + + @pytest.mark.asyncio + async def test_add_memory_creates_long_term_records( + self, service, fake_client + ): + """Test add_memory writes explicit Redis Agent Memory records.""" + memory = MemoryEntry( + id="memory-1", + content=types.Content( + parts=[types.Part(text="The user prefers window seats.")] ), - ): - result = await service.search_memory( - app_name="test_app", - user_id="test_user", - query="test query", - ) - assert result.memories == [] + custom_metadata={"topics": ["travel"]}, + ) + + await service.add_memory( + app_name="app", + user_id="alice", + memories=[memory], + ) + + assert fake_client.created_records == [ + { + "id": "memory-1", + "text": "The user prefers window seats.", + "ownerId": "alice", + "namespace": "test-ns", + "sessionId": None, + "topics": ["travel", "app"], + "memoryType": "semantic", + } + ] @pytest.mark.asyncio - async def test_close_cleans_up_client(self, service): - """Test close method cleans up client.""" - mock_client = MagicMock() - mock_client.close = AsyncMock() + async def test_add_session_to_memory_creates_message_records( + self, service, fake_client + ): + """Test add_session_to_memory stores event text as message memory.""" + session = Session( + id="session-1", + app_name="app", + user_id="alice", + events=[], + ) - # Manually set the cached property - service.__dict__["_client"] = mock_client + await service.add_session_to_memory(session) - await service.close() + assert fake_client.created_records == [] + + @pytest.mark.asyncio + async def test_search_memory_can_use_agent_memory_server_backend(self): + """Test search_memory can use the self-hosted backend.""" + fake_client = FakeAgentMemoryServerClient() + fake_client.search_response = SimpleNamespace( + memories=[ + SimpleNamespace( + id="memory-1", + text="The user prefers aisle seats.", + namespace="test_ns", + user_id="alice", + session_id="session-1", + topics=["travel"], + memory_type="semantic", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + ] + ) + service = RedisLongTermMemoryService( + RedisLongTermMemoryServiceConfig( + backend="opensource-agent-memory", + default_namespace="test_ns", + recency_boost=False, + ) + ) + + with patch.object( + service, + "_get_agent_memory_server_client", + return_value=fake_client, + ): + result = await service.search_memory( + app_name="app", + user_id="alice", + query="seat preference", + ) - mock_client.close.assert_called_once() - assert "_client" not in service.__dict__ + assert fake_client.search_kwargs["namespace"] == {"eq": "test_ns"} + assert fake_client.search_kwargs["user_id"] == {"eq": "alice"} + assert result.memories[0].id == "memory-1" diff --git a/tests/sessions/test_working_memory.py b/tests/sessions/test_working_memory.py index 5da0646..42e3bb6 100644 --- a/tests/sessions/test_working_memory.py +++ b/tests/sessions/test_working_memory.py @@ -14,23 +14,75 @@ """Tests for RedisWorkingMemorySessionService.""" -from unittest.mock import AsyncMock -from unittest.mock import MagicMock +from datetime import datetime +from datetime import timezone +from types import SimpleNamespace 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 adk_redis.sessions import RedisWorkingMemorySessionService from adk_redis.sessions import RedisWorkingMemorySessionServiceConfig +class FakeAgentMemory: + """Fake async Redis Agent Memory client.""" + + def __init__(self): + self.added_events = [] + self.deleted_session_id = None + self.get_response = None + self.list_response = SimpleNamespace(items=[], next_page_token=None) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + async def add_session_event_async(self, **kwargs): + self.added_events.append(kwargs) + return SimpleNamespace(event=kwargs) + + async def get_session_memory_async(self, *, session_id): + if self.get_response is None: + error = RuntimeError("not found") + error.status_code = 404 # type: ignore[attr-defined] + raise error + return self.get_response + + async def delete_session_memory_async(self, *, session_id): + self.deleted_session_id = session_id + + async def list_sessions_async(self, *, limit, page_token): + return self.list_response + + +class FakeAgentMemoryServerClient: + """Fake self-hosted Agent Memory Server client.""" + + def __init__(self): + self.list_kwargs = None + self.list_response = SimpleNamespace(sessions=[]) + + async def list_sessions(self, **kwargs): + self.list_kwargs = kwargs + return self.list_response + + class TestRedisWorkingMemorySessionServiceConfig: """Tests for RedisWorkingMemorySessionServiceConfig.""" def test_default_values(self): """Test default configuration values.""" config = RedisWorkingMemorySessionServiceConfig() + assert config.backend == "redis-agent-memory" assert config.api_base_url == "http://localhost:8000" + assert config.api_key is None + assert config.store_id is None assert config.timeout == 30.0 assert config.default_namespace is None assert config.model_name is None @@ -43,6 +95,8 @@ def test_custom_values(self): """Test custom configuration values.""" config = RedisWorkingMemorySessionServiceConfig( api_base_url="http://custom:9000", + api_key="key", + store_id="store", timeout=60.0, default_namespace="test_ns", model_name="gpt-4", @@ -51,6 +105,8 @@ def test_custom_values(self): session_ttl_seconds=3600, ) assert config.api_base_url == "http://custom:9000" + assert config.api_key == "key" + assert config.store_id == "store" assert config.timeout == 60.0 assert config.default_namespace == "test_ns" assert config.model_name == "gpt-4" @@ -58,6 +114,13 @@ def test_custom_values(self): assert config.extraction_strategy == "summary" assert config.session_ttl_seconds == 3600 + def test_opensource_backend_value(self): + """Test the self-hosted backend value is accepted.""" + config = RedisWorkingMemorySessionServiceConfig( + backend="opensource-agent-memory" + ) + assert config.backend == "opensource-agent-memory" + class TestRedisWorkingMemorySessionServiceInit: """Tests for RedisWorkingMemorySessionService initialization.""" @@ -80,30 +143,227 @@ class TestRedisWorkingMemorySessionServiceMethods: """Tests for RedisWorkingMemorySessionService methods.""" @pytest.fixture - def service(self): + def fake_client(self): + """Create a fake Redis Agent Memory client.""" + return FakeAgentMemory() + + @pytest.fixture + def service(self, fake_client): """Create a service instance for testing.""" - return RedisWorkingMemorySessionService() + service = RedisWorkingMemorySessionService( + RedisWorkingMemorySessionServiceConfig(default_namespace="test_ns") + ) + with patch.object(service, "_get_client", return_value=fake_client): + yield service + + def test_storage_session_id_uses_managed_safe_hex(self, service): + """Test managed storage session IDs fit API charset and length limits.""" + storage_id = service._storage_session_id( + app_name="app", + user_id="alice", + session_id="session-1", + ) + + assert len(storage_id) == 64 + assert storage_id.isalnum() + assert ( + service._storage_session_id( + app_name="app", + user_id="alice", + session_id="session-1", + ) + == storage_id + ) + + def test_storage_session_id_hashes_uuid_session_ids(self, service): + """Test ADK UUID session IDs map to deterministic managed storage IDs.""" + storage_id = service._storage_session_id( + app_name="app", + user_id="user", + session_id="28a7d6e2-0f0f-43b6-b22c-47e03635ed34", + ) + + assert len(storage_id) == 64 + assert storage_id.isalnum() @pytest.mark.asyncio - async def test_list_sessions_returns_empty_on_error(self, service): - """Test list_sessions returns empty list on error.""" - with patch.object( - service, - "_client", - create=True, - new_callable=lambda: MagicMock( - list_sessions=AsyncMock(side_effect=Exception("Test error")) + async def test_append_event_writes_session_event(self, service, fake_client): + """Test append_event stores a Redis Agent Memory session event.""" + session = Session( + id="session-1", + app_name="app", + user_id="alice", + events=[], + ) + event = Event( + id="event-1", + author="user", + content=types.Content( + role="user", + parts=[types.Part(text="I prefer window seats.")], ), - ): - result = await service.list_sessions( - app_name="test_app", - user_id="test_user", - ) - assert result.sessions == [] + timestamp=1700000000, + ) + + await service.append_event(session, event) + + assert len(fake_client.added_events) == 1 + stored = fake_client.added_events[0] + assert stored["actor_id"] == "alice" + assert stored["content"] == [{"text": "I prefer window seats."}] + assert stored["metadata"]["namespace"] == "test-ns" + assert stored["metadata"]["adk_event_id"] == "event-1" @pytest.mark.asyncio - async def test_close_cleans_up_client(self, service): + async def test_get_session_returns_empty_before_first_event( + self, service, fake_client + ): + """Test get_session returns an empty session before Redis materializes it.""" + session = await service.get_session( + app_name="app", + user_id="alice", + session_id="fa58803d-9a11-4466-8f3f-40baf61b41c0", + ) + + assert session is not None + assert session.id == "fa58803d-9a11-4466-8f3f-40baf61b41c0" + assert session.events == [] + + @pytest.mark.asyncio + async def test_get_session_reconstructs_adk_events( + self, service, fake_client + ): + """Test get_session converts Redis events into ADK events.""" + storage_id = service._storage_session_id( + app_name="app", + user_id="alice", + session_id="session-1", + ) + fake_client.get_response = SimpleNamespace( + session_id=storage_id, + owner_id="alice", + events=[ + SimpleNamespace( + event_id="redis-event-1", + actor_id="alice", + role="user", + content=[{"text": "I prefer window seats."}], + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + metadata={ + "adk_event_id": "event-1", + "adk_author": "user", + "state_delta": {"seat": "window"}, + }, + ) + ], + ) + + session = await service.get_session( + app_name="app", + user_id="alice", + session_id="session-1", + ) + + assert session is not None + assert session.id == "session-1" + assert session.state == {"seat": "window"} + assert session.events[0].id == "event-1" + assert session.events[0].content.parts[0].text == "I prefer window seats." + + @pytest.mark.asyncio + async def test_list_sessions_resolves_storage_ids_from_metadata( + self, service, fake_client + ): + """Test list_sessions resolves managed storage IDs from event metadata.""" + storage_id = service._storage_session_id( + app_name="app", + user_id="alice", + session_id="28a7d6e2-0f0f-43b6-b22c-47e03635ed34", + ) + fake_client.get_response = SimpleNamespace( + session_id=storage_id, + owner_id="alice", + events=[ + SimpleNamespace( + metadata={ + "namespace": "test-ns", + "user_id": "alice", + "adk_session_id": "28a7d6e2-0f0f-43b6-b22c-47e03635ed34", + } + ) + ], + ) + fake_client.list_response = SimpleNamespace( + items=[storage_id], + next_page_token=None, + ) + + response = await service.list_sessions(app_name="app", user_id="alice") + + assert [session.id for session in response.sessions] == [ + "28a7d6e2-0f0f-43b6-b22c-47e03635ed34" + ] + + @pytest.mark.asyncio + async def test_list_sessions_filters_internal_ids(self, service, fake_client): + """Test list_sessions filters by namespace and user.""" + storage_id = service._storage_session_id( + app_name="app", + user_id="alice", + session_id="session-1", + ) + fake_client.get_response = SimpleNamespace( + session_id=storage_id, + owner_id="alice", + events=[ + SimpleNamespace( + metadata={ + "namespace": "test-ns", + "user_id": "alice", + "adk_session_id": "session-1", + } + ) + ], + ) + fake_client.list_response = SimpleNamespace( + items=[storage_id], + next_page_token=None, + ) + + response = await service.list_sessions(app_name="app", user_id="alice") + + assert [session.id for session in response.sessions] == ["session-1"] + response_other_user = await service.list_sessions( + app_name="app", user_id="bob" + ) + assert response_other_user.sessions == [] + + @pytest.mark.asyncio + async def test_close_completes_without_error(self, service): """Test close method completes without error.""" - # The close method no longer caches or closes clients - # Just verify it can be called without error await service.close() + + @pytest.mark.asyncio + async def test_list_sessions_can_use_agent_memory_server_backend(self): + """Test list_sessions can use the self-hosted backend.""" + fake_client = FakeAgentMemoryServerClient() + fake_client.list_response = SimpleNamespace(sessions=["session-1"]) + service = RedisWorkingMemorySessionService( + RedisWorkingMemorySessionServiceConfig( + backend="opensource-agent-memory", + default_namespace="test_ns", + ) + ) + + with patch.object( + service, + "_get_agent_memory_server_client", + return_value=fake_client, + ): + response = await service.list_sessions(app_name="app", user_id="alice") + + assert fake_client.list_kwargs == { + "namespace": "test_ns", + "user_id": "alice", + } + assert [session.id for session in response.sessions] == ["session-1"] diff --git a/tests/tools/test_memory_tools.py b/tests/tools/test_memory_tools.py new file mode 100644 index 0000000..048c3e5 --- /dev/null +++ b/tests/tools/test_memory_tools.py @@ -0,0 +1,192 @@ +# Copyright 2025 Redis, Inc. +# +# 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. + +"""Tests for Redis Agent Memory tools.""" + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from adk_redis.tools.memory import CreateMemoryTool +from adk_redis.tools.memory import DeleteMemoryTool +from adk_redis.tools.memory import MemoryToolConfig +from adk_redis.tools.memory import SearchMemoryTool +from adk_redis.tools.memory import UpdateMemoryTool + + +class FakeAgentMemory: + """Fake async Redis Agent Memory client.""" + + def __init__(self): + self.created_records = [] + self.deleted_ids = [] + self.search_request = None + self.update_kwargs = None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + async def bulk_create_long_term_memories_async(self, *, memories): + self.created_records.extend(memories) + return SimpleNamespace( + created=[memory["id"] for memory in memories], + errors=None, + ) + + async def search_long_term_memory_async(self, *, request): + self.search_request = request + return SimpleNamespace( + items=[ + SimpleNamespace( + id="memory-1", + text="The user prefers window seats.", + topics=["travel"], + memory_type="semantic", + created_at=None, + ) + ] + ) + + async def update_long_term_memory_async(self, **kwargs): + self.update_kwargs = kwargs + return SimpleNamespace(id=kwargs["memory_id"]) + + async def bulk_delete_long_term_memories_async(self, *, memory_ids): + self.deleted_ids.extend(memory_ids) + return SimpleNamespace(deleted=memory_ids, errors=None) + + +class FakeAgentMemoryServerClient: + """Fake self-hosted Agent Memory Server client.""" + + def __init__(self): + self.add_memory_kwargs = None + + async def add_memory_tool(self, **kwargs): + self.add_memory_kwargs = kwargs + return { + "success": True, + "memory_id": "memory-1", + "summary": "Memory created successfully", + } + + +@pytest.fixture +def config(): + """Create memory tool config.""" + return MemoryToolConfig( + default_namespace="test_ns", + default_owner_id="alice", + ) + + +@pytest.fixture +def fake_client(): + """Create fake Redis Agent Memory client.""" + return FakeAgentMemory() + + +def test_memory_tool_config_accepts_opensource_backend(): + """Memory tool config accepts the self-hosted backend value.""" + assert ( + MemoryToolConfig(backend="opensource-agent-memory").backend + == "opensource-agent-memory" + ) + + +@pytest.mark.asyncio +async def test_create_memory_tool_writes_record(config, fake_client): + """CreateMemoryTool writes a Redis Agent Memory record.""" + assert config.backend == "redis-agent-memory" + tool = CreateMemoryTool(config=config) + with patch.object(tool, "_get_client", return_value=fake_client): + result = await tool.run_async(args={"content": "User likes tea."}) + + assert result["status"] == "success" + assert fake_client.created_records[0]["text"] == "User likes tea." + assert fake_client.created_records[0]["ownerId"] == "alice" + assert fake_client.created_records[0]["namespace"] == "test-ns" + assert fake_client.created_records[0]["memoryType"] == "semantic" + + +@pytest.mark.asyncio +async def test_search_memory_tool_uses_owner_and_namespace_filter( + config, fake_client +): + """SearchMemoryTool scopes search by owner and namespace.""" + tool = SearchMemoryTool(config=config) + with patch.object(tool, "_get_client", return_value=fake_client): + result = await tool.run_async(args={"query": "seat"}) + + assert result["status"] == "success" + assert fake_client.search_request["filter"] == { + "namespace": {"eq": "test-ns"}, + "ownerId": {"eq": "alice"}, + } + + +@pytest.mark.asyncio +async def test_update_memory_tool_calls_update(config, fake_client): + """UpdateMemoryTool calls Redis Agent Memory update.""" + tool = UpdateMemoryTool(config=config) + with patch.object(tool, "_get_client", return_value=fake_client): + result = await tool.run_async( + args={"memory_id": "memory-1", "content": "Updated"} + ) + + assert result["status"] == "success" + assert fake_client.update_kwargs == { + "memory_id": "memory-1", + "text": "Updated", + } + + +@pytest.mark.asyncio +async def test_delete_memory_tool_calls_bulk_delete(config, fake_client): + """DeleteMemoryTool deletes memory IDs.""" + tool = DeleteMemoryTool(config=config) + with patch.object(tool, "_get_client", return_value=fake_client): + result = await tool.run_async(args={"memory_ids": ["memory-1"]}) + + assert result["status"] == "success" + assert result["deleted_count"] == 1 + assert fake_client.deleted_ids == ["memory-1"] + + +@pytest.mark.asyncio +async def test_create_memory_tool_can_use_agent_memory_server_backend(): + """CreateMemoryTool can write through the self-hosted backend.""" + fake_client = FakeAgentMemoryServerClient() + config = MemoryToolConfig( + backend="opensource-agent-memory", + default_namespace="test_ns", + default_user_id="alice", + ) + tool = CreateMemoryTool(config=config) + + with patch.object( + tool, + "_get_agent_memory_server_client", + return_value=fake_client, + ): + result = await tool.run_async(args={"content": "User likes tea."}) + + assert result["status"] == "success" + assert fake_client.add_memory_kwargs["text"] == "User likes tea." + assert fake_client.add_memory_kwargs["namespace"] == "test_ns" + assert fake_client.add_memory_kwargs["user_id"] == "alice"