Skip to content

Commit 509be09

Browse files
i-yliucopybara-github
authored andcommitted
fix(caching): prevent prompt cache invalidation when using dynamic tools
Introduce a framework-level `_dynamic_instructions` pipeline in ADK to prevent prompt cache invalidation when tools inject dynamic context (artifacts, memory preloads, MCP resources). Instead of tools eagerly appending instructions to `system_instruction` during `process_llm_request` (which alters the static system prompt prefix and invalidates Gemini prompt caches on every turn), tools now register instructions via `llm_request._append_dynamic_instructions`. A finalizer (`_finalize_dynamic_instructions`) in `base_llm_flow.py` resolves these dynamic instructions post-tool execution. To maintain backward compatibility for existing agents, this behavior is gated behind the `DYNAMIC_INSTRUCTION_ROUTING` feature flag: - When enabled, dynamic instructions are routed into `contents` (user turn) via `_add_instructions_to_user_content`, preserving the static system prompt prefix for caching. - When disabled (default), dynamic instructions append to `system_instruction`, preserving legacy prompt behavior. To enable this feature: - Environment variable: `export ADK_ENABLE_DYNAMIC_INSTRUCTION_ROUTING=1` - Python code: `override_feature_enabled(FeatureName.DYNAMIC_INSTRUCTION_ROUTING, True)` - ADK CLI: `adk web --enable_features=DYNAMIC_INSTRUCTION_ROUTING` Closes #3227 Co-authored-by: Yi Liu <yiliuly@google.com> PiperOrigin-RevId: 951562128
1 parent 49fdc26 commit 509be09

10 files changed

Lines changed: 205 additions & 8 deletions

File tree

src/google/adk/features/_feature_registry.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class FeatureName(str, Enum):
3737
COMPUTER_USE = "COMPUTER_USE"
3838
DATA_AGENT_TOOL_CONFIG = "DATA_AGENT_TOOL_CONFIG"
3939
DATA_AGENT_TOOLSET = "DATA_AGENT_TOOLSET"
40+
DYNAMIC_INSTRUCTION_ROUTING = "DYNAMIC_INSTRUCTION_ROUTING"
4041
DAYTONA_ENVIRONMENT = "DAYTONA_ENVIRONMENT"
4142
E2B_ENVIRONMENT = "E2B_ENVIRONMENT"
4243
ENVIRONMENT_SIMULATION = "ENVIRONMENT_SIMULATION"
@@ -131,6 +132,9 @@ class FeatureConfig:
131132
FeatureName.DATA_AGENT_TOOLSET: FeatureConfig(
132133
FeatureStage.EXPERIMENTAL, default_on=True
133134
),
135+
FeatureName.DYNAMIC_INSTRUCTION_ROUTING: FeatureConfig(
136+
FeatureStage.EXPERIMENTAL, default_on=False
137+
),
134138
FeatureName.DAYTONA_ENVIRONMENT: FeatureConfig(
135139
FeatureStage.EXPERIMENTAL, default_on=True
136140
),

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,9 @@ async def _preprocess_async(
10911091
# Run processors for tools.
10921092
await _process_agent_tools(invocation_context, llm_request)
10931093

1094+
# Finalize dynamic instructions from tools.
1095+
await _finalize_dynamic_instructions(invocation_context, llm_request)
1096+
10941097
async def _postprocess_async(
10951098
self,
10961099
invocation_context: InvocationContext,
@@ -1614,3 +1617,36 @@ def __get_llm(self, invocation_context: InvocationContext) -> BaseLlm:
16141617
f' but got {type(agent)}'
16151618
)
16161619
return agent.canonical_model
1620+
1621+
1622+
async def _finalize_dynamic_instructions(
1623+
invocation_context: InvocationContext,
1624+
llm_request: LlmRequest,
1625+
) -> None:
1626+
"""Finalizes and resolves dynamic instructions from LlmRequest."""
1627+
if not llm_request._dynamic_instructions:
1628+
return
1629+
1630+
combined_text = '\n\n'.join(llm_request._dynamic_instructions)
1631+
1632+
from ...features import FeatureName
1633+
from ...features import is_feature_enabled
1634+
1635+
# TODO: Deprecate system_instruction fallback and make user content routing standard.
1636+
if is_feature_enabled(FeatureName.DYNAMIC_INSTRUCTION_ROUTING):
1637+
from .contents import _add_instructions_to_user_content
1638+
1639+
instruction_content = types.Content(
1640+
role='user',
1641+
parts=[types.Part.from_text(text=combined_text)],
1642+
)
1643+
await _add_instructions_to_user_content(
1644+
invocation_context,
1645+
llm_request,
1646+
[instruction_content],
1647+
)
1648+
else:
1649+
llm_request.append_instructions([combined_text])
1650+
1651+
# Clear dynamic instructions to prevent double finalization.
1652+
llm_request._dynamic_instructions.clear()

src/google/adk/models/llm_request.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ class LlmRequest(BaseModel):
101101
the full history.
102102
"""
103103

104+
_dynamic_instructions: list[str] = PrivateAttr(default_factory=list)
105+
"""Dynamic instructions generated by tools that should be resolved in finalized contents/system instructions."""
106+
104107
_is_managed_agent: bool = PrivateAttr(default=False)
105108
"""Internal flag: whether this request was built by a ManagedAgent.
106109
@@ -110,6 +113,10 @@ class LlmRequest(BaseModel):
110113
server-side without a Gemini model.
111114
"""
112115

116+
def _append_dynamic_instructions(self, instructions: list[str]) -> None:
117+
"""Appends dynamic instructions to the request."""
118+
self._dynamic_instructions.extend(instructions)
119+
113120
def append_instructions(
114121
self, instructions: Union[list[str], types.Content]
115122
) -> list[types.Content]:

src/google/adk/tools/load_artifacts_tool.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,16 +272,16 @@ async def _append_artifacts_to_llm_request(
272272
if not artifact_names:
273273
return
274274

275-
# Tell the model about the available artifacts.
276-
llm_request.append_instructions([f"""You have a list of artifacts:
275+
instruction_text = f"""You have a list of artifacts:
277276
{json.dumps(artifact_names)}
278277
279278
When the user asks questions about any of the artifacts, you should call the
280279
`load_artifacts` function to load the artifact. Always call load_artifacts
281280
before answering questions related to the artifacts, regardless of whether the
282281
artifacts have been loaded before. Do not depend on prior answers about the
283282
artifacts.
284-
"""])
283+
"""
284+
llm_request._append_dynamic_instructions([instruction_text])
285285

286286
# Attach the content of the artifacts if the model requests them.
287287
# This only adds the content to the model request, instead of the session.

src/google/adk/tools/load_mcp_resource_tool.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,15 @@ async def _append_resources_to_llm_request(
110110
try:
111111
resource_names = await self._mcp_toolset.list_resources()
112112
if resource_names:
113-
llm_request.append_instructions([f"""You have a list of MCP resources:
113+
llm_request._append_dynamic_instructions(
114+
[f"""You have a list of MCP resources:
114115
{json.dumps(resource_names)}
115116
116117
When the user asks questions about any of the resources, you should call the
117118
`load_mcp_resource` function to load the resource. Always call load_mcp_resource
118119
before answering questions related to the resources.
119-
"""])
120+
"""]
121+
)
120122
except Exception as e:
121123
logger.warning("Failed to list MCP resources: %s", e)
122124

src/google/adk/tools/preload_memory_tool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async def process_llm_request(
8686
{full_memory_text}
8787
</PAST_CONVERSATIONS>
8888
"""
89-
llm_request.append_instructions([si])
89+
llm_request._append_dynamic_instructions([si])
9090

9191

9292
preload_memory_tool = PreloadMemoryTool()

tests/unittests/flows/llm_flows/test_base_llm_flow.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@
1818
from unittest import mock
1919
from unittest.mock import AsyncMock
2020

21+
from google.adk.agents.invocation_context import InvocationContext
2122
from google.adk.agents.live_request_queue import LiveRequestQueue
2223
from google.adk.agents.llm_agent import Agent
2324
from google.adk.agents.loop_agent import LoopAgent
2425
from google.adk.agents.run_config import RunConfig
2526
from google.adk.events.event import Event
27+
from google.adk.features import FeatureName
28+
from google.adk.features._feature_registry import temporary_feature_override
29+
from google.adk.flows.llm_flows.base_llm_flow import _finalize_dynamic_instructions
2630
from google.adk.flows.llm_flows.base_llm_flow import _handle_after_model_callback
2731
from google.adk.flows.llm_flows.base_llm_flow import _process_agent_tools
2832
from google.adk.flows.llm_flows.base_llm_flow import _ReconnectSentinel
@@ -1922,3 +1926,90 @@ async def test_send_to_model_rejects_function_call():
19221926
ValueError, match='User message cannot contain function calls'
19231927
):
19241928
await flow._send_to_model(mock_connection, invocation_context)
1929+
1930+
1931+
@pytest.mark.asyncio
1932+
async def test_finalize_dynamic_instructions_feature_disabled():
1933+
"""When feature flag is disabled, dynamic instructions append to system instruction."""
1934+
1935+
agent = Agent(name='test_agent', model='gemini-2.0-flash')
1936+
1937+
invocation_context = mock.Mock(spec=InvocationContext)
1938+
invocation_context.agent = agent
1939+
1940+
llm_request = LlmRequest()
1941+
llm_request._append_dynamic_instructions(['dynamic 1', 'dynamic 2'])
1942+
llm_request.contents.append(
1943+
types.Content(
1944+
role='user', parts=[types.Part.from_text(text='user question')]
1945+
)
1946+
)
1947+
with temporary_feature_override(
1948+
FeatureName.DYNAMIC_INSTRUCTION_ROUTING, False
1949+
):
1950+
await _finalize_dynamic_instructions(invocation_context, llm_request)
1951+
1952+
assert llm_request.config.system_instruction is not None
1953+
assert len(llm_request.contents) == 1
1954+
assert llm_request.contents[0].parts[0].text == 'user question'
1955+
1956+
1957+
@pytest.mark.asyncio
1958+
async def test_finalize_dynamic_instructions_feature_enabled():
1959+
"""When feature flag is enabled, dynamic instructions inject into contents."""
1960+
1961+
agent = Agent(name='test_agent', model='gemini-2.0-flash')
1962+
1963+
invocation_context = mock.Mock(spec=InvocationContext)
1964+
invocation_context.agent = agent
1965+
1966+
llm_request = LlmRequest()
1967+
llm_request._append_dynamic_instructions(['dynamic 1', 'dynamic 2'])
1968+
llm_request.contents.append(
1969+
types.Content(
1970+
role='user', parts=[types.Part.from_text(text='user question')]
1971+
)
1972+
)
1973+
1974+
with temporary_feature_override(
1975+
FeatureName.DYNAMIC_INSTRUCTION_ROUTING, True
1976+
):
1977+
await _finalize_dynamic_instructions(invocation_context, llm_request)
1978+
1979+
assert llm_request.config.system_instruction is None
1980+
assert len(llm_request.contents) == 2
1981+
assert llm_request.contents[0].role == 'user'
1982+
assert llm_request.contents[0].parts[0].text == 'dynamic 1\n\ndynamic 2'
1983+
assert llm_request.contents[1].role == 'user'
1984+
assert llm_request.contents[1].parts[0].text == 'user question'
1985+
1986+
1987+
@pytest.mark.asyncio
1988+
async def test_finalize_dynamic_instructions_with_static_instruction():
1989+
"""When static_instruction is set and feature flag enabled, it injects into contents."""
1990+
1991+
agent = Agent(name='test_agent', model='gemini-2.0-flash')
1992+
agent.static_instruction = 'static content'
1993+
1994+
invocation_context = mock.Mock(spec=InvocationContext)
1995+
invocation_context.agent = agent
1996+
1997+
llm_request = LlmRequest()
1998+
llm_request._append_dynamic_instructions(['dynamic 1', 'dynamic 2'])
1999+
llm_request.contents.append(
2000+
types.Content(
2001+
role='user', parts=[types.Part.from_text(text='user question')]
2002+
)
2003+
)
2004+
2005+
with temporary_feature_override(
2006+
FeatureName.DYNAMIC_INSTRUCTION_ROUTING, True
2007+
):
2008+
await _finalize_dynamic_instructions(invocation_context, llm_request)
2009+
2010+
assert llm_request.config.system_instruction is None
2011+
assert len(llm_request.contents) == 2
2012+
assert llm_request.contents[0].role == 'user'
2013+
assert llm_request.contents[0].parts[0].text == 'dynamic 1\n\ndynamic 2'
2014+
assert llm_request.contents[1].role == 'user'
2015+
assert llm_request.contents[1].parts[0].text == 'user question'

tests/unittests/tools/test_load_artifacts_tool.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,20 @@ def test_get_declaration_with_json_schema_feature_enabled():
453453
},
454454
},
455455
}
456+
457+
458+
@mark.asyncio
459+
async def test_load_artifacts_registers_dynamic_instructions():
460+
"""load_artifacts registers instructions in llm_request._dynamic_instructions."""
461+
tool_context = _StubToolContext(
462+
{'doc.txt': types.Part.from_text(text='hello')},
463+
)
464+
llm_request = LlmRequest()
465+
await load_artifacts_tool.process_llm_request(
466+
tool_context=tool_context, llm_request=llm_request
467+
)
468+
469+
assert len(llm_request._dynamic_instructions) == 1
470+
assert 'You have a list of artifacts' in llm_request._dynamic_instructions[0]
471+
assert llm_request.config.system_instruction is None
472+
assert len(llm_request.contents) == 0

tests/unittests/tools/test_load_mcp_resource_tool.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ async def test_process_llm_request_injects_list(self):
8080
tool_context=self.mock_tool_context, llm_request=llm_request
8181
)
8282

83-
llm_request.append_instructions.assert_called_once()
84-
instructions = llm_request.append_instructions.call_args[0][0]
83+
llm_request._append_dynamic_instructions.assert_called_once()
84+
instructions = llm_request._append_dynamic_instructions.call_args[0][0]
8585
assert "res1" in instructions[0]
8686
assert "res2" in instructions[0]
8787

tests/unittests/tools/test_load_memory_tool.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from unittest import mock
16+
from unittest.mock import AsyncMock
17+
1518
from google.adk.features import FeatureName
1619
from google.adk.features._feature_registry import temporary_feature_override
20+
from google.adk.models.llm_request import LlmRequest
1721
from google.adk.tools.load_memory_tool import load_memory_tool
22+
from google.adk.tools.preload_memory_tool import PreloadMemoryTool
23+
from google.adk.tools.tool_context import ToolContext
1824
from google.genai import types
25+
import pytest
1926

2027

2128
def test_get_declaration_with_json_schema_feature_disabled():
@@ -44,3 +51,36 @@ def test_get_declaration_with_json_schema_feature_enabled():
4451
},
4552
'required': ['query'],
4653
}
54+
55+
56+
@pytest.mark.asyncio
57+
async def test_preload_memory_registers_dynamic_instructions():
58+
"""Test that PreloadMemoryTool registers memory into _dynamic_instructions."""
59+
tool = PreloadMemoryTool()
60+
tool_context = mock.Mock(spec=ToolContext)
61+
tool_context.user_content = types.Content(
62+
role='user', parts=[types.Part.from_text(text='my query')]
63+
)
64+
mock_memory = mock.Mock()
65+
mock_memory.timestamp = None
66+
mock_memory.author = 'user'
67+
mock_memory.content = types.Content(
68+
parts=[
69+
types.Part.from_text(text='User prefers Python programming language.')
70+
]
71+
)
72+
73+
tool_context.search_memory = AsyncMock(
74+
return_value=mock.Mock(memories=[mock_memory])
75+
)
76+
77+
llm_request = LlmRequest()
78+
79+
await tool.process_llm_request(
80+
tool_context=tool_context, llm_request=llm_request
81+
)
82+
83+
assert len(llm_request._dynamic_instructions) == 1
84+
assert '<PAST_CONVERSATIONS>' in llm_request._dynamic_instructions[0]
85+
assert llm_request.config.system_instruction is None
86+
assert len(llm_request.contents) == 0

0 commit comments

Comments
 (0)