Repository navigation
static_instruction and instruction are moved behind conversation history when static_instruction contains non-text content #6652
Description
🔴 Required Information
Describe the Bug:
When LlmAgent.static_instruction contains non-text content such as a PDF provided through file_data, ADK converts that part into user content.
On the first turn, the generated request has the expected order:
static PDF content
dynamic instruction
current user message
On the second and subsequent turns, however, the same static PDF content is inserted after the existing conversation history:
previous user message
previous model response
static PDF content
dynamic instruction
current user message
This means the supposedly static PDF is no longer part of a stable request prefix across turns.
Because Gemini implicit context caching relies on matching a sufficiently large prefix, this ordering appears to prevent a large PDF in static_instruction from benefiting from implicit context caching.
The PDF itself contains approximately 40,040 image tokens. Despite sending the same PDF on consecutive requests, neither response contains cached_content_token_count.
This is especially surprising because static_instruction is documented as being sent at the beginning of the request for context-caching purposes.
adk-python/src/google/adk/agents/llm_agent.py
Lines 284 to 286 in 740582e
| This field is primarily for context caching optimization. Static instructions | |
| are sent as system instruction at the beginning of the request, allowing | |
| for improved performance when the static portion remains unchanged. Live API |
Steps to Reproduce:
-
Install ADK 2.6.1.
pip install google-adk==2.6.1
-
Create the agent from the minimal reproduction below.
-
Configure the Vertex AI backend.
export GOOGLE_GENAI_USE_VERTEXAI=TRUE export GOOGLE_CLOUD_PROJECT="your-project" export GOOGLE_CLOUD_LOCATION="global"
-
Start the agent with debug logging enabled.
adk web -v >debug.log 2>&1
-
Start a new session and send:
こんにちは -
In the same session, send:
論文の目次を教えて -
Inspect the generated
contentsandusage_metadatain the debug log.
Expected Behavior:
Contents originating from static_instruction should remain at the beginning of the generated request on every turn. The dynamic instruction should follow the static contents, before the conversation history.
For example:
static PDF content
dynamic instruction
previous user message
previous model response
current user message
This preserves the documented ordering between static_instruction and instruction, while keeping the large static PDF as a stable request prefix.
Implicit cache hits are controlled by the model backend and are not guaranteed. However, ADK should preserve this stable prefix rather than moving static content behind changing conversation history.
Observed Behavior:
The first request is ordered as follows:
1. Referenced file data + PDF file_data
2. Dynamic instruction
3. Current user message: こんにちは
The second request is ordered as follows:
1. Previous user message: こんにちは
2. Previous model response
3. Referenced file data + the same PDF file_data
4. Dynamic instruction
5. Current user message: 論文の目次を教えて
Usage metadata for the first response:
{
"candidates_token_count": 109,
"prompt_token_count": 40259,
"prompt_tokens_details": [
{
"modality": "IMAGE",
"token_count": 40040
},
{
"modality": "TEXT",
"token_count": 219
}
],
"thoughts_token_count": 109,
"total_token_count": 40477,
"traffic_type": "ON_DEMAND"
}Usage metadata for the second response:
{
"candidates_token_count": 1068,
"prompt_token_count": 40483,
"prompt_tokens_details": [
{
"modality": "TEXT",
"token_count": 443
},
{
"modality": "IMAGE",
"token_count": 40040
}
],
"thoughts_token_count": 852,
"total_token_count": 42403,
"traffic_type": "ON_DEMAND"
}Neither response reports cached_content_token_count.
Environment Details:
- ADK Library Version (
pip show google-adk): 2.6.1 - Desktop OS: macOS
- Python Version: 3.13.12
Model Information:
- Are you using LiteLLM: No
- Which model are you using:
gemini-3.6-flash - Backend: Vertex AI
🟡 Optional Information
Regression:
Unknown.
I have not verified an earlier ADK version in which non-text static_instruction content remained at the beginning of multi-turn requests.
Logs:
The relevant request ordering from the debug log is summarized above. The important difference is that the same PDF moves from the beginning of the first request to a position after the previous model response in the second request.
Full: https://gist.github.com/ftnext/c5e79dc8b19d6e59298633e4dd866c86
Screenshots / Video:
N/A
Additional Context:
The relevant flow appears to be:
-
Non-text parts of
static_instructionare extracted into user contents by
LlmRequest.append_instructions():
llm_request.py -
The contents processor rebuilds the conversation history and then inserts
all instruction-related user contents:
contents.py -
_add_instructions_to_user_content()inserts those contents before the last
continuous batch of user contents rather than at position zero:
contents.py
This placement is appropriate for dynamic instructions, but non-text contents originating from static_instruction appear to need separate placement so that they remain a stable prefix.
Related issues and PRs:
- Explicit context cache never engages (cache_name always None) when a dynamic instruction coexists with static_instruction #6062 and static_instruction + instruction pattern for context caching producing a permanently unstable fingerprint #6216 concern explicit context-cache fingerprinting.
- fix: fingerprint cacheable context prefix #6067 fixes explicit cache reuse.
- feat: optimize Gemini context caching boundary #5522 changes the explicit-cache boundary around the last user batch.
Those appear related but do not address this issue: the present problem is the actual GenerateContentRequest.contents ordering for non-text static instructions and its effect on provider-side implicit caching.
Minimal Reproduction Code:
Proposed Regression Test:
The following test can be added to tests/unittests/flows/llm_flows/test_instructions.py.
It verifies request structure rather than asserting an actual cache hit, since implicit cache hits are controlled by the backend.
diff --git a/tests/unittests/flows/llm_flows/test_instructions.py b/tests/unittests/flows/llm_flows/test_instructions.py
--- a/tests/unittests/flows/llm_flows/test_instructions.py
+++ b/tests/unittests/flows/llm_flows/test_instructions.py
@@
from google.adk.agents.run_config import RunConfig
+from google.adk.events.event import Event
from google.adk.flows.llm_flows import instructions
@@
+@pytest.mark.asyncio
+async def test_static_instruction_file_precedes_multi_turn_history():
+ """Test that a static file remains a stable prefix across turns."""
+ file_uri = "gs://test-bucket/reference.pdf"
+ agent = LlmAgent(
+ name="test_agent",
+ instruction="Dynamic instruction",
+ static_instruction=types.Content(
+ parts=[
+ types.Part(
+ file_data=types.FileData(
+ file_uri=file_uri,
+ mime_type="application/pdf",
+ )
+ )
+ ]
+ ),
+ )
+ invocation_context = await _create_invocation_context(agent)
+ invocation_context.session.events = [
+ Event(
+ invocation_id="inv1",
+ author="user",
+ content=types.UserContent("First message"),
+ ),
+ Event(
+ invocation_id="inv2",
+ author="test_agent",
+ content=types.ModelContent("First response"),
+ ),
+ Event(
+ invocation_id="inv3",
+ author="user",
+ content=types.UserContent("Second message"),
+ ),
+ ]
+ llm_request = LlmRequest()
+
+ async for _ in request_processor.run_async(
+ invocation_context, llm_request
+ ):
+ pass
+ async for _ in contents_processor.run_async(
+ invocation_context, llm_request
+ ):
+ pass
+
+ assert len(llm_request.contents) == 5
+
+ static_content = llm_request.contents[0]
+ assert static_content.role == "user"
+ assert static_content.parts[0].text == (
+ "Referenced file data: file_data_0"
+ )
+ assert static_content.parts[1].file_data
+ assert static_content.parts[1].file_data.file_uri == file_uri
+
+ dynamic_content = llm_request.contents[1]
+ assert dynamic_content.role == "user"
+ assert dynamic_content.parts[0].text == "Dynamic instruction"
+
+ assert llm_request.contents[2] == types.UserContent("First message")
+ assert llm_request.contents[3] == types.ModelContent("First response")
+ assert llm_request.contents[4] == types.UserContent("Second message")How often does this issue occur?
The request reordering occurs deterministically on the second and subsequent turns when conversation history contains a model response.
In the observed two-turn reproduction, no implicit cache hit was reported. The structural ordering issue occurs consistently, although an implicit cache hit itself is not guaranteed by the backend.
Activity
surajksharma07 commentedon Aug 11, 2026
@ftnext Confirmed this on main (v2.6.3) — static_instruction's non-text content only stays a stable prefix on turn 1; contents.py has no tracking to keep it pinned once history builds up on turn 2+.
#6653 already patches this by tracking the static-derived contents and re-inserting them at the front, including the tool-triggered dynamic-instruction edge case.
Could you try that patch on your setup and confirm the caching behavior actually improves before anyone raises a separate PR?
fix(flows): keep non-text static_instruction as a stable request prefix
ftnext commentedon Aug 15, 2026
@surajksharma07 Tested #6653 at commit
41c31c9e6e8c24958e565e1e31022c3b383bb24bwith my original PDF reproduction.The request ordering is fixed on the second turn. I observed:
(Detail: https://gist.github.com/ftnext/7aec59af0c84572da7c7f4add57bc63f#file-pr6653-debug-log-L377-L382 )
So the static PDF now remains at the beginning of the request as intended.
I can confirm the stable-prefix fix, but I cannot yet confirm that implicit context caching actually improves in my environment.
I did not observe
cached_content_token_countin the response metadata.surajksharma07 commentedon Aug 17, 2026
@ftnext Ordering being fixed is the actual ask here. Your own expected-behavior writeup already scoped it that way and called out that implicit cache hits aren't something ADK can guarantee, just the stable prefix. Whether a hit shows up after that is down to Vertex's own eligibility thresholds (minimum prefix size, TTL, warm-up) which is outside what request ordering alone controls so not seeing cached_content_token_count yet doesn't mean anything's still broken on our end.
Team is looking more into it.
fix: keep non-text static_instruction as a stable request prefix