Skip to content

Commit 20ba01c

Browse files
boyangsvlcopybara-github
authored andcommitted
fix: Fix instructions_utils matching invalid nested paths
Co-authored-by: Bo Yang <ybo@google.com> PiperOrigin-RevId: 934582581
1 parent 373be63 commit 20ba01c

2 files changed

Lines changed: 134 additions & 9 deletions

File tree

src/google/adk/utils/instructions_utils.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,28 @@ def _get_nested_value(obj: Any, path: str) -> Any:
120120

121121
return current
122122

123-
async def _replace_match(match) -> str:
124-
full_path = match.group().lstrip('{').rstrip('}').strip()
123+
def _is_valid_path(path: str) -> bool:
124+
"""Checks if the path is a valid state variable path."""
125+
parts = path.split('.')
126+
if not parts:
127+
return False
128+
129+
# Check first segment (can have prefix)
130+
first_seg = parts[0].removesuffix('?')
131+
if not _is_valid_state_name(first_seg):
132+
return False
133+
134+
# Check subsequent segments (must be plain identifiers)
135+
for part in parts[1:]:
136+
seg = part.removesuffix('?')
137+
if not seg.isidentifier():
138+
return False
139+
140+
return True
125141

126-
if full_path.startswith('artifact.'):
127-
var_name = full_path.removeprefix('artifact.')
142+
async def _evaluate_path(path: str) -> str:
143+
if path.startswith('artifact.'):
144+
var_name = path.removeprefix('artifact.')
128145
optional = var_name.endswith('?')
129146
if optional:
130147
var_name = var_name.removesuffix('?')
@@ -147,17 +164,40 @@ async def _replace_match(match) -> str:
147164
raise KeyError(f'Artifact {var_name} not found.')
148165
return str(artifact)
149166
else:
150-
if not _is_valid_state_name(full_path.split('.')[0].removesuffix('?')):
151-
return match.group()
152-
153167
try:
154-
value = _get_nested_value(invocation_context.session.state, full_path)
168+
value = _get_nested_value(invocation_context.session.state, path)
155169

156170
if value is None:
157171
return ''
158172
return str(value)
159173
except KeyError as e:
160-
raise KeyError(f'Context variable not found: `{full_path}`.') from e
174+
raise KeyError(f'Context variable not found: `{path}`.') from e
175+
176+
async def _replace_match(match) -> str:
177+
raw_match = match.group()
178+
leading_count = len(raw_match) - len(raw_match.lstrip('{'))
179+
trailing_count = len(raw_match) - len(raw_match.rstrip('}'))
180+
full_path = raw_match.lstrip('{').rstrip('}').strip()
181+
182+
if leading_count == trailing_count:
183+
n = leading_count
184+
if n % 2 == 0:
185+
# Even: Escaped, no evaluation
186+
half_n = n // 2
187+
return '{' * half_n + full_path + '}' * half_n
188+
else:
189+
# Odd: Evaluate and wrap
190+
if not _is_valid_path(full_path) and not full_path.startswith('artifact.'):
191+
return raw_match
192+
evaluated_value = await _evaluate_path(full_path)
193+
wrap_braces = (n - 1) // 2
194+
return '{' * wrap_braces + evaluated_value + '}' * wrap_braces
195+
else:
196+
# Asymmetric: fallback to old behavior (treat as N=1 if valid path)
197+
if not _is_valid_path(full_path) and not full_path.startswith('artifact.'):
198+
return raw_match
199+
return await _evaluate_path(full_path)
200+
161201

162202
return await _async_sub(r'{+[^{}]*}+', _replace_match, template)
163203

tests/unittests/utils/test_instructions_utils.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,3 +497,88 @@ def __init__(self):
497497
instruction_template, invocation_context
498498
)
499499
assert populated_instruction == "Name: Frank, Role: Engineer"
500+
501+
502+
@pytest.mark.asyncio
503+
async def test_inject_session_state_with_invalid_nested_path_returns_original():
504+
"""Test that invalid nested paths (e.g. containing spaces or equals) are ignored."""
505+
instruction_template = "Value: {LabelConfidence.Enum confidence = 441216274;}"
506+
invocation_context = await _create_test_readonly_context()
507+
508+
populated_instruction = await instructions_utils.inject_session_state(
509+
instruction_template, invocation_context
510+
)
511+
assert (
512+
populated_instruction
513+
== "Value: {LabelConfidence.Enum confidence = 441216274;}"
514+
)
515+
516+
517+
@pytest.mark.asyncio
518+
async def test_inject_session_state_escaped_braces():
519+
instruction_template = "This is a literal {{placeholder}} and this is {value}."
520+
invocation_context = await _create_test_readonly_context(
521+
state={"value": "real_value"}
522+
)
523+
populated_instruction = await instructions_utils.inject_session_state(
524+
instruction_template, invocation_context
525+
)
526+
assert populated_instruction == "This is a literal {placeholder} and this is real_value."
527+
528+
529+
@pytest.mark.asyncio
530+
async def test_inject_session_state_escaped_braces_missing_state():
531+
instruction_template = "This is a literal {{task_N.result}}."
532+
# task_N is NOT in state
533+
invocation_context = await _create_test_readonly_context(state={})
534+
populated_instruction = await instructions_utils.inject_session_state(
535+
instruction_template, invocation_context
536+
)
537+
assert populated_instruction == "This is a literal {task_N.result}."
538+
539+
540+
@pytest.mark.asyncio
541+
async def test_inject_session_state_triple_braces_evaluates_and_wraps():
542+
instruction_template = "This is a wrapped value: {{{value}}}."
543+
invocation_context = await _create_test_readonly_context(
544+
state={"value": "real_value"}
545+
)
546+
populated_instruction = await instructions_utils.inject_session_state(
547+
instruction_template, invocation_context
548+
)
549+
assert populated_instruction == "This is a wrapped value: {real_value}."
550+
551+
552+
@pytest.mark.asyncio
553+
async def test_inject_session_state_quadruple_braces():
554+
instruction_template = "This is literal double braces: {{{{placeholder}}}}."
555+
invocation_context = await _create_test_readonly_context(state={})
556+
populated_instruction = await instructions_utils.inject_session_state(
557+
instruction_template, invocation_context
558+
)
559+
assert populated_instruction == "This is literal double braces: {{placeholder}}."
560+
561+
562+
@pytest.mark.asyncio
563+
async def test_inject_session_state_asymmetric_braces_left():
564+
instruction_template = "Asymmetric left: {{value}."
565+
invocation_context = await _create_test_readonly_context(
566+
state={"value": "real_value"}
567+
)
568+
populated_instruction = await instructions_utils.inject_session_state(
569+
instruction_template, invocation_context
570+
)
571+
assert populated_instruction == "Asymmetric left: real_value."
572+
573+
574+
@pytest.mark.asyncio
575+
async def test_inject_session_state_asymmetric_braces_right():
576+
instruction_template = "Asymmetric right: {value}}."
577+
invocation_context = await _create_test_readonly_context(
578+
state={"value": "real_value"}
579+
)
580+
populated_instruction = await instructions_utils.inject_session_state(
581+
instruction_template, invocation_context
582+
)
583+
assert populated_instruction == "Asymmetric right: real_value."
584+

0 commit comments

Comments
 (0)