mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-06 15:39:24 +00:00
Merge branch 'main' into refactor/extract-crewai-a2a-package-v2
# Conflicts: # uv.lock
This commit is contained in:
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.2a3"
|
||||
__version__ = "1.14.3"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`).
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
250
lib/crewai/src/crewai/cli/remote_template/main.py
Normal file
250
lib/crewai/src/crewai/cli/remote_template/main.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
97
lib/crewai/src/crewai/events/types/checkpoint_events.py
Normal file
97
lib/crewai/src/crewai/events/types/checkpoint_events.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
0
lib/crewai/tests/cli/remote_template/__init__.py
Normal file
0
lib/crewai/tests/cli/remote_template/__init__.py
Normal file
283
lib/crewai/tests/cli/remote_template/test_main.py
Normal file
283
lib/crewai/tests/cli/remote_template/test_main.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
61
lib/crewai/tests/cli/test_replay_from_task.py
Normal file
61
lib/crewai/tests/cli/test_replay_from_task.py
Normal 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"]
|
||||
59
lib/crewai/tests/cli/test_run_crew.py
Normal file
59
lib/crewai/tests/cli/test_run_crew.py
Normal 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"]
|
||||
@@ -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)
|
||||
|
||||
|
||||
165
lib/crewai/tests/events/test_event_replay.py
Normal file
165
lib/crewai/tests/events/test_event_replay.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
402
lib/crewai/tests/test_checkpoint_cli.py
Normal file
402
lib/crewai/tests/test_checkpoint_cli.py
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
127
lib/crewai/tests/test_flow_execution_id.py
Normal file
127
lib/crewai/tests/test_flow_execution_id.py
Normal 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
|
||||
130
lib/crewai/tests/test_guardrail_serialization.py
Normal file
130
lib/crewai/tests/test_guardrail_serialization.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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!'"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.2a3"
|
||||
__version__ = "1.14.3"
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user