From 5f585ff5166eba59356d06a8187200d51b233069 Mon Sep 17 00:00:00 2001 From: Arul Date: Fri, 18 Sep 2026 15:38:28 +0530 Subject: [PATCH] feat(memory): add session and user memory deletion APIs Session deletion in session services leaves previously ingested conversations in memory services without a removal path. Add `delete_session_memory` and `delete_user_memory` to `BaseMemoryService`, implement them in `InMemoryMemoryService`, and expose corresponding convenience helpers on `Context` for retention lifecycle management and right-to-be-forgotten / GDPR compliance. Closes #7109 --- docs/guides/memory/memory_service/index.md | 18 +- src/google/adk/agents/context.py | 39 ++++ src/google/adk/memory/base_memory_service.py | 64 ++++- .../adk/memory/in_memory_memory_service.py | 28 ++- tests/unittests/agents/test_context.py | 73 ++++++ .../memory/test_in_memory_memory_service.py | 220 ++++++++++++++++++ 6 files changed, 438 insertions(+), 4 deletions(-) diff --git a/docs/guides/memory/memory_service/index.md b/docs/guides/memory/memory_service/index.md index 09d97562e86..ff72c182868 100644 --- a/docs/guides/memory/memory_service/index.md +++ b/docs/guides/memory/memory_service/index.md @@ -134,6 +134,21 @@ 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. +### Lifecycle & Removal + +Memory persists independently of sessions. Deleting a session in a `BaseSessionService` +does not remove what was previously ingested into memory. To purge memories, +`BaseMemoryService` provides explicit lifecycle methods: + +* `delete_session_memory(*, app_name, user_id, session_id)` removes all memory + entries associated with a specific session. +* `delete_user_memory(*, app_name, user_id)` removes all memories for a user, + supporting right-to-be-forgotten / GDPR deletion requirements. + +Inside an agent callback, `Context` provides matching helpers +`await ctx.delete_session_memory()` and `await ctx.delete_user_memory()`. Services +that do not support deletion raise `NotImplementedError`. + ### From inside an agent `Context` — what tools and callbacks receive — exposes the same operations @@ -150,7 +165,8 @@ async def save_to_memory(callback_context: Context) -> None: Attach that as an `after_agent_callback` and each turn is ingested as it finishes, rather than at some later point you have to remember to trigger. -`Context` also offers `add_events_to_memory`, `add_memory`, and `search_memory`. +`Context` also offers `add_events_to_memory`, `add_memory`, +`search_memory`, `delete_session_memory`, and `delete_user_memory`. ## The memory tools diff --git a/src/google/adk/agents/context.py b/src/google/adk/agents/context.py index a6830b790a6..b0139239d3a 100644 --- a/src/google/adk/agents/context.py +++ b/src/google/adk/agents/context.py @@ -817,6 +817,45 @@ async def search_memory(self, query: str) -> SearchMemoryResponse: query=query, ) + async def delete_session_memory( + self, + session_id: str | None = None, + ) -> None: + """Removes memory items for the current (or specified) session. + + Args: + session_id: Optional session ID to delete. Defaults to the current + session ID. + + Raises: + ValueError: If memory service is not available. + """ + if self._invocation_context.memory_service is None: + raise ValueError( + 'Cannot delete session memory: memory service is not available.' + ) + target_session_id = session_id or self._invocation_context.session.id + await self._invocation_context.memory_service.delete_session_memory( + app_name=self._invocation_context.app_name, + user_id=self._invocation_context.user_id, + session_id=target_session_id, + ) + + async def delete_user_memory(self) -> None: + """Removes all memories for the current user. + + Raises: + ValueError: If memory service is not available. + """ + if self._invocation_context.memory_service is None: + raise ValueError( + 'Cannot delete user memory: memory service is not available.' + ) + await self._invocation_context.memory_service.delete_user_memory( + app_name=self._invocation_context.app_name, + user_id=self._invocation_context.user_id, + ) + # ============================================================================ # UI Widget methods # ============================================================================ diff --git a/src/google/adk/memory/base_memory_service.py b/src/google/adk/memory/base_memory_service.py index 55b4e8d0e9c..d154f75e8fa 100644 --- a/src/google/adk/memory/base_memory_service.py +++ b/src/google/adk/memory/base_memory_service.py @@ -45,7 +45,19 @@ class BaseMemoryService(ABC): """Base class for memory services. The service provides functionality to ingest conversation history into memory - so that it can be used for user queries. + so that it can be used for user queries, as well as removal APIs to manage + retention and data lifecycles. + + ### Retention & Lifecycle Management + Session services persist conversational history within an active session, while + memory services persist durable recall across sessions. Deleting a session via + a `BaseSessionService` removes only the session record; memories previously + ingested or synthesized from that session remain in the memory service until + explicitly removed. + + Callers can use `delete_session_memory` to remove memories associated with a + specific session, or `delete_user_memory` to purge all memories for a user. + Services that do not support deletion will raise `NotImplementedError`. """ @abstractmethod @@ -138,3 +150,53 @@ async def search_memory( Returns: A SearchMemoryResponse containing the matching memories. """ + + async def delete_session_memory( + self, + *, + app_name: str, + user_id: str, + session_id: str, + ) -> None: + """Removes all memory items associated with a specific session. + + This enables retention lifecycle management when a session is closed or + deleted, ensuring that memories ingested from that session (e.g., via + `add_session_to_memory` or `add_events_to_memory`) do not outlive the + intended retention window. + + Args: + app_name: The application name for memory scope. + user_id: The user ID for memory scope. + session_id: The session ID whose memories should be removed. + + Raises: + NotImplementedError: If the memory service does not support session memory + deletion. + """ + raise NotImplementedError( + "This memory service does not support session memory deletion." + ) + + async def delete_user_memory( + self, + *, + app_name: str, + user_id: str, + ) -> None: + """Removes all memories associated with a user. + + This provides a complete lifecycle removal path for user data (e.g., for + right-to-be-forgotten / GDPR compliance or user account deletion). + + Args: + app_name: The application name for memory scope. + user_id: The user ID whose memories should be removed. + + Raises: + NotImplementedError: If the memory service does not support user memory + deletion. + """ + raise NotImplementedError( + "This memory service does not support user memory deletion." + ) diff --git a/src/google/adk/memory/in_memory_memory_service.py b/src/google/adk/memory/in_memory_memory_service.py index 124915314d0..76ec7c95ffa 100644 --- a/src/google/adk/memory/in_memory_memory_service.py +++ b/src/google/adk/memory/in_memory_memory_service.py @@ -141,8 +141,6 @@ async def search_memory( with self._lock: # Copy the events into a stable snapshot while holding the lock. Iterating # a live reference outside the lock would race with concurrent writers - # (add_session_to_memory / add_events_to_memory) mutating the same dict - # and lists, raising "dictionary changed size during iteration". session_event_lists = [ list(events) for events in self._session_events.get(user_key, {}).values() @@ -188,3 +186,29 @@ async def search_memory( return SearchMemoryResponse( memories=[memory for _, memory in scored_memories[:_MAX_SEARCH_RESULTS]] ) + + @override + async def delete_session_memory( + self, + *, + app_name: str, + user_id: str, + session_id: str, + ) -> None: + user_key = _user_key(app_name, user_id) + with self._lock: + if user_key in self._session_events: + self._session_events[user_key].pop(session_id, None) + if not self._session_events[user_key]: + del self._session_events[user_key] + + @override + async def delete_user_memory( + 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) diff --git a/tests/unittests/agents/test_context.py b/tests/unittests/agents/test_context.py index a55f8b1782d..bfc69a2f268 100644 --- a/tests/unittests/agents/test_context.py +++ b/tests/unittests/agents/test_context.py @@ -564,6 +564,79 @@ async def test_add_memory_no_service_raises(self, mock_invocation_context): ] ) + async def test_delete_session_memory_default_current_session( + self, mock_invocation_context + ): + + memory_service = AsyncMock() + mock_invocation_context.memory_service = memory_service + + context = Context(mock_invocation_context) + await context.delete_session_memory() + + memory_service.delete_session_memory.assert_called_once_with( + app_name=mock_invocation_context.app_name, + user_id=mock_invocation_context.user_id, + session_id=mock_invocation_context.session.id, + ) + + async def test_delete_session_memory_explicit_session_id( + self, mock_invocation_context + ): + + memory_service = AsyncMock() + mock_invocation_context.memory_service = memory_service + + context = Context(mock_invocation_context) + await context.delete_session_memory(session_id="custom-session-id") + + memory_service.delete_session_memory.assert_called_once_with( + app_name=mock_invocation_context.app_name, + user_id=mock_invocation_context.user_id, + session_id="custom-session-id", + ) + + async def test_delete_session_memory_no_service_raises( + self, mock_invocation_context + ): + + mock_invocation_context.memory_service = None + + context = Context(mock_invocation_context) + with pytest.raises( + ValueError, + match=( + r"Cannot delete session memory: memory service is not available\." + ), + ): + await context.delete_session_memory() + + async def test_delete_user_memory_success(self, mock_invocation_context): + + memory_service = AsyncMock() + mock_invocation_context.memory_service = memory_service + + context = Context(mock_invocation_context) + await context.delete_user_memory() + + memory_service.delete_user_memory.assert_called_once_with( + app_name=mock_invocation_context.app_name, + user_id=mock_invocation_context.user_id, + ) + + async def test_delete_user_memory_no_service_raises( + self, mock_invocation_context + ): + + mock_invocation_context.memory_service = None + + context = Context(mock_invocation_context) + with pytest.raises( + ValueError, + match=r"Cannot delete user memory: memory service is not available\.", + ): + await context.delete_user_memory() + class TestContextAddUiWidget: """Test render_ui_widget method in Context.""" diff --git a/tests/unittests/memory/test_in_memory_memory_service.py b/tests/unittests/memory/test_in_memory_memory_service.py index 1e95bfceb16..8cf347879a9 100644 --- a/tests/unittests/memory/test_in_memory_memory_service.py +++ b/tests/unittests/memory/test_in_memory_memory_service.py @@ -17,6 +17,8 @@ import unicodedata 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.in_memory_memory_service import InMemoryMemoryService from google.adk.platform import thread as platform_thread from google.adk.sessions.session import Session @@ -602,3 +604,221 @@ def reader(): assert ( not errors ), f'search_memory raced with concurrent writes: {errors[0]!r}' + + +@pytest.mark.asyncio +async def test_delete_session_memory(): + """Tests that delete_session_memory purges memories for a specific session.""" + memory_service = InMemoryMemoryService() + await memory_service.add_session_to_memory(MOCK_SESSION_1) + await memory_service.add_session_to_memory(MOCK_SESSION_2) + + + res1 = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='ADK' + ) + assert len(res1.memories) == 2 + + res2 = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='Python' + ) + assert len(res2.memories) == 1 + + + await memory_service.delete_session_memory( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + session_id=MOCK_SESSION_1.id, + ) + + + res1_after = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='ADK' + ) + assert not res1_after.memories + + + res2_after = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='Python' + ) + assert len(res2_after.memories) == 1 + + +@pytest.mark.asyncio +async def test_delete_session_memory_cleans_up_empty_user_bucket(): + """Tests that deleting the last session memory removes the user key.""" + memory_service = InMemoryMemoryService() + await memory_service.add_session_to_memory(MOCK_SESSION_1) + + user_key = (MOCK_APP_NAME, MOCK_USER_ID) + assert user_key in memory_service._session_events + + await memory_service.delete_session_memory( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + session_id=MOCK_SESSION_1.id, + ) + + assert user_key not in memory_service._session_events + + +@pytest.mark.asyncio +async def test_delete_session_memory_nonexistent_is_noop(): + """Tests that deleting a nonexistent session or user does not error.""" + memory_service = InMemoryMemoryService() + + await memory_service.delete_session_memory( + app_name=MOCK_APP_NAME, + user_id='nonexistent-user', + session_id='nonexistent-session', + ) + + + await memory_service.add_session_to_memory(MOCK_SESSION_1) + await memory_service.delete_session_memory( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + session_id='nonexistent-session', + ) + user_key = (MOCK_APP_NAME, MOCK_USER_ID) + assert MOCK_SESSION_1.id in memory_service._session_events[user_key] + + +@pytest.mark.asyncio +async def test_delete_user_memory(): + """Tests that delete_user_memory removes all sessions for a user.""" + 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_user_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID + ) + + + res1 = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='ADK' + ) + assert not res1.memories + res2 = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='Python' + ) + assert not res2.memories + + res_other = await memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_OTHER_USER_ID, query='secret' + ) + assert len(res_other.memories) == 1 + + +@pytest.mark.asyncio +async def test_delete_user_memory_nonexistent_is_noop(): + """Tests that deleting user memory for an unknown user is a no-op.""" + memory_service = InMemoryMemoryService() + await memory_service.delete_user_memory( + app_name=MOCK_APP_NAME, user_id='nonexistent-user' + ) + + +@pytest.mark.asyncio +async def test_base_memory_service_default_deletion_methods(): + """Tests that BaseMemoryService default deletion methods raise NotImplementedError.""" + + class _DummyMemoryService(BaseMemoryService): + + async def add_session_to_memory(self, session: Session) -> None: + pass + + async def search_memory( + self, *, app_name: str, user_id: str, query: str + ) -> SearchMemoryResponse: + return SearchMemoryResponse() + + dummy = _DummyMemoryService() + with pytest.raises( + NotImplementedError, match='does not support session memory deletion' + ): + await dummy.delete_session_memory( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + session_id='session-1', + ) + + with pytest.raises( + NotImplementedError, match='does not support user memory deletion' + ): + await dummy.delete_user_memory( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + ) + + +def test_delete_memory_is_thread_safe_against_concurrent_searches(): + """Deleting memory while other threads search must not crash.""" + memory_service = InMemoryMemoryService() + seed_loop = asyncio.new_event_loop() + try: + for i in range(50): + seed_loop.run_until_complete( + memory_service.add_session_to_memory(_make_session(f'seed-{i}')) + ) + finally: + seed_loop.close() + + errors = [] + stop = threading.Event() + barrier = threading.Barrier(3) + + def deleter(): + loop = asyncio.new_event_loop() + barrier.wait() + try: + for i in range(50): + if stop.is_set(): + return + loop.run_until_complete( + memory_service.delete_session_memory( + app_name=MOCK_APP_NAME, + user_id=MOCK_USER_ID, + session_id=f'session-seed-{i}', + ) + ) + except Exception as e: + errors.append(e) + stop.set() + finally: + loop.close() + + def reader(): + loop = asyncio.new_event_loop() + barrier.wait() + try: + for _ in range(500): + if stop.is_set(): + return + loop.run_until_complete( + memory_service.search_memory( + app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='fact' + ) + ) + except Exception as e: + errors.append(e) + stop.set() + finally: + loop.close() + + threads = [ + platform_thread.create_thread(deleter), + platform_thread.create_thread(reader), + platform_thread.create_thread(reader), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert ( + not errors + ), f'search_memory raced with concurrent deletes: {errors[0]!r}'