mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-20 01:55:38 +00:00
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
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user