diff --git a/contrib/crewai/LICENSE b/contrib/crewai/LICENSE new file mode 100644 index 00000000..dfbb8b76 --- /dev/null +++ b/contrib/crewai/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025, Arcade AI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/contrib/crewai/Makefile b/contrib/crewai/Makefile new file mode 100644 index 00000000..5140cfdf --- /dev/null +++ b/contrib/crewai/Makefile @@ -0,0 +1,62 @@ +VERSION ?= "0.1.1" + +.PHONY: install +install: ## Install the poetry environment and install the pre-commit hooks + @if ! command -v poetry >/dev/null 2>&1; then \ + echo "🚫 Poetry is not installed. Please install poetry before proceeding."; \ + exit 1; \ + fi + @echo "🚀 Creating virtual environment using pyenv and poetry" + @poetry install --all-extras + @poetry run pre-commit install + +.PHONY: check +check: ## Run code quality tools. + @echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check --lock" + @poetry check --lock + @echo "🚀 Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "🚀 Static type checking: Running mypy" + @poetry run mypy $(git ls-files '*.py') + +.PHONY: test +test: ## Test the code with pytest + @echo "🚀 Testing code: Running pytest" + @poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml + +.PHONY: set-version +set-version: ## Set the version in the pyproject.toml file + @echo "🚀 Setting version in pyproject.toml" + @poetry version $(VERSION) + +.PHONY: unset-version +unset-version: ## Set the version in the pyproject.toml file + @echo "🚀 Setting version in pyproject.toml" + @poetry version 0.1.0 + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "🚀 Creating wheel file" + @poetry build + +.PHONY: clean-build +clean-build: ## clean build artifacts + @rm -rf dist + +.PHONY: publish +publish: ## publish a release to pypi. + @echo "🚀 Publishing: Dry run." + @poetry config pypi-token.pypi $(PYPI_TOKEN) + @poetry publish --dry-run + @echo "🚀 Publishing." + @poetry publish + +.PHONY: build-and-publish +build-and-publish: build publish ## Build and publish. + +.PHONY: help +help: + @echo "🛠️ Arcade AI Dev Commands:\n" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.DEFAULT_GOAL := help diff --git a/contrib/crewai/README.md b/contrib/crewai/README.md new file mode 100644 index 00000000..d70099fd --- /dev/null +++ b/contrib/crewai/README.md @@ -0,0 +1,38 @@ +

+ + +

+
+

CrewAI Integration

+ + License + + + Downloads + + +
+ +

+ Docs • + Toolkits • + Cookbook • + Python Client • + JavaScript Client +

+ +## Overview + +`crewai-arcade` allows you to use Arcade tools in your CrewAI applications. + +## Installation + +```bash +pip install crewai-arcade +``` + +## Usage + +See the [examples](https://github.com/ArcadeAI/arcade-ai/tree/main/examples/crewai) for usage examples diff --git a/contrib/crewai/crewai_arcade/__init__.py b/contrib/crewai/crewai_arcade/__init__.py new file mode 100644 index 00000000..a46115ee --- /dev/null +++ b/contrib/crewai/crewai_arcade/__init__.py @@ -0,0 +1,3 @@ +from .manager import ArcadeToolManager + +__all__ = ["ArcadeToolManager"] diff --git a/contrib/crewai/crewai_arcade/_utilities.py b/contrib/crewai/crewai_arcade/_utilities.py new file mode 100644 index 00000000..f2e9702b --- /dev/null +++ b/contrib/crewai/crewai_arcade/_utilities.py @@ -0,0 +1,56 @@ +from typing import Any + +from arcadepy.types import ToolDefinition +from pydantic import BaseModel, Field, create_model + +# Mapping of Arcade value types to Python types +TYPE_MAPPING = { + "string": str, + "number": float, + "integer": int, + "boolean": bool, + "array": list, + "json": dict, +} + + +def get_python_type(val_type: str) -> Any: + """Map Arcade value types to Python types. + + Args: + val_type: The value type as a string. + + Returns: + Corresponding Python type. + """ + _type = TYPE_MAPPING.get(val_type) + if _type is None: + raise ValueError(f"Invalid value type: {val_type}") + return _type + + +def tool_definition_to_pydantic_model(tool_def: ToolDefinition) -> type[BaseModel]: + """Convert a ToolDefinition's inputs into a Pydantic BaseModel. + + Args: + tool_def: The ToolDefinition object to convert. + + Returns: + A Pydantic BaseModel class representing the tool's input schema. + """ + try: + fields: dict[str, Any] = {} + for param in tool_def.input.parameters or []: + param_type = get_python_type(param.value_schema.val_type) + if param_type == list and param.value_schema.inner_val_type: # noqa: E721 + inner_type: type[Any] = get_python_type(param.value_schema.inner_val_type) + param_type = list[inner_type] # type: ignore[valid-type] + param_description = param.description or "No description provided." + default = ... if param.required else None + fields[param.name] = ( + param_type, + Field(default=default, description=param_description), + ) + return create_model(f"{tool_def.name}Args", **fields) + except ValueError as e: + raise ValueError(f"Error converting {tool_def.name} parameters into pydantic model: {e}") diff --git a/contrib/crewai/crewai_arcade/manager.py b/contrib/crewai/crewai_arcade/manager.py new file mode 100644 index 00000000..f49965ee --- /dev/null +++ b/contrib/crewai/crewai_arcade/manager.py @@ -0,0 +1,363 @@ +from collections.abc import Iterator +from typing import Any, Callable, Optional, Protocol + +from arcadepy import Arcade +from arcadepy.types import ToolDefinition +from arcadepy.types.shared import AuthorizationResponse + +from crewai_arcade._utilities import tool_definition_to_pydantic_model +from crewai_arcade.structured import StructuredTool + +TOOL_NAME_SEPARATOR = "_" + + +class ArcadeToolExecutorProtocol(Protocol): + """Protocol for Arcade tool executor callback.""" + + def __call__( + self, + manager: "ArcadeToolManager", + name: str, + **input: dict[str, Any], # noqa: A002 + ) -> Any: ... + + +class ArcadeToolManager: + """Arcade tool manager for CrewAI + + Wraps Arcade tools as CrewAI StructuredTools + """ + + def __init__( + self, + client: Optional[Arcade] = None, + executor: Optional[ArcadeToolExecutorProtocol] = None, + *, + default_user_id: Optional[str] = None, + **kwargs: dict[str, Any], + ) -> None: + """Initialize the ArcadeToolManager. + + Example: + >>> manager = ArcadeToolManager(default_user_id="me@example.com", api_key="...") + >>> + >>> # retrieve a specific Arcade tool as a CrewAI tool and add it to the manager + >>> manager.get_tools(tools=["Search.SearchGoogle"]) + >>> + >>> # retrieve all Arcade tools in a toolkit as CrewAI tools and add them to the manager + >>> manager.get_tools(toolkits=["Search"]) + >>> + >>> # retrieve all tools in the manager as CrewAI tools + >>> manager.get_tools() + >>> + >>> # clear and initialize new tools in the manager + >>> manager.init_tools(tools=["Search.SearchGoogle"], toolkits=["Search"]) + + Args: + client: Arcade client instance. + executor: Optional custom executor callback. Useful for customizing the authorization and execution flow. + default_user_id: The default user id used for tool authorization and execution + when no custom executor is provided. + **kwargs: Additional keyword arguments for the Arcade client if the client is not provided. + + Note: + If no executor is provided, `default_user_id` must be specified so that the default + executor can call authorize and execute with that id. + """ + if not client: + api_key = kwargs.get("api_key") + base_url = kwargs.get("base_url") + arcade_kwargs = {"api_key": api_key, "base_url": base_url, **kwargs} + client = Arcade(**arcade_kwargs) # type: ignore[arg-type] + + self._client = client + self._tools: dict[str, ToolDefinition] = {} + self.default_user_id = default_user_id + + # Use the default executor if none is provided. + if executor is None and default_user_id is None: + raise ValueError("A default_user_id must be provided if no executor is specified.") + + self.executor = executor or self._default_executor + + if not callable(self.executor): + raise TypeError( + "executor must be callable and adhere to the ArcadeToolExecutorProtocol signature" + ) + + @property + def tools(self) -> list[str]: + return list(self._tools.keys()) + + def __iter__(self) -> Iterator[tuple[str, ToolDefinition]]: + yield from self._tools.items() + + def __len__(self) -> int: + return len(self._tools) + + def __getitem__(self, tool_name: str) -> ToolDefinition: + return self._tools[tool_name] + + def init_tools( + self, + tools: Optional[list[str]] = None, + toolkits: Optional[list[str]] = None, + ) -> None: + """Initialize the tools in the manager. + + This method clears any existing tools in the manager and replaces them + with tools and toolkits that are provided. If no tools or toolkits are + provided, then all tools in the Arcade client will be added. + + Example: + >>> manager = ArcadeToolManager(default_user_id="me@example.com", api_key="...") + >>> manager.init_tools(tools=["Search.SearchGoogle"]) + >>> manager.get_tools() + + Args: + tools: Optional list of tool names to include. + toolkits: Optional list of toolkits to include. + """ + self._tools = self._retrieve_tool_definitions(tools, toolkits) + + def add_tools( + self, tools: Optional[list[str]] = None, toolkits: Optional[list[str]] = None + ) -> None: + """Add tools to the manager. + + This method adds tools to the manager's internal tool list. If no tools or + toolkits are provided, all tools in the Arcade client will be added. + + Example: + >>> manager = ArcadeToolManager(default_user_id="me@example.com", api_key="...") + >>> manager.init_tools(tools=["Search.SearchGoogle"]) + >>> manager.add_tools(tools=["Google.ListEmails"], toolkits=["Slack"]) + >>> manager.get_tools() + + Args: + tools: List of tool names to add. + toolkits: List of toolkits to add tools from. + """ + new_tool_definitions = self._retrieve_tool_definitions(tools, toolkits) + self._tools.update(new_tool_definitions) + + def get_tools( + self, tools: Optional[list[str]] = None, toolkits: Optional[list[str]] = None + ) -> list[StructuredTool]: + """Retrieves the requested tools or toolkits from the manager. + + This method retrieves the provided tools or toolkits as CrewAI StructuredTools. + + If any provided tools or toolkits are not already present in the + internal tool list, then they are added to the manager. + If no tools or toolkits are provided, then all tools in the manager's + internal tool list are returned as CrewAI StructuredTools. + + Example: + >>> manager = ArcadeToolManager(default_user_id="me@example.com", api_key="...") + >>> + >>> # Retrieve a specific tool as a CrewAI tool + >>> manager.get_tools(tools=["Search.SearchGoogle"]) + >>> + >>> # Retrieve all tools in a toolkit as CrewAI tools + >>> manager.get_tools(toolkits=["Search"]) + >>> + >>> # Retrieve all tools in the manager as CrewAI tools + >>> manager.get_tools() + + Args: + tools: An optional list of tool names to retrieve and wrap. If any of these + tools are missing from the internal list, they will be added. + toolkits: An optional list of toolkits from which to retrieve and wrap tools. + Tools from these toolkits will be added if they are not already present. + + Returns: + A list of StructuredTool instances adapted from the specified tools. + """ + if tools or toolkits: + if len(self) == 0: + self.init_tools(tools, toolkits) + else: + new_tools = self._retrieve_tool_definitions(tools, toolkits) + self._tools.update(new_tools) + + # Wrap the requested tools as CrewAI StructuredTools + crewai_tools: list[StructuredTool] = [] + for tool_name, tool_def in self: + crewai_tools.append(self._wrap_arcade_tool(tool_name, tool_def)) + + return crewai_tools + + def authorize_tool(self, user_id: str, name: str) -> None: + """Handle the authorization flow. + + Args: + user_id: The user ID to authorize the tool for. + name: The name of the tool to authorize. + """ + + if self.requires_auth(name): + # Get authorization status + auth_response = self.authorize(name, user_id) + + if not self.is_authorized(auth_response.id): # type: ignore[arg-type] + # Handle authorization + print(f"Please use the following link to authorize: {auth_response.url}") + auth_response = self.wait_for_auth(auth_response) + + # Ensure authorization completed successfully + if not self.is_authorized(auth_response.id): # type: ignore[arg-type] + raise ValueError(f"Authorization failed for {name}. URL: {auth_response.url}") + + def execute_tool(self, user_id: str, name: str, **input: Any) -> Any: # noqa: A002 + """Handle the tool execution flow. + + Args: + user_id: The user ID to execute the tool for. + name: The name of the tool to execute. + **input: Dictionary of input arguments for the tool. + + Returns: + The output of the tool. + """ + response = self._client.tools.execute( + tool_name=name, + input=input, + user_id=user_id, + ) + + tool_error = response.output.error if response.output else None + if tool_error: + return str(tool_error) + if response.success: + return response.output.value # type: ignore[union-attr] + + return "Failed to call " + name + + def requires_auth(self, tool_name: str) -> bool: + """Check if a tool requires authorization.""" + cleaned_tool_name = tool_name.replace(".", TOOL_NAME_SEPARATOR) + tool_def = self._tools.get(cleaned_tool_name) + + if tool_def is None: + raise ValueError(f"Tool '{tool_name}' not found in this ArcadeToolManager instance") + + if tool_def.requirements is None: + return False + + return tool_def.requirements.authorization is not None + + def authorize(self, tool_name: str, user_id: str) -> AuthorizationResponse: + """Authorize a user for a tool. + + Args: + tool_name: The name of the tool to authorize. + user_id: The user ID to authorize. + + Returns: + AuthorizationResponse + """ + return self._client.tools.authorize(tool_name=tool_name, user_id=user_id) + + def is_authorized(self, authorization_id: str) -> bool: + """Check if a tool authorization is complete.""" + return self._client.auth.status(id=authorization_id).status == "completed" + + def wait_for_auth(self, auth_response: AuthorizationResponse) -> AuthorizationResponse: + """Wait for an authorization process to complete. + + Args: + auth_response: The authorization response from the initial authorize call. + + Returns: + AuthorizationResponse with completed status + """ + return self._client.auth.wait_for_completion(auth_response) + + def _wrap_arcade_tool(self, name: str, tool_def: ToolDefinition) -> StructuredTool: + """Wrap an Arcade tool as a CrewAI StructuredTool. + + Args: + name: The name of the tool to wrap. + tool_def: The definition of the tool to wrap. + + Returns: + A StructuredTool instance. + """ + description = tool_def.description or "No description provided." + args_schema = tool_definition_to_pydantic_model(tool_def) + tool_function = self._create_tool_function(name) + + return StructuredTool.from_function( + func=tool_function, + name=name, + description=description, + args_schema=args_schema, + ) + + def _retrieve_tool_definitions( + self, tools: Optional[list[str]] = None, toolkits: Optional[list[str]] = None + ) -> dict[str, ToolDefinition]: + """Retrieve tool definitions from the Arcade client. + + This method fetches tool definitions based on the provided tool names or toolkits. + If no specific tools or toolkits are provided, the method will fetch and return + all tools available in the Arcade client. + + Args: + tools: Optional list of tool names to retrieve. + toolkits: Optional list of toolkits to retrieve tools from. + + Returns: + A dictionary mapping full tool names to their corresponding ToolDefinition objects + """ + all_tools: list[ToolDefinition] = [] + + if tools: + single_tools = [self._client.tools.get(name=tool_id) for tool_id in tools] + all_tools.extend(single_tools) + + if toolkits: + for tk in toolkits: + all_tools.extend(self._client.tools.list(toolkit=tk)) + + if not tools and not toolkits: + # Retrieve all Arcade tools. + page_iterator = self._client.tools.list() + all_tools.extend(page_iterator) + + tool_definitions: dict[str, ToolDefinition] = {} + for tool in all_tools: + full_tool_name = f"{tool.toolkit.name}{TOOL_NAME_SEPARATOR}{tool.name}" + tool_definitions[full_tool_name] = tool + + return tool_definitions + + def _create_tool_function(self, tool_name: str) -> Callable[..., Any]: + """Creates a function wrapper for an Arcade tool. + + Args: + tool_name: The name of the tool to create a function for. + + Returns: + A callable function that executes the tool. + """ + + def tool_function(**kwargs: Any) -> Any: + return self.executor(self, tool_name, **kwargs) + + return tool_function + + @staticmethod + def _default_executor( + manager: "ArcadeToolManager", name: str, **tool_input: dict[str, Any] + ) -> Any: + """ + Default executor that performs authorization followed + by tool execution using the manager's default_user_id. + """ + if manager.default_user_id is None: + raise ValueError("default_user_id is not set in ArcadeToolManager.") + user_id = manager.default_user_id + manager.authorize_tool(user_id, name) + return manager.execute_tool(user_id, name, **tool_input) diff --git a/contrib/crewai/crewai_arcade/py.typed b/contrib/crewai/crewai_arcade/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/contrib/crewai/crewai_arcade/structured.py b/contrib/crewai/crewai_arcade/structured.py new file mode 100644 index 00000000..9e84b33d --- /dev/null +++ b/contrib/crewai/crewai_arcade/structured.py @@ -0,0 +1,54 @@ +from textwrap import dedent +from typing import Any, Callable, Optional + +from crewai.tools.base_tool import BaseTool +from pydantic import BaseModel as PydanticBaseModel + + +class StructuredTool(BaseTool): # type: ignore[no-any-unimported] + """A tool that executes functions with structured inputs using a schema.""" + + func: Callable[..., Any] + """The callable function that implements the tool's functionality.""" + + def _run(self, *args: Any, **kwargs: Any) -> Any: + """Execute the tool's function with the provided arguments.""" + return self.func(*args, **kwargs) + + @classmethod + def from_function( + cls, + func: Callable, + args_schema: type[PydanticBaseModel], + name: Optional[str] = None, + description: Optional[str] = None, + **kwargs: Any, + ) -> "StructuredTool": + """Create a new StructuredTool instance from a function. + + Args: + func: Function to wrap as a CrewAI tool + name: Custom name for the tool + description: Custom description of the tool's functionality + args_schema: Pydantic model defining the expected argument structure + **kwargs: Additional tool configuration parameters, like cache_function + + Returns: + StructuredTool: A new tool instance wrapping the provided function + """ + name = name or func.__name__ + + if description is None: + description = func.__doc__ + if description is None: + raise ValueError("Function must have a docstring if description not provided.") + # Clean up docstring + description = dedent(description).strip() + + return cls( + func=func, + args_schema=args_schema, + name=name, + description=description, + **kwargs, + ) diff --git a/contrib/crewai/pyproject.toml b/contrib/crewai/pyproject.toml new file mode 100644 index 00000000..221015ec --- /dev/null +++ b/contrib/crewai/pyproject.toml @@ -0,0 +1,45 @@ +[tool.poetry] +name = "crewai-arcade" +version = "0.1.1" +description = "An integration package connecting Arcade and CrewAI" +authors = ["Arcade "] +readme = "README.md" +repository = "https://github.com/arcadeai/arcade-ai/tree/main/contrib/crewai" +license = "MIT" + +[tool.poetry.dependencies] +python = ">=3.10,<3.13" +crewai = ">=0.1.0,<1.0.0" +pydantic = "^2.0.0" +arcadepy = "^1.0.0" + +[tool.poetry.group.dev.dependencies] +pytest = "^8.1.2" +pytest-cov = "^4.0.0" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" + + +[tool.mypy] +files = ["crewai_arcade"] +python_version = "3.10" +disallow_untyped_defs = "True" +disallow_any_unimported = "True" +no_implicit_optional = "True" +check_untyped_defs = "True" +warn_return_any = "True" +warn_unused_ignores = "True" +show_error_codes = "True" +ignore_missing_imports = "True" + +[tool.pytest.ini_options] +testpaths = ["tests"] + + +[tool.coverage.run] +branch = true +source = ["crewai_arcade"] + +[tool.coverage.report] +skip_empty = true diff --git a/contrib/crewai/tests/__init__.py b/contrib/crewai/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/contrib/crewai/tests/test_manager.py b/contrib/crewai/tests/test_manager.py new file mode 100644 index 00000000..ba977886 --- /dev/null +++ b/contrib/crewai/tests/test_manager.py @@ -0,0 +1,258 @@ +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from arcadepy.types import ToolDefinition +from crewai_arcade.manager import TOOL_NAME_SEPARATOR, ArcadeToolManager + +# --- Custom executor --- + + +def custom_executor(manager: ArcadeToolManager, name: str, **tool_input: dict[str, Any]) -> Any: + """Custom executor for testing purposes.""" + return "Tool executed" + + +# --- Fixtures --- + + +@pytest.fixture +def mock_client(): + """Create a fake Arcade client fixture.""" + return MagicMock() + + +@pytest.fixture +def manager_with_default_executor(mock_client): + """Return an ArcadeToolManager with a test default_user_id and fake client.""" + return ArcadeToolManager(default_user_id="test_user", client=mock_client) + + +@pytest.fixture +def manager_with_custom_executor(mock_client): + """Return an ArcadeToolManager with a test default_user_id and fake client using a custom executor.""" + return ArcadeToolManager( + default_user_id="test_user", client=mock_client, executor=custom_executor + ) + + +@pytest.fixture +def fake_tool_definition(): + """Return a fake tool definition for testing purposes.""" + fake_tool = MagicMock(spec=ToolDefinition) + fake_tool.name = "SearchGoogle" + fake_tool.description = "Test tool description" + fake_tool.toolkit = MagicMock() + fake_tool.toolkit.name = "Search" + fake_tool.requirements = None + fake_tool.input = MagicMock() + fake_tool.input.parameters = [] + return fake_tool + + +# --- Tests for _create_tool_function --- + + +def test_create_tool_function_success_custom(manager_with_custom_executor): + """ + Test that the tool function executes successfully using the custom executor. + The custom executor simply returns "Tool executed". + """ + tool_function = manager_with_custom_executor._create_tool_function("test_tool") + result = tool_function() + assert result == "Tool executed" + + +def test_create_tool_function_forwards_kwargs_custom(manager_with_custom_executor): + """ + Test that extra keyword arguments are forwarded correctly to the custom executor. + The custom executor ignores the kwargs and returns "Tool executed". + """ + tool_function = manager_with_custom_executor._create_tool_function("test_tool") + result = tool_function(param1="value1", param2=2) + assert result == "Tool executed" + + +def test_create_tool_function_unauthorized(manager_with_default_executor): + """ + Test that the tool function raises a ValueError when authorization fails using the default executor. + """ + # Mock an authorization failure by having authorize_tool raise ValueError. + manager_with_default_executor.authorize_tool = MagicMock( + side_effect=ValueError("Authorization failed for test_tool") + ) + manager_with_default_executor.execute_tool = MagicMock() + + tool_function = manager_with_default_executor._create_tool_function("test_tool") + + with pytest.raises(ValueError, match="Authorization failed for test_tool"): + tool_function() + + manager_with_default_executor.authorize_tool.assert_called_once_with("test_user", "test_tool") + manager_with_default_executor.execute_tool.assert_not_called() # auth fails before this is called + + +def test_create_tool_function_execution_failure(manager_with_default_executor): + """ + Test that when tool execution returns a failing value, that value is returned. + """ + manager_with_default_executor.authorize_tool = MagicMock() # auth passes + + manager_with_default_executor.execute_tool = MagicMock(return_value="error") + + tool_function = manager_with_default_executor._create_tool_function("test_tool") + result = tool_function() + + assert result == "error" + manager_with_default_executor.authorize_tool.assert_called_once_with("test_user", "test_tool") + manager_with_default_executor.execute_tool.assert_called_once_with("test_user", "test_tool") + + +# --- Test for _wrap_arcade_tool --- + + +def test_wrap_arcade_tool(manager_with_default_executor, fake_tool_definition): + """ + Test that _wrap_arcade_tool correctly creates a StructuredTool. + """ + fake_tool_definition.description = "Test tool" + tool_name = "test_tool" + + # Patch the conversion utilities. Also, override _create_tool_function to return a dummy function. + with ( + patch( + "crewai_arcade.manager.tool_definition_to_pydantic_model", return_value="args_schema" + ) as mock_to_model, + patch( + "crewai_arcade.structured.StructuredTool.from_function", return_value="structured_tool" + ) as mock_from_function, + patch.object( + manager_with_default_executor, + "_create_tool_function", + return_value=lambda *a, **kw: None, + ) as mock_create_tool, + ): + result = manager_with_default_executor._wrap_arcade_tool(tool_name, fake_tool_definition) + + assert result == "structured_tool" + mock_to_model.assert_called_once_with(fake_tool_definition) + mock_from_function.assert_called_once_with( + func=mock_create_tool.return_value, + name=tool_name, + description="Test tool", + args_schema="args_schema", + ) + + +# --- Tests for tool registration (init_tools, add_tools, get_tools) --- + + +def test_init_tools_with_tool(manager_with_default_executor, fake_tool_definition): + """ + Test that init_tools clears and initializes the tools in the manager using tool names. + """ + manager_with_default_executor._client.tools.get.return_value = fake_tool_definition + manager_with_default_executor.init_tools(tools=["Search.SearchGoogle"]) + + expected_key = ( + f"{fake_tool_definition.toolkit.name}{TOOL_NAME_SEPARATOR}{fake_tool_definition.name}" + ) + assert expected_key in manager_with_default_executor._tools + assert len(manager_with_default_executor._tools) == 1 + + +def test_init_tools_with_toolkit(manager_with_default_executor, fake_tool_definition): + """ + Test that init_tools correctly fetches tools using a toolkit. + """ + # Simulate that listing a toolkit returns a list with the fake tool + manager_with_default_executor._client.tools.list.return_value = [fake_tool_definition] + manager_with_default_executor.init_tools(toolkits=["Search"]) + + expected_key = ( + f"{fake_tool_definition.toolkit.name}{TOOL_NAME_SEPARATOR}{fake_tool_definition.name}" + ) + assert expected_key in manager_with_default_executor._tools + assert len(manager_with_default_executor._tools) == 1 + + +def test_init_tools_with_none(manager_with_default_executor, fake_tool_definition): + """ + Test that init_tools with no arguments retrieves all tools. + """ + manager_with_default_executor._client.tools.list.return_value = [fake_tool_definition] + manager_with_default_executor.init_tools() + + expected_key = ( + f"{fake_tool_definition.toolkit.name}{TOOL_NAME_SEPARATOR}{fake_tool_definition.name}" + ) + assert expected_key in manager_with_default_executor._tools + assert len(manager_with_default_executor._tools) == 1 + + +def test_add_tools(manager_with_default_executor, fake_tool_definition): + """ + Test that add_tools supplements the manager's existing tool dictionary. + """ + # Set an initial tool in _tools. + fake_initial_tool = MagicMock(spec=ToolDefinition) + fake_initial_tool.name = "InitialTool" + fake_initial_tool.toolkit = MagicMock() + fake_initial_tool.toolkit.name = "InitialToolkit" + initial_key = f"{fake_initial_tool.toolkit.name}{TOOL_NAME_SEPARATOR}{fake_initial_tool.name}" + manager_with_default_executor._tools[initial_key] = fake_initial_tool + + # Mock retrieval of a new tool. + manager_with_default_executor._client.tools.get.return_value = fake_tool_definition + manager_with_default_executor.add_tools(tools=["Search.SearchGoogle"]) + + new_key = f"{fake_tool_definition.toolkit.name}{TOOL_NAME_SEPARATOR}{fake_tool_definition.name}" + assert initial_key in manager_with_default_executor._tools + assert new_key in manager_with_default_executor._tools + + +def test_get_tools_with_existing_tools(manager_with_default_executor, fake_tool_definition): + """ + Test that get_tools wraps existing tools if they are already registered. + """ + manager_with_default_executor._client.tools.get.return_value = fake_tool_definition + manager_with_default_executor.init_tools(tools=["Search.SearchGoogle"]) + expected_key = ( + f"{fake_tool_definition.toolkit.name}{TOOL_NAME_SEPARATOR}{fake_tool_definition.name}" + ) + + # Patch _wrap_arcade_tool to verify that it is called. + with patch.object( + manager_with_default_executor, "_wrap_arcade_tool", side_effect=lambda name, td: (name, td) + ) as mock_wrap: + crewai_tools = manager_with_default_executor.get_tools() + + assert len(crewai_tools) == 1 + assert crewai_tools[0] == (expected_key, fake_tool_definition) + mock_wrap.assert_called_once_with(expected_key, fake_tool_definition) + + +def test_get_tools_with_missing_tool_and_toolkit( + manager_with_default_executor, fake_tool_definition +): + """ + Test that get_tools adds missing tools and toolkits when not already registered. + """ + manager_with_default_executor._tools = {} + manager_with_default_executor._client.tools.get.return_value = fake_tool_definition + manager_with_default_executor._client.tools.list.return_value = [fake_tool_definition] + + with patch.object( + manager_with_default_executor, "_wrap_arcade_tool", side_effect=lambda name, td: (name, td) + ) as mock_wrap: + crewai_tools = manager_with_default_executor.get_tools( + tools=["Search.SearchGoogle"], toolkits=["Search"] + ) + + expected_key = ( + f"{fake_tool_definition.toolkit.name}{TOOL_NAME_SEPARATOR}{fake_tool_definition.name}" + ) + assert expected_key in manager_with_default_executor._tools + assert len(crewai_tools) == 1 + assert crewai_tools[0] == (expected_key, fake_tool_definition) + mock_wrap.assert_called_once_with(expected_key, fake_tool_definition) diff --git a/contrib/crewai/tests/test_structured_tool.py b/contrib/crewai/tests/test_structured_tool.py new file mode 100644 index 00000000..18d7654b --- /dev/null +++ b/contrib/crewai/tests/test_structured_tool.py @@ -0,0 +1,92 @@ +import pytest +from crewai import Agent, Crew, Task +from crewai_arcade.structured import StructuredTool +from pydantic import BaseModel + + +class CalculatorInput(BaseModel): + x: float + y: float + + +def add_numbers(x: float, y: float) -> float: + """Add two numbers together.""" + return x + y + + +def unnamed_function(x: float, y: float) -> float: + return x + y + + +def test_structured_tool_basic(): + # Test basic functionality with explicit name and description + calculator_tool = StructuredTool.from_function( + func=add_numbers, + args_schema=CalculatorInput, + name="Calculator", + description="A tool that adds two numbers together", + ) + + expected_description = ( + "Tool Name: Calculator\n" + "Tool Arguments: {'x': {'description': None, 'type': 'float'}, 'y': {'description': None, 'type': 'float'}}\n" + "Tool Description: A tool that adds two numbers together" + ) + assert calculator_tool.description == expected_description + assert calculator_tool.func(2, 3) == 5 + + +def test_structured_tool_auto_name_description(): + # Test automatic name and description generation from function + calculator_tool = StructuredTool.from_function(func=add_numbers, args_schema=CalculatorInput) + + expected_description = ( + "Tool Name: add_numbers\n" + "Tool Arguments: {'x': {'description': None, 'type': 'float'}, 'y': {'description': None, 'type': 'float'}}\n" + "Tool Description: Add two numbers together." + ) + assert calculator_tool.description == expected_description + + +def test_structured_tool_validation_errors(): + # Test missing docstring + with pytest.raises( + ValueError, match="Function must have a docstring if description not provided." + ): + StructuredTool.from_function(func=unnamed_function, args_schema=CalculatorInput) + + +def test_structured_tool_in_crew(): + calculator_tool = StructuredTool.from_function( + func=add_numbers, + args_schema=CalculatorInput, + ) + + calculator_agent = Agent( + role="Math Expert", + goal="Perform mathematical calculations accurately", + backstory="An expert mathematician who specializes in calculations", + tools=[calculator_tool], + verbose=True, + cache=False, + ) + + addition_task = Task( + description="Add the numbers 5 and 3 together", + expected_output="The sum of 5 and 3 is 8", + agent=calculator_agent, + ) + + crew = Crew( + agents=[calculator_agent], + tasks=[addition_task], + ) + + # Assert crew structure + assert len(crew.agents) == 1 + assert len(crew.tasks) == 1 + assert crew.agents[0] == calculator_agent + assert crew.tasks[0] == addition_task + assert crew.tasks[0].agent == calculator_agent + assert len(crew.agents[0].tools) == 1 + assert isinstance(crew.agents[0].tools[0], StructuredTool) diff --git a/examples/crewai/crewai_with_arcade_tool.py b/examples/crewai/crewai_with_arcade_tool.py new file mode 100644 index 00000000..19bba268 --- /dev/null +++ b/examples/crewai/crewai_with_arcade_tool.py @@ -0,0 +1,134 @@ +""" + +This is an example of how to use Arcade with CrewAI. +The ArcadeToolManager allows you to handle both authorization and tool execution in a custom way. +This example demonstrates how to implement a custom auth handler and a custom tool execute handler. + +The example assumes the following: +1. You have an Arcade API key and have set the ARCADE_API_KEY environment variable. +2. You have an OpenAI API key and have set the OPENAI_API_KEY environment variable. +3. You have installed the necessary dependencies in the requirements.txt file: `pip install -r requirements.txt` + +""" + +from typing import Any + +from crewai import Agent, Crew, Task +from crewai.crews import CrewOutput +from crewai.llm import LLM +from crewai_arcade import ArcadeToolManager + +USER_ID = "user@example.com" + + +def custom_auth_flow( + manager: ArcadeToolManager, tool_name: str, **tool_input: dict[str, Any] +) -> Any: + """Custom auth flow for the ArcadeToolManager + + This function is called when CrewAI needs to call a tool that requires authorization. + Authorization is handled before executing the tool. + This function overrides the ArcadeToolManager's default auth flow performed by ArcadeToolManager.authorize_tool + """ + print(f"Authorization required for tool: '{tool_name}' with inputs:") + for input_name, input_value in tool_input.items(): + print(f" {input_name}: {input_value}") + + # Get authorization status + auth_response = manager.authorize(tool_name, USER_ID) + + # If the user is not authorized for the tool, + # then we need to handle the authorization before executing the tool + if not manager.is_authorized(auth_response.id): + # Handle authorization + print(f"\nTo authorize, visit: {auth_response.url}") + # Block until the user has completed the authorization + auth_response = manager.wait_for_auth(auth_response) + + # Ensure authorization completed successfully + if not manager.is_authorized(auth_response.id): + raise ValueError(f"Authorization failed for {tool_name}. URL: {auth_response.url}") + + +def custom_execute_flow( + manager: ArcadeToolManager, tool_name: str, **tool_input: dict[str, Any] +) -> Any: + """Custom tool execution flow for the ArcadeToolManager + + This function is called when CrewAI needs to execute a tool after any authorization has been handled. + This function overrides the ArcadeToolManager's default tool execution flow performed by ArcadeToolManager.execute_tool + """ + print(f"Executing tool: '{tool_name}' with inputs:") + for input_name, input_value in tool_input.items(): + print(f" {input_name}: {input_value}") + + # Execute the tool + response = manager._client.tools.execute( + tool_name=tool_name, + input=tool_input, + user_id=USER_ID, + ) + + # Handle the tool error if it exists + tool_error = response.output.error if response.output else None + if tool_error: + return str(tool_error) + + # Return the tool output if the tool was executed successfully + if response.success: + return response.output.value # type: ignore[union-attr] + + # Return a failure message if the tool was not executed successfully + return "Failed to call " + tool_name + + +def custom_tool_executor( + manager: ArcadeToolManager, tool_name: str, **tool_input: dict[str, Any] +) -> Any: + """Custom tool executor for the ArcadeToolManager + + ArcadeToolManager's default executor handles authorization and tool execution. + This function overrides the default executor to handle authorization and tool execution in a custom way. + """ + custom_auth_flow(manager, tool_name, **tool_input) + return custom_execute_flow(manager, tool_name, **tool_input) + + +def main() -> CrewOutput: + manager = ArcadeToolManager( + executor=custom_tool_executor, + ) + tools = manager.get_tools(tools=["Google.ListEmails"]) + + crew_agent = Agent( + role="Main Agent", + backstory="You are a helpful assistant", + goal="Help the user with their requests", + tools=tools, + allow_delegation=False, + verbose=True, + llm=LLM(model="gpt-4o"), + ) + + task = Task( + description="Get the 5 most recent emails from the user's inbox and summarize them and recommend a response for each.", + expected_output="A bulleted list with a one sentence summary of each email and a recommended response to the email.", + agent=crew_agent, + tools=crew_agent.tools, + ) + + crew = Crew( + agents=[crew_agent], + tasks=[task], + verbose=True, + memory=True, + ) + + result = crew.kickoff() + return result + + +if __name__ == "__main__": + result = main() + print("\n\n\n ------------ Result ------------ \n\n\n") + print(result) diff --git a/examples/crewai/requirements.txt b/examples/crewai/requirements.txt new file mode 100644 index 00000000..d78c37b6 --- /dev/null +++ b/examples/crewai/requirements.txt @@ -0,0 +1 @@ +crewai-arcade>=0.1.0 diff --git a/examples/crewai/simple_crewai_with_arcade_tool.py b/examples/crewai/simple_crewai_with_arcade_tool.py new file mode 100644 index 00000000..1127489e --- /dev/null +++ b/examples/crewai/simple_crewai_with_arcade_tool.py @@ -0,0 +1,46 @@ +""" +This is a simple example of how to use Arcade with CrewAI. +The example authenticates into the user's Gmail account, retrieves their 5 most recent emails, and summarizes them. + +The example assumes the following: +1. You have an Arcade API key and have set the ARCADE_API_KEY environment variable. +2. You have an OpenAI API key and have set the OPENAI_API_KEY environment variable. +3. You have installed the necessary dependencies in the requirements.txt file: `pip install -r requirements.txt` + +""" + +from crewai import Agent, Crew, Task +from crewai.llm import LLM +from crewai_arcade import ArcadeToolManager + +manager = ArcadeToolManager(default_user_id="user@example.com") +tools = manager.get_tools(tools=["Google.ListEmails"]) + +crew_agent = Agent( + role="Main Agent", + backstory="You are a helpful assistant", + goal="Help the user with their requests", + tools=tools, + allow_delegation=False, + verbose=True, + llm=LLM(model="gpt-4o"), +) + +task = Task( + description="Get the 5 most recent emails from the user's inbox and summarize them and recommend a response for each.", + expected_output="A bulleted list with a one sentence summary of each email and a recommended response to the email.", + agent=crew_agent, + tools=crew_agent.tools, +) + +crew = Crew( + agents=[crew_agent], + tasks=[task], + verbose=True, + memory=True, +) + +result = crew.kickoff() + +print("\n\n\n ------------ Result ------------ \n\n\n") +print(result)