mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-06 23:49:24 +00:00
Merge branch 'main' into gl/refactor/a2a-tool-based-delegation
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.5a3"
|
||||
__version__ = "1.14.5a5"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.14.5a3"
|
||||
__version__ = "1.14.5a5"
|
||||
|
||||
@@ -152,4 +152,4 @@ __all__ = [
|
||||
"wrap_file_source",
|
||||
]
|
||||
|
||||
__version__ = "1.14.5a3"
|
||||
__version__ = "1.14.5a5"
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -330,4 +330,4 @@ __all__ = [
|
||||
"ZapierActionTools",
|
||||
]
|
||||
|
||||
__version__ = "1.14.5a3"
|
||||
__version__ = "1.14.5a5"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
37
lib/crewai/src/crewai/llms/cache.py
Normal file
37
lib/crewai/src/crewai/llms/cache.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
196
lib/crewai/tests/llms/test_prompt_cache.py
Normal file
196
lib/crewai/tests/llms/test_prompt_cache.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""CrewAI development tools."""
|
||||
|
||||
__version__ = "1.14.5a3"
|
||||
__version__ = "1.14.5a5"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user