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 588414e19..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,9 +7,6 @@ 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, ) @@ -17,6 +14,5 @@ from crewai_tools.tools.crewai_platform_tools.crewai_platform_tools import ( __all__ = [ "CrewAIPlatformActionTool", - "CrewaiPlatformToolBuilder", "CrewaiPlatformTools", ] diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/_client.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/_client.py new file mode 100644 index 000000000..7c32b3763 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/_client.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from dataclasses import dataclass +import logging +import os +from typing import Any +from uuid import UUID + +from crewai.plus_api import PlusAPI +import requests + +from crewai_tools.tools.crewai_platform_tools.misc import ( + get_platform_integration_token, +) + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _PlatformToolSelector: + app: str + action: str | None + connection_id: UUID | None + + @classmethod + def from_string(cls, value: str) -> _PlatformToolSelector: + 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 _PlatformToolInfo: + app: str + action: str + connection_id: UUID | None + description: str + parameters: dict[str, Any] + + +def _should_verify_ssl() -> bool: + return os.environ.get("CREWAI_FACTORY", "false").lower() != "true" + + +class _PlatformToolsClient: + def _headers(self) -> dict[str, str]: + token = get_platform_integration_token() + headers = {"Authorization": f"Bearer {token}"} + if deployment_instance_uuid := os.getenv("CREWAI_DEPLOYMENT_INSTANCE_UUID"): + headers["X-Crewai-Deployment-Instance-Id"] = deployment_instance_uuid + return headers + + def get_tools( + self, selectors: list[_PlatformToolSelector] + ) -> list[_PlatformToolInfo]: + headers = self._headers() + resolved_tools: list[_PlatformToolInfo] = [] + + for selector in selectors: + resolved_tools.extend(self._get_tools_for_selector(selector, headers)) + + return resolved_tools + + def _get_tools_for_selector( + self, selector: _PlatformToolSelector, headers: dict[str, str] + ) -> list[_PlatformToolInfo]: + try: + url = f"{PlusAPI().base_url}/clipper/v1/applications/{selector.app}/tools" + if selector.action is not None: + url = f"{url}/{selector.action}" + connection_id = ( + str(selector.connection_id) if selector.connection_id else None + ) + response = requests.get( + url, + headers=headers, + timeout=30, + params={"connection_id": connection_id} if connection_id else None, + verify=_should_verify_ssl(), + ) + response.raise_for_status() + data = response.json()["data"] + tools = data if selector.action is None else [data] + return [ + _PlatformToolInfo( + app=selector.app, + action=tool["slug"], + connection_id=selector.connection_id, + description=tool["description"], + parameters=tool["input_schema"], + ) + for tool in tools + ] + except Exception as error: + logger.error(f"Failed to fetch platform tools for {selector}: {error}") + return [] + + def execute_action( + self, tool_info: _PlatformToolInfo, arguments: dict[str, Any] + ) -> requests.Response: + payload: dict[str, Any] = {"arguments": arguments} + if tool_info.connection_id: + payload["connection_id"] = str(tool_info.connection_id) + return requests.post( + url=( + f"{PlusAPI().base_url}/clipper/v1/applications/{tool_info.app}/" + f"tools/{tool_info.action}/execute" + ), + headers={ + **self._headers(), + "Content-Type": "application/json", + }, + json=payload, + timeout=60, + verify=_should_verify_ssl(), + ) 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 6c6a6c05c..be444cd3e 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 @@ -1,57 +1,63 @@ """Crewai Enterprise Tools.""" import json -import os from typing import Any from crewai.tools import BaseTool from crewai.tools.tool_failure import ToolFailure from crewai.utilities.pydantic_schema_utils import create_model_from_schema -from pydantic import Field, create_model -import requests +from crewai.utilities.string_utils import sanitize_tool_name +from pydantic import Field, PrivateAttr, create_model -from crewai_tools.tools.crewai_platform_tools.misc import ( - get_platform_api_base_url, - get_platform_integration_token, +from crewai_tools.tools.crewai_platform_tools._client import ( + _PlatformToolInfo, + _PlatformToolsClient, ) class CrewAIPlatformActionTool(BaseTool): + _client: _PlatformToolsClient = PrivateAttr() + _tool_info: _PlatformToolInfo = 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], - ): - parameters = action_schema.get("function", {}).get("parameters", {}) + tool_info: _PlatformToolInfo, + client: _PlatformToolsClient, + ) -> None: + action = tool_info.action + 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": f"{action}Schema", + } 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(f"{action}Schema") else: - args_schema = create_model(f"{action_name}Schema") + args_schema = create_model(f"{action}Schema") + + name_parts = [ + tool_info.app, + action, + str(tool_info.connection_id) if tool_info.connection_id else None, + ] + name = sanitize_tool_name("_".join(part for part in name_parts if part)) super().__init__( - name=action_name.lower().replace(" ", "_"), - description=description, + name=name, + description=tool_info.description, args_schema=args_schema, - app=app, + app=tool_info.app, ) - self.action_name = action_name - self.action_schema = action_schema + self._tool_info = tool_info + self._client = client def _run(self, **kwargs: Any) -> Any: try: @@ -59,26 +65,10 @@ class CrewAIPlatformActionTool(BaseTool): key: value for key, value in kwargs.items() if value is not None } - api_url = ( - f"{get_platform_api_base_url()}/actions/{self.action_name}/execute" + response = self._client.execute_action( + tool_info=self._tool_info, + arguments=cleaned_kwargs, ) - token = get_platform_integration_token() - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - } - payload = { - "integration": cleaned_kwargs if cleaned_kwargs else {"_noop": True} - } - - response = requests.post( - url=api_url, - headers=headers, - json=payload, - timeout=60, - verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true", - ) - data = response.json() if not response.ok: if isinstance(data, dict): @@ -89,20 +79,18 @@ class CrewAIPlatformActionTool(BaseTool): 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. return ToolFailure( message=f"API request failed: {error_message}", code=str(response.status_code), retryable=response.status_code >= 500, - details={"action": self.action_name}, + details={"action": self._tool_info.action}, ) return json.dumps(data, 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 23b6b9293..000000000 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tool_builder.py +++ /dev/null @@ -1,118 +0,0 @@ -"""CrewAI platform tool builder for fetching and creating action tools.""" - -import logging -import os -from types import TracebackType -from typing import Any - -from crewai.tools import BaseTool -import requests - -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.misc import ( - get_platform_api_base_url, - get_platform_integration_token, -) - - -logger = logging.getLogger(__name__) - - -class CrewaiPlatformToolBuilder: - """Builds platform tools from remote action schemas.""" - - def __init__( - self, - apps: list[str], - ) -> None: - self._apps = [ApplicationSelector(app) for app in apps] - 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.""" - actions_url = f"{get_platform_api_base_url()}/actions" - headers = {"Authorization": f"Bearer {get_platform_integration_token()}"} - apps = [ - f"{app.name}/{app.action}" if app.action is not None else app.name - for app in self._apps - ] - - try: - response = requests.get( - actions_url, - headers=headers, - timeout=30, - params={"apps": ",".join(apps)}, - verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true", - ) - response.raise_for_status() - 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, - ) - - 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 83016ddb8..190b99f0f 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 @@ -1,14 +1,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._client import ( + _PlatformToolSelector, + _PlatformToolsClient, +) +from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import ( + CrewAIPlatformActionTool, ) - - -logger = logging.getLogger(__name__) def CrewaiPlatformTools( # noqa: N802 @@ -22,6 +21,14 @@ def CrewaiPlatformTools( # noqa: N802 Returns: A list of BaseTool instances for platform actions """ - builder = CrewaiPlatformToolBuilder(apps=apps) + client = _PlatformToolsClient() + selectors = [_PlatformToolSelector.from_string(app) for app in apps] - return builder.tools() # type: ignore + tool_infos = client.get_tools(selectors) + + return ToolCollection( + [ + CrewAIPlatformActionTool(tool_info=tool_info, client=client) + for tool_info in tool_infos + ] + ) 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..60b87595c 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,12 +1,6 @@ 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.""" token = os.getenv("CREWAI_PLATFORM_INTEGRATION_TOKEN") or "" diff --git a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_client.py b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_client.py new file mode 100644 index 000000000..6ba0ed01f --- /dev/null +++ b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_client.py @@ -0,0 +1,247 @@ +from unittest.mock import Mock, patch + +import pytest +import requests + +from crewai_tools.tools.crewai_platform_tools._client import ( + _PlatformToolInfo, + _PlatformToolSelector, + _PlatformToolsClient, +) + + +@patch.dict( + "os.environ", + { + "CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", + "CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment_uuid", + }, + clear=True, +) +@patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") +def test_client_resolves_tool_info(mock_get): + github_response = Mock() + github_response.raise_for_status.return_value = None + github_response.json.return_value = { + "data": [ + { + "slug": "create_issue", + "description": "Create a GitHub issue", + "input_schema": { + "type": "object", + "properties": {"title": {"type": "string"}}, + }, + } + ] + } + slack_response = Mock() + slack_response.raise_for_status.return_value = None + slack_response.json.return_value = { + "data": { + "slug": "send_message", + "description": "Send a Slack message", + "input_schema": {}, + } + } + mock_get.side_effect = [github_response, slack_response] + selectors = [ + _PlatformToolSelector.from_string( + "github@550e8400-e29b-41d4-a716-446655440000" + ), + _PlatformToolSelector.from_string( + "slack/send_message@67e55044-10b1-426f-9247-bb680e5fe0c8" + ), + ] + + resolved = _PlatformToolsClient().get_tools(selectors) + + assert [call.kwargs for call in mock_get.call_args_list] == [ + { + "headers": { + "Authorization": "Bearer test_token", + "X-Crewai-Deployment-Instance-Id": "deployment_uuid", + }, + "timeout": 30, + "params": { + "connection_id": "550e8400-e29b-41d4-a716-446655440000" + }, + "verify": True, + }, + { + "headers": { + "Authorization": "Bearer test_token", + "X-Crewai-Deployment-Instance-Id": "deployment_uuid", + }, + "timeout": 30, + "params": { + "connection_id": "67e55044-10b1-426f-9247-bb680e5fe0c8" + }, + "verify": True, + }, + ] + assert [call.args[0] for call in mock_get.call_args_list] == [ + "https://app.crewai.com/clipper/v1/applications/github/tools", + "https://app.crewai.com/clipper/v1/applications/slack/tools/send_message", + ] + assert resolved == [ + _PlatformToolInfo( + app="github", + action="create_issue", + connection_id=selectors[0].connection_id, + description="Create a GitHub issue", + parameters={ + "type": "object", + "properties": {"title": {"type": "string"}}, + }, + ), + _PlatformToolInfo( + app="slack", + action="send_message", + connection_id=selectors[1].connection_id, + description="Send a Slack message", + parameters={}, + ), + ] + + +@patch.dict( + "os.environ", + {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, + clear=True, +) +@patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") +def test_client_accepts_empty_action_list(mock_get): + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"data": []} + mock_get.return_value = response + + resolved = _PlatformToolsClient().get_tools( + [_PlatformToolSelector.from_string("github")] + ) + + assert resolved == [] + + +@patch.dict( + "os.environ", + {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, + clear=True, +) +@patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") +def test_client_keeps_tools_when_one_selector_is_unavailable(mock_get): + github_response = Mock() + github_response.raise_for_status.return_value = None + github_response.json.return_value = { + "data": [ + { + "slug": "create_issue", + "description": "Create a GitHub issue", + "input_schema": {}, + } + ] + } + unavailable_response = Mock() + unavailable_response.raise_for_status.side_effect = requests.HTTPError( + "Application unavailable" + ) + slack_response = Mock() + slack_response.raise_for_status.return_value = None + slack_response.json.return_value = { + "data": [ + { + "slug": "send_message", + "description": "Send a Slack message", + "input_schema": {}, + } + ] + } + mock_get.side_effect = [github_response, unavailable_response, slack_response] + + resolved = _PlatformToolsClient().get_tools( + [ + _PlatformToolSelector.from_string("github"), + _PlatformToolSelector.from_string("unavailable"), + _PlatformToolSelector.from_string("slack"), + ] + ) + + assert resolved == [ + _PlatformToolInfo( + app="github", + action="create_issue", + connection_id=None, + description="Create a GitHub issue", + parameters={}, + ), + _PlatformToolInfo( + app="slack", + action="send_message", + connection_id=None, + description="Send a Slack message", + parameters={}, + ), + ] + + +@pytest.mark.parametrize( + ("factory_value", "expected"), + [ + (None, True), + ("false", True), + ("FALSE", True), + ("true", False), + ("TRUE", False), + ], +) +@patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") +def test_client_ssl_verification(mock_get, monkeypatch, factory_value, expected): + monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "test_token") + 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 = {"data": []} + mock_get.return_value = response + + _PlatformToolsClient().get_tools( + [_PlatformToolSelector.from_string("github")] + ) + + assert mock_get.call_args.kwargs["verify"] is expected + + +def test_client_requires_token(monkeypatch): + monkeypatch.delenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", raising=False) + + with pytest.raises(ValueError, match="No platform integration token found"): + _PlatformToolsClient().get_tools( + [_PlatformToolSelector.from_string("github")] + ) + + +@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_selector(selector, message): + with pytest.raises(ValueError) as error: + _PlatformToolSelector.from_string(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_action_tool.py b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_action_tool.py index 7737baf80..7e03d4e45 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,114 +1,100 @@ -from unittest.mock import patch, Mock -import os +from unittest.mock import Mock, patch -from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import ( - CrewAIPlatformActionTool, +import pytest + +from crewai_tools.tools.crewai_platform_tools._client import ( + _PlatformToolInfo, + _PlatformToolsClient, ) -class TestCrewAIPlatformActionToolVerify: - """Test suite for SSL verification behavior based on CREWAI_FACTORY environment variable""" +TOOL_INFO = _PlatformToolInfo( + app="test_app", + action="test_action", + connection_id=None, + description="Test action tool", + parameters={}, +) - 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", - app="test_app", - action_name="test_action", - action_schema=self.action_schema - ) +@patch.dict( + "os.environ", + { + "CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", + "CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment_uuid", + }, + clear=True, +) +@patch("crewai_tools.tools.crewai_platform_tools._client.requests.post") +def test_client_executes_action(mock_post): + response = Mock(ok=True, status_code=200) + response.json.return_value = {"result": "success"} + mock_post.return_value = response - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True) - @patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.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.ok = True - mock_response.json.return_value = {"result": "success"} - mock_post.return_value = mock_response + result = _PlatformToolsClient().execute_action( + TOOL_INFO, + {"test_param": "test_value"}, + ) - tool = self.create_test_tool() - tool._run(test_param="test_value") + mock_post.assert_called_once_with( + url=( + "https://app.crewai.com/clipper/v1/applications/" + "test_app/tools/test_action/execute" + ), + headers={ + "Authorization": "Bearer test_token", + "X-Crewai-Deployment-Instance-Id": "deployment_uuid", + "Content-Type": "application/json", + }, + json={"arguments": {"test_param": "test_value"}}, + timeout=60, + verify=True, + ) + assert result is response - mock_post.assert_called_once() - call_args = mock_post.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.crewai_platform_action_tool.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.ok = True - mock_response.json.return_value = {"result": "success"} - mock_post.return_value = mock_response +@patch.dict( + "os.environ", + {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, + clear=True, +) +@patch("crewai_tools.tools.crewai_platform_tools._client.requests.post") +def test_client_returns_action_error_response(mock_post): + response = Mock(ok=False, status_code=500) + response.json.return_value = {"error": "Action failed"} + mock_post.return_value = response - tool = self.create_test_tool() - tool._run(test_param="test_value") + result = _PlatformToolsClient().execute_action(TOOL_INFO, {}) - mock_post.assert_called_once() - call_args = mock_post.call_args - assert call_args.kwargs["verify"] is True + assert result is response - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "FALSE"}, clear=True) - @patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.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.ok = True - mock_response.json.return_value = {"result": "success"} - mock_post.return_value = mock_response - tool = self.create_test_tool() - tool._run(test_param="test_value") +@pytest.mark.parametrize( + ("factory_value", "expected"), + [ + (None, True), + ("false", True), + ("FALSE", True), + ("true", False), + ("TRUE", False), + ], +) +@patch("crewai_tools.tools.crewai_platform_tools._client.requests.post") +def test_client_ssl_verification( + mock_post, + monkeypatch, + factory_value, + expected, +): + monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "test_token") + if factory_value is None: + monkeypatch.delenv("CREWAI_FACTORY", raising=False) + else: + monkeypatch.setenv("CREWAI_FACTORY", factory_value) + response = Mock(ok=True, status_code=200) + response.json.return_value = {"result": "success"} + mock_post.return_value = response - mock_post.assert_called_once() - call_args = mock_post.call_args - assert call_args.kwargs["verify"] is True + _PlatformToolsClient().execute_action(TOOL_INFO, {}) - @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "true"}, clear=True) - @patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.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.ok = True - mock_response.json.return_value = {"result": "success"} - mock_post.return_value = mock_response - - tool = self.create_test_tool() - tool._run(test_param="test_value") - - mock_post.assert_called_once() - call_args = mock_post.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.crewai_platform_action_tool.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.ok = True - mock_response.json.return_value = {"result": "success"} - mock_post.return_value = mock_response - - tool = self.create_test_tool() - tool._run(test_param="test_value") - - mock_post.assert_called_once() - call_args = mock_post.call_args - assert call_args.kwargs["verify"] is False + assert mock_post.call_args.kwargs["verify"] is expected 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 e66022792..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.crewai_platform_tool_builder.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.crewai_platform_tool_builder.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.crewai_platform_tool_builder.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.crewai_platform_tool_builder.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.crewai_platform_tool_builder.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.crewai_platform_tool_builder.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.crewai_platform_tool_builder.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.crewai_platform_tool_builder.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.crewai_platform_tool_builder.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 b69b073ed..f17a098e3 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 @@ -1,18 +1,20 @@ import unittest from unittest.mock import Mock, patch +import requests + from crewai_tools.tools.crewai_platform_tools import CrewaiPlatformTools class TestCrewaiPlatformTools(unittest.TestCase): @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) @patch( - "crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get" + "crewai_tools.tools.crewai_platform_tools._client.requests.get" ) def test_crewai_platform_tools_basic(self, mock_get): mock_response = Mock() mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"actions": {"github": []}} + mock_response.json.return_value = {"data": []} mock_get.return_value = mock_response tools = CrewaiPlatformTools(apps=["github"]) @@ -21,18 +23,17 @@ class TestCrewaiPlatformTools(unittest.TestCase): @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) @patch( - "crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get" + "crewai_tools.tools.crewai_platform_tools._client.requests.get" ) def test_crewai_platform_tools_multiple_apps(self, mock_get): - mock_response = Mock() - mock_response.raise_for_status.return_value = None - mock_response.json.return_value = { - "actions": { - "github": [ + github_response = Mock() + github_response.raise_for_status.return_value = None + github_response.json.return_value = { + "data": [ { - "name": "create_issue", + "slug": "create_issue", "description": "Create a GitHub issue", - "parameters": { + "input_schema": { "type": "object", "properties": { "title": { @@ -44,12 +45,16 @@ class TestCrewaiPlatformTools(unittest.TestCase): "required": ["title"], }, } - ], - "slack": [ + ] + } + slack_response = Mock() + slack_response.raise_for_status.return_value = None + slack_response.json.return_value = { + "data": [ { - "name": "send_message", + "slug": "send_message", "description": "Send a Slack message", - "parameters": { + "input_schema": { "type": "object", "properties": { "channel": { @@ -64,31 +69,151 @@ class TestCrewaiPlatformTools(unittest.TestCase): "required": ["channel", "text"], }, } - ], - } + ] } - mock_get.return_value = mock_response + mock_get.side_effect = [github_response, slack_response] tools = CrewaiPlatformTools(apps=["github", "slack"]) assert tools is not None assert isinstance(tools, list) assert len(tools) == 2 - mock_get.assert_called_once() - args, kwargs = mock_get.call_args - assert ( - "apps=github,slack" in args[0] - or kwargs.get("params", {}).get("apps") == "github,slack" + assert [call.args[0].split("/clipper", 1)[1] for call in mock_get.call_args_list] == [ + "/v1/applications/github/tools", + "/v1/applications/slack/tools", + ] + + @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) + @patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") + def test_keeps_same_named_actions_from_different_apps(self, mock_get): + github_response = Mock() + github_response.raise_for_status.return_value = None + github_response.json.return_value = { + "data": [{"slug": "search", "description": "Search", "input_schema": {}}] + } + slack_response = Mock() + slack_response.raise_for_status.return_value = None + slack_response.json.return_value = { + "data": [{"slug": "search", "description": "Search", "input_schema": {}}] + } + mock_get.side_effect = [github_response, slack_response] + + tools = CrewaiPlatformTools(apps=["github", "slack"]) + + assert tools["github_search"].app == "github" + assert tools["slack_search"].app == "slack" + + @patch.dict( + "os.environ", + { + "CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", + "CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment_uuid", + }, + clear=True, + ) + @patch("crewai_tools.tools.crewai_platform_tools._client.requests.post") + @patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") + def test_executes_same_action_for_each_app(self, mock_get, mock_post): + github_response = Mock() + github_response.raise_for_status.return_value = None + github_response.json.return_value = { + "data": { + "slug": "search", + "description": "Search", + "input_schema": { + "properties": {"query": {"type": "string"}} + }, + } + } + slack_response = Mock() + slack_response.raise_for_status.return_value = None + slack_response.json.return_value = github_response.json.return_value + mock_get.side_effect = [github_response, slack_response] + response = Mock(ok=True, status_code=200) + response.json.return_value = {"data": {"output": []}} + mock_post.return_value = response + + tools = CrewaiPlatformTools(apps=["github/search", "slack/search"]) + tools["github_search"].run(query="release") + tools["slack_search"].run(query="release") + + assert [call.kwargs["url"] for call in mock_post.call_args_list] == [ + "https://app.crewai.com/clipper/v1/applications/github/tools/search/execute", + "https://app.crewai.com/clipper/v1/applications/slack/tools/search/execute", + ] + + @patch.dict( + "os.environ", + { + "CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", + "CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment_uuid", + }, + clear=True, + ) + @patch("crewai_tools.tools.crewai_platform_tools._client.requests.post") + @patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") + def test_executes_same_action_for_each_connection(self, mock_get, mock_post): + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = { + "data": { + "slug": "search", + "description": "Search", + "input_schema": { + "properties": {"query": {"type": "string"}} + }, + } + } + mock_get.return_value = response + execution_response = Mock(ok=True, status_code=200) + execution_response.json.return_value = {"data": {"output": []}} + mock_post.return_value = execution_response + connection_ids = [ + "550e8400-e29b-41d4-a716-446655440000", + "67e55044-10b1-426f-9247-bb680e5fe0c8", + ] + + tools = CrewaiPlatformTools( + apps=[f"github/search@{connection_id}" for connection_id in connection_ids] ) + for tool in tools: + tool.run(query="release") + + assert len(tools) == 2 + assert len({tool.name for tool in tools}) == 2 + assert all(tools[tool.name] is tool for tool in tools) + assert [call.kwargs["json"] for call in mock_post.call_args_list] == [ + { + "arguments": {"query": "release"}, + "connection_id": connection_id, + } + for connection_id in connection_ids + ] + + @patch.dict( + "os.environ", + {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, + clear=True, + ) + @patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") + def test_invalid_discovery_response_returns_empty_collection(self, mock_get): + response = Mock() + response.raise_for_status.return_value = None + response.json.side_effect = ValueError("Invalid JSON") + mock_get.return_value = response + + tools = CrewaiPlatformTools(apps=["github"]) + + assert tools == [] @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) def test_crewai_platform_tools_empty_apps(self): with patch( - "crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get" + "crewai_tools.tools.crewai_platform_tools._client.requests.get" ) as mock_get: mock_response = Mock() mock_response.raise_for_status.return_value = None - mock_response.json.return_value = {"actions": {}} + mock_response.json.return_value = {"data": []} mock_get.return_value = mock_response tools = CrewaiPlatformTools(apps=[]) @@ -98,16 +223,26 @@ class TestCrewaiPlatformTools(unittest.TestCase): @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) @patch( - "crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get" + "crewai_tools.tools.crewai_platform_tools._client.requests.get" ) def test_crewai_platform_tools_api_error_handling(self, mock_get): - mock_get.side_effect = Exception("API Error") + mock_get.side_effect = requests.RequestException("API Error") tools = CrewaiPlatformTools(apps=["github"]) assert tools is not None assert isinstance(tools, list) assert len(tools) == 0 + @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) + @patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") + def test_crewai_platform_tools_handles_unexpected_fetch_error(self, mock_get): + mock_get.side_effect = RuntimeError("API Error") + + tools = CrewaiPlatformTools(apps=["github"]) + + assert isinstance(tools, list) + assert len(tools) == 0 + def test_crewai_platform_tools_no_token(self): with patch.dict("os.environ", {}, clear=True): with self.assertRaises(ValueError) as context: diff --git a/lib/crewai/tests/agents/test_lite_agent.py b/lib/crewai/tests/agents/test_lite_agent.py index db3686a6a..60c9d2b5c 100644 --- a/lib/crewai/tests/agents/test_lite_agent.py +++ b/lib/crewai/tests/agents/test_lite_agent.py @@ -612,30 +612,27 @@ def test_lite_agent_with_invalid_llm(): @patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}) -@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post") -@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get") -@pytest.mark.vcr() +@patch("crewai_tools.tools.crewai_platform_tools._client.requests.post") +@patch("crewai_tools.tools.crewai_platform_tools._client.requests.get") def test_agent_kickoff_with_platform_tools(mock_get, mock_post): """Test that Agent.kickoff() properly integrates platform tools with LiteAgent""" 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": { - "type": "object", - "properties": { - "title": {"type": "string", "description": "Issue title"}, - "body": {"type": "string", "description": "Issue body"}, - }, - "required": ["title"], + "data": [ + { + "slug": "create_issue", + "description": "Create a GitHub issue", + "input_schema": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Issue title"}, + "body": {"type": "string", "description": "Issue body"}, }, - } - ] - } + "required": ["title"], + }, + } + ] } mock_get.return_value = mock_response @@ -647,11 +644,22 @@ def test_agent_kickoff_with_platform_tools(mock_get, mock_post): } mock_post.return_value = mock_post_response + mock_llm = Mock(spec=LLM) + mock_llm.call.side_effect = [ + "Thought: Create the issue\n" + "Action: github_create_issue\n" + 'Action Input: {"title": "Test GitHub Issue", "body": "Test body"}', + "Thought: The issue was created\nFinal Answer: Issue created", + ] + mock_llm.supports_function_calling.return_value = False + mock_llm.stop = [] + mock_llm.get_token_usage_summary.return_value = UsageMetrics() + agent = Agent( role="Test Agent", goal="Test goal", backstory="Test backstory", - llm=LLM(model="gpt-3.5-turbo"), + llm=mock_llm, apps=["github"], verbose=True ) @@ -660,6 +668,7 @@ def test_agent_kickoff_with_platform_tools(mock_get, mock_post): assert isinstance(result, LiteAgentOutput) assert result.raw is not None + mock_post.assert_called_once() @patch.dict("os.environ", {"EXA_API_KEY": "test_exa_key"}) diff --git a/lib/crewai/tests/cassettes/agents/test_agent_kickoff_with_platform_tools.yaml b/lib/crewai/tests/cassettes/agents/test_agent_kickoff_with_platform_tools.yaml deleted file mode 100644 index 7a64787cc..000000000 --- a/lib/crewai/tests/cassettes/agents/test_agent_kickoff_with_platform_tools.yaml +++ /dev/null @@ -1,344 +0,0 @@ -interactions: -- request: - body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour - personal goal is: Test goal"},{"role":"user","content":"\nCurrent Task: Create - a GitHub issue"}],"model":"gpt-3.5-turbo","tool_choice":"auto","tools":[{"type":"function","function":{"name":"create_issue","description":"Create - a GitHub issue","strict":true,"parameters":{"additionalProperties":false,"properties":{"title":{"description":"Issue - title","title":"Title","type":"string"},"body":{"default":null,"description":"Issue - body","title":"Body","type":"string"}},"required":["title","body"],"type":"object"}}}]}' - headers: - User-Agent: - - X-USER-AGENT-XXX - accept: - - application/json - accept-encoding: - - ACCEPT-ENCODING-XXX - authorization: - - AUTHORIZATION-XXX - connection: - - keep-alive - content-length: - - '596' - content-type: - - application/json - host: - - api.openai.com - x-stainless-arch: - - X-STAINLESS-ARCH-XXX - x-stainless-async: - - 'false' - x-stainless-lang: - - python - x-stainless-os: - - X-STAINLESS-OS-XXX - x-stainless-package-version: - - 1.83.0 - x-stainless-read-timeout: - - X-STAINLESS-READ-TIMEOUT-XXX - x-stainless-retry-count: - - '0' - x-stainless-runtime: - - CPython - x-stainless-runtime-version: - - 3.13.3 - method: POST - uri: https://api.openai.com/v1/chat/completions - response: - body: - string: "{\n \"id\": \"chatcmpl-D8D6N1Fkz1at9vvgiZp4cYgerJbuU\",\n \"object\": - \"chat.completion\",\n \"created\": 1770849359,\n \"model\": \"gpt-3.5-turbo-0125\",\n - \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": - \"assistant\",\n \"content\": null,\n \"tool_calls\": [\n {\n - \ \"id\": \"call_lLWMJ8icAP8pCVCANTDjIl9s\",\n \"type\": - \"function\",\n \"function\": {\n \"name\": \"create_issue\",\n - \ \"arguments\": \"{\\\"title\\\":\\\"Test GitHub Issue\\\",\\\"body\\\":\\\"This - is a test GitHub issue created for testing purposes.\\\"}\"\n }\n - \ }\n ],\n \"refusal\": null,\n \"annotations\": - []\n },\n \"logprobs\": null,\n \"finish_reason\": \"tool_calls\"\n - \ }\n ],\n \"usage\": {\n \"prompt_tokens\": 93,\n \"completion_tokens\": - 30,\n \"total_tokens\": 123,\n \"prompt_tokens_details\": {\n \"cached_tokens\": - 0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\": - {\n \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": - 0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\": - \"default\",\n \"system_fingerprint\": null\n}\n" - headers: - CF-RAY: - - CF-RAY-XXX - Connection: - - keep-alive - Content-Type: - - application/json - Date: - - Wed, 11 Feb 2026 22:36:00 GMT - Server: - - cloudflare - Strict-Transport-Security: - - STS-XXX - Transfer-Encoding: - - chunked - X-Content-Type-Options: - - X-CONTENT-TYPE-XXX - access-control-expose-headers: - - ACCESS-CONTROL-XXX - alt-svc: - - h3=":443"; ma=86400 - cf-cache-status: - - DYNAMIC - openai-organization: - - OPENAI-ORG-XXX - openai-processing-ms: - - '2999' - openai-project: - - OPENAI-PROJECT-XXX - openai-version: - - '2020-10-01' - set-cookie: - - SET-COOKIE-XXX - x-openai-proxy-wasm: - - v0.1 - x-ratelimit-limit-requests: - - X-RATELIMIT-LIMIT-REQUESTS-XXX - x-ratelimit-limit-tokens: - - X-RATELIMIT-LIMIT-TOKENS-XXX - x-ratelimit-remaining-requests: - - X-RATELIMIT-REMAINING-REQUESTS-XXX - x-ratelimit-remaining-tokens: - - X-RATELIMIT-REMAINING-TOKENS-XXX - x-ratelimit-reset-requests: - - X-RATELIMIT-RESET-REQUESTS-XXX - x-ratelimit-reset-tokens: - - X-RATELIMIT-RESET-TOKENS-XXX - x-request-id: - - X-REQUEST-ID-XXX - status: - code: 200 - message: OK -- request: - body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour - personal goal is: Test goal"},{"role":"user","content":"\nCurrent Task: Create - a GitHub issue"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_lLWMJ8icAP8pCVCANTDjIl9s","type":"function","function":{"name":"create_issue","arguments":"{\"title\":\"Test - GitHub Issue\",\"body\":\"This is a test GitHub issue created for testing purposes.\"}"}}]},{"role":"tool","tool_call_id":"call_lLWMJ8icAP8pCVCANTDjIl9s","name":"create_issue","content":"{\n \"success\": - true,\n \"issue_url\": \"https://github.com/test/repo/issues/1\"\n}"}],"model":"gpt-3.5-turbo","tool_choice":"auto","tools":[{"type":"function","function":{"name":"create_issue","description":"Create - a GitHub issue","strict":true,"parameters":{"additionalProperties":false,"properties":{"title":{"description":"Issue - title","title":"Title","type":"string"},"body":{"default":null,"description":"Issue - body","title":"Body","type":"string"}},"required":["title","body"],"type":"object"}}}]}' - headers: - User-Agent: - - X-USER-AGENT-XXX - accept: - - application/json - accept-encoding: - - ACCEPT-ENCODING-XXX - authorization: - - AUTHORIZATION-XXX - connection: - - keep-alive - content-length: - - '1042' - content-type: - - application/json - cookie: - - COOKIE-XXX - host: - - api.openai.com - x-stainless-arch: - - X-STAINLESS-ARCH-XXX - x-stainless-async: - - 'false' - x-stainless-lang: - - python - x-stainless-os: - - X-STAINLESS-OS-XXX - x-stainless-package-version: - - 1.83.0 - x-stainless-read-timeout: - - X-STAINLESS-READ-TIMEOUT-XXX - x-stainless-retry-count: - - '0' - x-stainless-runtime: - - CPython - x-stainless-runtime-version: - - 3.13.3 - method: POST - uri: https://api.openai.com/v1/chat/completions - response: - body: - string: "{\n \"id\": \"chatcmpl-D8D6Q2wrEW1ncIy69lWGrk2NTH9ln\",\n \"object\": - \"chat.completion\",\n \"created\": 1770849362,\n \"model\": \"gpt-3.5-turbo-0125\",\n - \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": - \"assistant\",\n \"content\": \"I have successfully created a test - GitHub issue. You can view it at [this link](https://github.com/test/repo/issues/1).\",\n - \ \"refusal\": null,\n \"annotations\": []\n },\n \"logprobs\": - null,\n \"finish_reason\": \"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\": - 156,\n \"completion_tokens\": 30,\n \"total_tokens\": 186,\n \"prompt_tokens_details\": - {\n \"cached_tokens\": 0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\": - {\n \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": - 0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\": - \"default\",\n \"system_fingerprint\": null\n}\n" - headers: - CF-RAY: - - CF-RAY-XXX - Connection: - - keep-alive - Content-Type: - - application/json - Date: - - Wed, 11 Feb 2026 22:36:03 GMT - Server: - - cloudflare - Strict-Transport-Security: - - STS-XXX - Transfer-Encoding: - - chunked - X-Content-Type-Options: - - X-CONTENT-TYPE-XXX - access-control-expose-headers: - - ACCESS-CONTROL-XXX - alt-svc: - - h3=":443"; ma=86400 - cf-cache-status: - - DYNAMIC - openai-organization: - - OPENAI-ORG-XXX - openai-processing-ms: - - '2740' - openai-project: - - OPENAI-PROJECT-XXX - openai-version: - - '2020-10-01' - set-cookie: - - SET-COOKIE-XXX - x-openai-proxy-wasm: - - v0.1 - x-ratelimit-limit-requests: - - X-RATELIMIT-LIMIT-REQUESTS-XXX - x-ratelimit-limit-tokens: - - X-RATELIMIT-LIMIT-TOKENS-XXX - x-ratelimit-remaining-requests: - - X-RATELIMIT-REMAINING-REQUESTS-XXX - x-ratelimit-remaining-tokens: - - X-RATELIMIT-REMAINING-TOKENS-XXX - x-ratelimit-reset-requests: - - X-RATELIMIT-RESET-REQUESTS-XXX - x-ratelimit-reset-tokens: - - X-RATELIMIT-RESET-TOKENS-XXX - x-request-id: - - X-REQUEST-ID-XXX - status: - code: 200 - message: OK -- request: - body: '{"messages":[{"role":"system","content":"You are Test Agent. Test backstory\nYour - personal goal is: Test goal"},{"role":"user","content":"\nCurrent Task: Create - a GitHub issue"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_lLWMJ8icAP8pCVCANTDjIl9s","type":"function","function":{"name":"create_issue","arguments":"{\"title\":\"Test - GitHub Issue\",\"body\":\"This is a test GitHub issue created for testing purposes.\"}"}}]},{"role":"tool","tool_call_id":"call_lLWMJ8icAP8pCVCANTDjIl9s","name":"create_issue","content":"{\n \"success\": - true,\n \"issue_url\": \"https://github.com/test/repo/issues/1\"\n}"},{"role":"assistant","content":"I - have successfully created a test GitHub issue. You can view it at [this link](https://github.com/test/repo/issues/1)."}],"model":"gpt-3.5-turbo","tool_choice":"auto","tools":[{"type":"function","function":{"name":"create_issue","description":"Create - a GitHub issue","strict":true,"parameters":{"additionalProperties":false,"properties":{"title":{"description":"Issue - title","title":"Title","type":"string"},"body":{"default":null,"description":"Issue - body","title":"Body","type":"string"}},"required":["title","body"],"type":"object"}}}]}' - headers: - User-Agent: - - X-USER-AGENT-XXX - accept: - - application/json - accept-encoding: - - ACCEPT-ENCODING-XXX - authorization: - - AUTHORIZATION-XXX - connection: - - keep-alive - content-length: - - '1195' - content-type: - - application/json - cookie: - - COOKIE-XXX - host: - - api.openai.com - x-stainless-arch: - - X-STAINLESS-ARCH-XXX - x-stainless-async: - - 'false' - x-stainless-lang: - - python - x-stainless-os: - - X-STAINLESS-OS-XXX - x-stainless-package-version: - - 1.83.0 - x-stainless-read-timeout: - - X-STAINLESS-READ-TIMEOUT-XXX - x-stainless-retry-count: - - '0' - x-stainless-runtime: - - CPython - x-stainless-runtime-version: - - 3.13.3 - method: POST - uri: https://api.openai.com/v1/chat/completions - response: - body: - string: "{\n \"id\": \"chatcmpl-D8D6R4KzXuzOPsV3Sa6gWnZyrLjm0\",\n \"object\": - \"chat.completion\",\n \"created\": 1770849363,\n \"model\": \"gpt-3.5-turbo-0125\",\n - \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": - \"assistant\",\n \"content\": \"Test goal\",\n \"refusal\": - null,\n \"annotations\": []\n },\n \"logprobs\": null,\n - \ \"finish_reason\": \"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\": - 189,\n \"completion_tokens\": 3,\n \"total_tokens\": 192,\n \"prompt_tokens_details\": - {\n \"cached_tokens\": 0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\": - {\n \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": - 0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\": - \"default\",\n \"system_fingerprint\": null\n}\n" - headers: - CF-RAY: - - CF-RAY-XXX - Connection: - - keep-alive - Content-Type: - - application/json - Date: - - Wed, 11 Feb 2026 22:36:03 GMT - Server: - - cloudflare - Strict-Transport-Security: - - STS-XXX - Transfer-Encoding: - - chunked - X-Content-Type-Options: - - X-CONTENT-TYPE-XXX - access-control-expose-headers: - - ACCESS-CONTROL-XXX - alt-svc: - - h3=":443"; ma=86400 - cf-cache-status: - - DYNAMIC - openai-organization: - - OPENAI-ORG-XXX - openai-processing-ms: - - '515' - openai-project: - - OPENAI-PROJECT-XXX - openai-version: - - '2020-10-01' - set-cookie: - - SET-COOKIE-XXX - x-openai-proxy-wasm: - - v0.1 - x-ratelimit-limit-requests: - - X-RATELIMIT-LIMIT-REQUESTS-XXX - x-ratelimit-limit-tokens: - - X-RATELIMIT-LIMIT-TOKENS-XXX - x-ratelimit-remaining-requests: - - X-RATELIMIT-REMAINING-REQUESTS-XXX - x-ratelimit-remaining-tokens: - - X-RATELIMIT-REMAINING-TOKENS-XXX - x-ratelimit-reset-requests: - - X-RATELIMIT-RESET-REQUESTS-XXX - x-ratelimit-reset-tokens: - - X-RATELIMIT-RESET-TOKENS-XXX - x-request-id: - - X-REQUEST-ID-XXX - status: - code: 200 - message: OK -version: 1 diff --git a/lib/crewai/tests/tools/test_tool_failure.py b/lib/crewai/tests/tools/test_tool_failure.py index 3e9e5f82e..17bfdb94c 100644 --- a/lib/crewai/tests/tools/test_tool_failure.py +++ b/lib/crewai/tests/tools/test_tool_failure.py @@ -1693,56 +1693,51 @@ class TestPlatformActionTool: """CrewAI AMP agentic-app actions -- the Slack case from the bug report.""" @staticmethod - def _tool() -> Any: + def _tool(client: Any) -> Any: import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as 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": [], - }, - } - }, + from crewai_tools.tools.crewai_platform_tools._client import ( + _PlatformToolInfo, ) - def test_non_ok_response_becomes_a_tool_failure(self, monkeypatch) -> None: # noqa: ANN001 + return mod.CrewAIPlatformActionTool( + tool_info=_PlatformToolInfo( + app="slack", + action="slackbot_send_message", + connection_id=None, + description="Send a Slack message", + parameters={ + "properties": {"channel": {"type": "string"}}, + "required": [], + }, + ), + client=client, + ) + + def test_non_ok_response_becomes_a_tool_failure(self) -> None: from unittest.mock import Mock - import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod - - response = Mock() - response.ok = False - response.status_code = 500 + response = Mock(ok=False, status_code=500) response.json.return_value = { "error": "Failed to execute action: Slack API error: channel_not_found" } - monkeypatch.setattr(mod.requests, "post", Mock(return_value=response)) - monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t") + client = Mock() + client.execute_action.return_value = response - result = self._tool()._run(channel="#joao-message") + result = self._tool(client)._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, monkeypatch) -> None: # noqa: ANN001 + def test_ok_response_still_returns_json(self) -> None: from unittest.mock import Mock - import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod - - response = Mock() - response.ok = True + response = Mock(ok=True) response.json.return_value = {"ts": "1234.5678"} - monkeypatch.setattr(mod.requests, "post", Mock(return_value=response)) - monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t") + client = Mock() + client.execute_action.return_value = response - result = self._tool()._run(channel="#general") + result = self._tool(client)._run(channel="#general") assert not isinstance(result, ToolFailure) assert "1234.5678" in result