mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-08 16:35:08 +00:00
fix(cli): unify crewai run flow input resolution and prompt from the state schema (#6466)
* fix(cli): unify `crewai run` flow input resolution; prompt from state schema
`crewai run` resolved the configured [tool.crewai] flow, but `--inputs` was
hard-gated behind `--definition` and routed through a separate branch — the two
ways of pointing at the same flow didn't share resolution, and required inputs
were never detected, prompted, or validated (a missing field only blew up at
runtime).
Now inputs and definition come from one place:
- Remove the "--inputs requires --definition" gate (cli.py, run_crew.py,
run_declarative_flow.py). `--inputs` alone resolves the configured flow,
exactly like a bare `crewai run`; `--definition` is purely an override. The
project-env re-exec forwards `--inputs` instead of rejecting it.
- Read the flow's state schema from the runtime Flow instance
(`type(flow.state).model_json_schema()`), which is reliable for both inline
`json_schema` and ref-imported `pydantic` state (the static definition's
json_schema is None for the common ref case).
- Plain `crewai run` detects required state fields (minus those satisfied by
state defaults) and prompts for them interactively, showing each field's
description; skipped in non-interactive / CREWAI_DMN mode.
- Validate against the schema before kickoff: pointed
"Missing required input 'x' — <description>" errors, and warn on unknown keys
with a did-you-mean suggestion (catches typos like `prospect_emai`).
`--inputs` on a non-flow project now errors clearly ("only supported for
declarative flows") instead of the old confusing gate.
Tests: schema-driven prompt/validate/override paths, unknown-key warning,
defaults-satisfy-required, type validation, and re-exec input forwarding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh
* fix(cli): forward reserved `id` input to flow kickoff; ruff format
- Cursor: the schema filter treated an `id` key in --inputs as unknown and
dropped it, regressing kickoff's persistence-restore support (inputs["id"]).
Let `id` pass through untouched (test: reserved_id_input_is_forwarded).
- Apply ruff format to run_declarative_flow.py (fixes the lint-run
`ruff format --check` step).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh
* fix(cli): don't block persistence-restore resume on schema validation
Cursor (High): `crewai run --inputs '{"id":"…"}'` is a persistence resume —
kickoff hydrates full state from storage, so schema-required fields may come
from the restored state rather than --inputs. The new required-field
prompt/validation was erroring/prompting before kickoff, breaking resume. When
`id` is present in --inputs, forward the inputs unchanged and skip the
prompt/validation. Test: test_id_only_input_skips_required_validation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh
* fix(cli): load project .env in the declarative-flow runner
The declarative-flow path never loaded .env — flow projects (type = "flow")
missed API keys/config that crew projects pick up. The JSON-crew path loads
Path.cwd()/.env with override=True (run_crew._run_json_crew); mirror that at
the top of run_declarative_flow() so flow projects behave the same regardless
of where crewai is installed. Test: run_declarative_flow_loads_project_env.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh
* feat(cli): unify runtime-input prompting across declarative flows and crews
Declarative (JSON) crews now resolve inputs the same way declarative flows
do, via a shared crewai_cli.input_prompt module (prompt_for_inputs,
parse_inputs_json, closest_name, is_interactive):
- accept --inputs (previously rejected for crews), forwarded to the crew
subprocess via CREWAI_JSON_CREW_INPUTS and validated before spinning up uv
- layer --inputs over the crew's declared `inputs` defaults
- prompt for missing {placeholder}s with the same UX as flows, and error
cleanly with a pointed per-name message when non-interactive
- warn on unknown keys with a "did you mean" suggestion
Unlike flows — whose state schema is authoritative, so unknown keys are
dropped — the crew placeholder scan is heuristic (agent/task text fields
only), so unrecognized keys are warned about but kept, to avoid discarding a
value a field the scan doesn't cover may rely on.
--inputs remains rejected for classic (Python/YAML) crews, which take their
inputs from main.py. run_declarative_flow's private input helpers move to the
shared module with no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh
* test(crewai): update mirrored CLI test after run_crew input refactor
lib/crewai/tests/cli/test_run_crew.py imports crewai_cli internals and is
collected by the lib/crewai test job (Run Tests). It still imported
_prompt_for_missing_inputs, which was replaced by _resolve_crew_inputs, so
the module failed to import — erroring pytest at collection and cancelling
the rest of the matrix via fail-fast.
Point it at _resolve_crew_inputs and patch the prompt in the shared
crewai_cli.input_prompt module where prompting now lives.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh
* fix(cli): filter unknown --inputs keys even on flow persistence restore
Review follow-up: the `id` (persistence-restore) branch of
_resolve_flow_inputs returned the raw payload, so typo keys passed alongside
`id` skipped the unknown-key warning/drop and reached kickoff — which can
fail strict (extra="forbid") flow state models. The restore path now still
warns on and drops unknown keys (keeping `id` and known state fields); it
only skips the required-field prompt and pre-kickoff validation, which
persistence hydrates. Regression test: test_id_restore_still_drops_unknown_keys.
Also drop the duplicate module import in test_input_prompt.py (both `import`
and `from ... import` of crewai_cli.input_prompt) flagged by the code-quality
bot; monkeypatching now uses the string target form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBYGqJHC2TMC6fonFziuuh
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -526,8 +526,8 @@ def run(
|
||||
inputs: str | None,
|
||||
) -> None:
|
||||
"""Run the Crew or Flow."""
|
||||
if inputs is not None and definition is None:
|
||||
raise click.UsageError("--inputs requires --definition")
|
||||
# --inputs no longer requires --definition: with no override it resolves the
|
||||
# configured [tool.crewai] flow, same as a bare `crewai run`.
|
||||
if trained_agents_file is not None and definition is not None:
|
||||
raise click.UsageError("--filename can only be used when running crews")
|
||||
|
||||
|
||||
84
lib/cli/src/crewai_cli/input_prompt.py
Normal file
84
lib/cli/src/crewai_cli/input_prompt.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Shared interactive prompting for runtime inputs (flows and crews).
|
||||
|
||||
``crewai run`` asks the user for values that were not provided up front — for a
|
||||
declarative flow (derived from its state schema) and for a declarative (JSON)
|
||||
crew (derived from the ``{placeholder}`` references in its agents and tasks).
|
||||
Both paths go through this module so the experience is identical: the same
|
||||
header, the same per-field prompt styling, and prompt chrome on stderr so
|
||||
stdout carries only the run's result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
import difflib
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from crewai_cli.utils import enable_prompt_line_editing, is_dmn_mode_enabled
|
||||
|
||||
|
||||
def parse_inputs_json(inputs: str | None) -> dict[str, Any] | None:
|
||||
"""Parse a ``--inputs`` JSON object, exiting with a pointed error if invalid."""
|
||||
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 closest_name(key: str, candidates: Iterable[str]) -> str | None:
|
||||
"""Nearest candidate name to a likely typo, if one is close enough."""
|
||||
matches = difflib.get_close_matches(key, list(candidates), n=1, cutoff=0.7)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def is_interactive() -> bool:
|
||||
"""Prompt only in an interactive terminal, never in non-interactive mode."""
|
||||
return not is_dmn_mode_enabled() and sys.stdin.isatty()
|
||||
|
||||
|
||||
def prompt_for_inputs(
|
||||
names: list[str],
|
||||
*,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
describe: Callable[[str], str | None] | None = None,
|
||||
coerce: Callable[[str, str], Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Prompt for each name and return ``{name: value}``.
|
||||
|
||||
``describe(name)`` returns an optional hint shown dim above the prompt (used
|
||||
by flows to surface a field's schema description). ``coerce(name, raw)``
|
||||
converts the typed string to the stored value (used by flows to coerce to
|
||||
the field's JSON-schema type); by default the raw string is kept as-is.
|
||||
|
||||
Prompt chrome is written to stderr so stdout carries only the run result.
|
||||
"""
|
||||
enable_prompt_line_editing()
|
||||
click.echo(err=True)
|
||||
click.secho(f" {title}", fg="cyan", bold=True, err=True)
|
||||
click.secho(f" {subtitle}", dim=True, err=True)
|
||||
|
||||
collected: dict[str, Any] = {}
|
||||
for name in names:
|
||||
if describe is not None and (hint := describe(name)):
|
||||
click.secho(f" {hint}", dim=True, err=True)
|
||||
raw = click.prompt(
|
||||
click.style(f" {name}", fg="cyan"),
|
||||
prompt_suffix=click.style(" > ", fg="bright_white"),
|
||||
)
|
||||
collected[name] = coerce(name, raw) if coerce is not None else raw
|
||||
return collected
|
||||
@@ -12,9 +12,14 @@ import click
|
||||
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
||||
from packaging import version
|
||||
|
||||
from crewai_cli.input_prompt import (
|
||||
closest_name,
|
||||
is_interactive,
|
||||
parse_inputs_json,
|
||||
prompt_for_inputs,
|
||||
)
|
||||
from crewai_cli.utils import (
|
||||
build_env_with_all_tool_credentials,
|
||||
enable_prompt_line_editing,
|
||||
is_dmn_mode_enabled,
|
||||
)
|
||||
from crewai_cli.version import get_crewai_tools_dependency, get_crewai_version
|
||||
@@ -31,6 +36,7 @@ _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"
|
||||
_CREWAI_JSON_CREW_DEFINITION_ENV = "CREWAI_JSON_CREW_DEFINITION"
|
||||
_CREWAI_JSON_CREW_INPUTS_ENV = "CREWAI_JSON_CREW_INPUTS"
|
||||
_FULL_CREWAI_INSTALL_MESSAGE = f"""\
|
||||
CrewAI CLI is installed without the `crewai` package required to run crews.
|
||||
|
||||
@@ -79,6 +85,8 @@ kwargs = {
|
||||
}
|
||||
if crew_definition := os.getenv("CREWAI_JSON_CREW_DEFINITION"):
|
||||
kwargs["crew_path"] = crew_definition
|
||||
if crew_inputs := os.getenv("CREWAI_JSON_CREW_INPUTS"):
|
||||
kwargs["inputs"] = crew_inputs
|
||||
|
||||
try:
|
||||
module._run_json_crew(**kwargs)
|
||||
@@ -138,8 +146,8 @@ def _extract_input_placeholders(text: str | None) -> set[str]:
|
||||
return set(_INPUT_PLACEHOLDER_RE.findall(text))
|
||||
|
||||
|
||||
def _missing_input_names(crew: Any, inputs: dict[str, Any]) -> list[str]:
|
||||
"""Return input placeholders used by a crew but not provided as defaults."""
|
||||
def _referenced_input_names(crew: Any) -> set[str]:
|
||||
"""All ``{placeholder}`` names referenced by a crew's agents and tasks."""
|
||||
placeholders: set[str] = set()
|
||||
|
||||
for agent in getattr(crew, "agents", []) or []:
|
||||
@@ -160,32 +168,70 @@ def _missing_input_names(crew: Any, inputs: dict[str, Any]) -> list[str]:
|
||||
_extract_input_placeholders(getattr(task, "output_file", None))
|
||||
)
|
||||
|
||||
return sorted(name for name in placeholders if name not in inputs)
|
||||
return placeholders
|
||||
|
||||
|
||||
def _prompt_for_missing_inputs(
|
||||
crew: Any, default_inputs: dict[str, Any]
|
||||
def _missing_input_names(crew: Any, inputs: dict[str, Any]) -> list[str]:
|
||||
"""Return input placeholders referenced by a crew but not provided as inputs."""
|
||||
return sorted(name for name in _referenced_input_names(crew) if name not in inputs)
|
||||
|
||||
|
||||
def _resolve_crew_inputs(
|
||||
crew: Any,
|
||||
default_inputs: dict[str, Any],
|
||||
provided: dict[str, Any] | None,
|
||||
*,
|
||||
interactive: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Ask for runtime values for placeholders that lack default inputs."""
|
||||
"""Resolve kickoff inputs for a declarative crew.
|
||||
|
||||
Mirrors the declarative-flow experience (``_resolve_flow_inputs``): layers
|
||||
``--inputs`` over the crew's declared ``inputs`` defaults, warns on provided
|
||||
keys that aren't referenced as ``{placeholder}``s, prompts for any
|
||||
still-missing placeholders when interactive, and exits with a pointed
|
||||
message when one is still missing.
|
||||
|
||||
Unlike flows — whose state schema is an authoritative contract, so unknown
|
||||
keys are dropped — the crew placeholder scan is heuristic (it only covers
|
||||
agent/task text fields). An unrecognized key is therefore warned about but
|
||||
*kept*, never dropped: dropping could silently discard a value that a field
|
||||
the scan doesn't cover actually relies on.
|
||||
"""
|
||||
referenced = _referenced_input_names(crew)
|
||||
inputs = dict(default_inputs or {})
|
||||
|
||||
for key, value in (provided or {}).items():
|
||||
if key not in referenced:
|
||||
suggestion = closest_name(key, referenced)
|
||||
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
|
||||
click.secho(
|
||||
f" Input '{key}' isn't referenced by any {{placeholder}} "
|
||||
f"in the crew.{hint}",
|
||||
fg="yellow",
|
||||
err=True,
|
||||
)
|
||||
inputs[key] = value
|
||||
|
||||
missing = _missing_input_names(crew, inputs)
|
||||
if not missing:
|
||||
return inputs
|
||||
|
||||
enable_prompt_line_editing()
|
||||
|
||||
click.echo()
|
||||
click.secho(" Runtime inputs", fg="cyan", bold=True)
|
||||
click.secho(
|
||||
" Values for {placeholder} references in your agents and tasks.",
|
||||
dim=True,
|
||||
)
|
||||
|
||||
for name in missing:
|
||||
inputs[name] = click.prompt(
|
||||
click.style(f" {name}", fg="cyan"),
|
||||
prompt_suffix=click.style(" > ", fg="bright_white"),
|
||||
if missing and interactive:
|
||||
inputs.update(
|
||||
prompt_for_inputs(
|
||||
missing,
|
||||
title="Crew inputs",
|
||||
subtitle="This crew needs the following to run.",
|
||||
)
|
||||
)
|
||||
missing = _missing_input_names(crew, inputs)
|
||||
|
||||
if missing:
|
||||
for name in missing:
|
||||
click.secho(f" Missing required input '{name}'", fg="red", err=True)
|
||||
click.secho(
|
||||
" Provide them via --inputs or the `inputs` object in crew.json(c).",
|
||||
dim=True,
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
return inputs
|
||||
|
||||
@@ -243,29 +289,14 @@ def _prepare_json_crew_for_tui(crew: Any) -> None:
|
||||
agent.llm.stream = True
|
||||
|
||||
|
||||
def _runtime_inputs_without_prompt(
|
||||
crew: Any, default_inputs: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Return runtime inputs in non-interactive mode or exit on missing values."""
|
||||
inputs = dict(default_inputs or {})
|
||||
missing = _missing_input_names(crew, inputs)
|
||||
if missing:
|
||||
missing_list = ", ".join(missing)
|
||||
click.echo(
|
||||
"Missing runtime inputs for CREWAI_DMN mode: "
|
||||
f"{missing_list}. Add them to the `inputs` object in crew.json(c).",
|
||||
err=True,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
return inputs
|
||||
|
||||
|
||||
def _run_json_crew_without_tui(crew_path: Path) -> Any:
|
||||
def _run_json_crew_without_tui(crew_path: Path, provided: dict[str, Any] | None) -> Any:
|
||||
"""Run a JSON-defined crew with plain terminal output."""
|
||||
with _json_loading_status("Preparing crew..."):
|
||||
crew, default_inputs = _load_json_crew(crew_path)
|
||||
|
||||
runtime_inputs = _runtime_inputs_without_prompt(crew, default_inputs)
|
||||
runtime_inputs = _resolve_crew_inputs(
|
||||
crew, default_inputs, provided, interactive=False
|
||||
)
|
||||
result = crew.kickoff(inputs=runtime_inputs)
|
||||
if result is not None:
|
||||
click.echo(str(result))
|
||||
@@ -275,6 +306,7 @@ def _run_json_crew_without_tui(crew_path: Path) -> Any:
|
||||
def _run_json_crew(
|
||||
trained_agents_file: str | None = None,
|
||||
crew_path: str | Path | None = None,
|
||||
inputs: str | None = None,
|
||||
) -> Any:
|
||||
"""Load and run a JSON-defined crew."""
|
||||
from dotenv import load_dotenv
|
||||
@@ -296,13 +328,17 @@ def _run_json_crew(
|
||||
)
|
||||
crew_path = Path(crew_path)
|
||||
|
||||
provided = parse_inputs_json(inputs)
|
||||
|
||||
if is_dmn_mode_enabled():
|
||||
return _run_json_crew_without_tui(crew_path)
|
||||
return _run_json_crew_without_tui(crew_path, provided)
|
||||
|
||||
crew_run_app_cls, crew, default_inputs, task_names, agent_names = (
|
||||
_load_json_crew_for_tui(crew_path)
|
||||
)
|
||||
runtime_inputs = _prompt_for_missing_inputs(crew, default_inputs)
|
||||
runtime_inputs = _resolve_crew_inputs(
|
||||
crew, default_inputs, provided, interactive=is_interactive()
|
||||
)
|
||||
|
||||
app = crew_run_app_cls(
|
||||
crew_name=crew.name or "Crew",
|
||||
@@ -411,12 +447,18 @@ def _json_crew_run_command(project_root: Path | None = None) -> list[str]:
|
||||
def _run_json_crew_in_project_env(
|
||||
trained_agents_file: str | None = None,
|
||||
crew_path: str | Path | None = None,
|
||||
inputs: str | None = None,
|
||||
) -> Any:
|
||||
"""Run JSON crews from the project's uv-managed environment."""
|
||||
# Validate --inputs up front so bad JSON fails before we spin up the uv env.
|
||||
if inputs is not None:
|
||||
parse_inputs_json(inputs)
|
||||
|
||||
if not (Path.cwd() / "pyproject.toml").is_file():
|
||||
return _run_json_crew(
|
||||
trained_agents_file=trained_agents_file,
|
||||
crew_path=crew_path,
|
||||
inputs=inputs,
|
||||
)
|
||||
|
||||
_install_json_crew_dependencies_if_needed()
|
||||
@@ -430,6 +472,8 @@ def _run_json_crew_in_project_env(
|
||||
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
|
||||
if crew_path is not None:
|
||||
env[_CREWAI_JSON_CREW_DEFINITION_ENV] = str(crew_path)
|
||||
if inputs is not None:
|
||||
env[_CREWAI_JSON_CREW_INPUTS_ENV] = inputs
|
||||
|
||||
try:
|
||||
subprocess.run( # noqa: S603
|
||||
@@ -569,11 +613,11 @@ def run_crew(
|
||||
``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.
|
||||
inputs: Optional JSON object of runtime inputs for a declarative flow
|
||||
or declarative (JSON) crew. Layered over the definition's own
|
||||
defaults; missing required values are prompted for interactively.
|
||||
"""
|
||||
if inputs is not None and definition is None:
|
||||
raise click.UsageError("--inputs requires --definition")
|
||||
|
||||
# --definition is a pure override: run that flow directly.
|
||||
if definition is not None:
|
||||
_run_explicit_declarative_flow(
|
||||
definition=definition,
|
||||
@@ -584,9 +628,13 @@ def run_crew(
|
||||
|
||||
pyproject_data = read_toml()
|
||||
if json_crew_definition := configured_project_json_crew(pyproject_data):
|
||||
# Declarative (JSON) crews resolve inputs the same way flows do: --inputs
|
||||
# layers over the crew's declared defaults, missing {placeholder}s are
|
||||
# prompted for, and unknown keys are flagged. Forward the raw JSON.
|
||||
_run_json_crew_in_project_env(
|
||||
trained_agents_file=trained_agents_file,
|
||||
crew_path=json_crew_definition,
|
||||
inputs=inputs,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -594,18 +642,29 @@ def run_crew(
|
||||
project_type = get_crewai_project_type(pyproject_data)
|
||||
|
||||
if project_type == "flow":
|
||||
# No --definition: resolve the configured [tool.crewai] flow — the same
|
||||
# resolution as a bare `crewai run` — and pass --inputs straight through.
|
||||
_run_flow_project(
|
||||
pyproject_data=pyproject_data,
|
||||
trained_agents_file=trained_agents_file,
|
||||
inputs=inputs,
|
||||
)
|
||||
return
|
||||
|
||||
_reject_inputs_for_non_flow(inputs)
|
||||
_run_classic_crew_project(
|
||||
pyproject_data=pyproject_data,
|
||||
trained_agents_file=trained_agents_file,
|
||||
)
|
||||
|
||||
|
||||
def _reject_inputs_for_non_flow(inputs: str | None) -> None:
|
||||
if inputs is not None:
|
||||
raise click.UsageError(
|
||||
"--inputs is only supported for declarative flows and crews"
|
||||
)
|
||||
|
||||
|
||||
def _run_explicit_declarative_flow(
|
||||
definition: str, inputs: str | None, trained_agents_file: str | None
|
||||
) -> None:
|
||||
@@ -618,7 +677,9 @@ def _run_explicit_declarative_flow(
|
||||
|
||||
|
||||
def _run_flow_project(
|
||||
pyproject_data: dict[str, Any], trained_agents_file: str | None
|
||||
pyproject_data: dict[str, Any],
|
||||
trained_agents_file: str | None,
|
||||
inputs: str | None = None,
|
||||
) -> None:
|
||||
if trained_agents_file is not None:
|
||||
raise click.UsageError("--filename can only be used when running crews")
|
||||
@@ -629,9 +690,16 @@ def _run_flow_project(
|
||||
)
|
||||
|
||||
if definition := configured_project_declarative_flow(pyproject_data):
|
||||
run_declarative_flow_in_project_env(definition=definition)
|
||||
run_declarative_flow_in_project_env(definition=definition, inputs=inputs)
|
||||
return
|
||||
|
||||
# No configured declarative flow definition to resolve inputs against.
|
||||
if inputs is not None:
|
||||
raise click.UsageError(
|
||||
"--inputs requires a declarative flow definition "
|
||||
"([tool.crewai].definition) or --definition"
|
||||
)
|
||||
|
||||
from crewai_cli.kickoff_flow import (
|
||||
_load_conversational_flow_from_kickoff_script,
|
||||
_run_conversational_flow_tui,
|
||||
|
||||
@@ -9,6 +9,12 @@ import click
|
||||
from crewai_core.project import ProjectDefinitionError, configured_project_definition
|
||||
from pydantic import ValidationError
|
||||
|
||||
from crewai_cli.input_prompt import (
|
||||
closest_name,
|
||||
is_interactive,
|
||||
parse_inputs_json,
|
||||
prompt_for_inputs,
|
||||
)
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials
|
||||
|
||||
|
||||
@@ -20,10 +26,13 @@ def run_declarative_flow_in_project_env(
|
||||
run_declarative_flow(definition=definition, inputs=inputs)
|
||||
return
|
||||
|
||||
# Re-run inside the project env (so the flow loads with the project's deps).
|
||||
# The configured definition is re-resolved there; forward --inputs so the
|
||||
# in-env run kicks off with the same values instead of losing them.
|
||||
command = ["uv", "run", "crewai", "run"]
|
||||
if inputs is not None:
|
||||
raise click.UsageError("--inputs is only supported with --definition")
|
||||
|
||||
_execute_declarative_flow_command(["uv", "run", "crewai", "run"])
|
||||
command += ["--inputs", inputs]
|
||||
_execute_declarative_flow_command(command)
|
||||
|
||||
|
||||
def plot_declarative_flow_in_project_env(definition: str | Path) -> None:
|
||||
@@ -36,12 +45,29 @@ def plot_declarative_flow_in_project_env(definition: str | Path) -> None:
|
||||
|
||||
|
||||
def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> None:
|
||||
"""Run a declarative flow from a definition path."""
|
||||
parsed_inputs = _parse_inputs(inputs)
|
||||
"""Run a declarative flow from a definition path.
|
||||
|
||||
Inputs come from one place: the flow's own state schema. Any ``--inputs``
|
||||
JSON is layered on top as an override, missing required fields are prompted
|
||||
for interactively, and everything is validated against the schema before
|
||||
kickoff — so a bare ``crewai run`` on a configured flow just works.
|
||||
"""
|
||||
# Load the project's .env before kickoff, mirroring the JSON-crew path
|
||||
# (run_crew._run_json_crew) so flow projects pick up API keys/config the
|
||||
# same way regardless of where crewai is installed.
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = Path.cwd() / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file, override=True)
|
||||
|
||||
provided = parse_inputs_json(inputs) or {}
|
||||
|
||||
flow = load_declarative_flow(definition)
|
||||
resolved_inputs = _resolve_flow_inputs(flow, provided)
|
||||
|
||||
try:
|
||||
flow = load_declarative_flow(definition)
|
||||
result = flow.kickoff(inputs=parsed_inputs)
|
||||
result = flow.kickoff(inputs=resolved_inputs or None)
|
||||
except Exception as exc:
|
||||
click.echo(
|
||||
f"An error occurred while running the declarative flow: {exc}", err=True
|
||||
@@ -51,6 +77,152 @@ def run_declarative_flow(definition: str | Path, inputs: str | None = None) -> N
|
||||
click.echo(_format_result(result))
|
||||
|
||||
|
||||
def _resolve_flow_inputs(flow: Any, provided: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve kickoff inputs from the flow's state schema.
|
||||
|
||||
Warns on unknown keys, prompts for missing required fields (unless
|
||||
non-interactive), and validates types before kickoff. Exits with a pointed
|
||||
message when a required input is still missing or an input is invalid.
|
||||
"""
|
||||
schema = _flow_state_schema(flow)
|
||||
if schema is None:
|
||||
# dict / unschematized state — nothing to derive; pass inputs through.
|
||||
return dict(provided)
|
||||
|
||||
properties = {
|
||||
name: spec
|
||||
for name, spec in (schema.get("properties") or {}).items()
|
||||
if name != "id"
|
||||
}
|
||||
state_model = type(flow.state)
|
||||
defaults = _flow_state_defaults(flow)
|
||||
|
||||
# ``id`` signals a persistence restore: kickoff hydrates the full state from
|
||||
# storage, so required fields may come from the restored state rather than
|
||||
# --inputs. We still filter the rest of the payload below, but skip the
|
||||
# required-field prompt and pre-kickoff validation, which would otherwise
|
||||
# fail on fields the resume will supply.
|
||||
restoring = "id" in provided
|
||||
|
||||
# Unknown keys are almost always typos — warn and drop them (they'd fail
|
||||
# structured-state validation at kickoff anyway). ``id`` is a reserved
|
||||
# kickoff key rather than a state field, so forward it untouched.
|
||||
collected: dict[str, Any] = {}
|
||||
for key, value in provided.items():
|
||||
if key == "id":
|
||||
collected["id"] = value
|
||||
continue
|
||||
if key in properties:
|
||||
collected[key] = value
|
||||
continue
|
||||
suggestion = closest_name(key, properties)
|
||||
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
|
||||
click.secho(
|
||||
f" Ignoring unknown input '{key}' — not in the flow's state schema.{hint}",
|
||||
fg="yellow",
|
||||
err=True,
|
||||
)
|
||||
|
||||
if restoring:
|
||||
return collected
|
||||
|
||||
missing = _missing_required(state_model, {**defaults, **collected})
|
||||
if missing and _is_interactive():
|
||||
collected.update(
|
||||
prompt_for_inputs(
|
||||
missing,
|
||||
title="Flow inputs",
|
||||
subtitle="This flow needs the following to run.",
|
||||
describe=lambda name: (properties.get(name) or {}).get("description"),
|
||||
coerce=lambda name, raw: _coerce_input(raw, properties.get(name) or {}),
|
||||
)
|
||||
)
|
||||
missing = _missing_required(state_model, {**defaults, **collected})
|
||||
|
||||
if missing:
|
||||
for name in missing:
|
||||
description = (properties.get(name) or {}).get("description")
|
||||
suffix = f" — {description}" if description else ""
|
||||
click.secho(
|
||||
f" Missing required input '{name}'{suffix}", fg="red", err=True
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
_validate_flow_inputs(state_model, {**defaults, **collected})
|
||||
return collected
|
||||
|
||||
|
||||
def _is_interactive() -> bool:
|
||||
"""Prompt only in an interactive terminal, never in non-interactive mode."""
|
||||
return is_interactive()
|
||||
|
||||
|
||||
def _flow_state_schema(flow: Any) -> dict[str, Any] | None:
|
||||
"""Return the flow's state JSON schema, or ``None`` for dict/plain state."""
|
||||
state = getattr(flow, "state", None)
|
||||
if state is None or isinstance(state, dict):
|
||||
return None
|
||||
model_json_schema = getattr(type(state), "model_json_schema", None)
|
||||
if not callable(model_json_schema):
|
||||
return None
|
||||
try:
|
||||
schema = model_json_schema()
|
||||
except Exception:
|
||||
return None
|
||||
return schema if isinstance(schema, dict) else None
|
||||
|
||||
|
||||
def _flow_state_defaults(flow: Any) -> dict[str, Any]:
|
||||
"""Declared state defaults (``state.default``) from the flow definition."""
|
||||
state_definition = getattr(getattr(flow, "_definition", None), "state", None)
|
||||
default = getattr(state_definition, "default", None)
|
||||
return dict(default) if isinstance(default, dict) else {}
|
||||
|
||||
|
||||
def _missing_required(state_model: Any, values: dict[str, Any]) -> list[str]:
|
||||
"""Required state fields not satisfied by ``values`` (defaults + inputs)."""
|
||||
try:
|
||||
state_model.model_validate(values)
|
||||
except ValidationError as exc:
|
||||
return [
|
||||
str(error["loc"][0])
|
||||
for error in exc.errors()
|
||||
if error.get("type") == "missing" and error.get("loc")
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_flow_inputs(state_model: Any, values: dict[str, Any]) -> None:
|
||||
"""Validate inputs against the state schema; exit with pointed type errors."""
|
||||
try:
|
||||
state_model.model_validate(values)
|
||||
except ValidationError as exc:
|
||||
for error in exc.errors():
|
||||
location = ".".join(str(part) for part in error.get("loc", ()))
|
||||
click.secho(
|
||||
f" Invalid input '{location}': {error.get('msg')}", fg="red", err=True
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
|
||||
def _coerce_input(raw: str, spec: dict[str, Any]) -> Any:
|
||||
"""Best-effort coerce a prompted string to the field's JSON-schema type."""
|
||||
field_type = spec.get("type")
|
||||
if field_type == "integer":
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return raw
|
||||
if field_type == "number":
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return raw
|
||||
if field_type == "boolean":
|
||||
return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
return raw
|
||||
|
||||
|
||||
def plot_declarative_flow(definition: str | Path) -> None:
|
||||
"""Plot a declarative flow from a definition path."""
|
||||
try:
|
||||
@@ -152,23 +324,6 @@ def _has_project_file(project_root: Path | None = None) -> bool:
|
||||
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 _format_result(result: Any) -> str:
|
||||
raw_result = getattr(result, "raw", result)
|
||||
if isinstance(raw_result, str):
|
||||
|
||||
@@ -151,12 +151,14 @@ def test_run_with_definition_uses_project_runner(run_crew, runner):
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.run_crew")
|
||||
def test_run_rejects_inputs_without_definition(run_crew, runner):
|
||||
def test_run_inputs_without_definition_calls_run_crew(run_crew, runner):
|
||||
# --inputs no longer requires --definition; the resolution happens in run_crew.
|
||||
result = runner.invoke(run, ["--inputs", '{"topic":"AI"}'])
|
||||
|
||||
assert result.exit_code == 2
|
||||
assert "Error: --inputs requires --definition" in result.output
|
||||
run_crew.assert_not_called()
|
||||
assert result.exit_code == 0
|
||||
run_crew.assert_called_once_with(
|
||||
trained_agents_file=None, definition=None, inputs='{"topic":"AI"}'
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("crewai_cli.cli.run_crew")
|
||||
|
||||
82
lib/cli/tests/test_input_prompt.py
Normal file
82
lib/cli/tests/test_input_prompt.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Tests for the shared runtime-input prompting used by flows and crews."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai_cli.input_prompt import (
|
||||
closest_name,
|
||||
parse_inputs_json,
|
||||
prompt_for_inputs,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_inputs_json_returns_none_for_none():
|
||||
assert parse_inputs_json(None) is None
|
||||
|
||||
|
||||
def test_parse_inputs_json_parses_object():
|
||||
assert parse_inputs_json('{"topic": "AI"}') == {"topic": "AI"}
|
||||
|
||||
|
||||
def test_parse_inputs_json_rejects_invalid_json(capsys):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parse_inputs_json("not json")
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert "Invalid --inputs JSON" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_parse_inputs_json_rejects_non_object(capsys):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parse_inputs_json("[1, 2, 3]")
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert "expected an object" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_closest_name_suggests_near_miss():
|
||||
assert closest_name("prospect_emai", ["prospect_email", "topic"]) == "prospect_email"
|
||||
|
||||
|
||||
def test_closest_name_returns_none_when_nothing_close():
|
||||
assert closest_name("zzzzz", ["prospect_email", "topic"]) is None
|
||||
|
||||
|
||||
def test_prompt_for_inputs_uses_describe_and_coerce(monkeypatch, capsys):
|
||||
seen: list[str] = []
|
||||
|
||||
def fake_prompt(text: str, **kwargs: object) -> str:
|
||||
seen.append(text)
|
||||
return "42"
|
||||
|
||||
monkeypatch.setattr("crewai_cli.input_prompt.click.prompt", fake_prompt)
|
||||
|
||||
result = prompt_for_inputs(
|
||||
["count"],
|
||||
title="Flow inputs",
|
||||
subtitle="This flow needs the following to run.",
|
||||
describe=lambda name: f"How many {name}?",
|
||||
coerce=lambda name, raw: int(raw),
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert result == {"count": 42}
|
||||
assert any("count" in text for text in seen)
|
||||
# Header, subtitle, and description hint all render on stderr.
|
||||
assert "Flow inputs" in captured.err
|
||||
assert "How many count?" in captured.err
|
||||
|
||||
|
||||
def test_prompt_for_inputs_keeps_raw_string_without_coerce(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.input_prompt.click.prompt", lambda text, **kwargs: "AI"
|
||||
)
|
||||
|
||||
result = prompt_for_inputs(
|
||||
["topic"],
|
||||
title="Crew inputs",
|
||||
subtitle="This crew needs the following to run.",
|
||||
)
|
||||
|
||||
assert result == {"topic": "AI"}
|
||||
@@ -9,6 +9,7 @@ import click
|
||||
import pytest
|
||||
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
|
||||
|
||||
import crewai_cli.input_prompt as input_prompt_module
|
||||
import crewai_cli.run_crew as run_crew_module
|
||||
|
||||
|
||||
@@ -44,9 +45,12 @@ def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
|
||||
)
|
||||
called: dict = {}
|
||||
|
||||
def fake_run_json_crew_in_project_env(trained_agents_file=None, crew_path=None):
|
||||
def fake_run_json_crew_in_project_env(
|
||||
trained_agents_file=None, crew_path=None, inputs=None
|
||||
):
|
||||
called["trained_agents_file"] = trained_agents_file
|
||||
called["crew_path"] = crew_path
|
||||
called["inputs"] = inputs
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
@@ -59,6 +63,7 @@ def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
|
||||
assert called == {
|
||||
"trained_agents_file": "some.pkl",
|
||||
"crew_path": Path("crew.jsonc"),
|
||||
"inputs": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -305,9 +310,10 @@ 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, crew_path=None):
|
||||
def fake_run_json_crew(trained_agents_file=None, crew_path=None, inputs=None):
|
||||
called["trained_agents_file"] = trained_agents_file
|
||||
called["crew_path"] = crew_path
|
||||
called["inputs"] = inputs
|
||||
return "result"
|
||||
|
||||
monkeypatch.setattr(run_crew_module, "_run_json_crew", fake_run_json_crew)
|
||||
@@ -318,7 +324,11 @@ def test_json_run_without_pyproject_runs_in_process(monkeypatch, tmp_path: Path)
|
||||
)
|
||||
== "result"
|
||||
)
|
||||
assert called == {"trained_agents_file": "trained.pkl", "crew_path": None}
|
||||
assert called == {
|
||||
"trained_agents_file": "trained.pkl",
|
||||
"crew_path": None,
|
||||
"inputs": None,
|
||||
}
|
||||
|
||||
|
||||
def test_json_project_env_run_failure_exits_nonzero(monkeypatch, tmp_path: Path):
|
||||
@@ -535,7 +545,9 @@ def _patch_tui_run(monkeypatch, status: str):
|
||||
lambda _path: (FakeApp, crew, {}, [], []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "_prompt_for_missing_inputs", lambda _crew, inputs: inputs
|
||||
run_crew_module,
|
||||
"_resolve_crew_inputs",
|
||||
lambda _crew, default_inputs, _provided, *, interactive: default_inputs,
|
||||
)
|
||||
monkeypatch.setattr(run_crew_module, "_print_post_tui_summary", lambda _app: None)
|
||||
|
||||
@@ -636,7 +648,168 @@ def test_run_json_crew_dmn_mode_exits_on_missing_inputs(
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exc_info.value.code == 1
|
||||
assert "Missing runtime inputs for CREWAI_DMN mode: topic" in captured.err
|
||||
assert "Missing required input 'topic'" in captured.err
|
||||
|
||||
|
||||
# ── Declarative-crew inputs: merge --inputs, warn, prompt (flow parity) ──
|
||||
|
||||
|
||||
def _crew_with_placeholders(*names: str) -> object:
|
||||
"""A minimal crew whose agent goal references each ``{name}`` placeholder."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
goal = " ".join(f"{{{name}}}" for name in names)
|
||||
return SimpleNamespace(
|
||||
agents=[SimpleNamespace(role="Researcher", goal=goal, backstory="")],
|
||||
tasks=[],
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_crew_inputs_merges_inputs_over_defaults():
|
||||
crew = _crew_with_placeholders("topic")
|
||||
|
||||
resolved = run_crew_module._resolve_crew_inputs(
|
||||
crew, {"topic": "AI"}, {"topic": "ML"}, interactive=False
|
||||
)
|
||||
|
||||
assert resolved == {"topic": "ML"}
|
||||
|
||||
|
||||
def test_resolve_crew_inputs_warns_and_keeps_unknown_input(capsys):
|
||||
# The placeholder scan is heuristic, so an unreferenced key is flagged but
|
||||
# kept (never silently dropped like a flow's schema would).
|
||||
crew = _crew_with_placeholders("topic")
|
||||
|
||||
resolved = run_crew_module._resolve_crew_inputs(
|
||||
crew, {}, {"topic": "AI", "topi": "typo"}, interactive=False
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert resolved == {"topic": "AI", "topi": "typo"}
|
||||
assert "isn't referenced by any {placeholder}" in captured.err
|
||||
assert "Did you mean 'topic'?" in captured.err
|
||||
|
||||
|
||||
def test_resolve_crew_inputs_prompts_when_interactive(monkeypatch):
|
||||
crew = _crew_with_placeholders("topic")
|
||||
prompted: list[str] = []
|
||||
|
||||
def fake_prompt(text: str, **kwargs: object) -> str:
|
||||
prompted.append(text)
|
||||
return "AI"
|
||||
|
||||
monkeypatch.setattr(input_prompt_module.click, "prompt", fake_prompt)
|
||||
|
||||
resolved = run_crew_module._resolve_crew_inputs(
|
||||
crew, {}, None, interactive=True
|
||||
)
|
||||
|
||||
assert resolved == {"topic": "AI"}
|
||||
assert any("topic" in text for text in prompted)
|
||||
|
||||
|
||||
def test_resolve_crew_inputs_errors_when_missing_non_interactive(capsys):
|
||||
crew = _crew_with_placeholders("topic")
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
run_crew_module._resolve_crew_inputs(crew, {}, None, interactive=False)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert "Missing required input 'topic'" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_run_json_crew_accepts_inputs_argument(monkeypatch, tmp_path: Path, capsys):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("CREWAI_DMN", "True")
|
||||
crew_path = tmp_path / "crew.jsonc"
|
||||
crew_path.write_text("{}")
|
||||
kickoff_calls: list[dict] = []
|
||||
|
||||
class FakeCrew:
|
||||
name = "Demo"
|
||||
agents = [_crew_with_placeholders("topic").agents[0]]
|
||||
tasks: list = []
|
||||
|
||||
def kickoff(self, inputs):
|
||||
kickoff_calls.append(inputs)
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "configured_project_json_crew", lambda: crew_path
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "_load_json_crew", lambda _path: (FakeCrew(), {})
|
||||
)
|
||||
|
||||
assert run_crew_module._run_json_crew(inputs='{"topic":"AI"}') == "ok"
|
||||
assert kickoff_calls == [{"topic": "AI"}]
|
||||
|
||||
|
||||
def test_run_json_crew_in_project_env_forwards_inputs(monkeypatch, tmp_path: Path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "_install_json_crew_dependencies_if_needed", lambda: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "build_env_with_all_tool_credentials", lambda: {}
|
||||
)
|
||||
captured_kwargs: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
run_crew_module.subprocess,
|
||||
"run",
|
||||
lambda command, **kwargs: captured_kwargs.append(kwargs),
|
||||
)
|
||||
|
||||
run_crew_module._run_json_crew_in_project_env(
|
||||
crew_path=tmp_path / "crew.jsonc", inputs='{"topic":"AI"}'
|
||||
)
|
||||
|
||||
env = captured_kwargs[0]["env"]
|
||||
assert env[run_crew_module._CREWAI_JSON_CREW_INPUTS_ENV] == '{"topic":"AI"}'
|
||||
|
||||
|
||||
def test_run_json_crew_in_project_env_rejects_invalid_inputs_json(
|
||||
monkeypatch, tmp_path: Path, capsys
|
||||
):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
|
||||
monkeypatch.setattr(
|
||||
run_crew_module.subprocess,
|
||||
"run",
|
||||
lambda *a, **k: pytest.fail("subprocess must not run on invalid --inputs"),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
run_crew_module._run_json_crew_in_project_env(
|
||||
crew_path=tmp_path / "crew.jsonc", inputs="not json"
|
||||
)
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert "Invalid --inputs JSON" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_run_crew_forwards_inputs_to_json_crew(monkeypatch):
|
||||
monkeypatch.setattr(run_crew_module, "read_toml", lambda *a, **k: {})
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"configured_project_json_crew",
|
||||
lambda *a, **k: Path("crew.jsonc"),
|
||||
)
|
||||
called: dict = {}
|
||||
monkeypatch.setattr(
|
||||
run_crew_module,
|
||||
"_run_json_crew_in_project_env",
|
||||
lambda **kw: called.update(kw),
|
||||
)
|
||||
|
||||
run_crew_module.run_crew(inputs='{"topic":"AI"}')
|
||||
|
||||
assert called == {
|
||||
"trained_agents_file": None,
|
||||
"crew_path": Path("crew.jsonc"),
|
||||
"inputs": '{"topic":"AI"}',
|
||||
}
|
||||
|
||||
|
||||
def test_configured_project_json_crew_defers_to_declared_flow_type(
|
||||
@@ -682,11 +855,51 @@ def test_configured_project_json_crew_ignores_missing_pyproject(
|
||||
assert run_crew_module.configured_project_json_crew() is None
|
||||
|
||||
|
||||
def test_run_crew_rejects_inputs_without_definition():
|
||||
def test_run_crew_inputs_rejected_for_classic_crew(monkeypatch):
|
||||
# --inputs works for declarative flows and declarative (JSON) crews, but a
|
||||
# classic crew takes its inputs from main.py, so it errors clearly.
|
||||
monkeypatch.setattr(run_crew_module, "read_toml", lambda *a, **k: {})
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "configured_project_json_crew", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "_warn_if_old_poetry_project", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(run_crew_module, "get_crewai_project_type", lambda *a, **k: "crew")
|
||||
|
||||
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
|
||||
assert (
|
||||
"--inputs is only supported for declarative flows and crews"
|
||||
in exc_info.value.message
|
||||
)
|
||||
|
||||
|
||||
def test_run_crew_inputs_without_definition_resolves_configured_flow(monkeypatch):
|
||||
# --inputs with no --definition resolves the configured [tool.crewai] flow,
|
||||
# exactly like a bare `crewai run`, and forwards the inputs.
|
||||
import crewai_cli.run_declarative_flow as rdf
|
||||
|
||||
calls: dict[str, object] = {}
|
||||
monkeypatch.setattr(run_crew_module, "read_toml", lambda *a, **k: {})
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "configured_project_json_crew", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_crew_module, "_warn_if_old_poetry_project", lambda *a, **k: None
|
||||
)
|
||||
monkeypatch.setattr(run_crew_module, "get_crewai_project_type", lambda *a, **k: "flow")
|
||||
monkeypatch.setattr(
|
||||
rdf, "configured_project_declarative_flow", lambda *a, **k: Path("flow.yaml")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rdf, "run_declarative_flow_in_project_env", lambda **kw: calls.update(kw)
|
||||
)
|
||||
|
||||
run_crew_module.run_crew(inputs='{"topic":"AI"}')
|
||||
|
||||
assert calls == {"definition": Path("flow.yaml"), "inputs": '{"topic":"AI"}'}
|
||||
|
||||
|
||||
def test_run_crew_rejects_filename_with_explicit_definition():
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import crewai_cli.input_prompt as input_prompt_module
|
||||
import crewai_cli.run_declarative_flow as run_declarative_flow_module
|
||||
|
||||
|
||||
@@ -146,3 +148,255 @@ def test_run_declarative_flow_in_process_inside_uv(
|
||||
)
|
||||
|
||||
assert capsys.readouterr().out == "AI\n"
|
||||
|
||||
|
||||
def test_run_declarative_flow_in_project_env_forwards_inputs(
|
||||
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: {},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
run_declarative_flow_module.subprocess,
|
||||
"run",
|
||||
lambda command, **kwargs: subprocess_calls.append(command),
|
||||
)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow_in_project_env(
|
||||
"flow.yaml", '{"topic":"AI"}'
|
||||
)
|
||||
|
||||
# --inputs is forwarded to the in-env run instead of being rejected.
|
||||
assert subprocess_calls == [
|
||||
["uv", "run", "crewai", "run", "--inputs", '{"topic":"AI"}']
|
||||
]
|
||||
|
||||
|
||||
# ── Schema-driven inputs: prompt, validate, override ────────────────
|
||||
|
||||
REQUIRED_FLOW_YAML = """\
|
||||
schema: crewai.flow/v1
|
||||
name: RequiredInputFlow
|
||||
config:
|
||||
suppress_flow_events: true
|
||||
state:
|
||||
type: json_schema
|
||||
json_schema:
|
||||
type: object
|
||||
properties:
|
||||
prospect_email:
|
||||
type: string
|
||||
description: Email address of the prospect to research
|
||||
required: [prospect_email]
|
||||
methods:
|
||||
begin:
|
||||
start: true
|
||||
do:
|
||||
call: expression
|
||||
expr: state.prospect_email
|
||||
"""
|
||||
|
||||
DEFAULTS_FLOW_YAML = """\
|
||||
schema: crewai.flow/v1
|
||||
name: DefaultsFlow
|
||||
config:
|
||||
suppress_flow_events: true
|
||||
state:
|
||||
type: json_schema
|
||||
json_schema:
|
||||
type: object
|
||||
properties:
|
||||
topic: {type: string}
|
||||
audience: {type: string}
|
||||
required: [topic, audience]
|
||||
default:
|
||||
topic: AI
|
||||
methods:
|
||||
begin:
|
||||
start: true
|
||||
do:
|
||||
call: expression
|
||||
expr: state.audience
|
||||
"""
|
||||
|
||||
TYPED_FLOW_YAML = """\
|
||||
schema: crewai.flow/v1
|
||||
name: TypedFlow
|
||||
config:
|
||||
suppress_flow_events: true
|
||||
state:
|
||||
type: json_schema
|
||||
json_schema:
|
||||
type: object
|
||||
properties:
|
||||
count: {type: integer}
|
||||
required: [count]
|
||||
methods:
|
||||
begin:
|
||||
start: true
|
||||
do:
|
||||
call: expression
|
||||
expr: state.count
|
||||
"""
|
||||
|
||||
|
||||
def _write(tmp_path: Path, contents: str) -> Path:
|
||||
path = tmp_path / "flow.yaml"
|
||||
path.write_text(contents, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_inputs_flag_satisfies_required_field(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
path = _write(tmp_path, REQUIRED_FLOW_YAML)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow(
|
||||
str(path), '{"prospect_email":"a@b.com"}'
|
||||
)
|
||||
|
||||
assert capsys.readouterr().out == "a@b.com\n"
|
||||
|
||||
|
||||
def test_missing_required_reports_pointed_error(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(run_declarative_flow_module, "_is_interactive", lambda: False)
|
||||
path = _write(tmp_path, REQUIRED_FLOW_YAML)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
run_declarative_flow_module.run_declarative_flow(str(path))
|
||||
|
||||
assert (
|
||||
"Missing required input 'prospect_email' — "
|
||||
"Email address of the prospect to research" in capsys.readouterr().err
|
||||
)
|
||||
|
||||
|
||||
def test_prompts_for_missing_required_when_interactive(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
path = _write(tmp_path, REQUIRED_FLOW_YAML)
|
||||
monkeypatch.setattr(run_declarative_flow_module, "_is_interactive", lambda: True)
|
||||
prompted: list[str] = []
|
||||
|
||||
def fake_prompt(text: str, **kwargs: object) -> str:
|
||||
prompted.append(text)
|
||||
return "typed@example.com"
|
||||
|
||||
monkeypatch.setattr(input_prompt_module.click, "prompt", fake_prompt)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow(str(path))
|
||||
|
||||
assert capsys.readouterr().out == "typed@example.com\n"
|
||||
assert any("prospect_email" in text for text in prompted)
|
||||
|
||||
|
||||
def test_defaults_satisfy_required_and_are_not_prompted(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(run_declarative_flow_module, "_is_interactive", lambda: False)
|
||||
path = _write(tmp_path, DEFAULTS_FLOW_YAML)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
run_declarative_flow_module.run_declarative_flow(str(path))
|
||||
|
||||
err = capsys.readouterr().err
|
||||
# topic has a state default -> satisfied; only audience is missing.
|
||||
assert "Missing required input 'audience'" in err
|
||||
assert "'topic'" not in err
|
||||
|
||||
|
||||
def test_warns_on_unknown_input_with_suggestion(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
path = _write(tmp_path, REQUIRED_FLOW_YAML)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow(
|
||||
str(path), '{"prospect_email":"a@b.com","prospect_emai":"typo"}'
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "a@b.com\n"
|
||||
assert "Ignoring unknown input 'prospect_emai'" in captured.err
|
||||
assert "Did you mean 'prospect_email'?" in captured.err
|
||||
|
||||
|
||||
def test_validates_input_types_before_kickoff(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
path = _write(tmp_path, TYPED_FLOW_YAML)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
run_declarative_flow_module.run_declarative_flow(str(path), '{"count":"nope"}')
|
||||
|
||||
assert "Invalid input 'count'" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_reserved_id_input_is_forwarded_not_dropped(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
# `id` is a reserved kickoff key (persistence restore); it must pass through
|
||||
# instead of being flagged as an unknown key and dropped.
|
||||
path = _write(tmp_path, REQUIRED_FLOW_YAML)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow(
|
||||
str(path), '{"id":"run-123","prospect_email":"a@b.com"}'
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "a@b.com\n"
|
||||
assert "Ignoring unknown input 'id'" not in captured.err
|
||||
|
||||
|
||||
def test_run_declarative_flow_loads_project_env(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Flow projects must pick up the project's .env, like crew projects do,
|
||||
# overriding any pre-existing value.
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("DECL_FLOW_ENV_PROBE", "old")
|
||||
(tmp_path / ".env").write_text("DECL_FLOW_ENV_PROBE=from_dotenv\n", encoding="utf-8")
|
||||
path = _write(tmp_path, REQUIRED_FLOW_YAML)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow(
|
||||
str(path), '{"prospect_email":"a@b.com"}'
|
||||
)
|
||||
|
||||
assert os.environ["DECL_FLOW_ENV_PROBE"] == "from_dotenv"
|
||||
|
||||
|
||||
def test_id_only_input_skips_required_validation(tmp_path: Path) -> None:
|
||||
# Resume via `crewai run --inputs '{"id":"..."}'` must not be blocked by the
|
||||
# required-field check: kickoff hydrates required state from persistence.
|
||||
path = _write(tmp_path, REQUIRED_FLOW_YAML)
|
||||
flow = run_declarative_flow_module.load_declarative_flow(str(path))
|
||||
|
||||
resolved = run_declarative_flow_module._resolve_flow_inputs(flow, {"id": "run-123"})
|
||||
|
||||
assert resolved == {"id": "run-123"}
|
||||
|
||||
|
||||
def test_id_restore_still_drops_unknown_keys(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
# A persistence restore (`id` present) still filters typo keys so they don't
|
||||
# reach kickoff and trip strict (extra="forbid") state models — it only
|
||||
# skips the required-field prompt/validation, not the unknown-key warning.
|
||||
path = _write(tmp_path, REQUIRED_FLOW_YAML)
|
||||
flow = run_declarative_flow_module.load_declarative_flow(str(path))
|
||||
|
||||
resolved = run_declarative_flow_module._resolve_flow_inputs(
|
||||
flow, {"id": "run-123", "prospect_emai": "typo"}
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert resolved == {"id": "run-123"} # id kept, typo dropped
|
||||
assert "Ignoring unknown input 'prospect_emai'" in captured.err
|
||||
assert "Ignoring unknown input 'id'" not in captured.err
|
||||
|
||||
@@ -14,7 +14,7 @@ from crewai_cli.run_crew import (
|
||||
_execute_uv_script,
|
||||
_load_json_crew_for_tui,
|
||||
_missing_input_names,
|
||||
_prompt_for_missing_inputs,
|
||||
_resolve_crew_inputs,
|
||||
)
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ def test_missing_input_names_scans_agent_and_task_placeholders() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_prompt_for_missing_inputs_merges_runtime_values(monkeypatch) -> None:
|
||||
def test_resolve_crew_inputs_merges_runtime_values(monkeypatch) -> None:
|
||||
crew = SimpleNamespace(
|
||||
agents=[SimpleNamespace(role="Researcher", goal="Cover {topic}", backstory="")],
|
||||
tasks=[
|
||||
@@ -123,9 +123,10 @@ def test_prompt_for_missing_inputs_merges_runtime_values(monkeypatch) -> None:
|
||||
return values["audience"]
|
||||
raise AssertionError(f"Unexpected prompt: {label}")
|
||||
|
||||
monkeypatch.setattr("crewai_cli.run_crew.click.prompt", prompt)
|
||||
# Prompting now lives in the shared crewai_cli.input_prompt helper.
|
||||
monkeypatch.setattr("crewai_cli.input_prompt.click.prompt", prompt)
|
||||
|
||||
assert _prompt_for_missing_inputs(crew, {"topic": "AI"}) == {
|
||||
assert _resolve_crew_inputs(crew, {"topic": "AI"}, None, interactive=True) == {
|
||||
"topic": "AI",
|
||||
"audience": "developers",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user