mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-26 18:48:10 +00:00
Compare commits
6 Commits
1.15.0
...
json-crews
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bf76aac35 | ||
|
|
1ad27d6235 | ||
|
|
e10c17fcf6 | ||
|
|
f364a7d988 | ||
|
|
2771c02f45 | ||
|
|
5d4851eac7 |
@@ -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>
|
||||
|
||||
|
||||
130
docs/index.mdx
130
docs/index.mdx
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
88
lib/crewai-tools/src/crewai_tools/security/safe_requests.py
Normal file
88
lib/crewai-tools/src/crewai_tools/security/safe_requests.py
Normal 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
|
||||
@@ -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 {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
lib/crewai-tools/tests/rag/conftest.py
Normal file
28
lib/crewai-tools/tests/rag/conftest.py
Normal 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)
|
||||
177
lib/crewai-tools/tests/utilities/test_safe_requests.py
Normal file
177
lib/crewai-tools/tests/utilities/test_safe_requests.py
Normal 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"}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user