From 566c902f80f2deae4dcdca55d1595f31c32074c9 Mon Sep 17 00:00:00 2001 From: Xiongwei Xiao <41932838+xxwtiancai@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:33:37 +0800 Subject: [PATCH 1/3] fix(memory): exclude thought parts from SQLite search results --- src/google/adk/memory/_sqlite_memory_service.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/google/adk/memory/_sqlite_memory_service.py b/src/google/adk/memory/_sqlite_memory_service.py index 9ae48aebbe0..ea7614d4351 100644 --- a/src/google/adk/memory/_sqlite_memory_service.py +++ b/src/google/adk/memory/_sqlite_memory_service.py @@ -553,6 +553,8 @@ def _escape_like_query(word: str) -> str: def _row_to_memory_entry(row: aiosqlite.Row) -> MemoryEntry: content = types.Content.model_validate_json(row["content_json"]) + if content.parts: + content.parts = [part for part in content.parts if not part.thought] return MemoryEntry( id=str(row["id"]), content=content, From 61a2e32443840004233634b4385f12ecbb1969d7 Mon Sep 17 00:00:00 2001 From: Xiongwei Xiao <41932838+xxwtiancai@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:35:01 +0800 Subject: [PATCH 2/3] test(memory): cover SQLite thought filtering and legacy rows --- .../memory/test_sqlite_memory_service.py | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/tests/unittests/memory/test_sqlite_memory_service.py b/tests/unittests/memory/test_sqlite_memory_service.py index cc0065985d1..2aa2e4bcde5 100644 --- a/tests/unittests/memory/test_sqlite_memory_service.py +++ b/tests/unittests/memory/test_sqlite_memory_service.py @@ -15,8 +15,11 @@ from __future__ import annotations import asyncio +from contextlib import closing +from datetime import datetime from pathlib import Path import sqlite3 +from typing import Literal import aiosqlite from google.adk.events.event import Event @@ -351,6 +354,195 @@ async def test_persistence_across_restarts(tmp_path, fts_mode): assert response.memories[0].custom_metadata["session_id"] == session.id +@pytest.mark.parametrize("in_memory", [False, True]) +@pytest.mark.parametrize("ingestion", ["events", "session"]) +async def test_search_returns_visible_parts_without_mutating_events( + tmp_path: Path, + fts_mode: Literal["off", "on"], + in_memory: bool, + ingestion: str, +) -> None: + """Search removes thoughts while preserving visible content and identity.""" + db_path = tmp_path / "memory.db" + service = SqliteMemoryService( + ":memory:" if in_memory else db_path, fts=fts_mode + ) + visible_parts = [ + types.Part(text="My preferred tea is saffron.", thought=False), + types.Part.from_bytes(data=b"visible image", mime_type="image/png"), + types.Part(text="Serve it hot."), + ] + event = Event( + id="event-1", + author="assistant", + timestamp=1.0, + content=types.Content( + role="model", + parts=[ + types.Part(text="Synthetic obsidiansecret", thought=True), + visible_parts[0], + types.Part( + inline_data=types.Blob( + data=b"synthetic thought image", mime_type="image/png" + ), + thought=True, + ), + *visible_parts[1:], + ], + ), + ) + original_event = event.model_copy(deep=True) + try: + if ingestion == "session": + await service.add_session_to_memory(_make_session([event])) + else: + await service.add_events_to_memory( + app_name="app", + user_id="user", + session_id="session-1", + events=[event], + ) + if not in_memory: + with closing(sqlite3.connect(db_path)) as conn: + stored_before_search = conn.execute( + "SELECT content_json FROM memory_events" + ).fetchone()[0] + + response = await service.search_memory( + app_name="app", user_id="user", query="saffron" + ) + + if not in_memory: + with closing(sqlite3.connect(db_path)) as conn: + stored_after_search = conn.execute( + "SELECT content_json FROM memory_events" + ).fetchone()[0] + assert stored_before_search == stored_after_search + assert stored_after_search == original_event.content.model_dump_json( + exclude_none=True + ) + assert event == original_event + assert len(response.memories) == 1 + memory = response.memories[0] + assert memory.content == types.Content(role="model", parts=visible_parts) + assert memory.id == "1" + assert memory.author == "assistant" + assert memory.timestamp == datetime.fromtimestamp(1.0).isoformat() + assert memory.custom_metadata == { + "session_id": "session-1", + "event_id": "event-1", + } + finally: + await service.close() + + +async def test_search_does_not_match_thought_text( + fts_mode: Literal["off", "on"], +) -> None: + """Thought text is not searchable even when the event has visible text.""" + service = SqliteMemoryService(":memory:", fts=fts_mode) + event = Event( + author="assistant", + content=types.Content( + role="model", + parts=[ + types.Part(text="Synthetic obsidiansecret", thought=True), + types.Part(text="My preferred tea is saffron."), + ], + ), + ) + try: + await service.add_events_to_memory( + app_name="app", user_id="user", events=[event] + ) + + response = await service.search_memory( + app_name="app", user_id="user", query="obsidiansecret" + ) + + assert not response.memories + finally: + await service.close() + + +async def test_search_filters_preexisting_thoughts_without_rewriting_storage( + tmp_path: Path, fts_mode: Literal["off", "on"] +) -> None: + """Reopening old rows filters returned thoughts without rewriting JSON. + + Seed stored JSON directly so this also catches an ingestion-only fix. + """ + db_path = tmp_path / "memory.db" + service = SqliteMemoryService(db_path, fts=fts_mode) + try: + await service.search_memory(app_name="app", user_id="user", query="saffron") + finally: + await service.close() + stored_content = """{ + "role": "model", + "parts": [ + {"text": "Synthetic obsidiansecret", "thought": true}, + {"text": "My preferred tea is saffron.", "thought": false}, + {"file_data": {"file_uri": "gs://test-bucket/image.png", + "mime_type": "image/png"}} + ] + }""" + with closing(sqlite3.connect(db_path)) as conn: + conn.execute( + """INSERT INTO memory_events ( + id, app_name, user_id, session_id, event_id, author, timestamp, + content_json, search_text + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + 42, + "app", + "user", + "old-session", + "old-event", + "assistant", + 1.0, + stored_content, + "my preferred tea is saffron.", + ), + ) + conn.commit() + content_before_search = conn.execute( + "SELECT content_json FROM memory_events WHERE id = 42" + ).fetchone()[0] + + reopened_service = SqliteMemoryService(db_path, fts=fts_mode) + try: + response = await reopened_service.search_memory( + app_name="app", user_id="user", query="saffron" + ) + finally: + await reopened_service.close() + with closing(sqlite3.connect(db_path)) as conn: + content_after_search = conn.execute( + "SELECT content_json FROM memory_events WHERE id = 42" + ).fetchone()[0] + + assert content_before_search == content_after_search == stored_content + assert len(response.memories) == 1 + memory = response.memories[0] + assert memory.content == types.Content( + role="model", + parts=[ + types.Part(text="My preferred tea is saffron.", thought=False), + types.Part.from_uri( + file_uri="gs://test-bucket/image.png", mime_type="image/png" + ), + ], + ) + assert memory.id == "42" + assert memory.author == "assistant" + assert memory.timestamp == datetime.fromtimestamp(1.0).isoformat() + assert memory.custom_metadata == { + "session_id": "old-session", + "event_id": "old-event", + } + + @pytest.mark.asyncio async def test_reopen_database_with_fts_finds_earlier_rows_once( tmp_path, require_fts5 From 4197c69f16293d2c1b41aafb9a5063da86d42867 Mon Sep 17 00:00:00 2001 From: Xiongwei Xiao <41932838+xxwtiancai@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:36:39 +0800 Subject: [PATCH 3/3] test(tools): verify SQLite memory at Runner model boundary --- .../unittests/tools/test_load_memory_tool.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/unittests/tools/test_load_memory_tool.py b/tests/unittests/tools/test_load_memory_tool.py index 7aa767a9574..4a307ba8066 100644 --- a/tests/unittests/tools/test_load_memory_tool.py +++ b/tests/unittests/tools/test_load_memory_tool.py @@ -12,11 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +from contextlib import closing +import sqlite3 +from typing import Literal from unittest import mock from unittest.mock import AsyncMock +from google.adk.agents.llm_agent import Agent +from google.adk.events.event import Event from google.adk.features import FeatureName from google.adk.features._feature_registry import temporary_feature_override +from google.adk.memory._sqlite_memory_service import SqliteMemoryService from google.adk.models.llm_request import LlmRequest from google.adk.tools.load_memory_tool import load_memory_tool from google.adk.tools.preload_memory_tool import PreloadMemoryTool @@ -24,6 +30,78 @@ from google.genai import types import pytest +from .. import testing_utils + + +@pytest.mark.parametrize('fts_mode', ['off', 'on']) +async def test_runner_loads_only_visible_sqlite_memory( + fts_mode: Literal['off', 'on'], +) -> None: + """The next model request contains visible memory without thought parts.""" + if fts_mode == 'on': + with closing(sqlite3.connect(':memory:')) as conn: + try: + conn.execute('CREATE VIRTUAL TABLE test_fts USING fts5(col)') + except sqlite3.OperationalError: + pytest.skip('FTS5 not available in this SQLite build.') + memory_service = SqliteMemoryService(':memory:', fts=fts_mode) + model = testing_utils.MockModel.create( + responses=[ + types.Part.from_function_call( + name='load_memory', args={'query': 'saffron'} + ), + types.Part(text='Your preferred tea is saffron.'), + ] + ) + runner = testing_utils.InMemoryRunner( + Agent(name='assistant', model=model, tools=[load_memory_tool]) + ) + runner.runner.memory_service = memory_service + try: + await memory_service.add_events_to_memory( + app_name=runner.app_name, + user_id='test_user', + session_id='history', + events=[ + Event( + author='assistant', + content=types.Content( + role='model', + parts=[ + types.Part( + text='Synthetic obsidiansecret', thought=True + ), + types.Part(text='My preferred tea is saffron.'), + ], + ), + ) + ], + ) + + await runner.run_async('What is my preferred tea?') + + assert len(model.requests) == 2 + function_responses = [ + part.function_response + for content in model.requests[1].contents + for part in content.parts or [] + if part.function_response + and part.function_response.name == 'load_memory' + ] + assert len(function_responses) == 1 + payload = function_responses[0].model_dump(mode='json') + memories = payload['response']['result']['memories'] + assert len(memories) == 1 + assert types.Content.model_validate( + memories[0]['content'] + ) == types.Content( + role='model', parts=[types.Part(text='My preferred tea is saffron.')] + ) + assert 'obsidiansecret' not in model.requests[1].model_dump_json() + finally: + await memory_service.close() + await runner.runner.close() + def test_get_declaration_with_json_schema_feature_disabled(): """Test that _get_declaration uses parameters when feature is disabled."""