From 5d16016e9481b836afd5c0b533d475dcb70ed32e Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:49:30 +0530 Subject: [PATCH 1/2] fix(prompt): handle escaped braces in f-string variable extraction extract_fstring_variables used the regex \{([^{}]+)\} to find template placeholders, which is unaware of str.format escaped braces. In an f-string/str.format template, {{ and }} are literal { and }, but the regex matched the text between them as a variable. A template such as 'Respond as JSON like {{"answer": "..."}}. Q: {question}' was reported as having the variables ['"answer": "..."', 'question'] instead of just ['question']. This disagreed with render_fstring_template, which calls template.format(**kwargs) and correctly renders {{ }} as literal braces. StringPromptTemplate.format_prompt then raised a spurious "Missing required variables" ValueError, because the phantom variable is never a valid kwarg. Literal JSON examples in prompts are a very common pattern, so valid templates were rejected. Replace the regex with string.Formatter().parse(), which shares str.format() semantics, so extraction and rendering agree. Escaped braces are ignored, format specs are dropped ({x:>10} -> 'x'), and duplicates and attribute/index access are preserved. Add unit tests for extract_fstring_variables and StringPromptTemplate.format_prompt covering escaped braces, format specs, and the existing behavior. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- dapr_agents/prompt/utils/fstring.py | 15 +++-- tests/prompt/test_string_prompt.py | 50 +++++++++++++++++ tests/prompt/utils/test_fstring.py | 85 +++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 tests/prompt/test_string_prompt.py create mode 100644 tests/prompt/utils/test_fstring.py diff --git a/dapr_agents/prompt/utils/fstring.py b/dapr_agents/prompt/utils/fstring.py index 8094b6580..3ed9b807a 100644 --- a/dapr_agents/prompt/utils/fstring.py +++ b/dapr_agents/prompt/utils/fstring.py @@ -12,7 +12,7 @@ # from typing import Any, List -import re +from string import Formatter def render_fstring_template(template: str, **kwargs: Any) -> str: @@ -39,7 +39,12 @@ def extract_fstring_variables(template: str) -> List[str]: Returns: List[str]: A list of variable names found in the template. """ - # Find all occurrences of {variable_name} in the template - # This will match {name}, {role}, etc. even when they're part of sentences - matches = re.findall(r"\{([^{}]+)\}", template) - return matches + # Use the stdlib string.Formatter, which shares str.format() semantics, so + # extraction matches how render_fstring_template renders the template. This + # correctly treats escaped braces ({{ and }}) as literal characters instead + # of variables, and drops any format spec (e.g. {value:>10} -> "value"). + return [ + field_name + for _, field_name, _, _ in Formatter().parse(template) + if field_name is not None + ] diff --git a/tests/prompt/test_string_prompt.py b/tests/prompt/test_string_prompt.py new file mode 100644 index 000000000..66b0a5524 --- /dev/null +++ b/tests/prompt/test_string_prompt.py @@ -0,0 +1,50 @@ +# +# Copyright 2026 The Dapr Authors +# 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. +# + +"""Unit tests for StringPromptTemplate.format_prompt() method.""" + +import pytest +from dapr_agents.prompt.string import StringPromptTemplate + + +class TestStringPromptTemplateFormatPrompt: + """Tests for StringPromptTemplate.format_prompt() method.""" + + def test_format_prompt_replaces_variable(self): + """A simple placeholder is replaced with the provided value.""" + template = StringPromptTemplate.from_template("Hello {name}") + assert template.format_prompt(name="Alice") == "Hello Alice" + + def test_format_prompt_with_escaped_braces(self): + """Escaped braces in a template do not trigger a missing-variable error. + + A literal JSON example (using {{ }}) is a common prompting pattern; the + escaped content must not be treated as a required variable. + """ + template = StringPromptTemplate.from_template( + 'Respond as JSON like {{"a": 1}}. Q: {question}' + ) + assert template.input_variables == ["question"] + result = template.format_prompt(question="x") + assert result == 'Respond as JSON like {"a": 1}. Q: x' + + def test_from_template_ignores_escaped_braces_in_variables(self): + """Escaped braces are excluded from the detected input variables.""" + template = StringPromptTemplate.from_template("{{literal}} and {real}") + assert template.input_variables == ["real"] + + def test_format_prompt_raises_on_missing_variable(self): + """A genuinely missing variable still raises a ValueError.""" + template = StringPromptTemplate.from_template("Hello {name}") + with pytest.raises(ValueError, match="Missing required variables"): + template.format_prompt() diff --git a/tests/prompt/utils/test_fstring.py b/tests/prompt/utils/test_fstring.py new file mode 100644 index 000000000..f0ec04324 --- /dev/null +++ b/tests/prompt/utils/test_fstring.py @@ -0,0 +1,85 @@ +# +# Copyright 2026 The Dapr Authors +# 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. +# + +"""Unit tests for f-string template variable extraction and rendering.""" + +from dapr_agents.prompt.utils.fstring import ( + extract_fstring_variables, + render_fstring_template, +) + + +class TestExtractFstringVariables: + """Tests for extract_fstring_variables().""" + + def test_extracts_simple_variable(self): + """A single placeholder is returned as a variable.""" + assert extract_fstring_variables("Hello {name}") == ["name"] + + def test_extracts_multiple_variables_in_sentence(self): + """Placeholders embedded in text are all extracted, in order.""" + assert extract_fstring_variables("Hi {name}, you are {age}") == [ + "name", + "age", + ] + + def test_preserves_duplicate_variables(self): + """Repeated placeholders are preserved (not de-duplicated).""" + assert extract_fstring_variables("{a} {b} {a}") == ["a", "b", "a"] + + def test_ignores_escaped_braces(self): + """Escaped braces ({{ and }}) are literal characters, not variables.""" + assert extract_fstring_variables("{{literal}}") == [] + + def test_mixes_escaped_braces_and_real_variables(self): + """A literal JSON example does not leak into the extracted variables.""" + template = 'JSON: {{"k": "v"}} and {q}' + assert extract_fstring_variables(template) == ["q"] + + def test_ignores_format_spec(self): + """A format spec is dropped, matching str.format field names.""" + assert extract_fstring_variables("{value:>10}") == ["value"] + + def test_supports_attribute_and_index_access(self): + """Attribute and index access field names are preserved.""" + assert extract_fstring_variables("{obj.attr} {arr[0]}") == [ + "obj.attr", + "arr[0]", + ] + + def test_returns_empty_for_no_placeholders(self): + """Plain text without placeholders yields no variables.""" + assert extract_fstring_variables("no placeholders here") == [] + + +class TestRenderFstringTemplate: + """Tests for render_fstring_template().""" + + def test_renders_escaped_braces_as_literals(self): + """Escaped braces render as literal braces and need no variable.""" + template = 'Respond as JSON like {{"answer": "..."}}. Q: {question}' + assert ( + render_fstring_template(template, question="What is 2+2?") + == 'Respond as JSON like {"answer": "..."}. Q: What is 2+2?' + ) + + def test_extract_and_render_agree_on_required_variables(self): + """Every variable render needs is reported by extract, and no extras.""" + template = 'Respond as JSON like {{"answer": "..."}}. Q: {question}' + variables = extract_fstring_variables(template) + assert variables == ["question"] + # Providing exactly the extracted variables renders without error. + rendered = render_fstring_template( + template, **{name: "x" for name in variables} + ) + assert rendered == 'Respond as JSON like {"answer": "..."}. Q: x' From b16822586366d1e035956ce238b68e1d15e2532d Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 14 Jul 2026 10:08:32 -0500 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Sam --- dapr_agents/prompt/utils/fstring.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/dapr_agents/prompt/utils/fstring.py b/dapr_agents/prompt/utils/fstring.py index 3ed9b807a..8c752e288 100644 --- a/dapr_agents/prompt/utils/fstring.py +++ b/dapr_agents/prompt/utils/fstring.py @@ -43,8 +43,13 @@ def extract_fstring_variables(template: str) -> List[str]: # extraction matches how render_fstring_template renders the template. This # correctly treats escaped braces ({{ and }}) as literal characters instead # of variables, and drops any format spec (e.g. {value:>10} -> "value"). - return [ - field_name - for _, field_name, _, _ in Formatter().parse(template) - if field_name is not None - ] + variables: List[str] = [] + for _, field_name, _, _ in Formatter().parse(template): + if field_name is None: + continue + if field_name == "" or field_name.isdigit(): + raise ValueError( + "Positional placeholders (e.g. '{}' or '{0}') are not supported; use named fields like '{name}'." + ) + variables.append(field_name) + return variables