feat: add interactive agent creation and TUI for multi-agent interaction

- Introduced a new `create_agent` command for interactive agent definition.
- Added `agent_tui.py` for a conversational TUI supporting multi-agent interactions.
- Updated CLI to support agent creation and training workflows.
- Enhanced `.gitignore` to exclude demo files and configuration artifacts.
- Implemented a benchmark runner for testing agent performance against defined cases.

This commit lays the groundwork for a more interactive and user-friendly experience in managing agents within the CrewAI framework.
This commit is contained in:
Joao Moura
2026-05-12 13:14:16 -04:00
committed by alex-clawd
parent c36827b45b
commit fe7f730546
49 changed files with 20653 additions and 29 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,380 @@
"""Benchmark runner for NewAgent — run agents against test cases and report results."""
from __future__ import annotations
import asyncio
import json
import re
import time
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
class BenchmarkCase(BaseModel):
"""A single benchmark test case."""
input: str
expected: str | None = None
criteria: str | None = None
class BenchmarkResult(BaseModel):
"""Result of running a single benchmark case."""
case_index: int
input: str
expected: str | None = None
actual: str = ""
model: str = ""
passed: bool = False
score: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
response_time_ms: int = 0
cost: float | None = None
def load_benchmark_cases(path: str | Path) -> list[BenchmarkCase]:
"""Load benchmark cases from a JSON or JSONC file.
Args:
path: Path to a JSON/JSONC file containing an array of test cases.
Returns:
List of BenchmarkCase instances.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If the file content is not a valid JSON array of cases.
"""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"Benchmark cases file not found: {path}")
raw = p.read_text(encoding="utf-8")
# Strip JSONC comments
clean = _strip_jsonc_comments(raw)
try:
data = json.loads(clean)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in benchmark cases file: {e}") from e
if not isinstance(data, list):
raise ValueError("Benchmark cases file must contain a JSON array")
cases: list[BenchmarkCase] = []
for i, item in enumerate(data):
if not isinstance(item, dict):
raise ValueError(f"Benchmark case at index {i} must be a JSON object")
if "input" not in item:
raise ValueError(f"Benchmark case at index {i} missing required 'input' field")
cases.append(BenchmarkCase(**item))
return cases
def _strip_jsonc_comments(text: str) -> str:
"""Strip // and /* */ comments from JSONC text."""
result = re.sub(r"(?<!:)//.*?$", "", text, flags=re.MULTILINE)
result = re.sub(r"/\*.*?\*/", "", result, flags=re.DOTALL)
return result
def _check_expected(expected: str, actual: str) -> tuple[bool, float]:
"""Check if expected output is found in actual (case-insensitive substring match).
Returns:
Tuple of (passed, score).
"""
if expected.lower() in actual.lower():
return True, 1.0
return False, 0.0
async def _judge_with_llm(
criteria: str,
input_text: str,
actual: str,
judge_model: str,
) -> tuple[bool, float]:
"""Use an LLM judge to evaluate a response against criteria.
Returns:
Tuple of (passed, score).
"""
from crewai.utilities.llm_utils import create_llm
judge_llm = create_llm(judge_model)
prompt = (
"You are an evaluation judge. Score the following response on a scale of 0.0 to 1.0.\n\n"
f"Input: {input_text}\n\n"
f"Response: {actual}\n\n"
f"Evaluation criteria: {criteria}\n\n"
"Respond with ONLY a JSON object in this exact format:\n"
'{"score": <float between 0.0 and 1.0>, "passed": <true or false>}\n'
"A score >= 0.7 should be considered passed."
)
try:
response = judge_llm.call(messages=[{"role": "user", "content": prompt}])
text = str(response) if not isinstance(response, str) else response
# Extract JSON from response
match = re.search(r"\{[^}]+\}", text)
if match:
result = json.loads(match.group())
score = float(result.get("score", 0.0))
score = max(0.0, min(1.0, score))
passed = bool(result.get("passed", score >= 0.7))
return passed, score
except Exception:
pass
return False, 0.0
def _parse_definition(source: Any) -> dict[str, Any]:
"""Parse an agent definition — delegates to crewai's parser."""
from crewai.new_agent.definition_parser import parse_agent_definition
return parse_agent_definition(source)
def _load_agent(source: Any) -> Any:
"""Load a NewAgent from a definition — delegates to crewai's loader."""
from crewai.new_agent.definition_parser import load_agent_from_definition
return load_agent_from_definition(source)
async def run_benchmark(
agent_def: dict[str, Any] | str | Path,
cases: list[BenchmarkCase],
models: list[str] | None = None,
judge_model: str = "openai/gpt-4o-mini",
) -> dict[str, list[BenchmarkResult]]:
"""Run benchmark cases against an agent definition, optionally across multiple models.
Args:
agent_def: Agent definition dict, JSON string, or file path.
cases: List of benchmark cases to run.
models: Optional list of model identifiers to compare. If None, uses agent's default.
judge_model: Model to use for LLM judge evaluation.
Returns:
Dict mapping model name to list of BenchmarkResult.
"""
defn = _parse_definition(agent_def)
if models is None or len(models) == 0:
models = [defn.get("llm", "default")]
results_by_model: dict[str, list[BenchmarkResult]] = {}
for model in models:
model_results: list[BenchmarkResult] = []
for i, case in enumerate(cases):
# Override the model and disable memory for benchmark runs
bench_defn = dict(defn)
if model != "default":
bench_defn["llm"] = model
bench_defn.setdefault("settings", {})
bench_defn["settings"]["memory_read_only"] = True
try:
agent = _load_agent(bench_defn)
except Exception as e:
model_results.append(
BenchmarkResult(
case_index=i,
input=case.input,
expected=case.expected,
actual=f"[Agent creation error: {e}]",
model=model,
passed=False,
score=0.0,
)
)
continue
start_ms = _current_time_ms()
try:
response = await agent.amessage(case.input)
elapsed_ms = _current_time_ms() - start_ms
actual = response.content
input_tokens = response.input_tokens or 0
output_tokens = response.output_tokens or 0
cost = response.cost
except Exception as e:
elapsed_ms = _current_time_ms() - start_ms
model_results.append(
BenchmarkResult(
case_index=i,
input=case.input,
expected=case.expected,
actual=f"[Error: {e}]",
model=model,
passed=False,
score=0.0,
response_time_ms=elapsed_ms,
)
)
continue
# Evaluate
passed = False
score = 0.0
if case.expected is not None:
passed, score = _check_expected(case.expected, actual)
if case.criteria is not None:
criteria_passed, criteria_score = await _judge_with_llm(
case.criteria, case.input, actual, judge_model
)
if case.expected is not None:
# Combine: both must pass, average scores
passed = passed and criteria_passed
score = (score + criteria_score) / 2.0
else:
passed = criteria_passed
score = criteria_score
model_results.append(
BenchmarkResult(
case_index=i,
input=case.input,
expected=case.expected,
actual=actual,
model=model,
passed=passed,
score=score,
input_tokens=input_tokens,
output_tokens=output_tokens,
response_time_ms=elapsed_ms,
cost=cost,
)
)
results_by_model[model] = model_results
return results_by_model
def _current_time_ms() -> int:
"""Return current time in milliseconds."""
return int(time.monotonic() * 1000)
def format_results_table(results: list[BenchmarkResult]) -> str:
"""Format benchmark results as a readable table.
Args:
results: List of BenchmarkResult for a single model.
Returns:
Formatted string table.
"""
if not results:
return "No results to display."
model = results[0].model
lines: list[str] = []
lines.append(f"Benchmark Results — Model: {model}")
lines.append("=" * 80)
header = f"{'#':<4} {'Pass':<6} {'Score':<7} {'Tokens':<12} {'Time (ms)':<10} {'Input (truncated)'}"
lines.append(header)
lines.append("-" * 80)
total_passed = 0
total_score = 0.0
total_input_tokens = 0
total_output_tokens = 0
total_time_ms = 0
for r in results:
status = "PASS" if r.passed else "FAIL"
tokens = f"{r.input_tokens}/{r.output_tokens}"
input_trunc = r.input[:40] + "..." if len(r.input) > 40 else r.input
line = f"{r.case_index:<4} {status:<6} {r.score:<7.2f} {tokens:<12} {r.response_time_ms:<10} {input_trunc}"
lines.append(line)
if r.passed:
total_passed += 1
total_score += r.score
total_input_tokens += r.input_tokens
total_output_tokens += r.output_tokens
total_time_ms += r.response_time_ms
lines.append("-" * 80)
n = len(results)
avg_score = total_score / n if n > 0 else 0.0
lines.append(f"Total: {total_passed}/{n} passed | Avg score: {avg_score:.2f} | "
f"Tokens: {total_input_tokens}/{total_output_tokens} | "
f"Total time: {total_time_ms}ms")
return "\n".join(lines)
def format_comparison_table(results_by_model: dict[str, list[BenchmarkResult]]) -> str:
"""Format a comparison table across multiple models.
Args:
results_by_model: Dict mapping model name to list of BenchmarkResult.
Returns:
Formatted comparison string.
"""
if not results_by_model:
return "No results to compare."
lines: list[str] = []
lines.append("Model Comparison")
lines.append("=" * 90)
header = f"{'Model':<30} {'Passed':<10} {'Avg Score':<12} {'In Tokens':<12} {'Out Tokens':<12} {'Time (ms)'}"
lines.append(header)
lines.append("-" * 90)
for model, results in results_by_model.items():
n = len(results)
passed = sum(1 for r in results if r.passed)
avg_score = sum(r.score for r in results) / n if n > 0 else 0.0
total_in = sum(r.input_tokens for r in results)
total_out = sum(r.output_tokens for r in results)
total_time = sum(r.response_time_ms for r in results)
model_trunc = model[:28] if len(model) > 28 else model
line = (
f"{model_trunc:<30} {passed}/{n:<8} {avg_score:<12.2f} "
f"{total_in:<12} {total_out:<12} {total_time}"
)
lines.append(line)
lines.append("-" * 90)
# Determine best model by average score
if results_by_model:
best_model = max(
results_by_model.keys(),
key=lambda m: (
sum(r.score for r in results_by_model[m]) / len(results_by_model[m])
if results_by_model[m]
else 0.0
),
)
best_score = (
sum(r.score for r in results_by_model[best_model])
/ len(results_by_model[best_model])
if results_by_model[best_model]
else 0.0
)
lines.append(f"Best model: {best_model} (avg score: {best_score:.2f})")
return "\n".join(lines)

View File

@@ -11,6 +11,7 @@ from crewai_core.token_manager import TokenManager
from crewai_cli.add_crew_to_flow import add_crew_to_flow
from crewai_cli.authentication.main import AuthenticationCommand
from crewai_cli.config import Settings
from crewai_cli.create_agent import create_agent
from crewai_cli.create_crew import create_crew
from crewai_cli.create_flow import create_flow
from crewai_cli.crew_chat import run_chat
@@ -91,20 +92,31 @@ def uv(uv_args: tuple[str, ...]) -> None:
@crewai.command()
@click.argument("type", type=click.Choice(["crew", "flow"]))
@click.argument("name")
@click.argument("type", type=click.Choice(["crew", "flow", "agent"]))
@click.argument("name", required=False, default=None)
@click.option("--provider", type=str, help="The provider to use for the crew")
@click.option("--skip_provider", is_flag=True, help="Skip provider validation")
def create(
type: str, name: str, provider: str | None, skip_provider: bool = False
type: str, name: str | None, provider: str | None, skip_provider: bool = False
) -> None:
"""Create a new crew, or flow."""
"""Create a new crew, flow, or agent.
For agents, NAME is optional — omit it to enter interactive mode.
"""
if type == "crew":
if name is None:
click.secho("Error: name is required for crew creation.", fg="red")
raise SystemExit(1)
create_crew(name, provider, skip_provider)
elif type == "flow":
if name is None:
click.secho("Error: name is required for flow creation.", fg="red")
raise SystemExit(1)
create_flow(name)
elif type == "agent":
create_agent(name)
else:
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
click.secho("Error: Invalid type. Must be 'crew', 'flow', or 'agent'.", fg="red")
@crewai.command()
@@ -133,19 +145,115 @@ def version(tools: bool) -> None:
"--n_iterations",
type=int,
default=5,
help="Number of iterations to train the crew",
help="Number of iterations to run training feedback.",
)
@click.option(
"-f",
"--filename",
type=str,
default="trained_agents_data.pkl",
help="Path to a custom file for training",
help="Path to a trained-agents pickle (Crew projects only).",
)
def train(n_iterations: int, filename: str) -> None:
"""Train the crew."""
click.echo(f"Training the Crew for {n_iterations} iterations")
train_crew(n_iterations, filename)
"""Train the crew or agents.
Auto-detects project type: if agents/ directory exists, runs interactive
NewAgent training (feedback → canonical memories). Otherwise falls back to
legacy Crew training.
"""
from pathlib import Path
from crewai_cli.run_crew import _needs_uv_relaunch, _relaunch_via_uv
agents_dir = Path("agents")
agent_files = (
sorted(agents_dir.glob("*.json")) + sorted(agents_dir.glob("*.jsonc"))
if agents_dir.is_dir()
else []
)
if agent_files:
if _needs_uv_relaunch():
_relaunch_via_uv(["train", "-n", str(n_iterations), "-f", filename])
_train_new_agents(agent_files, n_iterations)
else:
click.echo(f"Training the Crew for {n_iterations} iterations")
train_crew(n_iterations, filename)
def _train_new_agents(agent_files: list, n_iterations: int) -> None:
"""Run interactive training for NewAgent agents.
For each agent, loads benchmark cases, runs them, shows the response,
and asks the user for feedback. Feedback is saved as canonical memories.
"""
import asyncio
from pathlib import Path
from crewai_cli.benchmark import load_benchmark_cases
benchmarks_dir = Path("benchmarks")
agents_trained = 0
for agent_path in agent_files:
agent_name = agent_path.stem
cases_path = benchmarks_dir / f"{agent_name}_cases.json"
if not cases_path.exists():
click.secho(f" Skipping {agent_name} — no {cases_path}", fg="yellow")
continue
try:
cases = load_benchmark_cases(cases_path)
except (FileNotFoundError, ValueError) as e:
click.secho(f" Error loading cases for {agent_name}: {e}", fg="red")
continue
click.echo()
click.secho(f"Training {agent_name} ({len(cases)} cases, {n_iterations} iterations)", fg="cyan", bold=True)
try:
from crewai.new_agent.definition_parser import load_agent_definition
agent = load_agent_definition(str(agent_path))
except Exception as e:
click.secho(f" Error loading agent {agent_name}: {e}", fg="red")
continue
for iteration in range(n_iterations):
click.secho(f"\n Iteration {iteration + 1}/{n_iterations}", fg="cyan")
for case in cases:
user_input = case.input
click.echo(f"\n Input: {user_input}")
try:
response = asyncio.run(agent.amessage(user_input))
click.echo(f" Response: {response.content[:500]}")
except Exception as e:
click.secho(f" Error: {e}", fg="red")
continue
if case.criteria:
click.echo(f" Criteria: {case.criteria}")
feedback = click.prompt(
" Feedback (Enter to skip, or type feedback)",
default="",
show_default=False,
)
if feedback.strip():
agent.train(
feedback=feedback.strip(),
task_context=f"Input: {user_input}\nResponse: {response.content[:300]}",
)
click.secho(" ✓ Feedback saved as canonical memory", fg="green")
agents_trained += 1
click.echo()
if agents_trained == 0:
click.secho("No agents with matching benchmark cases found.", fg="yellow")
else:
click.secho(f"Training complete ({agents_trained} agent(s)).", fg="green", bold=True)
@crewai.command()
@@ -346,14 +454,14 @@ def memory(
"--n_iterations",
type=int,
default=3,
help="Number of iterations to Test the crew",
help="Number of iterations to run (Crew) or repetitions per case (NewAgent).",
)
@click.option(
"-m",
"--model",
type=str,
default="gpt-4o-mini",
help="LLM Model to run the tests on the Crew. For now only accepting only OpenAI models.",
default=None,
help="LLM model to test with. For NewAgent, defaults to each agent's configured model.",
)
@click.option(
"-f",
@@ -361,17 +469,136 @@ def memory(
"trained_agents_file",
type=str,
default=None,
help=(
"Path to a trained-agents pickle (produced by `crewai train -f`). "
"When set, agents load suggestions from this file instead of the "
"default trained_agents_data.pkl. Equivalent to setting "
"CREWAI_TRAINED_AGENTS_FILE."
),
help="Path to a trained-agents pickle (Crew projects only).",
)
def test(n_iterations: int, model: str, trained_agents_file: str | None) -> None:
"""Test the crew and evaluate the results."""
click.echo(f"Testing the crew for {n_iterations} iterations with model {model}")
evaluate_crew(n_iterations, model, trained_agents_file=trained_agents_file)
@click.option(
"--threshold",
type=float,
default=0.7,
help="Minimum score to pass a test case (NewAgent only, 0.0-1.0).",
)
@click.option(
"--judge-model",
type=str,
default="openai/gpt-4o-mini",
help="LLM model for evaluation judging (NewAgent only).",
)
def test(
n_iterations: int,
model: str | None,
trained_agents_file: str | None,
threshold: float,
judge_model: str,
) -> None:
"""Test the crew or agents and evaluate the results.
Auto-detects project type: if agents/ directory exists with .json/.jsonc
files, runs NewAgent benchmarks. Otherwise falls back to legacy Crew testing.
"""
from pathlib import Path
from crewai_cli.run_crew import _needs_uv_relaunch, _relaunch_via_uv
agents_dir = Path("agents")
agent_files = sorted(agents_dir.glob("*.json")) + sorted(agents_dir.glob("*.jsonc")) if agents_dir.is_dir() else []
if agent_files:
if _needs_uv_relaunch():
uv_args = ["test", "-n", str(n_iterations), "--threshold", str(threshold), "--judge-model", judge_model]
if model:
uv_args.extend(["-m", model])
if trained_agents_file:
uv_args.extend(["-f", trained_agents_file])
_relaunch_via_uv(uv_args)
_test_new_agents(agent_files, n_iterations, model, threshold, judge_model)
else:
crew_model = model or "gpt-4o-mini"
click.echo(f"Testing the crew for {n_iterations} iterations with model {crew_model}")
evaluate_crew(n_iterations, crew_model, trained_agents_file=trained_agents_file)
def _test_new_agents(
agent_files: list,
n_iterations: int,
model: str | None,
threshold: float,
judge_model: str,
) -> None:
"""Run NewAgent test cases with pass/fail threshold."""
import asyncio
from pathlib import Path
from crewai_cli.benchmark import (
format_results_table,
load_benchmark_cases,
run_benchmark,
)
benchmarks_dir = Path("benchmarks")
all_passed = True
agents_tested = 0
for agent_path in agent_files:
agent_name = agent_path.stem
cases_path = benchmarks_dir / f"{agent_name}_cases.json"
if not cases_path.exists():
click.secho(f" Skipping {agent_name} — no {cases_path} found", fg="yellow")
continue
try:
cases = load_benchmark_cases(cases_path)
except (FileNotFoundError, ValueError) as e:
click.secho(f" Error loading cases for {agent_name}: {e}", fg="red")
all_passed = False
continue
model_list = [model] if model else None
click.echo()
click.secho(f"Testing {agent_name} ({len(cases)} cases)", fg="cyan", bold=True)
try:
results_by_model = asyncio.run(
run_benchmark(
agent_def=str(agent_path),
cases=cases,
models=model_list,
judge_model=judge_model,
)
)
except Exception as e:
click.secho(f" Error running tests for {agent_name}: {e}", fg="red")
all_passed = False
continue
agents_tested += 1
for model_name, results in results_by_model.items():
click.echo(format_results_table(results))
failed = [r for r in results if r.score < threshold]
if failed:
all_passed = False
click.secho(
f" FAILED: {len(failed)}/{len(results)} cases below threshold ({threshold})",
fg="red",
)
else:
click.secho(
f" PASSED: all {len(results)} cases >= {threshold}",
fg="green",
)
click.echo()
if agents_tested == 0:
click.secho("No agents with matching benchmark cases found.", fg="yellow")
raise SystemExit(1)
elif all_passed:
click.secho(f"All tests passed ({agents_tested} agent(s)).", fg="green", bold=True)
else:
click.secho("Some tests failed.", fg="red", bold=True)
raise SystemExit(1)
@crewai.command(
@@ -600,6 +827,145 @@ def flow_add_crew(crew_name: str) -> None:
add_crew_to_flow(crew_name)
@crewai.group()
def agent() -> None:
"""Agent management commands."""
@agent.command(name="reset-history")
@click.argument("name")
@click.option(
"--keep-provenance",
is_flag=True,
help="Keep the provenance (decision audit trail) when clearing history.",
)
def agent_reset_history(name: str, keep_provenance: bool) -> None:
"""Clear conversation history for the named agent."""
from pathlib import Path
conversations_dir = Path.cwd() / ".crewai" / "conversations"
history_path = conversations_dir / f"{name}.json"
provenance_path = conversations_dir / f"{name}_provenance.json"
cleared: list[str] = []
if history_path.exists():
history_path.unlink()
cleared.append("conversation history")
if not keep_provenance and provenance_path.exists():
provenance_path.unlink()
cleared.append("provenance log")
if cleared:
click.secho(
f"Cleared {' and '.join(cleared)} for agent '{name}'.",
fg="green",
)
else:
click.secho(
f"No conversation history found for agent '{name}'.",
fg="yellow",
)
@agent.command(name="memory")
@click.argument("name")
@click.option("--search", "-s", default=None, help="Search memories by keyword")
@click.option("--clear", is_flag=True, help="Clear all memories")
@click.option("--limit", "-n", "limit_", default=10, help="Number of memories to show")
def agent_memory(name: str, search: str | None, clear: bool, limit_: int) -> None:
"""Inspect or manage agent memories."""
from pathlib import Path
agents_dir = Path.cwd() / "agents"
agent_path = None
for ext in (".json", ".jsonc"):
p = agents_dir / f"{name}{ext}"
if p.exists():
agent_path = p
break
if not agent_path:
click.echo(f"Agent '{name}' not found in agents/ directory.")
return
try:
from crewai.new_agent.definition_parser import load_agent_from_definition
agent_instance = load_agent_from_definition(agent_path, agents_dir)
except Exception as e:
click.echo(f"Failed to load agent '{name}': {e}")
return
if agent_instance is None:
click.echo(f"Could not create agent '{name}'.")
return
if clear:
if click.confirm(f"Clear all memories for '{name}'?"):
if hasattr(agent_instance, "_memory_instance") and agent_instance._memory_instance:
try:
agent_instance._memory_instance.reset()
click.echo(f"Memories cleared for '{name}'.")
except Exception as e:
click.echo(f"Failed to clear memories: {e}")
else:
click.echo(f"No memory configured for '{name}'.")
return
if not hasattr(agent_instance, "_memory_instance") or not agent_instance._memory_instance:
click.echo(f"No memory configured for '{name}'.")
return
# GAP-93: Rich formatted output for agent memory inspection
try:
from rich.console import Console
from rich.table import Table
except ImportError:
# Fall back to plain text if rich is not available
Console = None # type: ignore[assignment,misc]
try:
if search:
results = agent_instance._memory_instance.recall(search, limit=limit_, depth="shallow")
else:
results = agent_instance._memory_instance.list_records(limit=limit_)
if not results:
msg = f"No memories matching '{search}'" if search else f"No memories stored for '{name}'."
click.echo(msg)
return
if Console is not None:
console = Console()
title = f"Memories matching '{search}'{name}" if search else f"Memories — {name}"
table = Table(title=title, show_lines=True)
table.add_column("#", style="dim", width=4)
table.add_column("Content", min_width=40)
table.add_column("Type", width=10)
table.add_column("Scope", width=10)
for i, mem in enumerate(results, 1):
record = getattr(mem, "record", mem)
content = getattr(record, "content", "") or str(mem)
if len(content) > 200:
content = content[:200] + "..."
meta = getattr(record, "metadata", {}) or {}
mem_type = meta.get("type", "raw")
scope = getattr(record, "scope", meta.get("scope", ""))
table.add_row(str(i), content, mem_type, scope)
console.print(table)
else:
heading = f"Memories matching '{search}':" if search else f"Recent memories for '{name}':"
click.echo(heading)
for i, r in enumerate(results, 1):
click.echo(f" {i}. {str(r)[:100]}")
except Exception as e:
click.echo(f"Memory operation failed: {e}")
@crewai.group()
def triggers() -> None:
"""Trigger related commands. Use 'crewai triggers list' to see available triggers, or 'crewai triggers run app_slug/trigger_slug' to execute."""
@@ -956,5 +1322,73 @@ def checkpoint_prune(
prune_checkpoints(ctx.obj["location"], keep, older_than, dry_run)
@crewai.command()
@click.argument("agent_path", type=click.Path(exists=True))
@click.argument("cases_path", type=click.Path(exists=True))
@click.option(
"--models",
"-m",
multiple=True,
help="Models to compare (e.g., openai/gpt-4o openai/gpt-4o-mini)",
)
@click.option(
"--judge-model",
default="openai/gpt-4o-mini",
help="Model for LLM judge evaluation",
)
def benchmark(
agent_path: str,
cases_path: str,
models: tuple[str, ...],
judge_model: str,
) -> None:
"""Run agent against test cases and report results."""
import asyncio
from crewai_cli.benchmark import (
format_comparison_table,
format_results_table,
load_benchmark_cases,
run_benchmark,
)
try:
cases = load_benchmark_cases(cases_path)
except (FileNotFoundError, ValueError) as e:
click.secho(f"Error loading benchmark cases: {e}", fg="red")
raise SystemExit(1) from e
click.echo(f"Loaded {len(cases)} benchmark case(s) from {cases_path}")
click.echo(f"Agent definition: {agent_path}")
model_list = list(models) if models else None
if model_list:
click.echo(f"Models to compare: {', '.join(model_list)}")
click.echo(f"Judge model: {judge_model}")
click.echo()
try:
results_by_model = asyncio.run(
run_benchmark(
agent_def=agent_path,
cases=cases,
models=model_list,
judge_model=judge_model,
)
)
except Exception as e:
click.secho(f"Error running benchmark: {e}", fg="red")
raise SystemExit(1) from e
# Print results for each model
for model, results in results_by_model.items():
click.echo(format_results_table(results))
click.echo()
# Print comparison if multiple models
if len(results_by_model) > 1:
click.echo(format_comparison_table(results_by_model))
if __name__ == "__main__":
crewai()

View File

@@ -0,0 +1,754 @@
"""Create agent definitions via interactive prompts."""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
import click
from crewai_cli.constants import ENV_VARS, MODELS
from crewai_cli.utils import load_env_vars, write_env_file
AGENT_TEMPLATE = """\
{{
// Agent identity — defines the agent's persona and expertise
// These three fields shape how the agent thinks and communicates
"name": "{name}",
// What this agent does (any role you want)
"role": "{role}",
// The agent's primary objective
"goal": "{goal}",
// Background context that shapes personality and approach
"backstory": "{backstory}",
// Which LLM powers this agent
// Format: "provider/model" — e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514"
"llm": "{llm}",
// Separate LLM for tool/function calls (optional, defaults to main LLM)
// Useful for using a cheaper model for tool routing
// "function_calling_llm": "openai/gpt-4o-mini",
// Tools this agent can use — referenced by name from the crewai-tools package
// See: https://docs.crewai.com/tools for available tools
// Use "custom:tool_name" for custom tools defined in your tools/ directory
"tools": [],
// MCP servers — external tool servers following the Model Context Protocol
// Can be URLs ("https://mcp.example.com") or platform slugs ("notion")
"mcps": [],
// Platform app integrations — managed by CrewAI Platform
// App name ("gmail") or app/action ("gmail/send_email")
"apps": [],
// Coworkers — other agents this agent can delegate work to
// {{"ref": "name"}} for local agents in agents/ directory
// {{"amp": "handle"}} for agents from the CrewAI AMP repository (your org)
// {{"amp": "handle", "llm": "..."}} for AMP agents with LLM override
// {{"a2a": "url"}} for remote agents via A2A protocol
"coworkers": [],
// Knowledge sources — files/directories the agent can search for context
// Supports: PDF, CSV, JSON, TXT, Excel, and directories
"knowledge_sources": [],
// Output guardrail — validates agent responses before sending to user
// "type": "llm" uses an LLM to check the response against instructions
// Remove this block to disable guardrails
// "guardrail": {{
// "type": "llm",
// "instructions": "Never reveal internal pricing information.",
// "llm": "openai/gpt-4o-mini"
// }},
// Settings — all have sensible defaults, only override what you need
"settings": {{
// Agent remembers across conversations
"memory": true,
// Enable extended thinking / chain-of-thought
"reasoning": true,
// Dreaming: consolidate memories over time into canonical insights
"self_improving": true,
// Agent plans before complex tasks
"planning": true,
// Agent decides at runtime whether to plan (default: true)
// "auto_plan": true,
// Allow agent to spawn parallel copies for subtasks (default: true)
// "can_spawn_copies": true,
// How deep spawned copies can nest (default: 1)
// "max_spawn_depth": 1,
// Max parallel copies running at once (default: 4)
// "max_concurrent_spawns": 4,
// Messages sent to LLM per turn, null = all (default: null)
// "max_history_messages": null,
// Detect claimed-but-not-done actions (default: false)
// "narration_guard": false,
// Hours between dreaming cycles (default: 24)
// "dreaming_interval_hours": 24,
// New memories before dreaming triggers (default: 10)
// "dreaming_trigger_threshold": 10,
// Separate LLM for dreaming (default: uses agent's LLM)
// "dreaming_llm": "openai/gpt-4o-mini",
// Provenance detail level: "minimal", "standard", or "detailed"
// "provenance_detail": "standard"
}}
}}
"""
PROJECT_CONFIG_TEMPLATE = """\
{
// Project configuration for crewai agents
// Rooms define how agents collaborate in the TUI
"rooms": {
"common": {
// Which agents participate in this room
"agents": [],
// Engagement mode:
// "dm" — chat with one agent at a time (default)
// "tagged" — @mention to direct messages
// "organic" — all agents see messages, respond if relevant
"engagement": "dm"
}
}
}
"""
_STARTER_CASES = """\
[
{
"input": "Hello, what can you help me with?",
"criteria": "The agent should clearly describe its role and capabilities."
}
]
"""
_PROVIDER_TO_EXTRA: dict[str, str] = {
# Native providers with dedicated SDK extras
"anthropic": "anthropic",
"gemini": "google-genai",
"google": "google-genai",
"azure": "azure-ai-inference",
"azure_openai": "azure-ai-inference",
"bedrock": "bedrock",
"aws": "aws",
# Providers that route through litellm
"watsonx": "litellm",
"groq": "litellm",
"nvidia_nim": "litellm",
"huggingface": "litellm",
"sambanova": "litellm",
# OpenAI-compatible providers — no extra needed:
# openai, ollama, cerebras, deepseek, openrouter, hosted_vllm, dashscope
}
_PROVIDER_BONUS_EXTRAS: dict[str, list[str]] = {
"watsonx": ["watson"],
}
_GITIGNORE_TEMPLATE = """\
.env
__pycache__/
.DS_Store
.crewai/
"""
def _build_pyproject(project_name: str, crewai_version: str, llm_provider: str) -> str:
"""Build pyproject.toml content with the right LLM provider extra."""
extras = ["tools"]
provider_extra = _PROVIDER_TO_EXTRA.get(llm_provider, "")
if provider_extra and provider_extra not in extras:
extras.append(provider_extra)
for bonus in _PROVIDER_BONUS_EXTRAS.get(llm_provider, []):
if bonus not in extras:
extras.append(bonus)
extras_str = ",".join(extras)
lines = [
"[project]",
f'name = "{project_name}"',
'version = "0.1.0"',
'description = "CrewAI agent project"',
'requires-python = ">=3.10,<3.14"',
"dependencies = [",
f' "crewai[{extras_str}]>={crewai_version}",',
f' "crewai-cli>={crewai_version}",',
"]",
"",
"[tool.uv]",
'prerelease = "allow"',
"constraint-dependencies = [",
' "onnxruntime<=1.25.1",',
"]",
"",
"[tool.crewai]",
'type = "agent"',
"",
]
return "\n".join(lines)
def _bootstrap_project(base: Path, llm_model: str = "") -> None:
"""Create project structure if it doesn't exist yet."""
agents_dir = base / "agents"
agents_dir.mkdir(parents=True, exist_ok=True)
tools_dir = base / "tools"
tools_dir.mkdir(parents=True, exist_ok=True)
benchmarks_dir = base / "benchmarks"
benchmarks_dir.mkdir(parents=True, exist_ok=True)
config_path = base / "config.json"
if not config_path.exists():
config_path.write_text(PROJECT_CONFIG_TEMPLATE, encoding="utf-8")
provider = llm_model.split("/")[0].lower() if "/" in llm_model else ""
pyproject_path = base / "pyproject.toml"
if not pyproject_path.exists():
crewai_version = _get_crewai_version()
pyproject_path.write_text(
_build_pyproject(base.name, crewai_version, provider),
encoding="utf-8",
)
else:
_maybe_add_provider_extra(pyproject_path, provider)
gitignore_path = base / ".gitignore"
if not gitignore_path.exists():
gitignore_path.write_text(_GITIGNORE_TEMPLATE, encoding="utf-8")
def _maybe_add_provider_extra(pyproject_path: Path, provider: str) -> None:
"""If the pyproject.toml exists but doesn't include the provider extra, add it."""
all_extras = []
primary = _PROVIDER_TO_EXTRA.get(provider, "")
if primary:
all_extras.append(primary)
all_extras.extend(_PROVIDER_BONUS_EXTRAS.get(provider, []))
if not all_extras:
return
try:
content = pyproject_path.read_text(encoding="utf-8")
missing = [
e for e in all_extras
if f"[{e}]" not in content and f",{e}]" not in content and f",{e}," not in content
]
if not missing:
return
import re as _re
suffix = "," + ",".join(missing)
def _add_extras(m: _re.Match) -> str:
bracket = m.group(0)
return bracket[:-1] + suffix + "]"
updated = _re.sub(r'crewai\[[^\]]+\]', _add_extras, content, count=1)
if updated != content:
pyproject_path.write_text(updated, encoding="utf-8")
except Exception:
pass
def _get_crewai_version() -> str:
"""Get the installed crewai version for the dependency pin."""
try:
from crewai_cli.version import get_crewai_version
return get_crewai_version()
except Exception:
return "1.14.5"
def _run_uv_sync(base: Path) -> None:
"""Run uv sync to install dependencies."""
click.echo()
click.secho("Installing dependencies...", fg="cyan")
try:
result = subprocess.run(
["uv", "sync"],
cwd=str(base),
capture_output=True,
text=True,
timeout=300,
)
if result.returncode == 0:
click.secho("Dependencies installed successfully.", fg="green")
else:
click.secho("Failed to install dependencies:", fg="red")
if result.stderr:
click.echo(result.stderr)
click.echo("Try running: uv sync")
except FileNotFoundError:
click.secho(
"uv not found. Install it (https://docs.astral.sh/uv/) then run: uv sync",
fg="yellow",
)
except subprocess.TimeoutExpired:
click.secho("uv sync timed out. Run manually: uv sync", fg="yellow")
except Exception as e:
click.secho(f"Could not run uv sync: {e}", fg="yellow")
click.echo("Run manually: uv sync")
def _create_benchmark_cases(base: Path, agent_name: str) -> None:
"""Create a starter benchmark cases file for the agent."""
cases_path = base / "benchmarks" / f"{agent_name}_cases.json"
if cases_path.exists():
return
cases_path.parent.mkdir(parents=True, exist_ok=True)
cases_path.write_text(_STARTER_CASES, encoding="utf-8")
_POPULAR_MODELS: list[tuple[str, str]] = [
("openai/gpt-4o", "OpenAI GPT-4o"),
("openai/gpt-4o-mini", "OpenAI GPT-4o Mini (cheaper)"),
("openai/o3", "OpenAI o3 (reasoning)"),
("anthropic/claude-sonnet-4-6", "Anthropic Claude Sonnet 4.6"),
("anthropic/claude-haiku-4-5-20251001", "Anthropic Claude Haiku 4.5 (fast)"),
("gemini/gemini-2.5-pro-exp-03-25", "Google Gemini 2.5 Pro"),
("groq/llama-3.1-70b-versatile", "Groq Llama 3.1 70B (fast)"),
("ollama/llama3.1", "Ollama Llama 3.1 (local)"),
]
_POPULAR_TOOLS: list[tuple[str, str]] = [
("SerperDevTool", "Web search via Serper API"),
("ScrapeWebsiteTool", "Scrape and extract content from URLs"),
("FileReadTool", "Read local files"),
("FileWriterTool", "Write content to local files"),
("DirectoryReadTool", "List directory contents"),
("CodeInterpreterTool", "Execute Python code in a sandbox"),
("CSVSearchTool", "Search within CSV files"),
("PDFSearchTool", "Search within PDF documents"),
("JSONSearchTool", "Search within JSON files"),
("GithubSearchTool", "Search GitHub repositories"),
("YoutubeVideoSearchTool", "Search YouTube video transcripts"),
("TavilySearchTool", "Web search via Tavily API"),
("BraveSearchTool", "Web search via Brave API"),
("RagTool", "RAG over custom knowledge sources"),
("DallETool", "Generate images with DALL-E"),
("VisionTool", "Analyze images with vision models"),
]
_AGENT_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
# ── Arrow-key selection helpers ──────────────────────────────────
_CYAN = "\033[36m"
_BOLD = "\033[1m"
_GREEN = "\033[32m"
_DIM = "\033[2m"
_RESET = "\033[0m"
def _is_interactive() -> bool:
"""Check if stdin/stdout are real terminals (not piped or in tests)."""
try:
return sys.stdin.isatty() and sys.stdout.isatty()
except Exception:
return False
def _read_key() -> str:
"""Read a single keypress. Returns 'up', 'down', 'enter', 'space', or the char."""
if sys.platform == "win32":
import msvcrt
ch = msvcrt.getwch()
if ch in ("\x00", "\xe0"):
ch2 = msvcrt.getwch()
return {"H": "up", "P": "down"}.get(ch2, "")
if ch == "\r":
return "enter"
if ch == " ":
return "space"
if ch == "\x03":
raise KeyboardInterrupt
return ch
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
ch = sys.stdin.read(1)
if ch == "\x1b":
seq = sys.stdin.read(2)
if seq == "[A":
return "up"
if seq == "[B":
return "down"
return "esc"
if ch in ("\r", "\n"):
return "enter"
if ch == " ":
return "space"
if ch == "\x03":
raise KeyboardInterrupt
return ch
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _draw_single(labels: list[str], cursor: int, *, clear: bool = False) -> None:
"""Draw single-select menu. If clear=True, move cursor up first."""
total = len(labels)
if clear:
sys.stdout.write(f"\033[{total}A")
for i, label in enumerate(labels):
if i == cursor:
sys.stdout.write(f"\033[2K {_CYAN}{_RESET} {_BOLD}{label}{_RESET}\n")
else:
sys.stdout.write(f"\033[2K {label}\n")
sys.stdout.flush()
def _draw_multi(labels: list[str], cursor: int, selected: set[int], *, clear: bool = False) -> None:
"""Draw multi-select menu with checkboxes."""
hint = f" {_DIM}↑↓ navigate, space toggle, enter confirm{_RESET}"
total = len(labels) + 1 # +1 for hint line
if clear:
sys.stdout.write(f"\033[{total}A")
sys.stdout.write(f"\033[2K{hint}\n")
for i, label in enumerate(labels):
check = f"{_CYAN}[×]{_RESET}" if i in selected else "[ ]"
arrow = f"{_CYAN}{_RESET} " if i == cursor else " "
bold = f"{_BOLD}{label}{_RESET}" if i == cursor else label
sys.stdout.write(f"\033[2K {arrow}{check} {bold}\n")
sys.stdout.flush()
def _clear_lines(n: int) -> None:
"""Clear n lines above and position cursor at the top."""
sys.stdout.write(f"\033[{n}A")
for _ in range(n):
sys.stdout.write("\033[2K\n")
sys.stdout.write(f"\033[{n}A")
sys.stdout.flush()
def create_agent(name: str | None = None) -> None:
"""Create an agent definition interactively.
Both paths (with and without a name) ask the same structured
questions and produce the same annotated JSONC output.
"""
click.secho("\nCrewAI Agent Creator\n", fg="cyan", bold=True)
if name is None:
name = _prompt_agent_name()
base = Path.cwd()
# Directories are bootstrapped now, pyproject written after model selection
for d in ("agents", "tools", "benchmarks"):
(base / d).mkdir(parents=True, exist_ok=True)
dest = base / "agents" / f"{name}.jsonc"
if dest.exists():
if not click.confirm(f"File {dest} already exists. Overwrite?"):
click.secho("Operation cancelled.", fg="yellow")
return
click.secho(f"Configuring agent: {name}\n", fg="cyan")
role = click.prompt(" Role (what this agent does)", type=str)
goal = click.prompt(" Goal (the agent's objective)", type=str)
backstory = click.prompt(
" Backstory (context that shapes personality, optional)",
type=str, default="", show_default=False,
)
llm = _select_model()
tools = _select_tools()
content = AGENT_TEMPLATE.format(
name=name,
role=role,
goal=goal,
backstory=backstory,
llm=llm,
)
if tools:
tools_json = json.dumps(tools)
content = content.replace('"tools": []', f'"tools": {tools_json}')
dest.write_text(content, encoding="utf-8")
_bootstrap_project(base, llm)
_add_agent_to_config(base, name)
_create_benchmark_cases(base, name)
_setup_env(base, llm)
_run_uv_sync(base)
click.echo()
click.secho(f"Agent created: {dest}", fg="green", bold=True)
click.echo("Run: crewai run")
def _select_model() -> str:
"""Let the user pick an LLM model from popular options or type a custom one."""
labels = [f"{label} ({model_id})" for model_id, label in _POPULAR_MODELS]
labels.append("Other (enter manually)")
click.echo()
click.secho(" LLM Model:", fg="cyan")
if _is_interactive():
try:
_draw_single(labels, 0)
cursor = 0
total = len(labels)
while True:
key = _read_key()
if key == "up" and cursor > 0:
cursor -= 1
_draw_single(labels, cursor, clear=True)
elif key == "down" and cursor < total - 1:
cursor += 1
_draw_single(labels, cursor, clear=True)
elif key == "enter":
_clear_lines(total)
idx = cursor
break
except Exception:
idx = _select_model_fallback(labels)
else:
idx = _select_model_fallback(labels)
if idx == len(_POPULAR_MODELS):
custom = click.prompt(" Enter model (provider/model)", type=str)
return custom.strip()
selected = _POPULAR_MODELS[idx][0]
click.secho(f"{selected}", fg="green")
return selected
def _select_model_fallback(labels: list[str]) -> int:
"""Numbered fallback for non-TTY environments."""
for idx, label in enumerate(labels, 1):
click.echo(f" {idx}. {label}")
click.echo()
while True:
choice = click.prompt(" Select a model", type=str, default="1")
try:
num = int(choice)
if 1 <= num <= len(labels):
return num - 1
except ValueError:
pass
click.secho(f" Invalid choice. Enter 1-{len(labels)}.", fg="red")
def _select_tools() -> list[str]:
"""Let the user pick tools from popular options and/or add custom ones."""
labels = [f"{cls_name:<28s} {desc}" for cls_name, desc in _POPULAR_TOOLS]
labels.append("Add custom tool class names")
click.echo()
click.secho(" Tools (press Enter to skip):", fg="cyan")
if _is_interactive():
try:
indices = _select_tools_interactive(labels)
except Exception:
indices = _select_tools_fallback(labels)
else:
indices = _select_tools_fallback(labels)
selected: list[str] = []
has_custom = False
for idx in indices:
if idx == len(_POPULAR_TOOLS):
has_custom = True
elif 0 <= idx < len(_POPULAR_TOOLS):
cls_name = _POPULAR_TOOLS[idx][0]
if cls_name not in selected:
selected.append(cls_name)
if has_custom:
custom = click.prompt(
" Custom tool class names (comma-separated)",
type=str, default="", show_default=False,
)
for name in custom.split(","):
name = name.strip()
if name and name not in selected:
selected.append(name)
if selected:
click.secho(f"{', '.join(selected)}", fg="green")
return selected
def _select_tools_interactive(labels: list[str]) -> list[int]:
"""Arrow-key multi-select for tools."""
cursor = 0
chosen: set[int] = set()
total_lines = len(labels) + 1 # +1 for hint line
_draw_multi(labels, cursor, chosen)
while True:
key = _read_key()
if key == "up" and cursor > 0:
cursor -= 1
_draw_multi(labels, cursor, chosen, clear=True)
elif key == "down" and cursor < len(labels) - 1:
cursor += 1
_draw_multi(labels, cursor, chosen, clear=True)
elif key == "space":
if cursor in chosen:
chosen.discard(cursor)
else:
chosen.add(cursor)
_draw_multi(labels, cursor, chosen, clear=True)
elif key == "enter":
_clear_lines(total_lines)
return sorted(chosen)
def _select_tools_fallback(labels: list[str]) -> list[int]:
"""Numbered fallback for non-TTY environments."""
for idx, label in enumerate(labels, 1):
click.echo(f" {idx:2d}. {label}")
click.echo()
raw = click.prompt(
" Select tools (e.g. 1 3 5)", type=str, default="", show_default=False,
)
if not raw.strip():
return []
indices: list[int] = []
for token in raw.split():
try:
num = int(token)
if 1 <= num <= len(labels):
indices.append(num - 1)
except ValueError:
pass
return indices
def _setup_env(base: Path, llm_model: str) -> None:
"""Prompt for API keys based on the selected LLM provider and write .env."""
env_vars = load_env_vars(base)
provider = llm_model.split("/")[0].lower() if "/" in llm_model else ""
if not provider:
return
env_vars["MODEL"] = llm_model
already_set = all(
details.get("key_name", "") in env_vars
for details in ENV_VARS.get(provider, [])
if "key_name" in details
)
if already_set and env_vars.get("MODEL"):
return
if provider in ENV_VARS:
click.echo()
for details in ENV_VARS[provider]:
key_name = details.get("key_name")
if not key_name or key_name in env_vars:
continue
if details.get("default"):
env_vars[key_name] = details.get("API_BASE", "")
continue
value = click.prompt(
f" {details.get('prompt', f'Enter {key_name}')}",
default="", show_default=False,
)
if value.strip():
env_vars[key_name] = value.strip()
if env_vars:
write_env_file(base, env_vars)
click.secho("API keys saved to .env", fg="green")
else:
click.secho(
"No API keys provided. Create a .env file manually before running.",
fg="yellow",
)
def _prompt_agent_name() -> str:
"""Prompt for a valid agent identifier."""
while True:
name = click.prompt(
" Agent identifier (lowercase, hyphens/underscores, no spaces)",
type=str,
)
name = name.strip().lower()
if _AGENT_NAME_RE.match(name):
return name
click.secho(
" Invalid name — use lowercase letters, numbers, hyphens, or underscores.",
fg="red",
)
def _strip_comments(text: str) -> str:
"""Strip // and /* */ comments from JSONC text, then fix trailing commas."""
result = re.sub(r'(?<!:)//.*?$', '', text, flags=re.MULTILINE)
result = re.sub(r'/\*.*?\*/', '', result, flags=re.DOTALL)
result = re.sub(r',\s*([}\]])', r'\1', result)
return result
def _add_agent_to_config(base: Path, agent_name: str) -> None:
"""Add the agent to the common room in config.json."""
config_path = base / "config.json"
if not config_path.exists():
return
try:
raw = config_path.read_text(encoding="utf-8")
clean = _strip_comments(raw)
config = json.loads(clean)
rooms = config.get("rooms", {})
common = rooms.get("common", {"agents": [], "engagement": "dm"})
agents = common.get("agents", [])
if agent_name not in agents:
agents.append(agent_name)
common["agents"] = agents
rooms["common"] = common
config["rooms"] = rooms
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
except Exception as e:
click.echo(f"Warning: Could not update config.json: {e}", err=True)

View File

@@ -1,4 +1,5 @@
from enum import Enum
import os
import subprocess
import click
@@ -8,18 +9,60 @@ from packaging import version
from crewai_cli.utils import build_env_with_all_tool_credentials, read_toml
from crewai_cli.version import get_crewai_version
_UV_CONTEXT_VAR = "_CREWAI_UV"
class CrewType(Enum):
STANDARD = "standard"
FLOW = "flow"
def run_crew(trained_agents_file: str | None = None) -> None:
"""Run the crew or flow by running a command in the UV environment.
def _has_agents_dir() -> bool:
"""Check if current directory has an agents/ directory with definitions."""
from pathlib import Path
agents_dir = Path.cwd() / "agents"
if not agents_dir.is_dir():
return False
files = list(agents_dir.glob("*.json")) + list(agents_dir.glob("*.jsonc"))
return len(files) > 0
Starting from version 0.103.0, this command can be used to run both
standard crews and flows. For flows, it detects the type from pyproject.toml
and automatically runs the appropriate command.
def _needs_uv_relaunch() -> bool:
"""True when we should re-exec through ``uv run`` for the project venv."""
if os.environ.get(_UV_CONTEXT_VAR):
return False
from pathlib import Path
pyproject = Path.cwd() / "pyproject.toml"
if not pyproject.exists():
return False
try:
return 'type = "agent"' in pyproject.read_text(encoding="utf-8")
except Exception:
return False
def _relaunch_via_uv(args: list[str]) -> None:
"""Re-exec ``uv run crewai <args>`` inside the project venv, then exit."""
env = {**os.environ, _UV_CONTEXT_VAR: "1"}
cmd = ["uv", "run", "crewai", *args]
try:
result = subprocess.run(cmd, env=env)
raise SystemExit(result.returncode)
except FileNotFoundError:
click.secho(
"uv not found — running without project venv. "
"Install uv (https://docs.astral.sh/uv/) for full provider support.",
fg="yellow",
)
def run_crew(trained_agents_file: str | None = None) -> None:
"""Run the crew, flow, or agent TUI.
Detects the project type:
- If agents/ directory exists with definitions: launch agent TUI
- If pyproject.toml type is "flow": run the flow
- Otherwise: run the crew
Args:
trained_agents_file: Optional path to a trained-agents pickle produced
@@ -27,6 +70,18 @@ def run_crew(trained_agents_file: str | None = None) -> None:
``CREWAI_TRAINED_AGENTS_FILE`` so agents load suggestions from this
file instead of the default ``trained_agents_data.pkl``.
"""
# Check for agents/ directory first — agent projects don't need pyproject.toml
if _has_agents_dir():
if _needs_uv_relaunch():
uv_args = ["run"]
if trained_agents_file:
uv_args.extend(["-f", trained_agents_file])
_relaunch_via_uv(uv_args)
click.echo("Launching agent TUI...")
from crewai_cli.agent_tui import run_agent_tui
run_agent_tui()
return
crewai_version = get_crewai_version()
min_required_version = "0.71.0"
pyproject_data = read_toml()