Use a clear contract for platform tools

The platform tool builder keyed schemas by action name. Actions with the
same name from different apps could overwrite each other.

Resolve selectors into immutable tool metadata and use one private
client to fetch and execute actions. Keep each app identity when CrewAI
builds the tool collection.
This commit is contained in:
Vinicius Brasil
2026-08-31 17:35:48 -03:00
parent bf56bb13bd
commit f039d2a502
14 changed files with 752 additions and 1133 deletions

View File

@@ -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",
]

View File

@@ -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(),
)

View File

@@ -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

View File

@@ -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},
)

View File

@@ -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."""

View File

@@ -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
]
)

View File

@@ -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 ""

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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"})

View File

@@ -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

View File

@@ -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