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 new file mode 100644 index 000000000..ac2b9e347 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/application_selector.py @@ -0,0 +1,54 @@ +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_tool_builder.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tool_builder.py index 04ec5451e..23b6b9293 100644 --- 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 @@ -8,6 +8,9 @@ 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, ) @@ -27,7 +30,7 @@ class CrewaiPlatformToolBuilder: self, apps: list[str], ) -> None: - self._apps = apps + self._apps = [ApplicationSelector(app) for app in apps] self._actions_schema: dict[str, dict[str, Any]] = {} self._tools: list[BaseTool] | None = None @@ -42,18 +45,22 @@ class CrewaiPlatformToolBuilder: """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(self._apps)}, + 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 {self._apps}: {e}") + logger.error(f"Failed to fetch platform tools for apps {apps}: {e}") return raw_data = response.json() 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 index 03904a840..e66022792 100644 --- 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 @@ -84,7 +84,9 @@ class TestCrewaiPlatformToolBuilder(unittest.TestCase): } } - builder = CrewaiPlatformToolBuilder(apps=["github", "slack/send_message"]) + builder = CrewaiPlatformToolBuilder( + apps=["github", "slack/send_message", "custom/path/to/action"] + ) mock_response = Mock() mock_response.raise_for_status.return_value = None @@ -98,7 +100,9 @@ class TestCrewaiPlatformToolBuilder(unittest.TestCase): assert "/actions" in args[0] assert kwargs["headers"]["Authorization"] == "Bearer test_token" - assert kwargs["params"]["apps"] == "github,slack/send_message" + assert kwargs["params"]["apps"] == ( + "github,slack/send_message,custom/path/to/action" + ) assert "create_issue" in builder._actions_schema assert ( @@ -320,3 +324,52 @@ class TestCrewaiPlatformToolBuilderVerify(unittest.TestCase): 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)