mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-06 23:49:24 +00:00
Merge branch 'main' into lorenze/imp/memory-prompt-influence
This commit is contained in:
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.0a3"
|
||||
__version__ = "1.14.0"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
205
lib/crewai-tools/src/crewai_tools/security/safe_path.py
Normal file
205
lib/crewai-tools/src/crewai_tools/security/safe_path.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
FROM python:3.12-alpine
|
||||
|
||||
RUN pip install requests beautifulsoup4
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /workspace
|
||||
@@ -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).
|
||||
@@ -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}"
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
10
lib/crewai-tools/src/crewai_tools/utilities/safe_path.py
Normal file
10
lib/crewai-tools/src/crewai_tools/utilities/safe_path.py
Normal 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"]
|
||||
@@ -97,6 +97,7 @@ def test_extract_init_params_schema(mock_tool_extractor):
|
||||
assert init_params_schema.keys() == {
|
||||
"$defs",
|
||||
"properties",
|
||||
"required",
|
||||
"title",
|
||||
"type",
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
0
lib/crewai-tools/tests/utilities/__init__.py
Normal file
0
lib/crewai-tools/tests/utilities/__init__.py
Normal file
170
lib/crewai-tools/tests/utilities/test_safe_path.py
Normal file
170
lib/crewai-tools/tests/utilities/test_safe_path.py
Normal 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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
329
lib/crewai/src/crewai/cli/checkpoint_cli.py
Normal file
329
lib/crewai/src/crewai/cli/checkpoint_cli.py
Normal 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}")
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
11
lib/crewai/src/crewai/state/__init__.py
Normal file
11
lib/crewai/src/crewai/state/__init__.py
Normal 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",
|
||||
]
|
||||
218
lib/crewai/src/crewai/state/checkpoint_config.py
Normal file
218
lib/crewai/src/crewai/state/checkpoint_config.py
Normal 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)
|
||||
158
lib/crewai/src/crewai/state/checkpoint_listener.py
Normal file
158
lib/crewai/src/crewai/state/checkpoint_listener.py
Normal 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
Reference in New Issue
Block a user