diff --git a/src/google/adk/utils/instructions_utils.py b/src/google/adk/utils/instructions_utils.py index 05d7dd0c8..724cd26e7 100644 --- a/src/google/adk/utils/instructions_utils.py +++ b/src/google/adk/utils/instructions_utils.py @@ -105,7 +105,43 @@ async def _replace_match(match) -> str: else: raise KeyError(f'Context variable not found: `{var_name}`.') - return await _async_sub(r'{+[^{}]*}+', _replace_match, template) + # ESCAPE MECHANISM: Double-brace escaping for literal braces in instructions + # + # This implements a common templating escape pattern where double braces + # become literal single braces, allowing users to include JSON, code, or + # other brace-containing content in instruction templates. + # + # Processing order (CRITICAL - order matters!): + # 1. First, escape double braces to avoid template processing + # 2. Then, process single braces as template variables + # 3. Finally, restore escaped braces as literals + # + # Example transformations: + # Input: 'Use {name} with config {{"type": "llm"}}' + # Step 1: 'Use {name} with config PLACEHOLDER' (escape {{...}}) + # Step 2: 'Use Alice with config PLACEHOLDER' (process {name}) + # Step 3: 'Use Alice with config {"type": "llm"}' (restore literal) + # + # Why placeholders are needed: + # - Direct replacement could interfere with regex pattern matching + # - Placeholders ensure clean separation of escaping vs variable processing + # - Unique strings prevent accidental collisions with user content + + # Step 1: Replace double braces with unique placeholders + escaped_open = '__ADK_ESCAPED_OPEN_BRACE_PLACEHOLDER__' + escaped_close = '__ADK_ESCAPED_CLOSE_BRACE_PLACEHOLDER__' + template = template.replace('{{', escaped_open) + template = template.replace('}}', escaped_close) + + # Step 2: Process single braces for template variables using regex pattern + # Pattern r'{+[^{}]*}+' matches {variable_name} but not placeholders + result = await _async_sub(r'{+[^{}]*}+', _replace_match, template) + + # Step 3: Restore escaped braces as literal braces in final output + result = result.replace(escaped_open, '{') + result = result.replace(escaped_close, '}') + + return result def _is_valid_state_name(var_name): diff --git a/tests/unittests/flows/llm_flows/test_instructions.py b/tests/unittests/flows/llm_flows/test_instructions.py index cf5be5dca..3e149dde2 100644 --- a/tests/unittests/flows/llm_flows/test_instructions.py +++ b/tests/unittests/flows/llm_flows/test_instructions.py @@ -53,8 +53,9 @@ async def test_build_system_instruction(): pass assert request.config.system_instruction == ( - """Use the echo_info tool to echo 1234567890, 30, \ -{ non-identifier-float}}, {'key1': 'value1'} and {{'key2': 'value2'}}.""" + """Use the echo_info tool to echo 1234567890, \ +{customer_int }, { non-identifier-float}, \ +{'key1': 'value1'} and {'key2': 'value2'}.""" ) diff --git a/tests/unittests/utils/test_instructions_utils.py b/tests/unittests/utils/test_instructions_utils.py index 532d6fca2..2f56c308e 100644 --- a/tests/unittests/utils/test_instructions_utils.py +++ b/tests/unittests/utils/test_instructions_utils.py @@ -1,4 +1,18 @@ -from google.adk.agents.invocation_context import InvocationContext +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + from google.adk.agents.llm_agent import Agent from google.adk.agents.readonly_context import ReadonlyContext from google.adk.sessions.session import Session @@ -214,3 +228,41 @@ async def test_inject_session_state_artifact_service_not_initialized_raises_valu await instructions_utils.inject_session_state( instruction_template, invocation_context ) + + +@pytest.mark.asyncio +async def test_inject_session_state_with_double_brace_escaping(): + """Test that double braces {{}} become literal braces in output.""" + instruction_template = ( + "Use {{my_name}} for literal and {my_name} for template variable" + ) + invocation_context = await _create_test_readonly_context( + state={"my_name": "Sean"} + ) + + populated_instruction = await instructions_utils.inject_session_state( + instruction_template, invocation_context + ) + assert ( + populated_instruction + == "Use {my_name} for literal and Sean for template variable" + ) + + +@pytest.mark.asyncio +async def test_inject_session_state_with_json_literal_braces(): + """Test that JSON with escaped braces works correctly.""" + instruction_template = ( + 'Config format: {{"name": "{user_name}", "active": true}}' + ) + invocation_context = await _create_test_readonly_context( + state={"user_name": "Alice"} + ) + + populated_instruction = await instructions_utils.inject_session_state( + instruction_template, invocation_context + ) + assert ( + populated_instruction + == 'Config format: {"name": "Alice", "active": true}' + )