From 48cc5d4e5ea7ec84db148f07f36bb1f4054b5fff Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Tue, 1 Sep 2026 13:07:08 -0300 Subject: [PATCH] Decouple platform tools from the integrations API (#7180) * Decouple platform tools from the integrations API Define normalized selector and tool data so platform tool creation does not depend on the legacy API response shape. This contract makes the legacy client easier to replace later. - Move action discovery and response normalization into LegacyClient. - Pass ToolInfo from discovery through tool creation and execution. - Replace the builder flow with direct factory orchestration. - Preserve app, action, and connection data in immutable models. - Build sanitized tool names from the full tool identity. - Preserve legacy request, SSL, and failure behavior with contract tests. * fixup! Decouple platform tools from the integrations API * fixup! Decouple platform tools from the integrations API * fixup! Decouple platform tools from the integrations API * fixup! Decouple platform tools from the integrations API --- .../tools/crewai_platform_tools/__init__.py | 12 - .../application_selector.py | 54 --- .../crewai_platform_action_tool.py | 71 ++-- .../crewai_platform_tool_builder.py | 115 ------ .../crewai_platform_tools.py | 28 +- .../integrations_client.py | 188 +++++++-- .../tools/crewai_platform_tools/misc.py | 8 +- .../test_crewai_platform_action_tool.py | 105 +++-- .../test_crewai_platform_tool_builder.py | 375 ------------------ .../test_crewai_platform_tools.py | 143 ++++++- .../test_integrations_client.py | 347 ++++++++++++++++ lib/crewai/tests/tools/test_tool_failure.py | 46 ++- 12 files changed, 792 insertions(+), 700 deletions(-) delete mode 100644 lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/application_selector.py delete mode 100644 lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tool_builder.py delete mode 100644 lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tool_builder.py create mode 100644 lib/crewai-tools/tests/tools/crewai_platform_tools/test_integrations_client.py 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