From d20845f0a3fc0db9ec2837abcf021780f295730a Mon Sep 17 00:00:00 2001 From: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:43:55 +0530 Subject: [PATCH] feat: add platform tools to JSON crew wizard (#7384) * feat: support platform tools in JSON crews * style: clarify platform integration labels * fix: avoid repeating tool picker title * fix(platform): surface JSON tool discovery errors --- lib/cli/src/crewai_cli/create_json_crew.py | 9 ++- .../src/crewai_cli/platform_tools_catalog.py | 29 +++++++++ lib/cli/src/crewai_cli/tui_picker.py | 5 +- lib/cli/tests/test_create_crew.py | 47 +++++++++++++++ .../crewai_platform_tools.py | 23 ++------ .../test_crewai_platform_tools.py | 6 +- lib/crewai/src/crewai/project/json_loader.py | 57 ++++++++++++++---- lib/crewai/tests/project/test_json_loader.py | 59 +++++++++++++++++++ 8 files changed, 199 insertions(+), 36 deletions(-) create mode 100644 lib/cli/src/crewai_cli/platform_tools_catalog.py diff --git a/lib/cli/src/crewai_cli/create_json_crew.py b/lib/cli/src/crewai_cli/create_json_crew.py index a4b04975f..00f1cd4db 100644 --- a/lib/cli/src/crewai_cli/create_json_crew.py +++ b/lib/cli/src/crewai_cli/create_json_crew.py @@ -16,6 +16,7 @@ from rich.text import Text from crewai_cli.constants import ENV_VARS from crewai_cli.git import initialize_if_git_available from crewai_cli.model_catalog import get_provider_models +from crewai_cli.platform_tools_catalog import PLATFORM_TOOLS from crewai_cli.tui_picker import pick_many, pick_one from crewai_cli.utils import ( enable_prompt_line_editing, @@ -103,6 +104,7 @@ _TEMPLATES_DIR = Path(__file__).parent / "templates" / "json_crew" # ── Common tools for picker ──────────────────────────────────── _TOOL_CATEGORIES: list[tuple[str, list[tuple[str, str]]]] = [ + ("CrewAI Platform", PLATFORM_TOOLS), ( "Search & Research", [ @@ -304,6 +306,9 @@ def _show_interpolation_hint(kind: str) -> None: def _tool_label(name: str, description: str) -> str: + if name.startswith("platform:"): + app_name = description.removesuffix(" Integration").replace(" ", "") + return f"{description:<48s} Platform: {app_name.replace(' ', '')}Integration" return f"{description:<48s} {name}" @@ -351,6 +356,7 @@ def _select_tools() -> list[str]: selected: set[str] = set() expanded: str | None = None focus_category: str | None = None + first_render = True while True: labels: list[str] = [] @@ -387,13 +393,14 @@ def _select_tools() -> list[str]: labels.append(_tool_label(name, desc)) indices, action = pick_many( - "Tools (space to toggle, enter to confirm):", + "Tools (space to toggle, enter to confirm):" if first_render else "", labels, action_indices=action_indices, separator_indices=separator_indices, preselected=preselected, initial_cursor=initial_cursor, ) + first_render = False # Carry over toggles made on this screen; tools not visible in this # render keep their previous state. diff --git a/lib/cli/src/crewai_cli/platform_tools_catalog.py b/lib/cli/src/crewai_cli/platform_tools_catalog.py new file mode 100644 index 000000000..6de79343f --- /dev/null +++ b/lib/cli/src/crewai_cli/platform_tools_catalog.py @@ -0,0 +1,29 @@ +"""CrewAI AMP platform tools exposed by the crew-creation wizard.""" + +from crewai_core.platform_apps import PLATFORM_APPS + + +PLATFORM_TOOL_PREFIX = "platform:" + +_APP_DESCRIPTIONS: dict[str, str] = { + "asana": "Asana Integration", + "box": "Box Integration", + "clickup": "ClickUp Integration", + "github": "GitHub Integration", + "gmail": "Gmail Integration", + "google_calendar": "Google Calendar Integration", + "google_sheets": "Google Sheets Integration", + "hubspot": "HubSpot Integration", + "jira": "Jira Integration", + "linear": "Linear Integration", + "notion": "Notion Integration", + "salesforce": "Salesforce Integration", + "shopify": "Shopify Integration", + "slack": "Slack Integration", + "stripe": "Stripe Integration", + "zendesk": "Zendesk Integration", +} + +PLATFORM_TOOLS: list[tuple[str, str]] = [ + (f"{PLATFORM_TOOL_PREFIX}{app}", _APP_DESCRIPTIONS[app]) for app in PLATFORM_APPS +] diff --git a/lib/cli/src/crewai_cli/tui_picker.py b/lib/cli/src/crewai_cli/tui_picker.py index 69157658c..41b630620 100644 --- a/lib/cli/src/crewai_cli/tui_picker.py +++ b/lib/cli/src/crewai_cli/tui_picker.py @@ -388,8 +388,9 @@ def pick_many( Sorted list of selected indices, or ``(indices, action_index)`` when ``action_indices`` is provided. """ - click.echo() - click.secho(f" {title}", fg="cyan") + if title: + click.echo() + click.secho(f" {title}", fg="cyan") if _is_interactive(): try: diff --git a/lib/cli/tests/test_create_crew.py b/lib/cli/tests/test_create_crew.py index cb2a4820b..b2937dc33 100644 --- a/lib/cli/tests/test_create_crew.py +++ b/lib/cli/tests/test_create_crew.py @@ -7,6 +7,7 @@ from unittest import mock import pytest import tomli from click.testing import CliRunner +from crewai_core.platform_apps import PLATFORM_APPS from packaging.requirements import Requirement from packaging.version import Version import crewai_cli.create_json_crew as json_crew @@ -620,6 +621,52 @@ def test_json_wizard_tool_picker_lists_builtin_tools_across_categories(monkeypat }.isdisjoint(tool_names) +def test_json_wizard_platform_tool_selection_stays_in_agent_tools(monkeypatch): + picker_calls = 0 + + def pick_many(title: str, labels: list[str], **kwargs): + nonlocal picker_calls + picker_calls += 1 + if picker_calls == 1: + platform_row = next( + idx for idx, label in enumerate(labels) if "CrewAI Platform" in label + ) + return [], platform_row + + github = next( + idx + for idx, label in enumerate(labels) + if label.startswith("GitHub Integration") + and label.endswith("Platform: GitHubIntegration") + ) + return [github], None + + monkeypatch.setattr(json_crew, "pick_many", pick_many) + monkeypatch.setattr( + json_crew, "_prompt_text", lambda label, **kwargs: label.lower() + ) + monkeypatch.setattr(json_crew, "_select_model", lambda: "openai/gpt-5.5") + monkeypatch.setattr(json_crew, "_confirm", lambda *_args, **_kwargs: False) + + agent = json_crew._wizard_agent(agent_num=1, existing_names=[]) + + assert agent is not None + assert agent["tools"] == ["platform:github"] + assert '"tools": ["platform:github"]' in json_crew._agent_to_jsonc(agent) + + +def test_json_wizard_platform_catalog_contains_every_supported_app(): + platform_category = next( + tools + for category, tools in json_crew._TOOL_CATEGORIES + if category == "CrewAI Platform" + ) + + assert [name for name, _description in platform_category] == [ + f"platform:{app}" for app in PLATFORM_APPS + ] + + def test_multi_picker_skips_separator_on_initial_cursor(monkeypatch): cursors: list[int] = [] diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tools.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tools.py index 2dc083b98..a344f8cae 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tools.py +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_tools.py @@ -1,5 +1,3 @@ -import logging - from crewai.tools import BaseTool from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import ( @@ -11,9 +9,6 @@ from crewai_tools.tools.crewai_platform_tools.integrations_client import ( ) -logger = logging.getLogger(__name__) - - def CrewaiPlatformTools( # noqa: N802 apps: list[str], ) -> list[BaseTool]: @@ -28,17 +23,11 @@ def CrewaiPlatformTools( # noqa: N802 selectors = [ApplicationSelector.from_string(app) for app in apps] tools: list[BaseTool] = [] - try: - for selector in selectors: - client = client_for_selector(selector) - tools.extend( - CrewAIPlatformActionTool(tool_info, client=client) - for tool_info in client.get_actions([selector]) - ) - except ValueError: - raise - except Exception as error: - logger.error(f"Failed to fetch platform tools for apps {apps}: {error}") - return [] + for selector in selectors: + client = client_for_selector(selector) + tools.extend( + CrewAIPlatformActionTool(tool_info, client=client) + for tool_info in client.get_actions([selector]) + ) return tools diff --git a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tools.py b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tools.py index 62b0814fc..f3013675f 100644 --- a/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tools.py +++ b/lib/crewai-tools/tests/tools/crewai_platform_tools/test_crewai_platform_tools.py @@ -157,10 +157,8 @@ class TestCrewaiPlatformTools(unittest.TestCase): def test_crewai_platform_tools_api_error_handling(self, mock_get): mock_get.side_effect = Exception("API Error") - tools = CrewaiPlatformTools(apps=["github"]) - assert tools is not None - assert isinstance(tools, list) - assert len(tools) == 0 + with self.assertRaisesRegex(Exception, "API Error"): + CrewaiPlatformTools(apps=["github"]) def test_crewai_platform_tools_no_token(self): with patch.dict("os.environ", {}, clear=True): diff --git a/lib/crewai/src/crewai/project/json_loader.py b/lib/crewai/src/crewai/project/json_loader.py index c28d42cc3..c1c95a83a 100644 --- a/lib/crewai/src/crewai/project/json_loader.py +++ b/lib/crewai/src/crewai/project/json_loader.py @@ -1820,6 +1820,9 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li ) if not tool_def: continue + if tool_def.startswith("platform:"): + tools.extend(_resolve_platform_tools(tool_def.removeprefix("platform:"))) + continue if tool_def.startswith("custom:"): tools.append(_resolve_custom_tool(tool_def[7:], project_root=project_root)) continue @@ -1847,6 +1850,30 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li return tools +def _resolve_platform_tools(selector: str) -> list[Any]: + """Materialize an AMP application selector into CrewAI tools.""" + if not selector: + raise JSONProjectError( + "Invalid platform tool reference 'platform:': expected " + "'platform:'" + ) + + try: + from crewai_tools import CrewaiPlatformTools + except ImportError as e: + raise JSONProjectError( + "Platform tools require the 'crewai-tools' package. " + "Install CrewAI with the tools extra." + ) from e + + try: + return CrewaiPlatformTools(apps=[selector]) + except Exception as e: + raise JSONProjectError( + f"Failed to initialize platform tool 'platform:{selector}': {e}" + ) from e + + def _instantiate_tool_import_ref(ref: str) -> Any: from crewai.tools import BaseTool @@ -1990,19 +2017,25 @@ def _tool_definition_errors( f"got {type(tool_def).__name__}" ) continue - if not tool_def.startswith("custom:"): + if tool_def.startswith("platform:"): + if not tool_def.removeprefix("platform:"): + errors.append( + f"{source}: invalid platform tool reference 'platform:': " + "expected 'platform:'" + ) continue - try: - tool_file = _custom_tool_file(tool_def[7:], project_root) - except JSONProjectError as exc: - errors.append(f"{source}: {exc}") - continue - if not tool_file.exists(): - errors.append( - f"{source}: custom tool '{tool_def}' not found: expected " - f"{tool_file}. Create the file with a BaseTool subclass, or " - f"remove the tool from your crew JSON." - ) + if tool_def.startswith("custom:"): + try: + tool_file = _custom_tool_file(tool_def[7:], project_root) + except JSONProjectError as exc: + errors.append(f"{source}: {exc}") + continue + if not tool_file.exists(): + errors.append( + f"{source}: custom tool '{tool_def}' not found: expected " + f"{tool_file}. Create the file with a BaseTool subclass, or " + f"remove the tool from your crew JSON." + ) return errors diff --git a/lib/crewai/tests/project/test_json_loader.py b/lib/crewai/tests/project/test_json_loader.py index 638c0d500..727aebbb9 100644 --- a/lib/crewai/tests/project/test_json_loader.py +++ b/lib/crewai/tests/project/test_json_loader.py @@ -403,6 +403,52 @@ class TestLoadAgentFromDefinition: class TestResolveTools: + def test_platform_tool_refs_materialize_multiple_application_tools( + self, monkeypatch + ): + from crewai.project.json_loader import _resolve_tools + + github_tools = [object()] + linear_tools = [object(), object()] + calls: list[list[str]] = [] + + def build_platform_tools(apps: list[str]): + calls.append(apps) + return { + "github": github_tools, + "linear": linear_tools, + }[apps[0]] + + monkeypatch.setattr( + "crewai_tools.CrewaiPlatformTools", build_platform_tools + ) + + tools = _resolve_tools(["platform:github", "platform:linear"]) + + assert tools == github_tools + linear_tools + assert calls == [["github"], ["linear"]] + + def test_empty_platform_tool_ref_raises_with_guidance(self): + from crewai.project.json_loader import JSONProjectError, _resolve_tools + + with pytest.raises(JSONProjectError, match="platform:"): + _resolve_tools(["platform:"]) + + def test_platform_tool_discovery_errors_are_json_project_errors( + self, monkeypatch + ): + from crewai.project.json_loader import JSONProjectError, _resolve_tools + + def fail_platform_tool_discovery(apps: list[str]): + raise RuntimeError("discovery unavailable") + + monkeypatch.setattr( + "crewai_tools.CrewaiPlatformTools", fail_platform_tool_discovery + ) + + with pytest.raises(JSONProjectError, match="discovery unavailable"): + _resolve_tools(["platform:github"]) + def test_import_ref_tool_resolves(self, tmp_path, monkeypatch): from crewai.project.json_loader import _resolve_tools @@ -614,6 +660,19 @@ class TestValidationDoesNotExecuteTools: assert "Invalid custom tool name" in str(exc_info.value) + def test_validate_rejects_empty_platform_tool_ref(self, tmp_path): + from crewai.project.json_loader import ( + JSONProjectValidationError, + validate_crew_project, + ) + + crew_path = self._write_project(tmp_path, tool_line='"platform:"') + + with pytest.raises(JSONProjectValidationError) as exc_info: + validate_crew_project(crew_path, tmp_path / "agents") + + assert "platform:" in str(exc_info.value) + def test_validate_rejects_deep_python_ref_nesting(self, tmp_path): from crewai.project.json_loader import validate_crew_project