Skip to content

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions