diff --git a/src/crewai/tools/agent_tools/base_agent_tools.py b/src/crewai/tools/agent_tools/base_agent_tools.py index 247849ac7..d0317d088 100644 --- a/src/crewai/tools/agent_tools/base_agent_tools.py +++ b/src/crewai/tools/agent_tools/base_agent_tools.py @@ -1,3 +1,4 @@ +import logging from typing import Optional, Union from pydantic import Field @@ -8,6 +9,9 @@ from crewai.tools.base_tool import BaseTool from crewai.utilities import I18N +logger = logging.getLogger(__name__) + + class BaseAgentTool(BaseTool): """Base class for agent-related tools""" @@ -16,6 +20,20 @@ class BaseAgentTool(BaseTool): default_factory=I18N, description="Internationalization settings" ) + def sanitize_agent_name(self, name: str) -> str: + """ + Sanitize agent role name by trimming whitespace and setting to lowercase. + Removes quotes and newlines for consistent matching. + + Args: + name (str): The agent role name to sanitize + + Returns: + str: The sanitized agent role name, with whitespace trimmed, + converted to lowercase, and quotes/newlines removed + """ + return name.strip().casefold().replace('"', "").replace("\n", "") + def _get_coworker(self, coworker: Optional[str], **kwargs) -> Optional[str]: coworker = coworker or kwargs.get("co_worker") or kwargs.get("coworker") if coworker: @@ -25,11 +43,27 @@ class BaseAgentTool(BaseTool): return coworker def _execute( - self, agent_name: Union[str, None], task: str, context: Union[str, None] + self, + agent_name: Optional[str], + task: str, + context: Optional[str] = None ) -> str: + """ + Execute delegation to an agent with case-insensitive and whitespace-tolerant matching. + + Args: + agent_name: Name/role of the agent to delegate to (case-insensitive) + task: The specific question or task to delegate + context: Optional additional context for the task execution + + Returns: + str: The execution result from the delegated agent or an error message + if the agent cannot be found + """ try: if agent_name is None: agent_name = "" + logger.debug("No agent name provided, using empty string") # It is important to remove the quotes from the agent name. # The reason we have to do this is because less-powerful LLM's @@ -38,31 +72,49 @@ class BaseAgentTool(BaseTool): # {"task": "....", "coworker": ".... # when it should look like this: # {"task": "....", "coworker": "...."} - agent_name = agent_name.strip().casefold().replace('"', "").replace("\n", "") + sanitized_name = self.sanitize_agent_name(agent_name) + logger.debug(f"Sanitized agent name from '{agent_name}' to '{sanitized_name}'") + + available_agents = [agent.role for agent in self.agents] + logger.debug(f"Available agents: {available_agents}") + agent = [ # type: ignore # Incompatible types in assignment (expression has type "list[BaseAgent]", variable has type "str | None") available_agent for available_agent in self.agents - if available_agent.role.strip().casefold().replace("\n", "") == agent_name + if self.sanitize_agent_name(available_agent.role) == sanitized_name ] - except Exception as _: + logger.debug(f"Found {len(agent)} matching agents for role '{sanitized_name}'") + except (AttributeError, ValueError) as e: + # Handle specific exceptions that might occur during role name processing return self.i18n.errors("agent_tool_unexisting_coworker").format( coworkers="\n".join( - [f"- {agent.role.casefold()}" for agent in self.agents] - ) + [f"- {self.sanitize_agent_name(agent.role)}" for agent in self.agents] + ), + error=str(e) ) if not agent: + # No matching agent found after sanitization return self.i18n.errors("agent_tool_unexisting_coworker").format( coworkers="\n".join( - [f"- {agent.role.casefold()}" for agent in self.agents] - ) + [f"- {self.sanitize_agent_name(agent.role)}" for agent in self.agents] + ), + error=f"No agent found with role '{sanitized_name}'" ) agent = agent[0] - task_with_assigned_agent = Task( # type: ignore # Incompatible types in assignment (expression has type "Task", variable has type "str") - description=task, - agent=agent, - expected_output=agent.i18n.slice("manager_request"), - i18n=agent.i18n, - ) - return agent.execute_task(task_with_assigned_agent, context) + try: + task_with_assigned_agent = Task( + description=task, + agent=agent, + expected_output=agent.i18n.slice("manager_request"), + i18n=agent.i18n, + ) + logger.debug(f"Created task for agent '{self.sanitize_agent_name(agent.role)}': {task}") + return agent.execute_task(task_with_assigned_agent, context) + except Exception as e: + # Handle task creation or execution errors + return self.i18n.errors("agent_tool_execution_error").format( + agent_role=self.sanitize_agent_name(agent.role), + error=str(e) + ) diff --git a/tests/test_manager_llm_delegation.py b/tests/test_manager_llm_delegation.py new file mode 100644 index 000000000..8ad2889ee --- /dev/null +++ b/tests/test_manager_llm_delegation.py @@ -0,0 +1,52 @@ +import pytest +from crewai import Agent, Task +from crewai.tools.agent_tools.base_agent_tools import BaseAgentTool +from unittest.mock import MagicMock + +class TestAgentTool(BaseAgentTool): + """Concrete implementation of BaseAgentTool for testing.""" + def _run(self, *args, **kwargs): + """Implement required _run method.""" + return "Test response" + +@pytest.mark.parametrize("role_name,should_match", [ + ('Futel Official Infopoint', True), # exact match + (' "Futel Official Infopoint" ', True), # extra quotes and spaces + ('Futel Official Infopoint\n', True), # trailing newline + ('"Futel Official Infopoint"', True), # embedded quotes + (' FUTEL\nOFFICIAL INFOPOINT ', True), # multiple whitespace and newline + ('futel official infopoint', True), # lowercase + ('FUTEL OFFICIAL INFOPOINT', True), # uppercase + ('Non Existent Agent', False), # non-existent agent + (None, False), # None agent name +]) +def test_agent_tool_role_matching(role_name, should_match): + """Test that agent tools can match roles regardless of case, whitespace, and special characters.""" + # Create test agent + test_agent = Agent( + role='Futel Official Infopoint', + goal='Answer questions about Futel', + backstory='Futel Football Club info', + allow_delegation=False + ) + + # Create test agent tool + agent_tool = TestAgentTool( + name="test_tool", + description="Test tool", + agents=[test_agent] + ) + + # Test role matching + result = agent_tool._execute( + agent_name=role_name, + task='Test task', + context=None + ) + + if should_match: + assert "coworker mentioned not found" not in result.lower(), \ + f"Should find agent with role name: {role_name}" + else: + assert "coworker mentioned not found" in result.lower(), \ + f"Should not find agent with role name: {role_name}"