mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-07 16:09:30 +00:00
Compare commits
5 Commits
joaomdmour
...
joaomdmour
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792ca8ec75 | ||
|
|
67d9fcdcfc | ||
|
|
f83141083e | ||
|
|
327d991860 | ||
|
|
48a10d3874 |
@@ -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")
|
||||
|
||||
|
||||
@@ -571,9 +571,7 @@ def run_crew(
|
||||
definition: Optional path to a declarative Flow definition.
|
||||
inputs: Optional JSON object passed to a declarative Flow.
|
||||
"""
|
||||
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,6 +582,7 @@ def run_crew(
|
||||
|
||||
pyproject_data = read_toml()
|
||||
if json_crew_definition := configured_project_json_crew(pyproject_data):
|
||||
_reject_inputs_for_non_flow(inputs)
|
||||
_run_json_crew_in_project_env(
|
||||
trained_agents_file=trained_agents_file,
|
||||
crew_path=json_crew_definition,
|
||||
@@ -594,18 +593,27 @@ 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")
|
||||
|
||||
|
||||
def _run_explicit_declarative_flow(
|
||||
definition: str, inputs: str | None, trained_agents_file: str | None
|
||||
) -> None:
|
||||
@@ -618,7 +626,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 +639,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,
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from crewai_core.project import ProjectDefinitionError, configured_project_definition
|
||||
from pydantic import ValidationError
|
||||
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials
|
||||
from crewai_cli.utils import (
|
||||
build_env_with_all_tool_credentials,
|
||||
enable_prompt_line_editing,
|
||||
is_dmn_mode_enabled,
|
||||
)
|
||||
|
||||
|
||||
def run_declarative_flow_in_project_env(
|
||||
@@ -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(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,167 @@ 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)
|
||||
|
||||
# ``id`` signals a persistence restore: kickoff hydrates the full state from
|
||||
# storage, so required fields may come from the restored state rather than
|
||||
# --inputs. Forward the inputs unchanged instead of prompting/erroring for
|
||||
# fields the resume will supply.
|
||||
if "id" in provided:
|
||||
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)
|
||||
|
||||
# Unknown keys are almost always typos — warn and drop them (they'd fail
|
||||
# structured-state validation at kickoff anyway).
|
||||
collected: dict[str, Any] = {}
|
||||
for key, value in provided.items():
|
||||
if key in properties:
|
||||
collected[key] = value
|
||||
continue
|
||||
suggestion = _closest_key(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,
|
||||
)
|
||||
|
||||
missing = _missing_required(state_model, {**defaults, **collected})
|
||||
if missing and _is_interactive():
|
||||
collected.update(_prompt_for_flow_inputs(missing, properties))
|
||||
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 not is_dmn_mode_enabled() and sys.stdin.isatty()
|
||||
|
||||
|
||||
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 _prompt_for_flow_inputs(
|
||||
missing: list[str], properties: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Prompt for each missing required field, showing its schema description."""
|
||||
enable_prompt_line_editing()
|
||||
# Prompt chrome goes to stderr so stdout carries only the flow result.
|
||||
click.echo(err=True)
|
||||
click.secho(" Flow inputs", fg="cyan", bold=True, err=True)
|
||||
click.secho(" This flow needs the following to run.", dim=True, err=True)
|
||||
|
||||
collected: dict[str, Any] = {}
|
||||
for name in missing:
|
||||
spec = properties.get(name) or {}
|
||||
description = spec.get("description")
|
||||
if description:
|
||||
click.secho(f" {description}", dim=True, err=True)
|
||||
raw = click.prompt(
|
||||
click.style(f" {name}", fg="cyan"),
|
||||
prompt_suffix=click.style(" > ", fg="bright_white"),
|
||||
)
|
||||
collected[name] = _coerce_input(raw, spec)
|
||||
return collected
|
||||
|
||||
|
||||
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 _closest_key(key: str, properties: dict[str, Any]) -> str | None:
|
||||
"""Nearest schema field name to a likely typo, if one is close enough."""
|
||||
matches = difflib.get_close_matches(key, list(properties), n=1, cutoff=0.7)
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def plot_declarative_flow(definition: str | Path) -> None:
|
||||
"""Plot a declarative flow from a definition path."""
|
||||
try:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -682,11 +682,48 @@ 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_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".
|
||||
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" 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,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -146,3 +147,236 @@ 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(run_declarative_flow_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"}
|
||||
|
||||
@@ -1129,32 +1129,15 @@ class Agent(BaseAgent):
|
||||
return agent_tools.tools()
|
||||
|
||||
def get_platform_tools(self, apps: list[PlatformAppOrAction]) -> list[BaseTool]:
|
||||
platform_tools: list[BaseTool] = []
|
||||
try:
|
||||
from crewai_tools import (
|
||||
CrewaiPlatformTools,
|
||||
)
|
||||
|
||||
platform_tools = CrewaiPlatformTools(apps=apps)
|
||||
return CrewaiPlatformTools(apps=apps)
|
||||
except Exception as e:
|
||||
self._logger.log("error", f"Error getting platform tools: {e!s}")
|
||||
|
||||
if apps and not platform_tools:
|
||||
# A non-empty `apps` that resolves to zero tools leaves the agent
|
||||
# unable to do what those apps were for — surface it loudly (the
|
||||
# underlying resolver only logs, and Logger.log is verbose-gated) so
|
||||
# it isn't discovered as a mysterious runtime failure.
|
||||
app_names = ", ".join(str(app) for app in apps)
|
||||
warnings.warn(
|
||||
f"Agent '{self.role}' declares apps [{app_names}] but none "
|
||||
"resolved to any tools; the agent will run without them and may "
|
||||
"be unable to complete its goal. Check that these apps exist and "
|
||||
"are connected, and that CREWAI_PLATFORM_INTEGRATION_TOKEN is set.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return platform_tools
|
||||
return []
|
||||
|
||||
def get_mcp_tools(self, mcps: list[str | MCPServerConfig]) -> list[BaseTool]:
|
||||
"""Convert MCP server references/configs to CrewAI tools.
|
||||
|
||||
@@ -2595,59 +2595,6 @@ def test_agent_without_apps_no_platform_tools():
|
||||
assert tools == []
|
||||
|
||||
|
||||
def test_get_platform_tools_warns_when_apps_resolve_to_zero_tools(monkeypatch):
|
||||
"""A non-empty `apps` that resolves to no tools must warn loudly."""
|
||||
import crewai_tools
|
||||
|
||||
monkeypatch.setattr(crewai_tools, "CrewaiPlatformTools", lambda apps: [])
|
||||
agent = Agent(
|
||||
role="Empty Apps Agent",
|
||||
goal="Use apps",
|
||||
backstory="b",
|
||||
apps=["gmail", "slack"],
|
||||
)
|
||||
|
||||
with pytest.warns(UserWarning, match="resolved to any tools"):
|
||||
result = agent.get_platform_tools(agent.apps)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_platform_tools_quiet_when_tools_resolve(monkeypatch):
|
||||
"""No warning when the apps resolve to at least one tool."""
|
||||
from crewai.tools import tool
|
||||
|
||||
@tool
|
||||
def platform_tool() -> str:
|
||||
"""A resolved platform tool."""
|
||||
return "ok"
|
||||
|
||||
import crewai_tools
|
||||
|
||||
monkeypatch.setattr(crewai_tools, "CrewaiPlatformTools", lambda apps: [platform_tool])
|
||||
agent = Agent(role="A", goal="g", backstory="b", apps=["gmail"])
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
result = agent.get_platform_tools(agent.apps)
|
||||
|
||||
assert result == [platform_tool]
|
||||
assert not any("resolved to any tools" in str(w.message) for w in caught)
|
||||
|
||||
|
||||
def test_crew_prepare_tools_warns_on_empty_apps(monkeypatch):
|
||||
"""The warning also fires through the crew tool-injection path."""
|
||||
import crewai_tools
|
||||
|
||||
monkeypatch.setattr(crewai_tools, "CrewaiPlatformTools", lambda apps: [])
|
||||
agent = Agent(role="A", goal="g", backstory="b", apps=["gmail"])
|
||||
task = Task(description="d", expected_output="o", agent=agent)
|
||||
crew = Crew(agents=[agent], tasks=[task])
|
||||
|
||||
with pytest.warns(UserWarning, match="resolved to any tools"):
|
||||
crew._prepare_tools(agent, task, [])
|
||||
|
||||
|
||||
def test_agent_mcps_accepts_slug_with_specific_tool():
|
||||
"""Agent(mcps=["notion#get_page"]) must pass validation (_SLUG_RE)."""
|
||||
agent = Agent(
|
||||
|
||||
Reference in New Issue
Block a user