diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/__init__.py index e8f82975e..4eb9d7d43 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/__init__.py @@ -7,24 +7,12 @@ through the CrewAI platform API. from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import ( CrewAIPlatformActionTool, ) -from crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder import ( - CrewaiPlatformToolBuilder, -) from crewai_tools.tools.crewai_platform_tools.crewai_platform_tools import ( CrewaiPlatformTools, ) -from crewai_tools.tools.crewai_platform_tools.integrations_client import ( - IntegrationsClient, - IntegrationsResponse, - LegacyClient, -) __all__ = [ "CrewAIPlatformActionTool", - "CrewaiPlatformToolBuilder", "CrewaiPlatformTools", - "IntegrationsClient", - "IntegrationsResponse", - "LegacyClient", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/application_selector.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/application_selector.py deleted file mode 100644 index ac2b9e347..000000000 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/application_selector.py +++ /dev/null @@ -1,54 +0,0 @@ -from uuid import UUID - - -class ApplicationSelector: - """Parse an application selector. - - Selectors use the ``application[/action][@connection_uuid]`` syntax. - - Raises: - ValueError: If the selector does not follow the supported syntax. - """ - - name: str - action: str | None - connection_id: UUID | None - - def __init__(self, value: str) -> None: - if not value: - raise ValueError(f"Invalid application selector {value!r}: cannot be empty") - if "@" in value and "/" in value and value.index("@") < value.index("/"): - raise ValueError( - f"Invalid application selector {value!r}: " - "connection ID must be the last segment" - ) - - app, connection_separator, connection_id = value.partition("@") - name, action_separator, action = app.partition("/") - - if not name: - raise ValueError( - f"Invalid application selector {value!r}: application cannot be empty" - ) - if action_separator and not action: - raise ValueError( - f"Invalid application selector {value!r}: action cannot be empty" - ) - if connection_separator and not connection_id: - raise ValueError( - f"Invalid application selector {value!r}: connection ID cannot be empty" - ) - - parsed_connection_id = None - if connection_id: - try: - parsed_connection_id = UUID(connection_id) - except ValueError as error: - raise ValueError( - f"Invalid application selector {value!r}: " - "connection ID must be a valid UUID" - ) from error - - self.name = name - self.action = action if action_separator else None - self.connection_id = parsed_connection_id diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_action_tool.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_action_tool.py index 1f6f89d4f..c911cdec6 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_action_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_action_tool.py @@ -11,85 +11,66 @@ from pydantic import Field, PrivateAttr, create_model from crewai_tools.tools.crewai_platform_tools.integrations_client import ( IntegrationsClient, LegacyClient, + ToolExecutionFailure, + ToolInfo, ) class CrewAIPlatformActionTool(BaseTool): - _integrations_client: IntegrationsClient = PrivateAttr() + _client: IntegrationsClient = PrivateAttr() + _tool_info: ToolInfo = PrivateAttr() app: str = Field(description="The integration slug for this action") - action_name: str = Field(default="", description="The name of the action") - action_schema: dict[str, Any] = Field( - default_factory=dict, description="The schema of the action" - ) def __init__( self, - description: str, - app: str, - action_name: str, - action_schema: dict[str, Any], - integrations_client: IntegrationsClient | None = None, + tool_info: ToolInfo, + client: IntegrationsClient | None = None, ) -> None: - parameters = action_schema.get("function", {}).get("parameters", {}) + schema_name = f"{tool_info.qualified_name}Schema" + parameters = tool_info.parameters if parameters and parameters.get("properties"): try: if "title" not in parameters: - parameters = {**parameters, "title": f"{action_name}Schema"} + parameters = {**parameters, "title": schema_name} if "type" not in parameters: parameters = {**parameters, "type": "object"} args_schema = create_model_from_schema(parameters) except Exception: - args_schema = create_model(f"{action_name}Schema") + args_schema = create_model(schema_name) else: - args_schema = create_model(f"{action_name}Schema") + args_schema = create_model(schema_name) super().__init__( - name=action_name.lower().replace(" ", "_"), - description=description, + name=tool_info.qualified_name, + description=tool_info.description, args_schema=args_schema, - app=app, - ) - self.action_name = action_name - self.action_schema = action_schema - self._integrations_client = ( - integrations_client if integrations_client is not None else LegacyClient() + app=tool_info.app, ) + self._client = client if client is not None else LegacyClient() + self._tool_info = tool_info - def _run(self, **kwargs: Any) -> Any: + def _run(self, **kwargs: Any) -> str | ToolFailure: try: cleaned_kwargs = { key: value for key, value in kwargs.items() if value is not None } - response = self._integrations_client.execute_action( - self.action_name, cleaned_kwargs - ) + result = self._client.execute_action(self._tool_info, cleaned_kwargs) - data = response.json() - if not response.ok: - if isinstance(data, dict): - error_info = data.get("error", {}) - if isinstance(error_info, dict): - error_message = error_info.get("message", json.dumps(data)) - else: - error_message = str(error_info) - else: - error_message = str(data) - # A non-2xx here means the upstream app rejected the action - # (e.g. Slack's channel_not_found) -- report it, not prose. + if isinstance(result, ToolExecutionFailure): return ToolFailure( - message=f"API request failed: {error_message}", - code=str(response.status_code), - retryable=response.status_code >= 500, - details={"action": self.action_name}, + message=f"API request failed: {result.message}", + code=result.code, + retryable=result.retryable, + details={"action": self._tool_info.action}, ) - return json.dumps(data, indent=2) + return json.dumps(result.output, indent=2) except Exception as e: return ToolFailure( - message=f"Error executing action {self.action_name}: {e!s}", + message=f"Error executing action {self._tool_info.action}: {e!s}", code=e.__class__.__name__, - details={"action": self.action_name}, + details={"action": self._tool_info.action}, ) diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tool_builder.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tool_builder.py deleted file mode 100644 index 5e4796f9c..000000000 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tool_builder.py +++ /dev/null @@ -1,115 +0,0 @@ -"""CrewAI platform tool builder for fetching and creating action tools.""" - -import logging -from types import TracebackType -from typing import Any - -from crewai.tools import BaseTool - -from crewai_tools.tools.crewai_platform_tools.application_selector import ( - ApplicationSelector, -) -from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import ( - CrewAIPlatformActionTool, -) -from crewai_tools.tools.crewai_platform_tools.integrations_client import ( - IntegrationsClient, - LegacyClient, -) - - -logger = logging.getLogger(__name__) - - -class CrewaiPlatformToolBuilder: - """Builds platform tools from remote action schemas.""" - - def __init__( - self, - apps: list[str], - integrations_client: IntegrationsClient | None = None, - ) -> None: - self._apps = [ApplicationSelector(app) for app in apps] - self._integrations_client = ( - integrations_client if integrations_client is not None else LegacyClient() - ) - self._actions_schema: dict[str, dict[str, Any]] = {} - self._tools: list[BaseTool] | None = None - - def tools(self) -> list[BaseTool]: - """Fetch actions and return built tools.""" - if self._tools is None: - self._fetch_actions() - self._create_tools() - return self._tools if self._tools is not None else [] - - def _fetch_actions(self) -> None: - """Fetch action schemas from the platform API.""" - apps = [ - f"{app.name}/{app.action}" if app.action is not None else app.name - for app in self._apps - ] - - try: - response = self._integrations_client.get_actions(apps) - response.raise_for_status() - except ValueError: - raise - except Exception as e: - logger.error(f"Failed to fetch platform tools for apps {apps}: {e}") - return - - raw_data = response.json() - - self._actions_schema = {} - action_categories = raw_data.get("actions", {}) - - for app, action_list in action_categories.items(): - if isinstance(action_list, list): - for action in action_list: - if not isinstance(action, dict): - continue - if action_name := action.get("name"): - action_schema = { - "function": { - "name": action_name, - "description": action.get( - "description", f"Execute {action_name}" - ), - "parameters": action.get("parameters", {}), - "app": app, - } - } - self._actions_schema[action_name] = action_schema - - def _create_tools(self) -> None: - """Create tool instances from fetched action schemas.""" - tools: list[BaseTool] = [] - - for action_name, action_schema in self._actions_schema.items(): - function_details = action_schema.get("function", {}) - description = function_details.get("description", f"Execute {action_name}") - - tool = CrewAIPlatformActionTool( - description=description, - app=function_details["app"], - action_name=action_name, - action_schema=action_schema, - integrations_client=self._integrations_client, - ) - - tools.append(tool) - - self._tools = tools - - def __enter__(self) -> list[BaseTool]: - """Enter context manager and return tools.""" - return self.tools() - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Exit context manager.""" diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tools.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tools.py index 0ab955547..8fab93f40 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tools.py +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tools.py @@ -2,12 +2,13 @@ import logging from crewai.tools import BaseTool -from crewai_tools.adapters.tool_collection import ToolCollection -from crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder import ( - CrewaiPlatformToolBuilder, +from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import ( + CrewAIPlatformActionTool, ) from crewai_tools.tools.crewai_platform_tools.integrations_client import ( + ApplicationSelector, IntegrationsClient, + LegacyClient, ) @@ -16,19 +17,26 @@ logger = logging.getLogger(__name__) def CrewaiPlatformTools( # noqa: N802 apps: list[str], - integrations_client: IntegrationsClient | None = None, -) -> ToolCollection[BaseTool]: +) -> list[BaseTool]: """Factory function that returns crewai platform tools. Args: apps: List of platform apps to get tools that are available on the platform. - integrations_client: Client used to get and execute actions. Returns: A list of BaseTool instances for platform actions """ - builder = CrewaiPlatformToolBuilder( - apps=apps, integrations_client=integrations_client - ) + selectors = [ApplicationSelector.from_string(app) for app in apps] + client: IntegrationsClient = LegacyClient() - return builder.tools() # type: ignore + try: + tool_infos = client.get_actions(selectors) + except ValueError: + raise + except Exception as error: + logger.error(f"Failed to fetch platform tools for apps {apps}: {error}") + return [] + + return [ + CrewAIPlatformActionTool(tool_info, client=client) for tool_info in tool_infos + ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/integrations_client.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/integrations_client.py index 203ed9008..351f5be52 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/integrations_client.py +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/integrations_client.py @@ -1,70 +1,212 @@ """Contract and default client for platform integrations.""" +from __future__ import annotations + +from dataclasses import dataclass +import json import os from typing import Any, Protocol +from uuid import UUID +from crewai.utilities.string_utils import sanitize_tool_name +from crewai_core.plus_api import PlusAPI import requests from crewai_tools.tools.crewai_platform_tools.misc import ( - get_platform_api_base_url, get_platform_integration_token, ) -class IntegrationsResponse(Protocol): - """Define the response operations used by platform tools.""" +@dataclass(frozen=True) +class ApplicationSelector: + """Represent an application selector.""" + + app: str + action: str | None + connection_id: UUID | None + + @classmethod + def from_string(cls, value: str) -> ApplicationSelector: + """Parse the ``application[/action][@connection_uuid]`` syntax. + + Raises: + ValueError: If the selector does not follow the supported syntax. + """ + if not value: + raise ValueError(f"Invalid application selector {value!r}: cannot be empty") + if "@" in value and "/" in value and value.index("@") < value.index("/"): + raise ValueError( + f"Invalid application selector {value!r}: " + "connection ID must be the last segment" + ) + + app_and_action, connection_separator, connection_id = value.partition("@") + app, action_separator, action = app_and_action.partition("/") + + if not app: + raise ValueError( + f"Invalid application selector {value!r}: application cannot be empty" + ) + if action_separator and not action: + raise ValueError( + f"Invalid application selector {value!r}: action cannot be empty" + ) + if connection_separator and not connection_id: + raise ValueError( + f"Invalid application selector {value!r}: connection ID cannot be empty" + ) + + parsed_connection_id = None + if connection_id: + try: + parsed_connection_id = UUID(connection_id) + except ValueError as error: + raise ValueError( + f"Invalid application selector {value!r}: " + "connection ID must be a valid UUID" + ) from error + + return cls( + app=app, + action=action if action_separator else None, + connection_id=parsed_connection_id, + ) + + +@dataclass(frozen=True) +class ToolInfo: + """Describe a normalized platform action.""" + + app: str + action: str + connection_id: UUID | None + description: str + parameters: dict[str, Any] @property - def ok(self) -> bool: - """Return whether the request succeeded.""" + def qualified_name(self) -> str: + """Return the qualified tool name.""" + parts = [self.app, self.action] + if self.connection_id is not None: + parts.append(str(self.connection_id)) + return sanitize_tool_name("_".join(parts)) - @property - def status_code(self) -> int: - """Return the HTTP response status code.""" - def json(self) -> Any: - """Decode the response body as JSON.""" +@dataclass(frozen=True) +class ToolExecutionSuccess: + """Represent a successful platform action execution.""" - def raise_for_status(self) -> None: - """Raise an error when the request failed.""" + output: dict[str, Any] + + +@dataclass(frozen=True) +class ToolExecutionFailure: + """Represent an expected platform action failure.""" + + message: str + code: str + retryable: bool + + +ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure class IntegrationsClient(Protocol): - """Define the client operations required by CrewAI platform tools.""" + """Define the contract for platform integrations clients.""" - def get_actions(self, apps: list[str]) -> IntegrationsResponse: + def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]: """Get the actions available for the selected applications.""" def execute_action( - self, action_name: str, arguments: dict[str, Any] - ) -> IntegrationsResponse: + self, tool: ToolInfo, arguments: dict[str, Any] + ) -> ToolExecutionResult: """Execute an action with the given arguments.""" class LegacyClient: """Use the existing CrewAI platform integrations API.""" - def get_actions(self, apps: list[str]) -> requests.Response: + def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]: """Get the actions available for the selected applications.""" - return requests.get( - f"{get_platform_api_base_url()}/actions", + plus_api = PlusAPI() + apps = [ + f"{selector.app}/{selector.action}" + if selector.action is not None + else selector.app + for selector in selectors + ] + response = requests.get( + f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}/actions", headers={"Authorization": f"Bearer {get_platform_integration_token()}"}, timeout=30, params={"apps": ",".join(apps)}, verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true", ) + response.raise_for_status() + + tool_infos: list[ToolInfo] = [] + action_categories = response.json().get("actions", {}) + for app, actions in action_categories.items(): + if not isinstance(actions, list): + continue + for action_data in actions: + if not isinstance(action_data, dict): + continue + if action := action_data.get("name"): + parameters = action_data.get("parameters", {}) + if not isinstance(parameters, dict): + parameters = {} + + tool_infos.extend( + ToolInfo( + app=app, + action=action, + connection_id=selector.connection_id, + description=action_data.get( + "description", f"Execute {action}" + ), + parameters=parameters, + ) + for selector in selectors + if selector.app == app and selector.action in (None, action) + ) + + return tool_infos def execute_action( - self, action_name: str, arguments: dict[str, Any] - ) -> requests.Response: + self, tool: ToolInfo, arguments: dict[str, Any] + ) -> ToolExecutionResult: """Execute an action with the given arguments.""" - return requests.post( - url=f"{get_platform_api_base_url()}/actions/{action_name}/execute", + plus_api = PlusAPI() + response = requests.post( + url=( + f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}" + f"/actions/{tool.action}/execute" + ), headers={ "Authorization": f"Bearer {get_platform_integration_token()}", "Content-Type": "application/json", }, json={"integration": arguments if arguments else {"_noop": True}}, timeout=60, + allow_redirects=False, verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true", ) + data = response.json() + if not 200 <= response.status_code < 300: + if isinstance(data, dict): + error_info = data.get("error", {}) + if isinstance(error_info, dict): + error_message = error_info.get("message", json.dumps(data)) + else: + error_message = str(error_info) + else: + error_message = str(data) + + return ToolExecutionFailure( + message=str(error_message), + code=str(response.status_code), + retryable=response.status_code >= 500, + ) + + return ToolExecutionSuccess(output=data) diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/misc.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/misc.py index 06cf7147d..00144b0ce 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/misc.py +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/misc.py @@ -1,14 +1,8 @@ import os -def get_platform_api_base_url() -> str: - """Get the platform API base URL from environment or use default.""" - base_url = os.getenv("CREWAI_PLUS_URL", "https://app.crewai.com") - return f"{base_url}/crewai_plus/api/v1/integrations" - - def get_platform_integration_token() -> str: - """Get the platform API base URL from environment or use default.""" + """Get the platform integration token from the environment.""" token = os.getenv("CREWAI_PLATFORM_INTEGRATION_TOKEN") or "" if not token: raise ValueError( diff --git a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_action_tool.py b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_action_tool.py index bd44f39f3..2692a4794 100644 --- a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_action_tool.py +++ b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_action_tool.py @@ -1,43 +1,96 @@ -from unittest.mock import patch, Mock import os +from typing import cast +from unittest.mock import Mock, patch + +from crewai.tools.tool_failure import ToolFailure from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import ( CrewAIPlatformActionTool, ) +from crewai_tools.tools.crewai_platform_tools.integrations_client import ( + IntegrationsClient, + ToolExecutionFailure, + ToolExecutionSuccess, + ToolInfo, +) class TestCrewAIPlatformActionToolVerify: """Test suite for SSL verification behavior based on CREWAI_FACTORY environment variable""" def setup_method(self): - self.action_schema = { - "function": { - "name": "test_action", - "parameters": { - "properties": { - "test_param": { - "type": "string", - "description": "Test parameter" - } - }, - "required": [] - } - } - } - - def create_test_tool(self): - return CrewAIPlatformActionTool( - description="Test action tool", + self.tool_info = ToolInfo( app="test_app", - action_name="test_action", - action_schema=self.action_schema + action="test_action", + connection_id=None, + description="Test action tool", + parameters={ + "properties": { + "test_param": { + "type": "string", + "description": "Test parameter", + } + }, + "required": [], + }, + ) + + def create_test_tool( + self, client: IntegrationsClient | None = None + ) -> CrewAIPlatformActionTool: + return CrewAIPlatformActionTool(self.tool_info, client=client) + + def test_run_serializes_success_output(self): + client = Mock(spec=IntegrationsClient) + client.execute_action.return_value = ToolExecutionSuccess( + output={"result": {"id": 42}} + ) + + result = self.create_test_tool(cast(IntegrationsClient, client))._run( + test_param="test_value", optional_param=None + ) + + assert result == '{\n "result": {\n "id": 42\n }\n}' + assert client.execute_action.call_args.args[1] == {"test_param": "test_value"} + + def test_run_converts_expected_failure(self): + client = Mock(spec=IntegrationsClient) + client.execute_action.return_value = ToolExecutionFailure( + message="Channel not found", + code="404", + retryable=False, + ) + + result = self.create_test_tool(cast(IntegrationsClient, client))._run( + test_param="test_value" + ) + + assert result == ToolFailure( + message="API request failed: Channel not found", + code="404", + retryable=False, + details={"action": "test_action"}, + ) + + def test_run_preserves_unexpected_exception_fallback(self): + client = Mock(spec=IntegrationsClient) + client.execute_action.side_effect = ValueError("Invalid response JSON") + + result = self.create_test_tool(cast(IntegrationsClient, client))._run( + test_param="test_value" + ) + + assert result == ToolFailure( + message="Error executing action test_action: Invalid response JSON", + code="ValueError", + details={"action": "test_action"}, ) @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True) @patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post") def test_run_with_ssl_verification_default(self, mock_post): """Test that _run uses SSL verification by default when CREWAI_FACTORY is not set""" - mock_response = Mock() + mock_response = Mock(status_code=200) mock_response.ok = True mock_response.json.return_value = {"result": "success"} mock_post.return_value = mock_response @@ -53,7 +106,7 @@ class TestCrewAIPlatformActionToolVerify: @patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post") def test_run_with_ssl_verification_factory_false(self, mock_post): """Test that _run uses SSL verification when CREWAI_FACTORY is 'false'""" - mock_response = Mock() + mock_response = Mock(status_code=200) mock_response.ok = True mock_response.json.return_value = {"result": "success"} mock_post.return_value = mock_response @@ -69,7 +122,7 @@ class TestCrewAIPlatformActionToolVerify: @patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post") def test_run_with_ssl_verification_factory_false_uppercase(self, mock_post): """Test that _run uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)""" - mock_response = Mock() + mock_response = Mock(status_code=200) mock_response.ok = True mock_response.json.return_value = {"result": "success"} mock_post.return_value = mock_response @@ -85,7 +138,7 @@ class TestCrewAIPlatformActionToolVerify: @patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post") def test_run_without_ssl_verification_factory_true(self, mock_post): """Test that _run disables SSL verification when CREWAI_FACTORY is 'true'""" - mock_response = Mock() + mock_response = Mock(status_code=200) mock_response.ok = True mock_response.json.return_value = {"result": "success"} mock_post.return_value = mock_response @@ -101,7 +154,7 @@ class TestCrewAIPlatformActionToolVerify: @patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post") def test_run_without_ssl_verification_factory_true_uppercase(self, mock_post): """Test that _run disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)""" - mock_response = Mock() + mock_response = Mock(status_code=200) mock_response.ok = True mock_response.json.return_value = {"result": "success"} mock_post.return_value = mock_response diff --git a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tool_builder.py b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tool_builder.py deleted file mode 100644 index dedfcf3f2..000000000 --- a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tool_builder.py +++ /dev/null @@ -1,375 +0,0 @@ -import unittest -from unittest.mock import Mock, patch - -from crewai_tools.tools.crewai_platform_tools import ( - CrewAIPlatformActionTool, - CrewaiPlatformToolBuilder, -) -import pytest - - -class TestCrewaiPlatformToolBuilder(unittest.TestCase): - @pytest.fixture - def platform_tool_builder(self): - """Create a CrewaiPlatformToolBuilder instance for testing""" - return CrewaiPlatformToolBuilder(apps=["github", "slack"]) - - @pytest.fixture - def mock_api_response(self): - return { - "actions": { - "github": [ - { - "name": "create_issue", - "description": "Create a GitHub issue", - "parameters": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "Issue title", - }, - "body": {"type": "string", "description": "Issue body"}, - }, - "required": ["title"], - }, - } - ], - "slack": [ - { - "name": "send_message", - "description": "Send a Slack message", - "parameters": { - "type": "object", - "properties": { - "channel": { - "type": "string", - "description": "Channel name", - }, - "text": { - "type": "string", - "description": "Message text", - }, - }, - "required": ["channel", "text"], - }, - } - ], - } - } - - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) - @patch( - "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" - ) - def test_fetch_actions_success(self, mock_get): - mock_api_response = { - "actions": { - "github": [ - { - "name": "create_issue", - "description": "Create a GitHub issue", - "parameters": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "Issue title", - } - }, - "required": ["title"], - }, - } - ] - } - } - - builder = CrewaiPlatformToolBuilder( - apps=["github", "slack/send_message", "custom/path/to/action"] - ) - - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = mock_api_response - mock_get.return_value = mock_response - - builder._fetch_actions() - - mock_get.assert_called_once() - args, kwargs = mock_get.call_args - - assert "/actions" in args[0] - assert kwargs["headers"]["Authorization"] == "Bearer test_token" - assert kwargs["params"]["apps"] == ( - "github,slack/send_message,custom/path/to/action" - ) - - assert "create_issue" in builder._actions_schema - assert ( - builder._actions_schema["create_issue"]["function"]["name"] - == "create_issue" - ) - - def test_fetch_actions_no_token(self): - builder = CrewaiPlatformToolBuilder(apps=["github"]) - - with patch.dict("os.environ", {}, clear=True): - with self.assertRaises(ValueError) as context: - builder._fetch_actions() - assert "No platform integration token found" in str(context.exception) - - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) - @patch( - "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" - ) - def test_create_tools(self, mock_get): - mock_api_response = { - "actions": { - "github": [ - { - "name": "create_issue", - "description": "Create a GitHub issue", - "parameters": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "Issue title", - } - }, - "required": ["title"], - }, - } - ], - "slack": [ - { - "name": "send_message", - "description": "Send a Slack message", - "parameters": { - "type": "object", - "properties": { - "channel": { - "type": "string", - "description": "Channel name", - } - }, - "required": ["channel"], - }, - } - ], - } - } - - builder = CrewaiPlatformToolBuilder(apps=["github", "slack"]) - - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = mock_api_response - mock_get.return_value = mock_response - - tools = builder.tools() - - assert len(tools) == 2 - assert all(isinstance(tool, CrewAIPlatformActionTool) for tool in tools) - - tool_names = [tool.action_name for tool in tools] - assert "create_issue" in tool_names - assert "send_message" in tool_names - assert {tool.action_name: tool.app for tool in tools} == { - "create_issue": "github", - "send_message": "slack", - } - - github_tool = next((t for t in tools if t.action_name == "create_issue"), None) - slack_tool = next((t for t in tools if t.action_name == "send_message"), None) - - assert github_tool is not None - assert slack_tool is not None - assert "Create a GitHub issue" in github_tool.description - assert "Send a Slack message" in slack_tool.description - - def test_tools_caching(self): - builder = CrewaiPlatformToolBuilder(apps=["github"]) - - cached_tools = [] - - def mock_create_tools(): - builder._tools = cached_tools - - with ( - patch.object(builder, "_fetch_actions") as mock_fetch, - patch.object( - builder, "_create_tools", side_effect=mock_create_tools - ) as mock_create, - ): - tools1 = builder.tools() - assert mock_fetch.call_count == 1 - assert mock_create.call_count == 1 - - tools2 = builder.tools() - assert mock_fetch.call_count == 1 - assert mock_create.call_count == 1 - - assert tools1 is tools2 - - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) - def test_empty_apps_list(self): - builder = CrewaiPlatformToolBuilder(apps=[]) - - with patch( - "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" - ) as mock_get: - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"actions": {}} - mock_get.return_value = mock_response - - tools = builder.tools() - - assert isinstance(tools, list) - assert len(tools) == 0 - - _, kwargs = mock_get.call_args - assert kwargs["params"]["apps"] == "" - -class TestCrewaiPlatformToolBuilderVerify(unittest.TestCase): - """Test suite for SSL verification behavior in CrewaiPlatformToolBuilder""" - - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True) - @patch( - "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" - ) - def test_fetch_actions_with_ssl_verification_default(self, mock_get): - """Test that _fetch_actions uses SSL verification by default when CREWAI_FACTORY is not set""" - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"actions": {}} - mock_get.return_value = mock_response - - builder = CrewaiPlatformToolBuilder(apps=["github"]) - builder._fetch_actions() - - mock_get.assert_called_once() - call_args = mock_get.call_args - assert call_args.kwargs["verify"] is True - - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "false"}, clear=True) - @patch( - "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" - ) - def test_fetch_actions_with_ssl_verification_factory_false(self, mock_get): - """Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'false'""" - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"actions": {}} - mock_get.return_value = mock_response - - builder = CrewaiPlatformToolBuilder(apps=["github"]) - builder._fetch_actions() - - mock_get.assert_called_once() - call_args = mock_get.call_args - assert call_args.kwargs["verify"] is True - - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "FALSE"}, clear=True) - @patch( - "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" - ) - def test_fetch_actions_with_ssl_verification_factory_false_uppercase(self, mock_get): - """Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)""" - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"actions": {}} - mock_get.return_value = mock_response - - builder = CrewaiPlatformToolBuilder(apps=["github"]) - builder._fetch_actions() - - mock_get.assert_called_once() - call_args = mock_get.call_args - assert call_args.kwargs["verify"] is True - - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "true"}, clear=True) - @patch( - "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" - ) - def test_fetch_actions_without_ssl_verification_factory_true(self, mock_get): - """Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'true'""" - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"actions": {}} - mock_get.return_value = mock_response - - builder = CrewaiPlatformToolBuilder(apps=["github"]) - builder._fetch_actions() - - mock_get.assert_called_once() - call_args = mock_get.call_args - assert call_args.kwargs["verify"] is False - - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "TRUE"}, clear=True) - @patch( - "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" - ) - def test_fetch_actions_without_ssl_verification_factory_true_uppercase(self, mock_get): - """Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)""" - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"actions": {}} - mock_get.return_value = mock_response - - builder = CrewaiPlatformToolBuilder(apps=["github"]) - builder._fetch_actions() - - mock_get.assert_called_once() - call_args = mock_get.call_args - assert call_args.kwargs["verify"] is False - - -@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) -@patch( - "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" -) -def test_connection_ids_are_parsed_but_not_sent(mock_get): - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"actions": {}} - mock_get.return_value = mock_response - - builder = CrewaiPlatformToolBuilder( - apps=[ - "github@550E8400-E29B-41D4-A716-446655440000", - "slack/send_message@67e55044-10b1-426f-9247-bb680e5fe0c8", - ] - ) - - builder.tools() - - assert mock_get.call_args.kwargs["params"]["apps"] == ( - "github,slack/send_message" - ) - - -@pytest.mark.parametrize( - ("selector", "message"), - [ - ("", "cannot be empty"), - ( - "@550e8400-e29b-41d4-a716-446655440000", - "application cannot be empty", - ), - ("github/", "action cannot be empty"), - ("github@", "connection ID cannot be empty"), - ("github@not-a-uuid", "connection ID must be a valid UUID"), - ( - "github@550e8400-e29b-41d4-a716-446655440000/issues", - "connection ID must be the last segment", - ), - ], -) -def test_rejects_invalid_app_selector(selector, message): - with pytest.raises(ValueError) as error: - CrewaiPlatformToolBuilder(apps=[selector]) - - assert repr(selector) in str(error.value) - assert message in str(error.value) diff --git a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tools.py b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tools.py index 1706b7aa3..48d7c649f 100644 --- a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tools.py +++ b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tools.py @@ -17,7 +17,7 @@ class TestCrewaiPlatformTools(unittest.TestCase): tools = CrewaiPlatformTools(apps=["github"]) assert tools is not None - assert isinstance(tools, list) + assert type(tools) is list @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) @patch( @@ -73,6 +73,13 @@ class TestCrewaiPlatformTools(unittest.TestCase): assert tools is not None assert isinstance(tools, list) assert len(tools) == 2 + assert [tool.name for tool in tools] == [ + "github_create_issue", + "slack_send_message", + ] + assert [tool.app for tool in tools] == ["github", "slack"] + assert tools[0].description == "Create a GitHub issue" + assert tools[1].description == "Send a Slack message" mock_get.assert_called_once() args, kwargs = mock_get.call_args @@ -81,6 +88,45 @@ class TestCrewaiPlatformTools(unittest.TestCase): or kwargs.get("params", {}).get("apps") == "github,slack" ) + @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) + @patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" + ) + def test_invalid_parameter_schemas_do_not_abort_discovery(self, mock_get): + mock_response = Mock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = { + "actions": { + "github": [ + { + "name": "create_issue", + "description": "Create a GitHub issue", + "parameters": "invalid", + }, + { + "name": "close_issue", + "description": "Close a GitHub issue", + "parameters": [{"type": "string"}], + }, + { + "name": "list_issues", + "description": "List GitHub issues", + "parameters": {}, + }, + ] + } + } + mock_get.return_value = mock_response + + tools = CrewaiPlatformTools(apps=["github"]) + + assert [tool.name for tool in tools] == [ + "github_create_issue", + "github_close_issue", + "github_list_issues", + ] + assert all(tool.args_schema.model_fields == {} for tool in tools) + @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) def test_crewai_platform_tools_empty_apps(self): with patch( @@ -114,11 +160,17 @@ class TestCrewaiPlatformTools(unittest.TestCase): CrewaiPlatformTools(apps=["github"]) assert "No platform integration token found" in str(context.exception) - def test_crewai_platform_tools_accepts_an_integrations_client(self): - integrations_client = Mock() - actions_response = Mock() - actions_response.raise_for_status.return_value = None - actions_response.json.return_value = { + @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) + @patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post" + ) + @patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" + ) + def test_discovered_tool_executes_through_legacy_api(self, mock_get, mock_post): + discovery_response = Mock() + discovery_response.raise_for_status.return_value = None + discovery_response.json.return_value = { "actions": { "github": [ { @@ -133,18 +185,85 @@ class TestCrewaiPlatformTools(unittest.TestCase): ] } } - integrations_client.get_actions.return_value = actions_response + mock_get.return_value = discovery_response execution_response = Mock(ok=True, status_code=200) execution_response.json.return_value = {"issue": 42} - integrations_client.execute_action.return_value = execution_response + mock_post.return_value = execution_response tools = CrewaiPlatformTools( - apps=["github"], integrations_client=integrations_client + apps=["github@550e8400-e29b-41d4-a716-446655440000"] ) result = tools[0].run(title="Contract test") - integrations_client.get_actions.assert_called_once_with(["github"]) - integrations_client.execute_action.assert_called_once_with( - "create_issue", {"title": "Contract test"} + assert mock_get.call_args.kwargs["params"] == {"apps": "github"} + assert mock_post.call_args.kwargs["url"].endswith( + "/actions/create_issue/execute" ) + assert mock_post.call_args.kwargs["json"] == { + "integration": {"title": "Contract test"} + } assert result == '{\n "issue": 42\n}' + + @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) + @patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" + ) + def test_same_action_from_different_apps_has_unique_tool_names(self, mock_get): + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = { + "actions": { + "github": [ + { + "name": "search", + "description": "Search GitHub", + "parameters": {}, + } + ], + "slack": [ + { + "name": "search", + "description": "Search Slack", + "parameters": {}, + } + ], + } + } + mock_get.return_value = response + + tools = CrewaiPlatformTools(apps=["github", "slack"]) + + assert len(tools) == 2 + assert [tool.name for tool in tools] == ["github_search", "slack_search"] + assert [tool.app for tool in tools] == ["github", "slack"] + assert [tool.description for tool in tools] == ["Search GitHub", "Search Slack"] + + @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) + @patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" + ) + def test_tool_name_uses_its_sanitized_identity(self, mock_get): + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = { + "actions": { + "Google Drive": [ + { + "name": "CreateFile!", + "description": "Create a file", + "parameters": {}, + } + ] + } + } + mock_get.return_value = response + + tools = CrewaiPlatformTools( + apps=[ + "Google Drive/CreateFile!@550e8400-e29b-41d4-a716-446655440000" + ] + ) + + assert tools[0].name == ( + "google_drive_create_file_550e8400_e29b_41d4_a716_446655440000" + ) diff --git a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_integrations_client.py b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_integrations_client.py new file mode 100644 index 000000000..395af2c43 --- /dev/null +++ b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_integrations_client.py @@ -0,0 +1,347 @@ +from dataclasses import FrozenInstanceError +from typing import Any +from unittest.mock import Mock, patch +from uuid import UUID + +import pytest + +from crewai_tools.tools.crewai_platform_tools.integrations_client import ( + ApplicationSelector, + IntegrationsClient, + LegacyClient, + ToolExecutionFailure, + ToolExecutionSuccess, + ToolInfo, +) + + +@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) +@patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" +) +def test_legacy_client_normalizes_discovered_actions(mock_get: Mock) -> None: + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = { + "actions": { + "github": [ + { + "name": "create_issue", + "description": "Create a GitHub issue", + "parameters": { + "type": "object", + "properties": {"title": {"type": "string"}}, + }, + } + ] + } + } + mock_get.return_value = response + connection_id = UUID("550e8400-e29b-41d4-a716-446655440000") + + tools = LegacyClient().get_actions( + [ApplicationSelector.from_string(f"github/create_issue@{connection_id}")] + ) + + assert tools == [ + ToolInfo( + app="github", + action="create_issue", + connection_id=connection_id, + description="Create a GitHub issue", + parameters={ + "type": "object", + "properties": {"title": {"type": "string"}}, + }, + ) + ] + response.raise_for_status.assert_called_once_with() + assert mock_get.call_args.kwargs["params"] == {"apps": "github/create_issue"} + + +@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) +@patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" +) +def test_legacy_client_emits_action_for_each_matching_selector( + mock_get: Mock, +) -> None: + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = { + "actions": { + "github": [ + { + "name": "create_issue", + "description": "Create a GitHub issue", + "parameters": {}, + } + ] + } + } + mock_get.return_value = response + app_connection_id = UUID("550e8400-e29b-41d4-a716-446655440000") + action_connection_id = UUID("8c5f9d69-902b-4b48-a23c-8d037c242e1e") + + tools = LegacyClient().get_actions( + [ + ApplicationSelector.from_string(f"github@{app_connection_id}"), + ApplicationSelector.from_string( + f"github/create_issue@{action_connection_id}" + ), + ] + ) + + assert [tool.connection_id for tool in tools] == [ + app_connection_id, + action_connection_id, + ] + assert [tool.qualified_name for tool in tools] == [ + "github_create_issue_550e8400_e29b_41d4_a716_446655440000", + "github_create_issue_8c5f9d69_902b_4b48_a23c_8d037c242e1e", + ] + + +@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) +@patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" +) +def test_legacy_client_excludes_actions_without_a_matching_selector( + mock_get: Mock, +) -> None: + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = { + "actions": { + "github": [ + { + "name": "delete_issue", + "description": "Delete a GitHub issue", + "parameters": {}, + } + ] + } + } + mock_get.return_value = response + + tools = LegacyClient().get_actions( + [ApplicationSelector.from_string("github/create_issue")] + ) + + assert tools == [] + + +def test_tool_info_is_immutable() -> None: + tool_info = ToolInfo( + app="github", + action="create_issue", + connection_id=None, + description="Create an issue", + parameters={}, + ) + + with pytest.raises(FrozenInstanceError): + tool_info.action = "delete_issue" + + +@pytest.mark.parametrize( + ("result", "field", "value"), + [ + (ToolExecutionSuccess(output={"issue": 42}), "output", {"issue": 43}), + ( + ToolExecutionFailure( + message="Request failed", code="400", retryable=False + ), + "message", + "Another failure", + ), + ], +) +def test_tool_execution_results_are_immutable( + result: ToolExecutionSuccess | ToolExecutionFailure, + field: str, + value: Any, +) -> None: + with pytest.raises(FrozenInstanceError): + setattr(result, field, value) + + +def test_application_selector_is_immutable() -> None: + selector = ApplicationSelector.from_string( + "github/create_issue@550e8400-e29b-41d4-a716-446655440000" + ) + + assert selector.app == "github" + assert selector.action == "create_issue" + assert selector.connection_id == UUID("550e8400-e29b-41d4-a716-446655440000") + with pytest.raises(FrozenInstanceError): + selector.action = "delete_issue" + + +@pytest.mark.parametrize( + ("value", "message"), + [ + ("", "cannot be empty"), + ( + "@550e8400-e29b-41d4-a716-446655440000", + "application cannot be empty", + ), + ("github/", "action cannot be empty"), + ("github@", "connection ID cannot be empty"), + ("github@not-a-uuid", "connection ID must be a valid UUID"), + ( + "github@550e8400-e29b-41d4-a716-446655440000/issues", + "connection ID must be the last segment", + ), + ], +) +def test_application_selector_rejects_invalid_values( + value: str, message: str +) -> None: + with pytest.raises(ValueError) as error: + ApplicationSelector.from_string(value) + + assert repr(value) in str(error.value) + assert message in str(error.value) + + +@pytest.mark.parametrize( + ("factory_value", "verify"), + [ + (None, True), + ("false", True), + ("FALSE", True), + ("true", False), + ("TRUE", False), + ], +) +@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) +@patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get" +) +def test_legacy_client_preserves_discovery_ssl_behavior( + mock_get: Mock, + factory_value: str | None, + verify: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + if factory_value is None: + monkeypatch.delenv("CREWAI_FACTORY", raising=False) + else: + monkeypatch.setenv("CREWAI_FACTORY", factory_value) + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"actions": {}} + mock_get.return_value = response + + LegacyClient().get_actions([ApplicationSelector.from_string("github")]) + + assert mock_get.call_args.kwargs["verify"] is verify + + +@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) +@patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post" +) +@pytest.mark.parametrize( + ("arguments", "integration"), + [({"title": "Contract test"}, {"title": "Contract test"}), ({}, {"_noop": True})], +) +def test_legacy_client_preserves_execution_request( + mock_post: Mock, + arguments: dict[str, Any], + integration: dict[str, Any], +) -> None: + response = Mock(status_code=200) + response.json.return_value = {"issue": 42} + mock_post.return_value = response + tool_info = ToolInfo( + app="github", + action="create_issue", + connection_id=UUID("550e8400-e29b-41d4-a716-446655440000"), + description="Create an issue", + parameters={}, + ) + + client: IntegrationsClient = LegacyClient() + result = client.execute_action(tool_info, arguments) + + assert result == ToolExecutionSuccess(output={"issue": 42}) + mock_post.assert_called_once() + assert mock_post.call_args.kwargs["url"].endswith( + "/actions/create_issue/execute" + ) + assert mock_post.call_args.kwargs["headers"] == { + "Authorization": "Bearer test_token", + "Content-Type": "application/json", + } + assert mock_post.call_args.kwargs["json"] == {"integration": integration} + assert mock_post.call_args.kwargs["timeout"] == 60 + assert mock_post.call_args.kwargs["allow_redirects"] is False + + +@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) +@patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post" +) +@pytest.mark.parametrize( + ("response_data", "status_code", "message", "retryable"), + [ + ({"error": {"message": "Invalid issue"}}, 400, "Invalid issue", False), + ({"error": "Rate limited"}, 429, "Rate limited", False), + (["Service unavailable"], 503, "['Service unavailable']", True), + ({"reason": "Unknown"}, 500, '{"reason": "Unknown"}', True), + ], +) +def test_legacy_client_normalizes_execution_failures( + mock_post: Mock, + response_data: Any, + status_code: int, + message: str, + retryable: bool, +) -> None: + response = Mock(status_code=status_code) + response.json.return_value = response_data + mock_post.return_value = response + tool_info = ToolInfo( + app="github", + action="create_issue", + connection_id=None, + description="Create an issue", + parameters={}, + ) + + result = LegacyClient().execute_action(tool_info, {"title": "Contract test"}) + + assert result == ToolExecutionFailure( + message=message, + code=str(status_code), + retryable=retryable, + ) + + +@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) +@patch( + "crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post" +) +def test_legacy_client_treats_redirect_as_execution_failure( + mock_post: Mock, +) -> None: + response = Mock(status_code=302) + response.json.return_value = {"error": {"message": "Redirected"}} + mock_post.return_value = response + tool_info = ToolInfo( + app="github", + action="create_issue", + connection_id=None, + description="Create an issue", + parameters={}, + ) + + result = LegacyClient().execute_action(tool_info, {}) + + assert result == ToolExecutionFailure( + message="Redirected", + code="302", + retryable=False, + ) diff --git a/lib/crewai/tests/tools/test_tool_failure.py b/lib/crewai/tests/tools/test_tool_failure.py index dd03bb3fe..5f7e2dac9 100644 --- a/lib/crewai/tests/tools/test_tool_failure.py +++ b/lib/crewai/tests/tools/test_tool_failure.py @@ -1693,51 +1693,55 @@ class TestPlatformActionTool: """CrewAI AMP agentic-app actions -- the Slack case from the bug report.""" @staticmethod - def _tool(response: Any) -> Any: + def _tool() -> Any: import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod + import crewai_tools.tools.crewai_platform_tools.integrations_client as client_mod return mod.CrewAIPlatformActionTool( - description="Send a Slack message", - app="slack", - action_name="slackbot_send_message", - action_schema={ - "function": { - "name": "slackbot_send_message", - "parameters": { - "properties": {"channel": {"type": "string"}}, - "required": [], - }, - } - }, - integrations_client=SimpleNamespace( - execute_action=lambda _action_name, _arguments: response - ), + client_mod.ToolInfo( + app="slack", + action="slackbot_send_message", + connection_id=None, + description="Send a Slack message", + parameters={ + "properties": {"channel": {"type": "string"}}, + "required": [], + }, + ) ) - def test_non_ok_response_becomes_a_tool_failure(self) -> None: + def test_non_ok_response_becomes_a_tool_failure(self, monkeypatch) -> None: # noqa: ANN001 from unittest.mock import Mock + import crewai_tools.tools.crewai_platform_tools.integrations_client as client_mod + response = Mock() response.ok = False response.status_code = 500 response.json.return_value = { "error": "Failed to execute action: Slack API error: channel_not_found" } + monkeypatch.setattr(client_mod.requests, "post", Mock(return_value=response)) + monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t") - result = self._tool(response)._run(channel="#joao-message") + result = self._tool()._run(channel="#joao-message") assert isinstance(result, ToolFailure) assert "channel_not_found" in result.message assert result.retryable is True - def test_ok_response_still_returns_json(self) -> None: + def test_ok_response_still_returns_json(self, monkeypatch) -> None: # noqa: ANN001 from unittest.mock import Mock - response = Mock() + import crewai_tools.tools.crewai_platform_tools.integrations_client as client_mod + + response = Mock(status_code=200) response.ok = True response.json.return_value = {"ts": "1234.5678"} + monkeypatch.setattr(client_mod.requests, "post", Mock(return_value=response)) + monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t") - result = self._tool(response)._run(channel="#general") + result = self._tool()._run(channel="#general") assert not isinstance(result, ToolFailure) assert "1234.5678" in result