Merge branch 'main' into luzk/otel-instrumentation

This commit is contained in:
Lucas Gomide
2026-06-23 15:55:54 -03:00
committed by GitHub
46 changed files with 1445 additions and 525 deletions

View File

@@ -64,7 +64,7 @@ mode: "wide"
- تنفيذ أدوات تشغيل تعريف التدفق بدون كود Python
- دفع التغذية الراجعة البشرية من تعريف التدفق
- توصيل التكوين والاستمرارية من FlowDefinition إلى وقت التشغيل
- إضافة `crewai run --definition` التجريبية للتدفقات
- إضافة `crewai run --definition` للتدفقات التصريحية
- دعم تراجع نشر ZIP وتشغيل مشاريع الطاقم بتنسيق JSON
- تقديم الطواقم بتنسيق JSON أولاً

View File

@@ -959,7 +959,7 @@ source .venv/bin/activate
بعد تفعيل البيئة الافتراضية، يمكنك تشغيل التدفق بتنفيذ أحد الأوامر التالية:
```bash
crewai flow kickoff
crewai run
```
أو
@@ -1160,10 +1160,4 @@ crewai run
يكتشف هذا الأمر تلقائيًا ما إذا كان مشروعك تدفقًا (بناءً على إعداد `type = "flow"` في pyproject.toml الخاص بك) ويشغّله وفقًا لذلك. هذه هي الطريقة الموصى بها لتشغيل التدفقات من سطر الأوامر.
للتوافق مع الإصدارات السابقة، يمكنك أيضًا استخدام:
```shell
crewai flow kickoff
```
ومع ذلك، فإن أمر `crewai run` هو الطريقة المفضلة الآن لأنه يعمل لكل من فرق Crew والتدفقات.
أمر `crewai flow kickoff` القديم deprecated. استخدم `crewai run` لكل من فرق Crew والتدفقات.

View File

@@ -172,7 +172,7 @@ crewai install
## الخطوة 8: تشغيل Flow
```bash
crewai flow kickoff
crewai run
```
عند تشغيل هذا الأمر، ستشاهد Flow يعمل:

View File

@@ -64,7 +64,7 @@ mode: "wide"
- Implement Flow definition run tools without Python code
- Drive human feedback from the flow definition
- Wire config and persistence from FlowDefinition into the runtime
- Add experimental `crewai run --definition` for flows
- Add `crewai run --definition` for declarative flows
- Support ZIP deployment fallback and JSON crew project env runs
- Introduce JSON first crews

View File

@@ -956,13 +956,13 @@ Once all of the dependencies are installed, you need to activate the virtual env
source .venv/bin/activate
```
After activating the virtual environment, you can run the flow by executing one of the following commands:
After activating the virtual environment, you can run the flow with the CrewAI CLI:
```bash
crewai flow kickoff
crewai run
```
or
You can also run the project script directly:
```bash
uv run kickoff
@@ -1160,10 +1160,4 @@ crewai run
This command automatically detects if your project is a flow (based on the `type = "flow"` setting in your pyproject.toml) and runs it accordingly. This is the recommended way to run flows from the command line.
For backward compatibility, you can also use:
```shell
crewai flow kickoff
```
However, the `crewai run` command is now the preferred method as it works for both crews and flows.
The legacy `crewai flow kickoff` command is deprecated. Use `crewai run` for both crews and flows.

View File

@@ -395,7 +395,7 @@ crewai install
Now it's time to see your flow in action! Run it using the CrewAI CLI:
```bash
crewai flow kickoff
crewai run
```
When you run this command, you'll see your flow spring to life:

View File

@@ -64,7 +64,7 @@ mode: "wide"
- Python 코드 없이 Flow 정의 실행 도구 구현
- Flow 정의에서 인간 피드백 유도
- FlowDefinition의 구성 및 지속성을 런타임에 연결
- 흐름을 위한 실험적 `crewai run --definition` 추가
- 선언적 흐름을 위한 `crewai run --definition` 추가
- ZIP 배포 대체 및 JSON 크루 프로젝트 환경 실행 지원
- JSON 우선 크루 도입

View File

@@ -951,7 +951,7 @@ source .venv/bin/activate
가상 환경을 활성화한 후, 아래 명령어 중 하나를 실행하여 플로우를 실행할 수 있습니다:
```bash
crewai flow kickoff
crewai run
```
또는
@@ -1054,10 +1054,4 @@ crewai run
이 명령어는 프로젝트가 pyproject.toml의 `type = "flow"` 설정을 기반으로 flow인지 자동으로 감지하여 해당 방식으로 실행합니다. 명령줄에서 flow를 실행하는 권장 방법입니다.
하위 호환성을 위해 다음 명령어도 사용할 수 있습니다:
```shell
crewai flow kickoff
```
하지만 `crewai run` 명령어가 이제 crew와 flow 모두에 작동하므로 더욱 선호되는 방법입니다.
레거시 `crewai flow kickoff` 명령어는 deprecated되었습니다. crew와 flow 모두 `crewai run`을 사용하세요.

View File

@@ -393,7 +393,7 @@ crewai install
이제 여러분의 flow가 실제로 작동하는 모습을 볼 차례입니다! CrewAI CLI를 사용하여 flow를 실행하세요:
```bash
crewai flow kickoff
crewai run
```
이 명령어를 실행하면 flow가 다음과 같이 작동하는 것을 확인할 수 있습니다:

View File

@@ -64,7 +64,7 @@ mode: "wide"
- Implementar ferramentas de execução de definição de fluxo sem código Python
- Conduzir feedback humano a partir da definição de fluxo
- Conectar configuração e persistência do FlowDefinition ao tempo de execução
- Adicionar `crewai run --definition` experimental para fluxos
- Adicionar `crewai run --definition` para fluxos declarativos
- Suportar fallback de implantação ZIP e execuções de projeto de equipe em JSON
- Introduzir equipes em JSON primeiro

View File

@@ -948,7 +948,7 @@ source .venv/bin/activate
Com o ambiente ativado, execute o flow usando um dos comandos:
```bash
crewai flow kickoff
crewai run
```
ou
@@ -1052,10 +1052,4 @@ crewai run
O comando detecta automaticamente se seu projeto é um flow (com base na configuração `type = "flow"` no pyproject.toml) e executa conforme o esperado. Esse é o método recomendado para executar flows pelo terminal.
Por compatibilidade retroativa, também é possível usar:
```shell
crewai flow kickoff
```
No entanto, o comando `crewai run` é agora o preferido, pois funciona tanto para crews quanto para flows.
O comando legado `crewai flow kickoff` está deprecated. Use `crewai run` para crews e flows.

View File

@@ -393,7 +393,7 @@ crewai install
Agora é hora de ver seu flow em ação! Execute-o usando a CLI do CrewAI:
```bash
crewai flow kickoff
crewai run
```
Quando você rodar esse comando, verá seu flow ganhando vida:

View File

@@ -40,14 +40,6 @@ def replay_task_command(*args: Any, **kwargs: Any) -> Any:
return _replay_task_command(*args, **kwargs)
def run_flow_definition(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.run_flow_definition import (
run_flow_definition as _run_flow_definition,
)
return _run_flow_definition(*args, **kwargs)
def run_crew(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.run_crew import run_crew as _run_crew
@@ -155,12 +147,18 @@ def uv(uv_args: tuple[str, ...]) -> None:
is_flag=True,
help="Use classic Python/YAML project structure instead of JSON",
)
@click.option(
"--declarative",
is_flag=True,
help="Create a declarative Flow project instead of a Python Flow project",
)
def create(
type: str | None,
name: str | None,
provider: str | None,
skip_provider: bool = False,
classic: bool = False,
declarative: bool = False,
) -> None:
"""Create a new crew, or flow."""
dmn_mode = is_dmn_mode_enabled()
@@ -194,6 +192,8 @@ def create(
if dmn_mode:
skip_provider = True
if type == "crew":
if declarative:
raise click.UsageError("--declarative can only be used with flow projects")
if classic:
from crewai_cli.create_crew import create_crew
@@ -205,7 +205,7 @@ def create(
elif type == "flow":
from crewai_cli.create_flow import create_flow
create_flow(name)
create_flow(name, declarative=declarative)
else:
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
@@ -468,7 +468,7 @@ def memory(
type=str,
default=None,
help=(
"Path to a trained-agents pickle (produced by `crewai train -f`). "
"Crew-only: path to a trained-agents pickle (produced by `crewai train -f`). "
"When set, agents load suggestions from this file instead of the "
"default trained_agents_data.pkl. Equivalent to setting "
"CREWAI_TRAINED_AGENTS_FILE."
@@ -512,16 +512,13 @@ def install(context: click.Context) -> None:
"--definition",
type=str,
default=None,
help=(
"Experimental: path to a Flow Definition YAML/JSON file, "
"or an inline YAML/JSON string."
),
help="Flow-only: path to a declarative flow definition.",
)
@click.option(
"--inputs",
type=str,
default=None,
help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
help='Flow-only: JSON object passed to the declarative flow, e.g. \'{"topic":"AI"}\'.',
)
def run(
trained_agents_file: str | None,
@@ -531,16 +528,14 @@ def run(
"""Run the Crew or Flow."""
if inputs is not None and definition is None:
raise click.UsageError("--inputs requires --definition")
if trained_agents_file is not None and definition is not None:
raise click.UsageError("--filename can only be used when running crews")
if definition is not None:
click.secho(
"Warning: `crewai run --definition` is experimental and may change without notice.",
fg="yellow",
)
run_flow_definition(definition=definition, inputs=inputs)
return
run_crew(trained_agents_file=trained_agents_file)
run_crew(
trained_agents_file=trained_agents_file,
definition=definition,
inputs=inputs,
)
@crewai.command()
@@ -795,10 +790,11 @@ def flow() -> None:
@flow.command(name="kickoff")
def flow_run() -> None:
"""Kickoff the Flow."""
from crewai_cli.kickoff_flow import kickoff_flow
click.echo("Running the Flow")
kickoff_flow()
click.secho(
"The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead.",
fg="yellow",
)
run_crew(trained_agents_file=None, definition=None, inputs=None)
@flow.command(name="plot")

View File

@@ -5,7 +5,10 @@ import click
from crewai_core.telemetry import Telemetry
def create_flow(name: str) -> None:
DECLARATIVE_FLOW_FOLDERS = ("crews", "tools", "knowledge", "skills")
def create_flow(name: str, *, declarative: bool = False) -> None:
"""Create a new flow."""
folder_name = name.replace(" ", "_").replace("-", "_").lower()
class_name = name.replace("_", " ").replace("-", " ").title().replace(" ", "")
@@ -20,6 +23,17 @@ def create_flow(name: str) -> None:
telemetry = Telemetry()
telemetry.flow_creation_span(class_name)
if declarative:
_create_declarative_flow(name, class_name, folder_name, project_root)
else:
_create_python_flow(name, class_name, folder_name, project_root)
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)
def _create_python_flow(
name: str, class_name: str, folder_name: str, project_root: Path
) -> None:
(project_root / "src" / folder_name).mkdir(parents=True)
(project_root / "src" / folder_name / "crews").mkdir(parents=True)
(project_root / "src" / folder_name / "tools").mkdir(parents=True)
@@ -92,4 +106,41 @@ def create_flow(name: str) -> None:
fg="yellow",
)
click.secho(f"Flow {name} created successfully!", fg="green", bold=True)
def _create_declarative_flow(
name: str, class_name: str, folder_name: str, project_root: Path
) -> None:
project_root.mkdir(parents=True)
package_root = project_root / "src" / folder_name
package_root.mkdir(parents=True)
for folder in DECLARATIVE_FLOW_FOLDERS:
(package_root / folder).mkdir()
package_dir = Path(__file__).parent
templates_dir = package_dir / "templates" / "declarative_flow"
agents_md_src = package_dir / "templates" / "AGENTS.md"
if agents_md_src.exists():
shutil.copy2(agents_md_src, project_root / "AGENTS.md")
for src_file in templates_dir.rglob("*"):
if not src_file.is_file():
continue
relative_path = src_file.relative_to(templates_dir)
dst_file = (
project_root / relative_path
if relative_path.name in {".gitignore", "README.md", "pyproject.toml"}
else package_root / relative_path
)
dst_file.parent.mkdir(parents=True, exist_ok=True)
content = src_file.read_text(encoding="utf-8")
content = content.replace("{{name}}", name)
content = content.replace("{{flow_name}}", class_name)
content = content.replace("{{folder_name}}", folder_name)
dst_file.write_text(content, encoding="utf-8")
(project_root / ".env").write_text("OPENAI_API_KEY=YOUR_API_KEY", encoding="utf-8")
(package_root / "__init__.py").write_text("", encoding="utf-8")
for folder in DECLARATIVE_FLOW_FOLDERS:
(package_root / folder / ".gitkeep").write_text("", encoding="utf-8")

View File

@@ -1,23 +0,0 @@
import subprocess
import click
def kickoff_flow() -> None:
"""
Kickoff the flow by running a command in the UV environment.
"""
command = ["uv", "run", "kickoff"]
try:
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
if result.stderr:
click.echo(result.stderr, err=True)
except subprocess.CalledProcessError as e:
click.echo(f"An error occurred while running the flow: {e}", err=True)
click.echo(e.output, err=True)
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)

View File

@@ -5,19 +5,27 @@ import click
def plot_flow() -> None:
"""
Plot the flow by running a command in the UV environment.
Plot the flow from declarative config or the Python UV entrypoint.
"""
command = ["uv", "run", "plot"]
from crewai_cli.run_declarative_flow import (
configured_project_declarative_flow,
plot_declarative_flow_in_project_env,
)
try:
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
if definition := configured_project_declarative_flow():
plot_declarative_flow_in_project_env(definition)
else:
command = ["uv", "run", "plot"]
if result.stderr:
click.echo(result.stderr, err=True)
try:
subprocess.run( # noqa: S603
command, capture_output=False, text=True, check=True
)
except subprocess.CalledProcessError as e:
click.echo(f"An error occurred while plotting the flow: {e}", err=True)
click.echo(e.output, err=True)
except subprocess.CalledProcessError as e:
click.echo(f"An error occurred while plotting the flow: {e}", err=True)
raise SystemExit(1) from e
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)
raise SystemExit(1) from e

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
from enum import Enum
import os
from pathlib import Path
import re
@@ -27,11 +26,6 @@ if TYPE_CHECKING:
from crewai_cli.crew_run_tui import CrewRunApp
class CrewType(Enum):
STANDARD = "standard"
FLOW = "flow"
# Must accept the same names as the kickoff interpolation pattern in
# crewai.utilities.string_utils (_VARIABLE_PATTERN), including hyphens —
# otherwise placeholders are interpolated at runtime but never prompted for.
@@ -537,7 +531,11 @@ def _print_post_tui_summary(app: CrewRunApp) -> None:
)
def run_crew(trained_agents_file: str | None = None) -> None:
def run_crew(
trained_agents_file: str | None = None,
definition: str | None = None,
inputs: str | None = None,
) -> None:
"""Run the crew or flow.
Args:
@@ -545,15 +543,88 @@ def run_crew(trained_agents_file: str | None = None) -> None:
by ``crewai train -f``. When set, exported as
``CREWAI_TRAINED_AGENTS_FILE`` so agents load suggestions from this
file instead of the default ``trained_agents_data.pkl``.
definition: Optional path to a declarative Flow definition.
inputs: Optional JSON object passed to a declarative Flow.
"""
# JSON crew projects take precedence
if inputs is not None and definition is None:
raise click.UsageError("--inputs requires --definition")
if definition is not None:
_run_explicit_declarative_flow(
definition=definition,
inputs=inputs,
trained_agents_file=trained_agents_file,
)
return
if _has_json_crew():
_run_json_crew_in_project_env(trained_agents_file=trained_agents_file)
return
pyproject_data = read_toml()
_warn_if_old_poetry_project(pyproject_data)
project_type = _get_project_type(pyproject_data)
if project_type == "flow":
_run_flow_project(
pyproject_data=pyproject_data,
trained_agents_file=trained_agents_file,
)
return
_run_classic_crew_project(
pyproject_data=pyproject_data,
trained_agents_file=trained_agents_file,
)
def _run_explicit_declarative_flow(
definition: str, inputs: str | None, trained_agents_file: str | None
) -> None:
if trained_agents_file is not None:
raise click.UsageError("--filename can only be used when running crews")
from crewai_cli.run_declarative_flow import run_declarative_flow
run_declarative_flow(definition=definition, inputs=inputs)
def _run_flow_project(
pyproject_data: dict[str, Any], trained_agents_file: str | None
) -> None:
if trained_agents_file is not None:
raise click.UsageError("--filename can only be used when running crews")
from crewai_cli.run_declarative_flow import (
configured_project_declarative_flow,
run_declarative_flow_in_project_env,
)
if definition := configured_project_declarative_flow(pyproject_data):
run_declarative_flow_in_project_env(definition=definition)
return
_execute_uv_script("kickoff", entity_type="flow")
def _run_classic_crew_project(
pyproject_data: dict[str, Any], trained_agents_file: str | None
) -> None:
_execute_uv_script(
"run_crew",
entity_type="crew",
trained_agents_file=trained_agents_file,
)
def _get_project_type(pyproject_data: dict[str, Any]) -> str | None:
project_type = pyproject_data.get("tool", {}).get("crewai", {}).get("type")
return project_type if isinstance(project_type, str) else None
def _warn_if_old_poetry_project(pyproject_data: dict[str, Any]) -> None:
crewai_version = get_crewai_version()
min_required_version = "0.71.0"
pyproject_data = read_toml()
if pyproject_data.get("tool", {}).get("poetry") and (
version.parse(crewai_version) < version.parse(min_required_version)
@@ -564,25 +635,22 @@ def run_crew(trained_agents_file: str | None = None) -> None:
fg="red",
)
is_flow = pyproject_data.get("tool", {}).get("crewai", {}).get("type") == "flow"
crew_type = CrewType.FLOW if is_flow else CrewType.STANDARD
click.echo(f"Running the {'Flow' if is_flow else 'Crew'}")
execute_command(crew_type, trained_agents_file=trained_agents_file)
def execute_command(
crew_type: CrewType, trained_agents_file: str | None = None
def _execute_uv_script(
script_name: str,
*,
entity_type: str,
trained_agents_file: str | None = None,
) -> None:
"""Execute the appropriate command based on crew type.
"""Execute a project script through uv.
Args:
crew_type: The type of crew to run.
script_name: The project script to run.
entity_type: The user-facing entity being run.
trained_agents_file: Optional trained-agents pickle path forwarded to
the subprocess via the ``CREWAI_TRAINED_AGENTS_FILE`` env var.
"""
command = ["uv", "run", "kickoff" if crew_type == CrewType.FLOW else "run_crew"]
command = ["uv", "run", script_name]
env = build_env_with_all_tool_credentials()
if trained_agents_file:
@@ -592,21 +660,20 @@ def execute_command(
subprocess.run(command, capture_output=False, text=True, check=True, env=env) # noqa: S603
except subprocess.CalledProcessError as e:
handle_error(e, crew_type)
_handle_run_error(e, entity_type)
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)
def handle_error(error: subprocess.CalledProcessError, crew_type: CrewType) -> None:
def _handle_run_error(error: subprocess.CalledProcessError, entity_type: str) -> None:
"""
Handle subprocess errors with appropriate messaging.
Args:
error: The subprocess error that occurred
crew_type: The type of crew that was being run
entity_type: The type of entity that was being run
"""
entity_type = "flow" if crew_type == CrewType.FLOW else "crew"
click.echo(f"An error occurred while running the {entity_type}: {error}", err=True)
if error.output:

View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import json
from pathlib import Path
import subprocess
from typing import Any
import click
from crewai_cli.utils import build_env_with_all_tool_credentials
def run_declarative_flow_in_project_env(
definition: str, inputs: str | None = None
) -> None:
"""Run a declarative flow inside the project's Python environment."""
if is_declarative_flow_project_env() or not _has_project_file():
run_declarative_flow(definition=definition, inputs=inputs)
return
if inputs is not None:
raise click.UsageError("--inputs is only supported with --definition")
_execute_declarative_flow_command(["uv", "run", "crewai", "run"])
def plot_declarative_flow_in_project_env(definition: str) -> None:
"""Plot a declarative flow inside the project's Python environment."""
if is_declarative_flow_project_env() or not _has_project_file():
plot_declarative_flow(definition=definition)
return
_execute_declarative_flow_command(["uv", "run", "crewai", "flow", "plot"])
def run_declarative_flow(definition: str, inputs: str | None = None) -> None:
"""Run a declarative flow from a definition path."""
parsed_inputs = _parse_inputs(inputs)
try:
flow = load_declarative_flow(definition)
result = flow.kickoff(inputs=parsed_inputs)
except Exception as exc:
click.echo(
f"An error occurred while running the declarative flow: {exc}", err=True
)
raise SystemExit(1) from exc
click.echo(_format_result(result))
def plot_declarative_flow(definition: str) -> None:
"""Plot a declarative flow from a definition path."""
try:
flow = load_declarative_flow(definition)
flow.plot()
except Exception as exc:
click.echo(
f"An error occurred while plotting the declarative flow: {exc}", err=True
)
raise SystemExit(1) from exc
def load_declarative_flow(definition: str) -> Any:
"""Load a declarative Flow instance from a definition path."""
try:
from crewai.flow.flow import Flow
from crewai.flow.flow_definition import FlowDefinition
except ImportError as exc:
click.echo(
"Running declarative flows requires the full crewai package.",
err=True,
)
raise SystemExit(1) from exc
definition_path = Path(definition).expanduser()
definition_source = _read_declarative_flow_source(definition_path, definition)
flow_definition = _parse_declarative_flow(
FlowDefinition,
definition_source,
source_path=definition_path,
)
return Flow.from_definition(flow_definition)
def configured_project_declarative_flow(
pyproject_data: dict[str, Any] | None = None,
) -> str | None:
"""Return the configured declarative flow source for flow projects."""
if pyproject_data is None:
try:
from crewai_cli.utils import read_toml
pyproject_data = read_toml()
except Exception:
return None
crewai_config = pyproject_data.get("tool", {}).get("crewai", {})
if crewai_config.get("type") != "flow":
return None
definition = crewai_config.get("definition")
if not isinstance(definition, str):
return None
return definition.strip() or None
def _execute_declarative_flow_command(command: list[str]) -> None:
env = build_env_with_all_tool_credentials()
try:
subprocess.run( # noqa: S603
command,
capture_output=False,
text=True,
check=True,
env=env,
)
except subprocess.CalledProcessError as e:
raise SystemExit(e.returncode) from e
except Exception as e:
click.echo(
f"An unexpected error occurred while running the declarative flow: {e}",
err=True,
)
raise SystemExit(1) from e
def is_declarative_flow_project_env() -> bool:
import os
return os.environ.get("UV_RUN_RECURSION_DEPTH") is not None
def _has_project_file(project_root: Path | None = None) -> bool:
root = project_root or Path.cwd()
return (root / "pyproject.toml").is_file()
def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
if inputs is None:
return None
try:
parsed = json.loads(inputs)
except json.JSONDecodeError as exc:
click.echo(f"Invalid --inputs JSON: {exc}", err=True)
raise SystemExit(1) from exc
if not isinstance(parsed, dict):
click.echo("Invalid --inputs JSON: expected an object.", err=True)
raise SystemExit(1)
return parsed
def _read_declarative_flow_source(path: Path, definition: str) -> str:
try:
if path.is_file():
source = _read_declarative_flow_file(path)
elif path.exists():
click.echo(
f"Invalid --definition path: {definition} is not a file.", err=True
)
raise SystemExit(1)
else:
click.echo(
f"Invalid --definition path: {definition} does not exist.", err=True
)
raise SystemExit(1)
except OSError as exc:
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
return source
def _read_declarative_flow_file(path: Path) -> str:
try:
source = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
click.echo(
f"Unable to read --definition path {path}: {exc}",
err=True,
)
raise SystemExit(1) from exc
return source
def _parse_declarative_flow(
flow_definition_cls: type[Any], source: str, *, source_path: Path
) -> Any:
if _looks_like_json(source):
return flow_definition_cls.from_json(source, source_path=source_path)
return flow_definition_cls.from_yaml(source, source_path=source_path)
def _looks_like_json(source: str) -> bool:
stripped = source.lstrip()
return stripped.startswith("{")
def _format_result(result: Any) -> str:
raw_result = getattr(result, "raw", result)
if isinstance(raw_result, str):
return raw_result
try:
return json.dumps(raw_result, default=str)
except TypeError:
return str(raw_result)

View File

@@ -1,113 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import click
def run_flow_definition(definition: str, inputs: str | None = None) -> None:
"""Run a flow from a Flow Definition YAML/JSON string or file path."""
try:
from crewai.flow.flow import Flow
from crewai.flow.flow_definition import FlowDefinition
except ImportError as exc:
click.echo(
"Running flows from definitions requires the full crewai package.",
err=True,
)
raise SystemExit(1) from exc
parsed_inputs = _parse_inputs(inputs)
definition_source = _read_definition_source(definition)
try:
flow_definition = _parse_flow_definition(FlowDefinition, definition_source)
flow = Flow.from_definition(flow_definition)
result = flow.kickoff(inputs=parsed_inputs)
except Exception as exc:
click.echo(
f"An error occurred while running the flow definition: {exc}", err=True
)
raise SystemExit(1) from exc
click.echo(_format_result(result))
def _parse_inputs(inputs: str | None) -> dict[str, Any] | None:
if inputs is None:
return None
try:
parsed = json.loads(inputs)
except json.JSONDecodeError as exc:
click.echo(f"Invalid --inputs JSON: {exc}", err=True)
raise SystemExit(1) from exc
if not isinstance(parsed, dict):
click.echo("Invalid --inputs JSON: expected an object.", err=True)
raise SystemExit(1)
return parsed
def _read_definition_source(definition: str) -> str:
path = Path(definition).expanduser()
try:
is_file = path.is_file()
except OSError as exc:
if _looks_like_inline_definition(definition):
return definition
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
if is_file:
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
click.echo(
f"Unable to read --definition path {path}: {exc}",
err=True,
)
raise SystemExit(1) from exc
try:
if path.exists():
click.echo(
f"Invalid --definition path: {definition} is not a file.", err=True
)
raise SystemExit(1)
except OSError as exc:
click.echo(f"Invalid --definition path: {definition} ({exc})", err=True)
raise SystemExit(1) from exc
return definition
def _looks_like_inline_definition(definition: str) -> bool:
stripped = definition.lstrip()
return "\n" in definition or stripped.startswith(("{", "---")) or ":" in stripped
def _parse_flow_definition(flow_definition_cls: type[Any], source: str) -> Any:
if _looks_like_json(source):
return flow_definition_cls.from_json(source)
return flow_definition_cls.from_yaml(source)
def _looks_like_json(source: str) -> bool:
stripped = source.lstrip()
return stripped.startswith("{")
def _format_result(result: Any) -> str:
raw_result = getattr(result, "raw", result)
if isinstance(raw_result, str):
return raw_result
try:
return json.dumps(raw_result, default=str)
except TypeError:
return str(raw_result)

View File

@@ -62,7 +62,7 @@ crewai create flow <name> --skip_provider # New flow project
# Running
crewai run # Run crew or flow (auto-detects from pyproject.toml)
crewai flow kickoff # Legacy flow execution
crewai flow kickoff # Deprecated compatibility alias for crewai run
# Testing & training
crewai test # Test crew (default: 2 iterations, gpt-4o-mini)

View File

@@ -0,0 +1,5 @@
.env
.venv/
__pycache__/
.crewai/
output/

View File

@@ -0,0 +1,17 @@
# {{name}} Flow
This project defines a declarative CrewAI Flow in `src/{{folder_name}}/flow.yaml`.
## Install
```bash
crewai install
```
## Run
```bash
crewai run
```
Edit the declarative flow definition at `src/{{folder_name}}/flow.yaml` to change the flow. Add reusable crews under `src/{{folder_name}}/crews/`, custom Python tools under `src/{{folder_name}}/tools/`, and shared knowledge files under `src/{{folder_name}}/knowledge/`.

View File

@@ -0,0 +1,15 @@
schema: crewai.flow/v1
name: {{flow_name}}
description: A declarative CrewAI Flow.
state:
type: dict
default:
topic: AI agents
methods:
start:
start: true
do:
call: expression
expr: state.topic

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]==1.14.8a2"
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/{{folder_name}}"]
[tool.crewai]
type = "flow"
definition = "src/{{folder_name}}/flow.yaml"

View File

@@ -12,6 +12,7 @@ from crewai_cli.cli import (
deploy_remove,
deply_status,
flow_add_crew,
flow_run,
login,
reset_memories,
run,
@@ -126,38 +127,75 @@ def test_run_uses_project_runner_by_default(run_crew, runner):
result = runner.invoke(run)
assert result.exit_code == 0
run_crew.assert_called_once_with(trained_agents_file=None)
run_crew.assert_called_once_with(
trained_agents_file=None,
definition=None,
inputs=None,
)
assert "experimental" not in result.output.lower()
@mock.patch("crewai_cli.cli.run_flow_definition")
def test_run_with_definition_uses_definition_runner(run_flow_definition, runner):
@mock.patch("crewai_cli.cli.run_crew")
def test_run_with_definition_uses_project_runner(run_crew, runner):
result = runner.invoke(
run,
["--definition", "flow.yaml", "--inputs", '{"topic":"AI"}'],
)
assert result.exit_code == 0
assert (
"Warning: `crewai run --definition` is experimental and may change without notice."
in result.output
)
run_flow_definition.assert_called_once_with(
definition="flow.yaml", inputs='{"topic":"AI"}'
run_crew.assert_called_once_with(
trained_agents_file=None,
definition="flow.yaml",
inputs='{"topic":"AI"}',
)
@mock.patch("crewai_cli.cli.run_crew")
@mock.patch("crewai_cli.cli.run_flow_definition")
def test_run_rejects_inputs_without_definition(run_flow_definition, run_crew, runner):
def test_run_rejects_inputs_without_definition(run_crew, runner):
result = runner.invoke(run, ["--inputs", '{"topic":"AI"}'])
assert result.exit_code == 2
assert "Error: --inputs requires --definition" in result.output
run_flow_definition.assert_not_called()
run_crew.assert_not_called()
@mock.patch("crewai_cli.cli.run_crew")
def test_run_rejects_filename_with_definition(run_crew, runner):
result = runner.invoke(run, ["--definition", "flow.yaml", "--filename", "x.pkl"])
assert result.exit_code == 2
assert "Error: --filename can only be used when running crews" in result.output
run_crew.assert_not_called()
@mock.patch("crewai_cli.cli.run_crew")
def test_run_passes_filename_to_project_runner(run_crew, runner):
result = runner.invoke(run, ["--filename", "trained.pkl"])
assert result.exit_code == 0
run_crew.assert_called_once_with(
trained_agents_file="trained.pkl",
definition=None,
inputs=None,
)
@mock.patch("crewai_cli.cli.run_crew")
def test_flow_kickoff_is_deprecated_and_uses_run_path(run_crew, runner):
result = runner.invoke(flow_run)
assert result.exit_code == 0
run_crew.assert_called_once_with(
trained_agents_file=None,
definition=None,
inputs=None,
)
assert (
"The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead."
in result.output
)
@mock.patch("crewai_cli.create_json_crew.create_json_crew")
def test_create_crew_in_dmn_mode_skips_provider_prompts(create_json_crew, runner):
result = runner.invoke(create, ["crew", "DMN Crew"], env={"CREWAI_DMN": "True"})
@@ -166,6 +204,23 @@ def test_create_crew_in_dmn_mode_skips_provider_prompts(create_json_crew, runner
create_json_crew.assert_called_once_with("DMN Crew", None, True)
@mock.patch("crewai_cli.create_flow.create_flow")
def test_create_flow_declarative_uses_declarative_scaffold(create_flow, runner):
result = runner.invoke(create, ["flow", "My Flow", "--declarative"])
assert result.exit_code == 0
create_flow.assert_called_once_with("My Flow", declarative=True)
@mock.patch("crewai_cli.create_json_crew.create_json_crew")
def test_create_crew_rejects_declarative_flag(create_json_crew, runner):
result = runner.invoke(create, ["crew", "My Crew", "--declarative"])
assert result.exit_code == 2
assert "--declarative can only be used with flow projects" in result.output
create_json_crew.assert_not_called()
def test_create_requires_type_in_dmn_mode(runner):
result = runner.invoke(create, env={"CREWAI_DMN": "True"})

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from pathlib import Path
from click.testing import CliRunner
from pytest import MonkeyPatch
import tomli
from crewai_cli.cli import crewai
from crewai_cli.create_flow import create_flow
def test_create_flow_declarative_project_can_run(
tmp_path: Path, monkeypatch: MonkeyPatch
):
monkeypatch.chdir(tmp_path)
create_flow("Research Flow", declarative=True)
project_root = tmp_path / "research_flow"
assert project_root.is_dir()
pyproject = tomli.loads(
(project_root / "pyproject.toml").read_text(encoding="utf-8")
)
assert pyproject["project"]["name"] == "research_flow"
assert pyproject["project"]["requires-python"]
assert pyproject["project"]["dependencies"]
assert (project_root / pyproject["tool"]["crewai"]["definition"]).is_file()
monkeypatch.chdir(project_root)
result = CliRunner().invoke(crewai, ["run"], env={"UV_RUN_RECURSION_DEPTH": "1"})
assert result.exit_code == 0
assert "Running the Flow" not in result.output
assert "AI agents" in result.output

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from pathlib import Path
import subprocess
import pytest
from click.testing import CliRunner
from crewai_cli.cli import flow_run
import crewai_cli.plot_flow as plot_flow_module
FLOW_YAML = """\
schema: crewai.flow/v1
name: TestFlow
config:
suppress_flow_events: true
methods:
begin:
start: true
do:
call: expression
expr: "'AI'"
"""
def _write_flow_project(project_root: Path) -> None:
(project_root / "flow.yaml").write_text(FLOW_YAML, encoding="utf-8")
(project_root / "pyproject.toml").write_text(
'[project]\nname = "demo"\n\n'
'[tool.crewai]\ntype = "flow"\ndefinition = "flow.yaml"\n',
encoding="utf-8",
)
def test_flow_kickoff_runs_configured_declarative_definition(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
_write_flow_project(tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
result = CliRunner().invoke(flow_run)
assert result.exit_code == 0
assert (
"The command 'crewai flow kickoff' is deprecated. Use 'crewai run' instead."
in result.output
)
assert "AI\n" in result.output
assert "Running the Flow" not in result.output
def test_plot_flow_runs_configured_declarative_definition(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_write_flow_project(tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
plot_flow_module.plot_flow()
def test_flow_kickoff_delegates_to_run_crew(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = []
monkeypatch.setattr(
"crewai_cli.cli.run_crew",
lambda **kwargs: calls.append(kwargs),
)
result = CliRunner().invoke(flow_run)
assert result.exit_code == 0
assert calls == [
{"trained_agents_file": None, "definition": None, "inputs": None},
]
def test_plot_flow_keeps_python_entrypoint_without_definition(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
subprocess_calls = []
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(
subprocess,
"run",
lambda command, **kwargs: subprocess_calls.append((command, kwargs)),
)
plot_flow_module.plot_flow()
assert subprocess_calls == [
(
["uv", "run", "plot"],
{"capture_output": False, "text": True, "check": True},
)
]
def test_configured_project_declarative_flow(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text(
'[tool.crewai]\ntype = "flow"\ndefinition = " flow.yaml "\n',
encoding="utf-8",
)
from crewai_cli.run_declarative_flow import configured_project_declarative_flow
assert configured_project_declarative_flow() == "flow.yaml"

View File

@@ -568,3 +568,131 @@ def test_has_json_crew_true_without_pyproject(monkeypatch, tmp_path: Path):
(tmp_path / "crew.jsonc").write_text("{}")
assert run_crew_module._has_json_crew() is True
def test_run_crew_rejects_inputs_without_definition():
with pytest.raises(click.UsageError) as exc_info:
run_crew_module.run_crew(inputs='{"topic":"AI"}')
assert "--inputs requires --definition" in exc_info.value.message
def test_run_crew_rejects_filename_with_explicit_definition():
with pytest.raises(click.UsageError) as exc_info:
run_crew_module.run_crew(
trained_agents_file="trained.pkl",
definition="flow.yaml",
)
assert "--filename can only be used when running crews" in exc_info.value.message
def test_run_crew_runs_explicit_declarative_definition(monkeypatch, capsys):
calls = []
def fake_run_declarative_flow(definition: str, inputs: str | None = None):
calls.append((definition, inputs))
monkeypatch.setattr(
"crewai_cli.run_declarative_flow.run_declarative_flow",
fake_run_declarative_flow,
)
run_crew_module.run_crew(definition="flow.yaml", inputs='{"topic":"AI"}')
captured = capsys.readouterr()
assert "experimental" not in captured.out.lower()
assert calls == [("flow.yaml", '{"topic":"AI"}')]
def test_run_crew_runs_classic_crew_project(monkeypatch, capsys):
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
lambda: {"tool": {"crewai": {"type": "crew"}}},
)
monkeypatch.setattr(
run_crew_module,
"_execute_uv_script",
lambda script_name, **kwargs: calls.append((script_name, kwargs)),
)
run_crew_module.run_crew(trained_agents_file="trained.pkl")
assert capsys.readouterr().out == ""
assert calls == [
(
"run_crew",
{"entity_type": "crew", "trained_agents_file": "trained.pkl"},
)
]
def test_run_crew_runs_python_flow_project(monkeypatch, capsys):
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
lambda: {"tool": {"crewai": {"type": "flow"}}},
)
monkeypatch.setattr(
run_crew_module,
"_execute_uv_script",
lambda script_name, **kwargs: calls.append((script_name, kwargs)),
)
run_crew_module.run_crew()
assert capsys.readouterr().out == ""
assert calls == [("kickoff", {"entity_type": "flow"})]
def test_run_crew_rejects_filename_for_flow_project(monkeypatch):
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
lambda: {"tool": {"crewai": {"type": "flow"}}},
)
with pytest.raises(click.UsageError) as exc_info:
run_crew_module.run_crew(trained_agents_file="trained.pkl")
assert "--filename can only be used when running crews" in exc_info.value.message
def test_run_crew_runs_configured_declarative_flow_project(monkeypatch, capsys):
calls = []
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: False)
monkeypatch.setattr(
run_crew_module,
"read_toml",
lambda: {
"tool": {
"crewai": {
"type": "flow",
"definition": "flow.yaml",
}
}
},
)
monkeypatch.setattr(
"crewai_cli.run_declarative_flow.run_declarative_flow_in_project_env",
lambda definition, inputs=None: calls.append((definition, inputs)),
)
monkeypatch.setattr(
run_crew_module,
"_execute_uv_script",
lambda *_args, **_kwargs: pytest.fail("declarative flows must not run kickoff"),
)
run_crew_module.run_crew()
assert capsys.readouterr().out == ""
assert calls == [("flow.yaml", None)]

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from pathlib import Path
import pytest
import crewai_cli.run_declarative_flow as run_declarative_flow_module
FLOW_YAML = """\
schema: crewai.flow/v1
name: TestFlow
config:
suppress_flow_events: true
methods:
begin:
start: true
do:
call: expression
expr: state.topic
"""
def test_run_declarative_flow_reads_definition_file(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(FLOW_YAML, encoding="utf-8")
run_declarative_flow_module.run_declarative_flow(
str(definition_path), '{"topic":"AI"}'
)
assert capsys.readouterr().out == "AI\n"
def test_run_declarative_flow_rejects_non_object_inputs(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
definition_path = tmp_path / "flow.yaml"
definition_path.write_text(FLOW_YAML, encoding="utf-8")
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow(
str(definition_path), '["not", "an", "object"]'
)
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
def test_run_declarative_flow_reports_missing_file(
capsys: pytest.CaptureFixture[str],
) -> None:
with pytest.raises(SystemExit):
run_declarative_flow_module.run_declarative_flow("missing-flow.yaml")
assert (
"Invalid --definition path: missing-flow.yaml does not exist."
in capsys.readouterr().err
)
def test_run_declarative_flow_in_project_env_uses_uv(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
subprocess_calls = []
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("UV_RUN_RECURSION_DEPTH", raising=False)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
monkeypatch.setattr(
run_declarative_flow_module,
"build_env_with_all_tool_credentials",
lambda: {"EXISTING": "value"},
)
monkeypatch.setattr(
run_declarative_flow_module.subprocess,
"run",
lambda command, **kwargs: subprocess_calls.append((command, kwargs)),
)
run_declarative_flow_module.run_declarative_flow_in_project_env("flow.yaml")
assert subprocess_calls == [
(
["uv", "run", "crewai", "run"],
{
"capture_output": False,
"text": True,
"check": True,
"env": {"EXISTING": "value"},
},
)
]
def test_run_declarative_flow_in_process_inside_uv(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("UV_RUN_RECURSION_DEPTH", "1")
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "flow.yaml").write_text(FLOW_YAML, encoding="utf-8")
run_declarative_flow_module.run_declarative_flow_in_project_env(
"flow.yaml", '{"topic":"AI"}'
)
assert capsys.readouterr().out == "AI\n"

View File

@@ -1,156 +0,0 @@
from __future__ import annotations
import json
import sys
import types
import pytest
import yaml
from crewai_cli.run_flow_definition import run_flow_definition
class _FakeFlow:
def __init__(self, definition):
self.definition = definition
def kickoff(self, inputs=None):
return {
"flow": self.definition["name"],
"inputs": inputs or {},
}
class _FakeFlowFactory:
@classmethod
def from_definition(cls, definition):
return _FakeFlow(definition)
class _FakeFlowDefinition:
@classmethod
def from_yaml(cls, source):
return yaml.safe_load(source)
@classmethod
def from_json(cls, source):
return json.loads(source)
@pytest.fixture
def fake_flow_runtime(monkeypatch):
crewai_module = types.ModuleType("crewai")
flow_package = types.ModuleType("crewai.flow")
flow_module = types.ModuleType("crewai.flow.flow")
flow_definition_module = types.ModuleType("crewai.flow.flow_definition")
flow_module.Flow = _FakeFlowFactory
flow_definition_module.FlowDefinition = _FakeFlowDefinition
monkeypatch.setitem(sys.modules, "crewai", crewai_module)
monkeypatch.setitem(sys.modules, "crewai.flow", flow_package)
monkeypatch.setitem(sys.modules, "crewai.flow.flow", flow_module)
monkeypatch.setitem(
sys.modules, "crewai.flow.flow_definition", flow_definition_module
)
def _captured_json(capsys):
return json.loads(capsys.readouterr().out)
def test_run_flow_definition_reads_definition_file(
tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
run_flow_definition(str(definition_path), '{"topic":"AI"}')
assert _captured_json(capsys) == {
"flow": "TestFlow",
"inputs": {"topic": "AI"},
}
@pytest.mark.parametrize(
("definition_source", "expected_flow_name"),
[
pytest.param(
"schema: crewai.flow/v1\nname: InlineFlow\n",
"InlineFlow",
id="inline-yaml",
),
pytest.param(
'{"schema":"crewai.flow/v1","name":"InlineJsonFlow"}',
"InlineJsonFlow",
id="inline-json",
),
pytest.param(
'{"schema":"crewai.flow/v1","name":"' + ("JsonFlow" * 500) + '"}',
"JsonFlow" * 500,
id="large-inline-json",
),
],
)
def test_run_flow_definition_accepts_inline_definitions(
definition_source, expected_flow_name, capsys, fake_flow_runtime
):
run_flow_definition(definition_source)
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
@pytest.mark.parametrize(
("filename", "definition_source", "expected_flow_name"),
[
pytest.param(
"flow.yaml",
"schema: crewai.flow/v1\nname: YamlFileFlow\n",
"YamlFileFlow",
id="yaml-file",
),
pytest.param(
"flow.json",
'{"schema":"crewai.flow/v1","name":"JsonFlow"}',
"JsonFlow",
id="json-file",
),
],
)
def test_run_flow_definition_accepts_definition_files(
filename, definition_source, expected_flow_name, tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / filename
definition_path.write_text(definition_source)
run_flow_definition(str(definition_path))
assert _captured_json(capsys) == {"flow": expected_flow_name, "inputs": {}}
def test_run_flow_definition_rejects_non_object_inputs(fake_flow_runtime, capsys):
with pytest.raises(SystemExit):
run_flow_definition("name: TestFlow", '["not", "an", "object"]')
assert "Invalid --inputs JSON: expected an object." in capsys.readouterr().err
def test_run_flow_definition_reports_unreadable_file(
monkeypatch, tmp_path, capsys, fake_flow_runtime
):
definition_path = tmp_path / "flow.yaml"
definition_path.write_text("schema: crewai.flow/v1\nname: TestFlow\n")
def raise_permission_error(self, *args, **kwargs):
raise PermissionError("no access")
monkeypatch.setattr("pathlib.Path.read_text", raise_permission_error)
with pytest.raises(SystemExit):
run_flow_definition(str(definition_path))
err = capsys.readouterr().err
assert "Unable to read --definition path" in err
assert str(definition_path) in err
assert "no access" in err

View File

@@ -16,9 +16,9 @@ dependencies = [
"pyjwt>=2.13.0,<3",
"pydantic>=2.11.9,<2.13",
"rich>=13.7.1",
"opentelemetry-api~=1.34.0",
"opentelemetry-sdk~=1.34.0",
"opentelemetry-exporter-otlp-proto-http~=1.34.0",
"opentelemetry-api~=1.42.0",
"opentelemetry-sdk~=1.42.0",
"opentelemetry-exporter-otlp-proto-http~=1.42.0",
"tomli~=2.0.2",
]

View File

@@ -18,9 +18,9 @@ dependencies = [
"pdfplumber~=0.11.4",
"regex~=2026.1.15",
# Telemetry and Monitoring
"opentelemetry-api~=1.34.0",
"opentelemetry-sdk~=1.34.0",
"opentelemetry-exporter-otlp-proto-http~=1.34.0",
"opentelemetry-api~=1.42.0",
"opentelemetry-sdk~=1.42.0",
"opentelemetry-exporter-otlp-proto-http~=1.42.0",
# Data Handling
"chromadb~=1.1.0",
"tokenizers>=0.21,<1",

View File

@@ -373,9 +373,6 @@ To enable tracing, do any one of these:
status: str = "running",
) -> None:
"""Show method status panel."""
if not self.verbose:
return
if status == "running":
style = "yellow"
panel_title = "🔄 Flow Method Running"

View File

@@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_merge_flow_method_definition,
_method_action,
_set_flow_method_definition,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import ListenMethod
@@ -45,7 +45,7 @@ def listen(condition: FlowTrigger) -> FlowMethodDecorator:
def decorator(func: Callable[P, R]) -> ListenMethod[P, R]:
wrapper = ListenMethod(func)
_set_flow_method_definition(
_merge_flow_method_definition(
wrapper,
FlowMethodDefinition(
do=_method_action(func),

View File

@@ -19,8 +19,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_merge_flow_method_definition,
_method_action,
_set_flow_method_definition,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import RouterMethod
@@ -95,7 +95,7 @@ def _normalize_router_emit(value: Sequence[Any] | str) -> list[str]:
def router(
condition: FlowTrigger,
condition: FlowTrigger | None = None,
*,
emit: Sequence[str] | str | None = None,
) -> FlowMethodDecorator:
@@ -107,6 +107,7 @@ def router(
Args:
condition: Specifies when the router should execute. Can be:
- None: no listen trigger, used when stacking with @start() or @listen()
- str: Route label or method name that triggers this router
- FlowCondition: Result from or_() or and_(), including nested conditions
- Flow method reference: A method whose completion triggers this router
@@ -146,14 +147,17 @@ def router(
else:
router_events = _get_router_return_events(func) or []
_set_flow_method_definition(
method_definition_kwargs: dict[str, Any] = {
"do": _method_action(func),
"router": True,
"emit": router_events or None,
}
if condition is not None:
method_definition_kwargs["listen"] = _to_definition_condition(condition)
_merge_flow_method_definition(
wrapper,
FlowMethodDefinition(
do=_method_action(func),
listen=_to_definition_condition(condition),
router=True,
emit=router_events or None,
),
FlowMethodDefinition(**method_definition_kwargs),
)
return wrapper

View File

@@ -8,8 +8,8 @@ from crewai.flow.dsl._types import FlowMethodDecorator, FlowTrigger
from crewai.flow.dsl._utils import (
P,
R,
_merge_flow_method_definition,
_method_action,
_set_flow_method_definition,
)
from crewai.flow.flow_definition import FlowMethodDefinition
from crewai.flow.flow_wrappers import StartMethod
@@ -54,7 +54,7 @@ def start(
def decorator(func: Callable[P, R]) -> StartMethod[P, R]:
wrapper = StartMethod(func)
_set_flow_method_definition(
_merge_flow_method_definition(
wrapper,
FlowMethodDefinition(
do=_method_action(func),

View File

@@ -106,6 +106,25 @@ def _get_flow_method_definition(method: Any) -> FlowMethodDefinition | None:
return None
def _merge_flow_method_definition(
wrapper: FlowMethod[P, R],
definition: FlowMethodDefinition,
) -> None:
existing = _get_flow_method_definition(wrapper)
if existing is None:
_set_flow_method_definition(wrapper, definition)
return
updates = {
field_name: getattr(definition, field_name)
for field_name in definition.model_fields_set
}
_set_flow_method_definition(
wrapper,
existing.model_copy(deep=True, update=updates),
)
def _is_json_serializable(value: Any) -> bool:
try:
json.dumps(value)

View File

@@ -1,6 +1,6 @@
"""Flow Structure: the serializable, language-agnostic Flow contract.
"""Flow Definition: the serializable, declarative Flow contract.
Defines :class:`FlowDefinition` and its sub-models — a static, textual
Defines :class:`FlowDefinition` and its sub-models — a static, declarative
(JSON/YAML) representation of a Flow: its methods, trigger conditions,
state, and configuration. It is independent of the Python authoring
layer that may have produced it and of the engine that runs it (see
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
import logging
from pathlib import Path
import re
from typing import Annotated, Any, Literal, TypeAlias, cast
@@ -18,6 +19,7 @@ from pydantic import (
BaseModel,
ConfigDict,
Field,
PrivateAttr,
field_serializer,
model_validator,
)
@@ -406,10 +408,19 @@ class FlowCrewActionDefinition(BaseModel):
)
call: Literal["crew"] = Field(
description="Action discriminator. Use crew to run an inline Crew definition.",
description=(
"Action discriminator. Use crew to run an inline or referenced Crew "
"definition."
),
examples=["crew"],
)
with_: CrewDefinition = Field(
from_declaration: str | None = Field(
default=None,
description="Path to a JSON/JSONC Crew declaration file or folder.",
examples=["crews/research_crew"],
)
with_: CrewDefinition | None = Field(
default=None,
alias="with",
description="Inline Crew definition to load and execute for this action.",
examples=[
@@ -430,10 +441,26 @@ class FlowCrewActionDefinition(BaseModel):
"agent": "researcher",
}
],
"inputs": {"topic": "${state.topic}"},
}
],
)
inputs: dict[str, ExpressionData] | None = Field(
default=None,
description=(
"Input overrides passed to the Crew. String values are evaluated as CEL "
"only when the trimmed value starts with ${ and ends with }; all other "
"values are literal."
),
examples=[{"topic": "${state.topic}"}],
)
@model_validator(mode="after")
def _validate_crew_source(self) -> FlowCrewActionDefinition:
if bool(self.from_declaration) == (self.with_ is not None):
raise ValueError(
"crew action requires exactly one of from_declaration or with"
)
return self
class FlowAgentActionDefinition(BaseModel):
@@ -684,10 +711,12 @@ class FlowDefinition(BaseModel):
arbitrary_types_allowed=True,
)
_source_path: Path | None = PrivateAttr(default=None)
schema_: Literal["crewai.flow/v1"] = Field(
default="crewai.flow/v1",
alias="schema",
description="Flow Definition schema identifier and version.",
description="Declarative Flow schema identifier and version.",
examples=["crewai.flow/v1"],
)
name: str = Field(
@@ -764,29 +793,45 @@ class FlowDefinition(BaseModel):
allow_unicode=True,
)
@property
def source_path(self) -> Path | None:
"""Original definition file path, when loaded from a file."""
return self._source_path
@property
def source_dir(self) -> Path | None:
"""Directory used to resolve relative paths in the definition."""
if self._source_path is None:
return None
return self._source_path.parent
@classmethod
def from_dict(cls, data: dict[str, Any]) -> FlowDefinition:
def from_dict(
cls, data: dict[str, Any], *, source_path: Path | None = None
) -> FlowDefinition:
"""Load a definition from a dictionary."""
definition = cls.model_validate(data)
if source_path is not None:
definition._source_path = source_path.expanduser().resolve()
log_flow_definition_issues(definition)
return definition
@classmethod
def from_json(cls, data: str) -> FlowDefinition:
def from_json(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition:
"""Load a definition from JSON."""
return cls.from_dict(json.loads(data))
return cls.from_dict(json.loads(data), source_path=source_path)
@classmethod
def from_yaml(cls, data: str) -> FlowDefinition:
def from_yaml(cls, data: str, *, source_path: Path | None = None) -> FlowDefinition:
"""Load a definition from YAML."""
loaded = yaml.safe_load(data) or {}
if not isinstance(loaded, dict):
raise ValueError("Flow definition YAML must contain a mapping")
return cls.from_dict(loaded)
return cls.from_dict(loaded, source_path=source_path)
@classmethod
def json_schema(cls) -> dict[str, Any]:
"""Return the JSON Schema for the Flow Definition contract."""
"""Return the JSON Schema for the declarative Flow contract."""
return cls.model_json_schema(by_alias=True)
@@ -826,10 +871,16 @@ def _validate_action_cel(
return
if isinstance(action, FlowCrewActionDefinition):
Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.with.inputs",
)
if action.with_ is not None:
Expression(cast(ExpressionData, action.with_.inputs)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.with.inputs",
)
if action.inputs is not None:
Expression(cast(ExpressionData, action.inputs)).validate_template(
allowed_roots=allowed_roots,
source=f"{path}.inputs",
)
return
if isinstance(action, FlowAgentActionDefinition):
@@ -870,14 +921,6 @@ def _validate_action_cel(
def log_flow_definition_issues(definition: FlowDefinition) -> None:
for method_name, method in definition.methods.items():
path = f"methods.{method_name}"
if method.router and not method.is_start and method.listen is None:
_log_flow_definition_issue(
definition.name,
code="router_without_trigger",
severity="error",
path=path,
message="router: true requires either start or listen",
)
if method.emit and not method.router:
_log_flow_definition_issue(
definition.name,

View File

@@ -2472,11 +2472,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
object.__setattr__(
self, "_deferred_flow_started_event_id", started_event.event_id
)
if not self.suppress_flow_events:
self._log_flow_event(
f"Flow started with ID: {self.flow_id}", color="bold magenta"
)
# After FlowStarted: env events must not pre-empt trace batch init
# with implicit "crew" execution_type.
get_env_context()
@@ -3043,6 +3038,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
"""
# First, handle routers repeatedly until no router triggers anymore
router_results = []
router_result_payloads: dict[str, Any] = {}
router_result_to_feedback: dict[
str, Any
] = {} # Map outcome -> HumanFeedbackResult
@@ -3080,6 +3076,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
router_result_str = str(router_result)
router_result_event = FlowMethodName(router_result_str)
router_results.append(router_result_event)
router_result_payloads[router_result_str] = (
self.last_human_feedback
if self.last_human_feedback is not None
else router_result
)
if self.last_human_feedback is not None:
router_result_to_feedback[router_result_str] = (
@@ -3100,7 +3101,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
current_trigger, router_only=False
)
if listeners_triggered:
listener_result = router_result_to_feedback.get(
listener_result = router_result_payloads.get(
str(current_trigger), result
)
racing_group = self._get_racing_group_for_listeners(

View File

@@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable
import contextvars
import inspect
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, cast
from crewai.flow.expressions import Expression, ExpressionData
@@ -128,16 +129,34 @@ class CrewAction:
self.definition = definition
async def run(self, *_args: Any, **kwargs: Any) -> Any:
from crewai.project.crew_loader import load_crew_from_definition
from crewai.project.crew_loader import load_crew, load_crew_from_definition
local_context = _pop_local_context(kwargs)
crew_definition = self.definition.with_
if self.definition.from_declaration is not None:
crew, default_inputs = load_crew(
_resolve_crew_declaration(
self.definition.from_declaration,
base_dir=self.flow._definition.source_dir,
)
)
input_template = {**default_inputs, **(self.definition.inputs or {})}
else:
crew_definition = self.definition.with_
if crew_definition is None:
raise ValueError(
"crew action requires exactly one of from_declaration or with"
)
input_template = {
**crew_definition.inputs,
**(self.definition.inputs or {}),
}
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
inputs = Expression.from_flow(
cast(ExpressionData, crew_definition.inputs),
cast(ExpressionData, input_template),
self.flow,
local_context=local_context,
).render_template()
crew, _ = load_crew_from_definition(crew_definition, source="crew action")
return await crew.kickoff_async(inputs=inputs)
@@ -359,3 +378,29 @@ def _pop_local_context(kwargs: dict[str, Any]) -> LocalContext | None:
if not isinstance(local_context, dict):
raise TypeError("flow definition local context must be a mapping")
return cast(LocalContext, local_context)
def _resolve_crew_declaration(
from_declaration: str, *, base_dir: Path | None = None
) -> Path:
path = Path(from_declaration).expanduser()
if base_dir is not None:
resolved_base_dir = base_dir.expanduser().resolve()
if not path.is_absolute():
path = resolved_base_dir / path
resolved_path = path.resolve()
if not resolved_path.is_relative_to(resolved_base_dir):
raise ValueError(
"crew declaration path must be within the flow definition directory"
)
path = resolved_path
if not path.is_dir():
return path
for name in ("crew.jsonc", "crew.json"):
candidate = path / name
if candidate.is_file():
return candidate
return path / "crew.jsonc"

View File

@@ -11,11 +11,10 @@ import pytest
from crewai_cli.cli import run
from crewai_cli.run_crew import (
CrewType,
_execute_uv_script,
_load_json_crew_for_tui,
_missing_input_names,
_prompt_for_missing_inputs,
execute_command,
)
@@ -30,6 +29,8 @@ def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRu
run_crew_mock.assert_called_once_with(
trained_agents_file="my_custom_trained.pkl",
definition=None,
inputs=None,
)
assert result.exit_code == 0
@@ -38,7 +39,11 @@ def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRu
def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliRunner) -> None:
result = runner.invoke(run)
run_crew_mock.assert_called_once_with(trained_agents_file=None)
run_crew_mock.assert_called_once_with(
trained_agents_file=None,
definition=None,
inputs=None,
)
assert result.exit_code == 0
@@ -50,7 +55,11 @@ def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliR
def test_execute_command_sets_env_var_when_filename_provided(
_build_env: mock.Mock, subprocess_run: mock.Mock
) -> None:
execute_command(CrewType.STANDARD, trained_agents_file="my_custom_trained.pkl")
_execute_uv_script(
"run_crew",
entity_type="crew",
trained_agents_file="my_custom_trained.pkl",
)
_, kwargs = subprocess_run.call_args
assert kwargs["env"]["CREWAI_TRAINED_AGENTS_FILE"] == "my_custom_trained.pkl"
@@ -65,7 +74,7 @@ def test_execute_command_sets_env_var_when_filename_provided(
def test_execute_command_omits_env_var_when_filename_absent(
_build_env: mock.Mock, subprocess_run: mock.Mock
) -> None:
execute_command(CrewType.STANDARD)
_execute_uv_script("run_crew", entity_type="crew")
_, kwargs = subprocess_run.call_args
assert "CREWAI_TRAINED_AGENTS_FILE" not in kwargs["env"]

View File

@@ -386,6 +386,54 @@ def test_router_runtime_uses_flow_definition_without_legacy_router_metadata():
assert execution_order == ["begin", "decide", "handle_left"]
def test_start_router_runtime_routes_public_dsl_return_value():
execution_order = []
class StartRouterFlow(Flow):
@start()
@router(emit=["continue"])
def decide(self):
execution_order.append("decide")
return "continue"
@listen("continue")
def handle_continue(self, result):
execution_order.append(f"handle_continue:{result}")
return "done"
assert StartRouterFlow().kickoff() == "done"
assert execution_order == ["decide", "handle_continue:continue"]
def test_start_router_runtime_chains_to_stacked_listener_router():
execution_order = []
class ChainedStartRouterFlow(Flow):
@start()
@router(emit=["approved", "not_approved"])
def first_router(self):
execution_order.append("first_router")
return "approved"
@listen("approved")
@router(emit=["second_approval", "not_approved"])
def second_router(self):
execution_order.append("second_router")
return "second_approval"
@listen("second_approval")
def handle_second_approval(self, result):
execution_order.append(f"handle_second_approval:{result}")
return "done"
assert ChainedStartRouterFlow().kickoff() == "done"
assert execution_order == [
"first_router",
"second_router",
"handle_second_approval:second_approval",
]
def test_router_falsy_result_emits_runtime_event():
execution_order = []

View File

@@ -565,6 +565,54 @@ def test_flow_definition_classifies_start_router_from_human_feedback_emit():
assert entry_point.emit is None
def test_flow_definition_classifies_public_dsl_start_router():
class StartRouterFlow(Flow):
@start()
@router(emit=["continue", "stop"])
def entry_point(self):
return "continue"
@router(emit=["resume"])
@start()
def alternate_entry_point(self):
return "resume"
entry_point = StartRouterFlow.flow_definition().methods["entry_point"]
alternate_entry_point = StartRouterFlow.flow_definition().methods[
"alternate_entry_point"
]
assert entry_point.is_start is True
assert entry_point.router is True
assert entry_point.listen is None
assert entry_point.emit == ["continue", "stop"]
assert alternate_entry_point.is_start is True
assert alternate_entry_point.router is True
assert alternate_entry_point.listen is None
assert alternate_entry_point.emit == ["resume"]
def test_flow_definition_merges_stacked_listen_router():
class ChainedRouterFlow(Flow):
@start()
@router(emit=["approved", "not_approved"])
def first_router(self):
return "approved"
@listen("approved")
@router(emit=["second_approval", "not_approved"])
def second_router(self):
return "second_approval"
methods = ChainedRouterFlow.flow_definition().methods
assert methods["first_router"].is_start is True
assert methods["first_router"].listen is None
assert methods["second_router"].router is True
assert methods["second_router"].listen == "approved"
assert methods["second_router"].emit == ["second_approval", "not_approved"]
def test_flow_definition_round_trips_json_and_yaml():
class RoundTripFlow(Flow):
@start()
@@ -883,7 +931,7 @@ def test_flow_definition_ignores_legacy_diagnostics_loaded_from_contract():
assert "diagnostics" not in definition.to_dict()
def test_router_start_false_without_listen_logs_missing_trigger(caplog):
def test_router_start_false_without_listen_is_allowed(caplog):
caplog.set_level(logging.ERROR, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
@@ -901,12 +949,7 @@ def test_router_start_false_without_listen_logs_missing_trigger(caplog):
}
)
assert any(
record.levelno == logging.ERROR
and "router_without_trigger" in record.message
and "methods.decision" in record.message
for record in caplog.records
)
assert not caplog.records
def test_router_human_feedback_preserves_existing_router_metadata():
@@ -1048,7 +1091,7 @@ def test_flow_definition_cache_is_not_reused_by_subclasses():
assert set(child_definition.methods) == {"child_step"}
def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog):
def test_flow_definition_allows_router_without_trigger(caplog):
caplog.set_level(logging.WARNING, logger="crewai.flow.flow_definition")
flow_definition.FlowDefinition.from_dict(
@@ -1065,9 +1108,11 @@ def test_flow_definition_logs_validation_issues_when_loaded_from_contract(caplog
}
)
assert any(
record.levelno == logging.ERROR
and "LoadedFlow" in record.message
and "router_without_trigger" in record.message
for record in caplog.records
)
class StandaloneRouterFlow(Flow):
@router(emit=["continue"])
def decision(self):
return "continue"
StandaloneRouterFlow.flow_definition()
assert not caplog.records

View File

@@ -1005,8 +1005,8 @@ methods:
description: Research {topic}
expected_output: Findings about {topic}
agent: researcher
inputs:
topic: "${state.topic}"
inputs:
topic: "${state.topic}"
start: true
"""
@@ -1020,6 +1020,183 @@ methods:
}
def test_crew_action_runs_crew_from_declaration(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
from crewai import Crew
project_root = tmp_path / "project"
crew_root = project_root / "crews" / "research_crew"
agents_root = crew_root / "agents"
agents_root.mkdir(parents=True)
(agents_root / "researcher.jsonc").write_text(
"""
{
"role": "Researcher",
"goal": "Research {topic}",
"backstory": "Knows things."
}
""",
encoding="utf-8",
)
(crew_root / "crew.jsonc").write_text(
"""
{
"name": "referenced_research",
"agents": ["researcher"],
"tasks": [
{
"name": "research_task",
"description": "Research {topic}",
"expected_output": "Findings about {topic}",
"agent": "researcher"
}
],
"inputs": {
"topic": "Default topic",
"audience": "developers"
}
}
""",
encoding="utf-8",
)
async def fake_kickoff_async(
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
) -> dict[str, Any]:
return {
"crew": self.name,
"tasks": [task.description for task in self.tasks],
"inputs": inputs,
}
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
monkeypatch.chdir(project_root)
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
from_declaration: crews/research_crew
inputs:
topic: "${state.topic}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "referenced_research",
"tasks": ["Research {topic}"],
"inputs": {"topic": "AI", "audience": "developers"},
}
def test_crew_action_from_declaration_resolves_relative_to_flow_file(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
from crewai import Crew
project_root = tmp_path / "project"
crew_root = project_root / "crews" / "research_crew"
agents_root = crew_root / "agents"
agents_root.mkdir(parents=True)
(agents_root / "researcher.jsonc").write_text(
"""
{
"role": "Researcher",
"goal": "Research {topic}",
"backstory": "Knows things."
}
""",
encoding="utf-8",
)
(crew_root / "crew.jsonc").write_text(
"""
{
"name": "relative_research",
"agents": ["researcher"],
"tasks": [
{
"description": "Research {topic}",
"expected_output": "Findings about {topic}",
"agent": "researcher"
}
],
"inputs": {
"topic": "Default topic"
}
}
""",
encoding="utf-8",
)
async def fake_kickoff_async(
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
) -> dict[str, Any]:
return {"crew": self.name, "inputs": inputs}
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
flow_path = project_root / "flow.yaml"
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
from_declaration: crews/research_crew
inputs:
topic: "${state.topic}"
start: true
"""
flow_path.write_text(yaml_str, encoding="utf-8")
other_cwd = tmp_path / "other"
other_cwd.mkdir()
monkeypatch.chdir(other_cwd)
flow = Flow.from_definition(
FlowDefinition.from_yaml(yaml_str, source_path=flow_path)
)
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "relative_research",
"inputs": {"topic": "AI"},
}
def test_crew_action_from_declaration_rejects_paths_outside_flow_file(
tmp_path: Path,
):
flow_path = tmp_path / "project" / "flow.yaml"
flow_path.parent.mkdir()
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
from_declaration: ../outside/crew.jsonc
start: true
"""
flow = Flow.from_definition(
FlowDefinition.from_yaml(yaml_str, source_path=flow_path)
)
with pytest.raises(
ValueError,
match="crew declaration path must be within the flow definition directory",
):
flow.kickoff()
def test_crew_action_round_trips_with_inline_definition():
definition = FlowDefinition.from_dict(
{
@@ -1047,8 +1224,8 @@ def test_crew_action_round_trips_with_inline_definition():
"agent": "researcher",
}
],
"inputs": {"topic": "${state.topic}"},
},
"inputs": {"topic": "${state.topic}"},
},
}
},
@@ -1062,6 +1239,9 @@ def test_crew_action_round_trips_with_inline_definition():
]["role"]
== "Researcher"
)
assert definition.to_dict()["methods"]["research"]["do"]["inputs"] == {
"topic": "${state.topic}"
}
def test_crew_action_normalizes_named_agent_list_definition():
@@ -1162,7 +1342,7 @@ def test_crew_action_rejects_incomplete_inline_agent_definition():
)
def test_crew_action_rejects_ref():
def test_crew_action_rejects_python_ref_field():
with pytest.raises(ValidationError, match="ref"):
FlowDefinition.from_dict(
{
@@ -1174,7 +1354,6 @@ def test_crew_action_rejects_ref():
"do": {
"call": "crew",
"ref": "project.crew:build_crew",
"with": {"inputs": {"topic": "AI"}},
},
}
},

View File

@@ -46,6 +46,16 @@ class TestConsoleFormatterPauseResume:
formatter.resume_live_updates()
def test_flow_method_status_ignores_formatter_verbose(self):
formatter = ConsoleFormatter(verbose=False)
with patch.object(formatter, "print_panel") as mock_print_panel:
formatter.handle_method_status("categorize_tickets")
mock_print_panel.assert_called_once()
_, kwargs = mock_print_panel.call_args
assert kwargs["is_flow"] is True
def test_streaming_after_pause_resume_creates_new_session(self):
"""Test that streaming after pause/resume creates new Live session."""
formatter = ConsoleFormatter()

63
uv.lock generated
View File

@@ -13,7 +13,7 @@ resolution-markers = [
]
[options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer = "2026-06-20T16:46:21.117658Z"
exclude-newer-span = "P3D"
[options.exclude-newer-package]
@@ -1452,9 +1452,9 @@ requires-dist = [
{ name = "openai", specifier = ">=2.30.0,<3" },
{ name = "openpyxl", specifier = "~=3.1.5" },
{ name = "openpyxl", marker = "extra == 'openpyxl'", specifier = "~=3.1.5" },
{ name = "opentelemetry-api", specifier = "~=1.34.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.34.0" },
{ name = "opentelemetry-api", specifier = "~=1.42.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.42.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.42.0" },
{ name = "pandas", marker = "extra == 'pandas'", specifier = "~=2.2.3" },
{ name = "pdfplumber", specifier = "~=0.11.4" },
{ name = "portalocker", specifier = "~=2.7.0" },
@@ -1539,9 +1539,9 @@ requires-dist = [
{ name = "appdirs", specifier = "~=1.4.4" },
{ name = "cryptography", specifier = ">=42.0" },
{ name = "httpx", specifier = "~=0.28.1" },
{ name = "opentelemetry-api", specifier = "~=1.34.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.34.0" },
{ name = "opentelemetry-api", specifier = "~=1.42.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.42.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.42.0" },
{ name = "packaging", specifier = ">=23.0" },
{ name = "portalocker", specifier = "~=2.7.0" },
{ name = "pydantic", specifier = ">=2.11.9,<2.13" },
@@ -5585,45 +5585,44 @@ wheels = [
[[package]]
name = "opentelemetry-api"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/5e/94a8cb759e4e409022229418294e098ca7feca00eb3c467bb20cbd329bda/opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3", size = 64987, upload-time = "2025-06-10T08:55:19.818Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/3a/2ba85557e8dc024c0842ad22c570418dc02c36cbd1ab4b832a93edf071b8/opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c", size = 65767, upload-time = "2025-06-10T08:54:56.717Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/ba/786b4de7e39d88043622d901b92c4485835f43e0be76c2824d2687911bc2/opentelemetry_exporter_otlp-1.34.1.tar.gz", hash = "sha256:71c9ad342d665d9e4235898d205db17c5764cd7a69acb8a5dcd6d5e04c4c9988", size = 6173, upload-time = "2025-06-10T08:55:21.595Z" }
sdist = { url = "https://files.pythonhosted.org/packages/08/94/8637919a5d01f81dacf510234bc0110b944f4687a6e96b0a02adf2f6bdce/opentelemetry_exporter_otlp-1.42.1.tar.gz", hash = "sha256:2d9ebaed714377a67d224d46795ddcc11d2c877fa5de35fda70b6f3b010729a9", size = 6086, upload-time = "2026-05-21T16:32:51.963Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c1/259b8d8391c968e8f005d8a0ccefcb41aeef64cf55905cd0c0db4e22aaee/opentelemetry_exporter_otlp-1.34.1-py3-none-any.whl", hash = "sha256:f4a453e9cde7f6362fd4a090d8acf7881d1dc585540c7b65cbd63e36644238d4", size = 7040, upload-time = "2025-06-10T08:54:59.655Z" },
{ url = "https://files.pythonhosted.org/packages/6c/4d/c26080295a36fd22e201fefd7cb9c22cd203189b1af8cd73b158382b7ad8/opentelemetry_exporter_otlp-1.42.1-py3-none-any.whl", hash = "sha256:aedd54545bb0587cd45210abdc8be545af9c01413f3307786e276df1e3c83bee", size = 6733, upload-time = "2026-05-21T16:32:31.261Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/f0/ff235936ee40db93360233b62da932d4fd9e8d103cd090c6bcb9afaf5f01/opentelemetry_exporter_otlp_proto_common-1.34.1.tar.gz", hash = "sha256:b59a20a927facd5eac06edaf87a07e49f9e4a13db487b7d8a52b37cb87710f8b", size = 20817, upload-time = "2025-06-10T08:55:22.55Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/e8/8b292a11cc8d8d87ec0c4089ae21b6a58af49ca2e51fa916435bc922fdc7/opentelemetry_exporter_otlp_proto_common-1.34.1-py3-none-any.whl", hash = "sha256:8e2019284bf24d3deebbb6c59c71e6eef3307cd88eff8c633e061abba33f7e87", size = 18834, upload-time = "2025-06-10T08:55:00.806Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -5634,14 +5633,14 @@ dependencies = [
{ name = "opentelemetry-sdk" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/f7/bb63837a3edb9ca857aaf5760796874e7cecddc88a2571b0992865a48fb6/opentelemetry_exporter_otlp_proto_grpc-1.34.1.tar.gz", hash = "sha256:7c841b90caa3aafcfc4fee58487a6c71743c34c6dc1787089d8b0578bbd794dd", size = 22566, upload-time = "2025-06-10T08:55:23.214Z" }
sdist = { url = "https://files.pythonhosted.org/packages/87/87/ca7fc790dfdbcf4f9e9aab14a39ef1b7508ead13707e283de0b3131478d2/opentelemetry_exporter_otlp_proto_grpc-1.42.1.tar.gz", hash = "sha256:975c4461f167dd8ed8857d68d3b6b25f3d272eab896f6a9470d0f5b90e2faf15", size = 27140, upload-time = "2026-05-21T16:32:56.162Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/42/0a4dd47e7ef54edf670c81fc06a83d68ea42727b82126a1df9dd0477695d/opentelemetry_exporter_otlp_proto_grpc-1.34.1-py3-none-any.whl", hash = "sha256:04bb8b732b02295be79f8a86a4ad28fae3d4ddb07307a98c7aa6f331de18cca6", size = 18615, upload-time = "2025-06-10T08:55:02.214Z" },
{ url = "https://files.pythonhosted.org/packages/89/2b/28ba5b128f47fe8c3bab541000d6feb4b5a9bd26623ca013406f01c0fb60/opentelemetry_exporter_otlp_proto_grpc-1.42.1-py3-none-any.whl", hash = "sha256:0ae1177e2038b18a929b3098215243631ef91136cba26b7e2b12790ceb7e87cc", size = 19617, upload-time = "2026-05-21T16:32:34.278Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -5652,48 +5651,48 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/8f/954bc725961cbe425a749d55c0ba1df46832a5999eae764d1a7349ac1c29/opentelemetry_exporter_otlp_proto_http-1.34.1.tar.gz", hash = "sha256:aaac36fdce46a8191e604dcf632e1f9380c7d5b356b27b3e0edb5610d9be28ad", size = 15351, upload-time = "2025-06-10T08:55:24.657Z" }
sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/54/b05251c04e30c1ac70cf4a7c5653c085dfcf2c8b98af71661d6a252adc39/opentelemetry_exporter_otlp_proto_http-1.34.1-py3-none-any.whl", hash = "sha256:5251f00ca85872ce50d871f6d3cc89fe203b94c3c14c964bbdc3883366c705d8", size = 17744, upload-time = "2025-06-10T08:55:03.802Z" },
{ url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/b3/c3158dd012463bb7c0eb7304a85a6f63baeeb5b4c93a53845cf89f848c7e/opentelemetry_proto-1.34.1.tar.gz", hash = "sha256:16286214e405c211fc774187f3e4bbb1351290b8dfb88e8948af209ce85b719e", size = 34344, upload-time = "2025-06-10T08:55:32.25Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/ab/4591bfa54e946350ce8b3f28e5c658fe9785e7cd11e9c11b1671a867822b/opentelemetry_proto-1.34.1-py3-none-any.whl", hash = "sha256:eb4bb5ac27f2562df2d6857fc557b3a481b5e298bc04f94cc68041f00cebcbd2", size = 55692, upload-time = "2025-06-10T08:55:14.904Z" },
{ url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.34.1"
version = "1.42.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/41/fe20f9036433da8e0fcef568984da4c1d1c771fa072ecd1a4d98779dccdd/opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d", size = 159441, upload-time = "2025-06-10T08:55:33.028Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/1b/def4fe6aa73f483cabf4c748f4c25070d5f7604dcc8b52e962983491b29e/opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e", size = 118477, upload-time = "2025-06-10T08:55:16.02Z" },
{ url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.55b1"
version = "0.63b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5d/f0/f33458486da911f47c4aa6db9bda308bb80f3236c111bf848bd870c16b16/opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3", size = 119829, upload-time = "2025-06-10T08:55:33.881Z" }
sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/89/267b0af1b1d0ba828f0e60642b6a5116ac1fd917cde7fc02821627029bd1/opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed", size = 196223, upload-time = "2025-06-10T08:55:17.638Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" },
]
[[package]]