Skip to content
Open
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
16 changes: 16 additions & 0 deletions docs/guides/memory/memory_service/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,22 @@ holding `memories`, a list of `MemoryEntry`. Each entry carries `content` (a
`custom_metadata`. Memory is scoped by the `(app_name, user_id)` pair, so one
user never sees another's memories.

### Deletion

Two optional methods remove memories. A service that does not support them
raises `NotImplementedError`. A missing memory, or a user with nothing stored,
is a no-op.

* `delete_memory(*, app_name, user_id, memory_id)` deletes one entry by the
`id` returned on `MemoryEntry`.
* `delete_memories(*, app_name, user_id)` deletes every memory stored for
that user.

`InMemoryMemoryService` and `VertexAiMemoryBankService` implement both.
`VertexAiMemoryBankService.delete_memory` wraps Memory Bank
`memories.delete` after checking the memory belongs to the requested
`(app_name, user_id)` scope.

### From inside an agent

`Context` — what tools and callbacks receive — exposes the same operations
Expand Down
32 changes: 32 additions & 0 deletions src/google/adk/memory/base_memory_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,35 @@ async def search_memory(
Returns:
A SearchMemoryResponse containing the matching memories.
"""

async def delete_memory(
self, *, app_name: str, user_id: str, memory_id: str
) -> None:
"""Deletes a single memory entry by id.

Missing memories are a no-op. Implementations that cannot delete by id
raise ``NotImplementedError``.

Args:
app_name: The application name for memory scope.
user_id: The user ID for memory scope.
memory_id: The id of the memory to delete. This is the ``MemoryEntry.id``
returned by ``search_memory`` or supplied to ``add_memory``.
"""
raise NotImplementedError(
"This memory service does not support deleting memories."
)

async def delete_memories(self, *, app_name: str, user_id: str) -> None:
"""Deletes every memory stored for the given app and user.

A user with no stored memories is a no-op. Implementations that cannot
delete memories raise ``NotImplementedError``.

Args:
app_name: The application name for memory scope.
user_id: The user ID for memory scope.
"""
raise NotImplementedError(
"This memory service does not support deleting memories."
)
23 changes: 23 additions & 0 deletions src/google/adk/memory/in_memory_memory_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ async def search_memory(
content=event.content,
author=event.author,
timestamp=_utils.format_timestamp(event.timestamp),
id=event.id or None,
),
))

Expand All @@ -158,3 +159,25 @@ async def search_memory(
return SearchMemoryResponse(
memories=[memory for _, memory in scored_memories[:_MAX_SEARCH_RESULTS]]
)

@override
async def delete_memory(
self, *, app_name: str, user_id: str, memory_id: str
) -> None:
user_key = _user_key(app_name, user_id)

with self._lock:
session_events = self._session_events.get(user_key)
if not session_events:
return
for session_id, events in session_events.items():
remaining = [event for event in events if event.id != memory_id]
if len(remaining) != len(events):
session_events[session_id] = remaining

@override
async def delete_memories(self, *, app_name: str, user_id: str) -> None:
user_key = _user_key(app_name, user_id)

with self._lock:
self._session_events.pop(user_key, None)
135 changes: 135 additions & 0 deletions src/google/adk/memory/vertex_ai_memory_bank_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
import datetime
from functools import lru_cache
import logging
import re
from typing import Optional
from typing import TYPE_CHECKING

from google.auth.credentials import Credentials
from google.genai import types
from google.genai.errors import ClientError
from typing_extensions import override

from ..utils.vertex_ai_utils import get_express_mode_api_key
Expand Down Expand Up @@ -83,6 +85,8 @@

_ENABLE_CONSOLIDATION_KEY = 'enable_consolidation'

_MEMORY_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$')


def _should_use_generate_memories(
custom_metadata: Mapping[str, object] | None,
Expand Down Expand Up @@ -579,6 +583,7 @@ async def search_memory(
role='user',
),
timestamp=update_time.isoformat() if update_time else None,
id=_memory_id_from_resource(memory),
)
)
except AttributeError:
Expand All @@ -592,6 +597,77 @@ async def search_memory(
)
return SearchMemoryResponse(memories=memory_events)

@override
async def delete_memory(
self, *, app_name: str, user_id: str, memory_id: str
) -> None:
"""Deletes one Memory Bank memory after confirming it belongs to the user.

A missing memory is a no-op. A memory that exists but belongs to a
different ``(app_name, user_id)`` scope raises ``ValueError``.
"""
memory_resource_name = _memory_resource_name(
self._agent_engine_id, memory_id
)
api_client = self._get_api_client()
try:
existing = await api_client.agent_engines.memories.get(
name=memory_resource_name
)
except ClientError as e:
if e.code == 404:
return
raise
if not _memory_belongs_to_scope(
existing, app_name=app_name, user_id=user_id
):
raise ValueError(f'Memory {memory_id} does not belong to user {user_id}.')

try:
await api_client.agent_engines.memories.delete(name=memory_resource_name)
except ClientError as e:
if e.code == 404:
return
raise

@override
async def delete_memories(self, *, app_name: str, user_id: str) -> None:
"""Deletes every Memory Bank memory stored for the given app and user.

Lists memories for the scope, then wraps ``memories.delete`` for each.
"""
api_client = self._get_api_client()
retrieved_memories_iterator = (
await api_client.agent_engines.memories.retrieve(
name='reasoningEngines/' + self._agent_engine_id,
scope={
'app_name': app_name,
'user_id': user_id,
},
)
)

memory_names: list[str] = []
try:
async for retrieved_memory in retrieved_memories_iterator:
memory = getattr(retrieved_memory, 'memory', None)
if memory is None:
continue
name = getattr(memory, 'name', None)
if isinstance(name, str) and name:
memory_names.append(name)
except Exception:
logger.exception('Error while listing memories to delete.')
raise

for memory_name in memory_names:
try:
await api_client.agent_engines.memories.delete(name=memory_name)
except ClientError as e:
if e.code == 404:
continue
raise

async def retrieve_profiles(
self,
*,
Expand Down Expand Up @@ -1033,3 +1109,62 @@ def _to_vertex_metadata_value(
)
return None
return {'string_value': str(value)}


def _extract_short_memory_id(
memory_id: str, expected_engine_id: str | None = None
) -> str:
"""Extracts the short memory ID if a full resource name is provided."""
if '/' in memory_id:
parts = memory_id.split('/')
if len(parts) >= 2 and parts[-2] == 'memories':
if (
len(parts) >= 4
and parts[-4] == 'reasoningEngines'
and expected_engine_id
):
passed_engine_id = parts[-3]
if passed_engine_id != expected_engine_id:
raise ValueError(
'Memory resource name mismatch: memory belongs to '
f'reasoningEngine {passed_engine_id!r}, but service is '
f'configured for {expected_engine_id!r}.'
)
return parts[-1]
return memory_id


def _validate_memory_id(memory_id: str) -> None:
"""Rejects memory IDs that could escape the URL path segment."""
if not memory_id or not _MEMORY_ID_PATTERN.fullmatch(memory_id):
raise ValueError(
f'Invalid memory_id {memory_id!r}: must match'
f' {_MEMORY_ID_PATTERN.pattern}.'
)


def _memory_resource_name(agent_engine_id: str, memory_id: str) -> str:
"""Returns the Memory Bank resource name for a caller-supplied id."""
short_id = _extract_short_memory_id(
memory_id, expected_engine_id=agent_engine_id
)
_validate_memory_id(short_id)
return f'reasoningEngines/{agent_engine_id}/memories/{short_id}'


def _memory_id_from_resource(memory: object) -> str | None:
"""Returns the last path component of a Memory Bank resource name."""
name = getattr(memory, 'name', None)
if not isinstance(name, str) or not name:
return None
return name.rsplit('/', 1)[-1]


def _memory_belongs_to_scope(
memory: object, *, app_name: str, user_id: str
) -> bool:
"""Returns whether a Memory Bank memory is scoped to this app and user."""
scope = getattr(memory, 'scope', None)
if not isinstance(scope, Mapping):
return False
return scope.get('app_name') == app_name and scope.get('user_id') == user_id
109 changes: 109 additions & 0 deletions tests/unittests/memory/test_in_memory_memory_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ async def test_search_memory_simple_match():
assert len(result.memories) == 1
assert result.memories[0].content.parts[0].text == 'I like to code in Python.'
assert result.memories[0].author == 'user'
assert result.memories[0].id == 'event-2a'


@pytest.mark.asyncio
Expand Down Expand Up @@ -364,6 +365,114 @@ async def test_search_memory_does_not_collide_on_slash_in_identifiers():
assert not result.memories


@pytest.mark.asyncio
async def test_delete_memory_removes_entry_from_later_search():
"""Deleting a memory by id removes it from later search results."""
memory_service = InMemoryMemoryService()
await memory_service.add_session_to_memory(MOCK_SESSION_1)
await memory_service.add_session_to_memory(MOCK_SESSION_2)

await memory_service.delete_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, memory_id='event-2a'
)

result = await memory_service.search_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='Python'
)

assert not result.memories
remaining = await memory_service.search_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='ADK'
)
assert len(remaining.memories) == 2


@pytest.mark.asyncio
async def test_delete_memory_missing_id_is_a_noop():
"""Deleting an unknown memory id does not raise or change stored memories."""
memory_service = InMemoryMemoryService()
await memory_service.add_session_to_memory(MOCK_SESSION_1)

await memory_service.delete_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, memory_id='missing-id'
)

result = await memory_service.search_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='ADK'
)
assert len(result.memories) == 2


@pytest.mark.asyncio
async def test_delete_memory_does_not_remove_other_users_memory():
"""delete_memory is scoped to the requested user even when ids collide."""
memory_service = InMemoryMemoryService()
colliding_session = Session(
app_name=MOCK_APP_NAME,
user_id=MOCK_OTHER_USER_ID,
id='session-collision',
last_update_time=3000,
events=[
Event(
id='event-1a',
invocation_id='inv-collision',
author='user',
timestamp=60000,
content=types.Content(
parts=[types.Part(text='The ADK is a secret.')]
),
),
],
)
await memory_service.add_session_to_memory(MOCK_SESSION_1)
await memory_service.add_session_to_memory(colliding_session)

await memory_service.delete_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, memory_id='event-1a'
)

own_result = await memory_service.search_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='ADK'
)
other_result = await memory_service.search_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_OTHER_USER_ID, query='secret'
)
assert len(own_result.memories) == 1
assert len(other_result.memories) == 1


@pytest.mark.asyncio
async def test_delete_memories_clears_only_the_requested_user():
"""delete_memories wipes one user and leaves other users untouched."""
memory_service = InMemoryMemoryService()
await memory_service.add_session_to_memory(MOCK_SESSION_1)
await memory_service.add_session_to_memory(MOCK_SESSION_2)
await memory_service.add_session_to_memory(MOCK_SESSION_DIFFERENT_USER)

await memory_service.delete_memories(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID
)

own_result = await memory_service.search_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='ADK Python'
)
other_result = await memory_service.search_memory(
app_name=MOCK_APP_NAME, user_id=MOCK_OTHER_USER_ID, query='secret'
)
assert not own_result.memories
assert len(other_result.memories) == 1


@pytest.mark.asyncio
async def test_delete_memories_missing_user_is_a_noop():
"""Deleting memories for a user with none stored does not raise."""
memory_service = InMemoryMemoryService()

await memory_service.delete_memories(
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID
)


# --- Non-Latin language tests ---


Expand Down
Loading