From 5d4851eac797cafc45b726f65747fe2c9520fc42 Mon Sep 17 00:00:00 2001 From: Rip&Tear <84775494+theCyberTech@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:42:49 +0800 Subject: [PATCH 1/7] Fix SSRF redirect bypass in scraping fetches (#6331) * Validate redirects for scraping URL fetches * Prevent credential forwarding across redirects --- .../rag/loaders/docs_site_loader.py | 3 +- .../crewai_tools/rag/loaders/docx_loader.py | 5 +- .../crewai_tools/rag/loaders/pdf_loader.py | 5 +- .../src/crewai_tools/rag/loaders/utils.py | 4 +- .../rag/loaders/webpage_loader.py | 4 +- .../crewai_tools/security/safe_requests.py | 88 +++++++++ .../scrape_element_from_website.py | 6 +- .../scrape_website_tool.py | 6 +- lib/crewai-tools/tests/rag/conftest.py | 28 +++ .../tests/utilities/test_safe_requests.py | 177 ++++++++++++++++++ 10 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 lib/crewai-tools/src/crewai_tools/security/safe_requests.py create mode 100644 lib/crewai-tools/tests/rag/conftest.py create mode 100644 lib/crewai-tools/tests/utilities/test_safe_requests.py diff --git a/lib/crewai-tools/src/crewai_tools/rag/loaders/docs_site_loader.py b/lib/crewai-tools/src/crewai_tools/rag/loaders/docs_site_loader.py index 87b97266e..3f60bc35f 100644 --- a/lib/crewai-tools/src/crewai_tools/rag/loaders/docs_site_loader.py +++ b/lib/crewai-tools/src/crewai_tools/rag/loaders/docs_site_loader.py @@ -8,6 +8,7 @@ import requests from crewai_tools.rag.base_loader import BaseLoader, LoaderResult from crewai_tools.rag.source_content import SourceContent +from crewai_tools.security.safe_requests import safe_get class DocsSiteLoader(BaseLoader): @@ -26,7 +27,7 @@ class DocsSiteLoader(BaseLoader): docs_url = source.source try: - response = requests.get(docs_url, timeout=30) + response = safe_get(docs_url, timeout=30) response.raise_for_status() except requests.RequestException as e: raise ValueError( diff --git a/lib/crewai-tools/src/crewai_tools/rag/loaders/docx_loader.py b/lib/crewai-tools/src/crewai_tools/rag/loaders/docx_loader.py index 201bc12c2..df736e348 100644 --- a/lib/crewai-tools/src/crewai_tools/rag/loaders/docx_loader.py +++ b/lib/crewai-tools/src/crewai_tools/rag/loaders/docx_loader.py @@ -2,10 +2,9 @@ import os import tempfile from typing import Any -import requests - from crewai_tools.rag.base_loader import BaseLoader, LoaderResult from crewai_tools.rag.source_content import SourceContent +from crewai_tools.security.safe_requests import safe_get class DOCXLoader(BaseLoader): @@ -43,7 +42,7 @@ class DOCXLoader(BaseLoader): ) try: - response = requests.get(url, headers=headers, timeout=30) + response = safe_get(url, headers=headers, timeout=30) response.raise_for_status() # Create temporary file to save the DOCX content diff --git a/lib/crewai-tools/src/crewai_tools/rag/loaders/pdf_loader.py b/lib/crewai-tools/src/crewai_tools/rag/loaders/pdf_loader.py index 774de92d9..259181481 100644 --- a/lib/crewai-tools/src/crewai_tools/rag/loaders/pdf_loader.py +++ b/lib/crewai-tools/src/crewai_tools/rag/loaders/pdf_loader.py @@ -6,10 +6,9 @@ import tempfile from typing import Any from urllib.parse import urlparse -import requests - from crewai_tools.rag.base_loader import BaseLoader, LoaderResult from crewai_tools.rag.source_content import SourceContent +from crewai_tools.security.safe_requests import safe_get class PDFLoader(BaseLoader): @@ -47,7 +46,7 @@ class PDFLoader(BaseLoader): ) try: - response = requests.get(url, headers=headers, timeout=30) + response = safe_get(url, headers=headers, timeout=30) response.raise_for_status() with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file: diff --git a/lib/crewai-tools/src/crewai_tools/rag/loaders/utils.py b/lib/crewai-tools/src/crewai_tools/rag/loaders/utils.py index f944070c8..f239a0a05 100644 --- a/lib/crewai-tools/src/crewai_tools/rag/loaders/utils.py +++ b/lib/crewai-tools/src/crewai_tools/rag/loaders/utils.py @@ -23,7 +23,7 @@ def load_from_url( Raises: ValueError: If there's an error fetching the URL """ - import requests + from crewai_tools.security.safe_requests import safe_get headers = kwargs.get( "headers", @@ -34,7 +34,7 @@ def load_from_url( ) try: - response = requests.get(url, headers=headers, timeout=30) + response = safe_get(url, headers=headers, timeout=30) response.raise_for_status() return response.text except Exception as e: diff --git a/lib/crewai-tools/src/crewai_tools/rag/loaders/webpage_loader.py b/lib/crewai-tools/src/crewai_tools/rag/loaders/webpage_loader.py index 5d9a2d180..a22c09e66 100644 --- a/lib/crewai-tools/src/crewai_tools/rag/loaders/webpage_loader.py +++ b/lib/crewai-tools/src/crewai_tools/rag/loaders/webpage_loader.py @@ -2,10 +2,10 @@ import re from typing import Any, Final from bs4 import BeautifulSoup -import requests from crewai_tools.rag.base_loader import BaseLoader, LoaderResult from crewai_tools.rag.source_content import SourceContent +from crewai_tools.security.safe_requests import safe_get _SPACES_PATTERN: Final[re.Pattern[str]] = re.compile(r"[ \t]+") @@ -25,7 +25,7 @@ class WebPageLoader(BaseLoader): ) try: - response = requests.get(url, timeout=15, headers=headers) + response = safe_get(url, timeout=15, headers=headers) response.encoding = response.apparent_encoding soup = BeautifulSoup(response.text, "html.parser") diff --git a/lib/crewai-tools/src/crewai_tools/security/safe_requests.py b/lib/crewai-tools/src/crewai_tools/security/safe_requests.py new file mode 100644 index 000000000..505a5cdb6 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/security/safe_requests.py @@ -0,0 +1,88 @@ +"""HTTP helpers that preserve crewai-tools URL safety checks.""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urljoin, urlparse + +import requests + +from crewai_tools.security.safe_path import validate_url + + +_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308} +_SENSITIVE_HEADER_NAMES = { + "authorization", + "cookie", + "proxy-authorization", + "x-api-key", +} +_SENSITIVE_HEADER_FRAGMENTS = ("api-key", "apikey", "secret", "token") + + +def _same_origin(previous_url: str, next_url: str) -> bool: + previous = urlparse(previous_url) + next_ = urlparse(next_url) + return (previous.scheme, previous.netloc) == (next_.scheme, next_.netloc) + + +def _is_sensitive_header(header: str) -> bool: + normalized = header.lower() + return ( + normalized in _SENSITIVE_HEADER_NAMES + or normalized.startswith("authorization-") + or any(fragment in normalized for fragment in _SENSITIVE_HEADER_FRAGMENTS) + ) + + +def _strip_cross_origin_credentials(request_kwargs: dict[str, Any]) -> dict[str, Any]: + sanitized = {**request_kwargs} + headers = sanitized.get("headers") + if headers: + sanitized["headers"] = { + key: value + for key, value in headers.items() + if not _is_sensitive_header(str(key)) + } + sanitized.pop("cookies", None) + return sanitized + + +def safe_get(url: str, *, max_redirects: int = 10, **kwargs: Any) -> requests.Response: + """GET a URL while validating each redirect target before following it.""" + current_url = validate_url(url) + request_kwargs = {**kwargs, "allow_redirects": False} + timeout = request_kwargs.pop("timeout", 30) + history: list[requests.Response] = [] + redirects_followed = 0 + + while True: + response = requests.get(current_url, timeout=timeout, **request_kwargs) + if ( + response.status_code not in _REDIRECT_STATUS_CODES + or "Location" not in response.headers + ): + response.history = history + return response + + if redirects_followed >= max_redirects: + response.close() + raise ValueError(f"Too many redirects while fetching URL: {url}") + + location = response.headers.get("Location") + if not location: + response.history = history + return response + + try: + redirect_url = validate_url(urljoin(response.url, location)) + except ValueError: + response.close() + raise + + if not _same_origin(current_url, redirect_url): + request_kwargs = _strip_cross_origin_credentials(request_kwargs) + + history.append(response) + current_url = redirect_url + redirects_followed += 1 diff --git a/lib/crewai-tools/src/crewai_tools/tools/scrape_element_from_website/scrape_element_from_website.py b/lib/crewai-tools/src/crewai_tools/tools/scrape_element_from_website/scrape_element_from_website.py index 7bba12b72..535bbf9d5 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/scrape_element_from_website/scrape_element_from_website.py +++ b/lib/crewai-tools/src/crewai_tools/tools/scrape_element_from_website/scrape_element_from_website.py @@ -3,9 +3,8 @@ from typing import Any from crewai.tools import BaseTool from pydantic import BaseModel, Field -import requests -from crewai_tools.security.safe_path import validate_url +from crewai_tools.security.safe_requests import safe_get try: @@ -83,8 +82,7 @@ class ScrapeElementFromWebsiteTool(BaseTool): if website_url is None or css_element is None: raise ValueError("Both website_url and css_element must be provided.") - website_url = validate_url(website_url) - page = requests.get( + page = safe_get( website_url, headers=self.headers, cookies=self.cookies if self.cookies else {}, diff --git a/lib/crewai-tools/src/crewai_tools/tools/scrape_website_tool/scrape_website_tool.py b/lib/crewai-tools/src/crewai_tools/tools/scrape_website_tool/scrape_website_tool.py index d297dfe08..62bd7b0be 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/scrape_website_tool/scrape_website_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/scrape_website_tool/scrape_website_tool.py @@ -3,9 +3,8 @@ import re from typing import Any from pydantic import Field -import requests -from crewai_tools.security.safe_path import validate_url +from crewai_tools.security.safe_requests import safe_get try: @@ -75,8 +74,7 @@ class ScrapeWebsiteTool(BaseTool): if website_url is None: raise ValueError("Website URL must be provided.") - website_url = validate_url(website_url) - page = requests.get( + page = safe_get( website_url, timeout=15, headers=self.headers, diff --git a/lib/crewai-tools/tests/rag/conftest.py b/lib/crewai-tools/tests/rag/conftest.py new file mode 100644 index 000000000..a0301ae87 --- /dev/null +++ b/lib/crewai-tools/tests/rag/conftest.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import socket +from typing import Any + +import pytest + + +@pytest.fixture(autouse=True) +def public_example_dns(monkeypatch: pytest.MonkeyPatch) -> None: + original_getaddrinfo = socket.getaddrinfo + + def fake_getaddrinfo( + host: str, port: int, *args: Any, **kwargs: Any + ) -> list[tuple[Any, ...]]: + if host in {"example.com", "api.example.com"}: + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", port), + ) + ] + return original_getaddrinfo(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) diff --git a/lib/crewai-tools/tests/utilities/test_safe_requests.py b/lib/crewai-tools/tests/utilities/test_safe_requests.py new file mode 100644 index 000000000..f45dd86c6 --- /dev/null +++ b/lib/crewai-tools/tests/utilities/test_safe_requests.py @@ -0,0 +1,177 @@ +"""Tests for redirect-aware safe HTTP helpers.""" + +from __future__ import annotations + +import socket +from io import BytesIO +from typing import Any + +import pytest +import requests + +from crewai_tools.security.safe_requests import safe_get + + +def _response(url: str, status_code: int, *, location: str | None = None) -> requests.Response: + response = requests.Response() + response.status_code = status_code + response.url = url + response._content = b"ok" + response.raw = BytesIO() + if location is not None: + response.headers["Location"] = location + return response + + +@pytest.fixture +def public_dns(monkeypatch: pytest.MonkeyPatch) -> None: + original_getaddrinfo = socket.getaddrinfo + + def fake_getaddrinfo( + host: str, port: int, *args: Any, **kwargs: Any + ) -> list[tuple[Any, ...]]: + if host in {"public.example", "safe.example"}: + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", port), + ) + ] + return original_getaddrinfo(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + +def test_safe_get_blocks_direct_internal_url() -> None: + with pytest.raises(ValueError, match="private/reserved IP"): + safe_get("http://127.0.0.1/admin", timeout=15) + + +def _mock_get(monkeypatch: pytest.MonkeyPatch, get_response: Any) -> None: + monkeypatch.setattr( + "crewai_tools.security.safe_requests.requests.get", + get_response, + ) + + +def test_safe_get_blocks_redirect_to_internal_url( + monkeypatch: pytest.MonkeyPatch, public_dns: None +) -> None: + requested_urls: list[str] = [] + + def fake_get(url: str, **kwargs: Any) -> requests.Response: + requested_urls.append(url) + assert kwargs["allow_redirects"] is False + return _response(url, 302, location="http://127.0.0.1/admin") + + _mock_get(monkeypatch, fake_get) + + with pytest.raises(ValueError, match="private/reserved IP"): + safe_get("http://public.example/start", timeout=15) + + assert requested_urls == ["http://public.example/start"] + + +def test_safe_get_follows_safe_relative_redirect( + monkeypatch: pytest.MonkeyPatch, public_dns: None +) -> None: + requested_urls: list[str] = [] + + def fake_get(url: str, **kwargs: Any) -> requests.Response: + requested_urls.append(url) + assert kwargs["allow_redirects"] is False + if url == "http://public.example/start": + return _response(url, 302, location="/final") + return _response(url, 200) + + _mock_get(monkeypatch, fake_get) + + response = safe_get("http://public.example/start", timeout=15) + + assert response.status_code == 200 + assert response.url == "http://public.example/final" + assert requested_urls == [ + "http://public.example/start", + "http://public.example/final", + ] + assert len(response.history) == 1 + + +def test_safe_get_fails_closed_after_too_many_redirects( + monkeypatch: pytest.MonkeyPatch, public_dns: None +) -> None: + def fake_get(url: str, **kwargs: Any) -> requests.Response: + return _response(url, 302, location="http://safe.example/again") + + _mock_get(monkeypatch, fake_get) + + with pytest.raises(ValueError, match="Too many redirects"): + safe_get("http://public.example/start", max_redirects=1, timeout=15) + + +def test_safe_get_strips_credentials_on_cross_origin_redirect( + monkeypatch: pytest.MonkeyPatch, public_dns: None +) -> None: + requests_made: list[tuple[str, dict[str, Any]]] = [] + + def fake_get(url: str, **kwargs: Any) -> requests.Response: + requests_made.append((url, kwargs)) + if url == "http://public.example/start": + return _response(url, 302, location="http://safe.example/final") + return _response(url, 200) + + _mock_get(monkeypatch, fake_get) + + response = safe_get( + "http://public.example/start", + timeout=15, + headers={ + "Authorization": "Bearer token", + "Authorization-Custom": "secret token", + "Cookie": "session=abc", + "X-API-Key": "api key", + "X-CrewAI-Token": "crewai token", + "User-Agent": "crewai-test", + }, + cookies={"session": "abc"}, + ) + + assert response.status_code == 200 + assert requests_made[0][1]["headers"] == { + "Authorization": "Bearer token", + "Authorization-Custom": "secret token", + "Cookie": "session=abc", + "X-API-Key": "api key", + "X-CrewAI-Token": "crewai token", + "User-Agent": "crewai-test", + } + assert requests_made[0][1]["cookies"] == {"session": "abc"} + assert requests_made[1][1]["headers"] == {"User-Agent": "crewai-test"} + assert "cookies" not in requests_made[1][1] + + +def test_safe_get_preserves_credentials_on_same_origin_redirect( + monkeypatch: pytest.MonkeyPatch, public_dns: None +) -> None: + requests_made: list[tuple[str, dict[str, Any]]] = [] + + def fake_get(url: str, **kwargs: Any) -> requests.Response: + requests_made.append((url, kwargs)) + if url == "http://public.example/start": + return _response(url, 302, location="/final") + return _response(url, 200) + + _mock_get(monkeypatch, fake_get) + + safe_get( + "http://public.example/start", + timeout=15, + headers={"Authorization": "Bearer token"}, + cookies={"session": "abc"}, + ) + + assert requests_made[1][1]["headers"] == {"Authorization": "Bearer token"} + assert requests_made[1][1]["cookies"] == {"session": "abc"} From 2771c02f45f3b84a331bfc6c00b512cfe728ebcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Fri, 26 Jun 2026 05:10:55 -0300 Subject: [PATCH 2/7] docs: improve coding agent setup CTA (#6344) * docs: improve coding agent setup CTA * docs: move home CTA to published index * docs: address CTA review feedback --- docs/edge/en/installation.mdx | 108 ++++++++++++++++++++++------ docs/index.mdx | 130 +++++++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 26 deletions(-) diff --git a/docs/edge/en/installation.mdx b/docs/edge/en/installation.mdx index 66f80d248..381b92c8a 100644 --- a/docs/edge/en/installation.mdx +++ b/docs/edge/en/installation.mdx @@ -5,15 +5,49 @@ icon: wrench mode: "wide" --- -### Watch: Building CrewAI Agents & Flows with Coding Agent Skills +
+
+

+ Coding agent setup +

+

Set up CrewAI in your coding agent

+

+ Copy a ready-to-paste setup prompt for Claude Code, Codex, Cursor, or any coding agent. It installs the official CrewAI skills, checks the CLI, and points the agent at the right docs before it edits code. +

+
-Install our coding agent skills (Claude Code, Codex, ...) to quickly get your coding agents up and running with CrewAI. - - + const button = event.currentTarget; + const resetTimeout = button.dataset.resetTimeout; + if (resetTimeout) { + window.clearTimeout(Number(resetTimeout)); + } + try { + await navigator.clipboard.writeText(prompt); + button.textContent = "Copied"; + } catch { + button.textContent = "Copy failed"; + } finally { + button.dataset.resetTimeout = String(window.setTimeout(() => { + button.textContent = "Copy agent setup prompt"; + delete button.dataset.resetTimeout; + }, 1600)); + } + }} + > + Copy agent setup prompt + + + View coding-agent guide + +
+ + +### Watch: Building CrewAI Agents & Flows with Coding Agent Skills diff --git a/docs/index.mdx b/docs/index.mdx index ff6466f27..2afb9373a 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -27,9 +27,133 @@ mode: "wide"
- Get started - View changelog - API Reference + + Get started + + + + Coding-agent guide + + + API Reference +
From f364a7d98833b105023ee0ddaafcf68bcfd01032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Fri, 26 Jun 2026 05:19:14 -0300 Subject: [PATCH 3/7] Fix JSON crew version pin (#6342) * Fix JSON crew version pin * Use bounded CrewAI dependency range --- lib/cli/src/crewai_cli/create_flow.py | 8 ++++++++ lib/cli/src/crewai_cli/create_json_crew.py | 9 +++++++-- lib/cli/src/crewai_cli/run_crew.py | 8 ++++---- .../src/crewai_cli/templates/crew/pyproject.toml | 2 +- .../templates/declarative_flow/pyproject.toml | 2 +- .../src/crewai_cli/templates/flow/pyproject.toml | 2 +- .../src/crewai_cli/templates/tool/pyproject.toml | 2 +- lib/cli/src/crewai_cli/tools/main.py | 4 ++++ lib/cli/src/crewai_cli/utils.py | 5 +++++ lib/cli/src/crewai_cli/version.py | 16 ++++++++++++++++ lib/cli/tests/deploy/test_archive.py | 2 +- lib/cli/tests/test_create_crew.py | 5 +++-- lib/cli/tests/test_run_crew.py | 5 +---- lib/cli/tests/test_version.py | 7 +++++++ lib/cli/tests/tools/test_main.py | 4 ++++ 15 files changed, 64 insertions(+), 17 deletions(-) diff --git a/lib/cli/src/crewai_cli/create_flow.py b/lib/cli/src/crewai_cli/create_flow.py index adaa3d3bf..24fee7af5 100644 --- a/lib/cli/src/crewai_cli/create_flow.py +++ b/lib/cli/src/crewai_cli/create_flow.py @@ -4,6 +4,8 @@ import shutil import click from crewai_core.telemetry import Telemetry +from crewai_cli.version import get_crewai_tools_dependency + DECLARATIVE_FLOW_FOLDERS = ("crews", "tools", "knowledge", "skills") @@ -71,6 +73,9 @@ def _create_python_flow( content = content.replace("{{name}}", name) content = content.replace("{{flow_name}}", class_name) content = content.replace("{{folder_name}}", folder_name) + content = content.replace( + "{{crewai_tools_dependency}}", get_crewai_tools_dependency() + ) with open(dst_file, "w") as file: file.write(content) @@ -138,6 +143,9 @@ def _create_declarative_flow( content = content.replace("{{name}}", name) content = content.replace("{{flow_name}}", class_name) content = content.replace("{{folder_name}}", folder_name) + content = content.replace( + "{{crewai_tools_dependency}}", get_crewai_tools_dependency() + ) dst_file.write_text(content, encoding="utf-8") (project_root / ".env").write_text("OPENAI_API_KEY=YOUR_API_KEY", encoding="utf-8") diff --git a/lib/cli/src/crewai_cli/create_json_crew.py b/lib/cli/src/crewai_cli/create_json_crew.py index 9cc0c3787..ffc4090c2 100644 --- a/lib/cli/src/crewai_cli/create_json_crew.py +++ b/lib/cli/src/crewai_cli/create_json_crew.py @@ -20,6 +20,7 @@ from crewai_cli.utils import ( load_env_vars, write_env_file, ) +from crewai_cli.version import get_crewai_tools_dependency # ── Provider / model data ─────────────────────────────────────── @@ -89,7 +90,7 @@ description = "{name} using crewAI" authors = [{{ name = "Your Name", email = "you@example.com" }}] requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.14.8a1" + "{crewai_tools_dependency}" ] [build-system] @@ -1134,7 +1135,11 @@ def create_json_crew( # Write pyproject.toml (folder_path / "pyproject.toml").write_text( - _PYPROJECT_TOML.format(folder_name=folder_name, name=name), + _PYPROJECT_TOML.format( + folder_name=folder_name, + name=name, + crewai_tools_dependency=get_crewai_tools_dependency(), + ), encoding="utf-8", ) diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index de6c8c412..281f6270b 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -19,7 +19,7 @@ from crewai_cli.utils import ( is_dmn_mode_enabled, read_toml, ) -from crewai_cli.version import get_crewai_version +from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version if TYPE_CHECKING: @@ -32,12 +32,12 @@ if TYPE_CHECKING: _INPUT_PLACEHOLDER_RE = re.compile(r"(?=3.10,<3.14" dependencies = [ - "crewai[tools]==1.15.0" + "{{crewai_tools_dependency}}" ] [project.scripts] diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml index c19f3a85c..49498fb5a 100644 --- a/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml @@ -5,7 +5,7 @@ description = "{{name}} using crewAI" authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.15.0" + "{{crewai_tools_dependency}}" ] [build-system] diff --git a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml index 24e32f91f..12685b995 100644 --- a/lib/cli/src/crewai_cli/templates/flow/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/flow/pyproject.toml @@ -5,7 +5,7 @@ description = "{{name}} using crewAI" authors = [{ name = "Your Name", email = "you@example.com" }] requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.15.0" + "{{crewai_tools_dependency}}" ] [project.scripts] diff --git a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml index a994abfed..2c466718d 100644 --- a/lib/cli/src/crewai_cli/templates/tool/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/tool/pyproject.toml @@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}" readme = "README.md" requires-python = ">=3.10,<3.14" dependencies = [ - "crewai[tools]==1.15.0" + "{{crewai_tools_dependency}}" ] [tool.crewai] diff --git a/lib/cli/src/crewai_cli/tools/main.py b/lib/cli/src/crewai_cli/tools/main.py index 0c05f269d..9917f097a 100644 --- a/lib/cli/src/crewai_cli/tools/main.py +++ b/lib/cli/src/crewai_cli/tools/main.py @@ -23,6 +23,7 @@ from crewai_cli.utils import ( tree_copy, tree_find_and_replace, ) +from crewai_cli.version import get_crewai_tools_dependency console = Console() @@ -81,6 +82,9 @@ class ToolCommand(BaseCommand, PlusAPIMixin): tree_copy(template_dir, project_root) tree_find_and_replace(project_root, "{{folder_name}}", folder_name) tree_find_and_replace(project_root, "{{class_name}}", class_name) + tree_find_and_replace( + project_root, "{{crewai_tools_dependency}}", get_crewai_tools_dependency() + ) agents_md_src = Path(__file__).parent.parent / "templates" / "AGENTS.md" if agents_md_src.exists(): diff --git a/lib/cli/src/crewai_cli/utils.py b/lib/cli/src/crewai_cli/utils.py index 0834c1851..ee3a255a9 100644 --- a/lib/cli/src/crewai_cli/utils.py +++ b/lib/cli/src/crewai_cli/utils.py @@ -19,6 +19,8 @@ from crewai_core.tool_credentials import ( ) from rich.console import Console +from crewai_cli.version import get_crewai_tools_dependency + __all__ = [ "build_env_with_all_tool_credentials", @@ -73,6 +75,9 @@ def copy_template( content = content.replace("{{name}}", name) content = content.replace("{{crew_name}}", class_name) content = content.replace("{{folder_name}}", folder_name) + content = content.replace( + "{{crewai_tools_dependency}}", get_crewai_tools_dependency() + ) with open(dst, "w") as file: file.write(content) diff --git a/lib/cli/src/crewai_cli/version.py b/lib/cli/src/crewai_cli/version.py index cd9cc1d48..ad751b33e 100644 --- a/lib/cli/src/crewai_cli/version.py +++ b/lib/cli/src/crewai_cli/version.py @@ -13,10 +13,26 @@ from crewai_core.version import ( is_current_version_yanked as is_current_version_yanked, is_newer_version_available as is_newer_version_available, ) +from packaging.version import Version + +from crewai_cli import __version__ as _crewai_cli_version + + +def get_crewai_dependency_range(current_version: str | None = None) -> str: + """Return the supported CrewAI dependency range for generated projects.""" + parsed_version = Version(current_version or _crewai_cli_version) + return f">={parsed_version},<{parsed_version.major + 1}.0.0" + + +def get_crewai_tools_dependency(current_version: str | None = None) -> str: + """Return the generated-project dependency for CrewAI with tools.""" + return f"crewai[tools]{get_crewai_dependency_range(current_version)}" __all__ = [ "check_version", + "get_crewai_dependency_range", + "get_crewai_tools_dependency", "get_crewai_version", "get_latest_version_from_pypi", "is_current_version_yanked", diff --git a/lib/cli/tests/deploy/test_archive.py b/lib/cli/tests/deploy/test_archive.py index dff77a29b..56344dba2 100644 --- a/lib/cli/tests/deploy/test_archive.py +++ b/lib/cli/tests/deploy/test_archive.py @@ -176,7 +176,7 @@ def test_create_project_zip_keeps_json_project_root_shape(tmp_path: Path): [project] name = "json_crew" version = "0.1.0" -dependencies = ["crewai[tools]==1.14.8a1"] +dependencies = ["crewai[tools]>=1.15.0,<2.0.0"] [tool.crewai] type = "crew" diff --git a/lib/cli/tests/test_create_crew.py b/lib/cli/tests/test_create_crew.py index 5d87f8f3e..803dd5948 100644 --- a/lib/cli/tests/test_create_crew.py +++ b/lib/cli/tests/test_create_crew.py @@ -735,8 +735,9 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch): pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text()) dependency = pyproject["project"]["dependencies"][0] - assert dependency == "crewai[tools]==1.14.8a1" - assert Version("1.14.8a1") in Requirement(dependency).specifier + assert dependency == "crewai[tools]>=1.15.0,<2.0.0" + assert Version("1.15.0") in Requirement(dependency).specifier + assert Version("2.0.0") not in Requirement(dependency).specifier assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][ "only-include" ] == ["agents", "crew.jsonc", "tools", "knowledge", "skills"] diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index 6db073919..5b2e846b3 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -25,10 +25,7 @@ def test_missing_crewai_package_shows_full_install_hint(monkeypatch): message = exc_info.value.message assert "CrewAI CLI is installed without the `crewai` package" in message - assert ( - "uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'" - in message - ) + assert "uv tool install --force 'crewai[tools]>=1.15.0,<2.0.0'" in message assert "quotes are required in zsh" in message diff --git a/lib/cli/tests/test_version.py b/lib/cli/tests/test_version.py index 2d6d38eee..58d224dec 100644 --- a/lib/cli/tests/test_version.py +++ b/lib/cli/tests/test_version.py @@ -7,6 +7,8 @@ from unittest.mock import MagicMock, patch from crewai_cli.version import get_crewai_version as _get_ver from crewai_cli.version import ( + get_crewai_dependency_range, + get_crewai_tools_dependency, get_crewai_version, get_latest_version_from_pypi, is_current_version_yanked, @@ -31,6 +33,11 @@ def test_dynamic_versioning_consistency() -> None: assert len(package_version.strip()) > 0 +def test_generated_project_dependency_uses_next_major_upper_bound() -> None: + assert get_crewai_dependency_range("1.15.0") == ">=1.15.0,<2.0.0" + assert get_crewai_tools_dependency("1.15.0") == "crewai[tools]>=1.15.0,<2.0.0" + + class TestVersionChecking: """Test version checking utilities.""" diff --git a/lib/cli/tests/tools/test_main.py b/lib/cli/tests/tools/test_main.py index b8383cc0d..a103f0133 100644 --- a/lib/cli/tests/tools/test_main.py +++ b/lib/cli/tests/tools/test_main.py @@ -54,6 +54,10 @@ def test_create_success(mock_subprocess, capsys, tool_command): ) assert os.path.isfile(os.path.join("test_tool", "src", "test_tool", "tool.py")) + with open(os.path.join("test_tool", "pyproject.toml"), "r") as f: + content = f.read() + assert '"crewai[tools]>=1.15.0,<2.0.0"' in content + with open(os.path.join("test_tool", "src", "test_tool", "tool.py"), "r") as f: content = f.read() assert "class TestTool" in content From e10c17fcf6d8d7eb516db06492cc179bd2259ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Fri, 26 Jun 2026 14:34:07 -0300 Subject: [PATCH 4/7] Open deployment page after CLI deploy (#6343) * Open deployment page after CLI deploy * Format deploy browser URL helper * Handle browser launch failures * Prefer nested deployment identifiers --- lib/cli/src/crewai_cli/deploy/main.py | 62 +++++++++++++++++++ lib/cli/tests/deploy/test_deploy_main.py | 77 +++++++++++++++++++++++- 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/lib/cli/src/crewai_cli/deploy/main.py b/lib/cli/src/crewai_cli/deploy/main.py index 1049752f0..085c9e1dc 100644 --- a/lib/cli/src/crewai_cli/deploy/main.py +++ b/lib/cli/src/crewai_cli/deploy/main.py @@ -1,12 +1,15 @@ from pathlib import Path import subprocess from typing import Any +from urllib.parse import quote +import webbrowser from crewai_core.plus_api import CreateCrewPayload from rich.console import Console from crewai_cli import git from crewai_cli.command import BaseCommand, PlusAPIMixin +from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL from crewai_cli.deploy.archive import create_project_zip from crewai_cli.deploy.validate import DeployValidator, Severity, render_report from crewai_cli.utils import fetch_and_json_env_file, get_project_name @@ -14,6 +17,8 @@ from crewai_cli.utils import fetch_and_json_env_file, get_project_name console = Console() _MISSING_LOCKFILE_ERROR_CODES = {"missing_lockfile"} +_DEPLOYMENT_ID_KEYS = ("deployment_id", "deploymentId") +_DEPLOYMENT_FALLBACK_IDENTIFIER_KEYS = ("id", "uuid") def _run_predeploy_validation( @@ -79,6 +84,39 @@ def _env_summary(env_vars: dict[str, str]) -> str: return f"{len(env_vars)} env vars: {keys}" +def _deployment_identifier(json_response: dict[str, Any]) -> str | None: + """Return the best available identifier for a deployment show URL.""" + deployment = json_response.get("deployment") + + for key in _DEPLOYMENT_ID_KEYS: + value = json_response.get(key) + if value: + return str(value) + + if isinstance(deployment, dict): + for key in _DEPLOYMENT_ID_KEYS + _DEPLOYMENT_FALLBACK_IDENTIFIER_KEYS: + value = deployment.get(key) + if value: + return str(value) + + for key in _DEPLOYMENT_FALLBACK_IDENTIFIER_KEYS: + value = json_response.get(key) + if value: + return str(value) + + return None + + +def _deployment_page_url(base_url: str, json_response: dict[str, Any]) -> str | None: + """Build the CrewAI deployment show URL for a response payload.""" + identifier = _deployment_identifier(json_response) + if not identifier: + return None + return ( + f"{base_url.rstrip('/')}/crewai_plus/deployments/{quote(identifier, safe='')}" + ) + + def _needs_lockfile_for_deploy(project_root: Path | None = None) -> bool: """Return True when deploy should create the project's first lockfile.""" root = project_root or Path.cwd() @@ -165,6 +203,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin): console.print("crewai deploy status") console.print(" or") console.print(f'crewai deploy status --uuid "{json_response["uuid"]}"') + self._open_deployment_page(json_response) def _display_logs(self, log_messages: list[dict[str, Any]]) -> None: """ @@ -178,6 +217,28 @@ class DeployCommand(BaseCommand, PlusAPIMixin): f"{log_message['timestamp']} - {log_message['level']}: {log_message['message']}" ) + def _open_deployment_page(self, json_response: dict[str, Any]) -> None: + """Open the deployment show page in the user's browser when possible.""" + base_url = str( + getattr(self.plus_api_client, "base_url", None) + or DEFAULT_CREWAI_ENTERPRISE_URL + ) + deployment_url = _deployment_page_url(base_url, json_response) + if not deployment_url: + return + + console.print(f"\nOpening deployment page: [blue]{deployment_url}[/blue]") + try: + opened = webbrowser.open(deployment_url) + except Exception: + opened = False + + if not opened: + console.print( + "Could not open the deployment page automatically.", + style="yellow", + ) + def deploy(self, uuid: str | None = None, skip_validate: bool = False) -> None: """ Deploy a crew using either UUID or project name. @@ -438,6 +499,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin): console.print("crewai deploy push") console.print(" or") console.print(f"crewai deploy push --uuid {json_response['uuid']}") + self._open_deployment_page(json_response) def list_crews(self) -> None: """ diff --git a/lib/cli/tests/deploy/test_deploy_main.py b/lib/cli/tests/deploy/test_deploy_main.py index 7d91c77f3..06951f0fe 100644 --- a/lib/cli/tests/deploy/test_deploy_main.py +++ b/lib/cli/tests/deploy/test_deploy_main.py @@ -167,6 +167,36 @@ def test_prepare_project_for_deploy_creates_missing_lock_after_validation( assert validators == [] +def test_deployment_page_url_prefers_deployment_id(): + assert ( + deploy_main._deployment_page_url( + "https://app.crewai.com", + {"uuid": "crew-uuid", "deployment_id": 128687}, + ) + == "https://app.crewai.com/crewai_plus/deployments/128687" + ) + + +def test_deployment_page_url_prefers_nested_deployment_id_over_crew_uuid(): + assert ( + deploy_main._deployment_page_url( + "https://app.crewai.com", + {"uuid": "crew-uuid", "deployment": {"deployment_id": 128687}}, + ) + == "https://app.crewai.com/crewai_plus/deployments/128687" + ) + + +def test_deployment_page_url_falls_back_to_nested_uuid(): + assert ( + deploy_main._deployment_page_url( + "https://app.crewai.com/", + {"deployment": {"uuid": "deployment-uuid"}}, + ) + == "https://app.crewai.com/crewai_plus/deployments/deployment-uuid" + ) + + class TestDeployCommand(unittest.TestCase): @patch("crewai_cli.command.get_auth_token") @patch("crewai_cli.deploy.main.get_project_name") @@ -186,6 +216,12 @@ class TestDeployCommand(unittest.TestCase): self.deploy_command = deploy_main.DeployCommand() self.mock_client = self.deploy_command.plus_api_client + self.mock_client.base_url = "https://app.crewai.com" + self.mock_browser_open_patcher = patch( + "crewai_cli.deploy.main.webbrowser.open" + ) + self.mock_browser_open = self.mock_browser_open_patcher.start() + self.addCleanup(self.mock_browser_open_patcher.stop) def test_init_success(self): self.assertEqual(self.deploy_command.project_name, "test_project") @@ -272,11 +308,50 @@ class TestDeployCommand(unittest.TestCase): def test_display_deployment_info(self): with patch("sys.stdout", new=StringIO()) as fake_out: self.deploy_command._display_deployment_info( - {"uuid": "test-uuid", "status": "deployed"} + {"uuid": "test-uuid", "id": 128687, "status": "deployed"} ) self.assertIn("Deploying the crew...", fake_out.getvalue()) self.assertIn("test-uuid", fake_out.getvalue()) self.assertIn("deployed", fake_out.getvalue()) + self.assertIn( + "https://app.crewai.com/crewai_plus/deployments/128687", + fake_out.getvalue(), + ) + self.mock_browser_open.assert_called_once_with( + "https://app.crewai.com/crewai_plus/deployments/128687" + ) + + def test_display_deployment_info_warns_when_browser_open_returns_false(self): + self.mock_browser_open.return_value = False + + with patch("sys.stdout", new=StringIO()) as fake_out: + self.deploy_command._display_deployment_info( + {"uuid": "test-uuid", "id": 128687, "status": "deployed"} + ) + self.assertIn( + "Could not open the deployment page automatically.", + fake_out.getvalue(), + ) + + self.mock_browser_open.assert_called_once_with( + "https://app.crewai.com/crewai_plus/deployments/128687" + ) + + def test_display_deployment_info_warns_when_browser_open_raises(self): + self.mock_browser_open.side_effect = RuntimeError("no browser") + + with patch("sys.stdout", new=StringIO()) as fake_out: + self.deploy_command._display_deployment_info( + {"uuid": "test-uuid", "id": 128687, "status": "deployed"} + ) + self.assertIn( + "Could not open the deployment page automatically.", + fake_out.getvalue(), + ) + + self.mock_browser_open.assert_called_once_with( + "https://app.crewai.com/crewai_plus/deployments/128687" + ) def test_display_logs(self): with patch("sys.stdout", new=StringIO()) as fake_out: From 596150188b5f8f188f80fa94bba290bf17fe2c76 Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Fri, 26 Jun 2026 12:07:03 -0700 Subject: [PATCH 5/7] Require explicit CrewAI project definitions (#6358) * Require explicit CrewAI project definitions JSON crews and declarative flows now resolve from `[tool.crewai]` metadata instead of implicit filename discovery. This makes project type selection deterministic, prevents stray `crew.json(c)` files from changing CLI behavior, and centralizes definition path validation for run, install, deploy validation, plotting, and memory reset paths. `[tool.crewai].definition` must be a project-local file path. Absolute paths, `~`, missing files, directories, and paths escaping the project root are rejected so deploy and runtime commands use the same contract. Breaking changes and migration paths: * JSON crew projects are no longer discovered from `crew.json` or `crew.jsonc` alone. Add explicit metadata: ```toml [tool.crewai] type = "crew" definition = "crew.jsonc" ``` * Declarative flow projects must use a valid project-local definition path: ```toml [tool.crewai] type = "flow" definition = "flows/research.yaml" ``` * `Flow.from_definition(definition)` is removed. Use: ```python Flow.from_declaration(contents=definition) ``` * `FlowDefinition.to_json()` and `FlowDefinition.to_yaml()` are removed. Use `FlowDefinition.to_dict()` and serialize with the caller's JSON or YAML library. * `FlowDefinition.from_dict()` is removed. Use: ```python FlowDefinition.from_declaration(contents=data) ``` * `FlowDefinition.json_schema()` is removed. Use Pydantic's schema API only where schema generation is intentionally needed: ```python FlowDefinition.model_json_schema(by_alias=True) ``` * `crewai_cli.run_crew.find_crew_json_file()` and `_has_json_crew()` are removed. Use `configured_project_json_crew()` or the shared `crewai_core.project.configured_project_definition("crew")` helper. * `crewai reset-memories` now only loads JSON crews declared through `[tool.crewai].definition`, and invalid declared JSON crew definitions fail instead of silently falling back to classic crew discovery. * Address code review comments --- lib/cli/src/crewai_cli/create_json_crew.py | 1 + lib/cli/src/crewai_cli/deploy/validate.py | 64 ++-- lib/cli/src/crewai_cli/install_crew.py | 52 ++-- lib/cli/src/crewai_cli/run_crew.py | 116 ++++---- .../src/crewai_cli/run_declarative_flow.py | 81 +---- lib/cli/tests/deploy/test_archive.py | 3 + lib/cli/tests/deploy/test_validate.py | 43 ++- lib/cli/tests/test_create_crew.py | 4 + lib/cli/tests/test_install_crew.py | 2 + lib/cli/tests/test_run_crew.py | 84 ++++-- lib/crewai-core/src/crewai_core/project.py | 116 +++++++- lib/crewai-core/tests/test_smoke.py | 78 +++++ lib/crewai/src/crewai/flow/flow_definition.py | 40 +-- .../src/crewai/flow/runtime/__init__.py | 15 +- .../src/crewai/utilities/reset_memories.py | 35 +-- lib/crewai/tests/cli/test_cli.py | 18 +- lib/crewai/tests/test_flow_definition.py | 160 +++++++--- lib/crewai/tests/test_flow_from_definition.py | 276 +++++++++--------- lib/crewai/tests/test_flow_visualization.py | 6 +- 19 files changed, 723 insertions(+), 471 deletions(-) diff --git a/lib/cli/src/crewai_cli/create_json_crew.py b/lib/cli/src/crewai_cli/create_json_crew.py index ffc4090c2..f1e133637 100644 --- a/lib/cli/src/crewai_cli/create_json_crew.py +++ b/lib/cli/src/crewai_cli/create_json_crew.py @@ -102,6 +102,7 @@ only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"] [tool.crewai] type = "crew" +definition = "crew.jsonc" """ _GITIGNORE = """\ diff --git a/lib/cli/src/crewai_cli/deploy/validate.py b/lib/cli/src/crewai_cli/deploy/validate.py index b76303d56..0b2564d24 100644 --- a/lib/cli/src/crewai_cli/deploy/validate.py +++ b/lib/cli/src/crewai_cli/deploy/validate.py @@ -40,14 +40,18 @@ from typing import Any from crewai.project.json_loader import ( JSONProjectValidationError, - find_crew_json_file, find_json_project_file, validate_crew_project, ) +from crewai_core.project import ( + ProjectDefinitionError, + configured_project_definition, + get_crewai_project_config, + get_crewai_project_type, + read_toml, +) from rich.console import Console -from crewai_cli.utils import parse_toml - console = Console() logger = logging.getLogger(__name__) @@ -159,24 +163,16 @@ class DeployValidator: @property def _is_json_crew(self) -> bool: - """True for JSON crew projects, deferring to the declared type. - - A flow project that also contains a crew.json(c) file validates as - the flow it declares in pyproject.toml, not as a JSON crew. - """ - if find_crew_json_file(self.project_root) is None: - return False + """True for JSON crew projects with configured crew definitions.""" pyproject_path = self.project_root / "pyproject.toml" if not pyproject_path.exists(): - return True + return False try: - data = parse_toml(pyproject_path.read_text()) + data = read_toml(pyproject_path) except Exception: - return True - declared_type: str | None = ( - (data.get("tool") or {}).get("crewai", {}).get("type") - ) - return declared_type != "flow" + return False + crewai_config = get_crewai_project_config(data) + return crewai_config.get("type") == "crew" and "definition" in crewai_config def run(self) -> list[ValidationResult]: """Run all checks. Later checks are skipped when earlier ones make @@ -208,14 +204,32 @@ class DeployValidator: def _run_json_checks(self) -> list[ValidationResult]: """Validation suite for JSON-defined crew projects.""" - crew_path = find_crew_json_file(self.project_root) + self._check_pyproject() + self._check_lockfile() + + try: + crew_path = configured_project_definition( + "crew", + pyproject_data=self._pyproject, + project_root=self.project_root, + ) + except ProjectDefinitionError as exc: + self._add( + Severity.ERROR, + "invalid_crew_definition", + "[tool.crewai] definition is invalid", + detail=str(exc), + hint=( + "Set `[tool.crewai] definition` to a project-local JSON " + "or JSONC crew file." + ), + ) + return self.results + if crew_path is None: return self.results - agents_dir = self.project_root / "agents" - - self._check_pyproject() - self._check_lockfile() + agents_dir = crew_path.parent / "agents" agents_dir_ok = self._check_json_agents_dir(agents_dir) project = None @@ -346,7 +360,7 @@ class DeployValidator: return False try: - self._pyproject = parse_toml(pyproject_path.read_text()) + self._pyproject = read_toml(pyproject_path) except Exception as e: self._add( Severity.ERROR, @@ -374,9 +388,7 @@ class DeployValidator: self._project_name = name self._package_name = normalize_package_name(name) - self._is_flow = (self._pyproject.get("tool") or {}).get("crewai", {}).get( - "type" - ) == "flow" + self._is_flow = get_crewai_project_type(self._pyproject) == "flow" return True def _check_lockfile(self) -> None: diff --git a/lib/cli/src/crewai_cli/install_crew.py b/lib/cli/src/crewai_cli/install_crew.py index 75f4a35bd..e1944225f 100644 --- a/lib/cli/src/crewai_cli/install_crew.py +++ b/lib/cli/src/crewai_cli/install_crew.py @@ -2,53 +2,35 @@ from pathlib import Path import subprocess import click +from crewai_core.project import configured_project_definition, read_toml from crewai_cli.deploy.validate import normalize_package_name -from crewai_cli.utils import build_env_with_all_tool_credentials, parse_toml - - -def _find_json_crew_file(project_root: Path | None = None) -> Path | None: - """Return the JSON crew definition path when present.""" - root = project_root or Path.cwd() - for filename in ("crew.jsonc", "crew.json"): - crew_path = root / filename - if crew_path.is_file(): - return crew_path - return None +from crewai_cli.utils import build_env_with_all_tool_credentials def _is_json_crew_project(project_root: Path | None = None) -> bool: """Return True for JSON crew projects that do not need package install.""" root = project_root or Path.cwd() - if _find_json_crew_file(root) is None: - return False - pyproject_path = root / "pyproject.toml" if not pyproject_path.is_file(): - return True + return False - try: - pyproject = parse_toml(pyproject_path.read_text()) - except Exception: - return True - if not isinstance(pyproject, dict): - return True + pyproject = read_toml(pyproject_path) - tool_config = pyproject.get("tool") or {} - crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None - declared_type = ( - crewai_config.get("type") if isinstance(crewai_config, dict) else None - ) - project_config = pyproject.get("project") or {} - project_name = ( - project_config.get("name") if isinstance(project_config, dict) else None - ) - if isinstance(project_name, str): - package_name = normalize_package_name(project_name) - if package_name and (root / "src" / package_name / "crew.py").is_file(): - return False + if ( + configured_project_definition( + "crew", pyproject_data=pyproject, project_root=root + ) + is None + ): + return False - return declared_type != "flow" + project_name = pyproject.get("project", {}).get("name", "") + package_name = normalize_package_name(project_name) + if package_name and (root / "src" / package_name / "crew.py").is_file(): + return False + + return True # Be mindful about changing this. diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index 281f6270b..7601e2d76 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -1,23 +1,27 @@ from __future__ import annotations -from collections.abc import Callable from contextlib import AbstractContextManager, nullcontext import os from pathlib import Path import re import subprocess import sys -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import click from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV +from crewai_core.project import ( + ProjectDefinitionError, + configured_project_definition, + get_crewai_project_type, + read_toml, +) from packaging import version from crewai_cli.utils import ( build_env_with_all_tool_credentials, enable_prompt_line_editing, is_dmn_mode_enabled, - read_toml, ) from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version @@ -32,6 +36,7 @@ if TYPE_CHECKING: _INPUT_PLACEHOLDER_RE = re.compile(r"(? Callable[[], Path | None]: - from crewai.project.json_loader import find_crew_json_file as _find_crew_json_file - - return cast("Callable[[], Path | None]", _find_crew_json_file) - - def _is_missing_crewai_package(exc: ModuleNotFoundError) -> bool: return bool(exc.name and exc.name.startswith("crewai")) @@ -99,32 +102,23 @@ def _full_crewai_install_error() -> click.ClickException: return click.ClickException(_FULL_CREWAI_INSTALL_MESSAGE) -def find_crew_json_file() -> Path | None: +def configured_project_json_crew( + pyproject_data: dict[str, Any] | None = None, + project_root: Path | None = None, +) -> Path | None: + """Return the configured JSON crew definition for crew projects.""" + root = project_root or Path.cwd() + if pyproject_data is None and not (root / "pyproject.toml").is_file(): + return None + try: - return _import_find_crew_json_file()() - except ModuleNotFoundError as exc: - if _is_missing_crewai_package(exc): - raise _full_crewai_install_error() from exc - raise - - -def _has_json_crew() -> bool: - """Check if this is a JSON-defined crew project. - - The project type declared in pyproject.toml wins: a flow project that - happens to contain a crew.json(c) file still runs as a flow. A missing - or unreadable pyproject means a bare JSON crew project. - """ - if find_crew_json_file() is None: - return False - try: - pyproject_data = read_toml() - except Exception: - return True - declared_type: str | None = ( - pyproject_data.get("tool", {}).get("crewai", {}).get("type") - ) - return declared_type != "flow" + return configured_project_definition( + "crew", + pyproject_data=pyproject_data, + project_root=root, + ) + except ProjectDefinitionError as exc: + raise click.UsageError(str(exc)) from exc def _extract_input_placeholders(text: str | None) -> set[str]: @@ -199,7 +193,12 @@ def _json_loading_status(message: str) -> AbstractContextManager[Any]: def _load_json_crew(crew_path: Path) -> tuple[Any, dict[str, Any]]: - from crewai.project.crew_loader import load_crew + try: + from crewai.project.crew_loader import load_crew + except ModuleNotFoundError as exc: + if _is_missing_crewai_package(exc): + raise _full_crewai_install_error() from exc + raise return load_crew(crew_path) @@ -262,7 +261,10 @@ def _run_json_crew_without_tui(crew_path: Path) -> Any: return result -def _run_json_crew(trained_agents_file: str | None = None) -> Any: +def _run_json_crew( + trained_agents_file: str | None = None, + crew_path: str | Path | None = None, +) -> Any: """Load and run a JSON-defined crew.""" from dotenv import load_dotenv @@ -275,9 +277,13 @@ def _run_json_crew(trained_agents_file: str | None = None) -> Any: if trained_agents_file: os.environ[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file - crew_path = find_crew_json_file() if crew_path is None: - raise FileNotFoundError("No crew.jsonc or crew.json found") + crew_path = configured_project_json_crew() + if crew_path is None: + raise FileNotFoundError( + "No JSON crew definition configured in [tool.crewai].definition" + ) + crew_path = Path(crew_path) if is_dmn_mode_enabled(): return _run_json_crew_without_tui(crew_path) @@ -391,10 +397,16 @@ def _json_crew_run_command(project_root: Path | None = None) -> list[str]: return ["uv", "run", "--no-sync", "python", "-c", _JSON_CREW_RUNNER_CODE] -def _run_json_crew_in_project_env(trained_agents_file: str | None = None) -> Any: +def _run_json_crew_in_project_env( + trained_agents_file: str | None = None, + crew_path: str | Path | None = None, +) -> Any: """Run JSON crews from the project's uv-managed environment.""" if not (Path.cwd() / "pyproject.toml").is_file(): - return _run_json_crew(trained_agents_file=trained_agents_file) + return _run_json_crew( + trained_agents_file=trained_agents_file, + crew_path=crew_path, + ) _install_json_crew_dependencies_if_needed() @@ -405,6 +417,8 @@ def _run_json_crew_in_project_env(trained_agents_file: str | None = None) -> Any env[_CREWAI_RUNNER_SOURCE_DIR_ENV] = str(local_crewai_source_dir) if trained_agents_file: env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file + if crew_path is not None: + env[_CREWAI_JSON_CREW_DEFINITION_ENV] = str(crew_path) try: subprocess.run( # noqa: S603 @@ -557,13 +571,16 @@ def run_crew( ) return - if _has_json_crew(): - _run_json_crew_in_project_env(trained_agents_file=trained_agents_file) + pyproject_data = read_toml() + if json_crew_definition := configured_project_json_crew(pyproject_data): + _run_json_crew_in_project_env( + trained_agents_file=trained_agents_file, + crew_path=json_crew_definition, + ) return - pyproject_data = read_toml() _warn_if_old_poetry_project(pyproject_data) - project_type = _get_project_type(pyproject_data) + project_type = get_crewai_project_type(pyproject_data) if project_type == "flow": _run_flow_project( @@ -627,11 +644,6 @@ def _run_classic_crew_project( ) -def _get_project_type(pyproject_data: dict[str, Any]) -> str | None: - project_type = pyproject_data.get("tool", {}).get("crewai", {}).get("type") - return project_type if isinstance(project_type, str) else None - - def _warn_if_old_poetry_project(pyproject_data: dict[str, Any]) -> None: crewai_version = get_crewai_version() min_required_version = "0.71.0" diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index ea289d00b..49af0f079 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -1,11 +1,12 @@ from __future__ import annotations import json -from pathlib import Path, PureWindowsPath +from pathlib import Path import subprocess from typing import Any import click +from crewai_core.project import ProjectDefinitionError, configured_project_definition from pydantic import ValidationError from crewai_cli.utils import build_env_with_all_tool_credentials @@ -105,80 +106,18 @@ def configured_project_declarative_flow( project_root: Path | None = None, ) -> Path | None: """Return the configured declarative flow source for flow projects.""" - if pyproject_data is None: - try: - from crewai_cli.utils import read_toml - - pyproject_data = read_toml() - except Exception: - return None - - crewai_config = pyproject_data.get("tool", {}).get("crewai", {}) - if crewai_config.get("type") != "flow": + root = project_root or Path.cwd() + if pyproject_data is None and not (root / "pyproject.toml").is_file(): return None - definition = crewai_config.get("definition") - if not isinstance(definition, str): - return None - definition = definition.strip() - if not definition: - return None - - return _resolve_project_definition_path( - definition=definition, - project_root=project_root or Path.cwd(), - ) - - -def _resolve_project_definition_path(definition: str, project_root: Path) -> Path: - definition_path = Path(definition) - windows_definition_path = PureWindowsPath(definition) - - if definition.startswith("~"): - raise click.UsageError( - "[tool.crewai] definition must be a project-local path; " - f"got {definition!r}." - ) - - if definition_path.is_absolute() or windows_definition_path.is_absolute(): - raise click.UsageError( - "[tool.crewai] definition must be relative to the project root; " - f"got {definition!r}." - ) try: - root = project_root.resolve(strict=True) - except OSError as exc: - raise click.UsageError( - f"Invalid project root for [tool.crewai] definition: {exc}" - ) from exc - - candidate = root / definition_path - try: - resolved_candidate = candidate.resolve(strict=False) - except OSError as exc: - raise click.UsageError( - f"Invalid [tool.crewai] definition path {definition!r}: {exc}" - ) from exc - - if not resolved_candidate.is_relative_to(root): - raise click.UsageError( - "[tool.crewai] definition must resolve inside the project root; " - f"got {definition!r}." + return configured_project_definition( + "flow", + pyproject_data=pyproject_data, + project_root=root, ) - - if not resolved_candidate.exists(): - raise click.UsageError( - "[tool.crewai] definition must point to an existing file; " - f"got {definition!r}." - ) - - if not resolved_candidate.is_file(): - raise click.UsageError( - "[tool.crewai] definition must point to a regular file; " - f"got {definition!r}." - ) - - return resolved_candidate + except ProjectDefinitionError as exc: + raise click.UsageError(str(exc)) from exc def _execute_declarative_flow_command(command: list[str]) -> None: diff --git a/lib/cli/tests/deploy/test_archive.py b/lib/cli/tests/deploy/test_archive.py index 56344dba2..2dd2336c6 100644 --- a/lib/cli/tests/deploy/test_archive.py +++ b/lib/cli/tests/deploy/test_archive.py @@ -146,6 +146,7 @@ build-backend = "hatchling.build" [tool.crewai] type = "crew" +definition = "crew.jsonc" """.strip() + "\n" ) @@ -180,6 +181,7 @@ dependencies = ["crewai[tools]>=1.15.0,<2.0.0"] [tool.crewai] type = "crew" +definition = "crew.jsonc" """.strip() + "\n" ) @@ -221,6 +223,7 @@ custom = "custom.module:main" [tool.crewai] type = "crew" +definition = "crew.jsonc" """.strip() + "\n" ) diff --git a/lib/cli/tests/deploy/test_validate.py b/lib/cli/tests/deploy/test_validate.py index 1176fbbdf..31ecb0c8b 100644 --- a/lib/cli/tests/deploy/test_validate.py +++ b/lib/cli/tests/deploy/test_validate.py @@ -111,7 +111,12 @@ def _run_without_import_check(root: Path) -> DeployValidator: def _scaffold_json_crew(root: Path, *, task_agent: str = "researcher") -> None: - (root / "pyproject.toml").write_text(_make_pyproject(name="json_crew")) + (root / "pyproject.toml").write_text( + _make_pyproject( + name="json_crew", + extra='[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"', + ) + ) (root / "uv.lock").write_text("# dummy uv lockfile\n") agents_dir = root / "agents" agents_dir.mkdir() @@ -221,7 +226,6 @@ def test_json_crew_reports_project_metadata_before_invalid_json( tmp_path: Path, ) -> None: _scaffold_json_crew(tmp_path) - (tmp_path / "pyproject.toml").unlink() (tmp_path / "uv.lock").unlink() (tmp_path / "crew.jsonc").write_text('{"agents": ["researcher"], "tasks": []}\n') @@ -229,7 +233,6 @@ def test_json_crew_reports_project_metadata_before_invalid_json( v.run() codes = _codes(v) - assert "missing_pyproject" in codes assert "missing_lockfile" in codes assert "invalid_crew_json" in codes assert "missing_src_dir" not in codes @@ -546,17 +549,43 @@ def test_is_json_crew_defers_to_declared_flow_type(tmp_path): assert DeployValidator(project_root=tmp_path)._is_json_crew is False -def test_is_json_crew_true_for_declared_crew_type(tmp_path): +def test_is_json_crew_true_for_declared_crew_definition(tmp_path): + (tmp_path / "crew.jsonc").write_text("{}") + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\nversion = "0.1.0"\n\n' + '[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"\n' + ) + + assert DeployValidator(project_root=tmp_path)._is_json_crew is True + + +def test_is_json_crew_false_for_declared_crew_without_definition(tmp_path): (tmp_path / "crew.jsonc").write_text("{}") (tmp_path / "pyproject.toml").write_text( '[project]\nname = "demo"\nversion = "0.1.0"\n\n' '[tool.crewai]\ntype = "crew"\n' ) - assert DeployValidator(project_root=tmp_path)._is_json_crew is True + assert DeployValidator(project_root=tmp_path)._is_json_crew is False -def test_is_json_crew_true_without_pyproject(tmp_path): +def test_json_crew_non_string_definition_reports_invalid_definition( + tmp_path: Path, +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\nversion = "0.1.0"\n\n' + '[tool.crewai]\ntype = "crew"\ndefinition = ["crew.jsonc"]\n' + ) + + v = DeployValidator(project_root=tmp_path) + v.run() + + finding = next(r for r in v.results if r.code == "invalid_crew_definition") + assert finding.severity is Severity.ERROR + assert "must be a string" in finding.detail + + +def test_is_json_crew_false_without_pyproject(tmp_path): (tmp_path / "crew.jsonc").write_text("{}") - assert DeployValidator(project_root=tmp_path)._is_json_crew is True + assert DeployValidator(project_root=tmp_path)._is_json_crew is False diff --git a/lib/cli/tests/test_create_crew.py b/lib/cli/tests/test_create_crew.py index 803dd5948..61311ef4d 100644 --- a/lib/cli/tests/test_create_crew.py +++ b/lib/cli/tests/test_create_crew.py @@ -741,6 +741,10 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch): assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][ "only-include" ] == ["agents", "crew.jsonc", "tools", "knowledge", "skills"] + assert pyproject["tool"]["crewai"] == { + "type": "crew", + "definition": "crew.jsonc", + } crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text() assert ( diff --git a/lib/cli/tests/test_install_crew.py b/lib/cli/tests/test_install_crew.py index 6d61f4598..cd28a37c4 100644 --- a/lib/cli/tests/test_install_crew.py +++ b/lib/cli/tests/test_install_crew.py @@ -26,6 +26,7 @@ name = "json_crew" [tool.crewai] type = "crew" +definition = "crew.jsonc" """.strip() ) (tmp_path / "crew.jsonc").write_text("{}\n") @@ -45,6 +46,7 @@ name = "hybrid-crew" [tool.crewai] type = "crew" +definition = "crew.jsonc" """.strip() ) (tmp_path / "crew.jsonc").write_text("{}\n") diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index 5b2e846b3..2d0a23a6d 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -16,12 +16,17 @@ def test_missing_crewai_package_shows_full_install_hint(monkeypatch): def missing_crewai_package(): raise ModuleNotFoundError("No module named 'crewai'", name="crewai") - monkeypatch.setattr( - run_crew_module, "_import_find_crew_json_file", missing_crewai_package - ) + real_import = __import__ + + def fake_import(name, *args, **kwargs): + if name == "crewai.project.crew_loader": + missing_crewai_package() + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fake_import) with pytest.raises(click.ClickException) as exc_info: - run_crew_module.find_crew_json_file() + run_crew_module._load_json_crew(Path("crew.jsonc")) message = exc_info.value.message assert "CrewAI CLI is installed without the `crewai` package" in message @@ -31,11 +36,17 @@ def test_missing_crewai_package_shows_full_install_hint(monkeypatch): def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch): """crewai run -f must reach JSON crews, not only classic subprocess crews.""" - monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True) + monkeypatch.setattr(run_crew_module, "read_toml", lambda: {}) + monkeypatch.setattr( + run_crew_module, + "configured_project_json_crew", + lambda pyproject_data=None, project_root=None: Path("crew.jsonc"), + ) called: dict = {} - def fake_run_json_crew_in_project_env(trained_agents_file=None): + def fake_run_json_crew_in_project_env(trained_agents_file=None, crew_path=None): called["trained_agents_file"] = trained_agents_file + called["crew_path"] = crew_path monkeypatch.setattr( run_crew_module, @@ -45,7 +56,10 @@ def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch): run_crew_module.run_crew(trained_agents_file="some.pkl") - assert called == {"trained_agents_file": "some.pkl"} + assert called == { + "trained_agents_file": "some.pkl", + "crew_path": Path("crew.jsonc"), + } def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path: Path): @@ -71,8 +85,10 @@ def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path: monkeypatch.setattr(run_crew_module.subprocess, "run", fake_subprocess_run) + crew_path = tmp_path / "crew.jsonc" run_crew_module._run_json_crew_in_project_env( - trained_agents_file="trained.pkl" + trained_agents_file="trained.pkl", + crew_path=crew_path, ) expected_env = { @@ -81,6 +97,7 @@ def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path: Path(run_crew_module.__file__).resolve().parent ), CREWAI_TRAINED_AGENTS_FILE_ENV: "trained.pkl", + run_crew_module._CREWAI_JSON_CREW_DEFINITION_ENV: str(crew_path), } if local_crewai_source_dir := run_crew_module._find_local_crewai_source_dir(): expected_env[run_crew_module._CREWAI_RUNNER_SOURCE_DIR_ENV] = str( @@ -214,8 +231,9 @@ def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path) monkeypatch.chdir(tmp_path) called: dict = {} - def fake_run_json_crew(trained_agents_file=None): + def fake_run_json_crew(trained_agents_file=None, crew_path=None): called["trained_agents_file"] = trained_agents_file + called["crew_path"] = crew_path return "result" monkeypatch.setattr(run_crew_module, "_run_json_crew", fake_run_json_crew) @@ -226,7 +244,7 @@ def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path) ) == "result" ) - assert called == {"trained_agents_file": "trained.pkl"} + assert called == {"trained_agents_file": "trained.pkl", "crew_path": None} def test_json_project_env_run_failure_exits_nonzero(monkeypatch, tmp_path: Path): @@ -435,7 +453,7 @@ def _patch_tui_run(monkeypatch, status: str): crew = SimpleNamespace(name="Demo", tasks=[], agents=[]) monkeypatch.setattr( - run_crew_module, "find_crew_json_file", lambda: Path("crew.jsonc") + run_crew_module, "configured_project_json_crew", lambda: Path("crew.jsonc") ) monkeypatch.setattr( run_crew_module, @@ -489,7 +507,9 @@ def test_run_json_crew_dmn_mode_bypasses_tui(monkeypatch, tmp_path: Path, capsys kickoff_calls.append(inputs) return "plain result" - monkeypatch.setattr(run_crew_module, "find_crew_json_file", lambda: crew_path) + monkeypatch.setattr( + run_crew_module, "configured_project_json_crew", lambda: crew_path + ) monkeypatch.setattr( run_crew_module, "_load_json_crew", @@ -528,7 +548,9 @@ def test_run_json_crew_dmn_mode_exits_on_missing_inputs( tasks=[], ) - monkeypatch.setattr(run_crew_module, "find_crew_json_file", lambda: crew_path) + monkeypatch.setattr( + run_crew_module, "configured_project_json_crew", lambda: crew_path + ) monkeypatch.setattr( run_crew_module, "_load_json_crew", @@ -543,28 +565,47 @@ def test_run_json_crew_dmn_mode_exits_on_missing_inputs( assert "Missing runtime inputs for CREWAI_DMN mode: topic" in captured.err -def test_has_json_crew_defers_to_declared_flow_type(monkeypatch, tmp_path: Path): +def test_configured_project_json_crew_defers_to_declared_flow_type( + monkeypatch, tmp_path: Path +): """A flow project containing a stray crew.jsonc must still run as a flow.""" monkeypatch.chdir(tmp_path) (tmp_path / "crew.jsonc").write_text("{}") (tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "flow"\n') - assert run_crew_module._has_json_crew() is False + assert run_crew_module.configured_project_json_crew() is None -def test_has_json_crew_true_for_declared_crew_type(monkeypatch, tmp_path: Path): +def test_configured_project_json_crew_returns_declared_crew_definition( + monkeypatch, tmp_path: Path +): + monkeypatch.chdir(tmp_path) + crew_path = tmp_path / "crew.jsonc" + crew_path.write_text("{}") + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"\n' + ) + + assert run_crew_module.configured_project_json_crew() == crew_path.resolve() + + +def test_configured_project_json_crew_ignores_declared_crew_without_definition( + monkeypatch, tmp_path: Path +): monkeypatch.chdir(tmp_path) (tmp_path / "crew.jsonc").write_text("{}") (tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "crew"\n') - assert run_crew_module._has_json_crew() is True + assert run_crew_module.configured_project_json_crew() is None -def test_has_json_crew_true_without_pyproject(monkeypatch, tmp_path: Path): +def test_configured_project_json_crew_ignores_missing_pyproject( + monkeypatch, tmp_path: Path +): monkeypatch.chdir(tmp_path) (tmp_path / "crew.jsonc").write_text("{}") - assert run_crew_module._has_json_crew() is True + assert run_crew_module.configured_project_json_crew() is None def test_run_crew_rejects_inputs_without_definition(): @@ -605,7 +646,6 @@ def test_run_crew_runs_explicit_declarative_definition(monkeypatch, capsys): def test_run_crew_runs_classic_crew_project(monkeypatch, capsys): calls = [] - monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) monkeypatch.setattr( run_crew_module, "read_toml", @@ -631,7 +671,6 @@ def test_run_crew_runs_classic_crew_project(monkeypatch, capsys): def test_run_crew_runs_python_flow_project(monkeypatch, capsys): calls = [] - monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) monkeypatch.setattr( run_crew_module, "read_toml", @@ -660,7 +699,6 @@ def test_run_crew_runs_conversational_flow_tui(monkeypatch, capsys): flow = Flow() calls = [] - monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) monkeypatch.setattr( run_crew_module, "read_toml", @@ -689,7 +727,6 @@ def test_run_crew_runs_conversational_flow_tui(monkeypatch, capsys): def test_run_crew_rejects_filename_for_flow_project(monkeypatch): - monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) monkeypatch.setattr( run_crew_module, "read_toml", @@ -710,7 +747,6 @@ def test_run_crew_runs_configured_declarative_flow_project( monkeypatch.chdir(tmp_path) definition_path = tmp_path / "flow.yaml" definition_path.write_text("schema: crewai.flow/v1\n", encoding="utf-8") - monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) monkeypatch.setattr( run_crew_module, "read_toml", diff --git a/lib/crewai-core/src/crewai_core/project.py b/lib/crewai-core/src/crewai_core/project.py index 29d322304..9c7e6a33e 100644 --- a/lib/crewai-core/src/crewai_core/project.py +++ b/lib/crewai-core/src/crewai_core/project.py @@ -3,6 +3,7 @@ from __future__ import annotations from functools import reduce +from pathlib import Path, PureWindowsPath import sys from typing import Any @@ -16,7 +17,11 @@ if sys.version_info >= (3, 11): console = Console() -def read_toml(file_path: str = "pyproject.toml") -> dict[str, Any]: +class ProjectDefinitionError(ValueError): + """Invalid ``[tool.crewai].definition`` project configuration.""" + + +def read_toml(file_path: str | Path = "pyproject.toml") -> dict[str, Any]: """Read a TOML file from disk and return its parsed contents.""" with open(file_path, "rb") as f: return tomli.load(f) @@ -29,6 +34,115 @@ def parse_toml(content: str) -> dict[str, Any]: return tomli.loads(content) +def get_crewai_project_config(pyproject_data: dict[str, Any]) -> dict[str, Any]: + """Return the normalized ``[tool.crewai]`` table from pyproject data.""" + tool_config = pyproject_data.get("tool") + if not isinstance(tool_config, dict): + return {} + crewai_config = tool_config.get("crewai") + if not isinstance(crewai_config, dict): + return {} + return crewai_config + + +def get_crewai_project_type(pyproject_data: dict[str, Any]) -> str | None: + """Return ``[tool.crewai].type`` when configured.""" + project_type = get_crewai_project_config(pyproject_data).get("type") + return project_type if isinstance(project_type, str) else None + + +def configured_project_definition( + project_type: str, + *, + pyproject_data: dict[str, Any] | None = None, + project_root: Path | str | None = None, +) -> Path | None: + """Return a configured CrewAI definition path for a project type. + + ``[tool.crewai].type`` must match ``project_type`` and ``definition`` must + be a non-empty project-local file path. Missing definitions return ``None`` + so callers can fall back to legacy entrypoints for that project type. + """ + root = Path(project_root) if project_root is not None else Path.cwd() + if pyproject_data is None: + pyproject_data = read_toml(root / "pyproject.toml") + + crewai_config = get_crewai_project_config(pyproject_data) + if crewai_config.get("type") != project_type: + return None + + if "definition" not in crewai_config: + return None + raw_definition = crewai_config["definition"] + if not isinstance(raw_definition, str): + raise ProjectDefinitionError( + "[tool.crewai] definition must be a string project-local path; " + f"got {raw_definition!r}." + ) + + definition = raw_definition.strip() + if not definition: + raise ProjectDefinitionError( + "[tool.crewai] definition must be a non-empty project-local path." + ) + + return resolve_project_definition_path(definition=definition, project_root=root) + + +def resolve_project_definition_path(definition: str, project_root: Path | str) -> Path: + """Resolve a ``[tool.crewai].definition`` path inside ``project_root``.""" + root_path = Path(project_root) + definition_path = Path(definition) + windows_definition_path = PureWindowsPath(definition) + + if definition.startswith("~"): + raise ProjectDefinitionError( + "[tool.crewai] definition must be a project-local path; " + f"got {definition!r}." + ) + + if definition_path.is_absolute() or windows_definition_path.is_absolute(): + raise ProjectDefinitionError( + "[tool.crewai] definition must be relative to the project root; " + f"got {definition!r}." + ) + + try: + root = root_path.resolve(strict=True) + except OSError as exc: + raise ProjectDefinitionError( + f"Invalid project root for [tool.crewai] definition: {exc}" + ) from exc + + candidate = root / definition_path + try: + resolved_candidate = candidate.resolve(strict=False) + except OSError as exc: + raise ProjectDefinitionError( + f"Invalid [tool.crewai] definition path {definition!r}: {exc}" + ) from exc + + if not resolved_candidate.is_relative_to(root): + raise ProjectDefinitionError( + "[tool.crewai] definition must resolve inside the project root; " + f"got {definition!r}." + ) + + if not resolved_candidate.exists(): + raise ProjectDefinitionError( + "[tool.crewai] definition must point to an existing file; " + f"got {definition!r}." + ) + + if not resolved_candidate.is_file(): + raise ProjectDefinitionError( + "[tool.crewai] definition must point to a regular file; " + f"got {definition!r}." + ) + + return resolved_candidate + + def _get_nested_value(data: dict[str, Any], keys: list[str]) -> Any: return reduce(dict.__getitem__, keys, data) diff --git a/lib/crewai-core/tests/test_smoke.py b/lib/crewai-core/tests/test_smoke.py index 7980cfd1e..3decdaa73 100644 --- a/lib/crewai-core/tests/test_smoke.py +++ b/lib/crewai-core/tests/test_smoke.py @@ -10,6 +10,7 @@ from crewai_core import ( lock_store, paths, printer, + project, user_data, version, ) @@ -97,6 +98,83 @@ def test_unused_var_warning_silenced() -> None: assert os.environ is not None +def test_configured_project_definition_resolves_project_local_file( + tmp_path: Path, +) -> None: + definition = tmp_path / "crew.jsonc" + definition.write_text("{}\n") + + resolved = project.configured_project_definition( + "crew", + pyproject_data={ + "tool": { + "crewai": { + "type": "crew", + "definition": " crew.jsonc ", + } + } + }, + project_root=tmp_path, + ) + + assert resolved == definition.resolve() + + +def test_configured_project_definition_rejects_project_escape(tmp_path: Path) -> None: + outside = tmp_path.parent / f"{tmp_path.name}-outside-crew.jsonc" + outside.write_text("{}\n") + + with pytest.raises(project.ProjectDefinitionError): + project.configured_project_definition( + "crew", + pyproject_data={ + "tool": { + "crewai": { + "type": "crew", + "definition": "../outside-crew.jsonc", + } + } + }, + project_root=tmp_path, + ) + + +def test_configured_project_definition_rejects_non_string_definition( + tmp_path: Path, +) -> None: + with pytest.raises(project.ProjectDefinitionError, match="must be a string"): + project.configured_project_definition( + "crew", + pyproject_data={ + "tool": { + "crewai": { + "type": "crew", + "definition": ["crew.jsonc"], + } + } + }, + project_root=tmp_path, + ) + + +def test_configured_project_definition_rejects_empty_definition( + tmp_path: Path, +) -> None: + with pytest.raises(project.ProjectDefinitionError, match="non-empty"): + project.configured_project_definition( + "crew", + pyproject_data={ + "tool": { + "crewai": { + "type": "crew", + "definition": " ", + } + } + }, + project_root=tmp_path, + ) + + def test_core_telemetry_skips_duplicate_tracer_provider( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/lib/crewai/src/crewai/flow/flow_definition.py b/lib/crewai/src/crewai/flow/flow_definition.py index fa9c89ea8..5e445becd 100644 --- a/lib/crewai/src/crewai/flow/flow_definition.py +++ b/lib/crewai/src/crewai/flow/flow_definition.py @@ -9,7 +9,6 @@ layer that may have produced it and of the engine that runs it (see from __future__ import annotations -import json import logging from pathlib import Path import re @@ -780,19 +779,6 @@ class FlowDefinition(BaseModel): """Serialize the definition to a declaration-ready dictionary.""" return self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json") - def to_json(self, *, indent: int | None = 2, exclude_none: bool = True) -> str: - """Serialize the definition to JSON.""" - data = self.to_dict(exclude_none=exclude_none) - return json.dumps(data, indent=indent) - - def to_yaml(self, *, exclude_none: bool = True) -> str: - """Serialize the definition to YAML.""" - return yaml.safe_dump( - self.to_dict(exclude_none=exclude_none), - sort_keys=False, - allow_unicode=True, - ) - @property def source_path(self) -> Path | None: """Original definition file path, when loaded from a file.""" @@ -805,17 +791,6 @@ class FlowDefinition(BaseModel): return None return self._source_path.parent - @classmethod - def from_dict( - cls, data: dict[str, Any], *, source_path: Path | None = None - ) -> FlowDefinition: - """Load a definition from a dictionary.""" - definition = cls.model_validate(data) - if source_path is not None: - definition._source_path = source_path.expanduser().resolve() - log_flow_definition_issues(definition) - return definition - @classmethod def from_declaration( cls, @@ -835,7 +810,7 @@ class FlowDefinition(BaseModel): contents = source_path.expanduser().read_text(encoding="utf-8") if isinstance(contents, dict): - return cls.from_dict(contents) + return cls._load_mapping(contents) if not isinstance(contents, str): raise TypeError("Flow declaration contents must be a string or dictionary") @@ -848,12 +823,17 @@ class FlowDefinition(BaseModel): loaded = yaml.safe_load(contents) if not isinstance(loaded, dict): raise ValueError("Flow declaration must contain a mapping") - return cls.from_dict(loaded, source_path=source_path) + return cls._load_mapping(loaded, source_path=source_path) @classmethod - def json_schema(cls) -> dict[str, Any]: - """Return the JSON Schema for the declarative Flow contract.""" - return cls.model_json_schema(by_alias=True) + def _load_mapping( + cls, data: dict[str, Any], *, source_path: Path | None = None + ) -> FlowDefinition: + definition = cls.model_validate(data) + if source_path is not None: + definition._source_path = source_path.expanduser().resolve() + log_flow_definition_issues(definition) + return definition def _validate_step_name(name: str, *, field: str) -> None: diff --git a/lib/crewai/src/crewai/flow/runtime/__init__.py b/lib/crewai/src/crewai/flow/runtime/__init__.py index 8de5be409..b28eb5429 100644 --- a/lib/crewai/src/crewai/flow/runtime/__init__.py +++ b/lib/crewai/src/crewai/flow/runtime/__init__.py @@ -480,11 +480,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): cls._flow_definition = flow_definition return flow_definition - @classmethod - def from_definition(cls, definition: FlowDefinition, **kwargs: Any) -> Flow[Any]: - """Build a runnable Flow directly from a definition; no subclass required.""" - return cls.from_declaration(contents=definition, **kwargs) - @classmethod def from_declaration( cls, @@ -604,7 +599,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): config: Checkpoint configuration with ``restore_from`` set to the path of the checkpoint to load. definition: The FlowDefinition to restore a definition-built flow - (one created via ``Flow.from_definition``) from; its actions + (one created via ``Flow.from_declaration``) from; its actions are re-resolved since checkpoints carry no callables. Subclasses carry their own definition and don't need this. @@ -629,7 +624,9 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): entity._restore_from_checkpoint() return entity instance = ( - cls.from_definition(definition) if definition is not None else cls() + cls.from_declaration(contents=definition) + if definition is not None + else cls() ) instance.checkpoint_completed_methods = entity.checkpoint_completed_methods instance.checkpoint_method_outputs = entity.checkpoint_method_outputs @@ -1178,7 +1175,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): registered factory when present, else the built-in SQLite fallback). definition: The FlowDefinition to restore a definition-built flow - (one created via ``Flow.from_definition``) from. Subclasses + (one created via ``Flow.from_declaration``) from. Subclasses carry their own definition and don't need this. **kwargs: Additional keyword arguments passed to the Flow constructor @@ -1212,7 +1209,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): state_data, pending_context = loaded instance = ( - cls.from_definition(definition, persistence=persistence, **kwargs) + cls.from_declaration(contents=definition, persistence=persistence, **kwargs) if definition is not None else cls(persistence=persistence, **kwargs) ) diff --git a/lib/crewai/src/crewai/utilities/reset_memories.py b/lib/crewai/src/crewai/utilities/reset_memories.py index e9f4d525d..2470454dc 100644 --- a/lib/crewai/src/crewai/utilities/reset_memories.py +++ b/lib/crewai/src/crewai/utilities/reset_memories.py @@ -1,15 +1,16 @@ """Memory reset utilities for CrewAI crews and flows.""" +from pathlib import Path import subprocess from typing import Any import click +from crewai_core.project import configured_project_definition, read_toml from crewai.flow import Flow from crewai.memory.unified_memory import Memory from crewai.project.crew_loader import load_crew -from crewai.project.json_loader import find_crew_json_file -from crewai.utilities.project_utils import get_crews, get_flows, read_toml +from crewai.utilities.project_utils import get_crews, get_flows def _reset_flow_memory(flow: Flow[Any]) -> None: @@ -42,35 +43,20 @@ def _reset_flow_memory(flow: Flow[Any]) -> None: click.echo(f"Memory reset skipped: {exc}", err=True) -def _current_project_declares_flow() -> bool: - try: - pyproject_data = read_toml() - except Exception: - return False - - declared_type: str | None = ( - pyproject_data.get("tool", {}).get("crewai", {}).get("type") - ) - return declared_type == "flow" +def _configured_json_crew_path() -> Path | None: + if not Path("pyproject.toml").is_file(): + return None + pyproject_data = read_toml() + return configured_project_definition("crew", pyproject_data=pyproject_data) def _get_json_crew() -> Any | None: """Load a JSON-first crew from the current project, if present.""" - if _current_project_declares_flow(): - return None - - crew_path = find_crew_json_file() + crew_path = _configured_json_crew_path() if crew_path is None: return None - try: - crew, _ = load_crew(crew_path) - except Exception as exc: - click.echo( - f"Skipping JSON crew at {crew_path}: failed to load ({exc}).", - err=True, - ) - return None + crew, _ = load_crew(crew_path) return crew @@ -151,3 +137,4 @@ def reset_memories_command( except Exception as e: click.echo(f"An unexpected error occurred: {e}", err=True) + raise SystemExit(1) from e diff --git a/lib/crewai/tests/cli/test_cli.py b/lib/crewai/tests/cli/test_cli.py index 8b65c865f..387e3bb3d 100644 --- a/lib/crewai/tests/cli/test_cli.py +++ b/lib/crewai/tests/cli/test_cli.py @@ -240,6 +240,9 @@ def test_reset_no_crew_or_flow_found(runner): def test_reset_json_crew_memory(mock_crew, runner, monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) (tmp_path / "crew.jsonc").write_text("{}") + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"\n' + ) with mock.patch( "crewai.utilities.reset_memories.get_crews", return_value=[] @@ -251,16 +254,19 @@ def test_reset_json_crew_memory(mock_crew, runner, monkeypatch, tmp_path): ) as mock_load_crew: result = runner.invoke(reset_memories, ["-m"]) - mock_load_crew.assert_called_once_with(Path("crew.jsonc")) + mock_load_crew.assert_called_once_with((tmp_path / "crew.jsonc").resolve()) mock_crew.reset_memories.assert_called_once_with(command_type="memory") assert f"[Crew ({mock_crew.name})] Memory has been reset." in result.output -def test_reset_invalid_json_crew_does_not_block_classic_crew( +def test_reset_invalid_json_crew_blocks_reset( mock_crew, runner, monkeypatch, tmp_path ): monkeypatch.chdir(tmp_path) (tmp_path / "crew.jsonc").write_text("{invalid") + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"\n' + ) with mock.patch( "crewai.utilities.reset_memories.get_crews", return_value=[mock_crew] @@ -272,10 +278,10 @@ def test_reset_invalid_json_crew_does_not_block_classic_crew( ) as mock_load_crew: result = runner.invoke(reset_memories, ["-m"]) - mock_load_crew.assert_called_once_with(Path("crew.jsonc")) - mock_crew.reset_memories.assert_called_once_with(command_type="memory") - assert "Skipping JSON crew at crew.jsonc: failed to load (invalid JSON)." in result.output - assert f"[Crew ({mock_crew.name})] Memory has been reset." in result.output + mock_load_crew.assert_called_once_with((tmp_path / "crew.jsonc").resolve()) + mock_crew.reset_memories.assert_not_called() + assert result.exit_code != 0 + assert "An unexpected error occurred: invalid JSON" in result.output def test_reset_json_crew_skipped_for_declared_flow_project( diff --git a/lib/crewai/tests/test_flow_definition.py b/lib/crewai/tests/test_flow_definition.py index 89df6c902..191bdea62 100644 --- a/lib/crewai/tests/test_flow_definition.py +++ b/lib/crewai/tests/test_flow_definition.py @@ -65,7 +65,7 @@ def test_flow_public_exports_are_explicit(): def test_flow_definition_json_schema_carries_reference_descriptions(): - schema = flow_definition.FlowDefinition.json_schema() + schema = flow_definition.FlowDefinition.model_json_schema(by_alias=True) defs = schema["$defs"] assert schema["properties"]["schema"]["description"] @@ -120,7 +120,7 @@ def test_flow_definition_json_schema_carries_reference_descriptions(): def test_flow_definition_json_schema_carries_field_examples_only(): - schema = flow_definition.FlowDefinition.json_schema() + schema = flow_definition.FlowDefinition.model_json_schema(by_alias=True) defs = schema["$defs"] for model_name in [ @@ -437,7 +437,7 @@ def test_flow_definition_uses_collapsed_conversational_router_start(): assert methods["route_conversation"].router is True -def test_flow_definition_serializes_human_feedback_metadata(caplog): +def test_flow_definition_degrades_human_feedback_metadata(caplog): caplog.set_level(logging.WARNING, logger="crewai.flow.dsl._utils") marker = object() @@ -461,7 +461,7 @@ def test_flow_definition_serializes_human_feedback_metadata(caplog): and "not fully serializable" in record.message for record in caplog.records ) - definition.to_json() + definition.to_dict() def test_flow_definition_fragments_cover_start_listen_and_condition_sugar(): @@ -613,7 +613,7 @@ def test_flow_definition_merges_stacked_listen_router(): assert methods["second_router"].emit == ["second_approval", "not_approved"] -def test_flow_definition_round_trips_declaration_serialization(): +def test_flow_definition_from_declaration_accepts_json_and_yaml_strings(): class RoundTripFlow(Flow): @start() def begin(self): @@ -627,17 +627,67 @@ def test_flow_definition_round_trips_declaration_serialization(): def left(self): return "left" - definition = RoundTripFlow.flow_definition() - - round_trips = [ - flow_definition.FlowDefinition.from_declaration(contents=definition.to_json()), - flow_definition.FlowDefinition.from_declaration(contents=definition.to_yaml()), + expected = RoundTripFlow.flow_definition() + declarations = [ + """ + { + "schema": "crewai.flow/v1", + "name": "RoundTripFlow", + "methods": { + "begin": { + "start": true, + "do": { + "call": "code", + "ref": "test_flow_definition:RoundTripFlow.begin" + } + }, + "decide": { + "listen": "begin", + "router": true, + "do": { + "call": "code", + "ref": "test_flow_definition:RoundTripFlow.decide" + } + }, + "left": { + "listen": "left", + "do": { + "call": "code", + "ref": "test_flow_definition:RoundTripFlow.left" + } + } + } + } + """, + """ + schema: crewai.flow/v1 + name: RoundTripFlow + methods: + begin: + start: true + do: + call: code + ref: test_flow_definition:RoundTripFlow.begin + decide: + listen: begin + router: true + do: + call: code + ref: test_flow_definition:RoundTripFlow.decide + left: + listen: left + do: + call: code + ref: test_flow_definition:RoundTripFlow.left + """, ] - for round_trip in round_trips: - assert round_trip.to_dict() == definition.to_dict() - assert round_trip.methods["decide"].router is True - assert round_trip.methods["decide"].listen == "begin" + for declaration in declarations: + loaded = flow_definition.FlowDefinition.from_declaration(contents=declaration) + + assert loaded.name == expected.name + assert loaded.methods["decide"].router is True + assert loaded.methods["decide"].listen == "begin" def test_flow_definition_from_declaration_accepts_contents(): @@ -654,20 +704,41 @@ def test_flow_definition_from_declaration_accepts_contents(): }, }, } - definition = flow_definition.FlowDefinition.from_dict(data) + definition = flow_definition.FlowDefinition.from_declaration(contents=data) contents = [ definition, data, - definition.to_json(), - definition.to_yaml(), + """ + { + "schema": "crewai.flow/v1", + "name": "DeclarationFlow", + "methods": { + "begin": { + "start": true, + "do": { + "call": "expression", + "expr": "'started'" + } + } + } + } + """, + """ + schema: crewai.flow/v1 + name: DeclarationFlow + methods: + begin: + start: true + do: + call: expression + expr: "'started'" + """, ] - expected = definition.to_dict() for content in contents: loaded = flow_definition.FlowDefinition.from_declaration(contents=content) - assert loaded.to_dict() == expected - + assert loaded.to_dict() == definition.to_dict() def test_flow_definition_from_declaration_rejects_empty_file(tmp_path: Path): declaration_path = tmp_path / "flow.crewai" @@ -686,7 +757,7 @@ def test_flow_definition_from_declaration_rejects_falsey_non_mapping_contents( def test_flow_definition_from_declaration_accepts_paths(tmp_path: Path): - definition = flow_definition.FlowDefinition.from_dict( + definition = flow_definition.FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "DeclarationFlow", @@ -702,7 +773,19 @@ def test_flow_definition_from_declaration_accepts_paths(tmp_path: Path): } ) declaration_path = tmp_path / "flow.crewai" - declaration_path.write_text(definition.to_yaml(), encoding="utf-8") + declaration_path.write_text( + """ + schema: crewai.flow/v1 + name: DeclarationFlow + methods: + begin: + start: true + do: + call: expression + expr: "'started'" + """, + encoding="utf-8", + ) path_inputs = [ declaration_path, str(declaration_path), @@ -711,7 +794,9 @@ def test_flow_definition_from_declaration_accepts_paths(tmp_path: Path): for path_input in path_inputs: loaded = flow_definition.FlowDefinition.from_declaration(path=path_input) - assert loaded.to_dict() == definition.to_dict() + assert loaded.name == definition.name + assert loaded.methods["begin"].is_start is True + assert loaded.methods["begin"].do.call == "expression" assert loaded.source_path == declaration_path.resolve() @@ -744,8 +829,8 @@ def test_flow_definition_from_declaration_prefers_contents_over_path( assert loaded.source_path is None -def test_each_action_round_trips_declaration_serialization(): - definition = flow_definition.FlowDefinition.from_dict( +def test_each_action_loads_from_declaration(): + definition = flow_definition.FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "EachFlow", @@ -783,22 +868,13 @@ def test_each_action_round_trips_declaration_serialization(): } ) - round_trips = [ - flow_definition.FlowDefinition.from_declaration(contents=definition.to_json()), - flow_definition.FlowDefinition.from_declaration(contents=definition.to_yaml()), - ] - - for round_trip in round_trips: - assert round_trip.to_dict() == definition.to_dict() - assert round_trip.methods["process_rows"].description == ( - "Process every loaded row." - ) - assert round_trip.methods["process_rows"].do.call == "each" + assert definition.methods["process_rows"].description == "Process every loaded row." + assert definition.methods["process_rows"].do.call == "each" def test_flow_definition_rejects_invalid_method_names(): with pytest.raises(ValueError, match="Flow method names must match"): - flow_definition.FlowDefinition.from_dict( + flow_definition.FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "InvalidMethodNameFlow", @@ -1009,7 +1085,7 @@ def test_flow_definition_accepts_explicit_router_events(): def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract(): - definition = flow_definition.FlowDefinition.from_dict( + definition = flow_definition.FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "LoadedDiagnosticsFlow", @@ -1042,7 +1118,7 @@ def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract(): def test_router_start_false_without_listen_is_allowed(caplog): caplog.set_level(logging.ERROR, logger="crewai.flow.flow_definition") - flow_definition.FlowDefinition.from_dict( + flow_definition.FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "LoadedFlow", @@ -1118,7 +1194,7 @@ def test_dynamic_router_string_listener_is_valid_contract(): def test_static_string_listener_is_allowed_by_contract(): - definition = flow_definition.FlowDefinition.from_dict( + definition = flow_definition.FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "TypoFlow", @@ -1138,7 +1214,7 @@ def test_static_string_listener_is_allowed_by_contract(): def test_start_false_not_classified_as_start_method(): - definition = flow_definition.FlowDefinition.from_dict( + definition = flow_definition.FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "ExplicitNonStartFlow", @@ -1202,7 +1278,7 @@ def test_flow_definition_cache_is_not_reused_by_subclasses(): def test_flow_definition_allows_router_without_trigger(caplog): caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition") - flow_definition.FlowDefinition.from_dict( + flow_definition.FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "LoadedFlow", diff --git a/lib/crewai/tests/test_flow_from_definition.py b/lib/crewai/tests/test_flow_from_definition.py index 0c822c483..7764037bf 100644 --- a/lib/crewai/tests/test_flow_from_definition.py +++ b/lib/crewai/tests/test_flow_from_definition.py @@ -477,7 +477,7 @@ def assert_parity(flow_cls, yaml_str, inputs=None, ordered=True): class_result, class_events = _run_with_events(class_flow, inputs) definition = FlowDefinition.from_declaration(contents=yaml_str) - definition_flow = Flow.from_definition(definition) + definition_flow = Flow.from_declaration(contents=definition) definition_result, definition_events = _run_with_events(definition_flow, inputs) assert definition_result == class_result @@ -537,7 +537,7 @@ def test_cyclic_flow_parity(): def test_definition_flow_events_use_definition_name(): definition = FlowDefinition.from_declaration(contents=CHAIN_YAML) - flow = Flow.from_definition(definition) + flow = Flow.from_declaration(contents=definition) _, events = _run_with_events(flow) assert events assert all(flow_name == "ChainFlow" for _, _, flow_name in events) @@ -545,7 +545,7 @@ def test_definition_flow_events_use_definition_name(): def test_definition_method_without_action_is_invalid(): with pytest.raises(ValidationError, match="do"): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "NoActions", @@ -554,8 +554,8 @@ def test_definition_method_without_action_is_invalid(): ) -def test_from_definition_unresolvable_ref_raises(): - definition = FlowDefinition.from_dict( +def test_from_declaration_unresolvable_ref_raises(): + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "BadRefs", @@ -569,11 +569,11 @@ def test_from_definition_unresolvable_ref_raises(): ) with pytest.raises(ValueError, match="unresolvable actions.*begin"): - Flow.from_definition(definition) + Flow.from_declaration(contents=definition) -def test_from_definition_malformed_ref_raises(): - definition = FlowDefinition.from_dict( +def test_from_declaration_malformed_ref_raises(): + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "MalformedRefs", @@ -582,11 +582,11 @@ def test_from_definition_malformed_ref_raises(): ) with pytest.raises(ValueError, match="expected 'module:qualname'"): - Flow.from_definition(definition) + Flow.from_declaration(contents=definition) -def test_from_definition_local_scope_ref_raises(): - definition = FlowDefinition.from_dict( +def test_from_declaration_local_scope_ref_raises(): + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "LocalRefs", @@ -600,7 +600,7 @@ def test_from_definition_local_scope_ref_raises(): ) with pytest.raises(ValueError, match="expected 'module:qualname'"): - Flow.from_definition(definition) + Flow.from_declaration(contents=definition) def test_flow_definition_stamps_refs(): @@ -610,7 +610,7 @@ def test_flow_definition_stamps_refs(): assert definition.methods["shout"].do.ref == f"{__name__}:ChainFlow.shout" -def test_from_definition_runs_tool_action_with_static_inputs(): +def test_from_declaration_runs_tool_action_with_static_inputs(): yaml_str = f""" schema: crewai.flow/v1 name: ToolFlow @@ -625,13 +625,13 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff() == "found:ai agents" def test_tool_action_round_trips_with_inputs(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "ToolFlow", @@ -648,12 +648,12 @@ def test_tool_action_round_trips_with_inputs(): } ) - assert definition.to_dict()["methods"]["search"]["do"] == { - "call": "tool", - "ref": f"{__name__}:StaticSearchTool", - "with": {"search_query": "ai agents"}, - } - assert Flow.from_definition(definition).kickoff() == "search:ai agents" + action = definition.methods["search"].do + + assert action.call == "tool" + assert action.ref == f"{__name__}:StaticSearchTool" + assert action.with_ == {"search_query": "ai agents"} + assert Flow.from_declaration(contents=definition).kickoff() == "search:ai agents" def test_tool_action_renders_cel_inputs_at_runtime(): @@ -676,13 +676,13 @@ methods: listen: begin """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"topic": "ai"}) == "found:ai agents" def test_tool_action_treats_embedded_cel_marker_as_literal(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "ToolFlow", @@ -702,11 +702,11 @@ def test_tool_action_treats_embedded_cel_marker_as_literal(): } ) - assert Flow.from_definition(definition).kickoff() == "p}x:wrapped ${'a}b'} value" + assert Flow.from_declaration(contents=definition).kickoff() == "p}x:wrapped ${'a}b'} value" def test_tool_action_treats_marker_with_trailing_text_as_literal(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "ToolFlow", @@ -726,12 +726,12 @@ def test_tool_action_treats_marker_with_trailing_text_as_literal(): } ) - assert Flow.from_definition(definition).kickoff() == "p:${state.topic} extra" + assert Flow.from_declaration(contents=definition).kickoff() == "p:${state.topic} extra" def test_tool_action_rejects_adjacent_markers_as_invalid_cel(): with pytest.raises(ValidationError, match="invalid CEL expression"): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "ToolFlow", @@ -753,7 +753,7 @@ def test_tool_action_rejects_adjacent_markers_as_invalid_cel(): def test_tool_action_accepts_braces_in_full_cel_marker(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "ToolFlow", @@ -773,7 +773,7 @@ def test_tool_action_accepts_braces_in_full_cel_marker(): } ) - assert Flow.from_definition(definition).kickoff() == "p}x:ai agents" + assert Flow.from_declaration(contents=definition).kickoff() == "p}x:ai agents" def test_tool_action_renders_latest_output_by_method_name(): @@ -795,7 +795,7 @@ methods: listen: begin """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff() == "search:hello agents" @@ -820,7 +820,7 @@ methods: listen: build_query """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff() == "found:ai agents news" @@ -840,7 +840,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert ( flow.kickoff(inputs={"limit": 2, "domains": ["crewai.com", "example.com"]}) @@ -873,7 +873,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"question": "What is CrewAI?"}) == { "agent": "Analyst", @@ -911,7 +911,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"questions": ["one", "two"]}) == [ "Analyst:one", @@ -920,7 +920,7 @@ methods: def test_agent_action_round_trips_with_inline_definition(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "AgentFlow", @@ -942,17 +942,16 @@ def test_agent_action_round_trips_with_inline_definition(): } ) - round_trip = FlowDefinition.from_declaration(contents=definition.to_yaml()) - action = round_trip.to_dict()["methods"]["answer"]["do"] + action = definition.methods["answer"].do - assert action["call"] == "agent" - assert action["with"]["role"] == "Analyst" - assert action["with"]["input"] == "${state.question}" - assert action["with"]["settings"] == {"verbose": True} + assert action.call == "agent" + assert action.with_.role == "Analyst" + assert action.with_.input == "${state.question}" + assert action.with_.settings == {"verbose": True} def test_agent_action_json_schema_describes_inline_agent_definitions(): - schema_defs = FlowDefinition.json_schema()["$defs"] + schema_defs = FlowDefinition.model_json_schema(by_alias=True)["$defs"] assert set(schema_defs["AgentDefinition"]["properties"]) >= { "role", @@ -966,7 +965,7 @@ def test_agent_action_json_schema_describes_inline_agent_definitions(): def test_agent_action_rejects_non_string_input_in_definition(): with pytest.raises(ValidationError, match="agent.input must be a string"): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "AgentFlow", @@ -1047,7 +1046,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"topic": "AI"}) == { "crew": "inline_research", @@ -1123,7 +1122,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"topic": "AI"}) == { "crew": "referenced_research", @@ -1197,7 +1196,7 @@ methods: other_cwd.mkdir() monkeypatch.chdir(other_cwd) - flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path)) + flow = Flow.from_declaration(path=flow_path) assert flow.kickoff(inputs={"topic": "AI"}) == { "crew": "relative_research", @@ -1222,7 +1221,7 @@ methods: """ flow_path.write_text(yaml_str, encoding="utf-8") - flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path)) + flow = Flow.from_declaration(path=flow_path) with pytest.raises( ValueError, @@ -1232,7 +1231,7 @@ methods: def test_crew_action_round_trips_with_inline_definition(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "CrewFlow", @@ -1266,20 +1265,16 @@ def test_crew_action_round_trips_with_inline_definition(): } ) - assert definition.to_dict()["methods"]["research"]["do"]["call"] == "crew" - assert ( - definition.to_dict()["methods"]["research"]["do"]["with"]["agents"][ - "researcher" - ]["role"] - == "Researcher" - ) - assert definition.to_dict()["methods"]["research"]["do"]["inputs"] == { - "topic": "${state.topic}" - } + action = definition.methods["research"].do + + assert action.call == "crew" + assert action.with_ is not None + assert action.with_.agents["researcher"].role == "Researcher" + assert action.inputs == {"topic": "${state.topic}"} def test_crew_action_normalizes_named_agent_list_definition(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "CrewFlow", @@ -1311,16 +1306,15 @@ def test_crew_action_normalizes_named_agent_list_definition(): } ) - assert ( - definition.to_dict()["methods"]["research"]["do"]["with"]["agents"][ - "researcher" - ]["role"] - == "Researcher" - ) + action = definition.methods["research"].do + + assert action.call == "crew" + assert action.with_ is not None + assert action.with_.agents["researcher"].role == "Researcher" def test_crew_action_json_schema_describes_inline_crew_definitions(): - schema_defs = FlowDefinition.json_schema()["$defs"] + schema_defs = FlowDefinition.model_json_schema(by_alias=True)["$defs"] agents_schema = schema_defs["CrewDefinition"]["properties"]["agents"] assert set(schema_defs["CrewDefinition"]["properties"]) >= { @@ -1345,7 +1339,7 @@ def test_crew_action_json_schema_describes_inline_crew_definitions(): def test_crew_action_rejects_incomplete_inline_agent_definition(): with pytest.raises(ValidationError, match="goal"): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "CrewFlow", @@ -1378,7 +1372,7 @@ def test_crew_action_rejects_incomplete_inline_agent_definition(): def test_crew_action_rejects_python_ref_field(): with pytest.raises(ValidationError, match="ref"): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "CrewFlow", @@ -1397,7 +1391,7 @@ def test_crew_action_rejects_python_ref_field(): def test_crew_action_rejects_non_mapping_inputs_in_definition(): with pytest.raises(ValidationError, match="crew.inputs must be a mapping"): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "CrewFlow", @@ -1463,7 +1457,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"name": "hello"}) == "hello!" @@ -1482,7 +1476,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"value": "ok"}) == "callable:ok" @@ -1506,7 +1500,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [ "normalized:a", @@ -1533,7 +1527,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) caller_thread_id = threading.get_ident() assert flow.kickoff(inputs={"rows": ["a"]}) == ["process_rows:a"] @@ -1560,7 +1554,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["async:a", "async:b"] @@ -1582,7 +1576,7 @@ methods: FlowScriptExecutionDisabledError, match="CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION=1", ) as exc_info: - Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + Flow.from_declaration(contents=yaml_str) assert "methods with unresolvable actions" not in str(exc_info.value) @@ -1606,7 +1600,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"raw_score": 3.2}) == "rounded:4" assert flow.state["rounded"] == 4 @@ -1635,7 +1629,7 @@ methods: listen: seed """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff() == "alpha:alpha" assert flow.state["input_matches_output"] is True @@ -1673,7 +1667,7 @@ methods: listen: seed """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"rows": [" a ", " b "]}) == ["global:a", "global:b"] @@ -1705,7 +1699,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [ {"row": "a", "normalized": "saved:a"}, @@ -1734,7 +1728,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["a", "b"] assert flow._method_outputs == [ @@ -1772,7 +1766,7 @@ methods: listen: seed """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [ "local:a", @@ -1811,7 +1805,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff( inputs={ @@ -1845,7 +1839,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff(inputs={"rows": [{"kind": "keep", "value": "a"}]}) == ["a"] @@ -1872,7 +1866,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert flow.kickoff( inputs={ @@ -1902,7 +1896,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) with pytest.raises(ValueError, match="if expression must evaluate to a boolean"): flow.kickoff(inputs={"rows": [{"value": "truthy"}]}) @@ -1932,7 +1926,7 @@ methods: listen: process_rows """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) events = [] with crewai_event_bus.scoped_handlers(): @@ -1958,7 +1952,7 @@ methods: ], ) def test_each_action_rejects_non_list_inputs(expr, inputs): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "EachFlow", @@ -1979,7 +1973,7 @@ def test_each_action_rejects_non_list_inputs(expr, inputs): }, } ) - flow = Flow.from_definition(definition) + flow = Flow.from_declaration(contents=definition) with pytest.raises(ValueError, match="each.in must evaluate to an array"): flow.kickoff(inputs=inputs) @@ -2009,7 +2003,7 @@ def test_each_action_rejects_non_list_inputs(expr, inputs): ) def test_each_action_validates_step_shape(action_do): with pytest.raises(ValidationError): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "EachFlow", @@ -2029,7 +2023,7 @@ def test_each_action_validates_step_shape(action_do): def test_if_clauses_are_rejected_at_method_level(): with pytest.raises(ValidationError): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "TopLevelIfFlow", @@ -2049,7 +2043,7 @@ def test_if_clauses_are_rejected_at_method_level(): def test_each_action_rejects_nested_each_actions(): with pytest.raises(ValidationError): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "EachFlow", @@ -2103,14 +2097,14 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) with pytest.raises(RuntimeError, match="bad row"): flow.kickoff(inputs={"rows": ["ok", "bad"]}) def test_expression_action_round_trips(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "ExpressionFlow", @@ -2126,15 +2120,15 @@ def test_expression_action_round_trips(): } ) - assert definition.to_dict()["methods"]["classify"]["do"] == { - "call": "expression", - "expr": "state.score >= 80 ? 'qualified' : 'nurture'", - } - assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified" + action = definition.methods["classify"].do + + assert action.call == "expression" + assert action.expr == "state.score >= 80 ? 'qualified' : 'nurture'" + assert Flow.from_declaration(contents=definition).kickoff(inputs={"score": 90}) == "qualified" def test_explicit_cel_fields_accept_expression_markers(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "ExpressionFlow", @@ -2150,7 +2144,7 @@ def test_explicit_cel_fields_accept_expression_markers(): } ) - assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified" + assert Flow.from_declaration(contents=definition).kickoff(inputs={"score": 90}) == "qualified" def test_expression_local_context_recurses_into_dataclass_values(): @@ -2226,10 +2220,10 @@ methods: definition = FlowDefinition.from_declaration(contents=yaml_str) - assert Flow.from_definition(definition).kickoff( + assert Flow.from_declaration(contents=definition).kickoff( inputs={"direction": "left"} ) == "took-left" - assert Flow.from_definition(definition).kickoff( + assert Flow.from_declaration(contents=definition).kickoff( inputs={"direction": "right"} ) == "took-right" @@ -2267,7 +2261,7 @@ methods: def test_tool_action_requires_module_qualname_ref(): - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "ToolFlow", @@ -2285,7 +2279,7 @@ def test_tool_action_requires_module_qualname_ref(): ) with pytest.raises(ValueError, match="expected 'module:qualname'"): - Flow.from_definition(definition) + Flow.from_declaration(contents=definition) def test_pydantic_state_from_ref_parity(): @@ -2297,7 +2291,7 @@ def test_pydantic_state_from_ref_parity(): def test_pydantic_state_default_overlay(): - flow = Flow.from_definition( + flow = Flow.from_declaration(contents= FlowDefinition.from_declaration(contents=PYDANTIC_STATE_OVERLAY_YAML) ) result = flow.kickoff() @@ -2306,7 +2300,7 @@ def test_pydantic_state_default_overlay(): def test_json_schema_state(): - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=JSON_SCHEMA_STATE_YAML)) + flow = Flow.from_declaration(contents=JSON_SCHEMA_STATE_YAML) result = flow.kickoff() assert result == "count=1" assert flow.state.count == 1 @@ -2315,13 +2309,13 @@ def test_json_schema_state(): def test_json_schema_state_validates_inputs(): - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=JSON_SCHEMA_STATE_YAML)) + flow = Flow.from_declaration(contents=JSON_SCHEMA_STATE_YAML) with pytest.raises(ValueError, match="Invalid inputs"): flow.kickoff(inputs={"count": "not-a-number"}) def test_json_schema_state_required_fields_can_come_from_kickoff_inputs(): - flow = Flow.from_definition( + flow = Flow.from_declaration(contents= FlowDefinition.from_declaration(contents=JSON_SCHEMA_REQUIRED_INPUT_STATE_YAML) ) @@ -2333,7 +2327,7 @@ def test_json_schema_state_required_fields_can_come_from_kickoff_inputs(): def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable(): - flow = Flow.from_definition( + flow = Flow.from_declaration(contents= FlowDefinition.from_declaration(contents=PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML) ) result = flow.kickoff() @@ -2343,7 +2337,7 @@ def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable(): def test_pydantic_state_without_ref_or_schema_falls_back_to_dict(caplog): with caplog.at_level("ERROR"): - flow = Flow.from_definition( + flow = Flow.from_declaration(contents= FlowDefinition.from_declaration(contents=UNRESOLVABLE_STATE_YAML) ) assert "falling back to dict state" in caplog.text @@ -2357,13 +2351,13 @@ def test_pydantic_state_without_ref_or_schema_falls_back_to_dict(caplog): def test_dict_state_is_a_copy_of_default_plus_id(): definition = FlowDefinition.from_declaration(contents=DICT_STATE_YAML) - flow = Flow.from_definition(definition) + flow = Flow.from_declaration(contents=definition) assert flow.state["count"] == 5 assert flow.state["id"] flow.kickoff() assert flow.state["begin_ran"] is True - second = Flow.from_definition(definition) + second = Flow.from_declaration(contents=definition) assert second.state["count"] == 5 assert "begin_ran" not in second.state assert second.state["id"] != flow.state["id"] @@ -2372,7 +2366,7 @@ def test_dict_state_is_a_copy_of_default_plus_id(): def test_unknown_state_type_falls_back_to_dict(caplog): with caplog.at_level("WARNING"): - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=UNKNOWN_STATE_YAML)) + flow = Flow.from_declaration(contents=UNKNOWN_STATE_YAML) assert "falling back to dict state" in caplog.text result = flow.kickoff() @@ -2445,7 +2439,7 @@ def _run_capturing_flow_lifecycle(yaml_str, event_types): def capture(source, event): events.append(event) - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) result = flow.kickoff() return flow, result, events @@ -2483,13 +2477,13 @@ def test_config_suppress_flow_events_from_declaration(): def test_config_max_method_calls_from_declaration(): - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=CAPPED_LOOP_YAML)) + flow = Flow.from_declaration(contents=CAPPED_LOOP_YAML) with pytest.raises(RecursionError, match="has been called 2 times"): flow.kickoff() def test_config_stream_from_declaration(): - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=STREAMING_CHAIN_YAML)) + flow = Flow.from_declaration(contents=STREAMING_CHAIN_YAML) streaming = flow.kickoff() assert isinstance(streaming, FlowStreamingOutput) for _ in streaming: @@ -2521,24 +2515,24 @@ config: location: {tmp_path} """ ) - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) assert isinstance(flow.checkpoint, CheckpointConfig) assert flow.checkpoint.location == str(tmp_path) def test_config_input_provider_from_declaration(): - flow = Flow.from_definition( + flow = Flow.from_declaration(contents= FlowDefinition.from_declaration(contents=INPUT_PROVIDER_CHAIN_YAML) ) assert isinstance(flow.input_provider, StubInputProvider) -def test_round_trip_config_equivalence(): +def test_definition_config_equivalence(): class_flow = ConfiguredFlow() definition = FlowDefinition.from_declaration( - contents=ConfiguredFlow.flow_definition().to_yaml() + contents=ConfiguredFlow.flow_definition() ) - definition_flow = Flow.from_definition(definition) + definition_flow = Flow.from_declaration(contents=definition) assert definition.config.suppress_flow_events is True assert definition.config.max_method_calls == 5 @@ -2555,7 +2549,7 @@ def test_round_trip_config_equivalence(): def test_unknown_schema_rejected(): with pytest.raises(ValidationError, match="schema"): - FlowDefinition.from_dict( + FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v2", "name": "FutureSchema", @@ -2709,7 +2703,7 @@ class MethodPersistedFlow(Flow): def test_flow_level_persist_from_declaration_saves_once_per_method(): yaml_str = _flow_level_persist_yaml("yaml-flow-level") - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) result = flow.kickoff() assert result == "two" @@ -2721,7 +2715,7 @@ def test_flow_level_persist_from_declaration_saves_once_per_method(): def test_method_level_persist_from_declaration_saves_only_that_method(): yaml_str = _method_level_persist_yaml("yaml-method-level") - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) flow.kickoff() assert _saved_methods("yaml-method-level") == ["first"] @@ -2750,7 +2744,7 @@ methods: persist: enabled: false """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) flow.kickoff() assert _saved_methods("yaml-opt-out") == ["first"] @@ -2759,11 +2753,11 @@ methods: def test_persist_restore_by_id_from_declaration(): yaml_str = _flow_level_persist_yaml("yaml-restore") - flow1 = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow1 = Flow.from_declaration(contents=yaml_str) flow1.kickoff() assert flow1.state["count"] == 2 - flow2 = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow2 = Flow.from_declaration(contents=yaml_str) flow2.kickoff(inputs={"id": flow1.state["id"]}) assert flow2.state["count"] == 4 @@ -2782,13 +2776,13 @@ def test_method_level_persist_decorator_saves_only_that_method(): assert _saved_methods("method-decorator")[before:] == ["first"] -def test_round_trip_persist_equivalence(): +def test_definition_persist_equivalence(): definition = FlowDefinition.from_declaration( - contents=ClassPersistedFlow.flow_definition().to_yaml() + contents=ClassPersistedFlow.flow_definition() ) before = len(DefinitionStoreBackend.saves["class-decorator"]) - flow = Flow.from_definition(definition) + flow = Flow.from_declaration(contents=definition) flow.kickoff() assert _saved_methods("class-decorator")[before:] == ["first", "second"] @@ -2818,7 +2812,7 @@ methods: persistence_type: DefinitionStoreBackend store: yaml-mixed-method """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) flow.kickoff() assert _saved_methods("yaml-mixed-flow") == ["first"] @@ -2967,7 +2961,7 @@ methods: def test_human_feedback_from_declaration_default_outcome_routes(): - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=REVIEW_YAML)) + flow = Flow.from_declaration(contents=REVIEW_YAML) with patch.object(flow, "_request_human_feedback", return_value="") as request: result = flow.kickoff() @@ -2979,7 +2973,7 @@ def test_human_feedback_from_declaration_default_outcome_routes(): def test_human_feedback_from_declaration_collapses_and_routes(): - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=REVIEW_YAML)) + flow = Flow.from_declaration(contents=REVIEW_YAML) with ( patch.object(flow, "_request_human_feedback", return_value="ship it"), @@ -2991,13 +2985,13 @@ def test_human_feedback_from_declaration_collapses_and_routes(): assert [r.outcome for r in flow.human_feedback_history] == ["approved"] -def test_round_trip_human_feedback_equivalence(): +def test_definition_human_feedback_equivalence(): class_flow = ReviewFlow() with patch.object(class_flow, "_request_human_feedback", return_value=""): class_result = class_flow.kickoff() - definition = FlowDefinition.from_declaration(contents=ReviewFlow.flow_definition().to_yaml()) - twin = Flow.from_definition(definition) + definition = FlowDefinition.from_declaration(contents=ReviewFlow.flow_definition()) + twin = Flow.from_declaration(contents=definition) with patch.object(twin, "_request_human_feedback", return_value=""): twin_result = twin.kickoff() @@ -3012,7 +3006,7 @@ def test_round_trip_human_feedback_equivalence(): def test_human_feedback_pending_and_resume_from_declaration(): definition = FlowDefinition.from_declaration(contents=PENDING_REVIEW_YAML) - flow = Flow.from_definition(definition) + flow = Flow.from_declaration(contents=definition) pending = flow.kickoff() assert isinstance(pending, HumanFeedbackPending) @@ -3057,7 +3051,7 @@ methods: return "from-config" provider = RecordingProvider() - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) previous = flow_config.hitl_provider flow_config.hitl_provider = provider @@ -3160,7 +3154,7 @@ methods: message: "Review:" provider: {__name__}:_NeedsArgsProvider """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) with pytest.raises( ValueError, match="cannot instantiate human_feedback.provider ref" @@ -3181,7 +3175,7 @@ methods: message: "Review:" provider: missing_module_xyz:Provider """ - flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) + flow = Flow.from_declaration(contents=yaml_str) with pytest.raises( ValueError, match="unresolvable human_feedback.provider ref" @@ -3194,7 +3188,7 @@ def _checkpoint_chain_flow(tmp_path): from crewai.state.runtime import RuntimeState definition = FlowDefinition.from_declaration(contents=CHAIN_YAML) - flow = Flow.from_definition(definition) + flow = Flow.from_declaration(contents=definition) result = flow.kickoff() assert result == "confirmed:True" diff --git a/lib/crewai/tests/test_flow_visualization.py b/lib/crewai/tests/test_flow_visualization.py index 3dbe4e140..06b3ecf6f 100644 --- a/lib/crewai/tests/test_flow_visualization.py +++ b/lib/crewai/tests/test_flow_visualization.py @@ -80,7 +80,7 @@ class ComplexFlow(Flow): def _attach_flow_definition( flow_class: type[Flow], methods: dict[str, dict[str, object]] ) -> None: - flow_class._flow_definition = FlowDefinition.from_dict( + flow_class._flow_definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": flow_class.__name__, @@ -130,7 +130,7 @@ def test_build_flow_structure_from_flow_class(): def test_build_flow_structure_from_flow_definition(): """Test building visualization directly from a FlowDefinition.""" - definition = FlowDefinition.from_dict( + definition = FlowDefinition.from_declaration(contents= { "schema": "crewai.flow/v1", "name": "DefinedFlow", @@ -374,7 +374,7 @@ def test_topological_path_counting(): assert len(structure["edges"]) > 0 -def test_class_metadata_comes_from_definition(): +def test_class_metadata_comes_from_declaration(): """Test that nodes include only definition-derived class metadata.""" flow = SimpleFlow() structure = build_flow_structure(flow) From 8eaae40acf5a3e186f72d615082b47396f516231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Fri, 26 Jun 2026 16:28:47 -0300 Subject: [PATCH 6/7] Track TUI button telemetry (#6346) * feat(cli): track TUI button telemetry * fix(cli): use feature usage telemetry for TUI buttons --------- Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> --- lib/cli/src/crewai_cli/crew_run_tui.py | 13 ++++++++ lib/cli/tests/test_crew_run_tui.py | 33 ++++++++++++++++++++ lib/crewai-core/src/crewai_core/telemetry.py | 13 ++++++++ lib/crewai-core/tests/test_smoke.py | 27 ++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/lib/cli/src/crewai_cli/crew_run_tui.py b/lib/cli/src/crewai_cli/crew_run_tui.py index 81aae6c47..cefb7d142 100644 --- a/lib/cli/src/crewai_cli/crew_run_tui.py +++ b/lib/cli/src/crewai_cli/crew_run_tui.py @@ -10,6 +10,7 @@ import threading import time from typing import Any, ClassVar, cast +from crewai_core.telemetry import Telemetry from rich.text import Text from textual import work from textual.app import App, ComposeResult @@ -571,6 +572,7 @@ FooterKey .footer-key--key { self._want_deploy: bool = False self._trace_url: str | None = None self._consent_screen: TraceConsentScreen | None = None + self._telemetry: Telemetry | None = None # ── Layout ────────────────────────────────────────────── @@ -1042,10 +1044,21 @@ FooterKey .footer-key--key { self._unsubscribe() self.exit(self._crew_result) + def _record_tui_button_click(self, button_name: str) -> None: + try: + if self._telemetry is None: + self._telemetry = Telemetry() + self._telemetry.set_tracer() + self._telemetry.feature_usage_span(f"cli_usage:{button_name}") + except Exception: # noqa: S110 + pass + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id in ("btn-traces", "btn-traces-done"): + self._record_tui_button_click("view_traces") self.action_view_traces() elif event.button.id == "btn-deploy": + self._record_tui_button_click("deploy") self.action_deploy_crew() def _scroll_to_result(self) -> None: diff --git a/lib/cli/tests/test_crew_run_tui.py b/lib/cli/tests/test_crew_run_tui.py index 5c49dabf1..e41b4ec9b 100644 --- a/lib/cli/tests/test_crew_run_tui.py +++ b/lib/cli/tests/test_crew_run_tui.py @@ -1,5 +1,7 @@ from datetime import datetime import time +from types import SimpleNamespace +from unittest.mock import Mock import pytest @@ -126,6 +128,37 @@ def test_chain_deploy_does_not_login_for_deploy_exit(monkeypatch, capsys) -> Non assert "Deploy failed with exit code 42" in capsys.readouterr().out +def test_view_traces_button_click_records_telemetry(monkeypatch) -> None: + app = CrewRunApp() + app._status = "completed" + app._trace_url = "https://app.crewai.com/traces/test" + app._telemetry = Mock() + opened_urls: list[str] = [] + + monkeypatch.setattr("webbrowser.open", lambda url: opened_urls.append(url)) + + app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces"))) + + app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:view_traces") + assert opened_urls == ["https://app.crewai.com/traces/test"] + + +def test_deploy_button_click_records_telemetry() -> None: + app = CrewRunApp() + app._status = "completed" + app._crew_result = object() + app._telemetry = Mock() + app._unsubscribe = lambda: None # type: ignore[method-assign] + exits: list[object] = [] + app.exit = lambda result: exits.append(result) # type: ignore[method-assign] + + app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-deploy"))) + + app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:deploy") + assert app._want_deploy is True + assert exits == [app._crew_result] + + def test_conversation_turn_done_records_assistant_message() -> None: class RawResult: raw = "hello from the flow" diff --git a/lib/crewai-core/src/crewai_core/telemetry.py b/lib/crewai-core/src/crewai_core/telemetry.py index 08aef9b71..026c89243 100644 --- a/lib/crewai-core/src/crewai_core/telemetry.py +++ b/lib/crewai-core/src/crewai_core/telemetry.py @@ -249,6 +249,19 @@ class Telemetry: self._safe_telemetry_procedure(_operation) + def feature_usage_span(self, feature: str) -> None: + """Records that a feature was used. One span = one count.""" + from crewai_core.version import get_crewai_version + + def _operation() -> None: + tracer = trace.get_tracer("crewai.telemetry") + span = tracer.start_span("Feature Usage") + self._add_attribute(span, "crewai_version", get_crewai_version()) + self._add_attribute(span, "feature", feature) + close_span(span) + + self._safe_telemetry_procedure(_operation) + def flow_creation_span(self, flow_name: str) -> None: """Records the creation of a new flow.""" diff --git a/lib/crewai-core/tests/test_smoke.py b/lib/crewai-core/tests/test_smoke.py index 3decdaa73..44e350282 100644 --- a/lib/crewai-core/tests/test_smoke.py +++ b/lib/crewai-core/tests/test_smoke.py @@ -4,6 +4,7 @@ from __future__ import annotations import os from pathlib import Path +from unittest.mock import Mock from crewai_core import ( constants, @@ -206,3 +207,29 @@ def test_core_telemetry_skips_duplicate_tracer_provider( assert called is False assert telemetry.trace_set is True + + +def test_core_telemetry_records_feature_usage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from crewai_core.telemetry import Telemetry + + Telemetry._instance = None + monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False) + monkeypatch.delenv("CREWAI_DISABLE_TELEMETRY", raising=False) + monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False) + + tracer = Mock() + span = Mock() + tracer.start_span.return_value = span + monkeypatch.setattr( + "crewai_core.telemetry.trace.get_tracer", + lambda _name: tracer, + ) + + telemetry = Telemetry() + telemetry.feature_usage_span("cli_usage:view_traces") + + tracer.start_span.assert_called_once_with("Feature Usage") + span.set_attribute.assert_any_call("feature", "cli_usage:view_traces") + span.end.assert_called_once() From a149a30bc022050eb3430acefd78807e41d6e7a4 Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Fri, 26 Jun 2026 13:48:48 -0700 Subject: [PATCH 7/7] Fix JSON crew template rendering (#6359) JSON crews were not using existing CLI templates. --- lib/cli/src/crewai_cli/create_json_crew.py | 377 ++++-------------- lib/cli/src/crewai_cli/run_crew.py | 23 +- .../crewai_cli/templates/json_crew/.gitignore | 4 + .../crewai_cli/templates/json_crew/README.md | 20 + .../templates/json_crew/agent.jsonc | 59 +++ .../templates/json_crew/agent_settings.jsonc | 35 ++ .../crewai_cli/templates/json_crew/crew.jsonc | 58 +++ .../json_crew/knowledge/user_preference.txt | 1 + .../templates/json_crew/pyproject.toml | 20 + .../crewai_cli/templates/json_crew/task.jsonc | 40 ++ lib/cli/src/crewai_cli/utils.py | 29 +- lib/cli/tests/test_create_crew.py | 35 +- lib/cli/tests/test_run_crew.py | 74 ++++ 13 files changed, 465 insertions(+), 310 deletions(-) create mode 100644 lib/cli/src/crewai_cli/templates/json_crew/.gitignore create mode 100644 lib/cli/src/crewai_cli/templates/json_crew/README.md create mode 100644 lib/cli/src/crewai_cli/templates/json_crew/agent.jsonc create mode 100644 lib/cli/src/crewai_cli/templates/json_crew/agent_settings.jsonc create mode 100644 lib/cli/src/crewai_cli/templates/json_crew/crew.jsonc create mode 100644 lib/cli/src/crewai_cli/templates/json_crew/knowledge/user_preference.txt create mode 100644 lib/cli/src/crewai_cli/templates/json_crew/pyproject.toml create mode 100644 lib/cli/src/crewai_cli/templates/json_crew/task.jsonc diff --git a/lib/cli/src/crewai_cli/create_json_crew.py b/lib/cli/src/crewai_cli/create_json_crew.py index f1e133637..61a29b874 100644 --- a/lib/cli/src/crewai_cli/create_json_crew.py +++ b/lib/cli/src/crewai_cli/create_json_crew.py @@ -18,6 +18,7 @@ from crewai_cli.utils import ( enable_prompt_line_editing, is_dmn_mode_enabled, load_env_vars, + render_template, write_env_file, ) from crewai_cli.version import get_crewai_tools_dependency @@ -79,61 +80,7 @@ _PROVIDER_MODELS: dict[str, list[tuple[str, str]]] = { ], } - -# ── Static project files ─────────────────────────────────────── - -_PYPROJECT_TOML = """\ -[project] -name = "{folder_name}" -version = "0.1.0" -description = "{name} using crewAI" -authors = [{{ name = "Your Name", email = "you@example.com" }}] -requires-python = ">=3.10,<3.14" -dependencies = [ - "{crewai_tools_dependency}" -] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"] - -[tool.crewai] -type = "crew" -definition = "crew.jsonc" -""" - -_GITIGNORE = """\ -.env -__pycache__/ -.DS_Store -report.md -""" - -_README = """\ -# {name} - -A crewAI project using JSON-first configuration. - -## Running - -```bash -crewai run -``` - -## Project Structure - -- `agents/` - Agent definitions (JSONC) -- `crew.jsonc` - Crew definition with tasks and configuration -- `tools/` - Custom tools (Python) -- `knowledge/` - Knowledge files for agents - -> **Note:** `custom:` tool references execute `tools/.py` as local -> Python code when the crew loads. Only run crew projects from sources you -> trust. -""" +_TEMPLATES_DIR = Path(__file__).parent / "templates" / "json_crew" # ── Common tools for picker ──────────────────────────────────── @@ -694,187 +641,64 @@ def _default_agents_and_tasks( def _agent_to_jsonc(agent: dict[str, Any]) -> str: """Convert agent wizard data to JSONC string with comments.""" has_planning = agent["planning"] - delegation_val = "true" if agent["allow_delegation"] else "false" - delegation_comma = "," if has_planning else "" - - settings_lines = [] - settings_lines.append(" // Show detailed execution logs") - settings_lines.append(' "verbose": false,') - settings_lines.append("") - settings_lines.append( - " // Allow this agent to delegate tasks to other agents in the crew" + settings_block = _render_json_crew_template( + "agent_settings.jsonc", + { + "allow_delegation": "true" if agent["allow_delegation"] else "false", + "delegation_comma": "," if has_planning else "", + "planning_line": '"planning": true' + if has_planning + else '// "planning": false', + }, ) - settings_lines.append(f' "allow_delegation": {delegation_val}{delegation_comma}') - settings_lines.append("") - settings_lines.append( - " // Maximum reasoning iterations per task (prevents infinite loops)" + + return _render_json_crew_template( + "agent.jsonc", + { + "role_json": json.dumps(agent["role"]), + "goal_json": json.dumps(agent["goal"]), + "backstory_json": json.dumps(agent["backstory"]), + "llm_json": json.dumps(agent["llm"]), + "tools_json": json.dumps(agent["tools"]), + "settings_block": settings_block, + }, ) - settings_lines.append(' // "max_iter": 25,') - settings_lines.append("") - settings_lines.append(" // Maximum tokens for agent's response generation") - settings_lines.append(' // "max_tokens": null,') - settings_lines.append("") - settings_lines.append(" // Maximum execution time in seconds") - settings_lines.append(' // "max_execution_time": null,') - settings_lines.append("") - settings_lines.append(" // Maximum LLM requests per minute (rate limiting)") - settings_lines.append(' // "max_rpm": null,') - settings_lines.append("") - settings_lines.append(" // Enable agent-level memory (persists across tasks)") - settings_lines.append(' // "memory": false,') - settings_lines.append("") - settings_lines.append(" // Cache tool results to avoid duplicate calls") - settings_lines.append(' // "cache": true,') - settings_lines.append("") - settings_lines.append( - " // Auto-summarize context when it exceeds the LLM's context window" - ) - settings_lines.append(' // "respect_context_window": true,') - settings_lines.append("") - settings_lines.append(" // Maximum retries on execution errors") - settings_lines.append(' // "max_retry_limit": 2,') - settings_lines.append("") - settings_lines.append(" // Enable step-by-step planning before task execution") - if has_planning: - settings_lines.append(' "planning": true') - else: - settings_lines.append(' // "planning": false') - settings_lines.append("") - settings_lines.append(" // Include system prompt in LLM calls") - settings_lines.append(' // "use_system_prompt": true') - - settings_block = "\n".join(settings_lines) - - return f"""\ -{{ - // Agent's role title — appears in prompts and logs. - // You can use {{placeholder}} inputs in role, goal, or backstory. - // Example: "role": "Senior {{industry}} Researcher" - "role": {json.dumps(agent["role"])}, - - // Optional custom Agent subclass - // "type": {{"python": "my_project.agents.CustomAgent"}}, - - // The agent's primary objective - "goal": {json.dumps(agent["goal"])}, - - // Background story that shapes the agent's personality and approach - "backstory": {json.dumps(agent["backstory"])}, - - // LLM model in provider/model format - // Examples: "openai/gpt-4o", "anthropic/claude-sonnet-4-6", "ollama/llama3.3" - // For custom endpoints or deployment-based providers, replace with: - // "llm": {{"model": "llama3", "provider": "ollama", "base_url": "http://localhost:11434"}}, - // "llm": {{"deployment_name": "my-deployment", "provider": "azure", "api_version": "2024-10-21"}}, - "llm": {json.dumps(agent["llm"])}, - - // Override LLM used specifically for tool/function calling - // "function_calling_llm": "openai/gpt-5.4-mini", - - // Tools available to this agent - // Built-in: "SerperDevTool", "ScrapeWebsiteTool", "FileReadTool", etc. - // Custom: "custom:my_tool" loads from tools/my_tool.py - "tools": {json.dumps(agent["tools"])}, - - // Optional agent-level guardrail — validates this agent's final output. - // String guardrails are checked by an LLM and can reject/retry output. - // Python refs must point to module-level functions/classes in trusted code. - // "guardrail": "Only answer with information supported by retrieved evidence.", - // "step_callback": {{"python": "my_project.callbacks.on_agent_step"}}, - // "guardrail_max_retries": 2, - - // Advanced agent options: - // Docs: https://docs.crewai.com/concepts/agents - // "reasoning": true, - // "max_reasoning_attempts": 3, - // "planning_config": {{ - // "reasoning_effort": "medium", - // "llm": {{"model": "deepseek-chat", "provider": "deepseek"}} - // }}, - // "multimodal": false, - // "allow_code_execution": false, - // "code_execution_mode": "safe", - // "knowledge_sources": [], - // "knowledge_config": {{}}, - // "inject_date": true, - // "date_format": "%Y-%m-%d", - // "security_config": {{}}, - - // Agent behavior settings - "settings": {{ -{settings_block} - }} -}} -""" def _task_to_json_fragment(task: dict[str, Any]) -> str: """Convert task wizard data to a JSON-like fragment for embedding in crew JSONC.""" - lines = [] - lines.append(" {") - lines.append(" // Task identifier") - lines.append(f' "name": {json.dumps(task["name"])},') - lines.append("") - lines.append(" // What the task should accomplish") - lines.append( - " // Use {placeholder} inputs here; crewai run prompts for missing values" - ) - lines.append(f' "description": {json.dumps(task["description"])},') - lines.append("") - lines.append(" // Clear definition of what the output should look like") - lines.append(f' "expected_output": {json.dumps(task["expected_output"])},') - lines.append("") - lines.append( - " // Optional task guardrail(s) validate output before completion" - ) - lines.append(' // Use "guardrail" for one rule or "guardrails" for many') - lines.append(" // Failed guardrails retry up to guardrail_max_retries times") - lines.append(' // "guardrail": "Every factual claim needs context support.",') - lines.append(' // "guardrails": [') - lines.append(' // "Every factual claim must be supported by context.",') - lines.append(' // "The answer must match the expected output format."') - lines.append(" // ],") - lines.append(' // "guardrail_max_retries": 2,') - lines.append("") - lines.append(" // Advanced task options:") - lines.append(" // Docs: https://docs.crewai.com/concepts/tasks") - lines.append(' // "type": "ConditionalTask",') - lines.append( - ' // "condition": { "python": "my_project.conditions.should_run" },' - ) - lines.append( - ' // "output_json": { "python": "my_project.models.ReportOutput" },' - ) - lines.append(' // "output_pydantic": null,') - lines.append(' // "response_model": null,') - lines.append( - ' // "converter_cls": { "python": "my_project.converters.CustomConverter" },' - ) - lines.append(' // "markdown": false,') - lines.append(' // "input_files": { "brief": "data/brief.txt" },') - lines.append(' // "security_config": {},') - lines.append("") - lines.append(" // Which agent handles this task") - lines.append(f' "agent": {json.dumps(task["agent"])}') + has_context = bool(task.get("context")) + has_output_file = bool(task.get("output_file")) + context_block = "" + output_file_block = "" - if task.get("context"): - lines[-1] += "," # add comma to agent line - lines.append("") - lines.append(" // Task outputs used as context") - lines.append(f' "context": {json.dumps(task["context"])}') + if has_context: + context_block = ( + "\n\n" + " // Task outputs used as context\n" + f' "context": {json.dumps(task["context"])}' + f"{',' if has_output_file else ''}" + ) - if task.get("output_file"): - lines[-1] += "," - lines.append("") - lines.append(" // Save output to a file") - lines.append(f' "output_file": {json.dumps(task["output_file"])}') + if has_output_file: + output_file_block = ( + "\n\n" + " // Save output to a file\n" + f' "output_file": {json.dumps(task["output_file"])}' + ) - lines.append("") - lines.append(' // "tools": [],') - lines.append(' // "human_input": false,') - lines.append(' // "async_execution": false') - lines.append(" }") - return "\n".join(lines) + return _render_json_crew_template( + "task.jsonc", + { + "name_json": json.dumps(task["name"]), + "description_json": json.dumps(task["description"]), + "expected_output_json": json.dumps(task["expected_output"]), + "agent_json": json.dumps(task["agent"]), + "agent_comma": "," if has_context or has_output_file else "", + "context_block": context_block, + "output_file_block": output_file_block, + }, + ) def _crew_to_jsonc( @@ -894,69 +718,20 @@ def _crew_to_jsonc( inputs_lines[0] + "\n" + "\n".join(" " + line for line in inputs_lines[1:]) ) - process = settings.get("process", "sequential") memory = "true" if settings.get("memory") else "false" - return f"""\ -{{ - // Display name for this crew - "name": {json.dumps(name)}, - - // Agents to include — each must have a matching agents/.jsonc file - "agents": {agent_names_json}, - - // Task definitions — executed in order for sequential process - "tasks": [ -{tasks_fragments} - ], - - // Execution process - // "sequential" — tasks run in order, each receiving prior task outputs - // "hierarchical" — a manager agent delegates tasks (requires manager_llm) - "process": "{process}", - - // Enable verbose logging during execution - "verbose": true, - - // Enable crew memory — persists context and learnings across tasks - "memory": {memory}, - - // Automatically plan the execution strategy before running tasks - // "planning": false, - - // LLM for the planning step (used when planning is true) - // "planning_llm": "openai/gpt-4o", - - // LLM for the manager agent (required when process is "hierarchical") - // "manager_llm": "openai/gpt-4o", - - // Crew-level LLM fields also accept object form for custom endpoints - // "chat_llm": {{"model": "llama3", "provider": "ollama", "base_url": "http://localhost:11434"}}, - - // Advanced crew options: - // Docs: https://docs.crewai.com/concepts/crews - // For hierarchical crews, manager_agent can reference an agents/.jsonc file - // that is not included in the "agents" list. - // "manager_agent": "{agents[0]["name"]}", - // "before_kickoff_callbacks": [{{"python": "my_project.callbacks.before_kickoff"}}], - // "after_kickoff_callbacks": [{{"python": "my_project.callbacks.after_kickoff"}}], - // "function_calling_llm": "openai/gpt-4o-mini", - // "max_rpm": null, - // "cache": true, - // "knowledge_sources": [], - // "embedder": {{}}, - // "output_log_file": "crew.log", - // "stream": false, - // "tracing": false, - // "security_config": {{}}, - - // Optional runtime input defaults. - // Use {{placeholder}} in agent or task text, for example: - // "description": "Research {{topic}} and write a brief" - // `crewai run` prompts for any placeholders missing from this object. - "inputs": {inputs_json} -}} -""" + return _render_json_crew_template( + "crew.jsonc", + { + "name_json": json.dumps(name), + "agent_names_json": agent_names_json, + "tasks_fragments": tasks_fragments, + "process_json": json.dumps(settings.get("process", "sequential")), + "memory": memory, + "manager_agent_name": agents[0]["name"], + "inputs_json": inputs_json, + }, + ) # ── Model selection ───────────────────────────────────────────── @@ -1031,6 +806,12 @@ def _default_model_for_provider(provider: str | None) -> str | None: # ── Helpers ───────────────────────────────────────────────────── +def _render_json_crew_template( + template_name: str, replacements: dict[str, str] | None = None +) -> str: + return render_template(_TEMPLATES_DIR / template_name, replacements or {}) + + def _write_jsonc(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") @@ -1136,26 +917,32 @@ def create_json_crew( # Write pyproject.toml (folder_path / "pyproject.toml").write_text( - _PYPROJECT_TOML.format( - folder_name=folder_name, - name=name, - crewai_tools_dependency=get_crewai_tools_dependency(), + _render_json_crew_template( + "pyproject.toml", + { + "folder_name": folder_name, + "name": name, + "crewai_tools_dependency": get_crewai_tools_dependency(), + }, ), encoding="utf-8", ) # Write .gitignore - (folder_path / ".gitignore").write_text(_GITIGNORE, encoding="utf-8") + (folder_path / ".gitignore").write_text( + _render_json_crew_template(".gitignore"), + encoding="utf-8", + ) # Write README (folder_path / "README.md").write_text( - _README.format(name=name), + _render_json_crew_template("README.md", {"name": name}), encoding="utf-8", ) # Write knowledge placeholder (folder_path / "knowledge" / "user_preference.txt").write_text( - "# Add your knowledge files here\n", + _render_json_crew_template("knowledge/user_preference.txt"), encoding="utf-8", ) diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index 7601e2d76..8fde69ee8 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -10,12 +10,6 @@ from typing import TYPE_CHECKING, Any import click from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV -from crewai_core.project import ( - ProjectDefinitionError, - configured_project_definition, - get_crewai_project_type, - read_toml, -) from packaging import version from crewai_cli.utils import ( @@ -102,11 +96,28 @@ def _full_crewai_install_error() -> click.ClickException: return click.ClickException(_FULL_CREWAI_INSTALL_MESSAGE) +def read_toml(*args: Any, **kwargs: Any) -> dict[str, Any]: + from crewai_core.project import read_toml as _read_toml + + return _read_toml(*args, **kwargs) + + +def get_crewai_project_type(pyproject_data: dict[str, Any]) -> str | None: + from crewai_core.project import get_crewai_project_type as _get_crewai_project_type + + return _get_crewai_project_type(pyproject_data) + + def configured_project_json_crew( pyproject_data: dict[str, Any] | None = None, project_root: Path | None = None, ) -> Path | None: """Return the configured JSON crew definition for crew projects.""" + from crewai_core.project import ( + ProjectDefinitionError, + configured_project_definition, + ) + root = project_root or Path.cwd() if pyproject_data is None and not (root / "pyproject.toml").is_file(): return None diff --git a/lib/cli/src/crewai_cli/templates/json_crew/.gitignore b/lib/cli/src/crewai_cli/templates/json_crew/.gitignore new file mode 100644 index 000000000..012e44489 --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/json_crew/.gitignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +.DS_Store +report.md diff --git a/lib/cli/src/crewai_cli/templates/json_crew/README.md b/lib/cli/src/crewai_cli/templates/json_crew/README.md new file mode 100644 index 000000000..1208b2a40 --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/json_crew/README.md @@ -0,0 +1,20 @@ +# {{name}} + +A crewAI project using JSON-first configuration. + +## Running + +```bash +crewai run +``` + +## Project Structure + +- `agents/` - Agent definitions (JSONC) +- `crew.jsonc` - Crew definition with tasks and configuration +- `tools/` - Custom tools (Python) +- `knowledge/` - Knowledge files for agents + +> **Note:** `custom:` tool references execute `tools/.py` as local +> Python code when the crew loads. Only run crew projects from sources you +> trust. diff --git a/lib/cli/src/crewai_cli/templates/json_crew/agent.jsonc b/lib/cli/src/crewai_cli/templates/json_crew/agent.jsonc new file mode 100644 index 000000000..d89e59c1c --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/json_crew/agent.jsonc @@ -0,0 +1,59 @@ +{ + // Agent's role title — appears in prompts and logs. + // You can use {placeholder} inputs in role, goal, or backstory. + // Example: "role": "Senior {industry} Researcher" + "role": {{role_json}}, + + // Optional custom Agent subclass + // "type": {"python": "my_project.agents.CustomAgent"}, + + // The agent's primary objective + "goal": {{goal_json}}, + + // Background story that shapes the agent's personality and approach + "backstory": {{backstory_json}}, + + // LLM model in provider/model format + // Examples: "openai/gpt-4o", "anthropic/claude-sonnet-4-6", "ollama/llama3.3" + // For custom endpoints or deployment-based providers, replace with: + // "llm": {"model": "llama3", "provider": "ollama", "base_url": "http://localhost:11434"}, + // "llm": {"deployment_name": "my-deployment", "provider": "azure", "api_version": "2024-10-21"}, + "llm": {{llm_json}}, + + // Override LLM used specifically for tool/function calling + // "function_calling_llm": "openai/gpt-5.4-mini", + + // Tools available to this agent + // Built-in: "SerperDevTool", "ScrapeWebsiteTool", "FileReadTool", etc. + // Custom: "custom:my_tool" loads from tools/my_tool.py + "tools": {{tools_json}}, + + // Optional agent-level guardrail — validates this agent's final output. + // String guardrails are checked by an LLM and can reject/retry output. + // Python refs must point to module-level functions/classes in trusted code. + // "guardrail": "Only answer with information supported by retrieved evidence.", + // "step_callback": {"python": "my_project.callbacks.on_agent_step"}, + // "guardrail_max_retries": 2, + + // Advanced agent options: + // Docs: https://docs.crewai.com/concepts/agents + // "reasoning": true, + // "max_reasoning_attempts": 3, + // "planning_config": { + // "reasoning_effort": "medium", + // "llm": {"model": "deepseek-chat", "provider": "deepseek"} + // }, + // "multimodal": false, + // "allow_code_execution": false, + // "code_execution_mode": "safe", + // "knowledge_sources": [], + // "knowledge_config": {}, + // "inject_date": true, + // "date_format": "%Y-%m-%d", + // "security_config": {}, + + // Agent behavior settings + "settings": { +{{settings_block}} + } +} diff --git a/lib/cli/src/crewai_cli/templates/json_crew/agent_settings.jsonc b/lib/cli/src/crewai_cli/templates/json_crew/agent_settings.jsonc new file mode 100644 index 000000000..331af82ac --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/json_crew/agent_settings.jsonc @@ -0,0 +1,35 @@ + // Show detailed execution logs + "verbose": false, + + // Allow this agent to delegate tasks to other agents in the crew + "allow_delegation": {{allow_delegation}}{{delegation_comma}} + + // Maximum reasoning iterations per task (prevents infinite loops) + // "max_iter": 25, + + // Maximum tokens for agent's response generation + // "max_tokens": null, + + // Maximum execution time in seconds + // "max_execution_time": null, + + // Maximum LLM requests per minute (rate limiting) + // "max_rpm": null, + + // Enable agent-level memory (persists across tasks) + // "memory": false, + + // Cache tool results to avoid duplicate calls + // "cache": true, + + // Auto-summarize context when it exceeds the LLM's context window + // "respect_context_window": true, + + // Maximum retries on execution errors + // "max_retry_limit": 2, + + // Enable step-by-step planning before task execution + {{planning_line}} + + // Include system prompt in LLM calls + // "use_system_prompt": true diff --git a/lib/cli/src/crewai_cli/templates/json_crew/crew.jsonc b/lib/cli/src/crewai_cli/templates/json_crew/crew.jsonc new file mode 100644 index 000000000..b4a936b05 --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/json_crew/crew.jsonc @@ -0,0 +1,58 @@ +{ + // Display name for this crew + "name": {{name_json}}, + + // Agents to include — each must have a matching agents/.jsonc file + "agents": {{agent_names_json}}, + + // Task definitions — executed in order for sequential process + "tasks": [ +{{tasks_fragments}} + ], + + // Execution process + // "sequential" — tasks run in order, each receiving prior task outputs + // "hierarchical" — a manager agent delegates tasks (requires manager_llm) + "process": {{process_json}}, + + // Enable verbose logging during execution + "verbose": true, + + // Enable crew memory — persists context and learnings across tasks + "memory": {{memory}}, + + // Automatically plan the execution strategy before running tasks + // "planning": false, + + // LLM for the planning step (used when planning is true) + // "planning_llm": "openai/gpt-4o", + + // LLM for the manager agent (required when process is "hierarchical") + // "manager_llm": "openai/gpt-4o", + + // Crew-level LLM fields also accept object form for custom endpoints + // "chat_llm": {"model": "llama3", "provider": "ollama", "base_url": "http://localhost:11434"}, + + // Advanced crew options: + // Docs: https://docs.crewai.com/concepts/crews + // For hierarchical crews, manager_agent can reference an agents/.jsonc file + // that is not included in the "agents" list. + // "manager_agent": "{{manager_agent_name}}", + // "before_kickoff_callbacks": [{"python": "my_project.callbacks.before_kickoff"}], + // "after_kickoff_callbacks": [{"python": "my_project.callbacks.after_kickoff"}], + // "function_calling_llm": "openai/gpt-4o-mini", + // "max_rpm": null, + // "cache": true, + // "knowledge_sources": [], + // "embedder": {}, + // "output_log_file": "crew.log", + // "stream": false, + // "tracing": false, + // "security_config": {}, + + // Optional runtime input defaults. + // Use {placeholder} in agent or task text, for example: + // "description": "Research {topic} and write a brief" + // `crewai run` prompts for any placeholders missing from this object. + "inputs": {{inputs_json}} +} diff --git a/lib/cli/src/crewai_cli/templates/json_crew/knowledge/user_preference.txt b/lib/cli/src/crewai_cli/templates/json_crew/knowledge/user_preference.txt new file mode 100644 index 000000000..ae0f1e7a7 --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/json_crew/knowledge/user_preference.txt @@ -0,0 +1 @@ +# Add your knowledge files here diff --git a/lib/cli/src/crewai_cli/templates/json_crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/json_crew/pyproject.toml new file mode 100644 index 000000000..5cc3da30a --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/json_crew/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "{{folder_name}}" +version = "0.1.0" +description = "{{name}} using crewAI" +authors = [{ name = "Your Name", email = "you@example.com" }] +requires-python = ">=3.10,<3.14" +dependencies = [ + "{{crewai_tools_dependency}}" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"] + +[tool.crewai] +type = "crew" +definition = "crew.jsonc" diff --git a/lib/cli/src/crewai_cli/templates/json_crew/task.jsonc b/lib/cli/src/crewai_cli/templates/json_crew/task.jsonc new file mode 100644 index 000000000..796a746e9 --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/json_crew/task.jsonc @@ -0,0 +1,40 @@ + { + // Task identifier + "name": {{name_json}}, + + // What the task should accomplish + // Use {placeholder} inputs here; crewai run prompts for missing values + "description": {{description_json}}, + + // Clear definition of what the output should look like + "expected_output": {{expected_output_json}}, + + // Optional task guardrail(s) validate output before completion + // Use "guardrail" for one rule or "guardrails" for many + // Failed guardrails retry up to guardrail_max_retries times + // "guardrail": "Every factual claim needs context support.", + // "guardrails": [ + // "Every factual claim must be supported by context.", + // "The answer must match the expected output format." + // ], + // "guardrail_max_retries": 2, + + // Advanced task options: + // Docs: https://docs.crewai.com/concepts/tasks + // "type": "ConditionalTask", + // "condition": { "python": "my_project.conditions.should_run" }, + // "output_json": { "python": "my_project.models.ReportOutput" }, + // "output_pydantic": null, + // "response_model": null, + // "converter_cls": { "python": "my_project.converters.CustomConverter" }, + // "markdown": false, + // "input_files": { "brief": "data/brief.txt" }, + // "security_config": {}, + + // Which agent handles this task + "agent": {{agent_json}}{{agent_comma}}{{context_block}}{{output_file_block}} + + // "tools": [], + // "human_input": false, + // "async_execution": false + } diff --git a/lib/cli/src/crewai_cli/utils.py b/lib/cli/src/crewai_cli/utils.py index ee3a255a9..e20bcfea1 100644 --- a/lib/cli/src/crewai_cli/utils.py +++ b/lib/cli/src/crewai_cli/utils.py @@ -1,7 +1,9 @@ from __future__ import annotations +from collections.abc import Mapping import os from pathlib import Path +import re import shutil from typing import Any @@ -35,6 +37,7 @@ __all__ = [ "load_env_vars", "parse_toml", "read_toml", + "render_template", "tree_copy", "tree_find_and_replace", "write_env_file", @@ -42,6 +45,7 @@ __all__ = [ console = Console() +_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}") def is_dmn_mode_enabled() -> bool: @@ -69,14 +73,14 @@ def copy_template( src: Path, dst: Path, name: str, class_name: str, folder_name: str ) -> None: """Copy a file from src to dst.""" - with open(src, "r") as file: - content = file.read() - - content = content.replace("{{name}}", name) - content = content.replace("{{crew_name}}", class_name) - content = content.replace("{{folder_name}}", folder_name) - content = content.replace( - "{{crewai_tools_dependency}}", get_crewai_tools_dependency() + content = render_template( + src, + { + "name": name, + "crew_name": class_name, + "folder_name": folder_name, + "crewai_tools_dependency": get_crewai_tools_dependency(), + }, ) with open(dst, "w") as file: @@ -85,6 +89,15 @@ def copy_template( click.secho(f" - Created {dst}", fg="green") +def render_template(src: Path, replacements: Mapping[str, str]) -> str: + """Render a template file using ``{{placeholder}}`` replacements.""" + content = src.read_text(encoding="utf-8") + return _TEMPLATE_TOKEN_RE.sub( + lambda match: replacements.get(match.group(1), match.group(0)), + content, + ) + + def fetch_and_json_env_file(env_file_path: str = ".env") -> dict[str, Any]: """Fetch the environment variables from a .env file and return them as a dictionary.""" try: diff --git a/lib/cli/tests/test_create_crew.py b/lib/cli/tests/test_create_crew.py index 61311ef4d..d9ed7a24b 100644 --- a/lib/cli/tests/test_create_crew.py +++ b/lib/cli/tests/test_create_crew.py @@ -12,6 +12,8 @@ from packaging.version import Version import crewai_cli.create_json_crew as json_crew import crewai_cli.tui_picker as tui_picker from crewai_cli.create_crew import create_crew, create_folder_structure +from crewai_cli.utils import render_template +from crewai_cli.version import get_crewai_tools_dependency @pytest.fixture @@ -735,7 +737,7 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch): pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text()) dependency = pyproject["project"]["dependencies"][0] - assert dependency == "crewai[tools]>=1.15.0,<2.0.0" + assert dependency == get_crewai_tools_dependency() assert Version("1.15.0") in Requirement(dependency).specifier assert Version("2.0.0") not in Requirement(dependency).specifier assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][ @@ -816,6 +818,37 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch): assert '"knowledge_sources": []' in agent_template +def test_json_crew_uses_template_files(): + template_names = { + "pyproject.toml", + "README.md", + ".gitignore", + "agent.jsonc", + "agent_settings.jsonc", + "task.jsonc", + "crew.jsonc", + "knowledge/user_preference.txt", + } + + for template_name in template_names: + assert (json_crew._TEMPLATES_DIR / template_name).is_file() + + +def test_render_template_does_not_replace_tokens_inside_replacement_values(tmp_path): + template = tmp_path / "template.txt" + template.write_text("{{first}} {{second}}", encoding="utf-8") + + rendered = render_template( + template, + { + "first": "{{second}}", + "second": "done", + }, + ) + + assert rendered == "{{second}} done" + + def test_json_provider_default_model_helper(): assert json_crew._default_model_for_provider("openai") == "openai/gpt-5.5" assert json_crew._default_model_for_provider("anthropic/claude-custom") == ( diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index 2d0a23a6d..a1b4c73bc 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -227,6 +227,80 @@ def test_json_runner_code_loads_current_cli_package_over_project_env(tmp_path: P assert marker.read_text() == "current:trained.pkl" +def test_json_runner_imports_with_older_project_env_crewai_core(tmp_path: Path): + old_parent = tmp_path / "old_env" + old_crewai_core = old_parent / "crewai_core" + old_crewai_core.mkdir(parents=True) + (old_crewai_core / "__init__.py").write_text("") + (old_crewai_core / "constants.py").write_text( + "CREWAI_TRAINED_AGENTS_FILE_ENV = 'CREWAI_TRAINED_AGENTS_FILE'\n" + ) + (old_crewai_core / "project.py").write_text( + "def read_toml(*args, **kwargs):\n" + " return {}\n" + "def parse_toml(*args, **kwargs):\n" + " return {}\n" + "def get_project_description(*args, **kwargs):\n" + " return None\n" + "def get_project_name(*args, **kwargs):\n" + " return None\n" + "def get_project_version(*args, **kwargs):\n" + " return None\n" + ) + (old_crewai_core / "tool_credentials.py").write_text( + "def build_env_with_all_tool_credentials(*args, **kwargs):\n" + " return {}\n" + "def build_env_with_tool_repository_credentials(*args, **kwargs):\n" + " return {}\n" + ) + (old_crewai_core / "version.py").write_text( + "def check_version(*args, **kwargs):\n" + " return None\n" + "def get_crewai_version(*args, **kwargs):\n" + " return '1.0.0'\n" + "def get_latest_version_from_pypi(*args, **kwargs):\n" + " return None\n" + "def is_current_version_yanked(*args, **kwargs):\n" + " return False\n" + "def is_newer_version_available(*args, **kwargs):\n" + " return False\n" + ) + + marker = tmp_path / "marker.txt" + old_crewai_project = old_parent / "crewai" / "project" + old_crewai_project.mkdir(parents=True) + (old_parent / "crewai" / "__init__.py").write_text("") + (old_crewai_project / "__init__.py").write_text("") + (old_crewai_project / "crew_loader.py").write_text( + "from pathlib import Path\n" + "class Crew:\n" + " agents = []\n" + " tasks = []\n" + " def kickoff(self, inputs):\n" + f" Path({str(marker)!r}).write_text('ran')\n" + " return 'done'\n" + "def load_crew(path):\n" + " return Crew(), {}\n" + ) + + env = os.environ.copy() + env["PYTHONPATH"] = str(old_parent) + env["CREWAI_DMN"] = "true" + env[run_crew_module._CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV] = str( + Path(run_crew_module.__file__).resolve().parent + ) + env[run_crew_module._CREWAI_JSON_CREW_DEFINITION_ENV] = "crew.jsonc" + + subprocess.run( + [sys.executable, "-c", run_crew_module._JSON_CREW_RUNNER_CODE], + check=True, + env=env, + cwd=tmp_path, + ) + + assert marker.read_text() == "ran" + + def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path): monkeypatch.chdir(tmp_path) called: dict = {}