Repository navigation
Explicit context cache never engages (cache_name always None) when a dynamic instruction coexists with static_instruction #6062
Description
Summary
When an LlmAgent sets both static_instruction and a dynamic instruction (the documented pattern for context caching with per-turn state), the explicit context cache can never be created: cache_metadata.cache_name stays None on every call, forever.
Root cause is structural, not configuration: the dynamic instruction is injected into llm_request.contents at a position that moves on every model call, and the fingerprint-only metadata is computed over all contents (total_contents_count) — so two consecutive fingerprints can never be equal, and GeminiContextCacheManager only creates a cache when they are.
This is distinct from (and compounds with) #4698 (VertexAiSessionService drops cache_metadata across invocations) and #5847 (create gate uses full-request token count). Even with both of those fixed, the fingerprint chain still never matches whenever a dynamic instruction coexists with static_instruction.
Affected version
google-adk == 2.2.0(code paths below referenced from this version; the logic is present onmainas well)- Model:
gemini-3.5-flashon Vertex AI (eu),App.context_cache_config = ContextCacheConfig(min_tokens=4096, ttl_seconds=1800, cache_intervals=10)
Code-path walkthrough
-
The dynamic instruction becomes a request-scoped content.
flows/llm_flows/instructions.py(≈112–119): whenstatic_instructionis set, the templatedinstructionis appended tollm_request.contentsas a user-role content:elif agent.instruction and agent.static_instruction: ... llm_request.contents.append(dynamic_content)
-
The contents processor re-inserts it at a moving position.
flows/llm_flows/contents.py(≈66–68) preserves instruction-related contents, reassignsllm_request.contentsto the session history, then (≈1001–1020) re-inserts them "before the last continuous batch of user content":llm_request.contents[insert_index:insert_index] = instruction_contents
Because tool rounds append
function_call/function_responsepairs, the insertion index changes on every model call within a turn:- call 1:
contents = [dynamic, user]→ dynamic at index 0 - call 2:
contents = [user, fc, dynamic, fr]→ dynamic at index 2 - call 3:
contents = [user, fc, fr, fc2, dynamic, fr2]→ index 4, etc.
And the dynamic content is request-scoped — it is not persisted in history, so on the next turn it vanishes from its old position entirely.
- call 1:
-
The fingerprint-only metadata covers ALL contents.
models/gemini_context_cache_manager.py,handle_context_caching: on both the no-previous-metadata branch and the fingerprints-don't-match branch, the stored fingerprint is computed overtotal_contents_count = len(llm_request.contents)— which includes the dynamic instruction content (and the latest user message):total_contents_count = len(llm_request.contents) fingerprint = self._generate_cache_fingerprint(llm_request, total_contents_count) return CacheMetadata(fingerprint=fingerprint, contents_count=total_contents_count)
-
Therefore the chain can never match. The next call compares the old fingerprint against a fingerprint over the first
old.contents_countcontents of the new request. Because the dynamic content has moved (or disappeared) within exactly that index range, the comparison fails on every call. Each failure stores a new fingerprint-only metadata over all contents — which again includes the moving dynamic content — and the cycle repeats._create_new_cache_with_contentsis unreachable.
Observed in production
Multi-agent app on Vertex AI Agent Engine, root agent with static_instruction (byte-stable, ~27k tokens) + dynamic instruction (a small per-turn state block). Telemetry (cache_metadata per LLM response) across hundreds of calls:
| call | session | contents_count | fingerprint | cache_name |
|---|---|---|---|---|
| t1 | A | 2 | a390ed3e… |
null |
| t2 | A | 5 | 2d9ca8e5… |
null |
| t3 | A | 8 | 5be86adb… |
null |
| t1 | B | 2 | fef6b998… |
null |
100% of calls: a new fingerprint, cache_name: null, invocations_used: null. Meanwhile Vertex implicit caching hits ~33% token-weighted on the same traffic (avg ~39k cached tokens/call), confirming the prefix (system_instruction + tools) is byte-stable — only the ADK explicit-cache chaining fails.
Expected behavior
With static_instruction + dynamic instruction (the combination the docs recommend precisely for caching), the explicit cache should engage. Two reasonable fixes:
- Exclude instruction-related contents from the fingerprint/cacheable region. The manager already excludes the "last continuous user batch"; request-scoped instruction contents should be excluded the same way (they are by construction non-cacheable, per-request data). E.g., fingerprint over
system_instruction + tools + contents[:k]wherekcounts only persisted history contents. - Pin the insertion position of instruction contents to the very end of
contents(after the trailing user batch) so they always fall outside the cacheable prefix and outside_find_count_of_contents_to_cache's region — combined with computing the stored fingerprint over the cacheable region only (nottotal_contents_count).
Minimal repro (verified on 2.2.0, InMemoryRunner — so independent of #4698)
import asyncio
from google.adk import Agent
from google.adk.apps.app import App
from google.adk.agents.context_cache_config import ContextCacheConfig
from google.adk.runners import InMemoryRunner
from google.genai import types
def get_time() -> str:
"""Returns the current time."""
return "12:00"
agent = Agent(
model="gemini-3.1-flash-lite",
name="repro",
static_instruction="You are a terse helpful agent. " + ("Always be precise. " * 2500), # ~10k tokens (> Gemini's 4096 floor)
instruction="Turn context: {locale?}", # <-- THE TRIGGER: any non-empty dynamic instruction
tools=[get_time], # so each turn has >= 2 model calls
)
app = App(name="repro", root_agent=agent,
context_cache_config=ContextCacheConfig(min_tokens=1024, ttl_seconds=600, cache_intervals=5))
async def main():
runner = InMemoryRunner(app=app)
session = await runner.session_service.create_session(app_name="repro", user_id="u1")
for msg in ["what time is it?", "and now?"]:
async for ev in runner.run_async(user_id="u1", session_id=session.id,
new_message=types.Content(role="user", parts=[types.Part(text=msg)])):
cm = getattr(ev, "cache_metadata", None)
if cm is not None:
print(f"fp={cm.fingerprint[:12]} n={cm.contents_count} cache_name={cm.cache_name}")
asyncio.run(main())Actual output WITH the dynamic instruction (2 turns, 4 model calls):
fp=6a3c5dbf6562 n=2 cache_name=None
fp=2925d0f6b501 n=4 cache_name=None # implicit caching hits (~8.1k cached) — explicit never created
fp=4ef4a8adbd08 n=6 cache_name=None
fp=9d789fde4e3d n=8 cache_name=None
Actual output with instruction= removed (everything else identical):
fp=c6408000424a n=1 cache_name=None
fp=c6408000424a n=1 cache_name=...reasoningEngines-like cache id # created on call 2 (fingerprint matched)
fp=c6408000424a n=1 cache_name=<same> # reused, 10,044 cached tokens billed at cache rate
fp=c6408000424a n=1 cache_name=<same>
The only delta is the presence of a dynamic instruction. (Side note: with the dynamic instruction present, Vertex implicit caching still hits the stable prefix — confirming the prefix itself is byte-stable and only the explicit-cache fingerprint chaining fails.)
Environment
- ADK Library Version: 2.2.0
- Python: 3.13
- Backend: Vertex AI (also reproduces with InMemorySessionService, so independent of Context caching not working with Agent Engine (VertexAiSessionService drops cache_metadata) #4698)
- LiteLLM: No
Activity
surajksharma07 commentedon Jun 10, 2026
/adk-issue-analyze
surajksharma07 commentedon Jun 11, 2026
@bemurta Thanks for the detailed writeup — reproduced this on 2.2.0 and the analysis checks out: the fingerprint is computed over all contents, so the request-scoped dynamic instruction shifting position breaks the chain on every call, exactly as you described.
On my end, fingerprinting only the cacheable prefix (what _find_count_of_contents_to_cache returns) made the explicit cache engage with the dynamic instruction still present — which is the same direction PR #6067 takes.
In the meantime, worth trying as a workaround: move the per-turn state block out of instruction (e.g. appending it to the user message instead) and check whether cache_name gets set on your traffic.
Could you verify and test that thoroughly on your side?
It would also help validate the prefix-fingerprint approach in #6067 before it moves forward!
fix: fingerprint cacheable context prefix
fix: fingerprint cacheable context prefix