Merge branch 'main' into lorenze/imp/memory-prompt-influence

This commit is contained in:
Lorenze Jay
2026-04-07 17:50:11 -07:00
committed by GitHub
182 changed files with 8863 additions and 3388 deletions

View File

@@ -152,4 +152,4 @@ __all__ = [
"wrap_file_source",
]
__version__ = "1.14.0a3"
__version__ = "1.14.0"

View File

@@ -10,8 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests~=2.32.5",
"docker~=7.1.0",
"crewai==1.14.0a3",
"crewai==1.14.0",
"tiktoken~=0.8.0",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",

View File

@@ -35,9 +35,6 @@ from crewai_tools.tools.browserbase_load_tool.browserbase_load_tool import (
from crewai_tools.tools.code_docs_search_tool.code_docs_search_tool import (
CodeDocsSearchTool,
)
from crewai_tools.tools.code_interpreter_tool.code_interpreter_tool import (
CodeInterpreterTool,
)
from crewai_tools.tools.composio_tool.composio_tool import ComposioTool
from crewai_tools.tools.contextualai_create_agent_tool.contextual_create_agent_tool import (
ContextualAICreateAgentTool,
@@ -225,7 +222,6 @@ __all__ = [
"BrowserbaseLoadTool",
"CSVSearchTool",
"CodeDocsSearchTool",
"CodeInterpreterTool",
"ComposioTool",
"ContextualAICreateAgentTool",
"ContextualAIParseTool",
@@ -309,4 +305,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.0a3"
__version__ = "1.14.0"

View File

@@ -109,7 +109,7 @@ class DataTypes:
if isinstance(content, str):
try:
url = urlparse(content)
is_url = bool(url.scheme and url.netloc) or url.scheme == "file"
is_url = bool(url.scheme in ("http", "https") and url.netloc)
except Exception: # noqa: S110
pass

View File

@@ -0,0 +1,205 @@
"""Path and URL validation utilities for crewai-tools.
Provides validation for file paths and URLs to prevent unauthorized
file access and server-side request forgery (SSRF) when tools accept
user-controlled or LLM-controlled inputs at runtime.
Set CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true to bypass validation (not
recommended for production).
"""
from __future__ import annotations
import ipaddress
import logging
import os
import socket
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
_UNSAFE_PATHS_ENV = "CREWAI_TOOLS_ALLOW_UNSAFE_PATHS"
def _is_escape_hatch_enabled() -> bool:
"""Check if the unsafe paths escape hatch is enabled."""
return os.environ.get(_UNSAFE_PATHS_ENV, "").lower() in ("true", "1", "yes")
# ---------------------------------------------------------------------------
# File path validation
# ---------------------------------------------------------------------------
def validate_file_path(path: str, base_dir: str | None = None) -> str:
"""Validate that a file path is safe to read.
Resolves symlinks and ``..`` components, then checks that the resolved
path falls within *base_dir* (defaults to the current working directory).
Args:
path: The file path to validate.
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
Returns:
The resolved, validated absolute path.
Raises:
ValueError: If the path escapes the allowed directory.
"""
if _is_escape_hatch_enabled():
logger.warning(
"%s is enabled — skipping file path validation for: %s",
_UNSAFE_PATHS_ENV,
path,
)
return os.path.realpath(path)
if base_dir is None:
base_dir = os.getcwd()
resolved_base = os.path.realpath(base_dir)
resolved_path = os.path.realpath(
os.path.join(resolved_base, path) if not os.path.isabs(path) else path
)
# Ensure the resolved path is within the base directory.
# When resolved_base already ends with a separator (e.g. the filesystem
# root "/"), appending os.sep would double it ("//"), so use the base
# as-is in that case.
prefix = resolved_base if resolved_base.endswith(os.sep) else resolved_base + os.sep
if not resolved_path.startswith(prefix) and resolved_path != resolved_base:
raise ValueError(
f"Path '{path}' resolves to '{resolved_path}' which is outside "
f"the allowed directory '{resolved_base}'. "
f"Set {_UNSAFE_PATHS_ENV}=true to bypass this check."
)
return resolved_path
def validate_directory_path(path: str, base_dir: str | None = None) -> str:
"""Validate that a directory path is safe to read.
Same as :func:`validate_file_path` but also checks that the path
is an existing directory.
Args:
path: The directory path to validate.
base_dir: Allowed root directory. Defaults to ``os.getcwd()``.
Returns:
The resolved, validated absolute path.
Raises:
ValueError: If the path escapes the allowed directory or is not a directory.
"""
validated = validate_file_path(path, base_dir)
if not os.path.isdir(validated):
raise ValueError(f"Path '{validated}' is not a directory.")
return validated
# ---------------------------------------------------------------------------
# URL validation
# ---------------------------------------------------------------------------
# Private and reserved IP ranges that should not be accessed
_BLOCKED_IPV4_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), # Link-local / cloud metadata
ipaddress.ip_network("0.0.0.0/32"),
]
_BLOCKED_IPV6_NETWORKS = [
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("::/128"),
ipaddress.ip_network("fc00::/7"), # Unique local addresses
ipaddress.ip_network("fe80::/10"), # Link-local IPv6
]
def _is_private_or_reserved(ip_str: str) -> bool:
"""Check if an IP address is private, reserved, or otherwise unsafe."""
try:
addr = ipaddress.ip_address(ip_str)
# Unwrap IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1) to IPv4
# so they are only checked against IPv4 networks (avoids TypeError when
# an IPv4Address is compared against an IPv6Network).
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
addr = addr.ipv4_mapped
networks = (
_BLOCKED_IPV4_NETWORKS
if isinstance(addr, ipaddress.IPv4Address)
else _BLOCKED_IPV6_NETWORKS
)
return any(addr in network for network in networks)
except ValueError:
return True # If we can't parse, block it
def validate_url(url: str) -> str:
"""Validate that a URL is safe to fetch.
Blocks ``file://`` scheme entirely. For ``http``/``https``, resolves
DNS and checks that the target IP is not private or reserved (prevents
SSRF to internal services and cloud metadata endpoints).
Args:
url: The URL to validate.
Returns:
The validated URL string.
Raises:
ValueError: If the URL uses a blocked scheme or resolves to a
private/reserved IP address.
"""
if _is_escape_hatch_enabled():
logger.warning(
"%s is enabled — skipping URL validation for: %s",
_UNSAFE_PATHS_ENV,
url,
)
return url
parsed = urlparse(url)
# Block file:// scheme
if parsed.scheme == "file":
raise ValueError(
f"file:// URLs are not allowed: '{url}'. "
f"Use a file path instead, or set {_UNSAFE_PATHS_ENV}=true to bypass."
)
# Only allow http and https
if parsed.scheme not in ("http", "https"):
raise ValueError(
f"URL scheme '{parsed.scheme}' is not allowed. Only http and https are supported."
)
if not parsed.hostname:
raise ValueError(f"URL has no hostname: '{url}'")
# Resolve DNS and check IPs
try:
addrinfos = socket.getaddrinfo(
parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80)
)
except socket.gaierror as exc:
raise ValueError(f"Could not resolve hostname: '{parsed.hostname}'") from exc
for _family, _, _, _, sockaddr in addrinfos:
ip_str = str(sockaddr[0])
if _is_private_or_reserved(ip_str):
raise ValueError(
f"URL '{url}' resolves to private/reserved IP {ip_str}. "
f"Access to internal networks is not allowed. "
f"Set {_UNSAFE_PATHS_ENV}=true to bypass."
)
return url

View File

@@ -24,9 +24,6 @@ from crewai_tools.tools.browserbase_load_tool.browserbase_load_tool import (
from crewai_tools.tools.code_docs_search_tool.code_docs_search_tool import (
CodeDocsSearchTool,
)
from crewai_tools.tools.code_interpreter_tool.code_interpreter_tool import (
CodeInterpreterTool,
)
from crewai_tools.tools.composio_tool.composio_tool import ComposioTool
from crewai_tools.tools.contextualai_create_agent_tool.contextual_create_agent_tool import (
ContextualAICreateAgentTool,
@@ -210,7 +207,6 @@ __all__ = [
"BrowserbaseLoadTool",
"CSVSearchTool",
"CodeDocsSearchTool",
"CodeInterpreterTool",
"ComposioTool",
"ContextualAICreateAgentTool",
"ContextualAIParseTool",

View File

@@ -7,6 +7,8 @@ from crewai.tools import BaseTool, EnvVar
from pydantic import BaseModel, Field
import requests
from crewai_tools.security.safe_path import validate_url
class BrightDataConfig(BaseModel):
API_URL: str = "https://api.brightdata.com/request"
@@ -134,6 +136,7 @@ class BrightDataWebUnlockerTool(BaseTool):
"Content-Type": "application/json",
}
validate_url(url)
try:
response = requests.post(
self.base_url, json=payload, headers=headers, timeout=30

View File

@@ -1,6 +0,0 @@
FROM python:3.12-alpine
RUN pip install requests beautifulsoup4
# Set the working directory
WORKDIR /workspace

View File

@@ -1,95 +0,0 @@
# CodeInterpreterTool
## Description
This tool is used to give the Agent the ability to run code (Python3) from the code generated by the Agent itself. The code is executed in a Docker container for secure isolation.
It is incredibly useful since it allows the Agent to generate code, run it in an isolated environment, get the result and use it to make decisions.
## ⚠️ Security Requirements
**Docker is REQUIRED** for safe code execution. The tool will refuse to execute code without Docker to prevent security vulnerabilities.
### Why Docker is Required
Previous versions included a "restricted sandbox" fallback when Docker was unavailable. This has been **removed** due to critical security vulnerabilities:
- The Python-based sandbox could be escaped via object introspection
- Attackers could recover the original `__import__` function and access any module
- This allowed arbitrary command execution on the host system
**Docker provides real process isolation** and is the only secure way to execute untrusted code.
## Requirements
- **Docker (REQUIRED)** - Install from [docker.com](https://docs.docker.com/get-docker/)
## Installation
Install the crewai_tools package
```shell
pip install 'crewai[tools]'
```
## Example
Remember that when using this tool, the code must be generated by the Agent itself. The code must be Python3 code. It will take some time the first time to run because it needs to build the Docker image.
### Basic Usage (Docker Container - Recommended)
```python
from crewai_tools import CodeInterpreterTool
Agent(
...
tools=[CodeInterpreterTool()],
)
```
### Custom Dockerfile
If you need to pass your own Dockerfile:
```python
from crewai_tools import CodeInterpreterTool
Agent(
...
tools=[CodeInterpreterTool(user_dockerfile_path="<Dockerfile_path>")],
)
```
### Manual Docker Host Configuration
If it is difficult to connect to the Docker daemon automatically (especially for macOS users), you can set up the Docker host manually:
```python
from crewai_tools import CodeInterpreterTool
Agent(
...
tools=[CodeInterpreterTool(
user_docker_base_url="<Docker Host Base Url>",
user_dockerfile_path="<Dockerfile_path>"
)],
)
```
### Unsafe Mode (NOT RECOMMENDED)
If you absolutely cannot use Docker and **fully trust the code source**, you can use unsafe mode:
```python
from crewai_tools import CodeInterpreterTool
# WARNING: Only use with fully trusted code!
Agent(
...
tools=[CodeInterpreterTool(unsafe_mode=True)],
)
```
**⚠️ SECURITY WARNING:** `unsafe_mode=True` executes code directly on the host without any isolation. Only use this if:
- You completely trust the code being executed
- You understand the security risks
- You cannot install Docker in your environment
For production use, **always use Docker** (the default mode).

View File

@@ -1,424 +0,0 @@
"""Code Interpreter Tool for executing Python code in isolated environments.
This module provides a tool for executing Python code either in a Docker container for
safe isolation or directly in a restricted sandbox. It includes mechanisms for blocking
potentially unsafe operations and importing restricted modules.
"""
import importlib.util
import os
import subprocess
import sys
from types import ModuleType
from typing import Any, ClassVar, TypedDict
from crewai.tools import BaseTool
from docker import ( # type: ignore[import-untyped]
DockerClient,
from_env as docker_from_env,
)
from docker.errors import ImageNotFound, NotFound # type: ignore[import-untyped]
from pydantic import BaseModel, Field
from typing_extensions import Unpack
from crewai_tools.printer import Printer
class RunKwargs(TypedDict, total=False):
"""Keyword arguments for the _run method."""
code: str
libraries_used: list[str]
class CodeInterpreterSchema(BaseModel):
"""Schema for defining inputs to the CodeInterpreterTool.
This schema defines the required parameters for code execution,
including the code to run and any libraries that need to be installed.
"""
code: str = Field(
...,
description="Python3 code used to be interpreted in the Docker container. ALWAYS PRINT the final result and the output of the code",
)
libraries_used: list[str] = Field(
...,
description="List of libraries used in the code with proper installing names separated by commas. Example: numpy,pandas,beautifulsoup4",
)
class SandboxPython:
"""INSECURE: A restricted Python execution environment with known vulnerabilities.
WARNING: This class does NOT provide real security isolation and is vulnerable to
sandbox escape attacks via Python object introspection. Attackers can recover the
original __import__ function and bypass all restrictions.
DO NOT USE for untrusted code execution. Use Docker containers instead.
This class attempts to restrict access to dangerous modules and built-in functions
but provides no real security boundary against a motivated attacker.
"""
BLOCKED_MODULES: ClassVar[set[str]] = {
"os",
"sys",
"subprocess",
"shutil",
"importlib",
"inspect",
"tempfile",
"sysconfig",
"builtins",
}
UNSAFE_BUILTINS: ClassVar[set[str]] = {
"exec",
"eval",
"open",
"compile",
"input",
"globals",
"locals",
"vars",
"help",
"dir",
}
@staticmethod
def restricted_import(
name: str,
custom_globals: dict[str, Any] | None = None,
custom_locals: dict[str, Any] | None = None,
fromlist: list[str] | None = None,
level: int = 0,
) -> ModuleType:
"""A restricted import function that blocks importing of unsafe modules.
Args:
name: The name of the module to import.
custom_globals: Global namespace to use.
custom_locals: Local namespace to use.
fromlist: List of items to import from the module.
level: The level value passed to __import__.
Returns:
The imported module if allowed.
Raises:
ImportError: If the module is in the blocked modules list.
"""
if name in SandboxPython.BLOCKED_MODULES:
raise ImportError(f"Importing '{name}' is not allowed.")
return __import__(name, custom_globals, custom_locals, fromlist or (), level)
@staticmethod
def safe_builtins() -> dict[str, Any]:
"""Creates a dictionary of built-in functions with unsafe ones removed.
Returns:
A dictionary of safe built-in functions and objects.
"""
import builtins
safe_builtins = {
k: v
for k, v in builtins.__dict__.items()
if k not in SandboxPython.UNSAFE_BUILTINS
}
safe_builtins["__import__"] = SandboxPython.restricted_import
return safe_builtins
@staticmethod
def exec(code: str, locals_: dict[str, Any]) -> None:
"""Executes Python code in a restricted environment.
Args:
code: The Python code to execute as a string.
locals_: A dictionary that will be used for local variable storage.
"""
exec(code, {"__builtins__": SandboxPython.safe_builtins()}, locals_) # noqa: S102
class CodeInterpreterTool(BaseTool):
"""A tool for executing Python code in isolated environments.
This tool provides functionality to run Python code either in a Docker container
for safe isolation or directly in a restricted sandbox. It can handle installing
Python packages and executing arbitrary Python code.
"""
name: str = "Code Interpreter"
description: str = "Interprets Python3 code strings with a final print statement."
args_schema: type[BaseModel] = CodeInterpreterSchema
default_image_tag: str = "code-interpreter:latest"
code: str | None = None
user_dockerfile_path: str | None = None
user_docker_base_url: str | None = None
unsafe_mode: bool = False
@staticmethod
def _get_installed_package_path() -> str:
"""Gets the installation path of the crewai_tools package.
Returns:
The directory path where the package is installed.
Raises:
RuntimeError: If the package cannot be found.
"""
spec = importlib.util.find_spec("crewai_tools")
if spec is None or spec.origin is None:
raise RuntimeError("Cannot find crewai_tools package installation path")
return os.path.dirname(spec.origin)
def _verify_docker_image(self) -> None:
"""Verifies if the Docker image is available or builds it if necessary.
Checks if the required Docker image exists. If not, builds it using either a
user-provided Dockerfile or the default one included with the package.
Raises:
FileNotFoundError: If the Dockerfile cannot be found.
"""
client = (
docker_from_env()
if self.user_docker_base_url is None
else DockerClient(base_url=self.user_docker_base_url)
)
try:
client.images.get(self.default_image_tag)
except ImageNotFound:
if self.user_dockerfile_path and os.path.exists(self.user_dockerfile_path):
dockerfile_path = self.user_dockerfile_path
else:
package_path = self._get_installed_package_path()
dockerfile_path = os.path.join(
package_path, "tools/code_interpreter_tool"
)
if not os.path.exists(dockerfile_path):
raise FileNotFoundError(
f"Dockerfile not found in {dockerfile_path}"
) from None
client.images.build(
path=dockerfile_path,
tag=self.default_image_tag,
rm=True,
)
def _run(self, **kwargs: Unpack[RunKwargs]) -> str:
"""Runs the code interpreter tool with the provided arguments.
Args:
**kwargs: Keyword arguments that should include 'code' and 'libraries_used'.
Returns:
The output of the executed code as a string.
"""
code: str | None = kwargs.get("code", self.code)
libraries_used: list[str] = kwargs.get("libraries_used", [])
if not code:
return "No code provided to execute."
if self.unsafe_mode:
return self.run_code_unsafe(code, libraries_used)
return self.run_code_safety(code, libraries_used)
@staticmethod
def _install_libraries(container: Any, libraries: list[str]) -> None:
"""Installs required Python libraries in the Docker container.
Args:
container: The Docker container where libraries will be installed.
libraries: A list of library names to install using pip.
"""
for library in libraries:
container.exec_run(["pip", "install", library])
def _init_docker_container(self) -> Any:
"""Initializes and returns a Docker container for code execution.
Stops and removes any existing container with the same name before creating
a new one. Maps the current working directory to /workspace in the container.
Returns:
A Docker container object ready for code execution.
"""
container_name = "code-interpreter"
client = docker_from_env()
current_path = os.getcwd()
# Check if the container is already running
try:
existing_container = client.containers.get(container_name)
existing_container.stop()
existing_container.remove()
except NotFound:
pass # Container does not exist, no need to remove
return client.containers.run(
self.default_image_tag,
detach=True,
tty=True,
working_dir="/workspace",
name=container_name,
volumes={current_path: {"bind": "/workspace", "mode": "rw"}},
)
@staticmethod
def _check_docker_available() -> bool:
"""Checks if Docker is available and running on the system.
Attempts to run the 'docker info' command to verify Docker availability.
Prints appropriate messages if Docker is not installed or not running.
Returns:
True if Docker is available and running, False otherwise.
"""
try:
subprocess.run(
["docker", "info"], # noqa: S607
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=1,
)
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
Printer.print(
"Docker is installed but not running or inaccessible.",
color="bold_purple",
)
return False
except FileNotFoundError:
Printer.print("Docker is not installed", color="bold_purple")
return False
def run_code_safety(self, code: str, libraries_used: list[str]) -> str:
"""Runs code in the safest available environment.
Requires Docker to be available for secure code execution. Fails closed
if Docker is not available to prevent sandbox escape vulnerabilities.
Args:
code: The Python code to execute as a string.
libraries_used: A list of Python library names to install before execution.
Returns:
The output of the executed code as a string.
Raises:
RuntimeError: If Docker is not available, as the restricted sandbox
is vulnerable to escape attacks and should not be used
for untrusted code execution.
"""
if self._check_docker_available():
return self.run_code_in_docker(code, libraries_used)
error_msg = (
"Docker is required for safe code execution but is not available. "
"The restricted sandbox fallback has been removed due to security vulnerabilities "
"that allow sandbox escape via Python object introspection. "
"Please install Docker (https://docs.docker.com/get-docker/) or use unsafe_mode=True "
"if you trust the code source and understand the security risks."
)
Printer.print(error_msg, color="bold_red")
raise RuntimeError(error_msg)
def run_code_in_docker(self, code: str, libraries_used: list[str]) -> str:
"""Runs Python code in a Docker container for safe isolation.
Creates a Docker container, installs the required libraries, executes the code,
and then cleans up by stopping and removing the container.
Args:
code: The Python code to execute as a string.
libraries_used: A list of Python library names to install before execution.
Returns:
The output of the executed code as a string, or an error message if execution failed.
"""
Printer.print("Running code in Docker environment", color="bold_blue")
self._verify_docker_image()
container = self._init_docker_container()
self._install_libraries(container, libraries_used)
exec_result: Any = container.exec_run(["python3", "-c", code])
container.stop()
container.remove()
if exec_result.exit_code != 0:
return f"Something went wrong while running the code: \n{exec_result.output.decode('utf-8')}"
return str(exec_result.output.decode("utf-8"))
@staticmethod
def run_code_in_restricted_sandbox(code: str) -> str:
"""DEPRECATED AND INSECURE: Runs Python code in a restricted sandbox environment.
WARNING: This method is vulnerable to sandbox escape attacks via Python object
introspection and should NOT be used for untrusted code execution. It has been
deprecated and is only kept for backward compatibility with trusted code.
The "restricted" environment can be bypassed by attackers who can:
- Use object graph introspection to recover the original __import__ function
- Access any Python module including os, subprocess, sys, etc.
- Execute arbitrary commands on the host system
Use run_code_in_docker() for secure code execution, or run_code_unsafe()
if you explicitly acknowledge the security risks.
Args:
code: The Python code to execute as a string.
Returns:
The value of the 'result' variable from the executed code,
or an error message if execution failed.
"""
Printer.print(
"WARNING: Running code in INSECURE restricted sandbox (vulnerable to escape attacks)",
color="bold_red",
)
exec_locals: dict[str, Any] = {}
try:
SandboxPython.exec(code=code, locals_=exec_locals)
return exec_locals.get("result", "No result variable found.") # type: ignore[no-any-return]
except Exception as e:
return f"An error occurred: {e!s}"
@staticmethod
def run_code_unsafe(code: str, libraries_used: list[str]) -> str:
"""Runs code directly on the host machine without any safety restrictions.
WARNING: This mode is unsafe and should only be used in trusted environments
with code from trusted sources.
Args:
code: The Python code to execute as a string.
libraries_used: A list of Python library names to install before execution.
Returns:
The value of the 'result' variable from the executed code,
or an error message if execution failed.
"""
Printer.print("WARNING: Running code in unsafe mode", color="bold_magenta")
# Install libraries on the host machine
for library in libraries_used:
subprocess.run( # noqa: S603
[sys.executable, "-m", "pip", "install", library], check=False
)
# Execute the code
try:
exec_locals: dict[str, Any] = {}
exec(code, {}, exec_locals) # noqa: S102
return exec_locals.get("result", "No result variable found.") # type: ignore[no-any-return]
except Exception as e:
return f"An error occurred: {e!s}"

View File

@@ -3,6 +3,8 @@ from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_file_path
class ContextualAICreateAgentSchema(BaseModel):
"""Schema for contextual create agent tool."""
@@ -47,6 +49,7 @@ class ContextualAICreateAgentTool(BaseTool):
document_paths: list[str],
) -> str:
"""Create a complete RAG pipeline with documents."""
resolved_paths = [validate_file_path(doc_path) for doc_path in document_paths]
try:
import os
@@ -56,7 +59,7 @@ class ContextualAICreateAgentTool(BaseTool):
# Upload documents
document_ids = []
for doc_path in document_paths:
for doc_path in resolved_paths:
if not os.path.exists(doc_path):
raise FileNotFoundError(f"Document not found: {doc_path}")

View File

@@ -1,6 +1,8 @@
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_file_path
class ContextualAIParseSchema(BaseModel):
"""Schema for contextual parse tool."""
@@ -45,6 +47,7 @@ class ContextualAIParseTool(BaseTool):
"""Parse a document using Contextual AI's parser."""
if output_types is None:
output_types = ["markdown-per-page"]
file_path = validate_file_path(file_path)
try:
import json
import os

View File

@@ -4,6 +4,8 @@ from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_directory_path
class FixedDirectoryReadToolSchema(BaseModel):
"""Input for DirectoryReadTool."""
@@ -39,6 +41,7 @@ class DirectoryReadTool(BaseTool):
if directory is None:
raise ValueError("Directory must be provided.")
directory = validate_directory_path(directory)
if directory[-1] == "/":
directory = directory[:-1]
files_list = [

View File

@@ -3,6 +3,7 @@ from typing import Any
from pydantic import BaseModel, Field
from crewai_tools.rag.data_types import DataType
from crewai_tools.security.safe_path import validate_directory_path
from crewai_tools.tools.rag.rag_tool import RagTool
@@ -37,6 +38,7 @@ class DirectorySearchTool(RagTool):
self._generate_description()
def add(self, directory: str) -> None: # type: ignore[override]
directory = validate_directory_path(directory)
super().add(directory, data_type=DataType.DIRECTORY)
def _run( # type: ignore[override]

View File

@@ -3,6 +3,8 @@ from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_file_path
class FileReadToolSchema(BaseModel):
"""Input for FileReadTool."""
@@ -76,6 +78,7 @@ class FileReadTool(BaseTool):
if file_path is None:
return "Error: No file path provided. Please provide a file path either in the constructor or as an argument."
file_path = validate_file_path(file_path)
try:
with open(file_path, "r") as file:
if start_line == 1 and line_count is None:

View File

@@ -5,6 +5,8 @@ import zipfile
from crewai.tools import BaseTool
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_file_path
class FileCompressorToolInput(BaseModel):
"""Input schema for FileCompressorTool."""
@@ -40,12 +42,15 @@ class FileCompressorTool(BaseTool):
overwrite: bool = False,
format: str = "zip",
) -> str:
input_path = validate_file_path(input_path)
if not os.path.exists(input_path):
return f"Input path '{input_path}' does not exist."
if not output_path:
output_path = self._generate_output_path(input_path, format)
output_path = validate_file_path(output_path)
format_extension = {
"zip": ".zip",
"tar": ".tar",

View File

@@ -5,6 +5,8 @@ from typing import Any
from crewai.tools import BaseTool, EnvVar
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from crewai_tools.security.safe_path import validate_url
try:
from firecrawl import FirecrawlApp # type: ignore[import-untyped]
@@ -106,6 +108,7 @@ class FirecrawlCrawlWebsiteTool(BaseTool):
if not self._firecrawl:
raise RuntimeError("FirecrawlApp not properly initialized")
url = validate_url(url)
return self._firecrawl.crawl(url=url, poll_interval=2, **self.config)

View File

@@ -5,6 +5,8 @@ from typing import Any
from crewai.tools import BaseTool, EnvVar
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from crewai_tools.security.safe_path import validate_url
try:
from firecrawl import FirecrawlApp # type: ignore[import-untyped]
@@ -106,6 +108,7 @@ class FirecrawlScrapeWebsiteTool(BaseTool):
if not self._firecrawl:
raise RuntimeError("FirecrawlApp not properly initialized")
url = validate_url(url)
return self._firecrawl.scrape(url=url, **self.config)

View File

@@ -4,6 +4,8 @@ from typing import Any, Literal
from crewai.tools import BaseTool, EnvVar
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_url
class HyperbrowserLoadToolSchema(BaseModel):
url: str = Field(description="Website URL")
@@ -119,6 +121,7 @@ class HyperbrowserLoadTool(BaseTool):
) from e
params = self._prepare_params(params)
url = validate_url(url)
if operation == "scrape":
scrape_params = StartScrapeJobParams(url=url, **params)

View File

@@ -4,6 +4,8 @@ from crewai.tools import BaseTool
from pydantic import BaseModel, Field
import requests
from crewai_tools.security.safe_path import validate_url
class JinaScrapeWebsiteToolInput(BaseModel):
"""Input schema for JinaScrapeWebsiteTool."""
@@ -45,6 +47,7 @@ class JinaScrapeWebsiteTool(BaseTool):
"Website URL must be provided either during initialization or execution"
)
url = validate_url(url)
response = requests.get(
f"https://r.jina.ai/{url}", headers=self.headers, timeout=15
)

View File

@@ -11,6 +11,8 @@ from crewai.tools.base_tool import BaseTool
from crewai.utilities.types import LLMMessage
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_file_path
class OCRToolSchema(BaseModel):
"""Input schema for Optical Character Recognition Tool.
@@ -98,5 +100,6 @@ class OCRTool(BaseTool):
Returns:
str: Base64-encoded image data as a UTF-8 string.
"""
image_path = validate_file_path(image_path)
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode()

View File

@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
import os
from typing import Any, Literal, cast
from crewai.rag.core.base_embeddings_callable import EmbeddingFunction
@@ -246,7 +247,94 @@ class RagTool(BaseTool):
# Auto-detect type from extension
rag_tool.add("path/to/document.pdf") # auto-detects PDF
"""
self.adapter.add(*args, **kwargs)
# Validate file paths and URLs before adding to prevent
# unauthorized file reads and SSRF.
from urllib.parse import urlparse
from crewai_tools.security.safe_path import validate_file_path, validate_url
def _check_url(value: str, label: str) -> None:
try:
validate_url(value)
except ValueError as e:
raise ValueError(f"Blocked unsafe {label}: {e}") from e
def _check_path(value: str, label: str) -> str:
try:
return validate_file_path(value)
except ValueError as e:
raise ValueError(f"Blocked unsafe {label}: {e}") from e
validated_args: list[ContentItem] = []
for arg in args:
source_ref = (
str(arg.get("source", arg.get("content", "")))
if isinstance(arg, dict)
else str(arg)
)
# Check if it's a URL — only catch urlparse-specific errors here;
# validate_url's ValueError must propagate so it is never silently bypassed.
try:
parsed = urlparse(source_ref)
except (ValueError, AttributeError):
parsed = None
if parsed is not None and parsed.scheme in ("http", "https", "file"):
try:
validate_url(source_ref)
except ValueError as e:
raise ValueError(f"Blocked unsafe URL: {e}") from e
validated_args.append(arg)
continue
# Check if it looks like a file path (not a plain text string).
# Check both os.sep (backslash on Windows) and "/" so that
# forward-slash paths like "sub/file.txt" are caught on all platforms.
if (
os.path.sep in source_ref
or "/" in source_ref
or source_ref.startswith(".")
or os.path.isabs(source_ref)
):
try:
resolved_ref = validate_file_path(source_ref)
except ValueError as e:
raise ValueError(f"Blocked unsafe file path: {e}") from e
# Use the resolved path to prevent symlink TOCTOU
if isinstance(arg, dict):
arg = {**arg}
if "source" in arg:
arg["source"] = resolved_ref
elif "content" in arg:
arg["content"] = resolved_ref
else:
arg = resolved_ref
validated_args.append(arg)
# Validate keyword path/URL arguments — these are equally user-controlled
# and must not bypass the checks applied to positional args.
if "path" in kwargs and kwargs.get("path") is not None:
kwargs["path"] = _check_path(str(kwargs["path"]), "path")
if "file_path" in kwargs and kwargs.get("file_path") is not None:
kwargs["file_path"] = _check_path(str(kwargs["file_path"]), "file_path")
if "directory_path" in kwargs and kwargs.get("directory_path") is not None:
kwargs["directory_path"] = _check_path(
str(kwargs["directory_path"]), "directory_path"
)
if "url" in kwargs and kwargs.get("url") is not None:
_check_url(str(kwargs["url"]), "url")
if "website" in kwargs and kwargs.get("website") is not None:
_check_url(str(kwargs["website"]), "website")
if "github_url" in kwargs and kwargs.get("github_url") is not None:
_check_url(str(kwargs["github_url"]), "github_url")
if "youtube_url" in kwargs and kwargs.get("youtube_url") is not None:
_check_url(str(kwargs["youtube_url"]), "youtube_url")
self.adapter.add(*validated_args, **kwargs)
def _run(
self,

View File

@@ -5,6 +5,8 @@ from crewai.tools import BaseTool
from pydantic import BaseModel, Field
import requests
from crewai_tools.security.safe_path import validate_url
try:
from bs4 import BeautifulSoup
@@ -81,6 +83,7 @@ class ScrapeElementFromWebsiteTool(BaseTool):
if website_url is None or css_element is None:
raise ValueError("Both website_url and css_element must be provided.")
website_url = validate_url(website_url)
page = requests.get(
website_url,
headers=self.headers,

View File

@@ -5,6 +5,8 @@ from typing import Any
from pydantic import Field
import requests
from crewai_tools.security.safe_path import validate_url
try:
from bs4 import BeautifulSoup
@@ -73,6 +75,7 @@ class ScrapeWebsiteTool(BaseTool):
if website_url is None:
raise ValueError("Website URL must be provided.")
website_url = validate_url(website_url)
page = requests.get(
website_url,
timeout=15,

View File

@@ -5,6 +5,8 @@ from typing import Any, Literal
from crewai.tools import BaseTool, EnvVar
from pydantic import BaseModel, Field
from crewai_tools.security.safe_path import validate_url
logger = logging.getLogger(__file__)
@@ -72,6 +74,7 @@ class ScrapflyScrapeWebsiteTool(BaseTool):
) -> str | None:
from scrapfly import ScrapeConfig
url = validate_url(url)
scrape_config = scrape_config if scrape_config is not None else {}
try:
response = self.scrapfly.scrape( # type: ignore[union-attr]

View File

@@ -5,6 +5,8 @@ from crewai.tools import BaseTool, EnvVar
from pydantic import BaseModel, Field
import requests
from crewai_tools.security.safe_path import validate_url
class SerperScrapeWebsiteInput(BaseModel):
"""Input schema for SerperScrapeWebsite."""
@@ -42,6 +44,7 @@ class SerperScrapeWebsiteTool(BaseTool):
Returns:
Scraped website content as a string
"""
validate_url(url)
try:
# Serper API endpoint
api_url = "https://scrape.serper.dev"

View File

@@ -5,6 +5,7 @@ from crewai.tools import EnvVar
from pydantic import BaseModel, Field
import requests
from crewai_tools.security.safe_path import validate_url
from crewai_tools.tools.rag.rag_tool import RagTool
@@ -48,6 +49,7 @@ class SerplyWebpageToMarkdownTool(RagTool):
if self.proxy_location and not self.headers.get("X-Proxy-Location"):
self.headers["X-Proxy-Location"] = self.proxy_location
validate_url(url)
data = {"url": url, "method": "GET", "response_type": "markdown"}
response = requests.request(
"POST",

View File

@@ -7,6 +7,8 @@ from crewai.tools import BaseTool, EnvVar
from crewai.utilities.types import LLMMessage
from pydantic import BaseModel, Field, PrivateAttr, field_validator
from crewai_tools.security.safe_path import validate_file_path
class ImagePromptSchema(BaseModel):
"""Input for Vision Tool."""
@@ -135,5 +137,6 @@ class VisionTool(BaseTool):
Returns:
Base64-encoded image data
"""
image_path = validate_file_path(image_path)
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode()

View File

@@ -3,6 +3,7 @@ from typing import Any
from pydantic import BaseModel, Field
from crewai_tools.rag.data_types import DataType
from crewai_tools.security.safe_path import validate_url
from crewai_tools.tools.rag.rag_tool import RagTool
@@ -37,6 +38,7 @@ class WebsiteSearchTool(RagTool):
self._generate_description()
def add(self, website: str) -> None: # type: ignore[override]
website = validate_url(website)
super().add(website, data_type=DataType.WEBSITE)
def _run( # type: ignore[override]

View File

@@ -0,0 +1,10 @@
"""Backward-compatible re-export from crewai_tools.security.safe_path."""
from crewai_tools.security.safe_path import (
validate_directory_path,
validate_file_path,
validate_url,
)
__all__ = ["validate_directory_path", "validate_file_path", "validate_url"]

View File

@@ -97,6 +97,7 @@ def test_extract_init_params_schema(mock_tool_extractor):
assert init_params_schema.keys() == {
"$defs",
"properties",
"required",
"title",
"type",
}

View File

@@ -3,10 +3,21 @@ from tempfile import TemporaryDirectory
from typing import cast
from unittest.mock import MagicMock, Mock, patch
import pytest
from crewai_tools.adapters.crewai_rag_adapter import CrewAIRagAdapter
from crewai_tools.tools.rag.rag_tool import RagTool
@pytest.fixture(autouse=True)
def allow_tmp_paths(monkeypatch: pytest.MonkeyPatch) -> None:
"""Allow absolute paths outside CWD (e.g. /tmp/) for these RagTool tests.
Path validation is tested separately in test_rag_tool_path_validation.py.
"""
monkeypatch.setenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", "true")
@patch("crewai_tools.adapters.crewai_rag_adapter.get_rag_client")
@patch("crewai_tools.adapters.crewai_rag_adapter.create_client")
def test_rag_tool_initialization(

View File

@@ -10,6 +10,15 @@ from crewai_tools.rag.data_types import DataType
from crewai_tools.tools.rag.rag_tool import RagTool
@pytest.fixture(autouse=True)
def allow_tmp_paths(monkeypatch: pytest.MonkeyPatch) -> None:
"""Allow absolute paths outside CWD (e.g. /tmp/) for these data-type tests.
Path validation is tested separately in test_rag_tool_path_validation.py.
"""
monkeypatch.setenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", "true")
@pytest.fixture
def mock_rag_client() -> MagicMock:
"""Create a mock RAG client for testing."""

View File

@@ -0,0 +1,80 @@
"""Tests for path and URL validation in RagTool.add() — both positional and keyword args."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from crewai_tools.tools.rag.rag_tool import RagTool
@pytest.fixture()
def mock_rag_client() -> MagicMock:
mock_client = MagicMock()
mock_client.get_or_create_collection = MagicMock(return_value=None)
mock_client.add_documents = MagicMock(return_value=None)
mock_client.search = MagicMock(return_value=[])
return mock_client
@pytest.fixture()
def tool(mock_rag_client: MagicMock) -> RagTool:
with (
patch("crewai_tools.adapters.crewai_rag_adapter.get_rag_client", return_value=mock_rag_client),
patch("crewai_tools.adapters.crewai_rag_adapter.create_client", return_value=mock_rag_client),
):
return RagTool()
# ---------------------------------------------------------------------------
# Positional arg validation (existing behaviour, regression guard)
# ---------------------------------------------------------------------------
class TestPositionalArgValidation:
def test_blocks_traversal_in_positional_arg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe"):
tool.add("../../etc/passwd")
def test_blocks_file_url_in_positional_arg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe"):
tool.add("file:///etc/passwd")
# ---------------------------------------------------------------------------
# Keyword arg validation (the newly fixed gap)
# ---------------------------------------------------------------------------
class TestKwargPathValidation:
def test_blocks_traversal_via_path_kwarg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe path"):
tool.add(path="../../etc/passwd")
def test_blocks_traversal_via_file_path_kwarg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe file_path"):
tool.add(file_path="/etc/passwd")
def test_blocks_traversal_via_directory_path_kwarg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe directory_path"):
tool.add(directory_path="../../sensitive_dir")
def test_blocks_file_url_via_url_kwarg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe url"):
tool.add(url="file:///etc/passwd")
def test_blocks_private_ip_via_url_kwarg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe url"):
tool.add(url="http://169.254.169.254/latest/meta-data/")
def test_blocks_private_ip_via_website_kwarg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe website"):
tool.add(website="http://192.168.1.1/")
def test_blocks_file_url_via_github_url_kwarg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe github_url"):
tool.add(github_url="file:///etc/passwd")
def test_blocks_file_url_via_youtube_url_kwarg(self, tool):
with pytest.raises(ValueError, match="Blocked unsafe youtube_url"):
tool.add(youtube_url="file:///etc/passwd")

View File

@@ -1,253 +0,0 @@
import sys
from unittest.mock import patch
from crewai_tools.tools.code_interpreter_tool.code_interpreter_tool import (
CodeInterpreterTool,
SandboxPython,
)
import pytest
@pytest.fixture
def printer_mock():
with patch("crewai_tools.printer.Printer.print") as mock:
yield mock
@pytest.fixture
def docker_unavailable_mock():
with patch(
"crewai_tools.tools.code_interpreter_tool.code_interpreter_tool.CodeInterpreterTool._check_docker_available",
return_value=False,
) as mock:
yield mock
@patch("crewai_tools.tools.code_interpreter_tool.code_interpreter_tool.docker_from_env")
def test_run_code_in_docker(docker_mock, printer_mock):
tool = CodeInterpreterTool()
code = "print('Hello, World!')"
libraries_used = ["numpy", "pandas"]
expected_output = "Hello, World!\n"
docker_mock().containers.run().exec_run().exit_code = 0
docker_mock().containers.run().exec_run().output = expected_output.encode()
result = tool.run_code_in_docker(code, libraries_used)
assert result == expected_output
printer_mock.assert_called_with(
"Running code in Docker environment", color="bold_blue"
)
@patch("crewai_tools.tools.code_interpreter_tool.code_interpreter_tool.docker_from_env")
def test_run_code_in_docker_with_error(docker_mock, printer_mock):
tool = CodeInterpreterTool()
code = "print(1/0)"
libraries_used = ["numpy", "pandas"]
expected_output = "Something went wrong while running the code: \nZeroDivisionError: division by zero\n"
docker_mock().containers.run().exec_run().exit_code = 1
docker_mock().containers.run().exec_run().output = (
b"ZeroDivisionError: division by zero\n"
)
result = tool.run_code_in_docker(code, libraries_used)
assert result == expected_output
printer_mock.assert_called_with(
"Running code in Docker environment", color="bold_blue"
)
@patch("crewai_tools.tools.code_interpreter_tool.code_interpreter_tool.docker_from_env")
def test_run_code_in_docker_with_script(docker_mock, printer_mock):
tool = CodeInterpreterTool()
code = """print("This is line 1")
print("This is line 2")"""
libraries_used = []
expected_output = "This is line 1\nThis is line 2\n"
docker_mock().containers.run().exec_run().exit_code = 0
docker_mock().containers.run().exec_run().output = expected_output.encode()
result = tool.run_code_in_docker(code, libraries_used)
assert result == expected_output
printer_mock.assert_called_with(
"Running code in Docker environment", color="bold_blue"
)
def test_docker_unavailable_raises_error(printer_mock, docker_unavailable_mock):
"""Test that execution fails when Docker is unavailable in safe mode."""
tool = CodeInterpreterTool()
code = """
result = 2 + 2
print(result)
"""
with pytest.raises(RuntimeError) as exc_info:
tool.run(code=code, libraries_used=[])
assert "Docker is required for safe code execution" in str(exc_info.value)
assert "sandbox escape" in str(exc_info.value)
def test_restricted_sandbox_running_with_blocked_modules():
"""Test that restricted modules cannot be imported when using the deprecated sandbox directly."""
tool = CodeInterpreterTool()
restricted_modules = SandboxPython.BLOCKED_MODULES
for module in restricted_modules:
code = f"""
import {module}
result = "Import succeeded"
"""
# Note: run_code_in_restricted_sandbox is deprecated and insecure
# This test verifies the old behavior but should not be used in production
result = tool.run_code_in_restricted_sandbox(code)
assert f"An error occurred: Importing '{module}' is not allowed" in result
def test_restricted_sandbox_running_with_blocked_builtins():
"""Test that restricted builtins are not available when using the deprecated sandbox directly."""
tool = CodeInterpreterTool()
restricted_builtins = SandboxPython.UNSAFE_BUILTINS
for builtin in restricted_builtins:
code = f"""
{builtin}("test")
result = "Builtin available"
"""
# Note: run_code_in_restricted_sandbox is deprecated and insecure
# This test verifies the old behavior but should not be used in production
result = tool.run_code_in_restricted_sandbox(code)
assert f"An error occurred: name '{builtin}' is not defined" in result
def test_restricted_sandbox_running_with_no_result_variable(
printer_mock, docker_unavailable_mock
):
"""Test behavior when no result variable is set in deprecated sandbox."""
tool = CodeInterpreterTool()
code = """
x = 10
"""
# Note: run_code_in_restricted_sandbox is deprecated and insecure
# This test verifies the old behavior but should not be used in production
result = tool.run_code_in_restricted_sandbox(code)
assert result == "No result variable found."
def test_unsafe_mode_running_with_no_result_variable(
printer_mock, docker_unavailable_mock
):
"""Test behavior when no result variable is set."""
tool = CodeInterpreterTool(unsafe_mode=True)
code = """
x = 10
"""
result = tool.run(code=code, libraries_used=[])
printer_mock.assert_called_with(
"WARNING: Running code in unsafe mode", color="bold_magenta"
)
assert result == "No result variable found."
@patch("crewai_tools.tools.code_interpreter_tool.code_interpreter_tool.subprocess.run")
def test_unsafe_mode_installs_libraries_without_shell(
subprocess_run_mock, printer_mock, docker_unavailable_mock
):
"""Test that library installation uses subprocess.run with shell=False, not os.system."""
tool = CodeInterpreterTool(unsafe_mode=True)
code = "result = 1"
libraries_used = ["numpy", "pandas"]
tool.run(code=code, libraries_used=libraries_used)
assert subprocess_run_mock.call_count == 2
for call, library in zip(subprocess_run_mock.call_args_list, libraries_used):
args, kwargs = call
# Must be list form (no shell expansion possible)
assert args[0] == [sys.executable, "-m", "pip", "install", library]
# shell= must not be True (defaults to False)
assert kwargs.get("shell", False) is False
@patch("crewai_tools.tools.code_interpreter_tool.code_interpreter_tool.subprocess.run")
def test_unsafe_mode_library_name_with_shell_metacharacters_does_not_invoke_shell(
subprocess_run_mock, printer_mock, docker_unavailable_mock
):
"""Test that a malicious library name cannot inject shell commands."""
tool = CodeInterpreterTool(unsafe_mode=True)
code = "result = 1"
malicious_library = "numpy; rm -rf /"
tool.run(code=code, libraries_used=[malicious_library])
subprocess_run_mock.assert_called_once()
args, kwargs = subprocess_run_mock.call_args
# The entire malicious string is passed as a single argument — no shell parsing
assert args[0] == [sys.executable, "-m", "pip", "install", malicious_library]
assert kwargs.get("shell", False) is False
def test_unsafe_mode_running_unsafe_code(printer_mock, docker_unavailable_mock):
"""Test behavior when no result variable is set."""
tool = CodeInterpreterTool(unsafe_mode=True)
code = """
import os
os.system("ls -la")
result = eval("5/1")
"""
result = tool.run(code=code, libraries_used=[])
printer_mock.assert_called_with(
"WARNING: Running code in unsafe mode", color="bold_magenta"
)
assert 5.0 == result
@pytest.mark.xfail(
reason=(
"run_code_in_restricted_sandbox is known to be vulnerable to sandbox "
"escape via object introspection. This test encodes the desired secure "
"behavior (no escape possible) and will start passing once the "
"vulnerability is fixed or the function is removed."
)
)
def test_sandbox_escape_vulnerability_demonstration(printer_mock):
"""Demonstrate that the restricted sandbox is vulnerable to escape attacks.
This test shows that an attacker can use Python object introspection to bypass
the restricted sandbox and access blocked modules like 'os'. This is why the
sandbox should never be used for untrusted code execution.
NOTE: This test uses the deprecated run_code_in_restricted_sandbox directly
to demonstrate the vulnerability. In production, Docker is now required.
"""
tool = CodeInterpreterTool()
# Classic Python sandbox escape via object introspection
escape_code = """
# Recover the real __import__ function via object introspection
for cls in ().__class__.__bases__[0].__subclasses__():
if cls.__name__ == 'catch_warnings':
# Get the real builtins module
real_builtins = cls()._module.__builtins__
real_import = real_builtins['__import__']
# Now we can import os and execute commands
os = real_import('os')
# Demonstrate we have escaped the sandbox
result = "SANDBOX_ESCAPED" if hasattr(os, 'system') else "FAILED"
break
"""
# The deprecated sandbox is vulnerable to this attack
result = tool.run_code_in_restricted_sandbox(escape_code)
# Desired behavior: the restricted sandbox should prevent this escape.
# If this assertion fails, run_code_in_restricted_sandbox remains vulnerable.
assert result != "SANDBOX_ESCAPED", (
"The restricted sandbox was bypassed via object introspection. "
"This indicates run_code_in_restricted_sandbox is still vulnerable and "
"is why Docker is now required for safe code execution."
)

View File

@@ -23,6 +23,15 @@ from crewai_tools.tools.rag.rag_tool import Adapter
import pytest
@pytest.fixture(autouse=True)
def allow_tmp_paths(monkeypatch: pytest.MonkeyPatch) -> None:
"""Allow absolute paths outside CWD (e.g. /tmp/) for these search-tool tests.
Path validation is tested separately in test_rag_tool_path_validation.py.
"""
monkeypatch.setenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", "true")
@pytest.fixture
def mock_adapter():
mock_adapter = MagicMock(spec=Adapter)

View File

@@ -0,0 +1,170 @@
"""Tests for path and URL validation utilities."""
from __future__ import annotations
import os
import pytest
from crewai_tools.security.safe_path import (
validate_directory_path,
validate_file_path,
validate_url,
)
# ---------------------------------------------------------------------------
# File path validation
# ---------------------------------------------------------------------------
class TestValidateFilePath:
"""Tests for validate_file_path."""
def test_valid_relative_path(self, tmp_path):
"""Normal relative path within the base directory."""
(tmp_path / "data.json").touch()
result = validate_file_path("data.json", str(tmp_path))
assert result == str(tmp_path / "data.json")
def test_valid_nested_path(self, tmp_path):
"""Nested path within base directory."""
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "file.txt").touch()
result = validate_file_path("sub/file.txt", str(tmp_path))
assert result == str(tmp_path / "sub" / "file.txt")
def test_rejects_dotdot_traversal(self, tmp_path):
"""Reject ../ traversal that escapes base_dir."""
with pytest.raises(ValueError, match="outside the allowed directory"):
validate_file_path("../../etc/passwd", str(tmp_path))
def test_rejects_absolute_path_outside_base(self, tmp_path):
"""Reject absolute path outside base_dir."""
with pytest.raises(ValueError, match="outside the allowed directory"):
validate_file_path("/etc/passwd", str(tmp_path))
def test_allows_absolute_path_inside_base(self, tmp_path):
"""Allow absolute path that's inside base_dir."""
(tmp_path / "ok.txt").touch()
result = validate_file_path(str(tmp_path / "ok.txt"), str(tmp_path))
assert result == str(tmp_path / "ok.txt")
def test_rejects_symlink_escape(self, tmp_path):
"""Reject symlinks that point outside base_dir."""
link = tmp_path / "sneaky_link"
# Create a symlink pointing to /etc/passwd
os.symlink("/etc/passwd", str(link))
with pytest.raises(ValueError, match="outside the allowed directory"):
validate_file_path("sneaky_link", str(tmp_path))
def test_defaults_to_cwd(self):
"""When no base_dir is given, use cwd."""
cwd = os.getcwd()
# A file in cwd should be valid
result = validate_file_path(".", None)
assert result == os.path.realpath(cwd)
def test_escape_hatch(self, tmp_path, monkeypatch):
"""CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true bypasses validation."""
monkeypatch.setenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", "true")
# This would normally be rejected
result = validate_file_path("/etc/passwd", str(tmp_path))
assert result == os.path.realpath("/etc/passwd")
class TestValidateDirectoryPath:
"""Tests for validate_directory_path."""
def test_valid_directory(self, tmp_path):
(tmp_path / "subdir").mkdir()
result = validate_directory_path("subdir", str(tmp_path))
assert result == str(tmp_path / "subdir")
def test_rejects_file_as_directory(self, tmp_path):
(tmp_path / "file.txt").touch()
with pytest.raises(ValueError, match="not a directory"):
validate_directory_path("file.txt", str(tmp_path))
def test_rejects_traversal(self, tmp_path):
with pytest.raises(ValueError, match="outside the allowed directory"):
validate_directory_path("../../", str(tmp_path))
# ---------------------------------------------------------------------------
# URL validation
# ---------------------------------------------------------------------------
class TestValidateUrl:
"""Tests for validate_url."""
def test_valid_https_url(self):
"""Normal HTTPS URL should pass."""
result = validate_url("https://example.com/data.json")
assert result == "https://example.com/data.json"
def test_valid_http_url(self):
"""Normal HTTP URL should pass."""
result = validate_url("http://example.com/api")
assert result == "http://example.com/api"
def test_blocks_file_scheme(self):
"""file:// URLs must be blocked."""
with pytest.raises(ValueError, match="file:// URLs are not allowed"):
validate_url("file:///etc/passwd")
def test_blocks_file_scheme_with_host(self):
with pytest.raises(ValueError, match="file:// URLs are not allowed"):
validate_url("file://localhost/etc/shadow")
def test_blocks_localhost(self):
"""localhost must be blocked (resolves to 127.0.0.1)."""
with pytest.raises(ValueError, match="private/reserved IP"):
validate_url("http://localhost/admin")
def test_blocks_127_0_0_1(self):
with pytest.raises(ValueError, match="private/reserved IP"):
validate_url("http://127.0.0.1/admin")
def test_blocks_cloud_metadata(self):
"""AWS/GCP/Azure metadata endpoint must be blocked."""
with pytest.raises(ValueError, match="private/reserved IP"):
validate_url("http://169.254.169.254/latest/meta-data/")
def test_blocks_private_10_range(self):
with pytest.raises(ValueError, match="private/reserved IP"):
validate_url("http://10.0.0.1/internal")
def test_blocks_private_172_range(self):
with pytest.raises(ValueError, match="private/reserved IP"):
validate_url("http://172.16.0.1/internal")
def test_blocks_private_192_range(self):
with pytest.raises(ValueError, match="private/reserved IP"):
validate_url("http://192.168.1.1/router")
def test_blocks_zero_address(self):
with pytest.raises(ValueError, match="private/reserved IP"):
validate_url("http://0.0.0.0/")
def test_blocks_ipv6_localhost(self):
with pytest.raises(ValueError, match="private/reserved IP"):
validate_url("http://[::1]/admin")
def test_blocks_ftp_scheme(self):
with pytest.raises(ValueError, match="not allowed"):
validate_url("ftp://example.com/file")
def test_blocks_empty_hostname(self):
with pytest.raises(ValueError, match="no hostname"):
validate_url("http:///path")
def test_blocks_unresolvable_host(self):
with pytest.raises(ValueError, match="Could not resolve"):
validate_url("http://this-host-definitely-does-not-exist-abc123.com/")
def test_escape_hatch(self, monkeypatch):
"""CREWAI_TOOLS_ALLOW_UNSAFE_PATHS=true bypasses URL validation."""
monkeypatch.setenv("CREWAI_TOOLS_ALLOW_UNSAFE_PATHS", "true")
# file:// would normally be blocked
result = validate_url("file:///etc/passwd")
assert result == "file:///etc/passwd"

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,7 @@ dependencies = [
"uv~=0.9.13",
"aiosqlite~=0.21.0",
"pyyaml~=6.0",
"aiofiles~=24.1.0",
"lancedb>=0.29.2,<0.30.1",
]
@@ -54,7 +55,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.0a3",
"crewai-tools==1.14.0",
]
embeddings = [
"tiktoken~=0.8.0"

View File

@@ -16,7 +16,7 @@ from crewai.knowledge.knowledge import Knowledge
from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM
from crewai.process import Process
from crewai.runtime_state import _entity_discriminator
from crewai.state.checkpoint_config import CheckpointConfig # noqa: F401
from crewai.task import Task
from crewai.tasks.llm_guardrail import LLMGuardrail
from crewai.tasks.task_output import TaskOutput
@@ -46,7 +46,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.14.0a3"
__version__ = "1.14.0"
_telemetry_submitted = False
@@ -100,8 +100,8 @@ def __getattr__(name: str) -> Any:
try:
from crewai.agents.agent_builder.base_agent import BaseAgent as _BaseAgent
from crewai.agents.agent_builder.base_agent_executor_mixin import (
CrewAgentExecutorMixin as _CrewAgentExecutorMixin,
from crewai.agents.agent_builder.base_agent_executor import (
BaseAgentExecutor as _BaseAgentExecutor,
)
from crewai.agents.tools_handler import ToolsHandler as _ToolsHandler
from crewai.experimental.agent_executor import AgentExecutor as _AgentExecutor
@@ -119,10 +119,18 @@ try:
"Flow": Flow,
"BaseLLM": BaseLLM,
"Task": Task,
"CrewAgentExecutorMixin": _CrewAgentExecutorMixin,
"BaseAgentExecutor": _BaseAgentExecutor,
"ExecutionContext": ExecutionContext,
"StandardPromptResult": _StandardPromptResult,
"SystemPromptResult": _SystemPromptResult,
}
from crewai.tools.base_tool import BaseTool as _BaseTool
from crewai.tools.structured_tool import CrewStructuredTool as _CrewStructuredTool
_base_namespace["BaseTool"] = _BaseTool
_base_namespace["CrewStructuredTool"] = _CrewStructuredTool
try:
from crewai.a2a.config import (
A2AClientConfig as _A2AClientConfig,
@@ -156,41 +164,55 @@ try:
**sys.modules[_BaseAgent.__module__].__dict__,
}
import crewai.state.runtime as _runtime_state_mod
for _mod_name in (
_BaseAgent.__module__,
Agent.__module__,
Crew.__module__,
Flow.__module__,
Task.__module__,
"crewai.agents.crew_agent_executor",
_runtime_state_mod.__name__,
_AgentExecutor.__module__,
):
sys.modules[_mod_name].__dict__.update(_resolve_namespace)
from crewai.agents.crew_agent_executor import (
CrewAgentExecutor as _CrewAgentExecutor,
)
from crewai.tasks.conditional_task import ConditionalTask as _ConditionalTask
_BaseAgentExecutor.model_rebuild(force=True, _types_namespace=_full_namespace)
_BaseAgent.model_rebuild(force=True, _types_namespace=_full_namespace)
Task.model_rebuild(force=True, _types_namespace=_full_namespace)
_ConditionalTask.model_rebuild(force=True, _types_namespace=_full_namespace)
_CrewAgentExecutor.model_rebuild(force=True, _types_namespace=_full_namespace)
Crew.model_rebuild(force=True, _types_namespace=_full_namespace)
Flow.model_rebuild(force=True, _types_namespace=_full_namespace)
_AgentExecutor.model_rebuild(force=True, _types_namespace=_full_namespace)
from typing import Annotated
from pydantic import Discriminator, RootModel, Tag
from pydantic import Field
from crewai.state.runtime import RuntimeState
Entity = Annotated[
Annotated[Flow, Tag("flow")] # type: ignore[type-arg]
| Annotated[Crew, Tag("crew")]
| Annotated[Agent, Tag("agent")],
Discriminator(_entity_discriminator),
Flow | Crew | Agent, # type: ignore[type-arg]
Field(discriminator="entity_type"),
]
RuntimeState = RootModel[list[Entity]]
RuntimeState.model_rebuild(
force=True,
_types_namespace={**_full_namespace, "Entity": Entity},
)
try:
Agent.model_rebuild(force=True, _types_namespace=_full_namespace)
except PydanticUserError:
pass
except (ImportError, PydanticUserError):
import logging as _logging
@@ -206,6 +228,7 @@ __all__ = [
"BaseLLM",
"Crew",
"CrewOutput",
"Entity",
"ExecutionContext",
"Flow",
"Knowledge",

View File

@@ -9,8 +9,6 @@ import contextvars
from datetime import datetime
import json
from pathlib import Path
import shutil
import subprocess
import time
from typing import (
TYPE_CHECKING,
@@ -27,7 +25,6 @@ from pydantic import (
BeforeValidator,
ConfigDict,
Field,
InstanceOf,
PrivateAttr,
model_validator,
)
@@ -117,7 +114,6 @@ except ImportError:
if TYPE_CHECKING:
from crewai_files import FileInput
from crewai_tools import CodeInterpreterTool
from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig
from crewai.agents.agent_builder.base_agent import PlatformAppOrAction
@@ -195,12 +191,12 @@ class Agent(BaseAgent):
llm: Annotated[
str | BaseLLM | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=str | None, when_used="json"),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(description="Language model that will run the agent.", default=None)
function_calling_llm: Annotated[
str | BaseLLM | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=str | None, when_used="json"),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(description="Language model that will run the agent.", default=None)
system_template: str | None = Field(
default=None, description="System format for the agent."
@@ -212,7 +208,9 @@ class Agent(BaseAgent):
default=None, description="Response format for the agent."
)
allow_code_execution: bool | None = Field(
default=False, description="Enable code execution for the agent."
default=False,
deprecated=True,
description="Deprecated. CodeInterpreterTool is no longer available. Use dedicated sandbox services instead.",
)
respect_context_window: bool = Field(
default=True,
@@ -237,7 +235,8 @@ class Agent(BaseAgent):
)
code_execution_mode: Literal["safe", "unsafe"] = Field(
default="safe",
description="Mode for code execution: 'safe' (using Docker) or 'unsafe' (direct execution).",
deprecated=True,
description="Deprecated. CodeInterpreterTool is no longer available. Use dedicated sandbox services instead.",
)
planning_config: PlanningConfig | None = Field(
default=None,
@@ -297,8 +296,8 @@ class Agent(BaseAgent):
Can be a single A2AConfig/A2AClientConfig/A2AServerConfig, or a list of any number of A2AConfig/A2AClientConfig with a single A2AServerConfig.
""",
)
agent_executor: InstanceOf[CrewAgentExecutor] | InstanceOf[AgentExecutor] | None = (
Field(default=None, description="An instance of the CrewAgentExecutor class.")
agent_executor: CrewAgentExecutor | AgentExecutor | None = Field(
default=None, description="An instance of the CrewAgentExecutor class."
)
executor_class: Annotated[
type[CrewAgentExecutor] | type[AgentExecutor],
@@ -330,7 +329,13 @@ class Agent(BaseAgent):
self._setup_agent_executor()
if self.allow_code_execution:
self._validate_docker_installation()
warnings.warn(
"allow_code_execution is deprecated and will be removed in v2.0. "
"CodeInterpreterTool is no longer available. "
"Use dedicated sandbox services like E2B or Modal.",
DeprecationWarning,
stacklevel=2,
)
self.set_skills()
@@ -1011,10 +1016,10 @@ class Agent(BaseAgent):
)
self.agent_executor = self.executor_class(
llm=self.llm,
task=task, # type: ignore[arg-type]
task=task,
i18n=self.i18n,
agent=self,
crew=self.crew, # type: ignore[arg-type]
crew=self.crew,
tools=parsed_tools,
prompt=prompt,
original_tools=raw_tools,
@@ -1057,7 +1062,8 @@ class Agent(BaseAgent):
if self.agent_executor is None:
raise RuntimeError("Agent executor is not initialized.")
self.agent_executor.task = task
if task is not None:
self.agent_executor.task = task
self.agent_executor.tools = tools
self.agent_executor.original_tools = raw_tools
self.agent_executor.prompt = prompt
@@ -1076,7 +1082,7 @@ class Agent(BaseAgent):
self.agent_executor.tools_handler = self.tools_handler
self.agent_executor.request_within_rpm_limit = rpm_limit_fn
if self.agent_executor.llm:
if isinstance(self.agent_executor.llm, BaseLLM):
existing_stop = getattr(self.agent_executor.llm, "stop", [])
self.agent_executor.llm.stop = list(
set(
@@ -1123,20 +1129,15 @@ class Agent(BaseAgent):
return [AddImageTool()]
def get_code_execution_tools(self) -> list[CodeInterpreterTool]:
"""Return code interpreter tools based on the agent's execution mode."""
try:
from crewai_tools import (
CodeInterpreterTool,
)
unsafe_mode = self.code_execution_mode == "unsafe"
return [CodeInterpreterTool(unsafe_mode=unsafe_mode)]
except ModuleNotFoundError:
self._logger.log(
"info", "Coding tools not available. Install crewai_tools. "
)
return []
def get_code_execution_tools(self) -> list[Any]:
"""Deprecated: CodeInterpreterTool is no longer available."""
warnings.warn(
"CodeInterpreterTool is no longer available. "
"Use dedicated sandbox services like E2B or Modal.",
DeprecationWarning,
stacklevel=2,
)
return []
@staticmethod
def get_output_converter(
@@ -1216,28 +1217,14 @@ class Agent(BaseAgent):
self._logger.log("warning", f"Failed to inject date: {e!s}")
def _validate_docker_installation(self) -> None:
"""Check if Docker is installed and running."""
docker_path = shutil.which("docker")
if not docker_path:
raise RuntimeError(
f"Docker is not installed. Please install Docker to use code execution with agent: {self.role}"
)
try:
subprocess.run( # noqa: S603
[str(docker_path), "info"],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Docker is not running. Please start Docker to use code execution with agent: {self.role}"
) from e
except subprocess.TimeoutExpired as e:
raise RuntimeError(
f"Docker command timed out. Please check your Docker installation for agent: {self.role}"
) from e
"""Deprecated: No-op. CodeInterpreterTool is no longer available."""
warnings.warn(
"CodeInterpreterTool is no longer available. "
"Use dedicated sandbox services like E2B or Modal.",
DeprecationWarning,
stacklevel=2,
)
return
def __repr__(self) -> str:
return f"Agent(role={self.role}, goal={self.goal}, backstory={self.backstory})"

View File

@@ -14,8 +14,8 @@ from pydantic import (
BaseModel,
BeforeValidator,
Field,
InstanceOf,
PrivateAttr,
SerializeAsAny,
field_validator,
model_validator,
)
@@ -24,7 +24,7 @@ from pydantic_core import PydanticCustomError
from typing_extensions import Self
from crewai.agent.internal.meta import AgentMeta
from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin
from crewai.agents.agent_builder.base_agent_executor import BaseAgentExecutor
from crewai.agents.agent_builder.utilities.base_token_process import TokenProcess
from crewai.agents.cache.cache_handler import CacheHandler
from crewai.agents.tools_handler import ToolsHandler
@@ -39,6 +39,7 @@ from crewai.memory.unified_memory import Memory
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.security_config import SecurityConfig
from crewai.skills.models import Skill
from crewai.state.checkpoint_config import CheckpointConfig, _coerce_checkpoint
from crewai.tools.base_tool import BaseTool, Tool
from crewai.types.callback import SerializableCallable
from crewai.utilities.config import process_config
@@ -51,6 +52,7 @@ from crewai.utilities.string_utils import interpolate_only
if TYPE_CHECKING:
from crewai.context import ExecutionContext
from crewai.crew import Crew
from crewai.state.provider.core import BaseProvider
def _validate_crew_ref(value: Any) -> Any:
@@ -63,7 +65,31 @@ def _serialize_crew_ref(value: Any) -> str | None:
return str(value.id) if hasattr(value, "id") else str(value)
_LLM_TYPE_REGISTRY: dict[str, str] = {
"base": "crewai.llms.base_llm.BaseLLM",
"litellm": "crewai.llm.LLM",
"openai": "crewai.llms.providers.openai.completion.OpenAICompletion",
"anthropic": "crewai.llms.providers.anthropic.completion.AnthropicCompletion",
"azure": "crewai.llms.providers.azure.completion.AzureCompletion",
"bedrock": "crewai.llms.providers.bedrock.completion.BedrockCompletion",
"gemini": "crewai.llms.providers.gemini.completion.GeminiCompletion",
}
def _validate_llm_ref(value: Any) -> Any:
if isinstance(value, dict):
import importlib
llm_type = value.get("llm_type")
if not llm_type or llm_type not in _LLM_TYPE_REGISTRY:
raise ValueError(
f"Unknown or missing llm_type: {llm_type!r}. "
f"Expected one of {list(_LLM_TYPE_REGISTRY)}"
)
dotted = _LLM_TYPE_REGISTRY[llm_type]
mod_path, cls_name = dotted.rsplit(".", 1)
cls = getattr(importlib.import_module(mod_path), cls_name)
return cls(**value)
return value
@@ -75,12 +101,37 @@ def _resolve_agent(value: Any, info: Any) -> Any:
return Agent.model_validate(value, context=getattr(info, "context", None))
def _serialize_llm_ref(value: Any) -> str | None:
_EXECUTOR_TYPE_REGISTRY: dict[str, str] = {
"base": "crewai.agents.agent_builder.base_agent_executor.BaseAgentExecutor",
"crew": "crewai.agents.crew_agent_executor.CrewAgentExecutor",
"experimental": "crewai.experimental.agent_executor.AgentExecutor",
}
def _validate_executor_ref(value: Any) -> Any:
if isinstance(value, dict):
import importlib
executor_type = value.get("executor_type")
if not executor_type or executor_type not in _EXECUTOR_TYPE_REGISTRY:
raise ValueError(
f"Unknown or missing executor_type: {executor_type!r}. "
f"Expected one of {list(_EXECUTOR_TYPE_REGISTRY)}"
)
dotted = _EXECUTOR_TYPE_REGISTRY[executor_type]
mod_path, cls_name = dotted.rsplit(".", 1)
cls = getattr(importlib.import_module(mod_path), cls_name)
return cls.model_validate(value)
return value
def _serialize_llm_ref(value: Any) -> dict[str, Any] | None:
if value is None:
return None
if isinstance(value, str):
return value
return getattr(value, "model", str(value))
return {"model": value}
result: dict[str, Any] = value.model_dump()
return result
_SLUG_RE: Final[re.Pattern[str]] = re.compile(
@@ -197,13 +248,19 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
max_iter: int = Field(
default=25, description="Maximum iterations for an agent to execute a task"
)
agent_executor: InstanceOf[CrewAgentExecutorMixin] | None = Field(
agent_executor: SerializeAsAny[BaseAgentExecutor] | None = Field(
default=None, description="An instance of the CrewAgentExecutor class."
)
@field_validator("agent_executor", mode="before")
@classmethod
def _validate_agent_executor(cls, v: Any) -> Any:
return _validate_executor_ref(v)
llm: Annotated[
str | BaseLLM | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=str | None, when_used="json"),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(default=None, description="Language model that will run the agent.")
crew: Annotated[
Crew | str | None,
@@ -243,6 +300,14 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
default_factory=SecurityConfig,
description="Security configuration for the agent, including fingerprinting.",
)
checkpoint: Annotated[
CheckpointConfig | bool | None,
BeforeValidator(_coerce_checkpoint),
] = Field(
default=None,
description="Automatic checkpointing configuration. "
"True for defaults, False to opt out, None to inherit.",
)
callbacks: list[SerializableCallable] = Field(
default_factory=list, description="Callbacks to be used for the agent"
)
@@ -276,6 +341,30 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
)
execution_context: ExecutionContext | None = Field(default=None)
@classmethod
def from_checkpoint(
cls, path: str, *, provider: BaseProvider | None = None
) -> Self:
"""Restore an Agent from a checkpoint file."""
from crewai.context import apply_execution_context
from crewai.state.provider.json_provider import JsonProvider
from crewai.state.runtime import RuntimeState
state = RuntimeState.from_checkpoint(
path,
provider=provider or JsonProvider(),
context={"from_checkpoint": True},
)
for entity in state.root:
if isinstance(entity, cls):
if entity.execution_context is not None:
apply_execution_context(entity.execution_context)
if entity.agent_executor is not None:
entity.agent_executor.agent = entity
entity.agent_executor._resuming = True
return entity
raise ValueError(f"No {cls.__name__} found in checkpoint: {path}")
@model_validator(mode="before")
@classmethod
def process_model_config(cls, values: Any) -> dict[str, Any]:

View File

@@ -2,37 +2,38 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field, PrivateAttr
from crewai.agents.parser import AgentFinish
from crewai.memory.utils import sanitize_scope_name
from crewai.utilities.printer import Printer
from crewai.utilities.string_utils import sanitize_tool_name
from crewai.utilities.types import LLMMessage
if TYPE_CHECKING:
from crewai.agent import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crew import Crew
from crewai.task import Task
from crewai.utilities.i18n import I18N
from crewai.utilities.types import LLMMessage
class CrewAgentExecutorMixin:
crew: Crew | None
agent: Agent
task: Task | None
iterations: int
max_iter: int
messages: list[LLMMessage]
_i18n: I18N
_printer: Printer = Printer()
class BaseAgentExecutor(BaseModel):
model_config = {"arbitrary_types_allowed": True}
executor_type: str = "base"
crew: Crew | None = Field(default=None, exclude=True)
agent: BaseAgent | None = Field(default=None, exclude=True)
task: Task | None = Field(default=None, exclude=True)
iterations: int = Field(default=0)
max_iter: int = Field(default=25)
messages: list[LLMMessage] = Field(default_factory=list)
_resuming: bool = PrivateAttr(default=False)
_i18n: I18N | None = PrivateAttr(default=None)
def _save_to_memory(self, output: AgentFinish) -> None:
"""Save task result to unified memory (memory or crew._memory).
Extends the memory's root_scope with agent-specific path segment
(e.g., '/crew/research-crew/agent/researcher') so that agent memories
are scoped hierarchically under their crew.
"""
"""Save task result to unified memory (memory or crew._memory)."""
if self.agent is None:
return
memory = getattr(self.agent, "memory", None) or (
getattr(self.crew, "_memory", None) if self.crew else None
)
@@ -49,11 +50,9 @@ class CrewAgentExecutorMixin:
)
extracted = memory.extract_memories(raw)
if extracted:
# Get the memory's existing root_scope
base_root = getattr(memory, "root_scope", None)
if isinstance(base_root, str) and base_root:
# Memory has a root_scope — extend it with agent info
agent_role = self.agent.role or "unknown"
sanitized_role = sanitize_scope_name(agent_role)
agent_root = f"{base_root.rstrip('/')}/agent/{sanitized_role}"
@@ -63,7 +62,6 @@ class CrewAgentExecutorMixin:
extracted, agent_role=self.agent.role, root_scope=agent_root
)
else:
# No base root_scope — don't inject one, preserve backward compat
memory.remember_many(extracted, agent_role=self.agent.role)
except Exception as e:
self.agent._logger.log("error", f"Failed to save to memory: {e}")

View File

@@ -1,71 +1,34 @@
"""Token usage tracking utilities.
"""Token usage tracking utilities."""
This module provides utilities for tracking token consumption and request
metrics during agent execution.
"""
from pydantic import BaseModel, Field
from crewai.types.usage_metrics import UsageMetrics
class TokenProcess:
"""Track token usage during agent processing.
class TokenProcess(BaseModel):
"""Track token usage during agent processing."""
Attributes:
total_tokens: Total number of tokens used.
prompt_tokens: Number of tokens used in prompts.
cached_prompt_tokens: Number of cached prompt tokens used.
completion_tokens: Number of tokens used in completions.
successful_requests: Number of successful requests made.
"""
def __init__(self) -> None:
"""Initialize token tracking with zero values."""
self.total_tokens: int = 0
self.prompt_tokens: int = 0
self.cached_prompt_tokens: int = 0
self.completion_tokens: int = 0
self.successful_requests: int = 0
total_tokens: int = Field(default=0)
prompt_tokens: int = Field(default=0)
cached_prompt_tokens: int = Field(default=0)
completion_tokens: int = Field(default=0)
successful_requests: int = Field(default=0)
def sum_prompt_tokens(self, tokens: int) -> None:
"""Add prompt tokens to the running totals.
Args:
tokens: Number of prompt tokens to add.
"""
self.prompt_tokens += tokens
self.total_tokens += tokens
def sum_completion_tokens(self, tokens: int) -> None:
"""Add completion tokens to the running totals.
Args:
tokens: Number of completion tokens to add.
"""
self.completion_tokens += tokens
self.total_tokens += tokens
def sum_cached_prompt_tokens(self, tokens: int) -> None:
"""Add cached prompt tokens to the running total.
Args:
tokens: Number of cached prompt tokens to add.
"""
self.cached_prompt_tokens += tokens
def sum_successful_requests(self, requests: int) -> None:
"""Add successful requests to the running total.
Args:
requests: Number of successful requests to add.
"""
self.successful_requests += requests
def get_summary(self) -> UsageMetrics:
"""Get a summary of all tracked metrics.
Returns:
UsageMetrics object with current totals.
"""
return UsageMetrics(
total_tokens=self.total_tokens,
prompt_tokens=self.prompt_tokens,

View File

@@ -1,3 +1,4 @@
# mypy: disable-error-code="union-attr,arg-type"
"""Agent executor for crew AI agents.
Handles agent execution flow including LLM interactions, tool execution,
@@ -12,12 +13,20 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
import contextvars
import inspect
import logging
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
from pydantic import BaseModel, GetCoreSchemaHandler, ValidationError
from pydantic_core import CoreSchema, core_schema
from pydantic import (
AliasChoices,
BaseModel,
BeforeValidator,
ConfigDict,
Field,
ValidationError,
)
from pydantic.functional_serializers import PlainSerializer
from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin
from crewai.agents.agent_builder.base_agent import _serialize_llm_ref, _validate_llm_ref
from crewai.agents.agent_builder.base_agent_executor import BaseAgentExecutor
from crewai.agents.parser import (
AgentAction,
AgentFinish,
@@ -38,6 +47,7 @@ from crewai.hooks.tool_hooks import (
get_after_tool_call_hooks,
get_before_tool_call_hooks,
)
from crewai.types.callback import SerializableCallable
from crewai.utilities.agent_utils import (
aget_llm_response,
convert_tools_to_openai_schema,
@@ -58,8 +68,9 @@ from crewai.utilities.agent_utils import (
from crewai.utilities.constants import TRAINING_DATA_FILE
from crewai.utilities.file_store import aget_all_files, get_all_files
from crewai.utilities.i18n import I18N, get_i18n
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
from crewai.utilities.string_utils import sanitize_tool_name
from crewai.utilities.token_counter_callback import TokenCalcHandler
from crewai.utilities.tool_utils import (
aexecute_tool_and_check_finality,
execute_tool_and_check_finality,
@@ -70,11 +81,8 @@ from crewai.utilities.training_handler import CrewTrainingHandler
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from crewai.agent import Agent
from crewai.agents.tools_handler import ToolsHandler
from crewai.crew import Crew
from crewai.llms.base_llm import BaseLLM
from crewai.task import Task
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.tool_types import ToolResult
@@ -82,87 +90,59 @@ if TYPE_CHECKING:
from crewai.utilities.types import LLMMessage
class CrewAgentExecutor(CrewAgentExecutorMixin):
class CrewAgentExecutor(BaseAgentExecutor):
"""Executor for crew agents.
Manages the execution lifecycle of an agent including prompt formatting,
LLM interactions, tool execution, and feedback handling.
"""
def __init__(
self,
llm: BaseLLM,
task: Task,
crew: Crew,
agent: Agent,
prompt: SystemPromptResult | StandardPromptResult,
max_iter: int,
tools: list[CrewStructuredTool],
tools_names: str,
stop_words: list[str],
tools_description: str,
tools_handler: ToolsHandler,
step_callback: Any = None,
original_tools: list[BaseTool] | None = None,
function_calling_llm: BaseLLM | Any | None = None,
respect_context_window: bool = False,
request_within_rpm_limit: Callable[[], bool] | None = None,
callbacks: list[Any] | None = None,
response_model: type[BaseModel] | None = None,
i18n: I18N | None = None,
) -> None:
"""Initialize executor.
executor_type: Literal["crew"] = "crew"
llm: Annotated[
BaseLLM | str | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(default=None)
prompt: SystemPromptResult | StandardPromptResult | None = Field(default=None)
tools: list[CrewStructuredTool] = Field(default_factory=list)
tools_names: str = Field(default="")
stop: list[str] = Field(
default_factory=list, validation_alias=AliasChoices("stop", "stop_words")
)
tools_description: str = Field(default="")
tools_handler: ToolsHandler | None = Field(default=None)
step_callback: SerializableCallable | None = Field(default=None, exclude=True)
original_tools: list[BaseTool] = Field(default_factory=list)
function_calling_llm: Annotated[
BaseLLM | str | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(default=None)
respect_context_window: bool = Field(default=False)
request_within_rpm_limit: SerializableCallable | None = Field(
default=None, exclude=True
)
callbacks: list[TokenCalcHandler] = Field(default_factory=list, exclude=True)
response_model: type[BaseModel] | None = Field(default=None, exclude=True)
ask_for_human_input: bool = Field(default=False)
log_error_after: int = Field(default=3)
before_llm_call_hooks: list[SerializableCallable] = Field(
default_factory=list, exclude=True
)
after_llm_call_hooks: list[SerializableCallable] = Field(
default_factory=list, exclude=True
)
Args:
llm: Language model instance.
task: Task to execute.
crew: Crew instance.
agent: Agent to execute.
prompt: Prompt templates.
max_iter: Maximum iterations.
tools: Available tools.
tools_names: Tool names string.
stop_words: Stop word list.
tools_description: Tool descriptions.
tools_handler: Tool handler instance.
step_callback: Optional step callback.
original_tools: Original tool list.
function_calling_llm: Optional function calling LLM.
respect_context_window: Respect context limits.
request_within_rpm_limit: RPM limit check function.
callbacks: Optional callbacks list.
response_model: Optional Pydantic model for structured outputs.
"""
self._i18n: I18N = i18n or get_i18n()
self.llm = llm
self.task = task
self.agent = agent
self.crew = crew
self.prompt = prompt
self.tools = tools
self.tools_names = tools_names
self.stop = stop_words
self.max_iter = max_iter
self.callbacks = callbacks or []
self._printer: Printer = Printer()
self.tools_handler = tools_handler
self.original_tools = original_tools or []
self.step_callback = step_callback
self.tools_description = tools_description
self.function_calling_llm = function_calling_llm
self.respect_context_window = respect_context_window
self.request_within_rpm_limit = request_within_rpm_limit
self.response_model = response_model
self.ask_for_human_input = False
self.messages: list[LLMMessage] = []
self.iterations = 0
self.log_error_after = 3
self.before_llm_call_hooks: list[Callable[..., Any]] = []
self.after_llm_call_hooks: list[Callable[..., Any]] = []
self.before_llm_call_hooks.extend(get_before_llm_call_hooks())
self.after_llm_call_hooks.extend(get_after_llm_call_hooks())
if self.llm:
# This may be mutating the shared llm object and needs further evaluation
model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)
def __init__(self, i18n: I18N | None = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._i18n = i18n or get_i18n()
if not self.before_llm_call_hooks:
self.before_llm_call_hooks.extend(get_before_llm_call_hooks())
if not self.after_llm_call_hooks:
self.after_llm_call_hooks.extend(get_after_llm_call_hooks())
if self.llm and not isinstance(self.llm, str):
existing_stop = getattr(self.llm, "stop", [])
self.llm.stop = list(
set(
@@ -179,7 +159,11 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
Returns:
bool: True if tool should be used or not.
"""
return self.llm.supports_stop_words() if self.llm else False
from crewai.llms.base_llm import BaseLLM
return (
self.llm.supports_stop_words() if isinstance(self.llm, BaseLLM) else False
)
def _setup_messages(self, inputs: dict[str, Any]) -> None:
"""Set up messages for the agent execution.
@@ -191,7 +175,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if provider.setup_messages(cast(ExecutorContext, cast(object, self))):
return
if "system" in self.prompt:
if self.prompt is not None and "system" in self.prompt:
system_prompt = self._format_prompt(
cast(str, self.prompt.get("system", "")), inputs
)
@@ -200,7 +184,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
)
self.messages.append(format_message_for_llm(system_prompt, role="system"))
self.messages.append(format_message_for_llm(user_prompt))
else:
elif self.prompt is not None:
user_prompt = self._format_prompt(self.prompt.get("prompt", ""), inputs)
self.messages.append(format_message_for_llm(user_prompt))
@@ -215,9 +199,11 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
Returns:
Dictionary with agent output.
"""
self._setup_messages(inputs)
self._inject_multimodal_files(inputs)
if self._resuming:
self._resuming = False
else:
self._setup_messages(inputs)
self._inject_multimodal_files(inputs)
self._show_start_logs()
@@ -227,13 +213,13 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
formatted_answer = self._invoke_loop()
except AssertionError:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content="Agent failed to reach a final answer. This is likely a bug - please report it.",
color="red",
)
raise
except Exception as e:
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
if self.ask_for_human_input:
@@ -341,10 +327,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if has_reached_max_iterations(self.iterations, self.max_iter):
formatted_answer = handle_max_iterations_exceeded(
formatted_answer,
printer=self._printer,
printer=PRINTER,
i18n=self._i18n,
messages=self.messages,
llm=self.llm,
llm=cast("BaseLLM", self.llm),
callbacks=self.callbacks,
verbose=self.agent.verbose,
)
@@ -353,10 +339,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
enforce_rpm_limit(self.request_within_rpm_limit)
answer = get_llm_response(
llm=self.llm,
llm=cast("BaseLLM", self.llm),
messages=self.messages,
callbacks=self.callbacks,
printer=self._printer,
printer=PRINTER,
from_task=self.task,
from_agent=self.agent,
response_model=self.response_model,
@@ -428,8 +414,8 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
formatted_answer, tool_result
)
self._invoke_step_callback(formatted_answer) # type: ignore[arg-type]
self._append_message(formatted_answer.text) # type: ignore[union-attr]
self._invoke_step_callback(formatted_answer)
self._append_message(formatted_answer.text)
except OutputParserError as e:
formatted_answer = handle_output_parser_exception( # type: ignore[assignment]
@@ -437,7 +423,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
messages=self.messages,
iterations=self.iterations,
log_error_after=self.log_error_after,
printer=self._printer,
printer=PRINTER,
verbose=self.agent.verbose,
)
@@ -448,15 +434,15 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if is_context_length_exceeded(e):
handle_context_length(
respect_context_window=self.respect_context_window,
printer=self._printer,
printer=PRINTER,
messages=self.messages,
llm=self.llm,
llm=cast("BaseLLM", self.llm),
callbacks=self.callbacks,
i18n=self._i18n,
verbose=self.agent.verbose,
)
continue
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise e
finally:
self.iterations += 1
@@ -497,10 +483,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if has_reached_max_iterations(self.iterations, self.max_iter):
formatted_answer = handle_max_iterations_exceeded(
None,
printer=self._printer,
printer=PRINTER,
i18n=self._i18n,
messages=self.messages,
llm=self.llm,
llm=cast("BaseLLM", self.llm),
callbacks=self.callbacks,
verbose=self.agent.verbose,
)
@@ -514,10 +500,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
# without executing them. The executor handles tool execution
# via _handle_native_tool_calls to properly manage message history.
answer = get_llm_response(
llm=self.llm,
llm=cast("BaseLLM", self.llm),
messages=self.messages,
callbacks=self.callbacks,
printer=self._printer,
printer=PRINTER,
tools=openai_tools,
available_functions=None,
from_task=self.task,
@@ -585,15 +571,15 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if is_context_length_exceeded(e):
handle_context_length(
respect_context_window=self.respect_context_window,
printer=self._printer,
printer=PRINTER,
messages=self.messages,
llm=self.llm,
llm=cast("BaseLLM", self.llm),
callbacks=self.callbacks,
i18n=self._i18n,
verbose=self.agent.verbose,
)
continue
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise e
finally:
self.iterations += 1
@@ -607,10 +593,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
enforce_rpm_limit(self.request_within_rpm_limit)
answer = get_llm_response(
llm=self.llm,
llm=cast("BaseLLM", self.llm),
messages=self.messages,
callbacks=self.callbacks,
printer=self._printer,
printer=PRINTER,
from_task=self.task,
from_agent=self.agent,
response_model=self.response_model,
@@ -966,7 +952,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
before_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict or {},
tool=structured_tool, # type: ignore[arg-type]
tool=structured_tool,
agent=self.agent,
task=self.task,
crew=self.crew,
@@ -980,7 +966,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
break
except Exception as hook_error:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Error in before_tool_call hook: {hook_error}",
color="red",
)
@@ -1031,7 +1017,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
after_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict or {},
tool=structured_tool, # type: ignore[arg-type]
tool=structured_tool,
agent=self.agent,
task=self.task,
crew=self.crew,
@@ -1046,7 +1032,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
after_hook_context.tool_result = result
except Exception as hook_error:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Error in after_tool_call hook: {hook_error}",
color="red",
)
@@ -1093,7 +1079,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if self.agent and self.agent.verbose:
cache_info = " (from cache)" if from_cache else ""
self._printer.print(
PRINTER.print(
content=f"Tool {func_name} executed with result{cache_info}: {result[:200]}...",
color="green",
)
@@ -1119,9 +1105,11 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
Returns:
Dictionary with agent output.
"""
self._setup_messages(inputs)
await self._ainject_multimodal_files(inputs)
if self._resuming:
self._resuming = False
else:
self._setup_messages(inputs)
await self._ainject_multimodal_files(inputs)
self._show_start_logs()
@@ -1131,13 +1119,13 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
formatted_answer = await self._ainvoke_loop()
except AssertionError:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content="Agent failed to reach a final answer. This is likely a bug - please report it.",
color="red",
)
raise
except Exception as e:
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
if self.ask_for_human_input:
@@ -1181,10 +1169,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if has_reached_max_iterations(self.iterations, self.max_iter):
formatted_answer = handle_max_iterations_exceeded(
formatted_answer,
printer=self._printer,
printer=PRINTER,
i18n=self._i18n,
messages=self.messages,
llm=self.llm,
llm=cast("BaseLLM", self.llm),
callbacks=self.callbacks,
verbose=self.agent.verbose,
)
@@ -1193,10 +1181,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
enforce_rpm_limit(self.request_within_rpm_limit)
answer = await aget_llm_response(
llm=self.llm,
llm=cast("BaseLLM", self.llm),
messages=self.messages,
callbacks=self.callbacks,
printer=self._printer,
printer=PRINTER,
from_task=self.task,
from_agent=self.agent,
response_model=self.response_model,
@@ -1267,8 +1255,8 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
formatted_answer, tool_result
)
await self._ainvoke_step_callback(formatted_answer) # type: ignore[arg-type]
self._append_message(formatted_answer.text) # type: ignore[union-attr]
await self._ainvoke_step_callback(formatted_answer)
self._append_message(formatted_answer.text)
except OutputParserError as e:
formatted_answer = handle_output_parser_exception( # type: ignore[assignment]
@@ -1276,7 +1264,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
messages=self.messages,
iterations=self.iterations,
log_error_after=self.log_error_after,
printer=self._printer,
printer=PRINTER,
verbose=self.agent.verbose,
)
@@ -1286,15 +1274,15 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if is_context_length_exceeded(e):
handle_context_length(
respect_context_window=self.respect_context_window,
printer=self._printer,
printer=PRINTER,
messages=self.messages,
llm=self.llm,
llm=cast("BaseLLM", self.llm),
callbacks=self.callbacks,
i18n=self._i18n,
verbose=self.agent.verbose,
)
continue
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise e
finally:
self.iterations += 1
@@ -1329,10 +1317,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if has_reached_max_iterations(self.iterations, self.max_iter):
formatted_answer = handle_max_iterations_exceeded(
None,
printer=self._printer,
printer=PRINTER,
i18n=self._i18n,
messages=self.messages,
llm=self.llm,
llm=cast("BaseLLM", self.llm),
callbacks=self.callbacks,
verbose=self.agent.verbose,
)
@@ -1346,10 +1334,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
# without executing them. The executor handles tool execution
# via _handle_native_tool_calls to properly manage message history.
answer = await aget_llm_response(
llm=self.llm,
llm=cast("BaseLLM", self.llm),
messages=self.messages,
callbacks=self.callbacks,
printer=self._printer,
printer=PRINTER,
tools=openai_tools,
available_functions=None,
from_task=self.task,
@@ -1416,15 +1404,15 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if is_context_length_exceeded(e):
handle_context_length(
respect_context_window=self.respect_context_window,
printer=self._printer,
printer=PRINTER,
messages=self.messages,
llm=self.llm,
llm=cast("BaseLLM", self.llm),
callbacks=self.callbacks,
i18n=self._i18n,
verbose=self.agent.verbose,
)
continue
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise e
finally:
self.iterations += 1
@@ -1438,10 +1426,10 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
enforce_rpm_limit(self.request_within_rpm_limit)
answer = await aget_llm_response(
llm=self.llm,
llm=cast("BaseLLM", self.llm),
messages=self.messages,
callbacks=self.callbacks,
printer=self._printer,
printer=PRINTER,
from_task=self.task,
from_agent=self.agent,
response_model=self.response_model,
@@ -1589,7 +1577,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
if train_iteration is None or not isinstance(train_iteration, int):
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content="Invalid or missing train iteration. Cannot save training data.",
color="red",
)
@@ -1613,7 +1601,7 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
agent_training_data[train_iteration]["improved_output"] = result.output
else:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=(
f"No existing training data for agent {agent_id} and iteration "
f"{train_iteration}. Cannot save improved output."
@@ -1687,14 +1675,3 @@ class CrewAgentExecutor(CrewAgentExecutorMixin):
return format_message_for_llm(
self._i18n.slice("feedback_instructions").format(feedback=feedback)
)
@classmethod
def __get_pydantic_core_schema__(
cls, _source_type: Any, _handler: GetCoreSchemaHandler
) -> CoreSchema:
"""Generate Pydantic core schema for BaseClient Protocol.
This allows the Protocol to be used in Pydantic models without
requiring arbitrary_types_allowed=True.
"""
return core_schema.any_schema()

View File

@@ -30,7 +30,7 @@ from crewai.utilities.types import LLMMessage
if TYPE_CHECKING:
from crewai.agent import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.task import Task
logger = logging.getLogger(__name__)
@@ -56,7 +56,7 @@ class PlannerObserver:
def __init__(
self,
agent: Agent,
agent: BaseAgent,
task: Task | None = None,
kickoff_input: str = "",
) -> None:

View File

@@ -40,7 +40,7 @@ from crewai.utilities.agent_utils import (
)
from crewai.utilities.i18n import I18N, get_i18n
from crewai.utilities.planning_types import TodoItem
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
from crewai.utilities.step_execution_context import StepExecutionContext, StepResult
from crewai.utilities.string_utils import sanitize_tool_name
from crewai.utilities.tool_utils import execute_tool_and_check_finality
@@ -48,7 +48,7 @@ from crewai.utilities.types import LLMMessage
if TYPE_CHECKING:
from crewai.agent import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.agents.tools_handler import ToolsHandler
from crewai.crew import Crew
from crewai.llms.base_llm import BaseLLM
@@ -88,7 +88,7 @@ class StepExecutor:
self,
llm: BaseLLM,
tools: list[CrewStructuredTool],
agent: Agent,
agent: BaseAgent,
original_tools: list[BaseTool] | None = None,
tools_handler: ToolsHandler | None = None,
task: Task | None = None,
@@ -109,7 +109,6 @@ class StepExecutor:
self.request_within_rpm_limit = request_within_rpm_limit
self.callbacks = callbacks or []
self._i18n: I18N = i18n or get_i18n()
self._printer: Printer = Printer()
# Native tool support — set up once
self._use_native_tools = check_native_tool_support(
@@ -585,7 +584,7 @@ class StepExecutor:
task=self.task,
crew=self.crew,
event_source=self,
printer=self._printer,
printer=PRINTER,
verbose=bool(self.agent and self.agent.verbose),
)

View File

@@ -3,17 +3,14 @@ from pathlib import Path
import click
from crewai.cli.utils import copy_template
from crewai.utilities.printer import Printer
_printer = Printer()
from crewai.utilities.printer import PRINTER
def add_crew_to_flow(crew_name: str) -> None:
"""Add a new crew to the current flow."""
# Check if pyproject.toml exists in the current directory
if not Path("pyproject.toml").exists():
_printer.print(
PRINTER.print(
"This command must be run from the root of a flow project.", color="red"
)
raise click.ClickException(
@@ -25,7 +22,7 @@ def add_crew_to_flow(crew_name: str) -> None:
crews_folder = flow_folder / "src" / flow_folder.name / "crews"
if not crews_folder.exists():
_printer.print("Crews folder does not exist in the current flow.", color="red")
PRINTER.print("Crews folder does not exist in the current flow.", color="red")
raise click.ClickException("Crews folder does not exist in the current flow.")
# Create the crew within the flow's crews directory

View File

@@ -0,0 +1,329 @@
"""CLI commands for inspecting checkpoint files."""
from __future__ import annotations
from datetime import datetime
import glob
import json
import os
import sqlite3
from typing import Any
import click
_SQLITE_MAGIC = b"SQLite format 3\x00"
_SELECT_ALL = """
SELECT id, created_at, json(data)
FROM checkpoints
ORDER BY rowid DESC
"""
_SELECT_ONE = """
SELECT id, created_at, json(data)
FROM checkpoints
WHERE id = ?
"""
_SELECT_LATEST = """
SELECT id, created_at, json(data)
FROM checkpoints
ORDER BY rowid DESC
LIMIT 1
"""
def _is_sqlite(path: str) -> bool:
"""Check if a file is a SQLite database by reading its magic bytes."""
if not os.path.isfile(path):
return False
try:
with open(path, "rb") as f:
return f.read(16) == _SQLITE_MAGIC
except OSError:
return False
def _parse_checkpoint_json(raw: str, source: str) -> dict[str, Any]:
"""Parse checkpoint JSON into metadata dict."""
data = json.loads(raw)
entities = data.get("entities", [])
nodes = data.get("event_record", {}).get("nodes", {})
event_count = len(nodes)
trigger_event = None
if nodes:
last_node = max(
nodes.values(),
key=lambda n: n.get("event", {}).get("emission_sequence") or 0,
)
trigger_event = last_node.get("event", {}).get("type")
parsed_entities: list[dict[str, Any]] = []
for entity in entities:
tasks = entity.get("tasks", [])
completed = sum(1 for t in tasks if t.get("output") is not None)
info: dict[str, Any] = {
"type": entity.get("entity_type", "unknown"),
"name": entity.get("name"),
"id": entity.get("id"),
}
if tasks:
info["tasks_completed"] = completed
info["tasks_total"] = len(tasks)
info["tasks"] = [
{
"description": t.get("description", ""),
"completed": t.get("output") is not None,
}
for t in tasks
]
parsed_entities.append(info)
return {
"source": source,
"event_count": event_count,
"trigger": trigger_event,
"entities": parsed_entities,
}
def _format_size(size: int) -> str:
if size < 1024:
return f"{size}B"
if size < 1024 * 1024:
return f"{size / 1024:.1f}KB"
return f"{size / 1024 / 1024:.1f}MB"
def _ts_from_name(name: str) -> str | None:
"""Extract timestamp from checkpoint ID or filename."""
stem = os.path.basename(name).split("_")[0].removesuffix(".json")
try:
dt = datetime.strptime(stem, "%Y%m%dT%H%M%S")
except ValueError:
return None
return dt.strftime("%Y-%m-%d %H:%M:%S")
def _entity_summary(entities: list[dict[str, Any]]) -> str:
parts = []
for ent in entities:
etype = ent.get("type", "unknown")
ename = ent.get("name", "")
completed = ent.get("tasks_completed")
total = ent.get("tasks_total")
if completed is not None and total is not None:
parts.append(f"{etype}:{ename} [{completed}/{total} tasks]")
else:
parts.append(f"{etype}:{ename}")
return ", ".join(parts) if parts else "empty"
# --- JSON directory ---
def _list_json(location: str) -> list[dict[str, Any]]:
pattern = os.path.join(location, "*.json")
results = []
for path in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True):
name = os.path.basename(path)
try:
with open(path) as f:
raw = f.read()
meta = _parse_checkpoint_json(raw, source=name)
meta["name"] = name
meta["ts"] = _ts_from_name(name)
meta["size"] = os.path.getsize(path)
meta["path"] = path
except Exception:
meta = {"name": name, "ts": None, "size": 0, "entities": [], "source": name}
results.append(meta)
return results
def _info_json_latest(location: str) -> dict[str, Any] | None:
pattern = os.path.join(location, "*.json")
files = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
if not files:
return None
path = files[0]
with open(path) as f:
raw = f.read()
meta = _parse_checkpoint_json(raw, source=os.path.basename(path))
meta["name"] = os.path.basename(path)
meta["ts"] = _ts_from_name(path)
meta["size"] = os.path.getsize(path)
meta["path"] = path
return meta
def _info_json_file(path: str) -> dict[str, Any]:
with open(path) as f:
raw = f.read()
meta = _parse_checkpoint_json(raw, source=os.path.basename(path))
meta["name"] = os.path.basename(path)
meta["ts"] = _ts_from_name(path)
meta["size"] = os.path.getsize(path)
meta["path"] = path
return meta
# --- SQLite ---
def _list_sqlite(db_path: str) -> list[dict[str, Any]]:
results = []
with sqlite3.connect(db_path) as conn:
for row in conn.execute(_SELECT_ALL):
checkpoint_id, created_at, raw = row
try:
meta = _parse_checkpoint_json(raw, source=checkpoint_id)
meta["name"] = checkpoint_id
meta["ts"] = _ts_from_name(checkpoint_id) or created_at
except Exception:
meta = {
"name": checkpoint_id,
"ts": created_at,
"entities": [],
"source": checkpoint_id,
}
results.append(meta)
return results
def _info_sqlite_latest(db_path: str) -> dict[str, Any] | None:
with sqlite3.connect(db_path) as conn:
row = conn.execute(_SELECT_LATEST).fetchone()
if not row:
return None
checkpoint_id, created_at, raw = row
meta = _parse_checkpoint_json(raw, source=checkpoint_id)
meta["name"] = checkpoint_id
meta["ts"] = _ts_from_name(checkpoint_id) or created_at
meta["db"] = db_path
return meta
def _info_sqlite_id(db_path: str, checkpoint_id: str) -> dict[str, Any] | None:
with sqlite3.connect(db_path) as conn:
row = conn.execute(_SELECT_ONE, (checkpoint_id,)).fetchone()
if not row:
return None
cid, created_at, raw = row
meta = _parse_checkpoint_json(raw, source=cid)
meta["name"] = cid
meta["ts"] = _ts_from_name(cid) or created_at
meta["db"] = db_path
return meta
# --- Public API ---
def list_checkpoints(location: str) -> None:
"""List all checkpoints at a location."""
if _is_sqlite(location):
entries = _list_sqlite(location)
label = f"SQLite: {location}"
elif os.path.isdir(location):
entries = _list_json(location)
label = location
else:
click.echo(f"Not a directory or SQLite database: {location}")
return
if not entries:
click.echo(f"No checkpoints found in {label}")
return
click.echo(f"Found {len(entries)} checkpoint(s) in {label}\n")
for entry in entries:
ts = entry.get("ts") or "unknown"
name = entry.get("name", "")
size = _format_size(entry["size"]) if "size" in entry else ""
trigger = entry.get("trigger") or ""
summary = _entity_summary(entry.get("entities", []))
parts = [name, ts]
if size:
parts.append(size)
if trigger:
parts.append(trigger)
parts.append(summary)
click.echo(f" {' '.join(parts)}")
def info_checkpoint(path: str) -> None:
"""Show details of a single checkpoint."""
meta: dict[str, Any] | None = None
# db_path#checkpoint_id format
if "#" in path:
db_path, checkpoint_id = path.rsplit("#", 1)
if _is_sqlite(db_path):
meta = _info_sqlite_id(db_path, checkpoint_id)
if not meta:
click.echo(f"Checkpoint not found: {checkpoint_id}")
return
# SQLite file — show latest
if meta is None and _is_sqlite(path):
meta = _info_sqlite_latest(path)
if not meta:
click.echo(f"No checkpoints in database: {path}")
return
click.echo(f"Latest checkpoint: {meta['name']}\n")
# Directory — show latest JSON
if meta is None and os.path.isdir(path):
meta = _info_json_latest(path)
if not meta:
click.echo(f"No checkpoints found in {path}")
return
click.echo(f"Latest checkpoint: {meta['name']}\n")
# Specific JSON file
if meta is None and os.path.isfile(path):
try:
meta = _info_json_file(path)
except Exception as exc:
click.echo(f"Failed to read checkpoint: {exc}")
return
if meta is None:
click.echo(f"Not found: {path}")
return
_print_info(meta)
def _print_info(meta: dict[str, Any]) -> None:
ts = meta.get("ts") or "unknown"
source = meta.get("path") or meta.get("db") or meta.get("source", "")
click.echo(f"Source: {source}")
click.echo(f"Name: {meta.get('name', '')}")
click.echo(f"Time: {ts}")
if "size" in meta:
click.echo(f"Size: {_format_size(meta['size'])}")
click.echo(f"Events: {meta.get('event_count', 0)}")
trigger = meta.get("trigger")
if trigger:
click.echo(f"Trigger: {trigger}")
for ent in meta.get("entities", []):
eid = str(ent.get("id", ""))[:8]
click.echo(f"\n {ent['type']}: {ent.get('name', 'unnamed')} ({eid}...)")
tasks = ent.get("tasks")
if isinstance(tasks, list):
click.echo(
f" Tasks: {ent['tasks_completed']}/{ent['tasks_total']} completed"
)
for i, task in enumerate(tasks):
status = "done" if task.get("completed") else "pending"
desc = str(task.get("description", ""))
if len(desc) > 70:
desc = desc[:67] + "..."
click.echo(f" {i + 1}. [{status}] {desc}")

View File

@@ -609,7 +609,6 @@ def env() -> None:
@env.command("view")
def env_view() -> None:
"""View tracing-related environment variables."""
import os
from pathlib import Path
from rich.console import Console
@@ -738,7 +737,6 @@ def traces_disable() -> None:
@traces.command("status")
def traces_status() -> None:
"""Show current trace collection status."""
import os
from rich.console import Console
from rich.panel import Panel
@@ -788,5 +786,28 @@ def traces_status() -> None:
console.print(panel)
@crewai.group()
def checkpoint() -> None:
"""Inspect checkpoint files."""
@checkpoint.command("list")
@click.argument("location", default="./.checkpoints")
def checkpoint_list(location: str) -> None:
"""List checkpoints in a directory."""
from crewai.cli.checkpoint_cli import list_checkpoints
list_checkpoints(location)
@checkpoint.command("info")
@click.argument("path", default="./.checkpoints")
def checkpoint_info(path: str) -> None:
"""Show details of a checkpoint. Pass a file or directory for latest."""
from crewai.cli.checkpoint_cli import info_checkpoint
info_checkpoint(path)
if __name__ == "__main__":
crewai()

View File

@@ -19,12 +19,10 @@ from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM
from crewai.types.crew_chat import ChatInputField, ChatInputs
from crewai.utilities.llm_utils import create_llm
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
from crewai.utilities.types import LLMMessage
_printer = Printer()
MIN_REQUIRED_VERSION: Final[Literal["0.98.0"]] = "0.98.0"
@@ -121,9 +119,9 @@ def run_chat() -> None:
def show_loading(event: threading.Event) -> None:
"""Display animated loading dots while processing."""
while not event.is_set():
_printer.print(".", end="")
PRINTER.print(".", end="")
time.sleep(1)
_printer.print("")
PRINTER.print("")
def initialize_chat_llm(crew: Crew) -> LLM | BaseLLM | None:

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.0a3"
"crewai[tools]==1.14.0"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "{{name}} using crewAI"
authors = [{ name = "Your Name", email = "you@example.com" }]
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.0a3"
"crewai[tools]==1.14.0"
]
[project.scripts]

View File

@@ -5,7 +5,7 @@ description = "Power up your crews with {{folder_name}}"
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"crewai[tools]==1.14.0a3"
"crewai[tools]==1.14.0"
]
[tool.crewai]

View File

@@ -90,7 +90,7 @@ class ExecutionContext(BaseModel):
flow_id: str | None = Field(default=None)
flow_method_name: str = Field(default="unknown")
event_id_stack: tuple[tuple[str, str], ...] = Field(default=())
event_id_stack: tuple[tuple[str, str], ...] = Field(default_factory=tuple)
last_event_id: str | None = Field(default=None)
triggering_event_id: str | None = Field(default=None)
emission_sequence: int = Field(default=0)

View File

@@ -42,6 +42,7 @@ if TYPE_CHECKING:
from opentelemetry.trace import Span
from crewai.context import ExecutionContext
from crewai.state.provider.core import BaseProvider
try:
from crewai_files import get_supported_content_types
@@ -103,6 +104,7 @@ from crewai.rag.types import SearchResult
from crewai.security.fingerprint import Fingerprint
from crewai.security.security_config import SecurityConfig
from crewai.skills.models import Skill
from crewai.state.checkpoint_config import CheckpointConfig, _coerce_checkpoint
from crewai.task import Task
from crewai.tasks.conditional_task import ConditionalTask
from crewai.tasks.task_output import TaskOutput
@@ -234,7 +236,7 @@ class Crew(FlowTrackable, BaseModel):
manager_llm: Annotated[
str | BaseLLM | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=str | None, when_used="json"),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(description="Language model that will run the agent.", default=None)
manager_agent: Annotated[
BaseAgent | None,
@@ -243,7 +245,7 @@ class Crew(FlowTrackable, BaseModel):
function_calling_llm: Annotated[
str | LLM | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=str | None, when_used="json"),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(description="Language model that will run the agent.", default=None)
config: Json[dict[str, Any]] | dict[str, Any] | None = Field(default=None)
id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
@@ -296,7 +298,7 @@ class Crew(FlowTrackable, BaseModel):
planning_llm: Annotated[
str | BaseLLM | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=str | None, when_used="json"),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(
default=None,
description=(
@@ -321,7 +323,7 @@ class Crew(FlowTrackable, BaseModel):
chat_llm: Annotated[
str | BaseLLM | None,
BeforeValidator(_validate_llm_ref),
PlainSerializer(_serialize_llm_ref, return_type=str | None, when_used="json"),
PlainSerializer(_serialize_llm_ref, return_type=dict | None, when_used="json"),
] = Field(
default=None,
description="LLM used to handle chatting with the crew.",
@@ -339,6 +341,14 @@ class Crew(FlowTrackable, BaseModel):
default_factory=SecurityConfig,
description="Security configuration for the crew, including fingerprinting.",
)
checkpoint: Annotated[
CheckpointConfig | bool | None,
BeforeValidator(_coerce_checkpoint),
] = Field(
default=None,
description="Automatic checkpointing configuration. "
"True for defaults, False to opt out, None to inherit.",
)
token_usage: UsageMetrics | None = Field(
default=None,
description="Metrics for the LLM usage during all tasks execution.",
@@ -353,6 +363,113 @@ class Crew(FlowTrackable, BaseModel):
checkpoint_train: bool | None = Field(default=None)
checkpoint_kickoff_event_id: str | None = Field(default=None)
@classmethod
def from_checkpoint(
cls, path: str, *, provider: BaseProvider | None = None
) -> Crew:
"""Restore a Crew from a checkpoint file, ready to resume via kickoff().
Args:
path: Path to a checkpoint JSON file.
provider: Storage backend to read from. Defaults to JsonProvider.
Returns:
A Crew instance. Call kickoff() to resume from the last completed task.
"""
from crewai.context import apply_execution_context
from crewai.events.event_bus import crewai_event_bus
from crewai.state.provider.json_provider import JsonProvider
from crewai.state.runtime import RuntimeState
state = RuntimeState.from_checkpoint(
path,
provider=provider or JsonProvider(),
context={"from_checkpoint": True},
)
crewai_event_bus.set_runtime_state(state)
for entity in state.root:
if isinstance(entity, cls):
if entity.execution_context is not None:
apply_execution_context(entity.execution_context)
entity._restore_runtime()
return entity
raise ValueError(f"No Crew found in checkpoint: {path}")
def _restore_runtime(self) -> None:
"""Re-create runtime objects after restoring from a checkpoint."""
for agent in self.agents:
agent.crew = self
executor = agent.agent_executor
if executor and executor.messages:
executor.crew = self
executor.agent = agent
executor._resuming = True
else:
agent.agent_executor = None
for task in self.tasks:
if task.agent is not None:
for agent in self.agents:
if agent.role == task.agent.role:
task.agent = agent
if agent.agent_executor is not None and task.output is None:
agent.agent_executor.task = task
break
if self.checkpoint_inputs is not None:
self._inputs = self.checkpoint_inputs
if self.checkpoint_kickoff_event_id is not None:
self._kickoff_event_id = self.checkpoint_kickoff_event_id
if self.checkpoint_train is not None:
self._train = self.checkpoint_train
self._restore_event_scope()
def _restore_event_scope(self) -> None:
"""Rebuild the event scope stack from the checkpoint's event record."""
from crewai.events.base_events import set_emission_counter
from crewai.events.event_bus import crewai_event_bus
from crewai.events.event_context import (
restore_event_scope,
set_last_event_id,
)
state = crewai_event_bus._runtime_state
if state is None:
return
# Restore crew scope and the in-progress task scope. Inner scopes
# (agent, llm, tool) are re-created by the executor on resume.
stack: list[tuple[str, str]] = []
if self._kickoff_event_id:
stack.append((self._kickoff_event_id, "crew_kickoff_started"))
# Find the task_started event for the in-progress task (skipped on resume)
for task in self.tasks:
if task.output is None:
task_id_str = str(task.id)
for node in state.event_record.nodes.values():
if (
node.event.type == "task_started"
and node.event.task_id == task_id_str
):
stack.append((node.event.event_id, "task_started"))
break
break
restore_event_scope(tuple(stack))
# Restore last_event_id and emission counter from the record
last_event_id: str | None = None
max_seq = 0
for node in state.event_record.nodes.values():
seq = node.event.emission_sequence or 0
if seq > max_seq:
max_seq = seq
last_event_id = node.event.event_id
if last_event_id is not None:
set_last_event_id(last_event_id)
if max_seq > 0:
set_emission_counter(max_seq)
@field_validator("id", mode="before")
@classmethod
def _deny_user_set_id(cls, v: UUID4 | None, info: Any) -> UUID4 | None:
@@ -381,7 +498,8 @@ class Crew(FlowTrackable, BaseModel):
@model_validator(mode="after")
def set_private_attrs(self) -> Crew:
"""set private attributes."""
self._cache_handler = CacheHandler()
if not getattr(self, "_cache_handler", None):
self._cache_handler = CacheHandler()
event_listener = EventListener()
# Determine and set tracing state once for this execution
@@ -1055,6 +1173,10 @@ class Crew(FlowTrackable, BaseModel):
Returns:
CrewOutput: Final output of the crew
"""
custom_start = self._get_execution_start_index(tasks)
if custom_start is not None:
start_index = custom_start
task_outputs: list[TaskOutput] = []
pending_tasks: list[tuple[Task, asyncio.Task[TaskOutput], int]] = []
last_sync_output: TaskOutput | None = None
@@ -1236,7 +1358,12 @@ class Crew(FlowTrackable, BaseModel):
manager.crew = self
def _get_execution_start_index(self, tasks: list[Task]) -> int | None:
return None
if self.checkpoint_kickoff_event_id is None:
return None
for i, task in enumerate(tasks):
if task.output is None:
return i
return len(tasks) if tasks else None
def _execute_tasks(
self,

View File

@@ -105,6 +105,9 @@ def setup_agents(
agent.function_calling_llm = function_calling_llm # type: ignore[attr-defined]
if not agent.step_callback: # type: ignore[attr-defined]
agent.step_callback = step_callback # type: ignore[attr-defined]
executor = getattr(agent, "agent_executor", None)
if executor and getattr(executor, "_resuming", False):
continue
agent.create_agent_executor()
@@ -157,10 +160,8 @@ def prepare_task_execution(
# Handle replay skip
if start_index is not None and task_index < start_index:
if task.output:
if task.async_execution:
task_outputs.append(task.output)
else:
task_outputs = [task.output]
task_outputs.append(task.output)
if not task.async_execution:
last_sync_output = task.output
return (
TaskExecutionData(agent=None, tools=[], should_skip=True),
@@ -183,7 +184,9 @@ def prepare_task_execution(
tools_for_task,
)
crew._log_task_start(task, agent_to_use.role)
executor = agent_to_use.agent_executor
if not (executor and executor._resuming):
crew._log_task_start(task, agent_to_use.role)
return (
TaskExecutionData(agent=agent_to_use, tools=tools_for_task),
@@ -275,10 +278,15 @@ def prepare_kickoff(
"""
from crewai.events.base_events import reset_emission_counter
from crewai.events.event_bus import crewai_event_bus
from crewai.events.event_context import get_current_parent_id, reset_last_event_id
from crewai.events.event_context import (
get_current_parent_id,
reset_last_event_id,
)
from crewai.events.types.crew_events import CrewKickoffStartedEvent
if get_current_parent_id() is None:
resuming = crew.checkpoint_kickoff_event_id is not None
if not resuming and get_current_parent_id() is None:
reset_emission_counter()
reset_last_event_id()
@@ -296,14 +304,29 @@ def prepare_kickoff(
normalized = {}
normalized = before_callback(normalized)
started_event = CrewKickoffStartedEvent(crew_name=crew.name, inputs=normalized)
crew._kickoff_event_id = started_event.event_id
future = crewai_event_bus.emit(crew, started_event)
if future is not None:
try:
future.result()
except Exception: # noqa: S110
pass
if resuming and crew._kickoff_event_id:
if crew.verbose:
from crewai.events.utils.console_formatter import ConsoleFormatter
fmt = ConsoleFormatter(verbose=True)
content = fmt.create_status_content(
"Resuming from Checkpoint",
crew.name or "Crew",
"bright_magenta",
ID=str(crew.id),
)
fmt.print_panel(
content, "\U0001f504 Resuming from Checkpoint", "bright_magenta"
)
else:
started_event = CrewKickoffStartedEvent(crew_name=crew.name, inputs=normalized)
crew._kickoff_event_id = started_event.event_id
future = crewai_event_bus.emit(crew, started_event)
if future is not None:
try:
future.result()
except Exception: # noqa: S110
pass
crew._task_output_handler.reset()
crew._logging_color = "bold_purple"

View File

@@ -5,17 +5,24 @@ of events throughout the CrewAI system, supporting both synchronous and asynchro
event handlers with optional dependency management.
"""
from __future__ import annotations
import asyncio
import atexit
from collections.abc import Callable, Generator
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import contextmanager
import contextvars
import logging
import threading
from typing import Any, Final, ParamSpec, TypeVar
from typing import TYPE_CHECKING, Any, Final, ParamSpec, TypeVar
from typing_extensions import Self
if TYPE_CHECKING:
from crewai.state.runtime import RuntimeState
from crewai.events.base_events import BaseEvent, get_next_emission_sequence
from crewai.events.depends import Depends
from crewai.events.event_context import (
@@ -43,10 +50,16 @@ from crewai.events.types.event_bus_types import (
)
from crewai.events.types.llm_events import LLMStreamChunkEvent
from crewai.events.utils.console_formatter import ConsoleFormatter
from crewai.events.utils.handlers import is_async_handler, is_call_handler_safe
from crewai.events.utils.handlers import (
_get_param_count,
is_async_handler,
is_call_handler_safe,
)
from crewai.utilities.rw_lock import RWLock
logger = logging.getLogger(__name__)
P = ParamSpec("P")
R = TypeVar("R")
@@ -87,6 +100,7 @@ class CrewAIEventsBus:
_futures_lock: threading.Lock
_executor_initialized: bool
_has_pending_events: bool
_runtime_state: RuntimeState | None
def __new__(cls) -> Self:
"""Create or return the singleton instance.
@@ -122,6 +136,8 @@ class CrewAIEventsBus:
# Lazy initialization flags - executor and loop created on first emit
self._executor_initialized = False
self._has_pending_events = False
self._runtime_state: RuntimeState | None = None
self._registered_entity_ids: set[int] = set()
def _ensure_executor_initialized(self) -> None:
"""Lazily initialize the thread pool executor and event loop.
@@ -209,25 +225,16 @@ class CrewAIEventsBus:
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Decorator to register an event handler for a specific event type.
Handlers can accept 2 or 3 arguments:
- ``(source, event)`` — standard handler
- ``(source, event, state: RuntimeState)`` — handler with runtime state
Args:
event_type: The event class to listen for
depends_on: Optional dependency or list of dependencies. Handlers with
dependencies will execute after their dependencies complete.
depends_on: Optional dependency or list of dependencies.
Returns:
Decorator function that registers the handler
Example:
>>> from crewai.events import crewai_event_bus, Depends
>>> from crewai.events.types.llm_events import LLMCallStartedEvent
>>>
>>> @crewai_event_bus.on(LLMCallStartedEvent)
>>> def setup_context(source, event):
... print("Setting up context")
>>>
>>> @crewai_event_bus.on(LLMCallStartedEvent, depends_on=Depends(setup_context))
>>> def process(source, event):
... print("Processing (runs after setup_context)")
"""
def decorator(handler: Callable[P, R]) -> Callable[P, R]:
@@ -248,6 +255,42 @@ class CrewAIEventsBus:
return decorator
def set_runtime_state(self, state: RuntimeState) -> None:
"""Set the RuntimeState that will be passed to event handlers."""
with self._instance_lock:
self._runtime_state = state
self._registered_entity_ids = {id(e) for e in state.root}
def register_entity(self, entity: Any) -> None:
"""Add an entity to the RuntimeState, creating it if needed.
Agents that belong to an already-registered Crew are tracked
but not appended to root, since they are serialized as part
of the Crew's agents list.
"""
eid = id(entity)
if eid in self._registered_entity_ids:
return
with self._instance_lock:
if eid in self._registered_entity_ids:
return
self._registered_entity_ids.add(eid)
if getattr(entity, "entity_type", None) == "agent":
crew = getattr(entity, "crew", None)
if crew is not None and id(crew) in self._registered_entity_ids:
return
if self._runtime_state is None:
from crewai import RuntimeState
if RuntimeState is None:
logger.warning(
"RuntimeState unavailable; skipping entity registration."
)
return
self._runtime_state = RuntimeState(root=[entity])
else:
self._runtime_state.root.append(entity)
def off(
self,
event_type: type[BaseEvent],
@@ -294,10 +337,12 @@ class CrewAIEventsBus:
event: The event instance
handlers: Frozenset of sync handlers to call
"""
state = self._runtime_state
errors: list[tuple[SyncHandler, Exception]] = [
(handler, error)
for handler in handlers
if (error := is_call_handler_safe(handler, source, event)) is not None
if (error := is_call_handler_safe(handler, source, event, state))
is not None
]
if errors:
@@ -319,7 +364,14 @@ class CrewAIEventsBus:
event: The event instance
handlers: Frozenset of async handlers to call
"""
coros = [handler(source, event) for handler in handlers]
state = self._runtime_state
async def _call(handler: AsyncHandler) -> Any:
if _get_param_count(handler) >= 3:
return await handler(source, event, state) # type: ignore[call-arg]
return await handler(source, event) # type: ignore[call-arg]
coros = [_call(handler) for handler in handlers]
results = await asyncio.gather(*coros, return_exceptions=True)
for handler, result in zip(handlers, results, strict=False):
if isinstance(result, Exception):
@@ -391,6 +443,53 @@ class CrewAIEventsBus:
if level_async:
await self._acall_handlers(source, event, level_async)
def _register_source(self, source: Any) -> None:
"""Register the source entity in RuntimeState if applicable."""
if (
getattr(source, "entity_type", None) in ("flow", "crew", "agent")
and id(source) not in self._registered_entity_ids
):
self.register_entity(source)
def _record_event(self, event: BaseEvent) -> None:
"""Add an event to the RuntimeState event record."""
if self._runtime_state is not None:
self._runtime_state.event_record.add(event)
def _prepare_event(self, source: Any, event: BaseEvent) -> None:
"""Register source, set scope/sequence metadata, and record the event.
This method mutates ContextVar state (scope stack, last_event_id)
and must only be called from synchronous emit paths.
"""
self._register_source(source)
event.previous_event_id = get_last_event_id()
event.triggered_by_event_id = get_triggering_event_id()
event.emission_sequence = get_next_emission_sequence()
if event.parent_event_id is None:
event_type_name = event.type
if event_type_name in SCOPE_ENDING_EVENTS:
event.parent_event_id = get_enclosing_parent_id()
popped = pop_event_scope()
if popped is None:
handle_empty_pop(event_type_name)
else:
popped_event_id, popped_type = popped
event.started_event_id = popped_event_id
expected_start = VALID_EVENT_PAIRS.get(event_type_name)
if expected_start and popped_type and popped_type != expected_start:
handle_mismatch(event_type_name, popped_type, expected_start)
elif event_type_name in SCOPE_STARTING_EVENTS:
event.parent_event_id = get_current_parent_id()
push_event_scope(event.event_id, event_type_name)
else:
event.parent_event_id = get_current_parent_id()
set_last_event_id(event.event_id)
self._record_event(event)
def emit(self, source: Any, event: BaseEvent) -> Future[None] | None:
"""Emit an event to all registered handlers.
@@ -417,29 +516,8 @@ class CrewAIEventsBus:
... await asyncio.wrap_future(future) # In async test
... # or future.result(timeout=5.0) in sync code
"""
event.previous_event_id = get_last_event_id()
event.triggered_by_event_id = get_triggering_event_id()
event.emission_sequence = get_next_emission_sequence()
if event.parent_event_id is None:
event_type_name = event.type
if event_type_name in SCOPE_ENDING_EVENTS:
event.parent_event_id = get_enclosing_parent_id()
popped = pop_event_scope()
if popped is None:
handle_empty_pop(event_type_name)
else:
popped_event_id, popped_type = popped
event.started_event_id = popped_event_id
expected_start = VALID_EVENT_PAIRS.get(event_type_name)
if expected_start and popped_type and popped_type != expected_start:
handle_mismatch(event_type_name, popped_type, expected_start)
elif event_type_name in SCOPE_STARTING_EVENTS:
event.parent_event_id = get_current_parent_id()
push_event_scope(event.event_id, event_type_name)
else:
event.parent_event_id = get_current_parent_id()
self._prepare_event(source, event)
set_last_event_id(event.event_id)
event_type = type(event)
with self._rwlock.r_locked():
@@ -538,6 +616,10 @@ class CrewAIEventsBus:
source: The object emitting the event
event: The event instance to emit
"""
self._register_source(source)
event.emission_sequence = get_next_emission_sequence()
self._record_event(event)
event_type = type(event)
with self._rwlock.r_locked():

View File

@@ -133,6 +133,11 @@ def triggered_by_scope(event_id: str) -> Generator[None, None, None]:
_triggering_event_id.set(previous)
def restore_event_scope(stack: tuple[tuple[str, str], ...]) -> None:
"""Restore the event scope stack from a checkpoint."""
_event_id_stack.set(stack)
def push_event_scope(event_id: str, event_type: str = "") -> None:
"""Push an event ID and type onto the scope stack."""
config = _event_context_config.get() or _default_config

View File

@@ -73,7 +73,7 @@ class A2ADelegationStartedEvent(A2AEventBase):
extensions: List of A2A extension URIs in use.
"""
type: str = "a2a_delegation_started"
type: Literal["a2a_delegation_started"] = "a2a_delegation_started"
endpoint: str
task_description: str
agent_id: str
@@ -106,7 +106,7 @@ class A2ADelegationCompletedEvent(A2AEventBase):
extensions: List of A2A extension URIs in use.
"""
type: str = "a2a_delegation_completed"
type: Literal["a2a_delegation_completed"] = "a2a_delegation_completed"
status: str
result: str | None = None
error: str | None = None
@@ -140,7 +140,7 @@ class A2AConversationStartedEvent(A2AEventBase):
extensions: List of A2A extension URIs in use.
"""
type: str = "a2a_conversation_started"
type: Literal["a2a_conversation_started"] = "a2a_conversation_started"
agent_id: str
endpoint: str
context_id: str | None = None
@@ -171,7 +171,7 @@ class A2AMessageSentEvent(A2AEventBase):
extensions: List of A2A extension URIs in use.
"""
type: str = "a2a_message_sent"
type: Literal["a2a_message_sent"] = "a2a_message_sent"
message: str
turn_number: int
context_id: str | None = None
@@ -203,7 +203,7 @@ class A2AResponseReceivedEvent(A2AEventBase):
extensions: List of A2A extension URIs in use.
"""
type: str = "a2a_response_received"
type: Literal["a2a_response_received"] = "a2a_response_received"
response: str
turn_number: int
context_id: str | None = None
@@ -237,7 +237,7 @@ class A2AConversationCompletedEvent(A2AEventBase):
extensions: List of A2A extension URIs in use.
"""
type: str = "a2a_conversation_completed"
type: Literal["a2a_conversation_completed"] = "a2a_conversation_completed"
status: Literal["completed", "failed"]
final_result: str | None = None
error: str | None = None
@@ -263,7 +263,7 @@ class A2APollingStartedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_polling_started"
type: Literal["a2a_polling_started"] = "a2a_polling_started"
task_id: str
context_id: str | None = None
polling_interval: float
@@ -286,7 +286,7 @@ class A2APollingStatusEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_polling_status"
type: Literal["a2a_polling_status"] = "a2a_polling_status"
task_id: str
context_id: str | None = None
state: str
@@ -309,7 +309,9 @@ class A2APushNotificationRegisteredEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_push_notification_registered"
type: Literal["a2a_push_notification_registered"] = (
"a2a_push_notification_registered"
)
task_id: str
context_id: str | None = None
callback_url: str
@@ -334,7 +336,7 @@ class A2APushNotificationReceivedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_push_notification_received"
type: Literal["a2a_push_notification_received"] = "a2a_push_notification_received"
task_id: str
context_id: str | None = None
state: str
@@ -359,7 +361,7 @@ class A2APushNotificationSentEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_push_notification_sent"
type: Literal["a2a_push_notification_sent"] = "a2a_push_notification_sent"
task_id: str
context_id: str | None = None
callback_url: str
@@ -381,7 +383,7 @@ class A2APushNotificationTimeoutEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_push_notification_timeout"
type: Literal["a2a_push_notification_timeout"] = "a2a_push_notification_timeout"
task_id: str
context_id: str | None = None
timeout_seconds: float
@@ -405,7 +407,7 @@ class A2AStreamingStartedEvent(A2AEventBase):
extensions: List of A2A extension URIs in use.
"""
type: str = "a2a_streaming_started"
type: Literal["a2a_streaming_started"] = "a2a_streaming_started"
task_id: str | None = None
context_id: str | None = None
endpoint: str
@@ -434,7 +436,7 @@ class A2AStreamingChunkEvent(A2AEventBase):
extensions: List of A2A extension URIs in use.
"""
type: str = "a2a_streaming_chunk"
type: Literal["a2a_streaming_chunk"] = "a2a_streaming_chunk"
task_id: str | None = None
context_id: str | None = None
chunk: str
@@ -462,7 +464,7 @@ class A2AAgentCardFetchedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_agent_card_fetched"
type: Literal["a2a_agent_card_fetched"] = "a2a_agent_card_fetched"
endpoint: str
a2a_agent_name: str | None = None
agent_card: dict[str, Any] | None = None
@@ -486,7 +488,7 @@ class A2AAuthenticationFailedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_authentication_failed"
type: Literal["a2a_authentication_failed"] = "a2a_authentication_failed"
endpoint: str
auth_type: str | None = None
error: str
@@ -517,7 +519,7 @@ class A2AArtifactReceivedEvent(A2AEventBase):
extensions: List of A2A extension URIs in use.
"""
type: str = "a2a_artifact_received"
type: Literal["a2a_artifact_received"] = "a2a_artifact_received"
task_id: str
artifact_id: str
artifact_name: str | None = None
@@ -550,7 +552,7 @@ class A2AConnectionErrorEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_connection_error"
type: Literal["a2a_connection_error"] = "a2a_connection_error"
endpoint: str
error: str
error_type: str | None = None
@@ -571,7 +573,7 @@ class A2AServerTaskStartedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_server_task_started"
type: Literal["a2a_server_task_started"] = "a2a_server_task_started"
task_id: str
context_id: str
metadata: dict[str, Any] | None = None
@@ -587,7 +589,7 @@ class A2AServerTaskCompletedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_server_task_completed"
type: Literal["a2a_server_task_completed"] = "a2a_server_task_completed"
task_id: str
context_id: str
result: str
@@ -603,7 +605,7 @@ class A2AServerTaskCanceledEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_server_task_canceled"
type: Literal["a2a_server_task_canceled"] = "a2a_server_task_canceled"
task_id: str
context_id: str
metadata: dict[str, Any] | None = None
@@ -619,7 +621,7 @@ class A2AServerTaskFailedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_server_task_failed"
type: Literal["a2a_server_task_failed"] = "a2a_server_task_failed"
task_id: str
context_id: str
error: str
@@ -634,7 +636,7 @@ class A2AParallelDelegationStartedEvent(A2AEventBase):
task_description: Description of the task being delegated.
"""
type: str = "a2a_parallel_delegation_started"
type: Literal["a2a_parallel_delegation_started"] = "a2a_parallel_delegation_started"
endpoints: list[str]
task_description: str
@@ -649,7 +651,9 @@ class A2AParallelDelegationCompletedEvent(A2AEventBase):
results: Summary of results from each agent.
"""
type: str = "a2a_parallel_delegation_completed"
type: Literal["a2a_parallel_delegation_completed"] = (
"a2a_parallel_delegation_completed"
)
endpoints: list[str]
success_count: int
failure_count: int
@@ -675,7 +679,7 @@ class A2ATransportNegotiatedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_transport_negotiated"
type: Literal["a2a_transport_negotiated"] = "a2a_transport_negotiated"
endpoint: str
a2a_agent_name: str | None = None
negotiated_transport: str
@@ -708,7 +712,7 @@ class A2AContentTypeNegotiatedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_content_type_negotiated"
type: Literal["a2a_content_type_negotiated"] = "a2a_content_type_negotiated"
endpoint: str
a2a_agent_name: str | None = None
skill_name: str | None = None
@@ -738,7 +742,7 @@ class A2AContextCreatedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_context_created"
type: Literal["a2a_context_created"] = "a2a_context_created"
context_id: str
created_at: float
metadata: dict[str, Any] | None = None
@@ -755,7 +759,7 @@ class A2AContextExpiredEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_context_expired"
type: Literal["a2a_context_expired"] = "a2a_context_expired"
context_id: str
created_at: float
age_seconds: float
@@ -775,7 +779,7 @@ class A2AContextIdleEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_context_idle"
type: Literal["a2a_context_idle"] = "a2a_context_idle"
context_id: str
idle_seconds: float
task_count: int
@@ -792,7 +796,7 @@ class A2AContextCompletedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_context_completed"
type: Literal["a2a_context_completed"] = "a2a_context_completed"
context_id: str
total_tasks: int
duration_seconds: float
@@ -811,7 +815,7 @@ class A2AContextPrunedEvent(A2AEventBase):
metadata: Custom A2A metadata key-value pairs.
"""
type: str = "a2a_context_pruned"
type: Literal["a2a_context_pruned"] = "a2a_context_pruned"
context_id: str
task_count: int
age_seconds: float

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from typing import Any, Literal
from pydantic import ConfigDict, model_validator
from typing_extensions import Self
@@ -21,7 +21,7 @@ class AgentExecutionStartedEvent(BaseEvent):
task: Any
tools: Sequence[BaseTool | CrewStructuredTool] | None
task_prompt: str
type: str = "agent_execution_started"
type: Literal["agent_execution_started"] = "agent_execution_started"
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -38,7 +38,7 @@ class AgentExecutionCompletedEvent(BaseEvent):
agent: BaseAgent
task: Any
output: str
type: str = "agent_execution_completed"
type: Literal["agent_execution_completed"] = "agent_execution_completed"
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -55,7 +55,7 @@ class AgentExecutionErrorEvent(BaseEvent):
agent: BaseAgent
task: Any
error: str
type: str = "agent_execution_error"
type: Literal["agent_execution_error"] = "agent_execution_error"
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -73,7 +73,7 @@ class LiteAgentExecutionStartedEvent(BaseEvent):
agent_info: dict[str, Any]
tools: Sequence[BaseTool | CrewStructuredTool] | None
messages: str | list[dict[str, str]]
type: str = "lite_agent_execution_started"
type: Literal["lite_agent_execution_started"] = "lite_agent_execution_started"
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -83,7 +83,7 @@ class LiteAgentExecutionCompletedEvent(BaseEvent):
agent_info: dict[str, Any]
output: str
type: str = "lite_agent_execution_completed"
type: Literal["lite_agent_execution_completed"] = "lite_agent_execution_completed"
class LiteAgentExecutionErrorEvent(BaseEvent):
@@ -91,7 +91,7 @@ class LiteAgentExecutionErrorEvent(BaseEvent):
agent_info: dict[str, Any]
error: str
type: str = "lite_agent_execution_error"
type: Literal["lite_agent_execution_error"] = "lite_agent_execution_error"
# Agent Eval events
@@ -100,7 +100,7 @@ class AgentEvaluationStartedEvent(BaseEvent):
agent_role: str
task_id: str | None = None
iteration: int
type: str = "agent_evaluation_started"
type: Literal["agent_evaluation_started"] = "agent_evaluation_started"
class AgentEvaluationCompletedEvent(BaseEvent):
@@ -110,7 +110,7 @@ class AgentEvaluationCompletedEvent(BaseEvent):
iteration: int
metric_category: Any
score: Any
type: str = "agent_evaluation_completed"
type: Literal["agent_evaluation_completed"] = "agent_evaluation_completed"
class AgentEvaluationFailedEvent(BaseEvent):
@@ -119,7 +119,7 @@ class AgentEvaluationFailedEvent(BaseEvent):
task_id: str | None = None
iteration: int
error: str
type: str = "agent_evaluation_failed"
type: Literal["agent_evaluation_failed"] = "agent_evaluation_failed"
def _set_agent_fingerprint(event: BaseEvent, agent: BaseAgent) -> None:

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from crewai.events.base_events import BaseEvent
@@ -37,14 +37,14 @@ class CrewKickoffStartedEvent(CrewBaseEvent):
"""Event emitted when a crew starts execution"""
inputs: dict[str, Any] | None
type: str = "crew_kickoff_started"
type: Literal["crew_kickoff_started"] = "crew_kickoff_started"
class CrewKickoffCompletedEvent(CrewBaseEvent):
"""Event emitted when a crew completes execution"""
output: Any
type: str = "crew_kickoff_completed"
type: Literal["crew_kickoff_completed"] = "crew_kickoff_completed"
total_tokens: int = 0
@@ -52,7 +52,7 @@ class CrewKickoffFailedEvent(CrewBaseEvent):
"""Event emitted when a crew fails to complete execution"""
error: str
type: str = "crew_kickoff_failed"
type: Literal["crew_kickoff_failed"] = "crew_kickoff_failed"
class CrewTrainStartedEvent(CrewBaseEvent):
@@ -61,7 +61,7 @@ class CrewTrainStartedEvent(CrewBaseEvent):
n_iterations: int
filename: str
inputs: dict[str, Any] | None
type: str = "crew_train_started"
type: Literal["crew_train_started"] = "crew_train_started"
class CrewTrainCompletedEvent(CrewBaseEvent):
@@ -69,14 +69,14 @@ class CrewTrainCompletedEvent(CrewBaseEvent):
n_iterations: int
filename: str
type: str = "crew_train_completed"
type: Literal["crew_train_completed"] = "crew_train_completed"
class CrewTrainFailedEvent(CrewBaseEvent):
"""Event emitted when a crew fails to complete training"""
error: str
type: str = "crew_train_failed"
type: Literal["crew_train_failed"] = "crew_train_failed"
class CrewTestStartedEvent(CrewBaseEvent):
@@ -85,20 +85,20 @@ class CrewTestStartedEvent(CrewBaseEvent):
n_iterations: int
eval_llm: str | Any | None
inputs: dict[str, Any] | None
type: str = "crew_test_started"
type: Literal["crew_test_started"] = "crew_test_started"
class CrewTestCompletedEvent(CrewBaseEvent):
"""Event emitted when a crew completes testing"""
type: str = "crew_test_completed"
type: Literal["crew_test_completed"] = "crew_test_completed"
class CrewTestFailedEvent(CrewBaseEvent):
"""Event emitted when a crew fails to complete testing"""
error: str
type: str = "crew_test_failed"
type: Literal["crew_test_failed"] = "crew_test_failed"
class CrewTestResultEvent(CrewBaseEvent):
@@ -107,4 +107,4 @@ class CrewTestResultEvent(CrewBaseEvent):
quality: float
execution_duration: float
model: str
type: str = "crew_test_result"
type: Literal["crew_test_result"] = "crew_test_result"

View File

@@ -6,10 +6,17 @@ from typing import Any, TypeAlias
from crewai.events.base_events import BaseEvent
SyncHandler: TypeAlias = Callable[[Any, BaseEvent], None]
AsyncHandler: TypeAlias = Callable[[Any, BaseEvent], Coroutine[Any, Any, None]]
SyncHandler: TypeAlias = (
Callable[[Any, BaseEvent], None] | Callable[[Any, BaseEvent, Any], None]
)
AsyncHandler: TypeAlias = (
Callable[[Any, BaseEvent], Coroutine[Any, Any, None]]
| Callable[[Any, BaseEvent, Any], Coroutine[Any, Any, None]]
)
SyncHandlerSet: TypeAlias = frozenset[SyncHandler]
AsyncHandlerSet: TypeAlias = frozenset[AsyncHandler]
Handler: TypeAlias = Callable[[Any, BaseEvent], Any]
Handler: TypeAlias = (
Callable[[Any, BaseEvent], Any] | Callable[[Any, BaseEvent, Any], Any]
)
ExecutionPlan: TypeAlias = list[set[Handler]]

View File

@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
@@ -17,14 +17,14 @@ class FlowStartedEvent(FlowEvent):
flow_name: str
inputs: dict[str, Any] | None = None
type: str = "flow_started"
type: Literal["flow_started"] = "flow_started"
class FlowCreatedEvent(FlowEvent):
"""Event emitted when a flow is created"""
flow_name: str
type: str = "flow_created"
type: Literal["flow_created"] = "flow_created"
class MethodExecutionStartedEvent(FlowEvent):
@@ -34,7 +34,7 @@ class MethodExecutionStartedEvent(FlowEvent):
method_name: str
state: dict[str, Any] | BaseModel
params: dict[str, Any] | None = None
type: str = "method_execution_started"
type: Literal["method_execution_started"] = "method_execution_started"
class MethodExecutionFinishedEvent(FlowEvent):
@@ -44,7 +44,7 @@ class MethodExecutionFinishedEvent(FlowEvent):
method_name: str
result: Any = None
state: dict[str, Any] | BaseModel
type: str = "method_execution_finished"
type: Literal["method_execution_finished"] = "method_execution_finished"
class MethodExecutionFailedEvent(FlowEvent):
@@ -53,7 +53,7 @@ class MethodExecutionFailedEvent(FlowEvent):
flow_name: str
method_name: str
error: Exception
type: str = "method_execution_failed"
type: Literal["method_execution_failed"] = "method_execution_failed"
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -78,7 +78,7 @@ class MethodExecutionPausedEvent(FlowEvent):
flow_id: str
message: str
emit: list[str] | None = None
type: str = "method_execution_paused"
type: Literal["method_execution_paused"] = "method_execution_paused"
class FlowFinishedEvent(FlowEvent):
@@ -86,7 +86,7 @@ class FlowFinishedEvent(FlowEvent):
flow_name: str
result: Any | None = None
type: str = "flow_finished"
type: Literal["flow_finished"] = "flow_finished"
state: dict[str, Any] | BaseModel
@@ -110,14 +110,14 @@ class FlowPausedEvent(FlowEvent):
state: dict[str, Any] | BaseModel
message: str
emit: list[str] | None = None
type: str = "flow_paused"
type: Literal["flow_paused"] = "flow_paused"
class FlowPlotEvent(FlowEvent):
"""Event emitted when a flow plot is created"""
flow_name: str
type: str = "flow_plot"
type: Literal["flow_plot"] = "flow_plot"
class FlowInputRequestedEvent(FlowEvent):
@@ -138,7 +138,7 @@ class FlowInputRequestedEvent(FlowEvent):
method_name: str
message: str
metadata: dict[str, Any] | None = None
type: str = "flow_input_requested"
type: Literal["flow_input_requested"] = "flow_input_requested"
class FlowInputReceivedEvent(FlowEvent):
@@ -163,7 +163,7 @@ class FlowInputReceivedEvent(FlowEvent):
response: str | None = None
metadata: dict[str, Any] | None = None
response_metadata: dict[str, Any] | None = None
type: str = "flow_input_received"
type: Literal["flow_input_received"] = "flow_input_received"
class HumanFeedbackRequestedEvent(FlowEvent):
@@ -187,7 +187,7 @@ class HumanFeedbackRequestedEvent(FlowEvent):
message: str
emit: list[str] | None = None
request_id: str | None = None
type: str = "human_feedback_requested"
type: Literal["human_feedback_requested"] = "human_feedback_requested"
class HumanFeedbackReceivedEvent(FlowEvent):
@@ -209,4 +209,4 @@ class HumanFeedbackReceivedEvent(FlowEvent):
feedback: str
outcome: str | None = None
request_id: str | None = None
type: str = "human_feedback_received"
type: Literal["human_feedback_received"] = "human_feedback_received"

View File

@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Literal
from crewai.events.base_events import BaseEvent
@@ -20,14 +20,16 @@ class KnowledgeEventBase(BaseEvent):
class KnowledgeRetrievalStartedEvent(KnowledgeEventBase):
"""Event emitted when a knowledge retrieval is started."""
type: str = "knowledge_search_query_started"
type: Literal["knowledge_search_query_started"] = "knowledge_search_query_started"
class KnowledgeRetrievalCompletedEvent(KnowledgeEventBase):
"""Event emitted when a knowledge retrieval is completed."""
query: str
type: str = "knowledge_search_query_completed"
type: Literal["knowledge_search_query_completed"] = (
"knowledge_search_query_completed"
)
retrieved_knowledge: str
@@ -35,13 +37,13 @@ class KnowledgeQueryStartedEvent(KnowledgeEventBase):
"""Event emitted when a knowledge query is started."""
task_prompt: str
type: str = "knowledge_query_started"
type: Literal["knowledge_query_started"] = "knowledge_query_started"
class KnowledgeQueryFailedEvent(KnowledgeEventBase):
"""Event emitted when a knowledge query fails."""
type: str = "knowledge_query_failed"
type: Literal["knowledge_query_failed"] = "knowledge_query_failed"
error: str
@@ -49,12 +51,12 @@ class KnowledgeQueryCompletedEvent(KnowledgeEventBase):
"""Event emitted when a knowledge query is completed."""
query: str
type: str = "knowledge_query_completed"
type: Literal["knowledge_query_completed"] = "knowledge_query_completed"
class KnowledgeSearchQueryFailedEvent(KnowledgeEventBase):
"""Event emitted when a knowledge search query fails."""
query: str
type: str = "knowledge_search_query_failed"
type: Literal["knowledge_search_query_failed"] = "knowledge_search_query_failed"
error: str

View File

@@ -1,5 +1,5 @@
from enum import Enum
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel
@@ -43,7 +43,7 @@ class LLMCallStartedEvent(LLMEventBase):
multimodal content (text, images, etc.)
"""
type: str = "llm_call_started"
type: Literal["llm_call_started"] = "llm_call_started"
messages: str | list[dict[str, Any]] | None = None
tools: list[dict[str, Any]] | None = None
callbacks: list[Any] | None = None
@@ -53,7 +53,7 @@ class LLMCallStartedEvent(LLMEventBase):
class LLMCallCompletedEvent(LLMEventBase):
"""Event emitted when a LLM call completes"""
type: str = "llm_call_completed"
type: Literal["llm_call_completed"] = "llm_call_completed"
messages: str | list[dict[str, Any]] | None = None
response: Any
call_type: LLMCallType
@@ -64,7 +64,7 @@ class LLMCallFailedEvent(LLMEventBase):
"""Event emitted when a LLM call fails"""
error: str
type: str = "llm_call_failed"
type: Literal["llm_call_failed"] = "llm_call_failed"
class FunctionCall(BaseModel):
@@ -82,7 +82,7 @@ class ToolCall(BaseModel):
class LLMStreamChunkEvent(LLMEventBase):
"""Event emitted when a streaming chunk is received"""
type: str = "llm_stream_chunk"
type: Literal["llm_stream_chunk"] = "llm_stream_chunk"
chunk: str
tool_call: ToolCall | None = None
call_type: LLMCallType | None = None
@@ -92,6 +92,6 @@ class LLMStreamChunkEvent(LLMEventBase):
class LLMThinkingChunkEvent(LLMEventBase):
"""Event emitted when a thinking/reasoning chunk is received from a thinking model"""
type: str = "llm_thinking_chunk"
type: Literal["llm_thinking_chunk"] = "llm_thinking_chunk"
chunk: str
response_id: str | None = None

View File

@@ -1,6 +1,6 @@
from collections.abc import Callable
from inspect import getsource
from typing import Any
from typing import Any, Literal
from crewai.events.base_events import BaseEvent
@@ -12,6 +12,8 @@ class LLMGuardrailBaseEvent(BaseEvent):
from_agent: Any | None = None
agent_role: str | None = None
agent_id: str | None = None
guardrail_type: str | None = None
guardrail_name: str | None = None
def __init__(self, **data: Any) -> None:
super().__init__(**data)
@@ -27,7 +29,7 @@ class LLMGuardrailStartedEvent(LLMGuardrailBaseEvent):
retry_count: The number of times the guardrail has been retried
"""
type: str = "llm_guardrail_started"
type: Literal["llm_guardrail_started"] = "llm_guardrail_started"
guardrail: str | Callable[..., Any]
retry_count: int
@@ -37,9 +39,17 @@ class LLMGuardrailStartedEvent(LLMGuardrailBaseEvent):
super().__init__(**data)
if isinstance(self.guardrail, (LLMGuardrail, HallucinationGuardrail)):
if isinstance(self.guardrail, HallucinationGuardrail):
self.guardrail_type = "hallucination"
self.guardrail_name = self.guardrail.description.strip()
self.guardrail = self.guardrail.description.strip()
elif isinstance(self.guardrail, LLMGuardrail):
self.guardrail_type = "llm"
self.guardrail_name = self.guardrail.description.strip()
self.guardrail = self.guardrail.description.strip()
elif callable(self.guardrail):
self.guardrail_type = "function"
self.guardrail_name = getattr(self.guardrail, "__name__", None)
self.guardrail = getsource(self.guardrail).strip()
@@ -53,21 +63,8 @@ class LLMGuardrailCompletedEvent(LLMGuardrailBaseEvent):
retry_count: The number of times the guardrail has been retried
"""
type: str = "llm_guardrail_completed"
type: Literal["llm_guardrail_completed"] = "llm_guardrail_completed"
success: bool
result: Any
error: str | None = None
retry_count: int
class LLMGuardrailFailedEvent(LLMGuardrailBaseEvent):
"""Event emitted when a guardrail task fails
Attributes:
error: The error message
retry_count: The number of times the guardrail has been retried
"""
type: str = "llm_guardrail_failed"
error: str
retry_count: int

View File

@@ -1,6 +1,6 @@
"""Agent logging events that don't reference BaseAgent to avoid circular imports."""
from typing import Any
from typing import Any, Literal
from pydantic import ConfigDict
@@ -13,7 +13,7 @@ class AgentLogsStartedEvent(BaseEvent):
agent_role: str
task_description: str | None = None
verbose: bool = False
type: str = "agent_logs_started"
type: Literal["agent_logs_started"] = "agent_logs_started"
class AgentLogsExecutionEvent(BaseEvent):
@@ -22,6 +22,6 @@ class AgentLogsExecutionEvent(BaseEvent):
agent_role: str
formatted_answer: Any
verbose: bool = False
type: str = "agent_logs_execution"
type: Literal["agent_logs_execution"] = "agent_logs_execution"
model_config = ConfigDict(arbitrary_types_allowed=True)

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Any
from typing import Any, Literal
from crewai.events.base_events import BaseEvent
@@ -24,7 +24,7 @@ class MCPEvent(BaseEvent):
class MCPConnectionStartedEvent(MCPEvent):
"""Event emitted when starting to connect to an MCP server."""
type: str = "mcp_connection_started"
type: Literal["mcp_connection_started"] = "mcp_connection_started"
connect_timeout: int | None = None
is_reconnect: bool = (
False # True if this is a reconnection, False for first connection
@@ -34,7 +34,7 @@ class MCPConnectionStartedEvent(MCPEvent):
class MCPConnectionCompletedEvent(MCPEvent):
"""Event emitted when successfully connected to an MCP server."""
type: str = "mcp_connection_completed"
type: Literal["mcp_connection_completed"] = "mcp_connection_completed"
started_at: datetime | None = None
completed_at: datetime | None = None
connection_duration_ms: float | None = None
@@ -46,7 +46,7 @@ class MCPConnectionCompletedEvent(MCPEvent):
class MCPConnectionFailedEvent(MCPEvent):
"""Event emitted when connection to an MCP server fails."""
type: str = "mcp_connection_failed"
type: Literal["mcp_connection_failed"] = "mcp_connection_failed"
error: str
error_type: str | None = None # "timeout", "authentication", "network", etc.
started_at: datetime | None = None
@@ -56,7 +56,7 @@ class MCPConnectionFailedEvent(MCPEvent):
class MCPToolExecutionStartedEvent(MCPEvent):
"""Event emitted when starting to execute an MCP tool."""
type: str = "mcp_tool_execution_started"
type: Literal["mcp_tool_execution_started"] = "mcp_tool_execution_started"
tool_name: str
tool_args: dict[str, Any] | None = None
@@ -64,7 +64,7 @@ class MCPToolExecutionStartedEvent(MCPEvent):
class MCPToolExecutionCompletedEvent(MCPEvent):
"""Event emitted when MCP tool execution completes."""
type: str = "mcp_tool_execution_completed"
type: Literal["mcp_tool_execution_completed"] = "mcp_tool_execution_completed"
tool_name: str
tool_args: dict[str, Any] | None = None
result: Any | None = None
@@ -76,7 +76,7 @@ class MCPToolExecutionCompletedEvent(MCPEvent):
class MCPToolExecutionFailedEvent(MCPEvent):
"""Event emitted when MCP tool execution fails."""
type: str = "mcp_tool_execution_failed"
type: Literal["mcp_tool_execution_failed"] = "mcp_tool_execution_failed"
tool_name: str
tool_args: dict[str, Any] | None = None
error: str
@@ -92,7 +92,7 @@ class MCPConfigFetchFailedEvent(BaseEvent):
failed, or native MCP resolution failed after config was fetched.
"""
type: str = "mcp_config_fetch_failed"
type: Literal["mcp_config_fetch_failed"] = "mcp_config_fetch_failed"
slug: str
error: str
error_type: str | None = None # "not_connected", "api_error", "connection_failed"

View File

@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Literal
from crewai.events.base_events import BaseEvent
@@ -23,7 +23,7 @@ class MemoryBaseEvent(BaseEvent):
class MemoryQueryStartedEvent(MemoryBaseEvent):
"""Event emitted when a memory query is started"""
type: str = "memory_query_started"
type: Literal["memory_query_started"] = "memory_query_started"
query: str
limit: int
score_threshold: float | None = None
@@ -32,7 +32,7 @@ class MemoryQueryStartedEvent(MemoryBaseEvent):
class MemoryQueryCompletedEvent(MemoryBaseEvent):
"""Event emitted when a memory query is completed successfully"""
type: str = "memory_query_completed"
type: Literal["memory_query_completed"] = "memory_query_completed"
query: str
results: Any
limit: int
@@ -43,7 +43,7 @@ class MemoryQueryCompletedEvent(MemoryBaseEvent):
class MemoryQueryFailedEvent(MemoryBaseEvent):
"""Event emitted when a memory query fails"""
type: str = "memory_query_failed"
type: Literal["memory_query_failed"] = "memory_query_failed"
query: str
limit: int
score_threshold: float | None = None
@@ -53,7 +53,7 @@ class MemoryQueryFailedEvent(MemoryBaseEvent):
class MemorySaveStartedEvent(MemoryBaseEvent):
"""Event emitted when a memory save operation is started"""
type: str = "memory_save_started"
type: Literal["memory_save_started"] = "memory_save_started"
value: str | None = None
metadata: dict[str, Any] | None = None
agent_role: str | None = None
@@ -62,7 +62,7 @@ class MemorySaveStartedEvent(MemoryBaseEvent):
class MemorySaveCompletedEvent(MemoryBaseEvent):
"""Event emitted when a memory save operation is completed successfully"""
type: str = "memory_save_completed"
type: Literal["memory_save_completed"] = "memory_save_completed"
value: str
metadata: dict[str, Any] | None = None
agent_role: str | None = None
@@ -72,7 +72,7 @@ class MemorySaveCompletedEvent(MemoryBaseEvent):
class MemorySaveFailedEvent(MemoryBaseEvent):
"""Event emitted when a memory save operation fails"""
type: str = "memory_save_failed"
type: Literal["memory_save_failed"] = "memory_save_failed"
value: str | None = None
metadata: dict[str, Any] | None = None
agent_role: str | None = None
@@ -82,14 +82,14 @@ class MemorySaveFailedEvent(MemoryBaseEvent):
class MemoryRetrievalStartedEvent(MemoryBaseEvent):
"""Event emitted when memory retrieval for a task prompt starts"""
type: str = "memory_retrieval_started"
type: Literal["memory_retrieval_started"] = "memory_retrieval_started"
task_id: str | None = None
class MemoryRetrievalCompletedEvent(MemoryBaseEvent):
"""Event emitted when memory retrieval for a task prompt completes successfully"""
type: str = "memory_retrieval_completed"
type: Literal["memory_retrieval_completed"] = "memory_retrieval_completed"
task_id: str | None = None
memory_content: str
retrieval_time_ms: float
@@ -98,6 +98,6 @@ class MemoryRetrievalCompletedEvent(MemoryBaseEvent):
class MemoryRetrievalFailedEvent(MemoryBaseEvent):
"""Event emitted when memory retrieval for a task prompt fails."""
type: str = "memory_retrieval_failed"
type: Literal["memory_retrieval_failed"] = "memory_retrieval_failed"
task_id: str | None = None
error: str

View File

@@ -5,7 +5,7 @@ PlannerObserver analyzes step execution results and decides on plan
continuation, refinement, or replanning.
"""
from typing import Any
from typing import Any, Literal
from crewai.events.base_events import BaseEvent
@@ -32,7 +32,7 @@ class StepObservationStartedEvent(ObservationEvent):
Fires after every step execution, before the observation LLM call.
"""
type: str = "step_observation_started"
type: Literal["step_observation_started"] = "step_observation_started"
class StepObservationCompletedEvent(ObservationEvent):
@@ -42,7 +42,7 @@ class StepObservationCompletedEvent(ObservationEvent):
the plan is still valid, and what action to take next.
"""
type: str = "step_observation_completed"
type: Literal["step_observation_completed"] = "step_observation_completed"
step_completed_successfully: bool = True
key_information_learned: str = ""
remaining_plan_still_valid: bool = True
@@ -59,7 +59,7 @@ class StepObservationFailedEvent(ObservationEvent):
but the event allows monitoring/alerting on observation failures.
"""
type: str = "step_observation_failed"
type: Literal["step_observation_failed"] = "step_observation_failed"
error: str = ""
@@ -70,7 +70,7 @@ class PlanRefinementEvent(ObservationEvent):
sharpening pending todo descriptions based on new information.
"""
type: str = "plan_refinement"
type: Literal["plan_refinement"] = "plan_refinement"
refined_step_count: int = 0
refinements: list[str] | None = None
@@ -82,7 +82,7 @@ class PlanReplanTriggeredEvent(ObservationEvent):
regenerated from scratch, preserving completed step results.
"""
type: str = "plan_replan_triggered"
type: Literal["plan_replan_triggered"] = "plan_replan_triggered"
replan_reason: str = ""
replan_count: int = 0
completed_steps_preserved: int = 0
@@ -94,6 +94,6 @@ class GoalAchievedEarlyEvent(ObservationEvent):
Remaining steps will be skipped and execution will finalize.
"""
type: str = "goal_achieved_early"
type: Literal["goal_achieved_early"] = "goal_achieved_early"
steps_remaining: int = 0
steps_completed: int = 0

View File

@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Literal
from crewai.events.base_events import BaseEvent
@@ -24,7 +24,7 @@ class ReasoningEvent(BaseEvent):
class AgentReasoningStartedEvent(ReasoningEvent):
"""Event emitted when an agent starts reasoning about a task."""
type: str = "agent_reasoning_started"
type: Literal["agent_reasoning_started"] = "agent_reasoning_started"
agent_role: str
task_id: str
@@ -32,7 +32,7 @@ class AgentReasoningStartedEvent(ReasoningEvent):
class AgentReasoningCompletedEvent(ReasoningEvent):
"""Event emitted when an agent finishes its reasoning process."""
type: str = "agent_reasoning_completed"
type: Literal["agent_reasoning_completed"] = "agent_reasoning_completed"
agent_role: str
task_id: str
plan: str
@@ -42,7 +42,7 @@ class AgentReasoningCompletedEvent(ReasoningEvent):
class AgentReasoningFailedEvent(ReasoningEvent):
"""Event emitted when the reasoning process fails."""
type: str = "agent_reasoning_failed"
type: Literal["agent_reasoning_failed"] = "agent_reasoning_failed"
agent_role: str
task_id: str
error: str

View File

@@ -6,7 +6,7 @@ Events emitted during skill discovery, loading, and activation.
from __future__ import annotations
from pathlib import Path
from typing import Any
from typing import Any, Literal
from crewai.events.base_events import BaseEvent
@@ -28,14 +28,14 @@ class SkillEvent(BaseEvent):
class SkillDiscoveryStartedEvent(SkillEvent):
"""Event emitted when skill discovery begins."""
type: str = "skill_discovery_started"
type: Literal["skill_discovery_started"] = "skill_discovery_started"
search_path: Path
class SkillDiscoveryCompletedEvent(SkillEvent):
"""Event emitted when skill discovery completes."""
type: str = "skill_discovery_completed"
type: Literal["skill_discovery_completed"] = "skill_discovery_completed"
search_path: Path
skills_found: int
skill_names: list[str]
@@ -44,19 +44,19 @@ class SkillDiscoveryCompletedEvent(SkillEvent):
class SkillLoadedEvent(SkillEvent):
"""Event emitted when a skill is loaded at metadata level."""
type: str = "skill_loaded"
type: Literal["skill_loaded"] = "skill_loaded"
disclosure_level: int = 1
class SkillActivatedEvent(SkillEvent):
"""Event emitted when a skill is activated (promoted to instructions level)."""
type: str = "skill_activated"
type: Literal["skill_activated"] = "skill_activated"
disclosure_level: int = 2
class SkillLoadFailedEvent(SkillEvent):
"""Event emitted when skill loading fails."""
type: str = "skill_load_failed"
type: Literal["skill_load_failed"] = "skill_load_failed"
error: str

View File

@@ -1,12 +1,20 @@
from typing import Any
from typing import Any, Literal
from crewai.events.base_events import BaseEvent
from crewai.tasks.task_output import TaskOutput
def _set_task_fingerprint(event: BaseEvent, task: Any) -> None:
"""Set fingerprint data on an event from a task object."""
if task is not None and task.fingerprint:
"""Set task identity and fingerprint data on an event."""
if task is None:
return
task_id = getattr(task, "id", None)
if task_id is not None:
event.task_id = str(task_id)
task_name = getattr(task, "name", None) or getattr(task, "description", None)
if task_name:
event.task_name = task_name
if task.fingerprint:
event.source_fingerprint = task.fingerprint.uuid_str
event.source_type = "task"
if task.fingerprint.metadata:
@@ -16,7 +24,7 @@ def _set_task_fingerprint(event: BaseEvent, task: Any) -> None:
class TaskStartedEvent(BaseEvent):
"""Event emitted when a task starts"""
type: str = "task_started"
type: Literal["task_started"] = "task_started"
context: str | None
task: Any | None = None
@@ -29,7 +37,7 @@ class TaskCompletedEvent(BaseEvent):
"""Event emitted when a task completes"""
output: TaskOutput
type: str = "task_completed"
type: Literal["task_completed"] = "task_completed"
task: Any | None = None
def __init__(self, **data: Any) -> None:
@@ -41,7 +49,7 @@ class TaskFailedEvent(BaseEvent):
"""Event emitted when a task fails"""
error: str
type: str = "task_failed"
type: Literal["task_failed"] = "task_failed"
task: Any | None = None
def __init__(self, **data: Any) -> None:
@@ -52,7 +60,7 @@ class TaskFailedEvent(BaseEvent):
class TaskEvaluationEvent(BaseEvent):
"""Event emitted when a task evaluation is completed"""
type: str = "task_evaluation"
type: Literal["task_evaluation"] = "task_evaluation"
evaluation_type: str
task: Any | None = None

View File

@@ -1,6 +1,6 @@
from collections.abc import Callable
from datetime import datetime
from typing import Any
from typing import Any, Literal
from pydantic import ConfigDict
@@ -55,7 +55,7 @@ class ToolUsageEvent(BaseEvent):
class ToolUsageStartedEvent(ToolUsageEvent):
"""Event emitted when a tool execution is started"""
type: str = "tool_usage_started"
type: Literal["tool_usage_started"] = "tool_usage_started"
class ToolUsageFinishedEvent(ToolUsageEvent):
@@ -65,35 +65,35 @@ class ToolUsageFinishedEvent(ToolUsageEvent):
finished_at: datetime
from_cache: bool = False
output: Any
type: str = "tool_usage_finished"
type: Literal["tool_usage_finished"] = "tool_usage_finished"
class ToolUsageErrorEvent(ToolUsageEvent):
"""Event emitted when a tool execution encounters an error"""
error: Any
type: str = "tool_usage_error"
type: Literal["tool_usage_error"] = "tool_usage_error"
class ToolValidateInputErrorEvent(ToolUsageEvent):
"""Event emitted when a tool input validation encounters an error"""
error: Any
type: str = "tool_validate_input_error"
type: Literal["tool_validate_input_error"] = "tool_validate_input_error"
class ToolSelectionErrorEvent(ToolUsageEvent):
"""Event emitted when a tool selection encounters an error"""
error: Any
type: str = "tool_selection_error"
type: Literal["tool_selection_error"] = "tool_selection_error"
class ToolExecutionErrorEvent(BaseEvent):
"""Event emitted when a tool execution encounters an error"""
error: Any
type: str = "tool_execution_error"
type: Literal["tool_execution_error"] = "tool_execution_error"
tool_name: str
tool_args: dict[str, Any]
tool_class: Callable[..., Any]

View File

@@ -10,6 +10,23 @@ from crewai.events.base_events import BaseEvent
from crewai.events.types.event_bus_types import AsyncHandler, SyncHandler
@functools.lru_cache(maxsize=256)
def _get_param_count_cached(handler: Any) -> int:
return len(inspect.signature(handler).parameters)
def _get_param_count(handler: Any) -> int:
"""Return the number of parameters a handler accepts, with caching.
Falls back to uncached introspection for unhashable handlers
like functools.partial.
"""
try:
return _get_param_count_cached(handler)
except TypeError:
return len(inspect.signature(handler).parameters)
def is_async_handler(
handler: Any,
) -> TypeIs[AsyncHandler]:
@@ -41,6 +58,7 @@ def is_call_handler_safe(
handler: SyncHandler,
source: Any,
event: BaseEvent,
state: Any = None,
) -> Exception | None:
"""Safely call a single handler and return any exception.
@@ -48,12 +66,16 @@ def is_call_handler_safe(
handler: The handler function to call
source: The object that emitted the event
event: The event instance
state: Optional RuntimeState passed as third arg if handler accepts it
Returns:
Exception if handler raised one, None otherwise
"""
try:
handler(source, event)
if _get_param_count(handler) >= 3:
handler(source, event, state) # type: ignore[call-arg]
else:
handler(source, event) # type: ignore[call-arg]
return None
except Exception as e:
return e

View File

@@ -1,3 +1,4 @@
# mypy: disable-error-code="union-attr,arg-type"
from __future__ import annotations
import asyncio
@@ -21,7 +22,7 @@ from rich.console import Console
from rich.text import Text
from typing_extensions import Self
from crewai.agents.agent_builder.base_agent_executor_mixin import CrewAgentExecutorMixin
from crewai.agents.agent_builder.base_agent_executor import BaseAgentExecutor
from crewai.agents.parser import (
AgentAction,
AgentFinish,
@@ -97,7 +98,7 @@ from crewai.utilities.planning_types import (
TodoItem,
TodoList,
)
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
from crewai.utilities.step_execution_context import StepExecutionContext, StepResult
from crewai.utilities.string_utils import sanitize_tool_name
from crewai.utilities.tool_utils import execute_tool_and_check_finality
@@ -106,11 +107,8 @@ from crewai.utilities.types import LLMMessage
if TYPE_CHECKING:
from crewai.agent import Agent
from crewai.agents.tools_handler import ToolsHandler
from crewai.crew import Crew
from crewai.llms.base_llm import BaseLLM
from crewai.task import Task
from crewai.tools.tool_types import ToolResult
from crewai.utilities.prompts import StandardPromptResult, SystemPromptResult
@@ -155,7 +153,7 @@ class AgentExecutorState(BaseModel):
)
class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): # type: ignore[pydantic-unexpected]
"""Agent Executor for both standalone agents and crew-bound agents.
_skip_auto_memory prevents Flow from eagerly allocating a Memory
@@ -163,7 +161,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
Inherits from:
- Flow[AgentExecutorState]: Provides flow orchestration capabilities
- CrewAgentExecutorMixin: Provides memory methods (short/long/external term)
- BaseAgentExecutor: Provides memory methods (short/long/external term)
This executor can operate in two modes:
- Standalone mode: When crew and task are None (used by Agent.kickoff())
@@ -172,9 +170,9 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
_skip_auto_memory: bool = True
executor_type: Literal["experimental"] = "experimental"
suppress_flow_events: bool = True # always suppress for executor
llm: BaseLLM = Field(exclude=True)
agent: Agent = Field(exclude=True)
prompt: SystemPromptResult | StandardPromptResult = Field(exclude=True)
max_iter: int = Field(default=25, exclude=True)
tools: list[CrewStructuredTool] = Field(default_factory=list, exclude=True)
@@ -182,8 +180,6 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
stop_words: list[str] = Field(default_factory=list, exclude=True)
tools_description: str = Field(default="", exclude=True)
tools_handler: ToolsHandler | None = Field(default=None, exclude=True)
task: Task | None = Field(default=None, exclude=True)
crew: Crew | None = Field(default=None, exclude=True)
step_callback: Any = Field(default=None, exclude=True)
original_tools: list[BaseTool] = Field(default_factory=list, exclude=True)
function_calling_llm: BaseLLM | None = Field(default=None, exclude=True)
@@ -203,7 +199,6 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
)
_i18n: I18N = PrivateAttr(default_factory=get_i18n)
_printer: Printer = PrivateAttr(default_factory=Printer)
_console: Console = PrivateAttr(default_factory=Console)
_last_parser_error: OutputParserError | None = PrivateAttr(default=None)
_last_context_error: Exception | None = PrivateAttr(default=None)
@@ -268,17 +263,17 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
"""Get thread-safe state proxy."""
return StateProxy(self._state, self._state_lock) # type: ignore[return-value]
@property
@property # type: ignore[misc]
def iterations(self) -> int:
"""Compatibility property for mixin - returns state iterations."""
return self._state.iterations # type: ignore[no-any-return]
return int(self._state.iterations)
@iterations.setter
def iterations(self, value: int) -> None:
"""Set state iterations."""
self._state.iterations = value
@property
@property # type: ignore[misc]
def messages(self) -> list[LLMMessage]:
"""Compatibility property - returns state messages."""
return self._state.messages # type: ignore[no-any-return]
@@ -395,28 +390,28 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
"""
config = self.agent.planning_config
if config is not None:
return config.reasoning_effort
return str(config.reasoning_effort)
return "medium"
def _get_max_replans(self) -> int:
"""Get max replans from planning config or default to 3."""
config = self.agent.planning_config
if config is not None:
return config.max_replans
return int(config.max_replans)
return 3
def _get_max_step_iterations(self) -> int:
"""Get max step iterations from planning config or default to 15."""
config = self.agent.planning_config
if config is not None:
return config.max_step_iterations
return int(config.max_step_iterations)
return 15
def _get_step_timeout(self) -> int | None:
"""Get per-step timeout from planning config or default to None."""
config = self.agent.planning_config
if config is not None:
return config.step_timeout
return int(config.step_timeout) if config.step_timeout is not None else None
return None
def _build_context_for_todo(self, todo: TodoItem) -> StepExecutionContext:
@@ -507,7 +502,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=(
f"[Observe] Step {current_todo.step_number} "
f"(effort={effort}): "
@@ -557,7 +552,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
current_todo.step_number, result=current_todo.result
)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=(
f"[Low] Step {current_todo.step_number} hard-failed "
f"— triggering replan: {observation.replan_reason}"
@@ -576,7 +571,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
if self.agent.verbose:
completed = self.state.todos.completed_count
total = len(self.state.todos.items)
self._printer.print(
PRINTER.print(
content=f"[Low] Step {current_todo.step_number} done ({completed}/{total}) — continuing",
color="green",
)
@@ -609,7 +604,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
if self.agent.verbose:
completed = self.state.todos.completed_count
total = len(self.state.todos.items)
self._printer.print(
PRINTER.print(
content=f"[Medium] Step {current_todo.step_number} succeeded ({completed}/{total}) — continuing",
color="green",
)
@@ -622,7 +617,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
current_todo.step_number, result=current_todo.result
)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=(
f"[Medium] Step {current_todo.step_number} failed + replan required "
f"— triggering replan: {observation.replan_reason}"
@@ -642,7 +637,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
if self.agent.verbose:
failed = len(self.state.todos.get_failed_todos())
total = len(self.state.todos.items)
self._printer.print(
PRINTER.print(
content=(
f"[Medium] Step {current_todo.step_number} failed but no replan needed "
f"({failed} failed/{total} total) — continuing"
@@ -684,7 +679,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
current_todo.step_number, result=current_todo.result
)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content="[Decide] Goal achieved early — finalizing",
color="green",
)
@@ -696,7 +691,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
current_todo.step_number, result=current_todo.result
)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"[Decide] Full replan needed: {observation.replan_reason}",
color="yellow",
)
@@ -709,7 +704,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
current_todo.step_number, result=current_todo.result
)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content="[Decide] Step failed — triggering replan",
color="yellow",
)
@@ -722,7 +717,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
current_todo.step_number, result=current_todo.result
)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content="[Decide] Plan valid but refining upcoming steps",
color="cyan",
)
@@ -735,7 +730,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
if self.agent.verbose:
completed = self.state.todos.completed_count
total = len(self.state.todos.items)
self._printer.print(
PRINTER.print(
content=f"[Decide] Continue plan ({completed}/{total} done)",
color="green",
)
@@ -780,7 +775,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"[Refine] Updated {len(remaining)} pending step(s)",
color="cyan",
)
@@ -815,7 +810,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content="Goal achieved early — skipping remaining steps",
color="green",
)
@@ -833,7 +828,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
if self.state.replan_count >= max_replans:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Max replans ({max_replans}) reached — finalizing with current results",
color="yellow",
)
@@ -940,7 +935,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
# Plan-and-Execute path: use StepExecutor for isolated execution
if getattr(self.agent, "planning_enabled", False):
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=(
f"[Execute] Step {current.step_number}: "
f"{current.description[:60]}..."
@@ -975,7 +970,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
if self.agent.verbose:
status = "success" if result.success else "failed"
self._printer.print(
PRINTER.print(
content=(
f"[Execute] Step {current.step_number} {status} "
f"({result.execution_time:.1f}s, "
@@ -1084,7 +1079,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
todo.result = error_msg
self.state.todos.mark_failed(todo.step_number, result=error_msg)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Todo {todo.step_number} failed: {error_msg}",
color="red",
)
@@ -1109,7 +1104,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
if self.agent.verbose:
status = "success" if step_result.success else "failed"
self._printer.print(
PRINTER.print(
content=(
f"[Execute] Step {todo.step_number} {status} "
f"({step_result.execution_time:.1f}s, "
@@ -1156,7 +1151,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
self.state.todos.mark_failed(todo.step_number, result=todo.result)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=(
f"[Observe] Step {todo.step_number} "
f"(effort={effort}): "
@@ -1207,7 +1202,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
"""Force agent to provide final answer when max iterations exceeded."""
formatted_answer = handle_max_iterations_exceeded(
formatted_answer=None,
printer=self._printer,
printer=PRINTER,
i18n=self._i18n,
messages=list(self.state.messages),
llm=self.llm,
@@ -1236,7 +1231,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
llm=self.llm,
messages=list(self.state.messages),
callbacks=self.callbacks,
printer=self._printer,
printer=PRINTER,
from_task=self.task,
from_agent=self.agent,
response_model=self.response_model,
@@ -1286,7 +1281,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
return "context_error"
if e.__class__.__module__.startswith("litellm"):
raise e
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
@router("continue_reasoning_native")
@@ -1322,7 +1317,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
llm=self.llm,
messages=list(self.state.messages),
callbacks=self.callbacks,
printer=self._printer,
printer=PRINTER,
tools=self._openai_tools,
available_functions=None,
from_task=self.task,
@@ -1377,7 +1372,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
return "context_error"
if e.__class__.__module__.startswith("litellm"):
raise e
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
def _route_finish_with_todos(
@@ -1446,9 +1441,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
)
except Exception as e:
if self.agent and self.agent.verbose:
self._printer.print(
content=f"Error in tool execution: {e}", color="red"
)
PRINTER.print(content=f"Error in tool execution: {e}", color="red")
if self.task:
self.task.increment_tools_errors()
@@ -1602,7 +1595,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
# Log the tool execution
if self.agent and self.agent.verbose:
cache_info = " (from cache)" if from_cache else ""
self._printer.print(
PRINTER.print(
content=f"Tool {func_name} executed with result{cache_info}: {result[:200]}...",
color="green",
)
@@ -1640,7 +1633,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
# Log the tool execution
if self.agent and self.agent.verbose:
cache_info = " (from cache)" if from_cache else ""
self._printer.print(
PRINTER.print(
content=f"Tool {func_name} executed with result{cache_info}: {result[:200]}...",
color="green",
)
@@ -1790,7 +1783,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
before_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
tool=structured_tool, # type: ignore[arg-type]
tool=structured_tool,
agent=self.agent,
task=self.task,
crew=self.crew,
@@ -1804,7 +1797,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
break
except Exception as hook_error:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Error in before_tool_call hook: {hook_error}",
color="red",
)
@@ -1864,7 +1857,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
after_hook_context = ToolCallHookContext(
tool_name=func_name,
tool_input=args_dict,
tool=structured_tool, # type: ignore[arg-type]
tool=structured_tool,
agent=self.agent,
task=self.task,
crew=self.crew,
@@ -1879,7 +1872,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
after_hook_context.tool_result = result
except Exception as hook_error:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Error in after_tool_call hook: {hook_error}",
color="red",
)
@@ -2037,7 +2030,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
if self.agent.verbose:
completed = self.state.todos.completed_count
total = len(self.state.todos.items)
self._printer.print(
PRINTER.print(
content=f"✓ Todo {step_number} completed ({completed}/{total})",
color="green",
)
@@ -2104,7 +2097,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
self._finalize_called = True
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"[Finalize] todos_count={len(self.state.todos.items)}, todos_with_results={sum(1 for t in self.state.todos.items if t.result)}",
color="magenta",
)
@@ -2267,7 +2260,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
except Exception as e:
if self.agent and self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Synthesis LLM call failed ({e}), falling back to concatenation",
color="yellow",
)
@@ -2352,7 +2345,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
self.state.last_replan_reason = reason
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Triggering replan (attempt {self.state.replan_count}): {reason}",
color="yellow",
)
@@ -2412,7 +2405,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
self.state.todos.replace_pending_todos(new_todos)
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Replan: {len(new_todos)} new steps (completed history preserved)",
color="green",
)
@@ -2496,7 +2489,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
if self.state.replan_count >= max_replans:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Max replans ({max_replans}) reached — finalizing with current results",
color="yellow",
)
@@ -2522,7 +2515,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
messages=list(self.state.messages),
iterations=self.state.iterations,
log_error_after=self.log_error_after,
printer=self._printer,
printer=PRINTER,
verbose=self.agent.verbose,
)
@@ -2538,7 +2531,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
"""Recover from context length errors and retry."""
handle_context_length(
respect_context_window=self.respect_context_window,
printer=self._printer,
printer=PRINTER,
messages=self.state.messages,
llm=self.llm,
callbacks=self.callbacks,
@@ -2641,7 +2634,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
self._console.print(fail_text)
raise
except Exception as e:
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
finally:
self._is_executing = False
@@ -2732,7 +2725,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
self._console.print(fail_text)
raise
except Exception as e:
handle_unknown_error(self._printer, e, verbose=self.agent.verbose)
handle_unknown_error(PRINTER, e, verbose=self.agent.verbose)
raise
finally:
self._is_executing = False
@@ -2797,7 +2790,7 @@ class AgentExecutor(Flow[AgentExecutorState], CrewAgentExecutorMixin):
task.result()
except Exception as e:
if self.agent.verbose:
self._printer.print(
PRINTER.print(
content=f"Error in async step_callback task: {e!s}",
color="red",
)

View File

@@ -1,133 +0,0 @@
from typing import Any, Literal, TypedDict
from typing_extensions import NotRequired
DarkGray = Literal["#333333"]
CrewAIOrange = Literal["#FF5A50"]
Gray = Literal["#666666"]
White = Literal["#FFFFFF"]
Black = Literal["#000000"]
DARK_GRAY: Literal["#333333"] = "#333333"
CREWAI_ORANGE: Literal["#FF5A50"] = "#FF5A50"
GRAY: Literal["#666666"] = "#666666"
WHITE: Literal["#FFFFFF"] = "#FFFFFF"
BLACK: Literal["#000000"] = "#000000"
class FlowColors(TypedDict):
bg: White
start: CrewAIOrange
method: DarkGray
router: DarkGray
router_border: CrewAIOrange
edge: Gray
router_edge: CrewAIOrange
text: White
class FontStyles(TypedDict, total=False):
color: DarkGray | CrewAIOrange | Gray | White | Black
multi: Literal["html"]
class StartNodeStyle(TypedDict):
color: CrewAIOrange
shape: Literal["box"]
font: FontStyles
label: NotRequired[str]
margin: dict[str, int]
class MethodNodeStyle(TypedDict):
color: DarkGray
shape: Literal["box"]
font: FontStyles
label: NotRequired[str]
margin: dict[str, int]
class RouterNodeStyle(TypedDict):
color: dict[str, Any]
shape: Literal["box"]
font: FontStyles
label: NotRequired[str]
borderWidth: int
borderWidthSelected: int
shapeProperties: dict[str, list[int] | bool]
margin: dict[str, int]
class CrewNodeStyle(TypedDict):
color: dict[str, CrewAIOrange | White]
shape: Literal["box"]
font: FontStyles
label: NotRequired[str]
borderWidth: int
borderWidthSelected: int
shapeProperties: dict[str, bool]
margin: dict[str, int]
class NodeStyles(TypedDict):
start: StartNodeStyle
method: MethodNodeStyle
router: RouterNodeStyle
crew: CrewNodeStyle
COLORS: FlowColors = {
"bg": WHITE,
"start": CREWAI_ORANGE,
"method": DARK_GRAY,
"router": DARK_GRAY,
"router_border": CREWAI_ORANGE,
"edge": GRAY,
"router_edge": CREWAI_ORANGE,
"text": WHITE,
}
NODE_STYLES: NodeStyles = {
"start": {
"color": CREWAI_ORANGE,
"shape": "box",
"font": {"color": WHITE},
"margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
},
"method": {
"color": DARK_GRAY,
"shape": "box",
"font": {"color": WHITE},
"margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
},
"router": {
"color": {
"background": DARK_GRAY,
"border": CREWAI_ORANGE,
"highlight": {
"border": CREWAI_ORANGE,
"background": DARK_GRAY,
},
},
"shape": "box",
"font": {"color": WHITE},
"borderWidth": 3,
"borderWidthSelected": 4,
"shapeProperties": {"borderDashes": [5, 5]},
"margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
},
"crew": {
"color": {
"background": WHITE,
"border": CREWAI_ORANGE,
},
"shape": "box",
"font": {"color": BLACK},
"borderWidth": 3,
"borderWidthSelected": 4,
"shapeProperties": {"borderDashes": False},
"margin": {"top": 10, "bottom": 8, "left": 10, "right": 10},
},
}

View File

@@ -113,6 +113,7 @@ from crewai.flow.utils import (
)
from crewai.memory.memory_scope import MemoryScope, MemorySlice
from crewai.memory.unified_memory import Memory
from crewai.state.checkpoint_config import CheckpointConfig, _coerce_checkpoint
if TYPE_CHECKING:
@@ -121,6 +122,7 @@ if TYPE_CHECKING:
from crewai.context import ExecutionContext
from crewai.flow.async_feedback.types import PendingFeedbackContext
from crewai.llms.base_llm import BaseLLM
from crewai.state.provider.core import BaseProvider
from crewai.flow.visualization import build_flow_structure, render_interactive
from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput
@@ -919,11 +921,64 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
max_method_calls: int = Field(default=100)
execution_context: ExecutionContext | None = Field(default=None)
checkpoint: Annotated[
CheckpointConfig | bool | None,
BeforeValidator(_coerce_checkpoint),
] = Field(default=None)
@classmethod
def from_checkpoint(
cls, path: str, *, provider: BaseProvider | None = None
) -> Flow: # type: ignore[type-arg]
"""Restore a Flow from a checkpoint file."""
from crewai.context import apply_execution_context
from crewai.events.event_bus import crewai_event_bus
from crewai.state.provider.json_provider import JsonProvider
from crewai.state.runtime import RuntimeState
state = RuntimeState.from_checkpoint(
path,
provider=provider or JsonProvider(),
context={"from_checkpoint": True},
)
crewai_event_bus.set_runtime_state(state)
for entity in state.root:
if not isinstance(entity, Flow):
continue
if entity.execution_context is not None:
apply_execution_context(entity.execution_context)
if isinstance(entity, cls):
entity._restore_from_checkpoint()
return entity
instance = cls()
instance.checkpoint_completed_methods = entity.checkpoint_completed_methods
instance.checkpoint_method_outputs = entity.checkpoint_method_outputs
instance.checkpoint_method_counts = entity.checkpoint_method_counts
instance.checkpoint_state = entity.checkpoint_state
instance._restore_from_checkpoint()
return instance
raise ValueError(f"No Flow found in checkpoint: {path}")
checkpoint_completed_methods: set[str] | None = Field(default=None)
checkpoint_method_outputs: list[Any] | None = Field(default=None)
checkpoint_method_counts: dict[str, int] | None = Field(default=None)
checkpoint_state: dict[str, Any] | None = Field(default=None)
def _restore_from_checkpoint(self) -> None:
"""Restore private execution state from checkpoint fields."""
if self.checkpoint_completed_methods is not None:
self._completed_methods = {
FlowMethodName(m) for m in self.checkpoint_completed_methods
}
if self.checkpoint_method_outputs is not None:
self._method_outputs = list(self.checkpoint_method_outputs)
if self.checkpoint_method_counts is not None:
self._method_execution_counts = {
FlowMethodName(k): v for k, v in self.checkpoint_method_counts.items()
}
if self.checkpoint_state is not None:
self._restore_state(self.checkpoint_state)
_methods: dict[FlowMethodName, FlowMethod[Any, Any]] = PrivateAttr(
default_factory=dict
)

View File

@@ -28,13 +28,13 @@ import asyncio
from collections.abc import Callable
import functools
import logging
from typing import TYPE_CHECKING, Any, ClassVar, Final, TypeVar, cast
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
from pydantic import BaseModel
from crewai.flow.persistence.base import FlowPersistence
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
if TYPE_CHECKING:
@@ -56,8 +56,6 @@ LOG_MESSAGES: Final[dict[str, str]] = {
class PersistenceDecorator:
"""Class to handle flow state persistence with consistent logging."""
_printer: ClassVar[Printer] = Printer()
@classmethod
def persist_state(
cls,
@@ -104,7 +102,7 @@ class PersistenceDecorator:
# Log state saving only if verbose is True
if verbose:
cls._printer.print(
PRINTER.print(
LOG_MESSAGES["save_state"].format(flow_uuid), color="cyan"
)
logger.info(LOG_MESSAGES["save_state"].format(flow_uuid))
@@ -119,19 +117,19 @@ class PersistenceDecorator:
except Exception as e:
error_msg = LOG_MESSAGES["save_error"].format(method_name, str(e))
if verbose:
cls._printer.print(error_msg, color="red")
PRINTER.print(error_msg, color="red")
logger.error(error_msg)
raise RuntimeError(f"State persistence failed: {e!s}") from e
except AttributeError as e:
error_msg = LOG_MESSAGES["state_missing"]
if verbose:
cls._printer.print(error_msg, color="red")
PRINTER.print(error_msg, color="red")
logger.error(error_msg)
raise ValueError(error_msg) from e
except (TypeError, ValueError) as e:
error_msg = LOG_MESSAGES["id_missing"]
if verbose:
cls._printer.print(error_msg, color="red")
PRINTER.print(error_msg, color="red")
logger.error(error_msg)
raise ValueError(error_msg) from e

View File

@@ -32,14 +32,12 @@ from crewai.flow.flow_wrappers import (
SimpleFlowCondition,
)
from crewai.flow.types import FlowMethodCallable, FlowMethodName
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
if TYPE_CHECKING:
from crewai.flow.flow import Flow
_printer = Printer()
def _extract_string_literals_from_type_annotation(
node: ast.expr,
@@ -181,7 +179,7 @@ def get_possible_return_constants(
return None
except Exception as e:
if verbose:
_printer.print(
PRINTER.print(
f"Error retrieving source code for function {function.__name__}: {e}",
color="red",
)
@@ -194,27 +192,27 @@ def get_possible_return_constants(
code_ast = ast.parse(source)
except IndentationError as e:
if verbose:
_printer.print(
PRINTER.print(
f"IndentationError while parsing source code of {function.__name__}: {e}",
color="red",
)
_printer.print(f"Source code:\n{source}", color="yellow")
PRINTER.print(f"Source code:\n{source}", color="yellow")
return None
except SyntaxError as e:
if verbose:
_printer.print(
PRINTER.print(
f"SyntaxError while parsing source code of {function.__name__}: {e}",
color="red",
)
_printer.print(f"Source code:\n{source}", color="yellow")
PRINTER.print(f"Source code:\n{source}", color="yellow")
return None
except Exception as e:
if verbose:
_printer.print(
PRINTER.print(
f"Unexpected error while parsing source code of {function.__name__}: {e}",
color="red",
)
_printer.print(f"Source code:\n{source}", color="yellow")
PRINTER.print(f"Source code:\n{source}", color="yellow")
return None
return_values: set[str] = set()
@@ -395,13 +393,13 @@ def get_possible_return_constants(
StateAttributeVisitor().visit(class_ast)
except Exception as e:
if verbose:
_printer.print(
PRINTER.print(
f"Could not analyze class context for {function.__name__}: {e}",
color="yellow",
)
except Exception as e:
if verbose:
_printer.print(
PRINTER.print(
f"Could not introspect class for {function.__name__}: {e}",
color="yellow",
)

View File

@@ -9,7 +9,7 @@ from crewai.hooks.types import (
BeforeLLMCallHookCallable,
BeforeLLMCallHookType,
)
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
if TYPE_CHECKING:
@@ -138,16 +138,15 @@ class LLMCallHookContext:
... print("LLM call skipped by user")
"""
printer = Printer()
event_listener.formatter.pause_live_updates()
try:
printer.print(content=f"\n{prompt}", color="bold_yellow")
printer.print(content=default_message, color="cyan")
PRINTER.print(content=f"\n{prompt}", color="bold_yellow")
PRINTER.print(content=default_message, color="cyan")
response = input().strip()
if response:
printer.print(content="\nProcessing your input...", color="cyan")
PRINTER.print(content="\nProcessing your input...", color="cyan")
return response
finally:

View File

@@ -9,7 +9,7 @@ from crewai.hooks.types import (
BeforeToolCallHookCallable,
BeforeToolCallHookType,
)
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
if TYPE_CHECKING:
@@ -100,16 +100,15 @@ class ToolCallHookContext:
... return None # Allow execution
"""
printer = Printer()
event_listener.formatter.pause_live_updates()
try:
printer.print(content=f"\n{prompt}", color="bold_yellow")
printer.print(content=default_message, color="cyan")
PRINTER.print(content=f"\n{prompt}", color="bold_yellow")
PRINTER.print(content=default_message, color="cyan")
response = input().strip()
if response:
printer.print(content="\nProcessing your input...", color="cyan")
PRINTER.print(content="\nProcessing your input...", color="cyan")
return response
finally:

View File

@@ -91,7 +91,7 @@ from crewai.utilities.guardrail import process_guardrail
from crewai.utilities.guardrail_types import GuardrailCallable, GuardrailType
from crewai.utilities.i18n import I18N, get_i18n
from crewai.utilities.llm_utils import create_llm
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
from crewai.utilities.pydantic_schema_utils import generate_model_description
from crewai.utilities.token_counter_callback import TokenCalcHandler
from crewai.utilities.tool_utils import execute_tool_and_check_finality
@@ -270,7 +270,6 @@ class LiteAgent(FlowTrackable, BaseModel):
_key: str = PrivateAttr(default_factory=lambda: str(uuid.uuid4()))
_messages: list[LLMMessage] = PrivateAttr(default_factory=list)
_iterations: int = PrivateAttr(default=0)
_printer: Printer = PrivateAttr(default_factory=Printer)
_guardrail: GuardrailCallable | None = PrivateAttr(default=None)
_guardrail_retry_count: int = PrivateAttr(default=0)
_callbacks: list[TokenCalcHandler] = PrivateAttr(default_factory=list)
@@ -528,11 +527,11 @@ class LiteAgent(FlowTrackable, BaseModel):
except Exception as e:
if self.verbose:
self._printer.print(
PRINTER.print(
content="Agent failed to reach a final answer. This is likely a bug - please report it.",
color="red",
)
handle_unknown_error(self._printer, e, verbose=self.verbose)
handle_unknown_error(PRINTER, e, verbose=self.verbose)
# Emit error event
crewai_event_bus.emit(
self,
@@ -609,7 +608,7 @@ class LiteAgent(FlowTrackable, BaseModel):
self._memory.remember_many(extracted, agent_role=self.role)
except Exception as e:
if self.verbose:
self._printer.print(
PRINTER.print(
content=f"Failed to save to memory: {e}",
color="yellow",
)
@@ -661,7 +660,7 @@ class LiteAgent(FlowTrackable, BaseModel):
formatted_result = result
except ConverterError as e:
if self.verbose:
self._printer.print(
PRINTER.print(
content=f"Failed to parse output into response format after retries: {e.message}",
color="yellow",
)
@@ -704,7 +703,7 @@ class LiteAgent(FlowTrackable, BaseModel):
)
self._guardrail_retry_count += 1
if self.verbose:
self._printer.print(
PRINTER.print(
f"Guardrail failed. Retrying ({self._guardrail_retry_count}/{self.guardrail_max_retries})..."
f"\n{guardrail_result.error}"
)
@@ -875,7 +874,7 @@ class LiteAgent(FlowTrackable, BaseModel):
if has_reached_max_iterations(self._iterations, self.max_iterations):
formatted_answer = handle_max_iterations_exceeded(
formatted_answer,
printer=self._printer,
printer=PRINTER,
i18n=self.i18n,
messages=self._messages,
llm=cast(LLM, self.llm),
@@ -890,8 +889,8 @@ class LiteAgent(FlowTrackable, BaseModel):
llm=cast(LLM, self.llm),
messages=self._messages,
callbacks=self._callbacks,
printer=self._printer,
from_agent=self,
printer=PRINTER,
from_agent=self, # type: ignore[arg-type]
executor_context=self,
response_model=response_model,
verbose=self.verbose,
@@ -933,7 +932,7 @@ class LiteAgent(FlowTrackable, BaseModel):
self._append_message(formatted_answer.text, role="assistant")
except OutputParserError as e:
if self.verbose:
self._printer.print(
PRINTER.print(
content="Failed to parse LLM output. Retrying...",
color="yellow",
)
@@ -942,7 +941,7 @@ class LiteAgent(FlowTrackable, BaseModel):
messages=self._messages,
iterations=self._iterations,
log_error_after=3,
printer=self._printer,
printer=PRINTER,
verbose=self.verbose,
)
@@ -953,7 +952,7 @@ class LiteAgent(FlowTrackable, BaseModel):
if is_context_length_exceeded(e):
handle_context_length(
respect_context_window=self.respect_context_window,
printer=self._printer,
printer=PRINTER,
messages=self._messages,
llm=cast(LLM, self.llm),
callbacks=self._callbacks,
@@ -961,7 +960,7 @@ class LiteAgent(FlowTrackable, BaseModel):
verbose=self.verbose,
)
continue
handle_unknown_error(self._printer, e, verbose=self.verbose)
handle_unknown_error(PRINTER, e, verbose=self.verbose)
raise e
finally:

View File

@@ -3,18 +3,14 @@ from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable
from datetime import datetime
import io
import json
import logging
import os
import sys
import threading
from typing import (
TYPE_CHECKING,
Any,
Final,
Literal,
TextIO,
TypedDict,
cast,
)
@@ -66,7 +62,7 @@ except ImportError:
if TYPE_CHECKING:
from crewai.agent.core import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.task import Task
from crewai.tools.base_tool import BaseTool
from crewai.utilities.types import LLMMessage
@@ -102,72 +98,6 @@ if LITELLM_AVAILABLE:
litellm.suppress_debug_info = True
class FilteredStream(io.TextIOBase):
_lock = None
def __init__(self, original_stream: TextIO):
self._original_stream = original_stream
self._lock = threading.Lock()
def write(self, s: str) -> int:
if not self._lock:
self._lock = threading.Lock()
with self._lock:
lower_s = s.lower()
# Skip common noisy LiteLLM banners and any other lines that contain "litellm"
if (
"litellm.info:" in lower_s
or "Consider using a smaller input or implementing a text splitting strategy"
in lower_s
):
return 0
return self._original_stream.write(s)
def flush(self) -> None:
if self._lock:
with self._lock:
return self._original_stream.flush()
return None
def __getattr__(self, name: str) -> Any:
"""Delegate attribute access to the wrapped original stream.
This ensures compatibility with libraries (e.g., Rich) that rely on
attributes such as `encoding`, `isatty`, `buffer`, etc., which may not
be explicitly defined on this proxy class.
"""
return getattr(self._original_stream, name)
# Delegate common properties/methods explicitly so they aren't shadowed by
# the TextIOBase defaults (e.g., .encoding returns None by default, which
# confuses Rich). These explicit pass-throughs ensure the wrapped Console
# still sees a fully-featured stream.
@property
def encoding(self) -> str | Any: # type: ignore[override]
return getattr(self._original_stream, "encoding", "utf-8")
def isatty(self) -> bool:
return self._original_stream.isatty()
def fileno(self) -> int:
return self._original_stream.fileno()
def writable(self) -> bool:
return True
# Apply the filtered stream globally so that any subsequent writes containing the filtered
# keywords (e.g., "litellm") are hidden from terminal output. We guard against double
# wrapping to ensure idempotency in environments where this module might be reloaded.
if not isinstance(sys.stdout, FilteredStream):
sys.stdout = FilteredStream(sys.stdout)
if not isinstance(sys.stderr, FilteredStream):
sys.stderr = FilteredStream(sys.stderr)
MIN_CONTEXT: Final[int] = 1024
MAX_CONTEXT: Final[int] = 2097152 # Current max from gemini-1.5-pro
ANTHROPIC_PREFIXES: Final[tuple[str, str, str]] = ("anthropic/", "claude-", "claude/")
@@ -343,6 +273,7 @@ class AccumulatedToolArgs(BaseModel):
class LLM(BaseLLM):
llm_type: Literal["litellm"] = "litellm"
completion_cost: float | None = None
timeout: float | int | None = None
top_p: float | None = None
@@ -735,7 +666,7 @@ class LLM(BaseLLM):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> Any:
"""Handle a streaming response from the LLM.
@@ -1048,7 +979,7 @@ class LLM(BaseLLM):
accumulated_tool_args: defaultdict[int, AccumulatedToolArgs],
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_id: str | None = None,
) -> Any:
for tool_call in tool_calls:
@@ -1137,7 +1068,7 @@ class LLM(BaseLLM):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Handle a non-streaming response from the LLM.
@@ -1289,7 +1220,7 @@ class LLM(BaseLLM):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Handle an async non-streaming response from the LLM.
@@ -1430,7 +1361,7 @@ class LLM(BaseLLM):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> Any:
"""Handle an async streaming response from the LLM.
@@ -1606,7 +1537,7 @@ class LLM(BaseLLM):
tool_calls: list[Any],
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
) -> Any:
"""Handle a tool call from the LLM.
@@ -1702,7 +1633,7 @@ class LLM(BaseLLM):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""High-level LLM call method.
@@ -1852,7 +1783,7 @@ class LLM(BaseLLM):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Async high-level LLM call method.
@@ -2001,7 +1932,7 @@ class LLM(BaseLLM):
response: Any,
call_type: LLMCallType,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
messages: str | list[LLMMessage] | None = None,
usage: dict[str, Any] | None = None,
) -> None:

View File

@@ -53,7 +53,7 @@ except ImportError:
if TYPE_CHECKING:
from crewai.agent.core import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.task import Task
from crewai.tools.base_tool import BaseTool
from crewai.utilities.types import LLMMessage
@@ -117,6 +117,7 @@ class BaseLLM(BaseModel, ABC):
model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)
llm_type: str = "base"
model: str
temperature: float | None = None
api_key: str | None = None
@@ -240,7 +241,7 @@ class BaseLLM(BaseModel, ABC):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Call the LLM with the given messages.
@@ -277,7 +278,7 @@ class BaseLLM(BaseModel, ABC):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Call the LLM with the given messages.
@@ -434,7 +435,7 @@ class BaseLLM(BaseModel, ABC):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
) -> None:
"""Emit LLM call started event."""
from crewai.utilities.serialization import to_serializable
@@ -458,7 +459,7 @@ class BaseLLM(BaseModel, ABC):
response: Any,
call_type: LLMCallType,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
messages: str | list[LLMMessage] | None = None,
usage: dict[str, Any] | None = None,
) -> None:
@@ -483,7 +484,7 @@ class BaseLLM(BaseModel, ABC):
self,
error: str,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
) -> None:
"""Emit LLM call failed event."""
crewai_event_bus.emit(
@@ -501,7 +502,7 @@ class BaseLLM(BaseModel, ABC):
self,
chunk: str,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
tool_call: dict[str, Any] | None = None,
call_type: LLMCallType | None = None,
response_id: str | None = None,
@@ -533,7 +534,7 @@ class BaseLLM(BaseModel, ABC):
self,
chunk: str,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_id: str | None = None,
) -> None:
"""Emit thinking/reasoning chunk event from a thinking model.
@@ -561,7 +562,7 @@ class BaseLLM(BaseModel, ABC):
function_args: dict[str, Any],
available_functions: dict[str, Any],
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
) -> str | None:
"""Handle tool execution with proper event emission.
@@ -827,7 +828,7 @@ class BaseLLM(BaseModel, ABC):
def _invoke_before_llm_call_hooks(
self,
messages: list[LLMMessage],
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
) -> bool:
"""Invoke before_llm_call hooks for direct LLM calls (no agent context).
@@ -856,7 +857,7 @@ class BaseLLM(BaseModel, ABC):
LLMCallHookContext,
get_before_llm_call_hooks,
)
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
before_hooks = get_before_llm_call_hooks()
if not before_hooks:
@@ -871,21 +872,20 @@ class BaseLLM(BaseModel, ABC):
crew=None,
)
verbose = getattr(from_agent, "verbose", True) if from_agent else True
printer = Printer()
try:
for hook in before_hooks:
result = hook(hook_context)
if result is False:
if verbose:
printer.print(
PRINTER.print(
content="LLM call blocked by before_llm_call hook",
color="yellow",
)
return False
except Exception as e:
if verbose:
printer.print(
PRINTER.print(
content=f"Error in before_llm_call hook: {e}",
color="yellow",
)
@@ -896,7 +896,7 @@ class BaseLLM(BaseModel, ABC):
self,
messages: list[LLMMessage],
response: str,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
) -> str:
"""Invoke after_llm_call hooks for direct LLM calls (no agent context).
@@ -926,7 +926,7 @@ class BaseLLM(BaseModel, ABC):
LLMCallHookContext,
get_after_llm_call_hooks,
)
from crewai.utilities.printer import Printer
from crewai.utilities.printer import PRINTER
after_hooks = get_after_llm_call_hooks()
if not after_hooks:
@@ -942,7 +942,6 @@ class BaseLLM(BaseModel, ABC):
response=response,
)
verbose = getattr(from_agent, "verbose", True) if from_agent else True
printer = Printer()
modified_response = response
try:
@@ -953,7 +952,7 @@ class BaseLLM(BaseModel, ABC):
hook_context.response = modified_response
except Exception as e:
if verbose:
printer.print(
PRINTER.print(
content=f"Error in after_llm_call hook: {e}",
color="yellow",
)

View File

@@ -148,6 +148,7 @@ class AnthropicCompletion(BaseLLM):
offering native tool use, streaming support, and proper message formatting.
"""
llm_type: Literal["anthropic"] = "anthropic"
model: str = "claude-3-5-sonnet-20241022"
timeout: float | None = None
max_retries: int = 2

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import logging
import os
from typing import Any, TypedDict
from typing import Any, Literal, TypedDict
from urllib.parse import urlparse
from pydantic import BaseModel, PrivateAttr, model_validator
@@ -74,6 +74,7 @@ class AzureCompletion(BaseLLM):
offering native function calling, streaming support, and proper Azure authentication.
"""
llm_type: Literal["azure"] = "azure"
endpoint: str | None = None
api_version: str | None = None
timeout: float | None = None

View File

@@ -5,7 +5,7 @@ from contextlib import AsyncExitStack
import json
import logging
import os
from typing import TYPE_CHECKING, Any, TypedDict, cast
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
from pydantic import BaseModel, PrivateAttr, model_validator
from typing_extensions import Required
@@ -228,6 +228,7 @@ class BedrockCompletion(BaseLLM):
- Model-specific conversation format handling (e.g., Cohere requirements)
"""
llm_type: Literal["bedrock"] = "bedrock"
model: str = "anthropic.claude-3-5-sonnet-20241022-v2:0"
aws_access_key_id: str | None = None
aws_secret_access_key: str | None = None

View File

@@ -41,6 +41,7 @@ class GeminiCompletion(BaseLLM):
offering native function calling, streaming support, and proper Gemini formatting.
"""
llm_type: Literal["gemini"] = "gemini"
model: str = "gemini-2.0-flash-001"
project: str | None = None
location: str | None = None

View File

@@ -10,7 +10,11 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict
import httpx
from openai import APIConnectionError, AsyncOpenAI, NotFoundError, OpenAI, Stream
from openai.lib.streaming.chat import ChatCompletionStream
from openai.types.chat import ChatCompletion, ChatCompletionChunk
from openai.types.chat import (
ChatCompletion,
ChatCompletionChunk,
ChatCompletionMessageFunctionToolCall,
)
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import ChoiceDelta
from openai.types.responses import (
@@ -37,7 +41,7 @@ from crewai.utilities.types import LLMMessage
if TYPE_CHECKING:
from crewai.agent.core import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.task import Task
from crewai.tools.base_tool import BaseTool
@@ -184,6 +188,8 @@ class OpenAICompletion(BaseLLM):
chain-of-thought without storing data on OpenAI servers.
"""
llm_type: Literal["openai"] = "openai"
BUILTIN_TOOL_TYPES: ClassVar[dict[str, str]] = {
"web_search": "web_search_preview",
"file_search": "file_search",
@@ -367,7 +373,7 @@ class OpenAICompletion(BaseLLM):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Call OpenAI API (Chat Completions or Responses based on api setting).
@@ -435,7 +441,7 @@ class OpenAICompletion(BaseLLM):
tools: list[dict[str, BaseTool]] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Call OpenAI Chat Completions API."""
@@ -467,7 +473,7 @@ class OpenAICompletion(BaseLLM):
callbacks: list[Any] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Async call to OpenAI API (Chat Completions or Responses).
@@ -530,7 +536,7 @@ class OpenAICompletion(BaseLLM):
tools: list[dict[str, BaseTool]] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Async call to OpenAI Chat Completions API."""
@@ -561,7 +567,7 @@ class OpenAICompletion(BaseLLM):
tools: list[dict[str, BaseTool]] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Call OpenAI Responses API."""
@@ -592,7 +598,7 @@ class OpenAICompletion(BaseLLM):
tools: list[dict[str, BaseTool]] | None = None,
available_functions: dict[str, Any] | None = None,
from_task: Task | None = None,
from_agent: Agent | None = None,
from_agent: BaseAgent | None = None,
response_model: type[BaseModel] | None = None,
) -> str | Any:
"""Async call to OpenAI Responses API."""
@@ -1630,10 +1636,8 @@ class OpenAICompletion(BaseLLM):
# If there are tool_calls and available_functions, execute the tools
if message.tool_calls and available_functions:
tool_call = message.tool_calls[0]
if not hasattr(tool_call, "function") or tool_call.function is None:
raise ValueError(
f"Unsupported tool call type: {type(tool_call).__name__}"
)
if not isinstance(tool_call, ChatCompletionMessageFunctionToolCall):
return message.content
function_name = tool_call.function.name
try:
@@ -2018,11 +2022,13 @@ class OpenAICompletion(BaseLLM):
# If there are tool_calls and available_functions, execute the tools
if message.tool_calls and available_functions:
from openai.types.chat.chat_completion_message_function_tool_call import (
ChatCompletionMessageFunctionToolCall,
)
tool_call = message.tool_calls[0]
if not hasattr(tool_call, "function") or tool_call.function is None:
raise ValueError(
f"Unsupported tool call type: {type(tool_call).__name__}"
)
if not isinstance(tool_call, ChatCompletionMessageFunctionToolCall):
return message.content
function_name = tool_call.function.name
try:

View File

@@ -6,7 +6,6 @@ import sqlite3
from typing import Any
from crewai.task import Task
from crewai.utilities import Printer
from crewai.utilities.crew_json_encoder import CrewJSONEncoder
from crewai.utilities.errors import DatabaseError, DatabaseOperationError
from crewai.utilities.lock_store import lock as store_lock
@@ -27,7 +26,6 @@ class KickoffTaskOutputsSQLiteStorage:
db_path = str(Path(db_storage_path()) / "latest_kickoff_task_outputs.db")
self.db_path = db_path
self._lock_name = f"sqlite:{os.path.realpath(self.db_path)}"
self._printer: Printer = Printer()
self._initialize_db()
def _initialize_db(self) -> None:

View File

@@ -6,10 +6,7 @@ from chromadb.api.types import Documents, EmbeddingFunction, Embeddings
from typing_extensions import Unpack
from crewai.rag.embeddings.providers.ibm.types import WatsonXProviderConfig
from crewai.utilities.printer import Printer
_printer = Printer()
from crewai.utilities.printer import PRINTER
class WatsonXEmbeddingFunction(EmbeddingFunction[Documents]):
@@ -164,5 +161,5 @@ class WatsonXEmbeddingFunction(EmbeddingFunction[Documents]):
return cast(Embeddings, embeddings)
except Exception as e:
if self._verbose:
_printer.print(f"Error during WatsonX embedding: {e}", color="red")
PRINTER.print(f"Error during WatsonX embedding: {e}", color="red")
raise

View File

@@ -1,18 +0,0 @@
"""Unified runtime state for crewAI.
``RuntimeState`` is a ``RootModel`` whose ``model_dump_json()`` produces a
complete, self-contained snapshot of every active entity in the program.
The ``Entity`` type alias and ``RuntimeState`` model are built at import time
in ``crewai/__init__.py`` after all forward references are resolved.
"""
from typing import Any
def _entity_discriminator(v: dict[str, Any] | object) -> str:
if isinstance(v, dict):
raw = v.get("entity_type", "agent")
else:
raw = getattr(v, "entity_type", "agent")
return str(raw)

View File

@@ -0,0 +1,11 @@
from crewai.state.checkpoint_config import CheckpointConfig, CheckpointEventType
from crewai.state.provider.json_provider import JsonProvider
from crewai.state.provider.sqlite_provider import SqliteProvider
__all__ = [
"CheckpointConfig",
"CheckpointEventType",
"JsonProvider",
"SqliteProvider",
]

View File

@@ -0,0 +1,218 @@
"""Checkpoint configuration for automatic state persistence."""
from __future__ import annotations
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, model_validator
from crewai.state.provider.json_provider import JsonProvider
from crewai.state.provider.sqlite_provider import SqliteProvider
CheckpointEventType = Literal[
# Task
"task_started",
"task_completed",
"task_failed",
"task_evaluation",
# Crew
"crew_kickoff_started",
"crew_kickoff_completed",
"crew_kickoff_failed",
"crew_train_started",
"crew_train_completed",
"crew_train_failed",
"crew_test_started",
"crew_test_completed",
"crew_test_failed",
"crew_test_result",
# Agent
"agent_execution_started",
"agent_execution_completed",
"agent_execution_error",
"lite_agent_execution_started",
"lite_agent_execution_completed",
"lite_agent_execution_error",
"agent_evaluation_started",
"agent_evaluation_completed",
"agent_evaluation_failed",
# Flow
"flow_created",
"flow_started",
"flow_finished",
"flow_paused",
"method_execution_started",
"method_execution_finished",
"method_execution_failed",
"method_execution_paused",
"human_feedback_requested",
"human_feedback_received",
"flow_input_requested",
"flow_input_received",
# LLM
"llm_call_started",
"llm_call_completed",
"llm_call_failed",
"llm_stream_chunk",
"llm_thinking_chunk",
# LLM Guardrail
"llm_guardrail_started",
"llm_guardrail_completed",
"llm_guardrail_failed",
# Tool
"tool_usage_started",
"tool_usage_finished",
"tool_usage_error",
"tool_validate_input_error",
"tool_selection_error",
"tool_execution_error",
# Memory
"memory_save_started",
"memory_save_completed",
"memory_save_failed",
"memory_query_started",
"memory_query_completed",
"memory_query_failed",
"memory_retrieval_started",
"memory_retrieval_completed",
"memory_retrieval_failed",
# Knowledge
"knowledge_search_query_started",
"knowledge_search_query_completed",
"knowledge_query_started",
"knowledge_query_completed",
"knowledge_query_failed",
"knowledge_search_query_failed",
# Reasoning
"agent_reasoning_started",
"agent_reasoning_completed",
"agent_reasoning_failed",
# MCP
"mcp_connection_started",
"mcp_connection_completed",
"mcp_connection_failed",
"mcp_tool_execution_started",
"mcp_tool_execution_completed",
"mcp_tool_execution_failed",
"mcp_config_fetch_failed",
# Observation
"step_observation_started",
"step_observation_completed",
"step_observation_failed",
"plan_refinement",
"plan_replan_triggered",
"goal_achieved_early",
# Skill
"skill_discovery_started",
"skill_discovery_completed",
"skill_loaded",
"skill_activated",
"skill_load_failed",
# Logging
"agent_logs_started",
"agent_logs_execution",
# A2A
"a2a_delegation_started",
"a2a_delegation_completed",
"a2a_conversation_started",
"a2a_conversation_completed",
"a2a_message_sent",
"a2a_response_received",
"a2a_polling_started",
"a2a_polling_status",
"a2a_push_notification_registered",
"a2a_push_notification_received",
"a2a_push_notification_sent",
"a2a_push_notification_timeout",
"a2a_streaming_started",
"a2a_streaming_chunk",
"a2a_agent_card_fetched",
"a2a_authentication_failed",
"a2a_artifact_received",
"a2a_connection_error",
"a2a_server_task_started",
"a2a_server_task_completed",
"a2a_server_task_canceled",
"a2a_server_task_failed",
"a2a_parallel_delegation_started",
"a2a_parallel_delegation_completed",
"a2a_transport_negotiated",
"a2a_content_type_negotiated",
"a2a_context_created",
"a2a_context_expired",
"a2a_context_idle",
"a2a_context_completed",
"a2a_context_pruned",
# System
"SIGTERM",
"SIGINT",
"SIGHUP",
"SIGTSTP",
"SIGCONT",
# Env
"cc_env",
"codex_env",
"cursor_env",
"default_env",
]
def _coerce_checkpoint(v: Any) -> Any:
"""BeforeValidator for checkpoint fields on Crew/Flow/Agent.
Converts True to CheckpointConfig and triggers handler registration.
"""
if v is True:
v = CheckpointConfig()
if isinstance(v, CheckpointConfig):
from crewai.state.checkpoint_listener import _ensure_handlers_registered
_ensure_handlers_registered()
return v
class CheckpointConfig(BaseModel):
"""Configuration for automatic checkpointing.
When set on a Crew, Flow, or Agent, checkpoints are written
automatically whenever the specified event(s) fire.
"""
location: str = Field(
default="./.checkpoints",
description="Storage destination. For JsonProvider this is a directory "
"path; for SqliteProvider it is a database file path.",
)
on_events: list[CheckpointEventType | Literal["*"]] = Field(
default=["task_completed"],
description="Event types that trigger a checkpoint write. "
'Use ["*"] to checkpoint on every event.',
)
provider: Annotated[
JsonProvider | SqliteProvider,
Field(discriminator="provider_type"),
] = Field(
default_factory=JsonProvider,
description="Storage backend. Defaults to JsonProvider.",
)
max_checkpoints: int | None = Field(
default=None,
description="Maximum checkpoints to keep. Oldest are pruned after "
"each write. None means keep all.",
)
@model_validator(mode="after")
def _register_handlers(self) -> CheckpointConfig:
from crewai.state.checkpoint_listener import _ensure_handlers_registered
_ensure_handlers_registered()
return self
@property
def trigger_all(self) -> bool:
return "*" in self.on_events
@property
def trigger_events(self) -> set[str]:
return set(self.on_events)

View File

@@ -0,0 +1,158 @@
"""Event listener that writes checkpoints automatically.
Handlers are registered lazily — only when the first ``CheckpointConfig``
is resolved (i.e. an entity actually has checkpointing enabled). This
avoids per-event overhead when no entity uses checkpointing.
"""
from __future__ import annotations
import logging
import threading
from typing import Any
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crew import Crew
from crewai.events.base_events import BaseEvent
from crewai.events.event_bus import CrewAIEventsBus, crewai_event_bus
from crewai.flow.flow import Flow
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.state.runtime import RuntimeState, _prepare_entities
from crewai.task import Task
logger = logging.getLogger(__name__)
_handlers_registered = False
_register_lock = threading.Lock()
_SENTINEL = object()
def _ensure_handlers_registered() -> None:
"""Register checkpoint handlers on the event bus once, lazily."""
global _handlers_registered
if _handlers_registered:
return
with _register_lock:
if _handlers_registered:
return
_register_all_handlers(crewai_event_bus)
_handlers_registered = True
def _resolve(value: CheckpointConfig | bool | None) -> CheckpointConfig | None | object:
"""Coerce a checkpoint field value.
Returns:
CheckpointConfig — use this config.
_SENTINEL — explicit opt-out (``False``), stop walking parents.
None — not configured, keep walking parents.
"""
if isinstance(value, CheckpointConfig):
_ensure_handlers_registered()
return value
if value is True:
_ensure_handlers_registered()
return CheckpointConfig()
if value is False:
return _SENTINEL
return None # None = inherit
def _find_checkpoint(source: Any) -> CheckpointConfig | None:
"""Find the CheckpointConfig for an event source.
Walks known relationships: Task -> Agent -> Crew. Flow and Agent
carry their own checkpoint field directly.
A ``None`` value means "not configured, inherit from parent".
A ``False`` value means "opt out" and stops the walk.
"""
if isinstance(source, Flow):
result = _resolve(source.checkpoint)
return result if isinstance(result, CheckpointConfig) else None
if isinstance(source, Crew):
result = _resolve(source.checkpoint)
return result if isinstance(result, CheckpointConfig) else None
if isinstance(source, BaseAgent):
result = _resolve(source.checkpoint)
if isinstance(result, CheckpointConfig):
return result
if result is _SENTINEL:
return None
crew = source.crew
if isinstance(crew, Crew):
result = _resolve(crew.checkpoint)
return result if isinstance(result, CheckpointConfig) else None
return None
if isinstance(source, Task):
agent = source.agent
if isinstance(agent, BaseAgent):
result = _resolve(agent.checkpoint)
if isinstance(result, CheckpointConfig):
return result
if result is _SENTINEL:
return None
crew = agent.crew
if isinstance(crew, Crew):
result = _resolve(crew.checkpoint)
return result if isinstance(result, CheckpointConfig) else None
return None
return None
def _do_checkpoint(state: RuntimeState, cfg: CheckpointConfig) -> None:
"""Write a checkpoint and prune old ones if configured."""
_prepare_entities(state.root)
data = state.model_dump_json()
cfg.provider.checkpoint(data, cfg.location)
if cfg.max_checkpoints is not None:
cfg.provider.prune(cfg.location, cfg.max_checkpoints)
def _should_checkpoint(source: Any, event: BaseEvent) -> CheckpointConfig | None:
"""Return the CheckpointConfig if this event should trigger a checkpoint."""
cfg = _find_checkpoint(source)
if cfg is None:
return None
if not cfg.trigger_all and event.type not in cfg.trigger_events:
return None
return cfg
def _on_any_event(source: Any, event: BaseEvent, state: Any) -> None:
"""Sync handler registered on every event class."""
cfg = _should_checkpoint(source, event)
if cfg is None:
return
try:
_do_checkpoint(state, cfg)
except Exception:
logger.warning("Auto-checkpoint failed for event %s", event.type, exc_info=True)
def _register_all_handlers(event_bus: CrewAIEventsBus) -> None:
"""Register the checkpoint handler on all known event classes.
Only the sync handler is registered. The event bus runs sync handlers
in a ``ThreadPoolExecutor``, so blocking I/O is safe and we avoid
writing duplicate checkpoints from both sync and async dispatch.
"""
seen: set[type] = set()
def _collect(cls: type[BaseEvent]) -> None:
for sub in cls.__subclasses__():
if sub not in seen:
seen.add(sub)
type_field = sub.model_fields.get("type")
if (
type_field
and type_field.default
and type_field.default != "base_event"
):
event_bus.register_handler(sub, _on_any_event)
_collect(sub)
_collect(BaseEvent)

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