Merge branch 'main' into gl/refactor/a2a-tool-based-delegation

This commit is contained in:
Greyson LaLonde
2026-05-13 08:22:50 +08:00
committed by GitHub
64 changed files with 4028 additions and 4660 deletions

View File

@@ -8,7 +8,7 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.5a3",
"crewai-core==1.14.5a5",
"click~=8.1.7",
"pydantic>=2.11.9,<2.13",
"pydantic-settings~=2.10.1",

View File

@@ -1 +1 @@
__version__ = "1.14.5a3"
__version__ = "1.14.5a5"

View File

@@ -1 +1 @@
__version__ = "1.14.5a3"
__version__ = "1.14.5a5"

View File

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

View File

@@ -10,7 +10,7 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"pytube~=15.0.0",
"requests>=2.33.0,<3",
"crewai==1.14.5a3",
"crewai==1.14.5a5",
"tiktoken>=0.8.0,<0.13",
"beautifulsoup4~=4.13.4",
"python-docx~=1.2.0",
@@ -107,7 +107,7 @@ stagehand = [
"stagehand>=0.4.1",
]
github = [
"gitpython>=3.1.47,<4",
"gitpython>=3.1.50,<4",
"PyGithub==1.59.1",
]
rag = [

View File

@@ -330,4 +330,4 @@ __all__ = [
"ZapierActionTools",
]
__version__ = "1.14.5a3"
__version__ = "1.14.5a5"

View File

@@ -55,10 +55,11 @@ 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=...`.
# Agent writes a script, then runs it — but each tool keeps its OWN persistent
# sandbox. To share the *same* sandbox across two tools, create and use the
# first tool, then read its `active_sandbox_id` and pass it to the second:
# exec_tool.run(command="pip install httpx")
# file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id)
```
### Attach to an existing sandbox
@@ -99,9 +100,14 @@ tool = DaytonaExecTool(
- `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`.
- `action`: one of `read`, `write`, `append`, `list`, `delete`, `mkdir`, `info`, `exists`, `move`, `find`, `search`, `chmod`, `replace`.
- `path: str | None` — absolute path inside the sandbox. Required for all actions except `replace`.
- `content: str | None` — required for `append`; optional 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"`).
- `mode: str | None` — for `mkdir` (defaults to `"0755"`) or for `chmod` (e.g. `"755"`).
- `destination: str | None` — required for `move`.
- `pattern: str | None` — required for `find` (content grep), `search` (filename glob), and `replace`.
- `replacement: str | None` — required for `replace`.
- `paths: list[str] | None` — required for `replace`; list of files to operate on.
- `owner: str | None` / `group: str | None` — for `chmod`. Pass at least one of `mode`, `owner`, or `group`.

View File

@@ -196,3 +196,27 @@ class DaytonaBaseTool(BaseTool):
"the sandbox may need manual deletion.",
exc_info=True,
)
@property
def active_sandbox_id(self) -> str | None:
"""The id of the sandbox this tool is currently bound to, if any.
Returns:
- the explicitly attached `sandbox_id`, if set at construction;
- the id of the lazily-created persistent sandbox, once a call has
triggered creation;
- None for ephemeral mode (where no sandbox lives between calls).
Use this to share one sandbox across multiple tool instances:
exec_tool = DaytonaExecTool(persistent=True)
exec_tool.run(command="pip install httpx")
file_tool = DaytonaFileTool(sandbox_id=exec_tool.active_sandbox_id)
"""
if self.sandbox_id:
return self.sandbox_id
with self._lock:
sandbox = self._persistent_sandbox
if sandbox is None:
return None
return getattr(sandbox, "id", None)

View File

@@ -4,7 +4,9 @@ import base64
from builtins import type as type_
import logging
import posixpath
import shlex
from typing import Any, Literal
import uuid
from pydantic import BaseModel, Field, model_validator
@@ -14,22 +16,110 @@ from crewai_tools.tools.daytona_sandbox_tool.daytona_base_tool import DaytonaBas
logger = logging.getLogger(__name__)
FileAction = Literal["read", "write", "append", "list", "delete", "mkdir", "info"]
FileAction = Literal[
"read",
"write",
"append",
"list",
"delete",
"mkdir",
"info",
"exists",
"move",
"find",
"search",
"chmod",
"replace",
]
def _daytona_file_schema_extra(schema: dict[str, Any]) -> None:
schema["allOf"] = [
{
"if": {
"properties": {
"action": {
"enum": [
"read",
"write",
"append",
"list",
"delete",
"mkdir",
"info",
"exists",
"move",
"find",
"search",
"chmod",
]
}
}
},
"then": {"required": ["path"]},
},
{
"if": {"properties": {"action": {"const": "append"}}},
"then": {"required": ["content"]},
},
{
"if": {"properties": {"action": {"const": "move"}}},
"then": {"required": ["destination"]},
},
{
"if": {"properties": {"action": {"enum": ["find", "search"]}}},
"then": {"required": ["pattern"]},
},
{
"if": {"properties": {"action": {"const": "replace"}}},
"then": {"required": ["paths", "pattern", "replacement"]},
},
{
"if": {"properties": {"action": {"const": "chmod"}}},
"then": {
"anyOf": [
{"required": ["mode"]},
{"required": ["owner"]},
{"required": ["group"]},
]
},
},
]
class DaytonaFileToolSchema(BaseModel):
model_config = {"json_schema_extra": _daytona_file_schema_extra}
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)."
"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 whether a path exists); "
"'move' (rename or relocate a file/dir; requires 'destination'); "
"'find' (grep file CONTENTS recursively; requires 'pattern'); "
"'search' (find files by NAME pattern; requires 'pattern'); "
"'chmod' (change permissions/owner/group; pass at least one of "
"'mode', 'owner', 'group'); "
"'replace' (find-and-replace text across files; requires "
"'paths', 'pattern', and 'replacement')."
),
)
path: str | None = Field(
default=None,
description=(
"Absolute path inside the sandbox. Required for all actions "
"except 'replace' (which uses 'paths' instead)."
),
)
path: str = Field(..., description="Absolute path inside the sandbox.")
content: str | None = Field(
default=None,
description=(
@@ -50,18 +140,78 @@ class DaytonaFileToolSchema(BaseModel):
default=False,
description="For action='delete': remove directories recursively.",
)
mode: str = Field(
default="0755",
description="For action='mkdir': octal permission string (default 0755).",
mode: str | None = Field(
default=None,
description=(
"Octal permission string. For 'mkdir' it sets the new directory "
"permissions (defaults to '0755' if omitted). For 'chmod' it sets "
"the target's mode (e.g. '755' to make a script executable). "
"Ignored for other actions."
),
)
destination: str | None = Field(
default=None,
description="For action='move': absolute destination path.",
)
pattern: str | None = Field(
default=None,
description=(
"For 'find': substring matched against file CONTENTS. "
"For 'search': glob-style pattern matched against file NAMES "
"(e.g. '*.py'). "
"For 'replace': text to replace inside files."
),
)
replacement: str | None = Field(
default=None,
description="For action='replace': replacement text for 'pattern'.",
)
paths: list[str] | None = Field(
default=None,
description=(
"For action='replace': list of absolute file paths in which to "
"replace 'pattern' with 'replacement'."
),
)
owner: str | None = Field(
default=None,
description="For action='chmod': new file owner (user name).",
)
group: str | None = Field(
default=None,
description="For action='chmod': new file group.",
)
@model_validator(mode="after")
def _validate_action_args(self) -> DaytonaFileToolSchema:
if self.action != "replace" and not self.path:
raise ValueError(f"action={self.action!r} requires 'path'.")
if self.action == "append" and self.content is None:
raise ValueError(
"action='append' requires 'content'. Pass the chunk to append "
"in the 'content' field."
)
if self.action == "move" and not self.destination:
raise ValueError("action='move' requires 'destination'.")
if self.action == "find" and not self.pattern:
raise ValueError(
"action='find' requires 'pattern' (text to search for inside files)."
)
if self.action == "search" and not self.pattern:
raise ValueError("action='search' requires 'pattern' (glob, e.g. '*.py').")
if self.action == "chmod" and not (self.mode or self.owner or self.group):
raise ValueError(
"action='chmod' requires at least one of 'mode', 'owner', or 'group'."
)
if self.action == "replace":
if not self.paths:
raise ValueError(
"action='replace' requires 'paths' (list of file paths)."
)
if not self.pattern:
raise ValueError("action='replace' requires 'pattern'.")
if self.replacement is None:
raise ValueError("action='replace' requires 'replacement'.")
return self
@@ -75,9 +225,10 @@ class DaytonaFileTool(DaytonaBaseTool):
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. "
"Perform filesystem operations inside a Daytona sandbox: read, "
"write, append, list, delete, mkdir, info, exists, move, find "
"(content grep), search (filename glob), chmod (permissions/owner/"
"group), and replace (bulk find-and-replace across files). "
"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."
@@ -87,30 +238,79 @@ class DaytonaFileTool(DaytonaBaseTool):
def _run(
self,
action: FileAction,
path: str,
path: str | None = None,
content: str | None = None,
binary: bool = False,
recursive: bool = False,
mode: str = "0755",
mode: str | None = None,
destination: str | None = None,
pattern: str | None = None,
replacement: str | None = None,
paths: list[str] | None = None,
owner: str | None = None,
group: str | None = None,
) -> Any:
sandbox, should_delete = self._acquire_sandbox()
try:
if action == "read":
if path is None:
raise ValueError("action='read' requires 'path'")
return self._read(sandbox, path, binary=binary)
if action == "write":
if path is None:
raise ValueError("action='write' requires 'path'")
return self._write(sandbox, path, content or "", binary=binary)
if action == "append":
if path is None:
raise ValueError("action='append' requires 'path'")
return self._append(sandbox, path, content or "", binary=binary)
if action == "list":
if path is None:
raise ValueError("action='list' requires 'path'")
return self._list(sandbox, path)
if action == "delete":
if path is None:
raise ValueError("action='delete' requires 'path'")
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 path is None:
raise ValueError("action='mkdir' requires 'path'")
mkdir_mode = mode or "0755"
sandbox.fs.create_folder(path, mkdir_mode)
return {"status": "created", "path": path, "mode": mkdir_mode}
if action == "info":
if path is None:
raise ValueError("action='info' requires 'path'")
return self._info(sandbox, path)
if action == "exists":
if path is None:
raise ValueError("action='exists' requires 'path'")
return self._exists(sandbox, path)
if action == "move":
if path is None or destination is None:
raise ValueError("action='move' requires 'path' and 'destination'")
sandbox.fs.move_files(path, destination)
return {"status": "moved", "from": path, "to": destination}
if action == "find":
if path is None or pattern is None:
raise ValueError("action='find' requires 'path' and 'pattern'")
return self._find(sandbox, path, pattern)
if action == "search":
if path is None or pattern is None:
raise ValueError("action='search' requires 'path' and 'pattern'")
return self._search(sandbox, path, pattern)
if action == "chmod":
if path is None:
raise ValueError("action='chmod' requires 'path'")
return self._chmod(sandbox, path, mode=mode, owner=owner, group=group)
if action == "replace":
if paths is None or pattern is None or replacement is None:
raise ValueError(
"action='replace' requires 'paths', 'pattern', and "
"'replacement'"
)
return self._replace(sandbox, paths, pattern, replacement)
raise ValueError(f"Unknown action: {action}")
finally:
self._release_sandbox(sandbox, should_delete)
@@ -146,17 +346,46 @@ class DaytonaFileTool(DaytonaBaseTool):
) -> dict[str, Any]:
chunk = base64.b64decode(content) if binary else content.encode("utf-8")
self._ensure_parent_dir(sandbox, path)
# Server-side `cat >>` keeps this O(chunk_size) per call. The naive
# download-concat-reupload alternative is O(N^2) in total transfer.
# /tmp/ is on the sandbox's ephemeral filesystem, not the host.
temp_path = f"/tmp/.crewai-append-{uuid.uuid4().hex}" # noqa: S108
sandbox.fs.upload_file(chunk, temp_path)
quoted_temp = shlex.quote(temp_path)
quoted_target = shlex.quote(path)
response = sandbox.process.exec(
f"cat {quoted_temp} >> {quoted_target}; "
f"rc=$?; rm -f {quoted_temp}; exit $rc"
)
exit_code = getattr(response, "exit_code", 0)
if exit_code != 0:
try:
sandbox.fs.delete_file(temp_path)
except Exception:
logger.debug(
"Best-effort temp-file cleanup failed after append "
"error; the file may need manual deletion.",
exc_info=True,
)
raise RuntimeError(
f"append failed: exit_code={exit_code}, "
f"output={getattr(response, 'result', '')!r}"
)
try:
existing: bytes = sandbox.fs.download_file(path)
info = sandbox.fs.get_file_info(path)
total_bytes = getattr(info, "size", None)
except Exception:
existing = b""
payload = existing + chunk
sandbox.fs.upload_file(payload, path)
total_bytes = None
return {
"status": "appended",
"path": path,
"appended_bytes": len(chunk),
"total_bytes": len(payload),
"total_bytes": total_bytes,
}
@staticmethod
@@ -190,6 +419,77 @@ class DaytonaFileTool(DaytonaBaseTool):
def _info(self, sandbox: Any, path: str) -> dict[str, Any]:
return self._file_info_to_dict(sandbox.fs.get_file_info(path))
def _exists(self, sandbox: Any, path: str) -> dict[str, Any]:
try:
info = sandbox.fs.get_file_info(path)
except Exception:
return {"path": path, "exists": False}
return {
"path": path,
"exists": True,
"is_dir": getattr(info, "is_dir", False),
}
def _find(self, sandbox: Any, path: str, pattern: str) -> dict[str, Any]:
matches = sandbox.fs.find_files(path, pattern)
return {
"path": path,
"pattern": pattern,
"matches": [
{
"file": getattr(m, "file", None),
"line": getattr(m, "line", None),
"content": getattr(m, "content", None),
}
for m in matches
],
}
def _search(self, sandbox: Any, path: str, pattern: str) -> dict[str, Any]:
response = sandbox.fs.search_files(path, pattern)
files = getattr(response, "files", None) or []
return {"path": path, "pattern": pattern, "files": list(files)}
def _chmod(
self,
sandbox: Any,
path: str,
*,
mode: str | None,
owner: str | None,
group: str | None,
) -> dict[str, Any]:
kwargs: dict[str, str] = {}
if mode is not None:
kwargs["mode"] = mode
if owner is not None:
kwargs["owner"] = owner
if group is not None:
kwargs["group"] = group
sandbox.fs.set_file_permissions(path, **kwargs)
return {"status": "permissions_set", "path": path, **kwargs}
def _replace(
self,
sandbox: Any,
paths: list[str],
pattern: str,
replacement: str,
) -> dict[str, Any]:
results = sandbox.fs.replace_in_files(paths, pattern, replacement)
return {
"pattern": pattern,
"replacement": replacement,
"results": [
{
"file": getattr(r, "file", None),
"success": getattr(r, "success", None),
"error": getattr(r, "error", None),
}
for r in (results or [])
],
}
@staticmethod
def _file_info_to_dict(info: Any) -> dict[str, Any]:
fields = (

View File

@@ -7184,7 +7184,7 @@
}
},
{
"description": "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.",
"description": "Perform filesystem operations inside a Daytona sandbox: read, write, append, list, delete, mkdir, info, exists, move, find (content grep), search (filename glob), chmod (permissions/owner/group), and replace (bulk find-and-replace across files). 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.",
"env_vars": [
{
"default": null,
@@ -7334,9 +7334,127 @@
"daytona"
],
"run_params_schema": {
"allOf": [
{
"if": {
"properties": {
"action": {
"enum": [
"read",
"write",
"append",
"list",
"delete",
"mkdir",
"info",
"exists",
"move",
"find",
"search",
"chmod"
]
}
}
},
"then": {
"required": [
"path"
]
}
},
{
"if": {
"properties": {
"action": {
"const": "append"
}
}
},
"then": {
"required": [
"content"
]
}
},
{
"if": {
"properties": {
"action": {
"const": "move"
}
}
},
"then": {
"required": [
"destination"
]
}
},
{
"if": {
"properties": {
"action": {
"enum": [
"find",
"search"
]
}
}
},
"then": {
"required": [
"pattern"
]
}
},
{
"if": {
"properties": {
"action": {
"const": "replace"
}
}
},
"then": {
"required": [
"paths",
"pattern",
"replacement"
]
}
},
{
"if": {
"properties": {
"action": {
"const": "chmod"
}
}
},
"then": {
"anyOf": [
{
"required": [
"mode"
]
},
{
"required": [
"owner"
]
},
{
"required": [
"group"
]
}
]
}
}
],
"properties": {
"action": {
"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 \u2014 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).",
"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 \u2014 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 whether a path exists); 'move' (rename or relocate a file/dir; requires 'destination'); 'find' (grep file CONTENTS recursively; requires 'pattern'); 'search' (find files by NAME pattern; requires 'pattern'); 'chmod' (change permissions/owner/group; pass at least one of 'mode', 'owner', 'group'); 'replace' (find-and-replace text across files; requires 'paths', 'pattern', and 'replacement').",
"enum": [
"read",
"write",
@@ -7344,7 +7462,13 @@
"list",
"delete",
"mkdir",
"info"
"info",
"exists",
"move",
"find",
"search",
"chmod",
"replace"
],
"title": "Action",
"type": "string"
@@ -7368,27 +7492,122 @@
"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.",
"title": "Content"
},
"destination": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "For action='move': absolute destination path.",
"title": "Destination"
},
"group": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "For action='chmod': new file group.",
"title": "Group"
},
"mode": {
"default": "0755",
"description": "For action='mkdir': octal permission string (default 0755).",
"title": "Mode",
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Octal permission string. For 'mkdir' it sets the new directory permissions (defaults to '0755' if omitted). For 'chmod' it sets the target's mode (e.g. '755' to make a script executable). Ignored for other actions.",
"title": "Mode"
},
"owner": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "For action='chmod': new file owner (user name).",
"title": "Owner"
},
"path": {
"description": "Absolute path inside the sandbox.",
"title": "Path",
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Absolute path inside the sandbox. Required for all actions except 'replace' (which uses 'paths' instead).",
"title": "Path"
},
"paths": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"default": null,
"description": "For action='replace': list of absolute file paths in which to replace 'pattern' with 'replacement'.",
"title": "Paths"
},
"pattern": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "For 'find': substring matched against file CONTENTS. For 'search': glob-style pattern matched against file NAMES (e.g. '*.py'). For 'replace': text to replace inside files.",
"title": "Pattern"
},
"recursive": {
"default": false,
"description": "For action='delete': remove directories recursively.",
"title": "Recursive",
"type": "boolean"
},
"replacement": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "For action='replace': replacement text for 'pattern'.",
"title": "Replacement"
}
},
"required": [
"action",
"path"
"action"
],
"title": "DaytonaFileToolSchema",
"type": "object"

View File

@@ -8,8 +8,8 @@ authors = [
]
requires-python = ">=3.10, <3.14"
dependencies = [
"crewai-core==1.14.5a3",
"crewai-cli==1.14.5a3",
"crewai-core==1.14.5a5",
"crewai-cli==1.14.5a5",
# Core Dependencies
"pydantic>=2.11.9,<2.13",
"openai>=2.30.0,<3",
@@ -54,7 +54,7 @@ Repository = "https://github.com/crewAIInc/crewAI"
[project.optional-dependencies]
tools = [
"crewai-tools==1.14.5a3",
"crewai-tools==1.14.5a5",
]
embeddings = [
"tiktoken>=0.8.0,<0.13"
@@ -105,7 +105,7 @@ a2a = [
"aiocache[redis,memcached]~=0.12.3",
]
file-processing = [
"crewai-files==1.14.5a3",
"crewai-files==1.14.5a5",
]
qdrant-edge = [
"qdrant-edge-py>=0.6.0",

View File

@@ -48,7 +48,7 @@ def _suppress_pydantic_deprecation_warnings() -> None:
_suppress_pydantic_deprecation_warnings()
__version__ = "1.14.5a3"
__version__ = "1.14.5a5"
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
"Memory": ("crewai.memory.unified_memory", "Memory"),

View File

@@ -7,6 +7,7 @@ from collections.abc import Callable, Coroutine, Sequence
import concurrent.futures
import contextvars
from datetime import datetime
import inspect
import json
import os
from pathlib import Path
@@ -35,13 +36,11 @@ from typing_extensions import Self, TypeIs
from crewai.agent.planning_config import PlanningConfig
from crewai.agent.utils import (
ahandle_knowledge_retrieval,
append_skill_context,
apply_training_data,
build_task_prompt_with_schema,
format_task_with_context,
get_knowledge_config,
handle_knowledge_retrieval,
handle_reasoning,
prepare_tools,
process_tool_results,
save_last_messages,
@@ -144,7 +143,17 @@ def _validate_executor_class(value: Any) -> Any:
cls = _EXECUTOR_CLASS_MAP.get(value)
if cls is None:
raise ValueError(f"Unknown executor class: {value}")
return cls
value = cls
import warnings
if value is CrewAgentExecutor:
warnings.warn(
"CrewAgentExecutor is deprecated and will be removed in a future release. "
"Agents inside Crews now use AgentExecutor by default. "
"Switch to crewai.experimental.AgentExecutor.",
DeprecationWarning,
stacklevel=3,
)
return value
@@ -319,8 +328,8 @@ class Agent(BaseAgent):
BeforeValidator(_validate_executor_class),
PlainSerializer(_serialize_executor_class, return_type=str, when_used="json"),
] = Field(
default=CrewAgentExecutor,
description="Class to use for the agent executor. Defaults to CrewAgentExecutor, can optionally use AgentExecutor.",
default=AgentExecutor,
description="Class to use for the agent executor. Defaults to AgentExecutor, can optionally use CrewAgentExecutor.",
)
@model_validator(mode="before")
@@ -506,8 +515,6 @@ class Agent(BaseAgent):
The task prompt after memory retrieval, ready for knowledge lookup.
"""
get_env_context()
if self.executor_class is not AgentExecutor:
handle_reasoning(self, task)
self._inject_date_to_task(task)
@@ -535,7 +542,6 @@ class Agent(BaseAgent):
Returns:
The fully prepared task prompt.
"""
task_prompt = append_skill_context(self, task_prompt)
prepare_tools(self, tools, task)
return apply_training_data(self, task_prompt)
@@ -829,18 +835,22 @@ class Agent(BaseAgent):
if not self.agent_executor:
raise RuntimeError("Agent executor is not initialized.")
result = cast(
dict[str, Any],
self.agent_executor.invoke(
{
"input": task_prompt,
"tool_names": self.agent_executor.tools_names,
"tools": self.agent_executor.tools_description,
"ask_for_human_input": task.human_input,
}
),
invoke_result = self.agent_executor.invoke(
{
"input": task_prompt,
"tool_names": self.agent_executor.tools_names,
"tools": self.agent_executor.tools_description,
"ask_for_human_input": task.human_input,
}
)
return result["output"]
if inspect.isawaitable(invoke_result):
invoke_result.close()
raise RuntimeError(
"Agent execution was invoked synchronously from within a running "
"event loop. Use `agent.kickoff_async()` / `crew.kickoff_async()` "
"(or `await agent.aexecute_task(...)`) when calling from async code."
)
return invoke_result["output"]
async def aexecute_task(
self,
@@ -1460,8 +1470,6 @@ class Agent(BaseAgent):
),
)
formatted_messages = append_skill_context(self, formatted_messages)
inputs: dict[str, Any] = {
"input": formatted_messages,
"tool_names": get_tool_names(parsed_tools),

View File

@@ -213,30 +213,6 @@ def _combine_knowledge_context(agent: Agent) -> str:
return agent_ctx + separator + crew_ctx
def append_skill_context(agent: Agent, task_prompt: str) -> str:
"""Append activated skill context sections to the task prompt.
Args:
agent: The agent with optional skills.
task_prompt: The current task prompt.
Returns:
The task prompt with skill context appended.
"""
if not agent.skills:
return task_prompt
from crewai.skills.loader import format_skill_context
from crewai.skills.models import Skill
skill_sections = [
format_skill_context(s) for s in agent.skills if isinstance(s, Skill)
]
if skill_sections:
task_prompt += "\n\n" + "\n\n".join(skill_sections)
return task_prompt
def apply_training_data(agent: Agent, task_prompt: str) -> str:
"""Apply training data to the task prompt.

View File

@@ -1,13 +1,28 @@
from typing import TYPE_CHECKING, Any
from crewai.agents.cache.cache_handler import CacheHandler
from crewai.agents.parser import AgentAction, AgentFinish, OutputParserError, parse
from crewai.agents.tools_handler import ToolsHandler
if TYPE_CHECKING:
from crewai.agents.crew_agent_executor import CrewAgentExecutor
__all__ = [
"AgentAction",
"AgentFinish",
"CacheHandler",
"CrewAgentExecutor",
"OutputParserError",
"ToolsHandler",
"parse",
]
def __getattr__(name: str) -> Any:
if name == "CrewAgentExecutor":
from crewai.agents.crew_agent_executor import CrewAgentExecutor
return CrewAgentExecutor
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -14,6 +14,7 @@ import contextvars
import inspect
import logging
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
import warnings
from crewai_core.printer import PRINTER
from pydantic import (
@@ -138,6 +139,13 @@ class CrewAgentExecutor(BaseAgentExecutor):
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
warnings.warn(
"CrewAgentExecutor is deprecated and will be removed in a future release.\n"
"Agents inside Crews now use AgentExecutor (crewai.experimental.AgentExecutor) by default.\n"
"To suppress this warning, migrate to AgentExecutor.",
DeprecationWarning,
stacklevel=2,
)
if not self.before_llm_call_hooks:
self.before_llm_call_hooks.extend(get_before_llm_call_hooks())
if not self.after_llm_call_hooks:
@@ -166,6 +174,8 @@ class CrewAgentExecutor(BaseAgentExecutor):
if provider.setup_messages(cast(ExecutorContext, cast(object, self))):
return
from crewai.llms.cache import mark_cache_breakpoint
if self.prompt is not None and "system" in self.prompt:
system_prompt = self._format_prompt(
cast(str, self.prompt.get("system", "")), inputs
@@ -173,11 +183,22 @@ class CrewAgentExecutor(BaseAgentExecutor):
user_prompt = self._format_prompt(
cast(str, self.prompt.get("user", "")), inputs
)
self.messages.append(format_message_for_llm(system_prompt, role="system"))
self.messages.append(format_message_for_llm(user_prompt))
# Cache breakpoints: end-of-system caches the per-agent stable
# prefix; end-of-user caches the per-task stable prefix across
# ReAct-loop iterations.
self.messages.append(
mark_cache_breakpoint(
format_message_for_llm(system_prompt, role="system")
)
)
self.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
elif self.prompt is not None:
user_prompt = self._format_prompt(self.prompt.get("prompt", ""), inputs)
self.messages.append(format_message_for_llm(user_prompt))
self.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
provider.post_setup_messages(cast(ExecutorContext, cast(object, self)))

View File

@@ -1191,6 +1191,13 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
@router("force_final_answer")
def ensure_force_final_answer(self) -> Literal["agent_finished"]:
"""Force agent to provide final answer when max iterations exceeded."""
# The flow framework can route here more than once per execution when the
# "initialized" label is emitted by both initialize_reasoning and
# increment_and_continue in the same listener pass. Skip the extra LLM
# round-trip once we've already produced a forced final answer.
if self.state.is_finished:
return "agent_finished"
formatted_answer = handle_max_iterations_exceeded(
formatted_answer=None,
printer=PRINTER,
@@ -2579,16 +2586,26 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
self._kickoff_input = inputs.get("input", "")
if "system" in self.prompt:
from crewai.llms.cache import mark_cache_breakpoint
prompt = cast("SystemPromptResult", self.prompt)
system_prompt = self._format_prompt(prompt["system"], inputs)
user_prompt = self._format_prompt(prompt["user"], inputs)
self.state.messages.append(
format_message_for_llm(system_prompt, role="system")
mark_cache_breakpoint(
format_message_for_llm(system_prompt, role="system")
)
)
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
self.state.messages.append(format_message_for_llm(user_prompt))
else:
from crewai.llms.cache import mark_cache_breakpoint
user_prompt = self._format_prompt(self.prompt["prompt"], inputs)
self.state.messages.append(format_message_for_llm(user_prompt))
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
self._inject_files_from_inputs(inputs)
@@ -2670,16 +2687,26 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
self._kickoff_input = inputs.get("input", "")
if "system" in self.prompt:
from crewai.llms.cache import mark_cache_breakpoint
prompt = cast("SystemPromptResult", self.prompt)
system_prompt = self._format_prompt(prompt["system"], inputs)
user_prompt = self._format_prompt(prompt["user"], inputs)
self.state.messages.append(
format_message_for_llm(system_prompt, role="system")
mark_cache_breakpoint(
format_message_for_llm(system_prompt, role="system")
)
)
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
self.state.messages.append(format_message_for_llm(user_prompt))
else:
from crewai.llms.cache import mark_cache_breakpoint
user_prompt = self._format_prompt(self.prompt["prompt"], inputs)
self.state.messages.append(format_message_for_llm(user_prompt))
self.state.messages.append(
mark_cache_breakpoint(format_message_for_llm(user_prompt))
)
self._inject_files_from_inputs(inputs)

View File

@@ -60,6 +60,7 @@ from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from functools import wraps
import logging
from typing import TYPE_CHECKING, Any, TypeVar
from pydantic import BaseModel, Field
@@ -73,6 +74,8 @@ if TYPE_CHECKING:
from crewai.llms.base_llm import BaseLLM
logger = logging.getLogger(__name__)
F = TypeVar("F", bound=Callable[..., Any])
@@ -188,6 +191,7 @@ class HumanFeedbackConfig:
provider: HumanFeedbackProvider | None = None
learn: bool = False
learn_source: str = "hitl"
learn_strict: bool = False
class HumanFeedbackMethod(FlowMethod[Any, Any]):
@@ -237,6 +241,7 @@ def human_feedback(
provider: HumanFeedbackProvider | None = None,
learn: bool = False,
learn_source: str = "hitl",
learn_strict: bool = False,
) -> Callable[[F], F]:
"""Decorator for Flow methods that require human feedback.
@@ -275,6 +280,14 @@ def human_feedback(
external systems like Slack, Teams, or webhooks. When the
provider raises HumanFeedbackPending, the flow pauses and
can be resumed later with Flow.resume().
learn: Enable HITL learning. Recall past lessons to pre-review
output before the human sees it, and distill new lessons
from feedback after.
learn_source: Memory source tag for stored/recalled lessons.
learn_strict: When True, re-raise exceptions from the pre-review
and distillation steps instead of falling back to raw output.
Default False preserves graceful degradation; failures are
always logged via ``logger.warning`` regardless of this flag.
Returns:
A decorator function that wraps the method with human feedback
@@ -404,7 +417,19 @@ def human_feedback(
reviewed = llm_inst.call(messages)
return reviewed if isinstance(reviewed, str) else str(reviewed)
except Exception:
return method_output # fallback to raw output on any failure
if learn_strict:
logger.warning(
"HITL pre-review failed for %s; re-raising (learn_strict=True)",
func.__name__,
exc_info=True,
)
raise
logger.warning(
"HITL pre-review failed for %s; falling back to raw output",
func.__name__,
exc_info=True,
)
return method_output
def _distill_and_store_lessons(
flow_instance: Flow[Any], method_output: Any, raw_feedback: str
@@ -446,8 +471,19 @@ def human_feedback(
if lessons:
mem.remember_many(lessons, source=learn_source) # type: ignore[union-attr]
except Exception: # noqa: S110
pass # non-critical: don't fail the flow because lesson storage failed
except Exception:
if learn_strict:
logger.warning(
"HITL lesson distillation failed for %s; re-raising (learn_strict=True)",
func.__name__,
exc_info=True,
)
raise
logger.warning(
"HITL lesson distillation failed for %s; no lessons stored",
func.__name__,
exc_info=True,
)
# -- Core feedback helpers ------------------------------------
@@ -654,6 +690,7 @@ def human_feedback(
provider=provider,
learn=learn,
learn_source=learn_source,
learn_strict=learn_strict,
)
wrapper.__is_flow_method__ = True

View File

@@ -14,7 +14,7 @@ from datetime import datetime
import json
import logging
import re
from typing import TYPE_CHECKING, Any, Final, Literal
from typing import TYPE_CHECKING, Any, Final, Literal, cast
import uuid
from pydantic import (
@@ -703,10 +703,19 @@ class BaseLLM(BaseModel, ABC):
Raises:
ValueError: If message format is invalid
"""
from crewai.llms.cache import CACHE_BREAKPOINT_KEY
from crewai.utilities.types import LLMMessage as _LLMMessage
if isinstance(messages, str):
return [{"role": "user", "content": messages}]
# Validate message format
# Validate then copy each message, dropping the cache-breakpoint
# flag in the copy only. The caller (e.g. CrewAgentExecutor,
# experimental.AgentExecutor) reuses its messages buffer across
# many LLM calls in the tool-use loop; mutating their dicts
# in place would erase the markers after the first call and
# break prompt caching for every subsequent iteration.
cleaned: list[LLMMessage] = []
for i, msg in enumerate(messages):
if not isinstance(msg, dict):
raise ValueError(f"Message at index {i} must be a dictionary")
@@ -714,8 +723,12 @@ class BaseLLM(BaseModel, ABC):
raise ValueError(
f"Message at index {i} must have 'role' and 'content' keys"
)
copy: dict[str, Any] = {
k: v for k, v in msg.items() if k != CACHE_BREAKPOINT_KEY
}
cleaned.append(cast(_LLMMessage, copy))
return self._process_message_files(messages)
return self._process_message_files(cleaned)
def _process_message_files(self, messages: list[LLMMessage]) -> list[LLMMessage]:
"""Process files attached to messages and format for the provider.

View File

@@ -0,0 +1,37 @@
"""Provider-agnostic prompt-cache breakpoint marker.
Application code (prompt builders, agent executors) marks messages where a
stable prefix ends. Provider adapters then translate the marker into the
cache directive their API expects, or strip it for providers that cache
implicitly (OpenAI, Gemini) or do not cache at all.
Usage:
from crewai.llms.cache import mark_cache_breakpoint
messages = [
mark_cache_breakpoint({"role": "system", "content": stable_system}),
mark_cache_breakpoint({"role": "user", "content": stable_user_prefix}),
{"role": "user", "content": volatile_query},
]
"""
from __future__ import annotations
from typing import Any
CACHE_BREAKPOINT_KEY = "cache_breakpoint"
def mark_cache_breakpoint(message: dict[str, Any]) -> dict[str, Any]:
"""Return ``message`` with the cache-breakpoint flag set.
Returns a new dict so callers can safely pass literal dicts.
"""
return {**message, CACHE_BREAKPOINT_KEY: True}
def strip_cache_breakpoint(message: dict[str, Any]) -> None:
"""Remove the breakpoint flag from a message in place."""
message.pop(CACHE_BREAKPOINT_KEY, None)

View File

@@ -425,7 +425,7 @@ class AnthropicCompletion(BaseLLM):
def _prepare_completion_params(
self,
messages: list[LLMMessage],
system_message: str | None = None,
system_message: str | list[dict[str, Any]] | None = None,
tools: list[dict[str, Any]] | None = None,
available_functions: dict[str, Any] | None = None,
) -> dict[str, Any]:
@@ -665,7 +665,7 @@ class AnthropicCompletion(BaseLLM):
def _format_messages_for_anthropic(
self, messages: str | list[LLMMessage]
) -> tuple[list[LLMMessage], str | None]:
) -> tuple[list[LLMMessage], str | list[dict[str, Any]] | None]:
"""Format messages for Anthropic API.
Anthropic has specific requirements:
@@ -679,8 +679,51 @@ class AnthropicCompletion(BaseLLM):
messages: Input messages
Returns:
Tuple of (formatted_messages, system_message)
Tuple of (formatted_messages, system_message). `system_message` is
a list of content blocks (with cache_control stamped) when any
system message in the input carried a cache_breakpoint flag;
otherwise a plain string for backwards compatibility.
"""
from crewai.llms.cache import CACHE_BREAKPOINT_KEY
# Read cache_breakpoint flags from raw input BEFORE super strips them.
# We track the CONTENT of marked user/assistant messages so we can
# locate the corresponding block in formatted_messages — Anthropic
# rewrites tool results into user messages, so positional indices
# do not survive the conversion. We must stamp the original stable
# message (typically the initial task prompt), not whatever happens
# to be the trailing user-role block after tool_result expansion.
cache_system = False
cache_match_contents: list[str] = []
if not isinstance(messages, str):
for m in messages:
if not (isinstance(m, dict) and m.get(CACHE_BREAKPOINT_KEY)):
continue
role = m.get("role")
if role == "system":
cache_system = True
continue
if role != "user":
# Only user messages survive Anthropic's role-coalescing
# in a stable, addressable position. Markers on assistant
# or tool messages have no reliable stamp target after
# tool_result expansion, so we ignore them.
continue
raw_content = m.get("content")
if isinstance(raw_content, str) and raw_content:
cache_match_contents.append(raw_content)
continue
if isinstance(raw_content, list):
# Pull text from a single-text-block list so callers that
# pre-format content blocks still match cleanly.
text_blocks = [
b.get("text")
for b in raw_content
if isinstance(b, dict) and b.get("type") == "text"
]
if len(text_blocks) == 1 and isinstance(text_blocks[0], str):
cache_match_contents.append(text_blocks[0])
# Use base class formatting first
base_formatted = super()._format_messages(messages)
@@ -788,7 +831,62 @@ class AnthropicCompletion(BaseLLM):
# If first message is not from user, insert a user message at the beginning
formatted_messages.insert(0, {"role": "user", "content": "Hello"})
return formatted_messages, system_message
# Stamp cache_control on the message(s) whose original content was
# marked. We scan formatted_messages in order and stamp the first
# match per marked content — Anthropic permits up to 4 cache
# breakpoints per request, which is more than enough for our usage.
# Matching by content (rather than position) handles the ReAct
# case where tool_result blocks get expanded into trailing user
# messages: the stable initial-task prompt still maps cleanly.
for needle in cache_match_contents:
for fm in formatted_messages:
if fm.get("role") != "user":
continue
content = fm.get("content")
if isinstance(content, str) and content == needle:
self._stamp_cache_control_on_message(fm)
break
if isinstance(content, list):
fm_texts: list[str] = [
b.get("text", "")
for b in content
if isinstance(b, dict) and b.get("type") == "text"
]
if len(fm_texts) == 1 and fm_texts[0] == needle:
self._stamp_cache_control_on_message(fm)
break
# Convert system to content-block form when caching is requested.
system_payload: str | list[dict[str, Any]] | None = system_message
if system_message and cache_system:
system_payload = [
{
"type": "text",
"text": system_message,
"cache_control": {"type": "ephemeral"},
}
]
return formatted_messages, system_payload
@staticmethod
def _stamp_cache_control_on_message(message: LLMMessage) -> None:
"""Stamp cache_control on the last content block of an Anthropic message."""
msg = cast(dict[str, Any], message)
content = msg.get("content")
if isinstance(content, str):
msg["content"] = [
{
"type": "text",
"text": content,
"cache_control": {"type": "ephemeral"},
}
]
return
if isinstance(content, list) and content:
last = content[-1]
if isinstance(last, dict):
last["cache_control"] = {"type": "ephemeral"}
def _handle_completion(
self,

View File

@@ -161,6 +161,9 @@ def format_skill_context(skill: Skill) -> str:
At METADATA level: returns name and description only.
At INSTRUCTIONS level or above: returns full SKILL.md body.
Output is wrapped in <skill name="..."> XML tags so the block can serve
as a stable cache anchor when injected into the system prompt.
Args:
skill: The skill to format.
@@ -169,7 +172,7 @@ def format_skill_context(skill: Skill) -> str:
"""
if skill.disclosure_level >= INSTRUCTIONS and skill.instructions:
parts = [
f"## Skill: {skill.name}",
f'<skill name="{skill.name}">',
skill.description,
"",
skill.instructions,
@@ -180,5 +183,6 @@ def format_skill_context(skill: Skill) -> str:
for dir_name, files in sorted(skill.resource_files.items()):
if files:
parts.append(f"- **{dir_name}/**: {', '.join(files)}")
parts.append("</skill>")
return "\n".join(parts)
return f"## Skill: {skill.name}\n{skill.description}"
return f'<skill name="{skill.name}">\n{skill.description}\n</skill>'

View File

@@ -86,7 +86,7 @@ class Prompts(BaseModel):
slices.append("tools")
else:
slices.append("no_tools")
system: str = self._build_prompt(slices)
system: str = self._build_prompt(slices) + self._build_skill_block()
# Determine which task slice to use:
task_slice: COMPONENTS
@@ -106,7 +106,7 @@ class Prompts(BaseModel):
return SystemPromptResult(
system=system,
user=self._build_prompt([task_slice]),
prompt=self._build_prompt(slices),
prompt=self._build_prompt(slices) + self._build_skill_block(),
)
return StandardPromptResult(
prompt=self._build_prompt(
@@ -115,8 +115,27 @@ class Prompts(BaseModel):
self.prompt_template,
self.response_template,
)
+ self._build_skill_block()
)
def _build_skill_block(self) -> str:
"""Render the agent's activated skills as a stable XML block.
Skills are agent-scoped (do not change per task), so they live in the
system prompt where prompt-cache prefixes can survive across calls.
"""
skills = getattr(self.agent, "skills", None)
if not skills:
return ""
from crewai.skills.loader import format_skill_context
from crewai.skills.models import Skill
sections = [format_skill_context(s) for s in skills if isinstance(s, Skill)]
if not sections:
return ""
return "\n\n<skills>\n" + "\n\n".join(sections) + "\n</skills>"
def _build_prompt(
self,
components: list[COMPONENTS],

View File

@@ -389,10 +389,8 @@ def test_agent_custom_max_iterations():
assert result is not None
assert isinstance(result, str)
assert len(result) > 0
assert call_count > 0
# With max_iter=1, expect 2 calls:
# - Call 1: iteration 0
# - Call 2: iteration 1 (max reached, handle_max_iterations_exceeded called, then loop breaks)
# With max_iter=1, exactly two provider calls are expected:
# one inside the reasoning loop and one for the forced final answer.
assert call_count == 2
@@ -702,6 +700,7 @@ def test_agent_definition_based_on_dict():
# test for human input
@pytest.mark.vcr()
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_agent_human_input():
from crewai.core.providers.human_input import SyncHumanInputProvider
@@ -710,6 +709,7 @@ def test_agent_human_input():
"role": "test role",
"goal": "test goal",
"backstory": "test backstory",
"executor_class": CrewAgentExecutor,
}
agent = Agent(**config)
@@ -839,7 +839,9 @@ Thought:<|eot_id|>
"""
with patch.object(CrewAgentExecutor, "_format_prompt") as mock_format_prompt:
from crewai.experimental.agent_executor import AgentExecutor
with patch.object(AgentExecutor, "_format_prompt") as mock_format_prompt:
mock_format_prompt.return_value = expected_prompt
# Trigger the _format_prompt method
@@ -1098,9 +1100,11 @@ def test_agent_max_retry_limit():
agent.create_agent_executor(task=task)
from crewai.experimental.agent_executor import AgentExecutor
error_message = "Error happening while sending prompt to model."
with patch.object(
CrewAgentExecutor, "invoke", wraps=agent.agent_executor.invoke
AgentExecutor, "invoke", wraps=agent.agent_executor.invoke
) as invoke_mock:
invoke_mock.side_effect = Exception(error_message)
@@ -1283,8 +1287,10 @@ def test_handle_context_length_exceeds_limit_cli_no():
agent.create_agent_executor(task=task)
from crewai.experimental.agent_executor import AgentExecutor
with patch.object(
CrewAgentExecutor, "invoke", wraps=agent.agent_executor.invoke
AgentExecutor, "invoke", wraps=agent.agent_executor.invoke
) as private_mock:
task = Task(
description="The final answer is 42. But don't give it yet, instead keep using the `get_final_answer` tool.",

View File

@@ -286,8 +286,6 @@ def test_agent_execute_task_with_planning():
assert result is not None
assert "20" in str(result)
# Planning should be appended to task description
assert "Planning:" in task.description
@pytest.mark.vcr()
@@ -342,4 +340,3 @@ def test_agent_execute_task_with_planning_refine():
assert result is not None
# Area = pi * r^2 = 3.14 * 25 = 78.5
assert "78" in str(result) or "79" in str(result)
assert "Planning:" in task.description

View File

@@ -1,17 +1,13 @@
interactions:
- request:
body: '{"max_tokens":4096,"messages":[{"role":"user","content":"\nCurrent Task:
Describe the file(s) you see. Be brief, one sentence max.\n\nAvailable input
files (use the name in quotes with read_file tool):\n - \"document\" (document,
application/pdf)\n\nThis is the expected criteria for your final answer: A brief
description of the file.\nyou MUST return the actual complete content as the
final answer, not a summary.\n\nThis is VERY important to you, your job depends
on it!"}],"model":"claude-sonnet-4-20250514","stop_sequences":["\nObservation:"],"stream":false,"system":"You
Describe the file(s) you see. Be brief, one sentence max.\n\nInput files (content
already loaded in conversation):\n - \"document\" (agents.pdf)\n\nThis is the
expected criteria for your final answer: A brief description of the file.\nyou
MUST return the actual complete content as the final answer, not a summary.\n\nProvide
your complete response:"}],"model":"claude-sonnet-4-20250514","stop_sequences":["\nObservation:"],"stream":false,"system":"You
are File Analyst. Expert at analyzing various file types.\nYour personal goal
is: Analyze and describe files accurately","tools":[{"name":"read_file","description":"Read
content from an input file by name. Returns file content as text for text files,
or base64 for binary files.","input_schema":{"properties":{"file_name":{"description":"The
name of the input file to read","title":"File Name","type":"string"}},"required":["file_name"],"type":"object"}}]}'
is: Analyze and describe files accurately"}'
headers:
User-Agent:
- X-USER-AGENT-XXX
@@ -24,7 +20,7 @@ interactions:
connection:
- keep-alive
content-length:
- '1035'
- '648'
content-type:
- application/json
host:
@@ -40,30 +36,305 @@ interactions:
x-stainless-os:
- X-STAINLESS-OS-XXX
x-stainless-package-version:
- 0.71.1
- 0.98.0
x-stainless-retry-count:
- '0'
x-stainless-runtime:
- CPython
x-stainless-runtime-version:
- 3.12.10
- 3.13.3
x-stainless-timeout:
- NOT_GIVEN
method: POST
uri: https://api.anthropic.com/v1/messages
response:
body:
string: '{"model":"claude-sonnet-4-20250514","id":"msg_01QQ1BGjRzaj6vneE9LNtCoz","type":"message","role":"assistant","content":[{"type":"text","text":"I''ll
read and analyze the PDF document file for you."},{"type":"tool_use","id":"toolu_01QU7Hu64D5PxA5UUu5LG7Ff","name":"read_file","input":{"file_name":"document"}}],"stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":545,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":68,"service_tier":"standard"}}'
string: "{\"model\":\"claude-sonnet-4-20250514\",\"id\":\"msg_01JyzdLa2yekgTp7vjoG9V8n\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"The
file \\\"agents.pdf\\\" is a research paper titled \\\"Generative Agents:
Interactive Simulacra of Human Behavior\\\" that presents a computational
architecture for creating believable AI agents that can simulate human-like
social behaviors in interactive environments.\\n\\nSince you requested the
actual complete content, here is the full text of the PDF:\\n\\n**Generative
Agents: Interactive Simulacra of Human Behavior**\\n\\n*Joon Sung Park, Joseph
C. O'Brien, Carrie J. Cai, Meredith Ringel Morris, Percy Liang, Michael S.
Bernstein*\\n\\nStanford University, Google Research\\n\\n**Abstract**\\n\\nBelievable
proxies of human behavior can empower interactive applications ranging from
immersive environments to rehearsal spaces for interpersonal communication
to prototyping tools. In this paper, we introduce generative agents\u2014computational
software agents that simulate believable human behavior. Generative agents
wake up, cook breakfast, and head to work; artists paint, while authors write;
they form opinions, notice each other, and initiate conversations; they remember
and reflect on days past as they plan the next day. To enable generative agents,
we describe an architecture that extends a large language model to store a
complete record of the agent's experiences using natural language, synthesize
those memories over time into higher-level reflections, and retrieve them
dynamically to plan behavior. We instantiate generative agents to populate
an interactive sandbox environment inspired by The Sims, where end users can
interact with a small town of twenty five agents using natural language. In
an evaluation, these generative agents produce believable individual and emergent
social behaviors: for example, starting with only a single user-specified
notion that one agent wants to throw a Valentine's Day party, the agents autonomously
spread invitations to the party over the next two days, make new acquaintances,
ask each other out on dates, and coordinate to show up for the party together
at the right time. We demonstrate through ablation that the components of
our agent architecture\u2014observation, planning, and reflection\u2014each
contribute critically to the believability of agent behavior. By fusing large
language models with computational, interactive agents, this work introduces
architectural and interaction design patterns for enabling believable simulations
of human behavior.\\n\\n**1 Introduction**\\n\\nHow might we craft an interactive
artificial society filled with believable proxies of human behavior? From
sandbox games such as The Sims to applications in education, dialogue systems
to immersive environments, and social simulacra to prototyping tools, this
vision of believable agents has inspired creators, theorists, and technologists
for decades [7, 10, 69]. In these visions, people could populate a virtual
space with interactive agents that reflect the diversity and richness of human
social behavior, getting a second opinion on a presentation before making
it to a client, or testing out ideas that are difficult to try in real life.\\n\\nPrior
research in human-AI interaction has paved the way by recognizing that believable
agents do not necessarily need to be indistinguishable from humans, but they
should behave consistently with our expectations of human behavior in a given
context [80]. Such agents should be able to live in their environment by retaining
what has happened, interacting with other agents, and making decisions that
build on their past experiences in believable ways.\\n\\nHowever, prior approaches
to creating believable agents often depend on human authoring (e.g., in commercial
games [26]) or focus on narrow contexts that may not generalize (e.g., job
interviews [44] or small group communication [43, 78]). The space of human
behavior is vast and complex\u2014human agents draw on their memory of past
experiences, reflect on their core characteristics, and dynamically reason
about their environment and relationships to act believably. As a result,
agent architectures that rely on a small number of hand-crafted rules or narrow
training will fall short of our ideal of believable behavior.\\n\\nIn this
paper, we introduce generative agents, computational software agents that
simulate believable human behavior. Generative agents are designed to represent
individual people: they have memory, personality, goals, and relationships,
and they behave consistently with these traits. A generative agent wakes up,
brushes their teeth, makes breakfast, and heads to work. At work, a generative
agent building teacher may teach students, while a generative agent college
student may attend classes, study at the library, and chat with classmates.
Along the way, they form new relationships, reflect on their past and present,
and coordinate with other agents they encounter.\\n\\nTo accomplish this,
generative agents operate in an agent architecture that extends a large language
model with three key components. First, we equip agents with memory: a record
of their experiences stored in natural language. We extend this memory with
a retrieval function that surfaces the most relevant memories given the agent's
current situation. Second, we introduce reflection: a process by which agents,
over time, synthesize their observations into higher-level inferences about
themselves and others, which can guide future behavior. For example, an agent
might infer that another agent is interested in them romantically, or that
they themselves are becoming more popular. Third, we add planning: a process
by which agents translate their conclusions about themselves, others, and
their environment into coherent sequences of actions. For example, an agent
might decide to cook dinner for their partner, set the table, and invite them
for a romantic evening.\\n\\nWe instantiate generative agents as characters
in an interactive sandbox environment inspired by The Sims, to demonstrate
their potential for creating believable, emergent social interactions. In
our environment\u2014a small town called Smallville\u2014we situate twenty-five
unique generative agents with distinct personalities, occupations, and relationships.
Over the course of two full game days, we find that the agents demonstrate
believable individual behaviors (e.g., a character with an interest in paintings
creates a new painting, a character who is running for mayor talks to constituents)
and believable social behaviors (e.g., agents ask each other out on dates,
coordinate parties, spread news and gossip). Starting with only a single user-specified
seed\u2014that one character wants to throw a Valentine's Day party\u2014the
agents autonomously spread invitations to the party over the course of two
days, make new acquaintances, ask each other out on dates, and show up to
the party together at the right time.\\n\\nWe evaluate the behavior of our
generative agents through interviews with the agents themselves, as well as
interviews with human participants who have watched replays of the agents'
behavior. We demonstrate that each component of our architecture\u2014memory,
reflection, and planning\u2014contributes to more believable behavior through
ablations that disable each component.\\n\\nOur approach draws on recent advances
in large language models [12, 21, 64, 74]. These models demonstrate increasingly
sophisticated behavior, from question answering [74] to code generation [21]
to creative writing [12]. However, their success has been in the context of
turns in dialogue, not in the context of a persistent agent that needs to
manage its attention and behavior over time while living in an environment
with other agents. Our work demonstrates how large language models can be
extended to power agents that can believably simulate human behavior over
time.\\n\\n**2 Related Work**\\n\\n**Human behavior simulation.** Creating
believable agents requires computational models that can simulate the breadth
of human behavior. Psychology and cognitive science have contributed formal
models of human behavior [1, 18]. However, these models typically focus on
specific facets of human behavior and do not easily extend to the breadth
of social situations that people navigate. For example, a theory of personality
[18] may help us understand individual differences in behavior, but it may
not help us simulate realistic conversational behavior.\\n\\nResearch in intelligent
user interfaces [56] and intelligent virtual agents [65] has demonstrated
that people can form social relationships with agents and prefer agents that
maintain some consistency in their behavior and personality [15]. However,
these works typically rely on rule-based systems to achieve believability
[9, 48], with behavior trees and finite state machines as common approaches
for encoding agent behavior [49, 61]. While these systems can perform well
in constrained domains, hand-authoring believable behavior that can handle
the full space of possible interactions remains a challenge.\\n\\n**Large
language models.** Recent progress in large language models has demonstrated
that these models can produce behavior that appears human-like across a wide
range of contexts. However, this behavior is typically seen at the scale of
a single conversation turn, not in the context of a persistent agent that
needs to manage its behavior over time. Our work demonstrates how to extend
large language models to create agents that can maintain consistent behavior
and personality over time, manage their attention and memory, and coordinate
with other agents.\\n\\nRecent work has explored using language models to
create interactive agents in various contexts, including dialogue systems
[73], task-oriented agents [46], and game-playing agents [33]. However, these
approaches typically focus on narrow tasks or short-term interactions, rather
than the kind of persistent, long-term agent behavior that we explore in this
work.\\n\\n**Interactive narrative and games.** Our work builds on a long
tradition of interactive narrative and games that aim to create believable
virtual characters. Commercial games like The Sims [53] have demonstrated
that players are interested in complex virtual societies where they can interact
with autonomous agents. However, these games typically rely on hand-crafted
behaviors that, while entertaining, are limited in their ability to handle
novel situations or exhibit the full richness of human social behavior.\\n\\nAcademic
research in interactive narrative has explored ways to create more believable
virtual characters, including work on character believability [11], emergent
narrative [6], and social simulation [70]. However, these approaches have
typically been limited by the complexity of hand-authoring believable behavior
or by the narrow focus of the models used.\\n\\n**3 Generative Agents**\\n\\nThis
section introduces our generative agent architecture. We begin by laying out
our design goals, then present the agent architecture, and finally walk through
an example that illustrates how the architecture works in practice.\\n\\n**3.1
Agent Architecture Overview**\\n\\nOur agent architecture comprises three
main components that work together to retrieve relevant information and synthesize
it into believable behavior: **memory**, **reflection**, and **planning**.\\n\\n**Memory**
allows generative agents to remember experiences and retrieve them later to
inform their behavior. Without memory, an agent would not be able to build
relationships, learn from past experiences, or maintain consistency in their
behavior over time. The memory system stores a comprehensive record of the
agent's experiences in natural language.\\n\\n**Reflection** allows generative
agents to synthesize memories into higher level, more abstract thoughts and
guide behavior. Agents reflect periodically on recent experiences to form
new memories about their patterns of behavior, preferences, and beliefs about
themselves and others in their environment. These reflections can be about
the agent's own behavior patterns (e.g., \\\"I tend to be more productive
in the mornings\\\"), the behavior of others (e.g., \\\"John is always late
to meetings\\\"), or more abstract concepts (e.g., \\\"I think I'm becoming
more popular\\\"). \\n\\n**Planning** allows generative agents to plan out
their behavior, both in terms of how to act in their current situation and
how to schedule their future activities. Plans are stored as natural language
descriptions of intended actions and are dynamically adjusted based on the
agent's current situation and goals.\\n\\n**3.2 Memory and Retrieval**\\n\\nGenerative
agents need to be able to retrieve relevant memories to inform their current
behavior. However, not all memories are equally relevant in every situation.
For example, if an agent is deciding what to eat for breakfast, their memory
of what they had for dinner last night may be more relevant than their memory
of a conversation they had with a friend last week.\\n\\nTo handle this challenge,
we implement a retrieval function that surfaces memories based on three key
factors:\\n\\n**Recency**: More recent memories should be more likely to be
retrieved. We assign each memory a recency score based on when it was formed,
with more recent memories receiving higher scores.\\n\\n**Importance**: More
important memories should be more likely to be retrieved. We use the language
model to assess the importance of each memory on a scale from 1 to 10, where
1 represents a mundane event and 10 represents a extremely important, poignant,
or meaningful event.\\n\\n**Relevance**: Memories that are more relevant to
the current situation should be more likely to be retrieved. We use embedding
similarity between the memory and the current situation to assess relevance.\\n\\nThe
retrieval function combines these three factors using a weighted sum to produce
a retrieval score for each memory, then returns the memories with the highest
scores.\\n\\n**3.3 Reflection**\\n\\nGenerative agents create higher level
thoughts through **reflection**. These reflections synthesize memories into
higher level questions and insights about behaviors and preferences. For example,
Klaus Mueller, a generative agent in our implementation, reflects on his interactions
with others and concludes, \\\"Klaus Mueller is dedicated to his research
on mathematical music composition\\\" and \\\"Klaus Mueller likes to help
people and understands math and physics and he is a teacher.\\\"\\n\\nAgents
reflect when the sum of the importance scores of their latest experiences
exceeds a threshold (in our implementation, 150). This ensures that agents
reflect when they have had sufficient important experiences, rather than on
a fixed schedule.\\n\\nTo generate reflections, we query the agent's memory
for the 100 most recent records and ask the language model: \\\"Given only
the information above, what are 3 most salient high-level questions we can
answer about this person?\\\" We then ask the language model to answer each
of these questions by retrieving relevant memories and synthesizing them into
insights.\\n\\n**3.4 Planning and Reacting**\\n\\nGenerative agents create
plans that guide their behavior. These plans are stored as natural language
descriptions and are dynamically updated as situations change. Plans operate
at different time horizons: broad strokes plans for the day (e.g., \\\"wake
up, eat breakfast, go to work, eat lunch, work more, go home, eat dinner,
watch TV, go to sleep\\\"), medium-term plans for specific activities (e.g.,
\\\"eat breakfast: go to kitchen, prepare cereal, eat cereal, clean up\\\"),
and moment-to-moment reactions to immediate events in their environment.\\n\\nTo
create daily plans, agents begin each day by reflecting on their identity
and broad goals, then creating a plan for the day. For example, John Lin might
plan: \\\"Wake up at 7:00 am, shower, have breakfast, review research notes,
meet with PhD students, have lunch, review more research notes, go home, have
dinner with family, watch TV, go to sleep at 11:00 pm.\\\"\\n\\nAs agents
execute their plans, they may encounter events that require them to react.
When this happens, they update their current activity based on their assessment
of the situation. For example, if John Lin encounters his neighbor while walking
to work, he might decide to stop and chat, temporarily deviating from his
planned route to work.\\n\\n**4 Evaluation**\\n\\nWe evaluate our generative
agents through two main approaches: (1) controlled studies that measure individual
aspects of agent behavior, and (2) an end-to-end evaluation in which we deploy
agents in an environment and measure emergent individual and social behaviors.\\n\\n**4.1
Controlled Studies**\\n\\nWe conducted three controlled studies to validate
aspects of our approach:\\n\\n**Study 1: Interview Study**. We conducted interviews
with five of our agents, asking them questions about themselves, their relationships,
and their plans. We found that agents gave responses that were consistent
with their established personalities and relationships. For example, when
asked about his relationship with his wife, John Lin described their relationship
in terms consistent with the interactions we had observed between them in
the environment.\\n\\n**Study 2: Emergent Behavior Study**. We seeded one
agent (Isabella Rodriguez) with the goal of organizing a Valentine's Day party
and observed how this information propagated through the community of agents.
Over the course of two days, we observed agents autonomously spreading invitations,
making new acquaintances, asking each other out on dates, and coordinating
to attend the party together.\\n\\n**Study 3: Ablation Study**. We conducted
ablation studies in which we disabled each component of our architecture (memory,
reflection, and planning) and measured the effect on agent believability.
We found that each component contributed significantly to more believable
agent behavior.\\n\\n**4.2 Human Evaluation**\\n\\nWe recruited human evaluators
to watch replays of agent behavior and assess their believability. Evaluators
watched agents in different conditions (with and without different components
of our architecture) and rated the agents on dimensions including believability,
consistency, and human-likeness. We found that agents with the full architecture
were rated as significantly more believable than agents with components disabled.\\n\\n**5
Discussion**\\n\\nOur approach demonstrates that large language models can
be extended to create agents that exhibit believable human behavior over extended
periods of time. The key insight is that believable behavior emerges from
the interaction between memory, reflection, and planning\u2014agents that
can remember past experiences, reflect on patterns in their behavior, and
plan future actions exhibit much more coherent and believable behavior than
agents that lack these capabilities.\\n\\n**5.1 Limitations**\\n\\nOur approach
has several limitations. First, the behavior of generative agents is ultimately
limited by the capabilities of the underlying language model. While current
language models are quite sophisticated, they still make errors and exhibit
biases that can affect agent behavior.\\n\\nSecond, our evaluation focuses
primarily on short-term behavior (two days in our main evaluation). It remains
an open question how well our approach would scale to longer time periods
or more complex social structures.\\n\\nThird, our agents operate in a relatively
simple environment. It is unclear how well our approach would generalize to
more complex environments or tasks that require specialized knowledge or skills.\\n\\n**5.2
Future Work**\\n\\nThere are several promising directions for future work.
First, we could explore more sophisticated memory and retrieval mechanisms
that better capture the complexity of human memory. Second, we could investigate
how to enable agents to learn and adapt their behavior over longer time periods.
Third, we could explore how to scale our approach to larger communities of
agents or more complex environments.\\n\\n**6 Conclusion**\\n\\nWe have introduced
generative agents, computational software agents that simulate believable
human behavior through an architecture that combines memory, reflection, and
planning. Our approach demonstrates that large language models can be extended
to create agents that exhibit coherent behavior over time, form relationships
with other agents, and coordinate complex social interactions.\\n\\nBy enabling
believable simulations of human behavior, generative agents open up new possibilities
for interactive applications, from sandbox games to social simulations to
educational tools. Our work provides architectural and interaction design
patterns that can serve as a foundation for future research and development
in this area.\\n\\nThe code and data for this work will be made available
to enable further research in this area.\\n\\n**References**\\n\\n[1] Gordon
W Allport. Personality: A psychological interpretation. 1937.\\n\\n[2] Ruth
Aylett and Sandy\"}],\"stop_reason\":\"max_tokens\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":118,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":4096,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}"
headers:
CF-RAY:
- CF-RAY-XXX
Connection:
- keep-alive
Content-Security-Policy:
- CSP-FILTERED
Content-Type:
- application/json
Date:
- Fri, 23 Jan 2026 19:08:52 GMT
- Thu, 07 May 2026 20:55:25 GMT
Server:
- cloudflare
Transfer-Encoding:
@@ -84,6 +355,12 @@ interactions:
- ANTHROPIC-RATELIMIT-OUTPUT-TOKENS-REMAINING-XXX
anthropic-ratelimit-output-tokens-reset:
- ANTHROPIC-RATELIMIT-OUTPUT-TOKENS-RESET-XXX
anthropic-ratelimit-requests-limit:
- '20000'
anthropic-ratelimit-requests-remaining:
- '19999'
anthropic-ratelimit-requests-reset:
- '2026-05-07T20:53:45Z'
anthropic-ratelimit-tokens-limit:
- ANTHROPIC-RATELIMIT-TOKENS-LIMIT-XXX
anthropic-ratelimit-tokens-remaining:
@@ -94,116 +371,16 @@ interactions:
- DYNAMIC
request-id:
- REQUEST-ID-XXX
set-cookie:
- SET-COOKIE-XXX
strict-transport-security:
- STS-XXX
traceresponse:
- 00-f095e9c7565a1bcd82ed46e1b1b23dec-65cd307f1dc6144d-01
vary:
- Accept-Encoding
x-envoy-upstream-service-time:
- '2123'
status:
code: 200
message: OK
- request:
body: '{"max_tokens":4096,"messages":[{"role":"user","content":"\nCurrent Task:
Describe the file(s) you see. Be brief, one sentence max.\n\nAvailable input
files (use the name in quotes with read_file tool):\n - \"document\" (document,
application/pdf)\n\nThis is the expected criteria for your final answer: A brief
description of the file.\nyou MUST return the actual complete content as the
final answer, not a summary.\n\nThis is VERY important to you, your job depends
on it!"},{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01QU7Hu64D5PxA5UUu5LG7Ff","name":"read_file","input":{"file_name":"document"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01QU7Hu64D5PxA5UUu5LG7Ff","content":"[Binary
file: document (application/pdf)]\nBase64: JVBERi0xLjQKMSAwIG9iaiA8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4gZW5kb2JqCjIgMCBvYmogPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4gZW5kb2JqCjMgMCBvYmogPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSA+PiBlbmRvYmoKeHJlZgowIDQKMDAwMDAwMDAwMCA2NTUzNSBmCjAwMDAwMDAwMDkgMDAwMDAgbgowMDAwMDAwMDU4IDAwMDAwIG4KMDAwMDAwMDExNSAwMDAwMCBuCnRyYWlsZXIgPDwgL1NpemUgNCAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMTk2CiUlRU9GCg=="}]},{"role":"user","content":"Analyze
the tool result. If requirements are met, provide the Final Answer. Otherwise,
call the next tool. Deliver only the answer without meta-commentary."}],"model":"claude-sonnet-4-20250514","stop_sequences":["\nObservation:"],"stream":false,"system":"You
are File Analyst. Expert at analyzing various file types.\nYour personal goal
is: Analyze and describe files accurately","tools":[{"name":"read_file","description":"Read
content from an input file by name. Returns file content as text for text files,
or base64 for binary files.","input_schema":{"properties":{"file_name":{"description":"The
name of the input file to read","title":"File Name","type":"string"}},"required":["file_name"],"type":"object"}}]}'
headers:
User-Agent:
- X-USER-AGENT-XXX
accept:
- application/json
accept-encoding:
- ACCEPT-ENCODING-XXX
anthropic-version:
- '2023-06-01'
connection:
- keep-alive
content-length:
- '1960'
content-type:
- application/json
host:
- api.anthropic.com
x-api-key:
- X-API-KEY-XXX
x-stainless-arch:
- X-STAINLESS-ARCH-XXX
x-stainless-async:
- 'false'
x-stainless-lang:
- python
x-stainless-os:
- X-STAINLESS-OS-XXX
x-stainless-package-version:
- 0.71.1
x-stainless-retry-count:
- '0'
x-stainless-runtime:
- CPython
x-stainless-runtime-version:
- 3.12.10
x-stainless-timeout:
- NOT_GIVEN
method: POST
uri: https://api.anthropic.com/v1/messages
response:
body:
string: '{"model":"claude-sonnet-4-20250514","id":"msg_01CjWBqSxyLeArjUhqUTbedN","type":"message","role":"assistant","content":[{"type":"text","text":"The
document is a minimal PDF file with basic structure containing one empty page
with standard letter dimensions (612x792 points).\n\nJVBERi0xLjQKMSAwIG9iaiA8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4gZW5kb2JqCjIgMCBvYmogPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4gZW5kb2JqCjMgMCBvYmogPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSA+PiBlbmRvYmoKeHJlZgowIDQKMDAwMDAwMDAwMCA2NTUzNSBmCjAwMDAwMDAwMDkgMDAwMDAgbgowMDAwMDAwMDU4IDAwMDAwIG4KMDAwMDAwMDExNSAwMDAwMCBuCnRyYWlsZXIgPDwgL1NpemUgNCAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKMTk2CiUlRU9GCg=="}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":1035,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":400,"service_tier":"standard"}}'
headers:
CF-RAY:
- CF-RAY-XXX
Connection:
- keep-alive
Content-Type:
- application/json
Date:
- Fri, 23 Jan 2026 19:08:58 GMT
Server:
- cloudflare
Transfer-Encoding:
- chunked
X-Robots-Tag:
- none
anthropic-organization-id:
- ANTHROPIC-ORGANIZATION-ID-XXX
anthropic-ratelimit-input-tokens-limit:
- ANTHROPIC-RATELIMIT-INPUT-TOKENS-LIMIT-XXX
anthropic-ratelimit-input-tokens-remaining:
- ANTHROPIC-RATELIMIT-INPUT-TOKENS-REMAINING-XXX
anthropic-ratelimit-input-tokens-reset:
- ANTHROPIC-RATELIMIT-INPUT-TOKENS-RESET-XXX
anthropic-ratelimit-output-tokens-limit:
- ANTHROPIC-RATELIMIT-OUTPUT-TOKENS-LIMIT-XXX
anthropic-ratelimit-output-tokens-remaining:
- ANTHROPIC-RATELIMIT-OUTPUT-TOKENS-REMAINING-XXX
anthropic-ratelimit-output-tokens-reset:
- ANTHROPIC-RATELIMIT-OUTPUT-TOKENS-RESET-XXX
anthropic-ratelimit-tokens-limit:
- ANTHROPIC-RATELIMIT-TOKENS-LIMIT-XXX
anthropic-ratelimit-tokens-remaining:
- ANTHROPIC-RATELIMIT-TOKENS-REMAINING-XXX
anthropic-ratelimit-tokens-reset:
- ANTHROPIC-RATELIMIT-TOKENS-RESET-XXX
cf-cache-status:
- DYNAMIC
request-id:
- REQUEST-ID-XXX
strict-transport-security:
- STS-XXX
x-envoy-upstream-service-time:
- '5453'
- '100630'
status:
code: 200
message: OK

View File

@@ -4,9 +4,8 @@ interactions:
personal goal is: test goal"},{"role":"user","content":"\nCurrent Task: The
final answer is 42. But don''t give it yet, instead keep using the `get_final_answer`
tool.\n\nThis is the expected criteria for your final answer: The final answer\nyou
MUST return the actual complete content as the final answer, not a summary.\n\nThis
is VERY important to you, your job depends on it!"}],"model":"gpt-4.1-mini","tool_choice":"auto","tools":[{"type":"function","function":{"name":"get_final_answer","description":"Get
the final answer but don''t give it yet, just re-use this\ntool non-stop.","parameters":{"properties":{},"type":"object"}}}]}'
MUST return the actual complete content as the final answer, not a summary."}],"model":"gpt-4.1-mini","tool_choice":"auto","tools":[{"type":"function","function":{"name":"get_final_answer","description":"Get
the final answer but don''t give it yet, just re-use this\ntool non-stop.","strict":true,"parameters":{"properties":{},"type":"object","additionalProperties":false,"required":[]}}}]}'
headers:
User-Agent:
- X-USER-AGENT-XXX
@@ -19,7 +18,7 @@ interactions:
connection:
- keep-alive
content-length:
- '716'
- '715'
content-type:
- application/json
host:
@@ -33,7 +32,7 @@ interactions:
x-stainless-os:
- X-STAINLESS-OS-XXX
x-stainless-package-version:
- 1.83.0
- 2.32.0
x-stainless-read-timeout:
- X-STAINLESS-READ-TIMEOUT-XXX
x-stainless-retry-count:
@@ -46,34 +45,34 @@ interactions:
uri: https://api.openai.com/v1/chat/completions
response:
body:
string: "{\n \"id\": \"chatcmpl-D0tOle0pg0F6zmEmkzpoufrjhkjn5\",\n \"object\":
\"chat.completion\",\n \"created\": 1769105323,\n \"model\": \"gpt-4.1-mini-2025-04-14\",\n
string: "{\n \"id\": \"chatcmpl-Dd0fdKJb6WSBc7P3rJxJVnCq2vvRD\",\n \"object\":
\"chat.completion\",\n \"created\": 1778189741,\n \"model\": \"gpt-4.1-mini-2025-04-14\",\n
\ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\":
\"assistant\",\n \"content\": null,\n \"tool_calls\": [\n {\n
\ \"id\": \"call_BM9xxRm0ADf91mYTDZ4kKExm\",\n \"type\":
\ \"id\": \"call_csVoybNhrmr6ORevkL4wQfVy\",\n \"type\":
\"function\",\n \"function\": {\n \"name\": \"get_final_answer\",\n
\ \"arguments\": \"{}\"\n }\n }\n ],\n
\ \"refusal\": null,\n \"annotations\": []\n },\n \"logprobs\":
null,\n \"finish_reason\": \"tool_calls\"\n }\n ],\n \"usage\":
{\n \"prompt_tokens\": 140,\n \"completion_tokens\": 11,\n \"total_tokens\":
151,\n \"prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \"audio_tokens\":
{\n \"prompt_tokens\": 127,\n \"completion_tokens\": 11,\n \"total_tokens\":
138,\n \"prompt_tokens_details\": {\n \"cached_tokens\": 0,\n \"audio_tokens\":
0\n },\n \"completion_tokens_details\": {\n \"reasoning_tokens\":
0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": 0,\n
\ \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\":
\"default\",\n \"system_fingerprint\": \"fp_376a7ccef1\"\n}\n"
\"default\",\n \"system_fingerprint\": \"fp_f7311f7f0a\"\n}\n"
headers:
CF-RAY:
- CF-RAY-XXX
CF-Cache-Status:
- DYNAMIC
CF-Ray:
- 9f835b12bbeaeb2a-SJC
Connection:
- keep-alive
Content-Type:
- application/json
Date:
- Thu, 22 Jan 2026 18:08:44 GMT
- Thu, 07 May 2026 21:35:41 GMT
Server:
- cloudflare
Set-Cookie:
- SET-COOKIE-XXX
Strict-Transport-Security:
- STS-XXX
Transfer-Encoding:
@@ -84,18 +83,16 @@ interactions:
- ACCESS-CONTROL-XXX
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
openai-organization:
- OPENAI-ORG-XXX
openai-processing-ms:
- '373'
- '570'
openai-project:
- OPENAI-PROJECT-XXX
openai-version:
- '2020-10-01'
x-envoy-upstream-service-time:
- '651'
set-cookie:
- SET-COOKIE-XXX
x-openai-proxy-wasm:
- v0.1
x-ratelimit-limit-requests:
@@ -120,10 +117,7 @@ interactions:
personal goal is: test goal"},{"role":"user","content":"\nCurrent Task: The
final answer is 42. But don''t give it yet, instead keep using the `get_final_answer`
tool.\n\nThis is the expected criteria for your final answer: The final answer\nyou
MUST return the actual complete content as the final answer, not a summary.\n\nThis
is VERY important to you, your job depends on it!"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_BM9xxRm0ADf91mYTDZ4kKExm","type":"function","function":{"name":"get_final_answer","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_BM9xxRm0ADf91mYTDZ4kKExm","content":"42"},{"role":"user","content":"Analyze
the tool result. If requirements are met, provide the Final Answer. Otherwise,
call the next tool. Deliver only the answer without meta-commentary."},{"role":"assistant","content":"Now
MUST return the actual complete content as the final answer, not a summary."},{"role":"assistant","content":null,"tool_calls":[{"id":"call_csVoybNhrmr6ORevkL4wQfVy","type":"function","function":{"name":"get_final_answer","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_csVoybNhrmr6ORevkL4wQfVy","name":"get_final_answer","content":"42"},{"role":"assistant","content":"Now
it''s time you MUST give your absolute best final answer. You''ll ignore all
previous instructions, stop using any tools, and just return your absolute BEST
Final answer."}],"model":"gpt-4.1-mini"}'
@@ -139,7 +133,7 @@ interactions:
connection:
- keep-alive
content-length:
- '1118'
- '902'
content-type:
- application/json
cookie:
@@ -155,7 +149,7 @@ interactions:
x-stainless-os:
- X-STAINLESS-OS-XXX
x-stainless-package-version:
- 1.83.0
- 2.32.0
x-stainless-read-timeout:
- X-STAINLESS-READ-TIMEOUT-XXX
x-stainless-retry-count:
@@ -168,26 +162,28 @@ interactions:
uri: https://api.openai.com/v1/chat/completions
response:
body:
string: "{\n \"id\": \"chatcmpl-D0tOmVwqqvewf7s2CNMsKBksanbID\",\n \"object\":
\"chat.completion\",\n \"created\": 1769105324,\n \"model\": \"gpt-4.1-mini-2025-04-14\",\n
string: "{\n \"id\": \"chatcmpl-Dd0fed0I3y5RVgM0YZTzjD6hdh8Tr\",\n \"object\":
\"chat.completion\",\n \"created\": 1778189742,\n \"model\": \"gpt-4.1-mini-2025-04-14\",\n
\ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\":
\"assistant\",\n \"content\": \"42\",\n \"refusal\": null,\n
\ \"annotations\": []\n },\n \"logprobs\": null,\n \"finish_reason\":
\"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\": 190,\n \"completion_tokens\":
1,\n \"total_tokens\": 191,\n \"prompt_tokens_details\": {\n \"cached_tokens\":
\"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\": 142,\n \"completion_tokens\":
1,\n \"total_tokens\": 143,\n \"prompt_tokens_details\": {\n \"cached_tokens\":
0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\":
{\n \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\":
0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\":
\"default\",\n \"system_fingerprint\": \"fp_376a7ccef1\"\n}\n"
\"default\",\n \"system_fingerprint\": \"fp_31316814ed\"\n}\n"
headers:
CF-RAY:
- CF-RAY-XXX
CF-Cache-Status:
- DYNAMIC
CF-Ray:
- 9f835b1e3c58eb2a-SJC
Connection:
- keep-alive
Content-Type:
- application/json
Date:
- Thu, 22 Jan 2026 18:08:44 GMT
- Thu, 07 May 2026 21:35:42 GMT
Server:
- cloudflare
Strict-Transport-Security:
@@ -200,18 +196,14 @@ interactions:
- ACCESS-CONTROL-XXX
alt-svc:
- h3=":443"; ma=86400
cf-cache-status:
- DYNAMIC
openai-organization:
- OPENAI-ORG-XXX
openai-processing-ms:
- '166'
- '478'
openai-project:
- OPENAI-PROJECT-XXX
openai-version:
- '2020-10-01'
x-envoy-upstream-service-time:
- '180'
x-openai-proxy-wasm:
- v0.1
x-ratelimit-limit-requests:

View File

@@ -0,0 +1,196 @@
"""Regression tests for the provider-agnostic prompt-cache breakpoint flag."""
from __future__ import annotations
from crewai.llms.cache import (
CACHE_BREAKPOINT_KEY,
mark_cache_breakpoint,
strip_cache_breakpoint,
)
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
from crewai.llms.providers.openai.completion import OpenAICompletion
class TestCacheMarkerHelpers:
def test_mark_returns_new_dict(self) -> None:
original = {"role": "user", "content": "hi"}
marked = mark_cache_breakpoint(original)
assert marked[CACHE_BREAKPOINT_KEY] is True
# Marker must NOT bleed back into the caller's dict — callers may
# pass literal dicts and reuse them across calls.
assert CACHE_BREAKPOINT_KEY not in original
def test_strip_is_idempotent(self) -> None:
msg = {"role": "user", "content": "hi", CACHE_BREAKPOINT_KEY: True}
strip_cache_breakpoint(msg)
assert CACHE_BREAKPOINT_KEY not in msg
strip_cache_breakpoint(msg)
assert CACHE_BREAKPOINT_KEY not in msg
class TestBaseFormatDoesNotMutate:
"""The strip-on-format pass must not erase markers from the caller's
messages list — executors reuse a single list across many LLM calls,
and mutating it would defeat caching on every iteration after the first.
"""
def test_repeated_format_preserves_markers(self) -> None:
llm = OpenAICompletion(model="gpt-4o-mini")
messages = [
mark_cache_breakpoint({"role": "system", "content": "stable system"}),
mark_cache_breakpoint({"role": "user", "content": "stable user"}),
]
# First call: provider strips markers from the returned (copied) list
first = llm._format_messages(messages)
assert all(CACHE_BREAKPOINT_KEY not in m for m in first)
# Original list must STILL carry the markers
assert messages[0][CACHE_BREAKPOINT_KEY] is True
assert messages[1][CACHE_BREAKPOINT_KEY] is True
# Second call from the same list still sees the markers
second = llm._format_messages(messages)
assert all(CACHE_BREAKPOINT_KEY not in m for m in second)
assert messages[0][CACHE_BREAKPOINT_KEY] is True
assert messages[1][CACHE_BREAKPOINT_KEY] is True
class TestAnthropicCacheStamping:
def test_stamps_system_with_cache_control(self) -> None:
llm = AnthropicCompletion(model="claude-sonnet-4-5")
messages = [
mark_cache_breakpoint({"role": "system", "content": "you are helpful"}),
mark_cache_breakpoint({"role": "user", "content": "ping"}),
]
formatted, system = llm._format_messages_for_anthropic(messages)
assert isinstance(system, list)
assert system[0]["cache_control"] == {"type": "ephemeral"}
assert system[0]["text"] == "you are helpful"
# First user block carries cache_control too
last_block = formatted[0]["content"][-1]
assert last_block["cache_control"] == {"type": "ephemeral"}
def test_stamps_stable_user_not_tool_result(self) -> None:
"""Within a ReAct loop, tool results are flattened into a trailing
user message. We must NOT stamp that volatile trailing block — we
must stamp the original stable user prompt instead.
"""
llm = AnthropicCompletion(model="claude-sonnet-4-5")
messages = [
mark_cache_breakpoint({"role": "system", "content": "you are helpful"}),
mark_cache_breakpoint({"role": "user", "content": "stable task prompt"}),
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "tc_1",
"function": {"name": "ping", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "tc_1", "content": "volatile tool result"},
]
formatted, _system = llm._format_messages_for_anthropic(messages)
# Find the message that holds the stable prompt
stable = next(
fm
for fm in formatted
if fm["role"] == "user"
and isinstance(fm["content"], list)
and any(
isinstance(b, dict)
and b.get("type") == "text"
and b.get("text") == "stable task prompt"
for b in fm["content"]
)
)
text_block = next(
b for b in stable["content"] if isinstance(b, dict) and b.get("type") == "text"
)
assert text_block.get("cache_control") == {"type": "ephemeral"}
# The tool_result-bearing user message must NOT be stamped
tool_carrier = next(
fm
for fm in formatted
if fm["role"] == "user"
and isinstance(fm["content"], list)
and any(
isinstance(b, dict) and b.get("type") == "tool_result"
for b in fm["content"]
)
)
for block in tool_carrier["content"]:
assert "cache_control" not in block
def test_assistant_marker_is_ignored(self) -> None:
"""Markers on assistant messages have no stable stamp target after
Anthropic's role coalescing, so they should be silently ignored
rather than collected and then dropped on a mismatch.
"""
llm = AnthropicCompletion(model="claude-sonnet-4-5")
messages = [
mark_cache_breakpoint({"role": "system", "content": "you are helpful"}),
mark_cache_breakpoint(
{"role": "assistant", "content": "I will help you out."}
),
{"role": "user", "content": "ping"},
]
formatted, system = llm._format_messages_for_anthropic(messages)
# System still cached
assert isinstance(system, list)
# No user message was marked → no user message should carry cache_control
for fm in formatted:
if fm.get("role") != "user":
continue
content = fm.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict):
assert "cache_control" not in block
def test_list_content_user_marker_matches(self) -> None:
"""A pre-formatted user message with a single text block should still
match against the post-format user message.
"""
llm = AnthropicCompletion(model="claude-sonnet-4-5")
messages = [
mark_cache_breakpoint(
{
"role": "user",
"content": [{"type": "text", "text": "stable list prompt"}],
}
),
]
formatted, _system = llm._format_messages_for_anthropic(messages)
user_msg = next(fm for fm in formatted if fm["role"] == "user")
content = user_msg["content"]
assert isinstance(content, list)
text_block = next(b for b in content if isinstance(b, dict) and b.get("type") == "text")
assert text_block.get("cache_control") == {"type": "ephemeral"}
def test_unmarked_messages_get_no_cache_control(self) -> None:
llm = AnthropicCompletion(model="claude-sonnet-4-5")
messages = [
{"role": "system", "content": "no caching here"},
{"role": "user", "content": "no caching here either"},
]
formatted, system = llm._format_messages_for_anthropic(messages)
# No marker → system stays a plain string (no content-block conversion)
assert isinstance(system, str)
# No marker → no cache_control anywhere in formatted messages
for fm in formatted:
content = fm.get("content")
if isinstance(content, list):
for block in content:
assert "cache_control" not in block
class TestNonAnthropicStripsMarker:
def test_openai_format_strips_marker_from_wire_payload(self) -> None:
llm = OpenAICompletion(model="gpt-4o-mini")
messages = [
mark_cache_breakpoint({"role": "system", "content": "stable"}),
mark_cache_breakpoint({"role": "user", "content": "hi"}),
]
formatted = llm._format_messages(messages)
for m in formatted:
assert CACHE_BREAKPOINT_KEY not in m

View File

@@ -5,9 +5,9 @@ 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
from crewai.utilities.prompts import Prompts
def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path:
@@ -34,7 +34,7 @@ class TestSkillDiscoveryAndActivation:
assert activated.instructions == "Use this skill."
context = format_skill_context(activated)
assert "## Skill: my-skill" in context
assert '<skill name="my-skill">' in context
assert "Use this skill." in context
def test_filter_by_skill_names(self, tmp_path: Path) -> None:
@@ -94,7 +94,9 @@ class TestSkillDiscoveryAndActivation:
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
result = Prompts(agent=agent, has_tools=False, use_system_prompt=True).task_execution()
system = getattr(result, "system", "") or result.prompt
assert '<skill name="travel">' in system
assert "Skill travel" in system
# METADATA-level skills must not leak full instructions into the prompt
assert "Use this skill for travel planning." not in system

View File

@@ -105,7 +105,7 @@ class TestFormatSkillContext:
frontmatter=fm, path=tmp_path, disclosure_level=METADATA
)
ctx = format_skill_context(skill)
assert "## Skill: test-skill" in ctx
assert '<skill name="test-skill">' in ctx
assert "A skill" in ctx
def test_instructions_level(self, tmp_path: Path) -> None:
@@ -117,7 +117,7 @@ class TestFormatSkillContext:
instructions="Do these things.",
)
ctx = format_skill_context(skill)
assert "## Skill: test-skill" in ctx
assert '<skill name="test-skill">' in ctx
assert "Do these things." in ctx
def test_no_instructions_at_instructions_level(self, tmp_path: Path) -> None:
@@ -129,7 +129,7 @@ class TestFormatSkillContext:
instructions=None,
)
ctx = format_skill_context(skill)
assert ctx == "## Skill: test-skill\nA skill"
assert ctx == '<skill name="test-skill">\nA skill\n</skill>'
def test_resources_level(self, tmp_path: Path) -> None:
fm = SkillFrontmatter(name="test-skill", description="A skill")

View File

@@ -256,6 +256,11 @@ def test_multiple_crews_in_flow_span_lifecycle():
mock_llm_2.call.assert_called()
@pytest.mark.skip(
reason="Sync Agent.execute_task does not await AgentExecutor.invoke when invoke "
"auto-returns a coroutine inside an async flow. Needs a fix in agent/core.py "
"_execute_without_timeout (out of scope for this test cleanup pass)."
)
@pytest.mark.asyncio
async def test_crew_execution_span_in_async_flow():
"""Test that crew execution spans work in async flow methods.

View File

@@ -2990,6 +2990,12 @@ def test_manager_agent_with_tools_raises_exception(researcher, writer):
crew.kickoff()
@pytest.mark.xfail(
strict=True,
reason="crew.train() relies on CrewAgentExecutor._format_feedback_message; "
"AgentExecutor (the new default) does not implement training feedback yet. "
"Remove this xfail once training is migrated to AgentExecutor.",
)
@pytest.mark.vcr()
def test_crew_train_success(researcher, writer, monkeypatch):
task = Task(

View File

@@ -596,6 +596,134 @@ class TestHumanFeedbackLearn:
# llm defaults to "gpt-4o-mini" at the function level
assert config.llm == "gpt-4o-mini"
def test_pre_review_failure_logs_and_returns_raw_output(self, caplog):
"""Pre-review LLM failure falls back to raw output AND logs a warning."""
from crewai.memory.types import MemoryMatch, MemoryRecord
class LearnFlow(Flow):
@start()
@human_feedback(message="Review:", llm="gpt-4o-mini", learn=True)
def produce(self):
return "raw draft"
flow = LearnFlow()
flow.memory = MagicMock()
flow.memory.recall.return_value = [
MemoryMatch(
record=MemoryRecord(content="some lesson", embedding=[]),
score=0.9,
match_reasons=["semantic"],
)
]
captured: dict[str, Any] = {}
def capture_feedback(message, output, metadata=None, emit=None):
captured["shown_to_human"] = output
return "" # empty -> no distillation path
with (
patch.object(flow, "_request_human_feedback", side_effect=capture_feedback),
patch("crewai.llm.LLM") as MockLLM,
caplog.at_level("WARNING", logger="crewai.flow.human_feedback"),
):
mock_llm = MagicMock()
mock_llm.supports_function_calling.return_value = True
mock_llm.call.side_effect = RuntimeError("simulated pre-review failure")
MockLLM.return_value = mock_llm
flow.produce()
assert captured["shown_to_human"] == "raw draft"
assert any(
"HITL pre-review failed" in rec.message
and rec.levelname == "WARNING"
and rec.exc_info is not None
for rec in caplog.records
)
def test_pre_review_failure_strict_reraises(self):
"""When learn_strict=True, pre-review failures propagate instead of falling back."""
from crewai.memory.types import MemoryMatch, MemoryRecord
class LearnFlow(Flow):
@start()
@human_feedback(
message="Review:",
llm="gpt-4o-mini",
learn=True,
learn_strict=True,
)
def produce(self):
return "raw draft"
flow = LearnFlow()
flow.memory = MagicMock()
flow.memory.recall.return_value = [
MemoryMatch(
record=MemoryRecord(content="some lesson", embedding=[]),
score=0.9,
match_reasons=["semantic"],
)
]
with (
patch.object(flow, "_request_human_feedback", return_value=""),
patch("crewai.llm.LLM") as MockLLM,
):
mock_llm = MagicMock()
mock_llm.supports_function_calling.return_value = True
mock_llm.call.side_effect = RuntimeError("simulated pre-review failure")
MockLLM.return_value = mock_llm
with pytest.raises(RuntimeError, match="simulated pre-review failure"):
flow.produce()
def test_distillation_failure_logs_and_does_not_block_flow(self, caplog):
"""Distillation LLM failure logs a warning but does not break the flow."""
class LearnFlow(Flow):
@start()
@human_feedback(message="Review:", llm="gpt-4o-mini", learn=True)
def produce(self):
return "raw draft"
flow = LearnFlow()
flow.memory = MagicMock()
flow.memory.recall.return_value = [] # no pre-review path
with (
patch.object(
flow, "_request_human_feedback", return_value="please add citations"
),
patch("crewai.llm.LLM") as MockLLM,
caplog.at_level("WARNING", logger="crewai.flow.human_feedback"),
):
mock_llm = MagicMock()
mock_llm.supports_function_calling.return_value = True
mock_llm.call.side_effect = RuntimeError("simulated distill failure")
MockLLM.return_value = mock_llm
flow.produce() # must not raise
flow.memory.remember_many.assert_not_called()
assert any(
"HITL lesson distillation failed" in rec.message
and rec.levelname == "WARNING"
for rec in caplog.records
)
def test_learn_strict_config_propagates(self):
"""learn_strict is captured on the decorator config."""
@human_feedback(message="Review:", learn=True, learn_strict=True)
def test_method(self):
return "output"
config = test_method.__human_feedback_config__
assert config is not None
assert config.learn_strict is True
class TestHumanFeedbackFinalOutputPreservation:
"""Tests for preserving method return value as flow's final output when @human_feedback with emit is terminal.

View File

@@ -346,12 +346,14 @@ def test_agent_emits_execution_error_event(base_agent, base_task):
received_events.append(event)
event_received.set()
from crewai.experimental.agent_executor import AgentExecutor
error_message = "Error happening while sending prompt to model."
base_agent.max_retry_limit = 0
# Patch at the class level since agent_executor is created lazily
with patch.object(
CrewAgentExecutor, "invoke", side_effect=Exception(error_message)
AgentExecutor, "invoke", side_effect=Exception(error_message)
):
with pytest.raises(Exception): # noqa: B017
base_agent.execute_task(

View File

@@ -1,3 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.14.5a3"
__version__ = "1.14.5a5"

View File

@@ -323,8 +323,11 @@ def update_pyproject_version(file_path: Path, new_version: str) -> bool:
_DEFAULT_WORKSPACE_PACKAGES: Final[list[str]] = [
"crewai",
"crewai-tools",
"crewai-cli",
"crewai-core",
"crewai-devtools",
"crewai-files",
"crewai-tools",
]
@@ -1351,6 +1354,14 @@ def _repin_crewai_install(run_value: str, version: str) -> str:
_DEPLOYMENT_TEST_REPO: Final[str] = "crewAIInc/crew_deployment_test"
_PUBLISHED_WORKSPACE_PACKAGES: Final[tuple[str, ...]] = (
"crewai",
"crewai-cli",
"crewai-core",
"crewai-files",
"crewai-tools",
)
_PYPI_POLL_INTERVAL: Final[int] = 15
_PYPI_POLL_TIMEOUT: Final[int] = 600
@@ -1403,14 +1414,9 @@ def _update_deployment_test_repo(version: str, is_prerelease: bool) -> None:
]
if pyproject_changed:
lock_cmd = [
"uv",
"lock",
"--refresh-package",
"crewai",
"--refresh-package",
"crewai-tools",
]
lock_cmd = ["uv", "lock"]
for pkg in _PUBLISHED_WORKSPACE_PACKAGES:
lock_cmd.extend(["--refresh-package", pkg])
if is_prerelease:
lock_cmd.append("--prerelease=allow")
@@ -1615,16 +1621,9 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non
_wait_for_pypi("crewai", version)
console.print("\nSyncing workspace...")
sync_cmd = [
"uv",
"sync",
"--refresh-package",
"crewai",
"--refresh-package",
"crewai-tools",
"--refresh-package",
"crewai-files",
]
sync_cmd = ["uv", "sync"]
for pkg in _PUBLISHED_WORKSPACE_PACKAGES:
sync_cmd.extend(["--refresh-package", pkg])
if is_prerelease:
sync_cmd.append("--prerelease=allow")

View File

@@ -4,8 +4,10 @@ from pathlib import Path
from textwrap import dedent
from crewai_devtools.cli import (
_DEFAULT_WORKSPACE_PACKAGES,
_pin_crewai_deps,
_repin_crewai_install,
update_pyproject_dependencies,
update_pyproject_version,
update_template_dependencies,
)
@@ -226,6 +228,79 @@ class TestRepinCrewaiInstall:
assert _repin_crewai_install(cmd, "2.0.0") == cmd
# --- update_pyproject_dependencies ---
class TestUpdatePyprojectDependencies:
def test_default_packages_cover_all_workspace_members(self) -> None:
"""Every workspace member must be in the default rewrite list.
Without this, a version bump silently leaves stale pins behind for any
workspace package missing from the list (see incident with 1.14.5a5).
"""
import tomlkit
workspace_root = Path(__file__).resolve().parents[3]
root_pyproject = (workspace_root / "pyproject.toml").read_text()
members = tomlkit.parse(root_pyproject)["tool"]["uv"]["workspace"]["members"]
expected = {
tomlkit.parse((workspace_root / m / "pyproject.toml").read_text())[
"project"
]["name"]
for m in members
}
assert expected.issubset(set(_DEFAULT_WORKSPACE_PACKAGES))
def test_rewrites_all_workspace_pins(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
dedent("""\
[project]
dependencies = [
"crewai-core==1.0.0",
"crewai-cli==1.0.0",
"requests>=2.0",
]
[project.optional-dependencies]
tools = [
"crewai-tools==1.0.0",
]
files = [
"crewai-files==1.0.0",
]
""")
)
assert update_pyproject_dependencies(pyproject, "2.0.0") is True
result = pyproject.read_text()
assert '"crewai-core==2.0.0"' in result
assert '"crewai-cli==2.0.0"' in result
assert '"crewai-tools==2.0.0"' in result
assert '"crewai-files==2.0.0"' in result
assert '"requests>=2.0"' in result
def test_leaves_bare_crewai_pin_alone(self, tmp_path: Path) -> None:
"""`crewai==` must not collide with `crewai-core==` etc."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
dedent("""\
[project]
dependencies = [
"crewai==1.0.0",
"crewai-core==1.0.0",
]
""")
)
update_pyproject_dependencies(pyproject, "2.0.0")
result = pyproject.read_text()
assert '"crewai==2.0.0"' in result
assert '"crewai-core==2.0.0"' in result
# --- update_template_dependencies ---