Merge branch 'main' into refactor/extract-crewai-a2a-package-v2

# Conflicts:
#	uv.lock
This commit is contained in:
Greyson LaLonde
2026-04-29 00:49:15 +08:00
131 changed files with 16433 additions and 1306 deletions

View File

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

View File

@@ -10,8 +10,8 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.14.2a3",
"tiktoken~=0.8.0",
"crewai==1.14.3",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
"youtube-transcript-api~=1.2.2",
@@ -69,7 +69,7 @@ linkup-sdk = [
"linkup-sdk>=0.2.2",
]
tavily-python = [
"tavily-python>=0.5.4",
"tavily-python~=0.7.14",
]
hyperbrowser = [
"hyperbrowser>=0.18.0",
@@ -112,7 +112,7 @@ github = [
]
rag = [
"python-docx>=1.1.0",
"lxml>=5.3.0,<5.4.0", # Pin to avoid etree import issues in 5.4.0
"lxml>=6.1.0,<7", # 6.1.0+ required for GHSA-vfmq-68hx-4jfw (XXE in iterparse)
]
xml = [
"unstructured[local-inference, all-docs]>=0.17.2"
@@ -139,6 +139,14 @@ contextual = [
"contextual-client>=0.1.0",
"nest-asyncio>=1.6.0",
]
daytona = [
"daytona~=0.140.0",
]
e2b = [
"e2b~=2.20.0",
"e2b-code-interpreter~=2.6.0",
]
[tool.uv]

View File

@@ -59,6 +59,11 @@ from crewai_tools.tools.dalle_tool.dalle_tool import DallETool
from crewai_tools.tools.databricks_query_tool.databricks_query_tool import (
DatabricksQueryTool,
)
from crewai_tools.tools.daytona_sandbox_tool import (
DaytonaExecTool,
DaytonaFileTool,
DaytonaPythonTool,
)
from crewai_tools.tools.directory_read_tool.directory_read_tool import (
DirectoryReadTool,
)
@@ -66,6 +71,11 @@ from crewai_tools.tools.directory_search_tool.directory_search_tool import (
DirectorySearchTool,
)
from crewai_tools.tools.docx_search_tool.docx_search_tool import DOCXSearchTool
from crewai_tools.tools.e2b_sandbox_tool import (
E2BExecTool,
E2BFileTool,
E2BPythonTool,
)
from crewai_tools.tools.exa_tools.exa_search_tool import EXASearchTool
from crewai_tools.tools.file_read_tool.file_read_tool import FileReadTool
from crewai_tools.tools.file_writer_tool.file_writer_tool import FileWriterTool
@@ -187,6 +197,12 @@ from crewai_tools.tools.stagehand_tool.stagehand_tool import StagehandTool
from crewai_tools.tools.tavily_extractor_tool.tavily_extractor_tool import (
TavilyExtractorTool,
)
from crewai_tools.tools.tavily_get_research_tool.tavily_get_research_tool import (
TavilyGetResearchTool,
)
from crewai_tools.tools.tavily_research_tool.tavily_research_tool import (
TavilyResearchTool,
)
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
@@ -232,8 +248,14 @@ __all__ = [
"DOCXSearchTool",
"DallETool",
"DatabricksQueryTool",
"DaytonaExecTool",
"DaytonaFileTool",
"DaytonaPythonTool",
"DirectoryReadTool",
"DirectorySearchTool",
"E2BExecTool",
"E2BFileTool",
"E2BPythonTool",
"EXASearchTool",
"EnterpriseActionTool",
"FileCompressorTool",
@@ -294,6 +316,8 @@ __all__ = [
"StagehandTool",
"TXTSearchTool",
"TavilyExtractorTool",
"TavilyGetResearchTool",
"TavilyResearchTool",
"TavilySearchTool",
"VisionTool",
"WeaviateVectorSearchTool",
@@ -305,4 +329,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.2a3"
__version__ = "1.14.3"

View File

@@ -48,6 +48,11 @@ from crewai_tools.tools.dalle_tool.dalle_tool import DallETool
from crewai_tools.tools.databricks_query_tool.databricks_query_tool import (
DatabricksQueryTool,
)
from crewai_tools.tools.daytona_sandbox_tool import (
DaytonaExecTool,
DaytonaFileTool,
DaytonaPythonTool,
)
from crewai_tools.tools.directory_read_tool.directory_read_tool import (
DirectoryReadTool,
)
@@ -55,6 +60,11 @@ from crewai_tools.tools.directory_search_tool.directory_search_tool import (
DirectorySearchTool,
)
from crewai_tools.tools.docx_search_tool.docx_search_tool import DOCXSearchTool
from crewai_tools.tools.e2b_sandbox_tool import (
E2BExecTool,
E2BFileTool,
E2BPythonTool,
)
from crewai_tools.tools.exa_tools.exa_search_tool import EXASearchTool
from crewai_tools.tools.file_read_tool.file_read_tool import FileReadTool
from crewai_tools.tools.file_writer_tool.file_writer_tool import FileWriterTool
@@ -174,6 +184,12 @@ from crewai_tools.tools.stagehand_tool.stagehand_tool import StagehandTool
from crewai_tools.tools.tavily_extractor_tool.tavily_extractor_tool import (
TavilyExtractorTool,
)
from crewai_tools.tools.tavily_get_research_tool.tavily_get_research_tool import (
TavilyGetResearchTool,
)
from crewai_tools.tools.tavily_research_tool.tavily_research_tool import (
TavilyResearchTool,
)
from crewai_tools.tools.tavily_search_tool.tavily_search_tool import TavilySearchTool
from crewai_tools.tools.txt_search_tool.txt_search_tool import TXTSearchTool
from crewai_tools.tools.vision_tool.vision_tool import VisionTool
@@ -217,8 +233,14 @@ __all__ = [
"DOCXSearchTool",
"DallETool",
"DatabricksQueryTool",
"DaytonaExecTool",
"DaytonaFileTool",
"DaytonaPythonTool",
"DirectoryReadTool",
"DirectorySearchTool",
"E2BExecTool",
"E2BFileTool",
"E2BPythonTool",
"EXASearchTool",
"FileCompressorTool",
"FileReadTool",
@@ -277,6 +299,8 @@ __all__ = [
"StagehandTool",
"TXTSearchTool",
"TavilyExtractorTool",
"TavilyGetResearchTool",
"TavilyResearchTool",
"TavilySearchTool",
"VisionTool",
"WeaviateVectorSearchTool",

View File

@@ -0,0 +1,107 @@
# Daytona Sandbox Tools
Run shell commands, execute Python, and manage files inside a [Daytona](https://www.daytona.io/) sandbox. Daytona provides isolated, ephemeral compute environments suitable for agent-driven code execution.
Three tools are provided so you can pick what the agent actually needs:
- **`DaytonaExecTool`** — run a shell command (`sandbox.process.exec`).
- **`DaytonaPythonTool`** — run a Python script (`sandbox.process.code_run`).
- **`DaytonaFileTool`** — read / write / list / delete files (`sandbox.fs.*`).
## Installation
```shell
uv add "crewai-tools[daytona]"
# or
pip install "crewai-tools[daytona]"
```
Set the API key:
```shell
export DAYTONA_API_KEY="..."
```
`DAYTONA_API_URL` and `DAYTONA_TARGET` are also respected if set.
## Sandbox lifecycle
All three tools share the same lifecycle controls from `DaytonaBaseTool`:
| Mode | When the sandbox is created | When it is deleted |
| --- | --- | --- |
| **Ephemeral** (default, `persistent=False`) | On every `_run` call | At the end of that same call |
| **Persistent** (`persistent=True`) | Lazily on first use | At process exit (via `atexit`), or manually via `tool.close()` |
| **Attach** (`sandbox_id="…"`) | Never — the tool attaches to an existing sandbox | Never — the tool will not delete a sandbox it did not create |
Ephemeral mode is the safe default: nothing leaks if the agent forgets to clean up. Use persistent mode when you want filesystem state or installed packages to carry across steps — this is typical when pairing `DaytonaFileTool` with `DaytonaExecTool`.
## Examples
### One-shot Python execution (ephemeral)
```python
from crewai_tools import DaytonaPythonTool
tool = DaytonaPythonTool()
result = tool.run(code="print(sum(range(10)))")
```
### Multi-step shell session (persistent)
```python
from crewai_tools import DaytonaExecTool, DaytonaFileTool
exec_tool = DaytonaExecTool(persistent=True)
file_tool = DaytonaFileTool(persistent=True)
# Agent writes a script, then runs it — both share the same sandbox instance
# because they each keep their own persistent sandbox. If you need the *same*
# sandbox across two tools, create one tool, grab the sandbox id via
# `tool._persistent_sandbox.id`, and pass it to the other via `sandbox_id=...`.
```
### Attach to an existing sandbox
```python
from crewai_tools import DaytonaExecTool
tool = DaytonaExecTool(sandbox_id="my-long-lived-sandbox")
```
### Custom create params
Pass Daytona's `CreateSandboxFromSnapshotParams` kwargs via `create_params`:
```python
tool = DaytonaExecTool(
persistent=True,
create_params={
"language": "python",
"env_vars": {"MY_FLAG": "1"},
"labels": {"owner": "crewai-agent"},
},
)
```
## Tool arguments
### `DaytonaExecTool`
- `command: str` — shell command to run.
- `cwd: str | None` — working directory.
- `env: dict[str, str] | None` — extra env vars for this command.
- `timeout: int | None` — seconds.
### `DaytonaPythonTool`
- `code: str` — Python source to execute.
- `argv: list[str] | None` — argv forwarded via `CodeRunParams`.
- `env: dict[str, str] | None` — env vars forwarded via `CodeRunParams`.
- `timeout: int | None` — seconds.
### `DaytonaFileTool`
- `action: "read" | "write" | "list" | "delete" | "mkdir" | "info"`
- `path: str` — absolute path inside the sandbox.
- `content: str | None` — required for `write`.
- `binary: bool` — if `True`, `content` is base64 on write / returned as base64 on read.
- `recursive: bool` — for `delete`, removes directories recursively.
- `mode: str` — for `mkdir`, octal permission string (default `"0755"`).

View File

@@ -0,0 +1,13 @@
from crewai_tools.tools.daytona_sandbox_tool.daytona_base_tool import DaytonaBaseTool
from crewai_tools.tools.daytona_sandbox_tool.daytona_exec_tool import DaytonaExecTool
from crewai_tools.tools.daytona_sandbox_tool.daytona_file_tool import DaytonaFileTool
from crewai_tools.tools.daytona_sandbox_tool.daytona_python_tool import (
DaytonaPythonTool,
)
__all__ = [
"DaytonaBaseTool",
"DaytonaExecTool",
"DaytonaFileTool",
"DaytonaPythonTool",
]

View File

@@ -0,0 +1,198 @@
from __future__ import annotations
import atexit
import logging
import os
import threading
from typing import Any, ClassVar
from crewai.tools import BaseTool, EnvVar
from pydantic import ConfigDict, Field, PrivateAttr
logger = logging.getLogger(__name__)
class DaytonaBaseTool(BaseTool):
"""Shared base for tools that act on a Daytona sandbox.
Lifecycle modes:
- persistent=False (default): create a fresh sandbox per `_run` call and
delete it when the call returns. Safer and stateless — nothing leaks if
the agent forgets cleanup.
- persistent=True: lazily create a single sandbox on first use, cache it
on the instance, and register an atexit hook to delete it at process
exit. Cheaper across many calls and lets files/state carry over.
- sandbox_id=<existing>: attach to a sandbox the caller already owns.
Never deleted by the tool.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
package_dependencies: list[str] = Field(default_factory=lambda: ["daytona"])
api_key: str | None = Field(
default_factory=lambda: os.getenv("DAYTONA_API_KEY"),
description="Daytona API key. Falls back to DAYTONA_API_KEY env var.",
json_schema_extra={"required": False},
)
api_url: str | None = Field(
default_factory=lambda: os.getenv("DAYTONA_API_URL"),
description="Daytona API URL override. Falls back to DAYTONA_API_URL env var.",
json_schema_extra={"required": False},
)
target: str | None = Field(
default_factory=lambda: os.getenv("DAYTONA_TARGET"),
description="Daytona target region. Falls back to DAYTONA_TARGET env var.",
json_schema_extra={"required": False},
)
persistent: bool = Field(
default=False,
description=(
"If True, reuse one sandbox across all calls to this tool instance "
"and delete it at process exit. Default False creates and deletes a "
"fresh sandbox per call."
),
)
sandbox_id: str | None = Field(
default=None,
description=(
"Attach to an existing sandbox by id or name instead of creating a "
"new one. The tool will never delete a sandbox it did not create."
),
)
create_params: dict[str, Any] | None = Field(
default=None,
description=(
"Optional kwargs forwarded to CreateSandboxFromSnapshotParams when "
"creating a sandbox (e.g. language, snapshot, env_vars, labels)."
),
)
sandbox_timeout: float = Field(
default=60.0,
description="Timeout in seconds for sandbox create/delete operations.",
)
env_vars: list[EnvVar] = Field(
default_factory=lambda: [
EnvVar(
name="DAYTONA_API_KEY",
description="API key for Daytona sandbox service",
required=False,
),
EnvVar(
name="DAYTONA_API_URL",
description="Daytona API base URL (optional)",
required=False,
),
EnvVar(
name="DAYTONA_TARGET",
description="Daytona target region (optional)",
required=False,
),
]
)
_client: Any | None = PrivateAttr(default=None)
_persistent_sandbox: Any | None = PrivateAttr(default=None)
_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
_cleanup_registered: bool = PrivateAttr(default=False)
_sdk_cache: ClassVar[dict[str, Any]] = {}
@classmethod
def _import_sdk(cls) -> dict[str, Any]:
if cls._sdk_cache:
return cls._sdk_cache
try:
from daytona import (
CreateSandboxFromSnapshotParams,
Daytona,
DaytonaConfig,
)
except ImportError as exc:
raise ImportError(
"The 'daytona' package is required for Daytona sandbox tools. "
"Install it with: uv add daytona (or) pip install daytona"
) from exc
cls._sdk_cache = {
"Daytona": Daytona,
"DaytonaConfig": DaytonaConfig,
"CreateSandboxFromSnapshotParams": CreateSandboxFromSnapshotParams,
}
return cls._sdk_cache
def _get_client(self) -> Any:
if self._client is not None:
return self._client
sdk = self._import_sdk()
config_kwargs: dict[str, Any] = {}
if self.api_key:
config_kwargs["api_key"] = self.api_key
if self.api_url:
config_kwargs["api_url"] = self.api_url
if self.target:
config_kwargs["target"] = self.target
config = sdk["DaytonaConfig"](**config_kwargs) if config_kwargs else None
self._client = sdk["Daytona"](config) if config else sdk["Daytona"]()
return self._client
def _build_create_params(self) -> Any | None:
if not self.create_params:
return None
sdk = self._import_sdk()
return sdk["CreateSandboxFromSnapshotParams"](**self.create_params)
def _acquire_sandbox(self) -> tuple[Any, bool]:
"""Return (sandbox, should_delete_after_use)."""
client = self._get_client()
if self.sandbox_id:
return client.get(self.sandbox_id), False
if self.persistent:
with self._lock:
if self._persistent_sandbox is None:
self._persistent_sandbox = client.create(
self._build_create_params(),
timeout=self.sandbox_timeout,
)
if not self._cleanup_registered:
atexit.register(self.close)
self._cleanup_registered = True
return self._persistent_sandbox, False
sandbox = client.create(
self._build_create_params(),
timeout=self.sandbox_timeout,
)
return sandbox, True
def _release_sandbox(self, sandbox: Any, should_delete: bool) -> None:
if not should_delete:
return
try:
sandbox.delete(timeout=self.sandbox_timeout)
except Exception:
logger.debug(
"Best-effort sandbox cleanup failed after ephemeral use; "
"the sandbox may need manual deletion.",
exc_info=True,
)
def close(self) -> None:
"""Delete the cached persistent sandbox if one exists."""
with self._lock:
sandbox = self._persistent_sandbox
self._persistent_sandbox = None
if sandbox is None:
return
try:
sandbox.delete(timeout=self.sandbox_timeout)
except Exception:
logger.debug(
"Best-effort persistent sandbox cleanup failed at close(); "
"the sandbox may need manual deletion.",
exc_info=True,
)

View File

@@ -0,0 +1,59 @@
from __future__ import annotations
from builtins import type as type_
from typing import Any
from pydantic import BaseModel, Field
from crewai_tools.tools.daytona_sandbox_tool.daytona_base_tool import DaytonaBaseTool
class DaytonaExecToolSchema(BaseModel):
command: str = Field(..., description="Shell command to execute in the sandbox.")
cwd: str | None = Field(
default=None,
description="Working directory to run the command in. Defaults to the sandbox work dir.",
)
env: dict[str, str] | None = Field(
default=None,
description="Optional environment variables to set for this command.",
)
timeout: int | None = Field(
default=None,
description="Maximum seconds to wait for the command to finish.",
)
class DaytonaExecTool(DaytonaBaseTool):
"""Run a shell command inside a Daytona sandbox."""
name: str = "Daytona Sandbox Exec"
description: str = (
"Execute a shell command inside a Daytona sandbox and return the exit "
"code and combined output. Use this to run builds, package installs, "
"git operations, or any one-off shell command."
)
args_schema: type_[BaseModel] = DaytonaExecToolSchema
def _run(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = None,
) -> Any:
sandbox, should_delete = self._acquire_sandbox()
try:
response = sandbox.process.exec(
command,
cwd=cwd,
env=env,
timeout=timeout,
)
return {
"exit_code": getattr(response, "exit_code", None),
"result": getattr(response, "result", None),
"artifacts": getattr(response, "artifacts", None),
}
finally:
self._release_sandbox(sandbox, should_delete)

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import base64
from builtins import type as type_
import logging
import posixpath
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
from crewai_tools.tools.daytona_sandbox_tool.daytona_base_tool import DaytonaBaseTool
logger = logging.getLogger(__name__)
FileAction = Literal["read", "write", "append", "list", "delete", "mkdir", "info"]
class DaytonaFileToolSchema(BaseModel):
action: FileAction = Field(
...,
description=(
"The filesystem action to perform: 'read' (returns file contents), "
"'write' (create or replace a file with content), 'append' (append "
"content to an existing file — use this for writing large files in "
"chunks to avoid hitting tool-call size limits), 'list' (lists a "
"directory), 'delete' (removes a file/dir), 'mkdir' (creates a "
"directory), 'info' (returns file metadata)."
),
)
path: str = Field(..., description="Absolute path inside the sandbox.")
content: str | None = Field(
default=None,
description=(
"Content to write or append. If omitted for 'write', an empty file "
"is created. For files larger than a few KB, prefer one 'write' "
"with empty content followed by multiple 'append' calls of ~4KB "
"each to stay within tool-call payload limits."
),
)
binary: bool = Field(
default=False,
description=(
"For 'write': treat content as base64 and upload raw bytes. "
"For 'read': return contents as base64 instead of decoded utf-8."
),
)
recursive: bool = Field(
default=False,
description="For action='delete': remove directories recursively.",
)
mode: str = Field(
default="0755",
description="For action='mkdir': octal permission string (default 0755).",
)
@model_validator(mode="after")
def _validate_action_args(self) -> DaytonaFileToolSchema:
if self.action == "append" and self.content is None:
raise ValueError(
"action='append' requires 'content'. Pass the chunk to append "
"in the 'content' field."
)
return self
class DaytonaFileTool(DaytonaBaseTool):
"""Read, write, and manage files inside a Daytona sandbox.
Notes:
- Most useful with `persistent=True` or an explicit `sandbox_id`. With the
default ephemeral mode, files disappear when this tool call finishes.
"""
name: str = "Daytona Sandbox Files"
description: str = (
"Perform filesystem operations inside a Daytona sandbox: read a file, "
"write content to a path, append content to an existing file, list a "
"directory, delete a path, make a directory, or fetch file metadata. "
"For files larger than a few KB, create the file with action='write' "
"and empty content, then send the body via multiple 'append' calls of "
"~4KB each to stay within tool-call payload limits."
)
args_schema: type_[BaseModel] = DaytonaFileToolSchema
def _run(
self,
action: FileAction,
path: str,
content: str | None = None,
binary: bool = False,
recursive: bool = False,
mode: str = "0755",
) -> Any:
sandbox, should_delete = self._acquire_sandbox()
try:
if action == "read":
return self._read(sandbox, path, binary=binary)
if action == "write":
return self._write(sandbox, path, content or "", binary=binary)
if action == "append":
return self._append(sandbox, path, content or "", binary=binary)
if action == "list":
return self._list(sandbox, path)
if action == "delete":
sandbox.fs.delete_file(path, recursive=recursive)
return {"status": "deleted", "path": path}
if action == "mkdir":
sandbox.fs.create_folder(path, mode)
return {"status": "created", "path": path, "mode": mode}
if action == "info":
return self._info(sandbox, path)
raise ValueError(f"Unknown action: {action}")
finally:
self._release_sandbox(sandbox, should_delete)
def _read(self, sandbox: Any, path: str, *, binary: bool) -> dict[str, Any]:
data: bytes = sandbox.fs.download_file(path)
if binary:
return {
"path": path,
"encoding": "base64",
"content": base64.b64encode(data).decode("ascii"),
}
try:
return {"path": path, "encoding": "utf-8", "content": data.decode("utf-8")}
except UnicodeDecodeError:
return {
"path": path,
"encoding": "base64",
"content": base64.b64encode(data).decode("ascii"),
"note": "File was not valid utf-8; returned as base64.",
}
def _write(
self, sandbox: Any, path: str, content: str, *, binary: bool
) -> dict[str, Any]:
payload = base64.b64decode(content) if binary else content.encode("utf-8")
self._ensure_parent_dir(sandbox, path)
sandbox.fs.upload_file(payload, path)
return {"status": "written", "path": path, "bytes": len(payload)}
def _append(
self, sandbox: Any, path: str, content: str, *, binary: bool
) -> dict[str, Any]:
chunk = base64.b64decode(content) if binary else content.encode("utf-8")
self._ensure_parent_dir(sandbox, path)
try:
existing: bytes = sandbox.fs.download_file(path)
except Exception:
existing = b""
payload = existing + chunk
sandbox.fs.upload_file(payload, path)
return {
"status": "appended",
"path": path,
"appended_bytes": len(chunk),
"total_bytes": len(payload),
}
@staticmethod
def _ensure_parent_dir(sandbox: Any, path: str) -> None:
"""Make sure the parent directory of `path` exists.
Daytona's upload returns 400 if the parent directory is missing. We
best-effort mkdir the parent; any error (e.g. already exists) is
swallowed because `create_folder` is not idempotent on the server.
"""
parent = posixpath.dirname(path)
if not parent or parent in ("/", "."):
return
try:
sandbox.fs.create_folder(parent, "0755")
except Exception:
logger.debug(
"Best-effort parent-directory create failed for %s; "
"assuming it already exists and proceeding with the write.",
parent,
exc_info=True,
)
def _list(self, sandbox: Any, path: str) -> dict[str, Any]:
entries = sandbox.fs.list_files(path)
return {
"path": path,
"entries": [self._file_info_to_dict(entry) for entry in entries],
}
def _info(self, sandbox: Any, path: str) -> dict[str, Any]:
return self._file_info_to_dict(sandbox.fs.get_file_info(path))
@staticmethod
def _file_info_to_dict(info: Any) -> dict[str, Any]:
fields = (
"name",
"size",
"mode",
"permissions",
"is_dir",
"mod_time",
"owner",
"group",
)
return {field: getattr(info, field, None) for field in fields}

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from builtins import type as type_
from typing import Any
from pydantic import BaseModel, Field
from crewai_tools.tools.daytona_sandbox_tool.daytona_base_tool import DaytonaBaseTool
class DaytonaPythonToolSchema(BaseModel):
code: str = Field(
...,
description="Python source to execute inside the sandbox.",
)
argv: list[str] | None = Field(
default=None,
description="Optional argv passed to the script (forwarded as params.argv).",
)
env: dict[str, str] | None = Field(
default=None,
description="Optional environment variables for the run (forwarded as params.env).",
)
timeout: int | None = Field(
default=None,
description="Maximum seconds to wait for the code to finish.",
)
class DaytonaPythonTool(DaytonaBaseTool):
"""Run Python source inside a Daytona sandbox."""
name: str = "Daytona Sandbox Python"
description: str = (
"Execute a block of Python code inside a Daytona sandbox and return the "
"exit code, captured stdout, and any produced artifacts. Use this for "
"data processing, quick scripts, or analysis that should run in an "
"isolated environment."
)
args_schema: type_[BaseModel] = DaytonaPythonToolSchema
def _run(
self,
code: str,
argv: list[str] | None = None,
env: dict[str, str] | None = None,
timeout: int | None = None,
) -> Any:
sandbox, should_delete = self._acquire_sandbox()
try:
params = self._build_code_run_params(argv=argv, env=env)
response = sandbox.process.code_run(code, params=params, timeout=timeout)
return {
"exit_code": getattr(response, "exit_code", None),
"result": getattr(response, "result", None),
"artifacts": getattr(response, "artifacts", None),
}
finally:
self._release_sandbox(sandbox, should_delete)
def _build_code_run_params(
self,
argv: list[str] | None,
env: dict[str, str] | None,
) -> Any | None:
if argv is None and env is None:
return None
try:
from daytona import CodeRunParams
except ImportError as exc:
raise ImportError(
"Could not import daytona.CodeRunParams while building "
"argv/env for sandbox.process.code_run. This usually means the "
"installed 'daytona' SDK is too old or incompatible. Upgrade "
"with: pip install -U 'crewai-tools[daytona]'"
) from exc
kwargs: dict[str, Any] = {}
if argv is not None:
kwargs["argv"] = argv
if env is not None:
kwargs["env"] = env
return CodeRunParams(**kwargs)

View File

@@ -0,0 +1,120 @@
# E2B Sandbox Tools
Run shell commands, execute Python, and manage files inside an [E2B](https://e2b.dev/) sandbox. E2B provides isolated, ephemeral VMs suitable for agent-driven code execution, with a Jupyter-style code interpreter for rich Python results.
Three tools are provided so you can pick what the agent actually needs:
- **`E2BExecTool`** — run a shell command (`sandbox.commands.run`).
- **`E2BPythonTool`** — run a Python cell in the E2B code interpreter (`sandbox.run_code`), returning stdout/stderr and rich results (charts, dataframes).
- **`E2BFileTool`** — read / write / list / delete files (`sandbox.files.*`).
## Installation
```shell
uv add "crewai-tools[e2b]"
# or
pip install "crewai-tools[e2b]"
```
Set the API key:
```shell
export E2B_API_KEY="..."
```
`E2B_DOMAIN` is also respected if set (for self-hosted or non-default deployments).
## Sandbox lifecycle
All three tools share the same lifecycle controls from `E2BBaseTool`:
| Mode | When the sandbox is created | When it is killed |
| --- | --- | --- |
| **Ephemeral** (default, `persistent=False`) | On every `_run` call | At the end of that same call |
| **Persistent** (`persistent=True`) | Lazily on first use | At process exit (via `atexit`), or manually via `tool.close()` |
| **Attach** (`sandbox_id="…"`) | Never — the tool attaches to an existing sandbox | Never — the tool will not kill a sandbox it did not create |
Ephemeral mode is the safe default: nothing leaks if the agent forgets to clean up. Use persistent mode when you want filesystem state or installed packages to carry across steps — this is typical when pairing `E2BFileTool` with `E2BExecTool`.
E2B sandboxes also auto-expire after an idle timeout. Tune it via `sandbox_timeout` (seconds, default `300`).
## Examples
### One-shot Python execution (ephemeral)
```python
from crewai_tools import E2BPythonTool
tool = E2BPythonTool()
result = tool.run(code="print(sum(range(10)))")
```
### Multi-step shell session (persistent)
```python
from crewai_tools import E2BExecTool, E2BFileTool
exec_tool = E2BExecTool(persistent=True)
file_tool = E2BFileTool(persistent=True)
# Each tool keeps its own persistent sandbox. If you need the *same* sandbox
# across two tools, create one tool, grab the sandbox id via
# `tool._persistent_sandbox.sandbox_id`, and pass it to the other via
# `sandbox_id=...`.
```
### Attach to an existing sandbox
```python
from crewai_tools import E2BExecTool
tool = E2BExecTool(sandbox_id="sbx_...")
```
### Custom create params
```python
tool = E2BExecTool(
persistent=True,
template="my-custom-template",
sandbox_timeout=600,
envs={"MY_FLAG": "1"},
metadata={"owner": "crewai-agent"},
)
```
## Tool arguments
### `E2BExecTool`
- `command: str` — shell command to run.
- `cwd: str | None` — working directory.
- `envs: dict[str, str] | None` — extra env vars for this command.
- `timeout: float | None` — seconds.
### `E2BPythonTool`
- `code: str` — source to execute.
- `language: str | None` — override kernel language (default: Python).
- `envs: dict[str, str] | None` — env vars for the run.
- `timeout: float | None` — seconds.
### `E2BFileTool`
- `action: "read" | "write" | "append" | "list" | "delete" | "mkdir" | "info" | "exists"`
- `path: str` — absolute path inside the sandbox.
- `content: str | None` — required for `append`; optional for `write`.
- `binary: bool` — if `True`, `content` is base64 on write / returned as base64 on read.
- `depth: int` — for `list`, how many levels to recurse (default 1).
## Security considerations
These tools hand the LLM arbitrary shell, Python, and filesystem access inside a remote VM. The threat model to keep in mind:
- **Prompt-injection is a code-execution vector.** If the agent ingests untrusted content (web pages, scraped documents, user-supplied files, emails, search results), a malicious instruction hidden in that content can coerce the agent into issuing commands to `E2BExecTool` / `E2BPythonTool`. Treat any pipeline that feeds untrusted text into an agent that also has these tools as equivalent to remote code execution — the LLM is the attacker's shell.
- **Ephemeral mode (the default) is the main blast-radius control.** A fresh sandbox is created per call and killed at the end, so injected commands cannot persist state, exfiltrate long-lived secrets, or build up tooling across turns. Leave `persistent=False` unless you have a concrete reason to change it.
- **Avoid this specific combination:**
- untrusted content in the agent's context, **plus**
- `persistent=True` or an explicit long-lived `sandbox_id`, **plus**
- a large `sandbox_timeout` or credentials/secrets seeded into the sandbox via `envs`.
That stack lets a single injection pivot into a long-running, credentialed shell that survives across turns. If you must run persistently, also keep `sandbox_timeout` short, scope `envs` to the minimum the task needs, and don't feed the same agent untrusted input.
- **Don't mount production credentials.** Anything you put into `envs`, `metadata`, or files written to the sandbox is reachable from the LLM. Use per-task scoped keys, not your personal API tokens.
- **E2B's VM isolation is the final backstop**, not a license to relax the above — isolation prevents escape to the host, but everything the sandbox can reach (the public internet, any service whose token you dropped in) is still fair game for an injected command.

View File

@@ -0,0 +1,12 @@
from crewai_tools.tools.e2b_sandbox_tool.e2b_base_tool import E2BBaseTool
from crewai_tools.tools.e2b_sandbox_tool.e2b_exec_tool import E2BExecTool
from crewai_tools.tools.e2b_sandbox_tool.e2b_file_tool import E2BFileTool
from crewai_tools.tools.e2b_sandbox_tool.e2b_python_tool import E2BPythonTool
__all__ = [
"E2BBaseTool",
"E2BExecTool",
"E2BFileTool",
"E2BPythonTool",
]

View File

@@ -0,0 +1,197 @@
from __future__ import annotations
import atexit
import logging
import os
import threading
from typing import Any, ClassVar
from crewai.tools import BaseTool, EnvVar
from pydantic import ConfigDict, Field, PrivateAttr, SecretStr
logger = logging.getLogger(__name__)
class E2BBaseTool(BaseTool):
"""Shared base for tools that act on an E2B sandbox.
Lifecycle modes:
- persistent=False (default): create a fresh sandbox per `_run` call and
kill it when the call returns. Safer and stateless — nothing leaks if
the agent forgets cleanup.
- persistent=True: lazily create a single sandbox on first use, cache it
on the instance, and register an atexit hook to kill it at process
exit. Cheaper across many calls and lets files/state carry over.
- sandbox_id=<existing>: attach to a sandbox the caller already owns.
Never killed by the tool.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
package_dependencies: list[str] = Field(default_factory=lambda: ["e2b"])
api_key: SecretStr | None = Field(
default_factory=lambda: (
SecretStr(val) if (val := os.getenv("E2B_API_KEY")) else None
),
description="E2B API key. Falls back to E2B_API_KEY env var.",
json_schema_extra={"required": False},
repr=False,
)
domain: str | None = Field(
default_factory=lambda: os.getenv("E2B_DOMAIN"),
description="E2B API domain override. Falls back to E2B_DOMAIN env var.",
json_schema_extra={"required": False},
)
template: str | None = Field(
default=None,
description=(
"Optional template/snapshot name or id to create the sandbox from. "
"Defaults to E2B's base template when omitted."
),
)
persistent: bool = Field(
default=False,
description=(
"If True, reuse one sandbox across all calls to this tool instance "
"and kill it at process exit. Default False creates and kills a "
"fresh sandbox per call."
),
)
sandbox_id: str | None = Field(
default=None,
description=(
"Attach to an existing sandbox by id instead of creating a new "
"one. The tool will never kill a sandbox it did not create."
),
)
sandbox_timeout: int = Field(
default=300,
description=(
"Idle timeout in seconds after which E2B auto-kills the sandbox. "
"Applied at create time and when attaching via sandbox_id."
),
)
envs: dict[str, str] | None = Field(
default=None,
description="Environment variables to set inside the sandbox at create time.",
)
metadata: dict[str, str] | None = Field(
default=None,
description="Metadata key-value pairs to attach to the sandbox at create time.",
)
env_vars: list[EnvVar] = Field(
default_factory=lambda: [
EnvVar(
name="E2B_API_KEY",
description="API key for E2B sandbox service",
required=False,
),
EnvVar(
name="E2B_DOMAIN",
description="E2B API domain (optional)",
required=False,
),
]
)
_persistent_sandbox: Any | None = PrivateAttr(default=None)
_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
_cleanup_registered: bool = PrivateAttr(default=False)
_sdk_cache: ClassVar[dict[str, Any]] = {}
@classmethod
def _import_sandbox_class(cls) -> Any:
"""Return the Sandbox class used by this tool.
Subclasses override this to swap in a different SDK (e.g. the code
interpreter sandbox). The default uses plain `e2b.Sandbox`.
"""
cached = cls._sdk_cache.get("e2b.Sandbox")
if cached is not None:
return cached
try:
from e2b import Sandbox # type: ignore[import-untyped]
except ImportError as exc:
raise ImportError(
"The 'e2b' package is required for E2B sandbox tools. "
"Install it with: uv add e2b (or) pip install e2b"
) from exc
cls._sdk_cache["e2b.Sandbox"] = Sandbox
return Sandbox
def _connect_kwargs(self) -> dict[str, Any]:
kwargs: dict[str, Any] = {}
if self.api_key is not None:
kwargs["api_key"] = self.api_key.get_secret_value()
if self.domain:
kwargs["domain"] = self.domain
if self.sandbox_timeout is not None:
kwargs["timeout"] = self.sandbox_timeout
return kwargs
def _create_kwargs(self) -> dict[str, Any]:
kwargs: dict[str, Any] = self._connect_kwargs()
if self.template is not None:
kwargs["template"] = self.template
if self.envs is not None:
kwargs["envs"] = self.envs
if self.metadata is not None:
kwargs["metadata"] = self.metadata
return kwargs
def _acquire_sandbox(self) -> tuple[Any, bool]:
"""Return (sandbox, should_kill_after_use)."""
sandbox_cls = self._import_sandbox_class()
if self.sandbox_id:
return (
sandbox_cls.connect(self.sandbox_id, **self._connect_kwargs()),
False,
)
if self.persistent:
with self._lock:
if self._persistent_sandbox is None:
self._persistent_sandbox = sandbox_cls.create(
**self._create_kwargs()
)
if not self._cleanup_registered:
atexit.register(self.close)
self._cleanup_registered = True
return self._persistent_sandbox, False
sandbox = sandbox_cls.create(**self._create_kwargs())
return sandbox, True
def _release_sandbox(self, sandbox: Any, should_kill: bool) -> None:
if not should_kill:
return
try:
sandbox.kill()
except Exception:
logger.debug(
"Best-effort sandbox cleanup failed after ephemeral use; "
"the sandbox may need manual termination.",
exc_info=True,
)
def close(self) -> None:
"""Kill the cached persistent sandbox if one exists."""
with self._lock:
sandbox = self._persistent_sandbox
self._persistent_sandbox = None
if sandbox is None:
return
try:
sandbox.kill()
except Exception:
logger.debug(
"Best-effort persistent sandbox cleanup failed at close(); "
"the sandbox may need manual termination.",
exc_info=True,
)

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from builtins import type as type_
from typing import Any
from pydantic import BaseModel, Field
from crewai_tools.tools.e2b_sandbox_tool.e2b_base_tool import E2BBaseTool
class E2BExecToolSchema(BaseModel):
command: str = Field(..., description="Shell command to execute in the sandbox.")
cwd: str | None = Field(
default=None,
description="Working directory to run the command in. Defaults to the sandbox home dir.",
)
envs: dict[str, str] | None = Field(
default=None,
description="Optional environment variables to set for this command.",
)
timeout: float | None = Field(
default=None,
description="Maximum seconds to wait for the command to finish.",
)
class E2BExecTool(E2BBaseTool):
"""Run a shell command inside an E2B sandbox."""
name: str = "E2B Sandbox Exec"
description: str = (
"Execute a shell command inside an E2B sandbox and return the exit "
"code, stdout, and stderr. Use this to run builds, package installs, "
"git operations, or any one-off shell command."
)
args_schema: type_[BaseModel] = E2BExecToolSchema
def _run(
self,
command: str,
cwd: str | None = None,
envs: dict[str, str] | None = None,
timeout: float | None = None,
) -> Any:
sandbox, should_kill = self._acquire_sandbox()
try:
run_kwargs: dict[str, Any] = {}
if cwd is not None:
run_kwargs["cwd"] = cwd
if envs is not None:
run_kwargs["envs"] = envs
if timeout is not None:
run_kwargs["timeout"] = timeout
result = sandbox.commands.run(command, **run_kwargs)
return {
"exit_code": getattr(result, "exit_code", None),
"stdout": getattr(result, "stdout", None),
"stderr": getattr(result, "stderr", None),
"error": getattr(result, "error", None),
}
finally:
self._release_sandbox(sandbox, should_kill)

View File

@@ -0,0 +1,220 @@
from __future__ import annotations
import base64
from builtins import type as type_
import logging
import posixpath
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
from crewai_tools.tools.e2b_sandbox_tool.e2b_base_tool import E2BBaseTool
logger = logging.getLogger(__name__)
FileAction = Literal[
"read", "write", "append", "list", "delete", "mkdir", "info", "exists"
]
class E2BFileToolSchema(BaseModel):
action: FileAction = Field(
...,
description=(
"The filesystem action to perform: 'read' (returns file contents), "
"'write' (create or replace a file with content), 'append' (append "
"content to an existing file — use this for writing large files in "
"chunks to avoid hitting tool-call size limits), 'list' (lists a "
"directory), 'delete' (removes a file/dir), 'mkdir' (creates a "
"directory), 'info' (returns file metadata), 'exists' (returns a "
"boolean for whether the path exists)."
),
)
path: str = Field(..., description="Absolute path inside the sandbox.")
content: str | None = Field(
default=None,
description=(
"Content to write or append. If omitted for 'write', an empty file "
"is created. For files larger than a few KB, prefer one 'write' "
"with empty content followed by multiple 'append' calls of ~4KB "
"each to stay within tool-call payload limits."
),
)
binary: bool = Field(
default=False,
description=(
"For 'write'/'append': treat content as base64 and upload raw "
"bytes. For 'read': return contents as base64 instead of decoded "
"utf-8."
),
)
depth: int = Field(
default=1,
description="For action='list': how many levels deep to recurse (default 1).",
)
@model_validator(mode="after")
def _validate_action_args(self) -> E2BFileToolSchema:
if self.action == "append" and self.content is None:
raise ValueError(
"action='append' requires 'content'. Pass the chunk to append "
"in the 'content' field."
)
return self
class E2BFileTool(E2BBaseTool):
"""Read, write, and manage files inside an E2B sandbox.
Notes:
- Most useful with `persistent=True` or an explicit `sandbox_id`. With
the default ephemeral mode, files disappear when this tool call
finishes.
"""
name: str = "E2B Sandbox Files"
description: str = (
"Perform filesystem operations inside an E2B sandbox: read a file, "
"write content to a path, append content to an existing file, list a "
"directory, delete a path, make a directory, fetch file metadata, or "
"check whether a path exists. For files larger than a few KB, create "
"the file with action='write' and empty content, then send the body "
"via multiple 'append' calls of ~4KB each to stay within tool-call "
"payload limits."
)
args_schema: type_[BaseModel] = E2BFileToolSchema
def _run(
self,
action: FileAction,
path: str,
content: str | None = None,
binary: bool = False,
depth: int = 1,
) -> Any:
sandbox, should_kill = self._acquire_sandbox()
try:
if action == "read":
return self._read(sandbox, path, binary=binary)
if action == "write":
return self._write(sandbox, path, content or "", binary=binary)
if action == "append":
return self._append(sandbox, path, content or "", binary=binary)
if action == "list":
return self._list(sandbox, path, depth=depth)
if action == "delete":
sandbox.files.remove(path)
return {"status": "deleted", "path": path}
if action == "mkdir":
created = sandbox.files.make_dir(path)
return {"status": "created", "path": path, "created": bool(created)}
if action == "info":
return self._info(sandbox, path)
if action == "exists":
return {"path": path, "exists": bool(sandbox.files.exists(path))}
raise ValueError(f"Unknown action: {action}")
finally:
self._release_sandbox(sandbox, should_kill)
def _read(self, sandbox: Any, path: str, *, binary: bool) -> dict[str, Any]:
if binary:
data: bytes = sandbox.files.read(path, format="bytes")
return {
"path": path,
"encoding": "base64",
"content": base64.b64encode(data).decode("ascii"),
}
try:
content: str = sandbox.files.read(path)
return {"path": path, "encoding": "utf-8", "content": content}
except UnicodeDecodeError:
data = sandbox.files.read(path, format="bytes")
return {
"path": path,
"encoding": "base64",
"content": base64.b64encode(data).decode("ascii"),
"note": "File was not valid utf-8; returned as base64.",
}
def _write(
self, sandbox: Any, path: str, content: str, *, binary: bool
) -> dict[str, Any]:
payload: str | bytes = base64.b64decode(content) if binary else content
self._ensure_parent_dir(sandbox, path)
sandbox.files.write(path, payload)
size = (
len(payload)
if isinstance(payload, (bytes, bytearray))
else len(payload.encode("utf-8"))
)
return {"status": "written", "path": path, "bytes": size}
def _append(
self, sandbox: Any, path: str, content: str, *, binary: bool
) -> dict[str, Any]:
chunk: bytes = base64.b64decode(content) if binary else content.encode("utf-8")
self._ensure_parent_dir(sandbox, path)
try:
existing: bytes = sandbox.files.read(path, format="bytes")
except Exception:
existing = b""
payload = existing + chunk
sandbox.files.write(path, payload)
return {
"status": "appended",
"path": path,
"appended_bytes": len(chunk),
"total_bytes": len(payload),
}
@staticmethod
def _ensure_parent_dir(sandbox: Any, path: str) -> None:
parent = posixpath.dirname(path)
if not parent or parent in ("/", "."):
return
try:
sandbox.files.make_dir(parent)
except Exception:
logger.debug(
"Best-effort parent-directory create failed for %s; "
"assuming it already exists and proceeding with the write.",
parent,
exc_info=True,
)
def _list(self, sandbox: Any, path: str, *, depth: int) -> dict[str, Any]:
entries = sandbox.files.list(path, depth=depth)
return {
"path": path,
"entries": [self._entry_to_dict(e) for e in entries],
}
def _info(self, sandbox: Any, path: str) -> dict[str, Any]:
return self._entry_to_dict(sandbox.files.get_info(path))
@staticmethod
def _entry_to_dict(entry: Any) -> dict[str, Any]:
fields = (
"name",
"path",
"type",
"size",
"mode",
"permissions",
"owner",
"group",
"modified_time",
"symlink_target",
)
result: dict[str, Any] = {}
for field in fields:
value = getattr(entry, field, None)
if value is not None and field == "modified_time":
result[field] = (
value.isoformat() if hasattr(value, "isoformat") else str(value)
)
else:
result[field] = value
return result

View File

@@ -0,0 +1,133 @@
from __future__ import annotations
from builtins import type as type_
from typing import Any, ClassVar
from pydantic import BaseModel, Field
from crewai_tools.tools.e2b_sandbox_tool.e2b_base_tool import E2BBaseTool
class E2BPythonToolSchema(BaseModel):
code: str = Field(
...,
description="Python source to execute inside the sandbox.",
)
language: str | None = Field(
default=None,
description=(
"Override the execution language (e.g. 'python', 'r', 'javascript'). "
"Defaults to Python when omitted."
),
)
envs: dict[str, str] | None = Field(
default=None,
description="Optional environment variables for the run.",
)
timeout: float | None = Field(
default=None,
description="Maximum seconds to wait for the code to finish.",
)
class E2BPythonTool(E2BBaseTool):
"""Run Python code inside an E2B code interpreter sandbox.
Uses `e2b_code_interpreter`, which runs cells in a persistent Jupyter-style
kernel so state (imports, variables) carries across calls when
`persistent=True`.
"""
name: str = "E2B Sandbox Python"
description: str = (
"Execute a block of Python code inside an E2B code interpreter sandbox "
"and return captured stdout, stderr, the final expression value, and "
"any rich results (charts, dataframes). Use this for data processing, "
"quick scripts, or analysis that should run in an isolated environment."
)
args_schema: type_[BaseModel] = E2BPythonToolSchema
package_dependencies: list[str] = Field(
default_factory=lambda: ["e2b_code_interpreter"],
)
_ci_cache: ClassVar[dict[str, Any]] = {}
@classmethod
def _import_sandbox_class(cls) -> Any:
cached = cls._ci_cache.get("Sandbox")
if cached is not None:
return cached
try:
from e2b_code_interpreter import Sandbox # type: ignore[import-untyped]
except ImportError as exc:
raise ImportError(
"The 'e2b_code_interpreter' package is required for the E2B "
"Python tool. Install it with: "
"uv add e2b-code-interpreter (or) "
"pip install e2b-code-interpreter"
) from exc
cls._ci_cache["Sandbox"] = Sandbox
return Sandbox
def _run(
self,
code: str,
language: str | None = None,
envs: dict[str, str] | None = None,
timeout: float | None = None,
) -> Any:
sandbox, should_kill = self._acquire_sandbox()
try:
run_kwargs: dict[str, Any] = {}
if language is not None:
run_kwargs["language"] = language
if envs is not None:
run_kwargs["envs"] = envs
if timeout is not None:
run_kwargs["timeout"] = timeout
execution = sandbox.run_code(code, **run_kwargs)
return self._serialize_execution(execution)
finally:
self._release_sandbox(sandbox, should_kill)
@staticmethod
def _serialize_execution(execution: Any) -> dict[str, Any]:
logs = getattr(execution, "logs", None)
error = getattr(execution, "error", None)
results = getattr(execution, "results", None) or []
return {
"text": getattr(execution, "text", None),
"stdout": list(getattr(logs, "stdout", []) or []) if logs else [],
"stderr": list(getattr(logs, "stderr", []) or []) if logs else [],
"error": (
{
"name": getattr(error, "name", None),
"value": getattr(error, "value", None),
"traceback": getattr(error, "traceback", None),
}
if error
else None
),
"results": [E2BPythonTool._serialize_result(r) for r in results],
"execution_count": getattr(execution, "execution_count", None),
}
@staticmethod
def _serialize_result(result: Any) -> dict[str, Any]:
fields = (
"text",
"html",
"markdown",
"svg",
"png",
"jpeg",
"pdf",
"latex",
"json",
"javascript",
"data",
"is_main_result",
"extra",
)
return {field: getattr(result, field, None) for field in fields}

View File

@@ -9,7 +9,7 @@ The `TavilyExtractorTool` allows CrewAI agents to extract structured content fro
To use the `TavilyExtractorTool`, you need to install the `tavily-python` library:
```shell
pip install 'crewai[tools]' tavily-python
uv add 'crewai[tools]' tavily-python
```
You also need to set your Tavily API key as an environment variable:

View File

@@ -0,0 +1,44 @@
# Tavily Get Research Tool
## Description
The `TavilyGetResearchTool` provides an interface to Tavily's research status endpoint through the Tavily Python SDK. It retrieves the current status and results of an existing Tavily research task by `request_id`.
## Installation
To use the `TavilyGetResearchTool`, you need to install the `tavily-python` library:
```shell
uv add 'crewai[tools]' tavily-python
```
## Environment Variables
Ensure your Tavily API key is set as an environment variable:
```bash
export TAVILY_API_KEY='your_tavily_api_key'
```
## Example
```python
from crewai_tools import TavilyGetResearchTool
tavily_get_research_tool = TavilyGetResearchTool()
status_result = tavily_get_research_tool.run(
request_id="Your Request ID Here"
)
print(status_result)
```
## Arguments
The `TavilyGetResearchTool` accepts the following arguments during initialization or when calling the `run` method:
- `request_id` (str): Existing Tavily research request ID to retrieve.
## Response Format
The tool returns a JSON string containing the current research task status and any available results from Tavily.

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
import json
import os
from typing import Any
from crewai.tools import BaseTool, EnvVar
from dotenv import load_dotenv
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
load_dotenv()
try:
from tavily import AsyncTavilyClient, TavilyClient # type: ignore[import-untyped]
TAVILY_AVAILABLE = True
except ImportError:
TAVILY_AVAILABLE = False
class TavilyGetResearchToolSchema(BaseModel):
"""Input schema for TavilyGetResearchTool."""
request_id: str = Field(
...,
description="Existing Tavily research request ID to fetch status and results for.",
)
class TavilyGetResearchTool(BaseTool):
"""Tool that uses the Tavily Research status endpoint to retrieve results."""
model_config = ConfigDict(arbitrary_types_allowed=True)
_client: Any | None = PrivateAttr(default=None)
_async_client: Any | None = PrivateAttr(default=None)
name: str = "Tavily Get Research"
description: str = (
"A tool that retrieves the status and results of an existing Tavily "
"research task by request ID. It returns Tavily responses as JSON."
)
args_schema: type[BaseModel] = TavilyGetResearchToolSchema
package_dependencies: list[str] = Field(default_factory=lambda: ["tavily-python"])
env_vars: list[EnvVar] = Field(
default_factory=lambda: [
EnvVar(
name="TAVILY_API_KEY",
description="API key for Tavily research service",
required=True,
),
]
)
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
if TAVILY_AVAILABLE:
api_key = os.getenv("TAVILY_API_KEY")
self._client = TavilyClient(api_key=api_key)
self._async_client = AsyncTavilyClient(api_key=api_key)
else:
try:
import subprocess
import click
except ImportError as e:
raise ImportError(
"The 'tavily-python' package is required. 'click' and "
"'subprocess' are also needed to assist with installation "
"if the package is missing. Please install 'tavily-python' "
"manually (e.g., 'pip install tavily-python') and ensure "
"'click' and 'subprocess' are available."
) from e
if click.confirm(
"You are missing the 'tavily-python' package, which is required "
"for TavilyGetResearchTool. Would you like to install it?"
):
try:
subprocess.run(["uv", "add", "tavily-python"], check=True) # noqa: S607
raise ImportError(
"'tavily-python' has been installed. Please restart your "
"Python application to use the TavilyGetResearchTool."
)
except subprocess.CalledProcessError as e:
raise ImportError(
f"Attempted to install 'tavily-python' but failed: {e}. "
"Please install it manually to use the TavilyGetResearchTool."
) from e
else:
raise ImportError(
"The 'tavily-python' package is required to use the "
"TavilyGetResearchTool. Please install it with: uv add tavily-python"
)
@staticmethod
def _stringify_response(response: Any) -> str:
if isinstance(response, str):
return response
return json.dumps(response, indent=2)
def _run(self, request_id: str) -> str:
"""Synchronously retrieves Tavily research task status and results."""
if not self._client:
raise ValueError(
"Tavily client is not initialized. Ensure 'tavily-python' is "
"installed and API key is set."
)
return self._stringify_response(self._client.get_research(request_id))
async def _arun(self, request_id: str) -> str:
"""Asynchronously retrieves Tavily research task status and results."""
if not self._async_client:
raise ValueError(
"Tavily async client is not initialized. Ensure 'tavily-python' is "
"installed and API key is set."
)
return self._stringify_response(
await self._async_client.get_research(request_id)
)

View File

@@ -0,0 +1,132 @@
# Tavily Research Tool
## Description
The `TavilyResearchTool` provides an interface to Tavily Research through the Tavily Python SDK. It creates research tasks from an `input` prompt and can optionally stream Server-Sent Events (SSE) when `stream=True`.
## Installation
To use the `TavilyResearchTool`, you need to install the `tavily-python` library:
```shell
uv add 'crewai[tools]' tavily-python
```
## Environment Variables
Ensure your Tavily API key is set as an environment variable:
```bash
export TAVILY_API_KEY='your_tavily_api_key'
```
## Example
Here's how to initialize and use the `TavilyResearchTool` within a CrewAI agent:
```python
from crewai import Agent, Task, Crew
from crewai_tools import TavilyResearchTool
# Initialize the tool
tavily_research_tool = TavilyResearchTool()
# Create an agent that uses the tool
researcher = Agent(
role="Research Analyst",
goal="Produce structured research reports",
backstory="An expert analyst who uses Tavily Research for deep web research.",
tools=[tavily_research_tool],
verbose=True,
)
# Create a task for the agent
research_task = Task(
description="Research the latest developments in AI infrastructure startups.",
expected_output="A detailed report with citations and supporting sources.",
agent=researcher,
)
# Run the crew
crew = Crew(
agents=[researcher],
tasks=[research_task],
verbose=2,
)
result = crew.kickoff()
print(result)
# Direct tool usage: create a structured research task
structured_result = tavily_research_tool.run(
input="Research the latest developments in AI infrastructure startups.",
model="pro",
output_schema={
"properties": {
"summary": {
"type": "string",
"description": "A concise summary of the research findings",
},
"key_trends": {
"type": "array",
"description": "The major trends identified in the research",
"items": {"type": "string"},
},
"companies": {
"type": "array",
"description": "Notable companies mentioned in the research",
"items": {
"type": "object",
"description": "A company entry",
"properties": {
"name": {
"type": "string",
"description": "The company name",
},
"focus": {
"type": "string",
"description": "The company's main area of focus",
},
"notable_update": {
"type": "string",
"description": "A notable recent update about the company",
},
},
"required": ["name", "focus", "notable_update"],
},
},
},
"required": ["summary", "key_trends", "companies"],
},
citation_format="apa",
)
print(structured_result)
# Direct tool usage: stream research updates
stream = tavily_research_tool.run(
input="Research the latest developments in AI infrastructure startups.",
model="mini",
stream=True,
)
for chunk in stream:
print(chunk.decode("utf-8", errors="replace"), end="")
```
## Arguments
The `TavilyResearchTool` accepts the following arguments during initialization or when calling the `run` method:
- `input` (str): The research task or question to investigate.
- `model` (Literal["mini", "pro", "auto"], optional): The Tavily research model to use. Defaults to `"auto"`.
- `output_schema` (dict[str, Any], optional): A JSON Schema used to structure the research output. Tavily expects top-level `properties` and optional `required` keys, and each property should include a `description`.
- `stream` (bool, optional): Whether to return Tavily's streaming SSE chunk generator. Defaults to `False`.
- `citation_format` (Literal["numbered", "mla", "apa", "chicago"], optional): Citation format for the report. Defaults to `"numbered"`.
## Response Format
The tool returns:
- A JSON string when creating a non-streaming research task
- A byte generator of SSE chunks when `stream=True`
Refer to the Tavily Research API documentation for the full response structure and streaming event format.

View File

@@ -0,0 +1,200 @@
from __future__ import annotations
from collections.abc import AsyncGenerator, Generator
import json
import os
from typing import Any, Literal, cast
from crewai.tools import BaseTool, EnvVar
from dotenv import load_dotenv
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
load_dotenv()
try:
from tavily import ( # type: ignore[import-untyped, import-not-found, unused-ignore]
AsyncTavilyClient,
TavilyClient,
)
TAVILY_AVAILABLE = True
except ImportError:
TAVILY_AVAILABLE = False
class TavilyResearchToolSchema(BaseModel):
"""Input schema for TavilyResearchTool."""
input: str = Field(
...,
description="The research task or question to investigate.",
)
model: Literal["mini", "pro", "auto"] = Field(
default="auto",
description="The model used by the Tavily research agent.",
)
output_schema: dict[str, Any] | None = Field(
default=None,
description="Optional JSON Schema that structures the research output.",
)
stream: bool = Field(
default=False,
description="Whether to stream research progress and results as SSE chunks.",
)
citation_format: Literal["numbered", "mla", "apa", "chicago"] = Field(
default="numbered",
description="Citation format for the research report.",
)
class TavilyResearchTool(BaseTool):
"""Tool that uses the Tavily Research API to create research tasks."""
model_config = ConfigDict(arbitrary_types_allowed=True)
_client: Any | None = PrivateAttr(default=None)
_async_client: Any | None = PrivateAttr(default=None)
name: str = "Tavily Research"
description: str = (
"A tool that creates Tavily research tasks and can stream research "
"progress and results. It returns Tavily responses as JSON or SSE chunks."
)
args_schema: type[BaseModel] = TavilyResearchToolSchema
model: Literal["mini", "pro", "auto"] = Field(
default="auto",
description="Default model used for new Tavily research tasks.",
)
output_schema: dict[str, Any] | None = Field(
default=None,
description="Default JSON Schema used to structure research output.",
)
stream: bool = Field(
default=False,
description="Whether new Tavily research tasks should stream responses by default.",
)
citation_format: Literal["numbered", "mla", "apa", "chicago"] = Field(
default="numbered",
description="Default citation format for Tavily research results.",
)
package_dependencies: list[str] = Field(default_factory=lambda: ["tavily-python"])
env_vars: list[EnvVar] = Field(
default_factory=lambda: [
EnvVar(
name="TAVILY_API_KEY",
description="API key for Tavily research service",
required=True,
),
]
)
def __init__(self, **kwargs: Any):
super().__init__(**kwargs)
if TAVILY_AVAILABLE:
api_key = os.getenv("TAVILY_API_KEY")
self._client = TavilyClient(api_key=api_key)
self._async_client = AsyncTavilyClient(api_key=api_key)
else:
try:
import subprocess
import click
except ImportError as e:
raise ImportError(
"The 'tavily-python' package is required. 'click' and "
"'subprocess' are also needed to assist with installation "
"if the package is missing. Please install 'tavily-python' "
"manually (e.g., 'pip install tavily-python') and ensure "
"'click' and 'subprocess' are available."
) from e
if click.confirm(
"You are missing the 'tavily-python' package, which is required "
"for TavilyResearchTool. Would you like to install it?"
):
try:
subprocess.run(["uv", "add", "tavily-python"], check=True) # noqa: S607
raise ImportError(
"'tavily-python' has been installed. Please restart your "
"Python application to use the TavilyResearchTool."
)
except subprocess.CalledProcessError as e:
raise ImportError(
f"Attempted to install 'tavily-python' but failed: {e}. "
"Please install it manually to use the TavilyResearchTool."
) from e
else:
raise ImportError(
"The 'tavily-python' package is required to use the "
"TavilyResearchTool. Please install it with: uv add tavily-python"
)
@staticmethod
def _stringify_response(response: Any) -> str:
if isinstance(response, str):
return response
return json.dumps(response, indent=2)
def _run(
self,
input: str,
model: Literal["mini", "pro", "auto"] | None = None,
output_schema: dict[str, Any] | None = None,
stream: bool | None = None,
citation_format: Literal["numbered", "mla", "apa", "chicago"] | None = None,
) -> str | Generator[bytes, None, None]:
"""Synchronously creates Tavily research tasks or streams results."""
if not self._client:
raise ValueError(
"Tavily client is not initialized. Ensure 'tavily-python' is "
"installed and API key is set."
)
use_stream = self.stream if stream is None else stream
result = self._client.research(
input=input,
model=self.model if model is None else model,
output_schema=self.output_schema
if output_schema is None
else output_schema,
stream=use_stream,
citation_format=(
self.citation_format if citation_format is None else citation_format
),
)
if use_stream:
return cast(Generator[bytes, None, None], result)
return self._stringify_response(result)
async def _arun(
self,
input: str,
model: Literal["mini", "pro", "auto"] | None = None,
output_schema: dict[str, Any] | None = None,
stream: bool | None = None,
citation_format: Literal["numbered", "mla", "apa", "chicago"] | None = None,
) -> str | AsyncGenerator[bytes, None]:
"""Asynchronously creates Tavily research tasks or streams results."""
if not self._async_client:
raise ValueError(
"Tavily async client is not initialized. Ensure 'tavily-python' is "
"installed and API key is set."
)
use_stream = self.stream if stream is None else stream
result = await self._async_client.research(
input=input,
model=self.model if model is None else model,
output_schema=self.output_schema
if output_schema is None
else output_schema,
stream=use_stream,
citation_format=(
self.citation_format if citation_format is None else citation_format
),
)
if use_stream:
return cast(AsyncGenerator[bytes, None], result)
return self._stringify_response(result)

View File

@@ -9,7 +9,7 @@ The `TavilySearchTool` provides an interface to the Tavily Search API, enabling
To use the `TavilySearchTool`, you need to install the `tavily-python` library:
```shell
pip install 'crewai[tools]' tavily-python
uv add 'crewai[tools]' tavily-python
```
## Environment Variables

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,8 @@ authors = [
requires-python = ">=3.10, <3.14"
dependencies = [
# Core Dependencies
"pydantic~=2.11.9",
"openai>=2.0.0,<3",
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
"instructor>=1.3.3",
# Text Processing
"pdfplumber~=0.11.4",
@@ -24,7 +24,7 @@ dependencies = [
"tokenizers>=0.21,<1",
"openpyxl~=3.1.5",
# Authentication and Security
"python-dotenv~=1.1.1",
"python-dotenv>=1.2.2,<2",
"pyjwt>=2.9.0,<3",
# TUI
"textual>=7.5.0",
@@ -55,10 +55,10 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.2a3",
"crewai-tools==1.14.3",
]
embeddings = [
"tiktoken~=0.8.0"
"tiktoken>=0.8.0,<0.13"
]
pandas = [
"pandas~=2.2.3",
@@ -84,7 +84,7 @@ voyageai = [
"voyageai~=0.3.5",
]
litellm = [
"litellm~=1.83.0",
"litellm>=1.83.7,<1.84",
]
bedrock = [
"boto3~=1.42.79",
@@ -94,6 +94,7 @@ google-genai = [
]
azure-ai-inference = [
"azure-ai-inference~=1.0.0b9",
"azure-identity>=1.17.0,<2",
]
anthropic = [
"anthropic~=0.73.0",

View File

@@ -1,10 +1,9 @@
import contextvars
import threading
from typing import Any
import urllib.request
import importlib
import sys
from typing import TYPE_CHECKING, Annotated, Any
import warnings
from pydantic import PydanticUserError
from pydantic import Field, PydanticUserError
from crewai.agent.core import Agent
from crewai.agent.planning_config import PlanningConfig
@@ -20,7 +19,10 @@ 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
from crewai.telemetry.telemetry import Telemetry
if TYPE_CHECKING:
from crewai.memory.unified_memory import Memory
def _suppress_pydantic_deprecation_warnings() -> None:
@@ -46,38 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.14.2a3"
_telemetry_submitted = False
def _track_install() -> None:
"""Track package installation/first-use via Scarf analytics."""
global _telemetry_submitted
if _telemetry_submitted or Telemetry._is_telemetry_disabled():
return
try:
pixel_url = "https://api.scarf.sh/v2/packages/CrewAI/crewai/docs/00f2dad1-8334-4a39-934e-003b2e1146db"
req = urllib.request.Request(pixel_url) # noqa: S310
req.add_header("User-Agent", f"CrewAI-Python/{__version__}")
with urllib.request.urlopen(req, timeout=2): # noqa: S310
_telemetry_submitted = True
except Exception: # noqa: S110
pass
def _track_install_async() -> None:
"""Track installation in background thread to avoid blocking imports."""
if not Telemetry._is_telemetry_disabled():
ctx = contextvars.copy_context()
thread = threading.Thread(target=ctx.run, args=(_track_install,), daemon=True)
thread.start()
_track_install_async()
__version__ = "1.14.3"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),
@@ -88,8 +59,6 @@ def __getattr__(name: str) -> Any:
"""Lazily import heavy modules (e.g. Memory → lancedb) on first access."""
if name in _LAZY_IMPORTS:
module_path, attr = _LAZY_IMPORTS[name]
import importlib
mod = importlib.import_module(module_path)
val = getattr(mod, attr)
globals()[name] = val
@@ -147,8 +116,6 @@ try:
except ImportError:
pass
import sys
_full_namespace = {
**_base_namespace,
"ToolsHandler": _ToolsHandler,
@@ -191,10 +158,6 @@ try:
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 Field
from crewai.state.runtime import RuntimeState
Entity = Annotated[

View File

@@ -8,6 +8,7 @@ import concurrent.futures
import contextvars
from datetime import datetime
import json
import os
from pathlib import Path
import time
from typing import (
@@ -29,7 +30,7 @@ from pydantic import (
model_validator,
)
from pydantic.functional_serializers import PlainSerializer
from typing_extensions import Self
from typing_extensions import Self, TypeIs
from crewai.agent.planning_config import PlanningConfig
from crewai.agent.utils import (
@@ -78,12 +79,12 @@ from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
from crewai.lite_agent_output import LiteAgentOutput
from crewai.llms.base_llm import BaseLLM
from crewai.mcp import MCPServerConfig
from crewai.mcp.tool_resolver import MCPToolResolver
from crewai.mcp.config import MCPServerConfig
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.fingerprint import Fingerprint
from crewai.skills.loader import activate_skill, discover_skills
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
from crewai.state.checkpoint_config import CheckpointConfig, apply_checkpoint
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.types.callback import SerializableCallable
from crewai.utilities.agent_utils import (
@@ -93,10 +94,14 @@ from crewai.utilities.agent_utils import (
parse_tools,
render_text_description_and_args,
)
from crewai.utilities.constants import TRAINED_AGENTS_DATA_FILE, TRAINING_DATA_FILE
from crewai.utilities.constants import (
CREWAI_TRAINED_AGENTS_FILE_ENV,
TRAINED_AGENTS_DATA_FILE,
TRAINING_DATA_FILE,
)
from crewai.utilities.converter import Converter, ConverterError
from crewai.utilities.env import get_env_context
from crewai.utilities.guardrail import process_guardrail
from crewai.utilities.guardrail import process_guardrail, serialize_guardrail_for_json
from crewai.utilities.guardrail_types import GuardrailCallable, GuardrailType
from crewai.utilities.i18n import I18N_DEFAULT
from crewai.utilities.llm_utils import create_llm
@@ -118,6 +123,7 @@ if TYPE_CHECKING:
from crewai.a2a.config import A2AClientConfig, A2AConfig, A2AServerConfig
from crewai.agents.agent_builder.base_agent import PlatformAppOrAction
from crewai.mcp.tool_resolver import MCPToolResolver
from crewai.task import Task
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
@@ -132,6 +138,13 @@ _EXECUTOR_CLASS_MAP: dict[str, type] = {
}
def _is_resuming_agent_executor(
executor: CrewAgentExecutor | AgentExecutor | None,
) -> TypeIs[AgentExecutor]:
"""Type guard: True when the executor is resuming from a checkpoint."""
return isinstance(executor, AgentExecutor) and executor._resuming
def _validate_executor_class(value: Any) -> Any:
if isinstance(value, str):
cls = _EXECUTOR_CLASS_MAP.get(value)
@@ -277,7 +290,14 @@ class Agent(BaseAgent):
default=None,
description="The Agent's role to be used from your repository.",
)
guardrail: GuardrailType | None = Field(
guardrail: Annotated[
GuardrailType | None,
PlainSerializer(
serialize_guardrail_for_json,
return_type=str | None,
when_used="json",
),
] = Field(
default=None,
description="Function or string description of a guardrail to validate agent output",
)
@@ -386,15 +406,17 @@ class Agent(BaseAgent):
self,
resolved_crew_skills: list[SkillModel] | None = None,
) -> None:
"""Resolve skill paths and activate skills to INSTRUCTIONS level.
"""Resolve skill paths while preserving explicit disclosure levels.
Path entries trigger discovery and activation. Pre-loaded Skill objects
below INSTRUCTIONS level are activated. Crew-level skills are merged in
with event emission so observability is consistent regardless of origin.
Path entries trigger discovery and activation because directory-based
skills opt into eager loading. Pre-loaded Skill objects keep their
current disclosure level so callers can attach METADATA-only skills and
progressively activate them later. Crew-level skills are merged in with
event emission so observability is consistent regardless of origin.
Args:
resolved_crew_skills: Pre-resolved crew skills (already discovered
and activated). When provided, avoids redundant discovery per agent.
resolved_crew_skills: Pre-resolved crew skills. When provided,
avoids redundant discovery per agent.
"""
from crewai.crew import Crew
@@ -435,8 +457,7 @@ class Agent(BaseAgent):
elif isinstance(item, SkillModel):
if item.name not in seen:
seen.add(item.name)
activated = activate_skill(item, source=self)
if activated is item and item.disclosure_level >= INSTRUCTIONS:
if item.disclosure_level >= INSTRUCTIONS:
crewai_event_bus.emit(
self,
event=SkillActivatedEvent(
@@ -446,7 +467,7 @@ class Agent(BaseAgent):
disclosure_level=item.disclosure_level,
),
)
resolved.append(activated)
resolved.append(item)
self.skills = resolved if resolved else None
@@ -1112,6 +1133,8 @@ class Agent(BaseAgent):
Delegates to :class:`~crewai.mcp.tool_resolver.MCPToolResolver`.
"""
self._cleanup_mcp_clients()
from crewai.mcp.tool_resolver import MCPToolResolver
self._mcp_resolver = MCPToolResolver(agent=self, logger=self._logger)
return self._mcp_resolver.resolve(mcps)
@@ -1163,7 +1186,10 @@ class Agent(BaseAgent):
def _use_trained_data(self, task_prompt: str) -> str:
"""Use trained data for the agent task prompt to improve output."""
if data := CrewTrainingHandler(TRAINED_AGENTS_DATA_FILE).load():
trained_file = os.getenv(
CREWAI_TRAINED_AGENTS_FILE_ENV, TRAINED_AGENTS_DATA_FILE
)
if data := CrewTrainingHandler(trained_file).load():
if trained_data_output := data.get(self.role):
task_prompt += (
"\n\nYou MUST follow these instructions: \n - "
@@ -1365,24 +1391,42 @@ class Agent(BaseAgent):
prompt, stop_words, rpm_limit_fn = self._build_execution_prompt(raw_tools)
executor = AgentExecutor(
llm=cast(BaseLLM, self.llm),
agent=self,
prompt=prompt,
max_iter=self.max_iter,
tools=parsed_tools,
tools_names=get_tool_names(parsed_tools),
stop_words=stop_words,
tools_description=render_text_description_and_args(parsed_tools),
tools_handler=self.tools_handler,
original_tools=raw_tools,
step_callback=self.step_callback,
function_calling_llm=self.function_calling_llm,
respect_context_window=self.respect_context_window,
request_within_rpm_limit=rpm_limit_fn,
callbacks=[TokenCalcHandler(self._token_process)],
response_model=response_format,
)
if _is_resuming_agent_executor(self.agent_executor):
executor = self.agent_executor
executor.tools = parsed_tools
executor.tools_names = get_tool_names(parsed_tools)
executor.tools_description = render_text_description_and_args(parsed_tools)
executor.original_tools = raw_tools
executor.prompt = prompt
executor.response_model = response_format
executor.stop_words = stop_words
executor.tools_handler = self.tools_handler
executor.step_callback = self.step_callback
executor.function_calling_llm = cast(
BaseLLM | None, self.function_calling_llm
)
executor.respect_context_window = self.respect_context_window
executor.request_within_rpm_limit = rpm_limit_fn
executor.callbacks = [TokenCalcHandler(self._token_process)]
else:
executor = AgentExecutor(
llm=cast(BaseLLM, self.llm),
agent=self,
prompt=prompt,
max_iter=self.max_iter,
tools=parsed_tools,
tools_names=get_tool_names(parsed_tools),
stop_words=stop_words,
tools_description=render_text_description_and_args(parsed_tools),
tools_handler=self.tools_handler,
original_tools=raw_tools,
step_callback=self.step_callback,
function_calling_llm=self.function_calling_llm,
respect_context_window=self.respect_context_window,
request_within_rpm_limit=rpm_limit_fn,
callbacks=[TokenCalcHandler(self._token_process)],
response_model=response_format,
)
all_files: dict[str, Any] = {}
if isinstance(messages, str):
@@ -1457,6 +1501,7 @@ class Agent(BaseAgent):
messages: str | list[LLMMessage],
response_format: type[Any] | None = None,
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
) -> LiteAgentOutput | Coroutine[Any, Any, LiteAgentOutput]:
"""Execute the agent with the given messages using the AgentExecutor.
@@ -1475,6 +1520,9 @@ class Agent(BaseAgent):
response_format: Optional Pydantic model for structured output.
input_files: Optional dict of named files to attach to the message.
Files can be paths, bytes, or File objects from crewai_files.
from_checkpoint: Optional checkpoint config. If ``restore_from``
is set, the agent resumes from that checkpoint. Remaining
config fields enable checkpointing for the run.
Returns:
LiteAgentOutput: The result of the agent execution.
@@ -1483,6 +1531,14 @@ class Agent(BaseAgent):
Note:
For explicit async usage outside of Flow, use kickoff_async() directly.
"""
restored = apply_checkpoint(self, from_checkpoint)
if restored is not None:
return restored.kickoff( # type: ignore[no-any-return]
messages=messages,
response_format=response_format,
input_files=input_files,
)
if is_inside_event_loop():
return self.kickoff_async(messages, response_format, input_files)
@@ -1491,14 +1547,17 @@ class Agent(BaseAgent):
)
try:
crewai_event_bus.emit(
self,
event=LiteAgentExecutionStartedEvent(
if self.checkpoint_kickoff_event_id is not None:
self._kickoff_event_id = self.checkpoint_kickoff_event_id
self.checkpoint_kickoff_event_id = None
else:
started_event = LiteAgentExecutionStartedEvent(
agent_info=agent_info,
tools=parsed_tools,
messages=messages,
),
)
)
crewai_event_bus.emit(self, event=started_event)
self._kickoff_event_id = started_event.event_id
output = self._execute_and_build_output(executor, inputs, response_format)
return self._finalize_kickoff(
@@ -1760,6 +1819,7 @@ class Agent(BaseAgent):
messages: str | list[LLMMessage],
response_format: type[Any] | None = None,
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
) -> LiteAgentOutput:
"""Execute the agent asynchronously with the given messages.
@@ -1775,23 +1835,36 @@ class Agent(BaseAgent):
response_format: Optional Pydantic model for structured output.
input_files: Optional dict of named files to attach to the message.
Files can be paths, bytes, or File objects from crewai_files.
from_checkpoint: Optional checkpoint config. If ``restore_from``
is set, the agent resumes from that checkpoint.
Returns:
LiteAgentOutput: The result of the agent execution.
"""
restored = apply_checkpoint(self, from_checkpoint)
if restored is not None:
return await restored.kickoff_async( # type: ignore[no-any-return]
messages=messages,
response_format=response_format,
input_files=input_files,
)
executor, inputs, agent_info, parsed_tools = self._prepare_kickoff(
messages, response_format, input_files
)
try:
crewai_event_bus.emit(
self,
event=LiteAgentExecutionStartedEvent(
if self.checkpoint_kickoff_event_id is not None:
self._kickoff_event_id = self.checkpoint_kickoff_event_id
self.checkpoint_kickoff_event_id = None
else:
started_event = LiteAgentExecutionStartedEvent(
agent_info=agent_info,
tools=parsed_tools,
messages=messages,
),
)
)
crewai_event_bus.emit(self, event=started_event)
self._kickoff_event_id = started_event.event_id
output = await self._execute_and_build_output_async(
executor, inputs, response_format
@@ -1808,6 +1881,7 @@ class Agent(BaseAgent):
messages: str | list[LLMMessage],
response_format: type[Any] | None = None,
input_files: dict[str, FileInput] | None = None,
from_checkpoint: CheckpointConfig | None = None,
) -> LiteAgentOutput:
"""Async version of kickoff. Alias for kickoff_async.
@@ -1815,8 +1889,12 @@ class Agent(BaseAgent):
messages: Either a string query or a list of message dictionaries.
response_format: Optional Pydantic model for structured output.
input_files: Optional dict of named files to attach to the message.
from_checkpoint: Optional checkpoint config. If ``restore_from``
is set, the agent resumes from that checkpoint.
Returns:
LiteAgentOutput: The result of the agent execution.
"""
return await self.kickoff_async(messages, response_format, input_files)
return await self.kickoff_async(
messages, response_format, input_files, from_checkpoint
)

View File

@@ -28,6 +28,9 @@ 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
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
from crewai.knowledge.knowledge import Knowledge
from crewai.knowledge.knowledge_config import KnowledgeConfig
from crewai.knowledge.source.base_knowledge_source import BaseKnowledgeSource
@@ -51,6 +54,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.runtime import RuntimeState
def _validate_crew_ref(value: Any) -> Any:
@@ -219,6 +223,7 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
_original_goal: str | None = PrivateAttr(default=None)
_original_backstory: str | None = PrivateAttr(default=None)
_token_process: TokenProcess = PrivateAttr(default_factory=TokenProcess)
_kickoff_event_id: str | None = PrivateAttr(default=None)
id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
role: str = Field(description="Role of the agent")
goal: str = Field(description="Objective of the agent")
@@ -335,30 +340,90 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
min_length=1,
)
execution_context: ExecutionContext | None = Field(default=None)
checkpoint_kickoff_event_id: str | None = Field(default=None)
@classmethod
def from_checkpoint(cls, config: CheckpointConfig) -> Self:
"""Restore an Agent from a checkpoint.
"""Restore an Agent from a checkpoint, ready to resume via kickoff().
Args:
config: Checkpoint configuration with ``restore_from`` set.
config: Checkpoint configuration with ``restore_from`` set to
the path of the checkpoint to load.
Returns:
An Agent instance. Call kickoff() to resume execution.
"""
from crewai.context import apply_execution_context
from crewai.state.runtime import RuntimeState
state = RuntimeState.from_checkpoint(config, 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)
if entity.agent_executor is not None:
entity.agent_executor.agent = entity
entity.agent_executor._resuming = True
entity._restore_runtime(state)
return entity
raise ValueError(
f"No {cls.__name__} found in checkpoint: {config.restore_from}"
)
@classmethod
def fork(cls, config: CheckpointConfig, branch: str | None = None) -> Self:
"""Fork an Agent from a checkpoint, creating a new execution branch.
Args:
config: Checkpoint configuration with ``restore_from`` set.
branch: Branch label for the fork. Auto-generated if not provided.
Returns:
An Agent instance on the new branch. Call kickoff() to run.
"""
agent = cls.from_checkpoint(config)
state = crewai_event_bus._runtime_state
if state is None:
raise RuntimeError("Cannot fork: no runtime state on the event bus.")
state.fork(branch)
return agent
def _restore_runtime(self, state: RuntimeState) -> None:
"""Re-create runtime objects after restoring from a checkpoint.
Args:
state: The RuntimeState containing the event record.
"""
if self.agent_executor is not None:
self.agent_executor.agent = self
self.agent_executor._resuming = True
if self.checkpoint_kickoff_event_id is not None:
self._kickoff_event_id = self.checkpoint_kickoff_event_id
self._restore_event_scope(state)
def _restore_event_scope(self, state: RuntimeState) -> None:
"""Rebuild the event scope stack from the checkpoint's event record.
Args:
state: The RuntimeState containing the event record.
"""
stack: list[tuple[str, str]] = []
kickoff_id = self._kickoff_event_id
if kickoff_id:
stack.append((kickoff_id, "lite_agent_execution_started"))
restore_event_scope(tuple(stack))
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)
@model_validator(mode="before")
@classmethod
def process_model_config(cls, values: Any) -> dict[str, Any]:

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timedelta, timezone
import glob
import json
import os
@@ -37,6 +37,26 @@ ORDER BY rowid DESC
LIMIT 1
"""
_DELETE_OLDER_THAN = """
DELETE FROM checkpoints
WHERE created_at < ?
"""
_DELETE_KEEP_N = """
DELETE FROM checkpoints WHERE rowid NOT IN (
SELECT rowid FROM checkpoints ORDER BY rowid DESC LIMIT ?
)
"""
_COUNT_CHECKPOINTS = "SELECT COUNT(*) FROM checkpoints"
_SELECT_LIKE = """
SELECT id, created_at, json(data)
FROM checkpoints
WHERE id LIKE ?
ORDER BY rowid DESC
"""
_DEFAULT_DIR = "./.checkpoints"
_DEFAULT_DB = "./.checkpoints.db"
@@ -86,17 +106,50 @@ def _parse_checkpoint_json(raw: str, source: str) -> dict[str, Any]:
"name": entity.get("name"),
"id": entity.get("id"),
}
raw_agents = entity.get("agents", [])
agents_by_id: dict[str, dict[str, Any]] = {}
parsed_agents: list[dict[str, Any]] = []
for ag in raw_agents:
agent_info: dict[str, Any] = {
"id": ag.get("id", ""),
"role": ag.get("role", ""),
"goal": ag.get("goal", ""),
}
parsed_agents.append(agent_info)
if ag.get("id"):
agents_by_id[str(ag["id"])] = agent_info
if parsed_agents:
info["agents"] = parsed_agents
if tasks:
info["tasks_completed"] = completed
info["tasks_total"] = len(tasks)
info["tasks"] = [
{
parsed_tasks: list[dict[str, Any]] = []
for t in tasks:
task_info: dict[str, Any] = {
"description": t.get("description", ""),
"completed": t.get("output") is not None,
"output": (t.get("output") or {}).get("raw", ""),
}
for t in tasks
]
task_agent = t.get("agent")
if isinstance(task_agent, dict):
task_info["agent_role"] = task_agent.get("role", "")
task_info["agent_id"] = task_agent.get("id", "")
elif isinstance(task_agent, str) and task_agent in agents_by_id:
task_info["agent_role"] = agents_by_id[task_agent].get("role", "")
task_info["agent_id"] = task_agent
parsed_tasks.append(task_info)
info["tasks"] = parsed_tasks
if entity.get("entity_type") == "flow":
completed_methods = entity.get("checkpoint_completed_methods")
if completed_methods:
info["completed_methods"] = sorted(completed_methods)
state = entity.get("checkpoint_state")
if isinstance(state, dict):
info["flow_state"] = state
parsed_entities.append(info)
inputs: dict[str, Any] = {}
@@ -173,9 +226,11 @@ def _entity_summary(entities: list[dict[str, Any]]) -> str:
def _list_json(location: str) -> list[dict[str, Any]]:
pattern = os.path.join(location, "*.json")
pattern = os.path.join(location, "**", "*.json")
results = []
for path in sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True):
for path in sorted(
glob.glob(pattern, recursive=True), key=os.path.getmtime, reverse=True
):
name = os.path.basename(path)
try:
with open(path) as f:
@@ -192,8 +247,10 @@ def _list_json(location: str) -> list[dict[str, Any]]:
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)
pattern = os.path.join(location, "**", "*.json")
files = sorted(
glob.glob(pattern, recursive=True), key=os.path.getmtime, reverse=True
)
if not files:
return None
path = files[0]
@@ -258,6 +315,8 @@ def _info_sqlite_latest(db_path: str) -> dict[str, Any] | None:
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:
row = conn.execute(_SELECT_LIKE, (f"%{checkpoint_id}%",)).fetchone()
if not row:
return None
cid, created_at, raw = row
@@ -380,3 +439,294 @@ def _print_info(meta: dict[str, Any]) -> None:
if len(desc) > 70:
desc = desc[:67] + "..."
click.echo(f" {i + 1}. [{status}] {desc}")
def _resolve_checkpoint(
location: str, checkpoint_id: str | None
) -> dict[str, Any] | None:
if _is_sqlite(location):
if checkpoint_id:
return _info_sqlite_id(location, checkpoint_id)
return _info_sqlite_latest(location)
if os.path.isdir(location):
if checkpoint_id:
from crewai.state.provider.json_provider import JsonProvider
_json_provider: JsonProvider = JsonProvider()
pattern: str = os.path.join(location, "**", "*.json")
all_files: list[str] = glob.glob(pattern, recursive=True)
matches: list[str] = [
f for f in all_files if checkpoint_id in _json_provider.extract_id(f)
]
matches.sort(key=os.path.getmtime, reverse=True)
if matches:
return _info_json_file(matches[0])
return None
return _info_json_latest(location)
if os.path.isfile(location):
return _info_json_file(location)
return None
def _entity_type_from_meta(meta: dict[str, Any]) -> str:
for ent in meta.get("entities", []):
if ent.get("type") == "flow":
return "flow"
if ent.get("type") == "agent":
return "agent"
return "crew"
def resume_checkpoint(location: str, checkpoint_id: str | None) -> None:
import asyncio
meta: dict[str, Any] | None = _resolve_checkpoint(location, checkpoint_id)
if meta is None:
if checkpoint_id:
click.echo(f"Checkpoint not found: {checkpoint_id}")
else:
click.echo(f"No checkpoints found in {location}")
return
restore_path: str = meta.get("path") or meta.get("source", "")
if meta.get("db"):
restore_path = f"{meta['db']}#{meta['name']}"
click.echo(f"Resuming from: {meta.get('name', restore_path)}")
_print_info(meta)
click.echo()
from crewai.state.checkpoint_config import CheckpointConfig
config: CheckpointConfig = CheckpointConfig(restore_from=restore_path)
entity_type: str = _entity_type_from_meta(meta)
inputs: dict[str, Any] | None = meta.get("inputs") or None
if entity_type == "flow":
from crewai.flow.flow import Flow
flow = Flow.from_checkpoint(config)
result = asyncio.run(flow.kickoff_async(inputs=inputs))
elif entity_type == "agent":
from crewai.agent import Agent
agent = Agent.from_checkpoint(config)
result = asyncio.run(agent.akickoff(messages="Resume execution."))
else:
from crewai.crew import Crew
crew = Crew.from_checkpoint(config)
result = asyncio.run(crew.akickoff(inputs=inputs))
click.echo(f"\nResult: {getattr(result, 'raw', result)}")
def _task_list_from_meta(meta: dict[str, Any]) -> list[dict[str, Any]]:
tasks: list[dict[str, Any]] = []
for ent in meta.get("entities", []):
tasks.extend(
{
"entity": ent.get("name", "unnamed"),
"description": t.get("description", ""),
"completed": t.get("completed", False),
"output": t.get("output", ""),
}
for t in ent.get("tasks", [])
)
return tasks
def diff_checkpoints(location: str, id1: str, id2: str) -> None:
meta1: dict[str, Any] | None = _resolve_checkpoint(location, id1)
meta2: dict[str, Any] | None = _resolve_checkpoint(location, id2)
if meta1 is None:
click.echo(f"Checkpoint not found: {id1}")
return
if meta2 is None:
click.echo(f"Checkpoint not found: {id2}")
return
name1: str = meta1.get("name", id1)
name2: str = meta2.get("name", id2)
click.echo(f"--- {name1}")
click.echo(f"+++ {name2}")
click.echo()
fields: list[tuple[str, str]] = [
("Time", "ts"),
("Branch", "branch"),
("Trigger", "trigger"),
("Events", "event_count"),
]
for label, key in fields:
v1: str = str(meta1.get(key, ""))
v2: str = str(meta2.get(key, ""))
if v1 != v2:
click.echo(f" {label}:")
click.echo(f" - {v1}")
click.echo(f" + {v2}")
inputs1: dict[str, Any] = meta1.get("inputs", {})
inputs2: dict[str, Any] = meta2.get("inputs", {})
all_keys: list[str] = sorted(set(list(inputs1.keys()) + list(inputs2.keys())))
changed_inputs: list[tuple[str, Any, Any]] = [
(k, inputs1.get(k, ""), inputs2.get(k, ""))
for k in all_keys
if inputs1.get(k) != inputs2.get(k)
]
if changed_inputs:
click.echo("\n Inputs:")
for key, v1, v2 in changed_inputs:
click.echo(f" {key}:")
click.echo(f" - {v1}")
click.echo(f" + {v2}")
tasks1: list[dict[str, Any]] = _task_list_from_meta(meta1)
tasks2: list[dict[str, Any]] = _task_list_from_meta(meta2)
max_tasks: int = max(len(tasks1), len(tasks2))
if max_tasks == 0:
return
click.echo("\n Tasks:")
for i in range(max_tasks):
t1: dict[str, Any] | None = tasks1[i] if i < len(tasks1) else None
t2: dict[str, Any] | None = tasks2[i] if i < len(tasks2) else None
if t1 is None:
desc: str = t2["description"][:60] if t2 else ""
click.echo(f" + {i + 1}. [new] {desc}")
continue
if t2 is None:
desc = t1["description"][:60]
click.echo(f" - {i + 1}. [removed] {desc}")
continue
desc = str(t1["description"][:60])
s1: str = "done" if t1["completed"] else "pending"
s2: str = "done" if t2["completed"] else "pending"
if s1 != s2:
click.echo(f" {i + 1}. {desc}")
click.echo(f" status: {s1} -> {s2}")
out1: str = (t1.get("output") or "").strip()
out2: str = (t2.get("output") or "").strip()
if out1 != out2:
if s1 == s2:
click.echo(f" {i + 1}. {desc}")
preview1: str = (
out1[:80] + ("..." if len(out1) > 80 else "") if out1 else "(empty)"
)
preview2: str = (
out2[:80] + ("..." if len(out2) > 80 else "") if out2 else "(empty)"
)
click.echo(" output:")
click.echo(f" - {preview1}")
click.echo(f" + {preview2}")
def _parse_duration(value: str) -> timedelta:
match: re.Match[str] | None = re.match(r"^(\d+)([dhm])$", value.strip())
if not match:
raise click.BadParameter(
f"Invalid duration: {value!r}. Use format like '7d', '24h', or '30m'."
)
amount: int = int(match.group(1))
unit: str = match.group(2)
if unit == "d":
return timedelta(days=amount)
if unit == "h":
return timedelta(hours=amount)
return timedelta(minutes=amount)
def _prune_json(location: str, keep: int | None, older_than: timedelta | None) -> int:
pattern: str = os.path.join(location, "**", "*.json")
files: list[str] = sorted(
glob.glob(pattern, recursive=True), key=os.path.getmtime, reverse=True
)
if not files:
return 0
to_delete: set[str] = set()
if keep is not None and len(files) > keep:
to_delete.update(files[keep:])
if older_than is not None:
cutoff: datetime = datetime.now(timezone.utc) - older_than
for path in files:
mtime: datetime = datetime.fromtimestamp(
os.path.getmtime(path), tz=timezone.utc
)
if mtime < cutoff:
to_delete.add(path)
deleted: int = 0
for path in to_delete:
try:
os.remove(path)
deleted += 1
except OSError: # noqa: PERF203
pass
for dirpath, dirnames, filenames in os.walk(location, topdown=False):
if dirpath != location and not filenames and not dirnames:
try:
os.rmdir(dirpath)
except OSError:
pass
return deleted
def _prune_sqlite(db_path: str, keep: int | None, older_than: timedelta | None) -> int:
deleted: int = 0
with sqlite3.connect(db_path) as conn:
if older_than is not None:
cutoff: str = (datetime.now(timezone.utc) - older_than).strftime(
"%Y%m%dT%H%M%S"
)
cursor: sqlite3.Cursor = conn.execute(_DELETE_OLDER_THAN, (cutoff,))
deleted += cursor.rowcount
if keep is not None:
cursor = conn.execute(_DELETE_KEEP_N, (keep,))
deleted += cursor.rowcount
conn.commit()
return deleted
def prune_checkpoints(
location: str, keep: int | None, older_than: str | None, dry_run: bool = False
) -> None:
if keep is None and older_than is None:
click.echo("Specify --keep N and/or --older-than DURATION (e.g. 7d, 24h)")
return
duration: timedelta | None = _parse_duration(older_than) if older_than else None
deleted: int
if _is_sqlite(location):
if dry_run:
with sqlite3.connect(location) as conn:
total: int = conn.execute(_COUNT_CHECKPOINTS).fetchone()[0]
click.echo(f"Would prune from {total} checkpoint(s) in {location}")
return
deleted = _prune_sqlite(location, keep, duration)
elif os.path.isdir(location):
if dry_run:
files: list[str] = glob.glob(
os.path.join(location, "**", "*.json"), recursive=True
)
click.echo(f"Would prune from {len(files)} checkpoint(s) in {location}")
return
deleted = _prune_json(location, keep, duration)
else:
click.echo(f"Not a directory or SQLite database: {location}")
return
click.echo(f"Pruned {deleted} checkpoint(s) from {location}")

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ from crewai.cli.install_crew import install_crew
from crewai.cli.kickoff_flow import kickoff_flow
from crewai.cli.organization.main import OrganizationCommand
from crewai.cli.plot_flow import plot_flow
from crewai.cli.remote_template.main import TemplateCommand
from crewai.cli.replay_from_task import replay_task_command
from crewai.cli.reset_memories_command import reset_memories_command
from crewai.cli.run_crew import run_crew
@@ -138,16 +139,29 @@ def train(n_iterations: int, filename: str) -> None:
type=str,
help="Replay the crew from this task ID, including all subsequent tasks.",
)
def replay(task_id: str) -> None:
"""
Replay the crew execution from a specific task.
@click.option(
"-f",
"--filename",
"trained_agents_file",
type=str,
default=None,
help=(
"Path to a trained-agents pickle (produced by `crewai train -f`). "
"When set, agents load suggestions from this file instead of the "
"default trained_agents_data.pkl. Equivalent to setting "
"CREWAI_TRAINED_AGENTS_FILE."
),
)
def replay(task_id: str, trained_agents_file: str | None) -> None:
"""Replay the crew execution from a specific task.
Args:
task_id (str): The ID of the task to replay from.
task_id: The ID of the task to replay from.
trained_agents_file: Optional trained-agents pickle path.
"""
try:
click.echo(f"Replaying the crew from task {task_id}")
replay_task_command(task_id)
replay_task_command(task_id, trained_agents_file=trained_agents_file)
except Exception as e:
click.echo(f"An error occurred while replaying: {e}", err=True)
@@ -331,10 +345,23 @@ def memory(
default="gpt-4o-mini",
help="LLM Model to run the tests on the Crew. For now only accepting only OpenAI models.",
)
def test(n_iterations: int, model: str) -> None:
@click.option(
"-f",
"--filename",
"trained_agents_file",
type=str,
default=None,
help=(
"Path to a trained-agents pickle (produced by `crewai train -f`). "
"When set, agents load suggestions from this file instead of the "
"default trained_agents_data.pkl. Equivalent to setting "
"CREWAI_TRAINED_AGENTS_FILE."
),
)
def test(n_iterations: int, model: str, trained_agents_file: str | None) -> None:
"""Test the crew and evaluate the results."""
click.echo(f"Testing the crew for {n_iterations} iterations with model {model}")
evaluate_crew(n_iterations, model)
evaluate_crew(n_iterations, model, trained_agents_file=trained_agents_file)
@crewai.command(
@@ -350,9 +377,22 @@ def install(context: click.Context) -> None:
@crewai.command()
def run() -> None:
@click.option(
"-f",
"--filename",
"trained_agents_file",
type=str,
default=None,
help=(
"Path to a trained-agents pickle (produced by `crewai train -f`). "
"When set, agents load suggestions from this file instead of the "
"default trained_agents_data.pkl. Equivalent to setting "
"CREWAI_TRAINED_AGENTS_FILE."
),
)
def run(trained_agents_file: str | None) -> None:
"""Run the Crew."""
run_crew()
run_crew(trained_agents_file=trained_agents_file)
@crewai.command()
@@ -496,6 +536,33 @@ def tool_publish(is_public: bool, force: bool) -> None:
tool_cmd.publish(is_public, force)
@crewai.group()
def template() -> None:
"""Browse and install project templates."""
@template.command(name="list")
def template_list() -> None:
"""List available templates and select one to install."""
template_cmd = TemplateCommand()
template_cmd.list_templates()
@template.command(name="add")
@click.argument("name")
@click.option(
"-o",
"--output-dir",
type=str,
default=None,
help="Directory name for the template (defaults to template name)",
)
def template_add(name: str, output_dir: str | None) -> None:
"""Add a template to the current directory."""
template_cmd = TemplateCommand()
template_cmd.add_template(name, output_dir)
@crewai.group()
def flow() -> None:
"""Flow related commands."""
@@ -845,5 +912,48 @@ def checkpoint_info(path: str) -> None:
info_checkpoint(_detect_location(path))
@checkpoint.command("resume")
@click.argument("checkpoint_id", required=False, default=None)
@click.pass_context
def checkpoint_resume(ctx: click.Context, checkpoint_id: str | None) -> None:
"""Resume from a checkpoint. Defaults to the most recent."""
from crewai.cli.checkpoint_cli import resume_checkpoint
resume_checkpoint(ctx.obj["location"], checkpoint_id)
@checkpoint.command("diff")
@click.argument("id1")
@click.argument("id2")
@click.pass_context
def checkpoint_diff(ctx: click.Context, id1: str, id2: str) -> None:
"""Compare two checkpoints side-by-side."""
from crewai.cli.checkpoint_cli import diff_checkpoints
diff_checkpoints(ctx.obj["location"], id1, id2)
@checkpoint.command("prune")
@click.option(
"--keep", type=int, default=None, help="Keep the N most recent checkpoints."
)
@click.option(
"--older-than",
default=None,
help="Remove checkpoints older than duration (e.g. 7d, 24h, 30m).",
)
@click.option(
"--dry-run", is_flag=True, help="Show what would be pruned without deleting."
)
@click.pass_context
def checkpoint_prune(
ctx: click.Context, keep: int | None, older_than: str | None, dry_run: bool
) -> None:
"""Remove old checkpoints."""
from crewai.cli.checkpoint_cli import prune_checkpoints
prune_checkpoints(ctx.obj["location"], keep, older_than, dry_run)
if __name__ == "__main__":
crewai()

View File

@@ -2,22 +2,33 @@ import subprocess
import click
from crewai.cli.utils import build_env_with_all_tool_credentials
from crewai.utilities.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
def evaluate_crew(n_iterations: int, model: str) -> None:
"""
Test and Evaluate the crew by running a command in the UV environment.
def evaluate_crew(
n_iterations: int, model: str, trained_agents_file: str | None = None
) -> None:
"""Test and Evaluate the crew by running a command in the UV environment.
Args:
n_iterations (int): The number of iterations to test the crew.
model (str): The model to test the crew with.
n_iterations: The number of iterations to test the crew.
model: The model to test the crew with.
trained_agents_file: Optional trained-agents pickle path forwarded to
the subprocess via the ``CREWAI_TRAINED_AGENTS_FILE`` env var.
"""
command = ["uv", "run", "test", str(n_iterations), model]
env = build_env_with_all_tool_credentials()
if trained_agents_file:
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
try:
if n_iterations <= 0:
raise ValueError("The number of iterations must be a positive integer.")
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
result = subprocess.run( # noqa: S603
command, capture_output=False, text=True, check=True, env=env
)
if result.stderr:
click.echo(result.stderr, err=True)

View File

@@ -0,0 +1,250 @@
import io
import logging
import os
import shutil
from typing import Any
import zipfile
import click
import httpx
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from crewai.cli.command import BaseCommand
logger = logging.getLogger(__name__)
console = Console()
GITHUB_ORG = "crewAIInc"
TEMPLATE_PREFIX = "template_"
GITHUB_API_BASE = "https://api.github.com"
BANNER = """\
[bold white] ██████╗██████╗ ███████╗██╗ ██╗[/bold white] [bold red] █████╗ ██╗[/bold red]
[bold white]██╔════╝██╔══██╗██╔════╝██║ ██║[/bold white] [bold red]██╔══██╗██║[/bold red]
[bold white]██║ ██████╔╝█████╗ ██║ █╗ ██║[/bold white] [bold red]███████║██║[/bold red]
[bold white]██║ ██╔══██╗██╔══╝ ██║███╗██║[/bold white] [bold red]██╔══██║██║[/bold red]
[bold white]╚██████╗██║ ██║███████╗╚███╔███╔╝[/bold white] [bold red]██║ ██║██║[/bold red]
[bold white] ╚═════╝╚═╝ ╚═╝╚══════╝ ╚══╝╚══╝[/bold white] [bold red]╚═╝ ╚═╝╚═╝[/bold red]
[dim white]████████╗███████╗███╗ ███╗██████╗ ██╗ █████╗ ████████╗███████╗███████╗[/dim white]
[dim white]╚══██╔══╝██╔════╝████╗ ████║██╔══██╗██║ ██╔══██╗╚══██╔══╝██╔════╝██╔════╝[/dim white]
[dim white] ██║ █████╗ ██╔████╔██║██████╔╝██║ ███████║ ██║ █████╗ ███████╗[/dim white]
[dim white] ██║ ██╔══╝ ██║╚██╔╝██║██╔═══╝ ██║ ██╔══██║ ██║ ██╔══╝ ╚════██║[/dim white]
[dim white] ██║ ███████╗██║ ╚═╝ ██║██║ ███████╗██║ ██║ ██║ ███████╗███████║[/dim white]
[dim white] ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝[/dim white]"""
class TemplateCommand(BaseCommand):
"""Handle template-related operations for CrewAI projects."""
def __init__(self) -> None:
super().__init__()
def list_templates(self) -> None:
"""List available templates with an interactive selector to install."""
templates = self._fetch_templates()
if not templates:
click.echo("No templates found.")
return
console.print(f"\n{BANNER}\n")
console.print(" [on cyan] templates [/on cyan]\n")
console.print(f" [green]o[/green] Source: https://github.com/{GITHUB_ORG}")
console.print(
f" [green]o[/green] Found [bold]{len(templates)}[/bold] templates\n"
)
console.print(" [green]o[/green] Select a template to install")
for idx, repo in enumerate(templates, start=1):
name = repo["name"].removeprefix(TEMPLATE_PREFIX)
description = repo.get("description") or ""
if description:
console.print(
f" [bold cyan]{idx}.[/bold cyan] [bold white]{name}[/bold white] [dim]({description})[/dim]"
)
else:
console.print(
f" [bold cyan]{idx}.[/bold cyan] [bold white]{name}[/bold white]"
)
console.print(" [bold cyan]q.[/bold cyan] [dim]Quit[/dim]\n")
while True:
choice = click.prompt("Enter your choice", type=str)
if choice.lower() == "q":
return
if choice.isdigit() and 1 <= int(choice) <= len(templates):
selected_index = int(choice) - 1
break
click.secho(
f"Please enter a number between 1 and {len(templates)}, or 'q' to quit.",
fg="yellow",
)
selected = templates[selected_index]
repo_name = selected["name"]
self._install_repo(repo_name)
def add_template(self, name: str, output_dir: str | None = None) -> None:
"""Download a template and copy it into the current working directory.
Args:
name: Template name (with or without the template_ prefix).
output_dir: Optional directory name. Defaults to the template name.
"""
repo_name = self._resolve_repo_name(name)
if repo_name is None:
click.secho(f"Template '{name}' not found.", fg="red")
click.echo("Run 'crewai template list' to see available templates.")
raise SystemExit(1)
self._install_repo(repo_name, output_dir)
def _install_repo(self, repo_name: str, output_dir: str | None = None) -> None:
"""Download and extract a template repo into the current directory.
Args:
repo_name: Full GitHub repo name (e.g. template_deep_research).
output_dir: Optional directory name. Defaults to the template name.
"""
folder_name = output_dir or repo_name.removeprefix(TEMPLATE_PREFIX)
dest = os.path.join(os.getcwd(), folder_name)
while os.path.exists(dest):
click.secho(f"Directory '{folder_name}' already exists.", fg="yellow")
folder_name = click.prompt(
"Enter a different directory name (or 'q' to quit)", type=str
)
if folder_name.lower() == "q":
return
dest = os.path.join(os.getcwd(), folder_name)
click.echo(
f"Downloading template '{repo_name.removeprefix(TEMPLATE_PREFIX)}'..."
)
zip_bytes = self._download_zip(repo_name)
self._extract_zip(zip_bytes, dest)
self._telemetry.template_installed_span(repo_name.removeprefix(TEMPLATE_PREFIX))
console.print(
f"\n [green]\u2713[/green] Installed template [bold white]{folder_name}[/bold white]"
f" [dim](source: github.com/{GITHUB_ORG}/{repo_name})[/dim]\n"
)
next_steps = Text()
next_steps.append(f" cd {folder_name}\n", style="bold white")
next_steps.append(" crewai install", style="bold white")
panel = Panel(
next_steps,
title="[green]\u25c7 Next steps[/green]",
title_align="left",
border_style="dim",
padding=(1, 2),
)
console.print(panel)
def _fetch_templates(self) -> list[dict[str, Any]]:
"""Fetch all template repos from the GitHub org."""
templates: list[dict[str, Any]] = []
page = 1
while True:
url = f"{GITHUB_API_BASE}/orgs/{GITHUB_ORG}/repos"
params: dict[str, str | int] = {
"per_page": 100,
"page": page,
"type": "public",
}
try:
response = httpx.get(url, params=params, timeout=15)
response.raise_for_status()
except httpx.HTTPError as e:
click.secho(f"Failed to fetch templates from GitHub: {e}", fg="red")
raise SystemExit(1) from e
repos = response.json()
if not repos:
break
templates.extend(
repo
for repo in repos
if repo["name"].startswith(TEMPLATE_PREFIX) and not repo.get("private")
)
page += 1
templates.sort(key=lambda r: r["name"])
return templates
def _resolve_repo_name(self, name: str) -> str | None:
"""Resolve user input to a full repo name, or None if not found."""
# Accept both 'deep_research' and 'template_deep_research'
candidates = [
f"{TEMPLATE_PREFIX}{name}"
if not name.startswith(TEMPLATE_PREFIX)
else name,
name,
]
templates = self._fetch_templates()
template_names = {t["name"] for t in templates}
for candidate in candidates:
if candidate in template_names:
return candidate
return None
def _download_zip(self, repo_name: str) -> bytes:
"""Download the default branch zipball for a repo."""
url = f"{GITHUB_API_BASE}/repos/{GITHUB_ORG}/{repo_name}/zipball"
try:
response = httpx.get(url, follow_redirects=True, timeout=60)
response.raise_for_status()
except httpx.HTTPError as e:
click.secho(f"Failed to download template: {e}", fg="red")
raise SystemExit(1) from e
return response.content
def _extract_zip(self, zip_bytes: bytes, dest: str) -> None:
"""Extract a GitHub zipball into dest, stripping the top-level directory."""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
# GitHub zipballs have a single top-level dir like 'crewAIInc-template_xxx-<sha>/'
members = zf.namelist()
if not members:
click.secho("Downloaded archive is empty.", fg="red")
raise SystemExit(1)
top_dir = members[0].split("/")[0] + "/"
os.makedirs(dest, exist_ok=True)
for member in members:
if member == top_dir or not member.startswith(top_dir):
continue
relative_path = member[len(top_dir) :]
if not relative_path:
continue
target = os.path.realpath(os.path.join(dest, relative_path))
if not target.startswith(
os.path.realpath(dest) + os.sep
) and target != os.path.realpath(dest):
continue
if member.endswith("/"):
os.makedirs(target, exist_ok=True)
else:
os.makedirs(os.path.dirname(target), exist_ok=True)
with zf.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)

View File

@@ -2,18 +2,27 @@ import subprocess
import click
from crewai.cli.utils import build_env_with_all_tool_credentials
from crewai.utilities.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
def replay_task_command(task_id: str) -> None:
"""
Replay the crew execution from a specific task.
def replay_task_command(task_id: str, trained_agents_file: str | None = None) -> None:
"""Replay the crew execution from a specific task.
Args:
task_id (str): The ID of the task to replay from.
task_id: The ID of the task to replay from.
trained_agents_file: Optional trained-agents pickle path forwarded to
the subprocess via the ``CREWAI_TRAINED_AGENTS_FILE`` env var.
"""
command = ["uv", "run", "replay", task_id]
env = build_env_with_all_tool_credentials()
if trained_agents_file:
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
try:
result = subprocess.run(command, capture_output=False, text=True, check=True) # noqa: S603
result = subprocess.run( # noqa: S603
command, capture_output=False, text=True, check=True, env=env
)
if result.stderr:
click.echo(result.stderr, err=True)

View File

@@ -5,6 +5,7 @@ import click
from packaging import version
from crewai.cli.utils import build_env_with_all_tool_credentials, read_toml
from crewai.utilities.constants import CREWAI_TRAINED_AGENTS_FILE_ENV
from crewai.utilities.version import get_crewai_version
@@ -13,13 +14,18 @@ class CrewType(Enum):
FLOW = "flow"
def run_crew() -> None:
"""
Run the crew or flow by running a command in the UV environment.
def run_crew(trained_agents_file: str | None = None) -> None:
"""Run the crew or flow by running a command in the UV environment.
Starting from version 0.103.0, this command can be used to run both
standard crews and flows. For flows, it detects the type from pyproject.toml
and automatically runs the appropriate command.
Args:
trained_agents_file: Optional path to a trained-agents pickle produced
by ``crewai train -f``. When set, exported as
``CREWAI_TRAINED_AGENTS_FILE`` so agents load suggestions from this
file instead of the default ``trained_agents_data.pkl``.
"""
crewai_version = get_crewai_version()
min_required_version = "0.71.0"
@@ -43,19 +49,24 @@ def run_crew() -> None:
click.echo(f"Running the {'Flow' if is_flow else 'Crew'}")
# Execute the appropriate command
execute_command(crew_type)
execute_command(crew_type, trained_agents_file=trained_agents_file)
def execute_command(crew_type: CrewType) -> None:
"""
Execute the appropriate command based on crew type.
def execute_command(
crew_type: CrewType, trained_agents_file: str | None = None
) -> None:
"""Execute the appropriate command based on crew type.
Args:
crew_type: The type of crew to run
crew_type: The type of crew to run.
trained_agents_file: Optional trained-agents pickle path forwarded to
the subprocess via the ``CREWAI_TRAINED_AGENTS_FILE`` env var.
"""
command = ["uv", "run", "kickoff" if crew_type == CrewType.FLOW else "run_crew"]
env = build_env_with_all_tool_credentials()
if trained_agents_file:
env[CREWAI_TRAINED_AGENTS_FILE_ENV] = trained_agents_file
try:
subprocess.run(command, capture_output=False, text=True, check=True, env=env) # noqa: S603

View File

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

View File

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

View File

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

View File

@@ -419,10 +419,32 @@ class Crew(FlowTrackable, BaseModel):
def _restore_runtime(self) -> None:
"""Re-create runtime objects after restoring from a checkpoint."""
from crewai.events.event_bus import crewai_event_bus
started_task_ids: set[str] = set()
state = crewai_event_bus._runtime_state
if state is not None:
for node in state.event_record.nodes.values():
if node.event.type == "task_started" and node.event.task_id:
started_task_ids.add(node.event.task_id)
resuming_task_agent_roles: set[str] = set()
for task in self.tasks:
if (
task.output is None
and task.agent is not None
and str(task.id) in started_task_ids
):
resuming_task_agent_roles.add(task.agent.role)
for agent in self.agents:
agent.crew = self
executor = agent.agent_executor
if executor and executor.messages:
if (
executor
and executor.messages
and agent.role in resuming_task_agent_roles
):
executor.crew = self
executor.agent = agent
executor._resuming = True

View File

@@ -354,9 +354,16 @@ def prepare_kickoff(
crew._set_tasks_callbacks()
crew._set_allow_crewai_trigger_context_for_first_task()
agents_to_setup: list[BaseAgent] = list(crew.agents)
seen_agent_ids: set[int] = {id(agent) for agent in agents_to_setup}
for task in crew.tasks:
if task.agent is not None and id(task.agent) not in seen_agent_ids:
agents_to_setup.append(task.agent)
seen_agent_ids.add(id(task.agent))
setup_agents(
crew,
crew.agents,
agents_to_setup,
crew.embedder,
crew.function_calling_llm,
crew.step_callback,

View File

@@ -6,111 +6,20 @@ This module provides the event infrastructure that allows users to:
- Build custom logging and analytics
- Extend CrewAI with custom event handlers
- Declare handler dependencies for ordered execution
Event type classes are lazy-loaded on first access to avoid importing
~12 Pydantic model modules (and their transitive deps) at package init time.
"""
from __future__ import annotations
import importlib
from typing import TYPE_CHECKING, Any
from crewai.events.base_event_listener import BaseEventListener
from crewai.events.depends import Depends
from crewai.events.event_bus import crewai_event_bus
from crewai.events.handler_graph import CircularDependencyError
from crewai.events.types.crew_events import (
CrewKickoffCompletedEvent,
CrewKickoffFailedEvent,
CrewKickoffStartedEvent,
CrewTestCompletedEvent,
CrewTestFailedEvent,
CrewTestResultEvent,
CrewTestStartedEvent,
CrewTrainCompletedEvent,
CrewTrainFailedEvent,
CrewTrainStartedEvent,
)
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowEvent,
FlowFinishedEvent,
FlowPlotEvent,
FlowStartedEvent,
HumanFeedbackReceivedEvent,
HumanFeedbackRequestedEvent,
MethodExecutionFailedEvent,
MethodExecutionFinishedEvent,
MethodExecutionStartedEvent,
)
from crewai.events.types.knowledge_events import (
KnowledgeQueryCompletedEvent,
KnowledgeQueryFailedEvent,
KnowledgeQueryStartedEvent,
KnowledgeRetrievalCompletedEvent,
KnowledgeRetrievalStartedEvent,
KnowledgeSearchQueryFailedEvent,
)
from crewai.events.types.llm_events import (
LLMCallCompletedEvent,
LLMCallFailedEvent,
LLMCallStartedEvent,
LLMStreamChunkEvent,
)
from crewai.events.types.llm_guardrail_events import (
LLMGuardrailCompletedEvent,
LLMGuardrailStartedEvent,
)
from crewai.events.types.logging_events import (
AgentLogsExecutionEvent,
AgentLogsStartedEvent,
)
from crewai.events.types.mcp_events import (
MCPConfigFetchFailedEvent,
MCPConnectionCompletedEvent,
MCPConnectionFailedEvent,
MCPConnectionStartedEvent,
MCPToolExecutionCompletedEvent,
MCPToolExecutionFailedEvent,
MCPToolExecutionStartedEvent,
)
from crewai.events.types.memory_events import (
MemoryQueryCompletedEvent,
MemoryQueryFailedEvent,
MemoryQueryStartedEvent,
MemoryRetrievalCompletedEvent,
MemoryRetrievalFailedEvent,
MemoryRetrievalStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.events.types.reasoning_events import (
AgentReasoningCompletedEvent,
AgentReasoningFailedEvent,
AgentReasoningStartedEvent,
ReasoningEvent,
)
from crewai.events.types.skill_events import (
SkillActivatedEvent,
SkillDiscoveryCompletedEvent,
SkillDiscoveryStartedEvent,
SkillEvent,
SkillLoadFailedEvent,
SkillLoadedEvent,
)
from crewai.events.types.task_events import (
TaskCompletedEvent,
TaskEvaluationEvent,
TaskFailedEvent,
TaskStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolExecutionErrorEvent,
ToolSelectionErrorEvent,
ToolUsageErrorEvent,
ToolUsageEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
ToolValidateInputErrorEvent,
)
if TYPE_CHECKING:
@@ -125,6 +34,250 @@ if TYPE_CHECKING:
LiteAgentExecutionErrorEvent,
LiteAgentExecutionStartedEvent,
)
from crewai.events.types.checkpoint_events import (
CheckpointBaseEvent,
CheckpointCompletedEvent,
CheckpointFailedEvent,
CheckpointForkBaseEvent,
CheckpointForkCompletedEvent,
CheckpointForkStartedEvent,
CheckpointPrunedEvent,
CheckpointRestoreBaseEvent,
CheckpointRestoreCompletedEvent,
CheckpointRestoreFailedEvent,
CheckpointRestoreStartedEvent,
CheckpointStartedEvent,
)
from crewai.events.types.crew_events import (
CrewKickoffCompletedEvent,
CrewKickoffFailedEvent,
CrewKickoffStartedEvent,
CrewTestCompletedEvent,
CrewTestFailedEvent,
CrewTestResultEvent,
CrewTestStartedEvent,
CrewTrainCompletedEvent,
CrewTrainFailedEvent,
CrewTrainStartedEvent,
)
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowEvent,
FlowFinishedEvent,
FlowPlotEvent,
FlowStartedEvent,
HumanFeedbackReceivedEvent,
HumanFeedbackRequestedEvent,
MethodExecutionFailedEvent,
MethodExecutionFinishedEvent,
MethodExecutionStartedEvent,
)
from crewai.events.types.knowledge_events import (
KnowledgeQueryCompletedEvent,
KnowledgeQueryFailedEvent,
KnowledgeQueryStartedEvent,
KnowledgeRetrievalCompletedEvent,
KnowledgeRetrievalStartedEvent,
KnowledgeSearchQueryFailedEvent,
)
from crewai.events.types.llm_events import (
LLMCallCompletedEvent,
LLMCallFailedEvent,
LLMCallStartedEvent,
LLMStreamChunkEvent,
)
from crewai.events.types.llm_guardrail_events import (
LLMGuardrailCompletedEvent,
LLMGuardrailStartedEvent,
)
from crewai.events.types.logging_events import (
AgentLogsExecutionEvent,
AgentLogsStartedEvent,
)
from crewai.events.types.mcp_events import (
MCPConfigFetchFailedEvent,
MCPConnectionCompletedEvent,
MCPConnectionFailedEvent,
MCPConnectionStartedEvent,
MCPToolExecutionCompletedEvent,
MCPToolExecutionFailedEvent,
MCPToolExecutionStartedEvent,
)
from crewai.events.types.memory_events import (
MemoryQueryCompletedEvent,
MemoryQueryFailedEvent,
MemoryQueryStartedEvent,
MemoryRetrievalCompletedEvent,
MemoryRetrievalFailedEvent,
MemoryRetrievalStartedEvent,
MemorySaveCompletedEvent,
MemorySaveFailedEvent,
MemorySaveStartedEvent,
)
from crewai.events.types.reasoning_events import (
AgentReasoningCompletedEvent,
AgentReasoningFailedEvent,
AgentReasoningStartedEvent,
ReasoningEvent,
)
from crewai.events.types.skill_events import (
SkillActivatedEvent,
SkillDiscoveryCompletedEvent,
SkillDiscoveryStartedEvent,
SkillEvent,
SkillLoadFailedEvent,
SkillLoadedEvent,
)
from crewai.events.types.task_events import (
TaskCompletedEvent,
TaskEvaluationEvent,
TaskFailedEvent,
TaskStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolExecutionErrorEvent,
ToolSelectionErrorEvent,
ToolUsageErrorEvent,
ToolUsageEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
ToolValidateInputErrorEvent,
)
# Map every event class name → its module path for lazy loading
_LAZY_EVENT_MAPPING: dict[str, str] = {
# agent_events
"AgentEvaluationCompletedEvent": "crewai.events.types.agent_events",
"AgentEvaluationFailedEvent": "crewai.events.types.agent_events",
"AgentEvaluationStartedEvent": "crewai.events.types.agent_events",
"AgentExecutionCompletedEvent": "crewai.events.types.agent_events",
"AgentExecutionErrorEvent": "crewai.events.types.agent_events",
"AgentExecutionStartedEvent": "crewai.events.types.agent_events",
"LiteAgentExecutionCompletedEvent": "crewai.events.types.agent_events",
"LiteAgentExecutionErrorEvent": "crewai.events.types.agent_events",
"LiteAgentExecutionStartedEvent": "crewai.events.types.agent_events",
# checkpoint_events
"CheckpointBaseEvent": "crewai.events.types.checkpoint_events",
"CheckpointCompletedEvent": "crewai.events.types.checkpoint_events",
"CheckpointFailedEvent": "crewai.events.types.checkpoint_events",
"CheckpointForkBaseEvent": "crewai.events.types.checkpoint_events",
"CheckpointForkCompletedEvent": "crewai.events.types.checkpoint_events",
"CheckpointForkStartedEvent": "crewai.events.types.checkpoint_events",
"CheckpointPrunedEvent": "crewai.events.types.checkpoint_events",
"CheckpointRestoreBaseEvent": "crewai.events.types.checkpoint_events",
"CheckpointRestoreCompletedEvent": "crewai.events.types.checkpoint_events",
"CheckpointRestoreFailedEvent": "crewai.events.types.checkpoint_events",
"CheckpointRestoreStartedEvent": "crewai.events.types.checkpoint_events",
"CheckpointStartedEvent": "crewai.events.types.checkpoint_events",
# crew_events
"CrewKickoffCompletedEvent": "crewai.events.types.crew_events",
"CrewKickoffFailedEvent": "crewai.events.types.crew_events",
"CrewKickoffStartedEvent": "crewai.events.types.crew_events",
"CrewTestCompletedEvent": "crewai.events.types.crew_events",
"CrewTestFailedEvent": "crewai.events.types.crew_events",
"CrewTestResultEvent": "crewai.events.types.crew_events",
"CrewTestStartedEvent": "crewai.events.types.crew_events",
"CrewTrainCompletedEvent": "crewai.events.types.crew_events",
"CrewTrainFailedEvent": "crewai.events.types.crew_events",
"CrewTrainStartedEvent": "crewai.events.types.crew_events",
# flow_events
"FlowCreatedEvent": "crewai.events.types.flow_events",
"FlowEvent": "crewai.events.types.flow_events",
"FlowFinishedEvent": "crewai.events.types.flow_events",
"FlowPlotEvent": "crewai.events.types.flow_events",
"FlowStartedEvent": "crewai.events.types.flow_events",
"HumanFeedbackReceivedEvent": "crewai.events.types.flow_events",
"HumanFeedbackRequestedEvent": "crewai.events.types.flow_events",
"MethodExecutionFailedEvent": "crewai.events.types.flow_events",
"MethodExecutionFinishedEvent": "crewai.events.types.flow_events",
"MethodExecutionStartedEvent": "crewai.events.types.flow_events",
# knowledge_events
"KnowledgeQueryCompletedEvent": "crewai.events.types.knowledge_events",
"KnowledgeQueryFailedEvent": "crewai.events.types.knowledge_events",
"KnowledgeQueryStartedEvent": "crewai.events.types.knowledge_events",
"KnowledgeRetrievalCompletedEvent": "crewai.events.types.knowledge_events",
"KnowledgeRetrievalStartedEvent": "crewai.events.types.knowledge_events",
"KnowledgeSearchQueryFailedEvent": "crewai.events.types.knowledge_events",
# llm_events
"LLMCallCompletedEvent": "crewai.events.types.llm_events",
"LLMCallFailedEvent": "crewai.events.types.llm_events",
"LLMCallStartedEvent": "crewai.events.types.llm_events",
"LLMStreamChunkEvent": "crewai.events.types.llm_events",
# llm_guardrail_events
"LLMGuardrailCompletedEvent": "crewai.events.types.llm_guardrail_events",
"LLMGuardrailStartedEvent": "crewai.events.types.llm_guardrail_events",
# logging_events
"AgentLogsExecutionEvent": "crewai.events.types.logging_events",
"AgentLogsStartedEvent": "crewai.events.types.logging_events",
# mcp_events
"MCPConfigFetchFailedEvent": "crewai.events.types.mcp_events",
"MCPConnectionCompletedEvent": "crewai.events.types.mcp_events",
"MCPConnectionFailedEvent": "crewai.events.types.mcp_events",
"MCPConnectionStartedEvent": "crewai.events.types.mcp_events",
"MCPToolExecutionCompletedEvent": "crewai.events.types.mcp_events",
"MCPToolExecutionFailedEvent": "crewai.events.types.mcp_events",
"MCPToolExecutionStartedEvent": "crewai.events.types.mcp_events",
# memory_events
"MemoryQueryCompletedEvent": "crewai.events.types.memory_events",
"MemoryQueryFailedEvent": "crewai.events.types.memory_events",
"MemoryQueryStartedEvent": "crewai.events.types.memory_events",
"MemoryRetrievalCompletedEvent": "crewai.events.types.memory_events",
"MemoryRetrievalFailedEvent": "crewai.events.types.memory_events",
"MemoryRetrievalStartedEvent": "crewai.events.types.memory_events",
"MemorySaveCompletedEvent": "crewai.events.types.memory_events",
"MemorySaveFailedEvent": "crewai.events.types.memory_events",
"MemorySaveStartedEvent": "crewai.events.types.memory_events",
# reasoning_events
"AgentReasoningCompletedEvent": "crewai.events.types.reasoning_events",
"AgentReasoningFailedEvent": "crewai.events.types.reasoning_events",
"AgentReasoningStartedEvent": "crewai.events.types.reasoning_events",
"ReasoningEvent": "crewai.events.types.reasoning_events",
# skill_events
"SkillActivatedEvent": "crewai.events.types.skill_events",
"SkillDiscoveryCompletedEvent": "crewai.events.types.skill_events",
"SkillDiscoveryStartedEvent": "crewai.events.types.skill_events",
"SkillEvent": "crewai.events.types.skill_events",
"SkillLoadFailedEvent": "crewai.events.types.skill_events",
"SkillLoadedEvent": "crewai.events.types.skill_events",
# task_events
"TaskCompletedEvent": "crewai.events.types.task_events",
"TaskEvaluationEvent": "crewai.events.types.task_events",
"TaskFailedEvent": "crewai.events.types.task_events",
"TaskStartedEvent": "crewai.events.types.task_events",
# tool_usage_events
"ToolExecutionErrorEvent": "crewai.events.types.tool_usage_events",
"ToolSelectionErrorEvent": "crewai.events.types.tool_usage_events",
"ToolUsageErrorEvent": "crewai.events.types.tool_usage_events",
"ToolUsageEvent": "crewai.events.types.tool_usage_events",
"ToolUsageFinishedEvent": "crewai.events.types.tool_usage_events",
"ToolUsageStartedEvent": "crewai.events.types.tool_usage_events",
"ToolValidateInputErrorEvent": "crewai.events.types.tool_usage_events",
}
_extension_exports: dict[str, Any] = {}
def __getattr__(name: str) -> Any:
"""Lazy import for event types and registered extensions."""
if name in _LAZY_EVENT_MAPPING:
module_path = _LAZY_EVENT_MAPPING[name]
module = importlib.import_module(module_path)
val = getattr(module, name)
globals()[name] = val # cache for subsequent access
return val
if name in _extension_exports:
value = _extension_exports[name]
if isinstance(value, str):
module_path, _, attr_name = value.rpartition(".")
if module_path:
module = importlib.import_module(module_path)
return getattr(module, attr_name)
return importlib.import_module(value)
return value
msg = f"module {__name__!r} has no attribute {name!r}"
raise AttributeError(msg)
__all__ = [
@@ -140,6 +293,18 @@ __all__ = [
"AgentReasoningFailedEvent",
"AgentReasoningStartedEvent",
"BaseEventListener",
"CheckpointBaseEvent",
"CheckpointCompletedEvent",
"CheckpointFailedEvent",
"CheckpointForkBaseEvent",
"CheckpointForkCompletedEvent",
"CheckpointForkStartedEvent",
"CheckpointPrunedEvent",
"CheckpointRestoreBaseEvent",
"CheckpointRestoreCompletedEvent",
"CheckpointRestoreFailedEvent",
"CheckpointRestoreStartedEvent",
"CheckpointStartedEvent",
"CircularDependencyError",
"CrewKickoffCompletedEvent",
"CrewKickoffFailedEvent",
@@ -214,42 +379,3 @@ __all__ = [
"_extension_exports",
"crewai_event_bus",
]
_AGENT_EVENT_MAPPING = {
"AgentEvaluationCompletedEvent": "crewai.events.types.agent_events",
"AgentEvaluationFailedEvent": "crewai.events.types.agent_events",
"AgentEvaluationStartedEvent": "crewai.events.types.agent_events",
"AgentExecutionCompletedEvent": "crewai.events.types.agent_events",
"AgentExecutionErrorEvent": "crewai.events.types.agent_events",
"AgentExecutionStartedEvent": "crewai.events.types.agent_events",
"LiteAgentExecutionCompletedEvent": "crewai.events.types.agent_events",
"LiteAgentExecutionErrorEvent": "crewai.events.types.agent_events",
"LiteAgentExecutionStartedEvent": "crewai.events.types.agent_events",
}
_extension_exports: dict[str, Any] = {}
def __getattr__(name: str) -> Any:
"""Lazy import for agent events and registered extensions."""
if name in _AGENT_EVENT_MAPPING:
import importlib
module_path = _AGENT_EVENT_MAPPING[name]
module = importlib.import_module(module_path)
return getattr(module, name)
if name in _extension_exports:
import importlib
value = _extension_exports[name]
if isinstance(value, str):
module_path, _, attr_name = value.rpartition(".")
if module_path:
module = importlib.import_module(module_path)
return getattr(module, attr_name)
return importlib.import_module(value)
return value
msg = f"module {__name__!r} has no attribute {name!r}"
raise AttributeError(msg)

View File

@@ -64,6 +64,22 @@ P = ParamSpec("P")
R = TypeVar("R")
_replaying: contextvars.ContextVar[bool] = contextvars.ContextVar(
"crewai_event_replaying", default=False
)
def is_replaying() -> bool:
"""Return True if the current context is dispatching a replayed event.
Listeners with side effects (checkpoint writes, external API calls that
should not be repeated) should early-return when this is true. Listeners
whose purpose is reconstructing timeline state (trace batch, console
formatter) should ignore the flag and process replayed events normally.
"""
return _replaying.get()
class CrewAIEventsBus:
"""Singleton event bus for handling events in CrewAI.
@@ -261,6 +277,11 @@ class CrewAIEventsBus:
self._runtime_state = state
self._registered_entity_ids = {id(e) for e in state.root}
@property
def runtime_state(self) -> RuntimeState | None:
"""The RuntimeState currently attached to the bus, if any."""
return self._runtime_state
def register_entity(self, entity: Any) -> None:
"""Add an entity to the RuntimeState, creating it if needed.
@@ -568,6 +589,87 @@ class CrewAIEventsBus:
return None
async def _acall_handlers_replaying(
self,
source: Any,
event: BaseEvent,
handlers: AsyncHandlerSet,
) -> None:
"""Call async handlers with the replaying flag set on the loop thread."""
token = _replaying.set(True)
try:
await self._acall_handlers(source, event, handlers)
finally:
_replaying.reset(token)
async def _emit_with_dependencies_replaying(
self, source: Any, event: BaseEvent
) -> None:
"""Dependency-aware dispatch with the replaying flag set."""
token = _replaying.set(True)
try:
await self._emit_with_dependencies(source, event)
finally:
_replaying.reset(token)
def replay(self, source: Any, event: BaseEvent) -> Future[None] | None:
"""Dispatch a previously-recorded event without mutating its fields.
Unlike :meth:`emit`, this does not run ``_prepare_event`` (so stored
event ids and ``emission_sequence`` are preserved) and does not
re-record the event. Listeners can call :func:`is_replaying` to
opt out of side-effectful processing.
Args:
source: The emitting object.
event: The previously-recorded event to dispatch.
Returns:
Future that completes when handlers finish, or None if no handlers.
"""
event_type = type(event)
with self._rwlock.r_locked():
if self._shutting_down:
return None
has_dependencies = event_type in self._handler_dependencies
sync_handlers = self._sync_handlers.get(event_type, frozenset())
async_handlers = self._async_handlers.get(event_type, frozenset())
if not sync_handlers and not async_handlers:
return None
self._ensure_executor_initialized()
self._has_pending_events = True
token = _replaying.set(True)
try:
if has_dependencies:
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._emit_with_dependencies_replaying(source, event),
self._loop,
)
)
if sync_handlers:
ctx = contextvars.copy_context()
sync_future = self._sync_executor.submit(
ctx.run, self._call_handlers, source, event, sync_handlers
)
self._track_future(sync_future)
if not async_handlers:
return sync_future
return self._track_future(
asyncio.run_coroutine_threadsafe(
self._acall_handlers_replaying(source, event, async_handlers),
self._loop,
)
)
finally:
_replaying.reset(token)
def flush(self, timeout: float | None = 30.0) -> bool:
"""Block until all pending event handlers complete.

View File

@@ -30,6 +30,17 @@ from crewai.events.types.agent_events import (
AgentExecutionStartedEvent,
LiteAgentExecutionCompletedEvent,
)
from crewai.events.types.checkpoint_events import (
CheckpointCompletedEvent,
CheckpointFailedEvent,
CheckpointForkCompletedEvent,
CheckpointForkStartedEvent,
CheckpointPrunedEvent,
CheckpointRestoreCompletedEvent,
CheckpointRestoreFailedEvent,
CheckpointRestoreStartedEvent,
CheckpointStartedEvent,
)
from crewai.events.types.crew_events import (
CrewKickoffCompletedEvent,
CrewKickoffFailedEvent,
@@ -183,4 +194,13 @@ EventTypes = (
| MCPToolExecutionCompletedEvent
| MCPToolExecutionFailedEvent
| MCPConfigFetchFailedEvent
| CheckpointStartedEvent
| CheckpointCompletedEvent
| CheckpointFailedEvent
| CheckpointForkStartedEvent
| CheckpointForkCompletedEvent
| CheckpointRestoreStartedEvent
| CheckpointRestoreCompletedEvent
| CheckpointRestoreFailedEvent
| CheckpointPrunedEvent
)

View File

@@ -81,8 +81,11 @@ class TraceBatchManager:
"""Initialize a new trace batch (thread-safe)"""
with self._batch_ready_cv:
if self.current_batch is not None:
# Lazy init (e.g. DefaultEnvEvent) may have created the batch without
# execution_type; merge metadata from a later flow/crew initializer.
self.current_batch.execution_metadata.update(execution_metadata)
logger.debug(
"Batch already initialized, skipping duplicate initialization"
"Batch already initialized, merged execution metadata and skipped duplicate initialization"
)
return self.current_batch

View File

@@ -60,12 +60,6 @@ from crewai.events.types.crew_events import (
CrewKickoffFailedEvent,
CrewKickoffStartedEvent,
)
from crewai.events.types.env_events import (
CCEnvEvent,
CodexEnvEvent,
CursorEnvEvent,
DefaultEnvEvent,
)
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowFinishedEvent,
@@ -212,7 +206,6 @@ class TraceCollectionListener(BaseEventListener):
self._listeners_setup = True
return
self._register_env_event_handlers(crewai_event_bus)
self._register_flow_event_handlers(crewai_event_bus)
self._register_context_event_handlers(crewai_event_bus)
self._register_action_event_handlers(crewai_event_bus)
@@ -221,25 +214,6 @@ class TraceCollectionListener(BaseEventListener):
self._listeners_setup = True
def _register_env_event_handlers(self, event_bus: CrewAIEventsBus) -> None:
"""Register handlers for environment context events."""
@event_bus.on(CCEnvEvent)
def on_cc_env(source: Any, event: CCEnvEvent) -> None:
self._handle_action_event("cc_env", source, event)
@event_bus.on(CodexEnvEvent)
def on_codex_env(source: Any, event: CodexEnvEvent) -> None:
self._handle_action_event("codex_env", source, event)
@event_bus.on(CursorEnvEvent)
def on_cursor_env(source: Any, event: CursorEnvEvent) -> None:
self._handle_action_event("cursor_env", source, event)
@event_bus.on(DefaultEnvEvent)
def on_default_env(source: Any, event: DefaultEnvEvent) -> None:
self._handle_action_event("default_env", source, event)
def _register_flow_event_handlers(self, event_bus: CrewAIEventsBus) -> None:
"""Register handlers for flow events."""
@@ -286,8 +260,8 @@ class TraceCollectionListener(BaseEventListener):
if self.batch_manager.batch_owner_type != "flow":
# Always call _initialize_crew_batch to claim ownership.
# If batch was already initialized by a concurrent action event
# (race condition with DefaultEnvEvent), initialize_batch() returns
# early but batch_owner_type is still correctly set to "crew".
# (e.g. LLM/tool before crew_kickoff_started), initialize_batch()
# returns early but batch_owner_type is still correctly set to "crew".
# Skip only when a parent flow already owns the batch.
self._initialize_crew_batch(source, event)
self._handle_trace_event("crew_kickoff_started", source, event)

View File

@@ -0,0 +1,97 @@
"""Event family for automatic state checkpointing and forking."""
from typing import Literal
from crewai.events.base_events import BaseEvent
class CheckpointBaseEvent(BaseEvent):
"""Base event for checkpoint lifecycle operations."""
type: str
location: str
provider: str
trigger: str | None = None
branch: str | None = None
parent_id: str | None = None
class CheckpointStartedEvent(CheckpointBaseEvent):
"""Event emitted immediately before a checkpoint is written."""
type: Literal["checkpoint_started"] = "checkpoint_started"
class CheckpointCompletedEvent(CheckpointBaseEvent):
"""Event emitted when a checkpoint has been written successfully."""
type: Literal["checkpoint_completed"] = "checkpoint_completed"
checkpoint_id: str
duration_ms: float
class CheckpointFailedEvent(CheckpointBaseEvent):
"""Event emitted when a checkpoint write fails."""
type: Literal["checkpoint_failed"] = "checkpoint_failed"
error: str
class CheckpointPrunedEvent(CheckpointBaseEvent):
"""Event emitted after pruning old checkpoints from a branch."""
type: Literal["checkpoint_pruned"] = "checkpoint_pruned"
removed_count: int
max_checkpoints: int
class CheckpointForkBaseEvent(BaseEvent):
"""Base event for fork lifecycle operations on a RuntimeState."""
type: str
branch: str
parent_branch: str | None = None
parent_checkpoint_id: str | None = None
class CheckpointForkStartedEvent(CheckpointForkBaseEvent):
"""Event emitted immediately before a fork relabels the branch."""
type: Literal["checkpoint_fork_started"] = "checkpoint_fork_started"
class CheckpointForkCompletedEvent(CheckpointForkBaseEvent):
"""Event emitted after a fork has established the new branch."""
type: Literal["checkpoint_fork_completed"] = "checkpoint_fork_completed"
class CheckpointRestoreBaseEvent(BaseEvent):
"""Base event for checkpoint restore lifecycle operations."""
type: str
location: str
provider: str | None = None
class CheckpointRestoreStartedEvent(CheckpointRestoreBaseEvent):
"""Event emitted immediately before a checkpoint restore begins."""
type: Literal["checkpoint_restore_started"] = "checkpoint_restore_started"
class CheckpointRestoreCompletedEvent(CheckpointRestoreBaseEvent):
"""Event emitted when a checkpoint has been restored successfully."""
type: Literal["checkpoint_restore_completed"] = "checkpoint_restore_completed"
checkpoint_id: str
branch: str | None = None
parent_id: str | None = None
duration_ms: float
class CheckpointRestoreFailedEvent(CheckpointRestoreBaseEvent):
"""Event emitted when a checkpoint restore fails."""
type: Literal["checkpoint_restore_failed"] = "checkpoint_restore_failed"
error: str

View File

@@ -153,7 +153,7 @@ class AgentExecutorState(BaseModel):
)
class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): # type: ignore[pydantic-unexpected]
class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
"""Agent Executor for both standalone agents and crew-bound agents.
_skip_auto_memory prevents Flow from eagerly allocating a Memory
@@ -1194,7 +1194,7 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): # type: ignor
return "initialized"
@router("force_final_answer")
def force_final_answer(self) -> Literal["agent_finished"]:
def ensure_force_final_answer(self) -> Literal["agent_finished"]:
"""Force agent to provide final answer when max iterations exceeded."""
formatted_answer = handle_max_iterations_exceeded(
formatted_answer=None,

View File

@@ -45,6 +45,7 @@ from pydantic import (
BeforeValidator,
ConfigDict,
Field,
PlainSerializer,
PrivateAttr,
SerializeAsAny,
ValidationError,
@@ -58,6 +59,7 @@ from crewai.events.event_bus import crewai_event_bus
from crewai.events.event_context import (
get_current_parent_id,
reset_last_event_id,
restore_event_scope,
triggered_by_scope,
)
from crewai.events.listeners.tracing.trace_listener import (
@@ -157,6 +159,37 @@ def _resolve_persistence(value: Any) -> Any:
return value
_INITIAL_STATE_CLASS_MARKER = "__crewai_pydantic_class_schema__"
def _serialize_initial_state(value: Any) -> Any:
"""Make ``initial_state`` safe for JSON checkpoint serialization.
``BaseModel`` class refs are emitted as their JSON schema under a sentinel
marker key so deserialization can round-trip them back to a class.
``BaseModel`` instances are dumped to JSON (round-trip as plain dicts,
which ``_create_initial_state`` accepts). Bare ``type`` values that are
not ``BaseModel`` subclasses (e.g. ``dict``) are dropped since they
can't be represented in JSON.
"""
if isinstance(value, type):
if issubclass(value, BaseModel):
return {_INITIAL_STATE_CLASS_MARKER: value.model_json_schema()}
return None
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
return value
def _deserialize_initial_state(value: Any) -> Any:
"""Rehydrate a class ref serialized by :func:`_serialize_initial_state`."""
if isinstance(value, dict) and _INITIAL_STATE_CLASS_MARKER in value:
from crewai.utilities.pydantic_schema_utils import create_model_from_schema
return create_model_from_schema(value[_INITIAL_STATE_CLASS_MARKER])
return value
class FlowState(BaseModel):
"""Base model for all flow states, ensuring each state has a unique ID."""
@@ -908,7 +941,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
entity_type: Literal["flow"] = "flow"
initial_state: Any = Field(default=None)
initial_state: Annotated[ # type: ignore[type-arg]
type[BaseModel] | type[dict] | dict[str, Any] | BaseModel | None,
BeforeValidator(_deserialize_initial_state),
PlainSerializer(_serialize_initial_state, return_type=Any, when_used="json"),
] = Field(default=None)
name: str | None = Field(default=None)
tracing: bool | None = Field(default=None)
stream: bool = Field(default=False)
@@ -980,13 +1017,18 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
A Flow instance on the new branch. Call kickoff() to run.
"""
flow = cls.from_checkpoint(config)
state = crewai_event_bus._runtime_state
state = crewai_event_bus.runtime_state
if state is None:
raise RuntimeError(
"Cannot fork: no runtime state on the event bus. "
"Ensure from_checkpoint() succeeded before calling fork()."
)
state.fork(branch)
new_id = str(uuid4())
if isinstance(flow._state, dict):
flow._state["id"] = new_id
else:
object.__setattr__(flow._state, "id", new_id)
return flow
checkpoint_completed_methods: set[str] | None = Field(default=None)
@@ -1008,6 +1050,8 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
}
if self.checkpoint_state is not None:
self._restore_state(self.checkpoint_state)
restore_event_scope(())
reset_last_event_id()
_methods: dict[FlowMethodName, FlowMethod[Any, Any]] = PrivateAttr(
default_factory=dict
@@ -1030,6 +1074,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
_human_feedback_method_outputs: dict[str, Any] = PrivateAttr(default_factory=dict)
_input_history: list[InputHistoryEntry] = PrivateAttr(default_factory=list)
_state: Any = PrivateAttr(default=None)
_execution_id: str = PrivateAttr(default_factory=lambda: str(uuid4()))
def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]: # type: ignore[override]
class _FlowGeneric(cls): # type: ignore[valid-type,misc]
@@ -1503,6 +1548,8 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
except Exception:
logger.warning("FlowStartedEvent handler failed", exc_info=True)
get_env_context()
context = self._pending_feedback_context
emit = context.emit
default_outcome = context.default_outcome
@@ -1818,6 +1865,27 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
except (AttributeError, TypeError):
return "" # Safely handle any unexpected attribute access issues
@property
def execution_id(self) -> str:
"""Stable identifier for this flow execution.
Separate from ``flow_id`` / ``state.id``, which consumers may
override via ``kickoff(inputs={"id": ...})`` to resume a persisted
flow. ``execution_id`` is never affected by ``inputs`` and stays
stable for the lifetime of a single run, so it is the correct key
for telemetry, tracing, and any external correlation that must
uniquely identify a single execution even when callers pass an
``id`` in ``inputs``.
Defaults to a fresh ``uuid4`` per ``Flow`` instance; assign to
override when an outer system already has an execution identity.
"""
return self._execution_id
@execution_id.setter
def execution_id(self, value: str) -> None:
self._execution_id = value
def _initialize_state(self, inputs: dict[str, Any]) -> None:
"""Initialize or update flow state with new inputs.
@@ -2004,7 +2072,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
restored = apply_checkpoint(self, from_checkpoint)
if restored is not None:
return restored.kickoff(inputs=inputs, input_files=input_files)
get_env_context()
if self.stream:
result_holder: list[Any] = []
current_task_info: TaskInfo = {
@@ -2132,13 +2199,15 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
flow_id_token = None
request_id_token = None
if current_flow_id.get() is None:
flow_id_token = current_flow_id.set(self.flow_id)
flow_id_token = current_flow_id.set(self.execution_id)
if current_flow_request_id.get() is None:
request_id_token = current_flow_request_id.set(self.flow_id)
request_id_token = current_flow_request_id.set(self.execution_id)
try:
# Reset flow state for fresh execution unless restoring from persistence
is_restoring = inputs and "id" in inputs and self.persistence is not None
is_restoring = (
inputs and "id" in inputs and self.persistence is not None
) or self.checkpoint_completed_methods is not None
if not is_restoring:
# Clear completed methods and outputs for a fresh start
self._completed_methods.clear()
@@ -2204,9 +2273,16 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
f"Flow started with ID: {self.flow_id}", color="bold magenta"
)
# After FlowStarted (when not suppressed): env events must not pre-empt
# trace batch init with implicit "crew" execution_type.
get_env_context()
if inputs is not None and "id" not in inputs:
self._initialize_state(inputs)
if self._is_execution_resuming:
await self._replay_recorded_events()
try:
# Determine which start methods to execute at kickoff
# Conditional start methods (with __trigger_methods__) are only triggered by their conditions
@@ -2354,6 +2430,44 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
"""
return await self.kickoff_async(inputs, input_files, from_checkpoint)
async def _replay_recorded_events(self) -> None:
"""Dispatch recorded ``MethodExecution*`` events from the event record."""
state = crewai_event_bus.runtime_state
if state is None:
return
record = state.event_record
if len(record) == 0:
return
replayable = (
MethodExecutionStartedEvent,
MethodExecutionFinishedEvent,
MethodExecutionFailedEvent,
)
flow_name = self.name or self.__class__.__name__
nodes = sorted(
(
n
for n in record.all_nodes()
if isinstance(n.event, replayable)
and n.event.flow_name == flow_name
and n.event.method_name in self._completed_methods
),
key=lambda n: n.event.emission_sequence or 0,
)
for node in nodes:
future = crewai_event_bus.replay(self, node.event)
if future is not None:
try:
await asyncio.wrap_future(future)
except Exception:
logger.warning(
"Replayed event handler failed: %s",
node.event.type,
exc_info=True,
)
async def _execute_start_method(self, start_method_name: FlowMethodName) -> None:
"""Executes a flow's start method and its triggered listeners.

View File

@@ -9,6 +9,7 @@ import time
from types import MethodType
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Literal,
cast,
@@ -25,6 +26,7 @@ from pydantic import (
field_validator,
model_validator,
)
from pydantic.functional_serializers import PlainSerializer
from typing_extensions import Self, deprecated
@@ -86,7 +88,7 @@ from crewai.utilities.converter import (
Converter,
ConverterError,
)
from crewai.utilities.guardrail import process_guardrail
from crewai.utilities.guardrail import process_guardrail, serialize_guardrail_for_json
from crewai.utilities.guardrail_types import GuardrailCallable, GuardrailType
from crewai.utilities.i18n import I18N_DEFAULT
from crewai.utilities.llm_utils import create_llm
@@ -235,7 +237,14 @@ class LiteAgent(FlowTrackable, BaseModel):
verbose: bool = Field(
default=False, description="Whether to print execution details"
)
guardrail: GuardrailType | None = Field(
guardrail: Annotated[
GuardrailType | None,
PlainSerializer(
serialize_guardrail_for_json,
return_type=str | None,
when_used="json",
),
] = Field(
default=None,
description="Function or string description of a guardrail to validate agent output",
)

View File

@@ -175,6 +175,16 @@ LLM_CONTEXT_WINDOW_SIZES: Final[dict[str, int]] = {
"us.amazon.nova-pro-v1:0": 300000,
"us.amazon.nova-micro-v1:0": 128000,
"us.amazon.nova-lite-v1:0": 300000,
# Claude 4 models
"us.anthropic.claude-opus-4-7": 1000000,
"us.anthropic.claude-sonnet-4-6": 1000000,
"us.anthropic.claude-opus-4-6-v1": 1000000,
"us.anthropic.claude-opus-4-5-20251101-v1:0": 200000,
"us.anthropic.claude-haiku-4-5-20251001-v1:0": 200000,
"us.anthropic.claude-sonnet-4-5-20250929-v1:0": 200000,
"us.anthropic.claude-opus-4-1-20250805-v1:0": 200000,
"us.anthropic.claude-opus-4-20250514-v1:0": 200000,
"us.anthropic.claude-sonnet-4-20250514-v1:0": 200000,
"us.anthropic.claude-3-5-sonnet-20240620-v1:0": 200000,
"us.anthropic.claude-3-5-haiku-20241022-v1:0": 200000,
"us.anthropic.claude-3-5-sonnet-20241022-v2:0": 200000,
@@ -193,15 +203,44 @@ LLM_CONTEXT_WINDOW_SIZES: Final[dict[str, int]] = {
"eu.anthropic.claude-3-5-sonnet-20240620-v1:0": 200000,
"eu.anthropic.claude-3-sonnet-20240229-v1:0": 200000,
"eu.anthropic.claude-3-haiku-20240307-v1:0": 200000,
# Claude 4 EU
"eu.anthropic.claude-opus-4-7": 1000000,
"eu.anthropic.claude-sonnet-4-6": 1000000,
"eu.anthropic.claude-opus-4-6-v1": 1000000,
"eu.anthropic.claude-opus-4-5-20251101-v1:0": 200000,
"eu.anthropic.claude-haiku-4-5-20251001-v1:0": 200000,
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0": 200000,
"eu.anthropic.claude-opus-4-1-20250805-v1:0": 200000,
"eu.anthropic.claude-opus-4-20250514-v1:0": 200000,
"eu.anthropic.claude-sonnet-4-20250514-v1:0": 200000,
"eu.meta.llama3-2-3b-instruct-v1:0": 131000,
"eu.meta.llama3-2-1b-instruct-v1:0": 131000,
"apac.anthropic.claude-3-5-sonnet-20240620-v1:0": 200000,
"apac.anthropic.claude-3-5-sonnet-20241022-v2:0": 200000,
"apac.anthropic.claude-3-sonnet-20240229-v1:0": 200000,
"apac.anthropic.claude-3-haiku-20240307-v1:0": 200000,
# Claude 4 APAC
"apac.anthropic.claude-opus-4-7": 1000000,
"apac.anthropic.claude-sonnet-4-6": 1000000,
"apac.anthropic.claude-opus-4-6-v1": 1000000,
"apac.anthropic.claude-opus-4-5-20251101-v1:0": 200000,
"apac.anthropic.claude-haiku-4-5-20251001-v1:0": 200000,
"apac.anthropic.claude-sonnet-4-5-20250929-v1:0": 200000,
"apac.anthropic.claude-opus-4-1-20250805-v1:0": 200000,
"apac.anthropic.claude-opus-4-20250514-v1:0": 200000,
"apac.anthropic.claude-sonnet-4-20250514-v1:0": 200000,
"amazon.nova-pro-v1:0": 300000,
"amazon.nova-micro-v1:0": 128000,
"amazon.nova-lite-v1:0": 300000,
"anthropic.claude-opus-4-7": 1000000,
"anthropic.claude-sonnet-4-6": 1000000,
"anthropic.claude-opus-4-6-v1": 1000000,
"anthropic.claude-opus-4-5-20251101-v1:0": 200000,
"anthropic.claude-haiku-4-5-20251001-v1:0": 200000,
"anthropic.claude-sonnet-4-5-20250929-v1:0": 200000,
"anthropic.claude-opus-4-1-20250805-v1:0": 200000,
"anthropic.claude-opus-4-20250514-v1:0": 200000,
"anthropic.claude-sonnet-4-20250514-v1:0": 200000,
"anthropic.claude-3-5-sonnet-20240620-v1:0": 200000,
"anthropic.claude-3-5-haiku-20241022-v1:0": 200000,
"anthropic.claude-3-5-sonnet-20241022-v2:0": 200000,

View File

@@ -423,6 +423,34 @@ AZURE_MODELS: list[AzureModels] = [
BedrockModels: TypeAlias = Literal[
# Inference profiles (regional) - Claude 4
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"us.anthropic.claude-sonnet-4-20250514-v1:0",
"us.anthropic.claude-opus-4-5-20251101-v1:0",
"us.anthropic.claude-opus-4-20250514-v1:0",
"us.anthropic.claude-opus-4-1-20250805-v1:0",
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
"us.anthropic.claude-sonnet-4-6",
"us.anthropic.claude-opus-4-6-v1",
# Inference profiles - shorter versions
"us.anthropic.claude-sonnet-4-5-v1:0",
"us.anthropic.claude-opus-4-5-v1:0",
"us.anthropic.claude-opus-4-6-v1:0",
"us.anthropic.claude-haiku-4-5-v1:0",
"eu.anthropic.claude-sonnet-4-5-v1:0",
"eu.anthropic.claude-opus-4-5-v1:0",
"eu.anthropic.claude-haiku-4-5-v1:0",
"apac.anthropic.claude-sonnet-4-5-v1:0",
"apac.anthropic.claude-opus-4-5-v1:0",
"apac.anthropic.claude-haiku-4-5-v1:0",
# Global inference profiles
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
"global.anthropic.claude-sonnet-4-20250514-v1:0",
"global.anthropic.claude-opus-4-5-20251101-v1:0",
"global.anthropic.claude-opus-4-6-v1",
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
"global.anthropic.claude-sonnet-4-6",
# Direct model IDs
"ai21.jamba-1-5-large-v1:0",
"ai21.jamba-1-5-mini-v1:0",
"amazon.nova-lite-v1:0",
@@ -496,6 +524,34 @@ BedrockModels: TypeAlias = Literal[
"twelvelabs.pegasus-1-2-v1:0",
]
BEDROCK_MODELS: list[BedrockModels] = [
# Inference profiles (regional) - Claude 4
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"us.anthropic.claude-sonnet-4-20250514-v1:0",
"us.anthropic.claude-opus-4-5-20251101-v1:0",
"us.anthropic.claude-opus-4-20250514-v1:0",
"us.anthropic.claude-opus-4-1-20250805-v1:0",
"us.anthropic.claude-haiku-4-5-20251001-v1:0",
"us.anthropic.claude-sonnet-4-6",
"us.anthropic.claude-opus-4-6-v1",
# Inference profiles - shorter versions
"us.anthropic.claude-sonnet-4-5-v1:0",
"us.anthropic.claude-opus-4-5-v1:0",
"us.anthropic.claude-opus-4-6-v1:0",
"us.anthropic.claude-haiku-4-5-v1:0",
"eu.anthropic.claude-sonnet-4-5-v1:0",
"eu.anthropic.claude-opus-4-5-v1:0",
"eu.anthropic.claude-haiku-4-5-v1:0",
"apac.anthropic.claude-sonnet-4-5-v1:0",
"apac.anthropic.claude-opus-4-5-v1:0",
"apac.anthropic.claude-haiku-4-5-v1:0",
# Global inference profiles
"global.anthropic.claude-sonnet-4-5-20250929-v1:0",
"global.anthropic.claude-sonnet-4-20250514-v1:0",
"global.anthropic.claude-opus-4-5-20251101-v1:0",
"global.anthropic.claude-opus-4-6-v1",
"global.anthropic.claude-haiku-4-5-20251001-v1:0",
"global.anthropic.claude-sonnet-4-6",
# Direct model IDs
"ai21.jamba-1-5-large-v1:0",
"ai21.jamba-1-5-mini-v1:0",
"amazon.nova-lite-v1:0",

View File

@@ -183,11 +183,6 @@ class AzureCompletion(BaseLLM):
AzureCompletion._is_azure_openai_endpoint(self.endpoint)
)
if not self.api_key:
raise ValueError(
"Azure API key is required. Set AZURE_API_KEY environment "
"variable or pass api_key parameter."
)
if not self.endpoint:
raise ValueError(
"Azure endpoint is required. Set AZURE_ENDPOINT environment "
@@ -195,12 +190,39 @@ class AzureCompletion(BaseLLM):
)
client_kwargs: dict[str, Any] = {
"endpoint": self.endpoint,
"credential": AzureKeyCredential(self.api_key),
"credential": self._resolve_credential(),
}
if self.api_version:
client_kwargs["api_version"] = self.api_version
return client_kwargs
def _resolve_credential(self) -> Any:
"""Return an Azure credential, preferring the API key when set.
Without an API key, fall back to ``DefaultAzureCredential`` from
``azure-identity``. That chain auto-detects the standard keyless
paths the customer's environment may provide — OIDC Workload
Identity Federation (``AZURE_FEDERATED_TOKEN_FILE`` +
``AZURE_TENANT_ID`` + ``AZURE_CLIENT_ID``), Managed Identity on
AKS/Azure VMs, environment-configured service principals, and
developer tools like the Azure CLI. Installing ``azure-identity``
is what enables these paths; without it we raise the existing
API-key error.
"""
if self.api_key:
return AzureKeyCredential(self.api_key)
try:
from azure.identity import DefaultAzureCredential
except ImportError:
raise ValueError(
"Azure API key is required when azure-identity is not "
"installed. Set AZURE_API_KEY, or install azure-identity "
'for keyless auth: uv add "crewai[azure-ai-inference]"'
) from None
return DefaultAzureCredential()
def _get_sync_client(self) -> Any:
if self._client is None:
self._client = self._build_sync_client()

View File

@@ -17,10 +17,7 @@ from crewai.utilities.agent_utils import is_context_length_exceeded
from crewai.utilities.exceptions.context_window_exceeding_exception import (
LLMContextLengthExceededError,
)
from crewai.utilities.pydantic_schema_utils import (
generate_model_description,
sanitize_tool_params_for_bedrock_strict,
)
from crewai.utilities.pydantic_schema_utils import generate_model_description
from crewai.utilities.types import LLMMessage
@@ -173,7 +170,6 @@ class ToolSpec(TypedDict, total=False):
name: Required[str]
description: Required[str]
inputSchema: ToolInputSchema
strict: bool
class ConverseToolTypeDef(TypedDict):
@@ -1988,21 +1984,10 @@ class BedrockCompletion(BaseLLM):
"description": description,
}
func_info = tool.get("function", {})
strict_enabled = bool(func_info.get("strict"))
if parameters and isinstance(parameters, dict):
schema_params = (
sanitize_tool_params_for_bedrock_strict(parameters)
if strict_enabled
else parameters
)
input_schema: ToolInputSchema = {"json": schema_params}
input_schema: ToolInputSchema = {"json": parameters}
tool_spec["inputSchema"] = input_schema
if strict_enabled:
tool_spec["strict"] = True
converse_tool: ConverseToolTypeDef = {"toolSpec": tool_spec}
converse_tools.append(converse_tool)
@@ -2090,6 +2075,9 @@ class BedrockCompletion(BaseLLM):
# Context window sizes for common Bedrock models
context_windows = {
"anthropic.claude-sonnet-4": 200000,
"anthropic.claude-opus-4": 200000,
"anthropic.claude-haiku-4": 200000,
"anthropic.claude-3-5-sonnet": 200000,
"anthropic.claude-3-5-haiku": 200000,
"anthropic.claude-3-opus": 200000,

View File

@@ -976,6 +976,7 @@ class GeminiCompletion(BaseLLM):
"id": call_id,
"name": part.function_call.name,
"args": args_dict,
"raw_part": part,
}
self._emit_stream_chunk_event(
@@ -1060,29 +1061,20 @@ class GeminiCompletion(BaseLLM):
if call_data.get("name") != STRUCTURED_OUTPUT_TOOL_NAME
}
# If there are function calls but no available_functions,
# return them for the executor to handle
if non_structured_output_calls and not available_functions:
formatted_function_calls = [
{
"id": call_data["id"],
"function": {
"name": call_data["name"],
"arguments": json.dumps(call_data["args"]),
},
"type": "function",
}
raw_parts = [
call_data["raw_part"]
for call_data in non_structured_output_calls.values()
]
self._emit_call_completed_event(
response=formatted_function_calls,
response=raw_parts,
call_type=LLMCallType.TOOL_CALL,
from_task=from_task,
from_agent=from_agent,
messages=self._convert_contents_to_dict(contents),
usage=usage_data,
)
return formatted_function_calls
return raw_parts
# Handle completed function calls (excluding structured_output)
if non_structured_output_calls and available_functions:

View File

@@ -2,9 +2,17 @@
This module provides native MCP client functionality, allowing CrewAI agents
to connect to any MCP-compliant server using various transport types.
Heavy imports (MCPClient, MCPToolResolver, BaseTransport, TransportType) are
lazy-loaded on first access to avoid pulling in the ``mcp`` SDK (~400ms)
when only lightweight config/filter types are needed.
"""
from crewai.mcp.client import MCPClient
from __future__ import annotations
import importlib
from typing import TYPE_CHECKING, Any
from crewai.mcp.config import (
MCPServerConfig,
MCPServerHTTP,
@@ -18,8 +26,28 @@ from crewai.mcp.filters import (
create_dynamic_tool_filter,
create_static_tool_filter,
)
from crewai.mcp.tool_resolver import MCPToolResolver
from crewai.mcp.transports.base import BaseTransport, TransportType
if TYPE_CHECKING:
from crewai.mcp.client import MCPClient
from crewai.mcp.tool_resolver import MCPToolResolver
from crewai.mcp.transports.base import BaseTransport, TransportType
_LAZY: dict[str, tuple[str, str]] = {
"MCPClient": ("crewai.mcp.client", "MCPClient"),
"MCPToolResolver": ("crewai.mcp.tool_resolver", "MCPToolResolver"),
"BaseTransport": ("crewai.mcp.transports.base", "BaseTransport"),
"TransportType": ("crewai.mcp.transports.base", "TransportType"),
}
def __getattr__(name: str) -> Any:
if name in _LAZY:
mod_path, attr = _LAZY[name]
mod = importlib.import_module(mod_path)
val = getattr(mod, attr)
globals()[name] = val # cache for subsequent access
return val
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [

View File

@@ -417,9 +417,18 @@ class MCPToolResolver:
args_schema = None
if tool_def.get("inputSchema"):
args_schema = self._json_schema_to_pydantic(
tool_name, tool_def["inputSchema"]
)
try:
args_schema = self._json_schema_to_pydantic(
tool_name, tool_def["inputSchema"]
)
except Exception as e:
self._logger.log(
"warning",
f"Failed to build args schema for MCP tool "
f"'{tool_name}': {e}. Registering tool without a "
"typed schema.",
)
args_schema = None
tool_schema = {
"description": tool_def.get("description", ""),

View File

@@ -237,6 +237,8 @@ def crew(
self.tasks = instantiated_tasks
crew_instance: Crew = _call_method(meth, self, *args, **kwargs)
if "name" not in crew_instance.model_fields_set:
crew_instance.name = getattr(self, "_crew_name", None) or crew_instance.name
def callback_wrapper(
hook: Callable[Concatenate[CrewInstance, P2], R2], instance: CrewInstance

View File

@@ -10,12 +10,22 @@ from __future__ import annotations
import json
import logging
import threading
import time
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.events.event_bus import CrewAIEventsBus, crewai_event_bus, is_replaying
from crewai.events.types.checkpoint_events import (
CheckpointBaseEvent,
CheckpointCompletedEvent,
CheckpointFailedEvent,
CheckpointForkBaseEvent,
CheckpointPrunedEvent,
CheckpointRestoreBaseEvent,
CheckpointStartedEvent,
)
from crewai.flow.flow import Flow
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.state.runtime import RuntimeState, _prepare_entities
@@ -53,12 +63,26 @@ def _resolve(value: CheckpointConfig | bool | None) -> CheckpointConfig | None |
if isinstance(value, CheckpointConfig):
_ensure_handlers_registered()
return value
if value is True:
if value:
_ensure_handlers_registered()
return CheckpointConfig()
if value is False:
return _SENTINEL
return None # None = inherit
return None
def _resolve_from_agent(agent: BaseAgent) -> CheckpointConfig | None:
"""Resolve a checkpoint config starting from an agent, walking to its crew."""
result = _resolve(agent.checkpoint)
if isinstance(result, CheckpointConfig):
return result
if result is _SENTINEL:
return None
crew = agent.crew
if isinstance(crew, Crew):
crew_result = _resolve(crew.checkpoint)
return crew_result if isinstance(crew_result, CheckpointConfig) else None
return None
def _find_checkpoint(source: Any) -> CheckpointConfig | None:
@@ -77,28 +101,11 @@ def _find_checkpoint(source: Any) -> CheckpointConfig | None:
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
return _resolve_from_agent(source)
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 _resolve_from_agent(agent)
return None
return None
@@ -107,21 +114,106 @@ def _do_checkpoint(
state: RuntimeState, cfg: CheckpointConfig, event: BaseEvent | None = None
) -> None:
"""Write a checkpoint and prune old ones if configured."""
_prepare_entities(state.root)
payload = state.model_dump(mode="json")
if event is not None:
payload["trigger"] = event.type
data = json.dumps(payload)
location = cfg.provider.checkpoint(
data,
cfg.location,
parent_id=state._parent_id,
branch=state._branch,
provider_name: str = type(cfg.provider).__name__
trigger: str | None = event.type if event is not None else None
context: dict[str, Any] = {
"task_id": event.task_id if event is not None else None,
"task_name": event.task_name if event is not None else None,
"agent_id": event.agent_id if event is not None else None,
"agent_role": event.agent_role if event is not None else None,
}
parent_id_snapshot: str | None = state._parent_id
branch_snapshot: str = state._branch
crewai_event_bus.emit(
cfg,
CheckpointStartedEvent(
location=cfg.location,
provider=provider_name,
trigger=trigger,
branch=branch_snapshot,
parent_id=parent_id_snapshot,
**context,
),
)
start: float = time.perf_counter()
try:
_prepare_entities(state.root)
payload = state.model_dump(mode="json")
if event is not None:
payload["trigger"] = event.type
data = json.dumps(payload)
location = cfg.provider.checkpoint(
data,
cfg.location,
parent_id=parent_id_snapshot,
branch=branch_snapshot,
)
state._chain_lineage(cfg.provider, location)
checkpoint_id: str = cfg.provider.extract_id(location)
except Exception as exc:
crewai_event_bus.emit(
cfg,
CheckpointFailedEvent(
location=cfg.location,
provider=provider_name,
trigger=trigger,
branch=branch_snapshot,
parent_id=parent_id_snapshot,
error=str(exc),
**context,
),
)
raise
duration_ms: float = (time.perf_counter() - start) * 1000.0
msg: str = (
f"Checkpoint saved. Resume with: crewai checkpoint resume {checkpoint_id}"
)
logger.info(msg)
crewai_event_bus.emit(
cfg,
CheckpointCompletedEvent(
location=location,
provider=provider_name,
trigger=trigger,
branch=branch_snapshot,
parent_id=parent_id_snapshot,
checkpoint_id=checkpoint_id,
duration_ms=duration_ms,
**context,
),
)
state._chain_lineage(cfg.provider, location)
if cfg.max_checkpoints is not None:
cfg.provider.prune(cfg.location, cfg.max_checkpoints, branch=state._branch)
try:
removed_count: int = cfg.provider.prune(
cfg.location, cfg.max_checkpoints, branch=branch_snapshot
)
except Exception:
logger.warning(
"Checkpoint prune failed for %s (branch=%s)",
cfg.location,
branch_snapshot,
exc_info=True,
)
return
crewai_event_bus.emit(
cfg,
CheckpointPrunedEvent(
location=cfg.location,
provider=provider_name,
trigger=trigger,
branch=branch_snapshot,
parent_id=parent_id_snapshot,
removed_count=removed_count,
max_checkpoints=cfg.max_checkpoints,
**context,
),
)
def _should_checkpoint(source: Any, event: BaseEvent) -> CheckpointConfig | None:
@@ -136,6 +228,13 @@ def _should_checkpoint(source: Any, event: BaseEvent) -> CheckpointConfig | None
def _on_any_event(source: Any, event: BaseEvent, state: Any) -> None:
"""Sync handler registered on every event class."""
if is_replaying():
return
if isinstance(
event,
(CheckpointBaseEvent, CheckpointForkBaseEvent, CheckpointRestoreBaseEvent),
):
return
cfg = _should_checkpoint(source, event)
if cfg is None:
return
@@ -155,7 +254,8 @@ def _register_all_handlers(event_bus: CrewAIEventsBus) -> None:
seen: set[type] = set()
def _collect(cls: type[BaseEvent]) -> None:
for sub in cls.__subclasses__():
subclasses: list[type[BaseEvent]] = cls.__subclasses__()
for sub in subclasses:
if sub not in seen:
seen.add(sub)
type_field = sub.model_fields.get("type")

View File

@@ -39,7 +39,8 @@ def _build_event_type_map() -> None:
"""Populate _event_type_map from all BaseEvent subclasses."""
def _collect(cls: type[BaseEvent]) -> None:
for sub in cls.__subclasses__():
subclasses: list[type[BaseEvent]] = cls.__subclasses__()
for sub in subclasses:
type_field = sub.model_fields.get("type")
if type_field and type_field.default:
_event_type_map[type_field.default] = sub
@@ -196,6 +197,21 @@ class EventRecord(BaseModel):
node for node in self.nodes.values() if not node.neighbors("parent")
]
def all_nodes(self) -> list[EventNode]:
"""Return a snapshot of every node under the read lock.
Returns:
A list copy of the current nodes, safe to iterate without holding
the lock.
"""
with self._lock.r_locked():
return list(self.nodes.values())
def clear(self) -> None:
"""Remove all nodes from the record under the write lock."""
with self._lock.w_locked():
self.nodes.clear()
def __len__(self) -> int:
with self._lock.r_locked():
return len(self.nodes)

View File

@@ -61,13 +61,16 @@ class BaseProvider(BaseModel, ABC):
...
@abstractmethod
def prune(self, location: str, max_keep: int, *, branch: str = "main") -> None:
def prune(self, location: str, max_keep: int, *, branch: str = "main") -> int:
"""Remove old checkpoints, keeping at most *max_keep* per branch.
Args:
location: The storage destination passed to ``checkpoint``.
max_keep: Maximum number of checkpoints to retain.
branch: Only prune checkpoints on this branch.
Returns:
The number of checkpoints removed.
"""
...

View File

@@ -95,17 +95,20 @@ class JsonProvider(BaseProvider):
await f.write(data)
return str(file_path)
def prune(self, location: str, max_keep: int, *, branch: str = "main") -> None:
def prune(self, location: str, max_keep: int, *, branch: str = "main") -> int:
"""Remove oldest checkpoint files beyond *max_keep* on a branch."""
_safe_branch(location, branch)
branch_dir = os.path.join(location, branch)
pattern = os.path.join(branch_dir, "*.json")
files = sorted(glob.glob(pattern), key=os.path.getmtime)
removed = 0
for path in files if max_keep == 0 else files[:-max_keep]:
try:
os.remove(path)
removed += 1
except OSError: # noqa: PERF203
logger.debug("Failed to remove %s", path, exc_info=True)
return removed
def extract_id(self, location: str) -> str:
"""Extract the checkpoint ID from a file path.

View File

@@ -111,11 +111,13 @@ class SqliteProvider(BaseProvider):
await db.commit()
return f"{location}#{checkpoint_id}"
def prune(self, location: str, max_keep: int, *, branch: str = "main") -> None:
def prune(self, location: str, max_keep: int, *, branch: str = "main") -> int:
"""Remove oldest checkpoint rows beyond *max_keep* on a branch."""
with sqlite3.connect(location) as conn:
conn.execute(_PRUNE, (branch, branch, max_keep))
cursor = conn.execute(_PRUNE, (branch, branch, max_keep))
removed: int = cursor.rowcount
conn.commit()
return max(removed, 0)
def extract_id(self, location: str) -> str:
"""Extract the checkpoint ID from a ``db_path#id`` string."""

View File

@@ -10,6 +10,7 @@ via ``RuntimeState.model_rebuild()``.
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING, Any
import uuid
@@ -23,6 +24,17 @@ from pydantic import (
)
from crewai.context import capture_execution_context
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.checkpoint_events import (
CheckpointCompletedEvent,
CheckpointFailedEvent,
CheckpointForkCompletedEvent,
CheckpointForkStartedEvent,
CheckpointRestoreCompletedEvent,
CheckpointRestoreFailedEvent,
CheckpointRestoreStartedEvent,
CheckpointStartedEvent,
)
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.state.event_record import EventRecord
from crewai.state.provider.core import BaseProvider
@@ -44,9 +56,12 @@ def _sync_checkpoint_fields(entity: object) -> None:
entity: The entity whose private runtime attributes will be
copied into its public checkpoint fields.
"""
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crew import Crew
from crewai.flow.flow import Flow
if isinstance(entity, BaseAgent):
entity.checkpoint_kickoff_event_id = entity._kickoff_event_id
if isinstance(entity, Flow):
entity.checkpoint_completed_methods = (
set(entity._completed_methods) if entity._completed_methods else None
@@ -86,7 +101,7 @@ def _migrate(data: dict[str, Any]) -> dict[str, Any]:
"""
raw = data.get("crewai_version")
current = Version(get_crewai_version())
stored = Version(raw) if raw else Version("0.0.0")
stored = Version(raw) if isinstance(raw, str) and raw else Version("0.0.0")
if raw is None:
logger.warning("Checkpoint has no crewai_version — treating as 0.0.0")
@@ -156,6 +171,63 @@ class RuntimeState(RootModel): # type: ignore[type-arg]
self._checkpoint_id = provider.extract_id(location)
self._parent_id = self._checkpoint_id
def _begin_checkpoint(self, location: str) -> tuple[str, str | None, str, float]:
"""Emit the start event and return the invariant context for a checkpoint."""
provider_name: str = type(self._provider).__name__
parent_id_snapshot: str | None = self._parent_id
branch_snapshot: str = self._branch
crewai_event_bus.emit(
self,
CheckpointStartedEvent(
location=location,
provider=provider_name,
branch=branch_snapshot,
parent_id=parent_id_snapshot,
),
)
return provider_name, parent_id_snapshot, branch_snapshot, time.perf_counter()
def _emit_checkpoint_failed(
self,
location: str,
provider_name: str,
branch_snapshot: str,
parent_id_snapshot: str | None,
exc: Exception,
) -> None:
"""Emit the failure event for a checkpoint write."""
crewai_event_bus.emit(
self,
CheckpointFailedEvent(
location=location,
provider=provider_name,
branch=branch_snapshot,
parent_id=parent_id_snapshot,
error=str(exc),
),
)
def _emit_checkpoint_completed(
self,
result: str,
provider_name: str,
branch_snapshot: str,
parent_id_snapshot: str | None,
start: float,
) -> None:
"""Emit the completion event for a successful checkpoint write."""
crewai_event_bus.emit(
self,
CheckpointCompletedEvent(
location=result,
provider=provider_name,
branch=branch_snapshot,
parent_id=parent_id_snapshot,
checkpoint_id=self._provider.extract_id(result),
duration_ms=(time.perf_counter() - start) * 1000.0,
),
)
def checkpoint(self, location: str) -> str:
"""Write a checkpoint.
@@ -166,14 +238,27 @@ class RuntimeState(RootModel): # type: ignore[type-arg]
Returns:
A location identifier for the saved checkpoint.
"""
_prepare_entities(self.root)
result = self._provider.checkpoint(
self.model_dump_json(),
location,
parent_id=self._parent_id,
branch=self._branch,
provider_name, parent_id_snapshot, branch_snapshot, start = (
self._begin_checkpoint(location)
)
try:
_prepare_entities(self.root)
result = self._provider.checkpoint(
self.model_dump_json(),
location,
parent_id=parent_id_snapshot,
branch=branch_snapshot,
)
self._chain_lineage(self._provider, result)
except Exception as exc:
self._emit_checkpoint_failed(
location, provider_name, branch_snapshot, parent_id_snapshot, exc
)
raise
self._emit_checkpoint_completed(
result, provider_name, branch_snapshot, parent_id_snapshot, start
)
self._chain_lineage(self._provider, result)
return result
async def acheckpoint(self, location: str) -> str:
@@ -186,14 +271,27 @@ class RuntimeState(RootModel): # type: ignore[type-arg]
Returns:
A location identifier for the saved checkpoint.
"""
_prepare_entities(self.root)
result = await self._provider.acheckpoint(
self.model_dump_json(),
location,
parent_id=self._parent_id,
branch=self._branch,
provider_name, parent_id_snapshot, branch_snapshot, start = (
self._begin_checkpoint(location)
)
try:
_prepare_entities(self.root)
result = await self._provider.acheckpoint(
self.model_dump_json(),
location,
parent_id=parent_id_snapshot,
branch=branch_snapshot,
)
self._chain_lineage(self._provider, result)
except Exception as exc:
self._emit_checkpoint_failed(
location, provider_name, branch_snapshot, parent_id_snapshot, exc
)
raise
self._emit_checkpoint_completed(
result, provider_name, branch_snapshot, parent_id_snapshot, start
)
self._chain_lineage(self._provider, result)
return result
def fork(self, branch: str | None = None) -> None:
@@ -208,11 +306,32 @@ class RuntimeState(RootModel): # type: ignore[type-arg]
times without collisions.
"""
if branch:
self._branch = branch
new_branch = branch
elif self._checkpoint_id:
self._branch = f"fork/{self._checkpoint_id}_{uuid.uuid4().hex[:6]}"
new_branch = f"fork/{self._checkpoint_id}_{uuid.uuid4().hex[:6]}"
else:
self._branch = f"fork/{uuid.uuid4().hex[:8]}"
new_branch = f"fork/{uuid.uuid4().hex[:8]}"
parent_branch: str | None = self._branch
parent_checkpoint_id: str | None = self._checkpoint_id
crewai_event_bus.emit(
self,
CheckpointForkStartedEvent(
branch=new_branch,
parent_branch=parent_branch,
parent_checkpoint_id=parent_checkpoint_id,
),
)
self._branch = new_branch
crewai_event_bus.emit(
self,
CheckpointForkCompletedEvent(
branch=new_branch,
parent_branch=parent_branch,
parent_checkpoint_id=parent_checkpoint_id,
),
)
@classmethod
def from_checkpoint(cls, config: CheckpointConfig, **kwargs: Any) -> RuntimeState:
@@ -230,13 +349,41 @@ class RuntimeState(RootModel): # type: ignore[type-arg]
if config.restore_from is None:
raise ValueError("CheckpointConfig.restore_from must be set")
location = str(config.restore_from)
provider = detect_provider(location)
raw = provider.from_checkpoint(location)
state = cls.model_validate_json(raw, **kwargs)
state._provider = provider
checkpoint_id = provider.extract_id(location)
state._checkpoint_id = checkpoint_id
state._parent_id = checkpoint_id
crewai_event_bus.emit(config, CheckpointRestoreStartedEvent(location=location))
start: float = time.perf_counter()
provider_name: str | None = None
try:
provider = detect_provider(location)
provider_name = type(provider).__name__
raw = provider.from_checkpoint(location)
state = cls.model_validate_json(raw, **kwargs)
state._provider = provider
checkpoint_id = provider.extract_id(location)
state._checkpoint_id = checkpoint_id
state._parent_id = checkpoint_id
except Exception as exc:
crewai_event_bus.emit(
config,
CheckpointRestoreFailedEvent(
location=location,
provider=provider_name,
error=str(exc),
),
)
raise
crewai_event_bus.emit(
config,
CheckpointRestoreCompletedEvent(
location=location,
provider=provider_name,
checkpoint_id=checkpoint_id,
branch=state._branch,
parent_id=state._parent_id,
duration_ms=(time.perf_counter() - start) * 1000.0,
),
)
return state
@classmethod
@@ -257,13 +404,41 @@ class RuntimeState(RootModel): # type: ignore[type-arg]
if config.restore_from is None:
raise ValueError("CheckpointConfig.restore_from must be set")
location = str(config.restore_from)
provider = detect_provider(location)
raw = await provider.afrom_checkpoint(location)
state = cls.model_validate_json(raw, **kwargs)
state._provider = provider
checkpoint_id = provider.extract_id(location)
state._checkpoint_id = checkpoint_id
state._parent_id = checkpoint_id
crewai_event_bus.emit(config, CheckpointRestoreStartedEvent(location=location))
start: float = time.perf_counter()
provider_name: str | None = None
try:
provider = detect_provider(location)
provider_name = type(provider).__name__
raw = await provider.afrom_checkpoint(location)
state = cls.model_validate_json(raw, **kwargs)
state._provider = provider
checkpoint_id = provider.extract_id(location)
state._checkpoint_id = checkpoint_id
state._parent_id = checkpoint_id
except Exception as exc:
crewai_event_bus.emit(
config,
CheckpointRestoreFailedEvent(
location=location,
provider=provider_name,
error=str(exc),
),
)
raise
crewai_event_bus.emit(
config,
CheckpointRestoreCompletedEvent(
location=location,
provider=provider_name,
checkpoint_id=checkpoint_id,
branch=state._branch,
parent_id=state._parent_id,
duration_ms=(time.perf_counter() - start) * 1000.0,
),
)
return state

View File

@@ -32,6 +32,7 @@ from pydantic import (
field_validator,
model_validator,
)
from pydantic.functional_serializers import PlainSerializer
from pydantic_core import PydanticCustomError
from typing_extensions import Self
@@ -75,6 +76,8 @@ except ImportError:
from crewai.types.callback import SerializableCallable
from crewai.utilities.guardrail import (
process_guardrail,
serialize_guardrail_for_json,
serialize_guardrails_for_json,
)
from crewai.utilities.guardrail_types import (
GuardrailCallable,
@@ -86,6 +89,22 @@ from crewai.utilities.printer import PRINTER
from crewai.utilities.string_utils import interpolate_only
def _serialize_model_class(v: type[BaseModel] | None) -> dict[str, Any] | None:
"""Serialize a Pydantic model class reference to its JSON schema."""
return v.model_json_schema() if v else None
def _deserialize_model_class(v: Any) -> type[BaseModel] | None:
"""Hydrate a model class reference from checkpoint data."""
if v is None or isinstance(v, type):
return v
if isinstance(v, dict):
from crewai.utilities.pydantic_schema_utils import create_model_from_schema
return create_model_from_schema(v)
return None
class Task(BaseModel):
"""Class that represents a task to be executed.
@@ -141,15 +160,33 @@ class Task(BaseModel):
description="Whether the task should be executed asynchronously or not.",
default=False,
)
output_json: type[BaseModel] | None = Field(
output_json: Annotated[
type[BaseModel] | None,
BeforeValidator(_deserialize_model_class),
PlainSerializer(
_serialize_model_class, return_type=dict | None, when_used="json"
),
] = Field(
description="A Pydantic model to be used to create a JSON output.",
default=None,
)
output_pydantic: type[BaseModel] | None = Field(
output_pydantic: Annotated[
type[BaseModel] | None,
BeforeValidator(_deserialize_model_class),
PlainSerializer(
_serialize_model_class, return_type=dict | None, when_used="json"
),
] = Field(
description="A Pydantic model to be used to create a Pydantic output.",
default=None,
)
response_model: type[BaseModel] | None = Field(
response_model: Annotated[
type[BaseModel] | None,
BeforeValidator(_deserialize_model_class),
PlainSerializer(
_serialize_model_class, return_type=dict | None, when_used="json"
),
] = Field(
description="A Pydantic model for structured LLM outputs using native provider features.",
default=None,
)
@@ -189,16 +226,36 @@ class Task(BaseModel):
description="Whether the task should instruct the agent to return the final answer formatted in Markdown",
default=False,
)
converter_cls: type[Converter] | None = Field(
converter_cls: Annotated[
type[Converter] | None,
BeforeValidator(lambda v: v if v is None or isinstance(v, type) else None),
PlainSerializer(
_serialize_model_class, return_type=dict | None, when_used="json"
),
] = Field(
description="A converter class used to export structured output",
default=None,
)
processed_by_agents: set[str] = Field(default_factory=set)
guardrail: GuardrailType | None = Field(
guardrail: Annotated[
GuardrailType | None,
PlainSerializer(
serialize_guardrail_for_json,
return_type=str | None,
when_used="json",
),
] = Field(
default=None,
description="Function or string description of a guardrail to validate task output before proceeding to next task",
)
guardrails: GuardrailsType | None = Field(
guardrails: Annotated[
GuardrailsType | None,
PlainSerializer(
serialize_guardrails_for_json,
return_type=list[str] | str | None,
when_used="json",
),
] = Field(
default=None,
description="List of guardrails to validate task output before proceeding to next task. Also supports a single guardrail function or string description of a guardrail to validate task output before proceeding to next task",
)
@@ -1241,12 +1298,26 @@ Follow these guidelines:
tools=tools,
)
pydantic_output, json_output = self._export_output(result)
if isinstance(result, BaseModel):
raw = result.model_dump_json()
if self.output_pydantic:
pydantic_output = result
json_output = None
elif self.output_json:
pydantic_output = None
json_output = result.model_dump()
else:
pydantic_output = None
json_output = None
else:
raw = result
pydantic_output, json_output = self._export_output(result)
task_output = TaskOutput(
name=self.name or self.description,
description=self.description,
expected_output=self.expected_output,
raw=result,
raw=raw,
pydantic=pydantic_output,
json_dict=json_output,
agent=agent.role,
@@ -1337,12 +1408,26 @@ Follow these guidelines:
tools=tools,
)
pydantic_output, json_output = self._export_output(result)
if isinstance(result, BaseModel):
raw = result.model_dump_json()
if self.output_pydantic:
pydantic_output = result
json_output = None
elif self.output_json:
pydantic_output = None
json_output = result.model_dump()
else:
pydantic_output = None
json_output = None
else:
raw = result
pydantic_output, json_output = self._export_output(result)
task_output = TaskOutput(
name=self.name or self.description,
description=self.description,
expected_output=self.expected_output,
raw=result,
raw=raw,
pydantic=pydantic_output,
json_dict=json_output,
agent=agent.role,

View File

@@ -1058,3 +1058,20 @@ class Telemetry:
close_span(span)
self._safe_telemetry_operation(_operation)
def template_installed_span(self, template_name: str) -> None:
"""Records when a template is downloaded and installed.
Args:
template_name: Name of the template that was installed
(without the template_ prefix).
"""
def _operation() -> None:
tracer = trace.get_tracer("crewai.telemetry")
span = tracer.start_span("Template Installed")
self._add_attribute(span, "crewai_version", version("crewai"))
self._add_attribute(span, "template_name", template_name)
close_span(span)
self._safe_telemetry_operation(_operation)

View File

@@ -7,6 +7,7 @@ from crewai.utilities.printer import PrinterColor
TRAINING_DATA_FILE: Final[str] = "training_data.pkl"
TRAINED_AGENTS_DATA_FILE: Final[str] = "trained_agents_data.pkl"
CREWAI_TRAINED_AGENTS_FILE_ENV: Final[str] = "CREWAI_TRAINED_AGENTS_FILE"
KNOWLEDGE_DIRECTORY: Final[str] = "knowledge"
MAX_FILE_NAME_LENGTH: Final[int] = 255
EMITTER_COLOR: Final[PrinterColor] = "bold_blue"

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import warnings
from pydantic import BaseModel, Field, field_validator
from typing_extensions import Self
@@ -8,6 +9,46 @@ from typing_extensions import Self
from crewai.utilities.guardrail_types import GuardrailCallable
def serialize_guardrail_for_json(
value: Any, field_name: str = "guardrail"
) -> str | None:
"""Serialize a single guardrail value for JSON checkpointing.
String descriptions are preserved; callable references cannot be
JSON-serialized and are dropped with a warning so users know the
guardrail will not be present after a checkpoint restore.
"""
if value is None or isinstance(value, str):
return value
if callable(value):
warnings.warn(
f"Callable {field_name!r} cannot be JSON-serialized and will be dropped "
f"during checkpointing; restored checkpoints will not run this guardrail.",
UserWarning,
stacklevel=2,
)
return None
return None
def serialize_guardrails_for_json(
value: Any, field_name: str = "guardrails"
) -> list[str] | str | None:
"""Serialize a guardrails value (single or sequence) for JSON checkpointing.
Dropped callables are filtered out of lists rather than emitted as ``None``;
a ``None`` entry would fail validation against ``GuardrailCallable | str``
on checkpoint restore.
"""
if isinstance(value, (list, tuple)):
return [
item
for item in (serialize_guardrail_for_json(g, field_name) for g in value)
if item is not None
]
return serialize_guardrail_for_json(value, field_name)
if TYPE_CHECKING:
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.lite_agent import LiteAgent

View File

@@ -19,7 +19,18 @@ from collections.abc import Callable
from copy import deepcopy
import datetime
import logging
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, TypedDict, Union, cast
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Final,
ForwardRef,
Literal,
Optional,
TypedDict,
Union,
cast,
)
import uuid
import jsonref # type: ignore[import-untyped]
@@ -99,15 +110,26 @@ def resolve_refs(schema: dict[str, Any]) -> dict[str, Any]:
"""
defs = schema.get("$defs", {})
schema_copy = deepcopy(schema)
expanding: set[str] = set()
def _resolve(node: Any) -> Any:
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref.replace("#/$defs/", "")
if def_name in defs:
if def_name not in defs:
raise KeyError(f"Definition '{def_name}' not found in $defs.")
if def_name in expanding:
def_schema = defs[def_name]
stub: dict[str, Any] = {"type": def_schema.get("type", "object")}
if "description" in def_schema:
stub["description"] = def_schema["description"]
return stub
expanding.add(def_name)
try:
return _resolve(deepcopy(defs[def_name]))
raise KeyError(f"Definition '{def_name}' not found in $defs.")
finally:
expanding.discard(def_name)
return {k: _resolve(v) for k, v in node.items()}
if isinstance(node, list):
@@ -119,7 +141,11 @@ def resolve_refs(schema: dict[str, Any]) -> dict[str, Any]:
def add_key_in_dict_recursively(
d: dict[str, Any], key: str, value: Any, criteria: Callable[[dict[str, Any]], bool]
d: dict[str, Any],
key: str,
value: Any,
criteria: Callable[[dict[str, Any]], bool],
_seen: set[int] | None = None,
) -> dict[str, Any]:
"""Recursively adds a key/value pair to all nested dicts matching `criteria`.
@@ -128,22 +154,31 @@ def add_key_in_dict_recursively(
key: The key to add.
value: The value to add.
criteria: A function that returns True for dicts that should receive the key.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
The modified dictionary.
"""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
if criteria(d) and key not in d:
d[key] = value
for v in d.values():
add_key_in_dict_recursively(v, key, value, criteria)
add_key_in_dict_recursively(v, key, value, criteria, _seen)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for i in d:
add_key_in_dict_recursively(i, key, value, criteria)
add_key_in_dict_recursively(i, key, value, criteria, _seen)
return d
def force_additional_properties_false(d: Any) -> Any:
def force_additional_properties_false(d: Any, _seen: set[int] | None = None) -> Any:
"""Force additionalProperties=false on all object-type dicts recursively.
OpenAI strict mode requires all objects to have additionalProperties=false.
@@ -154,11 +189,17 @@ def force_additional_properties_false(d: Any) -> Any:
Args:
d: The dictionary/list to modify.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
The modified dictionary/list.
"""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
if d.get("type") == "object":
d["additionalProperties"] = False
if "properties" not in d:
@@ -166,10 +207,13 @@ def force_additional_properties_false(d: Any) -> Any:
if "required" not in d:
d["required"] = []
for v in d.values():
force_additional_properties_false(v)
force_additional_properties_false(v, _seen)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for i in d:
force_additional_properties_false(i)
force_additional_properties_false(i, _seen)
return d
@@ -183,7 +227,7 @@ OPENAI_SUPPORTED_FORMATS: Final[
}
def strip_unsupported_formats(d: Any) -> Any:
def strip_unsupported_formats(d: Any, _seen: set[int] | None = None) -> Any:
"""Remove format annotations that OpenAI strict mode doesn't support.
OpenAI only supports: date-time, date, time, duration.
@@ -191,11 +235,17 @@ def strip_unsupported_formats(d: Any) -> Any:
Args:
d: The dictionary/list to modify.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
The modified dictionary/list.
"""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
format_value = d.get("format")
if (
isinstance(format_value, str)
@@ -203,14 +253,17 @@ def strip_unsupported_formats(d: Any) -> Any:
):
del d["format"]
for v in d.values():
strip_unsupported_formats(v)
strip_unsupported_formats(v, _seen)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for i in d:
strip_unsupported_formats(i)
strip_unsupported_formats(i, _seen)
return d
def ensure_type_in_schemas(d: Any) -> Any:
def ensure_type_in_schemas(d: Any, _seen: set[int] | None = None) -> Any:
"""Ensure all schema objects in anyOf/oneOf have a 'type' key.
OpenAI strict mode requires every schema to have a 'type' key.
@@ -218,11 +271,17 @@ def ensure_type_in_schemas(d: Any) -> Any:
Args:
d: The dictionary/list to modify.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
The modified dictionary/list.
"""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
for key in ("anyOf", "oneOf"):
if key in d:
schema_list = d[key]
@@ -230,12 +289,15 @@ def ensure_type_in_schemas(d: Any) -> Any:
if isinstance(schema, dict) and schema == {}:
schema_list[i] = {"type": "object"}
else:
ensure_type_in_schemas(schema)
ensure_type_in_schemas(schema, _seen)
for v in d.values():
ensure_type_in_schemas(v)
ensure_type_in_schemas(v, _seen)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for item in d:
ensure_type_in_schemas(item)
ensure_type_in_schemas(item, _seen)
return d
@@ -318,7 +380,9 @@ def add_const_to_oneof_variants(schema: dict[str, Any]) -> dict[str, Any]:
return _process_oneof(deepcopy(schema))
def convert_oneof_to_anyof(schema: dict[str, Any]) -> dict[str, Any]:
def convert_oneof_to_anyof(
schema: dict[str, Any], _seen: set[int] | None = None
) -> dict[str, Any]:
"""Convert oneOf to anyOf for OpenAI compatibility.
OpenAI's Structured Outputs support anyOf better than oneOf.
@@ -326,26 +390,37 @@ def convert_oneof_to_anyof(schema: dict[str, Any]) -> dict[str, Any]:
Args:
schema: JSON schema dictionary.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
Modified schema with anyOf instead of oneOf.
"""
if _seen is None:
_seen = set()
if isinstance(schema, dict):
if id(schema) in _seen:
return schema
_seen.add(id(schema))
if "oneOf" in schema:
schema["anyOf"] = schema.pop("oneOf")
for value in schema.values():
if isinstance(value, dict):
convert_oneof_to_anyof(value)
convert_oneof_to_anyof(value, _seen)
elif isinstance(value, list):
if id(value) in _seen:
continue
_seen.add(id(value))
for item in value:
if isinstance(item, dict):
convert_oneof_to_anyof(item)
convert_oneof_to_anyof(item, _seen)
return schema
def ensure_all_properties_required(schema: dict[str, Any]) -> dict[str, Any]:
def ensure_all_properties_required(
schema: dict[str, Any], _seen: set[int] | None = None
) -> dict[str, Any]:
"""Ensure all properties are in the required array for OpenAI strict mode.
OpenAI's strict structured outputs require all properties to be listed
@@ -354,11 +429,17 @@ def ensure_all_properties_required(schema: dict[str, Any]) -> dict[str, Any]:
Args:
schema: JSON schema dictionary.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
Modified schema with all properties marked as required.
"""
if _seen is None:
_seen = set()
if isinstance(schema, dict):
if id(schema) in _seen:
return schema
_seen.add(id(schema))
if schema.get("type") == "object" and "properties" in schema:
properties = schema["properties"]
if properties:
@@ -366,16 +447,21 @@ def ensure_all_properties_required(schema: dict[str, Any]) -> dict[str, Any]:
for value in schema.values():
if isinstance(value, dict):
ensure_all_properties_required(value)
ensure_all_properties_required(value, _seen)
elif isinstance(value, list):
if id(value) in _seen:
continue
_seen.add(id(value))
for item in value:
if isinstance(item, dict):
ensure_all_properties_required(item)
ensure_all_properties_required(item, _seen)
return schema
def strip_null_from_types(schema: dict[str, Any]) -> dict[str, Any]:
def strip_null_from_types(
schema: dict[str, Any], _seen: set[int] | None = None
) -> dict[str, Any]:
"""Remove null type from anyOf/type arrays.
Pydantic generates `T | None` for optional fields, which creates schemas with
@@ -384,11 +470,17 @@ def strip_null_from_types(schema: dict[str, Any]) -> dict[str, Any]:
Args:
schema: JSON schema dictionary.
_seen: Internal set of visited ``id()``s, used to guard cyclic schemas.
Returns:
Modified schema with null types removed.
"""
if _seen is None:
_seen = set()
if isinstance(schema, dict):
if id(schema) in _seen:
return schema
_seen.add(id(schema))
if "anyOf" in schema:
any_of = schema["anyOf"]
non_null = [opt for opt in any_of if opt.get("type") != "null"]
@@ -408,11 +500,14 @@ def strip_null_from_types(schema: dict[str, Any]) -> dict[str, Any]:
for value in schema.values():
if isinstance(value, dict):
strip_null_from_types(value)
strip_null_from_types(value, _seen)
elif isinstance(value, list):
if id(value) in _seen:
continue
_seen.add(id(value))
for item in value:
if isinstance(item, dict):
strip_null_from_types(item)
strip_null_from_types(item, _seen)
return schema
@@ -451,16 +546,26 @@ _CLAUDE_STRICT_UNSUPPORTED: Final[tuple[str, ...]] = (
)
def _strip_keys_recursive(d: Any, keys: tuple[str, ...]) -> Any:
def _strip_keys_recursive(
d: Any, keys: tuple[str, ...], _seen: set[int] | None = None
) -> Any:
"""Recursively delete a fixed set of keys from a schema."""
if _seen is None:
_seen = set()
if isinstance(d, dict):
if id(d) in _seen:
return d
_seen.add(id(d))
for key in keys:
d.pop(key, None)
for v in d.values():
_strip_keys_recursive(v, keys)
_strip_keys_recursive(v, keys, _seen)
elif isinstance(d, list):
if id(d) in _seen:
return d
_seen.add(id(d))
for i in d:
_strip_keys_recursive(i, keys)
_strip_keys_recursive(i, keys, _seen)
return d
@@ -658,6 +763,25 @@ def build_rich_field_description(prop_schema: dict[str, Any]) -> str:
return ". ".join(parts) if parts else ""
def _inline_top_level_ref(schema: dict[str, Any]) -> dict[str, Any]:
"""Resolve only the top-level ``$ref``, preserving ``$defs`` for lazy inner resolution.
Used as a fallback when ``jsonref.replace_refs`` fails on circular schemas.
Inner ``$ref`` pointers are left intact so that :func:`_resolve_ref` can
resolve them during model construction, with cycle detection via ``in_progress``.
"""
schema = deepcopy(schema)
ref = schema.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
def_name = ref[len("#/$defs/") :]
defs = schema.get("$defs", {})
if def_name in defs:
resolved: dict[str, Any] = deepcopy(defs[def_name])
resolved.setdefault("$defs", defs)
return resolved
return schema
def create_model_from_schema( # type: ignore[no-any-unimported]
json_schema: dict[str, Any],
*,
@@ -712,19 +836,80 @@ def create_model_from_schema( # type: ignore[no-any-unimported]
>>> person.name
'John'
"""
json_schema = dict(jsonref.replace_refs(json_schema, proxies=False))
try:
json_schema = dict(jsonref.replace_refs(json_schema, proxies=False))
except (jsonref.JsonRefError, RecursionError):
json_schema = _inline_top_level_ref(json_schema)
effective_root = root_schema or json_schema
json_schema = force_additional_properties_false(json_schema)
effective_root = force_additional_properties_false(effective_root)
in_progress: dict[int, Any] = {}
model = _build_model_from_schema(
json_schema,
effective_root,
model_name=model_name,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
__config__=__config__,
__base__=__base__,
__module__=__module__,
__validators__=__validators__,
__cls_kwargs__=__cls_kwargs__,
)
types_namespace: dict[str, Any] = {
entry.__name__: entry
for entry in in_progress.values()
if isinstance(entry, type) and issubclass(entry, BaseModel)
}
for entry in in_progress.values():
if (
isinstance(entry, type)
and issubclass(entry, BaseModel)
and not getattr(entry, "__pydantic_complete__", True)
):
try:
entry.model_rebuild(_types_namespace=types_namespace)
except Exception as e:
logger.debug("model_rebuild failed for %s: %s", entry.__name__, e)
return model
def _build_model_from_schema( # type: ignore[no-any-unimported]
json_schema: dict[str, Any],
effective_root: dict[str, Any],
*,
model_name: str | None,
enrich_descriptions: bool,
in_progress: dict[int, Any],
__config__: ConfigDict | None = None,
__base__: type[BaseModel] | None = None,
__module__: str = __name__,
__validators__: dict[str, AnyClassMethod] | None = None,
__cls_kwargs__: dict[str, Any] | None = None,
) -> type[BaseModel]:
"""Inner builder shared by the public entry point and recursive nested-object creation.
Preprocessing via ``jsonref.replace_refs`` and the sanitization walkers is
run once by the public entry; this helper walks the already-normalized
schema and emits Pydantic models. ``in_progress`` maps ``id(schema)`` to
the model being built for that schema, so a cyclic ``$ref`` graph
degrades to a ``ForwardRef`` back-edge instead of blowing the stack.
"""
original_id = id(json_schema)
if "allOf" in json_schema:
json_schema = _merge_all_of_schemas(json_schema["allOf"], effective_root)
if "title" not in json_schema and "title" in (root_schema or {}):
json_schema["title"] = (root_schema or {}).get("title")
effective_name = model_name or json_schema.get("title") or "DynamicModel"
schema_id = id(json_schema)
in_progress[original_id] = effective_name
if schema_id != original_id:
in_progress[schema_id] = effective_name
field_definitions = {
name: _json_schema_to_pydantic_field(
name,
@@ -732,13 +917,14 @@ def create_model_from_schema( # type: ignore[no-any-unimported]
json_schema.get("required", []),
effective_root,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
for name, prop in (json_schema.get("properties", {}) or {}).items()
}
effective_config = __config__ or ConfigDict(extra="forbid")
return create_model_base(
model = create_model_base(
effective_name,
__config__=effective_config,
__base__=__base__,
@@ -747,6 +933,10 @@ def create_model_from_schema( # type: ignore[no-any-unimported]
__cls_kwargs__=__cls_kwargs__,
**field_definitions,
)
in_progress[original_id] = model
if schema_id != original_id:
in_progress[schema_id] = model
return model
def _json_schema_to_pydantic_field(
@@ -756,6 +946,7 @@ def _json_schema_to_pydantic_field(
root_schema: dict[str, Any],
*,
enrich_descriptions: bool = False,
in_progress: dict[int, Any] | None = None,
) -> Any:
"""Convert a JSON schema property to a Pydantic field definition.
@@ -774,6 +965,7 @@ def _json_schema_to_pydantic_field(
root_schema,
name_=name.title(),
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
is_required = name in required
@@ -833,7 +1025,7 @@ def _json_schema_to_pydantic_field(
field_params["pattern"] = json_schema["pattern"]
if not is_required:
type_ = type_ | None
type_ = Optional[type_] # noqa: UP045 - ForwardRef does not support `|`
if schema_extra:
field_params["json_schema_extra"] = schema_extra
@@ -906,6 +1098,7 @@ def _json_schema_to_pydantic_type(
*,
name_: str | None = None,
enrich_descriptions: bool = False,
in_progress: dict[int, Any] | None = None,
) -> Any:
"""Convert a JSON schema to a Python/Pydantic type.
@@ -914,10 +1107,23 @@ def _json_schema_to_pydantic_type(
root_schema: The root schema for resolving $ref.
name_: Optional name for nested models.
enrich_descriptions: Propagated to nested model creation.
in_progress: Map of ``id(schema_dict)`` to the Pydantic model
currently being built for that schema, or to a placeholder name
as a plain ``str`` while the model is still being constructed.
Populated by :func:`_build_model_from_schema`. Enables cycle
detection so a self-referential ``$ref`` graph resolves to a
:class:`ForwardRef` back-edge rather than recursing forever.
Returns:
A Python type corresponding to the JSON schema.
"""
if in_progress is not None:
cached = in_progress.get(id(json_schema))
if isinstance(cached, str):
return ForwardRef(cached)
if cached is not None:
return cached
ref = json_schema.get("$ref")
if ref:
ref_schema = _resolve_ref(ref, root_schema)
@@ -926,6 +1132,7 @@ def _json_schema_to_pydantic_type(
root_schema,
name_=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
enum_values = json_schema.get("enum")
@@ -945,6 +1152,7 @@ def _json_schema_to_pydantic_type(
root_schema,
name_=f"{name_ or 'Union'}Option{i}",
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
for i, schema in enumerate(any_of_schemas)
]
@@ -958,6 +1166,15 @@ def _json_schema_to_pydantic_type(
root_schema,
name_=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
if in_progress is not None:
return _build_model_from_schema(
json_schema,
root_schema,
model_name=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
merged = _merge_all_of_schemas(all_of_schemas, root_schema)
return _json_schema_to_pydantic_type(
@@ -965,6 +1182,7 @@ def _json_schema_to_pydantic_type(
root_schema,
name_=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
type_ = json_schema.get("type")
@@ -985,12 +1203,21 @@ def _json_schema_to_pydantic_type(
root_schema,
name_=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
return list[item_type] # type: ignore[valid-type]
return list
if type_ == "object":
properties = json_schema.get("properties")
if properties:
if in_progress is not None:
return _build_model_from_schema(
json_schema,
root_schema,
model_name=name_,
enrich_descriptions=enrich_descriptions,
in_progress=in_progress,
)
json_schema_ = json_schema.copy()
if json_schema_.get("title") is None:
json_schema_["title"] = name_ or "DynamicModel"

View File

@@ -7,6 +7,7 @@ import logging
import queue
import threading
from typing import Any, NamedTuple
import uuid
from typing_extensions import TypedDict
@@ -25,6 +26,10 @@ from crewai.utilities.string_utils import sanitize_tool_name
logger = logging.getLogger(__name__)
_current_stream_ids: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
"_current_stream_ids", default=()
)
class TaskInfo(TypedDict):
"""Task context information for streaming."""
@@ -45,6 +50,7 @@ class StreamingState(NamedTuple):
async_queue: asyncio.Queue[StreamChunk | None | Exception] | None
loop: asyncio.AbstractEventLoop | None
handler: Callable[[Any, BaseEvent], None]
stream_id: str | None = None
def _extract_tool_call_info(
@@ -106,6 +112,7 @@ def _create_stream_handler(
sync_queue: queue.Queue[StreamChunk | None | Exception],
async_queue: asyncio.Queue[StreamChunk | None | Exception] | None = None,
loop: asyncio.AbstractEventLoop | None = None,
stream_id: str | None = None,
) -> Callable[[Any, BaseEvent], None]:
"""Create a stream handler function.
@@ -114,21 +121,19 @@ def _create_stream_handler(
sync_queue: Synchronous queue for chunks.
async_queue: Optional async queue for chunks.
loop: Optional event loop for async operations.
stream_id: Stream scope ID for concurrent isolation.
Returns:
Handler function that can be registered with the event bus.
"""
def stream_handler(_: Any, event: BaseEvent) -> None:
"""Handle LLM stream chunk events and enqueue them.
Args:
_: Event source (unused).
event: The event to process.
"""
if not isinstance(event, LLMStreamChunkEvent):
return
if stream_id is not None and stream_id not in _current_stream_ids.get():
return
chunk = _create_stream_chunk(event, current_task_info)
if async_queue is not None and loop is not None:
@@ -203,7 +208,11 @@ def create_streaming_state(
async_queue = asyncio.Queue()
loop = asyncio.get_event_loop()
handler = _create_stream_handler(current_task_info, sync_queue, async_queue, loop)
stream_id = str(uuid.uuid4())
handler = _create_stream_handler(
current_task_info, sync_queue, async_queue, loop, stream_id=stream_id
)
crewai_event_bus.register_handler(LLMStreamChunkEvent, handler)
return StreamingState(
@@ -213,6 +222,7 @@ def create_streaming_state(
async_queue=async_queue,
loop=loop,
handler=handler,
stream_id=stream_id,
)
@@ -260,7 +270,12 @@ def create_chunk_generator(
Yields:
StreamChunk objects as they arrive.
"""
ctx = contextvars.copy_context()
if state.stream_id is not None:
token = _current_stream_ids.set((*_current_stream_ids.get(), state.stream_id))
ctx = contextvars.copy_context()
_current_stream_ids.reset(token)
else:
ctx = contextvars.copy_context()
thread = threading.Thread(target=ctx.run, args=(run_func,), daemon=True)
thread.start()
@@ -300,7 +315,12 @@ async def create_async_chunk_generator(
"Async queue not initialized. Use create_streaming_state(use_async=True)."
)
task = asyncio.create_task(run_coro())
if state.stream_id is not None:
token = _current_stream_ids.set((*_current_stream_ids.get(), state.stream_id))
task = asyncio.create_task(run_coro())
_current_stream_ids.reset(token)
else:
task = asyncio.create_task(run_coro())
try:
while True:

View File

@@ -1064,6 +1064,23 @@ def test_agent_use_trained_data(crew_training_handler):
)
@patch("crewai.agent.core.CrewTrainingHandler")
def test_agent_use_trained_data_honors_env_var(crew_training_handler, monkeypatch):
monkeypatch.setenv("CREWAI_TRAINED_AGENTS_FILE", "my_custom_trained.pkl")
agent = Agent(
role="researcher",
goal="test goal",
backstory="test backstory",
)
crew_training_handler.return_value.load.return_value = {}
agent._use_trained_data(task_prompt="What is 1 + 1?")
crew_training_handler.assert_has_calls(
[mock.call("my_custom_trained.pkl"), mock.call().load()]
)
def test_agent_max_retry_limit():
agent = Agent(
role="test role",

View File

@@ -0,0 +1,283 @@
import io
import os
import zipfile
from unittest.mock import MagicMock, patch
import httpx
import pytest
from click.testing import CliRunner
from crewai.cli.cli import template_add, template_list
from crewai.cli.remote_template.main import TemplateCommand
@pytest.fixture
def runner():
return CliRunner()
SAMPLE_REPOS = [
{"name": "template_deep_research", "description": "Deep research template", "private": False},
{"name": "template_pull_request_review", "description": "PR review template", "private": False},
{"name": "template_conversational_example", "description": "Conversational demo", "private": False},
{"name": "crewai", "description": "Main repo", "private": False},
{"name": "marketplace-crew-template", "description": "Marketplace", "private": False},
]
def _make_zipball(files: dict[str, str], top_dir: str = "crewAIInc-template_test-abc123") -> bytes:
"""Create an in-memory zipball mimicking GitHub's format."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(f"{top_dir}/", "")
for path, content in files.items():
zf.writestr(f"{top_dir}/{path}", content)
return buf.getvalue()
# --- CLI command tests ---
@patch("crewai.cli.cli.TemplateCommand")
def test_template_list_command(mock_cls, runner):
mock_instance = MagicMock()
mock_cls.return_value = mock_instance
result = runner.invoke(template_list)
assert result.exit_code == 0
mock_cls.assert_called_once()
mock_instance.list_templates.assert_called_once()
@patch("crewai.cli.cli.TemplateCommand")
def test_template_add_command(mock_cls, runner):
mock_instance = MagicMock()
mock_cls.return_value = mock_instance
result = runner.invoke(template_add, ["deep_research"])
assert result.exit_code == 0
mock_cls.assert_called_once()
mock_instance.add_template.assert_called_once_with("deep_research", None)
@patch("crewai.cli.cli.TemplateCommand")
def test_template_add_with_output_dir(mock_cls, runner):
mock_instance = MagicMock()
mock_cls.return_value = mock_instance
result = runner.invoke(template_add, ["deep_research", "-o", "my_project"])
assert result.exit_code == 0
mock_instance.add_template.assert_called_once_with("deep_research", "my_project")
# --- TemplateCommand unit tests ---
class TestTemplateCommand:
@pytest.fixture
def cmd(self):
with patch.object(TemplateCommand, "__init__", return_value=None):
instance = TemplateCommand()
instance._telemetry = MagicMock()
return instance
@patch("crewai.cli.remote_template.main.httpx.get")
def test_fetch_templates_filters_by_prefix(self, mock_get, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
# Return empty on page 2 to stop pagination
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
templates = cmd._fetch_templates()
assert len(templates) == 3
assert all(t["name"].startswith("template_") for t in templates)
@patch("crewai.cli.remote_template.main.httpx.get")
def test_fetch_templates_excludes_private(self, mock_get, cmd):
repos = [
{"name": "template_private_one", "description": "", "private": True},
{"name": "template_public_one", "description": "", "private": False},
]
mock_response = MagicMock()
mock_response.json.return_value = repos
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
templates = cmd._fetch_templates()
assert len(templates) == 1
assert templates[0]["name"] == "template_public_one"
@patch("crewai.cli.remote_template.main.httpx.get")
def test_fetch_templates_api_error(self, mock_get, cmd):
mock_get.side_effect = httpx.HTTPError("connection error")
with pytest.raises(SystemExit):
cmd._fetch_templates()
@patch("crewai.cli.remote_template.main.click.prompt", return_value="q")
@patch("crewai.cli.remote_template.main.httpx.get")
def test_list_templates_prints_output(self, mock_get, mock_prompt, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
with patch("crewai.cli.remote_template.main.console") as mock_console:
cmd.list_templates()
assert mock_console.print.call_count > 0
@patch("crewai.cli.remote_template.main.httpx.get")
def test_resolve_repo_name_with_prefix(self, mock_get, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
result = cmd._resolve_repo_name("template_deep_research")
assert result == "template_deep_research"
@patch("crewai.cli.remote_template.main.httpx.get")
def test_resolve_repo_name_without_prefix(self, mock_get, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
result = cmd._resolve_repo_name("deep_research")
assert result == "template_deep_research"
@patch("crewai.cli.remote_template.main.httpx.get")
def test_resolve_repo_name_not_found(self, mock_get, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
result = cmd._resolve_repo_name("nonexistent")
assert result is None
def test_extract_zip(self, cmd, tmp_path):
files = {
"README.md": "# Test Template",
"src/main.py": "print('hello')",
"config/settings.yaml": "key: value",
}
zip_bytes = _make_zipball(files)
dest = str(tmp_path / "output")
cmd._extract_zip(zip_bytes, dest)
assert os.path.isfile(os.path.join(dest, "README.md"))
assert os.path.isfile(os.path.join(dest, "src", "main.py"))
assert os.path.isfile(os.path.join(dest, "config", "settings.yaml"))
with open(os.path.join(dest, "src", "main.py")) as f:
assert f.read() == "print('hello')"
@patch.object(TemplateCommand, "_extract_zip")
@patch.object(TemplateCommand, "_download_zip")
@patch.object(TemplateCommand, "_resolve_repo_name")
def test_add_template_success(self, mock_resolve, mock_download, mock_extract, cmd, tmp_path):
mock_resolve.return_value = "template_deep_research"
mock_download.return_value = b"fake-zip-bytes"
os.chdir(tmp_path)
cmd.add_template("deep_research")
mock_resolve.assert_called_once_with("deep_research")
mock_download.assert_called_once_with("template_deep_research")
expected_dest = os.path.join(str(tmp_path), "deep_research")
mock_extract.assert_called_once_with(b"fake-zip-bytes", expected_dest)
@patch.object(TemplateCommand, "_resolve_repo_name")
def test_add_template_not_found(self, mock_resolve, cmd):
mock_resolve.return_value = None
with pytest.raises(SystemExit):
cmd.add_template("nonexistent")
@patch.object(TemplateCommand, "_extract_zip")
@patch.object(TemplateCommand, "_download_zip")
@patch("crewai.cli.remote_template.main.click.prompt", return_value="my_project")
@patch.object(TemplateCommand, "_resolve_repo_name")
def test_add_template_dir_exists_prompts_rename(self, mock_resolve, mock_prompt, mock_download, mock_extract, cmd, tmp_path):
mock_resolve.return_value = "template_deep_research"
mock_download.return_value = b"fake-zip-bytes"
existing = tmp_path / "deep_research"
existing.mkdir()
os.chdir(tmp_path)
cmd.add_template("deep_research")
expected_dest = os.path.join(str(tmp_path), "my_project")
mock_extract.assert_called_once_with(b"fake-zip-bytes", expected_dest)
@patch.object(TemplateCommand, "_resolve_repo_name")
@patch("crewai.cli.remote_template.main.click.prompt", return_value="q")
def test_add_template_dir_exists_quit(self, mock_prompt, mock_resolve, cmd, tmp_path):
mock_resolve.return_value = "template_deep_research"
existing = tmp_path / "deep_research"
existing.mkdir()
os.chdir(tmp_path)
cmd.add_template("deep_research")
# Should return without downloading
@patch.object(TemplateCommand, "_install_repo")
@patch("crewai.cli.remote_template.main.click.prompt", return_value="2")
@patch("crewai.cli.remote_template.main.httpx.get")
def test_list_templates_selects_and_installs(self, mock_get, mock_prompt, mock_install, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
with patch("crewai.cli.remote_template.main.console"):
cmd.list_templates()
# Templates are sorted by name; index 1 (choice "2") = template_deep_research
mock_install.assert_called_once_with("template_deep_research")
@patch.object(TemplateCommand, "_install_repo")
@patch("crewai.cli.remote_template.main.click.prompt", return_value="q")
@patch("crewai.cli.remote_template.main.httpx.get")
def test_list_templates_quit(self, mock_get, mock_prompt, mock_install, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
with patch("crewai.cli.remote_template.main.console"):
cmd.list_templates()
mock_install.assert_not_called()

View File

@@ -307,7 +307,7 @@ def test_version_command_with_tools(runner):
def test_test_default_iterations(evaluate_crew, runner):
result = runner.invoke(test)
evaluate_crew.assert_called_once_with(3, "gpt-4o-mini")
evaluate_crew.assert_called_once_with(3, "gpt-4o-mini", trained_agents_file=None)
assert result.exit_code == 0
assert "Testing the crew for 3 iterations with model gpt-4o-mini" in result.output
@@ -316,7 +316,7 @@ def test_test_default_iterations(evaluate_crew, runner):
def test_test_custom_iterations(evaluate_crew, runner):
result = runner.invoke(test, ["--n_iterations", "5", "--model", "gpt-4o"])
evaluate_crew.assert_called_once_with(5, "gpt-4o")
evaluate_crew.assert_called_once_with(5, "gpt-4o", trained_agents_file=None)
assert result.exit_code == 0
assert "Testing the crew for 5 iterations with model gpt-4o" in result.output

View File

@@ -27,6 +27,7 @@ def test_crew_success(mock_subprocess_run, n_iterations, model):
capture_output=False,
text=True,
check=True,
env=mock.ANY,
)
assert result is None
@@ -66,6 +67,7 @@ def test_test_crew_called_process_error(mock_subprocess_run, click):
capture_output=False,
text=True,
check=True,
env=mock.ANY,
)
click.echo.assert_has_calls(
[
@@ -91,7 +93,30 @@ def test_test_crew_unexpected_exception(mock_subprocess_run, click):
capture_output=False,
text=True,
check=True,
env=mock.ANY,
)
click.echo.assert_called_once_with(
"An unexpected error occurred: Unexpected error", err=True
)
@mock.patch("crewai.cli.evaluate_crew.subprocess.run")
def test_evaluate_crew_sets_trained_agents_env_var(mock_subprocess_run):
mock_subprocess_run.return_value = subprocess.CompletedProcess(
args=["uv", "run", "test", "1", "gpt-4o"], returncode=0
)
evaluate_crew.evaluate_crew(1, "gpt-4o", trained_agents_file="my_custom.pkl")
_, kwargs = mock_subprocess_run.call_args
assert kwargs["env"]["CREWAI_TRAINED_AGENTS_FILE"] == "my_custom.pkl"
@mock.patch("crewai.cli.evaluate_crew.subprocess.run")
def test_evaluate_crew_omits_env_var_without_filename(mock_subprocess_run):
mock_subprocess_run.return_value = subprocess.CompletedProcess(
args=["uv", "run", "test", "1", "gpt-4o"], returncode=0
)
evaluate_crew.evaluate_crew(1, "gpt-4o")
_, kwargs = mock_subprocess_run.call_args
assert "CREWAI_TRAINED_AGENTS_FILE" not in kwargs["env"]

View File

@@ -0,0 +1,61 @@
"""Tests for ``crewai replay`` and the trained-agents file plumbing."""
import subprocess
from unittest import mock
from click.testing import CliRunner
import pytest
from crewai.cli import replay_from_task
from crewai.cli.cli import replay
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@mock.patch("crewai.cli.cli.replay_task_command")
def test_replay_passes_filename(replay_task_command_mock: mock.Mock, runner: CliRunner) -> None:
result = runner.invoke(replay, ["-t", "abc123", "-f", "my_custom.pkl"])
replay_task_command_mock.assert_called_once_with(
"abc123", trained_agents_file="my_custom.pkl"
)
assert result.exit_code == 0
@mock.patch("crewai.cli.cli.replay_task_command")
def test_replay_without_filename_passes_none(
replay_task_command_mock: mock.Mock, runner: CliRunner
) -> None:
result = runner.invoke(replay, ["-t", "abc123"])
replay_task_command_mock.assert_called_once_with(
"abc123", trained_agents_file=None
)
assert result.exit_code == 0
@mock.patch("crewai.cli.replay_from_task.subprocess.run")
def test_replay_task_command_sets_env_var(mock_subprocess_run: mock.Mock) -> None:
mock_subprocess_run.return_value = subprocess.CompletedProcess(
args=["uv", "run", "replay", "abc123"], returncode=0
)
replay_from_task.replay_task_command("abc123", trained_agents_file="my_custom.pkl")
_, kwargs = mock_subprocess_run.call_args
assert kwargs["env"]["CREWAI_TRAINED_AGENTS_FILE"] == "my_custom.pkl"
@mock.patch("crewai.cli.replay_from_task.subprocess.run")
def test_replay_task_command_omits_env_var_without_filename(
mock_subprocess_run: mock.Mock,
) -> None:
mock_subprocess_run.return_value = subprocess.CompletedProcess(
args=["uv", "run", "replay", "abc123"], returncode=0
)
replay_from_task.replay_task_command("abc123")
_, kwargs = mock_subprocess_run.call_args
assert "CREWAI_TRAINED_AGENTS_FILE" not in kwargs["env"]

View File

@@ -0,0 +1,59 @@
"""Tests for the ``crewai run`` command and its subprocess plumbing."""
from unittest import mock
from click.testing import CliRunner
import pytest
from crewai.cli.cli import run
from crewai.cli.run_crew import CrewType, execute_command
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@mock.patch("crewai.cli.cli.run_crew")
def test_run_passes_filename_to_run_crew(run_crew_mock: mock.Mock, runner: CliRunner) -> None:
result = runner.invoke(run, ["-f", "my_custom_trained.pkl"])
run_crew_mock.assert_called_once_with(trained_agents_file="my_custom_trained.pkl")
assert result.exit_code == 0
@mock.patch("crewai.cli.cli.run_crew")
def test_run_without_filename_passes_none(run_crew_mock: mock.Mock, runner: CliRunner) -> None:
result = runner.invoke(run)
run_crew_mock.assert_called_once_with(trained_agents_file=None)
assert result.exit_code == 0
@mock.patch("crewai.cli.run_crew.subprocess.run")
@mock.patch(
"crewai.cli.run_crew.build_env_with_all_tool_credentials",
return_value={"EXISTING": "value"},
)
def test_execute_command_sets_env_var_when_filename_provided(
_build_env: mock.Mock, subprocess_run: mock.Mock
) -> None:
execute_command(CrewType.STANDARD, trained_agents_file="my_custom_trained.pkl")
_, kwargs = subprocess_run.call_args
assert kwargs["env"]["CREWAI_TRAINED_AGENTS_FILE"] == "my_custom_trained.pkl"
assert kwargs["env"]["EXISTING"] == "value"
@mock.patch("crewai.cli.run_crew.subprocess.run")
@mock.patch(
"crewai.cli.run_crew.build_env_with_all_tool_credentials",
return_value={"EXISTING": "value"},
)
def test_execute_command_omits_env_var_when_filename_absent(
_build_env: mock.Mock, subprocess_run: mock.Mock
) -> None:
execute_command(CrewType.STANDARD)
_, kwargs = subprocess_run.call_args
assert "CREWAI_TRAINED_AGENTS_FILE" not in kwargs["env"]

View File

@@ -161,7 +161,8 @@ def test_install_api_error(mock_get, capsys, tool_command):
@patch("crewai.cli.tools.main.git.Repository.is_synced", return_value=False)
def test_publish_when_not_in_sync(mock_is_synced, capsys, tool_command):
@patch("crewai.cli.tools.main.git.Repository.__init__", return_value=None)
def test_publish_when_not_in_sync(mock_init, mock_is_synced, capsys, tool_command):
with raises(SystemExit):
tool_command.publish(is_public=True)

View File

@@ -0,0 +1,165 @@
"""Tests for event bus replay dispatch and is_replaying flag."""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
from crewai.events.event_bus import _replaying, crewai_event_bus, is_replaying
from crewai.events.types.flow_events import (
MethodExecutionFinishedEvent,
MethodExecutionStartedEvent,
)
def _make_started(method: str, event_id: str, sequence: int) -> MethodExecutionStartedEvent:
"""Build a MethodExecutionStartedEvent with explicit ids/sequence."""
ev = MethodExecutionStartedEvent(
method_name=method,
flow_name="F",
params={},
state={},
)
ev.event_id = event_id
ev.emission_sequence = sequence
return ev
class TestReplayPreservesFields:
"""replay() must not overwrite event_id, parent_event_id, or emission_sequence."""
def test_preserves_ids_and_sequence(self) -> None:
captured: list[MethodExecutionStartedEvent] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MethodExecutionStartedEvent)
def _capture(_: Any, event: MethodExecutionStartedEvent) -> None:
captured.append(event)
ev = _make_started("outline", "orig-id-1", 42)
ev.parent_event_id = "parent-abc"
future = crewai_event_bus.replay(object(), ev)
if future is not None:
future.result(timeout=5.0)
assert len(captured) == 1
assert captured[0].event_id == "orig-id-1"
assert captured[0].parent_event_id == "parent-abc"
assert captured[0].emission_sequence == 42
class TestIsReplayingFlag:
"""is_replaying() must be True inside handlers dispatched via replay()."""
def test_flag_true_during_replay(self) -> None:
seen: list[bool] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MethodExecutionStartedEvent)
def _capture(_: Any, __: MethodExecutionStartedEvent) -> None:
seen.append(is_replaying())
ev = _make_started("m", "id-1", 1)
future = crewai_event_bus.replay(object(), ev)
if future is not None:
future.result(timeout=5.0)
assert seen == [True]
assert is_replaying() is False
def test_flag_false_during_emit(self) -> None:
seen: list[bool] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MethodExecutionStartedEvent)
def _capture(_: Any, __: MethodExecutionStartedEvent) -> None:
seen.append(is_replaying())
ev = _make_started("m", "id-1", 1)
future = crewai_event_bus.emit(object(), ev)
if future is not None:
future.result(timeout=5.0)
assert seen == [False]
class TestCheckpointListenerOptsOut:
"""CheckpointListener must early-return during replay."""
def test_checkpoint_not_written_on_replay(self) -> None:
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.state.checkpoint_listener import _on_any_event
class FlowLike:
entity_type = "flow"
checkpoint = CheckpointConfig(trigger_all=True)
ev = _make_started("m", "id-1", 1)
with patch("crewai.state.checkpoint_listener._do_checkpoint") as do_cp:
token = _replaying.set(True)
try:
_on_any_event(FlowLike(), ev, state=None)
finally:
_replaying.reset(token)
assert do_cp.call_count == 0
class TestFlowResumeReplaysEvents:
"""End-to-end: a resumed flow emits MethodExecution* events for completed methods."""
def test_resume_dispatches_completed_method_events(self, tmp_path) -> None:
from crewai.flow.flow import Flow, listen, start
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
db_path = tmp_path / "flows.db"
persistence = SQLiteFlowPersistence(str(db_path))
class ThreeStepFlow(Flow[dict]):
@start()
def step_a(self) -> str:
return "a"
@listen(step_a)
def step_b(self) -> str:
return "b"
@listen(step_b)
def step_c(self) -> str:
return "c"
if crewai_event_bus.runtime_state is not None:
crewai_event_bus.runtime_state.event_record.clear()
flow1 = ThreeStepFlow(persistence=persistence)
flow1.kickoff()
flow_id = flow1.state["id"]
captured_started: list[str] = []
captured_finished: list[str] = []
flow2 = ThreeStepFlow(persistence=persistence)
flow2._completed_methods = {"step_a", "step_b"}
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MethodExecutionStartedEvent)
def _cs(_: Any, event: MethodExecutionStartedEvent) -> None:
captured_started.append(event.method_name)
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def _cf(_: Any, event: MethodExecutionFinishedEvent) -> None:
captured_finished.append(event.method_name)
flow2.kickoff(inputs={"id": flow_id})
assert captured_started.count("step_a") == 1
assert captured_started.count("step_b") == 1
assert captured_started.count("step_c") == 1
assert captured_finished.count("step_a") == 1
assert captured_finished.count("step_b") == 1
assert captured_finished.count("step_c") == 1

View File

@@ -389,17 +389,41 @@ def test_azure_raises_error_when_endpoint_missing():
llm._get_sync_client()
def test_azure_raises_error_when_api_key_missing():
"""Credentials are validated lazily: construction succeeds, first
def test_azure_raises_error_when_api_key_missing_without_azure_identity():
"""Without an API key AND without ``azure-identity`` installed,
client build raises the descriptive error."""
from crewai.llms.providers.azure.completion import AzureCompletion
with patch.dict(os.environ, {}, clear=True):
llm = AzureCompletion(
model="gpt-4", endpoint="https://test.openai.azure.com"
)
with pytest.raises(ValueError, match="Azure API key is required"):
llm._get_sync_client()
with patch.dict("sys.modules", {"azure.identity": None}):
llm = AzureCompletion(
model="gpt-4", endpoint="https://test.openai.azure.com"
)
with pytest.raises(ValueError, match="Azure API key is required"):
llm._get_sync_client()
def test_azure_uses_default_credential_when_api_key_missing():
"""With ``azure-identity`` installed, a missing API key falls back to
``DefaultAzureCredential`` instead of raising. This is the path that
enables keyless auth (OIDC WIF on EKS/AKS, Managed Identity, Azure
CLI) without any crewAI-specific config."""
from unittest.mock import MagicMock
from crewai.llms.providers.azure.completion import AzureCompletion
sentinel = MagicMock(name="DefaultAzureCredential()")
with patch.dict(os.environ, {}, clear=True):
with patch(
"azure.identity.DefaultAzureCredential", return_value=sentinel
) as mock_cls:
llm = AzureCompletion(
model="gpt-4",
endpoint="https://test-ai.services.example.com",
)
kwargs = llm._make_client_kwargs()
assert kwargs["credential"] is sentinel
mock_cls.assert_called()
@pytest.mark.asyncio

View File

@@ -4,6 +4,8 @@ from pathlib import Path
import pytest
from crewai import Agent
from crewai.agent.utils import append_skill_context
from crewai.skills.loader import activate_skill, discover_skills, format_skill_context
from crewai.skills.models import INSTRUCTIONS, METADATA
@@ -76,3 +78,23 @@ class TestSkillDiscoveryAndActivation:
all_skills.extend(discover_skills(search_path))
names = {s.name for s in all_skills}
assert names == {"skill-a", "skill-b"}
def test_agent_preserves_metadata_for_discovered_skills(self, tmp_path: Path) -> None:
_create_skill_dir(tmp_path, "travel", body="Use this skill for travel planning.")
discovered = discover_skills(tmp_path)
agent = Agent(
role="Travel Advisor",
goal="Provide personalized travel suggestions.",
backstory="An experienced travel consultant.",
skills=discovered,
)
assert agent.skills is not None
assert agent.skills[0].disclosure_level == METADATA
assert agent.skills[0].instructions is None
prompt = append_skill_context(agent, "Plan a 10-day Japan itinerary.")
assert "## Skill: travel" in prompt
assert "Skill travel" in prompt
assert "Use this skill for travel planning." not in prompt

View File

@@ -11,11 +11,12 @@ from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from pydantic import BaseModel
from crewai.agent.core import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crew import Crew
from crewai.flow.flow import Flow, start
from crewai.flow.flow import _INITIAL_STATE_CLASS_MARKER, Flow, start
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.state.checkpoint_listener import (
_find_checkpoint,
@@ -310,6 +311,65 @@ class TestRuntimeStateLineage:
assert state._branch != first
class TestFlowInitialStateSerialization:
"""Regression tests for checkpoint serialization of ``Flow.initial_state``."""
def test_class_ref_serializes_as_schema(self) -> None:
class MyState(BaseModel):
id: str = "x"
foo: str = "bar"
flow = Flow(initial_state=MyState)
state = RuntimeState(root=[flow])
dumped = json.loads(state.model_dump_json())
entity = dumped["entities"][0]
wrapped = entity["initial_state"]
assert isinstance(wrapped, dict)
assert _INITIAL_STATE_CLASS_MARKER in wrapped
assert wrapped[_INITIAL_STATE_CLASS_MARKER].get("title") == "MyState"
def test_class_ref_round_trips_to_basemodel_subclass(self) -> None:
class MyState(BaseModel):
id: str = "x"
foo: str = "bar"
flow = Flow(initial_state=MyState)
raw = RuntimeState(root=[flow]).model_dump_json()
restored = RuntimeState.model_validate_json(
raw, context={"from_checkpoint": True}
)
rehydrated = restored.root[0].initial_state
assert isinstance(rehydrated, type)
assert issubclass(rehydrated, BaseModel)
assert set(rehydrated.model_fields.keys()) == {"id", "foo"}
def test_instance_serializes_as_values(self) -> None:
class MyState(BaseModel):
id: str = "x"
foo: str = "bar"
flow = Flow(initial_state=MyState(foo="baz"))
state = RuntimeState(root=[flow])
dumped = json.loads(state.model_dump_json())
entity = dumped["entities"][0]
assert entity["initial_state"] == {"id": "x", "foo": "baz"}
def test_dict_passthrough(self) -> None:
flow = Flow(initial_state={"id": "x", "foo": "bar"})
state = RuntimeState(root=[flow])
dumped = json.loads(state.model_dump_json())
entity = dumped["entities"][0]
assert entity["initial_state"] == {"id": "x", "foo": "bar"}
def test_dict_round_trips_as_dict(self) -> None:
flow = Flow(initial_state={"id": "x", "foo": "bar"})
raw = RuntimeState(root=[flow]).model_dump_json()
restored = RuntimeState.model_validate_json(
raw, context={"from_checkpoint": True}
)
assert restored.root[0].initial_state == {"id": "x", "foo": "bar"}
# ---------- JsonProvider forking ----------
@@ -523,6 +583,31 @@ class TestKickoffFromCheckpoint:
assert isinstance(crew.checkpoint, CheckpointConfig)
assert crew.checkpoint.on_events == ["task_completed"]
def test_agent_kickoff_delegates_to_from_checkpoint(self) -> None:
mock_restored = MagicMock(spec=Agent)
mock_restored.kickoff.return_value = "agent_result"
cfg = CheckpointConfig(restore_from="/path/to/agent_cp.json")
with patch.object(Agent, "from_checkpoint", return_value=mock_restored):
agent = Agent(role="r", goal="g", backstory="b", llm="gpt-4o-mini")
result = agent.kickoff(messages="hello", from_checkpoint=cfg)
mock_restored.kickoff.assert_called_once_with(
messages="hello", response_format=None, input_files=None
)
assert mock_restored.checkpoint.restore_from is None
assert result == "agent_result"
def test_agent_kickoff_config_only_sets_checkpoint(self) -> None:
cfg = CheckpointConfig(on_events=["lite_agent_execution_completed"])
agent = Agent(role="r", goal="g", backstory="b", llm="gpt-4o-mini")
assert agent.checkpoint is None
with patch.object(Agent, "_prepare_kickoff", side_effect=RuntimeError("stop")):
with pytest.raises(RuntimeError, match="stop"):
agent.kickoff(messages="hello", from_checkpoint=cfg)
assert isinstance(agent.checkpoint, CheckpointConfig)
assert agent.checkpoint.on_events == ["lite_agent_execution_completed"]
def test_flow_kickoff_delegates_to_from_checkpoint(self) -> None:
mock_restored = MagicMock(spec=Flow)
mock_restored.kickoff.return_value = "flow_result"
@@ -537,3 +622,75 @@ class TestKickoffFromCheckpoint:
)
assert mock_restored.checkpoint.restore_from is None
assert result == "flow_result"
# ---------- Agent checkpoint/fork ----------
class TestAgentCheckpoint:
def _make_agent_state(self) -> RuntimeState:
agent = Agent(role="r", goal="g", backstory="b", llm="gpt-4o-mini")
return RuntimeState(root=[agent])
def test_agent_from_checkpoint_sets_runtime_state(self) -> None:
state = self._make_agent_state()
state._provider = JsonProvider()
with tempfile.TemporaryDirectory() as d:
loc = state.checkpoint(d)
cfg = CheckpointConfig(restore_from=loc)
from crewai.events.event_bus import crewai_event_bus
crewai_event_bus._runtime_state = None
Agent.from_checkpoint(cfg)
assert crewai_event_bus._runtime_state is not None
def test_agent_fork_sets_branch(self) -> None:
state = self._make_agent_state()
state._provider = JsonProvider()
with tempfile.TemporaryDirectory() as d:
loc = state.checkpoint(d)
cfg = CheckpointConfig(restore_from=loc)
from crewai.events.event_bus import crewai_event_bus
Agent.fork(cfg, branch="agent-experiment")
rt = crewai_event_bus._runtime_state
assert rt is not None
assert rt._branch == "agent-experiment"
def test_agent_fork_auto_branch(self) -> None:
state = self._make_agent_state()
state._provider = JsonProvider()
with tempfile.TemporaryDirectory() as d:
loc = state.checkpoint(d)
cfg = CheckpointConfig(restore_from=loc)
from crewai.events.event_bus import crewai_event_bus
Agent.fork(cfg)
rt = crewai_event_bus._runtime_state
assert rt is not None
assert rt._branch.startswith("fork/")
def test_sync_checkpoint_fields_agent(self) -> None:
from crewai.state.runtime import _sync_checkpoint_fields
agent = Agent(role="r", goal="g", backstory="b", llm="gpt-4o-mini")
agent._kickoff_event_id = "evt-123"
_sync_checkpoint_fields(agent)
assert agent.checkpoint_kickoff_event_id == "evt-123"
def test_agent_restore_kickoff_event_id(self) -> None:
agent = Agent(role="r", goal="g", backstory="b", llm="gpt-4o-mini")
agent._kickoff_event_id = "evt-456"
state = RuntimeState(root=[agent])
state._provider = JsonProvider()
with tempfile.TemporaryDirectory() as d:
from crewai.state.runtime import _prepare_entities
_prepare_entities(state.root)
loc = state.checkpoint(d)
cfg = CheckpointConfig(restore_from=loc)
restored = Agent.from_checkpoint(cfg)
assert restored._kickoff_event_id == "evt-456"

View File

@@ -0,0 +1,402 @@
"""Tests for checkpoint CLI commands."""
from __future__ import annotations
import json
import os
import sqlite3
import tempfile
import time
from datetime import datetime, timedelta, timezone
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from crewai.cli.checkpoint_cli import (
_parse_checkpoint_json,
_parse_duration,
_prune_json,
_prune_sqlite,
_resolve_checkpoint,
_task_list_from_meta,
diff_checkpoints,
prune_checkpoints,
resume_checkpoint,
)
def _make_checkpoint_data(
tasks_completed: int = 2,
tasks_total: int = 4,
trigger: str = "task_completed",
branch: str = "main",
parent_id: str | None = None,
entity_type: str = "crew",
name: str = "test_crew",
inputs: dict[str, Any] | None = None,
) -> str:
tasks: list[dict[str, Any]] = []
for i in range(tasks_total):
t: dict[str, Any] = {
"description": f"Task {i + 1} description",
"expected_output": f"Output {i + 1}",
}
if i < tasks_completed:
t["output"] = {"raw": f"Result of task {i + 1}"}
else:
t["output"] = None
tasks.append(t)
data: dict[str, Any] = {
"entities": [
{
"entity_type": entity_type,
"name": name,
"id": "abc12345-1234-1234-1234-abcdef012345",
"tasks": tasks,
"agents": [],
"checkpoint_inputs": inputs or {},
}
],
"event_record": {"nodes": {f"node_{i}": {} for i in range(3)}},
"trigger": trigger,
"branch": branch,
"parent_id": parent_id,
}
return json.dumps(data)
def _write_json_checkpoint(
base_dir: str,
branch: str = "main",
name: str | None = None,
data: str | None = None,
tasks_completed: int = 2,
inputs: dict[str, Any] | None = None,
) -> str:
branch_dir = os.path.join(base_dir, branch)
os.makedirs(branch_dir, exist_ok=True)
if name is None:
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
name = f"{ts}_abcd1234_p-none.json"
path = os.path.join(branch_dir, name)
if data is None:
data = _make_checkpoint_data(tasks_completed=tasks_completed, inputs=inputs)
with open(path, "w") as f:
f.write(data)
return path
def _create_sqlite_checkpoint(
db_path: str,
checkpoint_id: str | None = None,
data: str | None = None,
tasks_completed: int = 2,
branch: str = "main",
inputs: dict[str, Any] | None = None,
) -> str:
if checkpoint_id is None:
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
checkpoint_id = f"{ts}_abcd1234"
if data is None:
data = _make_checkpoint_data(
tasks_completed=tasks_completed, branch=branch, inputs=inputs
)
with sqlite3.connect(db_path) as conn:
conn.execute(
"""CREATE TABLE IF NOT EXISTS checkpoints (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
parent_id TEXT,
branch TEXT NOT NULL DEFAULT 'main',
data JSONB NOT NULL
)"""
)
conn.execute(
"INSERT INTO checkpoints (id, created_at, parent_id, branch, data) "
"VALUES (?, ?, ?, ?, jsonb(?))",
(checkpoint_id, checkpoint_id.split("_")[0], None, branch, data),
)
conn.commit()
return checkpoint_id
class TestParseDuration:
def test_days(self) -> None:
assert _parse_duration("7d") == timedelta(days=7)
def test_hours(self) -> None:
assert _parse_duration("24h") == timedelta(hours=24)
def test_minutes(self) -> None:
assert _parse_duration("30m") == timedelta(minutes=30)
def test_invalid_raises(self) -> None:
with pytest.raises(Exception):
_parse_duration("abc")
def test_no_unit_raises(self) -> None:
with pytest.raises(Exception):
_parse_duration("7")
class TestResolveCheckpoint:
def test_json_latest(self) -> None:
with tempfile.TemporaryDirectory() as d:
_write_json_checkpoint(d, name="20260101T000000_aaaa1111_p-none.json")
time.sleep(0.01)
path2 = _write_json_checkpoint(
d, name="20260102T000000_bbbb2222_p-none.json", tasks_completed=3
)
meta = _resolve_checkpoint(d, None)
assert meta is not None
assert meta["path"] == path2
def test_json_by_id(self) -> None:
with tempfile.TemporaryDirectory() as d:
_write_json_checkpoint(d, name="20260101T000000_aaaa1111_p-none.json")
_write_json_checkpoint(d, name="20260102T000000_bbbb2222_p-none.json")
meta = _resolve_checkpoint(d, "aaaa1111")
assert meta is not None
assert "aaaa1111" in meta["name"]
def test_json_not_found(self) -> None:
with tempfile.TemporaryDirectory() as d:
_write_json_checkpoint(d)
assert _resolve_checkpoint(d, "nonexistent") is None
def test_sqlite_latest(self) -> None:
with tempfile.TemporaryDirectory() as d:
db_path = os.path.join(d, "test.db")
_create_sqlite_checkpoint(db_path, "20260101T000000_aaaa1111")
_create_sqlite_checkpoint(
db_path, "20260102T000000_bbbb2222", tasks_completed=3
)
meta = _resolve_checkpoint(db_path, None)
assert meta is not None
assert "bbbb2222" in meta["name"]
def test_sqlite_by_id(self) -> None:
with tempfile.TemporaryDirectory() as d:
db_path = os.path.join(d, "test.db")
_create_sqlite_checkpoint(db_path, "20260101T000000_aaaa1111")
_create_sqlite_checkpoint(db_path, "20260102T000000_bbbb2222")
meta = _resolve_checkpoint(db_path, "20260101T000000_aaaa1111")
assert meta is not None
assert "aaaa1111" in meta["name"]
def test_sqlite_partial_id(self) -> None:
with tempfile.TemporaryDirectory() as d:
db_path = os.path.join(d, "test.db")
_create_sqlite_checkpoint(db_path, "20260101T000000_aaaa1111")
_create_sqlite_checkpoint(db_path, "20260102T000000_bbbb2222")
meta = _resolve_checkpoint(db_path, "aaaa1111")
assert meta is not None
assert "aaaa1111" in meta["name"]
def test_nonexistent(self) -> None:
assert _resolve_checkpoint("/nonexistent/path", None) is None
class TestTaskListFromMeta:
def test_flattens_tasks(self) -> None:
data = _make_checkpoint_data(tasks_completed=2, tasks_total=3)
meta = _parse_checkpoint_json(data, "test")
tasks = _task_list_from_meta(meta)
assert len(tasks) == 3
assert tasks[0]["completed"] is True
assert tasks[2]["completed"] is False
def test_empty_entities(self) -> None:
assert _task_list_from_meta({"entities": []}) == []
class TestDiffCheckpoints:
def test_diff_shows_status_change(self, capsys: pytest.CaptureFixture[str]) -> None:
with tempfile.TemporaryDirectory() as d:
_write_json_checkpoint(
d, name="20260101T000000_aaaa1111_p-none.json", tasks_completed=1
)
_write_json_checkpoint(
d, name="20260102T000000_bbbb2222_p-none.json", tasks_completed=3
)
diff_checkpoints(d, "aaaa1111", "bbbb2222")
out = capsys.readouterr().out
assert "---" in out
assert "+++" in out
assert "status:" in out or "pending -> done" in out
def test_diff_shows_output_change(self, capsys: pytest.CaptureFixture[str]) -> None:
with tempfile.TemporaryDirectory() as d:
data1 = _make_checkpoint_data(tasks_completed=2)
data2 = json.loads(data1)
data2["entities"][0]["tasks"][0]["output"]["raw"] = "Updated result"
_write_json_checkpoint(
d,
name="20260101T000000_aaaa1111_p-none.json",
data=json.dumps(json.loads(data1)),
)
_write_json_checkpoint(
d,
name="20260102T000000_bbbb2222_p-none.json",
data=json.dumps(data2),
)
diff_checkpoints(d, "aaaa1111", "bbbb2222")
out = capsys.readouterr().out
assert "output:" in out
def test_diff_not_found(self, capsys: pytest.CaptureFixture[str]) -> None:
with tempfile.TemporaryDirectory() as d:
_write_json_checkpoint(d, name="20260101T000000_aaaa1111_p-none.json")
diff_checkpoints(d, "aaaa1111", "nonexistent")
out = capsys.readouterr().out
assert "not found" in out
def test_diff_input_change(self, capsys: pytest.CaptureFixture[str]) -> None:
with tempfile.TemporaryDirectory() as d:
_write_json_checkpoint(
d,
name="20260101T000000_aaaa1111_p-none.json",
inputs={"topic": "AI"},
)
_write_json_checkpoint(
d,
name="20260102T000000_bbbb2222_p-none.json",
inputs={"topic": "ML"},
)
diff_checkpoints(d, "aaaa1111", "bbbb2222")
out = capsys.readouterr().out
assert "Inputs:" in out
assert "AI" in out
assert "ML" in out
class TestPruneJson:
def test_keep_n(self) -> None:
with tempfile.TemporaryDirectory() as d:
for i in range(5):
_write_json_checkpoint(
d, name=f"2026010{i + 1}T000000_aaa{i}1111_p-none.json"
)
time.sleep(0.01)
deleted = _prune_json(d, keep=2, older_than=None)
assert deleted == 3
remaining = []
for root, _, files in os.walk(d):
remaining.extend(files)
assert len(remaining) == 2
def test_older_than(self) -> None:
with tempfile.TemporaryDirectory() as d:
old_path = _write_json_checkpoint(
d, name="20250101T000000_old01111_p-none.json"
)
os.utime(old_path, (0, 0))
_write_json_checkpoint(d, name="20990101T000000_new01111_p-none.json")
deleted = _prune_json(d, keep=None, older_than=timedelta(days=1))
assert deleted == 1
def test_empty_dir(self) -> None:
with tempfile.TemporaryDirectory() as d:
assert _prune_json(d, keep=2, older_than=None) == 0
def test_removes_empty_branch_dirs(self) -> None:
with tempfile.TemporaryDirectory() as d:
path = _write_json_checkpoint(
d,
branch="feature",
name="20260101T000000_aaaa1111_p-none.json",
)
os.utime(path, (0, 0))
_prune_json(d, keep=None, older_than=timedelta(days=1))
assert not os.path.exists(os.path.join(d, "feature"))
class TestPruneSqlite:
def test_keep_n(self) -> None:
with tempfile.TemporaryDirectory() as d:
db_path = os.path.join(d, "test.db")
for i in range(5):
_create_sqlite_checkpoint(
db_path, f"2026010{i + 1}T000000_aaa{i}1111"
)
deleted = _prune_sqlite(db_path, keep=2, older_than=None)
assert deleted == 3
with sqlite3.connect(db_path) as conn:
count = conn.execute("SELECT COUNT(*) FROM checkpoints").fetchone()[0]
assert count == 2
def test_older_than(self) -> None:
with tempfile.TemporaryDirectory() as d:
db_path = os.path.join(d, "test.db")
_create_sqlite_checkpoint(db_path, "20200101T000000_old01111")
_create_sqlite_checkpoint(db_path, "20990101T000000_new01111")
deleted = _prune_sqlite(db_path, keep=None, older_than=timedelta(days=1))
assert deleted >= 1
with sqlite3.connect(db_path) as conn:
count = conn.execute("SELECT COUNT(*) FROM checkpoints").fetchone()[0]
assert count >= 1
class TestPruneCommand:
def test_no_options_shows_help(self, capsys: pytest.CaptureFixture[str]) -> None:
with tempfile.TemporaryDirectory() as d:
prune_checkpoints(d, keep=None, older_than=None)
out = capsys.readouterr().out
assert "Specify" in out
def test_dry_run_json(self, capsys: pytest.CaptureFixture[str]) -> None:
with tempfile.TemporaryDirectory() as d:
_write_json_checkpoint(d)
prune_checkpoints(d, keep=1, older_than=None, dry_run=True)
out = capsys.readouterr().out
assert "Would prune" in out
def test_not_found(self, capsys: pytest.CaptureFixture[str]) -> None:
prune_checkpoints("/nonexistent", keep=1, older_than=None)
out = capsys.readouterr().out
assert "Not a directory" in out
class TestResumeCheckpoint:
def test_not_found(self, capsys: pytest.CaptureFixture[str]) -> None:
with tempfile.TemporaryDirectory() as d:
resume_checkpoint(d, "nonexistent")
out = capsys.readouterr().out
assert "not found" in out
def test_no_checkpoints(self, capsys: pytest.CaptureFixture[str]) -> None:
with tempfile.TemporaryDirectory() as d:
resume_checkpoint(d, None)
out = capsys.readouterr().out
assert "No checkpoints" in out
class TestDiscoverabilityMessage:
def test_checkpoint_listener_logs_resume_hint(self) -> None:
from crewai.state.checkpoint_listener import _do_checkpoint
from crewai.state.runtime import RuntimeState
state = MagicMock(spec=RuntimeState)
state.root = []
state.model_dump.return_value = {"entities": [], "event_record": {"nodes": {}}}
state._parent_id = None
state._branch = "main"
cfg = MagicMock()
cfg.location = "/tmp/cp"
cfg.max_checkpoints = None
cfg.provider.checkpoint.return_value = "/tmp/cp/main/20260101T000000_test1234_p-none.json"
cfg.provider.extract_id.return_value = "20260101T000000_test1234"
with (
patch("crewai.state.checkpoint_listener._prepare_entities"),
patch("crewai.state.checkpoint_listener.logger") as mock_logger,
):
_do_checkpoint(state, cfg)
cfg.provider.extract_id.assert_called_once()
mock_logger.info.assert_called_once()
logged: str = mock_logger.info.call_args[0][0]
assert "crewai checkpoint resume" in logged
assert "20260101T000000_test1234" in logged

View File

@@ -8,6 +8,7 @@ from concurrent.futures import Future
from hashlib import md5
import re
import sys
from typing import Any, cast
from unittest.mock import ANY, MagicMock, call, patch
from crewai.agent import Agent
@@ -17,6 +18,7 @@ from crewai.crew import Crew
from crewai.crews.crew_output import CrewOutput
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.crew_events import (
CrewKickoffStartedEvent,
CrewTestCompletedEvent,
CrewTestStartedEvent,
CrewTrainCompletedEvent,
@@ -4517,8 +4519,8 @@ def test_sets_flow_context_when_using_crewbase_pattern_inside_flow():
flow.kickoff()
assert captured_crew is not None
assert captured_crew._flow_id == flow.flow_id # type: ignore[attr-defined]
assert captured_crew._request_id == flow.flow_id # type: ignore[attr-defined]
assert captured_crew._flow_id == flow.execution_id # type: ignore[attr-defined]
assert captured_crew._request_id == flow.execution_id # type: ignore[attr-defined]
def test_sets_flow_context_when_outside_flow(researcher, writer):
@@ -4552,8 +4554,8 @@ def test_sets_flow_context_when_inside_flow(researcher, writer):
flow = MyFlow()
result = flow.kickoff()
assert result._flow_id == flow.flow_id # type: ignore[attr-defined]
assert result._request_id == flow.flow_id # type: ignore[attr-defined]
assert result._flow_id == flow.execution_id # type: ignore[attr-defined]
assert result._request_id == flow.execution_id # type: ignore[attr-defined]
def test_reset_knowledge_with_no_crew_knowledge(researcher, writer):
@@ -4741,6 +4743,92 @@ def test_default_crew_name(researcher, writer):
assert crew.name == "crew"
@pytest.mark.parametrize(
"explicit_name,expected",
[
(None, "ResearchAutomation"),
("My Research Automation", "My Research Automation"),
],
ids=["class_name_from_decorator", "explicit_name_preserved"],
)
def test_crew_kickoff_started_emits_display_name(
researcher, writer, explicit_name, expected
):
"""Kickoff events should use the decorator-provided display name when implicit."""
from crewai.crews.utils import prepare_kickoff
from crewai.project import CrewBase, agent, crew, task
@CrewBase
class ResearchAutomation:
agents_config = None
tasks_config = None
@agent
def researcher(self):
return researcher
@task
def first_task(self):
return Task(
description="Task 1",
expected_output="output",
agent=self.researcher(),
)
@crew
def crew(self):
crew_kwargs: dict[str, Any] = {
"agents": self.agents,
"tasks": self.tasks,
}
if explicit_name is not None:
crew_kwargs["name"] = explicit_name
return Crew(**crew_kwargs)
captured: list[str | None] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(CrewKickoffStartedEvent)
def _capture(_source: Any, event: CrewKickoffStartedEvent) -> None:
captured.append(event.crew_name)
automation_cls = cast(type[Any], ResearchAutomation)
prepare_kickoff(cast(Any, automation_cls()).crew(), inputs=None)
assert captured == [expected]
def test_prepare_kickoff_binds_task_only_agent_to_crew():
"""Agents referenced only via task.agent must get .crew set during prepare_kickoff.
Regression for crewAIInc/crewAI#5534: when Crew is built without
agents=[...], multimodal input_files were silently dropped because the
agent's .crew attribute was never assigned, gating file lookup off in
Task and CrewAgentExecutor.
"""
from crewai.crews.utils import prepare_kickoff
task_only_agent = Agent(
role="Solo",
goal="Describe inputs",
backstory="Solo agent assigned only via task.agent",
allow_delegation=False,
)
task = Task(
description="Describe the input.",
expected_output="A description.",
agent=task_only_agent,
)
crew = Crew(tasks=[task])
assert task_only_agent.crew is None
assert crew.agents == []
prepare_kickoff(crew, inputs=None)
assert task_only_agent.crew is crew
@pytest.mark.vcr()
def test_memory_remember_receives_task_content():
"""With memory=True, extract_memories receives raw content with task, agent, expected output, and result."""

View File

@@ -0,0 +1,127 @@
"""Regression tests for ``Flow.execution_id``.
``execution_id`` is the stable tracking identifier for a single flow run.
It must stay independent of ``state.id`` so that consumers passing an
``id`` in ``inputs`` (used for persistence restore) cannot destabilize
the identity used by telemetry, tracing, and external correlation.
"""
from __future__ import annotations
from typing import Any
import pytest
from crewai.flow.flow import Flow, FlowState, start
from crewai.flow.flow_context import current_flow_id, current_flow_request_id
class _CaptureState(FlowState):
captured_flow_id: str = ""
captured_state_id: str = ""
captured_current_flow_id: str = ""
captured_execution_id: str = ""
class _IdentityCaptureFlow(Flow[_CaptureState]):
initial_state = _CaptureState
@start()
def capture(self) -> None:
self.state.captured_flow_id = self.flow_id
self.state.captured_state_id = self.state.id
self.state.captured_current_flow_id = current_flow_id.get() or ""
self.state.captured_execution_id = self.execution_id
def test_execution_id_defaults_to_fresh_uuid_per_instance() -> None:
a = _IdentityCaptureFlow()
b = _IdentityCaptureFlow()
assert a.execution_id
assert b.execution_id
assert a.execution_id != b.execution_id
def test_execution_id_survives_consumer_id_in_inputs() -> None:
flow = _IdentityCaptureFlow()
original_execution_id = flow.execution_id
flow.kickoff(inputs={"id": "consumer-supplied-id"})
assert flow.state.id == "consumer-supplied-id"
assert flow.flow_id == "consumer-supplied-id"
assert flow.execution_id == original_execution_id
assert flow.execution_id != "consumer-supplied-id"
def test_two_runs_with_same_consumer_id_have_distinct_execution_ids() -> None:
flow_a = _IdentityCaptureFlow()
flow_b = _IdentityCaptureFlow()
colliding_id = "shared-consumer-id"
flow_a.kickoff(inputs={"id": colliding_id})
flow_b.kickoff(inputs={"id": colliding_id})
assert flow_a.state.id == colliding_id
assert flow_b.state.id == colliding_id
assert flow_a.execution_id != flow_b.execution_id
def test_execution_id_is_writable() -> None:
flow = _IdentityCaptureFlow()
flow.execution_id = "external-task-id"
assert flow.execution_id == "external-task-id"
flow.kickoff(inputs={"id": "consumer-supplied-id"})
assert flow.execution_id == "external-task-id"
assert flow.state.id == "consumer-supplied-id"
def test_current_flow_id_context_var_matches_execution_id() -> None:
flow = _IdentityCaptureFlow()
flow.execution_id = "external-task-id"
flow.kickoff(inputs={"id": "consumer-supplied-id"})
assert flow.state.captured_current_flow_id == "external-task-id"
assert flow.state.captured_flow_id == "consumer-supplied-id"
assert flow.state.captured_execution_id == "external-task-id"
def test_execution_id_not_included_in_serialized_state() -> None:
flow = _IdentityCaptureFlow()
flow.execution_id = "external-task-id"
flow.kickoff()
dumped = flow.state.model_dump()
assert "execution_id" not in dumped
assert "_execution_id" not in dumped
assert dumped["id"] == flow.state.id
def test_dict_state_flow_also_exposes_stable_execution_id() -> None:
class DictFlow(Flow[dict[str, Any]]):
initial_state = dict # type: ignore[assignment]
@start()
def noop(self) -> None:
pass
flow = DictFlow()
original = flow.execution_id
flow.kickoff(inputs={"id": "consumer-supplied-id"})
assert flow.state["id"] == "consumer-supplied-id"
assert flow.execution_id == original
@pytest.fixture(autouse=True)
def _reset_flow_context_vars():
yield
for var in (current_flow_id, current_flow_request_id):
try:
var.set(None)
except LookupError:
# ContextVar was never set in this context; nothing to reset.
pass

View File

@@ -0,0 +1,130 @@
"""Tests for JSON serialization of guardrail fields on Task, Agent, and LiteAgent.
Guardrails accept either string descriptions or callables. Callables cannot be
JSON-serialized, so the checkpoint path must drop them rather than raise.
"""
import pytest
from crewai import Agent, Task
from crewai.lite_agent import LiteAgent
from crewai.utilities.guardrail import (
serialize_guardrail_for_json,
serialize_guardrails_for_json,
)
def _example_guardrail(output):
return True, output
def test_serialize_guardrail_preserves_string() -> None:
assert serialize_guardrail_for_json("validate output") == "validate output"
def test_serialize_guardrail_returns_none_for_none() -> None:
assert serialize_guardrail_for_json(None) is None
def test_serialize_guardrail_drops_callable_with_warning() -> None:
with pytest.warns(UserWarning, match="cannot be JSON-serialized"):
assert serialize_guardrail_for_json(_example_guardrail) is None
def test_serialize_guardrails_drops_callables_from_list() -> None:
with pytest.warns(UserWarning):
result = serialize_guardrails_for_json(["check size", _example_guardrail])
assert result == ["check size"]
def test_serialize_guardrails_all_callables_returns_empty_list() -> None:
with pytest.warns(UserWarning):
result = serialize_guardrails_for_json([_example_guardrail, _example_guardrail])
assert result == []
def test_serialize_guardrails_handles_single_string() -> None:
assert serialize_guardrails_for_json("only check this") == "only check this"
def test_serialize_guardrails_handles_single_callable() -> None:
with pytest.warns(UserWarning):
assert serialize_guardrails_for_json(_example_guardrail) is None
def test_task_model_dump_json_with_string_guardrail() -> None:
agent = Agent(role="r", goal="g", backstory="b")
task = Task(
description="Do the thing",
expected_output="A thing",
agent=agent,
guardrail="output must be non-empty",
)
dumped = task.model_dump(mode="json")
assert dumped["guardrail"] == "output must be non-empty"
def test_task_model_dump_json_with_callable_guardrail_does_not_raise() -> None:
agent = Agent(role="r", goal="g", backstory="b")
task = Task(
description="Do the thing",
expected_output="A thing",
agent=agent,
guardrail=_example_guardrail,
)
with pytest.warns(UserWarning, match="cannot be JSON-serialized"):
dumped = task.model_dump(mode="json")
assert dumped["guardrail"] is None
def test_task_model_dump_json_with_callable_guardrails_list() -> None:
agent = Agent(role="r", goal="g", backstory="b")
task = Task(
description="Do the thing",
expected_output="A thing",
agent=agent,
guardrails=[_example_guardrail, "also check this"],
)
with pytest.warns(UserWarning):
dumped = task.model_dump(mode="json")
assert dumped["guardrails"] == ["also check this"]
def test_task_guardrails_round_trip_through_model_validate() -> None:
"""Serialized guardrails must round-trip — None entries would fail validation."""
agent = Agent(role="r", goal="g", backstory="b")
task = Task(
description="Do the thing",
expected_output="A thing",
agent=agent,
guardrails=[_example_guardrail, "also check this"],
)
with pytest.warns(UserWarning):
dumped = task.model_dump(mode="json", exclude={"id"})
if isinstance(dumped.get("agent"), dict):
dumped["agent"].pop("id", None)
Task.model_validate(dumped)
def test_agent_model_dump_json_with_callable_guardrail() -> None:
agent = Agent(
role="r",
goal="g",
backstory="b",
guardrail=_example_guardrail,
)
with pytest.warns(UserWarning, match="cannot be JSON-serialized"):
dumped = agent.model_dump(mode="json")
assert dumped["guardrail"] is None
def test_lite_agent_model_dump_json_with_callable_guardrail() -> None:
agent = LiteAgent(
role="r",
goal="g",
backstory="b",
guardrail=_example_guardrail,
)
with pytest.warns(UserWarning, match="cannot be JSON-serialized"):
dumped = agent.model_dump(mode="json")
assert dumped["guardrail"] is None

View File

@@ -648,7 +648,7 @@ def test_handle_streaming_tool_calls_no_tools(mock_emit):
assert_event_count(
mock_emit=mock_emit,
expected_stream_chunk=46,
expected_stream_chunk=47,
expected_completed_llm_call=1,
expected_final_chunk_result=response,
)

View File

@@ -1,4 +1,4 @@
from typing import Any, ClassVar
from typing import Any, ClassVar, cast
from unittest.mock import Mock, create_autospec, patch
import pytest
@@ -261,6 +261,55 @@ def test_crew_name():
assert crew._crew_name == "InternalCrew"
def test_crew_decorator_propagates_class_name_to_instance():
"""@crew-decorated factory method should set Crew.name to the decorated class name."""
sample_agent = Agent(role="r", goal="g", backstory="b")
sample_task = Task(description="d", expected_output="o", agent=sample_agent)
@CrewBase
class ImplicitNameCrewFactory:
agents_config = None
tasks_config = None
agents: list[BaseAgent] = [sample_agent]
tasks: list[Task] = [sample_task]
@crew
def crew(self):
return Crew(
agents=[sample_agent],
tasks=[sample_task],
)
factory_cls = cast(type[Any], ImplicitNameCrewFactory)
crew_instance: Crew = cast(Any, factory_cls()).crew()
assert crew_instance.name == "ImplicitNameCrewFactory"
def test_crew_decorator_preserves_explicit_name():
"""Explicit Crew(name=...) inside @crew should win over the @CrewBase class name."""
sample_agent = Agent(role="r", goal="g", backstory="b")
sample_task = Task(description="d", expected_output="o", agent=sample_agent)
@CrewBase
class NamedCrewFactory:
agents_config = None
tasks_config = None
agents: list[BaseAgent] = [sample_agent]
tasks: list[Task] = [sample_task]
@crew
def crew(self):
return Crew(
name="My Explicit Name",
agents=[sample_agent],
tasks=[sample_task],
)
factory_cls = cast(type[Any], NamedCrewFactory)
crew_instance: Crew = cast(Any, factory_cls()).crew()
assert crew_instance.name == "My Explicit Name"
@tool
def simple_tool():
"""Return 'Hi!'"""

View File

@@ -879,3 +879,91 @@ class TestStreamingImports:
assert StreamChunk is not None
assert StreamChunkType is not None
assert ToolCallChunk is not None
class TestConcurrentStreamIsolation:
"""Regression tests for concurrent streaming isolation (issue #5376)."""
def test_concurrent_streams_do_not_cross_contaminate(self) -> None:
"""Two concurrent streaming runs must each receive only their own chunks.
Mirrors the real production path: create_streaming_state in the caller,
then temporarily push the stream_id into the ContextVar, copy_context,
and reset — exactly as create_chunk_generator does.
"""
import contextvars
import threading
from crewai.utilities.streaming import (
TaskInfo,
_current_stream_ids,
_unregister_handler,
create_streaming_state,
)
task_info_a: TaskInfo = {
"index": 0,
"name": "task_a",
"id": "a",
"agent_role": "A",
"agent_id": "a",
}
task_info_b: TaskInfo = {
"index": 1,
"name": "task_b",
"id": "b",
"agent_role": "B",
"agent_id": "b",
}
state_a = create_streaming_state(task_info_a, [])
state_b = create_streaming_state(task_info_b, [])
def make_emitter_ctx(state: Any) -> contextvars.Context:
token = _current_stream_ids.set(
(*_current_stream_ids.get(), state.stream_id)
)
ctx = contextvars.copy_context()
_current_stream_ids.reset(token)
return ctx
ctx_a = make_emitter_ctx(state_a)
ctx_b = make_emitter_ctx(state_b)
def emit_chunks(prefix: str, call_id: str) -> None:
for text in [f"{prefix}1", f"{prefix}2", f"{prefix}3"]:
crewai_event_bus.emit(
None,
event=LLMStreamChunkEvent(
chunk=text, call_id=call_id, response_id="r"
),
)
t_a = threading.Thread(target=ctx_a.run, args=(lambda: emit_chunks("A", "ca"),))
t_b = threading.Thread(target=ctx_b.run, args=(lambda: emit_chunks("B", "cb"),))
t_a.start()
t_b.start()
t_a.join()
t_b.join()
chunks_a: list[str] = []
while not state_a.sync_queue.empty():
item = state_a.sync_queue.get_nowait()
if isinstance(item, StreamChunk):
chunks_a.append(item.content)
chunks_b: list[str] = []
while not state_b.sync_queue.empty():
item = state_b.sync_queue.get_nowait()
if isinstance(item, StreamChunk):
chunks_b.append(item.content)
assert set(chunks_a) == {"A1", "A2", "A3"}, (
f"Stream A received unexpected chunks: {chunks_a}"
)
assert set(chunks_b) == {"B1", "B2", "B3"}, (
f"Stream B received unexpected chunks: {chunks_b}"
)
_unregister_handler(state_a.handler)
_unregister_handler(state_b.handler)

View File

@@ -1640,3 +1640,43 @@ class TestBackendInitializedGatedOnSuccess:
assert bm.backend_initialized is False
assert bm.trace_batch_id is None
class TestTraceBatchManagerDuplicateInitMerge:
"""Second initialize_batch call merges execution_metadata (flow after lazy action)."""
def test_duplicate_initialize_merges_execution_metadata(self):
with (
patch(
"crewai.events.listeners.tracing.trace_batch_manager.should_auto_collect_first_time_traces",
return_value=True,
),
patch(
"crewai.events.listeners.tracing.trace_batch_manager.is_tracing_enabled_in_context",
return_value=True,
),
):
bm = TraceBatchManager()
bm.initialize_batch(
user_context={"privacy_level": "standard"},
execution_metadata={
"crew_name": "Unknown Crew",
"crewai_version": "9.9.9",
},
)
first_batch_id = bm.current_batch.batch_id
bm.initialize_batch(
user_context={"privacy_level": "standard"},
execution_metadata={
"flow_name": "ResearchFlow",
"execution_type": "flow",
"crewai_version": "9.9.9",
"execution_start": "2026-01-01T00:00:00+00:00",
},
)
assert bm.current_batch.batch_id == first_batch_id
meta = bm.current_batch.execution_metadata
assert meta.get("execution_type") == "flow"
assert meta.get("flow_name") == "ResearchFlow"
assert meta.get("crew_name") == "Unknown Crew"

View File

@@ -882,3 +882,110 @@ class TestEndToEndMCPSchema:
)
assert obj.filters.date_from == datetime.date(2025, 1, 1)
assert obj.filters.categories == ["news", "tech"]
# ---------------------------------------------------------------------------
# Recursive / circular $ref schemas (GH-5490)
# ---------------------------------------------------------------------------
RECURSIVE_NODE_SCHEMA: dict = {
"$defs": {
"Node": {
"type": "object",
"properties": {
"name": {"type": "string"},
"children": {
"type": "array",
"items": {"$ref": "#/$defs/Node"},
},
},
"required": ["name"],
}
},
"$ref": "#/$defs/Node",
}
MUTUAL_RECURSION_SCHEMA: dict = {
"$defs": {
"A": {
"type": "object",
"properties": {
"val": {"type": "string"},
"b": {"$ref": "#/$defs/B"},
},
"required": ["val"],
},
"B": {
"type": "object",
"properties": {
"val": {"type": "integer"},
"a": {"$ref": "#/$defs/A"},
},
"required": ["val"],
},
},
"$ref": "#/$defs/A",
}
class TestResolveRefsRecursive:
def test_circular_ref_preserves_type(self) -> None:
from crewai.utilities.pydantic_schema_utils import resolve_refs
resolved = resolve_refs(deepcopy(RECURSIVE_NODE_SCHEMA))
items = resolved["properties"]["children"]["items"]
assert items != {}, "Circular ref should not degrade to {}"
assert items.get("type") == "object"
def test_non_recursive_schema_still_resolves(self) -> None:
from crewai.utilities.pydantic_schema_utils import resolve_refs
schema = {
"$defs": {"Foo": {"type": "object", "properties": {"x": {"type": "integer"}}}},
"$ref": "#/$defs/Foo",
}
resolved = resolve_refs(schema)
assert resolved["properties"]["x"]["type"] == "integer"
class TestSanitizeRecursiveSchemas:
def test_anthropic_strict_preserves_recursive_type(self) -> None:
from crewai.utilities.pydantic_schema_utils import sanitize_tool_params_for_anthropic_strict
san = sanitize_tool_params_for_anthropic_strict(deepcopy(RECURSIVE_NODE_SCHEMA))
items = san["properties"]["children"]["items"]
assert items != {}
assert items.get("type") == "object"
def test_openai_strict_preserves_recursive_type(self) -> None:
from crewai.utilities.pydantic_schema_utils import sanitize_tool_params_for_openai_strict
san = sanitize_tool_params_for_openai_strict(deepcopy(RECURSIVE_NODE_SCHEMA))
items = san["properties"]["children"]["items"]
assert items != {}
assert items.get("type") == "object"
class TestCreateModelFromSchemaRecursive:
def test_model_creation_succeeds(self) -> None:
model = create_model_from_schema(deepcopy(RECURSIVE_NODE_SCHEMA), model_name="Node")
assert model is not None
assert model.__name__ == "Node"
def test_model_accepts_valid_recursive_data(self) -> None:
model = create_model_from_schema(deepcopy(RECURSIVE_NODE_SCHEMA), model_name="Node")
instance = model(name="root", children=[{"name": "child", "children": []}])
assert instance.name == "root"
assert len(instance.children) == 1
def test_model_rejects_missing_required_field(self) -> None:
import pytest
model = create_model_from_schema(deepcopy(RECURSIVE_NODE_SCHEMA), model_name="Node")
with pytest.raises(Exception):
model(children=[])
def test_mutual_recursion_schema(self) -> None:
model = create_model_from_schema(deepcopy(MUTUAL_RECURSION_SCHEMA), model_name="A")
instance = model(val="hello", b={"val": 42})
assert instance.val == "hello"

View File

@@ -13,7 +13,7 @@ dependencies = [
"click~=8.1.7",
"tomlkit~=0.13.2",
"openai>=1.83.0,<3",
"python-dotenv~=1.1.1",
"python-dotenv>=1.2.2,<2",
"pygithub~=1.59.1",
"rich>=13.9.4",
]

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.2a3"
__version__ = "1.14.3"

View File

@@ -154,6 +154,117 @@ def check_git_clean() -> None:
sys.exit(1)
def _branch_exists_local(branch: str, cwd: Path | None = None) -> bool:
try:
subprocess.run( # noqa: S603
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], # noqa: S607
cwd=cwd,
check=True,
capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def _branch_exists_remote(branch: str, cwd: Path | None = None) -> bool:
try:
output = run_command(["git", "ls-remote", "--heads", "origin", branch], cwd=cwd)
return bool(output.strip())
except subprocess.CalledProcessError:
return False
def _open_pr_url_for_branch(branch: str, cwd: Path | None = None) -> str | None:
"""Return URL of open PR for branch, or None if no open PR exists."""
try:
url = run_command(
[
"gh",
"pr",
"list",
"--head",
branch,
"--state",
"open",
"--json",
"url",
"--jq",
".[0].url // empty",
],
cwd=cwd,
)
return url or None
except subprocess.CalledProcessError:
return None
def create_or_reset_branch(branch: str, cwd: Path | None = None) -> None:
"""Create ``branch`` from current HEAD, resetting any stale copy.
If the branch exists locally or on origin, prompts the user to
choose between resetting it or aborting. If an open PR exists on
the branch, the prompt surfaces the PR URL and includes a
close-and-reset option so in-flight work isn't silently clobbered.
Raises:
SystemExit: If the user declines to reset.
"""
local_exists = _branch_exists_local(branch, cwd=cwd)
remote_exists = _branch_exists_remote(branch, cwd=cwd)
open_pr = _open_pr_url_for_branch(branch, cwd=cwd) if remote_exists else None
if local_exists or remote_exists:
if open_pr:
console.print(
f"\n[yellow]![/yellow] Branch [bold]{branch}[/bold] already has an open PR: {open_pr}"
)
prompt = "Close the PR, reset the branch, and continue?"
else:
where = []
if local_exists:
where.append("local")
if remote_exists:
where.append("remote")
console.print(
f"\n[yellow]![/yellow] Branch [bold]{branch}[/bold] already exists ({', '.join(where)}) with no open PR"
)
prompt = "Delete it and recreate?"
if not Confirm.ask(prompt, default=False):
console.print("[red]Aborted.[/red]")
sys.exit(1)
if open_pr:
console.print(f"Closing PR {open_pr}...")
run_command(
["gh", "pr", "close", branch, "--delete-branch"],
cwd=cwd,
)
# `gh pr close --delete-branch` removes the remote branch
# and, when checked out, the local branch too.
local_exists = _branch_exists_local(branch, cwd=cwd)
remote_exists = False
if local_exists:
current = run_command(
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd
).strip()
if current == branch:
console.print(
f"[yellow]![/yellow] Currently on {branch}, switching to main before delete"
)
run_command(["git", "checkout", "main"], cwd=cwd)
console.print(f"[yellow]![/yellow] Deleting local branch {branch}")
run_command(["git", "branch", "-D", branch], cwd=cwd)
if remote_exists:
console.print(f"[yellow]![/yellow] Deleting remote branch {branch}")
run_command(["git", "push", "origin", "--delete", branch], cwd=cwd)
run_command(["git", "checkout", "-b", branch], cwd=cwd)
def update_version_in_file(file_path: Path, new_version: str) -> bool:
"""Update __version__ attribute in a Python file.
@@ -980,7 +1091,7 @@ def _update_docs_and_create_pr(
if docs_files_staged:
docs_branch = f"docs/changelog-v{version}"
run_command(["git", "checkout", "-b", docs_branch])
create_or_reset_branch(docs_branch)
for f in docs_files_staged:
run_command(["git", "add", f])
run_command(
@@ -1418,7 +1529,7 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
console.print("[green]✓[/green] Workspace synced")
branch_name = f"feat/bump-version-{version}"
run_command(["git", "checkout", "-b", branch_name], cwd=repo_dir)
create_or_reset_branch(branch_name, cwd=repo_dir)
run_command(["git", "add", "."], cwd=repo_dir)
run_command(
["git", "commit", "-m", f"feat: bump versions to {version}"],
@@ -1616,18 +1727,20 @@ def bump(version: str, dry_run: bool, no_push: bool, no_commit: bool) -> None:
for pkg in packages:
console.print(f" - {pkg.name}")
console.print(f"\nUpdating version to {version}...")
_update_all_versions(cwd, lib_dir, version, packages, dry_run)
if no_commit:
console.print(f"\nUpdating version to {version}...")
_update_all_versions(cwd, lib_dir, version, packages, dry_run)
console.print("\nSkipping git operations (--no-commit flag set)")
else:
branch_name = f"feat/bump-version-{version}"
if not dry_run:
console.print(f"\nCreating branch {branch_name}...")
run_command(["git", "checkout", "-b", branch_name])
create_or_reset_branch(branch_name)
console.print("[green]✓[/green] Branch created")
console.print(f"\nUpdating version to {version}...")
_update_all_versions(cwd, lib_dir, version, packages, dry_run)
console.print("\nCommitting changes...")
run_command(["git", "add", "."])
run_command(
@@ -1643,6 +1756,8 @@ def bump(version: str, dry_run: bool, no_push: bool, no_commit: bool) -> None:
console.print(
f"[dim][DRY RUN][/dim] Would create branch: {branch_name}"
)
console.print(f"\nUpdating version to {version}...")
_update_all_versions(cwd, lib_dir, version, packages, dry_run)
console.print(
f"[dim][DRY RUN][/dim] Would commit: feat: bump versions to {version}"
)
@@ -1906,14 +2021,14 @@ def release(
console.print(f"\n[bold cyan]Phase 1: Bumping versions to {version}[/bold cyan]")
try:
_update_all_versions(cwd, lib_dir, version, packages, dry_run)
branch_name = f"feat/bump-version-{version}"
if not dry_run:
console.print(f"\nCreating branch {branch_name}...")
run_command(["git", "checkout", "-b", branch_name])
create_or_reset_branch(branch_name)
console.print("[green]✓[/green] Branch created")
_update_all_versions(cwd, lib_dir, version, packages, dry_run)
console.print("\nCommitting changes...")
run_command(["git", "add", "."])
run_command(["git", "commit", "-m", f"feat: bump versions to {version}"])
@@ -1943,6 +2058,7 @@ def release(
_poll_pr_until_merged(branch_name, "bump PR")
else:
console.print(f"[dim][DRY RUN][/dim] Would create branch: {branch_name}")
_update_all_versions(cwd, lib_dir, version, packages, dry_run)
console.print(
f"[dim][DRY RUN][/dim] Would commit: feat: bump versions to {version}"
)