mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-09 00:45:16 +00:00
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
This commit is contained in:
83
lib/cli/tests/test_input_prompt.py
Normal file
83
lib/cli/tests/test_input_prompt.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Tests for the shared runtime-input prompting used by flows and crews."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import crewai_cli.input_prompt as input_prompt_module
|
||||
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(input_prompt_module.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(
|
||||
input_prompt_module.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,9 +855,9 @@ def test_configured_project_json_crew_ignores_missing_pyproject(
|
||||
assert run_crew_module.configured_project_json_crew() is None
|
||||
|
||||
|
||||
def test_run_crew_inputs_without_definition_rejected_for_non_flow(monkeypatch):
|
||||
# --inputs is flow-only; in a non-flow project it now errors clearly instead
|
||||
# of the old "--inputs requires --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
|
||||
@@ -697,7 +870,10 @@ def test_run_crew_inputs_without_definition_rejected_for_non_flow(monkeypatch):
|
||||
with pytest.raises(click.UsageError) as exc_info:
|
||||
run_crew_module.run_crew(inputs='{"topic":"AI"}')
|
||||
|
||||
assert "--inputs is only supported for declarative flows" 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):
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
|
||||
|
||||
@@ -289,7 +290,7 @@ def test_prompts_for_missing_required_when_interactive(
|
||||
prompted.append(text)
|
||||
return "typed@example.com"
|
||||
|
||||
monkeypatch.setattr(run_declarative_flow_module.click, "prompt", fake_prompt)
|
||||
monkeypatch.setattr(input_prompt_module.click, "prompt", fake_prompt)
|
||||
|
||||
run_declarative_flow_module.run_declarative_flow(str(path))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user