Skip to content

Commit 7c7f1e7

Browse files
he-yufengGWeale
authored andcommitted
fix: fingerprint cacheable context prefix
Merge #6067 ## Summary - Use the cacheable content prefix for fingerprint-only cache metadata instead of the full request contents. - Keep request-scoped dynamic instruction contents out of the initial fingerprint chain. - Update `CacheMetadata.contents_count` docs to match the prefix semantics. Fixes #6062. ## Testing plan This PR is covered by focused unit tests for the Gemini context cache manager. The important behavior is that cache fingerprints are derived only from the cacheable prefix, while request-local dynamic instructions still flow into the request without changing the cache identity. ## Verification output Run locally on Windows with Python 3.13: ```text python -m py_compile src\google\adk\models\gemini_context_cache_manager.py src\google\adk\models\cache_metadata.py # passed PYTHONPATH=src python -m pytest tests\unittests\agents\test_gemini_context_cache_manager.py -q # passed python -m ruff check src\google\adk\models\gemini_context_cache_manager.py src\google\adk\models\cache_metadata.py tests\unittests\agents\test_gemini_context_cache_manager.py # passed git diff --check # passed ``` ## Risk Low. The patch narrows what contributes to fingerprint-only cache metadata; it does not change response generation, model selection, or live cache service calls. Co-authored-by: George Weale <[email protected]> COPYBARA_INTEGRATE_REVIEW=#6067 from he-yufeng:fix/context-cache-dynamic-instruction 44dc7bf PiperOrigin-RevId: 936129492
1 parent 9f3aeef commit 7c7f1e7

3 files changed

Lines changed: 92 additions & 21 deletions

File tree

‎src/google/adk/models/cache_metadata.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class CacheMetadata(BaseModel):
4848
None when no active cache exists.
4949
contents_count: Number of contents. When active cache exists, this is
5050
the count of cached contents. When no active cache exists, this is
51-
the total count of contents in the request.
51+
the count of the cacheable content prefix used for fingerprinting.
5252
created_at: Unix timestamp when the cache was created. None when
5353
no active cache exists.
5454
"""
@@ -87,7 +87,7 @@ class CacheMetadata(BaseModel):
8787
ge=0,
8888
description=(
8989
"Number of contents (cached contents when active cache exists, "
90-
"total contents in request when no active cache)"
90+
"cacheable content prefix when no active cache)"
9191
),
9292
)
9393

‎src/google/adk/models/gemini_context_cache_manager.py‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,31 +135,37 @@ async def handle_context_caching(
135135
contents_count=cache_contents_count,
136136
)
137137

138-
# Fingerprints don't match - recalculate with total contents
138+
# Fingerprints don't match - recalculate with the current cacheable
139+
# prefix. Request-scoped user contents, such as dynamic instructions,
140+
# should not become part of the fingerprint-only chain.
139141
logger.debug(
140142
"Fingerprints don't match, returning fingerprint-only metadata"
141143
)
142-
total_contents_count = len(llm_request.contents)
143-
fingerprint_for_all = self._generate_cache_fingerprint(
144-
llm_request, total_contents_count
144+
cache_contents_count = self._find_count_of_contents_to_cache(
145+
llm_request.contents
146+
)
147+
fingerprint = self._generate_cache_fingerprint(
148+
llm_request, cache_contents_count
145149
)
146150
return CacheMetadata(
147-
fingerprint=fingerprint_for_all,
148-
contents_count=total_contents_count,
151+
fingerprint=fingerprint,
152+
contents_count=cache_contents_count,
149153
)
150154

151155
# No existing cache metadata - return fingerprint-only metadata
152156
# We don't create cache without previous fingerprint to match
153157
logger.debug(
154158
"No existing cache metadata, creating fingerprint-only metadata"
155159
)
156-
total_contents_count = len(llm_request.contents)
160+
cache_contents_count = self._find_count_of_contents_to_cache(
161+
llm_request.contents
162+
)
157163
fingerprint = self._generate_cache_fingerprint(
158-
llm_request, total_contents_count
164+
llm_request, cache_contents_count
159165
)
160166
return CacheMetadata(
161167
fingerprint=fingerprint,
162-
contents_count=total_contents_count,
168+
contents_count=cache_contents_count,
163169
)
164170

165171
def _find_count_of_contents_to_cache(

‎tests/unittests/agents/test_gemini_context_cache_manager.py‎

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ async def test_handle_context_caching_no_existing_cache(self):
124124
assert result.invocations_used is None
125125
assert result.created_at is None
126126
assert result.fingerprint == "test_fp"
127-
assert result.contents_count == 5 # Total contents count
127+
assert result.contents_count == 0
128128

129129
# No cache should be created
130130
self.manager.genai_client.aio.caches.create.assert_not_called()
@@ -233,7 +233,7 @@ async def test_handle_context_caching_invalid_cache_fingerprint_mismatch(
233233
assert result.invocations_used is None
234234
assert result.created_at is None
235235
assert result.fingerprint == "new_fp"
236-
assert result.contents_count == 5 # Total contents count
236+
assert result.contents_count == 0
237237
mock_cleanup.assert_called_once_with(existing_cache.cache_name)
238238
self.manager.genai_client.aio.caches.create.assert_not_called()
239239

@@ -584,7 +584,7 @@ async def test_cache_creation_with_sufficient_token_count(self):
584584
assert result is not None
585585
assert result.cache_name is None # Fingerprint-only state
586586
assert result.fingerprint == "test_fp"
587-
assert result.contents_count == 3
587+
assert result.contents_count == 0
588588
self.manager.genai_client.aio.caches.create.assert_not_called()
589589

590590
async def test_cache_creation_with_insufficient_token_count(self):
@@ -752,12 +752,10 @@ async def test_multi_turn_fingerprint_stable_when_below_token_threshold(
752752
contents_counts_seen.append(result.contents_count)
753753
metadata = result
754754

755-
# First turn has no metadata, so uses total (1).
756-
# Subsequent turns preserve contents_count=1 from the prefix.
757-
# Fingerprint stays stable because contents[:1] is always the
758-
# same user message.
755+
# All contents in this helper are user-role messages, so there is no
756+
# cacheable content prefix before the final user batch.
759757
assert len(set(fingerprints_seen)) == 1
760-
assert contents_counts_seen == [1, 1, 1]
758+
assert contents_counts_seen == [0, 0, 0]
761759

762760
async def test_contents_count_should_remain_stable_after_cache_creation_failure(
763761
self,
@@ -911,7 +909,7 @@ async def test_fingerprint_only_metadata_transitions_to_active_cache(
911909

912910
assert result_1 is not None
913911
assert result_1.cache_name is None
914-
assert result_1.contents_count == 3
912+
assert result_1.contents_count == 0
915913

916914
# --- Second LLM call: carry forward fingerprint-only metadata ---
917915
# Contents grew but we still have same prefix
@@ -948,7 +946,74 @@ async def test_fingerprint_only_metadata_transitions_to_active_cache(
948946
assert result_2.cache_name == (
949947
"projects/test/locations/us-central1/cachedContents/new789"
950948
)
951-
assert result_2.contents_count == 3 # Preserved from prefix
949+
assert result_2.contents_count == 0 # Preserved from prefix
950+
assert result_2.invocations_used == 1
951+
self.manager.genai_client.aio.caches.create.assert_called_once()
952+
953+
async def test_dynamic_instruction_does_not_break_initial_cache_fingerprint(
954+
self,
955+
):
956+
"""Request-scoped dynamic instructions stay out of the cache prefix."""
957+
dynamic_instruction = types.Content(
958+
role="user", parts=[types.Part(text="Turn context: locale=en-US")]
959+
)
960+
user_msg = types.Content(
961+
role="user", parts=[types.Part(text="what time is it?")]
962+
)
963+
model_tool_call = types.Content(
964+
role="model",
965+
parts=[
966+
types.Part(
967+
function_call=types.FunctionCall(name="get_time", args={})
968+
)
969+
],
970+
)
971+
tool_response = types.Content(
972+
role="user",
973+
parts=[
974+
types.Part(
975+
function_response=types.FunctionResponse(
976+
name="get_time", response={"time": "12:00"}
977+
)
978+
)
979+
],
980+
)
981+
982+
request_1 = self.create_llm_request(contents_count=0)
983+
request_1.contents = [dynamic_instruction, user_msg]
984+
985+
result_1 = await self.manager.handle_context_caching(request_1)
986+
987+
assert result_1 is not None
988+
assert result_1.cache_name is None
989+
assert result_1.contents_count == 0
990+
991+
request_2 = self.create_llm_request(
992+
cache_metadata=result_1, contents_count=0
993+
)
994+
request_2.contents = [
995+
user_msg,
996+
model_tool_call,
997+
dynamic_instruction,
998+
tool_response,
999+
]
1000+
request_2.cacheable_contents_token_count = 4096
1001+
1002+
mock_cached_content = AsyncMock()
1003+
mock_cached_content.name = (
1004+
"projects/test/locations/us-central1/cachedContents/new789"
1005+
)
1006+
self.manager.genai_client.aio.caches.create = AsyncMock(
1007+
return_value=mock_cached_content
1008+
)
1009+
1010+
result_2 = await self.manager.handle_context_caching(request_2)
1011+
1012+
assert result_2 is not None
1013+
assert result_2.cache_name == (
1014+
"projects/test/locations/us-central1/cachedContents/new789"
1015+
)
1016+
assert result_2.contents_count == 0
9521017
assert result_2.invocations_used == 1
9531018
self.manager.genai_client.aio.caches.create.assert_called_once()
9541019

0 commit comments

Comments
 (0)