Skip to content

static_instruction and instruction are moved behind conversation history when static_instruction contains non-text content #6652

Closed

Description

@ftnext

🔴 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.

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:

  1. Install ADK 2.6.1.

    pip install google-adk==2.6.1
  2. Create the agent from the minimal reproduction below.

  3. Configure the Vertex AI backend.

    export GOOGLE_GENAI_USE_VERTEXAI=TRUE
    export GOOGLE_CLOUD_PROJECT="your-project"
    export GOOGLE_CLOUD_LOCATION="global"
  4. Start the agent with debug logging enabled.

    adk web -v >debug.log 2>&1
  5. Start a new session and send:

    こんにちは
    
  6. In the same session, send:

    論文の目次を教えて
    
  7. Inspect the generated contents and usage_metadata in 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:

  1. Non-text parts of static_instruction are extracted into user contents by
    LlmRequest.append_instructions():
    llm_request.py

  2. The contents processor rebuilds the conversation history and then inserts
    all instruction-related user contents:
    contents.py

  3. _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:

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:

https://github.com/ftnext/agent-practice/tree/5a09ef5daf16cf27a54144daa89d2529e864cd5c/adk/pdf/static_instruction

based on https://github.com/google/adk-python/tree/v2.6.1/contributing/samples/multimodal/static_non_text_content

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

  1. added
    agent config[Component] This issue is related to the Agent Config interface and implementation
    on Aug 10, 2026
  2. surajksharma07 commented on Aug 11, 2026

    @surajksharma07
    Collaborator

    @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?

  3. added a commit that references this issue on Aug 13, 2026
    f08de6e
  4. ftnext commented on Aug 15, 2026

    @ftnext
    ContributorAuthor

    @surajksharma07 Tested #6653 at commit 41c31c9e6e8c24958e565e1e31022c3b383bb24b with my original PDF reproduction.

    The request ordering is fixed on the second turn. I observed:

    static PDF content
    dynamic instruction
    previous user message
    previous model response
    current user message
    

    (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_count in the response metadata.

  5. surajksharma07 commented on Aug 17, 2026

    @surajksharma07
    Collaborator

    @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.

  6. added a commit that references this issue on Aug 18, 2026
    deda5b3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

agent config[Component] This issue is related to the Agent Config interface and implementationrequest clarification[Status] The maintainer need clarification or more information from the author

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions