From 2f5928e4bb8fd8ba28b010b8917fa1a3cb092a0b Mon Sep 17 00:00:00 2001 From: Gabe Date: Tue, 9 Jun 2026 13:42:42 -0300 Subject: [PATCH] fix: only treat interpolatable placeholders as crew inputs --- lib/crewai/src/crewai/crew.py | 16 +++++--- .../src/crewai/utilities/string_utils.py | 20 ++++++++++ lib/crewai/tests/test_crew.py | 23 +++++++++++ .../tests/utilities/test_string_utils.py | 40 ++++++++++++++++++- 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/lib/crewai/src/crewai/crew.py b/lib/crewai/src/crewai/crew.py index b2cebd3ed..0404da1c1 100644 --- a/lib/crewai/src/crewai/crew.py +++ b/lib/crewai/src/crewai/crew.py @@ -7,7 +7,6 @@ from copy import copy as shallow_copy from hashlib import md5 import json from pathlib import Path -import re from typing import ( TYPE_CHECKING, Annotated, @@ -142,7 +141,10 @@ from crewai.utilities.streaming import ( signal_end, signal_error, ) -from crewai.utilities.string_utils import sanitize_tool_name +from crewai.utilities.string_utils import ( + extract_template_variables, + sanitize_tool_name, +) from crewai.utilities.task_output_storage_handler import TaskOutputStorageHandler from crewai.utilities.training_handler import CrewTrainingHandler @@ -1960,20 +1962,24 @@ class Crew(FlowTrackable, BaseModel): Scans each task's 'description' + 'expected_output', and each agent's 'role', 'goal', and 'backstory'. + Only placeholders that interpolation can actually fill are returned; + non-identifier expressions such as ``{x if x else "y"}`` are ignored so + they are not surfaced as required inputs (matching interpolation + behavior, see :func:`extract_template_variables`). + Returns a set of all discovered placeholder names. """ - placeholder_pattern = re.compile(r"\{(.+?)}") required_inputs: set[str] = set() for task in self.tasks: # description and expected_output might contain e.g. {topic}, {user_name} text = f"{task.description or ''} {task.expected_output or ''}" - required_inputs.update(placeholder_pattern.findall(text)) + required_inputs.update(extract_template_variables(text)) for agent in self.agents: # role, goal, backstory might have placeholders like {role_detail}, etc. text = f"{agent.role or ''} {agent.goal or ''} {agent.backstory or ''}" - required_inputs.update(placeholder_pattern.findall(text)) + required_inputs.update(extract_template_variables(text)) return required_inputs diff --git a/lib/crewai/src/crewai/utilities/string_utils.py b/lib/crewai/src/crewai/utilities/string_utils.py index 800efebb9..a20619f57 100644 --- a/lib/crewai/src/crewai/utilities/string_utils.py +++ b/lib/crewai/src/crewai/utilities/string_utils.py @@ -23,6 +23,26 @@ def _duplicate_separator_pattern(separator: str) -> re.Pattern[str]: return re.compile(f"(?:{re.escape(separator)}){{2,}}") +def extract_template_variables(input_string: str | None) -> list[str]: + """Return the template variable names referenced in a string. + + Only recognizes placeholders that interpolation can actually fill, i.e. + ``{name}`` where ``name`` starts with a letter/underscore and contains only + letters, numbers, underscores, and hyphens. Expressions such as + ``{x if x else "y"}`` or JSON snippets are intentionally ignored so they are + never treated as required inputs. + + Args: + input_string: The string to scan. May be ``None`` or empty. + + Returns: + The matched variable names, in order of appearance (with duplicates). + """ + if not input_string: + return [] + return _VARIABLE_PATTERN.findall(input_string) + + def sanitize_tool_name(name: str, max_length: int = _MAX_TOOL_NAME_LENGTH) -> str: """Sanitize tool name for LLM provider compatibility. diff --git a/lib/crewai/tests/test_crew.py b/lib/crewai/tests/test_crew.py index 8ce25774e..c3cdc2623 100644 --- a/lib/crewai/tests/test_crew.py +++ b/lib/crewai/tests/test_crew.py @@ -3895,6 +3895,29 @@ def test_fetch_inputs(): ) +def test_fetch_inputs_ignores_non_identifier_placeholders(): + agent = Agent( + role="Report writer", + goal="Write a report for {company_name}.", + backstory="Expert reporter.", + ) + + task = Task( + description=( + 'Greet {company_name if company_name else "Individual Client"} ' + "and summarize {search_period}." + ), + expected_output="A summary for {company_name}.", + agent=agent, + ) + + crew = Crew(agents=[agent], tasks=[task]) + + # Only the simple {company_name} placeholders are returned; the inline conditional + # expression (which interpolation cannot fill) is ignored. + assert crew.fetch_inputs() == {"company_name", "search_period"} + + @pytest.mark.vcr() def test_task_tools_preserve_code_execution_tools(): """ diff --git a/lib/crewai/tests/utilities/test_string_utils.py b/lib/crewai/tests/utilities/test_string_utils.py index 7bd6db63c..a664092d9 100644 --- a/lib/crewai/tests/utilities/test_string_utils.py +++ b/lib/crewai/tests/utilities/test_string_utils.py @@ -1,7 +1,45 @@ from typing import Any, Dict, List, Union import pytest -from crewai.utilities.string_utils import interpolate_only +from crewai.utilities.string_utils import ( + extract_template_variables, + interpolate_only, +) + + +class TestExtractTemplateVariables: + """Tests for extract_template_variables in string_utils.py.""" + + def test_extracts_simple_identifiers(self): + assert extract_template_variables("Hi {name}, see {topic}.") == [ + "name", + "topic", + ] + + def test_allows_underscores_and_hyphens(self): + assert extract_template_variables("{user_name} {role-detail}") == [ + "user_name", + "role-detail", + ] + + def test_ignores_inline_expressions(self): + text = '{company_name if company_name else "Individual Client"}' + assert extract_template_variables(text) == [] + + def test_ignores_json_like_braces(self): + assert extract_template_variables('{"key": "value"}') == [] + + def test_matches_what_interpolation_fills(self): + text = 'Use {topic} and {x if x else "y"}.' + variables = extract_template_variables(text) + assert variables == ["topic"] + # interpolation fills exactly the extracted variable and leaves the rest + result = interpolate_only(text, {"topic": "AI"}) + assert result == 'Use AI and {x if x else "y"}.' + + @pytest.mark.parametrize("value", [None, ""]) + def test_handles_empty_input(self, value): + assert extract_template_variables(value) == [] class TestInterpolateOnly: