Skip to content

Commit c282a4d

Browse files
Merge branch 'main' into fix-litellm-azure-pdf-upload
2 parents 4118a1d + 5134c9b commit c282a4d

4 files changed

Lines changed: 193 additions & 8 deletions

File tree

src/google/adk/memory/in_memory_memory_service.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232

3333
_UNKNOWN_SESSION_ID = '__unknown_session_id__'
3434

35+
_MAX_SEARCH_RESULTS = 10
36+
3537

3638
def _user_key(app_name: str, user_id: str) -> tuple[str, str]:
3739
return (app_name, user_id)
@@ -45,7 +47,8 @@ def _extract_words_lower(text: str) -> set[str]:
4547
class InMemoryMemoryService(BaseMemoryService):
4648
"""An in-memory memory service for prototyping purpose only.
4749
48-
Uses keyword matching instead of semantic search.
50+
Uses keyword matching instead of semantic search. A search returns at most
51+
ten memories, the ones sharing the most words with the query.
4952
5053
This class is thread-safe, however, it should be used for testing and
5154
development only.
@@ -117,7 +120,7 @@ async def search_memory(
117120
]
118121

119122
words_in_query = _extract_words_lower(query)
120-
response = SearchMemoryResponse()
123+
scored_memories: list[tuple[int, MemoryEntry]] = []
121124

122125
for session_events in session_event_lists:
123126
for event in session_events:
@@ -129,13 +132,23 @@ async def search_memory(
129132
if not words_in_event:
130133
continue
131134

132-
if any(query_word in words_in_event for query_word in words_in_query):
133-
response.memories.append(
135+
matched_words = len(words_in_query & words_in_event)
136+
if matched_words:
137+
scored_memories.append((
138+
matched_words,
134139
MemoryEntry(
135140
content=event.content,
136141
author=event.author,
137142
timestamp=_utils.format_timestamp(event.timestamp),
138-
)
139-
)
140-
141-
return response
143+
),
144+
))
145+
146+
# Almost any two sentences share a word, so returning every event that
147+
# matches at least one query word returns most of the store, and callers
148+
# such as the preload_memory tool put all of it in the prompt. Keep the
149+
# events matching the most query words. The sort key reads only the count,
150+
# so it is stable and events matching equally stay in insertion order.
151+
scored_memories.sort(key=lambda scored_memory: -scored_memory[0])
152+
return SearchMemoryResponse(
153+
memories=[memory for _, memory in scored_memories[:_MAX_SEARCH_RESULTS]]
154+
)

tests/unittests/isolated_import_utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from __future__ import annotations
2222

23+
import json
2324
import os
2425
from pathlib import Path
2526
import subprocess
@@ -54,6 +55,33 @@ def run_isolated(source: str) -> subprocess.CompletedProcess[str]:
5455
)
5556

5657

58+
def loaded_top_level_packages(source: str) -> frozenset[str]:
59+
"""Returns the third-party top-level packages source leaves imported.
60+
61+
Standard-library modules, private modules and the pseudo-modules the
62+
interpreter injects carry no install or startup cost of their own, so they
63+
are dropped and only the distributions a caller pays for remain.
64+
"""
65+
result = run_isolated(f"""
66+
import json
67+
import sys
68+
{source}
69+
70+
names = {{
71+
name.partition('.')[0]
72+
for name, module in sys.modules.items()
73+
if getattr(module, '__spec__', None) is not None
74+
}}
75+
print(json.dumps(sorted(
76+
name
77+
for name in names - sys.stdlib_module_names
78+
if not name.startswith('_')
79+
)))
80+
""")
81+
assert result.returncode == 0, result.stderr
82+
return frozenset(json.loads(result.stdout.splitlines()[-1]))
83+
84+
5785
def assert_modules_unloaded(source: str, forbidden: tuple[str, ...]) -> None:
5886
"""Asserts source leaves every forbidden module (and submodule) unimported."""
5987
result = run_isolated(f"""

tests/unittests/memory/test_in_memory_memory_service.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,73 @@ async def test_search_memory_matches_non_latin_text():
393393
assert result.memories[0].content.parts[0].text == 'Привет мир'
394394

395395

396+
def _text_event(tag: str, text: str) -> Event:
397+
return Event(
398+
id=f'event-{tag}',
399+
invocation_id=f'inv-{tag}',
400+
author='user',
401+
timestamp=1.0,
402+
content=types.Content(parts=[types.Part(text=text)]),
403+
)
404+
405+
406+
@pytest.mark.asyncio
407+
async def test_search_memory_ranks_by_number_of_matching_words():
408+
"""Tests that the events matching the most query words come first."""
409+
memory_service = InMemoryMemoryService()
410+
await memory_service.add_session_to_memory(
411+
Session(
412+
app_name=MOCK_APP_NAME,
413+
user_id=MOCK_USER_ID,
414+
id='session-ranked',
415+
last_update_time=1000,
416+
events=[
417+
_text_event('ranked-a', 'The deploy is ready.'),
418+
_text_event('ranked-b', 'Ready.'),
419+
_text_event('ranked-c', 'The deploy status is ready.'),
420+
],
421+
)
422+
)
423+
424+
result = await memory_service.search_memory(
425+
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='deploy status ready'
426+
)
427+
428+
assert [memory.content.parts[0].text for memory in result.memories] == [
429+
'The deploy status is ready.',
430+
'The deploy is ready.',
431+
'Ready.',
432+
]
433+
434+
435+
@pytest.mark.asyncio
436+
async def test_search_memory_returns_at_most_ten_memories():
437+
"""Tests that a word shared with the whole store cannot return the store."""
438+
memory_service = InMemoryMemoryService()
439+
events = [_text_event(f'note-{i}', f'note {i} about work') for i in range(20)]
440+
events.append(_text_event('backlog', 'the backlog note about work'))
441+
await memory_service.add_session_to_memory(
442+
Session(
443+
app_name=MOCK_APP_NAME,
444+
user_id=MOCK_USER_ID,
445+
id='session-many',
446+
last_update_time=1000,
447+
events=events,
448+
)
449+
)
450+
451+
result = await memory_service.search_memory(
452+
app_name=MOCK_APP_NAME, user_id=MOCK_USER_ID, query='work backlog note'
453+
)
454+
455+
texts = [memory.content.parts[0].text for memory in result.memories]
456+
# The best match is stored last but ranks first, and the rest tie, so they
457+
# keep the order they were added in.
458+
assert texts == ['the backlog note about work'] + [
459+
f'note {i} about work' for i in range(9)
460+
]
461+
462+
396463
def _make_event(tag: str) -> Event:
397464
return Event(
398465
id=f'event-{tag}',

tests/unittests/test_import_loading.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
from . import isolated_import_utils
2828
from .isolated_import_utils import assert_modules_unloaded
29+
from .isolated_import_utils import loaded_top_level_packages
2930
from .isolated_import_utils import run_isolated
3031

3132
pytestmark = pytest.mark.skipif(
@@ -42,6 +43,59 @@
4243
'google.adk.workflow',
4344
)
4445

46+
# The statements almost every ADK program starts with, and therefore the two
47+
# import graphs whose cost every user pays.
48+
_ENTRY_POINTS = (
49+
'from google.adk.agents import Agent',
50+
'from google.adk.runners import Runner',
51+
)
52+
53+
# Third-party top-level packages an entry point may load. The forbidden lists
54+
# above pin individual deferrals on the lazy package inits; this one bounds the
55+
# whole graph, because the cost that reaches users arrives as a package nobody
56+
# noticed rather than as one somebody predicted.
57+
_ENTRY_POINT_PACKAGE_ALLOWLIST = frozenset({
58+
# Declared requirements that ADK imports at module scope.
59+
'click',
60+
'fastapi',
61+
'google',
62+
'httpx',
63+
'opentelemetry',
64+
'packaging',
65+
'pydantic',
66+
'python_multipart',
67+
'starlette',
68+
'tenacity',
69+
'websockets',
70+
# Reached through pydantic and httpx rather than through ADK.
71+
'annotated_doc',
72+
'annotated_types',
73+
'anyio',
74+
'certifi',
75+
'idna',
76+
'orjson',
77+
'pydantic_core',
78+
'pygments',
79+
'rich',
80+
'sniffio',
81+
'typing_extensions',
82+
'typing_inspection',
83+
'zstandard',
84+
# google.genai.types annotates optional fields with aiohttp and Pillow
85+
# types and imports whichever of the two the environment happens to have.
86+
# No ADK module imports either one, so these are absent in some installs.
87+
'PIL',
88+
'aiohappyeyeballs',
89+
'aiohttp',
90+
'aiosignal',
91+
'attr',
92+
'defusedxml',
93+
'frozenlist',
94+
'multidict',
95+
'propcache',
96+
'yarl',
97+
})
98+
4599

46100
@pytest.mark.parametrize(
47101
('module_name', 'forbidden'),
@@ -104,6 +158,29 @@ def test_package_import_defers_unrelated_runtime(
104158
)
105159

106160

161+
@pytest.mark.parametrize('statement', _ENTRY_POINTS, ids=('agent', 'runner'))
162+
def test_entry_point_loads_only_allowlisted_packages(statement: str) -> None:
163+
"""The two entry points every program uses load a reviewed set of packages.
164+
165+
The lazy package inits are already cheap, so a new eager dependency shows up
166+
here first: as a package nobody agreed to pay for on every ADK start.
167+
168+
The unit is the top-level import name, so a new eager dependency arriving
169+
under the `google` namespace, which ADK loads either way, does not show up
170+
here.
171+
"""
172+
unexpected = sorted(
173+
loaded_top_level_packages(statement) - _ENTRY_POINT_PACKAGE_ALLOWLIST
174+
)
175+
176+
assert not unexpected, (
177+
f'{statement!r} now loads {", ".join(unexpected)}, which every ADK'
178+
' process would pay for at startup. Move the import into the function'
179+
' that needs it, or add the package to the allowlist together with the'
180+
' reason it has to be eager.'
181+
)
182+
183+
107184
def test_constructing_agent_defers_optional_mcp_server_stack():
108185
"""A normal Agent does not import MCP just because its extra is installed."""
109186
if importlib.util.find_spec('mcp') is None:

0 commit comments

Comments
 (0)