Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/concepts/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ agent = Agent(
| `DeleteMemoryTool` | Remove memories by ID |
| `MemoryPromptTool` | Enrich a system prompt with relevant memories |

When a tool runs inside an ADK agent loop, it resolves the user in this order:
an explicit `user_id` argument, the invocation user from the ADK tool context,
`default_owner_id`, then `default_user_id`. Configured defaults are only used
when no per-call or context user is available.
Comment thread
nkanu17 marked this conversation as resolved.

## MCP vs SDK Decision

| | MCP | SDK Tools |
Expand All @@ -160,6 +165,8 @@ agent = Agent(
| `store_id` | `None` | Redis Agent Memory store ID |
| `timeout` | `30` | HTTP timeout in seconds |
| `default_namespace` | `default` | Namespace for memory isolation |
| `default_owner_id` | `None` | Default owner ID used when no per-call or context user is available |
| `default_user_id` | `None` | Legacy default user ID fallback after `default_owner_id` |
| `search_top_k` | `10` | Default max search results |
| `distance_threshold` | `None` | Compatibility alias for search threshold |
| `deduplicate` | `True` | Deduplicate when creating memories |
Expand Down
62 changes: 57 additions & 5 deletions src/adk_redis/tools/memory/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

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 read_field
from adk_redis.memory._utils import sanitize_managed_identifier
from adk_redis.tools.memory._config import MemoryToolConfig

Expand Down Expand Up @@ -184,15 +185,66 @@ def _get_namespace(self, namespace: str | None = None) -> str:
return sanitize_managed_identifier(resolved)
return resolved

def _get_user_id(self, user_id: str | None = None) -> str | None:
def _get_user_id(
self,
user_id: str | None = None,
tool_context: Any = None,
) -> str | None:
"""Get the user ID to use for operations.

Resolution order:

1. Explicit ``user_id`` argument.
2. The invocation user from the ADK ``tool_context`` (its public
``user_id`` property), when the tool runs inside an ADK agent loop.
3. ``config.default_owner_id``.
4. ``config.default_user_id``.

Args:
user_id: Optional user ID override.
tool_context: Optional ADK ToolContext for the current invocation.
Read defensively; a missing or broken context never raises.

Returns:
The user ID to use (override or default).
The resolved user ID, or None if no source provides one.
"""
return (
user_id or self._config.default_owner_id or self._config.default_user_id
)
if user_id:
return user_id

try:
context_user_id = getattr(tool_context, "user_id", None)
except Exception:
context_user_id = None
if isinstance(context_user_id, str) and context_user_id:
return context_user_id

return self._config.default_owner_id or self._config.default_user_id

def _require_memory_scope(
self,
memory: object,
*,
namespace: str,
user_id: str | None,
) -> None:
"""Require a memory record to belong to the resolved operation scope.

Args:
memory: Backend memory record to validate.
namespace: Resolved namespace for the current invocation.
user_id: Resolved user for the current invocation, if any.

Raises:
PermissionError: If the memory is outside the resolved scope.
"""
memory_namespace = read_field(memory, "namespace")
memory_user_id = read_field(memory, "owner_id")
if memory_user_id is None:
memory_user_id = read_field(memory, "user_id")

if memory_namespace != namespace or (
user_id is not None and memory_user_id != user_id
):
raise PermissionError(
"Memory is outside the resolved namespace or user scope"
)
7 changes: 5 additions & 2 deletions src/adk_redis/tools/memory/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,19 +123,22 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]:
topics: Optional list of topics/tags.
memory_type: Type of memory (semantic, episodic, message).
namespace: Optional namespace override.
user_id: Optional user ID override.
user_id: Optional user ID override. When omitted, the user is
resolved from the ADK tool_context invocation user, then the
configured defaults.

Returns:
A dictionary with status and memory_id.
"""
# ADK passes parameters in kwargs['args']
args = kwargs.get("args", kwargs)
tool_context = kwargs.get("tool_context")

content = args.get("content")
topics = args.get("topics", [])
memory_type_raw = args.get("memory_type", "semantic")
namespace = self._get_namespace(args.get("namespace"))
user_id = self._get_user_id(args.get("user_id"))
user_id = self._get_user_id(args.get("user_id"), tool_context=tool_context)

if not content:
return {"status": "error", "message": "content is required"}
Expand Down
48 changes: 43 additions & 5 deletions src/adk_redis/tools/memory/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@

from __future__ import annotations

import asyncio
import logging
import re
from typing import Any
from typing import Any, Awaitable, Callable

from google.genai import types

Expand All @@ -29,6 +30,8 @@

logger = logging.getLogger("adk_redis." + __name__)

_PREFLIGHT_BATCH_SIZE = 10


class DeleteMemoryTool(BaseMemoryTool):
"""Tool for deleting long-term memories.
Expand Down Expand Up @@ -103,32 +106,61 @@ def _get_declaration(self) -> types.FunctionDeclaration:
),
)

async def _validate_memory_scopes(
self,
*,
memory_ids: list[str],
get_memory: Callable[..., Awaitable[object]],
namespace: str,
user_id: str | None,
) -> None:
"""Validate memory scopes with bounded concurrent backend reads."""
for start in range(0, len(memory_ids), _PREFLIGHT_BATCH_SIZE):
batch = memory_ids[start : start + _PREFLIGHT_BATCH_SIZE]
memories = await asyncio.gather(
*(get_memory(memory_id=memory_id) for memory_id in batch)
)
for memory in memories:
self._require_memory_scope(
memory,
namespace=namespace,
user_id=user_id,
)

async def run_async(self, **kwargs: Any) -> dict[str, Any]:
"""Delete long-term memories by ID.

Args:
memory_ids: List of memory IDs to delete.
namespace: Optional namespace override.
user_id: Optional user ID override.
user_id: Optional user ID override. When omitted, the user is
resolved from the ADK tool_context invocation user, then the
configured defaults.
Comment thread
nkanu17 marked this conversation as resolved.

Returns:
A dictionary with status and deleted_count.
"""
# ADK passes parameters in kwargs['args']
args = kwargs.get("args", kwargs)
tool_context = kwargs.get("tool_context")

memory_ids = args.get("memory_ids", [])
self._get_namespace(args.get("namespace"))
self._get_user_id(args.get("user_id"))
namespace = self._get_namespace(args.get("namespace"))
user_id = self._get_user_id(args.get("user_id"), tool_context=tool_context)

if not memory_ids:
return {"status": "error", "message": "memory_ids is required"}

try:
if self._config.backend == OPENSOURCE_AGENT_MEMORY_BACKEND:
response = await self._get_agent_memory_server_client().delete_long_term_memories(
client = self._get_agent_memory_server_client()
await self._validate_memory_scopes(
memory_ids=memory_ids,
get_memory=client.get_long_term_memory,
namespace=namespace,
user_id=user_id,
)
response = await 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
Expand All @@ -141,6 +173,12 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]:
}

async with self._agent_memory() as agent_memory:
await self._validate_memory_scopes(
memory_ids=memory_ids,
get_memory=agent_memory.get_long_term_memory_async,
namespace=namespace,
user_id=user_id,
)
response = await agent_memory.bulk_delete_long_term_memories_async(
memory_ids=memory_ids,
)
Expand Down
7 changes: 5 additions & 2 deletions src/adk_redis/tools/memory/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,21 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]:
query: The query to search for relevant memories.
system_prompt: Optional base system prompt to enrich.
namespace: Optional namespace override.
user_id: Optional user ID override.
user_id: Optional user ID override. When omitted, the user is
resolved from the ADK tool_context invocation user, then the
configured defaults.

Returns:
A dictionary with status and enriched_prompt.
"""
# ADK passes parameters in kwargs['args']
args = kwargs.get("args", kwargs)
tool_context = kwargs.get("tool_context")

query = args.get("query")
system_prompt = args.get("system_prompt", "")
namespace = self._get_namespace(args.get("namespace"))
user_id = self._get_user_id(args.get("user_id"))
user_id = self._get_user_id(args.get("user_id"), tool_context=tool_context)

if not query:
return {"status": "error", "message": "query is required"}
Expand Down
7 changes: 5 additions & 2 deletions src/adk_redis/tools/memory/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,21 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]:
query: The search query.
limit: Maximum number of memories to return.
namespace: Optional namespace override.
user_id: Optional user ID override.
user_id: Optional user ID override. When omitted, the user is
resolved from the ADK tool_context invocation user, then the
configured defaults.

Returns:
A dictionary with status and list of memories.
"""
# ADK passes parameters in kwargs['args']
args = kwargs.get("args", kwargs)
tool_context = kwargs.get("tool_context")

query = args.get("query")
limit = args.get("limit", self._config.search_top_k)
namespace = self._get_namespace(args.get("namespace"))
user_id = self._get_user_id(args.get("user_id"))
user_id = self._get_user_id(args.get("user_id"), tool_context=tool_context)

if not query:
return {"status": "error", "message": "query is required"}
Expand Down
21 changes: 19 additions & 2 deletions src/adk_redis/tools/memory/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,19 +118,22 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]:
content: New content for the memory.
topics: New list of topics/tags.
namespace: Optional namespace override.
user_id: Optional user ID override.
user_id: Optional user ID override. When omitted, the user is
resolved from the ADK tool_context invocation user, then the
configured defaults.

Returns:
A dictionary with status and updated memory info.
"""
# ADK passes parameters in kwargs['args']
args = kwargs.get("args", kwargs)
tool_context = kwargs.get("tool_context")

memory_id = args.get("memory_id")
content = args.get("content")
topics = args.get("topics")
namespace = self._get_namespace(args.get("namespace"))
user_id = self._get_user_id(args.get("user_id"))
user_id = self._get_user_id(args.get("user_id"), tool_context=tool_context)
Comment thread
nkanu17 marked this conversation as resolved.

if not memory_id:
return {"status": "error", "message": "memory_id is required"}
Expand All @@ -150,6 +153,12 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]:
updates["topics"] = topics

client = self._get_agent_memory_server_client()
memory = await client.get_long_term_memory(memory_id=memory_id)
self._require_memory_scope(
memory,
namespace=namespace,
user_id=user_id,
)
response = await client.edit_long_term_memory(
memory_id=memory_id,
updates=updates,
Expand All @@ -171,6 +180,14 @@ async def run_async(self, **kwargs: Any) -> dict[str, Any]:
update_kwargs["owner_id"] = user_id

async with self._agent_memory() as agent_memory:
memory = await agent_memory.get_long_term_memory_async(
memory_id=memory_id
)
self._require_memory_scope(
memory,
namespace=namespace,
user_id=user_id,
)
response = await agent_memory.update_long_term_memory_async(
memory_id=memory_id,
**update_kwargs,
Expand Down
Loading
Loading