Compare commits

..

1 Commits

Author SHA1 Message Date
Joao Moura
0609959f0c feat(tools): make the file tools' backing store pluggable
FileReadTool and FileWriterTool assume a durable local disk. That holds on a
developer's machine and breaks in any deployment environment where the
runtime is ephemeral: whatever an agent writes is discarded when the run
ends, and a later run cannot read it back. A crew that generates a report in
one task and reads it in the next passes locally and fails there.

This adds the seam needed to point those tools at durable storage instead.
Both now route every path resolution and every read/write through a
FileStore, defaulting to LocalFileStore — the current filesystem behavior,
moved rather than rewritten. A deployment registers a different store
through register_file_store_factory and the tools pick it up.

The store owns its own containment, because the tools call nothing else
before doing I/O. For the local store that stays validate_file_path plus the
is_relative_to check; another store enforces whatever its own namespace
requires, which may be prefix-based rather than realpath-based. resolve()
and normalize() are separate so the reader can still pin its declared file
for identity without a containment check, and base_dir is anchored through
the store so both tools derive the same sandbox root from the same input.

open_text() returns a handle rather than a string, which keeps the local
store lazy: reading a small window out of a huge file does not pull the
whole thing into memory. A store that must fetch eagerly can wrap its
payload in StringIO.

Behaviour is unchanged: every pre-existing file tool test passes untouched.
The new suite stands in a store backed by a dict with no filesystem at all,
which is what proves the seam is real — a tool that reached past it to
open() or os.path would fail those assertions. It also covers the fallbacks
that keep this safe to ship before any integration exists: a factory
returning None, and a factory that raises, both leave the local filesystem
in place rather than breaking file I/O.

No new dependencies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:30:02 -07:00
61 changed files with 792 additions and 5271 deletions

View File

@@ -157,7 +157,6 @@ class MyCustomCrew:
- **FlowCreatedEvent**: يُرسل عند إنشاء تدفق
- **FlowStartedEvent**: يُرسل عند بدء تنفيذ تدفق
- **FlowFinishedEvent**: يُرسل عند اكتمال تنفيذ تدفق
- **FlowFailedEvent**: يُرسل عند فشل تنفيذ تدفق. يحتوي على اسم التدفق والاستثناء الذي أنهى التنفيذ.
- **FlowPausedEvent**: يُرسل عند إيقاف تدفق مؤقتًا بانتظار ملاحظات بشرية
### أحداث LLM

View File

@@ -256,7 +256,6 @@ CrewAI provides a wide range of events that you can listen for:
- **FlowCreatedEvent**: Emitted when a Flow is created
- **FlowStartedEvent**: Emitted when a Flow starts execution
- **FlowFinishedEvent**: Emitted when a Flow completes execution
- **FlowFailedEvent**: Emitted when a Flow execution fails. Contains the flow name and the exception that ended the execution.
- **FlowPausedEvent**: Emitted when a Flow is paused waiting for human feedback. Contains the flow name, flow ID, method name, current state, message shown when requesting feedback, and optional list of possible outcomes for routing.
- **FlowPlotEvent**: Emitted when a Flow is plotted
- **MethodExecutionStartedEvent**: Emitted when a Flow method starts execution

View File

@@ -9,10 +9,7 @@ mode: "wide"
Skills are self-contained directories that provide agents with **domain-specific instructions, guidelines, and reference material**. Each skill is defined by a `SKILL.md` file with YAML frontmatter and a markdown body.
Agents first receive each configured skill's name and description. When a
description applies to the current request, the agent loads that skill's full
instructions for that execution. This keeps unrelated instructions out of the
context while giving the agent the relevant expertise without code changes.
When activated, a skill's instructions are injected directly into the agent's task prompt — giving the agent expertise without requiring any code changes.
<Note type="info" title="Skills vs Tools — The Key Distinction">
**Skills are NOT tools.** This is the most common point of confusion.
@@ -82,13 +79,12 @@ reviewer = Agent(
role="Senior Code Reviewer",
goal="Review pull requests for quality and security issues",
backstory="Staff engineer with expertise in secure coding practices.",
skills=["./skills"], # Discovers review skills
skills=["./skills"], # Injects review guidelines
tools=[GithubSearchTool(), FileReadTool()], # Lets agent read code
)
```
The agent now has both **expertise** (loaded from the relevant skill when
needed) and **capabilities** (from the tools).
The agent now has both **expertise** (from the skill) and **capabilities** (from the tools).
---
@@ -328,8 +324,7 @@ The directory name must match the `name` field in `SKILL.md`. The `scripts/`, `r
## Pre-loading Skills
For more control, you can discover and activate skills programmatically.
Passing an activated skill makes its instructions always-on:
For more control, you can discover and activate skills programmatically:
```python
from pathlib import Path
@@ -356,21 +351,12 @@ agent = Agent(
Skills use **progressive disclosure** — only loading what's needed at each stage:
| Stage | What's loaded | When |
| :--------- | :------------------------------------ | :---------------------------------------- |
| Discovery | Name, description, frontmatter fields | Agent setup or `discover_skills()` |
| Activation | Full SKILL.md body text | Relevant runtime request or `activate_skill()` |
| Resources | Resource directory catalog | Explicit `load_resources()` call |
| Stage | What's loaded | When |
| :--------- | :------------------------------------ | :------------------ |
| Discovery | Name, description, frontmatter fields | `discover_skills()` |
| Activation | Full SKILL.md body text | `activate_skill()` |
With `skills=["./skills"]`, the directory is discovered at setup but the full
instructions are not placed in every prompt. The agent reviews the metadata on
each execution and loads only a skill that applies. The loaded instructions are
scoped to that execution, so skills selected for earlier calls do not accumulate
on the agent.
Inline skill strings and `Skill` objects already activated with
`activate_skill()` remain always-on. This provides an explicit opt-in when the
instructions should apply to every request.
During normal agent execution (passing directory paths via `skills=["./skills"]`), skills are automatically discovered and activated. The progressive loading only matters when using the programmatic API.
---

View File

@@ -334,126 +334,6 @@ writer1 = Agent(
#...
```
## Reporting Tool Failures
A tool can finish without raising and still fail to do what it was asked. Slack
answers `HTTP 200` with `{"ok": false, "error": "channel_not_found"}`; an MCP
server sets `isError`; a platform action returns an error payload. The tool call
"worked", so the error text reaches the agent as an ordinary result — the agent
narrates the problem in its final answer and the run is recorded as a success.
Return a `ToolFailure` instead of an error string and the framework can tell the
difference:
```python Code
from typing import Any
from crewai.tools import BaseTool
from crewai.tools.tool_failure import ToolFailure
class SendSlackMessage(BaseTool):
name: str = "send_slack_message"
description: str = "Post a message to a Slack channel."
def _run(self, channel: str, text: str) -> Any:
payload = slack.post(channel=channel, text=text)
if not payload["ok"]:
return ToolFailure(
message=f"Slack rejected the message: {payload['error']}",
code=payload["error"],
retryable=payload["error"] == "rate_limited",
)
return payload
```
The agent still reads plain prose — `ToolFailure.as_agent_message()` renders the
message — so model behavior is unchanged. What changes is that the failure is now
visible to everything downstream.
Detection is strictly declarative. CrewAI never guesses whether a string "looks
like" an error, so a tool that legitimately returns text about an error is never
misread as having failed. Failures are recorded when a tool returns a
`ToolFailure`, when a tool raises, when an MCP server sets `isError`, when a
tool's `max_usage_count` is spent, or when the agent calls a tool that does not exist.
### Choosing a Failure Policy
`tool_failure_policy` controls what happens next:
| Policy | Behavior |
| :-- | :-- |
| `ignore` | Nothing is recorded, emitted, or acted on. |
| `warn` *(default)* | Records the failure, emits `ToolFailureDetectedEvent`, and continues. |
| `raise` | Records and emits, then aborts with `ToolExecutionFailedError`. |
```python Code
from crewai import Agent, Crew, Task
from crewai.tools.tool_failure import ToolFailurePolicy
agent = Agent(
role="Slack Messenger",
goal="Post the report to Slack",
backstory="...",
tools=[SendSlackMessage()],
tool_failure_policy=ToolFailurePolicy.WARN,
)
# Tighten a single high-stakes task without changing the agent.
task = Task(
description="Post the final report to #engineering",
expected_output="Confirmation the message was posted",
agent=agent,
tool_failure_policy=ToolFailurePolicy.RAISE,
)
# Or set a baseline once for every agent in the crew.
crew = Crew(
agents=[agent],
tasks=[task],
tool_failure_policy=ToolFailurePolicy.WARN,
)
```
The most specific setting wins: **tool → task → agent → crew → `warn`**. Every
level defaults to `None`, meaning "inherit from the next one out", so the
effective default with nothing configured anywhere is `warn`.
### Inspecting Failures
Recorded failures are structured, so nothing downstream has to parse a string:
```python Code
result = crew.kickoff()
if result.has_tool_failures:
for record in result.tool_failures:
print(record.tool_name) # "send_slack_message"
print(record.failure.code) # "channel_not_found"
print(record.failure.reason) # ToolFailureReason.TOOL_REPORTED
print(record.summary())
```
`tool_failures` is available on `TaskOutput`, `CrewOutput`, and
`LiteAgentOutput`. A crew can finish successfully with a non-empty list — check
it before treating `raw` as complete.
To react as failures happen, subscribe to the event:
```python Code
from crewai.events import ToolFailureDetectedEvent
from crewai.events.event_bus import crewai_event_bus
@crewai_event_bus.on(ToolFailureDetectedEvent)
def on_tool_failure(source, event):
print(f"{event.tool_name} failed: {event.failure.message} ({event.policy})")
```
The event is emitted before the `raise` policy aborts, so subscribers always
observe the failure. `ToolUsageFinishedEvent` also carries a `failure` field, letting
a trace UI mark the call as failed without correlating two events.
## Conclusion
Tools are pivotal in extending the capabilities of CrewAI agents, enabling them to undertake a broad spectrum of tasks and collaborate effectively.

View File

@@ -255,7 +255,6 @@ CrewAI는 여러분이 청취할 수 있는 다양한 이벤트를 제공합니
- **FlowCreatedEvent**: Flow가 생성될 때 발생
- **FlowStartedEvent**: Flow가 실행을 시작할 때 발생
- **FlowFinishedEvent**: Flow가 실행을 완료할 때 발생
- **FlowFailedEvent**: Flow 실행이 실패할 때 발생합니다. Flow 이름과 실행을 종료시킨 예외를 포함합니다.
- **FlowPausedEvent**: 사람의 피드백을 기다리며 Flow가 일시 중지될 때 발생합니다. Flow 이름, Flow ID, 메서드 이름, 현재 상태, 피드백 요청 시 표시되는 메시지, 라우팅을 위한 선택적 결과 목록을 포함합니다.
- **FlowPlotEvent**: Flow가 플롯될 때 발생
- **MethodExecutionStartedEvent**: Flow 메서드가 실행을 시작할 때 발생

View File

@@ -256,7 +256,6 @@ O CrewAI fornece uma ampla variedade de eventos para escuta:
- **FlowCreatedEvent**: Emitido ao criar um Flow
- **FlowStartedEvent**: Emitido ao iniciar a execução de um Flow
- **FlowFinishedEvent**: Emitido ao concluir a execução de um Flow
- **FlowFailedEvent**: Emitido quando a execução de um Flow falha. Contém o nome do flow e a exceção que encerrou a execução.
- **FlowPausedEvent**: Emitido quando um Flow é pausado aguardando feedback humano. Contém o nome do flow, ID do flow, nome do método, estado atual, mensagem exibida ao solicitar feedback e lista opcional de resultados possíveis para roteamento.
- **FlowPlotEvent**: Emitido ao plotar um Flow
- **MethodExecutionStartedEvent**: Emitido ao iniciar a execução de um método do Flow

View File

@@ -0,0 +1,30 @@
"""Pluggable backing store for :class:`FileReadTool` / :class:`FileWriterTool`.
The tools default to :class:`LocalFileStore`, which reads and writes the
local filesystem exactly as they always have. A deployment environment where
the local disk is ephemeral can register a different store, so the same tools
persist somewhere durable without the agent, the crew definition, or the tool
arguments changing.
A store owns its own containment. ``resolve`` and ``resolve_within`` must
reject any path the caller should not reach, because the tools call nothing
else before doing I/O.
"""
from crewai_tools.file_storage.base import FileStore, FileStoreError
from crewai_tools.file_storage.local import LocalFileStore
from crewai_tools.file_storage.registry import (
register_file_store_factory,
reset_file_store_factory,
resolve_file_store,
)
__all__ = [
"FileStore",
"FileStoreError",
"LocalFileStore",
"register_file_store_factory",
"reset_file_store_factory",
"resolve_file_store",
]

View File

@@ -0,0 +1,103 @@
"""The store protocol the file tools are written against."""
from __future__ import annotations
from contextlib import AbstractContextManager
from typing import Protocol, TextIO, runtime_checkable
class FileStoreError(Exception):
"""A store failed for a reason with no stdlib exception that fits.
Stores should prefer the built-in filesystem exceptions where one
applies — ``FileNotFoundError``, ``PermissionError``,
``IsADirectoryError``, ``FileExistsError`` — because the tools already
translate those into their established messages. Raise this only for
failures specific to the backing service, such as an unreachable
endpoint or a size limit the local filesystem does not have.
"""
@runtime_checkable
class FileStore(Protocol):
"""Where :class:`FileReadTool` and :class:`FileWriterTool` do their I/O.
Paths crossing this boundary are *store paths*: whatever ``resolve``
returned. For the local store those are absolute filesystem paths; for a
remote store they may be keys or workspace-relative paths. The tools
never interpret them, they only pass them back.
"""
#: Short human-readable name, used in error messages so a failure makes
#: clear which store produced it (e.g. ``"local filesystem"``).
label: str
def resolve(self, path: str, base_dir: str | None = None) -> str:
"""Normalize *path* and confirm the caller may touch it.
Args:
path: The caller-supplied path, absolute or relative.
base_dir: Optional containment root supplied by the tool.
Returns:
The store path to use for subsequent calls.
Raises:
ValueError: If the path falls outside what the store allows.
"""
def normalize(self, path: str, base_dir: str | None = None) -> str:
"""Normalize *path* for identity comparison, without containment.
:class:`FileReadTool` uses this to pin the file declared at
construction, so it can recognize that path again later even if the
working directory has since moved. Unlike :meth:`resolve` it never
rejects: a path outside the sandbox still has a canonical form.
"""
def resolve_within(self, directory: str, filename: str) -> str:
"""Join *filename* under the already-resolved *directory*.
Kept separate from :meth:`resolve` because the writer applies two
levels of containment: the directory must be inside the store's
sandbox, and the filename must then stay inside that directory.
Raises:
ValueError: If *filename* escapes *directory*, or names the
directory itself.
"""
def display(self, resolved: str, base: str | None = None) -> str:
"""Return a label for *resolved* that is safe to show an LLM.
Must not leak absolute directory prefixes; the tools put the result
straight into agent-visible output.
"""
def exists(self, resolved: str) -> bool:
"""Whether something already lives at *resolved*."""
def ensure_parent(self, resolved: str) -> None:
"""Create the container *resolved* will live in, if it needs one.
Raises:
FileExistsError: If a non-container already occupies that name.
"""
def open_text(self, resolved: str, encoding: str) -> AbstractContextManager[TextIO]:
"""Open *resolved* for reading as text.
Returning a file-like object rather than a string keeps the local
store lazy, so reading a small window out of a huge file does not
pull the whole thing into memory. Remote stores that must fetch
eagerly can wrap the payload in ``io.StringIO``.
"""
def write_text(
self, resolved: str, content: str, encoding: str, *, overwrite: bool
) -> None:
"""Write *content* to *resolved*.
Raises:
FileExistsError: If the path exists and *overwrite* is false.
"""

View File

@@ -0,0 +1,88 @@
"""The default store: the local filesystem, sandboxed to a base directory."""
from __future__ import annotations
from contextlib import AbstractContextManager
import os
from pathlib import Path
from typing import TextIO
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
validate_file_path,
)
class LocalFileStore:
"""Reads and writes the local filesystem.
Containment is :func:`validate_file_path`: a resolved path must stay
inside ``base_dir`` (the working directory by default), with symlinks and
``..`` segments resolved first.
"""
label = "local filesystem"
def resolve(self, path: str, base_dir: str | None = None) -> str:
"""Resolve *path*, confining it to *base_dir*."""
return validate_file_path(path, base_dir)
def normalize(self, path: str, base_dir: str | None = None) -> str:
"""Resolve *path* the way the sandbox does, without rejecting it.
``validate_file_path`` and ``format_path_for_display`` both join a
relative path onto *base_dir* rather than the working directory.
Normalization has to agree with them, or the same relative string
would mean two different files.
"""
if os.path.isabs(path):
return os.path.realpath(path)
base = os.path.realpath(base_dir) if base_dir is not None else os.getcwd()
return os.path.realpath(os.path.join(base, path))
def resolve_within(self, directory: str, filename: str) -> str:
"""Join *filename* under *directory*, blocking every escape route.
``..``, absolute paths and symlinks are all resolved before the
check. ``is_relative_to`` compares whole path components, so it is
safe on case-insensitive filesystems and avoids the "//" prefix edge
case. A filename resolving to the directory itself (an empty
filename, say) is not a valid file target.
"""
root = Path(directory)
try:
resolved = Path(os.path.join(directory, filename)).resolve()
except (OSError, ValueError) as exc:
# e.g. an embedded null byte or an over-long name, which trip the
# underlying syscall. str() on an OSError carries the absolute
# filename, and the tools put this message straight into
# agent-visible output, so strip it back to the reason.
raise ValueError(format_error_for_display(exc)) from exc
if not resolved.is_relative_to(root) or resolved == root:
raise ValueError("the filename must not escape the target directory")
return str(resolved)
def display(self, resolved: str, base: str | None = None) -> str:
"""Return a path label with absolute prefixes stripped."""
return format_path_for_display(resolved, base)
def exists(self, resolved: str) -> bool:
return os.path.exists(resolved)
def ensure_parent(self, resolved: str) -> None:
"""Create the parent directory, including any missing ancestors."""
os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True)
def open_text(self, resolved: str, encoding: str) -> AbstractContextManager[TextIO]:
return open(resolved, "r", encoding=encoding)
def write_text(
self, resolved: str, content: str, encoding: str, *, overwrite: bool
) -> None:
# "x" makes the create-exclusive check atomic, so an existence race
# surfaces as FileExistsError rather than silently clobbering.
mode = "w" if overwrite else "x"
with open(resolved, mode, encoding=encoding) as handle:
handle.write(content)

View File

@@ -0,0 +1,74 @@
"""How a deployment swaps in a different store.
Kept as a process-wide factory rather than a tool argument on purpose: the
crews that need this are already written and deployed, and the point is that
they keep working unchanged when the runtime is ephemeral. An integration
package registers its factory at import time, and every file tool constructed
afterwards picks it up.
"""
from __future__ import annotations
from collections.abc import Callable
import logging
import threading
from crewai_tools.file_storage.base import FileStore
from crewai_tools.file_storage.local import LocalFileStore
logger = logging.getLogger(__name__)
#: A factory returns the store to use, or ``None`` to decline — which lets an
#: integration arm itself only when its backing service is actually
#: configured, and fall back to the local filesystem everywhere else.
FileStoreFactory = Callable[[], FileStore | None]
_lock = threading.Lock()
_factory: FileStoreFactory | None = None
_local = LocalFileStore()
def register_file_store_factory(factory: FileStoreFactory | None) -> None:
"""Install the factory consulted for every new file tool.
Args:
factory: Callable returning a :class:`FileStore`, or ``None`` to
decline and leave the local filesystem in place. Passing
``None`` as the factory itself unregisters.
"""
global _factory
with _lock:
_factory = factory
def reset_file_store_factory() -> None:
"""Drop any registered factory. Intended for tests."""
register_file_store_factory(None)
def resolve_file_store() -> FileStore:
"""Return the store the file tools should use.
A factory that raises is not allowed to take the tools down with it: an
integration failing to initialize should degrade to the local filesystem
— the behavior before it was installed — not break file I/O outright.
"""
with _lock:
factory = _factory
if factory is None:
return _local
try:
store = factory()
except Exception:
logger.warning(
"file store factory raised; falling back to the local filesystem",
exc_info=True,
)
return _local
if store is None:
return _local
return store

View File

@@ -5,7 +5,6 @@ import os
from typing import Any
from crewai.tools import BaseTool
from crewai.tools.tool_failure import ToolFailure
from crewai.utilities.pydantic_schema_utils import create_model_from_schema
from pydantic import Field, create_model
import requests
@@ -50,7 +49,7 @@ class CrewAIPlatformActionTool(BaseTool):
self.action_name = action_name
self.action_schema = action_schema
def _run(self, **kwargs: Any) -> Any:
def _run(self, **kwargs: Any) -> str:
try:
cleaned_kwargs = {
key: value for key, value in kwargs.items() if value is not None
@@ -86,20 +85,9 @@ class CrewAIPlatformActionTool(BaseTool):
error_message = str(error_info)
else:
error_message = str(data)
# A non-2xx here means the upstream app rejected the action
# (e.g. Slack's channel_not_found) -- report it, not prose.
return ToolFailure(
message=f"API request failed: {error_message}",
code=str(response.status_code),
retryable=response.status_code >= 500,
details={"action": self.action_name},
)
return f"API request failed: {error_message}"
return json.dumps(data, indent=2)
except Exception as e:
return ToolFailure(
message=f"Error executing action {self.action_name}: {e!s}",
code=e.__class__.__name__,
details={"action": self.action_name},
)
return f"Error executing action {self.action_name}: {e!s}"

View File

@@ -1,38 +1,16 @@
from itertools import islice
import os
from typing import Any
from crewai.tools import BaseTool
from pydantic import BaseModel, Field, PrivateAttr
from crewai_tools.file_storage import FileStore, resolve_file_store
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
format_sandbox_error,
validate_file_path,
)
def _resolve_against_base(path: str, base_dir: str | None) -> str:
"""Resolve *path* the way the sandbox does, anchoring relatives to *base_dir*.
``validate_file_path`` and ``format_path_for_display`` both join a relative
path onto *base_dir* rather than the working directory. Resolution has to
agree with them, or the same relative string would mean two different files.
Args:
path: The path to resolve.
base_dir: The anchor for relative paths. Defaults to the working directory.
Returns:
The resolved absolute path.
"""
if os.path.isabs(path):
return os.path.realpath(path)
base = os.path.realpath(base_dir) if base_dir is not None else os.getcwd()
return os.path.realpath(os.path.join(base, path))
class FileReadToolSchema(BaseModel):
"""Input for FileReadTool."""
@@ -103,6 +81,9 @@ class FileReadTool(BaseTool):
_declared_realpath: str | None = PrivateAttr(default=None)
# The label the tool's description shows the LLM for the declared file.
_declared_label: str | None = PrivateAttr(default=None)
# Resolved once per tool: a deployment installs its store before the crew
# is built, and swapping mid-run would change where a path points.
_store: FileStore = PrivateAttr(default=None) # type: ignore[assignment]
def __init__(
self,
@@ -121,14 +102,16 @@ class FileReadTool(BaseTool):
encoding (str): Text encoding used to decode the file.
**kwargs: Additional keyword arguments passed to BaseTool.
"""
store = resolve_file_store()
# Anchor base_dir once, so the sandbox root cannot move under a later
# chdir while the declared file stays pinned to its original location.
if base_dir is not None:
base_dir = os.path.realpath(base_dir)
base_dir = store.normalize(base_dir)
display_path = None
if file_path is not None:
display_path = format_path_for_display(file_path, base_dir)
display_path = store.display(store.normalize(file_path, base_dir), base_dir)
kwargs["description"] = (
f"A tool that reads file content. The default file is {display_path}, which is read when 'file_path' is omitted. You can also provide a different 'file_path' parameter to read another file, though reads are confined to the tool's allowed directory and a path that resolves outside it is rejected. Specify 'start_line' and 'line_count' to read specific parts of the file."
)
@@ -137,10 +120,9 @@ class FileReadTool(BaseTool):
self.file_path = file_path
self.base_dir = base_dir
self.encoding = encoding
self._store = store
self._declared_realpath = (
_resolve_against_base(file_path, base_dir)
if file_path is not None
else None
store.normalize(file_path, base_dir) if file_path is not None else None
)
self._declared_label = display_path
@@ -166,10 +148,10 @@ class FileReadTool(BaseTool):
declared = self._declared_realpath
if declared is not None and (
file_path == self._declared_label
or _resolve_against_base(file_path, self.base_dir) == declared
or self._store.normalize(file_path, self.base_dir) == declared
):
return declared
return validate_file_path(file_path, self.base_dir)
return self._store.resolve(file_path, self.base_dir)
def _run(
self,
@@ -195,9 +177,10 @@ class FileReadTool(BaseTool):
"directory tree.",
)
display_path = format_path_for_display(file_path, self.base_dir)
store = self._store
display_path = store.display(file_path, self.base_dir)
try:
with open(file_path, "r", encoding=self.encoding) as file:
with store.open_text(file_path, self.encoding) as file:
if start_line == 1 and line_count is None:
return file.read()

View File

@@ -1,14 +1,10 @@
import os
from pathlib import Path
from crewai.tools import BaseTool
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, PrivateAttr
from crewai_tools.file_storage import FileStore, resolve_file_store
from crewai_tools.security.safe_path import (
format_error_for_display,
format_path_for_display,
format_sandbox_error,
validate_file_path,
)
@@ -88,11 +84,24 @@ class FileWriterTool(BaseTool):
base_dir: str | None = None
encoding: str = "utf-8"
@field_validator("base_dir")
@classmethod
def _anchor_base_dir(cls, value: str | None) -> str | None:
"""Resolve base_dir once so a later chdir cannot move the sandbox."""
return os.path.realpath(value) if value is not None else None
# Resolved once per tool: a deployment installs its store before the crew
# is built, and swapping mid-run would change where a path points.
_store: FileStore = PrivateAttr(default=None) # type: ignore[assignment]
def model_post_init(self, context: object) -> None:
"""Bind the store, then anchor base_dir with the store's own grammar.
Anchoring cannot be a field validator: validators run before
``model_post_init``, so ``_store`` is not bound yet and the only option
there is ``os.path.realpath`` — local-filesystem semantics applied to a
path a remote store may not interpret that way at all. Doing it here
keeps every path decision inside the seam, and still resolves once so a
later chdir cannot move the sandbox.
"""
super().model_post_init(context)
self._store = resolve_file_store()
if self.base_dir is not None:
self.base_dir = self._store.normalize(self.base_dir)
def _run(
self,
@@ -109,11 +118,13 @@ class FileWriterTool(BaseTool):
except ValueError as e:
return f"An error occurred while writing to the file: {e!s}"
store = self._store
# Confine the target directory to base_dir so an LLM-chosen directory
# cannot reach outside the sandbox. validate_file_path also resolves
# symlinks and ".." components.
# cannot reach outside the sandbox. The store also resolves symlinks
# and ".." components before checking.
try:
resolved_directory = Path(validate_file_path(directory, self.base_dir))
resolved_directory = store.resolve(directory, self.base_dir)
except ValueError as e:
return "Error: Invalid directory: " + format_sandbox_error(
e,
@@ -121,31 +132,17 @@ class FileWriterTool(BaseTool):
"directory tree.",
)
# Keep filename inside the target directory, blocking "..", absolute
# paths and symlink escapes. is_relative_to() compares whole path
# components, so it is safe on case-insensitive filesystems and avoids
# the "//" prefix edge case. A filepath that resolves to the directory
# itself (e.g. an empty filename) is not a valid file target.
# Then keep filename inside that directory.
try:
resolved_filepath = Path(
os.path.join(resolved_directory, filename)
).resolve()
except (OSError, ValueError) as e:
# e.g. an embedded null byte, which trips the underlying syscall.
return f"Error: Invalid file path: {format_error_for_display(e)}"
resolved_filepath = store.resolve_within(resolved_directory, filename)
except ValueError as e:
return f"Error: Invalid file path — {e!s}"
display_filepath = format_path_for_display(
str(resolved_filepath), str(resolved_directory)
)
if (
not resolved_filepath.is_relative_to(resolved_directory)
or resolved_filepath == resolved_directory
):
return "Error: Invalid file path — the filename must not escape the target directory."
display_filepath = store.display(resolved_filepath, resolved_directory)
# Covers both a missing 'directory' and subdirectories inside 'filename'.
try:
os.makedirs(resolved_filepath.parent, exist_ok=True)
store.ensure_parent(resolved_filepath)
except FileExistsError:
return (
f"Error: Cannot write to {display_filepath} because a file already "
@@ -157,13 +154,16 @@ class FileWriterTool(BaseTool):
f"{format_error_for_display(e)}"
)
if resolved_filepath.exists() and not overwrite_file:
if store.exists(resolved_filepath) and not overwrite_file:
return f"File {display_filepath} already exists and overwrite option was not passed."
mode = "w" if overwrite_file else "x"
try:
with open(resolved_filepath, mode, encoding=self.encoding) as file:
file.write(content)
store.write_text(
resolved_filepath,
content,
self.encoding,
overwrite=overwrite_file,
)
except FileExistsError:
return f"File {display_filepath} already exists and overwrite option was not passed."
except Exception as e:

View File

@@ -0,0 +1,279 @@
"""The file tools must route all I/O through the registered store.
These tests stand in a store that keeps everything in a dict, with no
filesystem behind it at all. If a tool reaches past the seam to `open()` or
`os.path` the assertions fail, which is the point: it is the guarantee any
non-filesystem store depends on.
"""
from __future__ import annotations
import io
import posixpath
from crewai_tools import FileReadTool, FileWriterTool
from crewai_tools.file_storage import (
FileStore,
register_file_store_factory,
reset_file_store_factory,
resolve_file_store,
)
from crewai_tools.file_storage.local import LocalFileStore
import pytest
class MemoryFileStore:
"""A store with no filesystem: paths are keys in a dict.
Modelled on a remote object store — POSIX-ish paths, a flat namespace, no
symlinks, and containment by prefix rather than by `realpath`.
"""
label = "memory"
def __init__(self, root: str = "/ws") -> None:
self.root = root
self.files: dict[str, str] = {}
self.dirs: set[str] = {root}
def _abs(self, path: str, base_dir: str | None = None) -> str:
base = base_dir or self.root
joined = path if path.startswith("/") else posixpath.join(base, path)
return posixpath.normpath(joined)
def normalize(self, path: str, base_dir: str | None = None) -> str:
return self._abs(path, base_dir)
def resolve(self, path: str, base_dir: str | None = None) -> str:
resolved = self._abs(path, base_dir)
# Confine to base_dir when one is given, else to the store root. A
# remote store has to honour base_dir the same way the local one does,
# or the tools' sandbox argument would silently mean nothing.
root = posixpath.normpath(base_dir) if base_dir else self.root
if resolved != root and not resolved.startswith(root.rstrip("/") + "/"):
raise ValueError(
f"Path '{posixpath.basename(resolved)}' is outside the allowed "
f"directory."
)
return resolved
def resolve_within(self, directory: str, filename: str) -> str:
resolved = self._abs(filename, directory)
if resolved == directory or not resolved.startswith(directory + "/"):
raise ValueError("the filename must not escape the target directory")
return resolved
def display(self, resolved: str, base: str | None = None) -> str:
base = base or self.root
if resolved.startswith(base + "/"):
return resolved[len(base) + 1 :]
return posixpath.basename(resolved)
def exists(self, resolved: str) -> bool:
return resolved in self.files
def ensure_parent(self, resolved: str) -> None:
parent = posixpath.dirname(resolved)
if parent in self.files:
raise FileExistsError(parent)
self.dirs.add(parent)
def open_text(self, resolved: str, encoding: str):
if resolved not in self.files:
raise FileNotFoundError(resolved)
return io.StringIO(self.files[resolved])
def write_text(
self, resolved: str, content: str, encoding: str, *, overwrite: bool
) -> None:
if resolved in self.files and not overwrite:
raise FileExistsError(resolved)
self.files[resolved] = content
@pytest.fixture
def store():
memory = MemoryFileStore()
register_file_store_factory(lambda: memory)
yield memory
reset_file_store_factory()
def test_default_store_is_local():
assert isinstance(resolve_file_store(), LocalFileStore)
def test_memory_store_satisfies_the_protocol(store):
assert isinstance(store, FileStore)
def test_writer_writes_through_the_store(store, tmp_path, monkeypatch):
# cwd is a real, empty directory: nothing may touch it.
monkeypatch.chdir(tmp_path)
result = FileWriterTool()._run(
filename="report.md", content="# Report", overwrite=True
)
assert "successfully written" in result
assert store.files == {"/ws/report.md": "# Report"}
assert list(tmp_path.iterdir()) == []
def test_reader_reads_through_the_store(store, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
store.files["/ws/notes.txt"] = "line 1\nline 2\nline 3\n"
assert FileReadTool()._run(file_path="notes.txt") == "line 1\nline 2\nline 3\n"
def test_round_trip_between_the_two_tools(store, tmp_path, monkeypatch):
"""The reader must see what the writer wrote — the asymmetry that makes
an ephemeral runtime unusable."""
monkeypatch.chdir(tmp_path)
FileWriterTool()._run(filename="out/data.csv", content="a,b\n1,2\n", overwrite=True)
read_back = FileReadTool()._run(file_path="out/data.csv")
assert read_back == "a,b\n1,2\n"
def test_line_windows_work_on_a_remote_store(store, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
store.files["/ws/big.log"] = "".join(f"L{i}\n" for i in range(1, 101))
assert (
FileReadTool()._run(file_path="big.log", start_line=3, line_count=2)
== "L3\nL4\n"
)
def test_store_containment_is_honoured(store, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = FileReadTool()._run(file_path="/etc/passwd")
assert "Invalid file path" in result
assert "base_dir" in result
def test_writer_containment_is_honoured(store, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = FileWriterTool()._run(
filename="../escape.txt", content="x", overwrite=True
)
assert "Error" in result
assert store.files == {}
def test_overwrite_false_is_reported_by_the_store(store, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
tool = FileWriterTool()
assert "successfully written" in tool._run(filename="a.txt", content="one")
assert "already exists" in tool._run(filename="a.txt", content="two")
assert store.files["/ws/a.txt"] == "one"
def test_directory_that_is_a_file_reports_clearly(store, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
store.files["/ws/notadir"] = "x"
result = FileWriterTool()._run(
filename="f.txt", directory="notadir", content="x", overwrite=True
)
assert "a file already exists where a directory is needed" in result
def test_factory_returning_none_falls_back_to_local():
register_file_store_factory(lambda: None)
try:
assert isinstance(resolve_file_store(), LocalFileStore)
finally:
reset_file_store_factory()
def test_factory_that_raises_falls_back_to_local(caplog):
"""A broken integration must not take file I/O down with it."""
def boom() -> FileStore | None:
raise RuntimeError("backing service unreachable")
register_file_store_factory(boom)
try:
assert isinstance(resolve_file_store(), LocalFileStore)
finally:
reset_file_store_factory()
assert "falling back to the local filesystem" in caplog.text
def test_store_is_bound_once_per_tool(store, tmp_path, monkeypatch):
"""Swapping the factory mid-run must not move an existing tool's files."""
monkeypatch.chdir(tmp_path)
tool = FileWriterTool()
other = MemoryFileStore(root="/other")
register_file_store_factory(lambda: other)
tool._run(filename="a.txt", content="one", overwrite=True)
assert store.files == {"/ws/a.txt": "one"}
assert other.files == {}
def test_writer_anchors_base_dir_through_the_store(store, tmp_path, monkeypatch):
"""base_dir must be normalised by the store, not by os.path.realpath.
A remote store's paths are not filesystem paths, so anchoring with local
semantics would compute a sandbox root that its own resolve()/
resolve_within() do not agree with.
"""
monkeypatch.chdir(tmp_path)
tool = FileWriterTool(base_dir="scoped")
# The memory store roots at /ws, so its normalisation is what must show up.
assert tool.base_dir == "/ws/scoped"
assert str(tmp_path) not in str(tool.base_dir)
def test_writer_base_dir_confines_writes_under_a_remote_store(
store, tmp_path, monkeypatch
):
monkeypatch.chdir(tmp_path)
tool = FileWriterTool(base_dir="scoped")
assert "successfully written" in tool._run(
filename="in.txt", content="x", overwrite=True
)
assert "/ws/scoped/in.txt" in store.files
escaped = tool._run(filename="x.txt", directory="/ws/elsewhere", content="x")
assert "Error" in escaped
def test_reader_and_writer_agree_on_a_relative_base_dir(store, tmp_path, monkeypatch):
"""Both tools have to land on the same sandbox root for the same input."""
monkeypatch.chdir(tmp_path)
assert FileWriterTool(base_dir="shared").base_dir == (
FileReadTool(base_dir="shared").base_dir
)
def test_os_error_message_does_not_leak_an_absolute_path(tmp_path, monkeypatch):
"""resolve_within wraps OSError, and the writer returns that text verbatim.
str() on an OSError carries the absolute filename, so it has to be reduced
to the reason before it reaches an agent.
"""
monkeypatch.chdir(tmp_path)
result = FileWriterTool()._run(
filename="a\x00b.txt", content="x", overwrite=True
)
assert "Error" in result
assert str(tmp_path) not in result

File diff suppressed because it is too large Load Diff

View File

@@ -83,15 +83,9 @@ from crewai.mcp.config import MCPServerConfig
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.security.fingerprint import Fingerprint
from crewai.skills.loader import load_skills
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
from crewai.skills.models import Skill as SkillModel
from crewai.state.checkpoint_config import CheckpointConfig, apply_checkpoint
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.tools.tool_failure import (
ToolExecutionFailedError,
ToolFailureRecord,
merge_tool_failures,
tool_failure_collector,
)
from crewai.types.callback import SerializableCallable
from crewai.types.usage_metrics import UsageMetrics
from crewai.utilities.agent_utils import (
@@ -137,9 +131,7 @@ if TYPE_CHECKING:
from crewai.utilities.types import LLMMessage
# Deliberate stops, not transient errors: never swallowed into the
# max_retry_limit loop.
_passthrough_exceptions: tuple[type[Exception], ...] = (ToolExecutionFailedError,)
_passthrough_exceptions: tuple[type[Exception], ...] = ()
_EXECUTOR_CLASS_MAP: dict[str, type] = {
"CrewAgentExecutor": CrewAgentExecutor,
@@ -488,32 +480,9 @@ class Agent(BaseAgent):
self.skills = cast(
list[Path | SkillModel | str] | None,
load_skills(items, source=self, activate=False) or None,
load_skills(items, source=self) or None,
)
def _add_skill_loader_tool(
self,
tools: list[BaseTool],
task: Task | None = None,
) -> list[BaseTool]:
"""Add the internal loader used for request-scoped skill disclosure."""
from crewai.skills.tool import LoadSkillTool, create_skill_loader_tool
tools = [tool for tool in tools if not isinstance(tool, LoadSkillTool)]
skill_models = [
skill for skill in self.skills or [] if isinstance(skill, SkillModel)
]
loader = create_skill_loader_tool(
skill_models,
source=self,
task=task,
reserved_names=[tool.name for tool in tools],
)
if loader is None:
return tools
return [*tools, loader]
def _is_any_available_memory(self) -> bool:
"""Check if unified memory is available (agent or crew)."""
if getattr(self, "memory", None):
@@ -558,8 +527,6 @@ class Agent(BaseAgent):
self._inject_date_to_task(task)
self.reset_tool_failures()
if self.tools_handler:
self.tools_handler.last_used_tool = None
@@ -590,11 +557,11 @@ class Agent(BaseAgent):
return apply_training_data(self, task_prompt)
def _emit_skill_usage(self, task: Task) -> None:
"""Emit usage for always-on skills injected into this task's prompt.
"""Emit one SkillUsedEvent per skill injected into this task's prompt.
Metadata-only skills emit from ``LoadSkillTool`` if the model selects
them. This method covers explicitly activated and inline skills, whose
instructions are rendered on every execution.
Skills are agent-scoped and rendered into the prompt on every execution,
so this is the runtime usage signal traces need — attributing each skill
to the agent and task that used it.
Args:
task: The task whose prompt the skills are being applied to.
@@ -603,10 +570,7 @@ class Agent(BaseAgent):
return
for skill in self.skills:
if (
not isinstance(skill, SkillModel)
or skill.disclosure_level < INSTRUCTIONS
):
if not isinstance(skill, SkillModel):
continue
crewai_event_bus.emit(
self,
@@ -924,11 +888,6 @@ class Agent(BaseAgent):
raise TimeoutError(
f"Task '{task.description}' execution timed out after {timeout} seconds. Consider increasing max_execution_time or optimizing the task."
) from e
except _passthrough_exceptions:
# Wrapping a deliberate stop in RuntimeError would hide it from
# _check_execution_error and trigger the retry loop instead.
future.cancel()
raise
except Exception as e:
future.cancel()
raise RuntimeError(f"Task execution failed: {e!s}") from e
@@ -1091,8 +1050,6 @@ class Agent(BaseAgent):
Returns:
A tuple of (prompt, stop_words, rpm_limit_fn).
"""
from crewai.skills.tool import LoadSkillTool
use_native_tool_calling = self._supports_native_tool_calling(raw_tools)
prompt = Prompts(
@@ -1103,10 +1060,6 @@ class Agent(BaseAgent):
system_template=self.system_template,
prompt_template=self.prompt_template,
response_template=self.response_template,
skill_loader_tool_name=next(
(tool.name for tool in raw_tools if isinstance(tool, LoadSkillTool)),
None,
),
).task_execution()
stop_words = [I18N_DEFAULT.slice("observation")]
@@ -1129,8 +1082,7 @@ class Agent(BaseAgent):
Returns:
An instance of the CrewAgentExecutor class.
"""
configured_tools = tools if tools is not None else self.tools or []
raw_tools = self._add_skill_loader_tool(list(configured_tools), task=task)
raw_tools: list[BaseTool] = tools or self.tools or []
parsed_tools = parse_tools(raw_tools)
prompt, stop_words, rpm_limit_fn = self._build_execution_prompt(raw_tools)
@@ -1469,11 +1421,6 @@ class Agent(BaseAgent):
Returns:
Tuple of (executor, inputs, agent_info, parsed_tools) ready for execution.
"""
self.reset_tool_failures()
if self.tools_handler:
self.tools_handler.last_used_tool = None
if self.apps:
platform_tools = self.get_platform_tools(self.apps)
if platform_tools:
@@ -1487,7 +1434,7 @@ class Agent(BaseAgent):
self.tools = []
self.tools.extend(mcps)
raw_tools = list(self.tools or [])
raw_tools: list[BaseTool] = self.tools or []
agent_memory = getattr(self, "memory", None)
if agent_memory is not None:
@@ -1500,7 +1447,6 @@ class Agent(BaseAgent):
if sanitize_tool_name(mt.name) not in existing_names
)
raw_tools = self._add_skill_loader_tool(raw_tools)
parsed_tools = parse_tools(raw_tools)
agent_info = {
@@ -1803,7 +1749,6 @@ class Agent(BaseAgent):
executor: AgentExecutor,
response_format: type[Any] | None = None,
usage_baseline: UsageMetrics | None = None,
kickoff_failures: list[ToolFailureRecord] | None = None,
) -> LiteAgentOutput:
"""Build a LiteAgentOutput from an executor result dict.
@@ -1884,7 +1829,6 @@ class Agent(BaseAgent):
todos=todo_results,
replan_count=executor.state.replan_count,
last_replan_reason=executor.state.last_replan_reason,
tool_failures=list(kickoff_failures or []),
)
def _execute_and_build_output(
@@ -1895,10 +1839,9 @@ class Agent(BaseAgent):
usage_baseline: UsageMetrics | None = None,
) -> LiteAgentOutput:
"""Execute the agent synchronously and build the output object."""
with tool_failure_collector() as kickoff_failures:
result = cast(dict[str, Any], executor.invoke(inputs))
result = cast(dict[str, Any], executor.invoke(inputs))
return self._build_output_from_result(
result, executor, response_format, usage_baseline, kickoff_failures
result, executor, response_format, usage_baseline
)
async def _execute_and_build_output_async(
@@ -1909,10 +1852,9 @@ class Agent(BaseAgent):
usage_baseline: UsageMetrics | None = None,
) -> LiteAgentOutput:
"""Execute the agent asynchronously and build the output object."""
with tool_failure_collector() as kickoff_failures:
result = await executor.invoke_async(inputs)
result = await executor.invoke_async(inputs)
return self._build_output_from_result(
result, executor, response_format, usage_baseline, kickoff_failures
result, executor, response_format, usage_baseline
)
def _process_kickoff_guardrail(
@@ -1972,15 +1914,9 @@ class Agent(BaseAgent):
role="user",
)
retried = self._execute_and_build_output(
output = self._execute_and_build_output(
executor, inputs, response_format, usage_baseline
)
# The retry opens its own collector, so carry the blocked attempt's
# failures forward or they vanish from the final output.
retried.tool_failures = merge_tool_failures(
output.tool_failures, retried.tool_failures
)
output = retried
return self._process_kickoff_guardrail(
output=output,

View File

@@ -44,11 +44,6 @@ from crewai.security.security_config import SecurityConfig
from crewai.skills.models import Skill
from crewai.state.checkpoint_config import CheckpointConfig, _coerce_checkpoint
from crewai.tools.base_tool import BaseTool, Tool
from crewai.tools.tool_failure import (
ToolFailurePolicy,
ToolFailureRecord,
collect_tool_failures,
)
from crewai.types.callback import SerializableCallable
from crewai.utilities.config import process_config
from crewai.utilities.i18n import I18N, get_i18n
@@ -269,7 +264,6 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
_original_backstory: str | None = PrivateAttr(default=None)
_token_process: TokenProcess = PrivateAttr(default_factory=TokenProcess)
_kickoff_event_id: str | None = PrivateAttr(default=None)
_tool_failures: list[ToolFailureRecord] = PrivateAttr(default_factory=list)
id: UUID4 = Field(default_factory=uuid.uuid4, frozen=True)
role: str = Field(description="Role of the agent")
goal: str = Field(description="Objective of the agent")
@@ -304,15 +298,6 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
max_iter: int = Field(
default=25, description="Maximum iterations for an agent to execute a task"
)
tool_failure_policy: ToolFailurePolicy | None = Field(
default=None,
description=(
"How to react when a tool completes but reports that it failed. "
"'ignore' records nothing; 'warn' records and emits "
"ToolFailureDetectedEvent; 'raise' also aborts with "
"ToolExecutionFailedError. None inherits from the crew, then 'warn'."
),
)
agent_executor: Annotated[
SerializeAsAny[BaseAgentExecutor] | None,
BeforeValidator(_validate_executor_ref),
@@ -667,22 +652,6 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
]
return md5("|".join(source).encode(), usedforsecurity=False).hexdigest()
@property
def last_tool_failures(self) -> list[ToolFailureRecord]:
"""Tool failures recorded during the most recent execution.
Inside an execution this reports that execution's records, so a
shared agent running concurrent tasks does not leak between them.
Outside one it reports the most recent execution, like
``last_messages``. Empty when nothing failed or the policy is
``ignore``. Returns a copy.
"""
return collect_tool_failures(self)
def reset_tool_failures(self) -> None:
"""Clear recorded tool failures before a new execution begins."""
self._tool_failures = []
@abstractmethod
def execute_task(
self,

View File

@@ -23,10 +23,6 @@ class CacheHandler(BaseModel):
def add(self, tool: str, input: str, output: Any) -> None:
"""Add a tool result to the cache.
Declared failures are never stored: replaying one would make a
transient error permanent for the rest of the run, and every later hit
would re-report a call that did not run.
Args:
tool: Name of the tool.
input: Input string used for the tool.
@@ -35,11 +31,6 @@ class CacheHandler(BaseModel):
Notes:
- TODO: Rename 'input' parameter to avoid shadowing builtin.
"""
from crewai.tools.tool_failure import ToolFailure
if isinstance(output, ToolFailure):
return
with self._lock.w_locked():
self._cache[f"{tool}-{input}"] = output

View File

@@ -28,7 +28,6 @@ from crewai.events.types.tool_usage_events import (
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
)
from crewai.tools.tool_failure import ToolExecutionFailedError
from crewai.utilities.agent_utils import (
build_text_tool_calling_fallback_message,
build_tool_calls_assistant_message,
@@ -181,11 +180,6 @@ class StepExecutor:
tool_calls_made=tool_calls_made,
execution_time=elapsed,
)
except ToolExecutionFailedError:
# A deliberate stop: StepResult(success=False) would let the plan
# carry on.
raise
except Exception as e:
if self._use_native_tools and is_native_tool_calling_unsupported_error(e):
try:
@@ -224,11 +218,6 @@ class StepExecutor:
tool_calls_made=tool_calls_made,
execution_time=elapsed,
)
except ToolExecutionFailedError:
# Same as the outer handler, reached via the text-tooling
# fallback.
raise
except Exception as fallback_error:
e = fallback_error

View File

@@ -116,7 +116,6 @@ from crewai.tasks.task_output import TaskOutput
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.tools.agent_tools.read_file_tool import ReadFileTool
from crewai.tools.base_tool import BaseTool
from crewai.tools.tool_failure import ToolFailurePolicy
from crewai.types.callback import SerializableCallable
from crewai.types.streaming import CrewStreamingOutput
from crewai.types.usage_metrics import UsageMetrics
@@ -232,13 +231,6 @@ class Crew(FlowTrackable, BaseModel):
"unless they set a cache_function that prevents caching."
),
)
tool_failure_policy: ToolFailurePolicy | None = Field(
default=None,
description=(
"Baseline tool_failure_policy for every agent in this crew. None "
"means 'warn'. Agents, tasks and tools may override it."
),
)
tasks: list[Task] = Field(default_factory=list)
agents: Annotated[
list[BaseAgent],

View File

@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field
from crewai.tasks.output_format import OutputFormat
from crewai.tasks.task_output import TaskOutput
from crewai.tools.tool_failure import ToolFailureRecord
from crewai.types.usage_metrics import UsageMetrics
@@ -32,20 +31,6 @@ class CrewOutput(BaseModel):
default_factory=UsageMetrics,
)
@property
def tool_failures(self) -> list[ToolFailureRecord]:
"""Every tool failure recorded across all tasks, in task order.
A crew can finish successfully with a non-empty list -- agents narrate a
failed step and carry on. Check it before treating ``raw`` as complete.
"""
return [failure for task in self.tasks_output for failure in task.tool_failures]
@property
def has_tool_failures(self) -> bool:
"""Whether any tool reported a failure during this crew run."""
return any(task.tool_failures for task in self.tasks_output)
@property
def usage_metrics(self) -> dict[str, Any]:
"""Token usage as a plain dict.

View File

@@ -12,8 +12,8 @@ from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crews.crew_output import CrewOutput
from crewai.llms.base_llm import BaseLLM
from crewai.rag.embeddings.types import EmbedderConfig
from crewai.skills.loader import load_skills
from crewai.skills.models import Skill as SkillModel
from crewai.skills.loader import activate_skill, load_skills
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput
from crewai.utilities.file_store import store_files
from crewai.utilities.streaming import (
@@ -59,7 +59,13 @@ def _resolve_crew_skills(crew: Crew) -> list[SkillModel] | None:
if not isinstance(crew.skills, list) or not crew.skills:
return None
return load_skills(crew.skills, activate=False) or None
resolved = load_skills(crew.skills)
if not resolved:
return None
return [
activate_skill(skill) if skill.disclosure_level < INSTRUCTIONS else skill
for skill in resolved
]
def setup_agents(

View File

@@ -68,7 +68,6 @@ if TYPE_CHECKING:
ConversationTurnStartedEvent,
FlowCreatedEvent,
FlowEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowPlotEvent,
FlowStartedEvent,
@@ -148,7 +147,6 @@ if TYPE_CHECKING:
)
from crewai.events.types.tool_usage_events import (
ToolExecutionErrorEvent,
ToolFailureDetectedEvent,
ToolSelectionErrorEvent,
ToolUsageErrorEvent,
ToolUsageEvent,
@@ -196,7 +194,6 @@ _LAZY_EVENT_MAPPING: dict[str, str] = {
"ConversationTurnStartedEvent": "crewai.events.types.flow_events",
"FlowCreatedEvent": "crewai.events.types.flow_events",
"FlowEvent": "crewai.events.types.flow_events",
"FlowFailedEvent": "crewai.events.types.flow_events",
"FlowFinishedEvent": "crewai.events.types.flow_events",
"FlowPlotEvent": "crewai.events.types.flow_events",
"FlowStartedEvent": "crewai.events.types.flow_events",
@@ -254,7 +251,6 @@ _LAZY_EVENT_MAPPING: dict[str, str] = {
"TaskFailedEvent": "crewai.events.types.task_events",
"TaskStartedEvent": "crewai.events.types.task_events",
"ToolExecutionErrorEvent": "crewai.events.types.tool_usage_events",
"ToolFailureDetectedEvent": "crewai.events.types.tool_usage_events",
"ToolSelectionErrorEvent": "crewai.events.types.tool_usage_events",
"ToolUsageErrorEvent": "crewai.events.types.tool_usage_events",
"ToolUsageEvent": "crewai.events.types.tool_usage_events",
@@ -333,7 +329,6 @@ __all__ = [
"Depends",
"FlowCreatedEvent",
"FlowEvent",
"FlowFailedEvent",
"FlowFinishedEvent",
"FlowPlotEvent",
"FlowStartedEvent",
@@ -389,7 +384,6 @@ __all__ = [
"TaskFailedEvent",
"TaskStartedEvent",
"ToolExecutionErrorEvent",
"ToolFailureDetectedEvent",
"ToolSelectionErrorEvent",
"ToolUsageErrorEvent",
"ToolUsageEvent",

View File

@@ -269,7 +269,6 @@ SCOPE_STARTING_EVENTS: frozenset[str] = frozenset(
SCOPE_ENDING_EVENTS: frozenset[str] = frozenset(
{
"flow_finished",
"flow_failed",
"flow_paused",
"method_execution_finished",
"method_execution_failed",
@@ -321,7 +320,6 @@ SCOPE_ENDING_EVENTS: frozenset[str] = frozenset(
VALID_EVENT_PAIRS: dict[str, str] = {
"flow_finished": "flow_started",
"flow_failed": "flow_started",
"flow_paused": "flow_started",
"method_execution_finished": "method_execution_started",
"method_execution_failed": "method_execution_started",

View File

@@ -43,7 +43,6 @@ from crewai.events.types.env_events import (
from crewai.events.types.flow_events import (
ConversationTurnCompletedEvent,
FlowCreatedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowPausedEvent,
FlowStartedEvent,
@@ -114,7 +113,6 @@ from crewai.events.types.task_events import (
TaskStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolFailureDetectedEvent,
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
@@ -320,15 +318,6 @@ class EventListener(BaseEventListener):
source.flow_id,
)
@crewai_event_bus.on(FlowFailedEvent)
def on_flow_failed(source: Any, event: FlowFailedEvent) -> None:
if not getattr(source, "suppress_flow_events", False):
self.formatter.handle_flow_status(
event.flow_name,
source.flow_id,
"failed",
)
@crewai_event_bus.on(ConversationTurnCompletedEvent)
def on_conversation_turn_completed(
_: Any, event: ConversationTurnCompletedEvent
@@ -425,8 +414,6 @@ class EventListener(BaseEventListener):
@crewai_event_bus.on(ToolUsageFinishedEvent)
def on_tool_usage_finished(source: Any, event: ToolUsageFinishedEvent) -> None:
if not self.formatter.should_render_success_panel(event.failure):
return
if isinstance(source, LLM):
self.formatter.handle_llm_tool_usage_finished(
event.tool_name,
@@ -452,18 +439,6 @@ class EventListener(BaseEventListener):
event.run_attempts,
)
@crewai_event_bus.on(ToolFailureDetectedEvent)
def on_tool_failure_detected(
source: Any, event: ToolFailureDetectedEvent
) -> None:
if not self.formatter.should_render_failure_panel(event.failure):
return
self.formatter.handle_tool_failure_detected(
event.tool_name,
event.failure,
event.policy,
)
@crewai_event_bus.on(LLMCallStartedEvent)
def on_llm_call_started(_: Any, event: LLMCallStartedEvent) -> None:
self.text_stream = StringIO()

View File

@@ -58,7 +58,6 @@ from crewai.events.types.flow_events import (
ConversationTurnCompletedEvent,
ConversationTurnFailedEvent,
ConversationTurnStartedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowStartedEvent,
MethodExecutionFailedEvent,
@@ -118,7 +117,6 @@ from crewai.events.types.task_events import (
TaskStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolFailureDetectedEvent,
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
@@ -172,14 +170,12 @@ EventTypes = (
| ConversationTurnStartedEvent
| FlowStartedEvent
| FlowFinishedEvent
| FlowFailedEvent
| MethodExecutionStartedEvent
| MethodExecutionFinishedEvent
| MethodExecutionFailedEvent
| AgentExecutionErrorEvent
| ToolUsageFinishedEvent
| ToolUsageErrorEvent
| ToolFailureDetectedEvent
| ToolUsageStartedEvent
| LLMCallStartedEvent
| LLMCallCompletedEvent

View File

@@ -66,7 +66,6 @@ from crewai.events.types.flow_events import (
ConversationMessageAddedEvent,
ConversationRouteSelectedEvent,
FlowCreatedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowPlotEvent,
FlowStartedEvent,
@@ -127,7 +126,6 @@ from crewai.events.types.task_events import (
TaskStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolFailureDetectedEvent,
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
@@ -276,10 +274,6 @@ class TraceCollectionListener(BaseEventListener):
def on_flow_finished(source: Any, event: FlowFinishedEvent) -> None:
self._handle_trace_event("flow_finished", source, event)
@event_bus.on(FlowFailedEvent)
def on_flow_failed(source: Any, event: FlowFailedEvent) -> None:
self._handle_trace_event("flow_failed", source, event)
@event_bus.on(FlowPlotEvent)
def on_flow_plot(source: Any, event: FlowPlotEvent) -> None:
self._handle_action_event("flow_plot", source, event)
@@ -416,12 +410,6 @@ class TraceCollectionListener(BaseEventListener):
def on_tool_error(source: Any, event: ToolUsageErrorEvent) -> None:
self._handle_action_event("tool_usage_error", source, event)
@event_bus.on(ToolFailureDetectedEvent)
def on_tool_failure_detected(
source: Any, event: ToolFailureDetectedEvent
) -> None:
self._handle_action_event("tool_failure_detected", source, event)
@event_bus.on(MemoryQueryStartedEvent)
def on_memory_query_started(
source: Any, event: MemoryQueryStartedEvent

View File

@@ -94,24 +94,6 @@ class FlowFinishedEvent(FlowEvent):
state: dict[str, Any] | BaseModel
class FlowFailedEvent(FlowEvent):
"""Event emitted when a flow execution fails.
Attributes:
flow_name: Name of the flow that failed.
error: The exception that ended the execution.
"""
error: Exception
type: Literal["flow_failed"] = "flow_failed"
model_config = ConfigDict(arbitrary_types_allowed=True)
@field_serializer("error")
def _serialize_error(self, error: Exception) -> str:
return str(error)
class FlowPausedEvent(FlowEvent):
"""Event emitted when a flow is paused waiting for human feedback.

View File

@@ -66,8 +66,8 @@ class SkillUsedEvent(SkillEvent):
"""Event emitted when an agent uses a skill during task execution.
Discovery/load/activation events describe setup. This one is the runtime
signal: it fires when a metadata skill is selected or an always-on skill's
context is injected, so traces can attribute usage to an agent and task.
signal: it fires each time a skill's context is injected into an agent's
prompt for a task, so traces can attribute skill usage to an agent and task.
"""
type: Literal["skill_used"] = "skill_used"

View File

@@ -5,7 +5,6 @@ from typing import Any, Literal
from pydantic import ConfigDict
from crewai.events.base_events import BaseEvent
from crewai.tools.tool_failure import ToolFailure, ToolFailurePolicy
class ToolUsageEvent(BaseEvent):
@@ -67,11 +66,6 @@ class ToolUsageFinishedEvent(ToolUsageEvent):
finished_at: datetime
from_cache: bool = False
output: Any
failure: ToolFailure | None = None
"""Set when the tool ran but reported it did not succeed.
Lets a trace UI mark the call failed without correlating a second event.
"""
type: Literal["tool_usage_finished"] = "tool_usage_finished"
@@ -82,20 +76,6 @@ class ToolUsageErrorEvent(ToolUsageEvent):
type: Literal["tool_usage_error"] = "tool_usage_error"
class ToolFailureDetectedEvent(ToolUsageEvent):
"""Event emitted when a tool completed but reported that it failed.
Distinct from :class:`ToolUsageErrorEvent`, which covers a tool *raising*.
This is the quieter case: the call returned normally and says the work was
not done. Emitted for every policy except ``IGNORE``, and before a
``RAISE`` aborts, so subscribers see it even on an aborting run.
"""
failure: ToolFailure
policy: ToolFailurePolicy
type: Literal["tool_failure_detected"] = "tool_failure_detected"
class ToolValidateInputErrorEvent(ToolUsageEvent):
"""Event emitted when a tool input validation encounters an error"""

View File

@@ -12,7 +12,6 @@ from rich.live import Live
from rich.panel import Panel
from rich.text import Text
from crewai.tools.tool_failure import ToolFailureReason
from crewai.version import is_current_version_yanked, is_newer_version_available
@@ -493,55 +492,6 @@ To enable tracing, do any one of these:
content, f"✅ Tool Execution Completed (#{iteration})", "green"
)
@staticmethod
def should_render_success_panel(failure: Any) -> bool:
"""Whether a finished tool call should print the green panel.
A failed call must not read as successful, so the red panel replaces it.
"""
return failure is None
@staticmethod
def should_render_failure_panel(failure: Any) -> bool:
"""Whether a reported failure should print its own red panel.
A tool that *raised* already printed one via ``ToolUsageErrorEvent``,
so only the duplicate console output is skipped -- not the event.
"""
return getattr(failure, "reason", None) is not ToolFailureReason.EXCEPTION
def handle_tool_failure_detected(
self,
tool_name: str,
failure: Any,
policy: Any,
) -> None:
"""Render a tool that ran but reported it did not succeed.
The case that used to print as a green "Completed" panel.
"""
if not self.verbose:
return
with self._tool_counts_lock:
iteration = self.tool_usage_counts.get(tool_name, 1)
content = Text()
content.append("Tool Reported Failure\n", style="red bold")
content.append("Tool: ", style="white")
content.append(f"{tool_name}\n", style="red bold")
content.append("Reason: ", style="white")
content.append(f"{getattr(failure, 'reason', 'unknown')}\n", style="red")
if getattr(failure, "code", None):
content.append("Code: ", style="white")
content.append(f"{failure.code}\n", style="red")
content.append("Message: ", style="white")
content.append(f"{getattr(failure, 'message', failure)}\n", style="red")
content.append("Policy: ", style="white")
content.append(f"{getattr(policy, 'value', policy)}\n", style="red")
self.print_panel(content, f"⚠️ Tool Failure (#{iteration})", "red")
def handle_tool_usage_error(
self,
tool_name: str,

View File

@@ -73,15 +73,6 @@ from crewai.hooks.types import (
)
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.tool_failure import (
ToolExecutionFailedError,
ToolFailure,
ToolFailureReason,
detect_tool_failure,
failure_from_exception,
handle_tool_failure,
reportable_failure,
)
from crewai.utilities.agent_utils import (
_llm_stop_words_applied,
build_text_tool_calling_fallback_message,
@@ -1643,11 +1634,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
function_calling_llm=self.function_calling_llm,
crew=self.crew,
)
except ToolExecutionFailedError:
# A deliberate stop: the generic handler below would feed it back
# to the LLM as a recoverable observation.
raise
except Exception as e:
if self.agent and self.agent.verbose:
PRINTER.print(content=f"Error in tool execution: {e}", color="red")
@@ -1767,15 +1753,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
idx = future_to_idx[future]
try:
ordered_results[idx] = future.result()
except ToolExecutionFailedError:
# A deliberate stop: folding it into a tool result would
# let the remaining parallel calls carry on. Cancel the
# siblings that have not started so they never run.
# Ones already in flight cannot be interrupted -- Python
# threads are not cancellable -- so a concurrent tool may
# still complete before the abort surfaces.
pool.shutdown(wait=False, cancel_futures=True)
raise
except Exception as e:
tool_call = runnable_tool_calls[idx]
info = extract_tool_call_info(tool_call)
@@ -1822,8 +1799,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
original_tool
and hasattr(original_tool, "result_as_answer")
and original_tool.result_as_answer
# A failed tool must not become the final answer.
and execution_result.get("tool_failure") is None
):
self.state.current_answer = AgentFinish(
thought="Tool result is the final answer",
@@ -1862,8 +1837,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
original_tool
and hasattr(original_tool, "result_as_answer")
and original_tool.result_as_answer
# A failed tool must not become the final answer.
and execution_result.get("tool_failure") is None
):
# Set the result as the final answer
self.state.current_answer = AgentFinish(
@@ -1931,14 +1904,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
# Parse arguments
parsed_args, parse_error = parse_tool_call_args(func_args, func_name, call_id)
if parse_error is not None:
handle_tool_failure(
parse_error["tool_failure"],
tool_name=func_name,
tool_args=func_args,
agent=self.agent,
task=self.task,
crew=self.crew,
)
return parse_error
args_dict: dict[str, Any] = parsed_args or {}
@@ -1984,7 +1949,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
from_cache = False
result = "Tool not found"
raw_tool_result: Any = result
tool_failure: ToolFailure | None = None
input_str = json.dumps(args_dict) if args_dict else ""
if self.tools_handler and self.tools_handler.cache and output_tool is not None:
cached_result = self.tools_handler.cache.read(
@@ -1993,7 +1957,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
if cached_result is not None:
raw_tool_result = cached_result
result = format_native_tool_output_for_agent(output_tool, cached_result)
tool_failure = detect_tool_failure(cached_result)
from_cache = True
# Emit tool usage started event
@@ -2025,9 +1988,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
raw_tool_result = result
# The blocked message replaces any cached result, so a cached
# failure must not be attributed to this call.
tool_failure = None
elif not from_cache and not max_usage_reached and output_tool is not None:
if func_name in self._available_functions:
try:
@@ -2050,11 +2010,9 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
result = format_native_tool_output_for_agent(
output_tool, raw_result
)
tool_failure = detect_tool_failure(raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
raw_tool_result = result
tool_failure = failure_from_exception(e)
if self.task:
self.task.increment_tools_errors()
# Emit tool usage error event
@@ -2070,8 +2028,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
),
)
error_event_emitted = True
else:
tool_failure = self._unknown_tool_failure(func_name, result)
elif max_usage_reached:
# Return error message when max usage limit is reached
if original_tool:
@@ -2079,11 +2035,6 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
else:
result = f"Tool '{func_name}' has reached its maximum usage limit and cannot be used anymore."
raw_tool_result = result
tool_failure = ToolFailure(
message=result, reason=ToolFailureReason.USAGE_LIMIT
)
elif not from_cache:
tool_failure = self._unknown_tool_failure(func_name, result)
# Execute after_tool_call hooks (even if blocked, to allow logging/monitoring)
after_hook_context = ToolCallHookContext(
@@ -2112,50 +2063,17 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor):
agent_key=agent_key,
started_at=started_at,
finished_at=datetime.now(),
failure=reportable_failure(
tool_failure,
tool=structured_tool,
agent=self.agent,
task=self.task,
crew=self.crew,
),
),
)
# After the finished event, so subscribers see the full lifecycle even
# when the policy aborts.
if tool_failure is not None:
handle_tool_failure(
tool_failure,
tool_name=func_name,
tool_args=args_dict,
tool=structured_tool,
agent=self.agent,
task=self.task,
crew=self.crew,
)
return {
"call_id": call_id,
"func_name": func_name,
"result": result,
"from_cache": from_cache,
"original_tool": original_tool,
"tool_failure": tool_failure,
}
@staticmethod
def _unknown_tool_failure(func_name: str, result: str) -> ToolFailure:
"""Build the failure for a tool the model asked for but we lack.
The ReAct path reports this, so the native path must too.
"""
return ToolFailure(
message=result,
reason=ToolFailureReason.UNKNOWN_TOOL,
code=func_name,
)
def _extract_tool_name(self, tool_call: Any) -> str:
"""Extract tool name from various tool call formats."""
if hasattr(tool_call, "function"):

View File

@@ -67,7 +67,6 @@ from crewai.events.listeners.tracing.utils import (
)
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowPausedEvent,
FlowPlotEvent,
@@ -1342,7 +1341,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
except Exception as e:
if not hook_state["end_dispatched"]:
self._dispatch_execution_end_failure(e)
await self._emit_flow_failed(e, respect_suppression=True)
raise
finally:
# Match kickoff_async: drain pending handlers so the resumed
@@ -1384,68 +1382,35 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
)
emit = context.emit
if not self.suppress_flow_events:
# Opens the scope the finished event below closes; without it that
# event pops the enclosing ``flow_started`` instead.
future = crewai_event_bus.emit(
self,
MethodExecutionStartedEvent(
type="method_execution_started",
flow_name=self._definition.name,
method_name=context.method_name,
state=self._copy_and_serialize_state(),
),
)
if future and isinstance(future, Future):
try:
await asyncio.wrap_future(future)
except Exception:
logger.warning(
"MethodExecutionStartedEvent handler failed", exc_info=True
)
# The serialized context carries the full LLM config (a dict, or a
# legacy model string) — the single source for cross- and same-process
# resume.
result = await self._finalize_human_feedback(
method_name=context.method_name,
method_output=context.method_output,
raw_feedback=feedback,
emit=emit,
default_outcome=context.default_outcome,
llm=context.llm,
metadata=context.metadata,
)
collapsed_outcome = result.outcome
resumed_method_output = (
result.output
if emit and isinstance(result, HumanFeedbackResult)
else result
)
try:
# The serialized context carries the full LLM config (a dict, or a
# legacy model string) — the single source for cross- and
# same-process resume.
result = await self._finalize_human_feedback(
method_name=context.method_name,
method_output=context.method_output,
raw_feedback=feedback,
emit=emit,
default_outcome=context.default_outcome,
llm=context.llm,
metadata=context.metadata,
)
collapsed_outcome = result.outcome
resumed_method_output = (
result.output
if emit and isinstance(result, HumanFeedbackResult)
else result
)
self._completed_methods.add(FlowMethodName(context.method_name))
self._completed_methods.add(FlowMethodName(context.method_name))
await asyncio.to_thread(
self._persist_method_completion, FlowMethodName(context.method_name)
)
await asyncio.to_thread(
self._persist_method_completion, FlowMethodName(context.method_name)
)
self._pending_feedback_context = None
self._pending_feedback_context = None
if self.persistence is not None:
self.persistence.clear_pending_feedback(context.flow_id)
except Exception as e:
if not self.suppress_flow_events:
crewai_event_bus.emit(
self,
MethodExecutionFailedEvent(
type="method_execution_failed",
flow_name=self._definition.name,
method_name=context.method_name,
error=e,
),
)
raise
if self.persistence is not None:
self.persistence.clear_pending_feedback(context.flow_id)
if not self.suppress_flow_events:
crewai_event_bus.emit(
@@ -2131,11 +2096,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
# EXECUTION_START/EXECUTION_END dispatch independently.
execution_start_dispatched = False
execution_end_dispatched = False
# Guards the failure event: everything between here and the
# ``flow_started`` emission below (hooks, input handling, state
# restore) can raise, and a ``flow_failed`` with no opener would pop
# an unrelated scope.
flow_scope_open = False
try:
from crewai.hooks.contexts import (
@@ -2275,7 +2235,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
and get_current_parent_id() is None
):
restore_event_scope(((deferred_started_event_id, "flow_started"),))
flow_scope_open = True
elif get_current_parent_id() is None:
reset_emission_counter()
reset_last_event_id()
@@ -2290,7 +2249,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
inputs=inputs,
)
future = crewai_event_bus.emit(self, started_event)
flow_scope_open = True
if future:
try:
await asyncio.wrap_future(future)
@@ -2482,8 +2440,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
# not (exactly-once per invocation).
if execution_start_dispatched and not execution_end_dispatched:
self._dispatch_execution_end_failure(e)
if flow_scope_open:
await self._emit_flow_failed(e)
raise
finally:
# Safety net for the exception path; the success path already
@@ -2531,67 +2487,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
except Exception: # noqa: S110 - aborting an already-failed execution is meaningless
pass
async def _emit_flow_failed(
self, error: Exception, *, respect_suppression: bool = False
) -> None:
"""Emit ``FlowFailedEvent`` and close out the trace batch for a failed run.
Mirrors the terminal block of the success path: drain pending event
handlers and background memory saves so their spans close before the
flow span does, then emit and finalize the trace batch. Never raises,
so the original exception propagates unchanged.
Args:
error: The exception that ended the execution.
respect_suppression: Skip the emission for suppressed flows. Only
the resume path gates its lifecycle events on
``suppress_flow_events``; ``kickoff_async`` emits them either
way and lets listeners filter.
"""
if self._should_defer_trace_finalization():
return
if respect_suppression and self.suppress_flow_events:
return
try:
if self._event_futures:
await asyncio.gather(
*[asyncio.wrap_future(f) for f in self._event_futures],
return_exceptions=True,
)
self._event_futures.clear()
await asyncio.to_thread(self._drain_memory_writes)
await asyncio.to_thread(crewai_event_bus.flush)
future = crewai_event_bus.emit(
self,
FlowFailedEvent(
type="flow_failed",
flow_name=self._definition.name,
error=error,
),
)
if future and isinstance(future, Future):
try:
await asyncio.wrap_future(future)
except Exception:
logger.warning("FlowFailedEvent handler failed", exc_info=True)
trace_listener = TraceCollectionListener()
if (
trace_listener.batch_manager.batch_owner_type == "flow"
and current_flow_id.get() == self.flow_id
and not trace_listener.batch_manager.defer_session_finalization
and not current_flow_defer_trace_finalization.get()
):
if trace_listener.first_time_handler.is_first_time:
trace_listener.first_time_handler.mark_events_collected()
trace_listener.first_time_handler.handle_execution_completion()
else:
trace_listener.batch_manager.finalize_batch()
except Exception:
logger.warning("Failed to signal flow failure", exc_info=True)
async def akickoff(
self,
inputs: dict[str, Any] | None = None,

View File

@@ -23,7 +23,6 @@ if TYPE_CHECKING:
from crewai.agent import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crew import Crew
from crewai.lite_agent import LiteAgent
from crewai.task import Task
from crewai.tools.structured_tool import CrewStructuredTool
@@ -56,7 +55,7 @@ class ToolCallHookContext:
tool_name: str,
tool_input: dict[str, Any],
tool: CrewStructuredTool,
agent: Agent | BaseAgent | LiteAgent | None = None,
agent: Agent | BaseAgent | None = None,
task: Task | None = None,
crew: Crew | None = None,
tool_result: str | None = None,

View File

@@ -72,12 +72,6 @@ from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM
from crewai.tools.base_tool import BaseTool
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.tool_failure import (
ToolExecutionFailedError,
ToolFailurePolicy,
ToolFailureRecord,
tool_failure_collector,
)
from crewai.utilities.agent_utils import (
enforce_rpm_limit,
format_message_for_llm,
@@ -228,14 +222,6 @@ class LiteAgent(FlowTrackable, BaseModel):
max_iterations: int = Field(
default=15, description="Maximum number of iterations for tool usage"
)
tool_failure_policy: ToolFailurePolicy | None = Field(
default=None,
description=(
"How to react when a tool runs to completion but reports that it "
"failed. None falls back to 'warn'. See "
"BaseAgent.tool_failure_policy."
),
)
max_execution_time: int | None = Field(
default=None, description=". Maximum execution time in seconds"
)
@@ -303,8 +289,6 @@ class LiteAgent(FlowTrackable, BaseModel):
_key: str = PrivateAttr(default_factory=lambda: str(uuid.uuid4()))
_messages: list[LLMMessage] = PrivateAttr(default_factory=list)
_iterations: int = PrivateAttr(default=0)
_tool_failures: list[ToolFailureRecord] = PrivateAttr(default_factory=list)
_kickoff_failures: list[ToolFailureRecord] = PrivateAttr(default_factory=list)
_guardrail: GuardrailCallable | None = PrivateAttr(default=None)
_guardrail_retry_count: int = PrivateAttr(default=0)
_callbacks: list[TokenCalcHandler] = PrivateAttr(default_factory=list)
@@ -466,14 +450,6 @@ class LiteAgent(FlowTrackable, BaseModel):
"""Return the original role for compatibility with tool interfaces."""
return self.role
@property
def last_tool_failures(self) -> list[ToolFailureRecord]:
"""Tool failures recorded during the most recent kickoff.
Mirrors ``BaseAgent.last_tool_failures`` so the shared helper works here.
"""
return list(self._tool_failures)
@property
def before_llm_call_hooks(
self,
@@ -543,34 +519,15 @@ class LiteAgent(FlowTrackable, BaseModel):
try:
self._iterations = 0
self.tools_results = []
self._tool_failures = []
self._messages = self._format_messages(
messages, response_format=response_format, input_files=input_files
)
self._inject_memory_context()
with tool_failure_collector() as kickoff_failures:
self._kickoff_failures = kickoff_failures
return self._execute_core(
agent_info=agent_info, response_format=response_format
)
except ToolExecutionFailedError as e:
# A deliberate stop, not a defect: no bug-report prompt.
if self.verbose:
PRINTER.print(
content=f"Agent stopped: {e}",
color="red",
)
crewai_event_bus.emit(
self,
event=LiteAgentExecutionErrorEvent(
agent_info=agent_info,
error=str(e),
),
return self._execute_core(
agent_info=agent_info, response_format=response_format
)
raise
except Exception as e:
if self.verbose:
@@ -734,9 +691,6 @@ class LiteAgent(FlowTrackable, BaseModel):
agent_role=self.role,
usage_metrics=usage_metrics.model_dump() if usage_metrics else None,
messages=self._messages,
# Read from whichever agent the executor was given, or the records
# go missing: original_agent under kickoff, self when standalone.
tool_failures=list(self._kickoff_failures),
)
if self._guardrail is not None:
@@ -962,9 +916,7 @@ class LiteAgent(FlowTrackable, BaseModel):
tools=self._parsed_tools,
agent_key=self.key,
agent_role=self.role,
# Fall back to self so a standalone LiteAgent still
# resolves a policy and records failures.
agent=self.original_agent or self,
agent=self.original_agent,
crew=None,
)
except Exception as e:
@@ -977,10 +929,6 @@ class LiteAgent(FlowTrackable, BaseModel):
)
self._append_message(formatted_answer.text, role="assistant")
except ToolExecutionFailedError:
# tool_failure_policy="raise" asked for the run to stop.
raise
except OutputParserError as e:
if self.verbose:
PRINTER.print(

View File

@@ -6,7 +6,6 @@ from typing import Any
from pydantic import BaseModel, Field
from crewai.tools.tool_failure import ToolFailureRecord
from crewai.types.usage_metrics import UsageMetrics
from crewai.utilities.planning_types import TodoItem
from crewai.utilities.types import LLMMessage
@@ -51,17 +50,6 @@ class LiteAgentOutput(BaseModel):
messages: list[LLMMessage] = Field(
description="Messages of the agent", default_factory=list
)
tool_failures: list[ToolFailureRecord] = Field(
default_factory=list,
description=(
"Tools that ran but reported they did not succeed. Empty under 'ignore'."
),
)
@property
def has_tool_failures(self) -> bool:
"""Whether any tool reported a failure while producing this output."""
return bool(self.tool_failures)
plan: str | None = Field(
default=None, description="The execution plan that was generated, if any"

View File

@@ -430,27 +430,7 @@ class MCPClient:
arguments: Tool arguments.
Returns:
Tool execution result content. The ``isError`` flag is dropped;
use :meth:`call_tool_result` when the caller needs it.
"""
return (await self.call_tool_result(tool_name, arguments)).content
async def call_tool_result(
self, tool_name: str, arguments: dict[str, Any] | None = None
) -> _MCPToolResult:
"""Call a tool and return its content together with the ``isError`` flag.
MCP servers report a failed tool as a *successful* JSON-RPC response
carrying ``isError: true``. Callers that only take the content cannot
tell that apart from a normal result, which is how a failed step ends
up looking like a successful one.
Args:
tool_name: Name of the tool to call.
arguments: Tool arguments.
Returns:
The content string plus whether the server flagged it as an error.
Tool execution result.
"""
if not self.connected:
await self.connect()
@@ -512,7 +492,7 @@ class MCPClient:
),
)
return tool_result
return tool_result.content
except Exception as e:
failed_at = datetime.now()
error_type = (

View File

@@ -6,7 +6,6 @@ for agent use, and format skill context for prompt injection.
from __future__ import annotations
from collections import Counter
from collections.abc import Iterable
import logging
from pathlib import Path
@@ -20,12 +19,7 @@ from crewai.events.types.skill_events import (
SkillLoadFailedEvent,
SkillLoadedEvent,
)
from crewai.skills.models import (
INSTRUCTIONS,
RESOURCES,
Skill,
SkillFrontmatter,
)
from crewai.skills.models import INSTRUCTIONS, RESOURCES, Skill, SkillFrontmatter
from crewai.skills.parser import (
SKILL_FILENAME,
load_skill_instructions,
@@ -154,39 +148,25 @@ def activate_skill(
def load_skill(
skill: Path | Skill | str,
source: BaseAgent | None = None,
*,
activate: bool = True,
) -> list[Skill]:
"""Load one skill input into Skill objects.
Accepts a pre-loaded Skill object, skill search path, inline SKILL.md
string, or '@org/name' registry reference. Path inputs can expand to many
skills. Pre-loaded Skill objects keep their disclosure level. Path and
registry inputs are activated by default; callers performing runtime
progressive disclosure can leave them at metadata level.
Args:
skill: Skill input to resolve.
source: Optional event source for event emission.
activate: Whether discovered path and registry skills should load their
full instructions immediately.
Returns:
Resolved Skill objects.
skills. Path and inline inputs are activated immediately; pre-loaded Skill
objects keep their disclosure level.
"""
if isinstance(skill, Skill):
return [skill]
if isinstance(skill, Path):
discovered = discover_skills(skill, source=source)
if not activate:
return discovered
return [activate_skill(s, source=source) for s in discovered]
return [
activate_skill(s, source=source)
for s in discover_skills(skill, source=source)
]
if isinstance(skill, str) and skill.startswith("@"):
from crewai.skills.registry import resolve_registry_ref
if activate:
return [resolve_registry_ref(skill, source=source)]
return [resolve_registry_ref(skill, source=source, activate=False)]
return [resolve_registry_ref(skill, source=source)]
if isinstance(skill, str) and skill.lstrip().startswith("---\n"):
frontmatter_dict, body = parse_frontmatter(skill.strip())
return [
@@ -198,10 +178,10 @@ def load_skill(
)
]
if isinstance(skill, str):
discovered = discover_skills(Path(skill), source=source)
if not activate:
return discovered
return [activate_skill(s, source=source) for s in discovered]
return [
activate_skill(s, source=source)
for s in discover_skills(Path(skill), source=source)
]
msg = f"Unsupported skill input: {skill!r}"
raise TypeError(msg)
@@ -210,27 +190,16 @@ def load_skill(
def load_skills(
skills: Iterable[Path | Skill | str],
source: BaseAgent | None = None,
*,
activate: bool = True,
) -> list[Skill]:
"""Load skill inputs into de-duplicated Skill objects.
Preserves first-seen order when multiple inputs resolve to the same skill
name. Registry refs are scoped by org so different orgs can publish skills
that share a frontmatter name.
Args:
skills: Skill inputs to resolve.
source: Optional event source for event emission.
activate: Whether discovered path and registry skills should load their
full instructions immediately.
Returns:
De-duplicated Skill objects.
"""
loaded: dict[str, Skill] = {}
for skill_input in skills:
for skill in load_skill(skill_input, source=source, activate=activate):
for skill in load_skill(skill_input, source=source):
dedup_key = skill.name
if isinstance(skill_input, str) and skill_input.startswith("@"):
from crewai.skills.registry import parse_registry_ref
@@ -242,42 +211,6 @@ def load_skills(
return list(loaded.values())
def build_skill_catalog(skills: Iterable[Skill]) -> dict[str, Skill]:
"""Label the metadata-only skills an execution can still load.
Skills resolved from different orgs or search paths can share a frontmatter
name. Colliding names are qualified by their parent directory — the org for
registry skills — so the catalog can address each of them individually.
Args:
skills: Skills to catalog. Skills already at INSTRUCTIONS level are
skipped because their instructions are rendered on every execution.
Returns:
Catalog labels mapped to their skill, in first-seen order.
"""
pending = [skill for skill in skills if skill.disclosure_level < INSTRUCTIONS]
ambiguous = {
name
for name, count in Counter(skill.name for skill in pending).items()
if count > 1
}
catalog: dict[str, Skill] = {}
for skill in pending:
label = (
f"{skill.path.parent.name}/{skill.name}"
if skill.name in ambiguous
else skill.name
)
qualified, attempt = label, 2
while label in catalog:
label = f"{qualified}-{attempt}"
attempt += 1
catalog[label] = skill
return catalog
def load_resources(skill: Skill) -> Skill:
"""Promote a skill to RESOURCES disclosure level.
@@ -290,7 +223,7 @@ def load_resources(skill: Skill) -> Skill:
return load_skill_resources(skill)
def format_skill_context(skill: Skill, *, label: str | None = None) -> str:
def format_skill_context(skill: Skill) -> str:
"""Format skill information for agent prompt injection.
At METADATA level: returns name and description only.
@@ -301,16 +234,13 @@ def format_skill_context(skill: Skill, *, label: str | None = None) -> str:
Args:
skill: The skill to format.
label: Name to render in place of the skill's own, used when a catalog
has qualified skills that share a frontmatter name.
Returns:
Formatted skill context string.
"""
name = label or skill.name
if skill.disclosure_level >= INSTRUCTIONS and skill.instructions:
parts = [
f'<skill name="{name}">',
f'<skill name="{skill.name}">',
skill.description,
"",
skill.instructions,
@@ -323,4 +253,4 @@ def format_skill_context(skill: Skill, *, label: str | None = None) -> str:
parts.append(f"- **{dir_name}/**: {', '.join(files)}")
parts.append("</skill>")
return "\n".join(parts)
return f'<skill name="{name}">\n{skill.description}\n</skill>'
return f'<skill name="{skill.name}">\n{skill.description}\n</skill>'

View File

@@ -114,8 +114,6 @@ def parse_registry_ref(ref: str) -> tuple[str, str]:
def resolve_registry_ref(
ref: str,
source: Any = None,
*,
activate: bool = True,
) -> Skill: # type: ignore[name-defined] # noqa: F821
"""Resolve a registry reference to a Skill object.
@@ -131,11 +129,9 @@ def resolve_registry_ref(
Args:
ref: A registry reference, e.g. '@acme/my-skill' or '@acme/my-skill@1.2.0'.
source: Optional source object passed through to skill loaders (for events).
activate: Whether to load the full SKILL.md instructions.
Returns:
A Skill loaded at INSTRUCTIONS level by default, or METADATA level
when ``activate`` is false.
A Skill loaded at INSTRUCTIONS disclosure level.
"""
from crewai.skills.loader import activate_skill
@@ -147,7 +143,7 @@ def resolve_registry_ref(
# is the only thing a pin can be checked against.
skill = _load_matching_skill(local_path, version)
if skill is not None:
return activate_skill(skill, source=source) if activate else skill
return activate_skill(skill, source=source)
cache = SkillCacheManager()
cached_path = cache.get_cached_path(org, name, version=version)
@@ -158,15 +154,9 @@ def resolve_registry_ref(
# metadata.version and re-download it on each resolution.
skill = _load_skill(cached_path)
if skill is not None:
return activate_skill(skill, source=source) if activate else skill
return activate_skill(skill, source=source)
return download_skill(
org,
name,
source=source,
version=version,
activate=activate,
)
return download_skill(org, name, source=source, version=version)
def _load_skill(path: Path) -> Skill | None: # type: ignore[name-defined] # noqa: F821
@@ -278,7 +268,6 @@ def download_skill(
source: Any = None,
*,
version: str | None = None,
activate: bool = True,
) -> Skill: # type: ignore[name-defined] # noqa: F821
"""Download a skill from the registry and store it in the cache.
@@ -287,11 +276,9 @@ def download_skill(
name: Skill name.
source: Optional source for event emission.
version: Optional pinned version; the latest is fetched when omitted.
activate: Whether to load the full SKILL.md instructions.
Returns:
The downloaded Skill at INSTRUCTIONS level by default, or METADATA
level when ``activate`` is false.
The downloaded Skill at INSTRUCTIONS level.
Raises:
ValueError: If *version* is given but blank.
@@ -389,4 +376,4 @@ def download_skill(
f"Skill archive for {ref!r} downloaded but no SKILL.md found in {skill_dir}"
)
skill = load_skill_metadata(skill_dir)
return activate_skill(skill, source=source) if activate else skill
return activate_skill(skill, source=source)

View File

@@ -1,116 +0,0 @@
"""Runtime tool for progressively disclosing Agent Skill instructions."""
from __future__ import annotations
from collections.abc import Collection
from typing import Any, Final
from pydantic import BaseModel, Field
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.skill_events import SkillUsedEvent
from crewai.skills.loader import (
activate_skill,
build_skill_catalog,
format_skill_context,
)
from crewai.skills.models import Skill
from crewai.tools.base_tool import BaseTool
from crewai.utilities.string_utils import sanitize_tool_name
LOAD_SKILL_TOOL_NAME: Final[str] = "load_skill"
class LoadSkillSchema(BaseModel):
"""Arguments accepted by the runtime skill loader."""
skill_name: str = Field(
...,
description="Exact name of the available skill whose instructions are needed.",
)
class LoadSkillTool(BaseTool):
"""Load one relevant skill's instructions for the current execution."""
name: str = LOAD_SKILL_TOOL_NAME
description: str = (
"Load one available skill's full instructions. Most requests need no "
"skill, so only call this when an available skill's description "
"clearly matches the current request."
)
args_schema: type[BaseModel] = LoadSkillSchema
catalog: dict[str, Skill] = Field(default_factory=dict, exclude=True)
source: Any = Field(default=None, exclude=True)
task: Any = Field(default=None, exclude=True)
def _run(self, skill_name: str, **kwargs: Any) -> str:
"""Load and return one skill's instruction block."""
skill = self.catalog.get(skill_name)
if skill is None:
available = ", ".join(self.catalog)
return (
f"Skill {skill_name!r} is not available. "
f"Available skills: {available or 'none'}."
)
activated = activate_skill(skill, source=self.source)
crewai_event_bus.emit(
self.source,
event=SkillUsedEvent(
from_agent=self.source,
from_task=self.task,
skill_name=skill_name,
skill_path=activated.path,
disclosure_level=activated.disclosure_level,
),
)
return format_skill_context(activated, label=skill_name)
def resolve_loader_tool_name(reserved_names: Collection[str] = ()) -> str:
"""Return a loader tool name that none of *reserved_names* already claims.
A configured tool holding the default name would otherwise be
indistinguishable from the loader, so the model could never reach the
loader the skill catalog points it at.
"""
taken = {sanitize_tool_name(name) for name in reserved_names}
if LOAD_SKILL_TOOL_NAME not in taken:
return LOAD_SKILL_TOOL_NAME
attempt = 2
while f"{LOAD_SKILL_TOOL_NAME}_{attempt}" in taken:
attempt += 1
return f"{LOAD_SKILL_TOOL_NAME}_{attempt}"
def create_skill_loader_tool(
skills: list[Skill] | None,
*,
source: Any = None,
task: Any = None,
reserved_names: Collection[str] = (),
) -> LoadSkillTool | None:
"""Create a loader tool when metadata-only skills are available.
Args:
skills: Skills configured on the agent.
source: Event source for the skill events the loader emits.
task: Task the loader is scoped to.
reserved_names: Names of tools already configured on the agent, which
the loader must not collide with.
Returns:
The loader tool, or None when every skill is already disclosed.
"""
catalog = build_skill_catalog(skills or [])
if not catalog:
return None
return LoadSkillTool(
name=resolve_loader_tool_name(reserved_names),
catalog=catalog,
source=source,
task=task,
)

View File

@@ -38,7 +38,6 @@ CheckpointEventType = Literal[
"flow_created",
"flow_started",
"flow_finished",
"flow_failed",
"flow_paused",
"method_execution_started",
"method_execution_finished",

View File

@@ -52,12 +52,6 @@ from crewai.security import Fingerprint, SecurityConfig
from crewai.tasks.output_format import OutputFormat
from crewai.tasks.task_output import TaskOutput
from crewai.tools.base_tool import BaseTool
from crewai.tools.tool_failure import (
ToolFailurePolicy,
ToolFailureRecord,
merge_tool_failures,
tool_failure_collector,
)
from crewai.utilities.config import process_config
from crewai.utilities.constants import NOT_SPECIFIED, _NotSpecified
from crewai.utilities.converter import (
@@ -280,13 +274,6 @@ class Task(BaseModel):
default=3, description="Maximum number of retries when guardrail fails"
)
retry_count: int = Field(default=0, description="Current number of retries")
tool_failure_policy: ToolFailurePolicy | None = Field(
default=None,
description=(
"Overrides the agent's tool_failure_policy for this task only. "
"None inherits."
),
)
start_time: datetime.datetime | None = Field(
default=None, description="Start time of the task execution"
)
@@ -690,12 +677,11 @@ class Task(BaseModel):
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
context = pre_step_ctx.payload
with tool_failure_collector() as execution_failures:
result = await agent.aexecute_task(
task=self,
context=context,
tools=tools,
)
result = await agent.aexecute_task(
task=self,
context=context,
tools=tools,
)
self._post_agent_execution(agent)
@@ -727,7 +713,6 @@ class Task(BaseModel):
agent=agent.role,
output_format=self._get_output_format(),
messages=agent.last_messages, # type: ignore[attr-defined]
tool_failures=list(execution_failures),
)
if self._guardrails:
@@ -846,12 +831,11 @@ class Task(BaseModel):
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
context = pre_step_ctx.payload
with tool_failure_collector() as execution_failures:
result = agent.execute_task(
task=self,
context=context,
tools=tools,
)
result = agent.execute_task(
task=self,
context=context,
tools=tools,
)
self._post_agent_execution(agent)
@@ -883,7 +867,6 @@ class Task(BaseModel):
agent=agent.role,
output_format=self._get_output_format(),
messages=agent.last_messages, # type: ignore[attr-defined]
tool_failures=list(execution_failures),
)
if self._guardrails:
@@ -1336,10 +1319,6 @@ Follow these guidelines:
max_attempts = self.guardrail_max_retries + 1
# Each retry resets the agent's failure list, so accumulate to keep
# failures from blocked attempts on the final output.
accumulated_failures: list[ToolFailureRecord] = list(task_output.tool_failures)
for attempt in range(max_attempts):
guardrail_result = process_guardrail(
output=task_output,
@@ -1364,12 +1343,7 @@ Follow these guidelines:
task_output.pydantic = pydantic_output
task_output.json_dict = json_output
elif isinstance(guardrail_result.result, TaskOutput):
# A guardrail may return a whole new output; carry the
# accumulated failures over or earlier attempts vanish.
task_output = guardrail_result.result
task_output.tool_failures = merge_tool_failures(
accumulated_failures, task_output.tool_failures
)
return task_output
@@ -1400,12 +1374,11 @@ Follow these guidelines:
content=f"Guardrail {guardrail_index if guardrail_index is not None else ''} blocked (attempt {attempt + 1}/{max_attempts}), retrying due to: {guardrail_result.error}\n",
color="yellow",
)
with tool_failure_collector() as retry_failures:
result = agent.execute_task(
task=self,
context=context,
tools=tools,
)
result = agent.execute_task(
task=self,
context=context,
tools=tools,
)
if isinstance(result, BaseModel):
raw = result.model_dump_json()
@@ -1432,9 +1405,7 @@ Follow these guidelines:
agent=agent.role,
output_format=self._get_output_format(),
messages=agent.last_messages, # type: ignore[attr-defined]
tool_failures=merge_tool_failures(accumulated_failures, retry_failures),
)
accumulated_failures = list(task_output.tool_failures)
return task_output
@@ -1457,10 +1428,6 @@ Follow these guidelines:
max_attempts = self.guardrail_max_retries + 1
# Each retry resets the agent's failure list, so accumulate to keep
# failures from blocked attempts on the final output.
accumulated_failures: list[ToolFailureRecord] = list(task_output.tool_failures)
for attempt in range(max_attempts):
guardrail_result = process_guardrail(
output=task_output,
@@ -1485,12 +1452,7 @@ Follow these guidelines:
task_output.pydantic = pydantic_output
task_output.json_dict = json_output
elif isinstance(guardrail_result.result, TaskOutput):
# A guardrail may return a whole new output; carry the
# accumulated failures over or earlier attempts vanish.
task_output = guardrail_result.result
task_output.tool_failures = merge_tool_failures(
accumulated_failures, task_output.tool_failures
)
return task_output
@@ -1521,12 +1483,11 @@ Follow these guidelines:
content=f"Guardrail {guardrail_index if guardrail_index is not None else ''} blocked (attempt {attempt + 1}/{max_attempts}), retrying due to: {guardrail_result.error}\n",
color="yellow",
)
with tool_failure_collector() as retry_failures:
result = await agent.aexecute_task(
task=self,
context=context,
tools=tools,
)
result = await agent.aexecute_task(
task=self,
context=context,
tools=tools,
)
if isinstance(result, BaseModel):
raw = result.model_dump_json()
@@ -1553,8 +1514,6 @@ Follow these guidelines:
agent=agent.role,
output_format=self._get_output_format(),
messages=agent.last_messages, # type: ignore[attr-defined]
tool_failures=merge_tool_failures(accumulated_failures, retry_failures),
)
accumulated_failures = list(task_output.tool_failures)
return task_output

View File

@@ -8,7 +8,6 @@ from typing import Any
from pydantic import BaseModel, Field, model_validator
from crewai.tasks.output_format import OutputFormat
from crewai.tools.tool_failure import ToolFailureRecord
from crewai.utilities.types import LLMMessage
@@ -47,18 +46,6 @@ class TaskOutput(BaseModel):
messages: list[LLMMessage] = Field(
description="Messages of the task", default_factory=list
)
tool_failures: list[ToolFailureRecord] = Field(
default_factory=list,
description=(
"Tools that ran during this task but reported they did not "
"succeed, so 'raw' may be incomplete. Empty under 'ignore'."
),
)
@property
def has_tool_failures(self) -> bool:
"""Whether any tool reported a failure while producing this output."""
return bool(self.tool_failures)
@model_validator(mode="after")
def set_summary(self) -> TaskOutput:

View File

@@ -1,20 +1,8 @@
from crewai.tools.base_tool import BaseTool, EnvVar, tool
from crewai.tools.tool_failure import (
ToolExecutionFailedError,
ToolFailure,
ToolFailurePolicy,
ToolFailureReason,
ToolFailureRecord,
)
__all__ = [
"BaseTool",
"EnvVar",
"ToolExecutionFailedError",
"ToolFailure",
"ToolFailurePolicy",
"ToolFailureReason",
"ToolFailureRecord",
"tool",
]

View File

@@ -38,7 +38,6 @@ from crewai.tools.structured_tool import (
build_schema_hint,
format_description_for_llm,
)
from crewai.tools.tool_failure import ToolFailure, ToolFailurePolicy, ToolFailureReason
from crewai.types.callback import SerializableCallable, _resolve_dotted_path
from crewai.utilities.string_utils import sanitize_tool_name
@@ -185,13 +184,6 @@ class BaseTool(BaseModel, ABC):
default=None,
description="Maximum number of times this tool can be used. None means unlimited usage.",
)
tool_failure_policy: ToolFailurePolicy | None = Field(
default=None,
description=(
"Overrides the agent's and task's tool_failure_policy for this "
"tool only. None inherits."
),
)
current_usage_count: int = Field(
default=0,
description="Current number of times this tool has been used.",
@@ -299,26 +291,21 @@ class BaseTool(BaseModel, ABC):
) from e
return kwargs
def _claim_usage(self) -> ToolFailure | None:
def _claim_usage(self) -> str | None:
"""Atomically check max usage and increment the counter.
Returns:
None if usage was claimed, otherwise a :class:`ToolFailure`. A
structured result rather than a bare string so every execution
path records a spent limit, instead of only the ones that
recognise the message.
None if usage was claimed successfully, or an error message
string if the tool has reached its usage limit.
"""
with self._usage_lock:
if (
self.max_usage_count is not None
and self.current_usage_count >= self.max_usage_count
):
return ToolFailure(
message=(
f"Tool '{self.name}' has reached its usage limit of "
f"{self.max_usage_count} times and cannot be used anymore."
),
reason=ToolFailureReason.USAGE_LIMIT,
return (
f"Tool '{self.name}' has reached its usage limit of "
f"{self.max_usage_count} times and cannot be used anymore."
)
self.current_usage_count += 1
return None
@@ -415,7 +402,6 @@ class BaseTool(BaseModel, ABC):
max_usage_count=self.max_usage_count,
current_usage_count=self.current_usage_count,
cache_function=self.cache_function,
tool_failure_policy=self.tool_failure_policy,
)
structured_tool._original_tool = self
return structured_tool

View File

@@ -11,7 +11,6 @@ import contextvars
from typing import Any
from crewai.tools import BaseTool
from crewai.tools.tool_failure import ToolFailure, ToolFailureReason
class MCPNativeTool(BaseTool):
@@ -71,15 +70,14 @@ class MCPNativeTool(BaseTool):
"""Get the server name."""
return self._server_name
def _run(self, **kwargs: Any) -> Any:
def _run(self, **kwargs: Any) -> str:
"""Execute tool using the MCP client session.
Args:
**kwargs: Arguments to pass to the MCP tool.
Returns:
The tool's text result, or a :class:`ToolFailure` when the server
answered with ``isError: true``.
Result from the MCP tool execution.
"""
try:
try:
@@ -100,7 +98,7 @@ class MCPNativeTool(BaseTool):
f"Error executing MCP tool {self.original_tool_name}: {e!s}"
) from e
async def _run_async(self, **kwargs: Any) -> Any:
async def _run_async(self, **kwargs: Any) -> str:
"""Async implementation of tool execution.
A fresh ``MCPClient`` is created for every invocation so that
@@ -110,36 +108,16 @@ class MCPNativeTool(BaseTool):
**kwargs: Arguments to pass to the MCP tool.
Returns:
The tool's text result, or a :class:`ToolFailure` when the server
answered with ``isError: true``.
Result from the MCP tool execution.
"""
client = self._client_factory()
await client.connect()
try:
tool_result = await client.call_tool_result(self.original_tool_name, kwargs)
result = await client.call_tool(self.original_tool_name, kwargs)
finally:
await client.disconnect()
content = self._extract_content(tool_result.content)
if tool_result.is_error:
# isError rides on an otherwise successful response; without this
# the agent cannot tell it apart from a real result.
return ToolFailure(
message=content,
reason=ToolFailureReason.MCP_ERROR,
details={
"server": self._server_name,
"tool": self._original_tool_name,
},
)
return content
@staticmethod
def _extract_content(result: Any) -> str:
"""Flatten an MCP result payload into the text the agent sees."""
if isinstance(result, str):
return result

View File

@@ -21,7 +21,6 @@ from pydantic import (
)
from typing_extensions import Self
from crewai.tools.tool_failure import ToolFailure, ToolFailurePolicy
from crewai.utilities.logger import Logger
from crewai.utilities.pydantic_schema_utils import (
create_model_from_schema,
@@ -57,11 +56,6 @@ def _infer_result_schema_from_callable(
def _format_tool_output_for_agent(tool: Any, raw_result: Any) -> str:
# Rendered as prose so the agent sees what an error string would have
# given it; the structured object is consumed by the policy machinery.
if isinstance(raw_result, ToolFailure):
return raw_result.as_agent_message()
original_tool = getattr(tool, "_original_tool", None)
if original_tool is not None:
return cast(str, original_tool.format_output_for_agent(raw_result))
@@ -211,7 +205,6 @@ class CrewStructuredTool(BaseModel):
result_as_answer: bool = Field(default=False)
max_usage_count: int | None = Field(default=None)
current_usage_count: int = Field(default=0)
tool_failure_policy: ToolFailurePolicy | None = Field(default=None)
cache_function: Any = Field(default=None, exclude=True)
_logger: Logger = PrivateAttr(default_factory=Logger)
_original_tool: Any = PrivateAttr(default=None)

View File

@@ -1,384 +0,0 @@
"""Structured signalling for tools that run but do not succeed.
A tool can complete without raising and still fail: Slack answers ``HTTP 200``
with ``{"ok": false, ...}``, an MCP server sets ``isError``. The call
"worked", so the error used to reach the agent as an ordinary string and the
run was recorded as a success.
A tool declares failure by returning a :class:`ToolFailure`; the policy
(:class:`ToolFailurePolicy`) decides the reaction. Detection is strictly
declarative -- nothing here guesses whether a string "looks like" an error.
"""
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from enum import Enum
import logging
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crew import Crew
from crewai.lite_agent import LiteAgent
from crewai.task import Task
class ToolFailureReason(str, Enum):
"""Why a tool call is considered unsuccessful."""
TOOL_REPORTED = "tool_reported"
"""The tool itself returned a :class:`ToolFailure`."""
EXCEPTION = "exception"
"""The tool raised; the framework caught it and fed the text to the agent."""
MCP_ERROR = "mcp_error"
"""An MCP server answered with ``isError: true``."""
USAGE_LIMIT = "usage_limit"
"""The tool's ``max_usage_count`` was already spent."""
UNKNOWN_TOOL = "unknown_tool"
"""The agent asked for a tool that does not exist."""
INVALID_INPUT = "invalid_input"
"""Arguments could not be parsed or validated into the tool's schema."""
class ToolFailurePolicy(str, Enum):
"""How an agent reacts when one of its tools reports a failure."""
IGNORE = "ignore"
"""Pre-1.16 behavior: the failure is not recorded, emitted, or acted on."""
WARN = "warn"
"""Record the failure, emit an event, and keep going. The default."""
RAISE = "raise"
"""Record the failure, emit an event, then abort with
:class:`ToolExecutionFailedError`."""
class ToolFailure(BaseModel):
"""A tool's own report that it did not do what it was asked.
Return one from ``_run``/``_arun`` instead of an error string. The agent
still sees text via :meth:`as_agent_message`, so model behavior is
unchanged -- but the framework now knows the call failed.
"""
model_config = ConfigDict(frozen=True)
message: str = Field(
description="Human and LLM readable explanation of what went wrong."
)
reason: ToolFailureReason = Field(
default=ToolFailureReason.TOOL_REPORTED,
description="Category of failure, for grouping and filtering.",
)
code: str | None = Field(
default=None,
description=(
"Machine-readable identifier from the failing system, "
"e.g. 'channel_not_found'."
),
)
retryable: bool = Field(
default=False,
description="Whether retrying the same call could plausibly succeed.",
)
details: dict[str, Any] = Field(
default_factory=dict,
description="Extra structured context the tool wants to preserve.",
)
def as_agent_message(self) -> str:
"""Render the text the agent sees for this failure."""
if self.code:
return f"{self.message} (code: {self.code})"
return self.message
class ToolFailureRecord(BaseModel):
"""A :class:`ToolFailure` plus the context of the call that produced it.
Lands on ``TaskOutput.tool_failures`` and on the event bus, so consumers
never parse a string to learn that a step failed.
"""
model_config = ConfigDict(frozen=True)
tool_name: str = Field(description="Name of the tool that failed.")
failure: ToolFailure = Field(description="The failure the tool reported.")
tool_args: dict[str, Any] | str | None = Field(
default=None, description="Arguments the tool was called with."
)
agent_role: str | None = Field(
default=None, description="Role of the agent that made the call."
)
task_name: str | None = Field(
default=None, description="Name or description of the task in flight."
)
task_id: str | None = Field(default=None, description="Id of the task in flight.")
@property
def message(self) -> str:
"""Shorthand for the underlying failure message."""
return self.failure.message
def summary(self) -> str:
"""One-line description suitable for logs and error messages."""
where = f" during '{self.task_name}'" if self.task_name else ""
return (
f"Tool '{self.tool_name}' failed{where}: {self.failure.as_agent_message()}"
)
class ToolExecutionFailedError(Exception):
"""Raised when a tool reports failure under :attr:`ToolFailurePolicy.RAISE`."""
def __init__(self, record: ToolFailureRecord) -> None:
self.record = record
super().__init__(record.summary())
def detect_tool_failure(result: Any) -> ToolFailure | None:
"""Return the failure a tool declared, if it declared one.
Only an explicit :class:`ToolFailure` counts, so a tool legitimately
returning text about an error is never misread as having failed.
"""
if isinstance(result, ToolFailure):
return result
return None
def failure_from_exception(
error: BaseException, *, retryable: bool = False
) -> ToolFailure:
"""Build a :class:`ToolFailure` from an exception a tool raised."""
return ToolFailure(
message=str(error) or error.__class__.__name__,
reason=ToolFailureReason.EXCEPTION,
code=error.__class__.__name__,
retryable=retryable,
)
def resolve_tool_failure_policy(
tool: Any = None,
agent: BaseAgent | LiteAgent | None = None,
task: Task | None = None,
crew: Crew | None = None,
) -> ToolFailurePolicy:
"""Resolve the effective policy for one call.
Most specific wins: tool, task, agent, crew, then
:attr:`ToolFailurePolicy.WARN`. Callers pass either a ``BaseTool`` or the
``CrewStructuredTool`` wrapping it, so both are read -- otherwise a
tool-scoped policy is ignored on every native function-calling path.
"""
original_tool = getattr(tool, "_original_tool", None) if tool is not None else None
for source in (tool, original_tool, task, agent, crew):
if source is None:
continue
policy = getattr(source, "tool_failure_policy", None)
if policy is None:
continue
try:
return ToolFailurePolicy(policy)
except ValueError:
# A malformed policy must not take down a tool call.
logger.warning(
"Ignoring invalid tool_failure_policy %r on %s; expected one of %s.",
policy,
type(source).__name__,
[member.value for member in ToolFailurePolicy],
)
return ToolFailurePolicy.WARN
def merge_tool_failures(
*groups: list[ToolFailureRecord],
) -> list[ToolFailureRecord]:
"""Concatenate failure lists, dropping records already present.
Guardrail retries rebuild the output from overlapping sources, so identity
is not enough to avoid duplicates.
"""
merged: list[ToolFailureRecord] = []
seen: set[tuple[Any, ...]] = set()
for group in groups:
for record in group:
key = (
record.tool_name,
record.failure.message,
record.failure.code,
record.task_id,
str(record.tool_args),
)
if key in seen:
continue
seen.add(key)
merged.append(record)
return merged
def collect_tool_failures(agent: Any) -> list[ToolFailureRecord]:
"""Failures for the execution in progress, else the agent's last ones.
Prefers the active collector so a shared agent running concurrent tasks
reports only the caller's own records. Tolerates agents that do not expose
the attribute at all, since reading telemetry must never raise.
"""
active = active_tool_failures()
if active is not None:
return list(active)
records = getattr(agent, "_tool_failures", None)
if not isinstance(records, list):
return []
return [record for record in records if isinstance(record, ToolFailureRecord)]
def _agent_id(agent: Any) -> str | None:
"""Stringified agent id, for correlating events with the call."""
agent_id = getattr(agent, "id", None)
return str(agent_id) if agent_id is not None else None
_active_failures: ContextVar[list[ToolFailureRecord] | None] = ContextVar(
"crewai_tool_failures", default=None
)
@contextmanager
def tool_failure_collector() -> Generator[list[ToolFailureRecord], None, None]:
"""Collect the failures of one execution, isolated from concurrent ones.
An agent may be shared by tasks running concurrently, so accumulating on
the agent lets one execution erase or inherit another's records. The
collector is a ContextVar, which asyncio tasks and threads copy, so each
execution sees only its own. Nesting is safe: a guardrail retry can open
its own scope inside the outer one.
"""
records: list[ToolFailureRecord] = []
token = _active_failures.set(records)
try:
yield records
finally:
_active_failures.reset(token)
def active_tool_failures() -> list[ToolFailureRecord] | None:
"""Records for the execution in progress, or None outside a collector."""
return _active_failures.get()
def _record_failure(agent: Any, record: ToolFailureRecord) -> None:
"""Store a record on the active collector and on the agent.
The collector is what outputs read, so it is authoritative. The agent copy
only backs ``last_tool_failures``, which reports the most recent execution
in the same best-effort way ``last_messages`` does.
"""
records = _active_failures.get()
if records is not None:
records.append(record)
failures = getattr(agent, "_tool_failures", None)
if isinstance(failures, list):
failures.append(record)
def reportable_failure(
failure: ToolFailure | None,
*,
tool: Any = None,
agent: BaseAgent | LiteAgent | None = None,
task: Task | None = None,
crew: Crew | None = None,
) -> ToolFailure | None:
"""Return the failure to attach to ``ToolUsageFinishedEvent``.
``None`` under :attr:`ToolFailurePolicy.IGNORE`, so that policy really does
surface nothing -- neither a record, nor an event, nor a flag on the
finished event that a trace UI would render as a failure.
"""
if failure is None:
return None
policy = resolve_tool_failure_policy(tool=tool, agent=agent, task=task, crew=crew)
return None if policy is ToolFailurePolicy.IGNORE else failure
def handle_tool_failure(
failure: ToolFailure,
*,
tool_name: str,
tool_args: dict[str, Any] | str | None = None,
tool: Any = None,
agent: BaseAgent | LiteAgent | None = None,
task: Task | None = None,
crew: Crew | None = None,
) -> ToolFailureRecord | None:
"""Apply the effective policy to a failure a tool just reported.
Records it on the agent and emits :class:`ToolFailureDetectedEvent`.
Returns the record, or ``None`` under :attr:`ToolFailurePolicy.IGNORE`.
Raises:
ToolExecutionFailedError: Under :attr:`ToolFailurePolicy.RAISE`.
"""
policy = resolve_tool_failure_policy(tool=tool, agent=agent, task=task, crew=crew)
if policy is ToolFailurePolicy.IGNORE:
return None
record = ToolFailureRecord(
tool_name=tool_name,
failure=failure,
tool_args=tool_args,
agent_role=getattr(agent, "role", None),
task_name=(task.name or task.description) if task else None,
task_id=str(task.id) if task else None,
)
_record_failure(agent, record)
# Local import: crewai.events imports tool types back, so a module-level
# import would cycle.
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.tool_usage_events import ToolFailureDetectedEvent
crewai_event_bus.emit(
agent,
ToolFailureDetectedEvent(
tool_name=tool_name,
tool_args=tool_args if tool_args is not None else {},
failure=failure,
policy=policy,
agent_role=record.agent_role,
agent_key=getattr(agent, "key", None),
# Set explicitly rather than via from_agent, which would also
# overwrite agent_role and lose the _original_role preference that
# the paired ToolUsage events use.
agent_id=_agent_id(agent),
agent=agent,
task_name=record.task_name,
task_id=record.task_id,
),
)
if policy is ToolFailurePolicy.RAISE:
raise ToolExecutionFailedError(record)
return record

View File

@@ -24,13 +24,6 @@ from crewai.events.types.tool_usage_events import (
from crewai.telemetry.telemetry import Telemetry
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.tool_calling import InstructorToolCalling, ToolCalling
from crewai.tools.tool_failure import (
ToolFailure,
ToolFailureReason,
detect_tool_failure,
failure_from_exception,
reportable_failure,
)
from crewai.utilities.agent_utils import (
get_tool_names,
render_text_description_and_args,
@@ -43,7 +36,6 @@ from crewai.utilities.string_utils import sanitize_tool_name
if TYPE_CHECKING:
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.agents.tools_handler import ToolsHandler
from crewai.crew import Crew
from crewai.lite_agent import LiteAgent
from crewai.llm import LLM
from crewai.task import Task
@@ -103,7 +95,6 @@ class ToolUsage:
agent: BaseAgent | LiteAgent | None = None,
action: Any = None,
fingerprint_context: dict[str, str] | None = None,
crew: Crew | None = None,
) -> None:
self._telemetry: Telemetry = Telemetry()
self._run_attempts: int = 1
@@ -115,17 +106,10 @@ class ToolUsage:
self.tools_handler = tools_handler
self.tools = tools
self.task = task
self.crew = crew
self.action = action
self.function_calling_llm = function_calling_llm
self.fingerprint_context = fingerprint_context or {}
self.last_raw_result: Any = _RAW_RESULT_UNSET
self.last_failure: ToolFailure | None = None
"""Failure reported by the most recent call, if any.
Covers both tool-returned failures and framework-generated ones (a
stringified exception, a spent usage limit).
"""
if (
self.function_calling_llm
@@ -281,10 +265,8 @@ class ToolUsage:
"run_attempts": self._run_attempts,
}
# Not every agent type carries a fingerprint (LiteAgent does not).
agent_fingerprint = getattr(self.agent, "fingerprint", None)
if agent_fingerprint:
event_data.update(agent_fingerprint)
if self.agent.fingerprint: # type: ignore
event_data.update(self.agent.fingerprint) # type: ignore
if self.task:
event_data["task_name"] = self.task.name or self.task.description
event_data["task_id"] = str(self.task.id)
@@ -327,10 +309,6 @@ class ToolUsage:
if usage_limit_error:
result = usage_limit_error
self.last_raw_result = result
self.last_failure = ToolFailure(
message=usage_limit_error,
reason=ToolFailureReason.USAGE_LIMIT,
)
self._telemetry.tool_usage_error(llm=self.function_calling_llm)
result = self._format_result(result=result)
elif result is None:
@@ -393,7 +371,6 @@ class ToolUsage:
attempts=self._run_attempts,
)
self.last_raw_result = result
self.last_failure = detect_tool_failure(result)
result = self._format_result(
result=tool.format_output_for_agent(result)
)
@@ -406,9 +383,6 @@ class ToolUsage:
if (
hasattr(available_tool, "result_as_answer")
and available_tool.result_as_answer
# A failed tool must not become the final answer;
# process_tool_results() reads this back independently.
and self.last_failure is None
):
result_as_answer = available_tool.result_as_answer
data["result_as_answer"] = result_as_answer
@@ -462,7 +436,6 @@ class ToolUsage:
f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}"
).message
self.last_raw_result = result
self.last_failure = failure_from_exception(e)
if self.task:
self.task.increment_tools_errors()
if self.agent and self.agent.verbose:
@@ -473,7 +446,6 @@ class ToolUsage:
should_retry = True
else:
self.last_raw_result = result
self.last_failure = detect_tool_failure(result)
result = self._format_result(
result=tool.format_output_for_agent(result)
)
@@ -532,10 +504,9 @@ class ToolUsage:
"run_attempts": self._run_attempts,
}
# Not every agent type carries a fingerprint (LiteAgent does not).
agent_fingerprint = getattr(self.agent, "fingerprint", None)
if agent_fingerprint:
event_data.update(agent_fingerprint)
# TODO: Investigate fingerprint attribute availability on BaseAgent/LiteAgent
if self.agent.fingerprint: # type: ignore
event_data.update(self.agent.fingerprint) # type: ignore
if self.task:
event_data["task_name"] = self.task.name or self.task.description
event_data["task_id"] = str(self.task.id)
@@ -578,10 +549,6 @@ class ToolUsage:
if usage_limit_error:
result = usage_limit_error
self.last_raw_result = result
self.last_failure = ToolFailure(
message=usage_limit_error,
reason=ToolFailureReason.USAGE_LIMIT,
)
self._telemetry.tool_usage_error(llm=self.function_calling_llm)
result = self._format_result(result=result)
elif result is None:
@@ -644,7 +611,6 @@ class ToolUsage:
attempts=self._run_attempts,
)
self.last_raw_result = result
self.last_failure = detect_tool_failure(result)
result = self._format_result(
result=tool.format_output_for_agent(result)
)
@@ -657,9 +623,6 @@ class ToolUsage:
if (
hasattr(available_tool, "result_as_answer")
and available_tool.result_as_answer
# A failed tool must not become the final answer;
# process_tool_results() reads this back independently.
and self.last_failure is None
):
result_as_answer = available_tool.result_as_answer
data["result_as_answer"] = result_as_answer
@@ -713,7 +676,6 @@ class ToolUsage:
f"\n{error_message}.\nMoving on then. {I18N_DEFAULT.slice('format').format(tool_names=self.tools_names)}"
).message
self.last_raw_result = result
self.last_failure = failure_from_exception(e)
if self.task:
self.task.increment_tools_errors()
if self.agent and self.agent.verbose:
@@ -724,7 +686,6 @@ class ToolUsage:
should_retry = True
else:
self.last_raw_result = result
self.last_failure = detect_tool_failure(result)
result = self._format_result(
result=tool.format_output_for_agent(result)
)
@@ -1027,13 +988,6 @@ class ToolUsage:
"finished_at": datetime.datetime.fromtimestamp(finished_at),
"from_cache": from_cache,
"output": result,
"failure": reportable_failure(
self.last_failure,
tool=tool,
agent=self.agent,
task=self.task,
crew=self.crew,
),
}
)
if self.task:
@@ -1044,13 +998,9 @@ class ToolUsage:
def _prepare_event_data(
self, tool: Any, tool_calling: ToolCalling | InstructorToolCalling
) -> dict[str, Any]:
agent_id = getattr(self.agent, "id", None) if self.agent else None
event_data = {
"run_attempts": self._run_attempts,
"delegations": self.task.delegations if self.task else 0,
# agent_key alone cannot correlate an event with a specific agent
# instance; the native paths already carry agent_id via from_agent.
"agent_id": str(agent_id) if agent_id is not None else None,
"tool_name": sanitize_tool_name(tool.name),
"tool_args": tool_calling.arguments,
"tool_class": tool.__class__.__name__,

View File

@@ -31,14 +31,6 @@ from crewai.tools.structured_tool import (
CrewStructuredTool,
strip_composite_description_prefix,
)
from crewai.tools.tool_failure import (
ToolFailure,
ToolFailureReason,
detect_tool_failure,
failure_from_exception,
handle_tool_failure,
reportable_failure,
)
from crewai.tools.tool_types import ToolResult
from crewai.utilities.errors import AgentRepositoryError
from crewai.utilities.exceptions.context_window_exceeding_exception import (
@@ -1540,9 +1532,6 @@ class NativeToolCallResult:
def format_native_tool_output_for_agent(tool: Any, raw_result: Any) -> str:
"""Format native tool output when a tool explicitly defines a formatter."""
if isinstance(raw_result, ToolFailure):
return raw_result.as_agent_message()
formatter = inspect.getattr_static(tool, "format_output_for_agent", None)
if formatter is None:
return str(raw_result)
@@ -1611,30 +1600,13 @@ def execute_single_native_tool_call(
call_id, func_name, func_args = info
parsed_args, parse_error = parse_tool_call_args(func_args, func_name, call_id)
if parse_error is not None:
# Previously the decode error was swallowed into empty args and the tool
# ran with no input at all.
handle_tool_failure(
parse_error["tool_failure"],
tool_name=func_name,
tool_args=func_args,
agent=agent,
task=task,
crew=crew,
)
return NativeToolCallResult(
call_id=call_id,
func_name=func_name,
result=parse_error["result"],
tool_message={
"role": "tool",
"tool_call_id": call_id,
"name": func_name,
"content": parse_error["result"],
},
)
args_dict = parsed_args if parsed_args is not None else {}
if isinstance(func_args, str):
try:
args_dict = json.loads(func_args)
except json.JSONDecodeError:
args_dict = {}
else:
args_dict = func_args
agent_key = getattr(agent, "key", "unknown") if agent else "unknown"
@@ -1656,14 +1628,12 @@ def execute_single_native_tool_call(
input_str = json.dumps(args_dict) if args_dict else ""
result = "Tool not found"
raw_tool_result: Any = result
tool_failure: ToolFailure | None = None
if tools_handler and tools_handler.cache and output_tool is not None:
cached_result = tools_handler.cache.read(tool=func_name, input=input_str)
if cached_result is not None:
raw_tool_result = cached_result
result = format_native_tool_output_for_agent(output_tool, cached_result)
tool_failure = detect_tool_failure(cached_result)
from_cache = True
started_at = datetime.now()
@@ -1696,9 +1666,6 @@ def execute_single_native_tool_call(
if hook_blocked:
result = f"Tool execution blocked by hook. Tool: {func_name}"
raw_tool_result = result
# The blocked message replaces any cached result, so a cached failure
# must not be attributed to this call.
tool_failure = None
elif not from_cache:
if func_name in available_functions and output_tool is not None:
try:
@@ -1718,11 +1685,9 @@ def execute_single_native_tool_call(
)
result = format_native_tool_output_for_agent(output_tool, raw_result)
tool_failure = detect_tool_failure(raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
raw_tool_result = result
tool_failure = failure_from_exception(e)
if task:
task.increment_tools_errors()
crewai_event_bus.emit(
@@ -1739,14 +1704,6 @@ def execute_single_native_tool_call(
),
)
error_event_emitted = True
else:
# Not cached and not executable: the model asked for a tool we do
# not have. The ReAct path reports this, so this one must too.
tool_failure = ToolFailure(
message=result,
reason=ToolFailureReason.UNKNOWN_TOOL,
code=func_name,
)
after_hook_context = ToolCallHookContext(
tool_name=func_name,
@@ -1776,29 +1733,9 @@ def execute_single_native_tool_call(
plan_step_description=plan_step_description,
started_at=started_at,
finished_at=datetime.now(),
failure=reportable_failure(
tool_failure,
tool=structured_tool,
agent=agent,
task=task,
crew=crew,
),
),
)
# After the finished event, so subscribers see the full lifecycle even
# when the policy aborts.
if tool_failure is not None:
handle_tool_failure(
tool_failure,
tool_name=func_name,
tool_args=args_dict,
tool=structured_tool,
agent=agent,
task=task,
crew=crew,
)
tool_message: LLMMessage = {
"role": "tool",
"tool_call_id": call_id,
@@ -1819,9 +1756,6 @@ def execute_single_native_tool_call(
and original_tool.result_as_answer
and not error_event_emitted
and not hook_blocked
# A declared failure is excluded for the same reason a raised one is:
# an error must not silently become the task's answer.
and tool_failure is None
)
return NativeToolCallResult(
@@ -1845,28 +1779,21 @@ def parse_tool_call_args(
Returns:
``(args_dict, None)`` on success, or ``(None, error_result)`` on
JSON parse failure where ``error_result`` is a ready-to-return dict
with the same shape as ``_execute_single_native_tool_call`` return
values, carrying an ``INVALID_INPUT`` failure for the caller to report.
with the same shape as ``_execute_single_native_tool_call`` return values.
"""
if isinstance(func_args, str):
try:
return json.loads(func_args), None
except json.JSONDecodeError as e:
message = (
f"Error: Failed to parse tool arguments as JSON: {e}. "
f"Please provide valid JSON arguments for the '{func_name}' tool."
)
return None, {
"call_id": call_id,
"func_name": func_name,
"result": message,
"result": (
f"Error: Failed to parse tool arguments as JSON: {e}. "
f"Please provide valid JSON arguments for the '{func_name}' tool."
),
"from_cache": False,
"original_tool": original_tool,
"tool_failure": ToolFailure(
message=message,
reason=ToolFailureReason.INVALID_INPUT,
code="json_decode_error",
),
}
return func_args, None

View File

@@ -70,10 +70,6 @@ class Prompts(BaseModel):
description="Whether to use the system prompt when no custom templates are provided",
)
agent: Any = Field(description="Reference to the agent using these prompts")
skill_loader_tool_name: str | None = Field(
default=None,
description="Name the runtime skill loader tool is registered under",
)
def task_execution(self) -> SystemPromptResult | StandardPromptResult:
"""Generate a standard prompt for task execution.
@@ -119,50 +115,22 @@ class Prompts(BaseModel):
)
def _build_skill_block(self) -> str:
"""Render always-on instructions and the available skill catalog.
"""Render the agent's activated skills as a stable XML block.
Metadata-only skills remain a stable prompt-cache anchor. Their full
instructions are disclosed through the runtime loader tool only when the
model determines that a skill applies to the current request.
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 build_skill_catalog, format_skill_context
from crewai.skills.models import INSTRUCTIONS, Skill
from crewai.skills.tool import LOAD_SKILL_TOOL_NAME
from crewai.skills.loader import format_skill_context
from crewai.skills.models import Skill
loaded = [skill for skill in skills if isinstance(skill, Skill)]
active = [
format_skill_context(skill)
for skill in loaded
if skill.disclosure_level >= INSTRUCTIONS
]
catalog = build_skill_catalog(loaded)
blocks: list[str] = []
if active:
blocks.append("<skills>\n" + "\n\n".join(active) + "\n</skills>")
if catalog:
loader = self.skill_loader_tool_name or LOAD_SKILL_TOOL_NAME
entries = "\n\n".join(
format_skill_context(skill, label=label)
for label, skill in catalog.items()
)
blocks.append(
"<available_skills>\n"
"These skills are not loaded. Most requests need none of them, "
"so answer directly unless one clearly applies to this request. "
f"When one does, call `{loader}` with its exact name to load its "
"full instructions, and do not follow a skill's instructions "
"without loading it first.\n\n"
f"{entries}\n"
"</available_skills>"
)
if not blocks:
sections = [format_skill_context(s) for s in skills if isinstance(s, Skill)]
if not sections:
return ""
return "\n\n" + "\n\n".join(blocks)
return "\n\n<skills>\n" + "\n\n".join(sections) + "\n</skills>"
def _build_prompt(
self,

View File

@@ -11,11 +11,6 @@ from crewai.hooks.tool_hooks import (
)
from crewai.security.fingerprint import Fingerprint
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.tool_failure import (
ToolFailure,
ToolFailureReason,
handle_tool_failure,
)
from crewai.tools.tool_types import ToolResult
from crewai.tools.tool_usage import ToolUsage, ToolUsageError
from crewai.utilities.i18n import I18N_DEFAULT
@@ -26,7 +21,6 @@ if TYPE_CHECKING:
from crewai.agent import Agent
from crewai.agents.agent_builder.base_agent import BaseAgent
from crewai.crew import Crew
from crewai.lite_agent import LiteAgent
from crewai.llm import LLM
from crewai.llms.base_llm import BaseLLM
from crewai.task import Task
@@ -39,7 +33,7 @@ async def aexecute_tool_and_check_finality(
agent_role: str | None = None,
tools_handler: ToolsHandler | None = None,
task: Task | None = None,
agent: Agent | BaseAgent | LiteAgent | None = None,
agent: Agent | BaseAgent | None = None,
function_calling_llm: BaseLLM | LLM | None = None,
fingerprint_context: dict[str, str] | None = None,
crew: Crew | None = None,
@@ -86,25 +80,11 @@ async def aexecute_tool_and_check_finality(
task=task,
agent=agent,
action=agent_action,
crew=crew,
)
tool_calling = tool_usage.parse_tool_calling(agent_action.text)
if isinstance(tool_calling, ToolUsageError):
# Mirrors the native paths, which report a malformed call as
# INVALID_INPUT rather than passing the message along silently.
handle_tool_failure(
ToolFailure(
message=tool_calling.message,
reason=ToolFailureReason.INVALID_INPUT,
),
tool_name=getattr(agent_action, "tool", "") or "unknown",
tool_args=getattr(agent_action, "tool_input", None),
agent=agent,
task=task,
crew=crew,
)
return ToolResult(tool_calling.message, False)
sanitized_tool_name = sanitize_tool_name(tool_calling.tool_name)
@@ -158,42 +138,15 @@ async def aexecute_tool_and_check_finality(
modified_result = run_after_tool_call_hooks(after_hook_context)
# After the hooks, so post_tool_call can still inspect or rewrite the
# result before the policy aborts.
if tool_usage.last_failure is not None:
handle_tool_failure(
tool_usage.last_failure,
tool_name=sanitized_tool_name,
tool_args=tool_input,
tool=tool,
agent=agent,
task=task,
crew=crew,
)
return ToolResult(
modified_result if modified_result is not None else tool_result,
# A failed tool must not become the final answer -- the same
# exclusion the native paths already apply to raised errors.
tool.result_as_answer and tool_usage.last_failure is None,
tool.result_as_answer,
)
tool_result = I18N_DEFAULT.errors("wrong_tool_name").format(
tool=sanitized_tool_name,
tools=", ".join(tool_name_to_tool_map.keys()),
)
handle_tool_failure(
ToolFailure(
message=tool_result,
reason=ToolFailureReason.UNKNOWN_TOOL,
code=sanitized_tool_name,
),
tool_name=sanitized_tool_name,
tool_args=tool_calling.arguments,
agent=agent,
task=task,
crew=crew,
)
return ToolResult(result=tool_result, result_as_answer=False)
@@ -204,7 +157,7 @@ def execute_tool_and_check_finality(
agent_role: str | None = None,
tools_handler: ToolsHandler | None = None,
task: Task | None = None,
agent: Agent | BaseAgent | LiteAgent | None = None,
agent: Agent | BaseAgent | None = None,
function_calling_llm: BaseLLM | LLM | None = None,
fingerprint_context: dict[str, str] | None = None,
crew: Crew | None = None,
@@ -249,25 +202,11 @@ def execute_tool_and_check_finality(
task=task,
agent=agent,
action=agent_action,
crew=crew,
)
tool_calling = tool_usage.parse_tool_calling(agent_action.text)
if isinstance(tool_calling, ToolUsageError):
# Mirrors the native paths, which report a malformed call as
# INVALID_INPUT rather than passing the message along silently.
handle_tool_failure(
ToolFailure(
message=tool_calling.message,
reason=ToolFailureReason.INVALID_INPUT,
),
tool_name=getattr(agent_action, "tool", "") or "unknown",
tool_args=getattr(agent_action, "tool_input", None),
agent=agent,
task=task,
crew=crew,
)
return ToolResult(tool_calling.message, False)
sanitized_tool_name = sanitize_tool_name(tool_calling.tool_name)
@@ -321,40 +260,13 @@ def execute_tool_and_check_finality(
modified_result = run_after_tool_call_hooks(after_hook_context)
# After the hooks, so post_tool_call can still inspect or rewrite the
# result before the policy aborts.
if tool_usage.last_failure is not None:
handle_tool_failure(
tool_usage.last_failure,
tool_name=sanitized_tool_name,
tool_args=tool_input,
tool=tool,
agent=agent,
task=task,
crew=crew,
)
return ToolResult(
modified_result if modified_result is not None else tool_result,
# A failed tool must not become the final answer -- the same
# exclusion the native paths already apply to raised errors.
tool.result_as_answer and tool_usage.last_failure is None,
tool.result_as_answer,
)
tool_result = I18N_DEFAULT.errors("wrong_tool_name").format(
tool=sanitized_tool_name,
tools=", ".join(tool_name_to_tool_map.keys()),
)
handle_tool_failure(
ToolFailure(
message=tool_result,
reason=ToolFailureReason.UNKNOWN_TOOL,
code=sanitized_tool_name,
),
tool_name=sanitized_tool_name,
tool_args=tool_calling.arguments,
agent=agent,
task=task,
crew=crew,
)
return ToolResult(result=tool_result, result_as_answer=False)

View File

@@ -12,8 +12,6 @@ import pytest
from crewai import Agent, Task
from crewai.events import crewai_event_bus
from crewai.events.types.skill_events import SkillUsedEvent
from crewai.skills.loader import activate_skill, discover_skills
from crewai.skills.tool import LoadSkillTool
# Handlers run on the event bus thread pool, so tests must flush before
@@ -31,13 +29,12 @@ def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path:
def _agent_with_skills(search_path: Path) -> Agent:
"""Create an agent with explicitly activated, always-on skills."""
skills = [activate_skill(skill) for skill in discover_skills(search_path)]
# discover_skills scans the SUBdirectories of the given path.
return Agent(
role="Analyst",
goal="Analyze things",
backstory="Experienced",
skills=skills,
skills=[str(search_path)],
llm="gpt-4o-mini",
)
@@ -47,43 +44,6 @@ def _task_for(agent: Agent) -> Task:
class TestSkillUsedEvent:
def test_metadata_skill_emits_only_after_runtime_selection(
self, tmp_path: Path
) -> None:
_create_skill_dir(tmp_path, "alpha")
agent = Agent(
role="Analyst",
goal="Analyze things",
backstory="Experienced",
skills=[tmp_path],
llm="gpt-4o-mini",
)
task = _task_for(agent)
agent.create_agent_executor(task=task)
assert agent.agent_executor is not None
loader = next(
tool
for tool in agent.agent_executor.original_tools
if isinstance(tool, LoadSkillTool)
)
received: list[SkillUsedEvent] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(SkillUsedEvent)
def _handler(source, event: SkillUsedEvent) -> None: # noqa: ARG001
received.append(event)
agent._emit_skill_usage(task)
assert crewai_event_bus.flush(timeout=10)
assert received == []
loader.run(skill_name="alpha")
assert crewai_event_bus.flush(timeout=10)
assert [event.skill_name for event in received] == ["alpha"]
assert received[0].task_id == str(task.id)
def test_emits_one_event_per_skill_with_agent_and_task_attribution(
self, tmp_path: Path
) -> None:
@@ -149,7 +109,7 @@ class TestSkillUsedEvent:
assert len(received) == 2
def test_reports_disclosure_level(self, tmp_path: Path) -> None:
"""Explicitly activated skills report INSTRUCTIONS level."""
"""Path-loaded skills are activated, so they report INSTRUCTIONS level."""
_create_skill_dir(tmp_path, "alpha")
agent = _agent_with_skills(tmp_path)
task = _task_for(agent)

View File

@@ -4,7 +4,6 @@ from unittest.mock import AsyncMock, patch
import pytest
from crewai.agent.core import Agent
from crewai.mcp.client import _MCPToolResult
from crewai.mcp.config import MCPServerHTTP, MCPServerSSE, MCPServerStdio
from crewai.tools.base_tool import BaseTool
@@ -40,9 +39,6 @@ def _make_mock_client(tool_definitions):
client.connect = AsyncMock()
client.disconnect = AsyncMock()
client.call_tool = AsyncMock(return_value="test result")
client.call_tool_result = AsyncMock(
return_value=_MCPToolResult("test result", False)
)
return client
@@ -231,9 +227,9 @@ def test_parallel_mcp_tool_execution_same_tool(mock_tool_definitions):
async def _call_tool(name, args):
call_log.append(name)
await asyncio.sleep(0.05)
return _MCPToolResult(f"result-{name}", False)
return f"result-{name}"
client.call_tool_result = AsyncMock(side_effect=_call_tool)
client.call_tool = AsyncMock(side_effect=_call_tool)
return client
with patch("crewai.mcp.tool_resolver.MCPClient", side_effect=_make_client):
@@ -277,9 +273,9 @@ def test_parallel_mcp_tool_execution_different_tools(mock_tool_definitions):
async def _call_tool(name, args):
call_log.append(name)
await asyncio.sleep(0.05)
return _MCPToolResult(f"result-{name}", False)
return f"result-{name}"
client.call_tool_result = AsyncMock(side_effect=_call_tool)
client.call_tool = AsyncMock(side_effect=_call_tool)
return client
with patch("crewai.mcp.tool_resolver.MCPClient", side_effect=_make_client):

View File

@@ -144,8 +144,7 @@ class TestSkillDiscoveryAndActivation:
assert agent.skills is not None
assert [skill.name for skill in agent.skills] == ["path-skill"]
assert [skill.disclosure_level for skill in agent.skills] == [METADATA]
assert [skill.instructions for skill in agent.skills] == [None]
assert [skill.instructions for skill in agent.skills] == ["Use the path skill."]
def test_crew_resolves_inline_skill_string(self) -> None:
agent = Agent(
@@ -176,7 +175,7 @@ class TestSkillDiscoveryAndActivation:
assert [skill.name for skill in skills] == ["crew-inline-review"]
assert [skill.instructions for skill in skills] == ["Apply this to every agent."]
def test_crew_preserves_preloaded_metadata_skill(self, tmp_path: Path) -> None:
def test_crew_activates_preloaded_metadata_skill(self, tmp_path: Path) -> None:
_create_skill_dir(
tmp_path,
"crew-preloaded",
@@ -203,5 +202,7 @@ class TestSkillDiscoveryAndActivation:
assert skills is not None
assert [skill.name for skill in skills] == ["crew-preloaded"]
assert [skill.disclosure_level for skill in skills] == [METADATA]
assert [skill.instructions for skill in skills] == [None]
assert [skill.disclosure_level for skill in skills] == [INSTRUCTIONS]
assert [skill.instructions for skill in skills] == [
"Apply this crew-level guidance to every agent."
]

View File

@@ -1,337 +0,0 @@
"""Regression tests for runtime skill progressive disclosure.
Run this focused test file with:
uv run pytest lib/crewai/tests/skills/test_progressive_disclosure.py -q
"""
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
from crewai import Agent, Task
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.skill_events import SkillUsedEvent
from crewai.llms.base_llm import BaseLLM
from crewai.skills.models import METADATA
from crewai.skills.parser import load_skill_metadata
from crewai.skills.tool import LoadSkillTool, create_skill_loader_tool
from crewai.tools.base_tool import BaseTool
from crewai.utilities.prompts import Prompts
class _ConflictingToolSchema(BaseModel):
query: str = Field(default="")
class _ConflictingTool(BaseTool):
"""A user tool that already claims the skill loader's default name."""
name: str = "load_skill"
description: str = "Load something unrelated to skills."
args_schema: type[BaseModel] = _ConflictingToolSchema
def _run(self, query: str = "", **kwargs: Any) -> str:
return "user tool result"
def _create_skill(
parent: Path,
name: str,
description: str,
instructions: str,
) -> None:
skill_dir = parent / name
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n{instructions}",
encoding="utf-8",
)
class _SkillChoosingLLM(BaseLLM):
"""Small deterministic LLM that exercises the real agent tool loop."""
def call(self, messages: Any, **kwargs: Any) -> str:
rendered = str(messages)
if "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS" in rendered:
return "Thought: I loaded the review skill.\nFinal Answer: python loaded"
if "TRAVEL_PRIVATE_INSTRUCTIONS" in rendered:
return "Thought: I loaded the travel skill.\nFinal Answer: travel loaded"
if "Review this Python change" in rendered:
return (
"Thought: The Python review skill applies.\n"
"Action: load_skill\n"
'Action Input: {"skill_name": "python-review"}'
)
if "Plan a weekend trip" in rendered:
return (
"Thought: The travel planning skill applies.\n"
"Action: load_skill\n"
'Action Input: {"skill_name": "travel-planning"}'
)
return "Thought: No skill applies.\nFinal Answer: no skill"
def supports_function_calling(self) -> bool:
return False
def supports_stop_words(self) -> bool:
return False
def get_context_window_size(self) -> int:
return 8_192
def test_agent_directory_skills_do_not_eagerly_disclose_instructions(
tmp_path: Path,
) -> None:
"""An agent should initially receive only the skill catalog metadata."""
_create_skill(
tmp_path,
"python-review",
"Review Python code when the user asks for a code review.",
"PYTHON_REVIEW_PRIVATE_INSTRUCTIONS",
)
_create_skill(
tmp_path,
"travel-planning",
"Plan travel when the user asks for a trip itinerary.",
"TRAVEL_PRIVATE_INSTRUCTIONS",
)
agent = Agent(
role="Assistant",
goal="Help with the current request.",
backstory="A general-purpose assistant.",
skills=[tmp_path],
)
assert agent.skills is not None
assert [skill.disclosure_level for skill in agent.skills] == [
METADATA,
METADATA,
]
assert [skill.instructions for skill in agent.skills] == [None, None]
def test_consecutive_kickoffs_select_skills_without_cross_call_leakage(
tmp_path: Path,
) -> None:
"""Each execution should independently choose its relevant skill."""
_create_skill(
tmp_path,
"python-review",
"Review Python code when the user asks for a code review.",
"PYTHON_REVIEW_PRIVATE_INSTRUCTIONS",
)
_create_skill(
tmp_path,
"travel-planning",
"Plan travel when the user asks for a trip itinerary.",
"TRAVEL_PRIVATE_INSTRUCTIONS",
)
agent = Agent(
role="Assistant",
goal="Help with the current request.",
backstory="A general-purpose assistant.",
skills=[tmp_path],
llm=_SkillChoosingLLM(model="skill-test"),
max_iter=3,
)
review_output = agent.kickoff("Review this Python change")
travel_output = agent.kickoff("Plan a weekend trip")
repeated_review_output = agent.kickoff("Review this Python change")
assert review_output.raw == "python loaded"
assert travel_output.raw == "travel loaded"
assert repeated_review_output.raw == "python loaded"
assert agent.skills is not None
assert [skill.disclosure_level for skill in agent.skills] == [
METADATA,
METADATA,
]
assert [skill.instructions for skill in agent.skills] == [None, None]
prompt = Prompts(
agent=agent,
has_tools=False,
use_system_prompt=True,
).task_execution()
rendered = getattr(prompt, "system", "") or prompt.prompt
assert "python-review" in rendered
assert "travel-planning" in rendered
assert "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS" not in rendered
assert "TRAVEL_PRIVATE_INSTRUCTIONS" not in rendered
def test_each_execution_can_disclose_only_its_relevant_skill(tmp_path: Path) -> None:
"""Loading one skill must not leak or permanently activate another."""
_create_skill(
tmp_path,
"python-review",
"Review Python code when the user asks for a code review.",
"PYTHON_REVIEW_PRIVATE_INSTRUCTIONS",
)
_create_skill(
tmp_path,
"travel-planning",
"Plan travel when the user asks for a trip itinerary.",
"TRAVEL_PRIVATE_INSTRUCTIONS",
)
agent = Agent(
role="Assistant",
goal="Help with the current request.",
backstory="A general-purpose assistant.",
skills=[tmp_path],
)
review_task = Task(
description="Review this Python change.",
expected_output="Review findings.",
agent=agent,
)
agent.create_agent_executor(task=review_task)
assert agent.agent_executor is not None
review_loader = next(
tool
for tool in agent.agent_executor.original_tools
if isinstance(tool, LoadSkillTool)
)
review_context = review_loader.run(skill_name="python-review")
assert "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS" in review_context
assert "TRAVEL_PRIVATE_INSTRUCTIONS" not in review_context
travel_task = Task(
description="Plan a weekend trip.",
expected_output="A travel itinerary.",
agent=agent,
)
agent.create_agent_executor(task=travel_task)
assert agent.agent_executor is not None
travel_loader = next(
tool
for tool in agent.agent_executor.original_tools
if isinstance(tool, LoadSkillTool)
)
travel_context = travel_loader.run(skill_name="travel-planning")
assert "TRAVEL_PRIVATE_INSTRUCTIONS" in travel_context
assert "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS" not in travel_context
assert agent.skills is not None
assert [skill.disclosure_level for skill in agent.skills] == [
METADATA,
METADATA,
]
assert [skill.instructions for skill in agent.skills] == [None, None]
def test_loader_stays_reachable_when_a_tool_claims_its_default_name(
tmp_path: Path,
) -> None:
"""A configured tool named load_skill must not shadow the skill loader."""
_create_skill(
tmp_path,
"python-review",
"Review Python code when the user asks for a code review.",
"PYTHON_REVIEW_PRIVATE_INSTRUCTIONS",
)
agent = Agent(
role="Assistant",
goal="Help with the current request.",
backstory="A general-purpose assistant.",
skills=[tmp_path],
tools=[_ConflictingTool()],
)
agent.create_agent_executor(
task=Task(
description="Review this Python change.",
expected_output="Review findings.",
agent=agent,
)
)
assert agent.agent_executor is not None
tools = agent.agent_executor.original_tools
loader = next(tool for tool in tools if isinstance(tool, LoadSkillTool))
assert loader.name != "load_skill"
assert [tool.name for tool in tools].count(loader.name) == 1
assert "PYTHON_REVIEW_PRIVATE_INSTRUCTIONS" in loader.run(
skill_name="python-review"
)
prompt = agent.agent_executor.prompt
rendered = prompt.get("system") or prompt.get("prompt")
assert f"call `{loader.name}`" in rendered
def test_same_named_skills_from_different_orgs_stay_individually_loadable(
tmp_path: Path,
) -> None:
"""Skills sharing a frontmatter name must each be addressable."""
_create_skill(
tmp_path / "acme",
"code-review",
"Review code the Acme way.",
"ACME_PRIVATE_INSTRUCTIONS",
)
_create_skill(
tmp_path / "globex",
"code-review",
"Review code the Globex way.",
"GLOBEX_PRIVATE_INSTRUCTIONS",
)
loader = create_skill_loader_tool(
[
load_skill_metadata(tmp_path / "acme" / "code-review"),
load_skill_metadata(tmp_path / "globex" / "code-review"),
]
)
assert loader is not None
assert sorted(loader.catalog) == ["acme/code-review", "globex/code-review"]
assert "ACME_PRIVATE_INSTRUCTIONS" in loader.run(skill_name="acme/code-review")
assert "GLOBEX_PRIVATE_INSTRUCTIONS" in loader.run(skill_name="globex/code-review")
def test_loaded_block_and_event_keep_the_catalog_label(tmp_path: Path) -> None:
"""A disambiguated skill must report the label the model was told to load."""
_create_skill(
tmp_path / "acme",
"code-review",
"Review code the Acme way.",
"ACME_PRIVATE_INSTRUCTIONS",
)
_create_skill(
tmp_path / "globex",
"code-review",
"Review code the Globex way.",
"GLOBEX_PRIVATE_INSTRUCTIONS",
)
loader = create_skill_loader_tool(
[
load_skill_metadata(tmp_path / "acme" / "code-review"),
load_skill_metadata(tmp_path / "globex" / "code-review"),
]
)
assert loader is not None
used: list[str] = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(SkillUsedEvent)
def _record(_source: Any, event: SkillUsedEvent) -> None:
used.append(event.skill_name)
context = loader.run(skill_name="globex/code-review")
assert crewai_event_bus.flush(timeout=10)
assert '<skill name="globex/code-review">' in context
assert used == ["globex/code-review"]

View File

@@ -10,7 +10,6 @@ from zipfile import ZipFile
from crewai.context import platform_context
from crewai.skills.cache import SkillCacheManager
from crewai.skills.models import METADATA
from crewai.skills.registry import (
SkillRef,
download_skill,
@@ -182,17 +181,6 @@ class TestParseSkillRef:
class TestResolveRegistryRef:
def test_can_resolve_metadata_without_loading_instructions(
self, tmp_path: Path
) -> None:
_write_local_skill(tmp_path, "my-skill")
with patch.object(Path, "cwd", return_value=tmp_path):
skill = resolve_registry_ref("@acme/my-skill", activate=False)
assert skill.disclosure_level == METADATA
assert skill.instructions is None
def test_prefers_project_local_skill_over_cached_skill(
self, tmp_path: Path
) -> None:

View File

@@ -162,7 +162,6 @@ def test_task_callback_returns_task_output():
"expected_output": "Bullet point list of 5 interesting ideas.",
"output_format": OutputFormat.RAW,
"messages": [],
"tool_failures": [],
}
assert output_dict == expected_output

File diff suppressed because it is too large Load Diff

View File

@@ -1031,14 +1031,7 @@ class TestParseToolCallArgs:
def test_error_result_has_correct_keys(self) -> None:
_, error = parse_tool_call_args("{bad json}", "tool", "call_7")
assert error is not None
assert set(error.keys()) == {
"call_id",
"func_name",
"result",
"from_cache",
"original_tool",
"tool_failure",
}
assert set(error.keys()) == {"call_id", "func_name", "result", "from_cache", "original_tool"}
class TestExecuteSingleNativeToolCall:

View File

@@ -23,7 +23,6 @@ from crewai.events.types.crew_events import (
)
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowStartedEvent,
HumanFeedbackReceivedEvent,
@@ -47,11 +46,8 @@ from crewai.events.types.tool_usage_events import (
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
)
from crewai.flow.async_feedback.types import PendingFeedbackContext
from crewai.flow.flow import Flow, listen, start
from crewai.flow.human_feedback import human_feedback
from crewai.flow.persistence.sqlite import SQLiteFlowPersistence
from crewai.hooks.dispatch import HookAborted, InterceptionPoint, clear_all, on
from crewai.llm import LLM
from crewai.task import Task
from crewai.tools.base_tool import BaseTool
@@ -560,274 +556,6 @@ def test_flow_emits_finish_event():
assert result == "completed"
def test_flow_emits_failed_event_paired_with_started_event():
started: list[FlowStartedEvent] = []
failed: list[FlowFailedEvent] = []
class BoomFlow(Flow[dict]):
@start()
def begin(self):
raise RuntimeError("boom")
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(FlowStartedEvent)
def handle_flow_started(source, event):
started.append(event)
@crewai_event_bus.on(FlowFailedEvent)
def handle_flow_failed(source, event):
failed.append(event)
with pytest.raises(RuntimeError, match="boom"):
BoomFlow().kickoff()
wait_for_event_handlers()
assert len(failed) == 1
assert failed[0].type == "flow_failed"
assert failed[0].flow_name == "BoomFlow"
assert isinstance(failed[0].error, RuntimeError)
assert str(failed[0].error) == "boom"
assert failed[0].started_event_id == started[0].event_id
def test_suppressed_flow_failure_matches_finished_event_emission():
finished: list[FlowFinishedEvent] = []
failed: list[FlowFailedEvent] = []
class SuppressedFlow(Flow):
suppress_flow_events: bool = True
@start()
def begin(self):
return "ok"
class SuppressedBoomFlow(Flow):
suppress_flow_events: bool = True
@start()
def begin(self):
raise RuntimeError("boom")
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(FlowFinishedEvent)
def handle_flow_finished(source, event):
finished.append(event)
@crewai_event_bus.on(FlowFailedEvent)
def handle_flow_failed(source, event):
failed.append(event)
SuppressedFlow().kickoff()
with pytest.raises(RuntimeError, match="boom"):
SuppressedBoomFlow().kickoff()
wait_for_event_handlers()
assert len(finished) == 1
assert len(failed) == 1
def test_abort_before_flow_started_emits_no_failed_event():
started: list[FlowStartedEvent] = []
failed: list[FlowFailedEvent] = []
class BlockedFlow(Flow):
@start()
def begin(self) -> str:
return "never runs"
clear_all()
try:
@on(InterceptionPoint.EXECUTION_START)
def block(_ctx):
raise HookAborted(reason="blocked by policy")
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(FlowStartedEvent)
def handle_flow_started(source, event):
started.append(event)
@crewai_event_bus.on(FlowFailedEvent)
def handle_flow_failed(source, event):
failed.append(event)
with pytest.raises(HookAborted):
BlockedFlow().kickoff()
wait_for_event_handlers()
finally:
clear_all()
assert started == []
assert failed == []
def test_resume_emits_failed_event_paired_with_resume_started_event(tmp_path):
started: list[FlowStartedEvent] = []
failed: list[FlowFailedEvent] = []
class ResumeBoomFlow(Flow):
@start()
def begin(self) -> str:
return "content"
@listen(begin)
def after_feedback(self, _feedback):
raise RuntimeError("boom on resume")
persistence = SQLiteFlowPersistence(str(tmp_path / "flow.db"))
flow_id = "resume-failure-test"
persistence.save_pending_feedback(
flow_uuid=flow_id,
context=PendingFeedbackContext(
flow_id=flow_id,
flow_class="ResumeBoomFlow",
method_name="begin",
method_output="content",
message="Review:",
),
state_data={"id": flow_id},
)
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(FlowStartedEvent)
def handle_flow_started(source, event):
started.append(event)
@crewai_event_bus.on(FlowFailedEvent)
def handle_flow_failed(source, event):
failed.append(event)
flow = ResumeBoomFlow.from_pending(flow_id, persistence)
with pytest.raises(RuntimeError, match="boom on resume"):
flow.resume("ok")
wait_for_event_handlers()
assert len(started) == 1
assert len(failed) == 1
assert failed[0].flow_name == "ResumeBoomFlow"
assert str(failed[0].error) == "boom on resume"
assert failed[0].started_event_id == started[0].event_id
def test_resume_pairs_resumed_method_events_with_their_own_scope(tmp_path):
started: list[FlowStartedEvent] = []
finished: list[FlowFinishedEvent] = []
method_started: list[MethodExecutionStartedEvent] = []
method_finished: list[MethodExecutionFinishedEvent] = []
class ResumeFlow(Flow):
@start()
def begin(self) -> str:
return "content"
@listen(begin)
def after_feedback(self, _feedback):
return "done"
persistence = SQLiteFlowPersistence(str(tmp_path / "flow.db"))
flow_id = "resume-pairing-test"
persistence.save_pending_feedback(
flow_uuid=flow_id,
context=PendingFeedbackContext(
flow_id=flow_id,
flow_class="ResumeFlow",
method_name="begin",
method_output="content",
message="Review:",
),
state_data={"id": flow_id},
)
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(FlowStartedEvent)
def handle_flow_started(source, event):
started.append(event)
@crewai_event_bus.on(FlowFinishedEvent)
def handle_flow_finished(source, event):
finished.append(event)
@crewai_event_bus.on(MethodExecutionStartedEvent)
def handle_method_started(source, event):
method_started.append(event)
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def handle_method_finished(source, event):
method_finished.append(event)
ResumeFlow.from_pending(flow_id, persistence).resume("ok")
wait_for_event_handlers()
resumed_started = next(e for e in method_started if e.method_name == "begin")
resumed_finished = next(e for e in method_finished if e.method_name == "begin")
assert resumed_finished.started_event_id == resumed_started.event_id
assert finished[0].started_event_id == started[0].event_id
def test_resume_failing_before_method_finishes_keeps_flow_pairing(tmp_path):
started: list[FlowStartedEvent] = []
failed: list[FlowFailedEvent] = []
method_failed: list[MethodExecutionFailedEvent] = []
class ResumeFlow(Flow):
@start()
def begin(self) -> str:
return "content"
@listen(begin)
def after_feedback(self, _feedback):
return "done"
persistence = SQLiteFlowPersistence(str(tmp_path / "flow.db"))
flow_id = "resume-finalize-failure-test"
persistence.save_pending_feedback(
flow_uuid=flow_id,
context=PendingFeedbackContext(
flow_id=flow_id,
flow_class="ResumeFlow",
method_name="begin",
method_output="content",
message="Review:",
),
state_data={"id": flow_id},
)
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(FlowStartedEvent)
def handle_flow_started(source, event):
started.append(event)
@crewai_event_bus.on(FlowFailedEvent)
def handle_flow_failed(source, event):
failed.append(event)
@crewai_event_bus.on(MethodExecutionFailedEvent)
def handle_method_failed(source, event):
method_failed.append(event)
flow = ResumeFlow.from_pending(flow_id, persistence)
with patch.object(
Flow,
"_finalize_human_feedback",
side_effect=RuntimeError("feedback collapse failed"),
):
with pytest.raises(RuntimeError, match="feedback collapse failed"):
flow.resume("ok")
wait_for_event_handlers()
assert len(method_failed) == 1
assert method_failed[0].method_name == "begin"
assert len(failed) == 1
assert failed[0].started_event_id == started[0].event_id
def test_flow_emits_method_execution_started_event():
received_events = []
lock = threading.Lock()