Skip to content

feat: Support escaping '{' and '}' in system instructions #2666

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion src/google/adk/utils/instructions_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
5 changes: 3 additions & 2 deletions tests/unittests/flows/llm_flows/test_instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}."""
)


Expand Down
54 changes: 53 additions & 1 deletion tests/unittests/utils/test_instructions_utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}'
)