From 7c7d25a4a6e4389e23037e70b8efdcd5341f44ea Mon Sep 17 00:00:00 2001 From: George Weale Date: Tue, 10 Feb 2026 10:04:15 -0800 Subject: [PATCH] fix: Support escaped curly braces in instruction templates Close #3527 Co-authored-by: George Weale PiperOrigin-RevId: 868199186 --- src/google/adk/utils/instructions_utils.py | 26 ++++++++++++++++++- .../utils/test_instructions_utils.py | 26 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/google/adk/utils/instructions_utils.py b/src/google/adk/utils/instructions_utils.py index 505b5cf1..1a5fa8dd 100644 --- a/src/google/adk/utils/instructions_utils.py +++ b/src/google/adk/utils/instructions_utils.py @@ -79,7 +79,16 @@ async def inject_session_state( return ''.join(result) async def _replace_match(match) -> str: - var_name = match.group().lstrip('{').rstrip('}').strip() + matched_text = match.group() + if matched_text.startswith('{{') and matched_text.endswith('}}'): + # Preserve escaped non-placeholder literals (e.g. JSON snippets), + # but keep escaped placeholders as literal placeholders. + escaped_value = matched_text[2:-2] + if _is_escaped_placeholder(escaped_value): + return matched_text[1:-1] + return matched_text + + var_name = matched_text.lstrip('{').rstrip('}').strip() optional = False if var_name.endswith('?'): optional = True @@ -124,6 +133,21 @@ async def inject_session_state( return await _async_sub(r'{+[^{}]*}+', _replace_match, template) +def _is_escaped_placeholder(value: str) -> bool: + """Checks if escaped braces contain a supported placeholder pattern.""" + var_name = value.strip() + if not var_name: + return False + + if var_name.endswith('?'): + var_name = var_name.removesuffix('?') + + if var_name.startswith('artifact.'): + return True + + return _is_valid_state_name(var_name) + + def _is_valid_state_name(var_name): """Checks if the variable name is a valid state name. diff --git a/tests/unittests/utils/test_instructions_utils.py b/tests/unittests/utils/test_instructions_utils.py index d76e5032..fa4f1ab0 100644 --- a/tests/unittests/utils/test_instructions_utils.py +++ b/tests/unittests/utils/test_instructions_utils.py @@ -146,6 +146,32 @@ async def test_inject_session_state_with_invalid_state_name_returns_original(): assert populated_instruction == "Hello {invalid-key}!" +@pytest.mark.asyncio +async def test_inject_session_state_with_escaped_braces_returns_literal(): + instruction_template = "Code sample: {{user_name}}. Value: {user_name}." + invocation_context = await _create_test_readonly_context( + state={"user_name": "Foo"} + ) + + populated_instruction = await instructions_utils.inject_session_state( + instruction_template, invocation_context + ) + assert populated_instruction == "Code sample: {user_name}. Value: Foo." + + +@pytest.mark.asyncio +async def test_inject_session_state_with_escaped_non_placeholder_keeps_double_braces(): + instruction_template = "Literal template: {{'key2': 'value2'}}." + invocation_context = await _create_test_readonly_context( + state={"user_name": "Foo"} + ) + + populated_instruction = await instructions_utils.inject_session_state( + instruction_template, invocation_context + ) + assert populated_instruction == "Literal template: {{'key2': 'value2'}}." + + @pytest.mark.asyncio async def test_inject_session_state_with_invalid_prefix_state_name_returns_original(): instruction_template = "Hello {invalid:key}!"