From 4cbfbdb23222cfa2c289eab6b64b55b8a312aa79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moura?= Date: Mon, 22 Jun 2026 13:22:46 -0300 Subject: [PATCH 01/22] Keep JSON crew projects and deploy archives Python-free (#6228) * fix: scaffold deployable json crews * fix: keep json crew scaffolds python-free * fix: keep json deploy archives python-free * fix: tighten json crew deploy validation * fix: address json crew pr checks * fix: clear langsmith audit advisory --- conftest.py | 16 +- lib/cli/src/crewai_cli/create_json_crew.py | 2 +- lib/cli/src/crewai_cli/deploy/archive.py | 266 --------------------- lib/cli/src/crewai_cli/deploy/validate.py | 32 ++- lib/cli/tests/deploy/test_archive.py | 94 +++++--- lib/cli/tests/deploy/test_validate.py | 35 +++ lib/cli/tests/test_create_crew.py | 20 +- pyproject.toml | 16 +- uv.lock | 112 +++++---- 9 files changed, 226 insertions(+), 367 deletions(-) diff --git a/conftest.py b/conftest.py index 27bf03a86..5987d653f 100644 --- a/conftest.py +++ b/conftest.py @@ -134,17 +134,21 @@ def bedrock_host_matcher(r1: Request, r2: Request) -> bool: # type: ignore[no-a ) -def _patched_make_vcr_request(httpx_request: Any, **kwargs: Any) -> Any: +def _patched_make_vcr_request( + httpx_request: Any, real_request_body: Any = None, **kwargs: Any +) -> Any: """Patched version of VCR's _make_vcr_request that handles binary content. The original implementation fails on binary request bodies (like file uploads) because it assumes all content can be decoded as UTF-8. """ - raw_body = httpx_request.read() - try: - body = raw_body.decode("utf-8") - except UnicodeDecodeError: - body = base64.b64encode(raw_body).decode("ascii") + raw_body = real_request_body if real_request_body is not None else httpx_request.read() + body: Any = raw_body + if isinstance(raw_body, bytes): + try: + body = raw_body.decode("utf-8") + except UnicodeDecodeError: + body = base64.b64encode(raw_body).decode("ascii") uri = str(httpx_request.url) headers = dict(httpx_request.headers) return Request(httpx_request.method, uri, body, headers) diff --git a/lib/cli/src/crewai_cli/create_json_crew.py b/lib/cli/src/crewai_cli/create_json_crew.py index 1df9f0310..9cc0c3787 100644 --- a/lib/cli/src/crewai_cli/create_json_crew.py +++ b/lib/cli/src/crewai_cli/create_json_crew.py @@ -680,7 +680,7 @@ def _default_agents_and_tasks( ] crew_settings = { "process": "sequential", - "memory": False, + "memory": True, "inputs": {}, } return agents, tasks, crew_settings diff --git a/lib/cli/src/crewai_cli/deploy/archive.py b/lib/cli/src/crewai_cli/deploy/archive.py index 14835579d..38b733fe4 100644 --- a/lib/cli/src/crewai_cli/deploy/archive.py +++ b/lib/cli/src/crewai_cli/deploy/archive.py @@ -1,15 +1,11 @@ from __future__ import annotations from pathlib import Path -import re import shutil import tempfile -from typing import Any import zipfile from crewai_cli import git -from crewai_cli.deploy.validate import normalize_package_name -from crewai_cli.utils import parse_toml _EXCLUDED_DIRS = { @@ -38,8 +34,6 @@ _EXCLUDED_SUFFIXES = { ".pyc", ".pyo", } -_SCRIPT_KEY_PATTERN = re.compile(r"^\s*(?P[A-Za-z0-9_.-]+|\"[^\"]+\"|'[^']+')\s*=") -_SECTION_PATTERN = re.compile(r"^\s*\[[^\]]+\]\s*(?:#.*)?$") def create_project_zip( @@ -143,267 +137,7 @@ def _stage_project(root: Path, files: list[Path]) -> Path: destination = staging_root / relative_path destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) - - if _is_json_crew_project(staging_root): - _add_json_crew_deploy_wrapper(staging_root) except Exception: shutil.rmtree(staging_root, ignore_errors=True) raise return staging_root - - -def _is_json_crew_project(root: Path) -> bool: - """Return True for JSON crew projects that need a Python deploy wrapper.""" - if not ((root / "crew.jsonc").is_file() or (root / "crew.json").is_file()): - return False - - project = _read_pyproject(root) - tool_config = project.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 - ) - if declared_type == "flow": - return False - - package_name = _package_name(root) - if package_name is None: - raise ValueError( - "Could not derive a valid Python package name from [project].name." - ) - - return not (root / "src" / package_name / "crew.py").is_file() - - -def _read_pyproject(root: Path) -> dict[str, Any]: - """Read pyproject.toml, returning an empty mapping on missing or invalid data.""" - pyproject_path = root / "pyproject.toml" - if not pyproject_path.is_file(): - return {} - try: - pyproject = parse_toml(pyproject_path.read_text()) - except Exception: - return {} - return pyproject if isinstance(pyproject, dict) else {} - - -def _package_name(root: Path) -> str | None: - """Return the normalized Python package name for the project.""" - project = _read_pyproject(root).get("project") - if not isinstance(project, dict): - return None - - name = project.get("name") - if not isinstance(name, str) or not name.strip(): - return None - - package_name = normalize_package_name(name) - return package_name or None - - -def _class_name(package_name: str) -> str: - """Return the generated wrapper class name for a package.""" - parts = [part for part in re.split(r"[^a-zA-Z0-9]+", package_name) if part] - class_name = "".join(part[:1].upper() + part[1:] for part in parts) - if not class_name: - return "JsonCrew" - if class_name[0].isdigit(): - return f"Crew{class_name}" - return class_name - - -def _add_json_crew_deploy_wrapper(root: Path) -> None: - """Add Python wrapper files required to deploy a JSON crew project.""" - package_name = _package_name(root) - if package_name is None: - raise ValueError( - "Could not derive a valid Python package name from [project].name." - ) - - package_dir = root / "src" / package_name - config_dir = package_dir / "config" - config_dir.mkdir(parents=True, exist_ok=True) - - class_name = _class_name(package_name) - crew_filename = "crew.jsonc" if (root / "crew.jsonc").is_file() else "crew.json" - - (package_dir / "__init__.py").write_text("", encoding="utf-8") - (config_dir / "agents.yaml").write_text("{}\n", encoding="utf-8") - (config_dir / "tasks.yaml").write_text("{}\n", encoding="utf-8") - (package_dir / "crew.py").write_text( - _json_crew_py(class_name, crew_filename), - encoding="utf-8", - ) - (package_dir / "main.py").write_text( - _json_main_py(package_name, class_name), - encoding="utf-8", - ) - _ensure_project_scripts(root, package_name) - - -def _json_crew_py(class_name: str, crew_filename: str) -> str: - """Render the generated crew.py module for a JSON crew.""" - return f'''from pathlib import Path - -from crewai import Crew -from crewai.project import CrewBase, crew -from crewai.project.crew_loader import load_crew - - -def _crew_path() -> Path: - return Path(__file__).resolve().parents[2] / "{crew_filename}" - - -@CrewBase -class {class_name}: - """Compatibility wrapper for a JSON-defined CrewAI project.""" - - @crew - def crew(self) -> Crew: - crew_instance, default_inputs = load_crew(_crew_path()) - self.default_inputs = default_inputs - return crew_instance -''' - - -def _json_main_py(package_name: str, class_name: str) -> str: - """Render the generated main.py entrypoints for a JSON crew.""" - return f"""#!/usr/bin/env python -import json -import sys - -from {package_name}.crew import {class_name} - - -def _load(): - wrapper = {class_name}() - crew = wrapper.crew() - return crew, getattr(wrapper, "default_inputs", {{}}) - - -def run(): - crew, inputs = _load() - return crew.kickoff(inputs=inputs) - - -def train(): - crew, inputs = _load() - return crew.train( - n_iterations=int(sys.argv[1]), - filename=sys.argv[2], - inputs=inputs, - ) - - -def replay(): - crew, _ = _load() - return crew.replay(task_id=sys.argv[1]) - - -def test(): - crew, inputs = _load() - return crew.test( - n_iterations=int(sys.argv[1]), - eval_llm=sys.argv[2], - inputs=inputs, - ) - - -def run_with_trigger(): - if len(sys.argv) < 2: - raise ValueError("No trigger payload provided.") - - crew, inputs = _load() - trigger_payload = json.loads(sys.argv[1]) - return crew.kickoff( - inputs={{**inputs, "crewai_trigger_payload": trigger_payload}} - ) -""" - - -def _ensure_project_scripts(root: Path, package_name: str) -> None: - """Ensure generated wrappers have project script entrypoints.""" - pyproject_path = root / "pyproject.toml" - if not pyproject_path.is_file(): - return - - content = pyproject_path.read_text(encoding="utf-8") - entries = _project_script_entries(package_name) - pyproject_path.write_text( - _update_project_scripts(content, entries), - encoding="utf-8", - ) - - -def _project_script_entries(package_name: str) -> dict[str, str]: - """Return script entrypoints required by the generated JSON wrapper.""" - return { - package_name: f"{package_name}.main:run", - "run_crew": f"{package_name}.main:run", - "train": f"{package_name}.main:train", - "replay": f"{package_name}.main:replay", - "test": f"{package_name}.main:test", - "run_with_trigger": f"{package_name}.main:run_with_trigger", - } - - -def _update_project_scripts(content: str, entries: dict[str, str]) -> str: - """Add or replace generated script entries in pyproject.toml content.""" - lines = content.rstrip().splitlines() - header_index = _project_scripts_header_index(lines) - if header_index is None: - return content.rstrip() + _project_scripts_block(entries) - - end_index = _section_end_index(lines, header_index + 1) - seen: set[str] = set() - for index in range(header_index + 1, end_index): - key = _script_key(lines[index]) - if key in entries: - lines[index] = _script_line(key, entries[key]) - seen.add(key) - - missing_lines = [ - _script_line(key, value) for key, value in entries.items() if key not in seen - ] - lines[end_index:end_index] = missing_lines - return "\n".join(lines).rstrip() + "\n" - - -def _project_scripts_header_index(lines: list[str]) -> int | None: - """Return the line index of the project scripts table, if present.""" - for index, line in enumerate(lines): - if line.strip() == "[project.scripts]": - return index - return None - - -def _section_end_index(lines: list[str], start_index: int) -> int: - """Return the exclusive end index for a TOML table section.""" - for index in range(start_index, len(lines)): - if _SECTION_PATTERN.match(lines[index]): - return index - return len(lines) - - -def _script_key(line: str) -> str | None: - """Return the script key for a pyproject script line.""" - match = _SCRIPT_KEY_PATTERN.match(line) - if not match: - return None - - key = match.group("key") - if key.startswith(("'", '"')) and key.endswith(("'", '"')): - return key[1:-1] - return key - - -def _script_line(key: str, value: str) -> str: - """Render a project script TOML entry.""" - return f'{key} = "{value}"' - - -def _project_scripts_block(entries: dict[str, str]) -> str: - """Render a project scripts TOML table.""" - lines = ["", "", "[project.scripts]"] - lines.extend(_script_line(key, value) for key, value in entries.items()) - return "\n".join(lines) + "\n" diff --git a/lib/cli/src/crewai_cli/deploy/validate.py b/lib/cli/src/crewai_cli/deploy/validate.py index 544ff20cf..b76303d56 100644 --- a/lib/cli/src/crewai_cli/deploy/validate.py +++ b/lib/cli/src/crewai_cli/deploy/validate.py @@ -212,8 +212,16 @@ class DeployValidator: if crew_path is None: return self.results + agents_dir = self.project_root / "agents" + + self._check_pyproject() + self._check_lockfile() + agents_dir_ok = self._check_json_agents_dir(agents_dir) + + project = None try: - project = validate_crew_project(crew_path, self.project_root / "agents") + if agents_dir_ok: + project = validate_crew_project(crew_path, agents_dir) except JSONProjectValidationError as e: self._add( Severity.ERROR, @@ -232,15 +240,27 @@ class DeployValidator: ) return self.results - agents_dir = self.project_root / "agents" - - self._check_pyproject() - self._check_lockfile() - self._check_env_vars_json(crew_path, agents_dir, project.agent_names) + if project is not None: + self._check_env_vars_json(crew_path, agents_dir, project.agent_names) self._check_version_vs_lockfile() return self.results + def _check_json_agents_dir(self, agents_dir: Path) -> bool: + if agents_dir.is_dir(): + return True + self._add( + Severity.ERROR, + "missing_agents_dir", + "Cannot find agents/ directory", + detail=( + "JSON crew projects load agent definitions from " + f"{agents_dir.relative_to(self.project_root)}/*.jsonc or *.json." + ), + hint="Create agents/ and add one JSON or JSONC file per agent.", + ) + return False + def _check_env_vars_json( self, crew_path: Path, agents_dir: Path, agent_names: list[str] ) -> None: diff --git a/lib/cli/tests/deploy/test_archive.py b/lib/cli/tests/deploy/test_archive.py index 1d0e2cc1a..dff77a29b 100644 --- a/lib/cli/tests/deploy/test_archive.py +++ b/lib/cli/tests/deploy/test_archive.py @@ -132,7 +132,7 @@ def test_create_project_zip_excludes_symlinked_files(tmp_path: Path): assert names == {"pyproject.toml"} -def test_create_project_zip_adds_json_project_wrapper(tmp_path: Path): +def test_create_project_zip_preserves_json_project_shape(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( """ [project] @@ -157,8 +157,6 @@ type = "crew" try: with zipfile.ZipFile(archive_path) as archive: names = set(archive.namelist()) - crew_py = archive.read("src/json_crew/crew.py").decode() - main_py = archive.read("src/json_crew/main.py").decode() pyproject = archive.read("pyproject.toml").decode() finally: archive_path.unlink(missing_ok=True) @@ -166,18 +164,50 @@ type = "crew" assert "uv.lock" not in names assert "crew.jsonc" in names assert "agents/researcher.jsonc" in names - assert "src/json_crew/__init__.py" in names - assert "src/json_crew/crew.py" in names - assert "src/json_crew/main.py" in names - assert "src/json_crew/config/agents.yaml" in names - assert "src/json_crew/config/tasks.yaml" in names - assert "load_crew(_crew_path())" in crew_py - assert "JsonCrew" in crew_py - assert "from json_crew.crew import JsonCrew" in main_py - assert "run_crew = \"json_crew.main:run\"" in pyproject + assert all(not name.startswith("src/") for name in names) + assert "run_crew" not in pyproject + assert "json_crew =" not in pyproject + assert "[project.scripts]" not in pyproject -def test_create_project_zip_updates_existing_json_project_scripts(tmp_path: Path): +def test_create_project_zip_keeps_json_project_root_shape(tmp_path: Path): + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "json_crew" +version = "0.1.0" +dependencies = ["crewai[tools]==1.14.8a1"] + +[tool.crewai] +type = "crew" +""".strip() + + "\n" + ) + (tmp_path / "uv.lock").write_text("# lock\n") + (tmp_path / "agents").mkdir() + (tmp_path / "agents" / "foo.jsonc").write_text("{}\n") + (tmp_path / "crew.jsonc").write_text("{}\n") + + archive_path = create_project_zip("json_crew", project_dir=tmp_path) + try: + with zipfile.ZipFile(archive_path) as archive: + names = set(archive.namelist()) + pyproject = archive.read("pyproject.toml").decode() + finally: + archive_path.unlink(missing_ok=True) + + assert names == { + "agents/foo.jsonc", + "crew.jsonc", + "pyproject.toml", + "uv.lock", + } + assert "run_crew" not in pyproject + assert "json_crew =" not in pyproject + assert "[project.scripts]" not in pyproject + + +def test_create_project_zip_does_not_rewrite_json_project_scripts(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( """ [project] @@ -203,14 +233,10 @@ type = "crew" finally: archive_path.unlink(missing_ok=True) - assert 'json_crew = "json_crew.main:run"' in pyproject - assert 'run_crew = "json_crew.main:run"' in pyproject - assert 'train = "json_crew.main:train"' in pyproject - assert 'replay = "json_crew.main:replay"' in pyproject - assert 'test = "json_crew.main:test"' in pyproject - assert 'run_with_trigger = "json_crew.main:run_with_trigger"' in pyproject + assert 'json_crew = "old.module:run"' in pyproject + assert 'run_crew = "old.module:run"' in pyproject assert 'custom = "custom.module:main"' in pyproject - assert "old.module:run" not in pyproject + assert pyproject.count("[project.scripts]") == 1 assert "[tool.crewai]" in pyproject @@ -221,7 +247,7 @@ type = "crew" '[tool]\ncrewai = "invalid"\n', ], ) -def test_create_project_zip_adds_json_wrapper_for_malformed_tool_config( +def test_create_project_zip_preserves_json_project_with_malformed_tool_config( tmp_path: Path, tool_config: str ): (tmp_path / "pyproject.toml").write_text( @@ -244,12 +270,13 @@ version = "0.1.0" finally: archive_path.unlink(missing_ok=True) - assert "src/json_crew/crew.py" in names - assert "src/json_crew/main.py" in names - assert "run_crew = \"json_crew.main:run\"" in pyproject + assert names == {"crew.jsonc", "pyproject.toml"} + assert "run_crew" not in pyproject + assert "json_crew =" not in pyproject + assert "[project.scripts]" not in pyproject -def test_create_project_zip_rejects_empty_normalized_package_name(tmp_path: Path): +def test_create_project_zip_accepts_json_project_without_package_name(tmp_path: Path): (tmp_path / "pyproject.toml").write_text( """ [project] @@ -263,8 +290,15 @@ type = "crew" ) (tmp_path / "crew.jsonc").write_text("{}\n") - with pytest.raises( - ValueError, - match=r"Could not derive a valid Python package name", - ): - create_project_zip("invalid", project_dir=tmp_path) + archive_path = create_project_zip("invalid", project_dir=tmp_path) + try: + with zipfile.ZipFile(archive_path) as archive: + names = set(archive.namelist()) + pyproject = archive.read("pyproject.toml").decode() + finally: + archive_path.unlink(missing_ok=True) + + assert names == {"crew.jsonc", "pyproject.toml"} + assert "run_crew" not in pyproject + assert "json_crew =" not in pyproject + assert "[project.scripts]" not in pyproject diff --git a/lib/cli/tests/deploy/test_validate.py b/lib/cli/tests/deploy/test_validate.py index 07ceef14f..1176fbbdf 100644 --- a/lib/cli/tests/deploy/test_validate.py +++ b/lib/cli/tests/deploy/test_validate.py @@ -200,6 +200,41 @@ def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None: assert "runtime-only" in finding.detail +def test_json_crew_requires_agents_dir_without_classic_errors(tmp_path: Path) -> None: + _scaffold_json_crew(tmp_path) + for path in (tmp_path / "agents").iterdir(): + path.unlink() + (tmp_path / "agents").rmdir() + + v = DeployValidator(project_root=tmp_path) + v.run() + + codes = _codes(v) + assert "missing_agents_dir" in codes + assert "missing_src_dir" not in codes + assert "missing_crew_py" not in codes + assert "missing_agents_yaml" not in codes + assert "missing_tasks_yaml" not in codes + + +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') + + v = DeployValidator(project_root=tmp_path) + 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 + + def test_missing_pyproject_errors(tmp_path: Path) -> None: v = _run_without_import_check(tmp_path) assert "missing_pyproject" in _codes(v) diff --git a/lib/cli/tests/test_create_crew.py b/lib/cli/tests/test_create_crew.py index adfe1757f..5d87f8f3e 100644 --- a/lib/cli/tests/test_create_crew.py +++ b/lib/cli/tests/test_create_crew.py @@ -712,8 +712,26 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch): default_llm="openai/gpt-5.5", ) assert (tmp_path / "json_crew" / "crew.jsonc").exists() + assert not (tmp_path / "json_crew" / "src").exists() assert not (tmp_path / "json_crew" / "tests").exists() assert not (tmp_path / "json_crew" / "config.jsonc").exists() + generated_paths = { + path.relative_to(tmp_path / "json_crew").as_posix() + for path in (tmp_path / "json_crew").rglob("*") + if path.is_file() + } + assert not any( + path.endswith("/crew.py") or path == "crew.py" for path in generated_paths + ) + assert not any( + path.endswith("/agents.yaml") or path == "agents.yaml" + for path in generated_paths + ) + assert not any( + path.endswith("/tasks.yaml") or path == "tasks.yaml" + for path in generated_paths + ) + assert not any(path.startswith("src/") for path in generated_paths) pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text()) dependency = pyproject["project"]["dependencies"][0] @@ -849,7 +867,7 @@ def test_json_create_dmn_mode_uses_non_interactive_defaults(tmp_path, monkeypatc crew_template = (project_root / "crew.jsonc").read_text() agent_template = (project_root / "agents" / "researcher.jsonc").read_text() - assert '"memory": false' in crew_template + assert '"memory": true' in crew_template assert '"description": "Research current AI trends and write a concise summary."' in ( crew_template ) diff --git a/pyproject.toml b/pyproject.toml index d36586f4a..845e365ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dev = [ "pytest==9.0.3", "pytest-asyncio==1.3.0", "pytest-subprocess==1.5.3", - "vcrpy==7.0.0", # pinned, less versions break pytest-recording + "vcrpy==8.2.1", # pinned, lower versions break pytest-recording "pytest-recording==0.13.4", "pytest-randomly==4.0.1", "pytest-timeout==2.4.0", @@ -171,8 +171,8 @@ info = "Commits must follow Conventional Commits 1.0.0." [tool.uv] exclude-newer = "3 days" -# pypdf 6.13.3 is a security fix newer than the global supply-chain cutoff. -exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" } +# These security fixes are newer than the global supply-chain cutoff. +exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z", msgpack = "2026-06-20T00:00:00Z", pydantic-settings = "2026-06-20T00:00:00Z", langsmith = "2026-06-20T00:00:00Z" } # composio-core pins rich<14 but textual requires rich>=14. # onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10. @@ -188,7 +188,8 @@ exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" } # python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers). # gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath). # urllib3 <2.7.0 has GHSA-qccp-gfcp-xxvc (ProxyManager cross-origin redirect leaks Authorization/Cookie) and GHSA-mf9v-mfxr-j63j (streaming decompression-bomb bypass); force 2.7.0+. -# langsmith <0.8.0 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure); force 0.8.0+. +# langsmith <0.8.18 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure) +# and GHSA-f4xh-w4cj-qxq8; force 0.8.18+. # authlib <1.6.12 has GHSA-jj8c-mmj3-mmgv (CSRF bypass in cache-based state storage) and PYSEC-2026-188. # pip 26.1.1 has PYSEC-2026-196; force 26.1.2+. # aiohttp <=3.13.x has GHSA-jg22-mg44-37j8, GHSA-hg6j-4rv6-33pg; fixed in 3.14.0; force 3.14.0+. @@ -196,6 +197,8 @@ exclude-newer-package = { pypdf = "2026-06-18T00:00:00Z" } # pip <26.1.1 has GHSA-58qw-9mgm-455v (archive handling); OSV considers 26.1.1 unaffected. # paramiko <5.0.0 has GHSA-r374-rxx8-8654 (SHA-1 in rsakey.py); OSV considers 5.0.0 unaffected. Transitive via composio-core. # starlette <1.3.1 has PYSEC-2026-161, GHSA-jp82-jpqv-5vv3, and GHSA-82w8-qh3p-5jfq. Transitive via fastapi. +# msgpack <1.2.1 has GHSA-6v7p-g79w-8964; transitive via pip-audit[filecache]. +# pydantic-settings <2.14.2 has GHSA-4xgf-cpjx-pc3j. # Keep OpenAI on the SDK range required by CrewAI when transitive dependencies # loosen or pin their own lower versions. override-dependencies = [ @@ -212,16 +215,17 @@ override-dependencies = [ "uv>=0.11.15,<1", "python-multipart>=0.0.27,<1", "gitpython>=3.1.50,<4", - "langsmith>=0.8.0,<1", + "langsmith>=0.8.18,<1", "authlib>=1.6.12", "pip>=26.1.2", "aiohttp>=3.14.0", # [chunking] carried here because override-dependencies replace the whole # requirement; without it the docling extra's chunking deps get stripped. "docling-core[chunking]>=2.74.1", - "pydantic-settings>=2.14.0", "paramiko>=5.0.0", "starlette>=1.3.1", + "msgpack>=1.2.1", + "pydantic-settings>=2.14.2", ] [tool.uv.workspace] diff --git a/uv.lock b/uv.lock index d8954d3ed..b623014c8 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,10 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P3D" [options.exclude-newer-package] +msgpack = "2026-06-20T00:00:00Z" +langsmith = "2026-06-20T00:00:00Z" pypdf = "2026-06-18T00:00:00Z" +pydantic-settings = "2026-06-20T00:00:00Z" [manifest] members = [ @@ -36,13 +39,14 @@ overrides = [ { name = "gitpython", specifier = ">=3.1.50,<4" }, { name = "langchain-core", specifier = ">=1.3.3,<2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2,<2" }, - { name = "langsmith", specifier = ">=0.8.0,<1" }, + { name = "langsmith", specifier = ">=0.8.18,<1" }, + { name = "msgpack", specifier = ">=1.2.1" }, { name = "onnxruntime", marker = "python_full_version < '3.11'", specifier = "<1.24" }, { name = "openai", specifier = ">=2.30.0,<3" }, { name = "paramiko", specifier = ">=5.0.0" }, { name = "pillow", specifier = ">=12.1.1" }, { name = "pip", specifier = ">=26.1.2" }, - { name = "pydantic-settings", specifier = ">=2.14.0" }, + { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "pypdf", specifier = ">=6.13.3,<7" }, { name = "python-multipart", specifier = ">=0.0.27,<1" }, { name = "rich", specifier = ">=13.7.1" }, @@ -77,7 +81,7 @@ dev = [ { name = "types-redis", specifier = "~=4.6" }, { name = "types-regex", specifier = "==2026.1.15.*" }, { name = "types-requests", specifier = "~=2.31.0.6" }, - { name = "vcrpy", specifier = "==7.0.0" }, + { name = "vcrpy", specifier = "==8.2.1" }, ] [[package]] @@ -3946,7 +3950,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2 [[package]] name = "langsmith" -version = "0.8.11" +version = "0.8.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3960,9 +3964,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/0d/082410ece26ff9f3ed4f87b014a8675be47cbd7d65f06b922045dfc21c47/langsmith-0.8.11.tar.gz", hash = "sha256:d9b3496f8f7ca63f4f2d1dfd368afd6c527923fff2ce4026c82ce85f37db3965", size = 4495842, upload-time = "2026-06-08T22:54:44.395Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/65/f9c9dc19b21a9076286fafdb0ab732c9019ddf71aa7e7d720a830a98fe2a/langsmith-0.8.11-py3-none-any.whl", hash = "sha256:08aa5e84b00703ecc11dbeafda78d84b92da4e8c6114e0be9b59df9e71afc59b", size = 478985, upload-time = "2026-06-08T22:54:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, ] [[package]] @@ -4702,45 +4706,53 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.2" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/a2/3b68a9e769db68668b25c6108444a35f9bd163bb848c0650d516761a59c0/msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2", size = 81318, upload-time = "2025-10-08T09:14:38.722Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/2b720cc341325c00be44e1ed59e7cfeae2678329fbf5aa68f5bda57fe728/msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87", size = 83786, upload-time = "2025-10-08T09:14:40.082Z" }, - { url = "https://files.pythonhosted.org/packages/71/e5/c2241de64bfceac456b140737812a2ab310b10538a7b34a1d393b748e095/msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251", size = 398240, upload-time = "2025-10-08T09:14:41.151Z" }, - { url = "https://files.pythonhosted.org/packages/b7/09/2a06956383c0fdebaef5aa9246e2356776f12ea6f2a44bd1368abf0e46c4/msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a", size = 406070, upload-time = "2025-10-08T09:14:42.821Z" }, - { url = "https://files.pythonhosted.org/packages/0e/74/2957703f0e1ef20637d6aead4fbb314330c26f39aa046b348c7edcf6ca6b/msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f", size = 393403, upload-time = "2025-10-08T09:14:44.38Z" }, - { url = "https://files.pythonhosted.org/packages/a5/09/3bfc12aa90f77b37322fc33e7a8a7c29ba7c8edeadfa27664451801b9860/msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f", size = 398947, upload-time = "2025-10-08T09:14:45.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4f/05fcebd3b4977cb3d840f7ef6b77c51f8582086de5e642f3fefee35c86fc/msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9", size = 64769, upload-time = "2025-10-08T09:14:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3e/b4547e3a34210956382eed1c85935fff7e0f9b98be3106b3745d7dec9c5e/msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa", size = 71293, upload-time = "2025-10-08T09:14:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, - { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, - { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" }, + { url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" }, + { url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, ] [[package]] @@ -6989,16 +7001,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.14.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -9698,17 +9710,15 @@ wheels = [ [[package]] name = "vcrpy" -version = "7.0.0" +version = "8.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, - { name = "urllib3" }, { name = "wrapt" }, - { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/d3/856e06184d4572aada1dd559ddec3bedc46df1f2edc5ab2c91121a2cccdb/vcrpy-7.0.0.tar.gz", hash = "sha256:176391ad0425edde1680c5b20738ea3dc7fb942520a48d2993448050986b3a50", size = 85502, upload-time = "2024-12-31T00:07:57.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/db/08183b845b0040bb877dad2bd7e4e0976fc232bb3476d7ee369c6c4f8b5a/vcrpy-8.2.1.tar.gz", hash = "sha256:d73a6e4eb6dae8148e659764b7a00e68cc51ba29ba9e6a85e1f0790ad96b97df", size = 90511, upload-time = "2026-06-16T13:20:52.906Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/5d/1f15b252890c968d42b348d1e9b0aa12d5bf3e776704178ec37cceccdb63/vcrpy-7.0.0-py2.py3-none-any.whl", hash = "sha256:55791e26c18daa363435054d8b35bd41a4ac441b6676167635d1b37a71dbe124", size = 42321, upload-time = "2024-12-31T00:07:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7c/0e812ab83f5289404c674f3461ba783250b967d34b5ab034d361236ec042/vcrpy-8.2.1-py3-none-any.whl", hash = "sha256:7ce58c9e2792b246f79d6f4b3e9660676cc6f853be17e1547305b4437ab1ff85", size = 44925, upload-time = "2026-06-16T13:20:51.734Z" }, ] [[package]] From 0391febc6cbdde16b5664641c99f8036a8f9466e Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Mon, 22 Jun 2026 14:04:45 -0700 Subject: [PATCH 02/22] Allow `@router()` as start method of a flow (#6288) This commit fixes a bug where a router method could not be the start method of a flow. This is useful when you want to route against the initial state, or even stack two routers. --- lib/crewai/src/crewai/flow/dsl/_listen.py | 4 +- lib/crewai/src/crewai/flow/dsl/_router.py | 22 +++--- lib/crewai/src/crewai/flow/dsl/_start.py | 4 +- lib/crewai/src/crewai/flow/dsl/_utils.py | 19 +++++ lib/crewai/src/crewai/flow/flow_definition.py | 8 -- .../src/crewai/flow/runtime/__init__.py | 8 +- lib/crewai/tests/test_flow.py | 48 ++++++++++++ lib/crewai/tests/test_flow_definition.py | 73 +++++++++++++++---- 8 files changed, 150 insertions(+), 36 deletions(-) diff --git a/lib/crewai/src/crewai/flow/dsl/_listen.py b/lib/crewai/src/crewai/flow/dsl/_listen.py index 37c9a9d25..b964532a2 100644 --- a/lib/crewai/src/crewai/flow/dsl/_listen.py +++ b/lib/crewai/src/crewai/flow/dsl/_listen.py @@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger from crewai.flow.dsl._utils import ( P, R, + _merge_flow_method_definition, _method_action, - _set_flow_method_definition, ) from crewai.flow.flow_definition import FlowMethodDefinition from crewai.flow.flow_wrappers import ListenMethod @@ -45,7 +45,7 @@ def listen(condition: FlowTrigger) -> FlowMethodDecorator: def decorator(func: Callable[P, R]) -> ListenMethod[P, R]: wrapper = ListenMethod(func) - _set_flow_method_definition( + _merge_flow_method_definition( wrapper, FlowMethodDefinition( do=_method_action(func), diff --git a/lib/crewai/src/crewai/flow/dsl/_router.py b/lib/crewai/src/crewai/flow/dsl/_router.py index 3edbf33ba..7f9941e42 100644 --- a/lib/crewai/src/crewai/flow/dsl/_router.py +++ b/lib/crewai/src/crewai/flow/dsl/_router.py @@ -19,8 +19,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger from crewai.flow.dsl._utils import ( P, R, + _merge_flow_method_definition, _method_action, - _set_flow_method_definition, ) from crewai.flow.flow_definition import FlowMethodDefinition from crewai.flow.flow_wrappers import RouterMethod @@ -95,7 +95,7 @@ def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]: def router( - condition: FlowTrigger, + condition: FlowTrigger | None = None, *, emit: Sequence[str] | str | None = None, ) -> FlowMethodDecorator: @@ -107,6 +107,7 @@ def router( Args: condition: Specifies when the router should execute. Can be: + - None: no listen trigger, used when stacking with @start() or @listen() - str: Route label or method name that triggers this router - FlowCondition: Result from or_() or and_(), including nested conditions - Flow method reference: A method whose completion triggers this router @@ -146,14 +147,17 @@ def router( else: router_events = _get_router_return_events(func) or [] - _set_flow_method_definition( + method_definition_kwargs: dict[str, Any] = { + "do": _method_action(func), + "router": True, + "emit": router_events or None, + } + if condition is not None: + method_definition_kwargs["listen"] = _to_definition_condition(condition) + + _merge_flow_method_definition( wrapper, - FlowMethodDefinition( - do=_method_action(func), - listen=_to_definition_condition(condition), - router=True, - emit=router_events or None, - ), + FlowMethodDefinition(**method_definition_kwargs), ) return wrapper diff --git a/lib/crewai/src/crewai/flow/dsl/_start.py b/lib/crewai/src/crewai/flow/dsl/_start.py index fe9f82974..5a41bc9df 100644 --- a/lib/crewai/src/crewai/flow/dsl/_start.py +++ b/lib/crewai/src/crewai/flow/dsl/_start.py @@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger from crewai.flow.dsl._utils import ( P, R, + _merge_flow_method_definition, _method_action, - _set_flow_method_definition, ) from crewai.flow.flow_definition import FlowMethodDefinition from crewai.flow.flow_wrappers import StartMethod @@ -54,7 +54,7 @@ def start( def decorator(func: Callable[P, R]) -> StartMethod[P, R]: wrapper = StartMethod(func) - _set_flow_method_definition( + _merge_flow_method_definition( wrapper, FlowMethodDefinition( do=_method_action(func), diff --git a/lib/crewai/src/crewai/flow/dsl/_utils.py b/lib/crewai/src/crewai/flow/dsl/_utils.py index 99d60a9e3..684264b28 100644 --- a/lib/crewai/src/crewai/flow/dsl/_utils.py +++ b/lib/crewai/src/crewai/flow/dsl/_utils.py @@ -106,6 +106,25 @@ def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None: return None +def _merge_flow_method_definition( + wrapper: FlowMethod[P, R], + definition: FlowMethodDefinition, +) -> None: + existing = _get_flow_method_definition(wrapper) + if existing is None: + _set_flow_method_definition(wrapper, definition) + return + + updates = { + field_name: getattr(definition, field_name) + for field_name in definition.model_fields_set + } + _set_flow_method_definition( + wrapper, + existing.model_copy(deep=True, update=updates), + ) + + def _is_json_serializable(value: Any) -> bool: try: json.dumps(value) diff --git a/lib/crewai/src/crewai/flow/flow_definition.py b/lib/crewai/src/crewai/flow/flow_definition.py index a566fcadd..5c277d3ce 100644 --- a/lib/crewai/src/crewai/flow/flow_definition.py +++ b/lib/crewai/src/crewai/flow/flow_definition.py @@ -870,14 +870,6 @@ def _validate_action_cel( def log_flow_definition_issues(definition: FlowDefinition) -> None: for method_name, method in definition.methods.items(): path = f"methods.{method_name}" - if method.router and not method.is_start and method.listen is None: - _log_flow_definition_issue( - definition.name, - code="router_without_trigger", - severity="error", - path=path, - message="router: true requires either start or listen", - ) if method.emit and not method.router: _log_flow_definition_issue( definition.name, diff --git a/lib/crewai/src/crewai/flow/runtime/__init__.py b/lib/crewai/src/crewai/flow/runtime/__init__.py index 85f150546..fa465cb71 100644 --- a/lib/crewai/src/crewai/flow/runtime/__init__.py +++ b/lib/crewai/src/crewai/flow/runtime/__init__.py @@ -3007,6 +3007,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): """ # First, handle routers repeatedly until no router triggers anymore router_results = [] + router_result_payloads: dict[str, Any] = {} router_result_to_feedback: dict[ str, Any ] = {} # Map outcome -> HumanFeedbackResult @@ -3044,6 +3045,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): router_result_str = str(router_result) router_result_event = FlowMethodName(router_result_str) router_results.append(router_result_event) + router_result_payloads[router_result_str] = ( + self.last_human_feedback + if self.last_human_feedback is not None + else router_result + ) if self.last_human_feedback is not None: router_result_to_feedback[router_result_str] = ( @@ -3064,7 +3070,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): current_trigger, router_only=False ) if listeners_triggered: - listener_result = router_result_to_feedback.get( + listener_result = router_result_payloads.get( str(current_trigger), result ) racing_group = self._get_racing_group_for_listeners( diff --git a/lib/crewai/tests/test_flow.py b/lib/crewai/tests/test_flow.py index e7bae8023..d0d0045b9 100644 --- a/lib/crewai/tests/test_flow.py +++ b/lib/crewai/tests/test_flow.py @@ -386,6 +386,54 @@ def test_router_runtime_uses_flow_definition_without_legacy_router_metadata(): assert execution_order == ["begin", "decide", "handle_left"] +def test_start_router_runtime_routes_public_dsl_return_value(): + execution_order = [] + + class StartRouterFlow(Flow): + @start() + @router(emit=["continue"]) + def decide(self): + execution_order.append("decide") + return "continue" + + @listen("continue") + def handle_continue(self, result): + execution_order.append(f"handle_continue:{result}") + return "done" + + assert StartRouterFlow().kickoff() == "done" + assert execution_order == ["decide", "handle_continue:continue"] + + +def test_start_router_runtime_chains_to_stacked_listener_router(): + execution_order = [] + + class ChainedStartRouterFlow(Flow): + @start() + @router(emit=["approved", "not_approved"]) + def first_router(self): + execution_order.append("first_router") + return "approved" + + @listen("approved") + @router(emit=["second_approval", "not_approved"]) + def second_router(self): + execution_order.append("second_router") + return "second_approval" + + @listen("second_approval") + def handle_second_approval(self, result): + execution_order.append(f"handle_second_approval:{result}") + return "done" + + assert ChainedStartRouterFlow().kickoff() == "done" + assert execution_order == [ + "first_router", + "second_router", + "handle_second_approval:second_approval", + ] + + def test_router_falsy_result_emits_runtime_event(): execution_order = [] diff --git a/lib/crewai/tests/test_flow_definition.py b/lib/crewai/tests/test_flow_definition.py index 4b5b8d37b..2aa654151 100644 --- a/lib/crewai/tests/test_flow_definition.py +++ b/lib/crewai/tests/test_flow_definition.py @@ -565,6 +565,54 @@ def test_flow_definition_classifies_start_router_from_human_feedback_emit(): assert entry_point.emit is None +def test_flow_definition_classifies_public_dsl_start_router(): + class StartRouterFlow(Flow): + @start() + @router(emit=["continue", "stop"]) + def entry_point(self): + return "continue" + + @router(emit=["resume"]) + @start() + def alternate_entry_point(self): + return "resume" + + entry_point = StartRouterFlow.flow_definition().methods["entry_point"] + alternate_entry_point = StartRouterFlow.flow_definition().methods[ + "alternate_entry_point" + ] + + assert entry_point.is_start is True + assert entry_point.router is True + assert entry_point.listen is None + assert entry_point.emit == ["continue", "stop"] + assert alternate_entry_point.is_start is True + assert alternate_entry_point.router is True + assert alternate_entry_point.listen is None + assert alternate_entry_point.emit == ["resume"] + + +def test_flow_definition_merges_stacked_listen_router(): + class ChainedRouterFlow(Flow): + @start() + @router(emit=["approved", "not_approved"]) + def first_router(self): + return "approved" + + @listen("approved") + @router(emit=["second_approval", "not_approved"]) + def second_router(self): + return "second_approval" + + methods = ChainedRouterFlow.flow_definition().methods + + assert methods["first_router"].is_start is True + assert methods["first_router"].listen is None + assert methods["second_router"].router is True + assert methods["second_router"].listen == "approved" + assert methods["second_router"].emit == ["second_approval", "not_approved"] + + def test_flow_definition_round_trips_json_and_yaml(): class RoundTripFlow(Flow): @start() @@ -883,7 +931,7 @@ def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract(): assert "diagnostics" not in definition.to_dict() -def test_router_start_false_without_listen_logs_missing_trigger(caplog): +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( @@ -901,12 +949,7 @@ def test_router_start_false_without_listen_logs_missing_trigger(caplog): } ) - assert any( - record.levelno == logging.ERROR - and "router_without_trigger" in record.message - and "methods.decision" in record.message - for record in caplog.records - ) + assert not caplog.records def test_router_human_feedback_preserves_existing_router_metadata(): @@ -1048,7 +1091,7 @@ def test_flow_definition_cache_is_not_reused_by_subclasses(): assert set(child_definition.methods) == {"child_step"} -def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog): +def test_flow_definition_allows_router_without_trigger(caplog): caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition") flow_definition.FlowDefinition.from_dict( @@ -1065,9 +1108,11 @@ def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog } ) - assert any( - record.levelno == logging.ERROR - and "LoadedFlow" in record.message - and "router_without_trigger" in record.message - for record in caplog.records - ) + class StandaloneRouterFlow(Flow): + @router(emit=["continue"]) + def decision(self): + return "continue" + + StandaloneRouterFlow.flow_definition() + + assert not caplog.records From 4b2ce00a09dd58b48ebe305e372782e09acc6333 Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Mon, 22 Jun 2026 19:58:17 -0700 Subject: [PATCH 03/22] Add declarative Flow CLI support (#6294) * Add declarative Flow CLI support Currently, declarative flows can be loaded by the runtime, but the CLI still treats them as an experimental definition file instead of a first-class Flow project shape. With this PR, `crewai create flow --declarative` scaffolds a YAML-backed Flow project, and `crewai run`, `crewai flow kickoff`, and `crewai flow plot` can run against the configured definition. This also lets crew actions reference reusable crew definition files or folders and override their inputs from the Flow definition, so declarative flows can compose existing declarative crews without inlining everything. * Address code review comments --- lib/cli/src/crewai_cli/cli.py | 25 ++- lib/cli/src/crewai_cli/create_flow.py | 55 ++++- lib/cli/src/crewai_cli/kickoff_flow.py | 30 ++- lib/cli/src/crewai_cli/plot_flow.py | 30 ++- .../src/crewai_cli/run_declarative_flow.py | 212 ++++++++++++++++++ lib/cli/src/crewai_cli/run_flow_definition.py | 113 ---------- .../templates/declarative_flow/.gitignore | 5 + .../templates/declarative_flow/README.md | 17 ++ .../templates/declarative_flow/flow.yaml | 15 ++ .../templates/declarative_flow/pyproject.toml | 20 ++ lib/cli/tests/test_cli.py | 31 ++- lib/cli/tests/test_create_flow.py | 37 +++ lib/cli/tests/test_flow_commands.py | 103 +++++++++ lib/cli/tests/test_run_declarative_flow.py | 111 +++++++++ lib/cli/tests/test_run_flow_definition.py | 156 ------------- lib/crewai/src/crewai/flow/flow_definition.py | 83 +++++-- .../src/crewai/flow/runtime/_actions.py | 53 ++++- lib/crewai/tests/test_flow_from_definition.py | 189 +++++++++++++++- 18 files changed, 951 insertions(+), 334 deletions(-) create mode 100644 lib/cli/src/crewai_cli/run_declarative_flow.py delete mode 100644 lib/cli/src/crewai_cli/run_flow_definition.py create mode 100644 lib/cli/src/crewai_cli/templates/declarative_flow/.gitignore create mode 100644 lib/cli/src/crewai_cli/templates/declarative_flow/README.md create mode 100644 lib/cli/src/crewai_cli/templates/declarative_flow/flow.yaml create mode 100644 lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml create mode 100644 lib/cli/tests/test_create_flow.py create mode 100644 lib/cli/tests/test_flow_commands.py create mode 100644 lib/cli/tests/test_run_declarative_flow.py delete mode 100644 lib/cli/tests/test_run_flow_definition.py diff --git a/lib/cli/src/crewai_cli/cli.py b/lib/cli/src/crewai_cli/cli.py index b153885f3..1a64a74f3 100644 --- a/lib/cli/src/crewai_cli/cli.py +++ b/lib/cli/src/crewai_cli/cli.py @@ -40,12 +40,12 @@ def replay_task_command(*args: Any, **kwargs: Any) -> Any: return _replay_task_command(*args, **kwargs) -def run_flow_definition(*args: Any, **kwargs: Any) -> Any: - from crewai_cli.run_flow_definition import ( - run_flow_definition as _run_flow_definition, +def run_declarative_flow(*args: Any, **kwargs: Any) -> Any: + from crewai_cli.run_declarative_flow import ( + run_declarative_flow as _run_declarative_flow, ) - return _run_flow_definition(*args, **kwargs) + return _run_declarative_flow(*args, **kwargs) def run_crew(*args: Any, **kwargs: Any) -> Any: @@ -155,12 +155,18 @@ def uv(uv_args: tuple[str, ...]) -> None: is_flag=True, help="Use classic Python/YAML project structure instead of JSON", ) +@click.option( + "--declarative", + is_flag=True, + help="Create a declarative Flow project instead of a Python Flow project", +) def create( type: str | None, name: str | None, provider: str | None, skip_provider: bool = False, classic: bool = False, + declarative: bool = False, ) -> None: """Create a new crew, or flow.""" dmn_mode = is_dmn_mode_enabled() @@ -194,6 +200,8 @@ def create( if dmn_mode: skip_provider = True if type == "crew": + if declarative: + raise click.UsageError("--declarative can only be used with flow projects") if classic: from crewai_cli.create_crew import create_crew @@ -205,7 +213,7 @@ def create( elif type == "flow": from crewai_cli.create_flow import create_flow - create_flow(name) + create_flow(name, declarative=declarative) else: click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red") @@ -512,10 +520,7 @@ def install(context: click.Context) -> None: "--definition", type=str, default=None, - help=( - "Experimental: path to a Flow Definition YAML/JSON file, " - "or an inline YAML/JSON string." - ), + help="Experimental: path to a declarative Flow YAML/JSON file.", ) @click.option( "--inputs", @@ -537,7 +542,7 @@ def run( "Warning: `crewai run --definition` is experimental and may change without notice.", fg="yellow", ) - run_flow_definition(definition=definition, inputs=inputs) + run_declarative_flow(definition=definition, inputs=inputs) return run_crew(trained_agents_file=trained_agents_file) diff --git a/lib/cli/src/crewai_cli/create_flow.py b/lib/cli/src/crewai_cli/create_flow.py index 5042d7679..adaa3d3bf 100644 --- a/lib/cli/src/crewai_cli/create_flow.py +++ b/lib/cli/src/crewai_cli/create_flow.py @@ -5,7 +5,10 @@ import click from crewai_core.telemetry import Telemetry -def create_flow(name: str) -> None: +DECLARATIVE_FLOW_FOLDERS = ("crews", "tools", "knowledge", "skills") + + +def create_flow(name: str, *, declarative: bool = False) -> None: """Create a new flow.""" folder_name = name.replace(" ", "_").replace("-", "_").lower() class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "") @@ -20,6 +23,17 @@ def create_flow(name: str) -> None: telemetry = Telemetry() telemetry.flow_creation_span(class_name) + if declarative: + _create_declarative_flow(name, class_name, folder_name, project_root) + else: + _create_python_flow(name, class_name, folder_name, project_root) + + click.secho(f"Flow {name} created successfully!", fg="green", bold=True) + + +def _create_python_flow( + name: str, class_name: str, folder_name: str, project_root: Path +) -> None: (project_root / "src" / folder_name).mkdir(parents=True) (project_root / "src" / folder_name / "crews").mkdir(parents=True) (project_root / "src" / folder_name / "tools").mkdir(parents=True) @@ -92,4 +106,41 @@ def create_flow(name: str) -> None: fg="yellow", ) - click.secho(f"Flow {name} created successfully!", fg="green", bold=True) + +def _create_declarative_flow( + name: str, class_name: str, folder_name: str, project_root: Path +) -> None: + project_root.mkdir(parents=True) + package_root = project_root / "src" / folder_name + package_root.mkdir(parents=True) + for folder in DECLARATIVE_FLOW_FOLDERS: + (package_root / folder).mkdir() + + package_dir = Path(__file__).parent + templates_dir = package_dir / "templates" / "declarative_flow" + + agents_md_src = package_dir / "templates" / "AGENTS.md" + if agents_md_src.exists(): + shutil.copy2(agents_md_src, project_root / "AGENTS.md") + + for src_file in templates_dir.rglob("*"): + if not src_file.is_file(): + continue + + relative_path = src_file.relative_to(templates_dir) + dst_file = ( + project_root / relative_path + if relative_path.name in {".gitignore", "README.md", "pyproject.toml"} + else package_root / relative_path + ) + dst_file.parent.mkdir(parents=True, exist_ok=True) + content = src_file.read_text(encoding="utf-8") + content = content.replace("{{name}}", name) + content = content.replace("{{flow_name}}", class_name) + content = content.replace("{{folder_name}}", folder_name) + dst_file.write_text(content, encoding="utf-8") + + (project_root / ".env").write_text("OPENAI_API_KEY=YOUR_API_KEY", encoding="utf-8") + (package_root / "__init__.py").write_text("", encoding="utf-8") + for folder in DECLARATIVE_FLOW_FOLDERS: + (package_root / folder / ".gitkeep").write_text("", encoding="utf-8") diff --git a/lib/cli/src/crewai_cli/kickoff_flow.py b/lib/cli/src/crewai_cli/kickoff_flow.py index b5bc0d81e..ff5f317dd 100644 --- a/lib/cli/src/crewai_cli/kickoff_flow.py +++ b/lib/cli/src/crewai_cli/kickoff_flow.py @@ -5,19 +5,27 @@ import click def kickoff_flow() -> None: """ - Kickoff the flow by running a command in the UV environment. + Kickoff the flow from declarative config or the Python UV entrypoint. """ - command = ["uv", "run", "kickoff"] + from crewai_cli.run_declarative_flow import ( + configured_project_declarative_flow, + run_declarative_flow_in_project_env, + ) - try: - result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603 + if definition := configured_project_declarative_flow(): + run_declarative_flow_in_project_env(definition=definition) + else: + command = ["uv", "run", "kickoff"] - if result.stderr: - click.echo(result.stderr, err=True) + try: + subprocess.run( # noqa: S603 + command, capture_output=False, text=True, check=True + ) - except subprocess.CalledProcessError as e: - click.echo(f"An error occurred while running the flow: {e}", err=True) - click.echo(e.output, err=True) + except subprocess.CalledProcessError as e: + click.echo(f"An error occurred while running the flow: {e}", err=True) + raise SystemExit(1) from e - except Exception as e: - click.echo(f"An unexpected error occurred: {e}", err=True) + except Exception as e: + click.echo(f"An unexpected error occurred: {e}", err=True) + raise SystemExit(1) from e diff --git a/lib/cli/src/crewai_cli/plot_flow.py b/lib/cli/src/crewai_cli/plot_flow.py index d97ccba77..d79bdc58b 100644 --- a/lib/cli/src/crewai_cli/plot_flow.py +++ b/lib/cli/src/crewai_cli/plot_flow.py @@ -5,19 +5,27 @@ import click def plot_flow() -> None: """ - Plot the flow by running a command in the UV environment. + Plot the flow from declarative config or the Python UV entrypoint. """ - command = ["uv", "run", "plot"] + from crewai_cli.run_declarative_flow import ( + configured_project_declarative_flow, + plot_declarative_flow_in_project_env, + ) - try: - result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603 + if definition := configured_project_declarative_flow(): + plot_declarative_flow_in_project_env(definition) + else: + command = ["uv", "run", "plot"] - if result.stderr: - click.echo(result.stderr, err=True) + try: + subprocess.run( # noqa: S603 + command, capture_output=False, text=True, check=True + ) - except subprocess.CalledProcessError as e: - click.echo(f"An error occurred while plotting the flow: {e}", err=True) - click.echo(e.output, err=True) + except subprocess.CalledProcessError as e: + click.echo(f"An error occurred while plotting the flow: {e}", err=True) + raise SystemExit(1) from e - except Exception as e: - click.echo(f"An unexpected error occurred: {e}", err=True) + except Exception as e: + click.echo(f"An unexpected error occurred: {e}", err=True) + raise SystemExit(1) from e diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py new file mode 100644 index 000000000..af7431b02 --- /dev/null +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +from typing import Any + +import click + +from crewai_cli.utils import build_env_with_all_tool_credentials + + +def run_declarative_flow_in_project_env( + definition: str, inputs: str | None = None +) -> None: + """Run a declarative flow inside the project's Python environment.""" + if is_declarative_flow_project_env() or not _has_project_file(): + run_declarative_flow(definition=definition, inputs=inputs) + return + + if inputs is not None: + raise click.UsageError("--inputs is only supported with --definition") + + _execute_declarative_flow_command(["uv", "run", "crewai", "flow", "kickoff"]) + + +def plot_declarative_flow_in_project_env(definition: str) -> None: + """Plot a declarative flow inside the project's Python environment.""" + if is_declarative_flow_project_env() or not _has_project_file(): + plot_declarative_flow(definition=definition) + return + + _execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"]) + + +def run_declarative_flow(definition: str, inputs: str | None = None) -> None: + """Run a declarative flow from a YAML/JSON file path.""" + parsed_inputs = _parse_inputs(inputs) + + try: + flow = load_declarative_flow(definition) + result = flow.kickoff(inputs=parsed_inputs) + except Exception as exc: + click.echo( + f"An error occurred while running the declarative flow: {exc}", err=True + ) + raise SystemExit(1) from exc + + click.echo(_format_result(result)) + + +def plot_declarative_flow(definition: str) -> None: + """Plot a declarative flow from a YAML/JSON file path.""" + try: + flow = load_declarative_flow(definition) + flow.plot() + except Exception as exc: + click.echo( + f"An error occurred while plotting the declarative flow: {exc}", err=True + ) + raise SystemExit(1) from exc + + +def load_declarative_flow(definition: str) -> Any: + """Load a declarative Flow instance from a YAML/JSON file path.""" + try: + from crewai.flow.flow import Flow + from crewai.flow.flow_definition import FlowDefinition + except ImportError as exc: + click.echo( + "Running declarative flows requires the full crewai package.", + err=True, + ) + raise SystemExit(1) from exc + + definition_path = Path(definition).expanduser() + definition_source = _read_declarative_flow_source(definition_path, definition) + + flow_definition = _parse_declarative_flow( + FlowDefinition, + definition_source, + source_path=definition_path, + ) + return Flow.from_definition(flow_definition) + + +def configured_project_declarative_flow( + pyproject_data: dict[str, Any] | None = None, +) -> str | 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": + return None + definition = crewai_config.get("definition") + if not isinstance(definition, str): + return None + return definition.strip() or None + + +def _execute_declarative_flow_command(command: list[str]) -> None: + env = build_env_with_all_tool_credentials() + + try: + subprocess.run( # noqa: S603 + command, + capture_output=False, + text=True, + check=True, + env=env, + ) + except subprocess.CalledProcessError as e: + raise SystemExit(e.returncode) from e + except Exception as e: + click.echo( + f"An unexpected error occurred while running the declarative flow: {e}", + err=True, + ) + raise SystemExit(1) from e + + +def is_declarative_flow_project_env() -> bool: + import os + + return os.environ.get("UV_RUN_RECURSION_DEPTH") is not None + + +def _has_project_file(project_root: Path | None = None) -> bool: + root = project_root or Path.cwd() + return (root / "pyproject.toml").is_file() + + +def _parse_inputs(inputs: str | None) -> dict[str, Any] | None: + if inputs is None: + return None + + try: + parsed = json.loads(inputs) + except json.JSONDecodeError as exc: + click.echo(f"Invalid --inputs JSON: {exc}", err=True) + raise SystemExit(1) from exc + + if not isinstance(parsed, dict): + click.echo("Invalid --inputs JSON: expected an object.", err=True) + raise SystemExit(1) + + return parsed + + +def _read_declarative_flow_source(path: Path, definition: str) -> str: + try: + if path.is_file(): + source = _read_declarative_flow_file(path) + elif path.exists(): + click.echo( + f"Invalid --definition path: {definition} is not a file.", err=True + ) + raise SystemExit(1) + else: + click.echo( + f"Invalid --definition path: {definition} does not exist.", err=True + ) + raise SystemExit(1) + except OSError as exc: + click.echo(f"Invalid --definition path: {definition} ({exc})", err=True) + raise SystemExit(1) from exc + + return source + + +def _read_declarative_flow_file(path: Path) -> str: + try: + source = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + click.echo( + f"Unable to read --definition path {path}: {exc}", + err=True, + ) + raise SystemExit(1) from exc + return source + + +def _parse_declarative_flow( + flow_definition_cls: type[Any], source: str, *, source_path: Path +) -> Any: + if _looks_like_json(source): + return flow_definition_cls.from_json(source, source_path=source_path) + + return flow_definition_cls.from_yaml(source, source_path=source_path) + + +def _looks_like_json(source: str) -> bool: + stripped = source.lstrip() + return stripped.startswith("{") + + +def _format_result(result: Any) -> str: + raw_result = getattr(result, "raw", result) + if isinstance(raw_result, str): + return raw_result + + try: + return json.dumps(raw_result, default=str) + except TypeError: + return str(raw_result) diff --git a/lib/cli/src/crewai_cli/run_flow_definition.py b/lib/cli/src/crewai_cli/run_flow_definition.py deleted file mode 100644 index 7acb6d9fe..000000000 --- a/lib/cli/src/crewai_cli/run_flow_definition.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -import click - - -def run_flow_definition(definition: str, inputs: str | None = None) -> None: - """Run a flow from a Flow Definition YAML/JSON string or file path.""" - try: - from crewai.flow.flow import Flow - from crewai.flow.flow_definition import FlowDefinition - except ImportError as exc: - click.echo( - "Running flows from definitions requires the full crewai package.", - err=True, - ) - raise SystemExit(1) from exc - - parsed_inputs = _parse_inputs(inputs) - definition_source = _read_definition_source(definition) - - try: - flow_definition = _parse_flow_definition(FlowDefinition, definition_source) - flow = Flow.from_definition(flow_definition) - result = flow.kickoff(inputs=parsed_inputs) - except Exception as exc: - click.echo( - f"An error occurred while running the flow definition: {exc}", err=True - ) - raise SystemExit(1) from exc - - click.echo(_format_result(result)) - - -def _parse_inputs(inputs: str | None) -> dict[str, Any] | None: - if inputs is None: - return None - - try: - parsed = json.loads(inputs) - except json.JSONDecodeError as exc: - click.echo(f"Invalid --inputs JSON: {exc}", err=True) - raise SystemExit(1) from exc - - if not isinstance(parsed, dict): - click.echo("Invalid --inputs JSON: expected an object.", err=True) - raise SystemExit(1) - - return parsed - - -def _read_definition_source(definition: str) -> str: - path = Path(definition).expanduser() - try: - is_file = path.is_file() - except OSError as exc: - if _looks_like_inline_definition(definition): - return definition - click.echo(f"Invalid --definition path: {definition} ({exc})", err=True) - raise SystemExit(1) from exc - - if is_file: - try: - return path.read_text(encoding="utf-8") - except (OSError, UnicodeError) as exc: - click.echo( - f"Unable to read --definition path {path}: {exc}", - err=True, - ) - raise SystemExit(1) from exc - - try: - if path.exists(): - click.echo( - f"Invalid --definition path: {definition} is not a file.", err=True - ) - raise SystemExit(1) - except OSError as exc: - click.echo(f"Invalid --definition path: {definition} ({exc})", err=True) - raise SystemExit(1) from exc - - return definition - - -def _looks_like_inline_definition(definition: str) -> bool: - stripped = definition.lstrip() - return "\n" in definition or stripped.startswith(("{", "---")) or ":" in stripped - - -def _parse_flow_definition(flow_definition_cls: type[Any], source: str) -> Any: - if _looks_like_json(source): - return flow_definition_cls.from_json(source) - - return flow_definition_cls.from_yaml(source) - - -def _looks_like_json(source: str) -> bool: - stripped = source.lstrip() - return stripped.startswith("{") - - -def _format_result(result: Any) -> str: - raw_result = getattr(result, "raw", result) - if isinstance(raw_result, str): - return raw_result - - try: - return json.dumps(raw_result, default=str) - except TypeError: - return str(raw_result) diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/.gitignore b/lib/cli/src/crewai_cli/templates/declarative_flow/.gitignore new file mode 100644 index 000000000..9b826004b --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/declarative_flow/.gitignore @@ -0,0 +1,5 @@ +.env +.venv/ +__pycache__/ +.crewai/ +output/ diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/README.md b/lib/cli/src/crewai_cli/templates/declarative_flow/README.md new file mode 100644 index 000000000..2de72c4df --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/declarative_flow/README.md @@ -0,0 +1,17 @@ +# {{name}} Flow + +This project defines a CrewAI Flow in `src/{{folder_name}}/flow.yaml`. + +## Install + +```bash +crewai install +``` + +## Run + +```bash +crewai flow kickoff +``` + +Edit `src/{{folder_name}}/flow.yaml` to change the flow. Add reusable crews under `src/{{folder_name}}/crews/`, custom Python tools under `src/{{folder_name}}/tools/`, and shared knowledge files under `src/{{folder_name}}/knowledge/`. diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/flow.yaml b/lib/cli/src/crewai_cli/templates/declarative_flow/flow.yaml new file mode 100644 index 000000000..3b07891fe --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/declarative_flow/flow.yaml @@ -0,0 +1,15 @@ +schema: crewai.flow/v1 +name: {{flow_name}} +description: A declarative CrewAI Flow. + +state: + type: dict + default: + topic: AI agents + +methods: + start: + start: true + do: + call: expression + expr: state.topic diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml b/lib/cli/src/crewai_cli/templates/declarative_flow/pyproject.toml new file mode 100644 index 000000000..e4a9a8693 --- /dev/null +++ b/lib/cli/src/crewai_cli/templates/declarative_flow/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]==1.14.8a2" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/{{folder_name}}"] + +[tool.crewai] +type = "flow" +definition = "src/{{folder_name}}/flow.yaml" diff --git a/lib/cli/tests/test_cli.py b/lib/cli/tests/test_cli.py index 3b5ce277f..9d8802f27 100644 --- a/lib/cli/tests/test_cli.py +++ b/lib/cli/tests/test_cli.py @@ -130,8 +130,8 @@ def test_run_uses_project_runner_by_default(run_crew, runner): assert "experimental" not in result.output.lower() -@mock.patch("crewai_cli.cli.run_flow_definition") -def test_run_with_definition_uses_definition_runner(run_flow_definition, runner): +@mock.patch("crewai_cli.cli.run_declarative_flow") +def test_run_with_definition_uses_definition_runner(run_declarative_flow, runner): result = runner.invoke( run, ["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'], @@ -142,19 +142,21 @@ def test_run_with_definition_uses_definition_runner(run_flow_definition, runner) "Warning: `crewai run --definition` is experimental and may change without notice." in result.output ) - run_flow_definition.assert_called_once_with( + run_declarative_flow.assert_called_once_with( definition="flow.yaml", inputs='{"topic":"AI"}' ) @mock.patch("crewai_cli.cli.run_crew") -@mock.patch("crewai_cli.cli.run_flow_definition") -def test_run_rejects_inputs_without_definition(run_flow_definition, run_crew, runner): +@mock.patch("crewai_cli.cli.run_declarative_flow") +def test_run_rejects_inputs_without_definition( + run_declarative_flow, run_crew, runner +): result = runner.invoke(run, ["--inputs", '{"topic":"AI"}']) assert result.exit_code == 2 assert "Error: --inputs requires --definition" in result.output - run_flow_definition.assert_not_called() + run_declarative_flow.assert_not_called() run_crew.assert_not_called() @@ -166,6 +168,23 @@ def test_create_crew_in_dmn_mode_skips_provider_prompts(create_json_crew, runner create_json_crew.assert_called_once_with("DMN Crew", None, True) +@mock.patch("crewai_cli.create_flow.create_flow") +def test_create_flow_declarative_uses_declarative_scaffold(create_flow, runner): + result = runner.invoke(create, ["flow", "My Flow", "--declarative"]) + + assert result.exit_code == 0 + create_flow.assert_called_once_with("My Flow", declarative=True) + + +@mock.patch("crewai_cli.create_json_crew.create_json_crew") +def test_create_crew_rejects_declarative_flag(create_json_crew, runner): + result = runner.invoke(create, ["crew", "My Crew", "--declarative"]) + + assert result.exit_code == 2 + assert "--declarative can only be used with flow projects" in result.output + create_json_crew.assert_not_called() + + def test_create_requires_type_in_dmn_mode(runner): result = runner.invoke(create, env={"CREWAI_DMN": "True"}) diff --git a/lib/cli/tests/test_create_flow.py b/lib/cli/tests/test_create_flow.py new file mode 100644 index 000000000..2fa941e58 --- /dev/null +++ b/lib/cli/tests/test_create_flow.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path + +from click.testing import CliRunner +from pytest import MonkeyPatch +import tomli + +from crewai_cli.cli import crewai +from crewai_cli.create_flow import create_flow + + +def test_create_flow_declarative_project_can_run( + tmp_path: Path, monkeypatch: MonkeyPatch +): + monkeypatch.chdir(tmp_path) + create_flow("Research Flow", declarative=True) + + project_root = tmp_path / "research_flow" + assert project_root.is_dir() + + pyproject = tomli.loads( + (project_root / "pyproject.toml").read_text(encoding="utf-8") + ) + assert pyproject["project"]["name"] == "research_flow" + assert pyproject["project"]["requires-python"] + assert pyproject["project"]["dependencies"] + assert (project_root / pyproject["tool"]["crewai"]["definition"]).is_file() + + monkeypatch.chdir(project_root) + result = CliRunner().invoke( + crewai, ["flow", "kickoff"], env={"UV_RUN_RECURSION_DEPTH": "1"} + ) + + assert result.exit_code == 0 + assert "Running the Flow" in result.output + assert "AI agents" in result.output diff --git a/lib/cli/tests/test_flow_commands.py b/lib/cli/tests/test_flow_commands.py new file mode 100644 index 000000000..6154ff642 --- /dev/null +++ b/lib/cli/tests/test_flow_commands.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +import subprocess + +import pytest + +import crewai_cli.kickoff_flow as kickoff_flow_module +import crewai_cli.plot_flow as plot_flow_module + + +FLOW_YAML = """\ +schema: crewai.flow/v1 +name: TestFlow +config: + suppress_flow_events: true +methods: + begin: + start: true + do: + call: expression + expr: "'AI'" +""" + + +def _write_flow_project(project_root: Path) -> None: + (project_root / "flow.yaml").write_text(FLOW_YAML, encoding="utf-8") + (project_root / "pyproject.toml").write_text( + '[project]\nname = "demo"\n\n' + '[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n', + encoding="utf-8", + ) + + +def test_kickoff_flow_runs_configured_declarative_definition( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _write_flow_project(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1") + + kickoff_flow_module.kickoff_flow() + + assert capsys.readouterr().out == "AI\n" + + +def test_plot_flow_runs_configured_declarative_definition( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _write_flow_project(tmp_path) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1") + + plot_flow_module.plot_flow() + + +@pytest.mark.parametrize( + ("command", "expected"), + [ + pytest.param(kickoff_flow_module.kickoff_flow, ["uv", "run", "kickoff"]), + pytest.param(plot_flow_module.plot_flow, ["uv", "run", "plot"]), + ], +) +def test_flow_commands_keep_python_entrypoint_without_definition( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + command: Callable[[], None], + expected: list[str], +) -> None: + subprocess_calls = [] + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + subprocess, + "run", + lambda command, **kwargs: subprocess_calls.append((command, kwargs)), + ) + + command() + + assert subprocess_calls == [ + ( + expected, + {"capture_output": False, "text": True, "check": True}, + ) + ] + + +def test_configured_project_declarative_flow( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "flow"\ndefinition = " flow.yaml "\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + assert configured_project_declarative_flow() == "flow.yaml" diff --git a/lib/cli/tests/test_run_declarative_flow.py b/lib/cli/tests/test_run_declarative_flow.py new file mode 100644 index 000000000..9808d6b17 --- /dev/null +++ b/lib/cli/tests/test_run_declarative_flow.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +import crewai_cli.run_declarative_flow as run_declarative_flow_module + + +FLOW_YAML = """\ +schema: crewai.flow/v1 +name: TestFlow +config: + suppress_flow_events: true +methods: + begin: + start: true + do: + call: expression + expr: state.topic +""" + + +def test_run_declarative_flow_reads_definition_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + definition_path = tmp_path / "flow.yaml" + definition_path.write_text(FLOW_YAML, encoding="utf-8") + + run_declarative_flow_module.run_declarative_flow( + str(definition_path), '{"topic":"AI"}' + ) + + assert capsys.readouterr().out == "AI\n" + + +def test_run_declarative_flow_rejects_non_object_inputs( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + definition_path = tmp_path / "flow.yaml" + definition_path.write_text(FLOW_YAML, encoding="utf-8") + + with pytest.raises(SystemExit): + run_declarative_flow_module.run_declarative_flow( + str(definition_path), '["not", "an", "object"]' + ) + + assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err + + +def test_run_declarative_flow_reports_missing_file( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit): + run_declarative_flow_module.run_declarative_flow("missing-flow.yaml") + + assert ( + "Invalid --definition path: missing-flow.yaml does not exist." + in capsys.readouterr().err + ) + + +def test_run_declarative_flow_in_project_env_uses_uv( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + subprocess_calls = [] + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False) + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n") + monkeypatch.setattr( + run_declarative_flow_module, + "build_env_with_all_tool_credentials", + lambda: {"EXISTING": "value"}, + ) + monkeypatch.setattr( + run_declarative_flow_module.subprocess, + "run", + lambda command, **kwargs: subprocess_calls.append((command, kwargs)), + ) + + run_declarative_flow_module.run_declarative_flow_in_project_env("flow.yaml") + + assert subprocess_calls == [ + ( + ["uv", "run", "crewai", "flow", "kickoff"], + { + "capture_output": False, + "text": True, + "check": True, + "env": {"EXISTING": "value"}, + }, + ) + ] + + +def test_run_declarative_flow_in_process_inside_uv( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1") + (tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n") + (tmp_path / "flow.yaml").write_text(FLOW_YAML, encoding="utf-8") + + run_declarative_flow_module.run_declarative_flow_in_project_env( + "flow.yaml", '{"topic":"AI"}' + ) + + assert capsys.readouterr().out == "AI\n" diff --git a/lib/cli/tests/test_run_flow_definition.py b/lib/cli/tests/test_run_flow_definition.py deleted file mode 100644 index 532f810be..000000000 --- a/lib/cli/tests/test_run_flow_definition.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import json -import sys -import types - -import pytest -import yaml - -from crewai_cli.run_flow_definition import run_flow_definition - - -class _FakeFlow: - def __init__(self, definition): - self.definition = definition - - def kickoff(self, inputs=None): - return { - "flow": self.definition["name"], - "inputs": inputs or {}, - } - - -class _FakeFlowFactory: - @classmethod - def from_definition(cls, definition): - return _FakeFlow(definition) - - -class _FakeFlowDefinition: - @classmethod - def from_yaml(cls, source): - return yaml.safe_load(source) - - @classmethod - def from_json(cls, source): - return json.loads(source) - - -@pytest.fixture -def fake_flow_runtime(monkeypatch): - crewai_module = types.ModuleType("crewai") - flow_package = types.ModuleType("crewai.flow") - flow_module = types.ModuleType("crewai.flow.flow") - flow_definition_module = types.ModuleType("crewai.flow.flow_definition") - - flow_module.Flow = _FakeFlowFactory - flow_definition_module.FlowDefinition = _FakeFlowDefinition - - monkeypatch.setitem(sys.modules, "crewai", crewai_module) - monkeypatch.setitem(sys.modules, "crewai.flow", flow_package) - monkeypatch.setitem(sys.modules, "crewai.flow.flow", flow_module) - monkeypatch.setitem( - sys.modules, "crewai.flow.flow_definition", flow_definition_module - ) - - -def _captured_json(capsys): - return json.loads(capsys.readouterr().out) - - -def test_run_flow_definition_reads_definition_file( - tmp_path, capsys, fake_flow_runtime -): - definition_path = tmp_path / "flow.yaml" - definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n") - - run_flow_definition(str(definition_path), '{"topic":"AI"}') - - assert _captured_json(capsys) == { - "flow": "TestFlow", - "inputs": {"topic": "AI"}, - } - - -@pytest.mark.parametrize( - ("definition_source", "expected_flow_name"), - [ - pytest.param( - "schema: crewai.flow/v1\nname: InlineFlow\n", - "InlineFlow", - id="inline-yaml", - ), - pytest.param( - '{"schema":"crewai.flow/v1","name":"InlineJsonFlow"}', - "InlineJsonFlow", - id="inline-json", - ), - pytest.param( - '{"schema":"crewai.flow/v1","name":"' + ("JsonFlow" * 500) + '"}', - "JsonFlow" * 500, - id="large-inline-json", - ), - ], -) -def test_run_flow_definition_accepts_inline_definitions( - definition_source, expected_flow_name, capsys, fake_flow_runtime -): - run_flow_definition(definition_source) - - assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}} - - -@pytest.mark.parametrize( - ("filename", "definition_source", "expected_flow_name"), - [ - pytest.param( - "flow.yaml", - "schema: crewai.flow/v1\nname: YamlFileFlow\n", - "YamlFileFlow", - id="yaml-file", - ), - pytest.param( - "flow.json", - '{"schema":"crewai.flow/v1","name":"JsonFlow"}', - "JsonFlow", - id="json-file", - ), - ], -) -def test_run_flow_definition_accepts_definition_files( - filename, definition_source, expected_flow_name, tmp_path, capsys, fake_flow_runtime -): - definition_path = tmp_path / filename - definition_path.write_text(definition_source) - - run_flow_definition(str(definition_path)) - - assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}} - - -def test_run_flow_definition_rejects_non_object_inputs(fake_flow_runtime, capsys): - with pytest.raises(SystemExit): - run_flow_definition("name: TestFlow", '["not", "an", "object"]') - - assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err - - -def test_run_flow_definition_reports_unreadable_file( - monkeypatch, tmp_path, capsys, fake_flow_runtime -): - definition_path = tmp_path / "flow.yaml" - definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n") - - def raise_permission_error(self, *args, **kwargs): - raise PermissionError("no access") - - monkeypatch.setattr("pathlib.Path.read_text", raise_permission_error) - - with pytest.raises(SystemExit): - run_flow_definition(str(definition_path)) - - err = capsys.readouterr().err - assert "Unable to read --definition path" in err - assert str(definition_path) in err - assert "no access" in err diff --git a/lib/crewai/src/crewai/flow/flow_definition.py b/lib/crewai/src/crewai/flow/flow_definition.py index 5c277d3ce..6f05853d0 100644 --- a/lib/crewai/src/crewai/flow/flow_definition.py +++ b/lib/crewai/src/crewai/flow/flow_definition.py @@ -1,6 +1,6 @@ -"""Flow Structure: the serializable, language-agnostic Flow contract. +"""Flow Definition: the serializable, declarative Flow contract. -Defines :class:`FlowDefinition` and its sub-models — a static, textual +Defines :class:`FlowDefinition` and its sub-models — a static, declarative (JSON/YAML) representation of a Flow: its methods, trigger conditions, state, and configuration. It is independent of the Python authoring layer that may have produced it and of the engine that runs it (see @@ -11,6 +11,7 @@ from __future__ import annotations import json import logging +from pathlib import Path import re from typing import Annotated, Any, Literal, TypeAlias, cast @@ -18,6 +19,7 @@ from pydantic import ( BaseModel, ConfigDict, Field, + PrivateAttr, field_serializer, model_validator, ) @@ -406,10 +408,19 @@ class FlowCrewActionDefinition(BaseModel): ) call: Literal["crew"] = Field( - description="Action discriminator. Use crew to run an inline Crew definition.", + description=( + "Action discriminator. Use crew to run an inline or referenced Crew " + "definition." + ), examples=["crew"], ) - with_: CrewDefinition = Field( + from_declaration: str | None = Field( + default=None, + description="Path to a JSON/JSONC Crew declaration file or folder.", + examples=["crews/research_crew"], + ) + with_: CrewDefinition | None = Field( + default=None, alias="with", description="Inline Crew definition to load and execute for this action.", examples=[ @@ -430,10 +441,26 @@ class FlowCrewActionDefinition(BaseModel): "agent": "researcher", } ], - "inputs": {"topic": "${state.topic}"}, } ], ) + inputs: dict[str, ExpressionData] | None = Field( + default=None, + description=( + "Input overrides passed to the Crew. String values are evaluated as CEL " + "only when the trimmed value starts with ${ and ends with }; all other " + "values are literal." + ), + examples=[{"topic": "${state.topic}"}], + ) + + @model_validator(mode="after") + def _validate_crew_source(self) -> FlowCrewActionDefinition: + if bool(self.from_declaration) == (self.with_ is not None): + raise ValueError( + "crew action requires exactly one of from_declaration or with" + ) + return self class FlowAgentActionDefinition(BaseModel): @@ -684,10 +711,12 @@ class FlowDefinition(BaseModel): arbitrary_types_allowed=True, ) + _source_path: Path | None = PrivateAttr(default=None) + schema_: Literal["crewai.flow/v1"] = Field( default="crewai.flow/v1", alias="schema", - description="Flow Definition schema identifier and version.", + description="Declarative Flow schema identifier and version.", examples=["crewai.flow/v1"], ) name: str = Field( @@ -764,29 +793,45 @@ class FlowDefinition(BaseModel): allow_unicode=True, ) + @property + def source_path(self) -> Path | None: + """Original definition file path, when loaded from a file.""" + return self._source_path + + @property + def source_dir(self) -> Path | None: + """Directory used to resolve relative paths in the definition.""" + if self._source_path is None: + return None + return self._source_path.parent + @classmethod - def from_dict(cls, data: dict[str, Any]) -> FlowDefinition: + 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_json(cls, data: str) -> FlowDefinition: + def from_json(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition: """Load a definition from JSON.""" - return cls.from_dict(json.loads(data)) + return cls.from_dict(json.loads(data), source_path=source_path) @classmethod - def from_yaml(cls, data: str) -> FlowDefinition: + def from_yaml(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition: """Load a definition from YAML.""" loaded = yaml.safe_load(data) or {} if not isinstance(loaded, dict): raise ValueError("Flow definition YAML must contain a mapping") - return cls.from_dict(loaded) + return cls.from_dict(loaded, source_path=source_path) @classmethod def json_schema(cls) -> dict[str, Any]: - """Return the JSON Schema for the Flow Definition contract.""" + """Return the JSON Schema for the declarative Flow contract.""" return cls.model_json_schema(by_alias=True) @@ -826,10 +871,16 @@ def _validate_action_cel( return if isinstance(action, FlowCrewActionDefinition): - Expression(cast(ExpressionData, action.with_.inputs)).validate_template( - allowed_roots=allowed_roots, - source=f"{path}.with.inputs", - ) + if action.with_ is not None: + Expression(cast(ExpressionData, action.with_.inputs)).validate_template( + allowed_roots=allowed_roots, + source=f"{path}.with.inputs", + ) + if action.inputs is not None: + Expression(cast(ExpressionData, action.inputs)).validate_template( + allowed_roots=allowed_roots, + source=f"{path}.inputs", + ) return if isinstance(action, FlowAgentActionDefinition): diff --git a/lib/crewai/src/crewai/flow/runtime/_actions.py b/lib/crewai/src/crewai/flow/runtime/_actions.py index c437e274b..c8f118775 100644 --- a/lib/crewai/src/crewai/flow/runtime/_actions.py +++ b/lib/crewai/src/crewai/flow/runtime/_actions.py @@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable import contextvars import inspect import os +from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, cast from crewai.flow.expressions import Expression, ExpressionData @@ -128,16 +129,34 @@ class CrewAction: self.definition = definition async def run(self, *_args: Any, **kwargs: Any) -> Any: - from crewai.project.crew_loader import load_crew_from_definition + from crewai.project.crew_loader import load_crew, load_crew_from_definition local_context = _pop_local_context(kwargs) - crew_definition = self.definition.with_ + if self.definition.from_declaration is not None: + crew, default_inputs = load_crew( + _resolve_crew_declaration( + self.definition.from_declaration, + base_dir=self.flow._definition.source_dir, + ) + ) + input_template = {**default_inputs, **(self.definition.inputs or {})} + else: + crew_definition = self.definition.with_ + if crew_definition is None: + raise ValueError( + "crew action requires exactly one of from_declaration or with" + ) + input_template = { + **crew_definition.inputs, + **(self.definition.inputs or {}), + } + crew, _ = load_crew_from_definition(crew_definition, source="crew action") + inputs = Expression.from_flow( - cast(ExpressionData, crew_definition.inputs), + cast(ExpressionData, input_template), self.flow, local_context=local_context, ).render_template() - crew, _ = load_crew_from_definition(crew_definition, source="crew action") return await crew.kickoff_async(inputs=inputs) @@ -359,3 +378,29 @@ def _pop_local_context(kwargs: dict[str, Any]) -> LocalContext | None: if not isinstance(local_context, dict): raise TypeError("flow definition local context must be a mapping") return cast(LocalContext, local_context) + + +def _resolve_crew_declaration( + from_declaration: str, *, base_dir: Path | None = None +) -> Path: + path = Path(from_declaration).expanduser() + if base_dir is not None: + resolved_base_dir = base_dir.expanduser().resolve() + if not path.is_absolute(): + path = resolved_base_dir / path + resolved_path = path.resolve() + if not resolved_path.is_relative_to(resolved_base_dir): + raise ValueError( + "crew declaration path must be within the flow definition directory" + ) + path = resolved_path + + if not path.is_dir(): + return path + + for name in ("crew.jsonc", "crew.json"): + candidate = path / name + if candidate.is_file(): + return candidate + + return path / "crew.jsonc" diff --git a/lib/crewai/tests/test_flow_from_definition.py b/lib/crewai/tests/test_flow_from_definition.py index 1ed8dbcf9..693d75ef5 100644 --- a/lib/crewai/tests/test_flow_from_definition.py +++ b/lib/crewai/tests/test_flow_from_definition.py @@ -1005,8 +1005,8 @@ methods: description: Research {topic} expected_output: Findings about {topic} agent: researcher - inputs: - topic: "${state.topic}" + inputs: + topic: "${state.topic}" start: true """ @@ -1020,6 +1020,183 @@ methods: } +def test_crew_action_runs_crew_from_declaration( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + from crewai import Crew + + project_root = tmp_path / "project" + crew_root = project_root / "crews" / "research_crew" + agents_root = crew_root / "agents" + agents_root.mkdir(parents=True) + (agents_root / "researcher.jsonc").write_text( + """ +{ + "role": "Researcher", + "goal": "Research {topic}", + "backstory": "Knows things." +} +""", + encoding="utf-8", + ) + (crew_root / "crew.jsonc").write_text( + """ +{ + "name": "referenced_research", + "agents": ["researcher"], + "tasks": [ + { + "name": "research_task", + "description": "Research {topic}", + "expected_output": "Findings about {topic}", + "agent": "researcher" + } + ], + "inputs": { + "topic": "Default topic", + "audience": "developers" + } +} +""", + encoding="utf-8", + ) + + async def fake_kickoff_async( + self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any + ) -> dict[str, Any]: + return { + "crew": self.name, + "tasks": [task.description for task in self.tasks], + "inputs": inputs, + } + + monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async) + monkeypatch.chdir(project_root) + + yaml_str = """ +schema: crewai.flow/v1 +name: CrewFlow +methods: + research: + do: + call: crew + from_declaration: crews/research_crew + inputs: + topic: "${state.topic}" + start: true +""" + + flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + + assert flow.kickoff(inputs={"topic": "AI"}) == { + "crew": "referenced_research", + "tasks": ["Research {topic}"], + "inputs": {"topic": "AI", "audience": "developers"}, + } + + +def test_crew_action_from_declaration_resolves_relative_to_flow_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + from crewai import Crew + + project_root = tmp_path / "project" + crew_root = project_root / "crews" / "research_crew" + agents_root = crew_root / "agents" + agents_root.mkdir(parents=True) + (agents_root / "researcher.jsonc").write_text( + """ +{ + "role": "Researcher", + "goal": "Research {topic}", + "backstory": "Knows things." +} +""", + encoding="utf-8", + ) + (crew_root / "crew.jsonc").write_text( + """ +{ + "name": "relative_research", + "agents": ["researcher"], + "tasks": [ + { + "description": "Research {topic}", + "expected_output": "Findings about {topic}", + "agent": "researcher" + } + ], + "inputs": { + "topic": "Default topic" + } +} +""", + encoding="utf-8", + ) + + async def fake_kickoff_async( + self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any + ) -> dict[str, Any]: + return {"crew": self.name, "inputs": inputs} + + monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async) + + flow_path = project_root / "flow.yaml" + yaml_str = """ +schema: crewai.flow/v1 +name: CrewFlow +methods: + research: + do: + call: crew + from_declaration: crews/research_crew + inputs: + topic: "${state.topic}" + start: true +""" + flow_path.write_text(yaml_str, encoding="utf-8") + + other_cwd = tmp_path / "other" + other_cwd.mkdir() + monkeypatch.chdir(other_cwd) + + flow = Flow.from_definition( + FlowDefinition.from_yaml(yaml_str, source_path=flow_path) + ) + + assert flow.kickoff(inputs={"topic": "AI"}) == { + "crew": "relative_research", + "inputs": {"topic": "AI"}, + } + + +def test_crew_action_from_declaration_rejects_paths_outside_flow_file( + tmp_path: Path, +): + flow_path = tmp_path / "project" / "flow.yaml" + flow_path.parent.mkdir() + yaml_str = """ +schema: crewai.flow/v1 +name: CrewFlow +methods: + research: + do: + call: crew + from_declaration: ../outside/crew.jsonc + start: true +""" + + flow = Flow.from_definition( + FlowDefinition.from_yaml(yaml_str, source_path=flow_path) + ) + + with pytest.raises( + ValueError, + match="crew declaration path must be within the flow definition directory", + ): + flow.kickoff() + + def test_crew_action_round_trips_with_inline_definition(): definition = FlowDefinition.from_dict( { @@ -1047,8 +1224,8 @@ def test_crew_action_round_trips_with_inline_definition(): "agent": "researcher", } ], - "inputs": {"topic": "${state.topic}"}, }, + "inputs": {"topic": "${state.topic}"}, }, } }, @@ -1062,6 +1239,9 @@ def test_crew_action_round_trips_with_inline_definition(): ]["role"] == "Researcher" ) + assert definition.to_dict()["methods"]["research"]["do"]["inputs"] == { + "topic": "${state.topic}" + } def test_crew_action_normalizes_named_agent_list_definition(): @@ -1162,7 +1342,7 @@ def test_crew_action_rejects_incomplete_inline_agent_definition(): ) -def test_crew_action_rejects_ref(): +def test_crew_action_rejects_python_ref_field(): with pytest.raises(ValidationError, match="ref"): FlowDefinition.from_dict( { @@ -1174,7 +1354,6 @@ def test_crew_action_rejects_ref(): "do": { "call": "crew", "ref": "project.crew:build_crew", - "with": {"inputs": {"topic": "AI"}}, }, } }, From 720a4c72161623341b78159c14c32d96a1a8a388 Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Mon, 22 Jun 2026 20:37:16 -0700 Subject: [PATCH 04/22] Keep flow method progress visible for nested crews (#6295) Inline crews default to `verbose=False`. They set the shared formatter's `verbose` value in `lib/crewai/src/crewai/crew.py`, which could hide flow method status from `lib/crewai/src/crewai/events/utils/ console_formatter.py`. Remove that `verbose` check for flow method status. Flow output is still controlled by `suppress_flow_events`. Normal quiet crews are unchanged because crew, task, and agent logs still use their own `verbose` checks. --- .../src/crewai/events/utils/console_formatter.py | 3 --- .../utilities/test_console_formatter_pause_resume.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/crewai/src/crewai/events/utils/console_formatter.py b/lib/crewai/src/crewai/events/utils/console_formatter.py index fdecf93cf..604c7b051 100644 --- a/lib/crewai/src/crewai/events/utils/console_formatter.py +++ b/lib/crewai/src/crewai/events/utils/console_formatter.py @@ -373,9 +373,6 @@ To enable tracing, do any one of these: status: str = "running", ) -> None: """Show method status panel.""" - if not self.verbose: - return - if status == "running": style = "yellow" panel_title = "🔄 Flow Method Running" diff --git a/lib/crewai/tests/utilities/test_console_formatter_pause_resume.py b/lib/crewai/tests/utilities/test_console_formatter_pause_resume.py index 0adb43d83..1ffbb3850 100644 --- a/lib/crewai/tests/utilities/test_console_formatter_pause_resume.py +++ b/lib/crewai/tests/utilities/test_console_formatter_pause_resume.py @@ -46,6 +46,16 @@ class TestConsoleFormatterPauseResume: formatter.resume_live_updates() + def test_flow_method_status_ignores_formatter_verbose(self): + formatter = ConsoleFormatter(verbose=False) + + with patch.object(formatter, "print_panel") as mock_print_panel: + formatter.handle_method_status("categorize_tickets") + + mock_print_panel.assert_called_once() + _, kwargs = mock_print_panel.call_args + assert kwargs["is_flow"] is True + def test_streaming_after_pause_resume_creates_new_session(self): """Test that streaming after pause/resume creates new Live session.""" formatter = ConsoleFormatter() From 221dfdb08e41dcc24e36bcc9e5a1a2d2cc0722df Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Mon, 22 Jun 2026 20:44:08 -0700 Subject: [PATCH 05/22] Consolidate `crewai run` and `crewai flow kickoff` (#6296) Make `crewai run` the single execution path for crews and flows, with `crewai flow kickoff` kept as a deprecated compatibility alias. --- docs/edge/ar/changelog.mdx | 2 +- docs/edge/ar/concepts/flows.mdx | 10 +- docs/edge/ar/guides/flows/first-flow.mdx | 2 +- docs/edge/en/changelog.mdx | 2 +- docs/edge/en/concepts/flows.mdx | 14 +- docs/edge/en/guides/flows/first-flow.mdx | 2 +- docs/edge/ko/changelog.mdx | 2 +- docs/edge/ko/concepts/flows.mdx | 10 +- docs/edge/ko/guides/flows/first-flow.mdx | 2 +- docs/edge/pt-BR/changelog.mdx | 2 +- docs/edge/pt-BR/concepts/flows.mdx | 10 +- docs/edge/pt-BR/guides/flows/first-flow.mdx | 2 +- lib/cli/src/crewai_cli/cli.py | 37 ++--- lib/cli/src/crewai_cli/kickoff_flow.py | 31 ----- lib/cli/src/crewai_cli/run_crew.py | 119 ++++++++++++---- .../src/crewai_cli/run_declarative_flow.py | 8 +- lib/cli/src/crewai_cli/templates/AGENTS.md | 2 +- .../templates/declarative_flow/README.md | 6 +- lib/cli/tests/test_cli.py | 61 +++++++-- lib/cli/tests/test_create_flow.py | 4 +- lib/cli/tests/test_flow_commands.py | 46 ++++--- lib/cli/tests/test_run_crew.py | 128 ++++++++++++++++++ lib/cli/tests/test_run_declarative_flow.py | 2 +- lib/crewai/tests/cli/test_run_crew.py | 19 ++- 24 files changed, 351 insertions(+), 172 deletions(-) delete mode 100644 lib/cli/src/crewai_cli/kickoff_flow.py diff --git a/docs/edge/ar/changelog.mdx b/docs/edge/ar/changelog.mdx index 6f3fc32f5..e70318eff 100644 --- a/docs/edge/ar/changelog.mdx +++ b/docs/edge/ar/changelog.mdx @@ -64,7 +64,7 @@ mode: "wide" - تنفيذ أدوات تشغيل تعريف التدفق بدون كود Python - دفع التغذية الراجعة البشرية من تعريف التدفق - توصيل التكوين والاستمرارية من FlowDefinition إلى وقت التشغيل - - إضافة `crewai run --definition` التجريبية للتدفقات + - إضافة `crewai run --definition` للتدفقات التصريحية - دعم تراجع نشر ZIP وتشغيل مشاريع الطاقم بتنسيق JSON - تقديم الطواقم بتنسيق JSON أولاً diff --git a/docs/edge/ar/concepts/flows.mdx b/docs/edge/ar/concepts/flows.mdx index da162310f..62d34b335 100644 --- a/docs/edge/ar/concepts/flows.mdx +++ b/docs/edge/ar/concepts/flows.mdx @@ -959,7 +959,7 @@ source .venv/bin/activate بعد تفعيل البيئة الافتراضية، يمكنك تشغيل التدفق بتنفيذ أحد الأوامر التالية: ```bash -crewai flow kickoff +crewai run ``` أو @@ -1160,10 +1160,4 @@ crewai run يكتشف هذا الأمر تلقائيًا ما إذا كان مشروعك تدفقًا (بناءً على إعداد `type = "flow"` في pyproject.toml الخاص بك) ويشغّله وفقًا لذلك. هذه هي الطريقة الموصى بها لتشغيل التدفقات من سطر الأوامر. -للتوافق مع الإصدارات السابقة، يمكنك أيضًا استخدام: - -```shell -crewai flow kickoff -``` - -ومع ذلك، فإن أمر `crewai run` هو الطريقة المفضلة الآن لأنه يعمل لكل من فرق Crew والتدفقات. +أمر `crewai flow kickoff` القديم deprecated. استخدم `crewai run` لكل من فرق Crew والتدفقات. diff --git a/docs/edge/ar/guides/flows/first-flow.mdx b/docs/edge/ar/guides/flows/first-flow.mdx index 9ee804653..322e71c2a 100644 --- a/docs/edge/ar/guides/flows/first-flow.mdx +++ b/docs/edge/ar/guides/flows/first-flow.mdx @@ -172,7 +172,7 @@ crewai install ## الخطوة 8: تشغيل Flow ```bash -crewai flow kickoff +crewai run ``` عند تشغيل هذا الأمر، ستشاهد Flow يعمل: diff --git a/docs/edge/en/changelog.mdx b/docs/edge/en/changelog.mdx index 924463ddc..b0a7492c1 100644 --- a/docs/edge/en/changelog.mdx +++ b/docs/edge/en/changelog.mdx @@ -64,7 +64,7 @@ mode: "wide" - Implement Flow definition run tools without Python code - Drive human feedback from the flow definition - Wire config and persistence from FlowDefinition into the runtime - - Add experimental `crewai run --definition` for flows + - Add `crewai run --definition` for declarative flows - Support ZIP deployment fallback and JSON crew project env runs - Introduce JSON first crews diff --git a/docs/edge/en/concepts/flows.mdx b/docs/edge/en/concepts/flows.mdx index 210c573ce..647512545 100644 --- a/docs/edge/en/concepts/flows.mdx +++ b/docs/edge/en/concepts/flows.mdx @@ -956,13 +956,13 @@ Once all of the dependencies are installed, you need to activate the virtual env source .venv/bin/activate ``` -After activating the virtual environment, you can run the flow by executing one of the following commands: +After activating the virtual environment, you can run the flow with the CrewAI CLI: ```bash -crewai flow kickoff +crewai run ``` -or +You can also run the project script directly: ```bash uv run kickoff @@ -1160,10 +1160,4 @@ crewai run This command automatically detects if your project is a flow (based on the `type = "flow"` setting in your pyproject.toml) and runs it accordingly. This is the recommended way to run flows from the command line. -For backward compatibility, you can also use: - -```shell -crewai flow kickoff -``` - -However, the `crewai run` command is now the preferred method as it works for both crews and flows. +The legacy `crewai flow kickoff` command is deprecated. Use `crewai run` for both crews and flows. diff --git a/docs/edge/en/guides/flows/first-flow.mdx b/docs/edge/en/guides/flows/first-flow.mdx index ad6638b77..d26a1eb2d 100644 --- a/docs/edge/en/guides/flows/first-flow.mdx +++ b/docs/edge/en/guides/flows/first-flow.mdx @@ -395,7 +395,7 @@ crewai install Now it's time to see your flow in action! Run it using the CrewAI CLI: ```bash -crewai flow kickoff +crewai run ``` When you run this command, you'll see your flow spring to life: diff --git a/docs/edge/ko/changelog.mdx b/docs/edge/ko/changelog.mdx index aea038515..1fa751baf 100644 --- a/docs/edge/ko/changelog.mdx +++ b/docs/edge/ko/changelog.mdx @@ -64,7 +64,7 @@ mode: "wide" - Python 코드 없이 Flow 정의 실행 도구 구현 - Flow 정의에서 인간 피드백 유도 - FlowDefinition의 구성 및 지속성을 런타임에 연결 - - 흐름을 위한 실험적 `crewai run --definition` 추가 + - 선언적 흐름을 위한 `crewai run --definition` 추가 - ZIP 배포 대체 및 JSON 크루 프로젝트 환경 실행 지원 - JSON 우선 크루 도입 diff --git a/docs/edge/ko/concepts/flows.mdx b/docs/edge/ko/concepts/flows.mdx index 91ca831fb..e168b7e3f 100644 --- a/docs/edge/ko/concepts/flows.mdx +++ b/docs/edge/ko/concepts/flows.mdx @@ -951,7 +951,7 @@ source .venv/bin/activate 가상 환경을 활성화한 후, 아래 명령어 중 하나를 실행하여 플로우를 실행할 수 있습니다: ```bash -crewai flow kickoff +crewai run ``` 또는 @@ -1054,10 +1054,4 @@ crewai run 이 명령어는 프로젝트가 pyproject.toml의 `type = "flow"` 설정을 기반으로 flow인지 자동으로 감지하여 해당 방식으로 실행합니다. 명령줄에서 flow를 실행하는 권장 방법입니다. -하위 호환성을 위해 다음 명령어도 사용할 수 있습니다: - -```shell -crewai flow kickoff -``` - -하지만 `crewai run` 명령어가 이제 crew와 flow 모두에 작동하므로 더욱 선호되는 방법입니다. +레거시 `crewai flow kickoff` 명령어는 deprecated되었습니다. crew와 flow 모두 `crewai run`을 사용하세요. diff --git a/docs/edge/ko/guides/flows/first-flow.mdx b/docs/edge/ko/guides/flows/first-flow.mdx index b8a693086..04d0f3edf 100644 --- a/docs/edge/ko/guides/flows/first-flow.mdx +++ b/docs/edge/ko/guides/flows/first-flow.mdx @@ -393,7 +393,7 @@ crewai install 이제 여러분의 flow가 실제로 작동하는 모습을 볼 차례입니다! CrewAI CLI를 사용하여 flow를 실행하세요: ```bash -crewai flow kickoff +crewai run ``` 이 명령어를 실행하면 flow가 다음과 같이 작동하는 것을 확인할 수 있습니다: diff --git a/docs/edge/pt-BR/changelog.mdx b/docs/edge/pt-BR/changelog.mdx index f611f48aa..2e10fc667 100644 --- a/docs/edge/pt-BR/changelog.mdx +++ b/docs/edge/pt-BR/changelog.mdx @@ -64,7 +64,7 @@ mode: "wide" - Implementar ferramentas de execução de definição de fluxo sem código Python - Conduzir feedback humano a partir da definição de fluxo - Conectar configuração e persistência do FlowDefinition ao tempo de execução - - Adicionar `crewai run --definition` experimental para fluxos + - Adicionar `crewai run --definition` para fluxos declarativos - Suportar fallback de implantação ZIP e execuções de projeto de equipe em JSON - Introduzir equipes em JSON primeiro diff --git a/docs/edge/pt-BR/concepts/flows.mdx b/docs/edge/pt-BR/concepts/flows.mdx index 73ac5019a..8879edca8 100644 --- a/docs/edge/pt-BR/concepts/flows.mdx +++ b/docs/edge/pt-BR/concepts/flows.mdx @@ -948,7 +948,7 @@ source .venv/bin/activate Com o ambiente ativado, execute o flow usando um dos comandos: ```bash -crewai flow kickoff +crewai run ``` ou @@ -1052,10 +1052,4 @@ crewai run O comando detecta automaticamente se seu projeto é um flow (com base na configuração `type = "flow"` no pyproject.toml) e executa conforme o esperado. Esse é o método recomendado para executar flows pelo terminal. -Por compatibilidade retroativa, também é possível usar: - -```shell -crewai flow kickoff -``` - -No entanto, o comando `crewai run` é agora o preferido, pois funciona tanto para crews quanto para flows. +O comando legado `crewai flow kickoff` está deprecated. Use `crewai run` para crews e flows. diff --git a/docs/edge/pt-BR/guides/flows/first-flow.mdx b/docs/edge/pt-BR/guides/flows/first-flow.mdx index 0069cf85f..0ff1ba5d6 100644 --- a/docs/edge/pt-BR/guides/flows/first-flow.mdx +++ b/docs/edge/pt-BR/guides/flows/first-flow.mdx @@ -393,7 +393,7 @@ crewai install Agora é hora de ver seu flow em ação! Execute-o usando a CLI do CrewAI: ```bash -crewai flow kickoff +crewai run ``` Quando você rodar esse comando, verá seu flow ganhando vida: diff --git a/lib/cli/src/crewai_cli/cli.py b/lib/cli/src/crewai_cli/cli.py index 1a64a74f3..b2050bc34 100644 --- a/lib/cli/src/crewai_cli/cli.py +++ b/lib/cli/src/crewai_cli/cli.py @@ -40,14 +40,6 @@ def replay_task_command(*args: Any, **kwargs: Any) -> Any: return _replay_task_command(*args, **kwargs) -def run_declarative_flow(*args: Any, **kwargs: Any) -> Any: - from crewai_cli.run_declarative_flow import ( - run_declarative_flow as _run_declarative_flow, - ) - - return _run_declarative_flow(*args, **kwargs) - - def run_crew(*args: Any, **kwargs: Any) -> Any: from crewai_cli.run_crew import run_crew as _run_crew @@ -476,7 +468,7 @@ def memory( type=str, default=None, help=( - "Path to a trained-agents pickle (produced by `crewai train -f`). " + "Crew-only: path to a trained-agents pickle (produced by `crewai train -f`). " "When set, agents load suggestions from this file instead of the " "default trained_agents_data.pkl. Equivalent to setting " "CREWAI_TRAINED_AGENTS_FILE." @@ -520,13 +512,13 @@ def install(context: click.Context) -> None: "--definition", type=str, default=None, - help="Experimental: path to a declarative Flow YAML/JSON file.", + help="Flow-only: path to a declarative flow definition.", ) @click.option( "--inputs", type=str, default=None, - help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.', + help='Flow-only: JSON object passed to the declarative flow, e.g. \'{"topic":"AI"}\'.', ) def run( trained_agents_file: str | None, @@ -536,16 +528,14 @@ def run( """Run the Crew or Flow.""" if inputs is not None and definition is None: raise click.UsageError("--inputs requires --definition") + if trained_agents_file is not None and definition is not None: + raise click.UsageError("--filename can only be used when running crews") - if definition is not None: - click.secho( - "Warning: `crewai run --definition` is experimental and may change without notice.", - fg="yellow", - ) - run_declarative_flow(definition=definition, inputs=inputs) - return - - run_crew(trained_agents_file=trained_agents_file) + run_crew( + trained_agents_file=trained_agents_file, + definition=definition, + inputs=inputs, + ) @crewai.command() @@ -797,13 +787,10 @@ def flow() -> None: """Flow related commands.""" -@flow.command(name="kickoff") +@flow.command(name="kickoff", deprecated=True) def flow_run() -> None: """Kickoff the Flow.""" - from crewai_cli.kickoff_flow import kickoff_flow - - click.echo("Running the Flow") - kickoff_flow() + run_crew(trained_agents_file=None, definition=None, inputs=None) @flow.command(name="plot") diff --git a/lib/cli/src/crewai_cli/kickoff_flow.py b/lib/cli/src/crewai_cli/kickoff_flow.py deleted file mode 100644 index ff5f317dd..000000000 --- a/lib/cli/src/crewai_cli/kickoff_flow.py +++ /dev/null @@ -1,31 +0,0 @@ -import subprocess - -import click - - -def kickoff_flow() -> None: - """ - Kickoff the flow from declarative config or the Python UV entrypoint. - """ - from crewai_cli.run_declarative_flow import ( - configured_project_declarative_flow, - run_declarative_flow_in_project_env, - ) - - if definition := configured_project_declarative_flow(): - run_declarative_flow_in_project_env(definition=definition) - else: - command = ["uv", "run", "kickoff"] - - try: - subprocess.run( # noqa: S603 - command, capture_output=False, text=True, check=True - ) - - except subprocess.CalledProcessError as e: - click.echo(f"An error occurred while running the flow: {e}", err=True) - raise SystemExit(1) from e - - except Exception as e: - click.echo(f"An unexpected error occurred: {e}", err=True) - raise SystemExit(1) from e diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index cbf2445d1..0fa61dc7a 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable from contextlib import AbstractContextManager, nullcontext -from enum import Enum import os from pathlib import Path import re @@ -27,11 +26,6 @@ if TYPE_CHECKING: from crewai_cli.crew_run_tui import CrewRunApp -class CrewType(Enum): - STANDARD = "standard" - FLOW = "flow" - - # Must accept the same names as the kickoff interpolation pattern in # crewai.utilities.string_utils (_VARIABLE_PATTERN), including hyphens — # otherwise placeholders are interpolated at runtime but never prompted for. @@ -537,7 +531,11 @@ def _print_post_tui_summary(app: CrewRunApp) -> None: ) -def run_crew(trained_agents_file: str | None = None) -> None: +def run_crew( + trained_agents_file: str | None = None, + definition: str | None = None, + inputs: str | None = None, +) -> None: """Run the crew or flow. Args: @@ -545,15 +543,90 @@ def run_crew(trained_agents_file: str | None = None) -> None: by ``crewai train -f``. When set, exported as ``CREWAI_TRAINED_AGENTS_FILE`` so agents load suggestions from this file instead of the default ``trained_agents_data.pkl``. + definition: Optional path to a declarative Flow definition. + inputs: Optional JSON object passed to a declarative Flow. """ - # JSON crew projects take precedence + if inputs is not None and definition is None: + raise click.UsageError("--inputs requires --definition") + + if definition is not None: + _run_explicit_declarative_flow( + definition=definition, + inputs=inputs, + trained_agents_file=trained_agents_file, + ) + return + if _has_json_crew(): _run_json_crew_in_project_env(trained_agents_file=trained_agents_file) return + pyproject_data = read_toml() + _warn_if_old_poetry_project(pyproject_data) + project_type = _get_project_type(pyproject_data) + + if project_type == "flow": + _run_flow_project( + pyproject_data=pyproject_data, + trained_agents_file=trained_agents_file, + ) + return + + _run_classic_crew_project( + pyproject_data=pyproject_data, + trained_agents_file=trained_agents_file, + ) + + +def _run_explicit_declarative_flow( + definition: str, inputs: str | None, trained_agents_file: str | None +) -> None: + if trained_agents_file is not None: + raise click.UsageError("--filename can only be used when running crews") + + from crewai_cli.run_declarative_flow import run_declarative_flow + + run_declarative_flow(definition=definition, inputs=inputs) + + +def _run_flow_project( + pyproject_data: dict[str, Any], trained_agents_file: str | None +) -> None: + if trained_agents_file is not None: + raise click.UsageError("--filename can only be used when running crews") + + click.echo("Running the Flow") + from crewai_cli.run_declarative_flow import ( + configured_project_declarative_flow, + run_declarative_flow_in_project_env, + ) + + if definition := configured_project_declarative_flow(pyproject_data): + run_declarative_flow_in_project_env(definition=definition) + return + + _execute_uv_script("kickoff", entity_type="flow") + + +def _run_classic_crew_project( + pyproject_data: dict[str, Any], trained_agents_file: str | None +) -> None: + click.echo("Running the Crew") + _execute_uv_script( + "run_crew", + entity_type="crew", + trained_agents_file=trained_agents_file, + ) + + +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" - pyproject_data = read_toml() if pyproject_data.get("tool", {}).get("poetry") and ( version.parse(crewai_version) < version.parse(min_required_version) @@ -564,25 +637,22 @@ def run_crew(trained_agents_file: str | None = None) -> None: fg="red", ) - is_flow = pyproject_data.get("tool", {}).get("crewai", {}).get("type") == "flow" - crew_type = CrewType.FLOW if is_flow else CrewType.STANDARD - click.echo(f"Running the {'Flow' if is_flow else 'Crew'}") - - execute_command(crew_type, trained_agents_file=trained_agents_file) - - -def execute_command( - crew_type: CrewType, trained_agents_file: str | None = None +def _execute_uv_script( + script_name: str, + *, + entity_type: str, + trained_agents_file: str | None = None, ) -> None: - """Execute the appropriate command based on crew type. + """Execute a project script through uv. Args: - crew_type: The type of crew to run. + script_name: The project script to run. + entity_type: The user-facing entity being run. trained_agents_file: Optional trained-agents pickle path forwarded to the subprocess via the ``CREWAI_TRAINED_AGENTS_FILE`` env var. """ - command = ["uv", "run", "kickoff" if crew_type == CrewType.FLOW else "run_crew"] + command = ["uv", "run", script_name] env = build_env_with_all_tool_credentials() if trained_agents_file: @@ -592,21 +662,20 @@ def execute_command( subprocess.run(command, capture_output=False, text=True, check=True, env=env) # noqa: S603 except subprocess.CalledProcessError as e: - handle_error(e, crew_type) + _handle_run_error(e, entity_type) except Exception as e: click.echo(f"An unexpected error occurred: {e}", err=True) -def handle_error(error: subprocess.CalledProcessError, crew_type: CrewType) -> None: +def _handle_run_error(error: subprocess.CalledProcessError, entity_type: str) -> None: """ Handle subprocess errors with appropriate messaging. Args: error: The subprocess error that occurred - crew_type: The type of crew that was being run + entity_type: The type of entity that was being run """ - entity_type = "flow" if crew_type == CrewType.FLOW else "crew" click.echo(f"An error occurred while running the {entity_type}: {error}", err=True) if error.output: diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index af7431b02..c6ff668c4 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -21,7 +21,7 @@ def run_declarative_flow_in_project_env( if inputs is not None: raise click.UsageError("--inputs is only supported with --definition") - _execute_declarative_flow_command(["uv", "run", "crewai", "flow", "kickoff"]) + _execute_declarative_flow_command(["uv", "run", "crewai", "run"]) def plot_declarative_flow_in_project_env(definition: str) -> None: @@ -34,7 +34,7 @@ def plot_declarative_flow_in_project_env(definition: str) -> None: def run_declarative_flow(definition: str, inputs: str | None = None) -> None: - """Run a declarative flow from a YAML/JSON file path.""" + """Run a declarative flow from a definition path.""" parsed_inputs = _parse_inputs(inputs) try: @@ -50,7 +50,7 @@ def run_declarative_flow(definition: str, inputs: str | None = None) -> None: def plot_declarative_flow(definition: str) -> None: - """Plot a declarative flow from a YAML/JSON file path.""" + """Plot a declarative flow from a definition path.""" try: flow = load_declarative_flow(definition) flow.plot() @@ -62,7 +62,7 @@ def plot_declarative_flow(definition: str) -> None: def load_declarative_flow(definition: str) -> Any: - """Load a declarative Flow instance from a YAML/JSON file path.""" + """Load a declarative Flow instance from a definition path.""" try: from crewai.flow.flow import Flow from crewai.flow.flow_definition import FlowDefinition diff --git a/lib/cli/src/crewai_cli/templates/AGENTS.md b/lib/cli/src/crewai_cli/templates/AGENTS.md index cb9fff256..8f39289e7 100644 --- a/lib/cli/src/crewai_cli/templates/AGENTS.md +++ b/lib/cli/src/crewai_cli/templates/AGENTS.md @@ -62,7 +62,7 @@ crewai create flow --skip_provider # New flow project # Running crewai run # Run crew or flow (auto-detects from pyproject.toml) -crewai flow kickoff # Legacy flow execution +crewai flow kickoff # Deprecated compatibility alias for crewai run # Testing & training crewai test # Test crew (default: 2 iterations, gpt-4o-mini) diff --git a/lib/cli/src/crewai_cli/templates/declarative_flow/README.md b/lib/cli/src/crewai_cli/templates/declarative_flow/README.md index 2de72c4df..697c0aa32 100644 --- a/lib/cli/src/crewai_cli/templates/declarative_flow/README.md +++ b/lib/cli/src/crewai_cli/templates/declarative_flow/README.md @@ -1,6 +1,6 @@ # {{name}} Flow -This project defines a CrewAI Flow in `src/{{folder_name}}/flow.yaml`. +This project defines a declarative CrewAI Flow in `src/{{folder_name}}/flow.yaml`. ## Install @@ -11,7 +11,7 @@ crewai install ## Run ```bash -crewai flow kickoff +crewai run ``` -Edit `src/{{folder_name}}/flow.yaml` to change the flow. Add reusable crews under `src/{{folder_name}}/crews/`, custom Python tools under `src/{{folder_name}}/tools/`, and shared knowledge files under `src/{{folder_name}}/knowledge/`. +Edit the declarative flow definition at `src/{{folder_name}}/flow.yaml` to change the flow. Add reusable crews under `src/{{folder_name}}/crews/`, custom Python tools under `src/{{folder_name}}/tools/`, and shared knowledge files under `src/{{folder_name}}/knowledge/`. diff --git a/lib/cli/tests/test_cli.py b/lib/cli/tests/test_cli.py index 9d8802f27..28f849ce2 100644 --- a/lib/cli/tests/test_cli.py +++ b/lib/cli/tests/test_cli.py @@ -12,6 +12,7 @@ from crewai_cli.cli import ( deploy_remove, deply_status, flow_add_crew, + flow_run, login, reset_memories, run, @@ -126,40 +127,72 @@ def test_run_uses_project_runner_by_default(run_crew, runner): result = runner.invoke(run) assert result.exit_code == 0 - run_crew.assert_called_once_with(trained_agents_file=None) + run_crew.assert_called_once_with( + trained_agents_file=None, + definition=None, + inputs=None, + ) assert "experimental" not in result.output.lower() -@mock.patch("crewai_cli.cli.run_declarative_flow") -def test_run_with_definition_uses_definition_runner(run_declarative_flow, runner): +@mock.patch("crewai_cli.cli.run_crew") +def test_run_with_definition_uses_project_runner(run_crew, runner): result = runner.invoke( run, ["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'], ) assert result.exit_code == 0 - assert ( - "Warning: `crewai run --definition` is experimental and may change without notice." - in result.output - ) - run_declarative_flow.assert_called_once_with( - definition="flow.yaml", inputs='{"topic":"AI"}' + run_crew.assert_called_once_with( + trained_agents_file=None, + definition="flow.yaml", + inputs='{"topic":"AI"}', ) @mock.patch("crewai_cli.cli.run_crew") -@mock.patch("crewai_cli.cli.run_declarative_flow") -def test_run_rejects_inputs_without_definition( - run_declarative_flow, run_crew, runner -): +def test_run_rejects_inputs_without_definition(run_crew, runner): result = runner.invoke(run, ["--inputs", '{"topic":"AI"}']) assert result.exit_code == 2 assert "Error: --inputs requires --definition" in result.output - run_declarative_flow.assert_not_called() run_crew.assert_not_called() +@mock.patch("crewai_cli.cli.run_crew") +def test_run_rejects_filename_with_definition(run_crew, runner): + result = runner.invoke(run, ["--definition", "flow.yaml", "--filename", "x.pkl"]) + + assert result.exit_code == 2 + assert "Error: --filename can only be used when running crews" in result.output + run_crew.assert_not_called() + + +@mock.patch("crewai_cli.cli.run_crew") +def test_run_passes_filename_to_project_runner(run_crew, runner): + result = runner.invoke(run, ["--filename", "trained.pkl"]) + + assert result.exit_code == 0 + run_crew.assert_called_once_with( + trained_agents_file="trained.pkl", + definition=None, + inputs=None, + ) + + +@mock.patch("crewai_cli.cli.run_crew") +def test_flow_kickoff_is_deprecated_and_uses_run_path(run_crew, runner): + result = runner.invoke(flow_run) + + assert result.exit_code == 0 + run_crew.assert_called_once_with( + trained_agents_file=None, + definition=None, + inputs=None, + ) + assert "DeprecationWarning" in result.output + + @mock.patch("crewai_cli.create_json_crew.create_json_crew") def test_create_crew_in_dmn_mode_skips_provider_prompts(create_json_crew, runner): result = runner.invoke(create, ["crew", "DMN Crew"], env={"CREWAI_DMN": "True"}) diff --git a/lib/cli/tests/test_create_flow.py b/lib/cli/tests/test_create_flow.py index 2fa941e58..256ace28c 100644 --- a/lib/cli/tests/test_create_flow.py +++ b/lib/cli/tests/test_create_flow.py @@ -28,9 +28,7 @@ def test_create_flow_declarative_project_can_run( assert (project_root / pyproject["tool"]["crewai"]["definition"]).is_file() monkeypatch.chdir(project_root) - result = CliRunner().invoke( - crewai, ["flow", "kickoff"], env={"UV_RUN_RECURSION_DEPTH": "1"} - ) + result = CliRunner().invoke(crewai, ["run"], env={"UV_RUN_RECURSION_DEPTH": "1"}) assert result.exit_code == 0 assert "Running the Flow" in result.output diff --git a/lib/cli/tests/test_flow_commands.py b/lib/cli/tests/test_flow_commands.py index 6154ff642..3158fc9e0 100644 --- a/lib/cli/tests/test_flow_commands.py +++ b/lib/cli/tests/test_flow_commands.py @@ -1,12 +1,12 @@ from __future__ import annotations -from collections.abc import Callable from pathlib import Path import subprocess import pytest +from click.testing import CliRunner -import crewai_cli.kickoff_flow as kickoff_flow_module +from crewai_cli.cli import flow_run import crewai_cli.plot_flow as plot_flow_module @@ -33,18 +33,19 @@ def _write_flow_project(project_root: Path) -> None: ) -def test_kickoff_flow_runs_configured_declarative_definition( +def test_flow_kickoff_runs_configured_declarative_definition( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - capsys: pytest.CaptureFixture[str], ) -> None: _write_flow_project(tmp_path) monkeypatch.chdir(tmp_path) monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1") - kickoff_flow_module.kickoff_flow() + result = CliRunner().invoke(flow_run) - assert capsys.readouterr().out == "AI\n" + assert result.exit_code == 0 + assert "DeprecationWarning" in result.output + assert "Running the Flow\nAI\n" in result.output def test_plot_flow_runs_configured_declarative_definition( @@ -57,18 +58,27 @@ def test_plot_flow_runs_configured_declarative_definition( plot_flow_module.plot_flow() -@pytest.mark.parametrize( - ("command", "expected"), - [ - pytest.param(kickoff_flow_module.kickoff_flow, ["uv", "run", "kickoff"]), - pytest.param(plot_flow_module.plot_flow, ["uv", "run", "plot"]), - ], -) -def test_flow_commands_keep_python_entrypoint_without_definition( +def test_flow_kickoff_delegates_to_run_crew( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = [] + + monkeypatch.setattr( + "crewai_cli.cli.run_crew", + lambda **kwargs: calls.append(kwargs), + ) + + result = CliRunner().invoke(flow_run) + + assert result.exit_code == 0 + assert calls == [ + {"trained_agents_file": None, "definition": None, "inputs": None}, + ] + + +def test_plot_flow_keeps_python_entrypoint_without_definition( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - command: Callable[[], None], - expected: list[str], ) -> None: subprocess_calls = [] @@ -79,11 +89,11 @@ def test_flow_commands_keep_python_entrypoint_without_definition( lambda command, **kwargs: subprocess_calls.append((command, kwargs)), ) - command() + plot_flow_module.plot_flow() assert subprocess_calls == [ ( - expected, + ["uv", "run", "plot"], {"capture_output": False, "text": True, "check": True}, ) ] diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index ebb48d72b..40255abed 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -568,3 +568,131 @@ def test_has_json_crew_true_without_pyproject(monkeypatch, tmp_path: Path): (tmp_path / "crew.jsonc").write_text("{}") assert run_crew_module._has_json_crew() is True + + +def test_run_crew_rejects_inputs_without_definition(): + with pytest.raises(click.UsageError) as exc_info: + run_crew_module.run_crew(inputs='{"topic":"AI"}') + + assert "--inputs requires --definition" in exc_info.value.message + + +def test_run_crew_rejects_filename_with_explicit_definition(): + with pytest.raises(click.UsageError) as exc_info: + run_crew_module.run_crew( + trained_agents_file="trained.pkl", + definition="flow.yaml", + ) + + assert "--filename can only be used when running crews" in exc_info.value.message + + +def test_run_crew_runs_explicit_declarative_definition(monkeypatch, capsys): + calls = [] + + def fake_run_declarative_flow(definition: str, inputs: str | None = None): + calls.append((definition, inputs)) + + monkeypatch.setattr( + "crewai_cli.run_declarative_flow.run_declarative_flow", + fake_run_declarative_flow, + ) + + run_crew_module.run_crew(definition="flow.yaml", inputs='{"topic":"AI"}') + + captured = capsys.readouterr() + assert "experimental" not in captured.out.lower() + assert calls == [("flow.yaml", '{"topic":"AI"}')] + + +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", + lambda: {"tool": {"crewai": {"type": "crew"}}}, + ) + monkeypatch.setattr( + run_crew_module, + "_execute_uv_script", + lambda script_name, **kwargs: calls.append((script_name, kwargs)), + ) + + run_crew_module.run_crew(trained_agents_file="trained.pkl") + + assert capsys.readouterr().out == "Running the Crew\n" + assert calls == [ + ( + "run_crew", + {"entity_type": "crew", "trained_agents_file": "trained.pkl"}, + ) + ] + + +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", + lambda: {"tool": {"crewai": {"type": "flow"}}}, + ) + monkeypatch.setattr( + run_crew_module, + "_execute_uv_script", + lambda script_name, **kwargs: calls.append((script_name, kwargs)), + ) + + run_crew_module.run_crew() + + assert capsys.readouterr().out == "Running the Flow\n" + assert calls == [("kickoff", {"entity_type": "flow"})] + + +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", + lambda: {"tool": {"crewai": {"type": "flow"}}}, + ) + + with pytest.raises(click.UsageError) as exc_info: + run_crew_module.run_crew(trained_agents_file="trained.pkl") + + assert "--filename can only be used when running crews" in exc_info.value.message + + +def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys): + calls = [] + + monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) + monkeypatch.setattr( + run_crew_module, + "read_toml", + lambda: { + "tool": { + "crewai": { + "type": "flow", + "definition": "flow.yaml", + } + } + }, + ) + monkeypatch.setattr( + "crewai_cli.run_declarative_flow.run_declarative_flow_in_project_env", + lambda definition, inputs=None: calls.append((definition, inputs)), + ) + monkeypatch.setattr( + run_crew_module, + "_execute_uv_script", + lambda *_args, **_kwargs: pytest.fail("declarative flows must not run kickoff"), + ) + + run_crew_module.run_crew() + + assert capsys.readouterr().out == "Running the Flow\n" + assert calls == [("flow.yaml", None)] diff --git a/lib/cli/tests/test_run_declarative_flow.py b/lib/cli/tests/test_run_declarative_flow.py index 9808d6b17..db1286ee7 100644 --- a/lib/cli/tests/test_run_declarative_flow.py +++ b/lib/cli/tests/test_run_declarative_flow.py @@ -83,7 +83,7 @@ def test_run_declarative_flow_in_project_env_uses_uv( assert subprocess_calls == [ ( - ["uv", "run", "crewai", "flow", "kickoff"], + ["uv", "run", "crewai", "run"], { "capture_output": False, "text": True, diff --git a/lib/crewai/tests/cli/test_run_crew.py b/lib/crewai/tests/cli/test_run_crew.py index 34074e526..c58772083 100644 --- a/lib/crewai/tests/cli/test_run_crew.py +++ b/lib/crewai/tests/cli/test_run_crew.py @@ -11,11 +11,10 @@ import pytest from crewai_cli.cli import run from crewai_cli.run_crew import ( - CrewType, + _execute_uv_script, _load_json_crew_for_tui, _missing_input_names, _prompt_for_missing_inputs, - execute_command, ) @@ -30,6 +29,8 @@ def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRu run_crew_mock.assert_called_once_with( trained_agents_file="my_custom_trained.pkl", + definition=None, + inputs=None, ) assert result.exit_code == 0 @@ -38,7 +39,11 @@ def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRu def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliRunner) -> None: result = runner.invoke(run) - run_crew_mock.assert_called_once_with(trained_agents_file=None) + run_crew_mock.assert_called_once_with( + trained_agents_file=None, + definition=None, + inputs=None, + ) assert result.exit_code == 0 @@ -50,7 +55,11 @@ def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliR def test_execute_command_sets_env_var_when_filename_provided( _build_env: mock.Mock, subprocess_run: mock.Mock ) -> None: - execute_command(CrewType.STANDARD, trained_agents_file="my_custom_trained.pkl") + _execute_uv_script( + "run_crew", + entity_type="crew", + trained_agents_file="my_custom_trained.pkl", + ) _, kwargs = subprocess_run.call_args assert kwargs["env"]["CREWAI_TRAINED_AGENTS_FILE"] == "my_custom_trained.pkl" @@ -65,7 +74,7 @@ def test_execute_command_sets_env_var_when_filename_provided( def test_execute_command_omits_env_var_when_filename_absent( _build_env: mock.Mock, subprocess_run: mock.Mock ) -> None: - execute_command(CrewType.STANDARD) + _execute_uv_script("run_crew", entity_type="crew") _, kwargs = subprocess_run.call_args assert "CREWAI_TRAINED_AGENTS_FILE" not in kwargs["env"] From 2eb4e3a236bada5432290654b8d442345eafb19e Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Mon, 22 Jun 2026 22:31:39 -0700 Subject: [PATCH 06/22] Improve crewai run startup UX (#6297) Remove redundant startup logs from `crewai run` and make the legacy flow command warning actionable. - Stop printing `Running the Flow` and `Running the Crew` before project execution. - Stop printing the redundant `Flow started with ID: ...` line while preserving flow lifecycle event emission. - Replace Click's generic `kickoff` deprecation warning with a clearer message that tells users to use `crewai run`. --- lib/cli/src/crewai_cli/cli.py | 6 +++++- lib/cli/src/crewai_cli/run_crew.py | 2 -- lib/cli/tests/test_cli.py | 5 ++++- lib/cli/tests/test_create_flow.py | 2 +- lib/cli/tests/test_flow_commands.py | 8 ++++++-- lib/cli/tests/test_run_crew.py | 6 +++--- lib/crewai/src/crewai/flow/runtime/__init__.py | 5 ----- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/lib/cli/src/crewai_cli/cli.py b/lib/cli/src/crewai_cli/cli.py index b2050bc34..30eccdc61 100644 --- a/lib/cli/src/crewai_cli/cli.py +++ b/lib/cli/src/crewai_cli/cli.py @@ -787,9 +787,13 @@ def flow() -> None: """Flow related commands.""" -@flow.command(name="kickoff", deprecated=True) +@flow.command(name="kickoff") def flow_run() -> None: """Kickoff the Flow.""" + click.secho( + "The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead.", + fg="yellow", + ) run_crew(trained_agents_file=None, definition=None, inputs=None) diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index 0fa61dc7a..f9948a297 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -595,7 +595,6 @@ def _run_flow_project( if trained_agents_file is not None: raise click.UsageError("--filename can only be used when running crews") - click.echo("Running the Flow") from crewai_cli.run_declarative_flow import ( configured_project_declarative_flow, run_declarative_flow_in_project_env, @@ -611,7 +610,6 @@ def _run_flow_project( def _run_classic_crew_project( pyproject_data: dict[str, Any], trained_agents_file: str | None ) -> None: - click.echo("Running the Crew") _execute_uv_script( "run_crew", entity_type="crew", diff --git a/lib/cli/tests/test_cli.py b/lib/cli/tests/test_cli.py index 28f849ce2..acdeee7ff 100644 --- a/lib/cli/tests/test_cli.py +++ b/lib/cli/tests/test_cli.py @@ -190,7 +190,10 @@ def test_flow_kickoff_is_deprecated_and_uses_run_path(run_crew, runner): definition=None, inputs=None, ) - assert "DeprecationWarning" in result.output + assert ( + "The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead." + in result.output + ) @mock.patch("crewai_cli.create_json_crew.create_json_crew") diff --git a/lib/cli/tests/test_create_flow.py b/lib/cli/tests/test_create_flow.py index 256ace28c..21487a7a1 100644 --- a/lib/cli/tests/test_create_flow.py +++ b/lib/cli/tests/test_create_flow.py @@ -31,5 +31,5 @@ def test_create_flow_declarative_project_can_run( result = CliRunner().invoke(crewai, ["run"], env={"UV_RUN_RECURSION_DEPTH": "1"}) assert result.exit_code == 0 - assert "Running the Flow" in result.output + assert "Running the Flow" not in result.output assert "AI agents" in result.output diff --git a/lib/cli/tests/test_flow_commands.py b/lib/cli/tests/test_flow_commands.py index 3158fc9e0..00e39b6db 100644 --- a/lib/cli/tests/test_flow_commands.py +++ b/lib/cli/tests/test_flow_commands.py @@ -44,8 +44,12 @@ def test_flow_kickoff_runs_configured_declarative_definition( result = CliRunner().invoke(flow_run) assert result.exit_code == 0 - assert "DeprecationWarning" in result.output - assert "Running the Flow\nAI\n" in result.output + assert ( + "The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead." + in result.output + ) + assert "AI\n" in result.output + assert "Running the Flow" not in result.output def test_plot_flow_runs_configured_declarative_definition( diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index 40255abed..c51fc16c5 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -622,7 +622,7 @@ def test_run_crew_runs_classic_crew_project(monkeypatch, capsys): run_crew_module.run_crew(trained_agents_file="trained.pkl") - assert capsys.readouterr().out == "Running the Crew\n" + assert capsys.readouterr().out == "" assert calls == [ ( "run_crew", @@ -648,7 +648,7 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys): run_crew_module.run_crew() - assert capsys.readouterr().out == "Running the Flow\n" + assert capsys.readouterr().out == "" assert calls == [("kickoff", {"entity_type": "flow"})] @@ -694,5 +694,5 @@ def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys): run_crew_module.run_crew() - assert capsys.readouterr().out == "Running the Flow\n" + assert capsys.readouterr().out == "" assert calls == [("flow.yaml", None)] diff --git a/lib/crewai/src/crewai/flow/runtime/__init__.py b/lib/crewai/src/crewai/flow/runtime/__init__.py index fa465cb71..c47526a78 100644 --- a/lib/crewai/src/crewai/flow/runtime/__init__.py +++ b/lib/crewai/src/crewai/flow/runtime/__init__.py @@ -2455,11 +2455,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): object.__setattr__( self, "_deferred_flow_started_event_id", started_event.event_id ) - if not self.suppress_flow_events: - self._log_flow_event( - f"Flow started with ID: {self.flow_id}", color="bold magenta" - ) - # After FlowStarted: env events must not pre-empt trace batch init # with implicit "crew" execution_type. get_env_context() From 793539173d1df02735f711179441cee026f03ef4 Mon Sep 17 00:00:00 2001 From: Lucas Gomide Date: Tue, 23 Jun 2026 15:51:22 -0300 Subject: [PATCH 07/22] fix: pin opentelemetry to ~=1.42.0 (#6292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `~=1.34.0` pin kept us on the unmaintained 1.34 line — last patched as `1.34.1` in June 2025, eight minor releases behind upstream — and caused `_create_exp_backoff_generator` `ImportError` crashes in factory deployments where the OpenTelemetry Operator's injected init container shadows `opentelemetry.exporter.otlp.proto.common._internal` with >=1.35 while our `opentelemetry-exporter-otlp-proto-grpc==1.34.1` still imports the removed private symbol. Pinning to `~=1.42.0` tracks the current upstream stable line; the resolver now lands on 1.42.1 and our public OTel trace API usage is unaffected. --- lib/crewai-core/pyproject.toml | 6 ++-- lib/crewai/pyproject.toml | 6 ++-- uv.lock | 63 +++++++++++++++++----------------- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/lib/crewai-core/pyproject.toml b/lib/crewai-core/pyproject.toml index e641548e4..405dd4d46 100644 --- a/lib/crewai-core/pyproject.toml +++ b/lib/crewai-core/pyproject.toml @@ -16,9 +16,9 @@ dependencies = [ "pyjwt>=2.13.0,<3", "pydantic>=2.11.9,<2.13", "rich>=13.7.1", - "opentelemetry-api~=1.34.0", - "opentelemetry-sdk~=1.34.0", - "opentelemetry-exporter-otlp-proto-http~=1.34.0", + "opentelemetry-api~=1.42.0", + "opentelemetry-sdk~=1.42.0", + "opentelemetry-exporter-otlp-proto-http~=1.42.0", "tomli~=2.0.2", ] diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index 95a41f97b..a90d6d7a1 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -18,9 +18,9 @@ dependencies = [ "pdfplumber~=0.11.4", "regex~=2026.1.15", # Telemetry and Monitoring - "opentelemetry-api~=1.34.0", - "opentelemetry-sdk~=1.34.0", - "opentelemetry-exporter-otlp-proto-http~=1.34.0", + "opentelemetry-api~=1.42.0", + "opentelemetry-sdk~=1.42.0", + "opentelemetry-exporter-otlp-proto-http~=1.42.0", # Data Handling "chromadb~=1.1.0", "tokenizers>=0.21,<1", diff --git a/uv.lock b/uv.lock index b623014c8..93e0f8be8 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-20T16:46:21.117658Z" exclude-newer-span = "P3D" [options.exclude-newer-package] @@ -1452,9 +1452,9 @@ requires-dist = [ { name = "openai", specifier = ">=2.30.0,<3" }, { name = "openpyxl", specifier = "~=3.1.5" }, { name = "openpyxl", marker = "extra == 'openpyxl'", specifier = "~=3.1.5" }, - { name = "opentelemetry-api", specifier = "~=1.34.0" }, - { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" }, - { name = "opentelemetry-sdk", specifier = "~=1.34.0" }, + { name = "opentelemetry-api", specifier = "~=1.42.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.42.0" }, + { name = "opentelemetry-sdk", specifier = "~=1.42.0" }, { name = "pandas", marker = "extra == 'pandas'", specifier = "~=2.2.3" }, { name = "pdfplumber", specifier = "~=0.11.4" }, { name = "portalocker", specifier = "~=2.7.0" }, @@ -1539,9 +1539,9 @@ requires-dist = [ { name = "appdirs", specifier = "~=1.4.4" }, { name = "cryptography", specifier = ">=42.0" }, { name = "httpx", specifier = "~=0.28.1" }, - { name = "opentelemetry-api", specifier = "~=1.34.0" }, - { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" }, - { name = "opentelemetry-sdk", specifier = "~=1.34.0" }, + { name = "opentelemetry-api", specifier = "~=1.42.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.42.0" }, + { name = "opentelemetry-sdk", specifier = "~=1.42.0" }, { name = "packaging", specifier = ">=23.0" }, { name = "portalocker", specifier = "~=2.7.0" }, { name = "pydantic", specifier = ">=2.11.9,<2.13" }, @@ -5585,45 +5585,44 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/5e/94a8cb759e4e409022229418294e098ca7feca00eb3c467bb20cbd329bda/opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3", size = 64987, upload-time = "2025-06-10T08:55:19.818Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/3a/2ba85557e8dc024c0842ad22c570418dc02c36cbd1ab4b832a93edf071b8/opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c", size = 65767, upload-time = "2025-06-10T08:54:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, ] [[package]] name = "opentelemetry-exporter-otlp" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/ba/786b4de7e39d88043622d901b92c4485835f43e0be76c2824d2687911bc2/opentelemetry_exporter_otlp-1.34.1.tar.gz", hash = "sha256:71c9ad342d665d9e4235898d205db17c5764cd7a69acb8a5dcd6d5e04c4c9988", size = 6173, upload-time = "2025-06-10T08:55:21.595Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/94/8637919a5d01f81dacf510234bc0110b944f4687a6e96b0a02adf2f6bdce/opentelemetry_exporter_otlp-1.42.1.tar.gz", hash = "sha256:2d9ebaed714377a67d224d46795ddcc11d2c877fa5de35fda70b6f3b010729a9", size = 6086, upload-time = "2026-05-21T16:32:51.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c1/259b8d8391c968e8f005d8a0ccefcb41aeef64cf55905cd0c0db4e22aaee/opentelemetry_exporter_otlp-1.34.1-py3-none-any.whl", hash = "sha256:f4a453e9cde7f6362fd4a090d8acf7881d1dc585540c7b65cbd63e36644238d4", size = 7040, upload-time = "2025-06-10T08:54:59.655Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4d/c26080295a36fd22e201fefd7cb9c22cd203189b1af8cd73b158382b7ad8/opentelemetry_exporter_otlp-1.42.1-py3-none-any.whl", hash = "sha256:aedd54545bb0587cd45210abdc8be545af9c01413f3307786e276df1e3c83bee", size = 6733, upload-time = "2026-05-21T16:32:31.261Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/f0/ff235936ee40db93360233b62da932d4fd9e8d103cd090c6bcb9afaf5f01/opentelemetry_exporter_otlp_proto_common-1.34.1.tar.gz", hash = "sha256:b59a20a927facd5eac06edaf87a07e49f9e4a13db487b7d8a52b37cb87710f8b", size = 20817, upload-time = "2025-06-10T08:55:22.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/e8/8b292a11cc8d8d87ec0c4089ae21b6a58af49ca2e51fa916435bc922fdc7/opentelemetry_exporter_otlp_proto_common-1.34.1-py3-none-any.whl", hash = "sha256:8e2019284bf24d3deebbb6c59c71e6eef3307cd88eff8c633e061abba33f7e87", size = 18834, upload-time = "2025-06-10T08:55:00.806Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -5634,14 +5633,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/f7/bb63837a3edb9ca857aaf5760796874e7cecddc88a2571b0992865a48fb6/opentelemetry_exporter_otlp_proto_grpc-1.34.1.tar.gz", hash = "sha256:7c841b90caa3aafcfc4fee58487a6c71743c34c6dc1787089d8b0578bbd794dd", size = 22566, upload-time = "2025-06-10T08:55:23.214Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/42/0a4dd47e7ef54edf670c81fc06a83d68ea42727b82126a1df9dd0477695d/opentelemetry_exporter_otlp_proto_grpc-1.34.1-py3-none-any.whl", hash = "sha256:04bb8b732b02295be79f8a86a4ad28fae3d4ddb07307a98c7aa6f331de18cca6", size = 18615, upload-time = "2025-06-10T08:55:02.214Z" }, + { url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -5652,48 +5651,48 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/8f/954bc725961cbe425a749d55c0ba1df46832a5999eae764d1a7349ac1c29/opentelemetry_exporter_otlp_proto_http-1.34.1.tar.gz", hash = "sha256:aaac36fdce46a8191e604dcf632e1f9380c7d5b356b27b3e0edb5610d9be28ad", size = 15351, upload-time = "2025-06-10T08:55:24.657Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/54/b05251c04e30c1ac70cf4a7c5653c085dfcf2c8b98af71661d6a252adc39/opentelemetry_exporter_otlp_proto_http-1.34.1-py3-none-any.whl", hash = "sha256:5251f00ca85872ce50d871f6d3cc89fe203b94c3c14c964bbdc3883366c705d8", size = 17744, upload-time = "2025-06-10T08:55:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/b3/c3158dd012463bb7c0eb7304a85a6f63baeeb5b4c93a53845cf89f848c7e/opentelemetry_proto-1.34.1.tar.gz", hash = "sha256:16286214e405c211fc774187f3e4bbb1351290b8dfb88e8948af209ce85b719e", size = 34344, upload-time = "2025-06-10T08:55:32.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/ab/4591bfa54e946350ce8b3f28e5c658fe9785e7cd11e9c11b1671a867822b/opentelemetry_proto-1.34.1-py3-none-any.whl", hash = "sha256:eb4bb5ac27f2562df2d6857fc557b3a481b5e298bc04f94cc68041f00cebcbd2", size = 55692, upload-time = "2025-06-10T08:55:14.904Z" }, + { url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.34.1" +version = "1.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/41/fe20f9036433da8e0fcef568984da4c1d1c771fa072ecd1a4d98779dccdd/opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d", size = 159441, upload-time = "2025-06-10T08:55:33.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/1b/def4fe6aa73f483cabf4c748f4c25070d5f7604dcc8b52e962983491b29e/opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e", size = 118477, upload-time = "2025-06-10T08:55:16.02Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.55b1" +version = "0.63b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/f0/f33458486da911f47c4aa6db9bda308bb80f3236c111bf848bd870c16b16/opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3", size = 119829, upload-time = "2025-06-10T08:55:33.881Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/89/267b0af1b1d0ba828f0e60642b6a5116ac1fd917cde7fc02821627029bd1/opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed", size = 196223, upload-time = "2025-06-10T08:55:17.638Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, ] [[package]] From 3452e5c18700136f9a15f59e82be9a813782d60a Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Tue, 23 Jun 2026 12:02:18 -0700 Subject: [PATCH 08/22] Add unified declarative flow loading (#6308) Add a single declaration loader shared by API and CLI callers. - Add FlowDefinition.from_declaration for FlowDefinition instances, dictionaries, YAML/JSON strings, and file paths - Add Flow.from_declaration to build runnable flows directly from the same inputs - Route declarative flow CLI loading through Flow.from_declaration so path handling and validation stay centralized ``` # Load just the serializable definition when you do not need to run it yet. definition = FlowDefinition.from_declaration(path="flows/research.crewai") definition = FlowDefinition.from_declaration(contents=flow_yaml) definition = FlowDefinition.from_declaration(contents=flow_dict) # Build a runnable flow directly from the same declaration inputs. flow = Flow.from_declaration(path="flows/research.crewai") flow = Flow.from_declaration(contents=flow_yaml) flow = Flow.from_declaration(contents=flow_dict) flow = Flow.from_declaration(contents=definition) # Run it like any other flow. result = flow.kickoff(inputs={"topic": "AI agents"}) # The CLI now goes through the same path-based loader. # crewai run --definition flows/research.crewai ``` --- .../src/crewai_cli/run_declarative_flow.py | 79 +++----- lib/cli/tests/test_run_declarative_flow.py | 37 ++++ lib/crewai/src/crewai/flow/flow_definition.py | 47 +++-- .../src/crewai/flow/runtime/__init__.py | 16 ++ lib/crewai/tests/test_flow_definition.py | 140 +++++++++++-- lib/crewai/tests/test_flow_from_definition.py | 190 ++++++++++-------- 6 files changed, 341 insertions(+), 168 deletions(-) diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index c6ff668c4..b70492777 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -6,6 +6,7 @@ import subprocess from typing import Any import click +from pydantic import ValidationError from crewai_cli.utils import build_env_with_all_tool_credentials @@ -65,7 +66,6 @@ def load_declarative_flow(definition: str) -> Any: """Load a declarative Flow instance from a definition path.""" try: from crewai.flow.flow import Flow - from crewai.flow.flow_definition import FlowDefinition except ImportError as exc: click.echo( "Running declarative flows requires the full crewai package.", @@ -74,14 +74,30 @@ def load_declarative_flow(definition: str) -> Any: raise SystemExit(1) from exc definition_path = Path(definition).expanduser() - definition_source = _read_declarative_flow_source(definition_path, definition) + try: + if not definition_path.is_file(): + if definition_path.exists(): + click.echo( + f"Invalid --definition path: {definition} is not a file.", + err=True, + ) + raise SystemExit(1) + click.echo( + f"Invalid --definition path: {definition} does not exist.", err=True + ) + raise SystemExit(1) + except OSError as exc: + click.echo(f"Invalid --definition path: {definition} ({exc})", err=True) + raise SystemExit(1) from exc - flow_definition = _parse_declarative_flow( - FlowDefinition, - definition_source, - source_path=definition_path, - ) - return Flow.from_definition(flow_definition) + try: + return Flow.from_declaration(path=definition_path) + except (OSError, UnicodeError, ValueError, ValidationError) as exc: + click.echo( + f"Unable to read --definition path {definition_path}: {exc}", + err=True, + ) + raise SystemExit(1) from exc def configured_project_declarative_flow( @@ -154,53 +170,6 @@ def _parse_inputs(inputs: str | None) -> dict[str, Any] | None: return parsed -def _read_declarative_flow_source(path: Path, definition: str) -> str: - try: - if path.is_file(): - source = _read_declarative_flow_file(path) - elif path.exists(): - click.echo( - f"Invalid --definition path: {definition} is not a file.", err=True - ) - raise SystemExit(1) - else: - click.echo( - f"Invalid --definition path: {definition} does not exist.", err=True - ) - raise SystemExit(1) - except OSError as exc: - click.echo(f"Invalid --definition path: {definition} ({exc})", err=True) - raise SystemExit(1) from exc - - return source - - -def _read_declarative_flow_file(path: Path) -> str: - try: - source = path.read_text(encoding="utf-8") - except (OSError, UnicodeError) as exc: - click.echo( - f"Unable to read --definition path {path}: {exc}", - err=True, - ) - raise SystemExit(1) from exc - return source - - -def _parse_declarative_flow( - flow_definition_cls: type[Any], source: str, *, source_path: Path -) -> Any: - if _looks_like_json(source): - return flow_definition_cls.from_json(source, source_path=source_path) - - return flow_definition_cls.from_yaml(source, source_path=source_path) - - -def _looks_like_json(source: str) -> bool: - stripped = source.lstrip() - return stripped.startswith("{") - - def _format_result(result: Any) -> str: raw_result = getattr(result, "raw", result) if isinstance(raw_result, str): diff --git a/lib/cli/tests/test_run_declarative_flow.py b/lib/cli/tests/test_run_declarative_flow.py index db1286ee7..8d5435c8f 100644 --- a/lib/cli/tests/test_run_declarative_flow.py +++ b/lib/cli/tests/test_run_declarative_flow.py @@ -60,6 +60,43 @@ def test_run_declarative_flow_reports_missing_file( ) +def test_run_declarative_flow_reports_empty_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + definition_path = tmp_path / "flow.yaml" + definition_path.write_text(" \n", encoding="utf-8") + + with pytest.raises(SystemExit): + run_declarative_flow_module.run_declarative_flow(str(definition_path)) + + assert "Flow declaration file is empty" in capsys.readouterr().err + + +@pytest.mark.parametrize( + "contents, expected_error", + [ + ("[]\n", "Flow declaration must contain a mapping"), + ("schema: crewai.flow/v1\nmethods: {}\n", "Field required"), + ], +) +def test_load_declarative_flow_reports_invalid_declarations( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + contents: str, + expected_error: str, +) -> None: + definition_path = tmp_path / "flow.yaml" + definition_path.write_text(contents, encoding="utf-8") + + with pytest.raises(SystemExit) as exc_info: + run_declarative_flow_module.load_declarative_flow(str(definition_path)) + + assert exc_info.value.code == 1 + stderr = capsys.readouterr().err + assert f"Unable to read --definition path {definition_path}:" in stderr + assert expected_error in stderr + + def test_run_declarative_flow_in_project_env_uses_uv( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/lib/crewai/src/crewai/flow/flow_definition.py b/lib/crewai/src/crewai/flow/flow_definition.py index 6f05853d0..fa9c89ea8 100644 --- a/lib/crewai/src/crewai/flow/flow_definition.py +++ b/lib/crewai/src/crewai/flow/flow_definition.py @@ -1,7 +1,7 @@ """Flow Definition: the serializable, declarative Flow contract. -Defines :class:`FlowDefinition` and its sub-models — a static, declarative -(JSON/YAML) representation of a Flow: its methods, trigger conditions, +Defines :class:`FlowDefinition` and its sub-models — a static declarative +representation of a Flow: its methods, trigger conditions, state, and configuration. It is independent of the Python authoring layer that may have produced it and of the engine that runs it (see ``runtime``). @@ -235,7 +235,7 @@ class FlowPersistenceDefinition(BaseModel): ``persistence`` may hold a live backend when the definition is built from a decorated class — the engine then persists through the exact instance - the user configured; the JSON/YAML projection degrades it to its + the user configured; the declarative projection degrades it to its serialized config. """ @@ -275,7 +275,7 @@ class FlowHumanFeedbackDefinition(BaseModel): """Static human feedback configuration. ``llm`` and ``provider`` may hold live Python objects when the definition - is built from a decorated class; the JSON/YAML projection degrades them to + is built from a decorated class; the declarative projection degrades them to a serialized config (``llm``) or a ``module:qualname`` ref (``provider``). """ @@ -777,7 +777,7 @@ class FlowDefinition(BaseModel): return self def to_dict(self, *, exclude_none: bool = True) -> dict[str, Any]: - """Serialize the definition to a JSON/YAML-ready dictionary.""" + """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: @@ -817,16 +817,37 @@ class FlowDefinition(BaseModel): return definition @classmethod - def from_json(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition: - """Load a definition from JSON.""" - return cls.from_dict(json.loads(data), source_path=source_path) + def from_declaration( + cls, + *, + contents: FlowDefinition | str | dict[str, Any] | None = None, + path: Path | str | None = None, + ) -> FlowDefinition: + """Load a declarative flow from contents or a file path.""" + if isinstance(contents, cls): + return contents - @classmethod - def from_yaml(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition: - """Load a definition from YAML.""" - loaded = yaml.safe_load(data) or {} + source_path: Path | None = None + if contents is None: + if path is None: + raise ValueError("Provide contents or path") + source_path = Path(path) + contents = source_path.expanduser().read_text(encoding="utf-8") + + if isinstance(contents, dict): + return cls.from_dict(contents) + + if not isinstance(contents, str): + raise TypeError("Flow declaration contents must be a string or dictionary") + + if not contents.strip(): + if source_path is not None: + raise ValueError(f"Flow declaration file is empty: {source_path}") + raise ValueError("Flow declaration contents are empty") + + loaded = yaml.safe_load(contents) if not isinstance(loaded, dict): - raise ValueError("Flow definition YAML must contain a mapping") + raise ValueError("Flow declaration must contain a mapping") return cls.from_dict(loaded, source_path=source_path) @classmethod diff --git a/lib/crewai/src/crewai/flow/runtime/__init__.py b/lib/crewai/src/crewai/flow/runtime/__init__.py index c47526a78..4b07f3533 100644 --- a/lib/crewai/src/crewai/flow/runtime/__init__.py +++ b/lib/crewai/src/crewai/flow/runtime/__init__.py @@ -25,6 +25,7 @@ from datetime import datetime import enum import inspect import logging +from pathlib import Path import threading from typing import ( TYPE_CHECKING, @@ -769,6 +770,21 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): @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, + *, + contents: FlowDefinition | str | dict[str, Any] | None = None, + path: Path | str | None = None, + **kwargs: Any, + ) -> Flow[Any]: + """Build a runnable declarative flow from contents or a file path.""" + definition = FlowDefinition.from_declaration( + contents=contents, + path=path, + ) return cls.model_validate( {**definition.config.model_dump(), **kwargs}, context={"flow_definition": definition}, diff --git a/lib/crewai/tests/test_flow_definition.py b/lib/crewai/tests/test_flow_definition.py index 2aa654151..89df6c902 100644 --- a/lib/crewai/tests/test_flow_definition.py +++ b/lib/crewai/tests/test_flow_definition.py @@ -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_json_and_yaml(): +def test_flow_definition_round_trips_declaration_serialization(): class RoundTripFlow(Flow): @start() def begin(self): @@ -629,16 +629,122 @@ def test_flow_definition_round_trips_json_and_yaml(): definition = RoundTripFlow.flow_definition() - json_round_trip = flow_definition.FlowDefinition.from_json(definition.to_json()) - yaml_round_trip = flow_definition.FlowDefinition.from_yaml(definition.to_yaml()) + round_trips = [ + flow_definition.FlowDefinition.from_declaration(contents=definition.to_json()), + flow_definition.FlowDefinition.from_declaration(contents=definition.to_yaml()), + ] - assert json_round_trip.to_dict() == definition.to_dict() - assert yaml_round_trip.to_dict() == definition.to_dict() - assert yaml_round_trip.methods["decide"].router is True - assert yaml_round_trip.methods["decide"].listen == "begin" + 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" -def test_each_action_round_trips_json_and_yaml(): +def test_flow_definition_from_declaration_accepts_contents(): + data = { + "schema": "crewai.flow/v1", + "name": "DeclarationFlow", + "methods": { + "begin": { + "start": True, + "do": { + "call": "expression", + "expr": "'started'", + }, + }, + }, + } + definition = flow_definition.FlowDefinition.from_dict(data) + contents = [ + definition, + data, + definition.to_json(), + definition.to_yaml(), + ] + expected = definition.to_dict() + + for content in contents: + loaded = flow_definition.FlowDefinition.from_declaration(contents=content) + + assert loaded.to_dict() == expected + + +def test_flow_definition_from_declaration_rejects_empty_file(tmp_path: Path): + declaration_path = tmp_path / "flow.crewai" + declaration_path.write_text(" \n", encoding="utf-8") + + with pytest.raises(ValueError, match="Flow declaration file is empty"): + flow_definition.FlowDefinition.from_declaration(path=declaration_path) + + +@pytest.mark.parametrize("contents", ["[]", "false", "0", "null", "~"]) +def test_flow_definition_from_declaration_rejects_falsey_non_mapping_contents( + contents: str, +): + with pytest.raises(ValueError, match="Flow declaration must contain a mapping"): + flow_definition.FlowDefinition.from_declaration(contents=contents) + + +def test_flow_definition_from_declaration_accepts_paths(tmp_path: Path): + definition = flow_definition.FlowDefinition.from_dict( + { + "schema": "crewai.flow/v1", + "name": "DeclarationFlow", + "methods": { + "begin": { + "start": True, + "do": { + "call": "expression", + "expr": "'started'", + }, + }, + }, + } + ) + declaration_path = tmp_path / "flow.crewai" + declaration_path.write_text(definition.to_yaml(), encoding="utf-8") + path_inputs = [ + declaration_path, + str(declaration_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.source_path == declaration_path.resolve() + + +def test_flow_definition_from_declaration_requires_input(): + with pytest.raises(ValueError, match="Provide contents or path"): + flow_definition.FlowDefinition.from_declaration() + + +def test_flow_definition_from_declaration_prefers_contents_over_path( + tmp_path: Path, +): + data = { + "schema": "crewai.flow/v1", + "name": "ContentsFlow", + "methods": { + "begin": { + "start": True, + "do": {"call": "expression", "expr": "'started'"}, + }, + }, + } + declaration_path = tmp_path / "missing.crewai" + + loaded = flow_definition.FlowDefinition.from_declaration( + contents=data, + path=declaration_path, + ) + + assert loaded.name == "ContentsFlow" + assert loaded.source_path is None + + +def test_each_action_round_trips_declaration_serialization(): definition = flow_definition.FlowDefinition.from_dict( { "schema": "crewai.flow/v1", @@ -677,15 +783,17 @@ def test_each_action_round_trips_json_and_yaml(): } ) - json_round_trip = flow_definition.FlowDefinition.from_json(definition.to_json()) - yaml_round_trip = flow_definition.FlowDefinition.from_yaml(definition.to_yaml()) + round_trips = [ + flow_definition.FlowDefinition.from_declaration(contents=definition.to_json()), + flow_definition.FlowDefinition.from_declaration(contents=definition.to_yaml()), + ] - assert json_round_trip.to_dict() == definition.to_dict() - assert yaml_round_trip.to_dict() == definition.to_dict() - assert yaml_round_trip.methods["process_rows"].description == ( - "Process every loaded row." - ) - assert yaml_round_trip.methods["process_rows"].do.call == "each" + 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" def test_flow_definition_rejects_invalid_method_names(): diff --git a/lib/crewai/tests/test_flow_from_definition.py b/lib/crewai/tests/test_flow_from_definition.py index 693d75ef5..909f56459 100644 --- a/lib/crewai/tests/test_flow_from_definition.py +++ b/lib/crewai/tests/test_flow_from_definition.py @@ -454,7 +454,7 @@ def assert_parity(flow_cls, yaml_str, inputs=None, ordered=True): class_flow = flow_cls() class_result, class_events = _run_with_events(class_flow, inputs) - definition = FlowDefinition.from_yaml(yaml_str) + definition = FlowDefinition.from_declaration(contents=yaml_str) definition_flow = Flow.from_definition(definition) definition_result, definition_events = _run_with_events(definition_flow, inputs) @@ -477,6 +477,21 @@ def test_simple_chain_parity(): assert flow.method_outputs == ["hello", "HELLO", "confirmed:True"] +def test_flow_from_declaration_builds_runnable_flow(): + flow = Flow.from_declaration(contents=CHAIN_YAML) + + assert flow.kickoff() == "confirmed:True" + assert flow.method_outputs == ["hello", "HELLO", "confirmed:True"] + + +def test_flow_from_declaration_accepts_flow_definition(): + definition = FlowDefinition.from_declaration(contents=CHAIN_YAML) + flow = Flow.from_declaration(contents=definition) + + assert flow.kickoff() == "confirmed:True" + assert flow.method_outputs == ["hello", "HELLO", "confirmed:True"] + + def test_and_or_merge_parity(): flow, _ = assert_parity(MergeFlow, MERGE_YAML, ordered=False) assert flow.state["joined"] is True @@ -499,7 +514,7 @@ def test_cyclic_flow_parity(): def test_definition_flow_events_use_definition_name(): - definition = FlowDefinition.from_yaml(CHAIN_YAML) + definition = FlowDefinition.from_declaration(contents=CHAIN_YAML) flow = Flow.from_definition(definition) _, events = _run_with_events(flow) assert events @@ -588,7 +603,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff() == "found:ai agents" @@ -639,7 +654,7 @@ methods: listen: begin """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"topic": "ai"}) == "found:ai agents" @@ -758,7 +773,7 @@ methods: listen: begin """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff() == "search:hello agents" @@ -783,7 +798,7 @@ methods: listen: build_query """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff() == "found:ai agents news" @@ -803,7 +818,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert ( flow.kickoff(inputs={"limit": 2, "domains": ["crewai.com", "example.com"]}) @@ -836,7 +851,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"question": "What is CrewAI?"}) == { "agent": "Analyst", @@ -874,7 +889,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"questions": ["one", "two"]}) == [ "Analyst:one", @@ -905,7 +920,7 @@ def test_agent_action_round_trips_with_inline_definition(): } ) - round_trip = FlowDefinition.from_yaml(definition.to_yaml()) + round_trip = FlowDefinition.from_declaration(contents=definition.to_yaml()) action = round_trip.to_dict()["methods"]["answer"]["do"] assert action["call"] == "agent" @@ -968,7 +983,7 @@ methods: """ with pytest.raises(ValidationError, match="invalid CEL expression"): - FlowDefinition.from_yaml(yaml_str) + FlowDefinition.from_declaration(contents=yaml_str) def test_crew_action_runs_inline_yaml_definition(monkeypatch: pytest.MonkeyPatch): @@ -1010,7 +1025,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"topic": "AI"}) == { "crew": "inline_research", @@ -1086,7 +1101,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"topic": "AI"}) == { "crew": "referenced_research", @@ -1160,9 +1175,7 @@ methods: other_cwd.mkdir() monkeypatch.chdir(other_cwd) - flow = Flow.from_definition( - FlowDefinition.from_yaml(yaml_str, source_path=flow_path) - ) + flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path)) assert flow.kickoff(inputs={"topic": "AI"}) == { "crew": "relative_research", @@ -1185,10 +1198,9 @@ methods: from_declaration: ../outside/crew.jsonc start: true """ + flow_path.write_text(yaml_str, encoding="utf-8") - flow = Flow.from_definition( - FlowDefinition.from_yaml(yaml_str, source_path=flow_path) - ) + flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path)) with pytest.raises( ValueError, @@ -1411,7 +1423,7 @@ methods: """ with pytest.raises(ValidationError, match="invalid CEL expression"): - FlowDefinition.from_yaml(yaml_str) + FlowDefinition.from_declaration(contents=yaml_str) def test_code_action_renders_keyword_inputs(): @@ -1429,7 +1441,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"name": "hello"}) == "hello!" @@ -1448,7 +1460,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"value": "ok"}) == "callable:ok" @@ -1472,7 +1484,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [ "normalized:a", @@ -1499,7 +1511,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) caller_thread_id = threading.get_ident() assert flow.kickoff(inputs={"rows": ["a"]}) == ["process_rows:a"] @@ -1526,7 +1538,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["async:a", "async:b"] @@ -1548,7 +1560,7 @@ methods: FlowScriptExecutionDisabledError, match="CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION=1", ) as exc_info: - Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert "methods with unresolvable actions" not in str(exc_info.value) @@ -1572,7 +1584,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"raw_score": 3.2}) == "rounded:4" assert flow.state["rounded"] == 4 @@ -1601,7 +1613,7 @@ methods: listen: seed """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff() == "alpha:alpha" assert flow.state["input_matches_output"] is True @@ -1639,7 +1651,7 @@ methods: listen: seed """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"rows": [" a ", " b "]}) == ["global:a", "global:b"] @@ -1671,7 +1683,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [ {"row": "a", "normalized": "saved:a"}, @@ -1700,7 +1712,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["a", "b"] assert flow._method_outputs == [ @@ -1738,7 +1750,7 @@ methods: listen: seed """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [ "local:a", @@ -1777,7 +1789,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff( inputs={ @@ -1811,7 +1823,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff(inputs={"rows": [{"kind": "keep", "value": "a"}]}) == ["a"] @@ -1838,7 +1850,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert flow.kickoff( inputs={ @@ -1868,7 +1880,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) with pytest.raises(ValueError, match="if expression must evaluate to a boolean"): flow.kickoff(inputs={"rows": [{"value": "truthy"}]}) @@ -1898,7 +1910,7 @@ methods: listen: process_rows """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) events = [] with crewai_event_bus.scoped_handlers(): @@ -2069,7 +2081,7 @@ methods: start: true """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) with pytest.raises(RuntimeError, match="bad row"): flow.kickoff(inputs={"rows": ["ok", "bad"]}) @@ -2190,7 +2202,7 @@ methods: listen: right """ - definition = FlowDefinition.from_yaml(yaml_str) + definition = FlowDefinition.from_declaration(contents=yaml_str) assert Flow.from_definition(definition).kickoff( inputs={"direction": "left"} @@ -2213,7 +2225,7 @@ methods: """ with pytest.raises(ValidationError, match="invalid CEL expression"): - FlowDefinition.from_yaml(yaml_str) + FlowDefinition.from_declaration(contents=yaml_str) def test_expression_action_rejects_unknown_cel_root(): @@ -2229,7 +2241,7 @@ methods: """ with pytest.raises(ValidationError, match="unknown CEL root"): - FlowDefinition.from_yaml(yaml_str) + FlowDefinition.from_declaration(contents=yaml_str) def test_tool_action_requires_module_qualname_ref(): @@ -2263,14 +2275,16 @@ def test_pydantic_state_from_ref_parity(): def test_pydantic_state_default_overlay(): - flow = Flow.from_definition(FlowDefinition.from_yaml(PYDANTIC_STATE_OVERLAY_YAML)) + flow = Flow.from_definition( + FlowDefinition.from_declaration(contents=PYDANTIC_STATE_OVERLAY_YAML) + ) result = flow.kickoff() assert result == "count=6" assert flow.state.count == 6 def test_json_schema_state(): - flow = Flow.from_definition(FlowDefinition.from_yaml(JSON_SCHEMA_STATE_YAML)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=JSON_SCHEMA_STATE_YAML)) result = flow.kickoff() assert result == "count=1" assert flow.state.count == 1 @@ -2279,14 +2293,14 @@ def test_json_schema_state(): def test_json_schema_state_validates_inputs(): - flow = Flow.from_definition(FlowDefinition.from_yaml(JSON_SCHEMA_STATE_YAML)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=JSON_SCHEMA_STATE_YAML)) with pytest.raises(ValueError, match="Invalid inputs"): flow.kickoff(inputs={"count": "not-a-number"}) def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable(): flow = Flow.from_definition( - FlowDefinition.from_yaml(PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML) + FlowDefinition.from_declaration(contents=PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML) ) result = flow.kickoff() assert result == "count=1" @@ -2295,7 +2309,9 @@ 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(FlowDefinition.from_yaml(UNRESOLVABLE_STATE_YAML)) + flow = Flow.from_definition( + FlowDefinition.from_declaration(contents=UNRESOLVABLE_STATE_YAML) + ) assert "falling back to dict state" in caplog.text result = flow.kickoff() @@ -2305,7 +2321,7 @@ 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_yaml(DICT_STATE_YAML) + definition = FlowDefinition.from_declaration(contents=DICT_STATE_YAML) flow = Flow.from_definition(definition) assert flow.state["count"] == 5 @@ -2322,7 +2338,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_yaml(UNKNOWN_STATE_YAML)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=UNKNOWN_STATE_YAML)) assert "falling back to dict state" in caplog.text result = flow.kickoff() @@ -2395,7 +2411,7 @@ def _run_capturing_flow_lifecycle(yaml_str, event_types): def capture(source, event): events.append(event) - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) result = flow.kickoff() return flow, result, events @@ -2409,7 +2425,7 @@ _LIFECYCLE_EVENTS = [ ] -def test_config_suppress_flow_events_from_yaml(): +def test_config_suppress_flow_events_from_declaration(): twin_events = [] with crewai_event_bus.scoped_handlers(): for event_type in _LIFECYCLE_EVENTS: @@ -2432,14 +2448,14 @@ def test_config_suppress_flow_events_from_yaml(): ) -def test_config_max_method_calls_from_yaml(): - flow = Flow.from_definition(FlowDefinition.from_yaml(CAPPED_LOOP_YAML)) +def test_config_max_method_calls_from_declaration(): + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=CAPPED_LOOP_YAML)) with pytest.raises(RecursionError, match="has been called 2 times"): flow.kickoff() -def test_config_stream_from_yaml(): - flow = Flow.from_definition(FlowDefinition.from_yaml(STREAMING_CHAIN_YAML)) +def test_config_stream_from_declaration(): + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=STREAMING_CHAIN_YAML)) streaming = flow.kickoff() assert isinstance(streaming, FlowStreamingOutput) for _ in streaming: @@ -2448,7 +2464,7 @@ def test_config_stream_from_yaml(): assert flow.stream is True -def test_config_defer_trace_finalization_from_yaml(): +def test_config_defer_trace_finalization_from_declaration(): _, _, baseline_events = _run_capturing_flow_lifecycle( CHAIN_YAML, [FlowFinishedEvent] ) @@ -2462,7 +2478,7 @@ def test_config_defer_trace_finalization_from_yaml(): assert deferred_events == [] -def test_config_checkpoint_from_yaml(tmp_path): +def test_config_checkpoint_from_declaration(tmp_path): yaml_str = ( CHAIN_YAML + f""" @@ -2471,19 +2487,23 @@ config: location: {tmp_path} """ ) - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) assert isinstance(flow.checkpoint, CheckpointConfig) assert flow.checkpoint.location == str(tmp_path) -def test_config_input_provider_from_yaml(): - flow = Flow.from_definition(FlowDefinition.from_yaml(INPUT_PROVIDER_CHAIN_YAML)) +def test_config_input_provider_from_declaration(): + flow = Flow.from_definition( + FlowDefinition.from_declaration(contents=INPUT_PROVIDER_CHAIN_YAML) + ) assert isinstance(flow.input_provider, StubInputProvider) def test_round_trip_config_equivalence(): class_flow = ConfiguredFlow() - definition = FlowDefinition.from_yaml(ConfiguredFlow.flow_definition().to_yaml()) + definition = FlowDefinition.from_declaration( + contents=ConfiguredFlow.flow_definition().to_yaml() + ) definition_flow = Flow.from_definition(definition) assert definition.config.suppress_flow_events is True @@ -2653,9 +2673,9 @@ class MethodPersistedFlow(Flow): return "two" -def test_flow_level_persist_from_yaml_saves_once_per_method(): +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_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) result = flow.kickoff() assert result == "two" @@ -2665,9 +2685,9 @@ def test_flow_level_persist_from_yaml_saves_once_per_method(): assert final_save["id"] == flow.state["id"] -def test_method_level_persist_from_yaml_saves_only_that_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_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) flow.kickoff() assert _saved_methods("yaml-method-level") == ["first"] @@ -2696,20 +2716,20 @@ methods: persist: enabled: false """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) flow.kickoff() assert _saved_methods("yaml-opt-out") == ["first"] -def test_persist_restore_by_id_from_yaml(): +def test_persist_restore_by_id_from_declaration(): yaml_str = _flow_level_persist_yaml("yaml-restore") - flow1 = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow1 = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) flow1.kickoff() assert flow1.state["count"] == 2 - flow2 = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow2 = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) flow2.kickoff(inputs={"id": flow1.state["id"]}) assert flow2.state["count"] == 4 @@ -2729,7 +2749,9 @@ def test_method_level_persist_decorator_saves_only_that_method(): def test_round_trip_persist_equivalence(): - definition = FlowDefinition.from_yaml(ClassPersistedFlow.flow_definition().to_yaml()) + definition = FlowDefinition.from_declaration( + contents=ClassPersistedFlow.flow_definition().to_yaml() + ) before = len(DefinitionStoreBackend.saves["class-decorator"]) flow = Flow.from_definition(definition) @@ -2738,7 +2760,7 @@ def test_round_trip_persist_equivalence(): assert _saved_methods("class-decorator")[before:] == ["first", "second"] -def test_method_persist_backend_overrides_flow_level_backend_from_yaml(): +def test_method_persist_backend_overrides_flow_level_backend_from_declaration(): yaml_str = f""" schema: crewai.flow/v1 name: PersistedFlow @@ -2762,7 +2784,7 @@ methods: persistence_type: DefinitionStoreBackend store: yaml-mixed-method """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) flow.kickoff() assert _saved_methods("yaml-mixed-flow") == ["first"] @@ -2910,8 +2932,8 @@ methods: """ -def test_human_feedback_from_yaml_default_outcome_routes(): - flow = Flow.from_definition(FlowDefinition.from_yaml(REVIEW_YAML)) +def test_human_feedback_from_declaration_default_outcome_routes(): + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=REVIEW_YAML)) with patch.object(flow, "_request_human_feedback", return_value="") as request: result = flow.kickoff() @@ -2922,8 +2944,8 @@ def test_human_feedback_from_yaml_default_outcome_routes(): assert flow.last_human_feedback.output == "draft-content" -def test_human_feedback_from_yaml_collapses_and_routes(): - flow = Flow.from_definition(FlowDefinition.from_yaml(REVIEW_YAML)) +def test_human_feedback_from_declaration_collapses_and_routes(): + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=REVIEW_YAML)) with ( patch.object(flow, "_request_human_feedback", return_value="ship it"), @@ -2940,7 +2962,7 @@ def test_round_trip_human_feedback_equivalence(): with patch.object(class_flow, "_request_human_feedback", return_value=""): class_result = class_flow.kickoff() - definition = FlowDefinition.from_yaml(ReviewFlow.flow_definition().to_yaml()) + definition = FlowDefinition.from_declaration(contents=ReviewFlow.flow_definition().to_yaml()) twin = Flow.from_definition(definition) with patch.object(twin, "_request_human_feedback", return_value=""): twin_result = twin.kickoff() @@ -2953,8 +2975,8 @@ def test_round_trip_human_feedback_equivalence(): ) -def test_human_feedback_pending_and_resume_from_yaml(): - definition = FlowDefinition.from_yaml(PENDING_REVIEW_YAML) +def test_human_feedback_pending_and_resume_from_declaration(): + definition = FlowDefinition.from_declaration(contents=PENDING_REVIEW_YAML) flow = Flow.from_definition(definition) pending = flow.kickoff() @@ -2975,7 +2997,7 @@ def test_human_feedback_pending_and_resume_from_yaml(): assert flow_id not in DefinitionStoreBackend.pending -def test_flow_config_provider_fallback_from_yaml(): +def test_flow_config_provider_fallback_from_declaration(): yaml_str = f""" schema: crewai.flow/v1 name: ConfigProviderFlow @@ -3001,7 +3023,7 @@ methods: return "from-config" provider = RecordingProvider() - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) previous = flow_config.hitl_provider flow_config.hitl_provider = provider @@ -3104,7 +3126,7 @@ methods: message: "Review:" provider: {__name__}:_NeedsArgsProvider """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) with pytest.raises( ValueError, match="cannot instantiate human_feedback.provider ref" @@ -3125,7 +3147,7 @@ methods: message: "Review:" provider: missing_module_xyz:Provider """ - flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str)) + flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str)) with pytest.raises( ValueError, match="unresolvable human_feedback.provider ref" @@ -3137,7 +3159,7 @@ def _checkpoint_chain_flow(tmp_path): from crewai.state.provider.json_provider import JsonProvider from crewai.state.runtime import RuntimeState - definition = FlowDefinition.from_yaml(CHAIN_YAML) + definition = FlowDefinition.from_declaration(contents=CHAIN_YAML) flow = Flow.from_definition(definition) result = flow.kickoff() assert result == "confirmed:True" @@ -3177,7 +3199,7 @@ state: methods: {} """ with pytest.raises(ValidationError, match="default"): - FlowDefinition.from_yaml(yaml_str) + FlowDefinition.from_declaration(contents=yaml_str) def test_definition_method_missing_from_class_fails_loudly(): From 658b8ee8b9b6f690809cc627c6fa0765c15f6c48 Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Tue, 23 Jun 2026 14:05:23 -0700 Subject: [PATCH 09/22] feat: bump versions to 1.14.8a3 (#6309) --- lib/cli/pyproject.toml | 2 +- lib/cli/src/crewai_cli/__init__.py | 2 +- lib/cli/src/crewai_cli/templates/crew/pyproject.toml | 2 +- .../crewai_cli/templates/declarative_flow/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/flow/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/tool/pyproject.toml | 2 +- lib/crewai-core/src/crewai_core/__init__.py | 2 +- lib/crewai-files/src/crewai_files/__init__.py | 2 +- lib/crewai-tools/pyproject.toml | 2 +- lib/crewai-tools/src/crewai_tools/__init__.py | 2 +- lib/crewai/pyproject.toml | 6 +++--- lib/crewai/src/crewai/__init__.py | 2 +- lib/devtools/src/crewai_devtools/__init__.py | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/cli/pyproject.toml b/lib/cli/pyproject.toml index f1daca897..e48f1fd3b 100644 --- a/lib/cli/pyproject.toml +++ b/lib/cli/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.8a2", + "crewai-core==1.14.8a3", "click>=8.1.7,<9", "pydantic>=2.11.9,<2.13", "pydantic-settings~=2.10.1", diff --git a/lib/cli/src/crewai_cli/__init__.py b/lib/cli/src/crewai_cli/__init__.py index 41eae5edd..7b09b91f5 100644 --- a/lib/cli/src/crewai_cli/__init__.py +++ b/lib/cli/src/crewai_cli/__init__.py @@ -1 +1 @@ -__version__ = "1.14.8a2" +__version__ = "1.14.8a3" diff --git a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml index bcf00bf71..44a1c84e2 100644 --- a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/crew/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.14.8a2" + "crewai[tools]==1.14.8a3" ] [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 e4a9a8693..c462fc779 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.14.8a2" + "crewai[tools]==1.14.8a3" ] [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 067dc6165..e0ff1433a 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.14.8a2" + "crewai[tools]==1.14.8a3" ] [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 e9b08f453..1b09cdf09 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.14.8a2" + "crewai[tools]==1.14.8a3" ] [tool.crewai] diff --git a/lib/crewai-core/src/crewai_core/__init__.py b/lib/crewai-core/src/crewai_core/__init__.py index 41eae5edd..7b09b91f5 100644 --- a/lib/crewai-core/src/crewai_core/__init__.py +++ b/lib/crewai-core/src/crewai_core/__init__.py @@ -1 +1 @@ -__version__ = "1.14.8a2" +__version__ = "1.14.8a3" diff --git a/lib/crewai-files/src/crewai_files/__init__.py b/lib/crewai-files/src/crewai_files/__init__.py index d1053421d..4e4030d60 100644 --- a/lib/crewai-files/src/crewai_files/__init__.py +++ b/lib/crewai-files/src/crewai_files/__init__.py @@ -152,4 +152,4 @@ __all__ = [ "wrap_file_source", ] -__version__ = "1.14.8a2" +__version__ = "1.14.8a3" diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index 531edc942..f0487171a 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14" dependencies = [ "pytube~=15.0.0", "requests>=2.33.0,<3", - "crewai==1.14.8a2", + "crewai==1.14.8a3", "tiktoken>=0.8.0,<0.13", "beautifulsoup4~=4.13.4", "python-docx~=1.2.0", diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 2f8853585..438943ebc 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -330,4 +330,4 @@ __all__ = [ "ZapierActionTools", ] -__version__ = "1.14.8a2" +__version__ = "1.14.8a3" diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index a90d6d7a1..fcdc1bd01 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -8,8 +8,8 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.8a2", - "crewai-cli==1.14.8a2", + "crewai-core==1.14.8a3", + "crewai-cli==1.14.8a3", # Core Dependencies "pydantic>=2.11.9,<2.13", "openai>=2.30.0,<3", @@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI" [project.optional-dependencies] tools = [ - "crewai-tools==1.14.8a2", + "crewai-tools==1.14.8a3", ] embeddings = [ "tiktoken>=0.8.0,<0.13" diff --git a/lib/crewai/src/crewai/__init__.py b/lib/crewai/src/crewai/__init__.py index 1d4e6af3a..40157afa9 100644 --- a/lib/crewai/src/crewai/__init__.py +++ b/lib/crewai/src/crewai/__init__.py @@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None: _suppress_pydantic_deprecation_warnings() -__version__ = "1.14.8a2" +__version__ = "1.14.8a3" _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "Memory": ("crewai.memory.unified_memory", "Memory"), diff --git a/lib/devtools/src/crewai_devtools/__init__.py b/lib/devtools/src/crewai_devtools/__init__.py index f5ec9a4c9..71b581c0f 100644 --- a/lib/devtools/src/crewai_devtools/__init__.py +++ b/lib/devtools/src/crewai_devtools/__init__.py @@ -1,3 +1,3 @@ """CrewAI development tools.""" -__version__ = "1.14.8a2" +__version__ = "1.14.8a3" From f2a074e35b2ce49ed1300400b61fd60074d90414 Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Tue, 23 Jun 2026 14:11:31 -0700 Subject: [PATCH 10/22] docs: snapshot and changelog for v1.14.8a3 (#6310) --- docs/edge/ar/changelog.mdx | 28 ++++++++++++++++++++++++++++ docs/edge/en/changelog.mdx | 28 ++++++++++++++++++++++++++++ docs/edge/ko/changelog.mdx | 28 ++++++++++++++++++++++++++++ docs/edge/pt-BR/changelog.mdx | 28 ++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+) diff --git a/docs/edge/ar/changelog.mdx b/docs/edge/ar/changelog.mdx index e70318eff..11d035da7 100644 --- a/docs/edge/ar/changelog.mdx +++ b/docs/edge/ar/changelog.mdx @@ -4,6 +4,34 @@ description: "تحديثات المنتج والتحسينات وإصلاحات icon: "clock" mode: "wide" --- + + ## v1.14.8a3 + + [عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a3) + + ## ما الذي تغير + + ### الميزات + - إضافة تحميل تدفق موحد إعلاني + - تحسين تجربة بدء تشغيل crewai run + - دمج `crewai run` و `crewai flow kickoff` + - الحفاظ على تقدم طريقة التدفق مرئيًا للفرق المتداخلة + - إضافة دعم واجهة سطر الأوامر الإعلانية للتدفق + - السماح باستخدام `@router()` كطريقة بدء لتدفق + - إضافة مخططات مخرجات مكتوبة لأدوات CrewAI + + ### إصلاحات الأخطاء + - تثبيت opentelemetry على ~=1.42.0 + + ### الوثائق + - إضافة صفحة استوديو "بطاقة واحدة لكل خطوة" + + ## المساهمون + + @jessemiller, @joaomdmoura, @lucasgomide, @vinibrsl + + + ## v1.14.8a2 diff --git a/docs/edge/en/changelog.mdx b/docs/edge/en/changelog.mdx index b0a7492c1..1b990991d 100644 --- a/docs/edge/en/changelog.mdx +++ b/docs/edge/en/changelog.mdx @@ -4,6 +4,34 @@ description: "Product updates, improvements, and bug fixes for CrewAI" icon: "clock" mode: "wide" --- + + ## v1.14.8a3 + + [View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a3) + + ## What's Changed + + ### Features + - Add unified declarative flow loading + - Improve crewai run startup UX + - Consolidate `crewai run` and `crewai flow kickoff` + - Keep flow method progress visible for nested crews + - Add declarative Flow CLI support + - Allow `@router()` as start method of a flow + - Add typed output schemas for CrewAI tools + + ### Bug Fixes + - Pin opentelemetry to ~=1.42.0 + + ### Documentation + - Add "One Card per Step" Studio page + + ## Contributors + + @jessemiller, @joaomdmoura, @lucasgomide, @vinibrsl + + + ## v1.14.8a2 diff --git a/docs/edge/ko/changelog.mdx b/docs/edge/ko/changelog.mdx index 1fa751baf..fe25de51c 100644 --- a/docs/edge/ko/changelog.mdx +++ b/docs/edge/ko/changelog.mdx @@ -4,6 +4,34 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정" icon: "clock" mode: "wide" --- + + ## v1.14.8a3 + + [GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a3) + + ## 변경 사항 + + ### 기능 + - 통합 선언적 흐름 로딩 추가 + - crewai run 시작 UX 개선 + - `crewai run`과 `crewai flow kickoff` 통합 + - 중첩된 크루의 흐름 메서드 진행 상황 표시 유지 + - 선언적 Flow CLI 지원 추가 + - 흐름의 시작 메서드로 `@router()` 허용 + - CrewAI 도구에 대한 타입이 지정된 출력 스키마 추가 + + ### 버그 수정 + - opentelemetry를 ~=1.42.0으로 고정 + + ### 문서 + - "단계당 한 카드" 스튜디오 페이지 추가 + + ## 기여자 + + @jessemiller, @joaomdmoura, @lucasgomide, @vinibrsl + + + ## v1.14.8a2 diff --git a/docs/edge/pt-BR/changelog.mdx b/docs/edge/pt-BR/changelog.mdx index 2e10fc667..5a2f653ce 100644 --- a/docs/edge/pt-BR/changelog.mdx +++ b/docs/edge/pt-BR/changelog.mdx @@ -4,6 +4,34 @@ description: "Atualizações de produto, melhorias e correções do CrewAI" icon: "clock" mode: "wide" --- + + ## v1.14.8a3 + + [Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a3) + + ## O que Mudou + + ### Recursos + - Adicionar carregamento de fluxo declarativo unificado + - Melhorar a experiência de inicialização do crewai run + - Consolidar `crewai run` e `crewai flow kickoff` + - Manter o progresso do método de fluxo visível para equipes aninhadas + - Adicionar suporte a Flow CLI declarativo + - Permitir `@router()` como método de início de um fluxo + - Adicionar esquemas de saída tipados para ferramentas CrewAI + + ### Correções de Bugs + - Fixar opentelemetry em ~=1.42.0 + + ### Documentação + - Adicionar página "Uma Cartão por Etapa" no Studio + + ## Contribuidores + + @jessemiller, @joaomdmoura, @lucasgomide, @vinibrsl + + + ## v1.14.8a2 From 1862ff8f6cfced99ed170a755bba3374098970a5 Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:04:09 -0700 Subject: [PATCH 11/22] Support conversational flows in the CLI TUI (#6293) * Add conversational flow TUI support * properly support tui --- lib/cli/src/crewai_cli/crew_run_tui.py | 240 ++++++++++++++++++++++++- lib/cli/src/crewai_cli/kickoff_flow.py | 105 +++++++++++ lib/cli/src/crewai_cli/run_crew.py | 10 ++ lib/cli/tests/test_crew_run_tui.py | 46 +++++ lib/cli/tests/test_kickoff_flow.py | 63 +++++++ lib/cli/tests/test_run_crew.py | 39 ++++ 6 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 lib/cli/src/crewai_cli/kickoff_flow.py create mode 100644 lib/cli/tests/test_kickoff_flow.py diff --git a/lib/cli/src/crewai_cli/crew_run_tui.py b/lib/cli/src/crewai_cli/crew_run_tui.py index 9b3930350..81aae6c47 100644 --- a/lib/cli/src/crewai_cli/crew_run_tui.py +++ b/lib/cli/src/crewai_cli/crew_run_tui.py @@ -17,7 +17,7 @@ from textual.binding import Binding, BindingType from textual.containers import Horizontal, Vertical, VerticalScroll from textual.css.query import NoMatches from textual.screen import ModalScreen -from textual.widgets import Button, Footer, Header, Static +from textual.widgets import Button, Footer, Header, Input, Static _SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" @@ -382,6 +382,18 @@ Screen { height: auto; } +#conversation-input { + display: none; + height: 3; + border-top: hkey #333333; + background: #1c1c1c; + color: #e0e0e0; +} + +#conversation-input:focus { + border-top: hkey #1F7982; +} + Header { background: #1c1c1c; color: #FF5A50; @@ -483,6 +495,7 @@ FooterKey .footer-key--key { total_tasks: int = 0, agent_names: list[str] | None = None, task_names: list[str] | None = None, + conversational: bool = False, ): super().__init__() self.title = f"CrewAI — {crew_name}" @@ -544,6 +557,13 @@ FooterKey .footer-key--key { self._event_handlers: list[tuple[type, Any]] = [] self._crew: Any = None + self._flow: Any = None + self._is_conversational = conversational + self._conversation_messages: list[tuple[str, str]] = [] + self._conversation_turns = 0 + self._conversation_turn_in_progress = False + self._conversation_previous_defer_trace_finalization: bool | None = None + self._conversation_exit_commands = {"exit", "quit"} self._default_inputs: dict[str, Any] | None = None self._crew_result: Any = None self._crew_json_path: Any = None @@ -566,6 +586,10 @@ FooterKey .footer-key--key { yield Static(id="task-header") with VerticalScroll(id="scroll-area"): yield Static(id="main-content") + yield Input( + placeholder="Message the flow...", + id="conversation-input", + ) with VerticalScroll(id="log-panel"): yield Static(id="log-content") yield Footer() @@ -574,7 +598,9 @@ FooterKey .footer-key--key { self._start_time = time.time() self._subscribe() self._tick_timer = self.set_interval(1 / 8, self._tick) - if self._crew: + if self._is_conversational and self._flow: + self._start_conversational_session() + elif self._crew: self._run_crew_worker() elif self._crew_json_path: self._load_and_run_worker() @@ -725,6 +751,140 @@ FooterKey .footer-key--key { self._tick_timer = self.set_interval(1 / 2, self._tick) self._unsubscribe_if_no_running_memory_save(wait_for_queued=True) + # ── Conversational flow execution ─────────────────────── + + def _start_conversational_session(self) -> None: + from crewai.events.listeners.tracing.utils import ( + set_suppress_tracing_messages, + set_tui_mode, + ) + + set_tui_mode(True) + set_suppress_tracing_messages(True) + with self._lock: + self._status = "chatting" + self._current_step = None + self._elapsed_frozen = None + self._conversation_previous_defer_trace_finalization = getattr( + self._flow, "defer_trace_finalization", False + ) + self._flow.defer_trace_finalization = True + + try: + input_widget = self.query_one("#conversation-input", Input) + input_widget.display = True + input_widget.focus() + except Exception: # noqa: S110 + pass + + def _finalize_conversational_session(self) -> None: + if not (self._is_conversational and self._flow): + return + try: + self._flow.finalize_session_traces() + except Exception: # noqa: S110 + pass + previous = self._conversation_previous_defer_trace_finalization + if previous is not None: + try: + self._flow.defer_trace_finalization = previous + except Exception: # noqa: S110 + pass + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id != "conversation-input": + return + if not self._is_conversational: + return + + message = event.value.strip() + event.input.value = "" + if not message: + return + if message.lower() in self._conversation_exit_commands: + self._finalize_conversational_session() + self._unsubscribe() + self.exit(self._crew_result) + return + if self._conversation_turn_in_progress: + return + + with self._lock: + self._conversation_messages.append(("user", message)) + self._conversation_turn_in_progress = True + self._conversation_turns += 1 + self._status = "working" + self._current_step = ("yellow", "Thinking…", "") + self._is_streaming = False + self._streaming_text = "" + self._task_full_output = "" + self._current_llm_text = "" + + event.input.disabled = True + self._run_conversation_turn_worker(message) + + @work(thread=True, exclusive=True, group="conversation") + def _run_conversation_turn_worker(self, message: str) -> None: + from crewai.events.listeners.tracing.utils import ( + set_suppress_tracing_messages, + set_tui_mode, + ) + + set_tui_mode(True) + set_suppress_tracing_messages(True) + try: + result = self._flow.handle_turn(message) + if hasattr(result, "get_full_text") and hasattr(result, "result"): + for _chunk in result: + pass + result = result.result + self.call_from_thread(self._on_conversation_turn_done, result) + except Exception as e: + self.call_from_thread(self._on_conversation_turn_failed, str(e)) + + def _on_conversation_turn_done(self, result: Any) -> None: + with self._lock: + output = self._stringify_output(result) + self._conversation_messages.append(("assistant", output)) + self._crew_result = result + self._conversation_turn_in_progress = False + self._status = "chatting" + self._is_streaming = False + self._streaming_text = "" + self._current_step = None + self._enable_conversation_input() + self._tick() + self._scroll_to_result() + + def _on_conversation_turn_failed(self, error: str) -> None: + with self._lock: + self._status = "failed" + self._error = error + self._conversation_turn_in_progress = False + self._is_streaming = False + self._current_step = None + self._enable_conversation_input() + self._tick() + + def _enable_conversation_input(self) -> None: + try: + input_widget = self.query_one("#conversation-input", Input) + input_widget.disabled = False + input_widget.focus() + except Exception: # noqa: S110 + pass + + def _stringify_output(self, result: Any) -> str: + raw_result = getattr(result, "raw", result) + if raw_result is None: + return "" + if isinstance(raw_result, str): + return raw_result + try: + return _json.dumps(raw_result, default=str, ensure_ascii=False) + except TypeError: + return str(raw_result) + # ── Actions ───────────────────────────────────────────── def action_toggle_sidebar(self) -> None: @@ -783,6 +943,7 @@ FooterKey .footer-key--key { self._refresh_log_panel() async def action_quit(self) -> None: + self._finalize_conversational_session() self._unsubscribe() self.exit(self._crew_result) @@ -958,6 +1119,30 @@ FooterKey .footer-key--key { t = Text() sidebar_width = 30 + if self._is_conversational: + t.append(" CONVERSATION\n", style=f"bold {_C_PRIMARY}") + t.append("\n") + if self._conversation_turn_in_progress: + t.append(f" {self._spinner()} ", style=_C_PRIMARY) + t.append("Working\n", style=f"bold {_C_TEXT}") + elif self._status == "failed": + t.append(" ✘ Failed\n", style=_C_RED) + else: + t.append(" ● Ready\n", style=_C_GREEN) + t.append(f" Turns {self._conversation_turns}\n", style=_C_DIM) + t.append("\n") + t.append(" TOKENS\n", style=f"bold {_C_PRIMARY}") + t.append("\n") + out = self._output_tokens + self._live_out_tokens + t.append(f" ↑ {self._input_tokens:,}\n", style=_C_DIM) + t.append(f" ↓ {out:,}\n", style=_C_DIM) + t.append("\n") + t.append(" COMMANDS\n", style=f"bold {_C_PRIMARY}") + t.append("\n") + t.append(" quit / exit\n", style=_C_DIM) + widget.update(t) + return + t.append(" TASKS\n", style=f"bold {_C_PRIMARY}") t.append("\n") @@ -1011,6 +1196,22 @@ FooterKey .footer-key--key { widget = self.query_one("#task-header", Static) t = Text() + if self._is_conversational: + if self._status == "failed": + t.append("✘ ", style=f"bold {_C_RED}") + t.append("Failed", style=f"bold {_C_RED}") + if self._error: + t.append(f"\n{self._error[:120]}", style=_C_RED) + elif self._conversation_turn_in_progress: + t.append(f"{self._spinner()} ", style=_C_PRIMARY) + t.append("Flow is responding", style=f"bold {_C_PRIMARY}") + else: + t.append("● ", style=f"bold {_C_GREEN}") + t.append("Conversational flow ready", style=f"bold {_C_GREEN}") + t.append(" Type a message below", style=_C_DIM) + widget.update(t) + return + if self._status == "completed": elapsed = self._elapsed_frozen or (time.time() - self._start_time) t.append("✔ ", style=f"bold {_C_GREEN}") @@ -1062,6 +1263,41 @@ FooterKey .footer-key--key { t = Text() should_scroll = False + if self._is_conversational: + if not self._conversation_messages and not self._is_streaming: + t.append(" Start the conversation below.\n", style=_C_MUTED) + for role, content in self._conversation_messages: + if role == "user": + t.append("\n You\n", style=f"bold {_C_TEAL}") + else: + t.append("\n Assistant\n", style=f"bold {_C_PRIMARY}") + rendered = _format_json_in_text(_unescape_text(content)) + for line in rendered.split("\n"): + style = _C_TEXT if role == "assistant" else _C_DIM + t.append(f" {line}\n", style=style) + + if self._is_streaming and self._streaming_text: + text = _unescape_text(self._filtered_streaming_text()) + if text.strip(): + t.append("\n Assistant\n", style=f"bold {_C_PRIMARY}") + for line in text.rstrip().split("\n")[-40:]: + t.append(f" {line}\n", style=_C_TEXT) + should_scroll = True + + if self._status == "failed" and self._error: + t.append("\n Error\n", style=f"bold {_C_RED}") + t.append(f" {self._error}\n", style=_C_RED) + + widget.update(t) + if should_scroll: + try: + self.query_one("#scroll-area", VerticalScroll).scroll_end( + animate=False + ) + except Exception: # noqa: S110 + pass + return + # Plan section if self._plan and self._plan.get("steps"): plan_title = self._plan.get("plan", "Plan") diff --git a/lib/cli/src/crewai_cli/kickoff_flow.py b/lib/cli/src/crewai_cli/kickoff_flow.py new file mode 100644 index 000000000..bde1ddee7 --- /dev/null +++ b/lib/cli/src/crewai_cli/kickoff_flow.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import importlib +import inspect +from pathlib import Path +import subprocess +import sys +from typing import Any + +import click + + +def _project_script_target(script_name: str) -> str | None: + try: + from crewai_cli.utils import read_toml + + pyproject = read_toml() + except Exception: + return None + + target = pyproject.get("project", {}).get("scripts", {}).get(script_name) + return target if isinstance(target, str) else None + + +def _prepare_project_import_path() -> None: + cwd = Path.cwd() + for path in (cwd / "src", cwd): + path_str = str(path) + if path.exists() and path_str not in sys.path: + sys.path.insert(0, path_str) + + +def _load_conversational_flow_from_kickoff_script() -> Any | None: + target = _project_script_target("kickoff") + if not target or ":" not in target: + return None + + module_name, _callable_name = target.split(":", 1) + _prepare_project_import_path() + + try: + module = importlib.import_module(module_name) + from crewai.flow.flow import Flow + except Exception: + return None + + for value in vars(module).values(): + if ( + inspect.isclass(value) + and value is not Flow + and issubclass(value, Flow) + and getattr(value, "conversational", False) + ): + return value() + + for value in vars(module).values(): + if ( + isinstance(value, Flow) + and getattr(value, "conversational", False) + and callable(getattr(value, "handle_turn", None)) + ): + return value + + return None + + +def _run_conversational_flow_tui(flow: Any) -> Any: + from crewai_cli.crew_run_tui import CrewRunApp + + app = CrewRunApp( + crew_name=getattr(flow, "name", None) or type(flow).__name__, + conversational=True, + ) + app._flow = flow + app.run() + + if app._status == "failed": + raise SystemExit(1) + + return app._crew_result + + +def kickoff_flow() -> None: + """ + Kickoff the flow by running a command in the UV environment. + """ + flow = _load_conversational_flow_from_kickoff_script() + if flow is not None: + _run_conversational_flow_tui(flow) + return + + command = ["uv", "run", "kickoff"] + + try: + result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603 + + if result.stderr: + click.echo(result.stderr, err=True) + + except subprocess.CalledProcessError as e: + click.echo(f"An error occurred while running the flow: {e}", err=True) + click.echo(e.output, err=True) + + except Exception as e: + click.echo(f"An unexpected error occurred: {e}", err=True) diff --git a/lib/cli/src/crewai_cli/run_crew.py b/lib/cli/src/crewai_cli/run_crew.py index f9948a297..de6c8c412 100644 --- a/lib/cli/src/crewai_cli/run_crew.py +++ b/lib/cli/src/crewai_cli/run_crew.py @@ -604,6 +604,16 @@ def _run_flow_project( run_declarative_flow_in_project_env(definition=definition) return + from crewai_cli.kickoff_flow import ( + _load_conversational_flow_from_kickoff_script, + _run_conversational_flow_tui, + ) + + flow = _load_conversational_flow_from_kickoff_script() + if flow is not None: + _run_conversational_flow_tui(flow) + return + _execute_uv_script("kickoff", entity_type="flow") diff --git a/lib/cli/tests/test_crew_run_tui.py b/lib/cli/tests/test_crew_run_tui.py index 969bc5ae2..5c49dabf1 100644 --- a/lib/cli/tests/test_crew_run_tui.py +++ b/lib/cli/tests/test_crew_run_tui.py @@ -126,6 +126,52 @@ 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_conversation_turn_done_records_assistant_message() -> None: + class RawResult: + raw = "hello from the flow" + + app = CrewRunApp(conversational=True) + app._conversation_turn_in_progress = True + app._enable_conversation_input = lambda: None # type: ignore[method-assign] + app._tick = lambda: None # type: ignore[method-assign] + app._scroll_to_result = lambda: None # type: ignore[method-assign] + + app._on_conversation_turn_done(RawResult()) + + assert app._conversation_messages == [("assistant", "hello from the flow")] + assert app._conversation_turn_in_progress is False + assert app._status == "chatting" + assert isinstance(app._crew_result, RawResult) + + +@pytest.mark.asyncio +async def test_conversation_input_submits_turn() -> None: + class FakeFlow: + defer_trace_finalization = False + + def handle_turn(self, message: str) -> str: + return f"reply: {message}" + + def finalize_session_traces(self) -> None: + pass + + app = CrewRunApp(crew_name="Demo", conversational=True) + app._flow = FakeFlow() + + async with app.run_test() as pilot: + await pilot.click("#conversation-input") + await pilot.press("h", "i", "enter") + for _ in range(50): + await pilot.pause(0.05) + if app._conversation_messages[-1:] == [("assistant", "reply: hi")]: + break + + assert app._conversation_messages == [ + ("user", "hi"), + ("assistant", "reply: hi"), + ] + + def test_plan_step_status_updates_only_the_explicit_step() -> None: app = _app_with_plan() diff --git a/lib/cli/tests/test_kickoff_flow.py b/lib/cli/tests/test_kickoff_flow.py new file mode 100644 index 000000000..52eb299ee --- /dev/null +++ b/lib/cli/tests/test_kickoff_flow.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import sys + +from crewai_cli import kickoff_flow + + +def test_loads_conversational_flow_from_kickoff_script(tmp_path, monkeypatch) -> None: + package_dir = tmp_path / "src" / "demo_chat" + package_dir.mkdir(parents=True) + (package_dir / "__init__.py").write_text("") + (package_dir / "main.py").write_text( + "\n".join( + [ + "from crewai.flow import Flow", + "", + "class DemoChatFlow(Flow):", + " conversational = True", + ] + ) + ) + (tmp_path / "pyproject.toml").write_text( + "\n".join( + [ + "[project]", + 'name = "demo-chat"', + "[project.scripts]", + 'kickoff = "demo_chat.main:kickoff"', + ] + ) + ) + monkeypatch.chdir(tmp_path) + sys.modules.pop("demo_chat.main", None) + sys.modules.pop("demo_chat", None) + + flow = kickoff_flow._load_conversational_flow_from_kickoff_script() + + assert flow is not None + assert type(flow).__name__ == "DemoChatFlow" + assert flow.conversational is True + + +def test_kickoff_flow_falls_back_to_uv_when_no_conversational_flow( + monkeypatch, +) -> None: + calls: list[list[str]] = [] + + def fake_run(command, capture_output, text, check): + calls.append(command) + + class Result: + stderr = "" + + return Result() + + monkeypatch.setattr( + kickoff_flow, "_load_conversational_flow_from_kickoff_script", lambda: None + ) + monkeypatch.setattr(kickoff_flow.subprocess, "run", fake_run) + + kickoff_flow.kickoff_flow() + + assert calls == [["uv", "run", "kickoff"]] diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index c51fc16c5..fd9daf167 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -645,6 +645,10 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys): "_execute_uv_script", lambda script_name, **kwargs: calls.append((script_name, kwargs)), ) + monkeypatch.setattr( + "crewai_cli.kickoff_flow._load_conversational_flow_from_kickoff_script", + lambda: None, + ) run_crew_module.run_crew() @@ -652,6 +656,41 @@ def test_run_crew_runs_python_flow_project(monkeypatch, capsys): assert calls == [("kickoff", {"entity_type": "flow"})] +def test_run_crew_runs_conversational_flow_tui(monkeypatch, capsys): + class Flow: + pass + + flow = Flow() + calls = [] + + monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) + monkeypatch.setattr( + run_crew_module, + "read_toml", + lambda: {"tool": {"crewai": {"type": "flow"}}}, + ) + monkeypatch.setattr( + "crewai_cli.kickoff_flow._load_conversational_flow_from_kickoff_script", + lambda: flow, + ) + monkeypatch.setattr( + "crewai_cli.kickoff_flow._run_conversational_flow_tui", + lambda loaded_flow: calls.append(loaded_flow), + ) + monkeypatch.setattr( + run_crew_module, + "_execute_uv_script", + lambda *_args, **_kwargs: pytest.fail( + "conversational flows must use the TUI" + ), + ) + + run_crew_module.run_crew() + + assert capsys.readouterr().out == "" + assert calls == [flow] + + def test_run_crew_rejects_filename_for_flow_project(monkeypatch): monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False) monkeypatch.setattr( From a046e6a50be633f76a1b128b70ef54363aa01d7c Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Tue, 23 Jun 2026 19:28:35 -0700 Subject: [PATCH 12/22] Validate declarative flow definition paths (#6311) --- .../src/crewai_cli/run_declarative_flow.py | 76 ++++++++-- lib/cli/tests/test_flow_commands.py | 133 +++++++++++++++++- lib/cli/tests/test_run_crew.py | 9 +- 3 files changed, 207 insertions(+), 11 deletions(-) diff --git a/lib/cli/src/crewai_cli/run_declarative_flow.py b/lib/cli/src/crewai_cli/run_declarative_flow.py index b70492777..ea289d00b 100644 --- a/lib/cli/src/crewai_cli/run_declarative_flow.py +++ b/lib/cli/src/crewai_cli/run_declarative_flow.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from pathlib import Path +from pathlib import Path, PureWindowsPath import subprocess from typing import Any @@ -12,7 +12,7 @@ from crewai_cli.utils import build_env_with_all_tool_credentials def run_declarative_flow_in_project_env( - definition: str, inputs: str | None = None + definition: str | Path, inputs: str | None = None ) -> None: """Run a declarative flow inside the project's Python environment.""" if is_declarative_flow_project_env() or not _has_project_file(): @@ -25,7 +25,7 @@ def run_declarative_flow_in_project_env( _execute_declarative_flow_command(["uv", "run", "crewai", "run"]) -def plot_declarative_flow_in_project_env(definition: str) -> None: +def plot_declarative_flow_in_project_env(definition: str | Path) -> None: """Plot a declarative flow inside the project's Python environment.""" if is_declarative_flow_project_env() or not _has_project_file(): plot_declarative_flow(definition=definition) @@ -34,7 +34,7 @@ def plot_declarative_flow_in_project_env(definition: str) -> None: _execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"]) -def run_declarative_flow(definition: str, inputs: str | None = None) -> None: +def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> None: """Run a declarative flow from a definition path.""" parsed_inputs = _parse_inputs(inputs) @@ -50,7 +50,7 @@ def run_declarative_flow(definition: str, inputs: str | None = None) -> None: click.echo(_format_result(result)) -def plot_declarative_flow(definition: str) -> None: +def plot_declarative_flow(definition: str | Path) -> None: """Plot a declarative flow from a definition path.""" try: flow = load_declarative_flow(definition) @@ -62,7 +62,7 @@ def plot_declarative_flow(definition: str) -> None: raise SystemExit(1) from exc -def load_declarative_flow(definition: str) -> Any: +def load_declarative_flow(definition: str | Path) -> Any: """Load a declarative Flow instance from a definition path.""" try: from crewai.flow.flow import Flow @@ -102,7 +102,8 @@ def load_declarative_flow(definition: str) -> Any: def configured_project_declarative_flow( pyproject_data: dict[str, Any] | None = None, -) -> str | None: + project_root: Path | None = None, +) -> Path | None: """Return the configured declarative flow source for flow projects.""" if pyproject_data is None: try: @@ -118,7 +119,66 @@ def configured_project_declarative_flow( definition = crewai_config.get("definition") if not isinstance(definition, str): return None - return definition.strip() or 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}." + ) + + 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 def _execute_declarative_flow_command(command: list[str]) -> None: diff --git a/lib/cli/tests/test_flow_commands.py b/lib/cli/tests/test_flow_commands.py index 00e39b6db..5743275e7 100644 --- a/lib/cli/tests/test_flow_commands.py +++ b/lib/cli/tests/test_flow_commands.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path import subprocess +import click import pytest from click.testing import CliRunner @@ -107,6 +108,8 @@ def test_configured_project_declarative_flow( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.chdir(tmp_path) + definition_path = tmp_path / "flow.yaml" + definition_path.write_text(FLOW_YAML, encoding="utf-8") (tmp_path / "pyproject.toml").write_text( '[tool.crewai]\ntype = "flow"\ndefinition = " flow.yaml "\n', encoding="utf-8", @@ -114,4 +117,132 @@ def test_configured_project_declarative_flow( from crewai_cli.run_declarative_flow import configured_project_declarative_flow - assert configured_project_declarative_flow() == "flow.yaml" + assert configured_project_declarative_flow() == definition_path.resolve() + + +@pytest.mark.parametrize( + ("definition", "expected_error"), + [ + ("C:/tmp/flow.yaml", "must be relative to the project root"), + ("~/flow.yaml", "must be a project-local path"), + ("../flow.yaml", "must resolve inside the project root"), + ], +) +def test_configured_project_declarative_flow_rejects_unsafe_paths( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + definition: str, + expected_error: str, +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition}"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert expected_error in exc_info.value.message + + +def test_configured_project_declarative_flow_allows_normalized_project_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + definition_path = tmp_path / "flow.yaml" + definition_path.write_text(FLOW_YAML, encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "flow"\ndefinition = "src/../flow.yaml"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + assert configured_project_declarative_flow() == definition_path.resolve() + + +def test_configured_project_declarative_flow_rejects_absolute_path( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + definition = tmp_path / "flow.yaml" + (tmp_path / "pyproject.toml").write_text( + f'[tool.crewai]\ntype = "flow"\ndefinition = "{definition.as_posix()}"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert "must be relative to the project root" in exc_info.value.message + + +def test_configured_project_declarative_flow_rejects_symlink_escape( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + outside_definition = tmp_path.parent / "outside-flow.yaml" + outside_definition.write_text(FLOW_YAML, encoding="utf-8") + link = tmp_path / "flow.yaml" + try: + link.symlink_to(outside_definition) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"symlinks unavailable: {exc}") + + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert "must resolve inside the project root" in exc_info.value.message + + +def test_configured_project_declarative_flow_rejects_missing_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "flow"\ndefinition = "missing-flow.yaml"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert "must point to an existing file" in exc_info.value.message + + +def test_configured_project_declarative_flow_rejects_directory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "flow.yaml").mkdir() + (tmp_path / "pyproject.toml").write_text( + '[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n', + encoding="utf-8", + ) + + from crewai_cli.run_declarative_flow import configured_project_declarative_flow + + with pytest.raises(click.UsageError) as exc_info: + configured_project_declarative_flow() + + assert "must point to a regular file" in exc_info.value.message diff --git a/lib/cli/tests/test_run_crew.py b/lib/cli/tests/test_run_crew.py index fd9daf167..6db073919 100644 --- a/lib/cli/tests/test_run_crew.py +++ b/lib/cli/tests/test_run_crew.py @@ -705,9 +705,14 @@ def test_run_crew_rejects_filename_for_flow_project(monkeypatch): assert "--filename can only be used when running crews" in exc_info.value.message -def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys): +def test_run_crew_runs_configured_declarative_flow_project( + monkeypatch, tmp_path: Path, capsys +): calls = [] + 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, @@ -734,4 +739,4 @@ def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys): run_crew_module.run_crew() assert capsys.readouterr().out == "" - assert calls == [("flow.yaml", None)] + assert calls == [(definition_path.resolve(), None)] From fac3e3579bd50ea186a20c23973b3f52795bb470 Mon Sep 17 00:00:00 2001 From: Rip&Tear <84775494+theCyberTech@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:50:41 +0800 Subject: [PATCH 13/22] Fix symlink path traversal in skill archive extraction (#6235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix symlink path traversal in skill archive extraction `_safe_extractall` (the Python < 3.12 fallback used by `crewai skills` archive unpacking) validated each member's *name* against the destination but never validated symlink/hardlink *targets*. A malicious skill tarball could plant a symlink escaping the destination (e.g. `link -> /home/user/.ssh`) followed by a regular member written through it (`link/authorized_keys`), escaping `dest` even though every member name resolves inside it — the classic symlink-extraction traversal. The 3.12+ path (`extractall(..., filter="data")`) already blocks this; the fallback now mirrors it by rejecting absolute link targets and any link target that resolves outside the destination directory. Adds regression tests covering absolute and relative escaping symlinks plus benign in-tree symlinks and ordinary archives. Co-Authored-By: Claude Opus 4.8 * Harden skill cache archive extraction * Reject special skill archive members --------- Co-authored-by: Claude Opus 4.8 --- .../crewai_cli/experimental/skills/main.py | 30 +++- lib/cli/tests/skills/test_safe_extract.py | 140 ++++++++++++++++++ .../src/crewai/experimental/skills/cache.py | 27 +++- .../tests/experimental/skills/test_cache.py | 95 +++++++++++- 4 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 lib/cli/tests/skills/test_safe_extract.py diff --git a/lib/cli/src/crewai_cli/experimental/skills/main.py b/lib/cli/src/crewai_cli/experimental/skills/main.py index 81f9b3fc5..612b85d82 100644 --- a/lib/cli/src/crewai_cli/experimental/skills/main.py +++ b/lib/cli/src/crewai_cli/experimental/skills/main.py @@ -378,12 +378,40 @@ class SkillCommand(BaseCommand, PlusAPIMixin): def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None: - """Path-traversal-safe extraction for Python < 3.12.""" + """Path-traversal-safe extraction for Python versions without tar filters. + + Validates both the member's own path and, for symlink/hardlink members, + the link target. Without the link-target check a malicious archive can + plant a symlink that escapes ``dest`` (e.g. ``link -> /home/user/.ssh``) + followed by a regular member written *through* that link + (``link/authorized_keys``), escaping ``dest`` even though every member + name resolves inside it. This mirrors the protection that + ``tarfile.extractall(..., filter="data")`` provides when available. + """ dest_resolved = dest.resolve() for member in tf.getmembers(): member_path = (dest / member.name).resolve() if not member_path.is_relative_to(dest_resolved): raise ValueError(f"Blocked path traversal attempt: {member.name!r}") + if not (member.isfile() or member.isdir() or member.issym() or member.islnk()): + raise ValueError(f"Blocked unsupported tar member: {member.name!r}") + if member.issym() or member.islnk(): + link_target = member.linkname + # Absolute link targets always escape the destination. + if os.path.isabs(link_target): + raise ValueError( + f"Blocked link target escaping destination: " + f"{member.name!r} -> {link_target!r}" + ) + # Hardlink names are relative to the archive root; symlink + # targets are relative to the member's own directory. + anchor = dest if member.islnk() else (dest / member.name).parent + resolved_target = (anchor / link_target).resolve() + if not resolved_target.is_relative_to(dest_resolved): + raise ValueError( + f"Blocked link target escaping destination: " + f"{member.name!r} -> {link_target!r}" + ) tf.extractall(dest) # noqa: S202 diff --git a/lib/cli/tests/skills/test_safe_extract.py b/lib/cli/tests/skills/test_safe_extract.py new file mode 100644 index 000000000..f1083f5fa --- /dev/null +++ b/lib/cli/tests/skills/test_safe_extract.py @@ -0,0 +1,140 @@ +"""Regression tests for path-traversal-safe archive extraction. + +Guards against symlink/hardlink-based path traversal in the fallback used on +Python versions without tarfile extraction filters. The filtered path relies on +`tarfile.extractall(..., filter="data")`; the fallback must provide the same +protection by validating link targets, not just member names. +""" + +from __future__ import annotations + +import io +import tarfile +from pathlib import Path + +import pytest + +from crewai_cli.experimental.skills.main import _safe_extractall + + +def _tar_from_members(build) -> tarfile.TarFile: + """Build an in-memory tar archive via `build(tf)` and return it for reading.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + build(tf) + buf.seek(0) + return tarfile.open(fileobj=buf, mode="r") + + +def test_blocks_symlink_escaping_destination(tmp_path: Path) -> None: + """A symlink whose target escapes dest, plus a file written through it, + must be rejected before anything is extracted.""" + outside = tmp_path / "outside" + outside.mkdir() + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + link = tarfile.TarInfo("link") + link.type = tarfile.SYMTYPE + link.linkname = str(outside) # absolute path outside dest + tf.addfile(link) + payload = b"pwned" + info = tarfile.TarInfo("link/evil.txt") + info.size = len(payload) + tf.addfile(info, io.BytesIO(payload)) + + with _tar_from_members(build) as tf: + with pytest.raises(ValueError, match="escaping destination"): + _safe_extractall(tf, dest) + + assert not (outside / "evil.txt").exists() + + +def test_blocks_relative_symlink_escaping_destination(tmp_path: Path) -> None: + """A relative symlink (../..) that escapes dest is also rejected.""" + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + link = tarfile.TarInfo("sub/link") + link.type = tarfile.SYMTYPE + link.linkname = "../../outside" # escapes dest from sub/ + tf.addfile(link) + + with _tar_from_members(build) as tf: + with pytest.raises(ValueError, match="escaping destination"): + _safe_extractall(tf, dest) + + +def test_blocks_hardlink_escaping_destination(tmp_path: Path) -> None: + """A hardlink whose target escapes dest is rejected.""" + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + link = tarfile.TarInfo("escape") + link.type = tarfile.LNKTYPE + link.linkname = "../outside.txt" # escapes archive root + tf.addfile(link) + + with _tar_from_members(build) as tf: + with pytest.raises(ValueError, match="escaping destination"): + _safe_extractall(tf, dest) + + +def test_blocks_special_tar_member(tmp_path: Path) -> None: + """Special tar members such as FIFOs are rejected.""" + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + fifo = tarfile.TarInfo("pipe") + fifo.type = tarfile.FIFOTYPE + tf.addfile(fifo) + + with _tar_from_members(build) as tf: + with pytest.raises(ValueError, match="unsupported tar member"): + _safe_extractall(tf, dest) + + +def test_allows_benign_relative_symlink(tmp_path: Path) -> None: + """A symlink that stays within dest is permitted.""" + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + payload = b"hi" + info = tarfile.TarInfo("real.txt") + info.size = len(payload) + tf.addfile(info, io.BytesIO(payload)) + link = tarfile.TarInfo("alias.txt") + link.type = tarfile.SYMTYPE + link.linkname = "real.txt" # stays inside dest + tf.addfile(link) + + with _tar_from_members(build) as tf: + _safe_extractall(tf, dest) + + assert (dest / "real.txt").read_bytes() == b"hi" + assert (dest / "alias.txt").is_symlink() + assert (dest / "alias.txt").readlink() == Path("real.txt") + + +def test_allows_benign_archive(tmp_path: Path) -> None: + """An ordinary archive of regular files extracts correctly.""" + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + for name, body in (("SKILL.md", b"# skill"), ("scripts/run.py", b"print(1)")): + payload = body + info = tarfile.TarInfo(name) + info.size = len(payload) + tf.addfile(info, io.BytesIO(payload)) + + with _tar_from_members(build) as tf: + _safe_extractall(tf, dest) + + assert (dest / "SKILL.md").read_bytes() == b"# skill" + assert (dest / "scripts" / "run.py").read_bytes() == b"print(1)" diff --git a/lib/crewai/src/crewai/experimental/skills/cache.py b/lib/crewai/src/crewai/experimental/skills/cache.py index e9752c4e8..065c2c521 100644 --- a/lib/crewai/src/crewai/experimental/skills/cache.py +++ b/lib/crewai/src/crewai/experimental/skills/cache.py @@ -9,6 +9,7 @@ from __future__ import annotations from datetime import datetime, timezone import json import logging +import os from pathlib import Path import tarfile from typing import TypedDict @@ -127,12 +128,36 @@ class SkillCacheManager: def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None: - """Path-traversal-safe extraction for Python < 3.12.""" + """Path-traversal-safe extraction for Python versions without tar filters. + + Validates both the member's own path and, for symlink/hardlink members, + the link target. Without the link-target check a malicious archive can + plant a symlink that escapes ``dest`` followed by a regular member written + through that link, escaping ``dest`` even though every member name resolves + inside it. This mirrors the protection that + ``tarfile.extractall(..., filter="data")`` provides when available. + """ dest_resolved = dest.resolve() for member in tf.getmembers(): member_path = (dest / member.name).resolve() if not member_path.is_relative_to(dest_resolved): raise ValueError(f"Blocked path traversal attempt: {member.name!r}") + if not (member.isfile() or member.isdir() or member.issym() or member.islnk()): + raise ValueError(f"Blocked unsupported tar member: {member.name!r}") + if member.issym() or member.islnk(): + link_target = member.linkname + if os.path.isabs(link_target): + raise ValueError( + f"Blocked link target escaping destination: " + f"{member.name!r} -> {link_target!r}" + ) + anchor = dest if member.islnk() else (dest / member.name).parent + resolved_target = (anchor / link_target).resolve() + if not resolved_target.is_relative_to(dest_resolved): + raise ValueError( + f"Blocked link target escaping destination: " + f"{member.name!r} -> {link_target!r}" + ) tf.extractall(dest) # noqa: S202 diff --git a/lib/crewai/tests/experimental/skills/test_cache.py b/lib/crewai/tests/experimental/skills/test_cache.py index 8b458bb3e..b6601dccf 100644 --- a/lib/crewai/tests/experimental/skills/test_cache.py +++ b/lib/crewai/tests/experimental/skills/test_cache.py @@ -8,7 +8,9 @@ import json import tarfile from pathlib import Path -from crewai.experimental.skills.cache import SkillCacheManager +import pytest + +from crewai.experimental.skills.cache import SkillCacheManager, _safe_extractall def _make_tar_gz(files: dict[str, str]) -> bytes: @@ -35,6 +37,15 @@ def _make_tar_gz(files: dict[str, str]) -> bytes: return out.getvalue() +def _tar_from_members(build) -> tarfile.TarFile: + """Build an in-memory tar archive via `build(tf)` and return it for reading.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + build(tf) + buf.seek(0) + return tarfile.open(fileobj=buf, mode="r") + + class TestSkillCacheManager: def test_get_cached_path_missing(self, tmp_path: Path) -> None: cache = SkillCacheManager(cache_root=tmp_path) @@ -113,3 +124,85 @@ class TestSkillCacheManager: dest = cache.store("acme", "my-skill", None, archive) meta = json.loads((dest / ".crewai_meta.json").read_text()) assert meta["version"] is None + + +def test_safe_extractall_blocks_symlink_escaping_cache_destination( + tmp_path: Path, +) -> None: + """A symlink whose target escapes dest is rejected before extraction.""" + outside = tmp_path / "outside" + outside.mkdir() + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + link = tarfile.TarInfo("link") + link.type = tarfile.SYMTYPE + link.linkname = str(outside) + tf.addfile(link) + payload = b"pwned" + info = tarfile.TarInfo("link/evil.txt") + info.size = len(payload) + tf.addfile(info, io.BytesIO(payload)) + + with _tar_from_members(build) as tf: + with pytest.raises(ValueError, match="escaping destination"): + _safe_extractall(tf, dest) + + assert not (outside / "evil.txt").exists() + + +def test_safe_extractall_blocks_hardlink_escaping_cache_destination( + tmp_path: Path, +) -> None: + """A hardlink whose target escapes dest is rejected.""" + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + link = tarfile.TarInfo("escape") + link.type = tarfile.LNKTYPE + link.linkname = "../outside.txt" + tf.addfile(link) + + with _tar_from_members(build) as tf: + with pytest.raises(ValueError, match="escaping destination"): + _safe_extractall(tf, dest) + + +def test_safe_extractall_blocks_special_cache_tar_member(tmp_path: Path) -> None: + """Special tar members such as FIFOs are rejected.""" + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + fifo = tarfile.TarInfo("pipe") + fifo.type = tarfile.FIFOTYPE + tf.addfile(fifo) + + with _tar_from_members(build) as tf: + with pytest.raises(ValueError, match="unsupported tar member"): + _safe_extractall(tf, dest) + + +def test_safe_extractall_allows_benign_cache_symlink(tmp_path: Path) -> None: + """A symlink that stays within dest is permitted.""" + dest = tmp_path / "dest" + dest.mkdir() + + def build(tf: tarfile.TarFile) -> None: + payload = b"hi" + info = tarfile.TarInfo("real.txt") + info.size = len(payload) + tf.addfile(info, io.BytesIO(payload)) + link = tarfile.TarInfo("alias.txt") + link.type = tarfile.SYMTYPE + link.linkname = "real.txt" + tf.addfile(link) + + with _tar_from_members(build) as tf: + _safe_extractall(tf, dest) + + assert (dest / "real.txt").read_bytes() == b"hi" + assert (dest / "alias.txt").is_symlink() + assert (dest / "alias.txt").readlink() == Path("real.txt") From 12a5e91efbd28bbad16f7f29e01a24e56eadffee Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Wed, 24 Jun 2026 09:14:14 -0700 Subject: [PATCH 14/22] feat: bump versions to 1.14.8a4 (#6318) --- lib/cli/pyproject.toml | 2 +- lib/cli/src/crewai_cli/__init__.py | 2 +- lib/cli/src/crewai_cli/templates/crew/pyproject.toml | 2 +- .../crewai_cli/templates/declarative_flow/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/flow/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/tool/pyproject.toml | 2 +- lib/crewai-core/src/crewai_core/__init__.py | 2 +- lib/crewai-files/src/crewai_files/__init__.py | 2 +- lib/crewai-tools/pyproject.toml | 2 +- lib/crewai-tools/src/crewai_tools/__init__.py | 2 +- lib/crewai/pyproject.toml | 6 +++--- lib/crewai/src/crewai/__init__.py | 2 +- lib/devtools/src/crewai_devtools/__init__.py | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/cli/pyproject.toml b/lib/cli/pyproject.toml index e48f1fd3b..1b04f4f32 100644 --- a/lib/cli/pyproject.toml +++ b/lib/cli/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.8a3", + "crewai-core==1.14.8a4", "click>=8.1.7,<9", "pydantic>=2.11.9,<2.13", "pydantic-settings~=2.10.1", diff --git a/lib/cli/src/crewai_cli/__init__.py b/lib/cli/src/crewai_cli/__init__.py index 7b09b91f5..928b412af 100644 --- a/lib/cli/src/crewai_cli/__init__.py +++ b/lib/cli/src/crewai_cli/__init__.py @@ -1 +1 @@ -__version__ = "1.14.8a3" +__version__ = "1.14.8a4" diff --git a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml index 44a1c84e2..258c654a2 100644 --- a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/crew/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.14.8a3" + "crewai[tools]==1.14.8a4" ] [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 c462fc779..a0ac1fb79 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.14.8a3" + "crewai[tools]==1.14.8a4" ] [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 e0ff1433a..c825051c5 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.14.8a3" + "crewai[tools]==1.14.8a4" ] [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 1b09cdf09..19534dff8 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.14.8a3" + "crewai[tools]==1.14.8a4" ] [tool.crewai] diff --git a/lib/crewai-core/src/crewai_core/__init__.py b/lib/crewai-core/src/crewai_core/__init__.py index 7b09b91f5..928b412af 100644 --- a/lib/crewai-core/src/crewai_core/__init__.py +++ b/lib/crewai-core/src/crewai_core/__init__.py @@ -1 +1 @@ -__version__ = "1.14.8a3" +__version__ = "1.14.8a4" diff --git a/lib/crewai-files/src/crewai_files/__init__.py b/lib/crewai-files/src/crewai_files/__init__.py index 4e4030d60..2c9bac458 100644 --- a/lib/crewai-files/src/crewai_files/__init__.py +++ b/lib/crewai-files/src/crewai_files/__init__.py @@ -152,4 +152,4 @@ __all__ = [ "wrap_file_source", ] -__version__ = "1.14.8a3" +__version__ = "1.14.8a4" diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index f0487171a..e6eac89c1 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14" dependencies = [ "pytube~=15.0.0", "requests>=2.33.0,<3", - "crewai==1.14.8a3", + "crewai==1.14.8a4", "tiktoken>=0.8.0,<0.13", "beautifulsoup4~=4.13.4", "python-docx~=1.2.0", diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 438943ebc..65c3a5d87 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -330,4 +330,4 @@ __all__ = [ "ZapierActionTools", ] -__version__ = "1.14.8a3" +__version__ = "1.14.8a4" diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index fcdc1bd01..2cbaa1cb3 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -8,8 +8,8 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.8a3", - "crewai-cli==1.14.8a3", + "crewai-core==1.14.8a4", + "crewai-cli==1.14.8a4", # Core Dependencies "pydantic>=2.11.9,<2.13", "openai>=2.30.0,<3", @@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI" [project.optional-dependencies] tools = [ - "crewai-tools==1.14.8a3", + "crewai-tools==1.14.8a4", ] embeddings = [ "tiktoken>=0.8.0,<0.13" diff --git a/lib/crewai/src/crewai/__init__.py b/lib/crewai/src/crewai/__init__.py index 40157afa9..b49b462e8 100644 --- a/lib/crewai/src/crewai/__init__.py +++ b/lib/crewai/src/crewai/__init__.py @@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None: _suppress_pydantic_deprecation_warnings() -__version__ = "1.14.8a3" +__version__ = "1.14.8a4" _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "Memory": ("crewai.memory.unified_memory", "Memory"), diff --git a/lib/devtools/src/crewai_devtools/__init__.py b/lib/devtools/src/crewai_devtools/__init__.py index 71b581c0f..bbffc72b1 100644 --- a/lib/devtools/src/crewai_devtools/__init__.py +++ b/lib/devtools/src/crewai_devtools/__init__.py @@ -1,3 +1,3 @@ """CrewAI development tools.""" -__version__ = "1.14.8a3" +__version__ = "1.14.8a4" From 9d0e3a841b2bbfa73ef5255506f95f5d4f22a876 Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Wed, 24 Jun 2026 09:19:33 -0700 Subject: [PATCH 15/22] docs: snapshot and changelog for v1.14.8a4 (#6319) --- docs/edge/ar/changelog.mdx | 23 +++++++++++++++++++++++ docs/edge/en/changelog.mdx | 23 +++++++++++++++++++++++ docs/edge/ko/changelog.mdx | 23 +++++++++++++++++++++++ docs/edge/pt-BR/changelog.mdx | 23 +++++++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/docs/edge/ar/changelog.mdx b/docs/edge/ar/changelog.mdx index 11d035da7..ee5c31b14 100644 --- a/docs/edge/ar/changelog.mdx +++ b/docs/edge/ar/changelog.mdx @@ -4,6 +4,29 @@ description: "تحديثات المنتج والتحسينات وإصلاحات icon: "clock" mode: "wide" --- + + ## v1.14.8a4 + + [عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4) + + ## ما الذي تغير + + ### الميزات + - دعم تدفقات المحادثة في واجهة سطر الأوامر TUI. + + ### إصلاحات الأخطاء + - إصلاح مسار التوجيه الرمزي في استخراج أرشيف المهارات. + - التحقق من صحة مسارات تعريف التدفق الإعلاني. + + ### الوثائق + - تحديث اللقطة وسجل التغييرات للإصدار v1.14.8a3. + + ## المساهمون + + @lorenzejay, @theCyberTech, @vinibrsl + + + ## v1.14.8a3 diff --git a/docs/edge/en/changelog.mdx b/docs/edge/en/changelog.mdx index 1b990991d..5c741a965 100644 --- a/docs/edge/en/changelog.mdx +++ b/docs/edge/en/changelog.mdx @@ -4,6 +4,29 @@ description: "Product updates, improvements, and bug fixes for CrewAI" icon: "clock" mode: "wide" --- + + ## v1.14.8a4 + + [View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4) + + ## What's Changed + + ### Features + - Support conversational flows in the CLI TUI. + + ### Bug Fixes + - Fix symlink path traversal in skill archive extraction. + - Validate declarative flow definition paths. + + ### Documentation + - Update snapshot and changelog for v1.14.8a3. + + ## Contributors + + @lorenzejay, @theCyberTech, @vinibrsl + + + ## v1.14.8a3 diff --git a/docs/edge/ko/changelog.mdx b/docs/edge/ko/changelog.mdx index fe25de51c..091b559bb 100644 --- a/docs/edge/ko/changelog.mdx +++ b/docs/edge/ko/changelog.mdx @@ -4,6 +4,29 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정" icon: "clock" mode: "wide" --- + + ## v1.14.8a4 + + [GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4) + + ## 변경 사항 + + ### 기능 + - CLI TUI에서 대화형 흐름 지원. + + ### 버그 수정 + - 기술 아카이브 추출 시 심볼릭 링크 경로 탐색 문제 수정. + - 선언적 흐름 정의 경로 검증. + + ### 문서 + - v1.14.8a3에 대한 스냅샷 및 변경 로그 업데이트. + + ## 기여자 + + @lorenzejay, @theCyberTech, @vinibrsl + + + ## v1.14.8a3 diff --git a/docs/edge/pt-BR/changelog.mdx b/docs/edge/pt-BR/changelog.mdx index 5a2f653ce..d03bce6fb 100644 --- a/docs/edge/pt-BR/changelog.mdx +++ b/docs/edge/pt-BR/changelog.mdx @@ -4,6 +4,29 @@ description: "Atualizações de produto, melhorias e correções do CrewAI" icon: "clock" mode: "wide" --- + + ## v1.14.8a4 + + [Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a4) + + ## O que Mudou + + ### Recursos + - Suporte a fluxos de conversa na TUI do CLI. + + ### Correções de Bugs + - Corrigir a travessia de caminho de symlink na extração de arquivo de habilidade. + - Validar os caminhos de definição de fluxo declarativo. + + ### Documentação + - Atualizar snapshot e changelog para v1.14.8a3. + + ## Contribuidores + + @lorenzejay, @theCyberTech, @vinibrsl + + + ## v1.14.8a3 From 5827abbc17ef1493f60a91366953755407cf2281 Mon Sep 17 00:00:00 2001 From: Jesse Miller Date: Wed, 24 Jun 2026 13:36:49 -0400 Subject: [PATCH 16/22] docs: nest One Card per Step under Crew Studio and drop rollout banner (AGE-107) (#6317) The page itself already landed on main via #6247. This rebases onto main and applies the two remaining changes: - Nest crew-studio + merged-step-card into a collapsible "Crew Studio" nav group (pencil icon), across edge and v1.14.7 in en, pt-BR, ko, ar. - Remove the temporary "Rolling out" Note banner (feature ships today). Co-authored-by: Claude Opus 4.8 (1M context) --- docs/docs.json | 80 +++++++++++++++---- .../enterprise/features/merged-step-card.mdx | 5 -- .../enterprise/features/merged-step-card.mdx | 5 -- .../enterprise/features/merged-step-card.mdx | 5 -- .../enterprise/features/merged-step-card.mdx | 5 -- .../enterprise/features/merged-step-card.mdx | 5 -- .../enterprise/features/merged-step-card.mdx | 5 -- .../enterprise/features/merged-step-card.mdx | 5 -- .../enterprise/features/merged-step-card.mdx | 5 -- 9 files changed, 64 insertions(+), 56 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 4b49fae7c..bc5f8c7bf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -397,8 +397,14 @@ "group": "Build", "pages": [ "edge/en/enterprise/features/automations", - "edge/en/enterprise/features/crew-studio", - "edge/en/enterprise/features/merged-step-card", + { + "group": "Crew Studio", + "icon": "pencil", + "pages": [ + "edge/en/enterprise/features/crew-studio", + "edge/en/enterprise/features/merged-step-card" + ] + }, "edge/en/enterprise/features/marketplace", "edge/en/enterprise/features/agent-repositories", "edge/en/enterprise/features/tools-and-integrations", @@ -922,8 +928,14 @@ "group": "Build", "pages": [ "v1.14.7/en/enterprise/features/automations", - "v1.14.7/en/enterprise/features/crew-studio", - "v1.14.7/en/enterprise/features/merged-step-card", + { + "group": "Crew Studio", + "icon": "pencil", + "pages": [ + "v1.14.7/en/enterprise/features/crew-studio", + "v1.14.7/en/enterprise/features/merged-step-card" + ] + }, "v1.14.7/en/enterprise/features/marketplace", "v1.14.7/en/enterprise/features/agent-repositories", "v1.14.7/en/enterprise/features/tools-and-integrations", @@ -8549,8 +8561,14 @@ "group": "Construir", "pages": [ "edge/pt-BR/enterprise/features/automations", - "edge/pt-BR/enterprise/features/crew-studio", - "edge/pt-BR/enterprise/features/merged-step-card", + { + "group": "Crew Studio", + "icon": "pencil", + "pages": [ + "edge/pt-BR/enterprise/features/crew-studio", + "edge/pt-BR/enterprise/features/merged-step-card" + ] + }, "edge/pt-BR/enterprise/features/marketplace", "edge/pt-BR/enterprise/features/agent-repositories", "edge/pt-BR/enterprise/features/tools-and-integrations", @@ -9051,8 +9069,14 @@ "group": "Construir", "pages": [ "v1.14.7/pt-BR/enterprise/features/automations", - "v1.14.7/pt-BR/enterprise/features/crew-studio", - "v1.14.7/pt-BR/enterprise/features/merged-step-card", + { + "group": "Crew Studio", + "icon": "pencil", + "pages": [ + "v1.14.7/pt-BR/enterprise/features/crew-studio", + "v1.14.7/pt-BR/enterprise/features/merged-step-card" + ] + }, "v1.14.7/pt-BR/enterprise/features/marketplace", "v1.14.7/pt-BR/enterprise/features/agent-repositories", "v1.14.7/pt-BR/enterprise/features/tools-and-integrations", @@ -16415,8 +16439,14 @@ "group": "빌드", "pages": [ "edge/ko/enterprise/features/automations", - "edge/ko/enterprise/features/crew-studio", - "edge/ko/enterprise/features/merged-step-card", + { + "group": "Crew Studio", + "icon": "pencil", + "pages": [ + "edge/ko/enterprise/features/crew-studio", + "edge/ko/enterprise/features/merged-step-card" + ] + }, "edge/ko/enterprise/features/marketplace", "edge/ko/enterprise/features/agent-repositories", "edge/ko/enterprise/features/tools-and-integrations", @@ -16929,8 +16959,14 @@ "group": "빌드", "pages": [ "v1.14.7/ko/enterprise/features/automations", - "v1.14.7/ko/enterprise/features/crew-studio", - "v1.14.7/ko/enterprise/features/merged-step-card", + { + "group": "Crew Studio", + "icon": "pencil", + "pages": [ + "v1.14.7/ko/enterprise/features/crew-studio", + "v1.14.7/ko/enterprise/features/merged-step-card" + ] + }, "v1.14.7/ko/enterprise/features/marketplace", "v1.14.7/ko/enterprise/features/agent-repositories", "v1.14.7/ko/enterprise/features/tools-and-integrations", @@ -24473,8 +24509,14 @@ "group": "البناء", "pages": [ "edge/ar/enterprise/features/automations", - "edge/ar/enterprise/features/crew-studio", - "edge/ar/enterprise/features/merged-step-card", + { + "group": "استوديو الطاقم", + "icon": "pencil", + "pages": [ + "edge/ar/enterprise/features/crew-studio", + "edge/ar/enterprise/features/merged-step-card" + ] + }, "edge/ar/enterprise/features/marketplace", "edge/ar/enterprise/features/agent-repositories", "edge/ar/enterprise/features/tools-and-integrations", @@ -24987,8 +25029,14 @@ "group": "البناء", "pages": [ "v1.14.7/ar/enterprise/features/automations", - "v1.14.7/ar/enterprise/features/crew-studio", - "v1.14.7/ar/enterprise/features/merged-step-card", + { + "group": "استوديو الطاقم", + "icon": "pencil", + "pages": [ + "v1.14.7/ar/enterprise/features/crew-studio", + "v1.14.7/ar/enterprise/features/merged-step-card" + ] + }, "v1.14.7/ar/enterprise/features/marketplace", "v1.14.7/ar/enterprise/features/agent-repositories", "v1.14.7/ar/enterprise/features/tools-and-integrations", diff --git a/docs/edge/ar/enterprise/features/merged-step-card.mdx b/docs/edge/ar/enterprise/features/merged-step-card.mdx index 76ab3e98f..776edfe10 100644 --- a/docs/edge/ar/enterprise/features/merged-step-card.mdx +++ b/docs/edge/ar/enterprise/features/merged-step-card.mdx @@ -5,11 +5,6 @@ icon: "layer-group" mode: "wide" --- -{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */} - - **الإطلاق يوم الأربعاء 24 يونيو.** تنتقل لوحة Studio إلى بطاقة واحدة لكل خطوة بدلاً من عُقد منفصلة للمهمة والوكيل، وذلك لتبسيط اللوحة مع إضافتنا لوظائف جديدة قريبًا. تستمر أتمتتك الحالية في العمل دون أي تغييرات مطلوبة — تبقى جميع إعدادات المهمة والوكيل متاحة، ولكن منظّمة في بطاقة واحدة. - - ## نظرة عامة على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة: diff --git a/docs/edge/en/enterprise/features/merged-step-card.mdx b/docs/edge/en/enterprise/features/merged-step-card.mdx index 6011d1302..f0a3be647 100644 --- a/docs/edge/en/enterprise/features/merged-step-card.mdx +++ b/docs/edge/en/enterprise/features/merged-step-card.mdx @@ -5,11 +5,6 @@ icon: "layer-group" mode: "wide" --- -{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */} - - **Rolling out Wednesday, June 24th.** The Studio canvas is moving to one card per step instead of separate task and agent nodes, to streamline the canvas as we add new functionality soon. Your existing automations keep working with no changes needed — every task and agent setting is still available, just organized onto a single card. - - ## Overview On the Studio canvas, each step of work is represented by a **single card**. The card combines two things that used to live in separate nodes: diff --git a/docs/edge/ko/enterprise/features/merged-step-card.mdx b/docs/edge/ko/enterprise/features/merged-step-card.mdx index 4103353cf..d45301d56 100644 --- a/docs/edge/ko/enterprise/features/merged-step-card.mdx +++ b/docs/edge/ko/enterprise/features/merged-step-card.mdx @@ -5,11 +5,6 @@ icon: "layer-group" mode: "wide" --- -{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */} - - **6월 24일 수요일 출시.** Studio 캔버스가 작업과 에이전트를 별도의 노드로 표시하는 대신 단계당 하나의 카드로 전환됩니다. 곧 추가될 새로운 기능을 위해 캔버스를 간소화하기 위한 변경입니다. 기존 자동화는 아무런 변경 없이 그대로 동작하며, 모든 작업 및 에이전트 설정은 단일 카드에 정리되어 그대로 사용할 수 있습니다. - - ## 개요 Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다: diff --git a/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx b/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx index 8b9d937d0..3ca6cee60 100644 --- a/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx +++ b/docs/edge/pt-BR/enterprise/features/merged-step-card.mdx @@ -5,11 +5,6 @@ icon: "layer-group" mode: "wide" --- -{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */} - - **Lançamento na quarta-feira, 24 de junho.** O canvas do Studio passa a exibir um card por etapa, em vez de nós separados para tarefa e agente, para simplificar o canvas à medida que adicionamos novas funcionalidades em breve. Suas automações existentes continuam funcionando sem nenhuma alteração necessária — cada configuração de tarefa e de agente continua disponível, apenas organizada em um único card. - - ## Visão geral No canvas do Studio, cada etapa de trabalho é representada por um **único card**. O card combina dois elementos que antes ficavam em nós separados: diff --git a/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx index 76ab3e98f..776edfe10 100644 --- a/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx +++ b/docs/v1.14.7/ar/enterprise/features/merged-step-card.mdx @@ -5,11 +5,6 @@ icon: "layer-group" mode: "wide" --- -{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */} - - **الإطلاق يوم الأربعاء 24 يونيو.** تنتقل لوحة Studio إلى بطاقة واحدة لكل خطوة بدلاً من عُقد منفصلة للمهمة والوكيل، وذلك لتبسيط اللوحة مع إضافتنا لوظائف جديدة قريبًا. تستمر أتمتتك الحالية في العمل دون أي تغييرات مطلوبة — تبقى جميع إعدادات المهمة والوكيل متاحة، ولكن منظّمة في بطاقة واحدة. - - ## نظرة عامة على لوحة Studio، تُمثَّل كل خطوة عمل بـ **بطاقة واحدة**. تجمع البطاقة بين عنصرين كانا في السابق في عُقد منفصلة: diff --git a/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx index 6011d1302..f0a3be647 100644 --- a/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx +++ b/docs/v1.14.7/en/enterprise/features/merged-step-card.mdx @@ -5,11 +5,6 @@ icon: "layer-group" mode: "wide" --- -{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */} - - **Rolling out Wednesday, June 24th.** The Studio canvas is moving to one card per step instead of separate task and agent nodes, to streamline the canvas as we add new functionality soon. Your existing automations keep working with no changes needed — every task and agent setting is still available, just organized onto a single card. - - ## Overview On the Studio canvas, each step of work is represented by a **single card**. The card combines two things that used to live in separate nodes: diff --git a/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx index 4103353cf..d45301d56 100644 --- a/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx +++ b/docs/v1.14.7/ko/enterprise/features/merged-step-card.mdx @@ -5,11 +5,6 @@ icon: "layer-group" mode: "wide" --- -{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */} - - **6월 24일 수요일 출시.** Studio 캔버스가 작업과 에이전트를 별도의 노드로 표시하는 대신 단계당 하나의 카드로 전환됩니다. 곧 추가될 새로운 기능을 위해 캔버스를 간소화하기 위한 변경입니다. 기존 자동화는 아무런 변경 없이 그대로 동작하며, 모든 작업 및 에이전트 설정은 단일 카드에 정리되어 그대로 사용할 수 있습니다. - - ## 개요 Studio 캔버스에서 각 작업 단계는 **하나의 카드**로 표현됩니다. 이 카드는 이전에 별도의 노드에 있던 두 가지를 결합합니다: diff --git a/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx b/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx index 8b9d937d0..3ca6cee60 100644 --- a/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx +++ b/docs/v1.14.7/pt-BR/enterprise/features/merged-step-card.mdx @@ -5,11 +5,6 @@ icon: "layer-group" mode: "wide" --- -{/* CLEANUP: This banner is the only time-bound content on the page. After the feature ships (Wednesday, June 24th 2026), delete the banner below — the rest of the page is evergreen present-tense docs and needs no other edits. */} - - **Lançamento na quarta-feira, 24 de junho.** O canvas do Studio passa a exibir um card por etapa, em vez de nós separados para tarefa e agente, para simplificar o canvas à medida que adicionamos novas funcionalidades em breve. Suas automações existentes continuam funcionando sem nenhuma alteração necessária — cada configuração de tarefa e de agente continua disponível, apenas organizada em um único card. - - ## Visão geral No canvas do Studio, cada etapa de trabalho é representada por um **único card**. O card combina dois elementos que antes ficavam em nós separados: From 156b3500b4efd2060cd75f7de69b8eeed845fdbb Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Wed, 24 Jun 2026 13:55:38 -0700 Subject: [PATCH 17/22] Fix JSON schema flow state kickoff inputs (#6325) Allow required JSON schema state fields to be supplied by kickoff inputs instead of requiring every field to exist in state.default before runtime. Example: a flow with required lead_name and no state.default can now run with kickoff inputs={"lead_name": "Ada Lovelace"}. --- .../src/crewai/flow/runtime/__init__.py | 7 +++- lib/crewai/tests/test_flow_from_definition.py | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/crewai/src/crewai/flow/runtime/__init__.py b/lib/crewai/src/crewai/flow/runtime/__init__.py index 4b07f3533..24f863c4e 100644 --- a/lib/crewai/src/crewai/flow/runtime/__init__.py +++ b/lib/crewai/src/crewai/flow/runtime/__init__.py @@ -227,7 +227,12 @@ def _build_definition_state_model( pass model_class = StateWithId - return model_class(**kwargs) + try: + return model_class(**kwargs) + except ValidationError as e: + if any(error.get("type") != "missing" for error in e.errors()): + raise + return model_class.model_construct(**kwargs) def _iter_condition_events(condition: FlowDefinitionCondition) -> Iterator[str]: diff --git a/lib/crewai/tests/test_flow_from_definition.py b/lib/crewai/tests/test_flow_from_definition.py index 909f56459..16160f3cf 100644 --- a/lib/crewai/tests/test_flow_from_definition.py +++ b/lib/crewai/tests/test_flow_from_definition.py @@ -357,6 +357,27 @@ methods: listen: begin """ +JSON_SCHEMA_REQUIRED_INPUT_STATE_YAML = """ +schema: crewai.flow/v1 +name: JsonSchemaRequiredInputStateFlow +state: + type: json_schema + json_schema: + title: LeadState + type: object + required: + - lead_name + properties: + lead_name: + type: string +methods: + begin: + start: true + do: + call: expression + expr: state.lead_name +""" + PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML = f""" schema: crewai.flow/v1 name: SchemaFallbackFlow @@ -2298,6 +2319,18 @@ def test_json_schema_state_validates_inputs(): flow.kickoff(inputs={"count": "not-a-number"}) +def test_json_schema_state_required_fields_can_come_from_kickoff_inputs(): + flow = Flow.from_definition( + FlowDefinition.from_declaration(contents=JSON_SCHEMA_REQUIRED_INPUT_STATE_YAML) + ) + + result = flow.kickoff(inputs={"lead_name": "Ada Lovelace"}) + + assert result == "Ada Lovelace" + assert flow.state.lead_name == "Ada Lovelace" + assert flow.state.id + + def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable(): flow = Flow.from_definition( FlowDefinition.from_declaration(contents=PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML) From 7738a1d30ca29aac9d760b10dc33a91576c84ac9 Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Wed, 24 Jun 2026 15:11:59 -0700 Subject: [PATCH 18/22] Make declarative refs work across flows and crews (#6326) Declarative flows already used `module:qualname` refs for runtime objects, but crew JSON tools still had their own lookup path. That meant examples like `project_tools:LookupTool` were treated as named `crewai_tools` lookups and failed with guidance that only mentioned `SerperDevTool` or `custom:`. Invalid refs such as `not_tools:NotATool` also missed the same BaseTool validation used by flow tool actions. Move ref resolution into a shared declarative helper, use it from flow tool actions and crew JSON loading, and require tool refs to resolve to `BaseTool` classes before instantiation. Validation still checks tool refs structurally, so validating a crew does not import or execute project code. --- .../src/crewai/flow/runtime/__init__.py | 18 ++++- .../src/crewai/flow/runtime/_actions.py | 21 +++--- lib/crewai/src/crewai/flow/runtime/_refs.py | 38 ---------- lib/crewai/src/crewai/project/json_loader.py | 37 +++++++++- .../src/crewai/utilities/declarative_refs.py | 69 +++++++++++++++++++ lib/crewai/tests/project/test_json_loader.py | 64 +++++++++++++++++ 6 files changed, 196 insertions(+), 51 deletions(-) delete mode 100644 lib/crewai/src/crewai/flow/runtime/_refs.py create mode 100644 lib/crewai/src/crewai/utilities/declarative_refs.py diff --git a/lib/crewai/src/crewai/flow/runtime/__init__.py b/lib/crewai/src/crewai/flow/runtime/__init__.py index 24f863c4e..4bb67a269 100644 --- a/lib/crewai/src/crewai/flow/runtime/__init__.py +++ b/lib/crewai/src/crewai/flow/runtime/__init__.py @@ -123,7 +123,6 @@ from crewai.flow.human_feedback import ( from crewai.flow.input_provider import InputProvider from crewai.flow.persistence.base import FlowPersistence from crewai.flow.runtime._actions import FlowScriptExecutionDisabledError, build_action -from crewai.flow.runtime._refs import resolve_instance_ref, resolve_ref from crewai.flow.types import ( FlowExecutionData, FlowMethodName, @@ -137,6 +136,7 @@ from crewai.state.checkpoint_config import ( _coerce_checkpoint, apply_checkpoint, ) +from crewai.utilities.declarative_refs import InvalidRefError, resolve_ref if TYPE_CHECKING: @@ -289,6 +289,18 @@ def _resolve_persistence(value: Any) -> Any: return value +def _resolve_instance_ref(ref: str, *, field: str) -> Any: + target = resolve_ref(ref, field=field) + if not inspect.isclass(target): + return target + try: + return target() + except Exception as e: + raise InvalidRefError( + f"cannot instantiate {field} ref {ref!r} without arguments: {e}" + ) from e + + def _serialize_persistence(value: Any) -> dict[str, Any] | None: if value is None: return None @@ -304,7 +316,7 @@ def _validate_input_provider(value: Any) -> Any: if value is None or isinstance(value, InputProvider): return value if isinstance(value, str) and ":" in value: - resolved = resolve_instance_ref(value, field="input_provider") + resolved = _resolve_instance_ref(value, field="input_provider") else: from crewai.types.callback import _dotted_path_to_instance @@ -3605,7 +3617,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): ) -> Any: provider = feedback_definition.provider if isinstance(provider, str): - provider = resolve_instance_ref(provider, field="human_feedback.provider") + provider = _resolve_instance_ref(provider, field="human_feedback.provider") if provider is None: from crewai.flow.flow_config import flow_config diff --git a/lib/crewai/src/crewai/flow/runtime/_actions.py b/lib/crewai/src/crewai/flow/runtime/_actions.py index c8f118775..9b5c8831f 100644 --- a/lib/crewai/src/crewai/flow/runtime/_actions.py +++ b/lib/crewai/src/crewai/flow/runtime/_actions.py @@ -24,7 +24,11 @@ from crewai.flow.flow_definition import ( FlowToolActionDefinition, ) from crewai.flow.runtime._outputs import outputs_by_name -from crewai.flow.runtime._refs import InvalidRefError, resolve_ref +from crewai.utilities.declarative_refs import ( + InvalidRefError, + resolve_class_ref, + resolve_ref, +) if TYPE_CHECKING: @@ -103,16 +107,17 @@ class ToolAction: ) def _build_tool(self) -> Any: - target = resolve_ref(self.definition.ref, field="do") from crewai.tools import BaseTool - if not (inspect.isclass(target) and issubclass(target, BaseTool)): - raise InvalidRefError( - f"invalid tool ref {self.definition.ref!r}; expected a BaseTool class" - ) - + tool_cls = cast( + Callable[[], BaseTool], + resolve_class_ref( + self.definition.ref, + field="do", + base_class=BaseTool, + ), + ) try: - tool_cls = cast(Callable[[], BaseTool], target) return tool_cls() except Exception as e: raise InvalidRefError( diff --git a/lib/crewai/src/crewai/flow/runtime/_refs.py b/lib/crewai/src/crewai/flow/runtime/_refs.py deleted file mode 100644 index 23ddafadb..000000000 --- a/lib/crewai/src/crewai/flow/runtime/_refs.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Resolution of ``module:qualname`` refs into live Python objects.""" - -from __future__ import annotations - -import importlib -import inspect -from operator import attrgetter -from typing import Any - - -class InvalidRefError(ValueError): - """A definition ref that cannot be resolved to a live object.""" - - -def resolve_ref(ref: str, *, field: str) -> Any: - """Import the object a definition's `module:qualname` ref points to.""" - module_name, _, qualname = ref.partition(":") - if "<" in ref or not module_name or not qualname: - raise InvalidRefError( - f"invalid {field} ref {ref!r}; expected 'module:qualname'" - ) - try: - return attrgetter(qualname)(importlib.import_module(module_name)) - except (ImportError, AttributeError) as e: - raise InvalidRefError(f"unresolvable {field} ref {ref!r}") from e - - -def resolve_instance_ref(ref: str, *, field: str) -> Any: - """Resolve a ref, auto-instantiating a no-arg class into an instance.""" - target = resolve_ref(ref, field=field) - if not inspect.isclass(target): - return target - try: - return target() - except Exception as e: - raise InvalidRefError( - f"cannot instantiate {field} ref {ref!r} without arguments: {e}" - ) from e diff --git a/lib/crewai/src/crewai/project/json_loader.py b/lib/crewai/src/crewai/project/json_loader.py index 0c6d7cbba..2c2a229fb 100644 --- a/lib/crewai/src/crewai/project/json_loader.py +++ b/lib/crewai/src/crewai/project/json_loader.py @@ -16,6 +16,8 @@ from urllib.parse import unquote, urlparse from pydantic import BaseModel, ValidationError +from crewai.utilities.declarative_refs import InvalidRefError, resolve_class_ref + logger = logging.getLogger(__name__) @@ -1820,6 +1822,9 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li if tool_def.startswith("custom:"): tools.append(_resolve_custom_tool(tool_def[7:], project_root=project_root)) continue + if ":" in tool_def: + tools.append(_instantiate_tool_import_ref(tool_def)) + continue try: tool_cls = _find_tool_class(tool_def) except Exception as e: @@ -1827,8 +1832,10 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li if tool_cls is None: raise JSONProjectError( f"Unknown tool '{tool_def}'. Tool names must match a class from " - f"the 'crewai_tools' package (e.g. 'SerperDevTool') or use the " - f"'custom:' prefix for a tool defined in tools/.py." + f"the 'crewai_tools' package (e.g. 'SerperDevTool'), use a " + f"'module:ClassName' import ref (e.g. 'crewai_tools:SerperDevTool'), " + f"or use the 'custom:' prefix for a tool defined in " + f"tools/.py." ) try: tools.append(tool_cls()) @@ -1839,6 +1846,32 @@ def _resolve_tools(tool_defs: list[Any], project_root: Path | None = None) -> li return tools +def _instantiate_tool_import_ref(ref: str) -> Any: + from crewai.tools import BaseTool + + try: + tool_cls = cast( + Callable[[], BaseTool], + resolve_class_ref(ref, field="tool", base_class=BaseTool), + ) + except InvalidRefError as e: + message = str(e) + if ( + message.startswith("unresolvable ") + or "expected 'module:qualname'" in message + ): + raise JSONProjectError(str(e)) from e + raise JSONProjectError( + f"invalid tool ref {ref!r}; expected a BaseTool class" + ) from e + try: + return tool_cls() + except Exception as e: + raise JSONProjectError( + f"cannot instantiate tool ref {ref!r} without arguments: {e}" + ) from e + + _tool_class_cache: dict[str, type | None] = {} diff --git a/lib/crewai/src/crewai/utilities/declarative_refs.py b/lib/crewai/src/crewai/utilities/declarative_refs.py new file mode 100644 index 000000000..5779fe129 --- /dev/null +++ b/lib/crewai/src/crewai/utilities/declarative_refs.py @@ -0,0 +1,69 @@ +"""Resolve Python refs used in project definitions. + +A ref must use this form: ``module:qualname``. ``module`` must name a Python +module we can import. ``qualname`` must name something inside that module. For +example, ``crewai_tools:SerperDevTool`` imports ``crewai_tools`` and returns +``SerperDevTool`` from it. Dots in ``qualname`` mean nested attributes. + +Examples: + +- ``crewai_tools:SerperDevTool`` imports ``crewai_tools`` and returns + ``SerperDevTool``. +- ``my_app.tools:Factory.build`` imports ``my_app.tools``, gets ``Factory``, + then gets ``build`` from ``Factory``. +- ``crewai_tools`` is invalid because it has no ``:``. +- ``crewai_tools:`` is invalid because it has no ``qualname``. + +These helpers are the shared contract for YAML/JSON definitions: + +- ``resolve_ref()`` checks the ref, imports the module, and returns the symbol + as-is. +- ``resolve_class_ref()`` does the same work, then checks that the symbol is a + class. It can also check that the class extends a base class. It does not + create an object. + +These helpers import user code. Code that must avoid that should check the raw +string shape instead. +""" + +from __future__ import annotations + +import importlib +import inspect +from operator import attrgetter +from typing import Any + + +class InvalidRefError(ValueError): + """A definition ref that cannot be resolved to a live Python symbol.""" + + +def resolve_ref(ref: str, *, field: str) -> Any: + """Return the Python symbol named by a project definition field.""" + module_name, _, qualname = ref.partition(":") + if "<" in ref or not module_name or not qualname: + raise InvalidRefError( + f"invalid {field} ref {ref!r}; expected 'module:qualname'" + ) + try: + return attrgetter(qualname)(importlib.import_module(module_name)) + except (ImportError, AttributeError) as e: + raise InvalidRefError(f"unresolvable {field} ref {ref!r}") from e + + +def resolve_class_ref( + ref: str, + *, + field: str, + base_class: type[Any] | None = None, +) -> type[Any]: + """Return the named class, with an optional base class check.""" + target = resolve_ref(ref, field=field) + if not inspect.isclass(target): + raise InvalidRefError(f"invalid {field} ref {ref!r}; expected a class") + if base_class is not None and not issubclass(target, base_class): + raise InvalidRefError( + f"invalid {field} ref {ref!r}; expected a subclass of " + f"{base_class.__module__}.{base_class.__name__}" + ) + return target diff --git a/lib/crewai/tests/project/test_json_loader.py b/lib/crewai/tests/project/test_json_loader.py index a5a8f85fa..ff2d5d48c 100644 --- a/lib/crewai/tests/project/test_json_loader.py +++ b/lib/crewai/tests/project/test_json_loader.py @@ -385,12 +385,52 @@ class TestLoadAgentFromDefinition: class TestResolveTools: + def test_import_ref_tool_resolves(self, tmp_path, monkeypatch): + from crewai.project.json_loader import _resolve_tools + + (tmp_path / "project_tools.py").write_text( + "from crewai.tools.base_tool import BaseTool\n" + "\n" + "class LookupTool(BaseTool):\n" + " name: str = 'lookup'\n" + " description: str = 'lookup input'\n" + "\n" + " def _run(self, text: str) -> str:\n" + " return text\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + + tools = _resolve_tools(["project_tools:LookupTool"]) + + assert len(tools) == 1 + assert tools[0].name == "lookup" + def test_unknown_tool_raises_with_guidance(self): from crewai.project.json_loader import JSONProjectError, _resolve_tools with pytest.raises(JSONProjectError, match="Unknown tool 'NotARealToolXYZ'"): _resolve_tools(["NotARealToolXYZ"]) + def test_import_ref_tool_must_resolve_to_basetool_class( + self, tmp_path, monkeypatch + ): + from crewai.project.json_loader import JSONProjectError, _resolve_tools + + (tmp_path / "not_tools.py").write_text( + "class NotATool:\n" + " pass\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + + with pytest.raises(JSONProjectError, match="expected a BaseTool class"): + _resolve_tools(["not_tools:NotATool"]) + + def test_unresolvable_import_ref_tool_raises_guidance(self): + from crewai.project.json_loader import JSONProjectError, _resolve_tools + + with pytest.raises(JSONProjectError, match="unresolvable tool ref"): + _resolve_tools(["not_a_real_module:MissingTool"]) + def test_missing_custom_tool_raises(self, tmp_path, monkeypatch): from crewai.project.json_loader import JSONProjectError, _resolve_tools @@ -505,6 +545,30 @@ class TestValidationDoesNotExecuteTools: assert not sentinel.exists(), "validation must not import Python refs" + def test_validate_does_not_import_tool_refs( + self, tmp_path, monkeypatch: pytest.MonkeyPatch + ): + from crewai.project.json_loader import validate_crew_project + + sentinel = tmp_path / "tool_ref_executed.txt" + (tmp_path / "project_tools.py").write_text( + "from pathlib import Path\n" + f"Path({str(sentinel)!r}).write_text('boom')\n" + "from crewai.tools.base_tool import BaseTool\n" + "class LookupTool(BaseTool):\n" + " name: str = 'lookup'\n" + " description: str = 'lookup input'\n" + " def _run(self, text: str) -> str:\n" + " return text\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + sys.modules.pop("project_tools", None) + crew_path = self._write_project(tmp_path, tool_line='"project_tools:LookupTool"') + + validate_crew_project(crew_path, tmp_path / "agents") + + assert not sentinel.exists(), "validation must not import tool refs" + def test_validate_reports_missing_custom_tool_file(self, tmp_path): from crewai.project.json_loader import ( JSONProjectValidationError, From 340d23ae5d2bd75424a787d8802395931887295b Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Wed, 24 Jun 2026 16:37:51 -0700 Subject: [PATCH 19/22] Remove `StateProxy` from flow state access (#6327) `StateProxy` looked like a thread-safety boundary, but it only protected a small slice of state operations. Some examples of operations that were not covered: - `self.state.counter += 1`, `self.state["counter"] += 1` (increments) - `self.state.user.profile.score += 1` (nested object mutations) - `self.state.config["limits"]["max"] = 10` (mutation through model fields) - `self.state.items[0].status = "done"` (list/container mutations) This commit decided to remove it completely for simplicity and performance: - Simpler runtime code - attr read: 24x faster, attr write: 27x faster, list append: 19x faster (local benchmark) - Clearer concurrency contract (lifecycle locks remain, but arbitrary shared state mutation is not presented as thread-safe) --- .../src/crewai/experimental/agent_executor.py | 7 +- lib/crewai/src/crewai/flow/flow.py | 6 - .../src/crewai/flow/runtime/__init__.py | 313 +----------------- .../tests/agents/test_agent_executor.py | 3 +- lib/crewai/tests/test_flow.py | 40 +-- lib/crewai/tests/test_flow_conversation.py | 2 - lib/crewai/tests/test_flow_from_definition.py | 3 +- lib/crewai/tests/test_flow_persistence.py | 2 +- 8 files changed, 25 insertions(+), 351 deletions(-) diff --git a/lib/crewai/src/crewai/experimental/agent_executor.py b/lib/crewai/src/crewai/experimental/agent_executor.py index 303330dc6..0ecd8e63a 100644 --- a/lib/crewai/src/crewai/experimental/agent_executor.py +++ b/lib/crewai/src/crewai/experimental/agent_executor.py @@ -54,7 +54,7 @@ from crewai.events.types.tool_usage_events import ( ToolUsageFinishedEvent, ToolUsageStartedEvent, ) -from crewai.flow.flow import Flow, StateProxy, listen, or_, router, start +from crewai.flow.flow import Flow, listen, or_, router, start from crewai.flow.types import FlowMethodName from crewai.hooks.llm_hooks import ( get_after_llm_call_hooks, @@ -276,11 +276,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): """ return self.llm.supports_stop_words() if self.llm else False - @property - def state(self) -> AgentExecutorState: - """Get thread-safe state proxy.""" - return StateProxy(self._state, self._state_lock) # type: ignore[return-value] - @property # type: ignore[misc] def iterations(self) -> int: """Compatibility property for mixin - returns state iterations.""" diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py index 19c161ffb..8635e2953 100644 --- a/lib/crewai/src/crewai/flow/flow.py +++ b/lib/crewai/src/crewai/flow/flow.py @@ -24,9 +24,6 @@ from crewai.flow.runtime import ( Flow as RuntimeFlow, FlowMeta, FlowState, - LockedDictProxy, - LockedListProxy, - StateProxy, ) @@ -42,9 +39,6 @@ __all__ = [ "Flow", "FlowMeta", "FlowState", - "LockedDictProxy", - "LockedListProxy", - "StateProxy", "and_", "listen", "or_", diff --git a/lib/crewai/src/crewai/flow/runtime/__init__.py b/lib/crewai/src/crewai/flow/runtime/__init__.py index 4bb67a269..8de5be409 100644 --- a/lib/crewai/src/crewai/flow/runtime/__init__.py +++ b/lib/crewai/src/crewai/flow/runtime/__init__.py @@ -1,8 +1,8 @@ """Flow Runtime: the engine that executes a Flow. -Provides the ``Flow`` class (kickoff/resume/listener dispatch), the -``FlowMeta`` metaclass, and the thread-safe state proxies. Flows -authored with the Python DSL (see ``dsl``) are described by a Flow +Provides the ``Flow`` class (kickoff/resume/listener dispatch) and the +``FlowMeta`` metaclass. Flows authored with the Python DSL (see ``dsl``) +are described by a Flow Structure (see ``flow_definition``) and executed here. """ @@ -11,12 +11,8 @@ from __future__ import annotations import asyncio from collections.abc import ( Callable, - ItemsView, - Iterable, Iterator, - KeysView, Sequence, - ValuesView, ) from concurrent.futures import Future, ThreadPoolExecutor import contextvars @@ -35,10 +31,8 @@ from typing import ( Generic, Literal, ParamSpec, - SupportsIndex, TypeVar, cast, - overload, ) from uuid import uuid4 @@ -383,304 +377,6 @@ R = TypeVar("R") F = TypeVar("F", bound=Callable[..., Any]) -class LockedListProxy(list, Generic[T]): # type: ignore[type-arg] - """Thread-safe proxy for list operations. - - Subclasses ``list`` so that ``isinstance(proxy, list)`` returns True, - which is required by libraries like LanceDB and Pydantic that do strict - type checks. All mutations go through the lock; reads delegate to the - underlying list. - """ - - def __init__(self, lst: list[T], lock: threading.Lock) -> None: - super().__init__() # empty builtin list; all access goes through self._list - self._list = lst - self._lock = lock - - def append(self, item: T) -> None: - with self._lock: - self._list.append(item) - - def extend(self, items: Iterable[T]) -> None: - with self._lock: - self._list.extend(items) - - def insert(self, index: SupportsIndex, item: T) -> None: - with self._lock: - self._list.insert(index, item) - - def remove(self, item: T) -> None: - with self._lock: - self._list.remove(item) - - def pop(self, index: SupportsIndex = -1) -> T: - with self._lock: - return self._list.pop(index) - - def clear(self) -> None: - with self._lock: - self._list.clear() - - @overload - def __setitem__(self, index: SupportsIndex, value: T) -> None: ... - @overload - def __setitem__(self, index: slice, value: Iterable[T]) -> None: ... - def __setitem__(self, index: Any, value: Any) -> None: - with self._lock: - self._list[index] = value - - def __delitem__(self, index: SupportsIndex | slice) -> None: - with self._lock: - del self._list[index] - - @overload - def __getitem__(self, index: SupportsIndex) -> T: ... - @overload - def __getitem__(self, index: slice) -> list[T]: ... - def __getitem__(self, index: Any) -> Any: - return self._list[index] - - def __len__(self) -> int: - return len(self._list) - - def __iter__(self) -> Iterator[T]: - return iter(self._list) - - def __contains__(self, item: object) -> bool: - return item in self._list - - def __repr__(self) -> str: - return repr(self._list) - - def __bool__(self) -> bool: - return bool(self._list) - - def index( - self, value: T, start: SupportsIndex = 0, stop: SupportsIndex | None = None - ) -> int: - if stop is None: - return self._list.index(value, start) - return self._list.index(value, start, stop) - - def count(self, value: T) -> int: - return self._list.count(value) - - def sort(self, *, key: Any = None, reverse: bool = False) -> None: - with self._lock: - self._list.sort(key=key, reverse=reverse) - - def reverse(self) -> None: - with self._lock: - self._list.reverse() - - def copy(self) -> list[T]: - return self._list.copy() - - def __add__(self, other: list[T]) -> list[T]: # type: ignore[override] - return self._list + other - - def __radd__(self, other: list[T]) -> list[T]: - return other + self._list - - def __iadd__(self, other: Iterable[T]) -> LockedListProxy[T]: # type: ignore[override] - with self._lock: - self._list += list(other) - return self - - def __mul__(self, n: SupportsIndex) -> list[T]: - return self._list * n - - def __rmul__(self, n: SupportsIndex) -> list[T]: - return self._list * n - - def __imul__(self, n: SupportsIndex) -> LockedListProxy[T]: - with self._lock: - self._list *= n - return self - - def __reversed__(self) -> Iterator[T]: - return reversed(self._list) - - def __eq__(self, other: object) -> bool: - """Compare based on the underlying list contents.""" - if isinstance(other, LockedListProxy): - # Avoid deadlocks by acquiring locks in a consistent order. - first, second = (self, other) if id(self) <= id(other) else (other, self) - with first._lock: - with second._lock: - return first._list == second._list - with self._lock: - return self._list == other - - def __ne__(self, other: object) -> bool: - return not self.__eq__(other) - - -class LockedDictProxy(dict, Generic[T]): # type: ignore[type-arg] - """Thread-safe proxy for dict operations. - - Subclasses ``dict`` so that ``isinstance(proxy, dict)`` returns True, - which is required by libraries like Pydantic that do strict type checks. - All mutations go through the lock; reads delegate to the underlying dict. - """ - - def __init__(self, d: dict[str, T], lock: threading.Lock) -> None: - super().__init__() # empty builtin dict; all access goes through self._dict - self._dict = d - self._lock = lock - - def __setitem__(self, key: str, value: T) -> None: - with self._lock: - self._dict[key] = value - - def __delitem__(self, key: str) -> None: - with self._lock: - del self._dict[key] - - def pop(self, key: str, *default: T) -> T: # type: ignore[override] - with self._lock: - return self._dict.pop(key, *default) - - def update(self, other: dict[str, T]) -> None: # type: ignore[override] - with self._lock: - self._dict.update(other) - - def clear(self) -> None: - with self._lock: - self._dict.clear() - - def setdefault(self, key: str, default: T) -> T: # type: ignore[override] - with self._lock: - return self._dict.setdefault(key, default) - - def __getitem__(self, key: str) -> T: - return self._dict[key] - - def __len__(self) -> int: - return len(self._dict) - - def __iter__(self) -> Iterator[str]: - return iter(self._dict) - - def __contains__(self, key: object) -> bool: - return key in self._dict - - def keys(self) -> KeysView[str]: # type: ignore[override] - return self._dict.keys() - - def values(self) -> ValuesView[T]: # type: ignore[override] - return self._dict.values() - - def items(self) -> ItemsView[str, T]: # type: ignore[override] - return self._dict.items() - - def get(self, key: str, default: T | None = None) -> T | None: # type: ignore[override] - return self._dict.get(key, default) - - def __repr__(self) -> str: - return repr(self._dict) - - def __bool__(self) -> bool: - return bool(self._dict) - - def copy(self) -> dict[str, T]: - return self._dict.copy() - - def __or__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override] - return self._dict | other - - def __ror__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override] - return other | self._dict - - def __ior__(self, other: dict[str, T]) -> LockedDictProxy[T]: # type: ignore[override] - with self._lock: - self._dict |= other - return self - - def __reversed__(self) -> Iterator[str]: - return reversed(self._dict) - - def __eq__(self, other: object) -> bool: - """Compare based on the underlying dict contents.""" - if isinstance(other, LockedDictProxy): - # Avoid deadlocks by acquiring locks in a consistent order. - first, second = (self, other) if id(self) <= id(other) else (other, self) - with first._lock: - with second._lock: - return first._dict == second._dict - with self._lock: - return self._dict == other - - def __ne__(self, other: object) -> bool: - return not self.__eq__(other) - - -class StateProxy(Generic[T]): - """Proxy that provides thread-safe access to flow state. - - Wraps state objects (dict or BaseModel) and uses a lock for all write - operations to prevent race conditions when parallel listeners modify state. - """ - - __slots__ = ("_proxy_lock", "_proxy_state") - - def __init__(self, state: T, lock: threading.Lock) -> None: - object.__setattr__(self, "_proxy_state", state) - object.__setattr__(self, "_proxy_lock", lock) - - def __getattr__(self, name: str) -> Any: - value = getattr(object.__getattribute__(self, "_proxy_state"), name) - lock = object.__getattribute__(self, "_proxy_lock") - if isinstance(value, list): - return LockedListProxy(value, lock) - if isinstance(value, dict): - return LockedDictProxy(value, lock) - return value - - def __setattr__(self, name: str, value: Any) -> None: - if name in ("_proxy_state", "_proxy_lock"): - object.__setattr__(self, name, value) - else: - if isinstance(value, LockedListProxy): - value = value._list - elif isinstance(value, LockedDictProxy): - value = value._dict - with object.__getattribute__(self, "_proxy_lock"): - setattr(object.__getattribute__(self, "_proxy_state"), name, value) - - def __getitem__(self, key: str) -> Any: - return object.__getattribute__(self, "_proxy_state")[key] - - def __setitem__(self, key: str, value: Any) -> None: - with object.__getattribute__(self, "_proxy_lock"): - object.__getattribute__(self, "_proxy_state")[key] = value - - def __delitem__(self, key: str) -> None: - with object.__getattribute__(self, "_proxy_lock"): - del object.__getattribute__(self, "_proxy_state")[key] - - def __contains__(self, key: str) -> bool: - return key in object.__getattribute__(self, "_proxy_state") - - def __repr__(self) -> str: - return repr(object.__getattribute__(self, "_proxy_state")) - - def _unwrap(self) -> T: - """Return the underlying state object.""" - return cast(T, object.__getattribute__(self, "_proxy_state")) - - def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: - """Return state as a dictionary. - - Works for both dict and BaseModel underlying states. - """ - state = object.__getattribute__(self, "_proxy_state") - if isinstance(state, dict): - return state - result: dict[str, Any] = state.model_dump(*args, **kwargs) - return result - - class FlowMeta(ModelMetaclass): def __new__( mcs, @@ -1025,7 +721,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): ) _method_outputs: list[Any] = PrivateAttr(default_factory=list) _definition: FlowDefinition = PrivateAttr() - _state_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) _or_listeners_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) _completed_methods: set[FlowMethodName] = PrivateAttr(default_factory=set) _method_call_counts: dict[FlowMethodName, int] = PrivateAttr(default_factory=dict) @@ -1947,7 +1642,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta): @property def state(self) -> T: - return StateProxy(self._state, self._state_lock) # type: ignore[return-value] + return cast(T, self._state) @property def method_outputs(self) -> list[Any]: diff --git a/lib/crewai/tests/agents/test_agent_executor.py b/lib/crewai/tests/agents/test_agent_executor.py index 992f7460b..e4de4a484 100644 --- a/lib/crewai/tests/agents/test_agent_executor.py +++ b/lib/crewai/tests/agents/test_agent_executor.py @@ -7,6 +7,7 @@ flow methods, routing logic, and error handling. from __future__ import annotations import asyncio +import threading from types import SimpleNamespace import time from typing import Any @@ -39,8 +40,6 @@ def _build_executor(**kwargs: Any) -> AgentExecutor: executor._human_feedback_method_outputs = {} executor._input_history = [] executor._is_execution_resuming = False - import threading - executor._state_lock = threading.Lock() executor._or_listeners_lock = threading.Lock() executor._execution_lock = threading.Lock() executor._finalize_lock = threading.Lock() diff --git a/lib/crewai/tests/test_flow.py b/lib/crewai/tests/test_flow.py index d0d0045b9..4b8a66671 100644 --- a/lib/crewai/tests/test_flow.py +++ b/lib/crewai/tests/test_flow.py @@ -1510,42 +1510,36 @@ def test_conditional_router_events_exclusivity(): assert "handle_event_c" not in execution_order -def test_state_consistency_across_parallel_branches(): - """Test that state remains consistent when branches execute in parallel. +def test_and_join_waits_for_parallel_branches(): + """Test that sibling branches complete before a joined listener runs. - Note: Branches triggered by the same parent execute in parallel for efficiency. - Thread-safe state access via StateProxy ensures no race conditions. - We check the execution order to ensure the branches execute in parallel. + Branches triggered by the same parent execute in parallel for efficiency. + Shared state updates are not guaranteed to be atomic, so this test uses a + locked local recorder instead of branch state mutation. """ execution_order = [] + execution_order_lock = threading.Lock() + + def record(method_name: str) -> None: + with execution_order_lock: + execution_order.append(method_name) class StateConsistencyFlow(Flow): - def __init__(self): - super().__init__() - self.state["counter"] = 0 - self.state["branch_a_value"] = None - self.state["branch_b_value"] = None - @start() def init(self): - execution_order.append("init") - self.state["counter"] = 10 + record("init") @listen(init) def branch_a(self): - execution_order.append("branch_a") - self.state["branch_a_value"] = self.state["counter"] - self.state["counter"] += 1 + record("branch_a") @listen(init) def branch_b(self): - execution_order.append("branch_b") - self.state["branch_b_value"] = self.state["counter"] - self.state["counter"] += 5 + record("branch_b") @listen(and_(branch_a, branch_b)) def verify_state(self): - execution_order.append("verify_state") + record("verify_state") flow = StateConsistencyFlow() flow.kickoff() @@ -1554,10 +1548,8 @@ def test_state_consistency_across_parallel_branches(): assert "branch_b" in execution_order assert "verify_state" in execution_order - assert flow.state["branch_a_value"] is not None - assert flow.state["branch_b_value"] is not None - - assert flow.state["counter"] == 16 + assert execution_order.index("branch_a") < execution_order.index("verify_state") + assert execution_order.index("branch_b") < execution_order.index("verify_state") def test_deeply_nested_conditions(): diff --git a/lib/crewai/tests/test_flow_conversation.py b/lib/crewai/tests/test_flow_conversation.py index 3fea6b471..d8cc0bd37 100644 --- a/lib/crewai/tests/test_flow_conversation.py +++ b/lib/crewai/tests/test_flow_conversation.py @@ -928,8 +928,6 @@ class TestConversationalFlow: conversational = True flow = BareChat() - # ``flow.state`` returns a ``StateProxy``; the underlying state is - # on ``flow._state``. Both views expose the same chat-shaped fields. assert isinstance(flow._state, ConversationState) assert flow.state.messages == [] assert flow.state.current_user_message is None diff --git a/lib/crewai/tests/test_flow_from_definition.py b/lib/crewai/tests/test_flow_from_definition.py index 16160f3cf..0c822c483 100644 --- a/lib/crewai/tests/test_flow_from_definition.py +++ b/lib/crewai/tests/test_flow_from_definition.py @@ -466,7 +466,8 @@ def _run_with_events(flow, inputs=None): def _state_without_id(flow): - snapshot = dict(flow.state.model_dump()) + state = flow.state + snapshot = dict(state if isinstance(state, dict) else state.model_dump()) snapshot.pop("id", None) return snapshot diff --git a/lib/crewai/tests/test_flow_persistence.py b/lib/crewai/tests/test_flow_persistence.py index e5331f7c0..b405cc64d 100644 --- a/lib/crewai/tests/test_flow_persistence.py +++ b/lib/crewai/tests/test_flow_persistence.py @@ -233,7 +233,7 @@ def test_persistence_with_base_model(tmp_path): assert message.role == "user" assert message.type == "text" assert message.content == "Hello, World!" - assert isinstance(flow.state._unwrap(), State) + assert isinstance(flow.state, State) def test_fork_with_restore_from_state_id(tmp_path): From 563b55f7ca8dd11f0e65bc3399c8f3e29a78cafb Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Wed, 24 Jun 2026 17:25:08 -0700 Subject: [PATCH 20/22] feat: bump versions to 1.14.8a5 (#6328) --- lib/cli/pyproject.toml | 2 +- lib/cli/src/crewai_cli/__init__.py | 2 +- lib/cli/src/crewai_cli/templates/crew/pyproject.toml | 2 +- .../crewai_cli/templates/declarative_flow/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/flow/pyproject.toml | 2 +- lib/cli/src/crewai_cli/templates/tool/pyproject.toml | 2 +- lib/crewai-core/src/crewai_core/__init__.py | 2 +- lib/crewai-files/src/crewai_files/__init__.py | 2 +- lib/crewai-tools/pyproject.toml | 2 +- lib/crewai-tools/src/crewai_tools/__init__.py | 2 +- lib/crewai/pyproject.toml | 6 +++--- lib/crewai/src/crewai/__init__.py | 2 +- lib/devtools/src/crewai_devtools/__init__.py | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/cli/pyproject.toml b/lib/cli/pyproject.toml index 1b04f4f32..320442750 100644 --- a/lib/cli/pyproject.toml +++ b/lib/cli/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.8a4", + "crewai-core==1.14.8a5", "click>=8.1.7,<9", "pydantic>=2.11.9,<2.13", "pydantic-settings~=2.10.1", diff --git a/lib/cli/src/crewai_cli/__init__.py b/lib/cli/src/crewai_cli/__init__.py index 928b412af..b47cad7ab 100644 --- a/lib/cli/src/crewai_cli/__init__.py +++ b/lib/cli/src/crewai_cli/__init__.py @@ -1 +1 @@ -__version__ = "1.14.8a4" +__version__ = "1.14.8a5" diff --git a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml b/lib/cli/src/crewai_cli/templates/crew/pyproject.toml index 258c654a2..666d71f8a 100644 --- a/lib/cli/src/crewai_cli/templates/crew/pyproject.toml +++ b/lib/cli/src/crewai_cli/templates/crew/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.14.8a4" + "crewai[tools]==1.14.8a5" ] [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 a0ac1fb79..986f21873 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.14.8a4" + "crewai[tools]==1.14.8a5" ] [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 c825051c5..ddb387be4 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.14.8a4" + "crewai[tools]==1.14.8a5" ] [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 19534dff8..4df1533b3 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.14.8a4" + "crewai[tools]==1.14.8a5" ] [tool.crewai] diff --git a/lib/crewai-core/src/crewai_core/__init__.py b/lib/crewai-core/src/crewai_core/__init__.py index 928b412af..b47cad7ab 100644 --- a/lib/crewai-core/src/crewai_core/__init__.py +++ b/lib/crewai-core/src/crewai_core/__init__.py @@ -1 +1 @@ -__version__ = "1.14.8a4" +__version__ = "1.14.8a5" diff --git a/lib/crewai-files/src/crewai_files/__init__.py b/lib/crewai-files/src/crewai_files/__init__.py index 2c9bac458..70165a462 100644 --- a/lib/crewai-files/src/crewai_files/__init__.py +++ b/lib/crewai-files/src/crewai_files/__init__.py @@ -152,4 +152,4 @@ __all__ = [ "wrap_file_source", ] -__version__ = "1.14.8a4" +__version__ = "1.14.8a5" diff --git a/lib/crewai-tools/pyproject.toml b/lib/crewai-tools/pyproject.toml index e6eac89c1..3dd5faf58 100644 --- a/lib/crewai-tools/pyproject.toml +++ b/lib/crewai-tools/pyproject.toml @@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14" dependencies = [ "pytube~=15.0.0", "requests>=2.33.0,<3", - "crewai==1.14.8a4", + "crewai==1.14.8a5", "tiktoken>=0.8.0,<0.13", "beautifulsoup4~=4.13.4", "python-docx~=1.2.0", diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 65c3a5d87..c4cfbab33 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -330,4 +330,4 @@ __all__ = [ "ZapierActionTools", ] -__version__ = "1.14.8a4" +__version__ = "1.14.8a5" diff --git a/lib/crewai/pyproject.toml b/lib/crewai/pyproject.toml index 2cbaa1cb3..a5751278b 100644 --- a/lib/crewai/pyproject.toml +++ b/lib/crewai/pyproject.toml @@ -8,8 +8,8 @@ authors = [ ] requires-python = ">=3.10, <3.14" dependencies = [ - "crewai-core==1.14.8a4", - "crewai-cli==1.14.8a4", + "crewai-core==1.14.8a5", + "crewai-cli==1.14.8a5", # Core Dependencies "pydantic>=2.11.9,<2.13", "openai>=2.30.0,<3", @@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI" [project.optional-dependencies] tools = [ - "crewai-tools==1.14.8a4", + "crewai-tools==1.14.8a5", ] embeddings = [ "tiktoken>=0.8.0,<0.13" diff --git a/lib/crewai/src/crewai/__init__.py b/lib/crewai/src/crewai/__init__.py index b49b462e8..3b9745160 100644 --- a/lib/crewai/src/crewai/__init__.py +++ b/lib/crewai/src/crewai/__init__.py @@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None: _suppress_pydantic_deprecation_warnings() -__version__ = "1.14.8a4" +__version__ = "1.14.8a5" _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "Memory": ("crewai.memory.unified_memory", "Memory"), diff --git a/lib/devtools/src/crewai_devtools/__init__.py b/lib/devtools/src/crewai_devtools/__init__.py index bbffc72b1..086c71f4f 100644 --- a/lib/devtools/src/crewai_devtools/__init__.py +++ b/lib/devtools/src/crewai_devtools/__init__.py @@ -1,3 +1,3 @@ """CrewAI development tools.""" -__version__ = "1.14.8a4" +__version__ = "1.14.8a5" From 178c2d212cfc27c08653638ba66d4e6648ebd0d5 Mon Sep 17 00:00:00 2001 From: Vinicius Brasil Date: Wed, 24 Jun 2026 17:31:32 -0700 Subject: [PATCH 21/22] docs: snapshot and changelog for v1.14.8a5 (#6329) --- docs/edge/ar/changelog.mdx | 26 ++++++++++++++++++++++++++ docs/edge/en/changelog.mdx | 26 ++++++++++++++++++++++++++ docs/edge/ko/changelog.mdx | 26 ++++++++++++++++++++++++++ docs/edge/pt-BR/changelog.mdx | 26 ++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/docs/edge/ar/changelog.mdx b/docs/edge/ar/changelog.mdx index ee5c31b14..dab6bebce 100644 --- a/docs/edge/ar/changelog.mdx +++ b/docs/edge/ar/changelog.mdx @@ -4,6 +4,32 @@ description: "تحديثات المنتج والتحسينات وإصلاحات icon: "clock" mode: "wide" --- + + ## v1.14.8a5 + + [عرض الإصدار على GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a5) + + ## ما الذي تغير + + ### الميزات + - جعل المراجع التصريحية تعمل عبر التدفقات والفرق (#6326) + + ### إصلاحات الأخطاء + - إصلاح مدخلات بدء حالة تدفق مخطط JSON (#6325) + + ### الوثائق + - وضع بطاقة واحدة لكل خطوة تحت استوديو الفريق وإزالة لافتة التوزيع (AGE-107) (#6317) + - تحديث اللقطة وسجل التغييرات للإصدار v1.14.8a4 (#6319) + + ### إعادة الهيكلة + - إزالة `StateProxy` من الوصول إلى حالة التدفق (#6327) + + ## المساهمون + + @jessemiller, @vinibrsl + + + ## v1.14.8a4 diff --git a/docs/edge/en/changelog.mdx b/docs/edge/en/changelog.mdx index 5c741a965..beb43c4c3 100644 --- a/docs/edge/en/changelog.mdx +++ b/docs/edge/en/changelog.mdx @@ -4,6 +4,32 @@ description: "Product updates, improvements, and bug fixes for CrewAI" icon: "clock" mode: "wide" --- + + ## v1.14.8a5 + + [View release on GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a5) + + ## What's Changed + + ### Features + - Make declarative refs work across flows and crews (#6326) + + ### Bug Fixes + - Fix JSON schema flow state kickoff inputs (#6325) + + ### Documentation + - Nest One Card per Step under Crew Studio and drop rollout banner (AGE-107) (#6317) + - Update snapshot and changelog for v1.14.8a4 (#6319) + + ### Refactoring + - Remove `StateProxy` from flow state access (#6327) + + ## Contributors + + @jessemiller, @vinibrsl + + + ## v1.14.8a4 diff --git a/docs/edge/ko/changelog.mdx b/docs/edge/ko/changelog.mdx index 091b559bb..9488f8335 100644 --- a/docs/edge/ko/changelog.mdx +++ b/docs/edge/ko/changelog.mdx @@ -4,6 +4,32 @@ description: "CrewAI의 제품 업데이트, 개선 사항 및 버그 수정" icon: "clock" mode: "wide" --- + + ## v1.14.8a5 + + [GitHub 릴리스 보기](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a5) + + ## 변경 사항 + + ### 기능 + - 선언적 참조가 흐름과 크루 간에 작동하도록 수정 (#6326) + + ### 버그 수정 + - JSON 스키마 흐름 상태 시작 입력 수정 (#6325) + + ### 문서 + - Crew Studio 아래에 단계별 One Card를 중첩하고 롤아웃 배너 제거 (AGE-107) (#6317) + - v1.14.8a4의 스냅샷 및 변경 로그 업데이트 (#6319) + + ### 리팩토링 + - 흐름 상태 접근에서 `StateProxy` 제거 (#6327) + + ## 기여자 + + @jessemiller, @vinibrsl + + + ## v1.14.8a4 diff --git a/docs/edge/pt-BR/changelog.mdx b/docs/edge/pt-BR/changelog.mdx index d03bce6fb..32adbc9a1 100644 --- a/docs/edge/pt-BR/changelog.mdx +++ b/docs/edge/pt-BR/changelog.mdx @@ -4,6 +4,32 @@ description: "Atualizações de produto, melhorias e correções do CrewAI" icon: "clock" mode: "wide" --- + + ## v1.14.8a5 + + [Ver release no GitHub](https://github.com/crewAIInc/crewAI/releases/tag/1.14.8a5) + + ## O que mudou + + ### Recursos + - Fazer referências declarativas funcionarem em diferentes fluxos e equipes (#6326) + + ### Correções de Bugs + - Corrigir entradas de kickoff de estado de fluxo do esquema JSON (#6325) + + ### Documentação + - Aninhar One Card por Etapa sob Crew Studio e remover banner de rollout (AGE-107) (#6317) + - Atualizar snapshot e changelog para v1.14.8a4 (#6319) + + ### Refatoração + - Remover `StateProxy` do acesso ao estado de fluxo (#6327) + + ## Contribuidores + + @jessemiller, @vinibrsl + + + ## v1.14.8a4 From 01fc389d4ab1f5bb020feec4152873d673a4286e Mon Sep 17 00:00:00 2001 From: Rip&Tear <84775494+theCyberTech@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:52:33 +0800 Subject: [PATCH 22/22] Restrict docs broken-links workflow permissions (#6330) --- .github/workflows/docs-broken-links.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docs-broken-links.yml b/.github/workflows/docs-broken-links.yml index 38ca499a4..e1b9aeb0e 100644 --- a/.github/workflows/docs-broken-links.yml +++ b/.github/workflows/docs-broken-links.yml @@ -13,6 +13,9 @@ on: - "docs.json" workflow_dispatch: +permissions: + contents: read + jobs: check-links: name: Check broken links