Compare commits

...

6 Commits

Author SHA1 Message Date
Vinicius Brasil
b9586874b3 Add lead scoring FlowDefinition example 2026-06-16 10:54:34 -07:00
João Moura
e9d568dc69 Deep Crew / Agent / Task attributes support on json (#6172)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
* Enhance JSON crew project handling and validation

- Updated `create_json_crew.py` to specify input files with a brief path.
- Refactored `crew_loader.py` to improve agent and task loading logic, including the introduction of a `build_agent` function and better handling of task classes.
- Enhanced `json_loader.py` with additional validation for agent and task definitions, including support for Python references and conditional tasks.
- Added tests in `test_crew_loader.py` and `test_json_loader.py` to ensure proper loading of agents, tasks, and validation of project structures, including custom types and conditional tasks.
- Improved error handling and validation safety across the project loading process.

* Enhance JSON crew configuration options in create_json_crew.py

- Added optional fields for custom agent subclasses and advanced task options, including condition checks and output specifications.
- Improved documentation comments for better clarity on agent and task configurations.
- Updated JSON crew handling to support additional callbacks for pre- and post-execution processes.

* Enhance JSON crew template tests in test_create_crew.py

- Added assertions for new optional fields in crew and agent templates, including conditional tasks, custom converters, and input file specifications.
- Improved validation checks for manager agents and callback references to ensure proper configuration in JSON crew definitions.
- Expanded documentation references within the tests to provide clearer guidance on the expected structure and usage of crew templates.

* Fix JSON crew PR review issues
2026-06-16 02:00:19 -03:00
Vinicius Brasil
fe2c236601 Add each composite action to FlowDefinition (#6164)
Lets a definition loop over an array without writing Python. Each
iteration exposes `item` and prior steps `outputs`.

```yaml
do:
  call: each
  in: state.rows
  do:
    - normalize:
        call: tool
        ref: my_tools:NormalizeRowTool
        with: { row: "${ item }" }
    - lead_scoring:
        call: agent
        # ...
```
2026-06-15 21:44:33 -07:00
João Moura
53c2284484 Support ZIP deployment fallback and JSON crew project env runs (#6166)
* Update crewAI CLI with various enhancements and fixes

- Updated `create_json_crew.py` to require `crewai[tools]>=1.14.7`.
- Enhanced `git.py` with improved repository initialization, including automatic initial commit creation and exclusion patterns for initial commits.
- Modified `install_crew.py` to allow error handling during installation with an optional `raise_on_error` parameter.
- Expanded `plus_api.py` to include methods for creating and updating crews from ZIP files.
- Introduced a new `archive.py` for creating deployable ZIP archives of CrewAI projects, ensuring local artifacts are excluded.
- Updated `run_crew.py` to manage JSON crew dependencies and run crews in the project's environment.
- Enhanced deployment logic in `main.py` to handle ZIP uploads and improve user feedback during deployment processes.
- Added tests for new functionalities and ensured existing tests reflect recent changes in behavior and requirements.

* fix(cli): address deploy zip review feedback

* fix(cli): sync missing lockfile before deploy

* fix(cli): preserve remote deploy on git setup warnings

* test(cli): use single deploy main import style

* fix(cli): skip project install for json crew sync

* fix(cli): load json runner from source checkout

* fix(cli): skip json crew sync when locked

* fix(cli): address deploy zip review feedback

* fix(cli): pass env on zip redeploy

* fix(cli): harden json run and zip fallback

* fix(cli): validate before deploy lock install

* fix(cli): respect poetry lock for json runs

* fix(cli): align json zip wrapper detection

* fix(deps): bump starlette audit floor

* fix(cli): avoid auth retry for deploy exits

* fix(cli): update json zip script entrypoints
2026-06-15 18:46:54 -03:00
Lorenze Jay
a5cc6f6d0e Add crewai_version to flow execution telemetry (#6167)
Some checks failed
CodeQL Advanced / Analyze (python) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
2026-06-15 09:34:01 -07:00
João Moura
bb477f8a91 JSON first crews (#6131)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
* feat(cli): introduce JSON crew project support and TUI enhancements

- Added support for creating and running JSON-defined crew projects, allowing users to scaffold projects with a new `create_json_crew.py` file.
- Implemented a full-screen Textual TUI for crew execution in `crew_run_tui.py`, enhancing user interaction with a two-column layout.
- Updated `run_crew.py` to prioritize JSON crew projects and added daemon mode for running without TUI.
- Introduced interactive pickers in `tui_picker.py` for improved CLI prompts.
- Enhanced validation for JSON crew files in `validate.py` to ensure proper structure and agent definitions.
- Updated `.gitignore` to exclude demo and crewai directories.

* feat: update LLM model references to gpt-5.4-mini

- Changed default LLM model from gpt-4o-mini to gpt-5.4-mini across various files, including CLI options, JSON crew configurations, and agent definitions.
- Enhanced benchmark and human feedback functionalities to utilize the new model.
- Improved user interface elements in the TUI for better interaction and feedback during execution.
- Added support for new skills directory in JSON crew project creation.

* feat(benchmark): add crew-level benchmarking functionality

- Introduced a new `benchmark` command in the CLI for crew-level benchmarking, allowing users to specify agents, models, and timeout settings.
- Implemented `CrewBenchmarkCase` to handle crew-level benchmark cases with inputs and criteria.
- Enhanced the benchmark runner to support progress tracking and detailed reporting of results for multiple models.
- Added tests for loading crew benchmark cases and validating their structure.
- Updated existing benchmark functions to accommodate the new crew-level execution model.

* feat(cli): enhance JSON crew project functionality and TUI improvements

- Added optional agent-level guardrails and advanced options in JSON crew configurations to improve output validation and flexibility.
- Updated the TUI to better handle plan step statuses, including visual indicators for task completion and failure.
- Introduced methods for parsing and managing step observation events, ensuring accurate updates to task statuses during execution.
- Enhanced validation for JSON crew projects, ensuring proper structure and error handling for agent and task definitions.
- Added comprehensive tests for new features and validation logic, ensuring robustness in JSON crew project handling.

* refactor(cli): streamline JSON crew project handling and improve validation

- Refactored JSON crew project loading and validation logic to enhance clarity and maintainability.
- Introduced utility functions for finding JSON crew files, improving code reuse across modules.
- Removed deprecated benchmark functionality and associated tests to simplify the codebase.
- Updated CLI commands to utilize the new JSON project structure, ensuring compatibility with recent changes.
- Enhanced test coverage for JSON crew project features, ensuring robust validation and error handling.

* feat(cli): enhance activity log navigation and focus management

- Added functionality to focus on the activity log when navigating through log entries.
- Implemented refresh logic for the log panel to ensure updates are displayed correctly during navigation.
- Improved keyboard navigation for log entries, allowing users to expand and scroll through logs seamlessly.
- Added tests to verify the correct behavior of log navigation and focus management in the TUI.

* feat(cli): enhance JSON crew project interaction and input handling

- Introduced a new function to enable prompt line editing for better user experience during input prompts.
- Updated the JSON crew project wizards to show interpolation hints for dynamic values, improving user guidance.
- Enhanced the handling of missing input placeholders by prompting users for required values during crew setup.
- Refactored the crew run logic to ensure proper loading and preparation of JSON-defined crews, including runtime input management.
- Added tests to verify the correct behavior of new input handling features and JSON crew project interactions.

* feat(cli): improve crew project input prompts and event handling

- Enhanced the `_prompt_text` function to allow for configurable spacing before prompts, improving user experience during input collection.
- Updated the wizards for agent and task creation to utilize the new prompt configuration, ensuring a more compact and streamlined interaction.
- Introduced new plan step lifecycle events (`PlanStepStartedEvent`, `PlanStepCompletedEvent`) to better track the execution status of plan steps.
- Refactored the step executor to emit these events during the execution of tasks, improving observability and debugging capabilities.
- Added tests to verify the correct behavior of new prompt handling and event emissions during crew project execution.

* fix: refine json-first crew interactions

* fix: prioritize common json crew tools

* fix: make json crew more tools expandable

* fix: show json crew tools by category

* feat(memory): update default embedder to OpenAI text-embedding-3-large and enhance memory compatibility

- Changed the default embedding model for Memory to OpenAI text-embedding-3-large, which uses 3072-dimensional vectors.
- Added warnings regarding compatibility issues with existing local memory stores created with 1536-dimensional embeddings.
- Updated documentation to reflect the new default embedder and its configuration options.
- Enhanced the CLI and codebase to support the new embedding model across various components, ensuring a seamless transition for users.

* fix: address PR review feedback for JSON-first crews

Review blockers:
- Forward trained_agents_file to JSON crews: crewai run -f now exports
  CREWAI_TRAINED_AGENTS_FILE for the in-process JSON crew path
- Wizard agent picker: Esc/cancel now reprompts instead of silently
  assigning the first agent
- JSON tool resolution hard-fails: unknown tool names, missing custom
  tool files, and invalid custom tool modules raise JSONProjectError
  with actionable messages instead of warn-and-continue
- Embedding dimension mismatch: LanceDB and Qdrant Edge storages raise
  EmbeddingDimensionMismatchError with reset/pin guidance instead of
  silently zero-filling vectors or returning empty search results
- Custom tool code execution documented in loader docstring and the
  scaffolded project README

CI fixes:
- ruff format across lib/
- All 133 PR-introduced mypy errors fixed (llm.py lazy-litellm and
  cli.py lazy command shims now use TYPE_CHECKING imports; textual
  is_mounted misuse fixed; pick_many overloads; misc annotations)

Bot review comments:
- Empty except blocks now have explanatory comments or debug logging
- Removed unused _C_BG/_C_PANEL/_C_BORDER globals and redundant
  import re; tests use a single import style for create_json_crew

Tests: trained-agents propagation, wizard cancel, tool resolution
failures, and dimension mismatch guidance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address second round of PR review comments

Cursor Bugbot:
- Wizard agent slugs: strip to [a-z0-9_] and fall back to agent_<n> so
  symbol-only roles can't produce an empty agents/.jsonc filename
- Wizard task names: dedupe against prior task names and fall back to
  task_<n> for symbol-only descriptions

CodeRabbit:
- Agent.message(): import Task explicitly at runtime instead of relying
  on the namespace injection done by crewai/__init__
- Async executor: move the native-tools-unsupported fallback from
  _ainvoke_loop_react (self-recursion) to _ainvoke_loop_native_tools,
  mirroring the sync implementation
- StepExecutor downgrade: keep the in-step conversation and append the
  text-tooling instructions instead of rebuilding messages, so completed
  native tool calls are not re-executed
- crewai-files: extension-based MIME lookup now runs before byte
  sniffing so csv/xml types are not degraded to text/plain
- Memory storages: validate every record in a save() batch against a
  consistent embedding dimension (LanceDB previously checked only the
  first record); added mixed-batch tests
- _print_post_tui_summary now typed against CrewRunApp
- Docs: Azure OpenAI default embedder change called out in the memory
  migration warning and provider table

Code quality bots:
- Removed unused _C_YELLOW/_C_CYAN (crew_run_tui) and _GREEN (tui_picker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cli): accordion tool picker in JSON crew wizard

The flat tool list had grown to ~90 rows. The picker now shows:
- Common tools always visible at the top
- Every other category as a single expandable row with tool and
  selection counts (e.g. "Search & Research  (27 tools, 2 selected)")
- Expanding a category collapses the previously expanded one
- Selections persist across expand/collapse via new preselected
  support in pick_many; cursor follows the toggled category row

tui_picker gains preselected + initial_cursor options on pick_many,
and Esc in multi-select now confirms the current selection instead of
discarding it (required so collapsing can't silently drop choices).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(cli): remove --daemon flag from crewai run

The flag only affected JSON crew projects — classic and flow projects
ignored it entirely, which made the behavior inconsistent. Removed the
option, the daemon code path (_run_json_crew_daemon), and its helper
(_load_json_crew_with_inputs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: update run command tests after --daemon removal

lib/crewai/tests/cli/test_run_crew.py still asserted the old
run_crew(trained_agents_file=..., daemon=False) call signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): exit codes, mid-run quit, async statuses, hyphen placeholders

Addresses the latest Bugbot review round:

- Failed JSON crew runs now exit non-zero (SystemExit(1)) so scripts
  and CI don't treat failures as success, mirroring the classic path
- Quitting the TUI mid-run now ends the process (os._exit(130));
  kickoff runs in a thread worker that cannot be force-cancelled, so
  letting the CLI return would leave LLM/tool work burning tokens in
  the background
- Sidebar task statuses are now async-safe: completion/failure events
  resolve the task's own row via identity instead of assuming the most
  recently started task, and starting a task no longer blanket-marks
  earlier active rows as done
- The runtime-input prompt regex now accepts hyphenated placeholder
  names ({my-topic}), matching kickoff's interpolation pattern

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: validation safety, custom tool sandboxing, TUI log integrity, memory error surfacing

- Deploy validation no longer executes project code: validation mode
  checks tool declarations structurally (well-formed entries, custom
  tool file exists) without importing or instantiating anything.
  custom:<name> resolution only happens on the actual run path.
- custom:<name> is constrained to [A-Za-z_][A-Za-z0-9_]* and the
  resolved path must stay inside the project's tools/ directory, so
  custom:../foo or absolute-path names cannot execute code outside it.
  Tool paths resolve relative to the crew project root, not cwd.
- TUI task logs are built from per-task state captured at task start
  (idx, description, agent, start time); an out-of-order completion
  takes its output from the event and no longer steals or resets the
  current task's streamed steps/output.
- EmbeddingDimensionMismatchError now inherits ValueError instead of
  RuntimeError so background saves surface it through
  MemorySaveFailedEvent instead of silently dropping the save; the
  shutdown catch in _background_encode_batch is narrowed to the
  "cannot schedule new futures" case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): declared project type wins over crew.json presence

A flow project that also contains a crew.json(c) file now runs and
validates as the flow it declares in pyproject.toml instead of being
hijacked by the JSON crew path. Both crewai run (_has_json_crew) and
deploy validation (_is_json_crew) check tool.crewai.type; a missing or
unreadable pyproject still means a bare JSON crew project.

Also documents why StepObservationFailedEvent intentionally marks the
plan step "done": the event signals an observer failure, not a step
failure, and the executor continues past it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): type the declared_type locals so mypy stays clean

Comparing an Any-typed .get() chain returns Any, which tripped
no-any-return on the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 04:19:48 -03:00
106 changed files with 13627 additions and 460 deletions

2
.gitignore vendored
View File

@@ -31,3 +31,5 @@ chromadb-*.lock
blogs/*
secrets/*
UNKNOWN.egg-info/
demos/*
.crewai/*

View File

@@ -101,7 +101,7 @@ crew = Crew(
)
```
When `memory=True`, the crew creates a default `Memory()` and passes the crew's `embedder` configuration through automatically. All agents in the crew share the crew's memory unless an agent has its own.
When `memory=True`, the crew creates a default `Memory()` and passes the crew's `embedder` configuration through automatically. All agents in the crew share the crew's memory unless an agent has its own. Without a custom `embedder`, memory uses OpenAI `text-embedding-3-large` embeddings.
After each task, the crew automatically extracts discrete facts from the task output and stores them. Before each task, the agent recalls relevant context from memory and injects it into the task prompt.
@@ -515,7 +515,11 @@ memory = Memory(
## Embedder Configuration
Memory needs an embedding model to convert text into vectors for semantic search. You can configure this in three ways.
Memory needs an embedding model to convert text into vectors for semantic search. By default, `Memory()` uses OpenAI `text-embedding-3-large` embeddings, which produce 3072-dimensional vectors. Set `OPENAI_API_KEY` for the default path, or configure a custom embedder in one of three ways.
<Warning>
Existing local memory stores created with 1536-dimensional embeddings, such as `text-embedding-3-small` or `text-embedding-ada-002`, may not be compatible with the `text-embedding-3-large` default. This applies to both the OpenAI and Azure OpenAI providers — Azure's default embedding model also changed from `text-embedding-ada-002` to `text-embedding-3-large`. If local testing fails with an embedding dimension mismatch, reset memory with `crewai reset-memories -m`, delete the local memory storage directory, or explicitly configure the older embedder model until you migrate.
</Warning>
### Passing to Memory Directly
@@ -523,7 +527,7 @@ Memory needs an embedding model to convert text into vectors for semantic search
from crewai import Memory
# As a config dict
memory = Memory(embedder={"provider": "openai", "config": {"model_name": "text-embedding-3-small"}})
memory = Memory(embedder={"provider": "openai", "config": {"model_name": "text-embedding-3-large"}})
# As a pre-built callable
from crewai.rag.embeddings.factory import build_embedder
@@ -542,7 +546,7 @@ crew = Crew(
agents=[...],
tasks=[...],
memory=True,
embedder={"provider": "openai", "config": {"model_name": "text-embedding-3-small"}},
embedder={"provider": "openai", "config": {"model_name": "text-embedding-3-large"}},
)
```
@@ -554,7 +558,7 @@ crew = Crew(
memory = Memory(embedder={
"provider": "openai",
"config": {
"model_name": "text-embedding-3-small",
"model_name": "text-embedding-3-large",
# "api_key": "sk-...", # or set OPENAI_API_KEY env var
},
})
@@ -701,9 +705,9 @@ memory = Memory(embedder=my_embedder)
| Provider | Key | Typical Model | Notes |
| :--- | :--- | :--- | :--- |
| OpenAI | `openai` | `text-embedding-3-small` | Default. Set `OPENAI_API_KEY`. |
| OpenAI | `openai` | `text-embedding-3-large` | Default. Set `OPENAI_API_KEY`. |
| Ollama | `ollama` | `mxbai-embed-large` | Local, no API key needed. |
| Azure OpenAI | `azure` | `text-embedding-ada-002` | Requires `deployment_id`. |
| Azure OpenAI | `azure` | `text-embedding-3-large` | Default model. Requires `deployment_id`. |
| Google AI | `google-generativeai` | `gemini-embedding-001` | Set `GOOGLE_API_KEY`. |
| Google Vertex | `google-vertex` | `gemini-embedding-001` | Requires `project_id`. |
| Cohere | `cohere` | `embed-english-v3.0` | Strong multilingual support. |
@@ -836,6 +840,9 @@ class MemoryMonitor(BaseEventListener):
**Background save errors in logs?**
- Memory saves run in a background thread. Errors are emitted as `MemorySaveFailedEvent` but don't crash the agent. Check logs for the root cause (usually LLM or embedder connection issues).
**Embedding dimension mismatch?**
- Existing local memory stores may have been created with a different embedding model. The default OpenAI memory embedder is now `text-embedding-3-large` (3072 dimensions), while older stores commonly used 1536-dimensional embeddings. For local testing, run `crewai reset-memories -m`, delete the local memory storage directory, or configure the previous embedder model explicitly.
**Concurrent write conflicts?**
- LanceDB operations are serialized with a shared lock and retried automatically on conflict. This handles multiple `Memory` instances pointing at the same database (e.g. agent memory + crew memory). No action needed.
@@ -862,7 +869,7 @@ All configuration is passed as keyword arguments to `Memory(...)`. Every paramet
| :--- | :--- | :--- |
| `llm` | `"gpt-4o-mini"` | LLM for analysis (model name or `BaseLLM` instance). |
| `storage` | `"lancedb"` | Storage backend (`"lancedb"`, a path string, or a `StorageBackend` instance). |
| `embedder` | `None` (OpenAI default) | Embedder (config dict, callable, or `None` for default OpenAI). |
| `embedder` | `None` (OpenAI `text-embedding-3-large`) | Embedder (config dict, callable, or `None` for default OpenAI). |
| `recency_weight` | `0.3` | Weight for recency in composite score. |
| `semantic_weight` | `0.5` | Weight for semantic similarity in composite score. |
| `importance_weight` | `0.2` | Weight for importance in composite score. |

View File

@@ -141,7 +141,7 @@ crew = Crew(
process=Process.sequential, # or Process.hierarchical
memory=True,
cache=True,
embedder={"provider": "openai", "config": {"model": "text-embedding-3-small"}},
embedder={"provider": "openai", "config": {"model": "text-embedding-3-large"}},
)
```
@@ -173,7 +173,7 @@ write = Task(
### Memory & embedder config {#memory-embedder-config}
If `memory=True` and you're not using the default OpenAI embeddings, you must pass an `embedder`:
If `memory=True` and you're not using the default OpenAI `text-embedding-3-large` embeddings, you must pass an `embedder`:
```python
crew = Crew(
@@ -187,4 +187,4 @@ crew = Crew(
)
```
Set the relevant provider credentials (`OPENAI_API_KEY`, `OLLAMA_HOST`, etc.) in your `.env` file. Memory storage paths are project-local by default — delete the project's memory directory if you change embedders, since dimensions don't mix.
Set the relevant provider credentials (`OPENAI_API_KEY`, `OLLAMA_HOST`, etc.) in your `.env` file. Memory storage paths are project-local by default. Existing local memory stores created with 1536-dimensional embeddings may not be compatible with the default OpenAI `text-embedding-3-large` embedder, which uses 3072 dimensions. If you hit a dimension mismatch, delete the project's memory directory, run `crewai reset-memories -m`, or explicitly configure the older embedder model until you migrate.

View File

@@ -3,42 +3,94 @@ from __future__ import annotations
from importlib.metadata import version as get_version
import os
import subprocess
from typing import Any
from typing import TYPE_CHECKING, Any
import click
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_crew import create_crew
from crewai_cli.create_flow import create_flow
from crewai_cli.crew_chat import run_chat
from crewai_cli.deploy.main import DeployCommand
from crewai_cli.enterprise.main import EnterpriseConfigureCommand
from crewai_cli.evaluate_crew import evaluate_crew
from crewai_cli.experimental.skills.main import SkillCommand
from crewai_cli.install_crew import install_crew
from crewai_cli.kickoff_flow import kickoff_flow
from crewai_cli.organization.main import OrganizationCommand
from crewai_cli.plot_flow import plot_flow
from crewai_cli.remote_template.main import TemplateCommand
from crewai_cli.replay_from_task import replay_task_command
from crewai_cli.reset_memories_command import reset_memories_command
from crewai_cli.run_crew import run_crew
from crewai_cli.run_flow_definition import run_flow_definition
from crewai_cli.settings.main import SettingsCommand
from crewai_cli.task_outputs import load_task_outputs
from crewai_cli.tools.main import ToolCommand
from crewai_cli.train_crew import train_crew
from crewai_cli.triggers.main import TriggersCommand
from crewai_cli.update_crew import update_crew
from crewai_cli.user_data import (
_load_user_data,
is_tracing_enabled,
update_user_data,
)
from crewai_cli.utils import build_env_with_all_tool_credentials, read_toml
from crewai_cli.utils import (
build_env_with_all_tool_credentials,
enable_prompt_line_editing,
read_toml,
)
def train_crew(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.train_crew import train_crew as _train_crew
return _train_crew(*args, **kwargs)
def evaluate_crew(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.evaluate_crew import evaluate_crew as _evaluate_crew
return _evaluate_crew(*args, **kwargs)
def replay_task_command(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.replay_from_task import replay_task_command as _replay_task_command
return _replay_task_command(*args, **kwargs)
def run_flow_definition(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.run_flow_definition import (
run_flow_definition as _run_flow_definition,
)
return _run_flow_definition(*args, **kwargs)
def run_crew(*args: Any, **kwargs: Any) -> Any:
from crewai_cli.run_crew import run_crew as _run_crew
return _run_crew(*args, **kwargs)
if TYPE_CHECKING:
# mypy sees the real classes; at runtime the shims below defer the
# heavy imports until a command actually instantiates them.
from crewai_cli.authentication.main import AuthenticationCommand
from crewai_cli.deploy.main import DeployCommand
from crewai_cli.organization.main import OrganizationCommand
from crewai_cli.remote_template.main import TemplateCommand
else:
class AuthenticationCommand:
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
from crewai_cli.authentication.main import (
AuthenticationCommand as _AuthenticationCommand,
)
return _AuthenticationCommand(*args, **kwargs)
class DeployCommand:
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
from crewai_cli.deploy.main import DeployCommand as _DeployCommand
return _DeployCommand(*args, **kwargs)
class TemplateCommand:
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
from crewai_cli.remote_template.main import (
TemplateCommand as _TemplateCommand,
)
return _TemplateCommand(*args, **kwargs)
class OrganizationCommand:
def __new__(cls, *args: Any, **kwargs: Any) -> Any:
from crewai_cli.organization.main import (
OrganizationCommand as _OrganizationCommand,
)
return _OrganizationCommand(*args, **kwargs)
def _get_cli_version() -> str:
@@ -91,17 +143,57 @@ def uv(uv_args: tuple[str, ...]) -> None:
@crewai.command()
@click.argument("type", type=click.Choice(["crew", "flow"]))
@click.argument("name")
@click.argument(
"type", required=False, default=None, type=click.Choice(["crew", "flow"])
)
@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")
@click.option(
"--classic",
is_flag=True,
help="Use classic Python/YAML project structure instead of JSON",
)
def create(
type: str, name: str, provider: str | None, skip_provider: bool = False
type: str | None,
name: str | None,
provider: str | None,
skip_provider: bool = False,
classic: bool = False,
) -> None:
"""Create a new crew, or flow."""
if not type:
from crewai_cli.tui_picker import pick
options = [
("crew", "A team of AI agents working together"),
(
"flow",
"A deterministic workflow with full control over agents and crews",
),
]
type = pick("What would you like to create?", options)
if type is None:
raise SystemExit(0)
click.echo()
if not name:
enable_prompt_line_editing()
name = click.prompt(
click.style(f" Name of your {type}", fg="cyan", bold=True),
prompt_suffix=click.style(" ", fg="bright_white"), # noqa: RUF001
)
if type == "crew":
create_crew(name, provider, skip_provider)
if classic:
from crewai_cli.create_crew import create_crew
create_crew(name, provider, skip_provider)
else:
from crewai_cli.create_json_crew import create_json_crew
create_json_crew(name, provider, skip_provider)
elif type == "flow":
from crewai_cli.create_flow import create_flow
create_flow(name)
else:
click.secho("Error: Invalid type. Must be 'crew' or 'flow'.", fg="red")
@@ -186,6 +278,8 @@ def replay(task_id: str, trained_agents_file: str | None) -> None:
def log_tasks_outputs() -> None:
"""Retrieve your latest crew.kickoff() task outputs."""
try:
from crewai_cli.task_outputs import load_task_outputs
tasks = load_task_outputs()
if not tasks:
@@ -274,6 +368,8 @@ def reset_memories(
"Please specify at least one memory type to reset using the appropriate flags."
)
return
from crewai_cli.reset_memories_command import reset_memories_command
reset_memories_command(memory, knowledge, agent_knowledge, kickoff_outputs, all)
except Exception as e:
click.echo(f"An error occurred while resetting memories: {e}", err=True)
@@ -296,7 +392,7 @@ def reset_memories(
"--embedder-model",
type=str,
default=None,
help="Embedder model name (e.g. text-embedding-3-small, gemini-embedding-001).",
help="Embedder model name (e.g. text-embedding-3-large, gemini-embedding-001).",
)
@click.option(
"--embedder-config",
@@ -351,7 +447,7 @@ def memory(
"-m",
"--model",
type=str,
default="gpt-4o-mini",
default="gpt-5.4-mini",
help="LLM Model to run the tests on the Crew. For now only accepting only OpenAI models.",
)
@click.option(
@@ -382,6 +478,8 @@ def test(n_iterations: int, model: str, trained_agents_file: str | None) -> None
@click.pass_context
def install(context: click.Context) -> None:
"""Install the Crew."""
from crewai_cli.install_crew import install_crew
install_crew(context.args)
@@ -415,7 +513,9 @@ def install(context: click.Context) -> None:
help='Experimental: JSON object passed to flow.kickoff(), e.g. \'{"topic":"AI"}\'.',
)
def run(
trained_agents_file: str | None, definition: str | None, inputs: str | None
trained_agents_file: str | None,
definition: str | None,
inputs: str | None,
) -> None:
"""Run the Crew or Flow."""
if inputs is not None and definition is None:
@@ -435,6 +535,8 @@ def run(
@crewai.command()
def update() -> None:
"""Update the pyproject.toml of the Crew project to use uv."""
from crewai_cli.update_crew import update_crew
update_crew()
@@ -544,6 +646,8 @@ def tool() -> None:
@tool.command(name="create")
@click.argument("handle")
def tool_create(handle: str) -> None:
from crewai_cli.tools.main import ToolCommand
tool_cmd = ToolCommand()
tool_cmd.create(handle)
@@ -551,6 +655,8 @@ def tool_create(handle: str) -> None:
@tool.command(name="install")
@click.argument("handle")
def tool_install(handle: str) -> None:
from crewai_cli.tools.main import ToolCommand
tool_cmd = ToolCommand()
tool_cmd.login()
tool_cmd.install(handle)
@@ -567,6 +673,8 @@ def tool_install(handle: str) -> None:
@click.option("--public", "is_public", flag_value=True, default=False)
@click.option("--private", "is_public", flag_value=False)
def tool_publish(is_public: bool, force: bool) -> None:
from crewai_cli.tools.main import ToolCommand
tool_cmd = ToolCommand()
tool_cmd.login()
tool_cmd.publish(is_public, force)
@@ -599,6 +707,8 @@ def skill() -> None:
help="Create skill in current dir instead of ./skills/",
)
def skill_create(name: str, in_project: bool) -> None:
from crewai_cli.experimental.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.create(name, in_project=in_project)
@@ -606,6 +716,8 @@ def skill_create(name: str, in_project: bool) -> None:
@skill.command(name="install")
@click.argument("ref")
def skill_install(ref: str) -> None:
from crewai_cli.experimental.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.install(ref)
@@ -622,6 +734,8 @@ def skill_install(ref: str) -> None:
@click.option("--private", "is_public", flag_value=False)
@click.option("--org", default=None, help="Organisation slug (overrides settings).")
def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
from crewai_cli.experimental.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.publish(is_public, org=org, force=force)
@@ -629,6 +743,8 @@ def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
@skill.command(name="list")
def skill_list() -> None:
"""List locally installed skills."""
from crewai_cli.experimental.skills.main import SkillCommand
skill_cmd = SkillCommand()
skill_cmd.list_cached()
@@ -668,6 +784,8 @@ def flow() -> None:
@flow.command(name="kickoff")
def flow_run() -> None:
"""Kickoff the Flow."""
from crewai_cli.kickoff_flow import kickoff_flow
click.echo("Running the Flow")
kickoff_flow()
@@ -675,6 +793,8 @@ def flow_run() -> None:
@flow.command(name="plot")
def flow_plot() -> None:
"""Plot the Flow."""
from crewai_cli.plot_flow import plot_flow
click.echo("Plotting the Flow")
plot_flow()
@@ -683,6 +803,8 @@ def flow_plot() -> None:
@click.argument("crew_name")
def flow_add_crew(crew_name: str) -> None:
"""Add a crew to an existing flow."""
from crewai_cli.add_crew_to_flow import add_crew_to_flow
click.echo(f"Adding crew {crew_name} to the flow")
add_crew_to_flow(crew_name)
@@ -695,6 +817,8 @@ def triggers() -> None:
@triggers.command(name="list")
def triggers_list() -> None:
"""List all available triggers from integrations."""
from crewai_cli.triggers.main import TriggersCommand
triggers_cmd = TriggersCommand()
triggers_cmd.list_triggers()
@@ -703,6 +827,8 @@ def triggers_list() -> None:
@click.argument("trigger_path")
def triggers_run(trigger_path: str) -> None:
"""Execute crew with trigger payload. Format: app_slug/trigger_slug"""
from crewai_cli.triggers.main import TriggersCommand
triggers_cmd = TriggersCommand()
triggers_cmd.execute_with_trigger(trigger_path)
@@ -715,6 +841,8 @@ def chat() -> None:
click.secho(
"\nStarting a conversation with the Crew\nType 'exit' or Ctrl+C to quit.\n",
)
from crewai_cli.crew_chat import run_chat
run_chat()
@@ -754,6 +882,8 @@ def enterprise() -> None:
@click.argument("enterprise_url")
def enterprise_configure(enterprise_url: str) -> None:
"""Configure CrewAI AMP OAuth2 settings from the provided Enterprise URL."""
from crewai_cli.enterprise.main import EnterpriseConfigureCommand
enterprise_command = EnterpriseConfigureCommand()
enterprise_command.configure(enterprise_url)
@@ -766,6 +896,8 @@ def config() -> None:
@config.command("list")
def config_list() -> None:
"""List all CLI configuration parameters."""
from crewai_cli.settings.main import SettingsCommand
config_command = SettingsCommand()
config_command.list()
@@ -775,6 +907,8 @@ def config_list() -> None:
@click.argument("value")
def config_set(key: str, value: str) -> None:
"""Set a CLI configuration parameter."""
from crewai_cli.settings.main import SettingsCommand
config_command = SettingsCommand()
config_command.set(key, value)
@@ -782,6 +916,8 @@ def config_set(key: str, value: str) -> None:
@config.command("reset")
def config_reset() -> None:
"""Reset all CLI configuration parameters to default values."""
from crewai_cli.settings.main import SettingsCommand
config_command = SettingsCommand()
config_command.reset_all_settings()

View File

@@ -13,6 +13,10 @@ from crewai_cli.plus_api import PlusAPI
console = Console()
class AuthenticationRequiredError(SystemExit):
"""Raised when a Plus API command needs the user to log in first."""
class BaseCommand:
def __init__(self) -> None:
self._telemetry = Telemetry()
@@ -31,7 +35,7 @@ class PlusAPIMixin:
style="bold red",
)
console.print("Run 'crewai login' to sign up/login.", style="bold green")
raise SystemExit from None
raise AuthenticationRequiredError from None
def _validate_response(self, response: httpx.Response) -> None:
"""Handle and display error messages from API responses.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,409 @@
from __future__ import annotations
from pathlib import Path
import re
import shutil
import tempfile
from typing import Any
import zipfile
from crewai_cli import git
from crewai_cli.deploy.validate import normalize_package_name
from crewai_cli.utils import parse_toml
_EXCLUDED_DIRS = {
".crewai",
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".tox",
".venv",
"__pycache__",
"build",
"dist",
"env",
"venv",
}
_EXCLUDED_FILES = {
".DS_Store",
".env",
}
_ALLOWED_ENV_EXAMPLES = {
".env.example",
".env.sample",
}
_EXCLUDED_SUFFIXES = {
".pyc",
".pyo",
}
_SCRIPT_KEY_PATTERN = re.compile(r"^\s*(?P<key>[A-Za-z0-9_.-]+|\"[^\"]+\"|'[^']+')\s*=")
_SECTION_PATTERN = re.compile(r"^\s*\[[^\]]+\]\s*(?:#.*)?$")
def create_project_zip(
project_name: str,
*,
project_dir: Path | None = None,
repository: git.Repository | None = None,
) -> Path:
"""Create a deployable ZIP archive for a CrewAI project."""
root = (project_dir or Path.cwd()).resolve()
files = _project_files(root, repository)
if not files:
raise ValueError("No deployable project files were found.")
staged_root = _stage_project(root, files)
archive_handle = tempfile.NamedTemporaryFile(
prefix=f"{project_name}-",
suffix=".zip",
delete=False,
)
archive_path = Path(archive_handle.name)
archive_handle.close()
try:
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
for relative_path in _walk_files(staged_root):
absolute_path = staged_root / relative_path
zip_file.write(absolute_path, relative_path.as_posix())
finally:
shutil.rmtree(staged_root, ignore_errors=True)
return archive_path
def _project_files(root: Path, repository: git.Repository | None = None) -> list[Path]:
"""Return project-relative files to include in the archive."""
if repository is not None:
return _repository_project_files(root, repository)
try:
repository = git.Repository(path=str(root), fetch=False)
except ValueError:
repository = None
if repository is not None:
return _repository_project_files(root, repository)
return [
path
for path in _walk_files(root)
if not _is_excluded(path) and _is_regular_file(root / path)
]
def _repository_project_files(root: Path, repository: git.Repository) -> list[Path]:
"""Return deployable files from Git while applying local safety excludes."""
files = [Path(path) for path in repository.deployable_files()]
return [
path
for path in files
if not _is_excluded(path) and _is_regular_file(root / path)
]
def _walk_files(root: Path) -> list[Path]:
"""List regular files below root as project-relative paths."""
return [
path.relative_to(root) for path in root.rglob("*") if _is_regular_file(path)
]
def _is_regular_file(path: Path) -> bool:
"""Return True for regular files, excluding symlinks to files."""
return path.is_file() and not path.is_symlink()
def _is_excluded(path: Path) -> bool:
"""Return True when a file should be omitted from deployment ZIPs."""
parts = set(path.parts)
if parts.intersection(_EXCLUDED_DIRS):
return True
name = path.name
if name in _EXCLUDED_FILES:
return True
if name.startswith(".env.") and name not in _ALLOWED_ENV_EXAMPLES:
return True
return path.suffix in _EXCLUDED_SUFFIXES
def _stage_project(root: Path, files: list[Path]) -> Path:
"""Copy archive files into a temporary staging directory."""
staging_root = Path(tempfile.mkdtemp(prefix="crewai-deploy-"))
try:
for relative_path in files:
source = root / relative_path
if not _is_regular_file(source):
continue
destination = staging_root / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
if _is_json_crew_project(staging_root):
_add_json_crew_deploy_wrapper(staging_root)
except Exception:
shutil.rmtree(staging_root, ignore_errors=True)
raise
return staging_root
def _is_json_crew_project(root: Path) -> bool:
"""Return True for JSON crew projects that need a Python deploy wrapper."""
if not ((root / "crew.jsonc").is_file() or (root / "crew.json").is_file()):
return False
project = _read_pyproject(root)
tool_config = project.get("tool") or {}
crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None
declared_type = (
crewai_config.get("type") if isinstance(crewai_config, dict) else None
)
if declared_type == "flow":
return False
package_name = _package_name(root)
if package_name is None:
raise ValueError(
"Could not derive a valid Python package name from [project].name."
)
return not (root / "src" / package_name / "crew.py").is_file()
def _read_pyproject(root: Path) -> dict[str, Any]:
"""Read pyproject.toml, returning an empty mapping on missing or invalid data."""
pyproject_path = root / "pyproject.toml"
if not pyproject_path.is_file():
return {}
try:
pyproject = parse_toml(pyproject_path.read_text())
except Exception:
return {}
return pyproject if isinstance(pyproject, dict) else {}
def _package_name(root: Path) -> str | None:
"""Return the normalized Python package name for the project."""
project = _read_pyproject(root).get("project")
if not isinstance(project, dict):
return None
name = project.get("name")
if not isinstance(name, str) or not name.strip():
return None
package_name = normalize_package_name(name)
return package_name or None
def _class_name(package_name: str) -> str:
"""Return the generated wrapper class name for a package."""
parts = [part for part in re.split(r"[^a-zA-Z0-9]+", package_name) if part]
class_name = "".join(part[:1].upper() + part[1:] for part in parts)
if not class_name:
return "JsonCrew"
if class_name[0].isdigit():
return f"Crew{class_name}"
return class_name
def _add_json_crew_deploy_wrapper(root: Path) -> None:
"""Add Python wrapper files required to deploy a JSON crew project."""
package_name = _package_name(root)
if package_name is None:
raise ValueError(
"Could not derive a valid Python package name from [project].name."
)
package_dir = root / "src" / package_name
config_dir = package_dir / "config"
config_dir.mkdir(parents=True, exist_ok=True)
class_name = _class_name(package_name)
crew_filename = "crew.jsonc" if (root / "crew.jsonc").is_file() else "crew.json"
(package_dir / "__init__.py").write_text("", encoding="utf-8")
(config_dir / "agents.yaml").write_text("{}\n", encoding="utf-8")
(config_dir / "tasks.yaml").write_text("{}\n", encoding="utf-8")
(package_dir / "crew.py").write_text(
_json_crew_py(class_name, crew_filename),
encoding="utf-8",
)
(package_dir / "main.py").write_text(
_json_main_py(package_name, class_name),
encoding="utf-8",
)
_ensure_project_scripts(root, package_name)
def _json_crew_py(class_name: str, crew_filename: str) -> str:
"""Render the generated crew.py module for a JSON crew."""
return f'''from pathlib import Path
from crewai import Crew
from crewai.project import CrewBase, crew
from crewai.project.crew_loader import load_crew
def _crew_path() -> Path:
return Path(__file__).resolve().parents[2] / "{crew_filename}"
@CrewBase
class {class_name}:
"""Compatibility wrapper for a JSON-defined CrewAI project."""
@crew
def crew(self) -> Crew:
crew_instance, default_inputs = load_crew(_crew_path())
self.default_inputs = default_inputs
return crew_instance
'''
def _json_main_py(package_name: str, class_name: str) -> str:
"""Render the generated main.py entrypoints for a JSON crew."""
return f"""#!/usr/bin/env python
import json
import sys
from {package_name}.crew import {class_name}
def _load():
wrapper = {class_name}()
crew = wrapper.crew()
return crew, getattr(wrapper, "default_inputs", {{}})
def run():
crew, inputs = _load()
return crew.kickoff(inputs=inputs)
def train():
crew, inputs = _load()
return crew.train(
n_iterations=int(sys.argv[1]),
filename=sys.argv[2],
inputs=inputs,
)
def replay():
crew, _ = _load()
return crew.replay(task_id=sys.argv[1])
def test():
crew, inputs = _load()
return crew.test(
n_iterations=int(sys.argv[1]),
eval_llm=sys.argv[2],
inputs=inputs,
)
def run_with_trigger():
if len(sys.argv) < 2:
raise ValueError("No trigger payload provided.")
crew, inputs = _load()
trigger_payload = json.loads(sys.argv[1])
return crew.kickoff(
inputs={{**inputs, "crewai_trigger_payload": trigger_payload}}
)
"""
def _ensure_project_scripts(root: Path, package_name: str) -> None:
"""Ensure generated wrappers have project script entrypoints."""
pyproject_path = root / "pyproject.toml"
if not pyproject_path.is_file():
return
content = pyproject_path.read_text(encoding="utf-8")
entries = _project_script_entries(package_name)
pyproject_path.write_text(
_update_project_scripts(content, entries),
encoding="utf-8",
)
def _project_script_entries(package_name: str) -> dict[str, str]:
"""Return script entrypoints required by the generated JSON wrapper."""
return {
package_name: f"{package_name}.main:run",
"run_crew": f"{package_name}.main:run",
"train": f"{package_name}.main:train",
"replay": f"{package_name}.main:replay",
"test": f"{package_name}.main:test",
"run_with_trigger": f"{package_name}.main:run_with_trigger",
}
def _update_project_scripts(content: str, entries: dict[str, str]) -> str:
"""Add or replace generated script entries in pyproject.toml content."""
lines = content.rstrip().splitlines()
header_index = _project_scripts_header_index(lines)
if header_index is None:
return content.rstrip() + _project_scripts_block(entries)
end_index = _section_end_index(lines, header_index + 1)
seen: set[str] = set()
for index in range(header_index + 1, end_index):
key = _script_key(lines[index])
if key in entries:
lines[index] = _script_line(key, entries[key])
seen.add(key)
missing_lines = [
_script_line(key, value) for key, value in entries.items() if key not in seen
]
lines[end_index:end_index] = missing_lines
return "\n".join(lines).rstrip() + "\n"
def _project_scripts_header_index(lines: list[str]) -> int | None:
"""Return the line index of the project scripts table, if present."""
for index, line in enumerate(lines):
if line.strip() == "[project.scripts]":
return index
return None
def _section_end_index(lines: list[str], start_index: int) -> int:
"""Return the exclusive end index for a TOML table section."""
for index in range(start_index, len(lines)):
if _SECTION_PATTERN.match(lines[index]):
return index
return len(lines)
def _script_key(line: str) -> str | None:
"""Return the script key for a pyproject script line."""
match = _SCRIPT_KEY_PATTERN.match(line)
if not match:
return None
key = match.group("key")
if key.startswith(("'", '"')) and key.endswith(("'", '"')):
return key[1:-1]
return key
def _script_line(key: str, value: str) -> str:
"""Render a project script TOML entry."""
return f'{key} = "{value}"'
def _project_scripts_block(entries: dict[str, str]) -> str:
"""Render a project scripts TOML table."""
lines = ["", "", "[project.scripts]"]
lines.extend(_script_line(key, value) for key, value in entries.items())
return "\n".join(lines) + "\n"

View File

@@ -1,3 +1,5 @@
from pathlib import Path
import subprocess
from typing import Any
from crewai_core.plus_api import CreateCrewPayload
@@ -5,14 +7,19 @@ from rich.console import Console
from crewai_cli import git
from crewai_cli.command import BaseCommand, PlusAPIMixin
from crewai_cli.deploy.validate import validate_project
from crewai_cli.deploy.archive import create_project_zip
from crewai_cli.deploy.validate import DeployValidator, Severity, render_report
from crewai_cli.utils import fetch_and_json_env_file, get_project_name
console = Console()
_MISSING_LOCKFILE_ERROR_CODES = {"missing_lockfile"}
def _run_predeploy_validation(skip_validate: bool) -> bool:
def _run_predeploy_validation(
skip_validate: bool,
ignored_error_codes: set[str] | None = None,
) -> bool:
"""Run pre-deploy validation unless skipped.
Returns True if deployment should proceed, False if it should abort.
@@ -24,8 +31,22 @@ def _run_predeploy_validation(skip_validate: bool) -> bool:
return True
console.print("Running pre-deploy validation...", style="bold blue")
validator = validate_project()
if not validator.ok:
validator = DeployValidator()
validator.run()
ignored_error_codes = ignored_error_codes or set()
visible_results = [
result
for result in validator.results
if result.severity is not Severity.ERROR
or result.code not in ignored_error_codes
]
render_report(visible_results)
blocking_errors = [
result for result in validator.errors if result.code not in ignored_error_codes
]
if blocking_errors:
console.print(
"\n[bold red]Pre-deploy validation failed. "
"Fix the issues above or re-run with --skip-validate.[/bold red]"
@@ -34,6 +55,79 @@ def _run_predeploy_validation(skip_validate: bool) -> bool:
return True
def _display_git_repository_help() -> None:
"""Explain how to prepare a new project for deployment."""
console.print(
"Initialized a local Git repository and created an initial commit.",
style="green",
)
def _display_git_remote_help() -> None:
"""Explain that ZIP deployment will be used without an origin remote."""
console.print(
"No origin remote found. Deploying from a ZIP upload instead.",
style="yellow",
)
def _env_summary(env_vars: dict[str, str]) -> str:
"""Return a compact description of environment variables for prompts."""
if not env_vars:
return "0 env vars"
keys = ", ".join(sorted(env_vars))
return f"{len(env_vars)} env vars: {keys}"
def _needs_lockfile_for_deploy(project_root: Path | None = None) -> bool:
"""Return True when deploy should create the project's first lockfile."""
root = project_root or Path.cwd()
if not (root / "pyproject.toml").is_file():
return False
return not (root / "uv.lock").is_file() and not (root / "poetry.lock").is_file()
def _ensure_lockfile_for_deploy() -> None:
"""Create a uv lockfile before deploy when a project has not been run yet."""
if not _needs_lockfile_for_deploy():
return
from crewai_cli.install_crew import install_crew
console.print(
"No lockfile found. Installing dependencies before deployment...",
style="bold blue",
)
try:
install_crew([], raise_on_error=True)
except subprocess.CalledProcessError as e:
raise SystemExit(e.returncode) from e
except Exception as e:
raise SystemExit(1) from e
def _prepare_project_for_deploy(skip_validate: bool) -> bool:
"""Validate deploy inputs before creating a missing lockfile."""
if skip_validate:
_run_predeploy_validation(skip_validate)
_ensure_lockfile_for_deploy()
return True
needs_lockfile = _needs_lockfile_for_deploy()
ignored_error_codes = _MISSING_LOCKFILE_ERROR_CODES if needs_lockfile else None
if not _run_predeploy_validation(
skip_validate,
ignored_error_codes=ignored_error_codes,
):
return False
if not needs_lockfile:
return True
_ensure_lockfile_for_deploy()
return _run_predeploy_validation(skip_validate)
class DeployCommand(BaseCommand, PlusAPIMixin):
"""
A class to handle deployment-related operations for CrewAI projects.
@@ -92,14 +186,30 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
uuid (Optional[str]): The UUID of the crew to deploy.
skip_validate (bool): Skip pre-deploy validation checks.
"""
if not _run_predeploy_validation(skip_validate):
if not _prepare_project_for_deploy(skip_validate):
return
self._telemetry.start_deployment_span(uuid)
console.print("Starting deployment...", style="bold blue")
if uuid:
repository = self._prepare_git_repository()
remote_repo_url = repository.origin_url() if repository else None
if remote_repo_url and uuid:
response = self.plus_api_client.deploy_by_uuid(uuid)
elif self.project_name:
elif remote_repo_url and self.project_name:
response = self.plus_api_client.deploy_by_name(self.project_name)
elif uuid:
_display_git_remote_help()
env_vars = fetch_and_json_env_file()
response = self._update_crew_from_zip(uuid, repository, env_vars)
elif self.project_name:
_display_git_remote_help()
deployment_uuid = self._deployment_uuid_by_name()
env_vars = fetch_and_json_env_file()
response = self._update_crew_from_zip(
deployment_uuid,
repository,
env_vars,
)
else:
self._standard_no_param_error_message()
return
@@ -107,6 +217,19 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
self._validate_response(response)
self._display_deployment_info(response.json())
def _deployment_uuid_by_name(self) -> str:
"""Resolve the current project's deployment UUID by project name."""
if not self.project_name:
raise ValueError("project_name is required to find a deployment")
response = self.plus_api_client.crew_status_by_name(self.project_name)
self._validate_response(response)
json_response = response.json()
uuid = json_response.get("uuid")
if not uuid:
raise ValueError("Deployment status response did not include a uuid")
return str(uuid)
def create_crew(self, confirm: bool = False, skip_validate: bool = False) -> None:
"""
Create a new crew deployment.
@@ -115,32 +238,143 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
confirm (bool): Whether to skip the interactive confirmation prompt.
skip_validate (bool): Skip pre-deploy validation checks.
"""
if not _run_predeploy_validation(skip_validate):
if not _prepare_project_for_deploy(skip_validate):
return
self._telemetry.create_crew_deployment_span()
console.print("Creating deployment...", style="bold blue")
env_vars = fetch_and_json_env_file()
repository = self._prepare_git_repository()
remote_repo_url = repository.origin_url() if repository else None
try:
remote_repo_url = git.Repository().origin_url()
except ValueError:
remote_repo_url = None
if remote_repo_url is None:
console.print("No remote repository URL found.", style="bold red")
console.print(
"Please ensure your project has a valid remote repository.",
style="yellow",
)
return
self._confirm_input(env_vars, remote_repo_url, confirm)
payload = self._create_payload(env_vars, remote_repo_url)
response = self.plus_api_client.create_crew(payload)
if remote_repo_url:
self._confirm_input(env_vars, remote_repo_url, confirm)
payload = self._create_payload(env_vars, remote_repo_url)
response = self.plus_api_client.create_crew(payload)
else:
_display_git_remote_help()
response = self._create_crew_from_zip(env_vars, repository, confirm)
self._validate_response(response)
self._display_creation_success(response.json())
def _prepare_git_repository(self) -> git.Repository | None:
"""Prepare Git for deploy while preserving remote deploy when possible."""
try:
repository = git.Repository(fetch=False)
except ValueError as exc:
if "not a Git repository" not in str(exc):
console.print(
f"{exc} Continuing with ZIP deployment.",
style="yellow",
)
return None
try:
repository = git.Repository.initialize()
except Exception as init_error:
console.print(
"Git auto-setup did not complete. Continuing with ZIP deployment.",
style="yellow",
)
console.print(str(init_error), style="dim")
try:
return git.Repository(fetch=False)
except Exception as repository_error:
console.print(str(repository_error), style="dim")
return None
_display_git_repository_help()
return repository
remote_repo_url = repository.origin_url()
if remote_repo_url:
try:
repository.fetch()
except ValueError as fetch_error:
console.print(
"Could not fetch from origin. Continuing with remote deployment.",
style="yellow",
)
console.print(str(fetch_error), style="dim")
try:
if repository.create_initial_commit_if_needed():
console.print(
"Created an initial Git commit for this project.",
style="green",
)
except Exception as commit_error:
console.print(
"Could not create an initial Git commit. "
"Continuing with remote deployment.",
style="yellow",
)
console.print(str(commit_error), style="dim")
return repository
try:
if repository.create_initial_commit_if_needed():
console.print(
"Created an initial Git commit for this project.",
style="green",
)
except Exception as commit_error:
console.print(
"Could not create an initial Git commit. "
"Continuing with ZIP deployment using Git file listing.",
style="yellow",
)
console.print(str(commit_error), style="dim")
return repository
return repository
def _create_crew_from_zip(
self,
env_vars: dict[str, str],
repository: git.Repository | None,
confirm: bool,
) -> Any:
"""Create a deployment by uploading a project ZIP archive."""
if not self.project_name:
raise ValueError("project_name is required to create a ZIP deployment")
console.print("Preparing project ZIP...", style="bold blue")
zip_file_path = create_project_zip(self.project_name, repository=repository)
try:
self._confirm_zip_input(env_vars, confirm)
console.print("Uploading project ZIP...", style="bold blue")
return self.plus_api_client.create_crew_from_zip(
zip_file_path,
name=self.project_name,
env=env_vars,
)
finally:
zip_file_path.unlink(missing_ok=True)
def _update_crew_from_zip(
self,
uuid: str,
repository: git.Repository | None,
env_vars: dict[str, str],
) -> Any:
"""Update an existing deployment by uploading a project ZIP archive."""
if not self.project_name:
raise ValueError("project_name is required to update a ZIP deployment")
console.print("Preparing project ZIP...", style="bold blue")
zip_file_path = create_project_zip(self.project_name, repository=repository)
try:
console.print("Uploading project ZIP...", style="bold blue")
return self.plus_api_client.update_crew_from_zip(
uuid,
zip_file_path,
env=env_vars,
)
finally:
zip_file_path.unlink(missing_ok=True)
def _confirm_input(
self, env_vars: dict[str, str], remote_repo_url: str, confirm: bool
) -> None:
@@ -153,11 +387,16 @@ class DeployCommand(BaseCommand, PlusAPIMixin):
confirm (bool): Whether to confirm input.
"""
if not confirm:
input(f"Press Enter to continue with the following Env vars: {env_vars}")
input(f"Press Enter to continue with {_env_summary(env_vars)}")
input(
f"Press Enter to continue with the following remote repository: {remote_repo_url}\n"
)
def _confirm_zip_input(self, env_vars: dict[str, str], confirm: bool) -> None:
"""Prompt before ZIP upload unless confirmation was already supplied."""
if not confirm:
input(f"Press Enter to continue with {_env_summary(env_vars)}")
def _create_payload(
self,
env_vars: dict[str, str],

View File

@@ -38,6 +38,12 @@ import subprocess
import sys
from typing import Any
from crewai.project.json_loader import (
JSONProjectValidationError,
find_crew_json_file,
find_json_project_file,
validate_crew_project,
)
from rich.console import Console
from crewai_cli.utils import parse_toml
@@ -151,9 +157,33 @@ class DeployValidator:
def ok(self) -> bool:
return not self.errors
@property
def _is_json_crew(self) -> bool:
"""True for JSON crew projects, deferring to the declared type.
A flow project that also contains a crew.json(c) file validates as
the flow it declares in pyproject.toml, not as a JSON crew.
"""
if find_crew_json_file(self.project_root) is None:
return False
pyproject_path = self.project_root / "pyproject.toml"
if not pyproject_path.exists():
return True
try:
data = parse_toml(pyproject_path.read_text())
except Exception:
return True
declared_type: str | None = (
(data.get("tool") or {}).get("crewai", {}).get("type")
)
return declared_type != "flow"
def run(self) -> list[ValidationResult]:
"""Run all checks. Later checks are skipped when earlier ones make
them impossible (e.g. no pyproject.toml → no lockfile check)."""
if self._is_json_crew:
return self._run_json_checks()
if not self._check_pyproject():
return self.results
@@ -176,6 +206,110 @@ class DeployValidator:
return self.results
def _run_json_checks(self) -> list[ValidationResult]:
"""Validation suite for JSON-defined crew projects."""
crew_path = find_crew_json_file(self.project_root)
if crew_path is None:
return self.results
try:
project = validate_crew_project(crew_path, self.project_root / "agents")
except JSONProjectValidationError as e:
self._add(
Severity.ERROR,
"invalid_crew_json",
f"{crew_path.name} has invalid JSON crew configuration",
detail="\n".join(e.errors),
hint="Fix the JSON crew, agent, and task references before deploying.",
)
return self.results
except Exception as e:
self._add(
Severity.ERROR,
"invalid_crew_json",
f"Cannot parse {crew_path.name}",
detail=str(e),
)
return self.results
agents_dir = self.project_root / "agents"
self._check_pyproject()
self._check_lockfile()
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
self._check_version_vs_lockfile()
return self.results
def _check_env_vars_json(
self, crew_path: Path, agents_dir: Path, agent_names: list[str]
) -> None:
"""Check for env var references in JSON crew files."""
referenced: set[str] = set()
pattern = re.compile(r"\$\{?([A-Z][A-Z0-9_]+)\}?")
try:
referenced.update(pattern.findall(crew_path.read_text(errors="ignore")))
except OSError as exc:
logger.debug("Skipping unreadable crew file %s: %s", crew_path, exc)
for name in agent_names:
agent_path = find_json_project_file(agents_dir, name)
if agent_path is None:
continue
try:
referenced.update(
pattern.findall(agent_path.read_text(errors="ignore"))
)
except OSError as exc:
logger.debug("Skipping unreadable agent file %s: %s", agent_path, exc)
for py_path in self.project_root.rglob("*.py"):
if ".venv" in py_path.parts:
continue
try:
text = py_path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
env_pattern = re.compile(
r"""(?x)
(?:os\.environ\s*(?:\[\s*|\.get\s*\(\s*)
|os\.getenv\s*\(\s*
|getenv\s*\(\s*)
['"]([A-Z][A-Z0-9_]*)['"]
"""
)
referenced.update(env_pattern.findall(text))
env_file = self.project_root / ".env"
env_keys: set[str] = set()
if env_file.exists():
for line in env_file.read_text(errors="ignore").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
env_keys.add(line.split("=", 1)[0].strip())
missing_known = sorted(
var
for var in referenced
if var in _KNOWN_API_KEY_HINTS
and var not in env_keys
and var not in os.environ
)
if missing_known:
self._add(
Severity.WARNING,
"env_vars_not_in_dotenv",
f"{len(missing_known)} referenced API key(s) not in .env",
detail=(
"These env vars are referenced in your project but not set "
f"locally: {', '.join(missing_known)}. Deploys will fail "
"unless they are added to the deployment's Environment "
"Variables in the CrewAI dashboard."
),
)
def _check_pyproject(self) -> bool:
pyproject_path = self.project_root / "pyproject.toml"
if not pyproject_path.exists():

View File

@@ -1,9 +1,31 @@
from __future__ import annotations
from functools import cached_property
from pathlib import Path
import subprocess
_INITIAL_COMMIT_EXCLUDE_PATTERNS = [
".crewai/",
".env",
".env.*",
"!.env.example",
"!.env.sample",
".mypy_cache/",
".pytest_cache/",
".ruff_cache/",
".tox/",
".venv/",
"__pycache__/",
"build/",
"dist/",
"env/",
"venv/",
]
class Repository:
def __init__(self, path: str = ".") -> None:
def __init__(self, path: str = ".", fetch: bool = True) -> None:
self.path = path
if not self.is_git_installed():
@@ -12,7 +34,8 @@ class Repository:
if not self.is_git_repo:
raise ValueError(f"{self.path} is not a Git repository.")
self.fetch()
if fetch:
self.fetch()
@staticmethod
def is_git_installed() -> bool:
@@ -30,7 +53,33 @@ class Repository:
def fetch(self) -> None:
"""Fetch latest updates from the remote."""
subprocess.run(["git", "fetch"], cwd=self.path, check=True) # noqa: S607
command = ["git", "fetch"]
result = subprocess.run( # noqa: S603
command,
cwd=self.path,
capture_output=True,
text=True,
)
if result.returncode == 0:
return
if "No remote repository specified" in result.stderr:
return
details = result.stderr.strip() or result.stdout.strip() or "no output"
raise ValueError(
f"Git fetch failed with exit code {result.returncode} "
f"for command {command!r}: {details}"
)
@classmethod
def initialize(cls, path: str = ".") -> Repository:
"""Initialize a Git repository and create an initial commit if needed."""
if not cls.is_git_installed():
raise ValueError("Git is not installed or not found in your PATH.")
subprocess.run(["git", "init"], cwd=path, check=True) # noqa: S607
repository = cls(path=path, fetch=False)
repository.create_initial_commit_if_needed()
return repository
def status(self) -> str:
"""Get the git status in porcelain format."""
@@ -48,6 +97,7 @@ class Repository:
["git", "rev-parse", "--is-inside-work-tree"], # noqa: S607
cwd=self.path,
encoding="utf-8",
stderr=subprocess.DEVNULL,
)
return True
except subprocess.CalledProcessError:
@@ -70,6 +120,74 @@ class Repository:
return False
return True
def has_commits(self) -> bool:
"""Return True if the repository has at least one commit."""
try:
subprocess.run(
["git", "rev-parse", "--verify", "HEAD"], # noqa: S607
cwd=self.path,
capture_output=True,
check=True,
text=True,
)
return True
except subprocess.CalledProcessError:
return False
def create_initial_commit_if_needed(self) -> bool:
"""Create a local initial commit when the repository has no commits."""
if self.has_commits():
return False
self._ensure_initial_commit_excludes()
subprocess.run(["git", "add", "."], cwd=self.path, check=True) # noqa: S607
command = [
"git",
"-c",
"user.name=CrewAI",
"-c",
"user.email=deploy@crewai.com",
"commit",
"--allow-empty",
"-m",
"Initial crew",
]
subprocess.run( # noqa: S603
command,
cwd=self.path,
check=True,
)
return True
def _ensure_initial_commit_excludes(self) -> None:
"""Add local-only ignore patterns before auto-staging an initial commit."""
exclude_file = Path(self.path) / ".git" / "info" / "exclude"
exclude_file.parent.mkdir(parents=True, exist_ok=True)
existing = exclude_file.read_text() if exclude_file.exists() else ""
existing_lines = set(existing.splitlines())
missing_patterns = [
pattern
for pattern in _INITIAL_COMMIT_EXCLUDE_PATTERNS
if pattern not in existing_lines
]
if not missing_patterns:
return
prefix = "" if existing.endswith("\n") or not existing else "\n"
patterns = "\n".join(missing_patterns)
exclude_file.write_text(
f"{existing}{prefix}# CrewAI deploy auto-commit excludes\n{patterns}\n"
)
def deployable_files(self) -> list[str]:
"""Return files tracked by Git or untracked and not ignored."""
output = subprocess.check_output(
["git", "ls-files", "--cached", "--others", "--exclude-standard"], # noqa: S607
cwd=self.path,
encoding="utf-8",
)
return [line for line in output.splitlines() if line]
def origin_url(self) -> str | None:
"""Get the Git repository's remote URL."""
try:

View File

@@ -1,20 +1,77 @@
from pathlib import Path
import subprocess
import click
from crewai_cli.utils import build_env_with_all_tool_credentials
from crewai_cli.deploy.validate import normalize_package_name
from crewai_cli.utils import build_env_with_all_tool_credentials, parse_toml
def _find_json_crew_file(project_root: Path | None = None) -> Path | None:
"""Return the JSON crew definition path when present."""
root = project_root or Path.cwd()
for filename in ("crew.jsonc", "crew.json"):
crew_path = root / filename
if crew_path.is_file():
return crew_path
return None
def _is_json_crew_project(project_root: Path | None = None) -> bool:
"""Return True for JSON crew projects that do not need package install."""
root = project_root or Path.cwd()
if _find_json_crew_file(root) is None:
return False
pyproject_path = root / "pyproject.toml"
if not pyproject_path.is_file():
return True
try:
pyproject = parse_toml(pyproject_path.read_text())
except Exception:
return True
if not isinstance(pyproject, dict):
return True
tool_config = pyproject.get("tool") or {}
crewai_config = tool_config.get("crewai") if isinstance(tool_config, dict) else None
declared_type = (
crewai_config.get("type") if isinstance(crewai_config, dict) else None
)
project_config = pyproject.get("project") or {}
project_name = (
project_config.get("name") if isinstance(project_config, dict) else None
)
if isinstance(project_name, str):
package_name = normalize_package_name(project_name)
if package_name and (root / "src" / package_name / "crew.py").is_file():
return False
return declared_type != "flow"
# Be mindful about changing this.
# on some environments we don't use this command but instead uv sync directly
# so if you expect this to support more things you will need to replicate it there
# ask @joaomdmoura if you are unsure
def install_crew(proxy_options: list[str]) -> None:
def install_crew(
proxy_options: list[str],
*,
raise_on_error: bool = False,
install_project: bool | None = None,
) -> None:
"""
Install the crew by running the UV command to lock and install.
"""
try:
command = ["uv", "sync", *proxy_options]
if install_project is None:
install_project = not _is_json_crew_project()
command = ["uv", "sync"]
if not install_project and "--no-install-project" not in proxy_options:
command.append("--no-install-project")
command.extend(proxy_options)
# Inject tool repository credentials so uv can authenticate
# against private package indexes (e.g. crewai tool repository).
@@ -22,11 +79,21 @@ def install_crew(proxy_options: list[str]) -> None:
# project depends on tools from a private index.
env = build_env_with_all_tool_credentials()
subprocess.run(command, check=True, capture_output=False, text=True, env=env) # noqa: S603
subprocess.run( # noqa: S603
command,
check=True,
capture_output=False,
text=True,
env=env,
)
except subprocess.CalledProcessError as e:
click.echo(f"An error occurred while running the crew: {e}", err=True)
click.echo(e.output, err=True)
if raise_on_error:
raise
except Exception as e:
click.echo(f"An unexpected error occurred: {e}", err=True)
if raise_on_error:
raise

View File

@@ -1,12 +1,95 @@
"""Re-export of ``crewai_core.plus_api.PlusAPI``.
Kept as a stable import path for the CLI; new code should import from
``crewai_core.plus_api`` directly.
"""
"""CrewAI CLI API client extensions."""
from __future__ import annotations
from crewai_core.plus_api import PlusAPI as PlusAPI
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import urljoin
from crewai_core.plus_api import PlusAPI as _CorePlusAPI
import httpx
HttpMethod = Literal["GET", "POST", "PATCH", "DELETE"]
class PlusAPI(_CorePlusAPI):
"""CLI API client.
The ZIP deployment methods live here as well as in newer crewai-core
versions so editable CLI installs still work when an older crewai-core is
present in the runtime environment.
"""
def _make_multipart_request(
self,
method: HttpMethod,
endpoint: str,
*,
zip_file_path: str | Path,
data: dict[str, str] | None = None,
timeout: float | None = None,
verify: bool = True,
) -> httpx.Response:
"""Send an authenticated multipart request containing a project ZIP."""
url = urljoin(self.base_url, endpoint)
headers = dict(cast(dict[str, str], self.headers))
headers.pop("Content-Type", None)
path = Path(zip_file_path)
request_kwargs: dict[str, Any] = {"headers": headers}
if data is not None:
request_kwargs["data"] = data
if timeout is not None:
request_kwargs["timeout"] = timeout
with (
path.open("rb") as file_handle,
httpx.Client(trust_env=False, verify=verify) as client,
):
files = {
"zip_file": (path.name, file_handle, "application/zip"),
}
return client.request(method, url, files=files, **request_kwargs)
def create_crew_from_zip(
self,
zip_file_path: str | Path,
*,
name: str | None = None,
env: dict[str, str] | None = None,
) -> httpx.Response:
"""Create a crew deployment from a local project ZIP archive."""
data: dict[str, str] = {}
if name:
data["name"] = name
if env:
data.update({f"env[{key}]": value for key, value in env.items()})
return self._make_multipart_request(
"POST",
f"{self.CREWS_RESOURCE}/zip",
zip_file_path=zip_file_path,
data=data or None,
timeout=300,
)
def update_crew_from_zip(
self,
uuid: str,
zip_file_path: str | Path,
*,
env: dict[str, str] | None = None,
) -> httpx.Response:
"""Update an existing crew deployment from a local project ZIP archive."""
data: dict[str, str] = {}
if env:
data.update({f"env[{key}]": value for key, value in env.items()})
return self._make_multipart_request(
"POST",
f"{self.CREWS_RESOURCE}/{uuid}/zip_update",
zip_file_path=zip_file_path,
data=data or None,
timeout=300,
)
__all__ = ["PlusAPI"]

View File

@@ -1,25 +1,475 @@
from __future__ import annotations
from contextlib import AbstractContextManager, nullcontext
from enum import Enum
import os
from pathlib import Path
import re
import subprocess
import sys
from typing import TYPE_CHECKING, Any
import click
from crewai.project.json_loader import find_crew_json_file
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
from packaging import version
from crewai_cli.utils import build_env_with_all_tool_credentials, read_toml
from crewai_cli.utils import (
build_env_with_all_tool_credentials,
enable_prompt_line_editing,
read_toml,
)
from crewai_cli.version import get_crewai_version
if TYPE_CHECKING:
from crewai_cli.crew_run_tui import CrewRunApp
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.
# Must accept the same names as the kickoff interpolation pattern in
# crewai.utilities.string_utils (_VARIABLE_PATTERN), including hyphens —
# otherwise placeholders are interpolated at runtime but never prompted for.
_INPUT_PLACEHOLDER_RE = re.compile(r"(?<!{){([A-Za-z_][A-Za-z0-9_\-]*)}(?!})")
_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV = "CREWAI_CLI_RUNNER_PACKAGE_DIR"
_CREWAI_RUNNER_SOURCE_DIR_ENV = "CREWAI_RUNNER_SOURCE_DIR"
_JSON_CREW_RUNNER_CODE = """
import importlib.util
import os
from pathlib import Path
import sys
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.
source_dir = os.environ.get("CREWAI_RUNNER_SOURCE_DIR")
if source_dir:
sys.path.insert(0, source_dir)
package_dir = Path(os.environ["CREWAI_CLI_RUNNER_PACKAGE_DIR"])
package_spec = importlib.util.spec_from_file_location(
"crewai_cli",
package_dir / "__init__.py",
submodule_search_locations=[str(package_dir)],
)
if package_spec is None or package_spec.loader is None:
raise ImportError(f"Cannot load CrewAI CLI package from {package_dir}")
package = importlib.util.module_from_spec(package_spec)
sys.modules["crewai_cli"] = package
package_spec.loader.exec_module(package)
module_path = package_dir / "run_crew.py"
module_spec = importlib.util.spec_from_file_location("crewai_cli.run_crew", module_path)
if module_spec is None or module_spec.loader is None:
raise ImportError(f"Cannot load CrewAI CLI runner from {module_path}")
module = importlib.util.module_from_spec(module_spec)
sys.modules["crewai_cli.run_crew"] = module
module_spec.loader.exec_module(module)
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
module._run_json_crew(
trained_agents_file=os.getenv(CREWAI_TRAINED_AGENTS_FILE_ENV)
)
""".strip()
def _has_json_crew() -> bool:
"""Check if this is a JSON-defined crew project.
The project type declared in pyproject.toml wins: a flow project that
happens to contain a crew.json(c) file still runs as a flow. A missing
or unreadable pyproject means a bare JSON crew project.
"""
if find_crew_json_file() is None:
return False
try:
pyproject_data = read_toml()
except Exception:
return True
declared_type: str | None = (
pyproject_data.get("tool", {}).get("crewai", {}).get("type")
)
return declared_type != "flow"
def _extract_input_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return set(_INPUT_PLACEHOLDER_RE.findall(text))
def _missing_input_names(crew: Any, inputs: dict[str, Any]) -> list[str]:
"""Return input placeholders used by a crew but not provided as defaults."""
placeholders: set[str] = set()
for agent in getattr(crew, "agents", []) or []:
placeholders.update(_extract_input_placeholders(getattr(agent, "role", None)))
placeholders.update(_extract_input_placeholders(getattr(agent, "goal", None)))
placeholders.update(
_extract_input_placeholders(getattr(agent, "backstory", None))
)
for task in getattr(crew, "tasks", []) or []:
placeholders.update(
_extract_input_placeholders(getattr(task, "description", None))
)
placeholders.update(
_extract_input_placeholders(getattr(task, "expected_output", None))
)
placeholders.update(
_extract_input_placeholders(getattr(task, "output_file", None))
)
return sorted(name for name in placeholders if name not in inputs)
def _prompt_for_missing_inputs(
crew: Any, default_inputs: dict[str, Any]
) -> dict[str, Any]:
"""Ask for runtime values for placeholders that lack default inputs."""
inputs = dict(default_inputs or {})
missing = _missing_input_names(crew, inputs)
if not missing:
return inputs
enable_prompt_line_editing()
click.echo()
click.secho(" Runtime inputs", fg="cyan", bold=True)
click.secho(
" Values for {placeholder} references in your agents and tasks.",
dim=True,
)
for name in missing:
inputs[name] = click.prompt(
click.style(f" {name}", fg="cyan"),
prompt_suffix=click.style(" > ", fg="bright_white"),
)
return inputs
def _json_loading_status(message: str) -> AbstractContextManager[Any]:
from rich.console import Console
from rich.text import Text
console = Console()
if not console.is_terminal:
return nullcontext()
return console.status(
Text(f" {message}", style="bold #1F7982"),
spinner="dots",
)
def _load_json_crew(crew_path: Path) -> tuple[Any, dict[str, Any]]:
from crewai.project.crew_loader import load_crew
return load_crew(crew_path)
def _load_json_crew_for_tui(
crew_path: Path,
) -> tuple[type[Any], Any, dict[str, Any], list[str], list[str]]:
with _json_loading_status("Preparing crew..."):
from crewai_cli.crew_run_tui import CrewRunApp
crew, default_inputs = _load_json_crew(crew_path)
_prepare_json_crew_for_tui(crew)
task_names = [
getattr(task, "name", "") or getattr(task, "description", "")[:40] or "Task"
for task in crew.tasks
]
agent_names = [
getattr(agent, "role", "") or getattr(agent, "name", "") or "Agent"
for agent in crew.agents
]
return CrewRunApp, crew, default_inputs, task_names, agent_names
def _prepare_json_crew_for_tui(crew: Any) -> None:
"""Apply the same quiet/streaming setup used by the TUI JSON loader."""
crew.verbose = False
for agent in crew.agents:
agent.verbose = False
if hasattr(agent, "llm") and hasattr(agent.llm, "stream"):
agent.llm.stream = True
def _run_json_crew(trained_agents_file: str | None = None) -> Any:
"""Load and run a JSON-defined crew."""
from dotenv import load_dotenv
env_file = Path.cwd() / ".env"
if env_file.exists():
load_dotenv(env_file, override=True)
# JSON crews run in-process, so export the trained-agents file directly
# instead of forwarding it to a subprocess like classic crews do.
if trained_agents_file:
os.environ[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
crew_path = find_crew_json_file()
if crew_path is None:
raise FileNotFoundError("No crew.jsonc or crew.json found")
crew_run_app_cls, crew, default_inputs, task_names, agent_names = (
_load_json_crew_for_tui(crew_path)
)
runtime_inputs = _prompt_for_missing_inputs(crew, default_inputs)
app = crew_run_app_cls(
crew_name=crew.name or "Crew",
total_tasks=len(crew.tasks),
agent_names=agent_names,
task_names=task_names,
)
app._crew = crew
app._default_inputs = runtime_inputs
app.run()
_print_post_tui_summary(app)
if app._status == "failed":
# Mirror the classic subprocess path: a failed crew must produce a
# non-zero exit code so scripts and CI don't treat it as success.
raise SystemExit(1)
if app._status not in ("completed", "failed"):
# User quit mid-run. kickoff runs in a thread worker that cannot be
# force-cancelled, so end the process to stop in-flight LLM and tool
# work instead of letting it burn tokens in the background.
click.secho("\n Run cancelled.", fg="yellow")
sys.stdout.flush()
os._exit(130)
if getattr(app, "_want_deploy", False):
_chain_deploy()
return app._crew_result
def _has_lockfile(project_root: Path | None = None) -> bool:
"""Return True when the project already has a dependency lockfile."""
return _has_uv_lockfile(project_root) or _has_poetry_lockfile(project_root)
def _has_uv_lockfile(project_root: Path | None = None) -> bool:
"""Return True when the project has a uv lockfile."""
root = project_root or Path.cwd()
return (root / "uv.lock").is_file()
def _has_poetry_lockfile(project_root: Path | None = None) -> bool:
"""Return True when the project has a Poetry lockfile."""
root = project_root or Path.cwd()
return (root / "poetry.lock").is_file()
def _uses_poetry_lockfile(project_root: Path | None = None) -> bool:
"""Return True when Poetry is the only available lock source."""
return _has_poetry_lockfile(project_root) and not _has_uv_lockfile(project_root)
def _has_project_venv(project_root: Path | None = None) -> bool:
"""Return True when the project already has a local uv environment."""
root = project_root or Path.cwd()
return (root / ".venv").is_dir()
def _install_json_crew_dependencies_if_needed() -> None:
"""Prepare JSON crew dependencies without mutating existing lockfiles."""
project_root = Path.cwd()
if not (project_root / "pyproject.toml").is_file():
return
has_uv_lockfile = _has_uv_lockfile(project_root)
has_lockfile = has_uv_lockfile or _has_poetry_lockfile(project_root)
if has_lockfile and _has_project_venv(project_root):
return
if _uses_poetry_lockfile(project_root):
return
from crewai_cli.install_crew import install_crew
try:
if has_uv_lockfile:
click.echo("Syncing dependencies from lockfile...")
install_crew(["--frozen"], raise_on_error=True)
else:
click.echo("Installing dependencies...")
install_crew([], raise_on_error=True)
except subprocess.CalledProcessError as e:
raise SystemExit(e.returncode) from e
except Exception as e:
raise SystemExit(1) from e
def _find_local_crewai_source_dir() -> Path | None:
"""Return the repo's CrewAI source dir when running from a source checkout."""
for parent in Path(__file__).resolve().parents:
candidate = parent / "lib" / "crewai" / "src"
if (candidate / "crewai" / "project" / "json_loader.py").is_file():
return candidate
return None
def _json_crew_run_command(project_root: Path | None = None) -> list[str]:
"""Return the project-environment command for running JSON crews."""
if _uses_poetry_lockfile(project_root):
return ["poetry", "run", "python", "-c", _JSON_CREW_RUNNER_CODE]
return ["uv", "run", "--no-sync", "python", "-c", _JSON_CREW_RUNNER_CODE]
def _run_json_crew_in_project_env(trained_agents_file: str | None = None) -> Any:
"""Run JSON crews from the project's uv-managed environment."""
if not (Path.cwd() / "pyproject.toml").is_file():
return _run_json_crew(trained_agents_file=trained_agents_file)
_install_json_crew_dependencies_if_needed()
command = _json_crew_run_command()
env = build_env_with_all_tool_credentials()
env[_CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV] = str(Path(__file__).resolve().parent)
if local_crewai_source_dir := _find_local_crewai_source_dir():
env[_CREWAI_RUNNER_SOURCE_DIR_ENV] = str(local_crewai_source_dir)
if trained_agents_file:
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
try:
subprocess.run( # noqa: S603
command,
capture_output=False,
text=True,
check=True,
env=env,
)
except subprocess.CalledProcessError as e:
raise SystemExit(e.returncode) from e
except Exception as e:
click.echo(f"An unexpected error occurred while running the JSON crew: {e}")
raise SystemExit(1) from e
return None
def _chain_deploy() -> None:
from rich.console import Console
console = Console()
def print_system_exit_failure(exc: SystemExit) -> None:
if isinstance(exc.code, int):
detail = f" with exit code {exc.code}"
elif exc.code:
detail = f": {exc.code}"
else:
detail = ""
console.print(f"\nDeploy failed{detail}\n", style="bold red")
try:
from crewai_cli.command import AuthenticationRequiredError
from crewai_cli.deploy.main import DeployCommand
console.print("\nStarting deployment…\n", style="bold #FF5A50")
DeployCommand().create_crew(confirm=True, skip_validate=True)
except AuthenticationRequiredError:
from crewai_cli.authentication.main import AuthenticationCommand
console.print()
AuthenticationCommand().login()
try:
DeployCommand().create_crew(confirm=True, skip_validate=True)
except AuthenticationRequiredError:
console.print(
"\nDeploy failed: authentication is still required.\n",
style="bold red",
)
except SystemExit as e:
print_system_exit_failure(e)
except Exception as e:
console.print(f"\nDeploy failed: {e}\n", style="bold red")
except SystemExit as e:
print_system_exit_failure(e)
except Exception as e:
console.print(f"\nDeploy failed: {e}\n", style="bold red")
def _print_post_tui_summary(app: CrewRunApp) -> None:
"""Print a summary to the terminal after the Textual TUI exits."""
import time
from rich.console import Console
from rich.markdown import Markdown
from rich.padding import Padding
from rich.panel import Panel
from rich.text import Text
console = Console()
elapsed = time.time() - app._start_time
out_tokens = app._output_tokens + app._live_out_tokens
token_parts = []
if app._input_tokens:
token_parts.append(f"{app._input_tokens:,}")
if out_tokens:
token_parts.append(f"{out_tokens:,}")
token_str = " ".join(token_parts)
if token_str:
token_str += " tokens"
crewai_red = "#FF5A50"
crewai_teal = "#1F7982"
if app._status == "completed":
summary = Text()
summary.append(
f" ✔ Completed {app._total_tasks} tasks",
style=f"bold {crewai_teal}",
)
summary.append(f" in {elapsed:.1f}s", style="dim")
if token_str:
summary.append(f" {token_str}", style="dim")
console.print(
Panel(
summary,
title=f" {app._crew_name} ",
title_align="left",
border_style=crewai_teal,
padding=(0, 1),
)
)
if app._final_output:
console.print()
console.print(Text(" Final Result", style=f"bold {crewai_teal}"))
console.print()
console.print(Padding(Markdown(app._final_output), (0, 2)))
elif app._status == "failed":
content = Text()
content.append(" ✘ Failed", style=f"bold {crewai_red}")
content.append(f" after {elapsed:.1f}s\n", style="dim")
if app._error:
content.append(f"\n {app._error}\n", style=crewai_red)
console.print(
Panel(
content,
title=f" {app._crew_name} ",
title_align="left",
border_style=crewai_red,
padding=(0, 1),
)
)
def run_crew(trained_agents_file: str | None = None) -> None:
"""Run the crew or flow.
Args:
trained_agents_file: Optional path to a trained-agents pickle produced
@@ -27,6 +477,11 @@ 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``.
"""
# JSON crew projects take precedence
if _has_json_crew():
_run_json_crew_in_project_env(trained_agents_file=trained_agents_file)
return
crewai_version = get_crewai_version()
min_required_version = "0.71.0"
pyproject_data = read_toml()

View File

@@ -0,0 +1,419 @@
"""Arrow-key interactive pickers for CLI prompts."""
from __future__ import annotations
from contextlib import suppress
import sys
from typing import overload
import click
# CrewAI brand: primary=#FF5A50 (coral), teal=#1F7982
_CORAL = "\033[38;2;255;90;80m" # #FF5A50
_TEAL = "\033[38;2;31;121;130m" # #1F7982
_BOLD = "\033[1m"
_DIM = "\033[2m"
_RESET = "\033[0m"
_HIDE_CURSOR = "\033[?25l"
_SHOW_CURSOR = "\033[?25h"
def _is_interactive() -> bool:
try:
return sys.stdin.isatty() and sys.stdout.isatty()
except Exception:
return False
def _read_key() -> str:
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 _clear_lines(n: int) -> None:
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 _draw_single(labels: list[str], cursor: int, *, clear: bool = False) -> None:
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 {_CORAL}{_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],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
clear: bool = False,
) -> None:
action_indices = action_indices or set()
separator_indices = separator_indices or set()
hint_text = "↑↓ navigate, space toggle, enter confirm"
if action_indices:
hint_text = "↑↓ navigate, space toggle, enter confirm, ▸ rows expand/collapse"
hint = f" {_DIM}{hint_text}{_RESET}"
total = len(labels) + 1
if clear:
sys.stdout.write(f"\033[{total}A")
sys.stdout.write(f"\033[2K{hint}\n")
for i, label in enumerate(labels):
if i in separator_indices:
sys.stdout.write(f"\033[2K {_TEAL}{label}{_RESET}\n")
continue
if i in action_indices:
check = " "
elif i in selected:
check = f"{_CORAL}[x]{_RESET}"
else:
check = "[ ]"
arrow = f"{_CORAL}{_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 _arrow_select_one(labels: list[str]) -> int:
cursor = 0
total = len(labels)
sys.stdout.write(_HIDE_CURSOR)
sys.stdout.flush()
try:
_draw_single(labels, cursor)
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)
return cursor
elif key in ("esc", "q"):
_clear_lines(total)
return -1
finally:
sys.stdout.write(_SHOW_CURSOR)
sys.stdout.flush()
def _arrow_select_multi(
labels: list[str],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> tuple[list[int], int | None]:
total = len(labels)
selected: set[int] = set(preselected or ())
action_indices = action_indices or set()
separator_indices = separator_indices or set()
if initial_cursor is not None and 0 <= initial_cursor < total:
cursor = initial_cursor
else:
cursor = _first_selectable_index(total, separator_indices)
sys.stdout.write(_HIDE_CURSOR)
sys.stdout.flush()
try:
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
)
while True:
key = _read_key()
if key == "up":
cursor = _next_selectable_index(cursor, -1, total, separator_indices)
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
)
elif key == "down":
cursor = _next_selectable_index(cursor, 1, total, separator_indices)
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
)
elif key == "space":
if cursor in action_indices:
_clear_lines(total + 1)
return sorted(selected), cursor
selected ^= {cursor}
_draw_multi(
labels,
cursor,
selected,
action_indices=action_indices,
separator_indices=separator_indices,
clear=True,
)
elif key == "enter":
_clear_lines(total + 1)
if cursor in action_indices:
return sorted(selected), cursor
return sorted(selected), None
elif key in ("esc", "q"):
_clear_lines(total + 1)
return sorted(selected), None
finally:
sys.stdout.write(_SHOW_CURSOR)
sys.stdout.flush()
def _numbered_select(labels: list[str]) -> int:
for idx, label in enumerate(labels, 1):
click.echo(f" {idx}. {label}")
click.echo()
while True:
choice = click.prompt(" Select", type=str, default="1")
if choice.lower() == "q":
return -1
try:
num = int(choice)
if 1 <= num <= len(labels):
return num - 1
except ValueError:
# Non-numeric input falls through to the shared error message.
pass
click.secho(f" Invalid choice. Enter 1-{len(labels)}.", fg="red")
def _numbered_select_multi(
labels: list[str],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
) -> tuple[list[int], int | None]:
action_indices = action_indices or set()
separator_indices = separator_indices or set()
numbered_indices: list[int] = []
for idx, label in enumerate(labels):
if idx in separator_indices:
click.secho(f" {label}", fg="cyan")
continue
numbered_indices.append(idx)
click.echo(f" {len(numbered_indices)}. {label}")
click.echo()
raw = click.prompt(
" Select (comma-separated numbers, or empty to skip)",
default="",
show_default=False,
)
if not raw.strip():
return sorted(preselected or ()), None
indices: list[int] = list(preselected or ())
for part in raw.split(","):
with suppress(ValueError):
num = int(part.strip())
if 1 <= num <= len(numbered_indices):
idx = numbered_indices[num - 1]
if idx in action_indices:
return sorted(set(indices)), idx
indices.append(idx)
return sorted(set(indices)), None
def _first_selectable_index(total: int, separator_indices: set[int]) -> int:
for idx in range(total):
if idx not in separator_indices:
return idx
return 0
def _next_selectable_index(
cursor: int,
direction: int,
total: int,
separator_indices: set[int],
) -> int:
next_cursor = cursor + direction
while 0 <= next_cursor < total:
if next_cursor not in separator_indices:
return next_cursor
next_cursor += direction
return cursor
# ── Public API ──────────────────────────────────────────────────
def pick(title: str, options: list[tuple[str, str]]) -> str | None:
"""Arrow-key single-select picker.
Args:
title: Header text.
options: List of ``(value, description)`` tuples.
Returns:
The *value* of the selected option, or ``None`` if cancelled.
"""
labels = [f"{value:<12s} {desc}" for value, desc in options]
click.echo()
click.secho(f" {title}", fg="cyan", bold=True)
click.echo()
if _is_interactive():
try:
idx = _arrow_select_one(labels)
except Exception:
idx = _numbered_select(labels)
else:
idx = _numbered_select(labels)
if idx < 0:
return None
value, _desc = options[idx]
click.secho(f"{value}", fg="green")
return value
def pick_one(title: str, labels: list[str]) -> int:
"""Arrow-key single-select from plain labels.
Returns:
Selected index, or ``-1`` if cancelled.
"""
click.echo()
click.secho(f" {title}", fg="cyan")
if _is_interactive():
try:
return _arrow_select_one(labels)
except Exception:
return _numbered_select(labels)
return _numbered_select(labels)
@overload
def pick_many(
title: str,
labels: list[str],
*,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> list[int]: ...
@overload
def pick_many(
title: str,
labels: list[str],
*,
action_indices: set[int],
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> tuple[list[int], int | None]: ...
def pick_many(
title: str,
labels: list[str],
*,
action_indices: set[int] | None = None,
separator_indices: set[int] | None = None,
preselected: set[int] | None = None,
initial_cursor: int | None = None,
) -> list[int] | tuple[list[int], int | None]:
"""Arrow-key multi-select with checkboxes.
Returns:
Sorted list of selected indices, or ``(indices, action_index)`` when
``action_indices`` is provided.
"""
click.echo()
click.secho(f" {title}", fg="cyan")
if _is_interactive():
try:
selected, action = _arrow_select_multi(
labels,
action_indices=action_indices,
separator_indices=separator_indices,
preselected=preselected,
initial_cursor=initial_cursor,
)
except Exception:
selected, action = _numbered_select_multi(
labels,
action_indices=action_indices,
separator_indices=separator_indices,
preselected=preselected,
)
else:
selected, action = _numbered_select_multi(
labels,
action_indices=action_indices,
separator_indices=separator_indices,
preselected=preselected,
)
if action_indices is None:
return selected
return selected, action

View File

@@ -24,6 +24,7 @@ __all__ = [
"build_env_with_all_tool_credentials",
"build_env_with_tool_repository_credentials",
"copy_template",
"enable_prompt_line_editing",
"fetch_and_json_env_file",
"get_project_description",
"get_project_name",
@@ -40,6 +41,19 @@ __all__ = [
console = Console()
def enable_prompt_line_editing() -> None:
"""Enable cursor movement/history editing for Click text prompts when available."""
try:
import readline
except ImportError:
return
try:
readline.parse_and_bind("set editing-mode emacs")
except Exception: # pragma: no cover - readline backends vary by platform
return
def copy_template(
src: Path, dst: Path, name: str, class_name: str, folder_name: str
) -> None:

View File

@@ -0,0 +1,270 @@
from pathlib import Path
import subprocess
import zipfile
import pytest
from crewai_cli.deploy.archive import create_project_zip
def test_create_project_zip_excludes_local_artifacts(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.py").write_text("print('hello')\n")
(tmp_path / ".env").write_text("OPENAI_API_KEY=secret\n")
(tmp_path / ".env.example").write_text("OPENAI_API_KEY=\n")
(tmp_path / "__pycache__").mkdir()
(tmp_path / "__pycache__" / "main.pyc").write_bytes(b"compiled")
(tmp_path / ".git").mkdir()
(tmp_path / ".git" / "config").write_text("[core]\n")
archive_path = create_project_zip("demo", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {
"pyproject.toml",
"uv.lock",
"src/main.py",
".env.example",
}
def test_create_project_zip_uses_repository_file_list(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
(tmp_path / "ignored.txt").write_text("ignored\n")
class RepositoryStub:
def deployable_files(self) -> list[str]:
return ["pyproject.toml", "uv.lock"]
archive_path = create_project_zip(
"demo",
project_dir=tmp_path,
repository=RepositoryStub(), # type: ignore[arg-type]
)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {"pyproject.toml", "uv.lock"}
def test_create_project_zip_without_repository_uses_git_ignore_rules(
tmp_path: Path,
):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / ".gitignore").write_text("node_modules/\nsecret.txt\n")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.py").write_text("print('hello')\n")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "package.json").write_text("{}\n")
(tmp_path / "secret.txt").write_text("secret\n")
try:
subprocess.run(
["git", "init"],
cwd=tmp_path,
capture_output=True,
check=True,
text=True,
)
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
pytest.skip(f"git is not available in this environment: {exc}")
archive_path = create_project_zip("demo", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
archive_path.unlink(missing_ok=True)
assert names == {
".gitignore",
"pyproject.toml",
"src/main.py",
}
def test_create_project_zip_does_not_fallback_when_repository_listing_fails(
tmp_path: Path,
):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
class RepositoryStub:
def deployable_files(self) -> list[str]:
raise RuntimeError("git listing failed")
with pytest.raises(RuntimeError, match="git listing failed"):
create_project_zip(
"demo",
project_dir=tmp_path,
repository=RepositoryStub(), # type: ignore[arg-type]
)
def test_create_project_zip_excludes_symlinked_files(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
outside_file = tmp_path.parent / f"{tmp_path.name}-secret.txt"
outside_file.write_text("secret\n")
archive_path: Path | None = None
try:
try:
(tmp_path / "external-secret.txt").symlink_to(outside_file)
except OSError as exc:
pytest.skip(f"symlinks are not supported in this environment: {exc}")
archive_path = create_project_zip("demo", project_dir=tmp_path)
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
finally:
if archive_path is not None:
archive_path.unlink(missing_ok=True)
outside_file.unlink(missing_ok=True)
assert names == {"pyproject.toml"}
def test_create_project_zip_adds_json_project_wrapper(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
dependencies = ["crewai[tools]>=1.15"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.crewai]
type = "crew"
""".strip()
+ "\n"
)
(tmp_path / "agents").mkdir()
(tmp_path / "agents" / "researcher.jsonc").write_text("{}\n")
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
crew_py = archive.read("src/json_crew/crew.py").decode()
main_py = archive.read("src/json_crew/main.py").decode()
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert "uv.lock" not in names
assert "crew.jsonc" in names
assert "agents/researcher.jsonc" in names
assert "src/json_crew/__init__.py" in names
assert "src/json_crew/crew.py" in names
assert "src/json_crew/main.py" in names
assert "src/json_crew/config/agents.yaml" in names
assert "src/json_crew/config/tasks.yaml" in names
assert "load_crew(_crew_path())" in crew_py
assert "JsonCrew" in crew_py
assert "from json_crew.crew import JsonCrew" in main_py
assert "run_crew = \"json_crew.main:run\"" in pyproject
def test_create_project_zip_updates_existing_json_project_scripts(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
version = "0.1.0"
[project.scripts]
json_crew = "old.module:run"
run_crew = "old.module:run"
custom = "custom.module:main"
[tool.crewai]
type = "crew"
""".strip()
+ "\n"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert 'json_crew = "json_crew.main:run"' in pyproject
assert 'run_crew = "json_crew.main:run"' in pyproject
assert 'train = "json_crew.main:train"' in pyproject
assert 'replay = "json_crew.main:replay"' in pyproject
assert 'test = "json_crew.main:test"' in pyproject
assert 'run_with_trigger = "json_crew.main:run_with_trigger"' in pyproject
assert 'custom = "custom.module:main"' in pyproject
assert "old.module:run" not in pyproject
assert "[tool.crewai]" in pyproject
@pytest.mark.parametrize(
"tool_config",
[
'tool = "invalid"\n',
'[tool]\ncrewai = "invalid"\n',
],
)
def test_create_project_zip_adds_json_wrapper_for_malformed_tool_config(
tmp_path: Path, tool_config: str
):
(tmp_path / "pyproject.toml").write_text(
f"""
[project]
name = "json_crew"
version = "0.1.0"
{tool_config}
""".strip()
+ "\n"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
archive_path = create_project_zip("json_crew", project_dir=tmp_path)
try:
with zipfile.ZipFile(archive_path) as archive:
names = set(archive.namelist())
pyproject = archive.read("pyproject.toml").decode()
finally:
archive_path.unlink(missing_ok=True)
assert "src/json_crew/crew.py" in names
assert "src/json_crew/main.py" in names
assert "run_crew = \"json_crew.main:run\"" in pyproject
def test_create_project_zip_rejects_empty_normalized_package_name(tmp_path: Path):
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "!!!"
version = "0.1.0"
[tool.crewai]
type = "crew"
""".strip()
+ "\n"
)
(tmp_path / "crew.jsonc").write_text("{}\n")
with pytest.raises(
ValueError,
match=r"Could not derive a valid Python package name",
):
create_project_zip("invalid", project_dir=tmp_path)

View File

@@ -1,16 +1,172 @@
import sys
import unittest
from io import StringIO
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, Mock, patch
import pytest
import json
import crewai_cli.deploy.main as deploy_main
import httpx
from crewai_cli.deploy.main import DeployCommand
from crewai_cli.deploy.validate import Severity, ValidationResult
from crewai_cli.utils import parse_toml
def test_ensure_lockfile_for_deploy_runs_install_when_lock_missing(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
calls = []
def fake_install_crew(proxy_options, *, raise_on_error=False):
calls.append((proxy_options, raise_on_error))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
deploy_main._ensure_lockfile_for_deploy()
assert calls == [([], True)]
def test_ensure_lockfile_for_deploy_skips_when_lock_exists(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
calls = []
def fake_install_crew(proxy_options, *, raise_on_error=False):
calls.append((proxy_options, raise_on_error))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
deploy_main._ensure_lockfile_for_deploy()
assert calls == []
def test_ensure_lockfile_for_deploy_skips_without_pyproject(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
calls = []
def fake_install_crew(proxy_options, *, raise_on_error=False):
calls.append((proxy_options, raise_on_error))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
deploy_main._ensure_lockfile_for_deploy()
assert calls == []
def test_ensure_lockfile_for_deploy_failure_exits_nonzero(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
def fake_install_crew(proxy_options, *, raise_on_error=False):
raise subprocess.CalledProcessError(42, ["uv", "sync"])
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
with pytest.raises(SystemExit) as exc_info:
deploy_main._ensure_lockfile_for_deploy()
assert exc_info.value.code == 42
class _FakeDeployValidator:
def __init__(self, results: list[ValidationResult]):
self.results = results
@property
def errors(self) -> list[ValidationResult]:
return [
result
for result in self.results
if result.severity is Severity.ERROR
]
def run(self) -> list[ValidationResult]:
return self.results
def test_prepare_project_for_deploy_blocks_install_when_validation_fails(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
install_calls = []
rendered_results = []
missing_lockfile = ValidationResult(
Severity.ERROR,
"missing_lockfile",
"Expected to find a lockfile",
)
invalid_config = ValidationResult(
Severity.ERROR,
"invalid_crew_json",
"crew.jsonc has invalid configuration",
)
monkeypatch.setattr(
deploy_main,
"DeployValidator",
lambda: _FakeDeployValidator([missing_lockfile, invalid_config]),
)
monkeypatch.setattr(deploy_main, "render_report", rendered_results.append)
def fake_install_crew(proxy_options, *, raise_on_error=False):
install_calls.append((proxy_options, raise_on_error))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
assert deploy_main._prepare_project_for_deploy(skip_validate=False) is False
assert install_calls == []
assert [[result.code for result in results] for results in rendered_results] == [
["invalid_crew_json"]
]
def test_prepare_project_for_deploy_creates_missing_lock_after_validation(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
install_calls = []
missing_lockfile = ValidationResult(
Severity.ERROR,
"missing_lockfile",
"Expected to find a lockfile",
)
validators = [
_FakeDeployValidator([missing_lockfile]),
_FakeDeployValidator([]),
]
def fake_validator():
return validators.pop(0)
def fake_install_crew(proxy_options, *, raise_on_error=False):
install_calls.append((proxy_options, raise_on_error))
(tmp_path / "uv.lock").write_text("# lock\n")
monkeypatch.setattr(deploy_main, "DeployValidator", fake_validator)
monkeypatch.setattr(deploy_main, "render_report", lambda results: None)
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
assert deploy_main._prepare_project_for_deploy(skip_validate=False) is True
assert install_calls == [([], True)]
assert validators == []
class TestDeployCommand(unittest.TestCase):
@patch("crewai_cli.command.get_auth_token")
@patch("crewai_cli.deploy.main.get_project_name")
@@ -28,19 +184,25 @@ class TestDeployCommand(unittest.TestCase):
self.mock_get_auth_token.return_value = "test_token"
self.mock_get_project_name.return_value = "test_project"
self.deploy_command = DeployCommand()
self.deploy_command = deploy_main.DeployCommand()
self.mock_client = self.deploy_command.plus_api_client
def test_init_success(self):
self.assertEqual(self.deploy_command.project_name, "test_project")
self.mock_plus_api.assert_called_once_with(api_key="test_token")
@patch("builtins.input")
def test_confirm_zip_input_only_confirms_env_vars(self, mock_input):
self.deploy_command._confirm_zip_input({"MODEL": "openai/gpt-5"}, False)
mock_input.assert_called_once_with("Press Enter to continue with 1 env vars: MODEL")
@patch("crewai_cli.command.get_auth_token")
def test_init_failure(self, mock_get_auth_token):
mock_get_auth_token.side_effect = Exception("Auth failed")
with self.assertRaises(SystemExit):
DeployCommand()
deploy_main.DeployCommand()
def test_validate_response_successful_response(self):
mock_response = Mock(spec=httpx.Response)
@@ -123,8 +285,15 @@ class TestDeployCommand(unittest.TestCase):
)
self.assertIn("2023-01-01 - INFO: Test log", fake_out.getvalue())
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_uuid(self, mock_display):
def test_deploy_with_uuid(self, mock_display, mock_repository):
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
@@ -135,8 +304,15 @@ class TestDeployCommand(unittest.TestCase):
self.mock_client.deploy_by_uuid.assert_called_once_with("test-uuid")
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_project_name(self, mock_display):
def test_deploy_with_project_name(self, mock_display, mock_repository):
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
@@ -147,16 +323,142 @@ class TestDeployCommand(unittest.TestCase):
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_remote_keeps_remote_path_when_fetch_fails(
self, mock_display, mock_repository, mock_create_project_zip
):
repository = mock_repository.return_value
repository.origin_url.return_value = "https://github.com/test/repo.git"
repository.fetch.side_effect = ValueError("fetch failed")
repository.create_initial_commit_if_needed.return_value = False
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_name.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.deploy(skip_validate=True)
output = fake_out.getvalue()
mock_repository.assert_called_once_with(fetch=False)
repository.fetch.assert_called_once_with()
self.assertIn("Continuing with remote deployment", output)
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
self.mock_client.update_crew_from_zip.assert_not_called()
mock_create_project_zip.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_remote_keeps_remote_path_when_initial_commit_fails(
self, mock_display, mock_repository, mock_create_project_zip
):
repository = mock_repository.return_value
repository.origin_url.return_value = "https://github.com/test/repo.git"
repository.create_initial_commit_if_needed.side_effect = RuntimeError(
"commit failed"
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_name.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.deploy(skip_validate=True)
output = fake_out.getvalue()
mock_repository.assert_called_once_with(fetch=False)
repository.fetch.assert_called_once_with()
self.assertIn("Continuing with remote deployment", output)
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
self.mock_client.update_crew_from_zip.assert_not_called()
mock_create_project_zip.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository.origin_url")
@patch("builtins.input")
def test_create_crew(self, mock_input, mock_git_origin_url, mock_fetch_env):
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_uuid_without_remote_updates_from_zip(
self, mock_display, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_git_origin_url.return_value = "https://github.com/test/repo.git"
mock_repository.return_value.origin_url.return_value = None
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.update_crew_from_zip.return_value = mock_response
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.mock_client.update_crew_from_zip.assert_called_once_with(
"test-uuid",
Path("/tmp/test_project.zip"),
env={"ENV_VAR": "value"},
)
self.mock_client.deploy_by_uuid.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_project_name_without_remote_updates_from_zip(
self, mock_display, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = None
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
status_response = MagicMock()
status_response.status_code = 200
status_response.is_success = True
status_response.json.return_value = {"uuid": "test-uuid"}
update_response = MagicMock()
update_response.status_code = 200
update_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.crew_status_by_name.return_value = status_response
self.mock_client.update_crew_from_zip.return_value = update_response
self.deploy_command.deploy(skip_validate=True)
self.mock_client.crew_status_by_name.assert_called_once_with("test_project")
self.mock_client.update_crew_from_zip.assert_called_once_with(
"test-uuid",
Path("/tmp/test_project.zip"),
env={"ENV_VAR": "value"},
)
self.mock_client.deploy_by_name.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
@pytest.mark.timeout(180)
def test_create_crew(self, mock_input, mock_repository, mock_fetch_env):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_input.return_value = ""
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "new-uuid", "status": "created"}
self.mock_client.create_crew.return_value = mock_response
@@ -165,6 +467,129 @@ class TestDeployCommand(unittest.TestCase):
self.assertIn("Deployment created successfully!", fake_out.getvalue())
self.assertIn("new-uuid", fake_out.getvalue())
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_crew_without_git_repo_initializes_and_uses_zip(
self, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.side_effect = ValueError("not a Git repository")
initialized_repository = MagicMock()
initialized_repository.origin_url.return_value = None
mock_repository.initialize.return_value = initialized_repository
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
self.mock_client.create_crew_from_zip.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew(confirm=True, skip_validate=True)
output = fake_out.getvalue()
self.assertIn("Initialized a local Git repository", output)
self.assertIn("Deploying from a ZIP upload", output)
mock_repository.initialize.assert_called_once_with()
mock_create_project_zip.assert_called_once_with(
"test_project", repository=initialized_repository
)
self.mock_client.create_crew_from_zip.assert_called_once_with(
Path("/tmp/test_project.zip"),
name="test_project",
env={"ENV_VAR": "value"},
)
self.mock_client.create_crew.assert_not_called()
@patch("crewai_cli.deploy.main.git.Repository")
def test_prepare_git_repository_returns_repo_when_init_commit_fails(
self, mock_repository
):
recovered_repository = MagicMock()
mock_repository.side_effect = [
ValueError("not a Git repository"),
recovered_repository,
]
mock_repository.initialize.side_effect = RuntimeError("commit failed")
with patch("sys.stdout", new=StringIO()) as fake_out:
repository = self.deploy_command._prepare_git_repository()
self.assertIs(repository, recovered_repository)
self.assertIn("Git auto-setup did not complete", fake_out.getvalue())
mock_repository.initialize.assert_called_once_with()
self.assertEqual(mock_repository.call_count, 2)
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_crew_without_remote_uses_zip(
self, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = None
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
self.mock_client.create_crew_from_zip.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew(confirm=True, skip_validate=True)
output = fake_out.getvalue()
self.assertIn("No origin remote found.", output)
self.assertIn("Deploying from a ZIP upload", output)
mock_create_project_zip.assert_called_once_with(
"test_project", repository=mock_repository.return_value
)
self.mock_client.create_crew_from_zip.assert_called_once_with(
Path("/tmp/test_project.zip"),
name="test_project",
env={"ENV_VAR": "value"},
)
self.mock_client.create_crew.assert_not_called()
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_crew_without_remote_uses_git_file_list_when_commit_fails(
self, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
repository = mock_repository.return_value
repository.origin_url.return_value = None
repository.create_initial_commit_if_needed.side_effect = RuntimeError(
"commit failed"
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
self.mock_client.create_crew_from_zip.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew(confirm=True, skip_validate=True)
output = fake_out.getvalue()
self.assertIn("Continuing with ZIP deployment using Git", output)
self.assertIn("file listing", output)
mock_create_project_zip.assert_called_once_with(
"test_project", repository=repository
)
self.mock_client.create_crew_from_zip.assert_called_once_with(
Path("/tmp/test_project.zip"),
name="test_project",
env={"ENV_VAR": "value"},
)
self.mock_client.create_crew.assert_not_called()
def test_list_crews(self):
mock_response = MagicMock()
mock_response.status_code = 200

View File

@@ -110,6 +110,45 @@ def _run_without_import_check(root: Path) -> DeployValidator:
return v
def _scaffold_json_crew(root: Path, *, task_agent: str = "researcher") -> None:
(root / "pyproject.toml").write_text(_make_pyproject(name="json_crew"))
(root / "uv.lock").write_text("# dummy uv lockfile\n")
agents_dir = root / "agents"
agents_dir.mkdir()
(agents_dir / "researcher.jsonc").write_text(
dedent(
"""
{
"role": "Researcher",
"goal": "Research things",
"backstory": "Experienced researcher",
"llm": "openai/gpt-4o-mini"
}
"""
).strip()
+ "\n"
)
(root / "crew.jsonc").write_text(
dedent(
f"""
{{
"name": "json_crew",
"agents": ["researcher"],
"tasks": [
{{
"name": "research",
"description": "Research https://example.com/a//b",
"expected_output": "Findings",
"agent": "{task_agent}"
}}
]
}}
"""
).strip()
+ "\n"
)
@pytest.mark.parametrize(
"project_name, expected",
[
@@ -129,6 +168,38 @@ def test_valid_standard_crew_project_passes(tmp_path: Path) -> None:
assert v.ok, f"expected clean run, got {v.results}"
def test_valid_json_crew_project_passes(tmp_path: Path) -> None:
_scaffold_json_crew(tmp_path)
v = DeployValidator(project_root=tmp_path)
v.run()
assert "invalid_crew_json" not in _codes(v)
def test_json_task_agent_mismatch_is_error(tmp_path: Path) -> None:
_scaffold_json_crew(tmp_path, task_agent="missing_agent")
v = DeployValidator(project_root=tmp_path)
v.run()
finding = next(r for r in v.results if r.code == "invalid_crew_json")
assert finding.severity is Severity.ERROR
assert "missing_agent" in finding.detail
def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
_scaffold_json_crew(tmp_path)
crew_path = tmp_path / "crew.jsonc"
crew_path.write_text(
crew_path.read_text().replace(
'"name": "json_crew",',
'"name": "json_crew",\n "id": "00000000-0000-4000-8000-000000000000",',
)
)
v = DeployValidator(project_root=tmp_path)
v.run()
finding = next(r for r in v.results if r.code == "invalid_crew_json")
assert finding.severity is Severity.ERROR
assert "runtime-only" in finding.detail
def test_missing_pyproject_errors(tmp_path: Path) -> None:
v = _run_without_import_check(tmp_path)
assert "missing_pyproject" in _codes(v)
@@ -410,7 +481,7 @@ def test_modern_crewai_pin_does_not_warn(tmp_path: Path) -> None:
def test_create_crew_aborts_on_validation_error(tmp_path: Path) -> None:
"""`crewai deploy create` must not contact the API when validation fails."""
from unittest.mock import MagicMock, patch as mock_patch
from unittest.mock import patch as mock_patch
from crewai_cli.deploy.main import DeployCommand
@@ -419,11 +490,38 @@ def test_create_crew_aborts_on_validation_error(tmp_path: Path) -> None:
mock_patch("crewai_cli.deploy.main.get_project_name", return_value="p"),
mock_patch("crewai_cli.command.PlusAPI") as mock_api,
mock_patch(
"crewai_cli.deploy.main.validate_project"
) as mock_validate,
"crewai_cli.deploy.main._prepare_project_for_deploy",
return_value=False,
),
):
mock_validate.return_value = MagicMock(ok=False)
cmd = DeployCommand()
cmd.create_crew()
assert not cmd.plus_api_client.create_crew.called
del mock_api # silence unused-var lint
del mock_api # silence unused-var lint
def test_is_json_crew_defers_to_declared_flow_type(tmp_path):
"""A flow project with a stray crew.jsonc must validate as a flow."""
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "demo"\nversion = "0.1.0"\n\n'
'[tool.crewai]\ntype = "flow"\n'
)
assert DeployValidator(project_root=tmp_path)._is_json_crew is False
def test_is_json_crew_true_for_declared_crew_type(tmp_path):
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "demo"\nversion = "0.1.0"\n\n'
'[tool.crewai]\ntype = "crew"\n'
)
assert DeployValidator(project_root=tmp_path)._is_json_crew is True
def test_is_json_crew_true_without_pyproject(tmp_path):
(tmp_path / "crew.jsonc").write_text("{}")
assert DeployValidator(project_root=tmp_path)._is_json_crew is True

View File

@@ -94,9 +94,9 @@ def test_version_command_with_tools(runner):
def test_test_default_iterations(evaluate_crew, runner):
result = runner.invoke(test)
evaluate_crew.assert_called_once_with(3, "gpt-4o-mini", trained_agents_file=None)
evaluate_crew.assert_called_once_with(3, "gpt-5.4-mini", trained_agents_file=None)
assert result.exit_code == 0
assert "Testing the crew for 3 iterations with model gpt-4o-mini" in result.output
assert "Testing the crew for 3 iterations with model gpt-5.4-mini" in result.output
@mock.patch("crewai_cli.cli.evaluate_crew")

View File

@@ -6,6 +6,8 @@ from unittest import mock
import pytest
from click.testing import CliRunner
import crewai_cli.create_json_crew as json_crew
import crewai_cli.tui_picker as tui_picker
from crewai_cli.create_crew import create_crew, create_folder_structure
@@ -345,3 +347,468 @@ def test_env_vars_are_uppercased_in_env_file(
env_file_path = crew_path / ".env"
content = env_file_path.read_text()
assert "MODEL=" in content
def test_json_wizard_defaults_to_sequential_and_memory_enabled(monkeypatch):
monkeypatch.setattr(
json_crew,
"_wizard_agent",
lambda **_: {
"name": "researcher",
"role": "Researcher",
"goal": "Research",
"backstory": "Researcher",
"llm": "openai/gpt-5.5",
"tools": [],
"planning": False,
"allow_delegation": False,
},
)
monkeypatch.setattr(
json_crew,
"_wizard_task",
lambda **_: {
"name": "research_task",
"description": "Research",
"expected_output": "Findings",
"agent": "researcher",
"context": [],
},
)
def confirm(label: str, default: bool = False) -> bool:
if label == "Enable crew memory?":
return default
return False
monkeypatch.setattr(json_crew, "_confirm", confirm)
monkeypatch.setattr(json_crew.click, "prompt", lambda *_, **__: "")
monkeypatch.setattr(
json_crew,
"pick_one",
lambda *_args, **_kwargs: pytest.fail("process should not be prompted"),
)
_agents, _tasks, settings = json_crew._wizard_agents_and_tasks(
skip_provider=True,
default_llm="openai/gpt-5.5",
)
assert settings == {"process": "sequential", "memory": True, "inputs": {}}
def test_json_wizard_shows_interpolation_hint(capsys):
json_crew._show_interpolation_hint("tasks")
output = capsys.readouterr().out
assert "{placeholder}" in output
assert "dynamic values" in output
assert "{topic}" not in output
assert "Description >" not in output
assert '"description"' not in output
def test_json_wizard_text_prompt_uses_full_prompt_for_readline(monkeypatch):
prompts: list[str] = []
monkeypatch.setattr(
json_crew, "_readline_safe_prompt", lambda prompt: f"safe:{prompt}"
)
monkeypatch.setattr(
"builtins.input", lambda prompt: prompts.append(prompt) or "Draft content"
)
assert json_crew._prompt_text("Goal", spacing_before=False) == "Draft content"
assert len(prompts) == 1
assert prompts[0].startswith("safe:")
assert "Goal" in prompts[0]
assert " > " in prompts[0]
def test_json_wizard_tool_picker_prioritizes_common_tools(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
return [1, 3], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
tools = json_crew._select_tools()
assert tools == ["SerperDevTool", "DirectoryReadTool"]
assert len(picker_calls) == 1
labels = picker_calls[0][1]
assert 0 in picker_calls[0][2]["separator_indices"]
assert labels[0] == "── Common tools ──"
assert labels[1].strip().endswith("SerperDevTool")
assert labels[2].strip().endswith("ScrapeWebsiteTool")
assert labels[3].strip().endswith("DirectoryReadTool")
assert labels[4].strip().endswith("FileReadTool")
assert labels[5].strip().endswith("FileWriterTool")
assert labels[1].index("Google search") < labels[1].index("SerperDevTool")
assert "More tools" not in labels
def test_json_wizard_tool_picker_collapses_categories_by_default(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
return [], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
json_crew._select_tools()
labels = picker_calls[0][1]
action_indices = picker_calls[0][2]["action_indices"]
# Categories show as collapsed action rows, not separators with tools
assert any(label.startswith("▸ Search & Research") for label in labels)
assert any(label.startswith("▸ Web Scraping") for label in labels)
assert not any(label.strip().endswith("BraveSearchTool") for label in labels)
assert len(action_indices) >= 4
# Only the common tools section is visible beyond the category rows
assert len(labels) == 1 + 5 + len(action_indices)
def test_json_wizard_tool_picker_expands_one_category_at_a_time(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
def find_category_row(labels: list[str], category: str) -> int:
return next(
idx for idx, label in enumerate(labels) if category in label
)
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
call_num = len(picker_calls)
if call_num == 1:
return [], find_category_row(labels, "Search & Research")
if call_num == 2:
# Search & Research is expanded; select BraveSearchTool and
# expand Web Scraping instead
brave = next(
idx
for idx, label in enumerate(labels)
if label.strip().endswith("BraveSearchTool")
)
return [brave], find_category_row(labels, "Web Scraping")
return [], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
tools = json_crew._select_tools()
assert tools == ["BraveSearchTool"]
assert len(picker_calls) == 3
# Second render: Search & Research expanded, others collapsed
labels2 = picker_calls[1][1]
assert any(label.startswith("▾ Search & Research") for label in labels2)
assert any(label.strip().endswith("BraveSearchTool") for label in labels2)
assert any(label.startswith("▸ Web Scraping") for label in labels2)
# Third render: Web Scraping expanded, Search & Research collapsed again
labels3 = picker_calls[2][1]
assert any(label.startswith("▸ Search & Research") for label in labels3)
assert any(label.startswith("▾ Web Scraping") for label in labels3)
assert not any(label.strip().endswith("BraveSearchTool") for label in labels3)
# The collapsed Search & Research row reports its selection count
assert any(
"Search & Research" in label and "1 selected" in label for label in labels3
)
# Cursor returns to the toggled category row
assert picker_calls[2][2]["initial_cursor"] == next(
idx for idx, label in enumerate(labels3) if "Web Scraping" in label
)
def test_json_wizard_tool_picker_preserves_selection_across_renders(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
call_num = len(picker_calls)
if call_num == 1:
# Select a common tool, then expand a category
category_row = next(
idx for idx, label in enumerate(labels) if "Web Scraping" in label
)
return [1], category_row
# Confirm without touching anything else
return sorted(kwargs["preselected"]), None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
tools = json_crew._select_tools()
# The common-tool selection survived the expand re-render via preselected
assert tools == ["SerperDevTool"]
assert 1 in picker_calls[1][2]["preselected"]
def test_json_wizard_tool_picker_lists_builtin_tools_across_categories(monkeypatch):
picker_calls: list[tuple[str, list[str], dict[str, object]]] = []
expanded_labels: list[str] = []
def pick_many(title: str, labels: list[str], **kwargs):
picker_calls.append((title, labels, kwargs))
expanded_labels.extend(labels)
action_indices = sorted(kwargs["action_indices"])
call_num = len(picker_calls)
if call_num <= len(action_indices):
# Expand the n-th category (indices shift between renders, so
# recompute from this render's action rows)
return [], action_indices[call_num - 1]
return [], None
monkeypatch.setattr(json_crew, "pick_many", pick_many)
json_crew._select_tools()
tool_names = {
label.rsplit(maxsplit=1)[-1]
for label in expanded_labels
if not label.startswith(("", "", "──"))
}
assert {
"DirectorySearchTool",
"MDXSearchTool",
"XMLSearchTool",
"YoutubeVideoSearchTool",
"S3ReaderTool",
"E2BExecTool",
"TavilyResearchTool",
"SerplyNewsSearchTool",
"BrowserbaseLoadTool",
"PatronusEvalTool",
}.issubset(tool_names)
assert {
"MCPServerAdapter",
"MongoDBVectorSearchConfig",
"ScrapegraphScrapeToolSchema",
"SnowflakeConfig",
}.isdisjoint(tool_names)
def test_multi_picker_skips_separator_on_initial_cursor(monkeypatch):
cursors: list[int] = []
monkeypatch.setattr(tui_picker, "_read_key", lambda: "enter")
monkeypatch.setattr(
tui_picker,
"_draw_multi",
lambda _labels, cursor, *_args, **_kwargs: cursors.append(cursor),
)
monkeypatch.setattr(tui_picker, "_clear_lines", lambda *_args, **_kwargs: None)
assert tui_picker._arrow_select_multi(
["── Common tools ──", "Google search via Serper API SerperDevTool"],
separator_indices={0},
) == ([], None)
assert cursors == [1]
def test_json_wizard_agent_attribute_prompts_are_compact(monkeypatch):
prompt_calls: list[tuple[str, bool]] = []
prompt_values = {
"Role": "Senior Dev Rel",
"Goal": "Draft content",
"Backstory": "Knows developer communities",
}
def prompt_text(
label: str,
default: str = "",
*,
spacing_before: bool = True,
) -> str:
prompt_calls.append((label, spacing_before))
return prompt_values[label]
monkeypatch.setattr(json_crew, "_prompt_text", prompt_text)
monkeypatch.setattr(json_crew, "_select_model", lambda: "openai/gpt-5.5")
monkeypatch.setattr(json_crew, "pick_many", lambda *_args, **_kwargs: ([], None))
monkeypatch.setattr(json_crew, "_confirm", lambda *_args, **_kwargs: False)
agent = json_crew._wizard_agent(agent_num=1, existing_names=[])
assert agent is not None
assert prompt_calls == [
("Role", False),
("Goal", False),
("Backstory", False),
]
def test_json_wizard_task_attribute_prompts_are_compact(monkeypatch):
prompt_calls: list[tuple[str, bool]] = []
prompt_values = {
"Description": "Research latest release",
"Expected output": "Release summary",
}
def prompt_text(
label: str,
default: str = "",
*,
spacing_before: bool = True,
) -> str:
prompt_calls.append((label, spacing_before))
return prompt_values[label]
monkeypatch.setattr(json_crew, "_prompt_text", prompt_text)
task = json_crew._wizard_task(
task_num=1,
agent_names=["senior_dev_rel"],
prior_task_names=[],
)
assert task is not None
assert prompt_calls == [
("Description", False),
("Expected output", False),
]
def test_json_create_provider_preselects_default_model(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
with mock.patch(
"crewai_cli.create_json_crew._wizard_agents_and_tasks"
) as mock_wizard:
mock_wizard.return_value = (
[
{
"name": "researcher",
"role": "Researcher",
"goal": "Research",
"backstory": "Researcher",
"llm": "openai/gpt-5.5",
"tools": [],
"planning": False,
"allow_delegation": False,
}
],
[
{
"name": "research_task",
"description": "Research",
"expected_output": "Findings",
"agent": "researcher",
"context": [],
}
],
{"process": "sequential", "memory": False, "inputs": {}},
)
json_crew.create_json_crew("JSON Crew", provider="openai", skip_provider=True)
mock_wizard.assert_called_once_with(
skip_provider=True,
default_llm="openai/gpt-5.5",
)
assert (tmp_path / "json_crew" / "crew.jsonc").exists()
assert not (tmp_path / "json_crew" / "tests").exists()
assert not (tmp_path / "json_crew" / "config.jsonc").exists()
crew_template = (tmp_path / "json_crew" / "crew.jsonc").read_text()
assert (
'"guardrail": "Every factual claim needs context support."'
in crew_template
)
assert '"guardrails": [' in crew_template
assert '"guardrail_max_retries": 2' in crew_template
assert "Docs: https://docs.crewai.com/concepts/tasks" in crew_template
assert '"output_pydantic": null' in crew_template
assert '"type": "ConditionalTask"' in crew_template
assert '"condition": { "python": "my_project.conditions.should_run" }' in (
crew_template
)
assert '"output_json": { "python": "my_project.models.ReportOutput" }' in (
crew_template
)
assert (
'"converter_cls": { "python": "my_project.converters.CustomConverter" }'
in crew_template
)
assert '"markdown": false' in crew_template
assert '"input_files": { "brief": "data/brief.txt" }' in crew_template
assert "Docs: https://docs.crewai.com/concepts/crews" in crew_template
assert "manager_agent can reference an agents/<name>.jsonc file" in crew_template
assert '"manager_agent": "researcher"' in crew_template
assert (
'"before_kickoff_callbacks": [{"python": '
'"my_project.callbacks.before_kickoff"}]'
) in crew_template
assert (
'"after_kickoff_callbacks": [{"python": '
'"my_project.callbacks.after_kickoff"}]'
) in crew_template
assert '"output_log_file": "crew.log"' in crew_template
assert "Crew-level LLM fields also accept object form" in crew_template
assert '"chat_llm": {"model": "llama3", "provider": "ollama"' in (
crew_template
)
assert "Use {placeholder} in agent or task text" in crew_template
assert "`crewai run` prompts for any placeholders" in crew_template
assert "Use {placeholder} inputs here" in crew_template
agent_template = (
tmp_path / "json_crew" / "agents" / "researcher.jsonc"
).read_text()
assert "You can use {placeholder} inputs in role, goal, or backstory" in (
agent_template
)
assert '"role": "Senior {industry} Researcher"' in agent_template
assert '"type": {"python": "my_project.agents.CustomAgent"}' in agent_template
assert "Optional agent-level guardrail" in agent_template
assert "Python refs must point to module-level functions/classes" in agent_template
assert (
'"step_callback": {"python": "my_project.callbacks.on_agent_step"}'
in agent_template
)
assert '"guardrail_max_retries": 2' in agent_template
assert "Docs: https://docs.crewai.com/concepts/agents" in agent_template
assert '"reasoning": true' in agent_template
assert "For custom endpoints or deployment-based providers" in agent_template
assert '"deployment_name": "my-deployment", "provider": "azure"' in (
agent_template
)
assert '"planning_config": {' in agent_template
assert '"llm": {"model": "deepseek-chat", "provider": "deepseek"}' in (
agent_template
)
assert '"knowledge_sources": []' in agent_template
def test_json_provider_default_model_helper():
assert json_crew._default_model_for_provider("openai") == "openai/gpt-5.5"
assert json_crew._default_model_for_provider("anthropic/claude-custom") == (
"anthropic/claude-custom"
)
assert json_crew._default_model_for_provider("unknown") is None
def test_json_wizard_task_reprompts_on_cancelled_agent_pick(monkeypatch):
"""Esc on the agent picker must reprompt, not silently assign agent 0."""
prompts = iter(["Do the research", "A report"])
monkeypatch.setattr(json_crew, "_prompt_text", lambda *a, **k: next(prompts))
pick_calls: list[str] = []
picks = iter([-1, 1])
def fake_pick_one(title: str, labels: list[str]) -> int:
pick_calls.append(title)
return next(picks)
monkeypatch.setattr(json_crew, "pick_one", fake_pick_one)
task = json_crew._wizard_task(
task_num=1,
agent_names=["first_agent", "second_agent"],
prior_task_names=[],
)
assert len(pick_calls) == 2
assert task["agent"] == "second_agent"

View File

@@ -0,0 +1,823 @@
from datetime import datetime
import time
import pytest
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.observation_events import (
GoalAchievedEarlyEvent,
PlanRefinementEvent,
PlanReplanTriggeredEvent,
PlanStepCompletedEvent,
PlanStepStartedEvent,
StepObservationCompletedEvent,
StepObservationFailedEvent,
StepObservationStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai_cli.command import AuthenticationRequiredError
from crewai_cli import run_crew
from crewai_cli.crew_run_tui import CrewRunApp
def _app_with_plan() -> CrewRunApp:
app = CrewRunApp()
app._plan = {
"plan": "Demo plan",
"steps": [
{"step_number": 1, "description": "First"},
{"step_number": 2, "description": "Second"},
{"step_number": 3, "description": "Third"},
],
}
app._plan_step_status = {1: "pending", 2: "pending", 3: "pending"}
return app
def _log_entry(name: str) -> dict:
now = time.time()
return {
"tool_name": name,
"status": "success",
"args": None,
"result": f"{name} result",
"error": None,
"start_time": now,
"duration": 1.0,
"task_idx": 1,
}
def _emit_event(event: object) -> None:
future = crewai_event_bus.emit(None, event)
if future:
future.result(timeout=5)
def test_chain_deploy_skips_validation_after_auth_retry(monkeypatch) -> None:
create_calls: list[dict[str, object]] = []
login_calls: list[bool] = []
class FakeDeployCommand:
attempts = 0
def create_crew(self, **kwargs) -> None:
create_calls.append(kwargs)
FakeDeployCommand.attempts += 1
if FakeDeployCommand.attempts == 1:
raise AuthenticationRequiredError
class FakeAuthenticationCommand:
def login(self) -> None:
login_calls.append(True)
monkeypatch.setattr("crewai_cli.deploy.main.DeployCommand", FakeDeployCommand)
monkeypatch.setattr(
"crewai_cli.authentication.main.AuthenticationCommand",
FakeAuthenticationCommand,
)
run_crew._chain_deploy()
assert create_calls == [
{"confirm": True, "skip_validate": True},
{"confirm": True, "skip_validate": True},
]
assert login_calls == [True]
def test_chain_deploy_does_not_login_for_deploy_exit(monkeypatch, capsys) -> None:
create_calls: list[dict[str, object]] = []
login_calls: list[bool] = []
class FakeDeployCommand:
def create_crew(self, **kwargs) -> None:
create_calls.append(kwargs)
raise SystemExit(42)
class FakeAuthenticationCommand:
def login(self) -> None:
login_calls.append(True)
monkeypatch.setattr("crewai_cli.deploy.main.DeployCommand", FakeDeployCommand)
monkeypatch.setattr(
"crewai_cli.authentication.main.AuthenticationCommand",
FakeAuthenticationCommand,
)
run_crew._chain_deploy()
assert create_calls == [{"confirm": True, "skip_validate": True}]
assert login_calls == []
assert "Deploy failed with exit code 42" in capsys.readouterr().out
def test_plan_step_status_updates_only_the_explicit_step() -> None:
app = _app_with_plan()
app._set_plan_step_status(2, "done")
assert app._plan_step_status == {
1: "pending",
2: "done",
3: "pending",
}
def test_step_observation_events_update_the_explicit_step() -> None:
app = _app_with_plan()
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
StepObservationStartedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
),
)
if future:
future.result(timeout=5)
assert app._plan_step_status == {
1: "pending",
2: "active",
3: "pending",
}
future = crewai_event_bus.emit(
None,
StepObservationCompletedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
step_completed_successfully=True,
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "pending",
2: "done",
3: "pending",
}
def test_plan_step_lifecycle_events_update_the_explicit_step() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
PlanStepStartedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
)
)
assert app._plan_step_status == {
1: "pending",
2: "active",
3: "pending",
}
_emit_event(
PlanStepCompletedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
success=True,
result="done",
)
)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "pending",
2: "done",
3: "pending",
}
def test_failed_plan_step_lifecycle_event_marks_exact_step_failed() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
PlanStepCompletedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
success=False,
error="Step failed",
)
)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "pending",
2: "failed",
3: "pending",
}
def test_tool_usage_events_do_not_advance_plan_steps() -> None:
app = _app_with_plan()
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
ToolUsageStartedEvent(tool_name="search", tool_args={"query": "CrewAI"}),
)
if future:
future.result(timeout=5)
now = datetime.now()
future = crewai_event_bus.emit(
None,
ToolUsageFinishedEvent(
tool_name="search",
tool_args={"query": "CrewAI"},
started_at=now,
finished_at=now,
output="result",
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "pending",
2: "pending",
3: "pending",
}
def test_next_tool_does_not_mark_unfinished_tool_successful() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
ToolUsageStartedEvent(tool_name="search", tool_args={"query": "CrewAI"}),
)
_emit_event(
ToolUsageStartedEvent(tool_name="scrape", tool_args={"url": "https://x"}),
)
finally:
app._unsubscribe()
assert app._log_entries[0]["status"] == "timeout"
assert app._log_entries[0]["result"] is None
assert app._log_entries[0]["error"] == (
"No result received before the next tool started"
)
assert app._log_entries[1]["status"] == "running"
assert app._plan_step_status == {
1: "pending",
2: "pending",
3: "pending",
}
def test_internal_reasoning_function_call_is_hidden_from_activity_log() -> None:
app = _app_with_plan()
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
ToolUsageStartedEvent(
tool_name="create_reasoning_plan",
tool_args={"plan": "Plan", "steps": [], "ready": True},
),
)
if future:
future.result(timeout=5)
now = datetime.now()
future = crewai_event_bus.emit(
None,
ToolUsageFinishedEvent(
tool_name="create_reasoning_plan",
tool_args={"plan": "Plan", "steps": [], "ready": True},
started_at=now,
finished_at=now,
output='{"plan":"Plan","steps":[],"ready":true}',
),
)
if future:
future.result(timeout=5)
future = crewai_event_bus.emit(
None,
ToolUsageErrorEvent(
tool_name="create_reasoning_plan",
tool_args={"plan": "Plan", "steps": [], "ready": True},
error="internal planning fallback",
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._log_entries == []
assert app._current_task_steps == []
def test_tool_failure_does_not_override_successful_plan_step_completion() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
PlanStepStartedEvent(
agent_role="Agent",
step_number=1,
step_description="First",
)
)
_emit_event(
ToolUsageStartedEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
plan_step_number=1,
plan_step_description="First",
)
)
_emit_event(
ToolUsageErrorEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
plan_step_number=1,
plan_step_description="First",
error="No results",
)
)
_emit_event(
PlanStepCompletedEvent(
agent_role="Agent",
step_number=1,
step_description="First",
success=True,
result="Recovered with another source",
)
)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "done",
2: "pending",
3: "pending",
}
def test_tool_event_step_metadata_is_stored_in_activity_log() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
ToolUsageStartedEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
plan_step_number=2,
plan_step_description="Second",
)
)
now = datetime.now()
_emit_event(
ToolUsageFinishedEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
plan_step_number=2,
plan_step_description="Second",
started_at=now,
finished_at=now,
output="Found official source",
)
)
finally:
app._unsubscribe()
assert app._log_entries[0]["plan_step_number"] == 2
assert app._plan_step_status == {
1: "pending",
2: "pending",
3: "pending",
}
def test_starting_next_tool_does_not_infer_plan_step_progress() -> None:
app = _app_with_plan()
app._subscribe()
try:
_emit_event(
ToolUsageStartedEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
)
)
_emit_event(
ToolUsageErrorEvent(
tool_name="search_the_internet_with_serper",
tool_args={"search_query": "CrewAI release"},
error="No results",
)
)
_emit_event(
ToolUsageStartedEvent(
tool_name="read_website_content",
tool_args={"url": "https://example.com"},
)
)
finally:
app._unsubscribe()
assert app._log_entries[0]["status"] == "error"
assert app._log_entries[1]["status"] == "running"
assert app._plan_step_status == {
1: "pending",
2: "pending",
3: "pending",
}
@pytest.mark.asyncio
async def test_crew_done_does_not_mark_unfinished_tool_successful() -> None:
app = _app_with_plan()
async with app.run_test(size=(100, 40)) as pilot:
app._plan_step_status = {1: "failed", 2: "done", 3: "pending"}
app._log_entries = [
{
"tool_name": "search",
"status": "running",
"args": '{"query": "CrewAI"}',
"result": None,
"error": None,
"start_time": time.time() - 2,
"duration": None,
"task_idx": 1,
}
]
app._on_crew_done("final output")
await pilot.pause()
assert app._log_entries[0]["status"] == "timeout"
assert app._log_entries[0]["result"] is None
assert app._log_entries[0]["error"] == "No result received before crew completed"
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
def test_streamed_step_observation_updates_named_step_only() -> None:
app = _app_with_plan()
updated = app._try_parse_step_observation(
'{"step_completed_successfully":true,'
'"key_information_learned":"Step 2 succeeded with the official source."}'
)
assert updated is True
assert app._plan_step_status == {
1: "pending",
2: "done",
3: "pending",
}
def test_failed_streamed_step_observation_marks_named_step_failed() -> None:
app = _app_with_plan()
updated = app._try_parse_step_observation(
'{"step_completed_successfully":false,'
'"key_information_learned":"Step 2 failed because the tool failed."}'
)
assert updated is True
assert app._plan_step_status == {
1: "pending",
2: "failed",
3: "pending",
}
def test_streamed_goal_achieved_observation_collapses_remaining_steps_done() -> None:
app = _app_with_plan()
updated = app._try_parse_step_observation(
'{"step_number":2,'
'"step_completed_successfully":true,'
'"key_information_learned":"Goal is already satisfied.",'
'"goal_already_achieved":true}'
)
assert updated is True
assert app._plan_step_status == {
1: "done",
2: "done",
3: "done",
}
def test_task_completion_collapses_pending_plan_steps_but_preserves_failed() -> None:
app = _app_with_plan()
app._plan_step_status = {1: "failed", 2: "done", 3: "pending"}
app._collapse_plan_on_task_done()
assert app._plan_step_status == {1: "failed", 2: "done", 3: "done"}
def test_observation_failure_collapses_to_done_because_executor_continues() -> None:
app = _app_with_plan()
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
StepObservationFailedEvent(
agent_role="Agent",
step_number=2,
step_description="Second",
error="observer timeout",
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "done",
2: "done",
3: "pending",
}
def test_goal_achieved_event_collapses_remaining_steps_done() -> None:
app = _app_with_plan()
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
GoalAchievedEarlyEvent(
agent_role="Agent",
step_number=2,
steps_completed=2,
steps_remaining=1,
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "done",
2: "done",
3: "done",
}
def test_replan_event_keeps_old_plan_until_next_streamed_plan_replaces_it() -> None:
app = _app_with_plan()
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
PlanReplanTriggeredEvent(
agent_role="Agent",
step_number=2,
replan_reason="Need updated sources",
replan_count=1,
completed_steps_preserved=1,
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan is not None
assert app._plan_step_status == {1: "pending", 2: "pending", 3: "pending"}
assert app._awaiting_replan is True
app._try_parse_plan(
'{"plan":"Updated plan","steps":['
'{"step_number":1,"description":"Updated first"},'
'{"step_number":2,"description":"Updated second"}]}'
)
assert app._plan == {
"plan": "Updated plan",
"steps": [
{"step_number": 1, "description": "Updated first"},
{"step_number": 2, "description": "Updated second"},
],
}
assert app._plan_step_status == {1: "pending", 2: "pending"}
assert app._awaiting_replan is False
def test_plan_refinement_updates_descriptions_without_new_statuses() -> None:
app = _app_with_plan()
app._plan_step_status = {1: "done", 2: "active", 3: "pending"}
app._subscribe()
try:
future = crewai_event_bus.emit(
None,
PlanRefinementEvent(
agent_role="Agent",
step_number=2,
refined_step_count=1,
refinements=["Step 3: Write the final answer from verified facts"],
),
)
if future:
future.result(timeout=5)
finally:
app._unsubscribe()
assert app._plan_step_status == {
1: "done",
2: "done",
3: "pending",
}
assert app._plan["steps"][2]["description"] == (
"Write the final answer from verified facts"
)
def test_step_observation_json_is_hidden_from_streaming_text() -> None:
app = _app_with_plan()
assert (
app._strip_step_observation_json(
'Visible before {"step_completed_successfully":true,'
'"key_information_learned":"Step 2 succeeded."} visible after'
)
== "Visible before visible after"
)
@pytest.mark.asyncio
async def test_completed_run_keeps_activity_log_keyboard_navigation_active() -> None:
app = CrewRunApp()
async with app.run_test(size=(100, 40)) as pilot:
app._log_entries = [_log_entry("search"), _log_entry("scrape")]
app._on_crew_done("final output")
await pilot.pause()
assert app.focused is app.query_one("#log-panel")
await pilot.press("down", "enter")
await pilot.pause()
assert app._log_cursor == 1
assert app._log_expanded == {1}
await pilot.press("up")
await pilot.pause()
assert app._log_cursor == 0
class _FakeTask:
fingerprint = None
def __init__(self, task_id: str, name: str) -> None:
self.id = task_id
self.name = name
self.description = name
def test_async_task_completion_marks_the_right_sidebar_row() -> None:
"""Overlapping tasks: completing task 1 while task 2 runs must not
mark task 2 done, and starting task 2 must not mark task 1 done."""
from crewai.events.types.task_events import TaskCompletedEvent, TaskStartedEvent
from crewai.tasks.task_output import TaskOutput
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
app._subscribe()
try:
task1 = _FakeTask("id-1", "first")
task2 = _FakeTask("id-2", "second")
for task in (task1, task2):
future = crewai_event_bus.emit(
None, TaskStartedEvent(context=None, task=task)
)
if future:
future.result(timeout=5)
# Both started: neither prematurely done
assert app._task_statuses == {1: "active", 2: "active"}
future = crewai_event_bus.emit(
None,
TaskCompletedEvent(
output=TaskOutput(description="first", raw="done", agent="a"),
task=task1,
),
)
if future:
future.result(timeout=5)
assert app._task_statuses == {1: "done", 2: "active"}
finally:
app._unsubscribe()
def test_pop_task_state_falls_back_to_current_task() -> None:
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
app._current_task_idx = 2
app._current_task_desc = "second"
class _Evt:
task = None
task_name = "unknown"
state = app._pop_task_state(_Evt())
assert state["idx"] == 2
assert state["desc"] == "second"
def test_overlapping_task_logs_keep_their_own_state() -> None:
"""Task 1 completing after task 2 started must log its own description,
agent, and output — and must not steal or reset task 2's stream state."""
from crewai.events.types.task_events import TaskCompletedEvent, TaskStartedEvent
from crewai.tasks.task_output import TaskOutput
app = CrewRunApp(total_tasks=2, task_names=["first", "second"])
app._subscribe()
try:
task1 = _FakeTask("id-1", "first")
task2 = _FakeTask("id-2", "second")
for task in (task1, task2):
future = crewai_event_bus.emit(
None, TaskStartedEvent(context=None, task=task)
)
if future:
future.result(timeout=5)
# Task 2 is current and has streamed state in flight
app._task_full_output = "task two streaming output"
app._current_task_steps = [{"type": "llm", "summary": "thinking"}]
future = crewai_event_bus.emit(
None,
TaskCompletedEvent(
output=TaskOutput(
description="first", raw="task one result", agent="a1"
),
task=task1,
),
)
if future:
future.result(timeout=5)
# Task 1's entry carries its own identity and output
entry1 = app._task_logs[-1]
assert entry1["idx"] == 1
assert entry1["desc"] == "first"
assert entry1["output"] == "task one result"
assert entry1["steps"] == []
# Task 2's in-flight stream state was not consumed or reset
assert app._task_full_output == "task two streaming output"
assert app._current_task_steps == [{"type": "llm", "summary": "thinking"}]
future = crewai_event_bus.emit(
None,
TaskCompletedEvent(
output=TaskOutput(
description="second", raw="task two result", agent="a2"
),
task=task2,
),
)
if future:
future.result(timeout=5)
entry2 = app._task_logs[-1]
assert entry2["idx"] == 2
assert entry2["desc"] == "second"
assert entry2["output"] == "task two streaming output"
assert any(step.get("summary") == "thinking" for step in entry2["steps"])
finally:
app._unsubscribe()

View File

@@ -31,6 +31,18 @@ def test_is_git_not_installed(fp):
Repository(path=".")
def test_fetch_failure_raises_value_error(fp):
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
fp.register(["git", "rev-parse", "--is-inside-work-tree"], stdout="true\n")
fp.register(["git", "fetch"], returncode=128, stderr="remote unavailable\n")
with pytest.raises(
ValueError,
match=r"Git fetch failed with exit code 128 for command \['git', 'fetch'\]: remote unavailable",
):
Repository(path=".")
def test_status(fp, repository):
fp.register(
["git", "status", "--branch", "--porcelain"],
@@ -99,3 +111,45 @@ def test_origin_url(fp, repository):
stdout="https://github.com/user/repo.git\n",
)
assert repository.origin_url() == "https://github.com/user/repo.git"
def test_initialize_creates_initial_commit(fp, tmp_path):
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
fp.register(["git", "init"], stdout="")
fp.register(["git", "--version"], stdout="git version 2.30.0\n")
fp.register(["git", "rev-parse", "--is-inside-work-tree"], stdout="true\n")
fp.register(["git", "rev-parse", "--verify", "HEAD"], returncode=1)
fp.register(["git", "add", "."], stdout="")
fp.register(
[
"git",
"-c",
"user.name=CrewAI",
"-c",
"user.email=deploy@crewai.com",
"commit",
"--allow-empty",
"-m",
"Initial crew",
],
stdout="",
)
repo = Repository.initialize(path=str(tmp_path))
assert repo.path == str(tmp_path)
exclude_file = tmp_path / ".git" / "info" / "exclude"
exclude_text = exclude_file.read_text()
assert ".env" in exclude_text
assert "!.env.example" in exclude_text
assert "!.env.sample" in exclude_text
assert "__pycache__/" in exclude_text
def test_deployable_files_uses_git_excludes(fp, repository):
fp.register(
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
stdout="pyproject.toml\nsrc/main.py\n",
)
assert repository.deployable_files() == ["pyproject.toml", "src/main.py"]

View File

@@ -0,0 +1,102 @@
from pathlib import Path
import subprocess
import pytest
import crewai_cli.install_crew as install_crew_module
@pytest.fixture(autouse=True)
def _tool_credentials(monkeypatch):
monkeypatch.setattr(
install_crew_module,
"build_env_with_all_tool_credentials",
lambda: {"CREWAI_TEST": "1"},
)
def test_install_crew_json_project_skips_project_install(
fp, monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "json_crew"
[tool.crewai]
type = "crew"
""".strip()
)
(tmp_path / "crew.jsonc").write_text("{}\n")
fp.register(["uv", "sync", "--no-install-project"], stdout="")
install_crew_module.install_crew([])
def test_install_crew_json_project_with_python_package_installs_project(
fp, monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "hybrid-crew"
[tool.crewai]
type = "crew"
""".strip()
)
(tmp_path / "crew.jsonc").write_text("{}\n")
package_dir = tmp_path / "src" / "hybrid_crew"
package_dir.mkdir(parents=True)
(package_dir / "crew.py").write_text("class HybridCrew: ...\n")
fp.register(["uv", "sync"], stdout="")
install_crew_module.install_crew([])
def test_install_crew_flow_project_installs_project(fp, monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text(
"""
[project]
name = "flow_project"
[tool.crewai]
type = "flow"
""".strip()
)
(tmp_path / "crew.jsonc").write_text("{}\n")
fp.register(["uv", "sync"], stdout="")
install_crew_module.install_crew([])
def test_install_crew_classic_project_installs_project(
fp, monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'classic'\n")
fp.register(["uv", "sync"], stdout="")
install_crew_module.install_crew([])
def test_install_crew_install_project_false_adds_no_install_project(fp):
fp.register(["uv", "sync", "--no-install-project", "--frozen"], stdout="")
install_crew_module.install_crew(["--frozen"], install_project=False)
def test_install_crew_reraises_sync_failure_when_requested(fp):
fp.register(["uv", "sync"], returncode=1, stderr="sync failed\n")
with pytest.raises(subprocess.CalledProcessError):
install_crew_module.install_crew([], raise_on_error=True)
def test_install_crew_swallows_sync_failure_by_default(fp):
fp.register(["uv", "sync"], returncode=1, stderr="sync failed\n")
install_crew_module.install_crew([])

View File

@@ -292,6 +292,36 @@ class TestPlusAPI(unittest.TestCase):
"POST", "/crewai_plus/api/v1/crews", json=payload
)
@patch("crewai_cli.plus_api.PlusAPI._make_multipart_request")
def test_create_crew_from_zip(self, mock_make_multipart_request):
self.api.create_crew_from_zip(
"/tmp/test.zip",
name="test_crew",
env={"ENV_VAR": "value"},
)
mock_make_multipart_request.assert_called_once_with(
"POST",
"/crewai_plus/api/v1/crews/zip",
zip_file_path="/tmp/test.zip",
data={"name": "test_crew", "env[ENV_VAR]": "value"},
timeout=300,
)
@patch("crewai_cli.plus_api.PlusAPI._make_multipart_request")
def test_update_crew_from_zip(self, mock_make_multipart_request):
self.api.update_crew_from_zip(
"test_uuid",
"/tmp/test.zip",
env={"ENV_VAR": "value"},
)
mock_make_multipart_request.assert_called_once_with(
"POST",
"/crewai_plus/api/v1/crews/test_uuid/zip_update",
zip_file_path="/tmp/test.zip",
data={"env[ENV_VAR]": "value"},
timeout=300,
)
@patch("crewai_core.plus_api.Settings")
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
def test_custom_base_url(self, mock_settings_class):

View File

@@ -0,0 +1,470 @@
"""Tests for crewai_cli.run_crew JSON crew handling."""
import os
from pathlib import Path
import subprocess
import sys
import pytest
from crewai_core.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
import crewai_cli.run_crew as run_crew_module
def test_run_crew_forwards_trained_agents_file_to_json_crews(monkeypatch):
"""crewai run -f must reach JSON crews, not only classic subprocess crews."""
monkeypatch.setattr(run_crew_module, "_has_json_crew", lambda: True)
called: dict = {}
def fake_run_json_crew_in_project_env(trained_agents_file=None):
called["trained_agents_file"] = trained_agents_file
monkeypatch.setattr(
run_crew_module,
"_run_json_crew_in_project_env",
fake_run_json_crew_in_project_env,
)
run_crew_module.run_crew(trained_agents_file="some.pkl")
assert called == {"trained_agents_file": "some.pkl"}
def test_json_run_uses_project_env_when_pyproject_exists(monkeypatch, tmp_path: Path):
"""JSON crew runs should execute inside the project uv environment."""
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
install_calls = []
subprocess_calls = []
monkeypatch.setattr(
run_crew_module,
"_install_json_crew_dependencies_if_needed",
lambda: install_calls.append(True),
)
monkeypatch.setattr(
run_crew_module,
"build_env_with_all_tool_credentials",
lambda: {"EXISTING": "value"},
)
def fake_subprocess_run(command, **kwargs):
subprocess_calls.append((command, kwargs))
monkeypatch.setattr(run_crew_module.subprocess, "run", fake_subprocess_run)
run_crew_module._run_json_crew_in_project_env(
trained_agents_file="trained.pkl"
)
expected_env = {
"EXISTING": "value",
run_crew_module._CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV: str(
Path(run_crew_module.__file__).resolve().parent
),
CREWAI_TRAINED_AGENTS_FILE_ENV: "trained.pkl",
}
if local_crewai_source_dir := run_crew_module._find_local_crewai_source_dir():
expected_env[run_crew_module._CREWAI_RUNNER_SOURCE_DIR_ENV] = str(
local_crewai_source_dir
)
assert install_calls == [True]
assert subprocess_calls == [
(
[
"uv",
"run",
"--no-sync",
"python",
"-c",
run_crew_module._JSON_CREW_RUNNER_CODE,
],
{
"capture_output": False,
"text": True,
"check": True,
"env": expected_env,
},
)
]
def test_json_run_uses_poetry_run_for_poetry_lock_without_uv_lock(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "poetry.lock").write_text("# lock\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: {},
)
subprocess_calls = []
def fake_subprocess_run(command, **kwargs):
subprocess_calls.append((command, kwargs))
monkeypatch.setattr(run_crew_module.subprocess, "run", fake_subprocess_run)
run_crew_module._run_json_crew_in_project_env()
expected_env = {
run_crew_module._CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV: str(
Path(run_crew_module.__file__).resolve().parent
),
}
if local_crewai_source_dir := run_crew_module._find_local_crewai_source_dir():
expected_env[run_crew_module._CREWAI_RUNNER_SOURCE_DIR_ENV] = str(
local_crewai_source_dir
)
assert subprocess_calls == [
(
[
"poetry",
"run",
"python",
"-c",
run_crew_module._JSON_CREW_RUNNER_CODE,
],
{
"capture_output": False,
"text": True,
"check": True,
"env": expected_env,
},
)
]
def test_json_runner_code_loads_current_cli_package_over_project_env(tmp_path: Path):
old_parent = tmp_path / "old"
old_pkg = old_parent / "crewai_cli"
old_pkg.mkdir(parents=True)
(old_pkg / "__init__.py").write_text("")
(old_pkg / "run_crew.py").write_text("raise ImportError('old package used')\n")
old_crewai_project = old_parent / "crewai" / "project"
old_crewai_project.mkdir(parents=True)
(old_parent / "crewai" / "__init__.py").write_text("")
(old_crewai_project / "__init__.py").write_text("")
(old_crewai_project / "json_loader.py").write_text(
"raise ImportError('old crewai used')\n"
)
current_pkg = tmp_path / "current" / "crewai_cli"
current_pkg.mkdir(parents=True)
marker = tmp_path / "marker.txt"
(current_pkg / "__init__.py").write_text("")
(current_pkg / "run_crew.py").write_text(
"from pathlib import Path\n"
"from crewai.project.json_loader import SOURCE\n"
"def _run_json_crew(trained_agents_file=None):\n"
f" Path({str(marker)!r}).write_text(SOURCE + ':' + (trained_agents_file or ''))\n"
)
current_crewai_project = tmp_path / "current_crewai_src" / "crewai" / "project"
current_crewai_project.mkdir(parents=True)
(tmp_path / "current_crewai_src" / "crewai" / "__init__.py").write_text("")
(current_crewai_project / "__init__.py").write_text("")
(current_crewai_project / "json_loader.py").write_text("SOURCE = 'current'\n")
env = os.environ.copy()
env["PYTHONPATH"] = str(old_parent)
env[run_crew_module._CREWAI_CLI_RUNNER_PACKAGE_DIR_ENV] = str(current_pkg)
env[run_crew_module._CREWAI_RUNNER_SOURCE_DIR_ENV] = str(
tmp_path / "current_crewai_src"
)
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = "trained.pkl"
subprocess.run(
[sys.executable, "-c", run_crew_module._JSON_CREW_RUNNER_CODE],
check=True,
env=env,
cwd=tmp_path,
)
assert marker.read_text() == "current:trained.pkl"
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):
called["trained_agents_file"] = trained_agents_file
return "result"
monkeypatch.setattr(run_crew_module, "_run_json_crew", fake_run_json_crew)
assert (
run_crew_module._run_json_crew_in_project_env(
trained_agents_file="trained.pkl"
)
== "result"
)
assert called == {"trained_agents_file": "trained.pkl"}
def test_json_project_env_run_failure_exits_nonzero(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: {}
)
def fake_subprocess_run(command, **kwargs):
raise subprocess.CalledProcessError(7, command)
monkeypatch.setattr(run_crew_module.subprocess, "run", fake_subprocess_run)
with pytest.raises(SystemExit) as exc_info:
run_crew_module._run_json_crew_in_project_env()
assert exc_info.value.code == 7
def test_json_run_installs_dependencies_when_pyproject_has_no_lockfile(
monkeypatch, tmp_path: Path
):
"""JSON crew runs should lock/sync project dependencies only when needed."""
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
calls = []
def fake_install_crew(
proxy_options, *, raise_on_error=False, install_project=None
):
calls.append((proxy_options, raise_on_error, install_project))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
run_crew_module._install_json_crew_dependencies_if_needed()
assert calls == [([], True, None)]
def test_json_run_syncs_frozen_when_uv_lock_exists_without_venv(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
calls = []
def fake_install_crew(
proxy_options, *, raise_on_error=False, install_project=None
):
calls.append((proxy_options, raise_on_error, install_project))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
run_crew_module._install_json_crew_dependencies_if_needed()
assert calls == [(["--frozen"], True, None)]
def test_json_run_skips_uv_sync_when_only_poetry_lock_exists_without_venv(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "poetry.lock").write_text("# lock\n")
calls = []
def fake_install_crew(
proxy_options, *, raise_on_error=False, install_project=None
):
calls.append((proxy_options, raise_on_error, install_project))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
run_crew_module._install_json_crew_dependencies_if_needed()
assert calls == []
@pytest.mark.parametrize("lockfile", ["uv.lock", "poetry.lock"])
def test_json_run_skips_dependency_install_when_lockfile_and_venv_exist(
monkeypatch, tmp_path: Path, lockfile: str
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / lockfile).write_text("# lock\n")
(tmp_path / ".venv").mkdir()
calls = []
def fake_install_crew(
proxy_options, *, raise_on_error=False, install_project=None
):
calls.append((proxy_options, raise_on_error, install_project))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
run_crew_module._install_json_crew_dependencies_if_needed()
assert calls == []
def test_json_run_skips_dependency_install_without_pyproject(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
calls = []
def fake_install_crew(
proxy_options, *, raise_on_error=False, install_project=None
):
calls.append((proxy_options, raise_on_error))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
run_crew_module._install_json_crew_dependencies_if_needed()
assert calls == []
def test_json_run_install_failure_exits_nonzero(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
def fake_install_crew(
proxy_options, *, raise_on_error=False, install_project=None
):
raise subprocess.CalledProcessError(42, ["uv", "sync"])
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
with pytest.raises(SystemExit) as exc_info:
run_crew_module._install_json_crew_dependencies_if_needed()
assert exc_info.value.code == 42
def test_run_json_crew_exports_trained_agents_env(monkeypatch, tmp_path: Path):
"""JSON crews run in-process, so the pickle path must land in the env var."""
monkeypatch.chdir(tmp_path)
monkeypatch.delenv(CREWAI_TRAINED_AGENTS_FILE_ENV, raising=False)
try:
# No crew.json(c) in tmp_path: the loader fails *after* the env var
# export, which is the part under test.
with pytest.raises(FileNotFoundError):
run_crew_module._run_json_crew(trained_agents_file="some.pkl")
assert os.environ[CREWAI_TRAINED_AGENTS_FILE_ENV] == "some.pkl"
finally:
os.environ.pop(CREWAI_TRAINED_AGENTS_FILE_ENV, None)
def test_run_json_crew_leaves_env_untouched_without_flag(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv(CREWAI_TRAINED_AGENTS_FILE_ENV, raising=False)
with pytest.raises(FileNotFoundError):
run_crew_module._run_json_crew()
assert CREWAI_TRAINED_AGENTS_FILE_ENV not in os.environ
def test_missing_input_names_accepts_hyphenated_placeholders():
"""The prompt regex must accept the same names kickoff interpolation does."""
from types import SimpleNamespace
crew = SimpleNamespace(
agents=[
SimpleNamespace(
role="Researcher", goal="Cover {my-topic}", backstory=""
)
],
tasks=[
SimpleNamespace(
description="Write about {my-topic} for {target-audience}",
expected_output="Post",
output_file=None,
)
],
)
assert run_crew_module._missing_input_names(crew, {}) == [
"my-topic",
"target-audience",
]
def _patch_tui_run(monkeypatch, status: str):
"""Stub the TUI pieces of _run_json_crew so only exit handling runs."""
class FakeApp:
def __init__(self, **kwargs):
self._status = status
self._crew_result = "result" if status == "completed" else None
self._want_deploy = False
def run(self):
pass
from types import SimpleNamespace
crew = SimpleNamespace(name="Demo", tasks=[], agents=[])
monkeypatch.setattr(
run_crew_module, "find_crew_json_file", lambda: Path("crew.jsonc")
)
monkeypatch.setattr(
run_crew_module,
"_load_json_crew_for_tui",
lambda _path: (FakeApp, crew, {}, [], []),
)
monkeypatch.setattr(
run_crew_module, "_prompt_for_missing_inputs", lambda _crew, inputs: inputs
)
monkeypatch.setattr(run_crew_module, "_print_post_tui_summary", lambda _app: None)
def test_run_json_crew_failed_status_exits_nonzero(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
_patch_tui_run(monkeypatch, status="failed")
with pytest.raises(SystemExit) as exc_info:
run_crew_module._run_json_crew()
assert exc_info.value.code == 1
def test_run_json_crew_completed_status_returns_result(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
_patch_tui_run(monkeypatch, status="completed")
assert run_crew_module._run_json_crew() == "result"
def test_has_json_crew_defers_to_declared_flow_type(monkeypatch, tmp_path: Path):
"""A flow project containing a stray crew.jsonc must still run as a flow."""
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "flow"\n')
assert run_crew_module._has_json_crew() is False
def test_has_json_crew_true_for_declared_crew_type(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
(tmp_path / "pyproject.toml").write_text('[tool.crewai]\ntype = "crew"\n')
assert run_crew_module._has_json_crew() is True
def test_has_json_crew_true_without_pyproject(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
(tmp_path / "crew.jsonc").write_text("{}")
assert run_crew_module._has_json_crew() is True

View File

@@ -157,14 +157,16 @@ def test_install_api_error(mock_get, capsys, tool_command):
mock_get.assert_called_once_with("error-tool")
@patch("crewai_cli.tools.main.git.Repository.fetch")
@patch("crewai_cli.tools.main.git.Repository.is_synced", return_value=False)
def test_publish_when_not_in_sync(mock_is_synced, mock_fetch, capsys, tool_command):
@patch("crewai_cli.tools.main.git.Repository")
def test_publish_when_not_in_sync(mock_repository, capsys, tool_command):
mock_repository.return_value.is_synced.return_value = False
with raises(SystemExit):
tool_command.publish(is_public=True)
output = capsys.readouterr().out
assert "Local changes need to be resolved before publishing" in output
mock_repository.return_value.is_synced.assert_called_once_with()
@patch("crewai_cli.tools.main.get_project_name", return_value="sample-tool")

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Final, Literal, TypedDict, cast
from urllib.parse import urljoin
@@ -190,6 +191,36 @@ class PlusAPI:
with httpx.Client(trust_env=False, verify=verify) as client:
return client.request(method, url, **request_kwargs)
def _make_multipart_request(
self,
method: HttpMethod,
endpoint: str,
*,
zip_file_path: str | Path,
data: dict[str, str] | None = None,
timeout: float | None = None,
verify: bool = True,
) -> httpx.Response:
"""Send an authenticated multipart request containing a project ZIP."""
url = urljoin(self.base_url, endpoint)
headers = dict(cast(dict[str, str], self.headers))
headers.pop("Content-Type", None)
path = Path(zip_file_path)
request_kwargs: dict[str, Any] = {"headers": headers}
if data is not None:
request_kwargs["data"] = data
if timeout is not None:
request_kwargs["timeout"] = timeout
with (
path.open("rb") as file_handle,
httpx.Client(trust_env=False, verify=verify) as client,
):
files = {
"zip_file": (path.name, file_handle, "application/zip"),
}
return client.request(method, url, files=files, **request_kwargs)
def login_to_tool_repository(
self, user_identifier: str | None = None
) -> httpx.Response:
@@ -312,6 +343,46 @@ class PlusAPI:
def create_crew(self, payload: CreateCrewPayload) -> httpx.Response:
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
def create_crew_from_zip(
self,
zip_file_path: str | Path,
*,
name: str | None = None,
env: dict[str, str] | None = None,
) -> httpx.Response:
"""Create a crew deployment from a local project ZIP archive."""
data: dict[str, str] = {}
if name:
data["name"] = name
if env:
data.update({f"env[{key}]": value for key, value in env.items()})
return self._make_multipart_request(
"POST",
f"{self.CREWS_RESOURCE}/zip",
zip_file_path=zip_file_path,
data=data or None,
timeout=300,
)
def update_crew_from_zip(
self,
uuid: str,
zip_file_path: str | Path,
*,
env: dict[str, str] | None = None,
) -> httpx.Response:
"""Update an existing crew deployment from a local project ZIP archive."""
data: dict[str, str] = {}
if env:
data.update({f"env[{key}]": value for key, value in env.items()})
return self._make_multipart_request(
"POST",
f"{self.CREWS_RESOURCE}/{uuid}/zip_update",
zip_file_path=zip_file_path,
data=data or None,
timeout=300,
)
def get_organizations(self) -> httpx.Response:
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Iterator
import inspect
import json
import mimetypes
from pathlib import Path
from typing import Annotated, Any, BinaryIO, Protocol, cast, runtime_checkable
@@ -23,6 +24,9 @@ from typing_extensions import TypeIs
from crewai_files.core.constants import DEFAULT_MAX_FILE_SIZE_BYTES, MAGIC_BUFFER_SIZE
OCTET_STREAM = "application/octet-stream"
@runtime_checkable
class AsyncReadable(Protocol):
"""Protocol for async readable streams."""
@@ -56,13 +60,51 @@ class _AsyncReadableValidator:
ValidatedAsyncReadable = Annotated[AsyncReadable, _AsyncReadableValidator()]
def _fallback_content_type(filename: str | None) -> str:
"""Get content type from filename extension or return default."""
def _detect_content_type_from_bytes(data: bytes) -> str | None:
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if data.startswith(b"%PDF-"):
return "application/pdf"
try:
decoded = data.decode("utf-8")
except UnicodeDecodeError:
return None
stripped = decoded.lstrip()
if stripped.startswith(("{", "[")):
try:
json.loads(decoded)
return "application/json"
except json.JSONDecodeError:
pass
if "\x00" not in decoded:
return "text/plain"
return None
def _fallback_content_type(filename: str | None, data: bytes | None = None) -> str:
"""Get content type from filename extension, then content sniffing.
The extension lookup runs first so specific types like ``text/csv`` or
``application/xml`` are not degraded to generic sniffed types such as
``text/plain``; byte sniffing only covers extensionless/unknown names.
"""
if filename:
mime_type, _ = mimetypes.guess_type(filename)
if mime_type:
return mime_type
return "application/octet-stream"
if data:
content_type = _detect_content_type_from_bytes(data)
if content_type:
return content_type
return OCTET_STREAM
def generate_filename(content_type: str) -> str:
@@ -97,9 +139,19 @@ def detect_content_type(data: bytes, filename: str | None = None) -> str:
import magic
result: str = magic.from_buffer(data[:MAGIC_BUFFER_SIZE], mime=True)
return result
if result != OCTET_STREAM:
return result
return _fallback_content_type(filename, data)
except ImportError:
return _fallback_content_type(filename)
return _fallback_content_type(filename, data)
def _read_magic_header(path: Path) -> bytes | None:
try:
with path.open("rb") as file:
return file.read(MAGIC_BUFFER_SIZE)
except OSError:
return None
def detect_content_type_from_path(path: Path, filename: str | None = None) -> str:
@@ -115,13 +167,16 @@ def detect_content_type_from_path(path: Path, filename: str | None = None) -> st
Returns:
The detected MIME type.
"""
fallback_filename = filename or path.name
try:
import magic
result: str = magic.from_file(str(path), mime=True)
return result
if result != OCTET_STREAM:
return result
return _fallback_content_type(fallback_filename, _read_magic_header(path))
except ImportError:
return _fallback_content_type(filename or path.name)
return _fallback_content_type(fallback_filename, _read_magic_header(path))
class _BinaryIOValidator:

View File

@@ -129,6 +129,20 @@ class FileResolver:
"""
return constraints is not None and constraints.supports_url_references
@classmethod
def _should_resolve_as_url_reference(
cls,
file: FileInput,
provider: ProviderType,
constraints: ProviderConstraints | None,
) -> bool:
"""Check if the provider can accept the current URL source directly."""
if not cls._is_url_source(file) or not cls._supports_url(constraints):
return False
provider_lower = provider.lower()
return "bedrock" not in provider_lower and "aws" not in provider_lower
@staticmethod
def _resolve_as_url(file: FileInput) -> UrlReference:
"""Resolve a URL source as UrlReference.
@@ -159,7 +173,7 @@ class FileResolver:
"""
constraints = get_constraints_for_provider(provider)
if self._is_url_source(file) and self._supports_url(constraints):
if self._should_resolve_as_url_reference(file, provider, constraints):
return self._resolve_as_url(file)
context = self._build_file_context(file)
@@ -424,7 +438,7 @@ class FileResolver:
"""
constraints = get_constraints_for_provider(provider)
if self._is_url_source(file) and self._supports_url(constraints):
if self._should_resolve_as_url_reference(file, provider, constraints):
return self._resolve_as_url(file)
context = self._build_file_context(file)

View File

@@ -0,0 +1,23 @@
import logging
from typing import Literal
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
logger = logging.getLogger("lead_flow")
class LogLeadInput(BaseModel):
message: str = Field(description="The message to log.")
level: Literal["debug", "info", "warning", "error"] = "info"
class LogLeadTool(BaseTool):
name: str = "log_lead"
description: str = "Log a message about a lead that was not pursued."
args_schema: type[BaseModel] = LogLeadInput
def _run(self, message: str, level: str = "info") -> str:
logger.log(logging.getLevelName(level.upper()), message)
return message

View File

@@ -0,0 +1,98 @@
# uv run --project lib/crewai crewai run --definition lib/crewai/examples/flows/lead_scoring_flow.yaml --inputs '{"lead":{"name":"Dana Lee","company":"Acme","employees":1200}}'
# uv run --project lib/crewai crewai run --definition lib/crewai/examples/flows/lead_scoring_flow.yaml --inputs '{"lead":{"name":"Sam Poe","company":"Tiny LLC","employees":3}}'
schema: crewai.flow/v1
name: LeadScoringFlow
description: Score an inbound lead, then route high-scoring leads to outreach and the rest to a log tool.
state:
type: dict
default:
lead: {}
methods:
score_lead:
start: true
do:
call: crew
with:
name: lead_scoring_crew
verbose: true
agents:
scorer:
role: Lead Qualification Analyst
goal: Assign a 0-100 fit score to inbound lead {name} from {company}
backstory: >
A revenue-ops veteran who scores leads against a clear ideal
customer profile: company size is the dominant signal.
tasks:
- name: score_lead_task
agent: scorer
description: >
Evaluate the inbound lead {name} from {company} ({employees}
employees) against this rubric, where company size dominates:
1000+ employees scores 85-100 (hot), 200-999 scores 70-84 (warm),
and under 200 scores 0-69 (cold). Return an integer score with a
one-line rationale.
expected_output: >
A LeadScore with an integer `score` (0-100), a short `reasoning`,
and a `tier` of "hot", "warm", or "cold".
output_pydantic:
type: object
properties:
score:
type: integer
reasoning:
type: string
tier:
type: string
enum: [hot, warm, cold]
required: [score, reasoning, tier]
inputs:
name: "${state.lead.name}"
company: "${state.lead.company}"
employees: "${state.lead.employees}"
route_by_score:
listen: score_lead
router: true
emit: [qualified, unqualified]
do:
call: expression
expr: "outputs.score_lead.pydantic.score >= 80 ? 'qualified' : 'unqualified'"
run_outreach:
listen: qualified
do:
call: crew
with:
name: outreach_crew
verbose: true
agents:
sdr:
role: Outbound SDR
goal: Draft a tailored first-touch email to {name} at {company}
backstory: >
A top-performing SDR who writes concise, personalized outreach
that earns replies from busy buyers.
tasks:
- name: draft_outreach_task
agent: sdr
description: >
Write a short, personalized first-touch email to {name} at
{company}. Ground the hook in this qualification rationale:
"{reasoning}".
expected_output: A ready-to-send outreach email with a subject line and body.
inputs:
name: "${state.lead.name}"
company: "${state.lead.company}"
reasoning: "${outputs.score_lead.pydantic.reasoning}"
log_unqualified:
listen: unqualified
do:
call: tool
ref: lead_flow.tools:LogLeadTool
with:
message: "${'Skipped low-fit lead ' + state.lead.name + ' (score ' + string(outputs.score_lead.pydantic.score) + ')'}"
level: info

View File

@@ -758,6 +758,31 @@ class Agent(BaseAgent):
self._check_execution_error(e, task)
return await self.aexecute_task(task, context, tools)
def message(self, content: str, **kwargs: Any) -> str:
"""Send a single message and get a response.
Creates a temporary Task + Crew, executes, and returns the raw output.
"""
from crewai.crew import Crew
from crewai.task import Task
from crewai.types.streaming import CrewStreamingOutput
task = Task(
description=content,
expected_output="Respond to the user's message appropriately.",
agent=self,
)
crew = Crew(
agents=[self],
tasks=[task],
verbose=self.verbose,
memory=self.memory or False,
)
result = crew.kickoff()
if isinstance(result, CrewStreamingOutput):
return result.result.raw
return result.raw
def execute_task(
self,
task: Task,

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
from typing import Literal
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, BeforeValidator, Field
from crewai.agents.agent_builder.base_agent import _validate_llm_ref
from crewai.llms.base_llm import BaseLLM
@@ -69,7 +70,7 @@ class PlanningConfig(BaseModel):
max_attempts=3,
max_steps=10,
plan_prompt="Create a focused plan for: {description}",
llm="gpt-4o-mini",
llm="gpt-5.4-mini",
),
)
```
@@ -139,7 +140,10 @@ class PlanningConfig(BaseModel):
"whether to continue or replan. None means no per-step timeout."
),
)
llm: str | BaseLLM | None = Field(
llm: Annotated[
str | BaseLLM | None,
BeforeValidator(_validate_llm_ref),
] = Field(
default=None,
description="LLM to use for planning. Uses agent's LLM if None.",
)

View File

@@ -81,7 +81,7 @@ class OpenAIAgentAdapter(BaseAgentAdapter):
Raises:
ImportError: If OpenAI agent dependencies are not installed.
"""
self.llm = kwargs.pop("model", "gpt-4o-mini")
self.llm = kwargs.pop("model", "gpt-5.4-mini")
super().__init__(**kwargs)
self._tool_adapter = OpenAIAgentToolAdapter(tools=kwargs.get("tools"))
self._converter_adapter = OpenAIConverterAdapter(agent_adapter=self)

View File

@@ -85,9 +85,28 @@ def _validate_llm_ref(value: Any) -> Any:
import inspect
llm_type = value.get("llm_type")
if not llm_type or llm_type not in _LLM_TYPE_REGISTRY:
if not llm_type:
model = (
value.get("model")
or value.get("model_name")
or value.get("deployment_name")
)
if not model:
raise ValueError(
"LLM config objects must include 'model', 'model_name', "
"or 'deployment_name', or a serialized 'llm_type'. "
f"Got keys: {list(value)}"
)
from crewai.llm import LLM
llm_kwargs = {**value, "model": model}
llm_kwargs.pop("model_name", None)
llm_kwargs.pop("deployment_name", None)
return LLM(**llm_kwargs)
if llm_type not in _LLM_TYPE_REGISTRY:
raise ValueError(
f"Unknown or missing llm_type: {llm_type!r}. "
f"Unknown llm_type: {llm_type!r}. "
f"Expected one of {list(_LLM_TYPE_REGISTRY)}"
)
dotted = _LLM_TYPE_REGISTRY[llm_type]
@@ -618,7 +637,10 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
if self.memory is True:
from crewai.memory.unified_memory import Memory
self.memory = Memory()
memory_kwargs: dict[str, Any] = {}
if self.llm is not None:
memory_kwargs["llm"] = self.llm
self.memory = Memory(**memory_kwargs)
elif self.memory is False:
self.memory = None
return self

View File

@@ -53,6 +53,7 @@ from crewai.types.callback import SerializableCallable
from crewai.utilities.agent_utils import (
_llm_stop_words_applied,
aget_llm_response,
build_text_tool_calling_fallback_message,
convert_tools_to_openai_schema,
enforce_rpm_limit,
format_message_for_llm,
@@ -64,6 +65,7 @@ from crewai.utilities.agent_utils import (
handle_unknown_error,
has_reached_max_iterations,
is_context_length_exceeded,
is_native_tool_calling_unsupported_error,
parse_tool_call_args,
process_llm_response,
track_delegation_if_needed,
@@ -464,6 +466,20 @@ class CrewAgentExecutor(BaseAgentExecutor):
self._show_logs(formatted_answer)
return formatted_answer
def _append_text_tool_calling_fallback_message(self) -> None:
"""Add text tool-calling instructions after native tools are rejected."""
if not self.tools:
return
self.messages.append(
format_message_for_llm(
build_text_tool_calling_fallback_message(
self.tools_description,
self.tools_names,
),
role="user",
)
)
def _invoke_loop_native_tools(self) -> AgentFinish:
"""Execute agent loop using native function calling.
@@ -557,6 +573,9 @@ class CrewAgentExecutor(BaseAgentExecutor):
return formatted_answer
except Exception as e:
if is_native_tool_calling_unsupported_error(e):
self._append_text_tool_calling_fallback_message()
return self._invoke_loop_react()
if e.__class__.__module__.startswith("litellm"):
raise e
if is_context_length_exceeded(e):
@@ -1369,6 +1388,9 @@ class CrewAgentExecutor(BaseAgentExecutor):
return formatted_answer
except Exception as e:
if is_native_tool_calling_unsupported_error(e):
self._append_text_tool_calling_fallback_message()
return await self._ainvoke_loop_react()
if e.__class__.__module__.startswith("litellm"):
raise e
if is_context_length_exceeded(e):

View File

@@ -29,14 +29,17 @@ from crewai.events.types.tool_usage_events import (
ToolUsageStartedEvent,
)
from crewai.utilities.agent_utils import (
build_text_tool_calling_fallback_message,
build_tool_calls_assistant_message,
check_native_tool_support,
enforce_rpm_limit,
execute_single_native_tool_call,
extract_task_section,
format_message_for_llm,
is_native_tool_calling_unsupported_error,
is_tool_call_list,
process_llm_response,
render_text_description_and_args,
setup_native_tools,
)
from crewai.utilities.i18n import I18N_DEFAULT
@@ -153,6 +156,7 @@ class StepExecutor:
if self._use_native_tools:
result_text = self._execute_native(
messages,
todo,
tool_calls_made,
max_step_iterations=max_step_iterations,
step_timeout=step_timeout,
@@ -161,6 +165,7 @@ class StepExecutor:
else:
result_text = self._execute_text_parsed(
messages,
todo,
tool_calls_made,
max_step_iterations=max_step_iterations,
step_timeout=step_timeout,
@@ -176,6 +181,46 @@ class StepExecutor:
execution_time=elapsed,
)
except Exception as e:
if self._use_native_tools and is_native_tool_calling_unsupported_error(e):
try:
self._use_native_tools = False
self._openai_tools = []
self._available_functions = {}
# Keep the conversation built so far (including any native
# tool round-trips already appended to ``messages``) and
# append the text-tooling instructions instead of
# restarting the step, so completed tool calls are not
# re-executed against a fresh context.
messages.append(
format_message_for_llm(
build_text_tool_calling_fallback_message(
render_text_description_and_args(self.tools),
", ".join(
sanitize_tool_name(t.name) for t in self.tools
),
),
role="user",
)
)
result_text = self._execute_text_parsed(
messages,
todo,
tool_calls_made,
max_step_iterations=max_step_iterations,
step_timeout=step_timeout,
start_time=start_time,
)
self._validate_expected_tool_usage(todo, tool_calls_made)
elapsed = time.monotonic() - start_time
return StepResult(
success=True,
result=result_text,
tool_calls_made=tool_calls_made,
execution_time=elapsed,
)
except Exception as fallback_error:
e = fallback_error
elapsed = time.monotonic() - start_time
return StepResult(
success=False,
@@ -272,6 +317,7 @@ class StepExecutor:
def _execute_text_parsed(
self,
messages: list[LLMMessage],
todo: TodoItem,
tool_calls_made: list[str],
max_step_iterations: int = 15,
step_timeout: int | None = None,
@@ -310,7 +356,7 @@ class StepExecutor:
if isinstance(formatted, AgentAction):
tool_calls_made.append(formatted.tool)
tool_result = self._execute_text_tool_with_events(formatted)
tool_result = self._execute_text_tool_with_events(formatted, todo)
last_tool_result = tool_result
messages.append({"role": "assistant", "content": answer_str})
messages.append(self._build_observation_message(tool_result))
@@ -320,7 +366,9 @@ class StepExecutor:
return last_tool_result
def _execute_text_tool_with_events(self, formatted: AgentAction) -> str:
def _execute_text_tool_with_events(
self, formatted: AgentAction, todo: TodoItem
) -> str:
"""Execute text-parsed tool calls with tool usage events."""
args_dict = self._parse_tool_args(formatted.tool_input)
agent_key = getattr(self.agent, "key", "unknown") if self.agent else "unknown"
@@ -333,6 +381,8 @@ class StepExecutor:
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
plan_step_number=todo.step_number,
plan_step_description=todo.description,
),
)
@@ -368,6 +418,8 @@ class StepExecutor:
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
plan_step_number=todo.step_number,
plan_step_description=todo.description,
error=e,
),
)
@@ -382,6 +434,8 @@ class StepExecutor:
from_agent=self.agent,
from_task=self.task,
agent_key=agent_key,
plan_step_number=todo.step_number,
plan_step_description=todo.description,
started_at=started_at,
finished_at=datetime.now(),
),
@@ -474,6 +528,7 @@ class StepExecutor:
def _execute_native(
self,
messages: list[LLMMessage],
todo: TodoItem,
tool_calls_made: list[str],
max_step_iterations: int = 15,
step_timeout: int | None = None,
@@ -513,7 +568,7 @@ class StepExecutor:
if isinstance(answer, list) and answer and is_tool_call_list(answer):
result = self._execute_native_tool_calls(
answer, messages, tool_calls_made
answer, messages, todo, tool_calls_made
)
accumulated_results.append(result)
continue
@@ -526,6 +581,7 @@ class StepExecutor:
self,
tool_calls: list[Any],
messages: list[LLMMessage],
todo: TodoItem,
tool_calls_made: list[str],
) -> str:
"""Execute a batch of native tool calls and return their results.
@@ -551,6 +607,8 @@ class StepExecutor:
event_source=self,
printer=PRINTER,
verbose=bool(self.agent and self.agent.verbose),
plan_step_number=todo.step_number,
plan_step_description=todo.description,
)
if call_result.func_name:

View File

@@ -658,7 +658,14 @@ class Crew(FlowTrackable, BaseModel):
from crewai.rag.embeddings.factory import build_embedder
embedder = build_embedder(cast(dict[str, Any], self.embedder))
self._memory = Memory(embedder=embedder, root_scope=crew_root_scope)
memory_kwargs: dict[str, Any] = {
"embedder": embedder,
"root_scope": crew_root_scope,
}
memory_llm = self._memory_llm()
if memory_llm is not None:
memory_kwargs["llm"] = memory_llm
self._memory = Memory(**memory_kwargs)
elif self.memory:
# User passed a Memory / MemoryScope / MemorySlice instance
# Respect user's configuration — don't auto-set root_scope
@@ -668,6 +675,16 @@ class Crew(FlowTrackable, BaseModel):
return self
def _memory_llm(self) -> str | BaseLLM | None:
"""Return the LLM auto-created memory should use for analysis."""
if self.chat_llm is not None:
return self.chat_llm
for agent in self.agents:
agent_llm: str | BaseLLM | None = getattr(agent, "llm", None)
if agent_llm is not None:
return agent_llm
return None
@model_validator(mode="after")
def create_crew_knowledge(self) -> Crew:
"""Create the knowledge for the crew."""

View File

@@ -116,6 +116,11 @@ if TYPE_CHECKING:
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.events.types.observation_events import (
PlanStepCompletedEvent,
PlanStepEvent,
PlanStepStartedEvent,
)
from crewai.events.types.reasoning_events import (
AgentReasoningCompletedEvent,
AgentReasoningFailedEvent,
@@ -220,6 +225,9 @@ _LAZY_EVENT_MAPPING: dict[str, str] = {
"MemorySaveCompletedEvent": "crewai.events.types.memory_events",
"MemorySaveFailedEvent": "crewai.events.types.memory_events",
"MemorySaveStartedEvent": "crewai.events.types.memory_events",
"PlanStepCompletedEvent": "crewai.events.types.observation_events",
"PlanStepEvent": "crewai.events.types.observation_events",
"PlanStepStartedEvent": "crewai.events.types.observation_events",
"AgentReasoningCompletedEvent": "crewai.events.types.reasoning_events",
"AgentReasoningFailedEvent": "crewai.events.types.reasoning_events",
"AgentReasoningStartedEvent": "crewai.events.types.reasoning_events",
@@ -349,6 +357,9 @@ __all__ = [
"MethodExecutionFailedEvent",
"MethodExecutionFinishedEvent",
"MethodExecutionStartedEvent",
"PlanStepCompletedEvent",
"PlanStepEvent",
"PlanStepStartedEvent",
"ReasoningEvent",
"SkillActivatedEvent",
"SkillDiscoveryCompletedEvent",

View File

@@ -99,6 +99,10 @@ from crewai.events.types.memory_events import (
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.events.types.observation_events import (
PlanStepCompletedEvent,
PlanStepStartedEvent,
)
from crewai.events.types.reasoning_events import (
AgentReasoningCompletedEvent,
AgentReasoningFailedEvent,
@@ -191,6 +195,8 @@ EventTypes = (
| MemoryRetrievalStartedEvent
| MemoryRetrievalCompletedEvent
| MemoryRetrievalFailedEvent
| PlanStepStartedEvent
| PlanStepCompletedEvent
| MCPConnectionStartedEvent
| MCPConnectionCompletedEvent
| MCPConnectionFailedEvent

View File

@@ -24,6 +24,7 @@ from crewai.events.listeners.tracing.types import TraceEvent
from crewai.events.listeners.tracing.utils import (
get_user_id,
is_tracing_enabled_in_context,
is_tui_mode,
should_auto_collect_first_time_traces,
)
from crewai.plus_api import PlusAPI
@@ -74,6 +75,7 @@ class TraceBatchManager:
self.defer_session_finalization: bool = False
self._batch_finalized: bool = False
self.backend_initialized: bool = False
self.trace_url: str | None = None
self.ephemeral_trace_url: str | None = None
try:
self.plus_api = PlusAPI(
@@ -108,7 +110,9 @@ class TraceBatchManager:
self.record_start_time("execution")
if should_auto_collect_first_time_traces():
if should_auto_collect_first_time_traces() or (
is_tui_mode() and not is_tracing_enabled_in_context()
):
self.trace_batch_id = self.current_batch.batch_id
else:
self._initialize_backend_batch(
@@ -411,6 +415,7 @@ class TraceBatchManager:
else f"{base_url}/crewai_plus/ephemeral_trace_batches/{batch_id}?access_code={access_code}"
)
self.trace_url = return_link
if is_ephemeral:
self.ephemeral_trace_url = return_link
@@ -428,7 +433,10 @@ class TraceBatchManager:
title="Trace Batch Finalization",
border_style="green",
)
if not should_auto_collect_first_time_traces():
if (
not should_auto_collect_first_time_traces()
and not is_tui_mode()
):
console.print(panel)
return True

View File

@@ -18,6 +18,7 @@ from crewai.events.listeners.tracing.trace_batch_manager import TraceBatchManage
from crewai.events.listeners.tracing.types import TraceEvent
from crewai.events.listeners.tracing.utils import (
is_tracing_enabled_in_context,
is_tui_mode,
safe_serialize_to_dict,
should_auto_collect_first_time_traces,
should_enable_tracing,
@@ -212,8 +213,8 @@ class TraceCollectionListener(BaseEventListener):
not should_enable_tracing()
and not is_tracing_enabled_in_context()
and not should_auto_collect_first_time_traces()
and not is_tui_mode()
):
self._listeners_setup = True
return
self._register_flow_event_handlers(crewai_event_bus)
@@ -297,6 +298,12 @@ class TraceCollectionListener(BaseEventListener):
if self._nested_in_flow_execution():
return
if self.batch_manager.batch_owner_type == "crew":
if is_tui_mode():
if self.first_time_handler.is_first_time:
self.first_time_handler.mark_events_collected()
elif is_tracing_enabled_in_context() or should_enable_tracing():
self.batch_manager.finalize_batch()
return
if self.first_time_handler.is_first_time:
self.first_time_handler.mark_events_collected()
self.first_time_handler.handle_execution_completion()
@@ -310,6 +317,12 @@ class TraceCollectionListener(BaseEventListener):
return
if self._nested_in_flow_execution():
return
if is_tui_mode():
if self.first_time_handler.is_first_time:
self.first_time_handler.mark_events_collected()
elif is_tracing_enabled_in_context() or should_enable_tracing():
self.batch_manager.finalize_batch()
return
if self.first_time_handler.is_first_time:
self.first_time_handler.mark_events_collected()
self.first_time_handler.handle_execution_completion()

View File

@@ -42,6 +42,7 @@ __all__ = [
"is_first_execution",
"is_tracing_enabled",
"is_tracing_enabled_in_context",
"is_tui_mode",
"mark_first_execution_completed",
"mark_first_execution_done",
"on_first_execution_tracing_confirmation",
@@ -50,6 +51,7 @@ __all__ = [
"safe_serialize_to_dict",
"set_suppress_tracing_messages",
"set_tracing_enabled",
"set_tui_mode",
"should_auto_collect_first_time_traces",
"should_enable_tracing",
"should_suppress_tracing_messages",
@@ -71,6 +73,16 @@ _suppress_tracing_messages: ContextVar[bool] = ContextVar(
"_suppress_tracing_messages", default=False
)
_tui_mode: ContextVar[bool] = ContextVar("_tui_mode", default=False)
def set_tui_mode(enabled: bool) -> object:
return _tui_mode.set(enabled)
def is_tui_mode() -> bool:
return _tui_mode.get()
def set_suppress_tracing_messages(suppress: bool) -> object:
"""Set whether to suppress tracing-related console messages.

View File

@@ -26,6 +26,38 @@ class ObservationEvent(BaseEvent):
self._set_agent_params(data)
class PlanStepEvent(BaseEvent):
"""Base event for authoritative plan step lifecycle updates."""
type: str
agent_role: str
step_number: int
step_description: str = ""
tool_to_use: str | None = None
from_task: Any | None = None
from_agent: Any | None = None
def __init__(self, **data: Any) -> None:
super().__init__(**data)
self._set_task_params(data)
self._set_agent_params(data)
class PlanStepStartedEvent(PlanStepEvent):
"""Emitted when a concrete plan step starts executing."""
type: Literal["plan_step_started"] = "plan_step_started"
class PlanStepCompletedEvent(PlanStepEvent):
"""Emitted when a concrete plan step reaches a terminal state."""
type: Literal["plan_step_completed"] = "plan_step_completed"
success: bool = True
result: str | None = None
error: str | None = None
class StepObservationStartedEvent(ObservationEvent):
"""Emitted when the Planner begins observing a step's result.

View File

@@ -21,6 +21,8 @@ class ToolUsageEvent(BaseEvent):
agent: Any | None = None
task_name: str | None = None
task_id: str | None = None
plan_step_number: int | None = None
plan_step_description: str | None = None
from_task: Any | None = None
from_agent: Any | None = None

View File

@@ -46,6 +46,8 @@ from crewai.events.types.observation_events import (
GoalAchievedEarlyEvent,
PlanRefinementEvent,
PlanReplanTriggeredEvent,
PlanStepCompletedEvent,
PlanStepStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
@@ -73,6 +75,7 @@ from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.utilities.agent_utils import (
_llm_stop_words_applied,
build_text_tool_calling_fallback_message,
check_native_tool_support,
enforce_rpm_limit,
extract_tool_call_info,
@@ -86,6 +89,7 @@ from crewai.utilities.agent_utils import (
has_reached_max_iterations,
is_context_length_exceeded,
is_inside_event_loop,
is_native_tool_calling_unsupported_error,
is_tool_call_list,
parse_tool_call_args,
process_llm_response,
@@ -241,6 +245,23 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
self._tool_name_mapping,
) = setup_native_tools(self.original_tools)
def _downgrade_to_text_tool_calling(self) -> None:
"""Switch a running execution from native tools to text tool calls."""
self.state.use_native_tools = False
self.state.pending_tool_calls.clear()
self._openai_tools = []
self._available_functions = {}
if self.tools:
self.state.messages.append(
format_message_for_llm(
build_text_tool_calling_fallback_message(
self.tools_description,
self.tools_names,
),
role="user",
)
)
def _is_tool_call_list(self, response: list[Any]) -> bool:
"""Check if a response is a list of tool calls."""
return is_tool_call_list(response)
@@ -349,6 +370,84 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
self.state.todos = TodoList(items=todos)
def _emit_plan_step_started(self, todo: TodoItem) -> None:
try:
crewai_event_bus.emit(
self.agent,
event=PlanStepStartedEvent(
agent_role=self.agent.role,
step_number=todo.step_number,
step_description=todo.description,
tool_to_use=todo.tool_to_use,
from_task=self.task,
from_agent=self.agent,
),
)
except Exception: # noqa: S110
pass
def _emit_plan_step_completed(
self,
todo: TodoItem,
*,
success: bool,
result: str | None = None,
error: str | None = None,
) -> None:
try:
crewai_event_bus.emit(
self.agent,
event=PlanStepCompletedEvent(
agent_role=self.agent.role,
step_number=todo.step_number,
step_description=todo.description,
tool_to_use=todo.tool_to_use,
success=success,
result=result,
error=error,
from_task=self.task,
from_agent=self.agent,
),
)
except Exception: # noqa: S110
pass
def _mark_todo_running(self, todo: TodoItem) -> None:
previous_status = todo.status
self.state.todos.mark_running(todo.step_number)
if previous_status != "running":
self._emit_plan_step_started(todo)
def _mark_todo_completed(
self,
step_number: int,
result: str | None = None,
) -> None:
todo = self.state.todos.get_by_step_number(step_number)
previous_status = todo.status if todo else None
self.state.todos.mark_completed(step_number, result=result)
todo = self.state.todos.get_by_step_number(step_number)
if todo and previous_status != "completed":
self._emit_plan_step_completed(todo, success=True, result=result)
def _mark_todo_failed(
self,
step_number: int,
result: str | None = None,
error: str | None = None,
) -> None:
todo = self.state.todos.get_by_step_number(step_number)
previous_status = todo.status if todo else None
self.state.todos.mark_failed(step_number, result=result)
todo = self.state.todos.get_by_step_number(step_number)
if todo and previous_status != "failed":
self._emit_plan_step_completed(
todo,
success=False,
result=result,
error=error,
)
def _ensure_step_executor(self) -> Any:
"""Lazily create the StepExecutor (avoids circular imports)."""
if self._step_executor is None:
@@ -597,8 +696,10 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
and not observation.step_completed_successfully
and observation.needs_full_replan
):
self.state.todos.mark_failed(
current_todo.step_number, result=current_todo.result
self._mark_todo_failed(
current_todo.step_number,
result=current_todo.result,
error=observation.replan_reason,
)
if self.agent.verbose:
PRINTER.print(
@@ -614,8 +715,9 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
return "replan_now"
if observation and not observation.step_completed_successfully:
self.state.todos.mark_failed(
current_todo.step_number, result=current_todo.result
self._mark_todo_failed(
current_todo.step_number,
result=current_todo.result,
)
if self.agent.verbose:
failed = len(self.state.todos.get_failed_todos())
@@ -629,9 +731,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
)
return "continue_plan"
self.state.todos.mark_completed(
current_todo.step_number, result=current_todo.result
)
self._mark_todo_completed(current_todo.step_number, result=current_todo.result)
if self.agent.verbose:
completed = self.state.todos.completed_count
@@ -661,7 +761,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
# If observation is missing or step succeeded — continue
if not observation or observation.step_completed_successfully:
self.state.todos.mark_completed(
self._mark_todo_completed(
current_todo.step_number, result=current_todo.result
)
if self.agent.verbose:
@@ -676,8 +776,10 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
# Step failed — only replan if observer explicitly requires it,
# otherwise mark done and continue (same gate as low-effort).
if observation.needs_full_replan:
self.state.todos.mark_failed(
current_todo.step_number, result=current_todo.result
self._mark_todo_failed(
current_todo.step_number,
result=current_todo.result,
error=observation.replan_reason,
)
if self.agent.verbose:
PRINTER.print(
@@ -694,9 +796,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
# Step failed but observer does not require a full replan — mark as
# failed (not completed) so get_failed_todos() tracks it correctly.
self.state.todos.mark_failed(
current_todo.step_number, result=current_todo.result
)
self._mark_todo_failed(current_todo.step_number, result=current_todo.result)
if self.agent.verbose:
failed = len(self.state.todos.get_failed_todos())
total = len(self.state.todos.items)
@@ -731,12 +831,12 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
observation = self.state.observations.get(current_todo.step_number)
if not observation:
# No observation available — default to continue
self.state.todos.mark_completed(current_todo.step_number)
self._mark_todo_completed(current_todo.step_number)
return "continue_plan"
# Goal already achieved — early termination
if observation.goal_already_achieved:
self.state.todos.mark_completed(
self._mark_todo_completed(
current_todo.step_number, result=current_todo.result
)
if self.agent.verbose:
@@ -748,8 +848,10 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
# Full replan needed
if observation.needs_full_replan:
self.state.todos.mark_failed(
current_todo.step_number, result=current_todo.result
self._mark_todo_failed(
current_todo.step_number,
result=current_todo.result,
error=observation.replan_reason,
)
if self.agent.verbose:
PRINTER.print(
@@ -761,9 +863,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
# Step failed — also trigger replan
if not observation.step_completed_successfully:
self.state.todos.mark_failed(
current_todo.step_number, result=current_todo.result
)
self._mark_todo_failed(current_todo.step_number, result=current_todo.result)
if self.agent.verbose:
PRINTER.print(
content="[Decide] Step failed — triggering replan",
@@ -773,7 +873,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
return "replan_now"
if observation.remaining_plan_still_valid and observation.suggested_refinements:
self.state.todos.mark_completed(
self._mark_todo_completed(
current_todo.step_number, result=current_todo.result
)
if self.agent.verbose:
@@ -783,9 +883,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
)
return "refine_and_continue"
self.state.todos.mark_completed(
current_todo.step_number, result=current_todo.result
)
self._mark_todo_completed(current_todo.step_number, result=current_todo.result)
if self.agent.verbose:
completed = self.state.todos.completed_count
total = len(self.state.todos.items)
@@ -961,7 +1059,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
return "needs_replan"
if len(ready) == 1:
self.state.todos.mark_running(ready[0].step_number)
self._mark_todo_running(ready[0])
return "single_todo_ready"
return "multiple_todos_ready"
@@ -1099,7 +1197,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
# Mark all ready todos as running
for todo in ready:
self.state.todos.mark_running(todo.step_number)
self._mark_todo_running(todo)
# Build context and executor for each todo, then run in parallel
async def _run_step(todo: TodoItem) -> tuple[TodoItem, object]:
@@ -1127,7 +1225,11 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
if isinstance(item, BaseException):
error_msg = f"Error: {item!s}"
todo.result = error_msg
self.state.todos.mark_failed(todo.step_number, result=error_msg)
self._mark_todo_failed(
todo.step_number,
result=error_msg,
error=error_msg,
)
if self.agent.verbose:
PRINTER.print(
content=f"Todo {todo.step_number} failed: {error_msg}",
@@ -1197,9 +1299,9 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
# Mark based on observation result
if observation.step_completed_successfully:
self.state.todos.mark_completed(todo.step_number, result=todo.result)
self._mark_todo_completed(todo.step_number, result=todo.result)
else:
self.state.todos.mark_failed(todo.step_number, result=todo.result)
self._mark_todo_failed(todo.step_number, result=todo.result)
if self.agent.verbose:
PRINTER.print(
@@ -1349,7 +1451,11 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
def call_llm_native_tools(
self,
) -> Literal[
"native_tool_calls", "native_finished", "context_error", "todo_satisfied"
"native_tool_calls",
"native_finished",
"context_error",
"todo_satisfied",
"continue_reasoning",
]:
"""Execute LLM call with native function calling.
@@ -1428,6 +1534,9 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
return self._route_finish_with_todos("native_finished")
except Exception as e:
if is_native_tool_calling_unsupported_error(e):
self._downgrade_to_text_tool_calling()
return "continue_reasoning"
if is_context_length_exceeded(e):
self._last_context_error = e
return "context_error"
@@ -2085,7 +2194,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
step_number: The step number to mark.
result: The result of the todo.
"""
self.state.todos.mark_completed(step_number, result=result)
self._mark_todo_completed(step_number, result=result)
if self.agent.verbose:
completed = self.state.todos.completed_count

View File

@@ -20,7 +20,7 @@ Example:
@human_feedback(
message="Review this:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
llm="gpt-5.4-mini",
provider=SlackProvider(),
)
def review(self):

View File

@@ -47,7 +47,7 @@ class PendingFeedbackContext:
method_output={"title": "Draft", "body": "..."},
message="Please review and approve or reject:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
llm="gpt-5.4-mini",
)
```
"""

View File

@@ -23,7 +23,7 @@ __all__ = ["HumanFeedbackResult", "human_feedback"]
def human_feedback(
message: str,
emit: Sequence[str] | None = None,
llm: str | BaseLLM | None = "gpt-4o-mini",
llm: str | BaseLLM | None = "gpt-5.4-mini",
default_outcome: str | None = None,
metadata: dict[str, Any] | None = None,
provider: HumanFeedbackProvider | None = None,

View File

@@ -11,9 +11,17 @@ from __future__ import annotations
import json
import logging
import re
from typing import Any, Literal as TypingLiteral
from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
RootModel,
field_serializer,
model_validator,
)
import yaml
from crewai.flow.conversational_definition import (
@@ -25,6 +33,7 @@ from crewai.flow.conversational_definition import (
logger = logging.getLogger(__name__)
FlowDefinitionCondition = str | dict[str, Any]
_STEP_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
__all__ = [
"FlowActionDefinition",
@@ -35,6 +44,8 @@ __all__ = [
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowEachActionDefinition",
"FlowEachInnerActionDefinition",
"FlowExpressionActionDefinition",
"FlowHumanFeedbackDefinition",
"FlowMethodDefinition",
@@ -148,10 +159,11 @@ class FlowHumanFeedbackDefinition(BaseModel):
class FlowCodeActionDefinition(BaseModel):
"""A Flow method action that executes importable Python code."""
model_config = ConfigDict(extra="forbid")
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: TypingLiteral["code"] = "code"
ref: str
with_: dict[str, Any] | None = Field(default=None, alias="with")
class FlowToolActionDefinition(BaseModel):
@@ -173,14 +185,66 @@ class FlowExpressionActionDefinition(BaseModel):
expr: str
FlowActionDefinition = (
FlowInnerActionDefinition = (
FlowCodeActionDefinition | FlowToolActionDefinition | FlowExpressionActionDefinition
)
class FlowEachInnerActionDefinition(RootModel[dict[str, FlowInnerActionDefinition]]):
"""One named action inside an ``each`` composite action."""
@model_validator(mode="after")
def _validate_action_mapping(self) -> FlowEachInnerActionDefinition:
if len(self.root) != 1:
raise ValueError("each.do entries must be one-key mappings")
_validate_step_name(self.name, field="each.do action names")
return self
@property
def name(self) -> str:
return next(iter(self.root))
@property
def action(self) -> FlowInnerActionDefinition:
return next(iter(self.root.values()))
class FlowEachActionDefinition(BaseModel):
"""A composite action that runs a sequential mini-pipeline for each item."""
model_config = ConfigDict(populate_by_name=True, extra="forbid")
call: TypingLiteral["each"]
in_: str = Field(alias="in")
do: list[FlowEachInnerActionDefinition]
@model_validator(mode="after")
def _validate_inner_action_list(self) -> FlowEachActionDefinition:
if not self.do:
raise ValueError("each.do must contain at least one action")
seen: set[str] = set()
for inner_action in self.do:
name = inner_action.name
if name in seen:
raise ValueError(f"each.do action names must be unique: {name!r}")
seen.add(name)
return self
FlowActionDefinition = (
FlowCodeActionDefinition
| FlowToolActionDefinition
| FlowExpressionActionDefinition
| FlowEachActionDefinition
)
class FlowMethodDefinition(BaseModel):
"""Static definition of one Flow method and its execution roles."""
description: str | None = None
do: FlowActionDefinition
start: bool | FlowDefinitionCondition | None = None
listen: FlowDefinitionCondition | None = None
@@ -227,6 +291,12 @@ class FlowDefinition(BaseModel):
methods: dict[str, FlowMethodDefinition] = Field(default_factory=dict)
diagnostics: list[FlowDefinitionDiagnostic] = Field(default_factory=list)
@model_validator(mode="after")
def _validate_method_names(self) -> FlowDefinition:
for method_name in self.methods:
_validate_step_name(method_name, field="Flow method names")
return self
def to_dict(self, *, exclude_none: bool = True) -> dict[str, Any]:
"""Serialize the definition to a JSON/YAML-ready dictionary."""
return self.model_dump(by_alias=True, exclude_none=exclude_none, mode="json")
@@ -369,6 +439,11 @@ def _deserialize_diagnostics(value: Any) -> list[FlowDefinitionDiagnostic]:
return [FlowDefinitionDiagnostic.model_validate(item) for item in value or []]
def _validate_step_name(name: str, *, field: str) -> None:
if not isinstance(name, str) or not _STEP_NAME_PATTERN.fullmatch(name):
raise ValueError(f"{field} must match {_STEP_NAME_PATTERN.pattern}")
def _merge_diagnostics(
*diagnostic_groups: list[FlowDefinitionDiagnostic],
) -> list[FlowDefinitionDiagnostic]:

View File

@@ -20,7 +20,7 @@ Example (synchronous, default):
@human_feedback(
message="Please review this content:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
llm="gpt-5.4-mini",
)
def generate_content(self):
return {"title": "Article", "body": "Content..."}
@@ -48,7 +48,7 @@ Example (asynchronous with custom provider):
@human_feedback(
message="Review this:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
llm="gpt-5.4-mini",
provider=SlackProvider(),
)
def generate_content(self):
@@ -173,7 +173,7 @@ class HumanFeedbackConfig:
message: str
emit: Sequence[str] | None = None
llm: str | BaseLLM | None = "gpt-4o-mini"
llm: str | BaseLLM | None = "gpt-5.4-mini"
default_outcome: str | None = None
metadata: dict[str, Any] | None = None
provider: HumanFeedbackProvider | None = None
@@ -212,7 +212,7 @@ def _validate_human_feedback_options(
if not llm:
raise ValueError(
"llm is required when emit is specified. "
"Provide an LLM model string (e.g., 'gpt-4o-mini') or a BaseLLM instance. "
"Provide an LLM model string (e.g., 'gpt-5.4-mini') or a BaseLLM instance. "
"See the CrewAI Human-in-the-Loop (HITL) documentation for more information: "
"https://docs.crewai.com/en/learn/human-feedback-in-flows"
)
@@ -235,12 +235,12 @@ def _resolve_llm_instance(llm: Any) -> Any:
from crewai.llm import LLM
if llm is None:
return LLM(model="gpt-4o-mini")
return LLM(model="gpt-5.4-mini")
if isinstance(llm, str):
return LLM(model=llm)
if isinstance(llm, dict):
deserialized = _deserialize_llm_from_context(llm)
return deserialized if deserialized is not None else LLM(model="gpt-4o-mini")
return deserialized if deserialized is not None else LLM(model="gpt-5.4-mini")
return llm # already a BaseLLM instance
@@ -362,7 +362,7 @@ def _distill_and_store_lessons(
def human_feedback(
message: str,
emit: Sequence[str] | None = None,
llm: str | BaseLLM | None = "gpt-4o-mini",
llm: str | BaseLLM | None = "gpt-5.4-mini",
default_outcome: str | None = None,
metadata: dict[str, Any] | None = None,
provider: HumanFeedbackProvider | None = None,

View File

@@ -121,11 +121,8 @@ from crewai.flow.human_feedback import (
)
from crewai.flow.input_provider import InputProvider
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.runtime._resolvers import (
resolve_action,
resolve_instance_ref,
resolve_ref,
)
from crewai.flow.runtime._actions import build_action
from crewai.flow.runtime._refs import resolve_instance_ref, resolve_ref
from crewai.flow.types import (
FlowExecutionData,
FlowMethodName,
@@ -1092,9 +1089,9 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
self._methods.update(methods)
def _action_bound_methods(self) -> dict[FlowMethodName, Callable[..., Any]]:
def resolve(name: str, definition: FlowMethodDefinition) -> Callable[..., Any]:
def build(name: str, definition: FlowMethodDefinition) -> Callable[..., Any]:
try:
return resolve_action(self, definition.do)
return build_action(self, definition.do)
except Exception as e:
unresolved.append(f"{name}: {e}")
return lambda *args, **kwargs: None
@@ -1102,9 +1099,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
methods: dict[FlowMethodName, Callable[..., Any]] = {}
unresolved: list[str] = []
for method_name, method_definition in self._definition.methods.items():
methods[FlowMethodName(method_name)] = resolve(
method_name, method_definition
)
methods[FlowMethodName(method_name)] = build(method_name, method_definition)
if unresolved:
raise ValueError(
f"Cannot build flow {self._definition.name!r} from its definition; "

View File

@@ -0,0 +1,221 @@
"""Build FlowDefinition actions into live runtime callables."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
import contextvars
import inspect
from typing import TYPE_CHECKING, Any, Protocol, cast
from crewai.flow.flow_definition import (
FlowActionDefinition,
FlowCodeActionDefinition,
FlowEachActionDefinition,
FlowEachInnerActionDefinition,
FlowExpressionActionDefinition,
FlowToolActionDefinition,
)
from crewai.flow.runtime._expressions import evaluate_expression, render_with_block
from crewai.flow.runtime._refs import InvalidRefError, resolve_ref
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
__all__ = ["build_action"]
LocalContext = dict[str, Any]
_LOCAL_CONTEXT_KWARG = "__flow_definition_local_context"
class _BuiltAction(Protocol):
def run(self, *args: Any, **kwargs: Any) -> Any: ...
class _ActionType(Protocol):
definition_type: type[Any]
def __call__(self, flow: Flow[Any], definition: Any) -> _BuiltAction: ...
class CodeAction:
definition_type = FlowCodeActionDefinition
def __init__(self, flow: Flow[Any], definition: FlowCodeActionDefinition) -> None:
self.flow = flow
self.definition = definition
self.handler = self._resolve_handler()
self.signature = inspect.signature(self.handler)
def run(self, *args: Any, **kwargs: Any) -> Any:
local_context = _pop_local_context(kwargs)
if self.definition.with_ is None:
return self.handler(*args, **kwargs)
return self.handler(
**render_with_block(
self.flow, self.definition.with_, local_context=local_context
)
)
def _resolve_handler(self) -> Callable[..., Any]:
ref = self.definition.ref
target = resolve_ref(ref, field="do")
if not callable(target):
raise InvalidRefError(f"invalid do ref {ref!r}; object is not callable")
handler = cast(Callable[..., Any], target)
if getattr(handler, "__self__", None) is None and hasattr(handler, "__get__"):
handler = handler.__get__(self.flow, type(self.flow))
return handler
class ToolAction:
definition_type = FlowToolActionDefinition
def __init__(self, flow: Flow[Any], definition: FlowToolActionDefinition) -> None:
self.flow = flow
self.definition = definition
self.tool = self._build_tool()
self.kwargs = definition.with_ or {}
def run(self, *_args: Any, **kwargs: Any) -> Any:
local_context = _pop_local_context(kwargs)
return self.tool.run(
**render_with_block(self.flow, self.kwargs, local_context=local_context)
)
def _build_tool(self) -> Any:
target = resolve_ref(self.definition.ref, field="do")
from crewai.tools import BaseTool
if not (inspect.isclass(target) and issubclass(target, BaseTool)):
raise InvalidRefError(
f"invalid tool ref {self.definition.ref!r}; expected a BaseTool class"
)
try:
tool_cls = cast(Callable[[], BaseTool], target)
return tool_cls()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate tool ref {self.definition.ref!r} "
f"without arguments: {e}"
) from e
class ExpressionAction:
definition_type = FlowExpressionActionDefinition
def __init__(
self, flow: Flow[Any], definition: FlowExpressionActionDefinition
) -> None:
self.flow = flow
self.definition = definition
def run(self, *_args: Any, **kwargs: Any) -> Any:
local_context = _pop_local_context(kwargs)
return evaluate_expression(
self.flow, self.definition.expr, local_context=local_context
)
class EachAction:
definition_type = FlowEachActionDefinition
def __init__(self, flow: Flow[Any], definition: FlowEachActionDefinition) -> None:
self.flow = flow
self.definition = definition
self.inner_actions = [
(inner_action.name, self._build_inner_action(inner_action))
for inner_action in definition.do
]
async def run(self, *_args: Any, **_kwargs: Any) -> list[Any]:
items = evaluate_expression(self.flow, self.definition.in_)
if not isinstance(items, list):
raise ValueError("each.in must evaluate to an array")
results: list[Any] = []
for item in items:
local_outputs: dict[str, Any] = {}
last_output: Any = None
for name, run_inner_action in self.inner_actions:
last_output = await run_inner_action(
{"item": item, "outputs": local_outputs}
)
local_outputs[name] = last_output
results.append(last_output)
return results
def _build_inner_action(
self, inner_action: FlowEachInnerActionDefinition
) -> Callable[[LocalContext], Any]:
run_action = build_action(self.flow, inner_action.action)
async def run_inner_action(local_context: LocalContext) -> Any:
kwargs = {_LOCAL_CONTEXT_KWARG: local_context}
if inspect.iscoroutinefunction(run_action):
result = run_action(**kwargs)
else:
ctx = contextvars.copy_context()
def run_with_context() -> Any:
return run_action(**kwargs)
result = await asyncio.to_thread(ctx.run, run_with_context)
if inspect.isawaitable(result):
result = await result
return result
return run_inner_action
_ACTION_TYPES: tuple[_ActionType, ...] = (
EachAction,
CodeAction,
ToolAction,
ExpressionAction,
)
def build_action(
flow: Flow[Any], definition: FlowActionDefinition
) -> Callable[..., Any]:
"""Turn one `do:` action into the callable the flow runs for that node."""
for action_type in _ACTION_TYPES:
if isinstance(definition, action_type.definition_type):
return _as_flow_method(action_type(flow, definition))
raise ValueError(f"unknown call type {getattr(definition, 'call', None)!r}")
def _as_flow_method(action: _BuiltAction) -> Callable[..., Any]:
run: Callable[..., Any]
if inspect.iscoroutinefunction(action.run):
async def run_async(*args: Any, **kwargs: Any) -> Any:
return await action.run(*args, **kwargs)
run = run_async
else:
def run_sync(*args: Any, **kwargs: Any) -> Any:
return action.run(*args, **kwargs)
run = run_sync
signature = getattr(action, "signature", None)
if signature is not None:
object.__setattr__(run, "__signature__", signature)
return run
def _pop_local_context(kwargs: dict[str, Any]) -> LocalContext | None:
local_context = kwargs.pop(_LOCAL_CONTEXT_KWARG, None)
if local_context is None:
return None
if not isinstance(local_context, dict):
raise TypeError("flow definition local context must be a mapping")
return cast(LocalContext, local_context)

View File

@@ -2,14 +2,12 @@
from __future__ import annotations
import copy
import dataclasses
from itertools import pairwise
import json
import re
from typing import TYPE_CHECKING, Any, cast
from pydantic import BaseModel
from crewai.utilities.serialization import to_serializable
if TYPE_CHECKING:
@@ -25,25 +23,45 @@ class FlowExpressionError(ValueError):
"""A FlowDefinition expression failed to parse or evaluate."""
def render_with_block(flow: Flow[Any], value: Any) -> Any:
def render_with_block(
flow: Flow[Any], value: Any, local_context: dict[str, Any] | None = None
) -> Any:
"""Render CEL expressions inside a FlowDefinition ``with:`` payload."""
context = _expression_context(flow)
context = _expression_context(flow, local_context=local_context)
return _render_value(value, context)
def evaluate_expression(flow: Flow[Any], expression: str) -> Any:
def evaluate_expression(
flow: Flow[Any], expression: str, local_context: dict[str, Any] | None = None
) -> Any:
"""Evaluate a FlowDefinition CEL expression against runtime context."""
expression = expression.strip()
if not expression:
raise FlowExpressionError("empty CEL expression")
return _eval_cel(expression, _expression_context(flow))
return _eval_cel(expression, _expression_context(flow, local_context=local_context))
def _expression_context(flow: Flow[Any]) -> dict[str, Any]:
return {
def _expression_context(
flow: Flow[Any], local_context: dict[str, Any] | None = None
) -> dict[str, Any]:
outputs = _outputs_by_name(flow._method_outputs)
context: dict[str, Any] = {
"state": flow._copy_and_serialize_state(),
"outputs": _outputs_by_name(flow._method_outputs),
"outputs": outputs,
}
if local_context:
local_values = {
key: to_serializable(value, max_depth=0)
for key, value in local_context.items()
}
local_outputs = local_values.pop("outputs", None)
local_values.pop("state", None)
context.update(local_values)
if local_outputs is not None:
if not isinstance(local_outputs, dict):
raise TypeError("flow definition local outputs must be a mapping")
context["outputs"] = {**outputs, **local_outputs}
return context
def _outputs_by_name(method_outputs: list[Any]) -> dict[str, Any]:
@@ -54,12 +72,7 @@ def _outputs_by_name(method_outputs: list[Any]) -> dict[str, Any]:
if isinstance(entry, dict) and "output" in entry:
method = str(entry.get("method", ""))
output = entry["output"]
output = copy.deepcopy(output)
if isinstance(output, BaseModel):
output = output.model_dump(mode="json")
elif dataclasses.is_dataclass(output) and not isinstance(output, type):
output = dataclasses.asdict(output)
outputs[method] = output
outputs[method] = to_serializable(output, max_depth=0)
return outputs

View File

@@ -0,0 +1,38 @@
"""Resolution of ``module:qualname`` refs into live Python objects."""
from __future__ import annotations
import importlib
import inspect
from operator import attrgetter
from typing import Any
class InvalidRefError(ValueError):
"""A definition ref that cannot be resolved to a live object."""
def resolve_ref(ref: str, *, field: str) -> Any:
"""Import the object a definition's `module:qualname` ref points to."""
module_name, _, qualname = ref.partition(":")
if "<" in ref or not module_name or not qualname:
raise InvalidRefError(
f"invalid {field} ref {ref!r}; expected 'module:qualname'"
)
try:
return attrgetter(qualname)(importlib.import_module(module_name))
except (ImportError, AttributeError) as e:
raise InvalidRefError(f"unresolvable {field} ref {ref!r}") from e
def resolve_instance_ref(ref: str, *, field: str) -> Any:
"""Resolve a ref, auto-instantiating a no-arg class into an instance."""
target = resolve_ref(ref, field=field)
if not inspect.isclass(target):
return target
try:
return target()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate {field} ref {ref!r} without arguments: {e}"
) from e

View File

@@ -1,116 +0,0 @@
"""Resolution of FlowDefinition refs (``module:qualname``) into live objects.
Every ref-shaped value in a definition — ``do`` actions, ``state.ref``,
``config.input_provider``, ``human_feedback.provider`` — resolves through
:func:`resolve_ref`. Failures are loud and name the field and the ref.
"""
from __future__ import annotations
from collections.abc import Callable
import importlib
import inspect
from operator import attrgetter
from typing import TYPE_CHECKING, Any, cast
from crewai.flow.flow_definition import (
FlowActionDefinition,
FlowCodeActionDefinition,
FlowExpressionActionDefinition,
FlowToolActionDefinition,
)
from crewai.flow.runtime._expressions import evaluate_expression, render_with_block
if TYPE_CHECKING:
from crewai.flow.runtime import Flow
class InvalidRefError(ValueError):
"""A definition ref that cannot be resolved to a live object."""
def resolve_ref(ref: str, *, field: str) -> Any:
"""Import the object a definition's `module:qualname` ref points to."""
module_name, _, qualname = ref.partition(":")
if "<" in ref or not module_name or not qualname:
raise InvalidRefError(
f"invalid {field} ref {ref!r}; expected 'module:qualname'"
)
try:
return attrgetter(qualname)(importlib.import_module(module_name))
except (ImportError, AttributeError) as e:
raise InvalidRefError(f"unresolvable {field} ref {ref!r}") from e
def resolve_instance_ref(ref: str, *, field: str) -> Any:
"""Resolve a ref, auto-instantiating a no-arg class into an instance."""
target = resolve_ref(ref, field=field)
if not inspect.isclass(target):
return target
try:
return target()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate {field} ref {ref!r} without arguments: {e}"
) from e
def _resolve_code_action(
flow: Flow[Any], action: FlowCodeActionDefinition
) -> Callable[..., Any]:
ref = action.ref
target = resolve_ref(ref, field="do")
if not callable(target):
raise InvalidRefError(f"invalid do ref {ref!r}; object is not callable")
handler = cast(Callable[..., Any], target)
if getattr(handler, "__self__", None) is None:
handler = handler.__get__(flow, type(flow))
return handler
def _resolve_tool_action(
flow: Flow[Any], action: FlowToolActionDefinition
) -> Callable[..., Any]:
target = resolve_ref(action.ref, field="do")
from crewai.tools import BaseTool
if not (inspect.isclass(target) and issubclass(target, BaseTool)):
raise InvalidRefError(
f"invalid tool ref {action.ref!r}; expected a BaseTool class"
)
try:
tool_cls = cast(Callable[[], BaseTool], target)
tool = tool_cls()
except Exception as e:
raise InvalidRefError(
f"cannot instantiate tool ref {action.ref!r} without arguments: {e}"
) from e
tool_kwargs = action.with_ or {}
def run_tool(*_args: Any, **_kwargs: Any) -> Any:
return tool.run(**render_with_block(flow, tool_kwargs))
return run_tool
def _resolve_expression_action(
flow: Flow[Any], action: FlowExpressionActionDefinition
) -> Callable[..., Any]:
def run_expression(*_args: Any, **_kwargs: Any) -> Any:
return evaluate_expression(flow, action.expr)
return run_expression
def resolve_action(flow: Flow[Any], action: FlowActionDefinition) -> Callable[..., Any]:
"""Turn one `do:` action into the callable the flow runs for that node."""
if action.call == "code":
return _resolve_code_action(flow, action)
if action.call == "tool":
return _resolve_tool_action(flow, action)
if action.call == "expression":
return _resolve_expression_action(flow, action)
raise ValueError(f"unknown call type {action.call!r}")

View File

@@ -390,7 +390,10 @@ class LiteAgent(FlowTrackable, BaseModel):
if self.memory is True:
from crewai.memory.unified_memory import Memory
object.__setattr__(self, "_memory", Memory())
memory_kwargs: dict[str, Any] = {}
if self.llm is not None:
memory_kwargs["llm"] = self.llm
object.__setattr__(self, "_memory", Memory(**memory_kwargs))
elif self.memory is not None and self.memory is not False:
object.__setattr__(self, "_memory", self.memory)
else:

View File

@@ -68,7 +68,17 @@ if TYPE_CHECKING:
from crewai.tools.base_tool import BaseTool
from crewai.utilities.types import LLMMessage
try:
load_dotenv()
logger = logging.getLogger(__name__)
# litellm is lazy-loaded to avoid its module-level dotenv.load_dotenv()
# from polluting env vars (e.g. MODEL= overriding embedder model_name).
# The TYPE_CHECKING imports give mypy the real types; at runtime the names
# stay None until _ensure_litellm() rebinds them.
_litellm_loaded = False
LITELLM_AVAILABLE = False
if TYPE_CHECKING:
import litellm
from litellm.litellm_core_utils.get_supported_openai_params import (
get_supported_openai_params,
@@ -85,28 +95,70 @@ try:
StreamingChoices as LiteLLMStreamingChoices,
)
from litellm.utils import supports_response_schema
LITELLM_AVAILABLE = True
except ImportError:
LITELLM_AVAILABLE = False
litellm = None # type: ignore[assignment]
Choices = None # type: ignore[assignment, misc]
LiteLLMDelta = None # type: ignore[assignment, misc]
Message = None # type: ignore[assignment, misc]
ModelResponseBase = None # type: ignore[assignment, misc]
ModelResponseStream = None # type: ignore[assignment, misc]
LiteLLMStreamingChoices = None # type: ignore[assignment, misc]
get_supported_openai_params = None # type: ignore[assignment]
ChatCompletionDeltaToolCall = None # type: ignore[assignment, misc]
Function = None # type: ignore[assignment, misc]
ModelResponse = None # type: ignore[assignment, misc]
supports_response_schema = None # type: ignore[assignment]
else:
litellm = None
Choices = None
LiteLLMDelta = None
Message = None
ModelResponseBase = None
ModelResponseStream = None
LiteLLMStreamingChoices = None
get_supported_openai_params = None
ChatCompletionDeltaToolCall = None
Function = None
ModelResponse = None
supports_response_schema = None
load_dotenv()
logger = logging.getLogger(__name__)
if LITELLM_AVAILABLE:
litellm.suppress_debug_info = True
def _ensure_litellm() -> bool:
"""Lazy-load litellm on first use. Returns True if available."""
global _litellm_loaded, LITELLM_AVAILABLE
global litellm, Choices, LiteLLMDelta, Message, ModelResponseBase
global ModelResponseStream, LiteLLMStreamingChoices, get_supported_openai_params
global ChatCompletionDeltaToolCall, Function
global ModelResponse, supports_response_schema
if _litellm_loaded:
return LITELLM_AVAILABLE
_litellm_loaded = True
try:
import litellm as _litellm
from litellm.litellm_core_utils.get_supported_openai_params import (
get_supported_openai_params as _get_supported_openai_params,
)
from litellm.types.utils import (
ChatCompletionDeltaToolCall as _ChatCompletionDeltaToolCall,
Choices as _Choices,
Delta as _LiteLLMDelta,
Function as _Function,
Message as _Message,
ModelResponse as _ModelResponse,
ModelResponseBase as _ModelResponseBase,
ModelResponseStream as _ModelResponseStream,
StreamingChoices as _LiteLLMStreamingChoices,
)
from litellm.utils import supports_response_schema as _supports_response_schema
litellm = _litellm
Choices = _Choices # type: ignore[misc]
LiteLLMDelta = _LiteLLMDelta # type: ignore[misc]
Message = _Message # type: ignore[misc]
ModelResponseBase = _ModelResponseBase # type: ignore[misc]
ModelResponseStream = _ModelResponseStream # type: ignore[misc]
LiteLLMStreamingChoices = _LiteLLMStreamingChoices # type: ignore[misc]
get_supported_openai_params = _get_supported_openai_params
ChatCompletionDeltaToolCall = _ChatCompletionDeltaToolCall # type: ignore[misc]
Function = _Function # type: ignore[misc]
ModelResponse = _ModelResponse # type: ignore[misc]
supports_response_schema = _supports_response_schema
_litellm.suppress_debug_info = True
LITELLM_AVAILABLE = True
except ImportError:
LITELLM_AVAILABLE = False
return LITELLM_AVAILABLE
MIN_CONTEXT: Final[int] = 1024
@@ -117,6 +169,7 @@ LLM_CONTEXT_WINDOW_SIZES: Final[dict[str, int]] = {
"gpt-4": 8192,
"gpt-4o": 128000,
"gpt-4o-mini": 200000,
"gpt-5.4-mini": 200000,
"gpt-4-turbo": 128000,
"gpt-4.1": 1047576, # Based on official docs
"gpt-4.1-mini-2025-04-14": 1047576,
@@ -411,7 +464,8 @@ class LLM(BaseLLM):
except Exception as e:
raise ImportError(f"Error importing native provider: {e}") from e
if not LITELLM_AVAILABLE:
# FALLBACK to LiteLLM — lazy-load on first use
if not _ensure_litellm():
native_list = ", ".join(SUPPORTED_NATIVE_PROVIDERS)
error_msg = (
f"Unable to initialize LLM with model '{model}'. "
@@ -632,7 +686,7 @@ class LLM(BaseLLM):
@model_validator(mode="after")
def _init_litellm(self) -> LLM:
self.is_litellm = True
if LITELLM_AVAILABLE:
if _ensure_litellm():
litellm.drop_params = True
self.set_callbacks(self.callbacks or [])
self.set_env_callbacks()
@@ -2290,7 +2344,8 @@ class LLM(BaseLLM):
Note: This validation only applies to the litellm fallback path.
Native providers have their own validation.
"""
if not LITELLM_AVAILABLE or supports_response_schema is None:
if not _ensure_litellm() or supports_response_schema is None:
# When litellm is not available, skip validation
# (this path should only be reached for litellm fallback models)
return
@@ -2310,7 +2365,7 @@ class LLM(BaseLLM):
Note: This method is only used by the litellm fallback path.
Native providers override this method with their own implementation.
"""
if not LITELLM_AVAILABLE:
if not _ensure_litellm():
# When litellm is not available, assume function calling is supported
# (all modern models support it)
return True
@@ -2334,7 +2389,7 @@ class LLM(BaseLLM):
if "gpt-5" in model_lower:
return False
if not LITELLM_AVAILABLE or get_supported_openai_params is None:
if not _ensure_litellm() or get_supported_openai_params is None:
# When litellm is not available, assume stop words are supported
return True
@@ -2382,7 +2437,8 @@ class LLM(BaseLLM):
Note: This only affects the litellm fallback path. Native providers
don't use litellm callbacks - they emit events via base_llm.py.
"""
if not LITELLM_AVAILABLE:
if not _ensure_litellm():
# When litellm is not available, callbacks are still stored
# but not registered with litellm globals
return
@@ -2420,7 +2476,8 @@ class LLM(BaseLLM):
This will set `litellm.success_callback` to ["langfuse", "langsmith"] and
`litellm.failure_callback` to ["langfuse"].
"""
if not LITELLM_AVAILABLE:
if not _ensure_litellm():
# When litellm is not available, env callbacks have no effect
return
with suppress_warnings():

View File

@@ -1300,6 +1300,7 @@ class AzureCompletion(BaseLLM):
"gpt-4": 8192,
"gpt-4o": 128000,
"gpt-4o-mini": 200000,
"gpt-5.4-mini": 200000,
"gpt-4-turbo": 128000,
"gpt-35-turbo": 16385,
"gpt-3.5-turbo": 16385,

View File

@@ -2406,6 +2406,7 @@ class OpenAICompletion(BaseLLM):
"gpt-4": 8192,
"gpt-4o": 128000,
"gpt-4o-mini": 200000,
"gpt-5.4-mini": 200000,
"gpt-4-turbo": 128000,
"gpt-4.1": 1047576,
"gpt-4.1-mini-2025-04-14": 1047576,

View File

@@ -8,6 +8,39 @@ from typing import Any, Protocol, runtime_checkable
from crewai.memory.types import MemoryRecord, ScopeInfo
class EmbeddingDimensionMismatchError(ValueError):
"""Raised when an embedding's dimensionality doesn't match the existing store.
The most common cause is upgrading CrewAI across the default-embedder
change (text-embedding-3-small, 1536 dims → text-embedding-3-large,
3072 dims) while keeping a local memory store created before the upgrade.
Deliberately not a ``RuntimeError``: background-save plumbing treats
``RuntimeError`` as interpreter/executor shutdown and silently drops the
save, which would swallow this actionable migration error.
"""
def __init__(self, stored_dim: int, new_dim: int) -> None:
self.stored_dim = stored_dim
self.new_dim = new_dim
super().__init__(
f"Embedding dimension mismatch: this memory store contains "
f"{stored_dim}-dimensional vectors, but the current embedder produced "
f"a {new_dim}-dimensional vector.\n\n"
"This usually means the store was created with a different embedding "
"model. CrewAI's default embedder changed from "
"text-embedding-3-small (1536 dims) to text-embedding-3-large "
"(3072 dims), so memory stores created before the upgrade are "
"incompatible with the new default.\n\n"
"To fix, do one of the following:\n"
" - Reset local memory so it is rebuilt with the new embedder:\n"
" crewai reset-memories --memory (or crew.reset_memories())\n"
" - Keep existing memories by pinning the previous embedder:\n"
' embedder={"provider": "openai", '
'"config": {"model": "text-embedding-3-small"}}'
)
@runtime_checkable
class StorageBackend(Protocol):
"""Protocol for pluggable memory storage backends."""

View File

@@ -15,15 +15,16 @@ from typing import Any
from crewai_core.lock_store import lock as store_lock
import lancedb # type: ignore[import-untyped]
from crewai.memory.storage.backend import EmbeddingDimensionMismatchError
from crewai.memory.types import MemoryRecord, ScopeInfo
_logger = logging.getLogger(__name__)
# Default embedding vector dimensionality (matches OpenAI text-embedding-3-small).
# Default embedding vector dimensionality (matches OpenAI text-embedding-3-large).
# Used when creating new tables and for zero-vector placeholder scans.
# Callers can override via the ``vector_dim`` constructor parameter.
DEFAULT_VECTOR_DIM = 1536
DEFAULT_VECTOR_DIM = 3072
# Safety cap on the number of rows returned by a single scan query.
# Prevents unbounded memory use when scanning large tables for scope info,
@@ -288,13 +289,19 @@ class LanceDBStorage:
def save(self, records: list[MemoryRecord]) -> None:
if not records:
return
# Auto-detect dimension from the first real embedding.
# Auto-detect dimension from the first real embedding and validate
# the whole batch against it — a silent mismatch would otherwise be
# zero-filled below and corrupt search results.
dim = None
for r in records:
if r.embedding and len(r.embedding) > 0:
dim = len(r.embedding)
break
if dim is None:
dim = len(r.embedding)
elif len(r.embedding) != dim:
raise EmbeddingDimensionMismatchError(dim, len(r.embedding))
is_new_table = self._table is None
if not is_new_table and dim and self._vector_dim and dim != self._vector_dim:
raise EmbeddingDimensionMismatchError(self._vector_dim, dim)
with store_lock(self._lock_name):
self._ensure_table(vector_dim=dim)
rows = [self._record_to_row(rec) for rec in records]
@@ -311,6 +318,15 @@ class LanceDBStorage:
def update(self, record: MemoryRecord) -> None:
"""Update a record by ID. Preserves created_at, updates last_accessed."""
if (
self._table is not None
and record.embedding
and self._vector_dim
and len(record.embedding) != self._vector_dim
):
raise EmbeddingDimensionMismatchError(
self._vector_dim, len(record.embedding)
)
with store_lock(self._lock_name):
self._ensure_table()
safe_id = str(record.id).replace("'", "''")
@@ -363,6 +379,10 @@ class LanceDBStorage:
) -> list[tuple[MemoryRecord, float]]:
if self._table is None:
return []
if self._vector_dim and len(query_embedding) != self._vector_dim:
raise EmbeddingDimensionMismatchError(
self._vector_dim, len(query_embedding)
)
query = self._table.search(query_embedding)
if scope_prefix is not None and scope_prefix.strip("/"):
prefix = scope_prefix.rstrip("/")

View File

@@ -36,6 +36,7 @@ from qdrant_edge import (
UpdateOperation,
)
from crewai.memory.storage.backend import EmbeddingDimensionMismatchError
from crewai.memory.types import MemoryRecord, ScopeInfo
@@ -43,7 +44,7 @@ _logger = logging.getLogger(__name__)
VECTOR_NAME: Final[str] = "memory"
DEFAULT_VECTOR_DIM: Final[int] = 1536
DEFAULT_VECTOR_DIM: Final[int] = 3072
_SCROLL_BATCH: Final[int] = 256
@@ -183,6 +184,10 @@ class QdrantEdgeStorage:
except Exception:
_logger.debug("Index creation failed (may already exist)", exc_info=True)
def _has_existing_data(self) -> bool:
"""True when either shard already holds persisted records."""
return self._local_has_data or self._central_path.exists()
def _record_to_point(self, record: MemoryRecord) -> Point:
"""Convert a MemoryRecord to a Qdrant Point."""
return Point(
@@ -277,11 +282,19 @@ class QdrantEdgeStorage:
if not records:
return
# Validate the batch is internally consistent before touching the
# store-level dimension.
batch_dim = 0
for r in records:
if r.embedding and len(r.embedding) > 0:
if batch_dim == 0:
batch_dim = len(r.embedding)
elif len(r.embedding) != batch_dim:
raise EmbeddingDimensionMismatchError(batch_dim, len(r.embedding))
if self._vector_dim == 0:
for r in records:
if r.embedding and len(r.embedding) > 0:
self._vector_dim = len(r.embedding)
break
self._vector_dim = batch_dim
elif batch_dim and batch_dim != self._vector_dim and self._has_existing_data():
raise EmbeddingDimensionMismatchError(self._vector_dim, batch_dim)
if self._config is None and self._vector_dim > 0:
self._config = self._build_config(self._vector_dim)
if self._config is None:
@@ -308,6 +321,14 @@ class QdrantEdgeStorage:
min_score: float = 0.0,
) -> list[tuple[MemoryRecord, float]]:
"""Search both central and local shards, merge results."""
if (
self._vector_dim
and len(query_embedding) != self._vector_dim
and self._has_existing_data()
):
raise EmbeddingDimensionMismatchError(
self._vector_dim, len(query_embedding)
)
filt = self._build_scope_filter(scope_prefix)
fetch_limit = limit * 3 if (categories or metadata_filter) else limit
all_scored: list[tuple[dict[str, Any], float, bool]] = []
@@ -466,6 +487,16 @@ class QdrantEdgeStorage:
def update(self, record: MemoryRecord) -> None:
"""Update a record by upserting with the same point ID."""
if (
self._config is not None
and record.embedding
and self._vector_dim
and len(record.embedding) != self._vector_dim
and self._has_existing_data()
):
raise EmbeddingDimensionMismatchError(
self._vector_dim, len(record.embedding)
)
if self._config is None:
if record.embedding and len(record.embedding) > 0:
self._vector_dim = len(record.embedding)

View File

@@ -66,7 +66,7 @@ class Memory(BaseModel):
memory_kind: Literal["memory"] = "memory"
llm: Annotated[BaseLLM | str, PlainValidator(_passthrough)] = Field(
default="gpt-4o-mini",
default="gpt-5.4-mini",
description="LLM for analysis (model name or BaseLLM instance).",
)
storage: Annotated[StorageBackend | str, PlainValidator(_passthrough)] = Field(
@@ -239,7 +239,7 @@ class Memory(BaseModel):
raise RuntimeError(
f"Memory requires an LLM for analysis but initialization failed: {e}\n\n"
"To fix this, do one of the following:\n"
" - Set OPENAI_API_KEY for the default model (gpt-4o-mini)\n"
" - Set OPENAI_API_KEY for the default model (gpt-5.4-mini)\n"
' - Pass a different model: Memory(llm="anthropic/claude-3-haiku-20240307")\n'
' - Pass any LLM instance: Memory(llm=LLM(model="your-model"))\n'
" - To skip LLM analysis, pass all fields explicitly to remember()\n"
@@ -261,7 +261,7 @@ class Memory(BaseModel):
raise RuntimeError(
f"Memory requires an embedder for vector search but initialization failed: {e}\n\n"
"To fix this, do one of the following:\n"
" - Set OPENAI_API_KEY for the default embedder (text-embedding-3-small)\n"
" - Set OPENAI_API_KEY for the default embedder (text-embedding-3-large)\n"
' - Pass a different embedder: Memory(embedder={{"provider": "google", "config": {{...}}}})\n'
" - Pass a callable: Memory(embedder=my_embedding_function)\n\n"
f"Docs: {self._MEMORY_DOCS_URL}"
@@ -322,12 +322,16 @@ class Memory(BaseModel):
"""Block until all pending background saves have completed.
Called automatically by ``recall()`` and should be called by the
crew at shutdown to ensure no saves are lost.
crew at shutdown to ensure no saves are lost. Background save failures
are already reported through ``MemorySaveFailedEvent`` and should not
fail the task, crew, or flow that produced the output.
"""
with self._pending_lock:
pending = list(self._pending_saves)
for future in pending:
future.result() # blocks until done; re-raises exceptions
if future.cancelled():
continue
future.exception() # blocks until done without re-raising failures
def close(self) -> None:
"""Drain pending saves, flush storage, and shut down the background thread pool."""
@@ -605,12 +609,16 @@ class Memory(BaseModel):
root_scope,
)
elapsed_ms = (time.perf_counter() - start) * 1000
except RuntimeError:
except RuntimeError as e:
# The encoding pipeline uses asyncio.run() -> to_thread() internally.
# If the process is shutting down, the default executor is closed and
# to_thread raises "cannot schedule new futures after shutdown".
# Silently abandon the save -- the process is exiting anyway.
return []
# Any other RuntimeError must propagate so the save future's
# done-callback reports it via MemorySaveFailedEvent.
if "cannot schedule new futures" in str(e):
return []
raise
try:
crewai_event_bus.emit(

View File

@@ -14,6 +14,8 @@ from crewai.project.annotations import (
tool,
)
from crewai.project.crew_base import CrewBase
from crewai.project.crew_loader import load_crew, load_crew_and_kickoff
from crewai.project.json_loader import load_agent, strip_jsonc_comments
__all__ = [
@@ -25,8 +27,12 @@ __all__ = [
"callback",
"crew",
"llm",
"load_agent",
"load_crew",
"load_crew_and_kickoff",
"output_json",
"output_pydantic",
"strip_jsonc_comments",
"task",
"tool",
]

View File

@@ -0,0 +1,109 @@
"""Load crew definitions from JSON/JSONC files and produce Crew instances."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import ValidationError
from crewai.project.json_loader import (
JSONProjectError,
JSONProjectValidationError,
_crew_kwargs_from_definition,
_task_class_from_definition,
_task_kwargs_from_definition,
load_json_crew_project,
)
def load_crew(
source: Path | str,
agents_dir: Path | None = None,
) -> tuple[Any, dict[str, Any]]:
"""Load a ``Crew`` from a JSON/JSONC definition file.
The definition file describes the crew's agents, tasks, process type, and
default inputs. Agent definitions are resolved from individual
``<name>.jsonc`` / ``<name>.json`` files inside an ``agents/`` directory.
"""
from crewai import Crew, Task
crew_path = Path(source)
project = load_json_crew_project(crew_path, agents_dir=agents_dir)
def build_agent(agent_def: Any) -> Any:
try:
return agent_def.agent_class(**agent_def.kwargs)
except ValidationError as exc:
raise JSONProjectError(
f"{agent_def.path}: validation failed: {exc}"
) from exc
except Exception as exc:
raise JSONProjectError(
f"{agent_def.path}: failed to load agent: {exc}"
) from exc
agents_map: dict[str, Any] = {}
for name, agent_def in project.agents.items():
agents_map[name] = build_agent(agent_def)
tasks_list: list[Task] = []
task_name_map: dict[str, Task] = {}
for index, task_defn in enumerate(project.task_definitions):
source_label = f"{crew_path}: tasks[{index}]"
task_class = _task_class_from_definition(task_defn, f"{source_label}: type")
task_kwargs = _task_kwargs_from_definition(
task_defn,
agents_map=agents_map,
task_name_map=task_name_map,
source=source_label,
project_root=crew_path.parent,
)
try:
task = task_class(**task_kwargs)
except ValidationError as exc:
raise JSONProjectError(f"{source_label}: validation failed: {exc}") from exc
except Exception as exc:
raise JSONProjectError(
f"{source_label}: failed to load task: {exc}"
) from exc
tasks_list.append(task)
task_name = task_defn.get("name")
if isinstance(task_name, str) and task_name:
task_name_map[task_name] = task
crew_kwargs = _crew_kwargs_from_definition(
project.definition,
agents=[agents_map[name] for name in project.agent_names],
tasks=tasks_list,
agents_map=agents_map,
source=crew_path,
)
try:
crew = Crew(**crew_kwargs)
except ValidationError as exc:
raise JSONProjectError(f"{crew_path}: validation failed: {exc}") from exc
except JSONProjectValidationError:
raise
except Exception as exc:
raise JSONProjectError(f"{crew_path}: failed to load crew: {exc}") from exc
return crew, project.definition.get("inputs", {})
def load_crew_and_kickoff(
crew_path: Path | str,
input_overrides: dict[str, Any] | None = None,
) -> Any:
"""Convenience function: load a crew and immediately kick it off."""
crew, default_inputs = load_crew(crew_path)
merged_inputs = {**default_inputs}
if input_overrides:
merged_inputs.update(input_overrides)
return crew.kickoff(inputs=merged_inputs)

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ from typing import Any
from chromadb.utils.embedding_functions.openai_embedding_function import (
OpenAIEmbeddingFunction,
)
from pydantic import AliasChoices, Field
from pydantic import AliasChoices, Field, model_validator
from crewai.rag.core.base_embeddings_provider import BaseEmbeddingsProvider
@@ -13,6 +13,14 @@ from crewai.rag.core.base_embeddings_provider import BaseEmbeddingsProvider
class AzureProvider(BaseEmbeddingsProvider[OpenAIEmbeddingFunction]):
"""Azure OpenAI embeddings provider."""
@model_validator(mode="before")
@classmethod
def _normalize_model_alias(cls, data: Any) -> Any:
if isinstance(data, dict) and "model" in data and "model_name" not in data:
data = data.copy()
data["model_name"] = data["model"]
return data
embedding_callable: type[OpenAIEmbeddingFunction] = Field(
default=OpenAIEmbeddingFunction,
description="Azure OpenAI embedding function class",
@@ -43,13 +51,11 @@ class AzureProvider(BaseEmbeddingsProvider[OpenAIEmbeddingFunction]):
),
)
model_name: str = Field(
default="text-embedding-ada-002",
default="text-embedding-3-large",
description="Model name to use for embeddings",
validation_alias=AliasChoices(
"EMBEDDINGS_OPENAI_MODEL_NAME",
"OPENAI_MODEL_NAME",
"AZURE_OPENAI_MODEL_NAME",
"model",
),
)
default_headers: dict[str, Any] | None = Field(

View File

@@ -12,7 +12,7 @@ class AzureProviderConfig(TypedDict, total=False):
api_base: str
api_type: Annotated[str, "azure"]
api_version: str
model_name: Annotated[str, "text-embedding-ada-002"]
model_name: Annotated[str, "text-embedding-3-large"]
default_headers: dict[str, Any]
dimensions: int
deployment_id: Required[str]

View File

@@ -5,7 +5,7 @@ from typing import Any
from chromadb.utils.embedding_functions.openai_embedding_function import (
OpenAIEmbeddingFunction,
)
from pydantic import AliasChoices, Field
from pydantic import AliasChoices, Field, model_validator
from crewai.rag.core.base_embeddings_provider import BaseEmbeddingsProvider
@@ -13,6 +13,14 @@ from crewai.rag.core.base_embeddings_provider import BaseEmbeddingsProvider
class OpenAIProvider(BaseEmbeddingsProvider[OpenAIEmbeddingFunction]):
"""OpenAI embeddings provider."""
@model_validator(mode="before")
@classmethod
def _normalize_model_alias(cls, data: Any) -> Any:
if isinstance(data, dict) and "model" in data and "model_name" not in data:
data = data.copy()
data["model_name"] = data["model"]
return data
embedding_callable: type[OpenAIEmbeddingFunction] = Field(
default=OpenAIEmbeddingFunction,
description="OpenAI embedding function class",
@@ -23,12 +31,11 @@ class OpenAIProvider(BaseEmbeddingsProvider[OpenAIEmbeddingFunction]):
validation_alias=AliasChoices("EMBEDDINGS_OPENAI_API_KEY", "OPENAI_API_KEY"),
)
model_name: str = Field(
default="text-embedding-ada-002",
default="text-embedding-3-large",
description="Model name to use for embeddings",
validation_alias=AliasChoices(
"EMBEDDINGS_OPENAI_MODEL_NAME",
"OPENAI_MODEL_NAME",
"model",
"model_name",
),
)
api_base: str | None = Field(

View File

@@ -9,7 +9,7 @@ class OpenAIProviderConfig(TypedDict, total=False):
"""Configuration for OpenAI provider."""
api_key: str
model_name: Annotated[str, "text-embedding-ada-002"]
model_name: Annotated[str, "text-embedding-3-large"]
api_base: str
api_type: str
api_version: str

View File

@@ -931,7 +931,7 @@ class Telemetry:
value: The attribute value.
"""
if span is None:
if span is None or value is None:
return
def _operation() -> None:
@@ -982,6 +982,11 @@ class Telemetry:
def _operation() -> None:
tracer = trace.get_tracer("crewai.telemetry")
span = tracer.start_span("Flow Execution")
self._add_attribute(
span,
"crewai_version",
version("crewai"),
)
self._add_attribute(span, "flow_name", flow_name)
self._add_attribute(span, "node_names", json.dumps(node_names))
close_span(span)

View File

@@ -65,6 +65,15 @@ class SummaryContent(TypedDict):
console = Console()
_MULTIPLE_NEWLINES: Final[re.Pattern[str]] = re.compile(r"\n+")
_NATIVE_TOOL_UNSUPPORTED_PATTERNS: Final[tuple[str, ...]] = (
"does not support tools",
"doesn't support tools",
"tools are not supported",
"tool calling is not supported",
"tool calls are not supported",
"function calling is not supported",
"does not support function calling",
)
def is_inside_event_loop() -> bool:
@@ -1273,6 +1282,28 @@ def check_native_tool_support(llm: Any, original_tools: list[BaseTool] | None) -
)
def is_native_tool_calling_unsupported_error(error: BaseException) -> bool:
"""Return whether an error means native tool calling is unavailable."""
message = str(error).lower()
return any(pattern in message for pattern in _NATIVE_TOOL_UNSUPPORTED_PATTERNS)
def build_text_tool_calling_fallback_message(
tools_description: str,
tools_names: str,
) -> str:
"""Build instructions for downgrading native tools to text tool calls."""
text_tooling_prompt = I18N_DEFAULT.slice("tools").format(
tools=tools_description,
tool_names=tools_names,
)
return (
"Native tool calling is unavailable for this model/provider. "
"Continue using CrewAI text tool calling instead.\n"
f"{text_tooling_prompt}"
)
def setup_native_tools(
original_tools: list[BaseTool],
) -> tuple[
@@ -1365,6 +1396,8 @@ def execute_single_native_tool_call(
event_source: Any,
printer: Printer | None = None,
verbose: bool = False,
plan_step_number: int | None = None,
plan_step_description: str | None = None,
) -> NativeToolCallResult:
"""Execute a single native tool call with full lifecycle management.
@@ -1446,6 +1479,8 @@ def execute_single_native_tool_call(
from_agent=agent,
from_task=task,
agent_key=agent_key,
plan_step_number=plan_step_number,
plan_step_description=plan_step_description,
),
)
@@ -1509,6 +1544,8 @@ def execute_single_native_tool_call(
from_agent=agent,
from_task=task,
agent_key=agent_key,
plan_step_number=plan_step_number,
plan_step_description=plan_step_description,
error=e,
),
)
@@ -1542,6 +1579,8 @@ def execute_single_native_tool_call(
from_agent=agent,
from_task=task,
agent_key=agent_key,
plan_step_number=plan_step_number,
plan_step_description=plan_step_description,
started_at=started_at,
finished_at=datetime.now(),
),

View File

@@ -11,12 +11,13 @@ logger = logging.getLogger(__name__)
def create_llm(
llm_value: str | LLM | Any | None = None,
llm_value: str | dict[str, Any] | LLM | Any | None = None,
) -> LLM | BaseLLM | None:
"""Creates or returns an LLM instance based on the given llm_value.
Args:
llm_value: LLM instance, model name string, None, or an object with LLM attributes.
llm_value: LLM instance, model name string, config dict, None, or an
object with LLM attributes.
Returns:
A BaseLLM instance if successful, or None if something fails.
@@ -32,6 +33,26 @@ def create_llm(
logger.error(f"Error instantiating LLM from string: {e}")
raise e
if isinstance(llm_value, dict):
try:
model = (
llm_value.get("model")
or llm_value.get("model_name")
or llm_value.get("deployment_name")
)
if not model:
raise ValueError(
"LLM config dictionaries must include 'model', "
"'model_name', or 'deployment_name'"
)
llm_params = {**llm_value, "model": model}
llm_params.pop("model_name", None)
llm_params.pop("deployment_name", None)
return LLM(**llm_params)
except Exception as e:
logger.error(f"Error instantiating LLM from dict: {e}")
raise e
if llm_value is None:
return _llm_via_environment_or_fallback()

View File

@@ -52,7 +52,7 @@ class CrewPlanner:
planning_agent_llm: Optional LLM model for the planning agent. Defaults to None.
"""
self.tasks = tasks
self.planning_agent_llm = planning_agent_llm or "gpt-4o-mini"
self.planning_agent_llm = planning_agent_llm or "gpt-5.4-mini"
def _handle_crew_planning(self) -> PlannerTaskPydanticOutput:
"""Handles the Crew planning by creating detailed step-by-step plans for each task.

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import dataclasses
from datetime import date, datetime
import json
from typing import Any, TypeAlias
@@ -23,21 +24,23 @@ def to_serializable(
) -> Serializable:
"""Converts a Python object into a JSON-compatible representation.
Supports primitives, datetime objects, collections, dictionaries, and
Pydantic models. Recursion depth is limited to prevent infinite nesting.
Supports primitives, datetime objects, collections, dictionaries,
dataclasses, and Pydantic models. Recursion depth is limited to prevent
infinite nesting.
Non-convertible objects default to their string representations.
Args:
obj: Object to transform.
exclude: Set of keys to exclude from the result.
max_depth: Maximum recursion depth. Defaults to 5.
max_depth: Maximum recursion depth. Defaults to 5. Values less than or
equal to 0 disable the depth limit.
_current_depth: Current recursion depth (for internal use).
_ancestors: Set of ancestor object ids for cycle detection (for internal use).
Returns:
Serializable: A JSON-compatible structure.
"""
if _current_depth >= max_depth:
if max_depth > 0 and _current_depth >= max_depth:
return repr(obj)
if exclude is None:
@@ -58,6 +61,18 @@ def to_serializable(
return f"<circular_ref:{type(obj).__name__}>"
new_ancestors = _ancestors | {object_id}
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
return {
field.name: to_serializable(
obj=getattr(obj, field.name),
exclude=exclude,
max_depth=max_depth,
_current_depth=_current_depth + 1,
_ancestors=new_ancestors,
)
for field in dataclasses.fields(obj)
if field.name not in exclude
}
if isinstance(obj, (list, tuple, set)):
return [
to_serializable(
@@ -84,7 +99,7 @@ def to_serializable(
if isinstance(obj, BaseModel):
try:
return to_serializable(
obj=obj.model_dump(exclude=exclude),
obj=obj.model_dump(mode="json", exclude=exclude),
max_depth=max_depth,
_current_depth=_current_depth + 1,
_ancestors=new_ancestors,

View File

@@ -13,15 +13,6 @@ from crewai.agents.agent_builder.utilities.base_token_process import TokenProces
from crewai.utilities.logger_utils import suppress_warnings
try:
from litellm.integrations.custom_logger import CustomLogger as LiteLLMCustomLogger
LITELLM_AVAILABLE = True
except ImportError:
LiteLLMCustomLogger = None # type: ignore[misc, assignment]
LITELLM_AVAILABLE = False
class TokenCalcHandler(BaseModel):
"""Handler for calculating and tracking token usage in LLM calls.

View File

@@ -28,6 +28,19 @@ from crewai.tools import tool
from crewai.utilities import RPMController
def test_agent_memory_true_uses_agent_llm_model():
agent = Agent(
role="test role",
goal="test goal",
backstory="test backstory",
llm="ollama/llama3",
memory=True,
)
assert agent.memory is not None
assert agent.memory.llm == "ollama/llama3"
def test_agent_llm_creation_with_env_vars():
original_api_key = os.environ.get("OPENAI_API_KEY")
original_api_base = os.environ.get("OPENAI_API_BASE")

View File

@@ -59,6 +59,10 @@ from crewai.experimental.agent_executor import (
)
from crewai.agents.parser import AgentAction, AgentFinish
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.observation_events import (
PlanStepCompletedEvent,
PlanStepStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
@@ -318,6 +322,41 @@ class TestAgentExecutor:
assert result == "native_finished"
assert get_llm_response_mock.call_args.kwargs["response_model"] is None
def test_call_llm_native_tools_falls_back_when_provider_rejects_tools(
self, mock_dependencies
):
"""Provider-level unsupported tools errors should downgrade to ReAct."""
executor = _build_executor(
**mock_dependencies,
original_tools=[Mock()],
callbacks=[],
)
executor._openai_tools = [{"type": "function", "function": {"name": "lookup"}}]
executor.state.use_native_tools = True
executor.state.pending_tool_calls = [Mock()]
executor.state.messages = [{"role": "user", "content": "Use a tool"}]
executor.tools = [Mock()]
executor.tools_names = "lookup"
executor.tools_description = "lookup: search for information"
with patch(
"crewai.experimental.agent_executor.get_llm_response",
side_effect=RuntimeError(
"Error code: 400 - registry.ollama.ai/library/mariner:latest "
"does not support tools"
),
):
result = executor.call_llm_native_tools()
assert result == "continue_reasoning"
assert executor.state.use_native_tools is False
assert executor.state.pending_tool_calls == []
assert executor.state.messages[-1]["role"] == "user"
assert "Native tool calling is unavailable" in executor.state.messages[-1][
"content"
]
assert "Action Input" in executor.state.messages[-1]["content"]
def test_finalize_success(self, mock_dependencies):
"""Test finalize with valid AgentFinish."""
with patch.object(AgentExecutor, "_show_logs") as mock_show_logs:
@@ -545,6 +584,7 @@ class TestStepExecutorCriticalFixes:
tool = Mock()
tool.name = "count_words"
tool.description = "count_words: Counts words in text"
task = Mock()
task.name = "test-task"
task.description = "test task description"
@@ -610,13 +650,126 @@ class TestStepExecutorCriticalFixes:
"crewai.agents.step_executor.execute_tool_and_check_finality",
return_value=ToolResult(result="2", result_as_answer=False),
):
output = step_executor._execute_text_tool_with_events(action)
todo = TodoItem(step_number=2, description="Count words")
output = step_executor._execute_text_tool_with_events(action, todo)
crewai_event_bus.flush()
assert output == "2"
assert len(started_events) >= 1
assert len(finished_events) >= 1
assert started_events[-1].plan_step_number == 2
assert started_events[-1].plan_step_description == "Count words"
assert finished_events[-1].plan_step_number == 2
assert finished_events[-1].plan_step_description == "Count words"
def test_step_executor_falls_back_when_native_tools_are_rejected(
self, step_executor
):
"""Plan steps should retry through text tool calls when native tools fail."""
step_executor._use_native_tools = True
step_executor._openai_tools = [{"type": "function", "function": {"name": "count_words"}}]
step_executor._available_functions = {"count_words": Mock()}
todo = TodoItem(step_number=1, description="Count words")
context = StepExecutionContext(task_description="task", task_goal="goal")
with (
patch.object(
step_executor,
"_execute_native",
side_effect=RuntimeError(
"registry.ollama.ai/library/mariner:latest does not support tools"
),
),
patch.object(
step_executor,
"_execute_text_parsed",
return_value="Counted words",
) as text_parsed,
):
result = step_executor.execute(todo, context)
assert result.success is True
assert result.result == "Counted words"
assert step_executor._use_native_tools is False
fallback_messages = text_parsed.call_args.args[0]
# The original conversation is preserved (system + user) and the
# text-tooling instructions are appended instead of rebuilding.
assert fallback_messages[0]["role"] == "system"
assert fallback_messages[-1]["role"] == "user"
assert "Action Input" in fallback_messages[-1]["content"]
def test_plan_step_lifecycle_events_are_emitted_from_todo_transitions(
self, mock_dependencies
):
"""Todo transitions should publish authoritative plan step events."""
from crewai.utilities.planning_types import TodoList
executor = _build_executor(**mock_dependencies)
todo = TodoItem(
step_number=1,
description="Search the official release",
tool_to_use="search",
)
executor.state.todos = TodoList(items=[todo])
started_events: list[PlanStepStartedEvent] = []
completed_events: list[PlanStepCompletedEvent] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(PlanStepStartedEvent)
def _on_started(_source, event):
started_events.append(event)
@crewai_event_bus.on(PlanStepCompletedEvent)
def _on_completed(_source, event):
completed_events.append(event)
executor._mark_todo_running(todo)
executor._mark_todo_completed(1, result="Found release")
crewai_event_bus.flush()
assert todo.status == "completed"
assert len(started_events) == 1
assert started_events[0].step_number == 1
assert started_events[0].step_description == "Search the official release"
assert started_events[0].tool_to_use == "search"
assert len(completed_events) == 1
assert completed_events[0].success is True
assert completed_events[0].step_number == 1
assert completed_events[0].result == "Found release"
def test_failed_todo_transition_emits_failed_plan_step_event(
self, mock_dependencies
):
"""Failed todo transitions should publish failed plan step events."""
from crewai.utilities.planning_types import TodoList
executor = _build_executor(**mock_dependencies)
todo = TodoItem(step_number=1, description="Search release")
executor.state.todos = TodoList(items=[todo])
completed_events: list[PlanStepCompletedEvent] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(PlanStepCompletedEvent)
def _on_completed(_source, event):
completed_events.append(event)
executor._mark_todo_failed(
1,
result="Error: no result",
error="No result",
)
crewai_event_bus.flush()
assert todo.status == "failed"
assert len(completed_events) == 1
assert completed_events[0].success is False
assert completed_events[0].step_number == 1
assert completed_events[0].result == "Error: no result"
assert completed_events[0].error == "No result"
@patch("crewai.experimental.agent_executor.handle_output_parser_exception")
def test_recover_from_parser_error(
@@ -1649,6 +1802,12 @@ class TestReasoningEffort:
executor.handle_step_observed_medium = (
AgentExecutor.handle_step_observed_medium.__get__(executor)
)
executor._mark_todo_completed = (
AgentExecutor._mark_todo_completed.__get__(executor)
)
executor._mark_todo_failed = (
AgentExecutor._mark_todo_failed.__get__(executor)
)
success_todo = TodoItem(
step_number=1,
@@ -1715,6 +1874,9 @@ class TestReasoningEffort:
executor.handle_step_observed_low = (
AgentExecutor.handle_step_observed_low.__get__(executor)
)
executor._mark_todo_completed = (
AgentExecutor._mark_todo_completed.__get__(executor)
)
todo = TodoItem(
step_number=1,
@@ -1748,6 +1910,12 @@ class TestReasoningEffort:
executor.handle_step_observed_low = (
AgentExecutor.handle_step_observed_low.__get__(executor)
)
executor._mark_todo_completed = (
AgentExecutor._mark_todo_completed.__get__(executor)
)
executor._mark_todo_failed = (
AgentExecutor._mark_todo_failed.__get__(executor)
)
todo = TodoItem(
step_number=1,
@@ -2065,13 +2233,13 @@ class TestTodoStatusTracking:
from crewai.experimental.agent_executor import AgentExecutor
source = inspect.getsource(AgentExecutor.handle_step_observed_medium)
assert "mark_failed" in source, (
"handle_step_observed_medium should use mark_failed for failed steps"
assert "_mark_todo_failed" in source, (
"handle_step_observed_medium should use _mark_todo_failed for failed steps"
)
failed_no_replan_idx = source.index("failed but no replan")
after_comment = source[failed_no_replan_idx:]
assert "mark_completed" not in after_comment, (
"mark_completed should not be called on failed steps"
assert "_mark_todo_completed" not in after_comment, (
"_mark_todo_completed should not be called on failed steps"
)
def test_failed_step_appears_in_get_failed_todos(self):

View File

@@ -1096,6 +1096,7 @@ def test_lite_agent_memory_true_resolves_to_default_memory():
)
assert agent._memory is not None
assert isinstance(agent._memory, Memory)
assert agent._memory.llm is agent.llm
@pytest.mark.filterwarnings("ignore:LiteAgent is deprecated")

View File

@@ -17,6 +17,7 @@ import pytest
from pydantic import BaseModel, Field
from crewai import Agent, Crew, Task
from crewai.agents.parser import AgentFinish
from crewai.events import crewai_event_bus
from crewai.hooks import register_after_tool_call_hook, register_before_tool_call_hook
from crewai.hooks.tool_hooks import ToolCallHookContext
@@ -1196,6 +1197,50 @@ class TestNativeToolCallingJsonParseError:
assert result["result"] == "ran: print(1)"
def test_native_tool_loop_falls_back_when_provider_rejects_tools(self) -> None:
"""Unsupported native tools errors should continue through ReAct."""
class SearchTool(BaseTool):
name: str = "search"
description: str = "Search for information"
def _run(self, query: str) -> str:
return f"result for {query}"
executor = self._make_executor([SearchTool()])
executor.llm = Mock()
executor.messages = [{"role": "user", "content": "Search for CrewAI"}]
executor.callbacks = []
executor.iterations = 0
executor.max_iter = 3
executor.request_within_rpm_limit = None
executor.respect_context_window = False
fallback_finish = AgentFinish(
thought="done",
output="final",
text="Final Answer: final",
)
with (
patch(
"crewai.agents.crew_agent_executor.get_llm_response",
side_effect=RuntimeError(
"registry.ollama.ai/library/mariner:latest does not support tools"
),
),
patch.object(
executor,
"_invoke_loop_react",
return_value=fallback_finish,
) as react_loop,
):
result = executor._invoke_loop_native_tools()
assert result is fallback_finish
react_loop.assert_called_once()
assert "Native tool calling is unavailable" in executor.messages[-1]["content"]
assert "Action Input" in executor.messages[-1]["content"]
def test_dict_args_bypass_json_parsing(self) -> None:
"""When func_args is already a dict, no JSON parsing occurs."""

View File

@@ -345,6 +345,36 @@ class TestPlusAPI(unittest.TestCase):
"POST", "/crewai_plus/api/v1/crews", json=payload
)
@patch("crewai_core.plus_api.PlusAPI._make_multipart_request")
def test_create_crew_from_zip(self, mock_make_multipart_request):
self.api.create_crew_from_zip(
"/tmp/test.zip",
name="test_crew",
env={"ENV_VAR": "value"},
)
mock_make_multipart_request.assert_called_once_with(
"POST",
"/crewai_plus/api/v1/crews/zip",
zip_file_path="/tmp/test.zip",
data={"name": "test_crew", "env[ENV_VAR]": "value"},
timeout=300,
)
@patch("crewai_core.plus_api.PlusAPI._make_multipart_request")
def test_update_crew_from_zip(self, mock_make_multipart_request):
self.api.update_crew_from_zip(
"test_uuid",
"/tmp/test.zip",
env={"ENV_VAR": "value"},
)
mock_make_multipart_request.assert_called_once_with(
"POST",
"/crewai_plus/api/v1/crews/test_uuid/zip_update",
zip_file_path="/tmp/test.zip",
data={"env[ENV_VAR]": "value"},
timeout=300,
)
@patch("crewai_core.plus_api.Settings")
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
def test_custom_base_url(self, mock_settings_class):

View File

@@ -1,12 +1,22 @@
"""Tests for the ``crewai run`` command and its subprocess plumbing."""
from pathlib import Path
import sys
from types import ModuleType
from types import SimpleNamespace
from unittest import mock
from click.testing import CliRunner
import pytest
from crewai_cli.cli import run
from crewai_cli.run_crew import CrewType, execute_command
from crewai_cli.run_crew import (
CrewType,
_load_json_crew_for_tui,
_missing_input_names,
_prompt_for_missing_inputs,
execute_command,
)
@pytest.fixture
@@ -14,15 +24,17 @@ def runner() -> CliRunner:
return CliRunner()
@mock.patch("crewai_cli.cli.run_crew")
@mock.patch("crewai_cli.run_crew.run_crew")
def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRunner) -> None:
result = runner.invoke(run, ["-f", "my_custom_trained.pkl"])
run_crew_mock.assert_called_once_with(trained_agents_file="my_custom_trained.pkl")
run_crew_mock.assert_called_once_with(
trained_agents_file="my_custom_trained.pkl",
)
assert result.exit_code == 0
@mock.patch("crewai_cli.cli.run_crew")
@mock.patch("crewai_cli.run_crew.run_crew")
def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliRunner) -> None:
result = runner.invoke(run)
@@ -56,4 +68,101 @@ def test_execute_command_omits_env_var_when_filename_absent(
execute_command(CrewType.STANDARD)
_, kwargs = subprocess_run.call_args
assert "CREWAI_TRAINED_AGENTS_FILE" not in kwargs["env"]
assert "CREWAI_TRAINED_AGENTS_FILE" not in kwargs["env"]
def test_missing_input_names_scans_agent_and_task_placeholders() -> None:
crew = SimpleNamespace(
agents=[
SimpleNamespace(
role="Researcher for {topic}",
goal="Write for {audience}",
backstory="Ignore escaped {{not_an_input}}",
)
],
tasks=[
SimpleNamespace(
description="Research {topic}",
expected_output="A post for {channel}",
output_file="{slug}.md",
)
],
)
assert _missing_input_names(crew, {"topic": "AI"}) == [
"audience",
"channel",
"slug",
]
def test_prompt_for_missing_inputs_merges_runtime_values(monkeypatch) -> None:
crew = SimpleNamespace(
agents=[SimpleNamespace(role="Researcher", goal="Cover {topic}", backstory="")],
tasks=[
SimpleNamespace(
description="Write for {audience}",
expected_output="Post",
output_file=None,
)
],
)
values = {"audience": "developers"}
def prompt(label: str, **_kwargs: object) -> str:
if "audience" in str(label):
return values["audience"]
raise AssertionError(f"Unexpected prompt: {label}")
monkeypatch.setattr("crewai_cli.run_crew.click.prompt", prompt)
assert _prompt_for_missing_inputs(crew, {"topic": "AI"}) == {
"topic": "AI",
"audience": "developers",
}
def test_load_json_crew_for_tui_prepares_metadata_before_prompt(monkeypatch) -> None:
class FakeApp:
pass
fake_tui_module = ModuleType("crewai_cli.crew_run_tui")
fake_tui_module.CrewRunApp = FakeApp
monkeypatch.setitem(sys.modules, "crewai_cli.crew_run_tui", fake_tui_module)
crew = SimpleNamespace(
name="Demo Crew",
tasks=[
SimpleNamespace(name="research_task", description="Research"),
SimpleNamespace(name="", description="Write summary for developers"),
],
agents=[
SimpleNamespace(role="Researcher", name="researcher"),
SimpleNamespace(role="", name="writer"),
],
)
prepared: list[object] = []
monkeypatch.setattr(
"crewai_cli.run_crew._json_loading_status",
lambda _message: mock.MagicMock(),
)
monkeypatch.setattr(
"crewai_cli.run_crew._load_json_crew",
lambda _path: (crew, {"topic": "AI"}),
)
monkeypatch.setattr(
"crewai_cli.run_crew._prepare_json_crew_for_tui",
lambda loaded_crew: prepared.append(loaded_crew),
)
app_cls, loaded_crew, default_inputs, task_names, agent_names = (
_load_json_crew_for_tui(Path("crew.jsonc"))
)
assert app_cls is FakeApp
assert loaded_crew is crew
assert default_inputs == {"topic": "AI"}
assert task_names == ["research_task", "Write summary for developers"]
assert agent_names == ["Researcher", "writer"]
assert prepared == [crew]

View File

@@ -531,6 +531,7 @@ def test_docling_source(mock_vector_db):
@pytest.mark.vcr
@pytest.mark.timeout(180)
def test_multiple_docling_sources() -> None:
urls: list[Path | str] = [
"https://lilianweng.github.io/posts/2024-11-28-reward-hacking/",

View File

@@ -383,6 +383,7 @@ def test_bedrock_completion_with_tools():
assert len(call_kwargs['tools']) > 0
@pytest.mark.timeout(180)
def test_bedrock_raises_error_when_model_not_found(bedrock_mocks):
"""Test that BedrockCompletion raises appropriate error when model not found"""
from botocore.exceptions import ClientError

View File

@@ -0,0 +1,159 @@
"""Embedding dimension mismatch must fail loudly with migration guidance.
The default embedder changed from text-embedding-3-small (1536 dims) to
text-embedding-3-large (3072 dims); stores created before the upgrade must
not silently zero-fill vectors or return empty search results.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from crewai.memory.storage.backend import EmbeddingDimensionMismatchError
from crewai.memory.types import MemoryRecord
@pytest.fixture
def lancedb_path(tmp_path: Path) -> Path:
return tmp_path / "mem"
def _record(dim: int, content: str = "test") -> MemoryRecord:
return MemoryRecord(content=content, scope="/foo", embedding=[0.1] * dim)
def test_lancedb_save_mismatch_raises(lancedb_path: Path) -> None:
from crewai.memory.storage.lancedb_storage import LanceDBStorage
storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
storage.save([_record(4)])
with pytest.raises(EmbeddingDimensionMismatchError) as exc_info:
storage.save([_record(8, "new embedder output")])
message = str(exc_info.value)
assert "4-dimensional" in message
assert "8-dimensional" in message
assert "crewai reset-memories --memory" in message
assert "text-embedding-3-small" in message
def test_lancedb_mixed_batch_mismatch_raises(lancedb_path: Path) -> None:
"""A single save() batch with inconsistent dimensions must be rejected."""
from crewai.memory.storage.lancedb_storage import LanceDBStorage
storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
storage.save([_record(4)])
with pytest.raises(EmbeddingDimensionMismatchError):
storage.save([_record(4), _record(8, "stray dimension")])
def test_lancedb_mixed_batch_on_fresh_store_raises(lancedb_path: Path) -> None:
from crewai.memory.storage.lancedb_storage import LanceDBStorage
storage = LanceDBStorage(path=str(lancedb_path))
with pytest.raises(EmbeddingDimensionMismatchError):
storage.save([_record(4), _record(8)])
def test_lancedb_search_mismatch_raises(lancedb_path: Path) -> None:
from crewai.memory.storage.lancedb_storage import LanceDBStorage
storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
storage.save([_record(4)])
with pytest.raises(EmbeddingDimensionMismatchError):
storage.search([0.1] * 8)
def test_lancedb_update_mismatch_raises(lancedb_path: Path) -> None:
from crewai.memory.storage.lancedb_storage import LanceDBStorage
storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
record = _record(4)
storage.save([record])
stale = MemoryRecord(
id=record.id, content="updated", scope="/foo", embedding=[0.1] * 8
)
with pytest.raises(EmbeddingDimensionMismatchError):
storage.update(stale)
def test_lancedb_reopened_store_detects_mismatch(lancedb_path: Path) -> None:
"""The upgrade scenario: an old store reopened with a new embedder."""
from crewai.memory.storage.lancedb_storage import LanceDBStorage
old = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
old.save([_record(4)])
reopened = LanceDBStorage(path=str(lancedb_path))
with pytest.raises(EmbeddingDimensionMismatchError):
reopened.save([_record(8)])
with pytest.raises(EmbeddingDimensionMismatchError):
reopened.search([0.1] * 8)
def test_lancedb_matching_dim_still_works(lancedb_path: Path) -> None:
from crewai.memory.storage.lancedb_storage import LanceDBStorage
storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
storage.save([_record(4)])
storage.save([_record(4, "second")])
assert len(storage.search([0.1] * 4, limit=5)) == 2
def test_error_is_not_a_runtime_error() -> None:
"""Background-save plumbing treats RuntimeError as executor shutdown and
silently drops the save — the mismatch must not be classified that way."""
err = EmbeddingDimensionMismatchError(1536, 3072)
assert not isinstance(err, RuntimeError)
assert isinstance(err, ValueError)
def test_background_save_propagates_dimension_mismatch(tmp_path: Path) -> None:
from unittest.mock import MagicMock
from crewai.memory.unified_memory import Memory
mem = Memory(
storage=str(tmp_path / "db"),
llm=MagicMock(),
embedder=lambda texts: [[0.1] * 4 for _ in texts],
)
def raise_mismatch(*_args: object, **_kwargs: object) -> None:
raise EmbeddingDimensionMismatchError(1536, 3072)
mem._encode_batch = raise_mismatch # type: ignore[method-assign]
with pytest.raises(EmbeddingDimensionMismatchError):
mem._background_encode_batch(["content"], None, None, None, None, None, False, None)
def test_background_save_still_swallows_shutdown_runtime_error(tmp_path: Path) -> None:
from unittest.mock import MagicMock
from crewai.memory.unified_memory import Memory
mem = Memory(
storage=str(tmp_path / "db"),
llm=MagicMock(),
embedder=lambda texts: [[0.1] * 4 for _ in texts],
)
def raise_shutdown(*_args: object, **_kwargs: object) -> None:
raise RuntimeError("cannot schedule new futures after shutdown")
mem._encode_batch = raise_shutdown # type: ignore[method-assign]
assert (
mem._background_encode_batch(
["content"], None, None, None, None, None, False, None
)
== []
)

View File

@@ -409,6 +409,36 @@ class TestCrewAutoScoping:
assert crew._memory is not None
assert hasattr(crew._memory, "root_scope")
assert crew._memory.root_scope == "/crew/research-crew"
assert crew._memory.llm is agent.llm
def test_crew_memory_true_prefers_chat_llm(self) -> None:
"""Auto-created crew memory uses chat_llm when configured."""
from crewai.agent import Agent
from crewai.crew import Crew
from crewai.task import Task
agent = Agent(
role="Researcher",
goal="Research",
backstory="Expert researcher",
llm="openai/gpt-4o-mini",
)
task = Task(
description="Do research",
expected_output="Report",
agent=agent,
)
crew = Crew(
name="Research Crew",
agents=[agent],
tasks=[task],
chat_llm="ollama/llama3",
memory=True,
)
assert crew._memory is not None
assert crew._memory.llm == "ollama/llama3"
def test_crew_memory_instance_preserves_no_root_scope(
self, tmp_path: Path, mock_embedder: MagicMock

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import datetime, timedelta
from pathlib import Path
import threading
from unittest.mock import MagicMock
import pytest
@@ -489,8 +490,8 @@ def test_composite_score_reranks_results(
"""Same semantic score: high-importance recent memory ranks first."""
from crewai.memory.unified_memory import Memory
# Use same dim as default LanceDB (1536) so storage does not overwrite embedding
emb = [0.1] * 1536
# Use same dim as default LanceDB (3072) so storage does not overwrite embedding
emb = [0.1] * 3072
mem = Memory(
storage=str(tmp_path / "rerank_db"),
llm=MagicMock(),
@@ -974,6 +975,42 @@ def test_recall_drains_pending_writes(tmp_path: Path, mock_embedder: MagicMock)
assert "Python" in matches[0].record.content
def test_drain_writes_reports_background_save_failure_without_raising(
tmp_path: Path, mock_embedder: MagicMock
) -> None:
"""Background memory failures should be reported without failing cleanup."""
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.memory_events import MemorySaveFailedEvent
from crewai.memory.unified_memory import Memory
failure_seen = threading.Event()
failures: list[MemorySaveFailedEvent] = []
mem = Memory(
storage=str(tmp_path / "db"),
llm=MagicMock(),
embedder=mock_embedder,
)
def fail_save() -> None:
raise ValueError("invalid model ID")
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MemorySaveFailedEvent)
def on_memory_save_failed(_source, event):
failures.append(event)
failure_seen.set()
mem._submit_save(fail_save)
mem.drain_writes()
assert failure_seen.wait(timeout=2)
assert failures
assert failures[0].value == "background save"
assert failures[0].error == "invalid model ID"
def test_close_drains_and_shuts_down(tmp_path: Path, mock_embedder: MagicMock) -> None:
"""close() should drain pending saves and shut down the pool."""
from crewai.memory.unified_memory import Memory

View File

@@ -0,0 +1,645 @@
"""Tests for crewai.project.crew_loader."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from crewai.llms.base_llm import BaseLLM
from crewai.project.json_loader import JSONProjectError, JSONProjectValidationError
from crewai.project.crew_loader import load_crew
def _write_python_defs(tmp_path: Path) -> None:
module = tmp_path / "json_refs.py"
module.write_text(
"from pydantic import BaseModel\n"
"from crewai import Agent, Task\n"
"from crewai.security.security_config import SecurityConfig\n"
"from crewai.utilities.converter import Converter\n"
"\n"
"def always_true(_context):\n"
" return True\n"
"\n"
"def task_callback(output):\n"
" return output\n"
"\n"
"class SpecialAgent(Agent):\n"
" specialty: str = 'general'\n"
"\n"
"class SpecialTask(Task):\n"
" priority: int = 0\n"
"\n"
"class ReportModel(BaseModel):\n"
" summary: str\n"
"\n"
"class SpecialConverter(Converter):\n"
" pass\n"
"\n"
"security_config = SecurityConfig(fingerprint='agent-seed')\n"
)
def _write_agent(agents_dir: Path, name: str, **overrides) -> Path:
defn = {
"role": f"{name} role",
"goal": f"{name} goal",
"backstory": f"{name} backstory",
}
defn.update(overrides)
f = agents_dir / f"{name}.jsonc"
f.write_text(json.dumps(defn))
return f
def _write_crew(project_dir: Path, crew_def: dict) -> Path:
f = project_dir / "crew.jsonc"
f.write_text(json.dumps(crew_def))
return f
def _input_file_path(value) -> Path:
if isinstance(value, dict):
source = value.get("source", value)
else:
source = getattr(value, "source", value)
path = getattr(source, "path", source)
return Path(str(path))
class TestLoadCrew:
def test_minimal_crew(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "researcher")
crew_def = {
"name": "test_crew",
"agents": ["researcher"],
"tasks": [
{
"name": "research",
"description": "Do research",
"expected_output": "Research findings",
"agent": "researcher",
}
],
}
crew_file = _write_crew(tmp_path, crew_def)
crew, inputs = load_crew(crew_file)
assert crew.name == "test_crew"
assert len(crew.agents) == 1
assert len(crew.tasks) == 1
assert crew.tasks[0].description == "Do research"
assert inputs == {}
def test_crew_with_default_inputs(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "researcher")
crew_def = {
"name": "test_crew",
"agents": ["researcher"],
"tasks": [
{
"name": "research",
"description": "Research {topic}",
"expected_output": "Findings about {topic}",
"agent": "researcher",
}
],
"inputs": {"topic": "AI"},
}
crew_file = _write_crew(tmp_path, crew_def)
crew, inputs = load_crew(crew_file)
assert inputs == {"topic": "AI"}
def test_crew_with_multiple_agents(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "researcher")
_write_agent(agents_dir, "writer")
crew_def = {
"name": "multi_crew",
"agents": ["researcher", "writer"],
"tasks": [
{
"name": "research",
"description": "Do research",
"expected_output": "Findings",
"agent": "researcher",
},
{
"name": "write",
"description": "Write report",
"expected_output": "Report",
"agent": "writer",
"context": ["research"],
},
],
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
assert len(crew.agents) == 2
assert len(crew.tasks) == 2
# Second task should have context referencing first task
assert crew.tasks[1].context is not None
assert len(crew.tasks[1].context) == 1
def test_crew_hierarchical_process(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "worker")
crew_def = {
"name": "hier_crew",
"agents": ["worker"],
"tasks": [
{
"name": "work",
"description": "Do work",
"expected_output": "Work done",
"agent": "worker",
}
],
"process": "hierarchical",
"manager_llm": "openai/gpt-4o",
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
from crewai import Process
assert crew.process == Process.hierarchical
def test_crew_hierarchical_manager_agent_from_separate_agent_file(
self, tmp_path: Path
):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "worker")
_write_agent(agents_dir, "manager")
crew_def = {
"name": "hier_manager_crew",
"agents": ["worker"],
"tasks": [
{
"name": "work",
"description": "Do work",
"expected_output": "Work done",
"agent": "manager",
}
],
"process": "hierarchical",
"manager_agent": "manager",
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
assert len(crew.agents) == 1
assert crew.agents[0].role == "worker role"
assert crew.manager_agent is not None
assert crew.manager_agent.role == "manager role"
assert crew.tasks[0].agent is crew.manager_agent
def test_crew_accepts_llm_config_objects(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "worker", llm="ollama/llama3")
crew_def = {
"name": "llm_config_crew",
"agents": ["worker"],
"tasks": [
{
"name": "work",
"description": "Do work",
"expected_output": "Work done",
"agent": "worker",
}
],
"process": "hierarchical",
"manager_llm": {
"model": "llama3",
"provider": "ollama",
"base_url": "http://localhost:11434",
},
"planning_llm": {
"model": "deepseek-chat",
"provider": "deepseek",
"api_key": "test-key",
},
"chat_llm": {
"model": "openrouter/anthropic/claude-3-opus",
"api_key": "test-key",
},
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
assert isinstance(crew.manager_llm, BaseLLM)
assert crew.manager_llm.model == "llama3"
assert crew.manager_llm.provider == "ollama"
assert crew.manager_llm.base_url == "http://localhost:11434/v1"
assert isinstance(crew.planning_llm, BaseLLM)
assert crew.planning_llm.model == "deepseek-chat"
assert crew.planning_llm.provider == "deepseek"
assert isinstance(crew.chat_llm, BaseLLM)
assert crew.chat_llm.model == "anthropic/claude-3-opus"
assert crew.chat_llm.provider == "openrouter"
def test_crew_accepts_public_crew_config_fields(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "worker")
crew_def = {
"name": "config_crew",
"agents": ["worker"],
"tasks": [
{
"name": "work",
"description": "Do work",
"expected_output": "Work done",
"agent": "worker",
}
],
"cache": False,
"max_rpm": 12,
"planning": True,
"planning_llm": "openai/gpt-4o-mini",
"share_crew": False,
"output_log_file": "crew.log",
"tracing": False,
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
assert crew.cache is False
assert crew.max_rpm == 12
assert crew.planning is True
assert crew.planning_llm == "openai/gpt-4o-mini"
assert crew.output_log_file == "crew.log"
assert crew.tracing is False
def test_crew_with_output_file(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "writer")
crew_def = {
"name": "output_crew",
"agents": ["writer"],
"tasks": [
{
"name": "write",
"description": "Write something",
"expected_output": "Written content",
"agent": "writer",
"output_file": "output.md",
}
],
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
assert crew.tasks[0].output_file == "output.md"
def test_task_accepts_public_task_config_fields(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "writer")
schema = {
"title": "ReportOutput",
"type": "object",
"properties": {
"summary": {"type": "string"},
},
"required": ["summary"],
}
crew_def = {
"name": "task_config_crew",
"agents": ["writer"],
"tasks": [
{
"name": "write",
"description": "Write something",
"expected_output": "Written content",
"agent": "writer",
"output_json": schema,
"response_model": schema,
"create_directory": False,
"human_input": True,
"markdown": True,
"guardrail": "Return a summary field.",
"guardrail_max_retries": 1,
"allow_crewai_trigger_context": False,
}
],
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
task = crew.tasks[0]
assert task.output_json is not None
assert "summary" in task.output_json.model_fields
assert task.response_model is not None
assert task.create_directory is False
assert task.human_input is True
assert task.markdown is True
assert task.guardrail == "Return a summary field."
assert task.allow_crewai_trigger_context is False
def test_crew_loads_conditional_task_with_python_condition(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
_write_python_defs(tmp_path)
monkeypatch.syspath_prepend(str(tmp_path))
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "worker")
crew_def = {
"name": "conditional_crew",
"agents": ["worker"],
"tasks": [
{
"name": "first",
"description": "First task",
"expected_output": "First output",
"agent": "worker",
},
{
"type": "ConditionalTask",
"name": "second",
"description": "Second task",
"expected_output": "Second output",
"agent": "worker",
"condition": {"python": "json_refs.always_true"},
},
],
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
from crewai.tasks.conditional_task import ConditionalTask
assert isinstance(crew.tasks[1], ConditionalTask)
assert crew.tasks[1].should_execute(None)
def test_crew_loads_custom_agent_and_task_types(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
_write_python_defs(tmp_path)
monkeypatch.syspath_prepend(str(tmp_path))
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(
agents_dir,
"specialist",
type={"python": "json_refs.SpecialAgent"},
security_config={"python": "json_refs.security_config"},
specialty="research",
)
crew_def = {
"name": "custom_types_crew",
"agents": ["specialist"],
"tasks": [
{
"type": {"python": "json_refs.SpecialTask"},
"name": "prioritized",
"description": "Do prioritized work",
"expected_output": "Prioritized output",
"agent": "specialist",
"priority": 7,
}
],
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
assert crew.agents[0].__class__.__name__ == "SpecialAgent"
assert crew.agents[0].specialty == "research"
from crewai.security.fingerprint import Fingerprint
assert crew.agents[0].security_config.fingerprint == Fingerprint.generate(
seed="agent-seed"
)
assert crew.tasks[0].__class__.__name__ == "SpecialTask"
assert crew.tasks[0].priority == 7
def test_crew_loads_python_ref_task_fields(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
_write_python_defs(tmp_path)
monkeypatch.syspath_prepend(str(tmp_path))
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "writer")
crew_def = {
"name": "python_refs_crew",
"agents": ["writer"],
"tasks": [
{
"name": "write",
"description": "Write something",
"expected_output": "Written content",
"agent": "writer",
"callback": {"python": "json_refs.task_callback"},
"output_json": {"python": "json_refs.ReportModel"},
"converter_cls": {"python": "json_refs.SpecialConverter"},
}
],
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
task = crew.tasks[0]
assert task.callback.__name__ == "task_callback"
assert task.output_json.__name__ == "ReportModel"
assert "summary" in task.output_json.model_fields
assert task.converter_cls.__name__ == "SpecialConverter"
def test_crew_loads_project_relative_input_files(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "reader")
data_dir = tmp_path / "data"
data_dir.mkdir()
brief_path = data_dir / "brief.txt"
spec_path = data_dir / "spec.md"
brief_path.write_text("brief")
spec_path.write_text("spec")
crew_def = {
"name": "input_files_crew",
"agents": ["reader"],
"tasks": [
{
"name": "read",
"description": "Read files",
"expected_output": "File summary",
"agent": "reader",
"input_files": {
"brief": "data/brief.txt",
"spec": {"source": "data/spec.md"},
},
}
],
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
input_files = crew.tasks[0].input_files
assert _input_file_path(input_files["brief"]) == brief_path
assert _input_file_path(input_files["spec"]) == spec_path
def test_missing_agent_file_raises(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
crew_def = {
"name": "broken_crew",
"agents": ["nonexistent"],
"tasks": [],
}
crew_file = _write_crew(tmp_path, crew_def)
with pytest.raises(FileNotFoundError, match="nonexistent"):
load_crew(crew_file)
def test_task_references_unknown_agent_raises(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "researcher")
crew_def = {
"name": "bad_ref_crew",
"agents": ["researcher"],
"tasks": [
{
"name": "task1",
"description": "Do something",
"expected_output": "Something",
"agent": "unknown_agent",
}
],
}
crew_file = _write_crew(tmp_path, crew_def)
with pytest.raises(JSONProjectError, match="unknown_agent"):
load_crew(crew_file)
def test_task_context_order_dependency(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "worker")
crew_def = {
"name": "order_crew",
"agents": ["worker"],
"tasks": [
{
"name": "task2",
"description": "Second task",
"expected_output": "Output",
"agent": "worker",
"context": ["task1"],
},
{
"name": "task1",
"description": "First task",
"expected_output": "Output",
"agent": "worker",
},
],
}
crew_file = _write_crew(tmp_path, crew_def)
with pytest.raises(JSONProjectError, match="task1"):
load_crew(crew_file)
def test_runtime_fields_are_rejected(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "worker")
crew_def = {
"name": "bad_runtime_crew",
"id": "00000000-0000-4000-8000-000000000000",
"agents": ["worker"],
"tasks": [
{
"name": "work",
"description": "Work",
"expected_output": "Done",
"agent": "worker",
}
],
}
crew_file = _write_crew(tmp_path, crew_def)
with pytest.raises(JSONProjectValidationError, match="runtime-only"):
load_crew(crew_file)
def test_custom_agents_dir(self, tmp_path: Path):
custom_dir = tmp_path / "my_agents"
custom_dir.mkdir()
_write_agent(custom_dir, "analyst")
crew_def = {
"name": "custom_dir_crew",
"agents": ["analyst"],
"tasks": [
{
"name": "analyze",
"description": "Analyze data",
"expected_output": "Analysis",
"agent": "analyst",
}
],
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file, agents_dir=custom_dir)
assert len(crew.agents) == 1
def test_crew_verbose_and_memory_flags(self, tmp_path: Path):
agents_dir = tmp_path / "agents"
agents_dir.mkdir()
_write_agent(agents_dir, "worker")
crew_def = {
"name": "flags_crew",
"agents": ["worker"],
"tasks": [
{
"name": "work",
"description": "Work",
"expected_output": "Done",
"agent": "worker",
}
],
"verbose": True,
"memory": True,
}
crew_file = _write_crew(tmp_path, crew_def)
crew, _ = load_crew(crew_file)
assert crew.verbose is True

View File

@@ -0,0 +1,519 @@
"""Tests for crewai.project.json_loader."""
from __future__ import annotations
import json
from pathlib import Path
import sys
import pytest
from crewai.llms.base_llm import BaseLLM
from crewai.project.json_loader import (
JSONProjectValidationError,
find_json_project_file,
load_agent,
strip_jsonc_comments,
)
class TestStripJsoncComments:
def test_strips_single_line_comments(self):
text = '{\n "key": "value" // this is a comment\n}'
result = strip_jsonc_comments(text)
data = json.loads(result)
assert data["key"] == "value"
def test_strips_block_comments(self):
text = '{\n /* block comment */\n "key": "value"\n}'
result = strip_jsonc_comments(text)
data = json.loads(result)
assert data["key"] == "value"
def test_preserves_urls_with_double_slash(self):
text = '{\n "url": "https://example.com"\n}'
result = strip_jsonc_comments(text)
data = json.loads(result)
assert data["url"] == "https://example.com"
def test_preserves_comment_markers_inside_strings(self):
text = """{
"url": "https://example.com/a//b",
"pattern": "keep /* this */ text",
"text": "value // not a comment",
}"""
result = strip_jsonc_comments(text)
data = json.loads(result)
assert data["url"] == "https://example.com/a//b"
assert data["pattern"] == "keep /* this */ text"
assert data["text"] == "value // not a comment"
def test_removes_trailing_commas(self):
text = '{\n "a": 1,\n "b": 2,\n}'
result = strip_jsonc_comments(text)
data = json.loads(result)
assert data == {"a": 1, "b": 2}
def test_removes_trailing_commas_in_arrays(self):
text = '{"arr": [1, 2, 3,]}'
result = strip_jsonc_comments(text)
data = json.loads(result)
assert data["arr"] == [1, 2, 3]
def test_plain_json_unchanged(self):
text = '{"key": "value"}'
result = strip_jsonc_comments(text)
assert json.loads(result) == {"key": "value"}
def test_find_json_project_file_prefers_jsonc(tmp_path: Path):
(tmp_path / "agent.json").write_text("{}")
jsonc_path = tmp_path / "agent.jsonc"
jsonc_path.write_text("{}")
assert find_json_project_file(tmp_path, "agent") == jsonc_path
class TestLoadAgent:
def test_load_minimal_agent(self, tmp_path: Path):
agent_def = {
"role": "Researcher",
"goal": "Find information",
"backstory": "Expert researcher.",
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
assert agent.role == "Researcher"
assert agent.goal == "Find information"
assert agent.backstory == "Expert researcher."
def test_load_agent_with_llm(self, tmp_path: Path):
agent_def = {
"role": "Coder",
"goal": "Write code",
"backstory": "Expert coder.",
"llm": "openai/gpt-4o",
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
assert agent.role == "Coder"
def test_load_agent_with_llm_config_object(self, tmp_path: Path):
agent_def = {
"role": "Coder",
"goal": "Write code",
"backstory": "Expert coder.",
"llm": {
"model": "llama3",
"provider": "ollama",
"temperature": 0.2,
"base_url": "http://localhost:11434",
},
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
assert isinstance(agent.llm, BaseLLM)
assert agent.llm.model == "llama3"
assert agent.llm.provider == "ollama"
assert agent.llm.temperature == 0.2
assert agent.llm.base_url == "http://localhost:11434/v1"
def test_load_agent_with_planning_config_llm_object(self, tmp_path: Path):
agent_def = {
"role": "Planner",
"goal": "Plan work",
"backstory": "Expert planner.",
"llm": "ollama/llama3",
"planning_config": {
"reasoning_effort": "high",
"llm": {
"model": "deepseek-chat",
"provider": "deepseek",
"api_key": "test-key",
},
},
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
assert agent.planning_config is not None
assert isinstance(agent.planning_config.llm, BaseLLM)
assert agent.planning_config.llm.model == "deepseek-chat"
assert agent.planning_config.llm.provider == "deepseek"
assert agent.planning_config.llm.api_key == "test-key"
def test_load_agent_with_settings_block(self, tmp_path: Path):
agent_def = {
"role": "Analyst",
"goal": "Analyze data",
"backstory": "Data expert.",
"settings": {
"verbose": True,
"allow_delegation": True,
"max_iter": 10,
"cache": False,
},
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
assert agent.role == "Analyst"
assert agent.verbose is True
assert agent.allow_delegation is True
assert agent.max_iter == 10
assert agent.cache is False
def test_load_agent_with_top_level_settings(self, tmp_path: Path):
agent_def = {
"role": "Analyst",
"goal": "Analyze data",
"backstory": "Data expert.",
"verbose": True,
"max_iter": 15,
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
assert agent.verbose is True
assert agent.max_iter == 15
def test_load_agent_accepts_public_agent_config_fields(self, tmp_path: Path):
agent_def = {
"role": "Analyst",
"goal": "Analyze data",
"backstory": "Data expert.",
"max_execution_time": 30,
"use_system_prompt": False,
"system_template": "system: {{ .System }}",
"prompt_template": "prompt: {{ .Prompt }}",
"response_template": "response: {{ .Response }}",
"inject_date": True,
"date_format": "%Y",
"guardrail": "Only return concise answers.",
"guardrail_max_retries": 1,
"security_config": {"fingerprint": "agent-seed"},
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
assert agent.max_execution_time == 30
assert agent.use_system_prompt is False
assert agent.system_template == "system: {{ .System }}"
assert agent.inject_date is True
assert agent.guardrail == "Only return concise answers."
def test_load_agent_accepts_serialized_tool_dict(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
module = tmp_path / "test_tools.py"
module.write_text(
"from crewai.tools.base_tool import BaseTool\n"
"class EchoTool(BaseTool):\n"
" name: str = 'echo'\n"
" description: str = 'Echo input'\n"
" def _run(self, value: str = '') -> str:\n"
" return value\n"
)
monkeypatch.syspath_prepend(str(tmp_path))
sys.modules.pop("test_tools", None)
agent_def = {
"role": "Tool User",
"goal": "Use tools",
"backstory": "Tool expert.",
"tools": [
{
"tool_type": "test_tools.EchoTool",
"name": "echo",
"description": "Echo input",
}
],
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
assert len(agent.tools or []) == 1
assert agent.tools[0].name == "echo"
def test_load_agent_accepts_static_mcp_tool_filter(self, tmp_path: Path):
agent_def = {
"role": "MCP User",
"goal": "Use MCP tools",
"backstory": "MCP expert.",
"mcps": [
{
"command": "python",
"args": ["server.py"],
"tool_filter": {
"type": "static",
"allowed_tool_names": ["read_file"],
"blocked_tool_names": ["delete_file"],
},
}
],
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
tool_filter = agent.mcps[0].tool_filter
assert tool_filter({"name": "read_file"})
assert not tool_filter({"name": "delete_file"})
assert not tool_filter({"name": "write_file"})
def test_load_agent_rejects_runtime_fields(self, tmp_path: Path):
agent_def = {
"id": "00000000-0000-4000-8000-000000000000",
"role": "Analyst",
"goal": "Analyze data",
"backstory": "Data expert.",
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
with pytest.raises(JSONProjectValidationError, match="runtime-only"):
load_agent(agent_file)
def test_settings_block_takes_precedence(self, tmp_path: Path):
agent_def = {
"role": "Analyst",
"goal": "Analyze data",
"backstory": "Data expert.",
"verbose": False,
"settings": {
"verbose": True,
},
}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
agent = load_agent(agent_file)
assert agent.verbose is True
def test_load_agent_from_jsonc(self, tmp_path: Path):
jsonc_content = """{
// This is a JSONC file with comments
"role": "Writer",
"goal": "Write articles",
"backstory": "Expert writer.",
/* multi-line
comment */
}"""
agent_file = tmp_path / "agent.jsonc"
agent_file.write_text(jsonc_content)
agent = load_agent(agent_file)
assert agent.role == "Writer"
def test_load_agent_missing_required_fields(self, tmp_path: Path):
agent_def = {"role": "Incomplete"}
agent_file = tmp_path / "agent.json"
agent_file.write_text(json.dumps(agent_def))
with pytest.raises(Exception):
load_agent(agent_file)
def test_load_agent_file_not_found(self):
with pytest.raises(FileNotFoundError):
load_agent(Path("/nonexistent/agent.json"))
class TestResolveTools:
def test_unknown_tool_raises_with_guidance(self):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
with pytest.raises(JSONProjectError, match="Unknown tool 'NotARealToolXYZ'"):
_resolve_tools(["NotARealToolXYZ"])
def test_missing_custom_tool_raises(self, tmp_path, monkeypatch):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
monkeypatch.chdir(tmp_path)
with pytest.raises(JSONProjectError, match="custom:missing"):
_resolve_tools(["custom:missing"])
def test_custom_tool_without_basetool_subclass_raises(self, tmp_path, monkeypatch):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
monkeypatch.chdir(tmp_path)
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
(tools_dir / "empty.py").write_text("x = 1\n")
with pytest.raises(JSONProjectError, match="No BaseTool subclass"):
_resolve_tools(["custom:empty"])
def test_custom_tool_resolves(self, tmp_path, monkeypatch):
from crewai.project.json_loader import _resolve_tools
monkeypatch.chdir(tmp_path)
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
(tools_dir / "echo.py").write_text(
"from crewai.tools.base_tool import BaseTool\n"
"\n"
"class EchoTool(BaseTool):\n"
" name: str = 'echo'\n"
" description: str = 'echo input'\n"
"\n"
" def _run(self, text: str) -> str:\n"
" return text\n"
)
tools = _resolve_tools(["custom:echo"])
assert len(tools) == 1
assert tools[0].name == "echo"
def test_serialized_tool_dicts_pass_through(self):
from crewai.project.json_loader import _resolve_tools
spec = {"tool_type": "some.module.Tool"}
assert _resolve_tools([spec]) == [spec]
class TestValidationDoesNotExecuteTools:
def _write_project(self, root, tool_line='"custom:landmine"'):
agents_dir = root / "agents"
agents_dir.mkdir()
(agents_dir / "worker.jsonc").write_text(
"{\n"
' "role": "Worker",\n'
' "goal": "Work",\n'
' "backstory": "Works hard",\n'
f' "tools": [{tool_line}]\n'
"}\n"
)
crew_path = root / "crew.jsonc"
crew_path.write_text(
"{\n"
' "agents": ["worker"],\n'
' "tasks": [\n'
' {"name": "t1", "description": "Do work", '
'"expected_output": "Done", "agent": "worker"}\n'
" ]\n"
"}\n"
)
return crew_path
def test_validate_does_not_execute_custom_tool_code(self, tmp_path):
from crewai.project.json_loader import validate_crew_project
sentinel = tmp_path / "executed.txt"
tools_dir = tmp_path / "tools"
tools_dir.mkdir()
(tools_dir / "landmine.py").write_text(
f"open({str(sentinel)!r}, 'w').write('boom')\n"
)
crew_path = self._write_project(tmp_path)
project = validate_crew_project(crew_path, tmp_path / "agents")
assert not sentinel.exists(), "validation must not execute tools/<name>.py"
assert project.agent_names == ["worker"]
def test_validate_does_not_import_python_refs(
self, tmp_path, monkeypatch: pytest.MonkeyPatch
):
from crewai.project.json_loader import validate_crew_project
sentinel = tmp_path / "python_ref_executed.txt"
(tmp_path / "callbacks.py").write_text(
"from pathlib import Path\n"
f"Path({str(sentinel)!r}).write_text('boom')\n"
"def step_callback(*_args, **_kwargs):\n"
" return None\n"
)
monkeypatch.syspath_prepend(str(tmp_path))
sys.modules.pop("callbacks", None)
crew_path = self._write_project(
tmp_path,
tool_line='{"tool_type": "some.module.Tool"}',
)
agent_file = tmp_path / "agents" / "worker.jsonc"
agent_def = json.loads(agent_file.read_text())
agent_def["step_callback"] = {"python": "callbacks.step_callback"}
agent_file.write_text(json.dumps(agent_def))
validate_crew_project(crew_path, tmp_path / "agents")
assert not sentinel.exists(), "validation must not import Python refs"
def test_validate_reports_missing_custom_tool_file(self, tmp_path):
from crewai.project.json_loader import (
JSONProjectValidationError,
validate_crew_project,
)
crew_path = self._write_project(tmp_path)
with pytest.raises(JSONProjectValidationError) as exc_info:
validate_crew_project(crew_path, tmp_path / "agents")
assert "custom:landmine" in str(exc_info.value)
assert "not found" in str(exc_info.value)
def test_validate_reports_path_escaping_custom_tool(self, tmp_path):
from crewai.project.json_loader import (
JSONProjectValidationError,
validate_crew_project,
)
crew_path = self._write_project(tmp_path, tool_line='"custom:../evil"')
with pytest.raises(JSONProjectValidationError) as exc_info:
validate_crew_project(crew_path, tmp_path / "agents")
assert "Invalid custom tool name" in str(exc_info.value)
class TestCustomToolPathSafety:
@pytest.mark.parametrize(
"bad_name",
["../evil", "..", "sub/inner", "/etc/passwd", "a-b", "", "name.py"],
)
def test_unsafe_names_rejected_at_runtime(self, bad_name, tmp_path, monkeypatch):
from crewai.project.json_loader import JSONProjectError, _resolve_tools
monkeypatch.chdir(tmp_path)
with pytest.raises(JSONProjectError, match="Invalid custom tool name"):
_resolve_tools([f"custom:{bad_name}"])
def test_resolves_relative_to_project_root_not_cwd(self, tmp_path, monkeypatch):
from crewai.project.json_loader import _resolve_tools
project_root = tmp_path / "project"
tools_dir = project_root / "tools"
tools_dir.mkdir(parents=True)
(tools_dir / "echo.py").write_text(
"from crewai.tools.base_tool import BaseTool\n"
"\n"
"class EchoTool(BaseTool):\n"
" name: str = 'echo'\n"
" description: str = 'echo input'\n"
"\n"
" def _run(self, text: str) -> str:\n"
" return text\n"
)
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
monkeypatch.chdir(elsewhere)
tools = _resolve_tools(["custom:echo"], project_root=project_root)
assert len(tools) == 1
assert tools[0].name == "echo"

View File

@@ -46,6 +46,30 @@ class TestModelKeyBackwardCompatibility:
)
assert provider.model_name == "text-embedding-3-large"
def test_openai_provider_ignores_chat_model_env(self, monkeypatch):
"""Test OpenAI embeddings don't inherit the chat model env var."""
monkeypatch.setenv("OPENAI_MODEL_NAME", "gpt-5.5")
monkeypatch.setenv("MODEL", "gpt-5.5")
monkeypatch.delenv("EMBEDDINGS_OPENAI_MODEL_NAME", raising=False)
provider = OpenAIProvider(api_key="test-key")
assert provider.model_name == "text-embedding-3-large"
def test_azure_provider_ignores_openai_chat_model_env(self, monkeypatch):
"""Test Azure embeddings don't inherit the OpenAI chat model env var."""
monkeypatch.setenv("OPENAI_MODEL_NAME", "gpt-5.5")
monkeypatch.setenv("MODEL", "gpt-5.5")
monkeypatch.delenv("EMBEDDINGS_OPENAI_MODEL_NAME", raising=False)
monkeypatch.delenv("AZURE_OPENAI_MODEL_NAME", raising=False)
provider = AzureProvider(
api_key="test-key",
deployment_id="test-deployment",
)
assert provider.model_name == "text-embedding-3-large"
def test_cohere_provider_accepts_model_key(self):
"""Test Cohere provider accepts 'model' as alias for 'model_name'."""
provider = CohereProvider(
@@ -361,4 +385,4 @@ class TestLegacyConfigurationFormats:
deployment_id="test-deployment",
model="text-embedding-3-large",
)
assert provider.model_name == "text-embedding-3-large"
assert provider.model_name == "text-embedding-3-large"

View File

@@ -1,11 +1,10 @@
import os
import threading
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from crewai import Agent, Crew, Task
from crewai.telemetry import Telemetry
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
@@ -71,6 +70,32 @@ def test_set_tracer_skips_when_provider_already_configured():
assert telemetry.trace_set is True
def test_flow_execution_span_records_crewai_version():
tracer = Mock()
span = Mock()
tracer.start_span.return_value = span
with (
patch.dict(
os.environ,
{
"CREWAI_DISABLE_TELEMETRY": "false",
"CREWAI_DISABLE_TRACKING": "false",
"OTEL_SDK_DISABLED": "false",
},
),
patch("crewai.telemetry.telemetry.TracerProvider"),
patch("crewai.telemetry.telemetry.trace.get_tracer", return_value=tracer),
patch("crewai.telemetry.telemetry.version", return_value="9.9.9"),
):
telemetry = Telemetry()
telemetry.flow_execution_span("ResearchFlow", ["start", "finish"])
tracer.start_span.assert_called_once_with("Flow Execution")
span.set_attribute.assert_any_call("crewai_version", "9.9.9")
span.set_attribute.assert_any_call("flow_name", "ResearchFlow")
@patch("crewai.telemetry.telemetry.logger.error")
@patch(
"opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter.export",
@@ -85,9 +110,8 @@ def test_telemetry_fails_due_connect_timeout(export_mock, logger_mock):
os.environ, {"CREWAI_DISABLE_TELEMETRY": "false", "OTEL_SDK_DISABLED": "false"}
):
telemetry = Telemetry()
telemetry.set_tracer()
tracer = trace.get_tracer(__name__)
tracer = telemetry.provider.get_tracer(__name__)
with tracer.start_as_current_span("test-span"):
agent = Agent(
role="agent",
@@ -103,7 +127,7 @@ def test_telemetry_fails_due_connect_timeout(export_mock, logger_mock):
crew = Crew(agents=[agent], tasks=[task], name="TestCrew")
crew.kickoff()
trace.get_tracer_provider().force_flush()
telemetry.provider.force_flush()
assert export_mock.called
assert logger_mock.call_count == export_mock.call_count

View File

@@ -1066,7 +1066,11 @@ class TestLLMObjectPreservedInContext:
persistence = SQLiteFlowPersistence(db_path)
from crewai.llm import LLM
mock_llm_obj = LLM(model="gemini-2.0-flash", provider="gemini")
mock_llm_obj = LLM(
model="llama3",
provider="ollama",
base_url="http://localhost:11434",
)
class PausingProvider:
def __init__(self, persistence: SQLiteFlowPersistence):
@@ -1116,19 +1120,19 @@ class TestLLMObjectPreservedInContext:
assert provider.captured_context is not None
assert isinstance(provider.captured_context.llm, dict)
assert provider.captured_context.llm["model"] == "gemini/gemini-2.0-flash"
assert provider.captured_context.llm["model"] == "ollama/llama3"
flow_id = result.context.flow_id
loaded = persistence.load_pending_feedback(flow_id)
assert loaded is not None
_, loaded_context = loaded
assert isinstance(loaded_context.llm, dict)
assert loaded_context.llm["model"] == "gemini/gemini-2.0-flash"
assert loaded_context.llm["model"] == "ollama/llama3"
flow2 = TestFlow.from_pending(flow_id, persistence)
assert flow2._pending_feedback_context is not None
assert isinstance(flow2._pending_feedback_context.llm, dict)
assert flow2._pending_feedback_context.llm["model"] == "gemini/gemini-2.0-flash"
assert flow2._pending_feedback_context.llm["model"] == "ollama/llama3"
with patch.object(flow2, "_collapse_to_outcome", return_value="approved") as mock_collapse:
flow2.resume("this looks good, proceed!")
@@ -1140,7 +1144,7 @@ class TestLLMObjectPreservedInContext:
assert call_kwargs.kwargs["outcomes"] == ["needs_changes", "approved"]
# LLM should be a live object (from _human_feedback_llm) or reconstructed, not None
assert call_kwargs.kwargs["llm"] is not None
assert getattr(call_kwargs.kwargs["llm"], "model", None) == "gemini-2.0-flash"
assert getattr(call_kwargs.kwargs["llm"], "model", None) == "llama3"
assert flow2.last_human_feedback.outcome == "approved"
assert flow2.result_path == "approved"
@@ -1172,20 +1176,24 @@ class TestLLMObjectPreservedInContext:
from crewai.flow.human_feedback import _serialize_llm_for_context
from crewai.llm import LLM
llm = LLM(model="gemini-2.0-flash", provider="gemini")
llm = LLM(
model="llama3",
provider="ollama",
base_url="http://localhost:11434",
)
result = _serialize_llm_for_context(llm)
assert isinstance(result, dict)
assert result["model"] == "gemini/gemini-2.0-flash"
assert result["model"] == "ollama/llama3"
def test_provider_prefix_not_doubled_when_already_present(self) -> None:
"""Test that provider prefix is not added when model already has a slash."""
from crewai.flow.human_feedback import _serialize_llm_for_context
from crewai.llm import LLM
llm = LLM(model="gemini/gemini-2.0-flash")
llm = LLM(model="ollama/llama3", base_url="http://localhost:11434")
result = _serialize_llm_for_context(llm)
assert isinstance(result, dict)
assert result["model"] == "gemini/gemini-2.0-flash"
assert result["model"] == "ollama/llama3"
def test_no_provider_attr_falls_back_to_bare_model(self) -> None:
"""Test that objects without to_config_dict fall back to model string."""

View File

@@ -44,6 +44,8 @@ def test_flow_public_exports_are_explicit():
"FlowDefinition",
"FlowDefinitionCondition",
"FlowDefinitionDiagnostic",
"FlowEachActionDefinition",
"FlowEachInnerActionDefinition",
"FlowExpressionActionDefinition",
"FlowHumanFeedbackDefinition",
"FlowMethodDefinition",
@@ -432,6 +434,73 @@ def test_flow_definition_round_trips_json_and_yaml():
assert yaml_round_trip.methods["decide"].listen == "begin"
def test_each_action_round_trips_json_and_yaml():
definition = flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
"methods": {
"process_rows": {
"description": "Process every loaded row.",
"start": True,
"do": {
"call": "each",
"in": "state.rows",
"do": [
{
"normalize": {
"call": "tool",
"ref": "my_tools:NormalizeRowTool",
"with": {"row": "${ item }"},
}
},
{
"save": {
"call": "code",
"ref": "my_flow:save_row",
"with": {
"row": "${ item }",
"normalized": "${ outputs.normalize }",
},
}
},
],
},
}
},
}
)
json_round_trip = flow_definition.FlowDefinition.from_json(definition.to_json())
yaml_round_trip = flow_definition.FlowDefinition.from_yaml(definition.to_yaml())
assert json_round_trip.to_dict() == definition.to_dict()
assert yaml_round_trip.to_dict() == definition.to_dict()
assert yaml_round_trip.methods["process_rows"].description == (
"Process every loaded row."
)
assert yaml_round_trip.methods["process_rows"].do.call == "each"
def test_flow_definition_rejects_invalid_method_names():
with pytest.raises(ValueError, match="Flow method names must match"):
flow_definition.FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "InvalidMethodNameFlow",
"methods": {
"process-rows": {
"start": True,
"do": {
"call": "expression",
"expr": "'done'",
},
}
},
}
)
def test_flow_definition_detects_persist_metadata():
@persist(verbose=True)
class PersistedFlow(Flow[dict]):

View File

@@ -1,12 +1,15 @@
from __future__ import annotations
import asyncio
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
import threading
from typing import Any, ClassVar
from unittest.mock import patch
import pytest
from pydantic import ValidationError
from pydantic import BaseModel, ValidationError
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.flow_events import (
@@ -44,6 +47,26 @@ class TypedInputsTool(BaseTool):
return f"{count}:{','.join(include_domains)}"
class AsyncResultTool(BaseTool):
name: str = "AsyncResultTool"
description: str = "Returns an async result from its sync entrypoint."
def _run(self, value: str) -> Any:
async def build_result() -> str:
await asyncio.sleep(0)
return f"async:{value}"
return build_result()
class CallableCodeAction:
def __call__(self, value: str) -> str:
return f"callable:{value}"
CALLABLE_CODE_ACTION = CallableCodeAction()
class ChainFlow(Flow):
@start()
def begin(self):
@@ -67,6 +90,41 @@ class ToolInputFlow(Flow):
return {"query": "ai agents", "suffix": " news"}
class EachActionFlow(Flow):
inner_thread_id: int | None = None
def normalize_row(self, row: str, prefix: str = "normalized") -> str:
return f"{prefix}:{row}"
def save_row(self, row: str, normalized: str) -> dict[str, str]:
return {"row": row, "normalized": normalized}
def keyword_code(self, name: str, punctuation: str) -> str:
return f"{name}{punctuation}"
def fail_on_bad_row(self, row: str) -> str:
if row == "bad":
raise RuntimeError("bad row")
return row
def require_threaded_context(self, row: str) -> str:
try:
asyncio.get_running_loop()
except RuntimeError:
pass
else:
raise RuntimeError("inner action ran on the event loop")
from crewai.flow.flow_context import current_flow_method_name
self.inner_thread_id = threading.get_ident()
return f"{current_flow_method_name.get()}:{row}"
def after_each(self) -> str:
self.state["after_count"] = self.state.get("after_count", 0) + 1
return f"after:{self.state['after_count']}"
CHAIN_YAML = f"""
schema: crewai.flow/v1
name: ChainFlow
@@ -727,6 +785,381 @@ methods:
flow.kickoff()
def test_code_action_renders_keyword_inputs():
yaml_str = f"""
schema: crewai.flow/v1
name: CodeWithFlow
methods:
greet:
do:
call: code
ref: {__name__}:EachActionFlow.keyword_code
with:
name: "${{state.name}}"
punctuation: "!"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"name": "hello"}) == "hello!"
def test_code_action_supports_callable_instance_refs():
yaml_str = f"""
schema: crewai.flow/v1
name: CallableInstanceFlow
methods:
call_instance:
do:
call: code
ref: {__name__}:CALLABLE_CODE_ACTION
with:
value: "${{state.value}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"value": "ok"}) == "callable:ok"
def test_each_action_executes_one_nested_code_action():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- normalize:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
row: "${{item}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
"normalized:a",
"normalized:b",
]
def test_each_action_runs_sync_inner_actions_off_event_loop_with_context():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- threaded:
call: code
ref: {__name__}:EachActionFlow.require_threaded_context
with:
row: "${{item}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
caller_thread_id = threading.get_ident()
assert flow.kickoff(inputs={"rows": ["a"]}) == ["process_rows:a"]
assert flow.inner_thread_id is not None
assert flow.inner_thread_id != caller_thread_id
def test_each_action_runs_async_tool_results_from_sync_inner_actions():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- async_tool:
call: tool
ref: {__name__}:AsyncResultTool
with:
value: "${{item}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["async:a", "async:b"]
def test_each_action_uses_iteration_outputs_between_nested_actions():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- normalize:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
row: "${{item}}"
prefix: saved
- save:
call: code
ref: {__name__}:EachActionFlow.save_row
with:
row: "${{item}}"
normalized: "${{outputs.normalize}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
{"row": "a", "normalized": "saved:a"},
{"row": "b", "normalized": "saved:b"},
]
def test_each_action_resets_inner_outputs_between_iterations():
yaml_str = """
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- leak_check:
call: expression
expr: "has(outputs.previous) ? outputs.previous : 'empty'"
- previous:
call: expression
expr: item
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["a", "b"]
assert flow._method_outputs == [
{"method": "process_rows", "output": ["a", "b"]}
]
def test_each_action_preserves_flow_outputs_and_prefers_inner_outputs():
yaml_str = """
schema: crewai.flow/v1
name: EachFlow
methods:
seed:
do:
call: expression
expr: "'global'"
start: true
process_rows:
do:
call: each
in: state.rows
do:
- before_shadow:
call: expression
expr: "outputs.seed + ':' + item"
- seed:
call: expression
expr: "'local:' + item"
- after_shadow:
call: expression
expr: "outputs.seed"
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
"local:a",
"local:b",
]
assert flow._method_outputs == [
{"method": "seed", "output": "global"},
{"method": "process_rows", "output": ["local:a", "local:b"]},
]
def test_each_action_empty_list_returns_empty_and_listener_runs_once():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- normalize:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
row: "${{item}}"
start: true
after_each:
do:
call: code
ref: {__name__}:EachActionFlow.after_each
listen: process_rows
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
events = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def on_finished(source, event):
events.append(event.method_name)
result = flow.kickoff(inputs={"rows": []})
assert result == "after:1"
assert flow.method_outputs == [[], "after:1"]
assert flow.state["after_count"] == 1
assert events.count("process_rows") == 1
assert events.count("after_each") == 1
@pytest.mark.parametrize(
("expr", "inputs"),
[
("1", {}),
('"rows"', {}),
("state.rows", {"rows": {"a": 1}}),
],
)
def test_each_action_rejects_non_list_inputs(expr, inputs):
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
"methods": {
"process_rows": {
"start": True,
"do": {
"call": "each",
"in": expr,
"do": [{"value": {"call": "expression", "expr": "item"}}],
},
}
},
}
)
flow = Flow.from_definition(definition)
with pytest.raises(ValueError, match="each.in must evaluate to an array"):
flow.kickoff(inputs=inputs)
@pytest.mark.parametrize(
"action_do",
[
[],
[{"first": {"call": "expression", "expr": "item"}, "second": {"call": "expression", "expr": "item"}}],
[{"1bad": {"call": "expression", "expr": "item"}}],
[
{"same": {"call": "expression", "expr": "item"}},
{"same": {"call": "expression", "expr": "item"}},
],
],
)
def test_each_action_validates_inner_action_shape(action_do):
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
"methods": {
"process_rows": {
"start": True,
"do": {
"call": "each",
"in": "state.rows",
"do": action_do,
},
}
},
}
)
def test_each_action_rejects_nested_each_actions():
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
"methods": {
"process_rows": {
"start": True,
"do": {
"call": "each",
"in": "state.rows",
"do": [
{
"nested": {
"call": "each",
"in": "state.children",
"do": [
{
"child": {
"call": "expression",
"expr": "item",
}
}
],
}
}
],
},
}
},
}
)
def test_each_action_failure_fails_outer_method():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- validate:
call: code
ref: {__name__}:EachActionFlow.fail_on_bad_row
with:
row: "${{item}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(RuntimeError, match="bad row"):
flow.kickoff(inputs={"rows": ["ok", "bad"]})
def test_expression_action_round_trips():
definition = FlowDefinition.from_dict(
{
@@ -751,6 +1184,26 @@ def test_expression_action_round_trips():
assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified"
def test_expression_local_context_recurses_into_dataclass_values():
from crewai.flow.runtime._expressions import evaluate_expression
class Payload(BaseModel):
name: str
@dataclass
class Row:
payload: Payload
assert (
evaluate_expression(
Flow(),
"item.payload.name",
local_context={"item": Row(payload=Payload(name="qualified"))},
)
== "qualified"
)
def test_expression_action_can_route_like_if_else():
yaml_str = f"""
schema: crewai.flow/v1
@@ -830,26 +1283,6 @@ def test_tool_action_requires_module_qualname_ref():
Flow.from_definition(definition)
def test_code_action_rejects_tool_inputs():
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "InvalidCodeActionFlow",
"methods": {
"begin": {
"start": True,
"do": {
"call": "code",
"ref": f"{__name__}:ChainFlow.begin",
"with": {"search_query": "ai agents"},
},
}
},
}
)
def test_pydantic_state_from_ref_parity():
flow, result = assert_parity(PydanticStateFlow, PYDANTIC_STATE_YAML)
assert result == "count=1"

View File

@@ -597,7 +597,7 @@ class TestHumanFeedbackLearn:
flow.memory.remember_many.assert_not_called()
def test_learn_true_uses_default_llm(self):
"""When learn=True and llm is not explicitly set, the default gpt-4o-mini is used."""
"""When learn=True and llm is not explicitly set, the default gpt-5.4-mini is used."""
@human_feedback(message="Review:", learn=True)
def test_method(self):
@@ -606,8 +606,8 @@ class TestHumanFeedbackLearn:
config = test_method.__human_feedback_config__
assert config is not None
assert config.learn is True
# llm defaults to "gpt-4o-mini" at the function level
assert config.llm == "gpt-4o-mini"
# llm defaults to "gpt-5.4-mini" at the function level
assert config.llm == "gpt-5.4-mini"
def test_pre_review_failure_logs_and_returns_raw_output(self, caplog):
"""Pre-review LLM failure falls back to raw output AND logs a warning."""

View File

@@ -850,24 +850,22 @@ class TestLLMConfigPreservation:
assert _deserialize_llm_from_context(None) is None
def test_serialize_llm_preserves_provider_specific_fields(self):
"""Test that provider-specific fields like project/location are serialized."""
"""Test that provider-specific fields like base_url are serialized."""
from crewai.flow.human_feedback import _serialize_llm_for_context
from crewai.llm import LLM
# Create a Gemini-style LLM with project and non-default location
llm = LLM(
model="gemini-2.0-flash",
provider="gemini",
project="my-project",
location="europe-west1",
model="llama3",
provider="ollama",
base_url="http://localhost:11434",
temperature=0.3,
)
serialized = _serialize_llm_for_context(llm)
assert isinstance(serialized, dict)
assert serialized.get("project") == "my-project"
assert serialized.get("location") == "europe-west1"
assert serialized.get("model") == "ollama/llama3"
assert serialized.get("base_url") == "http://localhost:11434/v1"
assert serialized.get("temperature") == 0.3
def test_config_preserved_through_full_flow_execution(self):

View File

@@ -463,6 +463,7 @@ def test_anthropic_message_formatting(anthropic_llm, system_message, user_messag
anthropic_llm._format_messages_for_anthropic([{"invalid": "message"}])
@pytest.mark.vcr()
def test_deepseek_r1_with_open_router():
if not os.getenv("OPEN_ROUTER_API_KEY"):
pytest.skip("OPEN_ROUTER_API_KEY not set; skipping test.")

Some files were not shown because too many files have changed in this diff Show More