From 3f25e535f4d6b528c753c4d6f71d30d4915bfadc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 02:49:10 +0000 Subject: [PATCH] Fix issue #2383: Add invoke method to BaseTool for models without function calling support Co-Authored-By: Joe Moura --- src/crewai/tools/base_tool.py | 42 ++++++++++++++++++++++- tests/tools/test_invoke_method.py | 55 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_invoke_method.py diff --git a/src/crewai/tools/base_tool.py b/src/crewai/tools/base_tool.py index b3c0f997c..22e12e557 100644 --- a/src/crewai/tools/base_tool.py +++ b/src/crewai/tools/base_tool.py @@ -1,7 +1,7 @@ import warnings from abc import ABC, abstractmethod from inspect import signature -from typing import Any, Callable, Type, get_args, get_origin +from typing import Any, Callable, Dict, Optional, Type, Union, get_args, get_origin from pydantic import ( BaseModel, @@ -75,6 +75,46 @@ class BaseTool(BaseModel, ABC): **kwargs: Any, ) -> Any: """Here goes the actual implementation of the tool.""" + + def invoke( + self, input: Union[str, dict], config: Optional[dict] = None, **kwargs: Any + ) -> Any: + """Main method for tool execution. + + This method provides a fallback implementation for models that don't support + function calling natively (like QwQ-32B-Preview and deepseek-chat). + It parses the input and calls the _run method with the appropriate arguments. + """ + if isinstance(input, str): + # Try to parse as JSON if it's a string + try: + import json + input = json.loads(input) + except json.JSONDecodeError: + # If not valid JSON, pass as a single argument + return self._run(input) + + if not isinstance(input, dict): + # If input is not a dict after parsing, pass it directly + return self._run(input) + + # Get the expected arguments from the schema + if hasattr(self, 'args_schema') and self.args_schema is not None: + try: + # Extract argument names from the schema + arg_names = list(self.args_schema.model_json_schema()["properties"].keys()) + + # Filter the input to only include valid arguments + filtered_args = {k: v for k, v in input.items() if k in arg_names} + + # Call _run with the filtered arguments + return self._run(**filtered_args) + except Exception: + # Fallback to passing the entire input dict if schema parsing fails + pass + + # If we couldn't parse the schema or there was an error, just pass the input dict + return self._run(**input) def to_structured_tool(self) -> CrewStructuredTool: """Convert this tool to a CrewStructuredTool instance.""" diff --git a/tests/tools/test_invoke_method.py b/tests/tools/test_invoke_method.py new file mode 100644 index 000000000..7e2119a06 --- /dev/null +++ b/tests/tools/test_invoke_method.py @@ -0,0 +1,55 @@ +from typing import Type + +import pytest +from pydantic import BaseModel, Field + +from crewai.tools import BaseTool + + +class TestToolInput(BaseModel): + param: str = Field(description="A test parameter") + + +class TestTool(BaseTool): + name: str = "Test Tool" + description: str = "A tool for testing the invoke method" + args_schema: Type[BaseModel] = TestToolInput + + def _run(self, param: str) -> str: + return f"Tool executed with: {param}" + + +def test_invoke_with_dict(): + """Test that invoke works with a dictionary input.""" + tool = TestTool() + result = tool.invoke(input={"param": "test value"}) + assert result == "Tool executed with: test value" + + +def test_invoke_with_json_string(): + """Test that invoke works with a JSON string input.""" + tool = TestTool() + result = tool.invoke(input='{"param": "test value"}') + assert result == "Tool executed with: test value" + + +def test_invoke_with_raw_string(): + """Test that invoke works with a raw string input.""" + tool = TestTool() + result = tool.invoke(input="test value") + assert result == "Tool executed with: test value" + + +def test_invoke_with_empty_dict(): + """Test that invoke handles empty dict input appropriately.""" + tool = TestTool() + with pytest.raises(Exception): + # Should raise an exception since param is required + tool.invoke(input={}) + + +def test_invoke_with_extra_args(): + """Test that invoke filters out extra arguments not in the schema.""" + tool = TestTool() + result = tool.invoke(input={"param": "test value", "extra": "ignored"}) + assert result == "Tool executed with: test value"