diff --git a/reproduce_issue_3226.py b/reproduce_issue_3226.py new file mode 100644 index 000000000..6a0709dbb --- /dev/null +++ b/reproduce_issue_3226.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Reproduction script for issue #3226: Cannot Register Custom Tools with Agents in CrewAI 0.150.0 +This script tests all the failing patterns mentioned in the issue. +""" + +import sys +import traceback +from typing import Any + +def test_function_tool(): + """Test 1: Function Tool with @tool decorator""" + print("=== Test 1: Function Tool with @tool decorator ===") + try: + from crewai.tools import tool + from crewai import Agent + + @tool + def fetch_logs(query: str) -> str: + """Fetch logs from New Relic based on query""" + return f"Logs for query: {query}" + + teacher = Agent( + role='CrashFetcher', + goal='Extract logs', + backstory='An agent that fetches logs', + tools=[fetch_logs], + allow_delegation=False + ) + print("✅ Function tool with @tool decorator: SUCCESS") + return True + except Exception as e: + print(f"❌ Function tool with @tool decorator: FAILED - {e}") + traceback.print_exc() + return False + +def test_dict_tool(): + """Test 2: Dict-based tool definition""" + print("\n=== Test 2: Dict-based tool definition ===") + try: + from crewai import Agent + + def fetch_logs_func(query: str) -> str: + return f"Logs for query: {query}" + + fetch_logs_dict = { + 'name': 'fetch_logs', + 'description': 'Fetch logs from New Relic', + 'func': fetch_logs_func + } + + teacher = Agent( + role='CrashFetcher', + goal='Extract logs', + backstory='An agent that fetches logs', + tools=[fetch_logs_dict], + allow_delegation=False + ) + print("✅ Dict-based tool: SUCCESS") + return True + except Exception as e: + print(f"❌ Dict-based tool: FAILED - {e}") + traceback.print_exc() + return False + +def test_basetool_class(): + """Test 3: BaseTool class inheritance""" + print("\n=== Test 3: BaseTool class inheritance ===") + try: + from crewai.tools import BaseTool + from crewai import Agent + + class FetchLogsTool(BaseTool): + name: str = "fetch_logs" + description: str = "Fetch logs from New Relic based on query" + + def _run(self, query: str) -> str: + return f"Logs for query: {query}" + + teacher = Agent( + role='CrashFetcher', + goal='Extract logs', + backstory='An agent that fetches logs', + tools=[FetchLogsTool()], + allow_delegation=False + ) + print("✅ BaseTool class inheritance: SUCCESS") + return True + except Exception as e: + print(f"❌ BaseTool class inheritance: FAILED - {e}") + traceback.print_exc() + return False + +def test_direct_function(): + """Test 4: Direct function assignment""" + print("\n=== Test 4: Direct function assignment ===") + try: + from crewai import Agent + + def fetch_logs(query: str) -> str: + """Fetch logs from New Relic based on query""" + return f"Logs for query: {query}" + + teacher = Agent( + role='CrashFetcher', + goal='Extract logs', + backstory='An agent that fetches logs', + tools=[fetch_logs], + allow_delegation=False + ) + print("✅ Direct function assignment: SUCCESS") + return True + except Exception as e: + print(f"❌ Direct function assignment: FAILED - {e}") + traceback.print_exc() + return False + +def main(): + """Run all tests and report results""" + print("Testing custom tool registration patterns from issue #3226\n") + + results = [] + results.append(test_function_tool()) + results.append(test_dict_tool()) + results.append(test_basetool_class()) + results.append(test_direct_function()) + + print(f"\n=== SUMMARY ===") + passed = sum(results) + total = len(results) + print(f"Tests passed: {passed}/{total}") + + if passed == total: + print("🎉 All custom tool patterns are working!") + return 0 + else: + print("💥 Some custom tool patterns are still broken") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/crewai/agents/agent_builder/base_agent.py b/src/crewai/agents/agent_builder/base_agent.py index ba2596f63..c3778d0bf 100644 --- a/src/crewai/agents/agent_builder/base_agent.py +++ b/src/crewai/agents/agent_builder/base_agent.py @@ -108,7 +108,7 @@ class BaseAgent(ABC, BaseModel): default=False, description="Enable agent to delegate and ask questions among each other.", ) - tools: Optional[List[BaseTool]] = Field( + tools: Optional[List[Any]] = Field( default_factory=list, description="Tools at agents' disposal" ) max_iter: int = Field( @@ -171,27 +171,48 @@ class BaseAgent(ABC, BaseModel): def validate_tools(cls, tools: List[Any]) -> List[BaseTool]: """Validate and process the tools provided to the agent. - This method ensures that each tool is either an instance of BaseTool - or an object with 'name', 'func', and 'description' attributes. If the - tool meets these criteria, it is processed and added to the list of - tools. Otherwise, a ValueError is raised. + This method ensures that each tool is either an instance of BaseTool, + a function (with or without @tool decorator), a dict with tool definition, + or an object with 'name', 'func', and 'description' attributes. """ if not tools: return [] + from crewai.tools.base_tool import tool + processed_tools = [] - required_attrs = ["name", "func", "description"] - for tool in tools: - if isinstance(tool, BaseTool): - processed_tools.append(tool) - elif all(hasattr(tool, attr) for attr in required_attrs): - # Tool has the required attributes, create a Tool instance - processed_tools.append(Tool.from_langchain(tool)) + for tool_item in tools: + if isinstance(tool_item, BaseTool): + processed_tools.append(tool_item) + elif callable(tool_item): + if hasattr(tool_item, '__doc__') and tool_item.__doc__: + converted_tool = tool(tool_item) + processed_tools.append(converted_tool) + else: + raise ValueError( + f"Function tool '{tool_item.__name__}' must have a docstring" + ) + elif isinstance(tool_item, dict): + required_keys = ['name', 'description', 'func'] + if all(key in tool_item for key in required_keys): + processed_tools.append(Tool( + name=tool_item['name'], + description=tool_item['description'], + func=tool_item['func'] + )) + else: + raise ValueError( + f"Dict tool must contain keys: {required_keys}. " + f"Got: {list(tool_item.keys())}" + ) + elif hasattr(tool_item, 'name') and hasattr(tool_item, 'func') and hasattr(tool_item, 'description'): + processed_tools.append(Tool.from_langchain(tool_item)) else: raise ValueError( - f"Invalid tool type: {type(tool)}. " - "Tool must be an instance of BaseTool or " - "an object with 'name', 'func', and 'description' attributes." + f"Invalid tool type: {type(tool_item)}. " + "Tool must be a BaseTool instance, function, dict with " + "'name'/'description'/'func' keys, or object with " + "'name'/'func'/'description' attributes." ) return processed_tools diff --git a/src/crewai/tools/__init__.py b/src/crewai/tools/__init__.py index 2467fa906..28c867327 100644 --- a/src/crewai/tools/__init__.py +++ b/src/crewai/tools/__init__.py @@ -1,7 +1,8 @@ -from .base_tool import BaseTool, tool, EnvVar +from .base_tool import BaseTool, tool, EnvVar, Tool __all__ = [ "BaseTool", + "Tool", "tool", "EnvVar", -] \ No newline at end of file +] diff --git a/src/crewai/utilities/agent_utils.py b/src/crewai/utilities/agent_utils.py index 40fa5ea07..700ecd2e1 100644 --- a/src/crewai/utilities/agent_utils.py +++ b/src/crewai/utilities/agent_utils.py @@ -30,10 +30,13 @@ def parse_tools(tools: List[BaseTool]) -> List[CrewStructuredTool]: tools_list = [] for tool in tools: - if isinstance(tool, CrewAITool): + if isinstance(tool, BaseTool): tools_list.append(tool.to_structured_tool()) else: - raise ValueError("Tool is not a CrewStructuredTool or BaseTool") + raise ValueError( + f"Tool must be a BaseTool instance, got {type(tool)}. " + "Ensure tools are properly validated before calling parse_tools." + ) return tools_list diff --git a/tests/test_custom_tools.py b/tests/test_custom_tools.py new file mode 100644 index 000000000..baebcc46e --- /dev/null +++ b/tests/test_custom_tools.py @@ -0,0 +1,222 @@ +""" +Test custom tool registration patterns to ensure all documented patterns work correctly. +This addresses issue #3226 where custom tool registration was broken in CrewAI 0.150.0. +""" + +import pytest +from typing import Any +from crewai import Agent +from crewai.tools import BaseTool, tool, Tool + + +class TestCustomToolPatterns: + """Test all custom tool patterns mentioned in issue #3226.""" + + def test_function_tool_with_decorator(self): + """Test function tool with @tool decorator.""" + @tool + def fetch_logs(query: str) -> str: + """Fetch logs from New Relic based on query""" + return f"Logs for query: {query}" + + agent = Agent( + role='CrashFetcher', + goal='Extract logs', + backstory='An agent that fetches logs', + tools=[fetch_logs], + allow_delegation=False + ) + + assert len(agent.tools) == 1 + assert agent.tools[0].name == "fetch_logs" + assert "Fetch logs from New Relic" in agent.tools[0].description + + def test_dict_based_tool(self): + """Test dict-based tool definition.""" + def fetch_logs_func(query: str) -> str: + return f"Logs for query: {query}" + + fetch_logs_dict = { + 'name': 'fetch_logs', + 'description': 'Fetch logs from New Relic', + 'func': fetch_logs_func + } + + agent = Agent( + role='CrashFetcher', + goal='Extract logs', + backstory='An agent that fetches logs', + tools=[fetch_logs_dict], + allow_delegation=False + ) + + assert len(agent.tools) == 1 + assert agent.tools[0].name == "fetch_logs" + assert "Fetch logs from New Relic" in agent.tools[0].description + + def test_basetool_class_inheritance(self): + """Test BaseTool class inheritance.""" + class FetchLogsTool(BaseTool): + name: str = "fetch_logs" + description: str = "Fetch logs from New Relic based on query" + + def _run(self, query: str) -> str: + return f"Logs for query: {query}" + + agent = Agent( + role='CrashFetcher', + goal='Extract logs', + backstory='An agent that fetches logs', + tools=[FetchLogsTool()], + allow_delegation=False + ) + + assert len(agent.tools) == 1 + assert agent.tools[0].name == "fetch_logs" + assert "Fetch logs from New Relic" in agent.tools[0].description + + def test_direct_function_assignment(self): + """Test direct function assignment.""" + def fetch_logs(query: str) -> str: + """Fetch logs from New Relic based on query""" + return f"Logs for query: {query}" + + agent = Agent( + role='CrashFetcher', + goal='Extract logs', + backstory='An agent that fetches logs', + tools=[fetch_logs], + allow_delegation=False + ) + + assert len(agent.tools) == 1 + assert agent.tools[0].name == "fetch_logs" + assert "Fetch logs from New Relic" in agent.tools[0].description + + def test_mixed_tool_types(self): + """Test mixing different tool types in the same agent.""" + @tool + def decorated_tool(query: str) -> str: + """A decorated tool""" + return f"Decorated: {query}" + + class ClassTool(BaseTool): + name: str = "class_tool" + description: str = "A class-based tool" + + def _run(self, query: str) -> str: + return f"Class: {query}" + + def function_tool(query: str) -> str: + """A function tool""" + return f"Function: {query}" + + dict_tool = { + 'name': 'dict_tool', + 'description': 'A dict-based tool', + 'func': lambda query: f"Dict: {query}" + } + + agent = Agent( + role='MultiTool', + goal='Use multiple tool types', + backstory='An agent with various tools', + tools=[decorated_tool, ClassTool(), function_tool, dict_tool], + allow_delegation=False + ) + + assert len(agent.tools) == 4 + tool_names = [tool.name for tool in agent.tools] + assert "decorated_tool" in tool_names + assert "class_tool" in tool_names + assert "function_tool" in tool_names + assert "dict_tool" in tool_names + + def test_invalid_tool_types(self): + """Test that invalid tool types raise appropriate errors.""" + with pytest.raises(ValueError, match="Invalid tool type"): + Agent( + role='Test', + goal='Test invalid tools', + backstory='Testing', + tools=["invalid_string_tool"], + allow_delegation=False + ) + + with pytest.raises(ValueError, match="Invalid tool type"): + Agent( + role='Test', + goal='Test invalid tools', + backstory='Testing', + tools=[123], + allow_delegation=False + ) + + def test_function_without_docstring_fails(self): + """Test that functions without docstrings fail validation.""" + def no_docstring_func(query: str) -> str: + return f"No docstring: {query}" + + with pytest.raises(ValueError, match="must have a docstring"): + Agent( + role='Test', + goal='Test function without docstring', + backstory='Testing', + tools=[no_docstring_func], + allow_delegation=False + ) + + def test_incomplete_dict_tool_fails(self): + """Test that dict tools missing required keys fail validation.""" + incomplete_dict = { + 'name': 'incomplete', + 'description': 'Missing func key' + } + + with pytest.raises(ValueError, match="Dict tool must contain keys"): + Agent( + role='Test', + goal='Test incomplete dict tool', + backstory='Testing', + tools=[incomplete_dict], + allow_delegation=False + ) + + def test_tool_execution(self): + """Test that tools can actually be executed.""" + @tool + def test_execution_tool(message: str) -> str: + """A tool for testing execution""" + return f"Executed: {message}" + + agent = Agent( + role='Executor', + goal='Execute tools', + backstory='An agent that executes tools', + tools=[test_execution_tool], + allow_delegation=False + ) + + tool_instance = agent.tools[0] + result = tool_instance.run(message="test") + assert result == "Executed: test" + + def test_tool_with_multiple_parameters(self): + """Test tools with multiple parameters work correctly.""" + @tool + def multi_param_tool(param1: str, param2: int, param3: bool = True) -> str: + """A tool with multiple parameters""" + return f"p1={param1}, p2={param2}, p3={param3}" + + agent = Agent( + role='MultiParam', + goal='Use multi-parameter tools', + backstory='An agent with complex tools', + tools=[multi_param_tool], + allow_delegation=False + ) + + assert len(agent.tools) == 1 + tool_instance = agent.tools[0] + result = tool_instance.run(param1="test", param2=42, param3=False) + assert result == "p1=test, p2=42, p3=False"