Merge branch 'main' into codex/fix-mysql-search-table-name-injection

This commit is contained in:
Rip&Tear
2026-06-27 10:10:42 +08:00
committed by GitHub
60 changed files with 1871 additions and 859 deletions

View File

@@ -8,7 +8,7 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.15.0",
"crewai-core==1.15.1a1",
"click>=8.1.7,<9",
"pydantic>=2.11.9,<2.13",
"pydantic-settings~=2.10.1",

View File

@@ -1 +1 @@
__version__ = "1.15.0"
__version__ = "1.15.1a1"

View File

@@ -6,6 +6,7 @@ import click
import tomli
from crewai_cli.constants import ENV_VARS, MODELS
from crewai_cli.git import initialize_if_git_available
from crewai_cli.provider import (
get_provider_data,
select_model,
@@ -318,4 +319,7 @@ def create_crew(
dst_file = src_folder / file_name
copy_template(src_file, dst_file, name, class_name, folder_name)
if not parent_folder:
initialize_if_git_available(folder_path)
click.secho(f"Crew {name} created successfully!", fg="green", bold=True)

View File

@@ -4,6 +4,9 @@ import shutil
import click
from crewai_core.telemetry import Telemetry
from crewai_cli.git import initialize_if_git_available
from crewai_cli.version import get_crewai_tools_dependency
DECLARATIVE_FLOW_FOLDERS = ("crews", "tools", "knowledge", "skills")
@@ -28,6 +31,8 @@ def create_flow(name: str, *, declarative: bool = False) -> None:
else:
_create_python_flow(name, class_name, folder_name, project_root)
initialize_if_git_available(project_root)
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)
@@ -71,6 +76,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 +146,9 @@ def _create_declarative_flow(
content = content.replace("{{name}}", name)
content = content.replace("{{flow_name}}", class_name)
content = content.replace("{{folder_name}}", folder_name)
content = content.replace(
"{{crewai_tools_dependency}}", get_crewai_tools_dependency()
)
dst_file.write_text(content, encoding="utf-8")
(project_root / ".env").write_text("OPENAI_API_KEY=YOUR_API_KEY", encoding="utf-8")

View File

@@ -13,13 +13,16 @@ from rich.console import Console
from rich.text import Text
from crewai_cli.constants import ENV_VARS
from crewai_cli.git import initialize_if_git_available
from crewai_cli.tui_picker import pick_many, pick_one
from crewai_cli.utils import (
enable_prompt_line_editing,
is_dmn_mode_enabled,
load_env_vars,
render_template,
write_env_file,
)
from crewai_cli.version import get_crewai_tools_dependency
# ── Provider / model data ───────────────────────────────────────
@@ -78,60 +81,7 @@ _PROVIDER_MODELS: dict[str, list[tuple[str, str]]] = {
],
}
# ── Static project files ───────────────────────────────────────
_PYPROJECT_TOML = """\
[project]
name = "{folder_name}"
version = "0.1.0"
description = "{name} using crewAI"
authors = [{{ name = "Your Name", email = "you@example.com" }}]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.8a1"
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
[tool.crewai]
type = "crew"
"""
_GITIGNORE = """\
.env
__pycache__/
.DS_Store
report.md
"""
_README = """\
# {name}
A crewAI project using JSON-first configuration.
## Running
```bash
crewai run
```
## Project Structure
- `agents/` - Agent definitions (JSONC)
- `crew.jsonc` - Crew definition with tasks and configuration
- `tools/` - Custom tools (Python)
- `knowledge/` - Knowledge files for agents
> **Note:** `custom:<name>` tool references execute `tools/<name>.py` as local
> Python code when the crew loads. Only run crew projects from sources you
> trust.
"""
_TEMPLATES_DIR = Path(__file__).parent / "templates" / "json_crew"
# ── Common tools for picker ────────────────────────────────────
@@ -692,187 +642,64 @@ def _default_agents_and_tasks(
def _agent_to_jsonc(agent: dict[str, Any]) -> str:
"""Convert agent wizard data to JSONC string with comments."""
has_planning = agent["planning"]
delegation_val = "true" if agent["allow_delegation"] else "false"
delegation_comma = "," if has_planning else ""
settings_lines = []
settings_lines.append(" // Show detailed execution logs")
settings_lines.append(' "verbose": false,')
settings_lines.append("")
settings_lines.append(
" // Allow this agent to delegate tasks to other agents in the crew"
settings_block = _render_json_crew_template(
"agent_settings.jsonc",
{
"allow_delegation": "true" if agent["allow_delegation"] else "false",
"delegation_comma": "," if has_planning else "",
"planning_line": '"planning": true'
if has_planning
else '// "planning": false',
},
)
settings_lines.append(f' "allow_delegation": {delegation_val}{delegation_comma}')
settings_lines.append("")
settings_lines.append(
" // Maximum reasoning iterations per task (prevents infinite loops)"
return _render_json_crew_template(
"agent.jsonc",
{
"role_json": json.dumps(agent["role"]),
"goal_json": json.dumps(agent["goal"]),
"backstory_json": json.dumps(agent["backstory"]),
"llm_json": json.dumps(agent["llm"]),
"tools_json": json.dumps(agent["tools"]),
"settings_block": settings_block,
},
)
settings_lines.append(' // "max_iter": 25,')
settings_lines.append("")
settings_lines.append(" // Maximum tokens for agent's response generation")
settings_lines.append(' // "max_tokens": null,')
settings_lines.append("")
settings_lines.append(" // Maximum execution time in seconds")
settings_lines.append(' // "max_execution_time": null,')
settings_lines.append("")
settings_lines.append(" // Maximum LLM requests per minute (rate limiting)")
settings_lines.append(' // "max_rpm": null,')
settings_lines.append("")
settings_lines.append(" // Enable agent-level memory (persists across tasks)")
settings_lines.append(' // "memory": false,')
settings_lines.append("")
settings_lines.append(" // Cache tool results to avoid duplicate calls")
settings_lines.append(' // "cache": true,')
settings_lines.append("")
settings_lines.append(
" // Auto-summarize context when it exceeds the LLM's context window"
)
settings_lines.append(' // "respect_context_window": true,')
settings_lines.append("")
settings_lines.append(" // Maximum retries on execution errors")
settings_lines.append(' // "max_retry_limit": 2,')
settings_lines.append("")
settings_lines.append(" // Enable step-by-step planning before task execution")
if has_planning:
settings_lines.append(' "planning": true')
else:
settings_lines.append(' // "planning": false')
settings_lines.append("")
settings_lines.append(" // Include system prompt in LLM calls")
settings_lines.append(' // "use_system_prompt": true')
settings_block = "\n".join(settings_lines)
return f"""\
{{
// Agent's role title — appears in prompts and logs.
// You can use {{placeholder}} inputs in role, goal, or backstory.
// Example: "role": "Senior {{industry}} Researcher"
"role": {json.dumps(agent["role"])},
// Optional custom Agent subclass
// "type": {{"python": "my_project.agents.CustomAgent"}},
// The agent's primary objective
"goal": {json.dumps(agent["goal"])},
// Background story that shapes the agent's personality and approach
"backstory": {json.dumps(agent["backstory"])},
// LLM model in provider/model format
// Examples: "openai/gpt-4o", "anthropic/claude-sonnet-4-6", "ollama/llama3.3"
// For custom endpoints or deployment-based providers, replace with:
// "llm": {{"model": "llama3", "provider": "ollama", "base_url": "http://localhost:11434"}},
// "llm": {{"deployment_name": "my-deployment", "provider": "azure", "api_version": "2024-10-21"}},
"llm": {json.dumps(agent["llm"])},
// Override LLM used specifically for tool/function calling
// "function_calling_llm": "openai/gpt-5.4-mini",
// Tools available to this agent
// Built-in: "SerperDevTool", "ScrapeWebsiteTool", "FileReadTool", etc.
// Custom: "custom:my_tool" loads from tools/my_tool.py
"tools": {json.dumps(agent["tools"])},
// Optional agent-level guardrail — validates this agent's final output.
// String guardrails are checked by an LLM and can reject/retry output.
// Python refs must point to module-level functions/classes in trusted code.
// "guardrail": "Only answer with information supported by retrieved evidence.",
// "step_callback": {{"python": "my_project.callbacks.on_agent_step"}},
// "guardrail_max_retries": 2,
// Advanced agent options:
// Docs: https://docs.crewai.com/concepts/agents
// "reasoning": true,
// "max_reasoning_attempts": 3,
// "planning_config": {{
// "reasoning_effort": "medium",
// "llm": {{"model": "deepseek-chat", "provider": "deepseek"}}
// }},
// "multimodal": false,
// "allow_code_execution": false,
// "code_execution_mode": "safe",
// "knowledge_sources": [],
// "knowledge_config": {{}},
// "inject_date": true,
// "date_format": "%Y-%m-%d",
// "security_config": {{}},
// Agent behavior settings
"settings": {{
{settings_block}
}}
}}
"""
def _task_to_json_fragment(task: dict[str, Any]) -> str:
"""Convert task wizard data to a JSON-like fragment for embedding in crew JSONC."""
lines = []
lines.append(" {")
lines.append(" // Task identifier")
lines.append(f' "name": {json.dumps(task["name"])},')
lines.append("")
lines.append(" // What the task should accomplish")
lines.append(
" // Use {placeholder} inputs here; crewai run prompts for missing values"
)
lines.append(f' "description": {json.dumps(task["description"])},')
lines.append("")
lines.append(" // Clear definition of what the output should look like")
lines.append(f' "expected_output": {json.dumps(task["expected_output"])},')
lines.append("")
lines.append(
" // Optional task guardrail(s) validate output before completion"
)
lines.append(' // Use "guardrail" for one rule or "guardrails" for many')
lines.append(" // Failed guardrails retry up to guardrail_max_retries times")
lines.append(' // "guardrail": "Every factual claim needs context support.",')
lines.append(' // "guardrails": [')
lines.append(' // "Every factual claim must be supported by context.",')
lines.append(' // "The answer must match the expected output format."')
lines.append(" // ],")
lines.append(' // "guardrail_max_retries": 2,')
lines.append("")
lines.append(" // Advanced task options:")
lines.append(" // Docs: https://docs.crewai.com/concepts/tasks")
lines.append(' // "type": "ConditionalTask",')
lines.append(
' // "condition": { "python": "my_project.conditions.should_run" },'
)
lines.append(
' // "output_json": { "python": "my_project.models.ReportOutput" },'
)
lines.append(' // "output_pydantic": null,')
lines.append(' // "response_model": null,')
lines.append(
' // "converter_cls": { "python": "my_project.converters.CustomConverter" },'
)
lines.append(' // "markdown": false,')
lines.append(' // "input_files": { "brief": "data/brief.txt" },')
lines.append(' // "security_config": {},')
lines.append("")
lines.append(" // Which agent handles this task")
lines.append(f' "agent": {json.dumps(task["agent"])}')
has_context = bool(task.get("context"))
has_output_file = bool(task.get("output_file"))
context_block = ""
output_file_block = ""
if task.get("context"):
lines[-1] += "," # add comma to agent line
lines.append("")
lines.append(" // Task outputs used as context")
lines.append(f' "context": {json.dumps(task["context"])}')
if has_context:
context_block = (
"\n\n"
" // Task outputs used as context\n"
f' "context": {json.dumps(task["context"])}'
f"{',' if has_output_file else ''}"
)
if task.get("output_file"):
lines[-1] += ","
lines.append("")
lines.append(" // Save output to a file")
lines.append(f' "output_file": {json.dumps(task["output_file"])}')
if has_output_file:
output_file_block = (
"\n\n"
" // Save output to a file\n"
f' "output_file": {json.dumps(task["output_file"])}'
)
lines.append("")
lines.append(' // "tools": [],')
lines.append(' // "human_input": false,')
lines.append(' // "async_execution": false')
lines.append(" }")
return "\n".join(lines)
return _render_json_crew_template(
"task.jsonc",
{
"name_json": json.dumps(task["name"]),
"description_json": json.dumps(task["description"]),
"expected_output_json": json.dumps(task["expected_output"]),
"agent_json": json.dumps(task["agent"]),
"agent_comma": "," if has_context or has_output_file else "",
"context_block": context_block,
"output_file_block": output_file_block,
},
)
def _crew_to_jsonc(
@@ -892,69 +719,20 @@ def _crew_to_jsonc(
inputs_lines[0] + "\n" + "\n".join(" " + line for line in inputs_lines[1:])
)
process = settings.get("process", "sequential")
memory = "true" if settings.get("memory") else "false"
return f"""\
{{
// Display name for this crew
"name": {json.dumps(name)},
// Agents to include — each must have a matching agents/<name>.jsonc file
"agents": {agent_names_json},
// Task definitions — executed in order for sequential process
"tasks": [
{tasks_fragments}
],
// Execution process
// "sequential" — tasks run in order, each receiving prior task outputs
// "hierarchical" — a manager agent delegates tasks (requires manager_llm)
"process": "{process}",
// Enable verbose logging during execution
"verbose": true,
// Enable crew memory — persists context and learnings across tasks
"memory": {memory},
// Automatically plan the execution strategy before running tasks
// "planning": false,
// LLM for the planning step (used when planning is true)
// "planning_llm": "openai/gpt-4o",
// LLM for the manager agent (required when process is "hierarchical")
// "manager_llm": "openai/gpt-4o",
// Crew-level LLM fields also accept object form for custom endpoints
// "chat_llm": {{"model": "llama3", "provider": "ollama", "base_url": "http://localhost:11434"}},
// Advanced crew options:
// Docs: https://docs.crewai.com/concepts/crews
// For hierarchical crews, manager_agent can reference an agents/<name>.jsonc file
// that is not included in the "agents" list.
// "manager_agent": "{agents[0]["name"]}",
// "before_kickoff_callbacks": [{{"python": "my_project.callbacks.before_kickoff"}}],
// "after_kickoff_callbacks": [{{"python": "my_project.callbacks.after_kickoff"}}],
// "function_calling_llm": "openai/gpt-4o-mini",
// "max_rpm": null,
// "cache": true,
// "knowledge_sources": [],
// "embedder": {{}},
// "output_log_file": "crew.log",
// "stream": false,
// "tracing": false,
// "security_config": {{}},
// Optional runtime input defaults.
// Use {{placeholder}} in agent or task text, for example:
// "description": "Research {{topic}} and write a brief"
// `crewai run` prompts for any placeholders missing from this object.
"inputs": {inputs_json}
}}
"""
return _render_json_crew_template(
"crew.jsonc",
{
"name_json": json.dumps(name),
"agent_names_json": agent_names_json,
"tasks_fragments": tasks_fragments,
"process_json": json.dumps(settings.get("process", "sequential")),
"memory": memory,
"manager_agent_name": agents[0]["name"],
"inputs_json": inputs_json,
},
)
# ── Model selection ─────────────────────────────────────────────
@@ -1029,6 +807,12 @@ def _default_model_for_provider(provider: str | None) -> str | None:
# ── Helpers ─────────────────────────────────────────────────────
def _render_json_crew_template(
template_name: str, replacements: dict[str, str] | None = None
) -> str:
return render_template(_TEMPLATES_DIR / template_name, replacements or {})
def _write_jsonc(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
@@ -1134,22 +918,32 @@ def create_json_crew(
# Write pyproject.toml
(folder_path / "pyproject.toml").write_text(
_PYPROJECT_TOML.format(folder_name=folder_name, name=name),
_render_json_crew_template(
"pyproject.toml",
{
"folder_name": folder_name,
"name": name,
"crewai_tools_dependency": get_crewai_tools_dependency(),
},
),
encoding="utf-8",
)
# Write .gitignore
(folder_path / ".gitignore").write_text(_GITIGNORE, encoding="utf-8")
(folder_path / ".gitignore").write_text(
_render_json_crew_template(".gitignore"),
encoding="utf-8",
)
# Write README
(folder_path / "README.md").write_text(
_README.format(name=name),
_render_json_crew_template("README.md", {"name": name}),
encoding="utf-8",
)
# Write knowledge placeholder
(folder_path / "knowledge" / "user_preference.txt").write_text(
"# Add your knowledge files here\n",
_render_json_crew_template("knowledge/user_preference.txt"),
encoding="utf-8",
)
@@ -1162,6 +956,8 @@ def create_json_crew(
for model in models:
_setup_env(folder_path, model)
initialize_if_git_available(folder_path)
click.echo()
click.secho(f" ✔ Crew {name} created successfully!", fg="green", bold=True)
click.echo()

View File

@@ -10,6 +10,7 @@ import threading
import time
from typing import Any, ClassVar, cast
from crewai_core.telemetry import Telemetry
from rich.text import Text
from textual import work
from textual.app import App, ComposeResult
@@ -571,6 +572,7 @@ FooterKey .footer-key--key {
self._want_deploy: bool = False
self._trace_url: str | None = None
self._consent_screen: TraceConsentScreen | None = None
self._telemetry: Telemetry | None = None
# ── Layout ──────────────────────────────────────────────
@@ -1042,10 +1044,21 @@ FooterKey .footer-key--key {
self._unsubscribe()
self.exit(self._crew_result)
def _record_tui_button_click(self, button_name: str) -> None:
try:
if self._telemetry is None:
self._telemetry = Telemetry()
self._telemetry.set_tracer()
self._telemetry.feature_usage_span(f"cli_usage:{button_name}")
except Exception: # noqa: S110
pass
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id in ("btn-traces", "btn-traces-done"):
self._record_tui_button_click("view_traces")
self.action_view_traces()
elif event.button.id == "btn-deploy":
self._record_tui_button_click("deploy")
self.action_deploy_crew()
def _scroll_to_result(self) -> None:

View File

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

View File

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

View File

@@ -201,3 +201,12 @@ class Repository:
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def initialize_if_git_available(path: Path) -> bool:
"""Initialize a Git repository when Git is available."""
if not Repository.is_git_installed():
return False
subprocess.run(["git", "init"], cwd=path, check=True) # noqa: S607
return True

View File

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

View File

@@ -1,13 +1,12 @@
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
@@ -17,9 +16,8 @@ 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 +30,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 +74,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 +96,40 @@ def _full_crewai_install_error() -> click.ClickException:
return click.ClickException(_FULL_CREWAI_INSTALL_MESSAGE)
def find_crew_json_file() -> Path | 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 read_toml(*args: Any, **kwargs: Any) -> dict[str, Any]:
from crewai_core.project import read_toml as _read_toml
return _read_toml(*args, **kwargs)
def _has_json_crew() -> bool:
"""Check if this is a JSON-defined crew project.
def get_crewai_project_type(pyproject_data: dict[str, Any]) -> str | None:
from crewai_core.project import get_crewai_project_type as _get_crewai_project_type
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 _get_crewai_project_type(pyproject_data)
def configured_project_json_crew(
pyproject_data: dict[str, Any] | None = None,
project_root: Path | None = None,
) -> Path | None:
"""Return the configured JSON crew definition for crew projects."""
from crewai_core.project import (
ProjectDefinitionError,
configured_project_definition,
)
return declared_type != "flow"
root = project_root or Path.cwd()
if pyproject_data is None and not (root / "pyproject.toml").is_file():
return None
try:
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 +204,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 +272,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 +288,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 +408,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 +428,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 +582,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 +655,6 @@ def _run_classic_crew_project(
)
def _get_project_type(pyproject_data: dict[str, Any]) -> str | None:
project_type = pyproject_data.get("tool", {}).get("crewai", {}).get("type")
return project_type if isinstance(project_type, str) else None
def _warn_if_old_poetry_project(pyproject_data: dict[str, Any]) -> None:
crewai_version = get_crewai_version()
min_required_version = "0.71.0"

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
.env
__pycache__/
.DS_Store
report.md

View File

@@ -0,0 +1,20 @@
# {{name}}
A crewAI project using JSON-first configuration.
## Running
```bash
crewai run
```
## Project Structure
- `agents/` - Agent definitions (JSONC)
- `crew.jsonc` - Crew definition with tasks and configuration
- `tools/` - Custom tools (Python)
- `knowledge/` - Knowledge files for agents
> **Note:** `custom:<name>` tool references execute `tools/<name>.py` as local
> Python code when the crew loads. Only run crew projects from sources you
> trust.

View File

@@ -0,0 +1,59 @@
{
// Agent's role title — appears in prompts and logs.
// You can use {placeholder} inputs in role, goal, or backstory.
// Example: "role": "Senior {industry} Researcher"
"role": {{role_json}},
// Optional custom Agent subclass
// "type": {"python": "my_project.agents.CustomAgent"},
// The agent's primary objective
"goal": {{goal_json}},
// Background story that shapes the agent's personality and approach
"backstory": {{backstory_json}},
// LLM model in provider/model format
// Examples: "openai/gpt-4o", "anthropic/claude-sonnet-4-6", "ollama/llama3.3"
// For custom endpoints or deployment-based providers, replace with:
// "llm": {"model": "llama3", "provider": "ollama", "base_url": "http://localhost:11434"},
// "llm": {"deployment_name": "my-deployment", "provider": "azure", "api_version": "2024-10-21"},
"llm": {{llm_json}},
// Override LLM used specifically for tool/function calling
// "function_calling_llm": "openai/gpt-5.4-mini",
// Tools available to this agent
// Built-in: "SerperDevTool", "ScrapeWebsiteTool", "FileReadTool", etc.
// Custom: "custom:my_tool" loads from tools/my_tool.py
"tools": {{tools_json}},
// Optional agent-level guardrail — validates this agent's final output.
// String guardrails are checked by an LLM and can reject/retry output.
// Python refs must point to module-level functions/classes in trusted code.
// "guardrail": "Only answer with information supported by retrieved evidence.",
// "step_callback": {"python": "my_project.callbacks.on_agent_step"},
// "guardrail_max_retries": 2,
// Advanced agent options:
// Docs: https://docs.crewai.com/concepts/agents
// "reasoning": true,
// "max_reasoning_attempts": 3,
// "planning_config": {
// "reasoning_effort": "medium",
// "llm": {"model": "deepseek-chat", "provider": "deepseek"}
// },
// "multimodal": false,
// "allow_code_execution": false,
// "code_execution_mode": "safe",
// "knowledge_sources": [],
// "knowledge_config": {},
// "inject_date": true,
// "date_format": "%Y-%m-%d",
// "security_config": {},
// Agent behavior settings
"settings": {
{{settings_block}}
}
}

View File

@@ -0,0 +1,35 @@
// Show detailed execution logs
"verbose": false,
// Allow this agent to delegate tasks to other agents in the crew
"allow_delegation": {{allow_delegation}}{{delegation_comma}}
// Maximum reasoning iterations per task (prevents infinite loops)
// "max_iter": 25,
// Maximum tokens for agent's response generation
// "max_tokens": null,
// Maximum execution time in seconds
// "max_execution_time": null,
// Maximum LLM requests per minute (rate limiting)
// "max_rpm": null,
// Enable agent-level memory (persists across tasks)
// "memory": false,
// Cache tool results to avoid duplicate calls
// "cache": true,
// Auto-summarize context when it exceeds the LLM's context window
// "respect_context_window": true,
// Maximum retries on execution errors
// "max_retry_limit": 2,
// Enable step-by-step planning before task execution
{{planning_line}}
// Include system prompt in LLM calls
// "use_system_prompt": true

View File

@@ -0,0 +1,58 @@
{
// Display name for this crew
"name": {{name_json}},
// Agents to include — each must have a matching agents/<name>.jsonc file
"agents": {{agent_names_json}},
// Task definitions — executed in order for sequential process
"tasks": [
{{tasks_fragments}}
],
// Execution process
// "sequential" — tasks run in order, each receiving prior task outputs
// "hierarchical" — a manager agent delegates tasks (requires manager_llm)
"process": {{process_json}},
// Enable verbose logging during execution
"verbose": true,
// Enable crew memory — persists context and learnings across tasks
"memory": {{memory}},
// Automatically plan the execution strategy before running tasks
// "planning": false,
// LLM for the planning step (used when planning is true)
// "planning_llm": "openai/gpt-4o",
// LLM for the manager agent (required when process is "hierarchical")
// "manager_llm": "openai/gpt-4o",
// Crew-level LLM fields also accept object form for custom endpoints
// "chat_llm": {"model": "llama3", "provider": "ollama", "base_url": "http://localhost:11434"},
// Advanced crew options:
// Docs: https://docs.crewai.com/concepts/crews
// For hierarchical crews, manager_agent can reference an agents/<name>.jsonc file
// that is not included in the "agents" list.
// "manager_agent": "{{manager_agent_name}}",
// "before_kickoff_callbacks": [{"python": "my_project.callbacks.before_kickoff"}],
// "after_kickoff_callbacks": [{"python": "my_project.callbacks.after_kickoff"}],
// "function_calling_llm": "openai/gpt-4o-mini",
// "max_rpm": null,
// "cache": true,
// "knowledge_sources": [],
// "embedder": {},
// "output_log_file": "crew.log",
// "stream": false,
// "tracing": false,
// "security_config": {},
// Optional runtime input defaults.
// Use {placeholder} in agent or task text, for example:
// "description": "Research {topic} and write a brief"
// `crewai run` prompts for any placeholders missing from this object.
"inputs": {{inputs_json}}
}

View File

@@ -0,0 +1 @@
# Add your knowledge files here

View File

@@ -0,0 +1,20 @@
[project]
name = "{{folder_name}}"
version = "0.1.0"
description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"{{crewai_tools_dependency}}"
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
only-include = ["agents", "crew.jsonc", "tools", "knowledge", "skills"]
[tool.crewai]
type = "crew"
definition = "crew.jsonc"

View File

@@ -0,0 +1,40 @@
{
// Task identifier
"name": {{name_json}},
// What the task should accomplish
// Use {placeholder} inputs here; crewai run prompts for missing values
"description": {{description_json}},
// Clear definition of what the output should look like
"expected_output": {{expected_output_json}},
// Optional task guardrail(s) validate output before completion
// Use "guardrail" for one rule or "guardrails" for many
// Failed guardrails retry up to guardrail_max_retries times
// "guardrail": "Every factual claim needs context support.",
// "guardrails": [
// "Every factual claim must be supported by context.",
// "The answer must match the expected output format."
// ],
// "guardrail_max_retries": 2,
// Advanced task options:
// Docs: https://docs.crewai.com/concepts/tasks
// "type": "ConditionalTask",
// "condition": { "python": "my_project.conditions.should_run" },
// "output_json": { "python": "my_project.models.ReportOutput" },
// "output_pydantic": null,
// "response_model": null,
// "converter_cls": { "python": "my_project.converters.CustomConverter" },
// "markdown": false,
// "input_files": { "brief": "data/brief.txt" },
// "security_config": {},
// Which agent handles this task
"agent": {{agent_json}}{{agent_comma}}{{context_block}}{{output_file_block}}
// "tools": [],
// "human_input": false,
// "async_execution": false
}

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
from collections.abc import Mapping
import os
from pathlib import Path
import re
import shutil
from typing import Any
@@ -19,6 +21,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",
@@ -33,6 +37,7 @@ __all__ = [
"load_env_vars",
"parse_toml",
"read_toml",
"render_template",
"tree_copy",
"tree_find_and_replace",
"write_env_file",
@@ -40,6 +45,7 @@ __all__ = [
console = Console()
_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}")
def is_dmn_mode_enabled() -> bool:
@@ -67,12 +73,15 @@ def copy_template(
src: Path, dst: Path, name: str, class_name: str, folder_name: str
) -> None:
"""Copy a file from src to dst."""
with open(src, "r") as file:
content = file.read()
content = content.replace("{{name}}", name)
content = content.replace("{{crew_name}}", class_name)
content = content.replace("{{folder_name}}", folder_name)
content = render_template(
src,
{
"name": name,
"crew_name": class_name,
"folder_name": folder_name,
"crewai_tools_dependency": get_crewai_tools_dependency(),
},
)
with open(dst, "w") as file:
file.write(content)
@@ -80,6 +89,15 @@ def copy_template(
click.secho(f" - Created {dst}", fg="green")
def render_template(src: Path, replacements: Mapping[str, str]) -> str:
"""Render a template file using ``{{placeholder}}`` replacements."""
content = src.read_text(encoding="utf-8")
return _TEMPLATE_TOKEN_RE.sub(
lambda match: replacements.get(match.group(1), match.group(0)),
content,
)
def fetch_and_json_env_file(env_file_path: str = ".env") -> dict[str, Any]:
"""Fetch the environment variables from a .env file and return them as a dictionary."""
try:

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,10 @@ from packaging.requirements import Requirement
from packaging.version import Version
import crewai_cli.create_json_crew as json_crew
import crewai_cli.tui_picker as tui_picker
from crewai_cli.cli import crewai
from crewai_cli.create_crew import create_crew, create_folder_structure
from crewai_cli.utils import render_template
from crewai_cli.version import get_crewai_tools_dependency
@pytest.fixture
@@ -107,6 +110,7 @@ def test_create_crew_with_trailing_slash_creates_valid_project(
"crewai_cli.create_crew.create_folder_structure"
) as mock_create_folder:
mock_folder_path = Path(work_dir) / "test_project"
mock_folder_path.mkdir()
mock_create_folder.return_value = (
mock_folder_path,
"test_project",
@@ -141,6 +145,7 @@ def test_create_crew_with_multiple_trailing_slashes(
"crewai_cli.create_crew.create_folder_structure"
) as mock_create_folder:
mock_folder_path = Path(work_dir) / "test_project"
mock_folder_path.mkdir()
mock_create_folder.return_value = (
mock_folder_path,
"test_project",
@@ -165,6 +170,7 @@ def test_create_crew_normal_name_still_works(
"crewai_cli.create_crew.create_folder_structure"
) as mock_create_folder:
mock_folder_path = Path(work_dir) / "normal_project"
mock_folder_path.mkdir()
mock_create_folder.return_value = (
mock_folder_path,
"normal_project",
@@ -176,6 +182,26 @@ def test_create_crew_normal_name_still_works(
mock_create_folder.assert_called_once_with("normal-project", None)
@pytest.mark.skipif(shutil.which("git") is None, reason="git is not installed")
@pytest.mark.parametrize(
("args", "project_root"),
[
(["create", "crew", "Git Crew"], "git_crew"),
(["create", "flow", "Git Flow"], "git_flow"),
],
)
def test_create_initializes_git_repo_when_git_is_available(
args, project_root, tmp_path, monkeypatch, runner
):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("CREWAI_DMN", "True")
result = runner.invoke(crewai, args)
assert result.exit_code == 0, result.output
assert (tmp_path / project_root / ".git").is_dir()
def test_create_folder_structure_handles_spaces_and_dashes_with_slash():
with tempfile.TemporaryDirectory() as temp_dir:
folder_path, folder_name, class_name = create_folder_structure(
@@ -735,11 +761,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 == get_crewai_tools_dependency()
assert Version("1.15.0") in Requirement(dependency).specifier
assert Version("2.0.0") not in Requirement(dependency).specifier
assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
"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 (
@@ -811,6 +842,37 @@ def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
assert '"knowledge_sources": []' in agent_template
def test_json_crew_uses_template_files():
template_names = {
"pyproject.toml",
"README.md",
".gitignore",
"agent.jsonc",
"agent_settings.jsonc",
"task.jsonc",
"crew.jsonc",
"knowledge/user_preference.txt",
}
for template_name in template_names:
assert (json_crew._TEMPLATES_DIR / template_name).is_file()
def test_render_template_does_not_replace_tokens_inside_replacement_values(tmp_path):
template = tmp_path / "template.txt"
template.write_text("{{first}} {{second}}", encoding="utf-8")
rendered = render_template(
template,
{
"first": "{{second}}",
"second": "done",
},
)
assert rendered == "{{second}} done"
def test_json_provider_default_model_helper():
assert json_crew._default_model_for_provider("openai") == "openai/gpt-5.5"
assert json_crew._default_model_for_provider("anthropic/claude-custom") == (

View File

@@ -1,5 +1,7 @@
from datetime import datetime
import time
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
@@ -126,6 +128,37 @@ def test_chain_deploy_does_not_login_for_deploy_exit(monkeypatch, capsys) -> Non
assert "Deploy failed with exit code 42" in capsys.readouterr().out
def test_view_traces_button_click_records_telemetry(monkeypatch) -> None:
app = CrewRunApp()
app._status = "completed"
app._trace_url = "https://app.crewai.com/traces/test"
app._telemetry = Mock()
opened_urls: list[str] = []
monkeypatch.setattr("webbrowser.open", lambda url: opened_urls.append(url))
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces")))
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:view_traces")
assert opened_urls == ["https://app.crewai.com/traces/test"]
def test_deploy_button_click_records_telemetry() -> None:
app = CrewRunApp()
app._status = "completed"
app._crew_result = object()
app._telemetry = Mock()
app._unsubscribe = lambda: None # type: ignore[method-assign]
exits: list[object] = []
app.exit = lambda result: exits.append(result) # type: ignore[method-assign]
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-deploy")))
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:deploy")
assert app._want_deploy is True
assert exits == [app._crew_result]
def test_conversation_turn_done_records_assistant_message() -> None:
class RawResult:
raw = "hello from the flow"

View File

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

View File

@@ -16,29 +16,37 @@ def test_missing_crewai_package_shows_full_install_hint(monkeypatch):
def missing_crewai_package():
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
monkeypatch.setattr(
run_crew_module, "_import_find_crew_json_file", missing_crewai_package
)
real_import = __import__
def fake_import(name, *args, **kwargs):
if name == "crewai.project.crew_loader":
missing_crewai_package()
return real_import(name, *args, **kwargs)
monkeypatch.setattr("builtins.__import__", fake_import)
with pytest.raises(click.ClickException) as exc_info:
run_crew_module.find_crew_json_file()
run_crew_module._load_json_crew(Path("crew.jsonc"))
message = exc_info.value.message
assert "CrewAI CLI is installed without the `crewai` package" in message
assert (
"uv tool install --force --prerelease=allow 'crewai[tools]==1.14.8a1'"
in message
)
assert "uv tool install --force 'crewai[tools]>=1.15.0,<2.0.0'" in message
assert "quotes are required in zsh" in message
def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
"""crewai run -f must reach JSON crews, not only classic subprocess crews."""
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True)
monkeypatch.setattr(run_crew_module, "read_toml", lambda: {})
monkeypatch.setattr(
run_crew_module,
"configured_project_json_crew",
lambda pyproject_data=None, project_root=None: Path("crew.jsonc"),
)
called: dict = {}
def fake_run_json_crew_in_project_env(trained_agents_file=None):
def fake_run_json_crew_in_project_env(trained_agents_file=None, crew_path=None):
called["trained_agents_file"] = trained_agents_file
called["crew_path"] = crew_path
monkeypatch.setattr(
run_crew_module,
@@ -48,7 +56,10 @@ def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
run_crew_module.run_crew(trained_agents_file="some.pkl")
assert called == {"trained_agents_file": "some.pkl"}
assert called == {
"trained_agents_file": "some.pkl",
"crew_path": Path("crew.jsonc"),
}
def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path: Path):
@@ -74,8 +85,10 @@ def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path:
monkeypatch.setattr(run_crew_module.subprocess, "run", fake_subprocess_run)
crew_path = tmp_path / "crew.jsonc"
run_crew_module._run_json_crew_in_project_env(
trained_agents_file="trained.pkl"
trained_agents_file="trained.pkl",
crew_path=crew_path,
)
expected_env = {
@@ -84,6 +97,7 @@ def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path:
Path(run_crew_module.__file__).resolve().parent
),
CREWAI_TRAINED_AGENTS_FILE_ENV: "trained.pkl",
run_crew_module._CREWAI_JSON_CREW_DEFINITION_ENV: str(crew_path),
}
if local_crewai_source_dir := run_crew_module._find_local_crewai_source_dir():
expected_env[run_crew_module._CREWAI_RUNNER_SOURCE_DIR_ENV] = str(
@@ -213,12 +227,87 @@ def test_json_runner_code_loads_current_cli_package_over_project_env(tmp_path: P
assert marker.read_text() == "current:trained.pkl"
def test_json_runner_imports_with_older_project_env_crewai_core(tmp_path: Path):
old_parent = tmp_path / "old_env"
old_crewai_core = old_parent / "crewai_core"
old_crewai_core.mkdir(parents=True)
(old_crewai_core / "__init__.py").write_text("")
(old_crewai_core / "constants.py").write_text(
"CREWAI_TRAINED_AGENTS_FILE_ENV = 'CREWAI_TRAINED_AGENTS_FILE'\n"
)
(old_crewai_core / "project.py").write_text(
"def read_toml(*args, **kwargs):\n"
" return {}\n"
"def parse_toml(*args, **kwargs):\n"
" return {}\n"
"def get_project_description(*args, **kwargs):\n"
" return None\n"
"def get_project_name(*args, **kwargs):\n"
" return None\n"
"def get_project_version(*args, **kwargs):\n"
" return None\n"
)
(old_crewai_core / "tool_credentials.py").write_text(
"def build_env_with_all_tool_credentials(*args, **kwargs):\n"
" return {}\n"
"def build_env_with_tool_repository_credentials(*args, **kwargs):\n"
" return {}\n"
)
(old_crewai_core / "version.py").write_text(
"def check_version(*args, **kwargs):\n"
" return None\n"
"def get_crewai_version(*args, **kwargs):\n"
" return '1.0.0'\n"
"def get_latest_version_from_pypi(*args, **kwargs):\n"
" return None\n"
"def is_current_version_yanked(*args, **kwargs):\n"
" return False\n"
"def is_newer_version_available(*args, **kwargs):\n"
" return False\n"
)
marker = tmp_path / "marker.txt"
old_crewai_project = old_parent / "crewai" / "project"
old_crewai_project.mkdir(parents=True)
(old_parent / "crewai" / "__init__.py").write_text("")
(old_crewai_project / "__init__.py").write_text("")
(old_crewai_project / "crew_loader.py").write_text(
"from pathlib import Path\n"
"class Crew:\n"
" agents = []\n"
" tasks = []\n"
" def kickoff(self, inputs):\n"
f" Path({str(marker)!r}).write_text('ran')\n"
" return 'done'\n"
"def load_crew(path):\n"
" return Crew(), {}\n"
)
env = os.environ.copy()
env["PYTHONPATH"] = str(old_parent)
env["CREWAI_DMN"] = "true"
env[run_crew_module._CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV] = str(
Path(run_crew_module.__file__).resolve().parent
)
env[run_crew_module._CREWAI_JSON_CREW_DEFINITION_ENV] = "crew.jsonc"
subprocess.run(
[sys.executable, "-c", run_crew_module._JSON_CREW_RUNNER_CODE],
check=True,
env=env,
cwd=tmp_path,
)
assert marker.read_text() == "ran"
def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
called: dict = {}
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 +318,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 +527,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 +581,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 +622,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 +639,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 +720,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 +745,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 +773,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 +801,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 +821,6 @@ def test_run_crew_runs_configured_declarative_flow_project(
monkeypatch.chdir(tmp_path)
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\n", encoding="utf-8")
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",

View File

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

View File

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

View File

@@ -1 +1 @@
__version__ = "1.15.0"
__version__ = "1.15.1a1"

View File

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

View File

@@ -249,6 +249,19 @@ class Telemetry:
self._safe_telemetry_procedure(_operation)
def feature_usage_span(self, feature: str) -> None:
"""Records that a feature was used. One span = one count."""
from crewai_core.version import get_crewai_version
def _operation() -> None:
tracer = trace.get_tracer("crewai.telemetry")
span = tracer.start_span("Feature Usage")
self._add_attribute(span, "crewai_version", get_crewai_version())
self._add_attribute(span, "feature", feature)
close_span(span)
self._safe_telemetry_procedure(_operation)
def flow_creation_span(self, flow_name: str) -> None:
"""Records the creation of a new flow."""

View File

@@ -4,12 +4,14 @@ from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import Mock
from crewai_core import (
constants,
lock_store,
paths,
printer,
project,
user_data,
version,
)
@@ -97,6 +99,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:
@@ -128,3 +207,29 @@ def test_core_telemetry_skips_duplicate_tracer_provider(
assert called is False
assert telemetry.trace_set is True
def test_core_telemetry_records_feature_usage(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from crewai_core.telemetry import Telemetry
Telemetry._instance = None
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TELEMETRY", raising=False)
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
tracer = Mock()
span = Mock()
tracer.start_span.return_value = span
monkeypatch.setattr(
"crewai_core.telemetry.trace.get_tracer",
lambda _name: tracer,
)
telemetry = Telemetry()
telemetry.feature_usage_span("cli_usage:view_traces")
tracer.start_span.assert_called_once_with("Feature Usage")
span.set_attribute.assert_any_call("feature", "cli_usage:view_traces")
span.end.assert_called_once()

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.15.0"
__version__ = "1.15.1a1"

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.15.0",
"crewai==1.15.1a1",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.15.0"
__version__ = "1.15.1a1"

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.15.0",
"crewai-cli==1.15.0",
"crewai-core==1.15.1a1",
"crewai-cli==1.15.1a1",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -55,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.15.0",
"crewai-tools==1.15.1a1",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.15.0"
__version__ = "1.15.1a1"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.15.0"
__version__ = "1.15.1a1"