Compare commits

..

6 Commits

Author SHA1 Message Date
Vinicius Brasil
0bf76aac35 Address code review comments 2026-06-26 11:24:20 -07:00
Vinicius Brasil
1ad27d6235 Require explicit CrewAI project definitions
JSON crews and declarative flows now resolve from `[tool.crewai]`
metadata instead of implicit filename discovery. This makes project type
selection deterministic, prevents stray `crew.json(c)` files from changing
CLI behavior, and centralizes definition path validation for run, install,
deploy validation, plotting, and memory reset paths.

`[tool.crewai].definition` must be a project-local file path. Absolute
paths, `~`, missing files, directories, and paths escaping the project root
are rejected so deploy and runtime commands use the same contract.

Breaking changes and migration paths:

* JSON crew projects are no longer discovered from `crew.json` or
  `crew.jsonc` alone. Add explicit metadata:

  ```toml
  [tool.crewai]
  type = "crew"
  definition = "crew.jsonc"
  ```

* Declarative flow projects must use a valid project-local definition path:

  ```toml
  [tool.crewai]
  type = "flow"
  definition = "flows/research.yaml"
  ```

* `Flow.from_definition(definition)` is removed. Use:

  ```python
  Flow.from_declaration(contents=definition)
  ```

* `FlowDefinition.to_json()` and `FlowDefinition.to_yaml()` are removed.
  Use `FlowDefinition.to_dict()` and serialize with the caller's JSON or
  YAML library.

* `FlowDefinition.from_dict()` is removed. Use:

  ```python
  FlowDefinition.from_declaration(contents=data)
  ```

* `FlowDefinition.json_schema()` is removed. Use Pydantic's schema API only
  where schema generation is intentionally needed:

  ```python
  FlowDefinition.model_json_schema(by_alias=True)
  ```

* `crewai_cli.run_crew.find_crew_json_file()` and `_has_json_crew()` are
  removed. Use `configured_project_json_crew()` or the shared
  `crewai_core.project.configured_project_definition("crew")` helper.

* `crewai reset-memories` now only loads JSON crews declared through
  `[tool.crewai].definition`, and invalid declared JSON crew definitions
  fail instead of silently falling back to classic crew discovery.
2026-06-26 11:24:20 -07:00
João Moura
e10c17fcf6 Open deployment page after CLI deploy (#6343)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run
* Open deployment page after CLI deploy

* Format deploy browser URL helper

* Handle browser launch failures

* Prefer nested deployment identifiers
2026-06-26 14:34:07 -03:00
João Moura
f364a7d988 Fix JSON crew version pin (#6342)
Some checks failed
Check Documentation Broken Links / Check broken links (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
* Fix JSON crew version pin

* Use bounded CrewAI dependency range
2026-06-26 05:19:14 -03:00
João Moura
2771c02f45 docs: improve coding agent setup CTA (#6344)
* docs: improve coding agent setup CTA

* docs: move home CTA to published index

* docs: address CTA review feedback
2026-06-26 05:10:55 -03:00
Rip&Tear
5d4851eac7 Fix SSRF redirect bypass in scraping fetches (#6331)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
* Validate redirects for scraping URL fetches

* Prevent credential forwarding across redirects
2026-06-25 17:42:49 -07:00
43 changed files with 1444 additions and 534 deletions

View File

@@ -5,15 +5,49 @@ icon: wrench
mode: "wide"
---
### Watch: Building CrewAI Agents & Flows with Coding Agent Skills
<div
style={{
display: "flex",
flexDirection: "column",
gap: 18,
padding: "24px",
marginBottom: 32,
borderRadius: 12,
border: "1px solid rgba(235,102,88,0.28)",
background: "linear-gradient(180deg, rgba(235,102,88,0.14) 0%, rgba(235,102,88,0.06) 100%)"
}}
>
<div>
<p style={{ margin: 0, color: "#EB6658", fontSize: 13, fontWeight: 700, textTransform: "uppercase" }}>
Coding agent setup
</p>
<h2 style={{ margin: "6px 0 8px" }}>Set up CrewAI in your coding agent</h2>
<p style={{ margin: 0, color: "var(--mint-text-2)", maxWidth: 760 }}>
Copy a ready-to-paste setup prompt for Claude Code, Codex, Cursor, or any coding agent. It installs the official CrewAI skills, checks the CLI, and points the agent at the right docs before it edits code.
</p>
</div>
Install our coding agent skills (Claude Code, Codex, ...) to quickly get your coding agents up and running with CrewAI.
<button
type="button"
className="button button-primary"
onClick={async (event) => {
const prompt = `Set up this environment so I can build with CrewAI.
<div style={{ display: "flex", flexWrap: "wrap", gap: 12, alignItems: "center" }}>
<button
type="button"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minHeight: 42,
padding: "0 16px",
borderRadius: 8,
border: "1px solid #EB6658",
background: "#EB6658",
color: "#FFFFFF",
fontSize: 15,
fontWeight: 700,
lineHeight: 1,
cursor: "pointer",
boxShadow: "0 10px 24px rgba(235,102,88,0.22)"
}}
onClick={async (event) => {
const prompt = `Set up this environment so I can build with CrewAI.
First install the official CrewAI coding-agent skills if this environment supports npx:
@@ -48,21 +82,49 @@ Setup steps:
Do not hardcode API keys. Use .env.
Do not invent CLI flags. Validate with crewai --help or crewai create --help.
If a command fails, show the exact command and error, explain the likely cause, fix what you can safely fix, and retry once.`;
const button = event.currentTarget;
try {
await navigator.clipboard.writeText(prompt);
button.textContent = "Copied";
} catch {
button.textContent = "Copy failed";
} finally {
window.setTimeout(() => {
button.textContent = "Copy instructions for coding agents";
}, 1600);
}
}}
>
Copy instructions for coding agents
</button>
const button = event.currentTarget;
const resetTimeout = button.dataset.resetTimeout;
if (resetTimeout) {
window.clearTimeout(Number(resetTimeout));
}
try {
await navigator.clipboard.writeText(prompt);
button.textContent = "Copied";
} catch {
button.textContent = "Copy failed";
} finally {
button.dataset.resetTimeout = String(window.setTimeout(() => {
button.textContent = "Copy agent setup prompt";
delete button.dataset.resetTimeout;
}, 1600));
}
}}
>
Copy agent setup prompt
</button>
<a
href="/en/guides/coding-tools/build-with-ai"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minHeight: 42,
padding: "0 16px",
borderRadius: 8,
border: "1px solid rgba(235,102,88,0.36)",
color: "#EB6658",
fontSize: 15,
fontWeight: 700,
lineHeight: 1,
textDecoration: "none"
}}
>
View coding-agent guide
</a>
</div>
</div>
### Watch: Building CrewAI Agents & Flows with Coding Agent Skills
<iframe src="https://www.loom.com/embed/befb9f68b81f42ad8112bfdd95a780af" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style={{width: "100%", height: "400px"}}></iframe>

View File

@@ -27,9 +27,133 @@ mode: "wide"
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center' }}>
<a className="button button-primary" href="/en/quickstart">Get started</a>
<a className="button" href="/en/changelog">View changelog</a>
<a className="button" href="/en/api-reference/introduction">API Reference</a>
<a
href="/en/quickstart"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minHeight: 42,
padding: "0 16px",
borderRadius: 8,
border: "1px solid #EB6658",
background: "#EB6658",
color: "#FFFFFF",
fontWeight: 700,
lineHeight: 1,
textDecoration: "none"
}}
>
Get started
</a>
<button
type="button"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minHeight: 42,
padding: "0 16px",
borderRadius: 8,
border: "1px solid rgba(235,102,88,0.36)",
background: "rgba(235,102,88,0.08)",
color: "#EB6658",
fontWeight: 700,
lineHeight: 1,
cursor: "pointer"
}}
onClick={async (event) => {
const prompt = `Set up this environment so I can build with CrewAI.
First install the official CrewAI coding-agent skills if this environment supports npx:
npx skills add crewaiinc/skills
If npx is missing or the current agent cannot load skills, do not fail the whole setup. Report the exact issue and continue using the CrewAI docs directly.
Use these CrewAI docs as source of truth before making assumptions:
- https://skills.crewai.com
- https://docs.crewai.com/llms.txt
- https://docs.crewai.com/en/installation
- https://docs.crewai.com/en/guides/coding-tools/build-with-ai
Setup steps:
1. Check python3 --version. CrewAI requires Python >=3.10 and <3.14.
2. Install uv if missing:
curl -LsSf https://astral.sh/uv/install.sh | sh
3. Source the uv environment if needed:
source "$HOME/.local/bin/env"
4. Install the CrewAI CLI:
uv tool install crewai
5. Verify the CLI:
crewai version
crewai create --help
6. Create a project:
CREWAI_DMN=true crewai create
7. After project creation, inspect the generated files before editing.
8. Run:
crewai install
crewai run
Do not hardcode API keys. Use .env.
Do not invent CLI flags. Validate with crewai --help or crewai create --help.
If a command fails, show the exact command and error, explain the likely cause, fix what you can safely fix, and retry once.`;
const button = event.currentTarget;
const resetTimeout = button.dataset.resetTimeout;
if (resetTimeout) {
window.clearTimeout(Number(resetTimeout));
}
try {
await navigator.clipboard.writeText(prompt);
button.textContent = "Copied";
} catch {
button.textContent = "Copy failed";
} finally {
button.dataset.resetTimeout = String(window.setTimeout(() => {
button.textContent = "Copy agent setup prompt";
delete button.dataset.resetTimeout;
}, 1600));
}
}}
>
Copy agent setup prompt
</button>
<a
href="/guides/coding-tools/build-with-ai"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minHeight: 42,
padding: "0 16px",
borderRadius: 8,
border: "1px solid rgba(235,102,88,0.28)",
color: "#EB6658",
fontWeight: 700,
lineHeight: 1,
textDecoration: "none"
}}
>
Coding-agent guide
</a>
<a
href="/en/api-reference/introduction"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minHeight: 42,
padding: "0 16px",
borderRadius: 8,
border: "1px solid rgba(235,102,88,0.18)",
color: "var(--mint-text-2)",
fontWeight: 700,
lineHeight: 1,
textDecoration: "none"
}}
>
API Reference
</a>
</div>
</div>

View File

@@ -4,6 +4,8 @@ import shutil
import click
from crewai_core.telemetry import Telemetry
from crewai_cli.version import get_crewai_tools_dependency
DECLARATIVE_FLOW_FOLDERS = ("crews", "tools", "knowledge", "skills")
@@ -71,6 +73,9 @@ def _create_python_flow(
content = content.replace("{{name}}", name)
content = content.replace("{{flow_name}}", class_name)
content = content.replace("{{folder_name}}", folder_name)
content = content.replace(
"{{crewai_tools_dependency}}", get_crewai_tools_dependency()
)
with open(dst_file, "w") as file:
file.write(content)
@@ -138,6 +143,9 @@ def _create_declarative_flow(
content = content.replace("{{name}}", name)
content = content.replace("{{flow_name}}", class_name)
content = content.replace("{{folder_name}}", folder_name)
content = content.replace(
"{{crewai_tools_dependency}}", get_crewai_tools_dependency()
)
dst_file.write_text(content, encoding="utf-8")
(project_root / ".env").write_text("OPENAI_API_KEY=YOUR_API_KEY", encoding="utf-8")

View File

@@ -20,6 +20,7 @@ from crewai_cli.utils import (
load_env_vars,
write_env_file,
)
from crewai_cli.version import get_crewai_tools_dependency
# ── Provider / model data ───────────────────────────────────────
@@ -89,7 +90,7 @@ description = "{name} using crewAI"
authors = [{{ name = "Your Name", email = "you@example.com" }}]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.8a1"
"{crewai_tools_dependency}"
]
[build-system]
@@ -101,6 +102,7 @@ only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
"""
_GITIGNORE = """\
@@ -1134,7 +1136,11 @@ def create_json_crew(
# Write pyproject.toml
(folder_path / "pyproject.toml").write_text(
_PYPROJECT_TOML.format(folder_name=folder_name, name=name),
_PYPROJECT_TOML.format(
folder_name=folder_name,
name=name,
crewai_tools_dependency=get_crewai_tools_dependency(),
),
encoding="utf-8",
)

View File

@@ -1,12 +1,15 @@
from pathlib import Path
import subprocess
from typing import Any
from urllib.parse import quote
import webbrowser
from crewai_core.plus_api import CreateCrewPayload
from rich.console import Console
from crewai_cli import git
from crewai_cli.command import BaseCommand, PlusAPIMixin
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai_cli.deploy.archive import create_project_zip
from crewai_cli.deploy.validate import DeployValidator, Severity, render_report
from crewai_cli.utils import fetch_and_json_env_file, get_project_name
@@ -14,6 +17,8 @@ from crewai_cli.utils import fetch_and_json_env_file, get_project_name
console = Console()
_MISSING_LOCKFILE_ERROR_CODES = {"missing_lockfile"}
_DEPLOYMENT_ID_KEYS = ("deployment_id", "deploymentId")
_DEPLOYMENT_FALLBACK_IDENTIFIER_KEYS = ("id", "uuid")
def _run_predeploy_validation(
@@ -79,6 +84,39 @@ def _env_summary(env_vars: dict[str, str]) -> str:
return f"{len(env_vars)} env vars: {keys}"
def _deployment_identifier(json_response: dict[str, Any]) -> str | None:
"""Return the best available identifier for a deployment show URL."""
deployment = json_response.get("deployment")
for key in _DEPLOYMENT_ID_KEYS:
value = json_response.get(key)
if value:
return str(value)
if isinstance(deployment, dict):
for key in _DEPLOYMENT_ID_KEYS + _DEPLOYMENT_FALLBACK_IDENTIFIER_KEYS:
value = deployment.get(key)
if value:
return str(value)
for key in _DEPLOYMENT_FALLBACK_IDENTIFIER_KEYS:
value = json_response.get(key)
if value:
return str(value)
return None
def _deployment_page_url(base_url: str, json_response: dict[str, Any]) -> str | None:
"""Build the CrewAI deployment show URL for a response payload."""
identifier = _deployment_identifier(json_response)
if not identifier:
return None
return (
f"{base_url.rstrip('/')}/crewai_plus/deployments/{quote(identifier, safe='')}"
)
def _needs_lockfile_for_deploy(project_root: Path | None = None) -> bool:
"""Return True when deploy should create the project's first lockfile."""
root = project_root or Path.cwd()
@@ -165,6 +203,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
console.print("crewai deploy status")
console.print(" or")
console.print(f'crewai deploy status --uuid "{json_response["uuid"]}"')
self._open_deployment_page(json_response)
def _display_logs(self, log_messages: list[dict[str, Any]]) -> None:
"""
@@ -178,6 +217,28 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
f"{log_message['timestamp']} - {log_message['level']}: {log_message['message']}"
)
def _open_deployment_page(self, json_response: dict[str, Any]) -> None:
"""Open the deployment show page in the user's browser when possible."""
base_url = str(
getattr(self.plus_api_client, "base_url", None)
or DEFAULT_CREWAI_ENTERPRISE_URL
)
deployment_url = _deployment_page_url(base_url, json_response)
if not deployment_url:
return
console.print(f"\nOpening deployment page: [blue]{deployment_url}[/blue]")
try:
opened = webbrowser.open(deployment_url)
except Exception:
opened = False
if not opened:
console.print(
"Could not open the deployment page automatically.",
style="yellow",
)
def deploy(self, uuid: str | None = None, skip_validate: bool = False) -> None:
"""
Deploy a crew using either UUID or project name.
@@ -438,6 +499,7 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
console.print("crewai deploy push")
console.print(" or")
console.print(f"crewai deploy push --uuid {json_response['uuid']}")
self._open_deployment_page(json_response)
def list_crews(self) -> None:
"""

View File

@@ -40,14 +40,18 @@ from typing import Any
from crewai.project.json_loader import (
JSONProjectValidationError,
find_crew_json_file,
find_json_project_file,
validate_crew_project,
)
from crewai_core.project import (
ProjectDefinitionError,
configured_project_definition,
get_crewai_project_config,
get_crewai_project_type,
read_toml,
)
from rich.console import Console
from crewai_cli.utils import parse_toml
console = Console()
logger = logging.getLogger(__name__)
@@ -159,24 +163,16 @@ class DeployValidator:
@property
def _is_json_crew(self) -> bool:
"""True for JSON crew projects, deferring to the declared type.
A flow project that also contains a crew.json(c) file validates as
the flow it declares in pyproject.toml, not as a JSON crew.
"""
if find_crew_json_file(self.project_root) is None:
return False
"""True for JSON crew projects with configured crew definitions."""
pyproject_path = self.project_root / "pyproject.toml"
if not pyproject_path.exists():
return True
return False
try:
data = parse_toml(pyproject_path.read_text())
data = read_toml(pyproject_path)
except Exception:
return True
declared_type: str | None = (
(data.get("tool") or {}).get("crewai", {}).get("type")
)
return declared_type != "flow"
return False
crewai_config = get_crewai_project_config(data)
return crewai_config.get("type") == "crew" and "definition" in crewai_config
def run(self) -> list[ValidationResult]:
"""Run all checks. Later checks are skipped when earlier ones make
@@ -208,14 +204,32 @@ class DeployValidator:
def _run_json_checks(self) -> list[ValidationResult]:
"""Validation suite for JSON-defined crew projects."""
crew_path = find_crew_json_file(self.project_root)
self._check_pyproject()
self._check_lockfile()
try:
crew_path = configured_project_definition(
"crew",
pyproject_data=self._pyproject,
project_root=self.project_root,
)
except ProjectDefinitionError as exc:
self._add(
Severity.ERROR,
"invalid_crew_definition",
"[tool.crewai] definition is invalid",
detail=str(exc),
hint=(
"Set `[tool.crewai] definition` to a project-local JSON "
"or JSONC crew file."
),
)
return self.results
if crew_path is None:
return self.results
agents_dir = self.project_root / "agents"
self._check_pyproject()
self._check_lockfile()
agents_dir = crew_path.parent / "agents"
agents_dir_ok = self._check_json_agents_dir(agents_dir)
project = None
@@ -346,7 +360,7 @@ class DeployValidator:
return False
try:
self._pyproject = parse_toml(pyproject_path.read_text())
self._pyproject = read_toml(pyproject_path)
except Exception as e:
self._add(
Severity.ERROR,
@@ -374,9 +388,7 @@ class DeployValidator:
self._project_name = name
self._package_name = normalize_package_name(name)
self._is_flow = (self._pyproject.get("tool") or {}).get("crewai", {}).get(
"type"
) == "flow"
self._is_flow = get_crewai_project_type(self._pyproject) == "flow"
return True
def _check_lockfile(self) -> None:

View File

@@ -2,53 +2,35 @@ from pathlib import Path
import subprocess
import click
from crewai_core.project import configured_project_definition, read_toml
from crewai_cli.deploy.validate import normalize_package_name
from crewai_cli.utils import build_env_with_all_tool_credentials, parse_toml
def _find_json_crew_file(project_root: Path | None = None) -> Path | None:
"""Return the JSON crew definition path when present."""
root = project_root or Path.cwd()
for filename in ("crew.jsonc", "crew.json"):
crew_path = root / filename
if crew_path.is_file():
return crew_path
return None
from crewai_cli.utils import build_env_with_all_tool_credentials
def _is_json_crew_project(project_root: Path | None = None) -> bool:
"""Return True for JSON crew projects that do not need package install."""
root = project_root or Path.cwd()
if _find_json_crew_file(root) is None:
return False
pyproject_path = root / "pyproject.toml"
if not pyproject_path.is_file():
return True
return False
try:
pyproject = parse_toml(pyproject_path.read_text())
except Exception:
return True
if not isinstance(pyproject, dict):
return True
pyproject = read_toml(pyproject_path)
tool_config = pyproject.get("tool") or {}
crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None
declared_type = (
crewai_config.get("type") if isinstance(crewai_config, dict) else None
)
project_config = pyproject.get("project") or {}
project_name = (
project_config.get("name") if isinstance(project_config, dict) else None
)
if isinstance(project_name, str):
package_name = normalize_package_name(project_name)
if package_name and (root / "src" / package_name / "crew.py").is_file():
return False
if (
configured_project_definition(
"crew", pyproject_data=pyproject, project_root=root
)
is None
):
return False
return declared_type != "flow"
project_name = pyproject.get("project", {}).get("name", "")
package_name = normalize_package_name(project_name)
if package_name and (root / "src" / package_name / "crew.py").is_file():
return False
return True
# Be mindful about changing this.

View File

@@ -1,25 +1,29 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
import os
from pathlib import Path
import re
import subprocess
import sys
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
import click
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
from crewai_core.project import (
ProjectDefinitionError,
configured_project_definition,
get_crewai_project_type,
read_toml,
)
from packaging import version
from crewai_cli.utils import (
build_env_with_all_tool_credentials,
enable_prompt_line_editing,
is_dmn_mode_enabled,
read_toml,
)
from crewai_cli.version import get_crewai_version
from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version
if TYPE_CHECKING:
@@ -32,12 +36,13 @@ if TYPE_CHECKING:
_INPUT_PLACEHOLDER_RE = re.compile(r"(?<!{){([A-Za-z_][A-Za-z0-9_\-]*)}(?!})")
_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV = "CREWAI_CLI_RUNNER_PACKAGE_DIR"
_CREWAI_RUNNER_SOURCE_DIR_ENV = "CREWAI_RUNNER_SOURCE_DIR"
_FULL_CREWAI_INSTALL_MESSAGE = """\
_CREWAI_JSON_CREW_DEFINITION_ENV = "CREWAI_JSON_CREW_DEFINITION"
_FULL_CREWAI_INSTALL_MESSAGE = f"""\
CrewAI CLI is installed without the `crewai` package required to run crews.
Install the full CrewAI prerelease package:
Install the full CrewAI package:
uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'
uv tool install --force '{get_crewai_tools_dependency()}'
The quotes are required in zsh so `crewai[tools]` is not treated as a glob.
"""
@@ -75,22 +80,20 @@ module_spec.loader.exec_module(module)
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
kwargs = {
"trained_agents_file": os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV),
}
if crew_definition := os.getenv("CREWAI_JSON_CREW_DEFINITION"):
kwargs["crew_path"] = crew_definition
try:
module._run_json_crew(
trained_agents_file=os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV)
)
module._run_json_crew(**kwargs)
except module.click.ClickException as exc:
exc.show()
raise SystemExit(exc.exit_code)
""".strip()
def _import_find_crew_json_file() -> Callable[[], Path | None]:
from crewai.project.json_loader import find_crew_json_file as _find_crew_json_file
return cast("Callable[[], Path | None]", _find_crew_json_file)
def _is_missing_crewai_package(exc: ModuleNotFoundError) -> bool:
return bool(exc.name and exc.name.startswith("crewai"))
@@ -99,32 +102,23 @@ def _full_crewai_install_error() -> click.ClickException:
return click.ClickException(_FULL_CREWAI_INSTALL_MESSAGE)
def find_crew_json_file() -> Path | None:
def configured_project_json_crew(
pyproject_data: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> Path | None:
"""Return the configured JSON crew definition for crew projects."""
root = project_root or Path.cwd()
if pyproject_data is None and not (root / "pyproject.toml").is_file():
return None
try:
return _import_find_crew_json_file()()
except ModuleNotFoundError as exc:
if _is_missing_crewai_package(exc):
raise _full_crewai_install_error() from exc
raise
def _has_json_crew() -> bool:
"""Check if this is a JSON-defined crew project.
The project type declared in pyproject.toml wins: a flow project that
happens to contain a crew.json(c) file still runs as a flow. A missing
or unreadable pyproject means a bare JSON crew project.
"""
if find_crew_json_file() is None:
return False
try:
pyproject_data = read_toml()
except Exception:
return True
declared_type: str | None = (
pyproject_data.get("tool", {}).get("crewai", {}).get("type")
)
return declared_type != "flow"
return configured_project_definition(
"crew",
pyproject_data=pyproject_data,
project_root=root,
)
except ProjectDefinitionError as exc:
raise click.UsageError(str(exc)) from exc
def _extract_input_placeholders(text: str | None) -> set[str]:
@@ -199,7 +193,12 @@ def _json_loading_status(message: str) -> AbstractContextManager[Any]:
def _load_json_crew(crew_path: Path) -> tuple[Any, dict[str, Any]]:
from crewai.project.crew_loader import load_crew
try:
from crewai.project.crew_loader import load_crew
except ModuleNotFoundError as exc:
if _is_missing_crewai_package(exc):
raise _full_crewai_install_error() from exc
raise
return load_crew(crew_path)
@@ -262,7 +261,10 @@ def _run_json_crew_without_tui(crew_path: Path) -> Any:
return result
def _run_json_crew(trained_agents_file: str | None = None) -> Any:
def _run_json_crew(
trained_agents_file: str | None = None,
crew_path: str | Path | None = None,
) -> Any:
"""Load and run a JSON-defined crew."""
from dotenv import load_dotenv
@@ -275,9 +277,13 @@ def _run_json_crew(trained_agents_file: str | None = None) -> Any:
if trained_agents_file:
os.environ[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
crew_path = find_crew_json_file()
if crew_path is None:
raise FileNotFoundError("No crew.jsonc or crew.json found")
crew_path = configured_project_json_crew()
if crew_path is None:
raise FileNotFoundError(
"No JSON crew definition configured in [tool.crewai].definition"
)
crew_path = Path(crew_path)
if is_dmn_mode_enabled():
return _run_json_crew_without_tui(crew_path)
@@ -391,10 +397,16 @@ def _json_crew_run_command(project_root: Path | None = None) -> list[str]:
return ["uv", "run", "--no-sync", "python", "-c", _JSON_CREW_RUNNER_CODE]
def _run_json_crew_in_project_env(trained_agents_file: str | None = None) -> Any:
def _run_json_crew_in_project_env(
trained_agents_file: str | None = None,
crew_path: str | Path | None = None,
) -> Any:
"""Run JSON crews from the project's uv-managed environment."""
if not (Path.cwd() / "pyproject.toml").is_file():
return _run_json_crew(trained_agents_file=trained_agents_file)
return _run_json_crew(
trained_agents_file=trained_agents_file,
crew_path=crew_path,
)
_install_json_crew_dependencies_if_needed()
@@ -405,6 +417,8 @@ def _run_json_crew_in_project_env(trained_agents_file: str | None = None) -> Any
env[_CREWAI_RUNNER_SOURCE_DIR_ENV] = str(local_crewai_source_dir)
if trained_agents_file:
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
if crew_path is not None:
env[_CREWAI_JSON_CREW_DEFINITION_ENV] = str(crew_path)
try:
subprocess.run( # noqa: S603
@@ -557,13 +571,16 @@ def run_crew(
)
return
if _has_json_crew():
_run_json_crew_in_project_env(trained_agents_file=trained_agents_file)
pyproject_data = read_toml()
if json_crew_definition := configured_project_json_crew(pyproject_data):
_run_json_crew_in_project_env(
trained_agents_file=trained_agents_file,
crew_path=json_crew_definition,
)
return
pyproject_data = read_toml()
_warn_if_old_poetry_project(pyproject_data)
project_type = _get_project_type(pyproject_data)
project_type = get_crewai_project_type(pyproject_data)
if project_type == "flow":
_run_flow_project(
@@ -627,11 +644,6 @@ def _run_classic_crew_project(
)
def _get_project_type(pyproject_data: dict[str, Any]) -> str | None:
project_type = pyproject_data.get("tool", {}).get("crewai", {}).get("type")
return project_type if isinstance(project_type, str) else None
def _warn_if_old_poetry_project(pyproject_data: dict[str, Any]) -> None:
crewai_version = get_crewai_version()
min_required_version = "0.71.0"

View File

@@ -1,11 +1,12 @@
from __future__ import annotations
import json
from pathlib import Path, PureWindowsPath
from pathlib import Path
import subprocess
from typing import Any
import click
from crewai_core.project import ProjectDefinitionError, configured_project_definition
from pydantic import ValidationError
from crewai_cli.utils import build_env_with_all_tool_credentials
@@ -105,80 +106,18 @@ def configured_project_declarative_flow(
project_root: Path | None = None,
) -> Path | None:
"""Return the configured declarative flow source for flow projects."""
if pyproject_data is None:
try:
from crewai_cli.utils import read_toml
pyproject_data = read_toml()
except Exception:
return None
crewai_config = pyproject_data.get("tool", {}).get("crewai", {})
if crewai_config.get("type") != "flow":
root = project_root or Path.cwd()
if pyproject_data is None and not (root / "pyproject.toml").is_file():
return None
definition = crewai_config.get("definition")
if not isinstance(definition, str):
return None
definition = definition.strip()
if not definition:
return None
return _resolve_project_definition_path(
definition=definition,
project_root=project_root or Path.cwd(),
)
def _resolve_project_definition_path(definition: str, project_root: Path) -> Path:
definition_path = Path(definition)
windows_definition_path = PureWindowsPath(definition)
if definition.startswith("~"):
raise click.UsageError(
"[tool.crewai] definition must be a project-local path; "
f"got {definition!r}."
)
if definition_path.is_absolute() or windows_definition_path.is_absolute():
raise click.UsageError(
"[tool.crewai] definition must be relative to the project root; "
f"got {definition!r}."
)
try:
root = project_root.resolve(strict=True)
except OSError as exc:
raise click.UsageError(
f"Invalid project root for [tool.crewai] definition: {exc}"
) from exc
candidate = root / definition_path
try:
resolved_candidate = candidate.resolve(strict=False)
except OSError as exc:
raise click.UsageError(
f"Invalid [tool.crewai] definition path {definition!r}: {exc}"
) from exc
if not resolved_candidate.is_relative_to(root):
raise click.UsageError(
"[tool.crewai] definition must resolve inside the project root; "
f"got {definition!r}."
return configured_project_definition(
"flow",
pyproject_data=pyproject_data,
project_root=root,
)
if not resolved_candidate.exists():
raise click.UsageError(
"[tool.crewai] definition must point to an existing file; "
f"got {definition!r}."
)
if not resolved_candidate.is_file():
raise click.UsageError(
"[tool.crewai] definition must point to a regular file; "
f"got {definition!r}."
)
return resolved_candidate
except ProjectDefinitionError as exc:
raise click.UsageError(str(exc)) from exc
def _execute_declarative_flow_command(command: list[str]) -> None:

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.15.0"
"{{crewai_tools_dependency}}"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.15.0"
"{{crewai_tools_dependency}}"
]
[build-system]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.15.0"
"{{crewai_tools_dependency}}"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.15.0"
"{{crewai_tools_dependency}}"
]
[tool.crewai]

View File

@@ -23,6 +23,7 @@ from crewai_cli.utils import (
tree_copy,
tree_find_and_replace,
)
from crewai_cli.version import get_crewai_tools_dependency
console = Console()
@@ -81,6 +82,9 @@ class ToolCommand(BaseCommand, PlusAPIMixin):
tree_copy(template_dir, project_root)
tree_find_and_replace(project_root, "{{folder_name}}", folder_name)
tree_find_and_replace(project_root, "{{class_name}}", class_name)
tree_find_and_replace(
project_root, "{{crewai_tools_dependency}}", get_crewai_tools_dependency()
)
agents_md_src = Path(__file__).parent.parent / "templates" / "AGENTS.md"
if agents_md_src.exists():

View File

@@ -19,6 +19,8 @@ from crewai_core.tool_credentials import (
)
from rich.console import Console
from crewai_cli.version import get_crewai_tools_dependency
__all__ = [
"build_env_with_all_tool_credentials",
@@ -73,6 +75,9 @@ def copy_template(
content = content.replace("{{name}}", name)
content = content.replace("{{crew_name}}", class_name)
content = content.replace("{{folder_name}}", folder_name)
content = content.replace(
"{{crewai_tools_dependency}}", get_crewai_tools_dependency()
)
with open(dst, "w") as file:
file.write(content)

View File

@@ -13,10 +13,26 @@ from crewai_core.version import (
is_current_version_yanked as is_current_version_yanked,
is_newer_version_available as is_newer_version_available,
)
from packaging.version import Version
from crewai_cli import __version__ as _crewai_cli_version
def get_crewai_dependency_range(current_version: str | None = None) -> str:
"""Return the supported CrewAI dependency range for generated projects."""
parsed_version = Version(current_version or _crewai_cli_version)
return f">={parsed_version},<{parsed_version.major + 1}.0.0"
def get_crewai_tools_dependency(current_version: str | None = None) -> str:
"""Return the generated-project dependency for CrewAI with tools."""
return f"crewai[tools]{get_crewai_dependency_range(current_version)}"
__all__ = [
"check_version",
"get_crewai_dependency_range",
"get_crewai_tools_dependency",
"get_crewai_version",
"get_latest_version_from_pypi",
"is_current_version_yanked",

View File

@@ -146,6 +146,7 @@ build-backend = "hatchling.build"
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
+ "\n"
)
@@ -176,10 +177,11 @@ def test_create_project_zip_keeps_json_project_root_shape(tmp_path: Path):
[project]
name = "json_crew"
version = "0.1.0"
dependencies = ["crewai[tools]==1.14.8a1"]
dependencies = ["crewai[tools]>=1.15.0,<2.0.0"]
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
+ "\n"
)
@@ -221,6 +223,7 @@ custom = "custom.module:main"
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
+ "\n"
)

View File

@@ -167,6 +167,36 @@ def test_prepare_project_for_deploy_creates_missing_lock_after_validation(
assert validators == []
def test_deployment_page_url_prefers_deployment_id():
assert (
deploy_main._deployment_page_url(
"https://app.crewai.com",
{"uuid": "crew-uuid", "deployment_id": 128687},
)
== "https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_deployment_page_url_prefers_nested_deployment_id_over_crew_uuid():
assert (
deploy_main._deployment_page_url(
"https://app.crewai.com",
{"uuid": "crew-uuid", "deployment": {"deployment_id": 128687}},
)
== "https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_deployment_page_url_falls_back_to_nested_uuid():
assert (
deploy_main._deployment_page_url(
"https://app.crewai.com/",
{"deployment": {"uuid": "deployment-uuid"}},
)
== "https://app.crewai.com/crewai_plus/deployments/deployment-uuid"
)
class TestDeployCommand(unittest.TestCase):
@patch("crewai_cli.command.get_auth_token")
@patch("crewai_cli.deploy.main.get_project_name")
@@ -186,6 +216,12 @@ class TestDeployCommand(unittest.TestCase):
self.deploy_command = deploy_main.DeployCommand()
self.mock_client = self.deploy_command.plus_api_client
self.mock_client.base_url = "https://app.crewai.com"
self.mock_browser_open_patcher = patch(
"crewai_cli.deploy.main.webbrowser.open"
)
self.mock_browser_open = self.mock_browser_open_patcher.start()
self.addCleanup(self.mock_browser_open_patcher.stop)
def test_init_success(self):
self.assertEqual(self.deploy_command.project_name, "test_project")
@@ -272,11 +308,50 @@ class TestDeployCommand(unittest.TestCase):
def test_display_deployment_info(self):
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._display_deployment_info(
{"uuid": "test-uuid", "status": "deployed"}
{"uuid": "test-uuid", "id": 128687, "status": "deployed"}
)
self.assertIn("Deploying the crew...", fake_out.getvalue())
self.assertIn("test-uuid", fake_out.getvalue())
self.assertIn("deployed", fake_out.getvalue())
self.assertIn(
"https://app.crewai.com/crewai_plus/deployments/128687",
fake_out.getvalue(),
)
self.mock_browser_open.assert_called_once_with(
"https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_display_deployment_info_warns_when_browser_open_returns_false(self):
self.mock_browser_open.return_value = False
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._display_deployment_info(
{"uuid": "test-uuid", "id": 128687, "status": "deployed"}
)
self.assertIn(
"Could not open the deployment page automatically.",
fake_out.getvalue(),
)
self.mock_browser_open.assert_called_once_with(
"https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_display_deployment_info_warns_when_browser_open_raises(self):
self.mock_browser_open.side_effect = RuntimeError("no browser")
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._display_deployment_info(
{"uuid": "test-uuid", "id": 128687, "status": "deployed"}
)
self.assertIn(
"Could not open the deployment page automatically.",
fake_out.getvalue(),
)
self.mock_browser_open.assert_called_once_with(
"https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_display_logs(self):
with patch("sys.stdout", new=StringIO()) as fake_out:

View File

@@ -111,7 +111,12 @@ def _run_without_import_check(root: Path) -> DeployValidator:
def _scaffold_json_crew(root: Path, *, task_agent: str = "researcher") -> None:
(root / "pyproject.toml").write_text(_make_pyproject(name="json_crew"))
(root / "pyproject.toml").write_text(
_make_pyproject(
name="json_crew",
extra='[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"',
)
)
(root / "uv.lock").write_text("# dummy uv lockfile\n")
agents_dir = root / "agents"
agents_dir.mkdir()
@@ -221,7 +226,6 @@ def test_json_crew_reports_project_metadata_before_invalid_json(
tmp_path: Path,
) -> None:
_scaffold_json_crew(tmp_path)
(tmp_path / "pyproject.toml").unlink()
(tmp_path / "uv.lock").unlink()
(tmp_path / "crew.jsonc").write_text('{"agents": ["researcher"], "tasks": []}\n')
@@ -229,7 +233,6 @@ def test_json_crew_reports_project_metadata_before_invalid_json(
v.run()
codes = _codes(v)
assert "missing_pyproject" in codes
assert "missing_lockfile" in codes
assert "invalid_crew_json" in codes
assert "missing_src_dir" not in codes
@@ -546,17 +549,43 @@ def test_is_json_crew_defers_to_declared_flow_type(tmp_path):
assert DeployValidator(project_root=tmp_path)._is_json_crew is False
def test_is_json_crew_true_for_declared_crew_type(tmp_path):
def test_is_json_crew_true_for_declared_crew_definition(tmp_path):
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "demo"\nversion = "0.1.0"\n\n'
'[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"\n'
)
assert DeployValidator(project_root=tmp_path)._is_json_crew is True
def test_is_json_crew_false_for_declared_crew_without_definition(tmp_path):
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "demo"\nversion = "0.1.0"\n\n'
'[tool.crewai]\ntype = "crew"\n'
)
assert DeployValidator(project_root=tmp_path)._is_json_crew is True
assert DeployValidator(project_root=tmp_path)._is_json_crew is False
def test_is_json_crew_true_without_pyproject(tmp_path):
def test_json_crew_non_string_definition_reports_invalid_definition(
tmp_path: Path,
) -> None:
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "demo"\nversion = "0.1.0"\n\n'
'[tool.crewai]\ntype = "crew"\ndefinition = ["crew.jsonc"]\n'
)
v = DeployValidator(project_root=tmp_path)
v.run()
finding = next(r for r in v.results if r.code == "invalid_crew_definition")
assert finding.severity is Severity.ERROR
assert "must be a string" in finding.detail
def test_is_json_crew_false_without_pyproject(tmp_path):
(tmp_path / "crew.jsonc").write_text("{}")
assert DeployValidator(project_root=tmp_path)._is_json_crew is True
assert DeployValidator(project_root=tmp_path)._is_json_crew is False

View File

@@ -735,11 +735,16 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
pyproject = tomli.loads((tmp_path / "json_crew" / "pyproject.toml").read_text())
dependency = pyproject["project"]["dependencies"][0]
assert dependency == "crewai[tools]==1.14.8a1"
assert Version("1.14.8a1") in Requirement(dependency).specifier
assert dependency == "crewai[tools]>=1.15.0,<2.0.0"
assert Version("1.15.0") in Requirement(dependency).specifier
assert Version("2.0.0") not in Requirement(dependency).specifier
assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
"only-include"
] == ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
assert pyproject["tool"]["crewai"] == {
"type": "crew",
"definition": "crew.jsonc",
}
crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text()
assert (

View File

@@ -26,6 +26,7 @@ name = "json_crew"
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
)
(tmp_path / "crew.jsonc").write_text("{}\n")
@@ -45,6 +46,7 @@ name = "hybrid-crew"
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
""".strip()
)
(tmp_path / "crew.jsonc").write_text("{}\n")

View File

@@ -16,29 +16,37 @@ def test_missing_crewai_package_shows_full_install_hint(monkeypatch):
def missing_crewai_package():
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
monkeypatch.setattr(
run_crew_module, "_import_find_crew_json_file", missing_crewai_package
)
real_import = __import__
def fake_import(name, *args, **kwargs):
if name == "crewai.project.crew_loader":
missing_crewai_package()
return real_import(name, *args, **kwargs)
monkeypatch.setattr("builtins.__import__", fake_import)
with pytest.raises(click.ClickException) as exc_info:
run_crew_module.find_crew_json_file()
run_crew_module._load_json_crew(Path("crew.jsonc"))
message = exc_info.value.message
assert "CrewAI CLI is installed without the `crewai` package" in message
assert (
"uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'"
in message
)
assert "uv tool install --force 'crewai[tools]>=1.15.0,<2.0.0'" in message
assert "quotes are required in zsh" in message
def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
"""crewai run -f must reach JSON crews, not only classic subprocess crews."""
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True)
monkeypatch.setattr(run_crew_module, "read_toml", lambda: {})
monkeypatch.setattr(
run_crew_module,
"configured_project_json_crew",
lambda pyproject_data=None, project_root=None: Path("crew.jsonc"),
)
called: dict = {}
def fake_run_json_crew_in_project_env(trained_agents_file=None):
def fake_run_json_crew_in_project_env(trained_agents_file=None, crew_path=None):
called["trained_agents_file"] = trained_agents_file
called["crew_path"] = crew_path
monkeypatch.setattr(
run_crew_module,
@@ -48,7 +56,10 @@ def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
run_crew_module.run_crew(trained_agents_file="some.pkl")
assert called == {"trained_agents_file": "some.pkl"}
assert called == {
"trained_agents_file": "some.pkl",
"crew_path": Path("crew.jsonc"),
}
def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path: Path):
@@ -74,8 +85,10 @@ def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path:
monkeypatch.setattr(run_crew_module.subprocess, "run", fake_subprocess_run)
crew_path = tmp_path / "crew.jsonc"
run_crew_module._run_json_crew_in_project_env(
trained_agents_file="trained.pkl"
trained_agents_file="trained.pkl",
crew_path=crew_path,
)
expected_env = {
@@ -84,6 +97,7 @@ def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path:
Path(run_crew_module.__file__).resolve().parent
),
CREWAI_TRAINED_AGENTS_FILE_ENV: "trained.pkl",
run_crew_module._CREWAI_JSON_CREW_DEFINITION_ENV: str(crew_path),
}
if local_crewai_source_dir := run_crew_module._find_local_crewai_source_dir():
expected_env[run_crew_module._CREWAI_RUNNER_SOURCE_DIR_ENV] = str(
@@ -217,8 +231,9 @@ def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path)
monkeypatch.chdir(tmp_path)
called: dict = {}
def fake_run_json_crew(trained_agents_file=None):
def fake_run_json_crew(trained_agents_file=None, crew_path=None):
called["trained_agents_file"] = trained_agents_file
called["crew_path"] = crew_path
return "result"
monkeypatch.setattr(run_crew_module, "_run_json_crew", fake_run_json_crew)
@@ -229,7 +244,7 @@ def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path)
)
== "result"
)
assert called == {"trained_agents_file": "trained.pkl"}
assert called == {"trained_agents_file": "trained.pkl", "crew_path": None}
def test_json_project_env_run_failure_exits_nonzero(monkeypatch, tmp_path: Path):
@@ -438,7 +453,7 @@ def _patch_tui_run(monkeypatch, status: str):
crew = SimpleNamespace(name="Demo", tasks=[], agents=[])
monkeypatch.setattr(
run_crew_module, "find_crew_json_file", lambda: Path("crew.jsonc")
run_crew_module, "configured_project_json_crew", lambda: Path("crew.jsonc")
)
monkeypatch.setattr(
run_crew_module,
@@ -492,7 +507,9 @@ def test_run_json_crew_dmn_mode_bypasses_tui(monkeypatch, tmp_path: Path, capsys
kickoff_calls.append(inputs)
return "plain result"
monkeypatch.setattr(run_crew_module, "find_crew_json_file", lambda: crew_path)
monkeypatch.setattr(
run_crew_module, "configured_project_json_crew", lambda: crew_path
)
monkeypatch.setattr(
run_crew_module,
"_load_json_crew",
@@ -531,7 +548,9 @@ def test_run_json_crew_dmn_mode_exits_on_missing_inputs(
tasks=[],
)
monkeypatch.setattr(run_crew_module, "find_crew_json_file", lambda: crew_path)
monkeypatch.setattr(
run_crew_module, "configured_project_json_crew", lambda: crew_path
)
monkeypatch.setattr(
run_crew_module,
"_load_json_crew",
@@ -546,28 +565,47 @@ def test_run_json_crew_dmn_mode_exits_on_missing_inputs(
assert "Missing runtime inputs for CREWAI_DMN mode: topic" in captured.err
def test_has_json_crew_defers_to_declared_flow_type(monkeypatch, tmp_path: Path):
def test_configured_project_json_crew_defers_to_declared_flow_type(
monkeypatch, tmp_path: Path
):
"""A flow project containing a stray crew.jsonc must still run as a flow."""
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "flow"\n')
assert run_crew_module._has_json_crew() is False
assert run_crew_module.configured_project_json_crew() is None
def test_has_json_crew_true_for_declared_crew_type(monkeypatch, tmp_path: Path):
def test_configured_project_json_crew_returns_declared_crew_definition(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
crew_path = tmp_path / "crew.jsonc"
crew_path.write_text("{}")
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"\n'
)
assert run_crew_module.configured_project_json_crew() == crew_path.resolve()
def test_configured_project_json_crew_ignores_declared_crew_without_definition(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "crew"\n')
assert run_crew_module._has_json_crew() is True
assert run_crew_module.configured_project_json_crew() is None
def test_has_json_crew_true_without_pyproject(monkeypatch, tmp_path: Path):
def test_configured_project_json_crew_ignores_missing_pyproject(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
assert run_crew_module._has_json_crew() is True
assert run_crew_module.configured_project_json_crew() is None
def test_run_crew_rejects_inputs_without_definition():
@@ -608,7 +646,6 @@ def test_run_crew_runs_explicit_declarative_definition(monkeypatch, capsys):
def test_run_crew_runs_classic_crew_project(monkeypatch, capsys):
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
@@ -634,7 +671,6 @@ def test_run_crew_runs_classic_crew_project(monkeypatch, capsys):
def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
@@ -663,7 +699,6 @@ def test_run_crew_runs_conversational_flow_tui(monkeypatch, capsys):
flow = Flow()
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
@@ -692,7 +727,6 @@ def test_run_crew_runs_conversational_flow_tui(monkeypatch, capsys):
def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
@@ -713,7 +747,6 @@ def test_run_crew_runs_configured_declarative_flow_project(
monkeypatch.chdir(tmp_path)
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\n", encoding="utf-8")
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",

View File

@@ -7,6 +7,8 @@ from unittest.mock import MagicMock, patch
from crewai_cli.version import get_crewai_version as _get_ver
from crewai_cli.version import (
get_crewai_dependency_range,
get_crewai_tools_dependency,
get_crewai_version,
get_latest_version_from_pypi,
is_current_version_yanked,
@@ -31,6 +33,11 @@ def test_dynamic_versioning_consistency() -> None:
assert len(package_version.strip()) > 0
def test_generated_project_dependency_uses_next_major_upper_bound() -> None:
assert get_crewai_dependency_range("1.15.0") == ">=1.15.0,<2.0.0"
assert get_crewai_tools_dependency("1.15.0") == "crewai[tools]>=1.15.0,<2.0.0"
class TestVersionChecking:
"""Test version checking utilities."""

View File

@@ -54,6 +54,10 @@ def test_create_success(mock_subprocess, capsys, tool_command):
)
assert os.path.isfile(os.path.join("test_tool", "src", "test_tool", "tool.py"))
with open(os.path.join("test_tool", "pyproject.toml"), "r") as f:
content = f.read()
assert '"crewai[tools]>=1.15.0,<2.0.0"' in content
with open(os.path.join("test_tool", "src", "test_tool", "tool.py"), "r") as f:
content = f.read()
assert "class TestTool" in content

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from functools import reduce
from pathlib import Path, PureWindowsPath
import sys
from typing import Any
@@ -16,7 +17,11 @@ if sys.version_info >= (3, 11):
console = Console()
def read_toml(file_path: str = "pyproject.toml") -> dict[str, Any]:
class ProjectDefinitionError(ValueError):
"""Invalid ``[tool.crewai].definition`` project configuration."""
def read_toml(file_path: str | Path = "pyproject.toml") -> dict[str, Any]:
"""Read a TOML file from disk and return its parsed contents."""
with open(file_path, "rb") as f:
return tomli.load(f)
@@ -29,6 +34,115 @@ def parse_toml(content: str) -> dict[str, Any]:
return tomli.loads(content)
def get_crewai_project_config(pyproject_data: dict[str, Any]) -> dict[str, Any]:
"""Return the normalized ``[tool.crewai]`` table from pyproject data."""
tool_config = pyproject_data.get("tool")
if not isinstance(tool_config, dict):
return {}
crewai_config = tool_config.get("crewai")
if not isinstance(crewai_config, dict):
return {}
return crewai_config
def get_crewai_project_type(pyproject_data: dict[str, Any]) -> str | None:
"""Return ``[tool.crewai].type`` when configured."""
project_type = get_crewai_project_config(pyproject_data).get("type")
return project_type if isinstance(project_type, str) else None
def configured_project_definition(
project_type: str,
*,
pyproject_data: dict[str, Any] | None = None,
project_root: Path | str | None = None,
) -> Path | None:
"""Return a configured CrewAI definition path for a project type.
``[tool.crewai].type`` must match ``project_type`` and ``definition`` must
be a non-empty project-local file path. Missing definitions return ``None``
so callers can fall back to legacy entrypoints for that project type.
"""
root = Path(project_root) if project_root is not None else Path.cwd()
if pyproject_data is None:
pyproject_data = read_toml(root / "pyproject.toml")
crewai_config = get_crewai_project_config(pyproject_data)
if crewai_config.get("type") != project_type:
return None
if "definition" not in crewai_config:
return None
raw_definition = crewai_config["definition"]
if not isinstance(raw_definition, str):
raise ProjectDefinitionError(
"[tool.crewai] definition must be a string project-local path; "
f"got {raw_definition!r}."
)
definition = raw_definition.strip()
if not definition:
raise ProjectDefinitionError(
"[tool.crewai] definition must be a non-empty project-local path."
)
return resolve_project_definition_path(definition=definition, project_root=root)
def resolve_project_definition_path(definition: str, project_root: Path | str) -> Path:
"""Resolve a ``[tool.crewai].definition`` path inside ``project_root``."""
root_path = Path(project_root)
definition_path = Path(definition)
windows_definition_path = PureWindowsPath(definition)
if definition.startswith("~"):
raise ProjectDefinitionError(
"[tool.crewai] definition must be a project-local path; "
f"got {definition!r}."
)
if definition_path.is_absolute() or windows_definition_path.is_absolute():
raise ProjectDefinitionError(
"[tool.crewai] definition must be relative to the project root; "
f"got {definition!r}."
)
try:
root = root_path.resolve(strict=True)
except OSError as exc:
raise ProjectDefinitionError(
f"Invalid project root for [tool.crewai] definition: {exc}"
) from exc
candidate = root / definition_path
try:
resolved_candidate = candidate.resolve(strict=False)
except OSError as exc:
raise ProjectDefinitionError(
f"Invalid [tool.crewai] definition path {definition!r}: {exc}"
) from exc
if not resolved_candidate.is_relative_to(root):
raise ProjectDefinitionError(
"[tool.crewai] definition must resolve inside the project root; "
f"got {definition!r}."
)
if not resolved_candidate.exists():
raise ProjectDefinitionError(
"[tool.crewai] definition must point to an existing file; "
f"got {definition!r}."
)
if not resolved_candidate.is_file():
raise ProjectDefinitionError(
"[tool.crewai] definition must point to a regular file; "
f"got {definition!r}."
)
return resolved_candidate
def _get_nested_value(data: dict[str, Any], keys: list[str]) -> Any:
return reduce(dict.__getitem__, keys, data)

View File

@@ -10,6 +10,7 @@ from crewai_core import (
lock_store,
paths,
printer,
project,
user_data,
version,
)
@@ -97,6 +98,83 @@ def test_unused_var_warning_silenced() -> None:
assert os.environ is not None
def test_configured_project_definition_resolves_project_local_file(
tmp_path: Path,
) -> None:
definition = tmp_path / "crew.jsonc"
definition.write_text("{}\n")
resolved = project.configured_project_definition(
"crew",
pyproject_data={
"tool": {
"crewai": {
"type": "crew",
"definition": " crew.jsonc ",
}
}
},
project_root=tmp_path,
)
assert resolved == definition.resolve()
def test_configured_project_definition_rejects_project_escape(tmp_path: Path) -> None:
outside = tmp_path.parent / f"{tmp_path.name}-outside-crew.jsonc"
outside.write_text("{}\n")
with pytest.raises(project.ProjectDefinitionError):
project.configured_project_definition(
"crew",
pyproject_data={
"tool": {
"crewai": {
"type": "crew",
"definition": "../outside-crew.jsonc",
}
}
},
project_root=tmp_path,
)
def test_configured_project_definition_rejects_non_string_definition(
tmp_path: Path,
) -> None:
with pytest.raises(project.ProjectDefinitionError, match="must be a string"):
project.configured_project_definition(
"crew",
pyproject_data={
"tool": {
"crewai": {
"type": "crew",
"definition": ["crew.jsonc"],
}
}
},
project_root=tmp_path,
)
def test_configured_project_definition_rejects_empty_definition(
tmp_path: Path,
) -> None:
with pytest.raises(project.ProjectDefinitionError, match="non-empty"):
project.configured_project_definition(
"crew",
pyproject_data={
"tool": {
"crewai": {
"type": "crew",
"definition": " ",
}
}
},
project_root=tmp_path,
)
def test_core_telemetry_skips_duplicate_tracer_provider(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View File

@@ -8,6 +8,7 @@ import requests
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
from crewai_tools.rag.source_content import SourceContent
from crewai_tools.security.safe_requests import safe_get
class DocsSiteLoader(BaseLoader):
@@ -26,7 +27,7 @@ class DocsSiteLoader(BaseLoader):
docs_url = source.source
try:
response = requests.get(docs_url, timeout=30)
response = safe_get(docs_url, timeout=30)
response.raise_for_status()
except requests.RequestException as e:
raise ValueError(

View File

@@ -2,10 +2,9 @@ import os
import tempfile
from typing import Any
import requests
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
from crewai_tools.rag.source_content import SourceContent
from crewai_tools.security.safe_requests import safe_get
class DOCXLoader(BaseLoader):
@@ -43,7 +42,7 @@ class DOCXLoader(BaseLoader):
)
try:
response = requests.get(url, headers=headers, timeout=30)
response = safe_get(url, headers=headers, timeout=30)
response.raise_for_status()
# Create temporary file to save the DOCX content

View File

@@ -6,10 +6,9 @@ import tempfile
from typing import Any
from urllib.parse import urlparse
import requests
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
from crewai_tools.rag.source_content import SourceContent
from crewai_tools.security.safe_requests import safe_get
class PDFLoader(BaseLoader):
@@ -47,7 +46,7 @@ class PDFLoader(BaseLoader):
)
try:
response = requests.get(url, headers=headers, timeout=30)
response = safe_get(url, headers=headers, timeout=30)
response.raise_for_status()
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_file:

View File

@@ -23,7 +23,7 @@ def load_from_url(
Raises:
ValueError: If there's an error fetching the URL
"""
import requests
from crewai_tools.security.safe_requests import safe_get
headers = kwargs.get(
"headers",
@@ -34,7 +34,7 @@ def load_from_url(
)
try:
response = requests.get(url, headers=headers, timeout=30)
response = safe_get(url, headers=headers, timeout=30)
response.raise_for_status()
return response.text
except Exception as e:

View File

@@ -2,10 +2,10 @@ import re
from typing import Any, Final
from bs4 import BeautifulSoup
import requests
from crewai_tools.rag.base_loader import BaseLoader, LoaderResult
from crewai_tools.rag.source_content import SourceContent
from crewai_tools.security.safe_requests import safe_get
_SPACES_PATTERN: Final[re.Pattern[str]] = re.compile(r"[ \t]+")
@@ -25,7 +25,7 @@ class WebPageLoader(BaseLoader):
)
try:
response = requests.get(url, timeout=15, headers=headers)
response = safe_get(url, timeout=15, headers=headers)
response.encoding = response.apparent_encoding
soup = BeautifulSoup(response.text, "html.parser")

View File

@@ -0,0 +1,88 @@
"""HTTP helpers that preserve crewai-tools URL safety checks."""
from __future__ import annotations
from typing import Any
from urllib.parse import urljoin, urlparse
import requests
from crewai_tools.security.safe_path import validate_url
_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
_SENSITIVE_HEADER_NAMES = {
"authorization",
"cookie",
"proxy-authorization",
"x-api-key",
}
_SENSITIVE_HEADER_FRAGMENTS = ("api-key", "apikey", "secret", "token")
def _same_origin(previous_url: str, next_url: str) -> bool:
previous = urlparse(previous_url)
next_ = urlparse(next_url)
return (previous.scheme, previous.netloc) == (next_.scheme, next_.netloc)
def _is_sensitive_header(header: str) -> bool:
normalized = header.lower()
return (
normalized in _SENSITIVE_HEADER_NAMES
or normalized.startswith("authorization-")
or any(fragment in normalized for fragment in _SENSITIVE_HEADER_FRAGMENTS)
)
def _strip_cross_origin_credentials(request_kwargs: dict[str, Any]) -> dict[str, Any]:
sanitized = {**request_kwargs}
headers = sanitized.get("headers")
if headers:
sanitized["headers"] = {
key: value
for key, value in headers.items()
if not _is_sensitive_header(str(key))
}
sanitized.pop("cookies", None)
return sanitized
def safe_get(url: str, *, max_redirects: int = 10, **kwargs: Any) -> requests.Response:
"""GET a URL while validating each redirect target before following it."""
current_url = validate_url(url)
request_kwargs = {**kwargs, "allow_redirects": False}
timeout = request_kwargs.pop("timeout", 30)
history: list[requests.Response] = []
redirects_followed = 0
while True:
response = requests.get(current_url, timeout=timeout, **request_kwargs)
if (
response.status_code not in _REDIRECT_STATUS_CODES
or "Location" not in response.headers
):
response.history = history
return response
if redirects_followed >= max_redirects:
response.close()
raise ValueError(f"Too many redirects while fetching URL: {url}")
location = response.headers.get("Location")
if not location:
response.history = history
return response
try:
redirect_url = validate_url(urljoin(response.url, location))
except ValueError:
response.close()
raise
if not _same_origin(current_url, redirect_url):
request_kwargs = _strip_cross_origin_credentials(request_kwargs)
history.append(response)
current_url = redirect_url
redirects_followed += 1

View File

@@ -3,9 +3,8 @@ from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
import requests
from crewai_tools.security.safe_path import validate_url
from crewai_tools.security.safe_requests import safe_get
try:
@@ -83,8 +82,7 @@ class ScrapeElementFromWebsiteTool(BaseTool):
if website_url is None or css_element is None:
raise ValueError("Both website_url and css_element must be provided.")
website_url = validate_url(website_url)
page = requests.get(
page = safe_get(
website_url,
headers=self.headers,
cookies=self.cookies if self.cookies else {},

View File

@@ -3,9 +3,8 @@ import re
from typing import Any
from pydantic import Field
import requests
from crewai_tools.security.safe_path import validate_url
from crewai_tools.security.safe_requests import safe_get
try:
@@ -75,8 +74,7 @@ class ScrapeWebsiteTool(BaseTool):
if website_url is None:
raise ValueError("Website URL must be provided.")
website_url = validate_url(website_url)
page = requests.get(
page = safe_get(
website_url,
timeout=15,
headers=self.headers,

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import socket
from typing import Any
import pytest
@pytest.fixture(autouse=True)
def public_example_dns(monkeypatch: pytest.MonkeyPatch) -> None:
original_getaddrinfo = socket.getaddrinfo
def fake_getaddrinfo(
host: str, port: int, *args: Any, **kwargs: Any
) -> list[tuple[Any, ...]]:
if host in {"example.com", "api.example.com"}:
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
6,
"",
("93.184.216.34", port),
)
]
return original_getaddrinfo(host, port, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)

View File

@@ -0,0 +1,177 @@
"""Tests for redirect-aware safe HTTP helpers."""
from __future__ import annotations
import socket
from io import BytesIO
from typing import Any
import pytest
import requests
from crewai_tools.security.safe_requests import safe_get
def _response(url: str, status_code: int, *, location: str | None = None) -> requests.Response:
response = requests.Response()
response.status_code = status_code
response.url = url
response._content = b"ok"
response.raw = BytesIO()
if location is not None:
response.headers["Location"] = location
return response
@pytest.fixture
def public_dns(monkeypatch: pytest.MonkeyPatch) -> None:
original_getaddrinfo = socket.getaddrinfo
def fake_getaddrinfo(
host: str, port: int, *args: Any, **kwargs: Any
) -> list[tuple[Any, ...]]:
if host in {"public.example", "safe.example"}:
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
6,
"",
("93.184.216.34", port),
)
]
return original_getaddrinfo(host, port, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
def test_safe_get_blocks_direct_internal_url() -> None:
with pytest.raises(ValueError, match="private/reserved IP"):
safe_get("http://127.0.0.1/admin", timeout=15)
def _mock_get(monkeypatch: pytest.MonkeyPatch, get_response: Any) -> None:
monkeypatch.setattr(
"crewai_tools.security.safe_requests.requests.get",
get_response,
)
def test_safe_get_blocks_redirect_to_internal_url(
monkeypatch: pytest.MonkeyPatch, public_dns: None
) -> None:
requested_urls: list[str] = []
def fake_get(url: str, **kwargs: Any) -> requests.Response:
requested_urls.append(url)
assert kwargs["allow_redirects"] is False
return _response(url, 302, location="http://127.0.0.1/admin")
_mock_get(monkeypatch, fake_get)
with pytest.raises(ValueError, match="private/reserved IP"):
safe_get("http://public.example/start", timeout=15)
assert requested_urls == ["http://public.example/start"]
def test_safe_get_follows_safe_relative_redirect(
monkeypatch: pytest.MonkeyPatch, public_dns: None
) -> None:
requested_urls: list[str] = []
def fake_get(url: str, **kwargs: Any) -> requests.Response:
requested_urls.append(url)
assert kwargs["allow_redirects"] is False
if url == "http://public.example/start":
return _response(url, 302, location="/final")
return _response(url, 200)
_mock_get(monkeypatch, fake_get)
response = safe_get("http://public.example/start", timeout=15)
assert response.status_code == 200
assert response.url == "http://public.example/final"
assert requested_urls == [
"http://public.example/start",
"http://public.example/final",
]
assert len(response.history) == 1
def test_safe_get_fails_closed_after_too_many_redirects(
monkeypatch: pytest.MonkeyPatch, public_dns: None
) -> None:
def fake_get(url: str, **kwargs: Any) -> requests.Response:
return _response(url, 302, location="http://safe.example/again")
_mock_get(monkeypatch, fake_get)
with pytest.raises(ValueError, match="Too many redirects"):
safe_get("http://public.example/start", max_redirects=1, timeout=15)
def test_safe_get_strips_credentials_on_cross_origin_redirect(
monkeypatch: pytest.MonkeyPatch, public_dns: None
) -> None:
requests_made: list[tuple[str, dict[str, Any]]] = []
def fake_get(url: str, **kwargs: Any) -> requests.Response:
requests_made.append((url, kwargs))
if url == "http://public.example/start":
return _response(url, 302, location="http://safe.example/final")
return _response(url, 200)
_mock_get(monkeypatch, fake_get)
response = safe_get(
"http://public.example/start",
timeout=15,
headers={
"Authorization": "Bearer token",
"Authorization-Custom": "secret token",
"Cookie": "session=abc",
"X-API-Key": "api key",
"X-CrewAI-Token": "crewai token",
"User-Agent": "crewai-test",
},
cookies={"session": "abc"},
)
assert response.status_code == 200
assert requests_made[0][1]["headers"] == {
"Authorization": "Bearer token",
"Authorization-Custom": "secret token",
"Cookie": "session=abc",
"X-API-Key": "api key",
"X-CrewAI-Token": "crewai token",
"User-Agent": "crewai-test",
}
assert requests_made[0][1]["cookies"] == {"session": "abc"}
assert requests_made[1][1]["headers"] == {"User-Agent": "crewai-test"}
assert "cookies" not in requests_made[1][1]
def test_safe_get_preserves_credentials_on_same_origin_redirect(
monkeypatch: pytest.MonkeyPatch, public_dns: None
) -> None:
requests_made: list[tuple[str, dict[str, Any]]] = []
def fake_get(url: str, **kwargs: Any) -> requests.Response:
requests_made.append((url, kwargs))
if url == "http://public.example/start":
return _response(url, 302, location="/final")
return _response(url, 200)
_mock_get(monkeypatch, fake_get)
safe_get(
"http://public.example/start",
timeout=15,
headers={"Authorization": "Bearer token"},
cookies={"session": "abc"},
)
assert requests_made[1][1]["headers"] == {"Authorization": "Bearer token"}
assert requests_made[1][1]["cookies"] == {"session": "abc"}

View File

@@ -9,7 +9,6 @@ layer that may have produced it and of the engine that runs it (see
from __future__ import annotations
import json
import logging
from pathlib import Path
import re
@@ -780,19 +779,6 @@ class FlowDefinition(BaseModel):
"""Serialize the definition to a declaration-ready dictionary."""
return self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json")
def to_json(self, *, indent: int | None = 2, exclude_none: bool = True) -> str:
"""Serialize the definition to JSON."""
data = self.to_dict(exclude_none=exclude_none)
return json.dumps(data, indent=indent)
def to_yaml(self, *, exclude_none: bool = True) -> str:
"""Serialize the definition to YAML."""
return yaml.safe_dump(
self.to_dict(exclude_none=exclude_none),
sort_keys=False,
allow_unicode=True,
)
@property
def source_path(self) -> Path | None:
"""Original definition file path, when loaded from a file."""
@@ -805,17 +791,6 @@ class FlowDefinition(BaseModel):
return None
return self._source_path.parent
@classmethod
def from_dict(
cls, data: dict[str, Any], *, source_path: Path | None = None
) -> FlowDefinition:
"""Load a definition from a dictionary."""
definition = cls.model_validate(data)
if source_path is not None:
definition._source_path = source_path.expanduser().resolve()
log_flow_definition_issues(definition)
return definition
@classmethod
def from_declaration(
cls,
@@ -835,7 +810,7 @@ class FlowDefinition(BaseModel):
contents = source_path.expanduser().read_text(encoding="utf-8")
if isinstance(contents, dict):
return cls.from_dict(contents)
return cls._load_mapping(contents)
if not isinstance(contents, str):
raise TypeError("Flow declaration contents must be a string or dictionary")
@@ -848,12 +823,17 @@ class FlowDefinition(BaseModel):
loaded = yaml.safe_load(contents)
if not isinstance(loaded, dict):
raise ValueError("Flow declaration must contain a mapping")
return cls.from_dict(loaded, source_path=source_path)
return cls._load_mapping(loaded, source_path=source_path)
@classmethod
def json_schema(cls) -> dict[str, Any]:
"""Return the JSON Schema for the declarative Flow contract."""
return cls.model_json_schema(by_alias=True)
def _load_mapping(
cls, data: dict[str, Any], *, source_path: Path | None = None
) -> FlowDefinition:
definition = cls.model_validate(data)
if source_path is not None:
definition._source_path = source_path.expanduser().resolve()
log_flow_definition_issues(definition)
return definition
def _validate_step_name(name: str, *, field: str) -> None:

View File

@@ -480,11 +480,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
cls._flow_definition = flow_definition
return flow_definition
@classmethod
def from_definition(cls, definition: FlowDefinition, **kwargs: Any) -> Flow[Any]:
"""Build a runnable Flow directly from a definition; no subclass required."""
return cls.from_declaration(contents=definition, **kwargs)
@classmethod
def from_declaration(
cls,
@@ -604,7 +599,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
config: Checkpoint configuration with ``restore_from`` set to
the path of the checkpoint to load.
definition: The FlowDefinition to restore a definition-built flow
(one created via ``Flow.from_definition``) from; its actions
(one created via ``Flow.from_declaration``) from; its actions
are re-resolved since checkpoints carry no callables.
Subclasses carry their own definition and don't need this.
@@ -629,7 +624,9 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
entity._restore_from_checkpoint()
return entity
instance = (
cls.from_definition(definition) if definition is not None else cls()
cls.from_declaration(contents=definition)
if definition is not None
else cls()
)
instance.checkpoint_completed_methods = entity.checkpoint_completed_methods
instance.checkpoint_method_outputs = entity.checkpoint_method_outputs
@@ -1178,7 +1175,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
registered factory when present, else the built-in SQLite
fallback).
definition: The FlowDefinition to restore a definition-built flow
(one created via ``Flow.from_definition``) from. Subclasses
(one created via ``Flow.from_declaration``) from. Subclasses
carry their own definition and don't need this.
**kwargs: Additional keyword arguments passed to the Flow constructor
@@ -1212,7 +1209,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
state_data, pending_context = loaded
instance = (
cls.from_definition(definition, persistence=persistence, **kwargs)
cls.from_declaration(contents=definition, persistence=persistence, **kwargs)
if definition is not None
else cls(persistence=persistence, **kwargs)
)

View File

@@ -1,15 +1,16 @@
"""Memory reset utilities for CrewAI crews and flows."""
from pathlib import Path
import subprocess
from typing import Any
import click
from crewai_core.project import configured_project_definition, read_toml
from crewai.flow import Flow
from crewai.memory.unified_memory import Memory
from crewai.project.crew_loader import load_crew
from crewai.project.json_loader import find_crew_json_file
from crewai.utilities.project_utils import get_crews, get_flows, read_toml
from crewai.utilities.project_utils import get_crews, get_flows
def _reset_flow_memory(flow: Flow[Any]) -> None:
@@ -42,35 +43,20 @@ def _reset_flow_memory(flow: Flow[Any]) -> None:
click.echo(f"Memory reset skipped: {exc}", err=True)
def _current_project_declares_flow() -> bool:
try:
pyproject_data = read_toml()
except Exception:
return False
declared_type: str | None = (
pyproject_data.get("tool", {}).get("crewai", {}).get("type")
)
return declared_type == "flow"
def _configured_json_crew_path() -> Path | None:
if not Path("pyproject.toml").is_file():
return None
pyproject_data = read_toml()
return configured_project_definition("crew", pyproject_data=pyproject_data)
def _get_json_crew() -> Any | None:
"""Load a JSON-first crew from the current project, if present."""
if _current_project_declares_flow():
return None
crew_path = find_crew_json_file()
crew_path = _configured_json_crew_path()
if crew_path is None:
return None
try:
crew, _ = load_crew(crew_path)
except Exception as exc:
click.echo(
f"Skipping JSON crew at {crew_path}: failed to load ({exc}).",
err=True,
)
return None
crew, _ = load_crew(crew_path)
return crew
@@ -151,3 +137,4 @@ def reset_memories_command(
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)
raise SystemExit(1) from e

View File

@@ -240,6 +240,9 @@ def test_reset_no_crew_or_flow_found(runner):
def test_reset_json_crew_memory(mock_crew, runner, monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"\n'
)
with mock.patch(
"crewai.utilities.reset_memories.get_crews", return_value=[]
@@ -251,16 +254,19 @@ def test_reset_json_crew_memory(mock_crew, runner, monkeypatch, tmp_path):
) as mock_load_crew:
result = runner.invoke(reset_memories, ["-m"])
mock_load_crew.assert_called_once_with(Path("crew.jsonc"))
mock_load_crew.assert_called_once_with((tmp_path / "crew.jsonc").resolve())
mock_crew.reset_memories.assert_called_once_with(command_type="memory")
assert f"[Crew ({mock_crew.name})] Memory has been reset." in result.output
def test_reset_invalid_json_crew_does_not_block_classic_crew(
def test_reset_invalid_json_crew_blocks_reset(
mock_crew, runner, monkeypatch, tmp_path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{invalid")
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "crew"\ndefinition = "crew.jsonc"\n'
)
with mock.patch(
"crewai.utilities.reset_memories.get_crews", return_value=[mock_crew]
@@ -272,10 +278,10 @@ def test_reset_invalid_json_crew_does_not_block_classic_crew(
) as mock_load_crew:
result = runner.invoke(reset_memories, ["-m"])
mock_load_crew.assert_called_once_with(Path("crew.jsonc"))
mock_crew.reset_memories.assert_called_once_with(command_type="memory")
assert "Skipping JSON crew at crew.jsonc: failed to load (invalid JSON)." in result.output
assert f"[Crew ({mock_crew.name})] Memory has been reset." in result.output
mock_load_crew.assert_called_once_with((tmp_path / "crew.jsonc").resolve())
mock_crew.reset_memories.assert_not_called()
assert result.exit_code != 0
assert "An unexpected error occurred: invalid JSON" in result.output
def test_reset_json_crew_skipped_for_declared_flow_project(

View File

@@ -65,7 +65,7 @@ def test_flow_public_exports_are_explicit():
def test_flow_definition_json_schema_carries_reference_descriptions():
schema = flow_definition.FlowDefinition.json_schema()
schema = flow_definition.FlowDefinition.model_json_schema(by_alias=True)
defs = schema["$defs"]
assert schema["properties"]["schema"]["description"]
@@ -120,7 +120,7 @@ def test_flow_definition_json_schema_carries_reference_descriptions():
def test_flow_definition_json_schema_carries_field_examples_only():
schema = flow_definition.FlowDefinition.json_schema()
schema = flow_definition.FlowDefinition.model_json_schema(by_alias=True)
defs = schema["$defs"]
for model_name in [
@@ -437,7 +437,7 @@ def test_flow_definition_uses_collapsed_conversational_router_start():
assert methods["route_conversation"].router is True
def test_flow_definition_serializes_human_feedback_metadata(caplog):
def test_flow_definition_degrades_human_feedback_metadata(caplog):
caplog.set_level(logging.WARNING, logger="crewai.flow.dsl._utils")
marker = object()
@@ -461,7 +461,7 @@ def test_flow_definition_serializes_human_feedback_metadata(caplog):
and "not fully serializable" in record.message
for record in caplog.records
)
definition.to_json()
definition.to_dict()
def test_flow_definition_fragments_cover_start_listen_and_condition_sugar():
@@ -613,7 +613,7 @@ def test_flow_definition_merges_stacked_listen_router():
assert methods["second_router"].emit == ["second_approval", "not_approved"]
def test_flow_definition_round_trips_declaration_serialization():
def test_flow_definition_from_declaration_accepts_json_and_yaml_strings():
class RoundTripFlow(Flow):
@start()
def begin(self):
@@ -627,17 +627,67 @@ def test_flow_definition_round_trips_declaration_serialization():
def left(self):
return "left"
definition = RoundTripFlow.flow_definition()
round_trips = [
flow_definition.FlowDefinition.from_declaration(contents=definition.to_json()),
flow_definition.FlowDefinition.from_declaration(contents=definition.to_yaml()),
expected = RoundTripFlow.flow_definition()
declarations = [
"""
{
"schema": "crewai.flow/v1",
"name": "RoundTripFlow",
"methods": {
"begin": {
"start": true,
"do": {
"call": "code",
"ref": "test_flow_definition:RoundTripFlow.begin"
}
},
"decide": {
"listen": "begin",
"router": true,
"do": {
"call": "code",
"ref": "test_flow_definition:RoundTripFlow.decide"
}
},
"left": {
"listen": "left",
"do": {
"call": "code",
"ref": "test_flow_definition:RoundTripFlow.left"
}
}
}
}
""",
"""
schema: crewai.flow/v1
name: RoundTripFlow
methods:
begin:
start: true
do:
call: code
ref: test_flow_definition:RoundTripFlow.begin
decide:
listen: begin
router: true
do:
call: code
ref: test_flow_definition:RoundTripFlow.decide
left:
listen: left
do:
call: code
ref: test_flow_definition:RoundTripFlow.left
""",
]
for round_trip in round_trips:
assert round_trip.to_dict() == definition.to_dict()
assert round_trip.methods["decide"].router is True
assert round_trip.methods["decide"].listen == "begin"
for declaration in declarations:
loaded = flow_definition.FlowDefinition.from_declaration(contents=declaration)
assert loaded.name == expected.name
assert loaded.methods["decide"].router is True
assert loaded.methods["decide"].listen == "begin"
def test_flow_definition_from_declaration_accepts_contents():
@@ -654,20 +704,41 @@ def test_flow_definition_from_declaration_accepts_contents():
},
},
}
definition = flow_definition.FlowDefinition.from_dict(data)
definition = flow_definition.FlowDefinition.from_declaration(contents=data)
contents = [
definition,
data,
definition.to_json(),
definition.to_yaml(),
"""
{
"schema": "crewai.flow/v1",
"name": "DeclarationFlow",
"methods": {
"begin": {
"start": true,
"do": {
"call": "expression",
"expr": "'started'"
}
}
}
}
""",
"""
schema: crewai.flow/v1
name: DeclarationFlow
methods:
begin:
start: true
do:
call: expression
expr: "'started'"
""",
]
expected = definition.to_dict()
for content in contents:
loaded = flow_definition.FlowDefinition.from_declaration(contents=content)
assert loaded.to_dict() == expected
assert loaded.to_dict() == definition.to_dict()
def test_flow_definition_from_declaration_rejects_empty_file(tmp_path: Path):
declaration_path = tmp_path / "flow.crewai"
@@ -686,7 +757,7 @@ def test_flow_definition_from_declaration_rejects_falsey_non_mapping_contents(
def test_flow_definition_from_declaration_accepts_paths(tmp_path: Path):
definition = flow_definition.FlowDefinition.from_dict(
definition = flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "DeclarationFlow",
@@ -702,7 +773,19 @@ def test_flow_definition_from_declaration_accepts_paths(tmp_path: Path):
}
)
declaration_path = tmp_path / "flow.crewai"
declaration_path.write_text(definition.to_yaml(), encoding="utf-8")
declaration_path.write_text(
"""
schema: crewai.flow/v1
name: DeclarationFlow
methods:
begin:
start: true
do:
call: expression
expr: "'started'"
""",
encoding="utf-8",
)
path_inputs = [
declaration_path,
str(declaration_path),
@@ -711,7 +794,9 @@ def test_flow_definition_from_declaration_accepts_paths(tmp_path: Path):
for path_input in path_inputs:
loaded = flow_definition.FlowDefinition.from_declaration(path=path_input)
assert loaded.to_dict() == definition.to_dict()
assert loaded.name == definition.name
assert loaded.methods["begin"].is_start is True
assert loaded.methods["begin"].do.call == "expression"
assert loaded.source_path == declaration_path.resolve()
@@ -744,8 +829,8 @@ def test_flow_definition_from_declaration_prefers_contents_over_path(
assert loaded.source_path is None
def test_each_action_round_trips_declaration_serialization():
definition = flow_definition.FlowDefinition.from_dict(
def test_each_action_loads_from_declaration():
definition = flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
@@ -783,22 +868,13 @@ def test_each_action_round_trips_declaration_serialization():
}
)
round_trips = [
flow_definition.FlowDefinition.from_declaration(contents=definition.to_json()),
flow_definition.FlowDefinition.from_declaration(contents=definition.to_yaml()),
]
for round_trip in round_trips:
assert round_trip.to_dict() == definition.to_dict()
assert round_trip.methods["process_rows"].description == (
"Process every loaded row."
)
assert round_trip.methods["process_rows"].do.call == "each"
assert definition.methods["process_rows"].description == "Process every loaded row."
assert definition.methods["process_rows"].do.call == "each"
def test_flow_definition_rejects_invalid_method_names():
with pytest.raises(ValueError, match="Flow method names must match"):
flow_definition.FlowDefinition.from_dict(
flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "InvalidMethodNameFlow",
@@ -1009,7 +1085,7 @@ def test_flow_definition_accepts_explicit_router_events():
def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract():
definition = flow_definition.FlowDefinition.from_dict(
definition = flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "LoadedDiagnosticsFlow",
@@ -1042,7 +1118,7 @@ def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract():
def test_router_start_false_without_listen_is_allowed(caplog):
caplog.set_level(logging.ERROR, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "LoadedFlow",
@@ -1118,7 +1194,7 @@ def test_dynamic_router_string_listener_is_valid_contract():
def test_static_string_listener_is_allowed_by_contract():
definition = flow_definition.FlowDefinition.from_dict(
definition = flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "TypoFlow",
@@ -1138,7 +1214,7 @@ def test_static_string_listener_is_allowed_by_contract():
def test_start_false_not_classified_as_start_method():
definition = flow_definition.FlowDefinition.from_dict(
definition = flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ExplicitNonStartFlow",
@@ -1202,7 +1278,7 @@ def test_flow_definition_cache_is_not_reused_by_subclasses():
def test_flow_definition_allows_router_without_trigger(caplog):
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
flow_definition.FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "LoadedFlow",

View File

@@ -477,7 +477,7 @@ def assert_parity(flow_cls, yaml_str, inputs=None, ordered=True):
class_result, class_events = _run_with_events(class_flow, inputs)
definition = FlowDefinition.from_declaration(contents=yaml_str)
definition_flow = Flow.from_definition(definition)
definition_flow = Flow.from_declaration(contents=definition)
definition_result, definition_events = _run_with_events(definition_flow, inputs)
assert definition_result == class_result
@@ -537,7 +537,7 @@ def test_cyclic_flow_parity():
def test_definition_flow_events_use_definition_name():
definition = FlowDefinition.from_declaration(contents=CHAIN_YAML)
flow = Flow.from_definition(definition)
flow = Flow.from_declaration(contents=definition)
_, events = _run_with_events(flow)
assert events
assert all(flow_name == "ChainFlow" for _, _, flow_name in events)
@@ -545,7 +545,7 @@ def test_definition_flow_events_use_definition_name():
def test_definition_method_without_action_is_invalid():
with pytest.raises(ValidationError, match="do"):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "NoActions",
@@ -554,8 +554,8 @@ def test_definition_method_without_action_is_invalid():
)
def test_from_definition_unresolvable_ref_raises():
definition = FlowDefinition.from_dict(
def test_from_declaration_unresolvable_ref_raises():
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "BadRefs",
@@ -569,11 +569,11 @@ def test_from_definition_unresolvable_ref_raises():
)
with pytest.raises(ValueError, match="unresolvable actions.*begin"):
Flow.from_definition(definition)
Flow.from_declaration(contents=definition)
def test_from_definition_malformed_ref_raises():
definition = FlowDefinition.from_dict(
def test_from_declaration_malformed_ref_raises():
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "MalformedRefs",
@@ -582,11 +582,11 @@ def test_from_definition_malformed_ref_raises():
)
with pytest.raises(ValueError, match="expected 'module:qualname'"):
Flow.from_definition(definition)
Flow.from_declaration(contents=definition)
def test_from_definition_local_scope_ref_raises():
definition = FlowDefinition.from_dict(
def test_from_declaration_local_scope_ref_raises():
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "LocalRefs",
@@ -600,7 +600,7 @@ def test_from_definition_local_scope_ref_raises():
)
with pytest.raises(ValueError, match="expected 'module:qualname'"):
Flow.from_definition(definition)
Flow.from_declaration(contents=definition)
def test_flow_definition_stamps_refs():
@@ -610,7 +610,7 @@ def test_flow_definition_stamps_refs():
assert definition.methods["shout"].do.ref == f"{__name__}:ChainFlow.shout"
def test_from_definition_runs_tool_action_with_static_inputs():
def test_from_declaration_runs_tool_action_with_static_inputs():
yaml_str = f"""
schema: crewai.flow/v1
name: ToolFlow
@@ -625,13 +625,13 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff() == "found:ai agents"
def test_tool_action_round_trips_with_inputs():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
@@ -648,12 +648,12 @@ def test_tool_action_round_trips_with_inputs():
}
)
assert definition.to_dict()["methods"]["search"]["do"] == {
"call": "tool",
"ref": f"{__name__}:StaticSearchTool",
"with": {"search_query": "ai agents"},
}
assert Flow.from_definition(definition).kickoff() == "search:ai agents"
action = definition.methods["search"].do
assert action.call == "tool"
assert action.ref == f"{__name__}:StaticSearchTool"
assert action.with_ == {"search_query": "ai agents"}
assert Flow.from_declaration(contents=definition).kickoff() == "search:ai agents"
def test_tool_action_renders_cel_inputs_at_runtime():
@@ -676,13 +676,13 @@ methods:
listen: begin
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"topic": "ai"}) == "found:ai agents"
def test_tool_action_treats_embedded_cel_marker_as_literal():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
@@ -702,11 +702,11 @@ def test_tool_action_treats_embedded_cel_marker_as_literal():
}
)
assert Flow.from_definition(definition).kickoff() == "p}x:wrapped ${'a}b'} value"
assert Flow.from_declaration(contents=definition).kickoff() == "p}x:wrapped ${'a}b'} value"
def test_tool_action_treats_marker_with_trailing_text_as_literal():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
@@ -726,12 +726,12 @@ def test_tool_action_treats_marker_with_trailing_text_as_literal():
}
)
assert Flow.from_definition(definition).kickoff() == "p:${state.topic} extra"
assert Flow.from_declaration(contents=definition).kickoff() == "p:${state.topic} extra"
def test_tool_action_rejects_adjacent_markers_as_invalid_cel():
with pytest.raises(ValidationError, match="invalid CEL expression"):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
@@ -753,7 +753,7 @@ def test_tool_action_rejects_adjacent_markers_as_invalid_cel():
def test_tool_action_accepts_braces_in_full_cel_marker():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
@@ -773,7 +773,7 @@ def test_tool_action_accepts_braces_in_full_cel_marker():
}
)
assert Flow.from_definition(definition).kickoff() == "p}x:ai agents"
assert Flow.from_declaration(contents=definition).kickoff() == "p}x:ai agents"
def test_tool_action_renders_latest_output_by_method_name():
@@ -795,7 +795,7 @@ methods:
listen: begin
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff() == "search:hello agents"
@@ -820,7 +820,7 @@ methods:
listen: build_query
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff() == "found:ai agents news"
@@ -840,7 +840,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert (
flow.kickoff(inputs={"limit": 2, "domains": ["crewai.com", "example.com"]})
@@ -873,7 +873,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"question": "What is CrewAI?"}) == {
"agent": "Analyst",
@@ -911,7 +911,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"questions": ["one", "two"]}) == [
"Analyst:one",
@@ -920,7 +920,7 @@ methods:
def test_agent_action_round_trips_with_inline_definition():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "AgentFlow",
@@ -942,17 +942,16 @@ def test_agent_action_round_trips_with_inline_definition():
}
)
round_trip = FlowDefinition.from_declaration(contents=definition.to_yaml())
action = round_trip.to_dict()["methods"]["answer"]["do"]
action = definition.methods["answer"].do
assert action["call"] == "agent"
assert action["with"]["role"] == "Analyst"
assert action["with"]["input"] == "${state.question}"
assert action["with"]["settings"] == {"verbose": True}
assert action.call == "agent"
assert action.with_.role == "Analyst"
assert action.with_.input == "${state.question}"
assert action.with_.settings == {"verbose": True}
def test_agent_action_json_schema_describes_inline_agent_definitions():
schema_defs = FlowDefinition.json_schema()["$defs"]
schema_defs = FlowDefinition.model_json_schema(by_alias=True)["$defs"]
assert set(schema_defs["AgentDefinition"]["properties"]) >= {
"role",
@@ -966,7 +965,7 @@ def test_agent_action_json_schema_describes_inline_agent_definitions():
def test_agent_action_rejects_non_string_input_in_definition():
with pytest.raises(ValidationError, match="agent.input must be a string"):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "AgentFlow",
@@ -1047,7 +1046,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "inline_research",
@@ -1123,7 +1122,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "referenced_research",
@@ -1197,7 +1196,7 @@ methods:
other_cwd.mkdir()
monkeypatch.chdir(other_cwd)
flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path))
flow = Flow.from_declaration(path=flow_path)
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "relative_research",
@@ -1222,7 +1221,7 @@ methods:
"""
flow_path.write_text(yaml_str, encoding="utf-8")
flow = Flow.from_definition(FlowDefinition.from_declaration(path=flow_path))
flow = Flow.from_declaration(path=flow_path)
with pytest.raises(
ValueError,
@@ -1232,7 +1231,7 @@ methods:
def test_crew_action_round_trips_with_inline_definition():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
@@ -1266,20 +1265,16 @@ def test_crew_action_round_trips_with_inline_definition():
}
)
assert definition.to_dict()["methods"]["research"]["do"]["call"] == "crew"
assert (
definition.to_dict()["methods"]["research"]["do"]["with"]["agents"][
"researcher"
]["role"]
== "Researcher"
)
assert definition.to_dict()["methods"]["research"]["do"]["inputs"] == {
"topic": "${state.topic}"
}
action = definition.methods["research"].do
assert action.call == "crew"
assert action.with_ is not None
assert action.with_.agents["researcher"].role == "Researcher"
assert action.inputs == {"topic": "${state.topic}"}
def test_crew_action_normalizes_named_agent_list_definition():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
@@ -1311,16 +1306,15 @@ def test_crew_action_normalizes_named_agent_list_definition():
}
)
assert (
definition.to_dict()["methods"]["research"]["do"]["with"]["agents"][
"researcher"
]["role"]
== "Researcher"
)
action = definition.methods["research"].do
assert action.call == "crew"
assert action.with_ is not None
assert action.with_.agents["researcher"].role == "Researcher"
def test_crew_action_json_schema_describes_inline_crew_definitions():
schema_defs = FlowDefinition.json_schema()["$defs"]
schema_defs = FlowDefinition.model_json_schema(by_alias=True)["$defs"]
agents_schema = schema_defs["CrewDefinition"]["properties"]["agents"]
assert set(schema_defs["CrewDefinition"]["properties"]) >= {
@@ -1345,7 +1339,7 @@ def test_crew_action_json_schema_describes_inline_crew_definitions():
def test_crew_action_rejects_incomplete_inline_agent_definition():
with pytest.raises(ValidationError, match="goal"):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
@@ -1378,7 +1372,7 @@ def test_crew_action_rejects_incomplete_inline_agent_definition():
def test_crew_action_rejects_python_ref_field():
with pytest.raises(ValidationError, match="ref"):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
@@ -1397,7 +1391,7 @@ def test_crew_action_rejects_python_ref_field():
def test_crew_action_rejects_non_mapping_inputs_in_definition():
with pytest.raises(ValidationError, match="crew.inputs must be a mapping"):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
@@ -1463,7 +1457,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"name": "hello"}) == "hello!"
@@ -1482,7 +1476,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"value": "ok"}) == "callable:ok"
@@ -1506,7 +1500,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
"normalized:a",
@@ -1533,7 +1527,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
caller_thread_id = threading.get_ident()
assert flow.kickoff(inputs={"rows": ["a"]}) == ["process_rows:a"]
@@ -1560,7 +1554,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["async:a", "async:b"]
@@ -1582,7 +1576,7 @@ methods:
FlowScriptExecutionDisabledError,
match="CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION=1",
) as exc_info:
Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
Flow.from_declaration(contents=yaml_str)
assert "methods with unresolvable actions" not in str(exc_info.value)
@@ -1606,7 +1600,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"raw_score": 3.2}) == "rounded:4"
assert flow.state["rounded"] == 4
@@ -1635,7 +1629,7 @@ methods:
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff() == "alpha:alpha"
assert flow.state["input_matches_output"] is True
@@ -1673,7 +1667,7 @@ methods:
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"rows": [" a ", " b "]}) == ["global:a", "global:b"]
@@ -1705,7 +1699,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
{"row": "a", "normalized": "saved:a"},
@@ -1734,7 +1728,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["a", "b"]
assert flow._method_outputs == [
@@ -1772,7 +1766,7 @@ methods:
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
"local:a",
@@ -1811,7 +1805,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(
inputs={
@@ -1845,7 +1839,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(inputs={"rows": [{"kind": "keep", "value": "a"}]}) == ["a"]
@@ -1872,7 +1866,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert flow.kickoff(
inputs={
@@ -1902,7 +1896,7 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
with pytest.raises(ValueError, match="if expression must evaluate to a boolean"):
flow.kickoff(inputs={"rows": [{"value": "truthy"}]})
@@ -1932,7 +1926,7 @@ methods:
listen: process_rows
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
events = []
with crewai_event_bus.scoped_handlers():
@@ -1958,7 +1952,7 @@ methods:
],
)
def test_each_action_rejects_non_list_inputs(expr, inputs):
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
@@ -1979,7 +1973,7 @@ def test_each_action_rejects_non_list_inputs(expr, inputs):
},
}
)
flow = Flow.from_definition(definition)
flow = Flow.from_declaration(contents=definition)
with pytest.raises(ValueError, match="each.in must evaluate to an array"):
flow.kickoff(inputs=inputs)
@@ -2009,7 +2003,7 @@ def test_each_action_rejects_non_list_inputs(expr, inputs):
)
def test_each_action_validates_step_shape(action_do):
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
@@ -2029,7 +2023,7 @@ def test_each_action_validates_step_shape(action_do):
def test_if_clauses_are_rejected_at_method_level():
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "TopLevelIfFlow",
@@ -2049,7 +2043,7 @@ def test_if_clauses_are_rejected_at_method_level():
def test_each_action_rejects_nested_each_actions():
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
@@ -2103,14 +2097,14 @@ methods:
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
with pytest.raises(RuntimeError, match="bad row"):
flow.kickoff(inputs={"rows": ["ok", "bad"]})
def test_expression_action_round_trips():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ExpressionFlow",
@@ -2126,15 +2120,15 @@ def test_expression_action_round_trips():
}
)
assert definition.to_dict()["methods"]["classify"]["do"] == {
"call": "expression",
"expr": "state.score >= 80 ? 'qualified' : 'nurture'",
}
assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified"
action = definition.methods["classify"].do
assert action.call == "expression"
assert action.expr == "state.score >= 80 ? 'qualified' : 'nurture'"
assert Flow.from_declaration(contents=definition).kickoff(inputs={"score": 90}) == "qualified"
def test_explicit_cel_fields_accept_expression_markers():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ExpressionFlow",
@@ -2150,7 +2144,7 @@ def test_explicit_cel_fields_accept_expression_markers():
}
)
assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified"
assert Flow.from_declaration(contents=definition).kickoff(inputs={"score": 90}) == "qualified"
def test_expression_local_context_recurses_into_dataclass_values():
@@ -2226,10 +2220,10 @@ methods:
definition = FlowDefinition.from_declaration(contents=yaml_str)
assert Flow.from_definition(definition).kickoff(
assert Flow.from_declaration(contents=definition).kickoff(
inputs={"direction": "left"}
) == "took-left"
assert Flow.from_definition(definition).kickoff(
assert Flow.from_declaration(contents=definition).kickoff(
inputs={"direction": "right"}
) == "took-right"
@@ -2267,7 +2261,7 @@ methods:
def test_tool_action_requires_module_qualname_ref():
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
@@ -2285,7 +2279,7 @@ def test_tool_action_requires_module_qualname_ref():
)
with pytest.raises(ValueError, match="expected 'module:qualname'"):
Flow.from_definition(definition)
Flow.from_declaration(contents=definition)
def test_pydantic_state_from_ref_parity():
@@ -2297,7 +2291,7 @@ def test_pydantic_state_from_ref_parity():
def test_pydantic_state_default_overlay():
flow = Flow.from_definition(
flow = Flow.from_declaration(contents=
FlowDefinition.from_declaration(contents=PYDANTIC_STATE_OVERLAY_YAML)
)
result = flow.kickoff()
@@ -2306,7 +2300,7 @@ def test_pydantic_state_default_overlay():
def test_json_schema_state():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=JSON_SCHEMA_STATE_YAML))
flow = Flow.from_declaration(contents=JSON_SCHEMA_STATE_YAML)
result = flow.kickoff()
assert result == "count=1"
assert flow.state.count == 1
@@ -2315,13 +2309,13 @@ def test_json_schema_state():
def test_json_schema_state_validates_inputs():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=JSON_SCHEMA_STATE_YAML))
flow = Flow.from_declaration(contents=JSON_SCHEMA_STATE_YAML)
with pytest.raises(ValueError, match="Invalid inputs"):
flow.kickoff(inputs={"count": "not-a-number"})
def test_json_schema_state_required_fields_can_come_from_kickoff_inputs():
flow = Flow.from_definition(
flow = Flow.from_declaration(contents=
FlowDefinition.from_declaration(contents=JSON_SCHEMA_REQUIRED_INPUT_STATE_YAML)
)
@@ -2333,7 +2327,7 @@ def test_json_schema_state_required_fields_can_come_from_kickoff_inputs():
def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable():
flow = Flow.from_definition(
flow = Flow.from_declaration(contents=
FlowDefinition.from_declaration(contents=PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML)
)
result = flow.kickoff()
@@ -2343,7 +2337,7 @@ def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable():
def test_pydantic_state_without_ref_or_schema_falls_back_to_dict(caplog):
with caplog.at_level("ERROR"):
flow = Flow.from_definition(
flow = Flow.from_declaration(contents=
FlowDefinition.from_declaration(contents=UNRESOLVABLE_STATE_YAML)
)
assert "falling back to dict state" in caplog.text
@@ -2357,13 +2351,13 @@ def test_pydantic_state_without_ref_or_schema_falls_back_to_dict(caplog):
def test_dict_state_is_a_copy_of_default_plus_id():
definition = FlowDefinition.from_declaration(contents=DICT_STATE_YAML)
flow = Flow.from_definition(definition)
flow = Flow.from_declaration(contents=definition)
assert flow.state["count"] == 5
assert flow.state["id"]
flow.kickoff()
assert flow.state["begin_ran"] is True
second = Flow.from_definition(definition)
second = Flow.from_declaration(contents=definition)
assert second.state["count"] == 5
assert "begin_ran" not in second.state
assert second.state["id"] != flow.state["id"]
@@ -2372,7 +2366,7 @@ def test_dict_state_is_a_copy_of_default_plus_id():
def test_unknown_state_type_falls_back_to_dict(caplog):
with caplog.at_level("WARNING"):
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=UNKNOWN_STATE_YAML))
flow = Flow.from_declaration(contents=UNKNOWN_STATE_YAML)
assert "falling back to dict state" in caplog.text
result = flow.kickoff()
@@ -2445,7 +2439,7 @@ def _run_capturing_flow_lifecycle(yaml_str, event_types):
def capture(source, event):
events.append(event)
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
result = flow.kickoff()
return flow, result, events
@@ -2483,13 +2477,13 @@ def test_config_suppress_flow_events_from_declaration():
def test_config_max_method_calls_from_declaration():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=CAPPED_LOOP_YAML))
flow = Flow.from_declaration(contents=CAPPED_LOOP_YAML)
with pytest.raises(RecursionError, match="has been called 2 times"):
flow.kickoff()
def test_config_stream_from_declaration():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=STREAMING_CHAIN_YAML))
flow = Flow.from_declaration(contents=STREAMING_CHAIN_YAML)
streaming = flow.kickoff()
assert isinstance(streaming, FlowStreamingOutput)
for _ in streaming:
@@ -2521,24 +2515,24 @@ config:
location: {tmp_path}
"""
)
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
assert isinstance(flow.checkpoint, CheckpointConfig)
assert flow.checkpoint.location == str(tmp_path)
def test_config_input_provider_from_declaration():
flow = Flow.from_definition(
flow = Flow.from_declaration(contents=
FlowDefinition.from_declaration(contents=INPUT_PROVIDER_CHAIN_YAML)
)
assert isinstance(flow.input_provider, StubInputProvider)
def test_round_trip_config_equivalence():
def test_definition_config_equivalence():
class_flow = ConfiguredFlow()
definition = FlowDefinition.from_declaration(
contents=ConfiguredFlow.flow_definition().to_yaml()
contents=ConfiguredFlow.flow_definition()
)
definition_flow = Flow.from_definition(definition)
definition_flow = Flow.from_declaration(contents=definition)
assert definition.config.suppress_flow_events is True
assert definition.config.max_method_calls == 5
@@ -2555,7 +2549,7 @@ def test_round_trip_config_equivalence():
def test_unknown_schema_rejected():
with pytest.raises(ValidationError, match="schema"):
FlowDefinition.from_dict(
FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v2",
"name": "FutureSchema",
@@ -2709,7 +2703,7 @@ class MethodPersistedFlow(Flow):
def test_flow_level_persist_from_declaration_saves_once_per_method():
yaml_str = _flow_level_persist_yaml("yaml-flow-level")
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
result = flow.kickoff()
assert result == "two"
@@ -2721,7 +2715,7 @@ def test_flow_level_persist_from_declaration_saves_once_per_method():
def test_method_level_persist_from_declaration_saves_only_that_method():
yaml_str = _method_level_persist_yaml("yaml-method-level")
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
flow.kickoff()
assert _saved_methods("yaml-method-level") == ["first"]
@@ -2750,7 +2744,7 @@ methods:
persist:
enabled: false
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
flow.kickoff()
assert _saved_methods("yaml-opt-out") == ["first"]
@@ -2759,11 +2753,11 @@ methods:
def test_persist_restore_by_id_from_declaration():
yaml_str = _flow_level_persist_yaml("yaml-restore")
flow1 = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow1 = Flow.from_declaration(contents=yaml_str)
flow1.kickoff()
assert flow1.state["count"] == 2
flow2 = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow2 = Flow.from_declaration(contents=yaml_str)
flow2.kickoff(inputs={"id": flow1.state["id"]})
assert flow2.state["count"] == 4
@@ -2782,13 +2776,13 @@ def test_method_level_persist_decorator_saves_only_that_method():
assert _saved_methods("method-decorator")[before:] == ["first"]
def test_round_trip_persist_equivalence():
def test_definition_persist_equivalence():
definition = FlowDefinition.from_declaration(
contents=ClassPersistedFlow.flow_definition().to_yaml()
contents=ClassPersistedFlow.flow_definition()
)
before = len(DefinitionStoreBackend.saves["class-decorator"])
flow = Flow.from_definition(definition)
flow = Flow.from_declaration(contents=definition)
flow.kickoff()
assert _saved_methods("class-decorator")[before:] == ["first", "second"]
@@ -2818,7 +2812,7 @@ methods:
persistence_type: DefinitionStoreBackend
store: yaml-mixed-method
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
flow.kickoff()
assert _saved_methods("yaml-mixed-flow") == ["first"]
@@ -2967,7 +2961,7 @@ methods:
def test_human_feedback_from_declaration_default_outcome_routes():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=REVIEW_YAML))
flow = Flow.from_declaration(contents=REVIEW_YAML)
with patch.object(flow, "_request_human_feedback", return_value="") as request:
result = flow.kickoff()
@@ -2979,7 +2973,7 @@ def test_human_feedback_from_declaration_default_outcome_routes():
def test_human_feedback_from_declaration_collapses_and_routes():
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=REVIEW_YAML))
flow = Flow.from_declaration(contents=REVIEW_YAML)
with (
patch.object(flow, "_request_human_feedback", return_value="ship it"),
@@ -2991,13 +2985,13 @@ def test_human_feedback_from_declaration_collapses_and_routes():
assert [r.outcome for r in flow.human_feedback_history] == ["approved"]
def test_round_trip_human_feedback_equivalence():
def test_definition_human_feedback_equivalence():
class_flow = ReviewFlow()
with patch.object(class_flow, "_request_human_feedback", return_value=""):
class_result = class_flow.kickoff()
definition = FlowDefinition.from_declaration(contents=ReviewFlow.flow_definition().to_yaml())
twin = Flow.from_definition(definition)
definition = FlowDefinition.from_declaration(contents=ReviewFlow.flow_definition())
twin = Flow.from_declaration(contents=definition)
with patch.object(twin, "_request_human_feedback", return_value=""):
twin_result = twin.kickoff()
@@ -3012,7 +3006,7 @@ def test_round_trip_human_feedback_equivalence():
def test_human_feedback_pending_and_resume_from_declaration():
definition = FlowDefinition.from_declaration(contents=PENDING_REVIEW_YAML)
flow = Flow.from_definition(definition)
flow = Flow.from_declaration(contents=definition)
pending = flow.kickoff()
assert isinstance(pending, HumanFeedbackPending)
@@ -3057,7 +3051,7 @@ methods:
return "from-config"
provider = RecordingProvider()
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
previous = flow_config.hitl_provider
flow_config.hitl_provider = provider
@@ -3160,7 +3154,7 @@ methods:
message: "Review:"
provider: {__name__}:_NeedsArgsProvider
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
with pytest.raises(
ValueError, match="cannot instantiate human_feedback.provider ref"
@@ -3181,7 +3175,7 @@ methods:
message: "Review:"
provider: missing_module_xyz:Provider
"""
flow = Flow.from_definition(FlowDefinition.from_declaration(contents=yaml_str))
flow = Flow.from_declaration(contents=yaml_str)
with pytest.raises(
ValueError, match="unresolvable human_feedback.provider ref"
@@ -3194,7 +3188,7 @@ def _checkpoint_chain_flow(tmp_path):
from crewai.state.runtime import RuntimeState
definition = FlowDefinition.from_declaration(contents=CHAIN_YAML)
flow = Flow.from_definition(definition)
flow = Flow.from_declaration(contents=definition)
result = flow.kickoff()
assert result == "confirmed:True"

View File

@@ -80,7 +80,7 @@ class ComplexFlow(Flow):
def _attach_flow_definition(
flow_class: type[Flow], methods: dict[str, dict[str, object]]
) -> None:
flow_class._flow_definition = FlowDefinition.from_dict(
flow_class._flow_definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": flow_class.__name__,
@@ -130,7 +130,7 @@ def test_build_flow_structure_from_flow_class():
def test_build_flow_structure_from_flow_definition():
"""Test building visualization directly from a FlowDefinition."""
definition = FlowDefinition.from_dict(
definition = FlowDefinition.from_declaration(contents=
{
"schema": "crewai.flow/v1",
"name": "DefinedFlow",
@@ -374,7 +374,7 @@ def test_topological_path_counting():
assert len(structure["edges"]) > 0
def test_class_metadata_comes_from_definition():
def test_class_metadata_comes_from_declaration():
"""Test that nodes include only definition-derived class metadata."""
flow = SimpleFlow()
structure = build_flow_structure(flow)