From d5bc82be2100d1a752a22c93b52b03d6ba906818 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 16 Nov 2025 04:03:48 +0000 Subject: [PATCH] Fix hierarchical delegation language-agnostic tool matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add language-agnostic tool name matching in ToolUsage._select_tool() - Support stable identifiers (delegate_work, ask_question) in addition to English names - Update delegation counting to recognize short identifiers - Update short-term memory filter to skip delegation actions with any alias - Add comprehensive unit tests for language-agnostic matching - Fixes issue #3925 where hierarchical process fails with non-English prompts Co-Authored-By: João --- .../base_agent_executor_mixin.py | 42 ++-- lib/crewai/src/crewai/tools/tool_usage.py | 87 +++++++- lib/crewai/tests/tools/test_tool_usage.py | 203 ++++++++++++++++++ 3 files changed, 313 insertions(+), 19 deletions(-) diff --git a/lib/crewai/src/crewai/agents/agent_builder/base_agent_executor_mixin.py b/lib/crewai/src/crewai/agents/agent_builder/base_agent_executor_mixin.py index 5864a4995..be0908817 100644 --- a/lib/crewai/src/crewai/agents/agent_builder/base_agent_executor_mixin.py +++ b/lib/crewai/src/crewai/agents/agent_builder/base_agent_executor_mixin.py @@ -35,23 +35,35 @@ class CrewAgentExecutorMixin: self.crew and self.agent and self.task - and "Action: Delegate work to coworker" not in output.text ): - try: - if ( - hasattr(self.crew, "_short_term_memory") - and self.crew._short_term_memory - ): - self.crew._short_term_memory.save( - value=output.text, - metadata={ - "observation": self.task.description, - }, + is_delegation = any( + pattern in output.text + for pattern in [ + "Action: Delegate work to coworker", + "Action: delegate_work", + "Action: delegate_work_to_coworker", + "Action: Ask question to coworker", + "Action: ask_question", + "Action: ask_question_to_coworker", + ] + ) + + if not is_delegation: + try: + if ( + hasattr(self.crew, "_short_term_memory") + and self.crew._short_term_memory + ): + self.crew._short_term_memory.save( + value=output.text, + metadata={ + "observation": self.task.description, + }, + ) + except Exception as e: + self.agent._logger.log( + "error", f"Failed to add to short term memory: {e}" ) - except Exception as e: - self.agent._logger.log( - "error", f"Failed to add to short term memory: {e}" - ) def _create_external_memory(self, output) -> None: """Create and save a external-term memory item if conditions are met.""" diff --git a/lib/crewai/src/crewai/tools/tool_usage.py b/lib/crewai/src/crewai/tools/tool_usage.py index 6f0e92cb8..c1efc9567 100644 --- a/lib/crewai/src/crewai/tools/tool_usage.py +++ b/lib/crewai/src/crewai/tools/tool_usage.py @@ -240,10 +240,18 @@ class ToolUsage: if result is None: try: - if calling.tool_name in [ + normalized_tool_name = self._normalize_tool_name(calling.tool_name) + is_delegation_tool = normalized_tool_name in [ + "delegate_work_to_coworker", + "delegate_work", + "ask_question_to_coworker", + "ask_question", + ] or calling.tool_name in [ "Delegate work to coworker", "Ask question to coworker", - ]: + ] + + if is_delegation_tool: coworker = ( calling.arguments.get("coworker") if calling.arguments else None ) @@ -400,7 +408,78 @@ class ToolUsage: return f"Tool '{tool_name}' has reached its usage limit of {tool.max_usage_count} times and cannot be used anymore." return None + def _normalize_tool_name(self, name: str) -> str: + """Normalize tool name for language-agnostic matching. + + Converts to lowercase, removes extra whitespace, and replaces + spaces/hyphens with underscores for consistent matching. + + Args: + name: The tool name to normalize + + Returns: + Normalized tool name + """ + if not name: + return "" + normalized = name.lower().strip() + normalized = normalized.replace(" ", "_").replace("-", "_") + while "__" in normalized: + normalized = normalized.replace("__", "_") + return normalized + + def _get_tool_aliases(self, tool: Any) -> list[str]: + """Get all possible aliases for a tool including stable identifiers. + + Args: + tool: The tool object + + Returns: + List of possible tool name aliases + """ + aliases = [tool.name] # Original name + + normalized = self._normalize_tool_name(tool.name) + if normalized and normalized != tool.name: + aliases.append(normalized) + + if tool.name == "Delegate work to coworker": + aliases.extend(["delegate_work", "delegate_work_to_coworker"]) + elif tool.name == "Ask question to coworker": + aliases.extend(["ask_question", "ask_question_to_coworker"]) + + return aliases + def _select_tool(self, tool_name: str) -> Any: + """Select a tool by name with language-agnostic matching support. + + Supports matching against: + 1. Exact tool name (case-insensitive) + 2. Normalized/slugified tool name + 3. Stable short identifiers (delegate_work, ask_question) + 4. Fuzzy matching as fallback (0.85 threshold) + + Args: + tool_name: The name of the tool to select + + Returns: + The selected tool object + + Raises: + Exception: If no matching tool is found + """ + normalized_input = self._normalize_tool_name(tool_name) + + for tool in self.tools: + aliases = self._get_tool_aliases(tool) + + if tool.name.lower().strip() == tool_name.lower().strip(): + return tool + + for alias in aliases: + if self._normalize_tool_name(alias) == normalized_input: + return tool + order_tools = sorted( self.tools, key=lambda tool: SequenceMatcher( @@ -410,13 +489,13 @@ class ToolUsage: ) for tool in order_tools: if ( - tool.name.lower().strip() == tool_name.lower().strip() - or SequenceMatcher( + SequenceMatcher( None, tool.name.lower().strip(), tool_name.lower().strip() ).ratio() > 0.85 ): return tool + if self.task: self.task.increment_tools_errors() tool_selection_data: dict[str, Any] = { diff --git a/lib/crewai/tests/tools/test_tool_usage.py b/lib/crewai/tests/tools/test_tool_usage.py index 927031302..ea4bb7b0f 100644 --- a/lib/crewai/tests/tools/test_tool_usage.py +++ b/lib/crewai/tests/tools/test_tool_usage.py @@ -742,3 +742,206 @@ def test_tool_usage_finished_event_with_cached_result(): assert isinstance(event.started_at, datetime.datetime) assert isinstance(event.finished_at, datetime.datetime) assert event.type == "tool_usage_finished" + + +def test_normalize_tool_name(): + """Test tool name normalization for language-agnostic matching.""" + tool_usage = ToolUsage( + tools_handler=MagicMock(), + tools=[], + task=MagicMock(), + function_calling_llm=None, + agent=MagicMock(), + action=MagicMock(), + ) + + assert tool_usage._normalize_tool_name("Delegate work to coworker") == "delegate_work_to_coworker" + assert tool_usage._normalize_tool_name("Ask question to coworker") == "ask_question_to_coworker" + assert tool_usage._normalize_tool_name("delegate_work") == "delegate_work" + assert tool_usage._normalize_tool_name("DELEGATE WORK") == "delegate_work" + assert tool_usage._normalize_tool_name("delegate-work") == "delegate_work" + assert tool_usage._normalize_tool_name(" delegate work ") == "delegate_work" + assert tool_usage._normalize_tool_name("") == "" + + +def test_get_tool_aliases_for_delegate_work(): + """Test that delegate work tool has correct aliases.""" + from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool + + delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock()) + + tool_usage = ToolUsage( + tools_handler=MagicMock(), + tools=[delegate_tool], + task=MagicMock(), + function_calling_llm=None, + agent=MagicMock(), + action=MagicMock(), + ) + + aliases = tool_usage._get_tool_aliases(delegate_tool) + + assert "Delegate work to coworker" in aliases + assert "delegate_work" in aliases + assert "delegate_work_to_coworker" in aliases + + +def test_get_tool_aliases_for_ask_question(): + """Test that ask question tool has correct aliases.""" + from crewai.tools.agent_tools.ask_question_tool import AskQuestionTool + + ask_tool = AskQuestionTool(agents=[], i18n=MagicMock()) + + tool_usage = ToolUsage( + tools_handler=MagicMock(), + tools=[ask_tool], + task=MagicMock(), + function_calling_llm=None, + agent=MagicMock(), + action=MagicMock(), + ) + + aliases = tool_usage._get_tool_aliases(ask_tool) + + assert "Ask question to coworker" in aliases + assert "ask_question" in aliases + assert "ask_question_to_coworker" in aliases + + +def test_select_tool_with_short_identifier(): + """Test tool selection using short identifiers like delegate_work.""" + from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool + from crewai.tools.agent_tools.ask_question_tool import AskQuestionTool + + delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock()) + ask_tool = AskQuestionTool(agents=[], i18n=MagicMock()) + + tool_usage = ToolUsage( + tools_handler=MagicMock(), + tools=[delegate_tool, ask_tool], + task=MagicMock(), + function_calling_llm=None, + agent=MagicMock(), + action=MagicMock(), + ) + + # Test short identifiers + selected = tool_usage._select_tool("delegate_work") + assert selected.name == "Delegate work to coworker" + + selected = tool_usage._select_tool("ask_question") + assert selected.name == "Ask question to coworker" + + # Test slugified versions + selected = tool_usage._select_tool("delegate_work_to_coworker") + assert selected.name == "Delegate work to coworker" + + selected = tool_usage._select_tool("ask_question_to_coworker") + assert selected.name == "Ask question to coworker" + + +def test_select_tool_with_exact_name(): + """Test tool selection with exact English name still works.""" + from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool + + delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock()) + + tool_usage = ToolUsage( + tools_handler=MagicMock(), + tools=[delegate_tool], + task=MagicMock(), + function_calling_llm=None, + agent=MagicMock(), + action=MagicMock(), + ) + + # Test exact name matching (backward compatibility) + selected = tool_usage._select_tool("Delegate work to coworker") + assert selected.name == "Delegate work to coworker" + + +def test_select_tool_case_insensitive(): + """Test tool selection is case-insensitive.""" + from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool + + delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock()) + + tool_usage = ToolUsage( + tools_handler=MagicMock(), + tools=[delegate_tool], + task=MagicMock(), + function_calling_llm=None, + agent=MagicMock(), + action=MagicMock(), + ) + + # Test case variations + selected = tool_usage._select_tool("DELEGATE_WORK") + assert selected.name == "Delegate work to coworker" + + selected = tool_usage._select_tool("Delegate_Work") + assert selected.name == "Delegate work to coworker" + + +def test_delegation_counting_with_short_identifiers(): + """Test that delegation counting works with short identifiers.""" + from crewai.tools.agent_tools.delegate_work_tool import DelegateWorkTool + from crewai.tools.tool_calling import ToolCalling + + delegate_tool = DelegateWorkTool(agents=[], i18n=MagicMock()) + mock_task = MagicMock() + mock_task.increment_delegations = MagicMock() + + tool_usage = ToolUsage( + tools_handler=MagicMock(), + tools=[delegate_tool], + task=mock_task, + function_calling_llm=None, + agent=MagicMock(), + action=MagicMock(), + ) + + # Create a ToolCalling with short identifier + calling = ToolCalling( + tool_name="delegate_work", + arguments={"coworker": "researcher", "task": "test", "context": "test"} + ) + + # Mock the tool invocation to avoid actual execution + with patch.object(delegate_tool, 'invoke', return_value="test result"): + tool_usage.use(calling, "test string") + + # Verify delegation was counted + mock_task.increment_delegations.assert_called_once() + + +def test_memory_filter_with_short_identifiers(): + """Test that memory filter recognizes short identifiers.""" + from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin + + class TestMixin(CrewAgentExecutorMixin): + def __init__(self): + self.crew = MagicMock() + self.crew._short_term_memory = MagicMock() + self.agent = MagicMock() + self.agent._logger = MagicMock() + self.task = MagicMock() + self.task.description = "test task" + + mixin = TestMixin() + + # Test with short identifier - should NOT save to memory + output = MagicMock() + output.text = "Action: delegate_work\nAction Input: {...}" + mixin._create_short_term_memory(output) + mixin.crew._short_term_memory.save.assert_not_called() + + # Test with English name - should NOT save to memory + output.text = "Action: Delegate work to coworker\nAction Input: {...}" + mixin._create_short_term_memory(output) + mixin.crew._short_term_memory.save.assert_not_called() + + # Test with non-delegation action - should save to memory + output.text = "Action: Some other tool\nAction Input: {...}" + mixin._create_short_term_memory(output) + mixin.crew._short_term_memory.save.assert_called_once()