Compare commits

...

3 Commits
1.15.8 ... main

Author SHA1 Message Date
João Moura
453676c61a feat(tools): surface tool failures instead of reporting them as success (#6712)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Check Documentation Broken Links / Check broken links (push) Waiting to run
Vulnerability Scan / pip-audit (push) Waiting to run
* feat(tools): surface tool failures instead of reporting them as success

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 CrewAI AMP action returns
`API request failed: ...`. In every case the call "worked", so the error
text reached the agent as an ordinary result, the agent narrated the
problem in prose, and the run was recorded as a success.

Concretely: five failed `slackbot_send_message` calls each rendered as
"Tool Execution Completed", the task passed, and the crew passed -- with
the only evidence being a sentence in the final answer. Nothing
downstream could tell the difference, and an agent that keeps going on a
step that silently did nothing builds the rest of its work on it.

Give that outcome a type and a reaction:

- `ToolFailure` -- what a tool returns instead of an error string. The
  agent still reads prose via `as_agent_message()`, so model behavior is
  unchanged; the framework now knows the call failed.
- `ToolFailurePolicy` -- `ignore` (previous behavior), `warn` (default:
  record + emit, keep going), `raise` (abort with
  `ToolExecutionFailedError`). Resolved most-specific-first: tool, task,
  agent, crew.
- `ToolFailureDetectedEvent` -- emitted before a `raise` aborts, so
  subscribers always observe the failure. `ToolUsageFinishedEvent` also
  carries a `failure` field so a trace UI can mark the call failed
  without correlating two events.
- `tool_failures` on `TaskOutput`, `CrewOutput` and `LiteAgentOutput`,
  plus `has_tool_failures`, so consumers never parse a string.

Detection is strictly declarative -- no string sniffing, so a tool that
legitimately returns text about an error is never misread as failing.
Failures come from a returned `ToolFailure`, a raised exception, MCP
`isError`, a spent `max_usage_count`, or an unknown tool.

Wired into all four tool-execution paths (the ReAct path and the three
native function-calling implementations). Sources updated to report
structurally: `MCPClient.call_tool_result()` preserves `isError` that
`call_tool()` dropped, and `CrewAIPlatformActionTool` returns a
`ToolFailure` for non-2xx and for caught exceptions.

Two latent bugs fixed along the way: `ToolUsage` assumed every agent has
a `fingerprint` (LiteAgent does not), and policy resolution now tolerates
malformed values rather than letting telemetry take down a tool call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* fix(tools): address review round 1 on tool-failure signalling

Five real defects from Bugbot, none of them cosmetic.

Tool-scoped policy never applied (high). `resolve_tool_failure_policy`
read `tool_failure_policy` off the object handed to it, but every
execution path passes the `CrewStructuredTool` wrapper, which never
carried the attribute -- and `BaseTool` never declared it in the first
place. A tool-scoped `raise`/`ignore` was silently ignored while the
docs and a unit test claimed otherwise; the test passed only because it
called the resolver directly with an authored tool. Declared the field on
`BaseTool`, propagated it through `to_structured_tool()` and
`CrewStructuredTool`, and made resolution fall back through
`_original_tool` so either shape works.

A failed call still printed the green "Completed" panel, then the red
one. That is the terminal version of the exact bug this PR is about.
Suppressed the success panel when the call reported failure.

A raised tool printed twice: `ToolUsageErrorEvent` already renders a red
panel, and the new failure panel repeated it. The event is still emitted
-- policy and traces need it -- but the duplicate console output is gone.
Both decisions now live in named predicates on `ConsoleFormatter` rather
than inline in the listener closure, so they are directly testable.

Unknown tools were reported on the ReAct path but silently ignored on all
three native paths, so the same miss was loud or silent depending on
executor style. Native paths now record `UNKNOWN_TOOL` too. This also
surfaced a live `NameError`: ruff had pruned `ToolFailureReason` from
`agent_utils` as unused, so the new branch would have crashed at runtime.

`LiteAgentOutput` had `tool_failures` but not `has_tool_failures`, which
the PR promised on all three output types -- an `AttributeError` for any
caller sharing one check across result types.

Testing: 16 further tests, 45 total. Two console tests were passing
vacuously because `emit()` dispatches sync handlers on a thread pool, so
the assertions raced the handler; they now assert on the predicates
directly, and the native-path test drains the bus with `flush()` and
checks the synchronously-written record. Full suite still matches
baseline exactly at 377 pre-existing failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* chore: update tool specifications

* fix(tools): address review round 2 and fix CI type failure

CI caught a type error I should have: widening `agent` to accept a
`LiteAgent` (so a standalone LiteAgent resolves its own policy) left the
declared signatures behind. Widened `execute_tool_and_check_finality`,
its async twin, and `ToolCallHookContext` to `Agent | BaseAgent |
LiteAgent | None`, which is what those actually receive now.

Seven CodeRabbit findings, all verified against the code first:

`raise` was being downgraded by three enclosing handlers. With
`max_execution_time` set, `_execute_with_timeout` wrapped every exception
in `RuntimeError`, so `_check_execution_error` no longer recognized the
passthrough and sent the task through the retry loop instead of aborting.
`StepExecutor.execute` turned it into `StepResult(success=False)` and let
the plan continue. `LiteAgent.kickoff` ran it through
`handle_unknown_error` and printed "This is likely a bug - please report
it" for what is a deliberate, configured stop.

Failure records were dropped on two paths. `reset_tool_failures()` only
ran in `_prepare_task_execution`, so `Agent.kickoff()` / `kickoff_async()`
— which enter through `_prepare_kickoff` — accumulated records across
runs. And a guardrail retry calls `execute_task` again, which resets the
agent, so a tool that failed on a blocked attempt vanished from the final
output entirely: a run could report zero failures having demonstrably
failed one. Failures now accumulate across guardrail attempts.

Writing the tests for that surfaced a further miss of my own:
`Agent.kickoff()` builds its `LiteAgentOutput` in `agent/core.py` via
`AgentExecutor`, not through `LiteAgent`, so `tool_failures` was always
empty there regardless of the recording fix. Wired up, and the LiteAgent
path now reads from whichever agent the executor was handed
(`original_agent` under kickoff, `self` standalone) rather than assuming.

`last_tool_failures` returns a copy, so a caller cannot mutate the
agent's record or watch it shift mid-run.

Testing: 7 further tests, 52 total, covering the timeout wrapper, the
retry limit, kickoff reset, the kickoff output path, copy semantics and
guardrail accumulation. Full suite matches baseline exactly at 377
pre-existing failures; mypy clean on every changed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* fix(tools): make crew-scoped policy real and close the last raise leak

Two findings, and the first was a documented feature that never worked.

`resolve_tool_failure_policy` consulted a crew, and the docs advertised
crew as a scope, but `Crew` had no `tool_failure_policy` field at all --
and even with one it was unreachable, because `BaseAgent` defaulted the
policy to `WARN` rather than `None`, so resolution always stopped at the
agent. Crew-level configuration was silently ignored.

Fixed by making "inherit" the default everywhere instead of baking `warn`
into one layer: `Crew` gains the field, and `BaseAgent`/`LiteAgent`
default to `None` like `Task` and `BaseTool` already did. The resolver
owns the single fallback, so the chain is genuinely
tool > task > agent > crew > warn and the effective default with nothing
configured is still `warn`. Reading `agent.tool_failure_policy` now
returns `None` (meaning "inherit") rather than `WARN`.

The other: `StepExecutor` re-raised `ToolExecutionFailedError` from its
outer handler, but the nested handler around the native-to-text tooling
fallback still caught it and returned `StepResult(success=False)`. An
agent whose LLM lacked native tool calling would therefore not abort
under `raise`. That is the third distinct place this exception was being
downgraded; it now re-raises there too.

Testing: 8 further tests, 60 total, including the full precedence chain
walked one level at a time and crew-scoped `raise`/`ignore` driven
end-to-end through `kickoff()` rather than only through the resolver --
the gap that let the original crew bug pass review. Full suite matches
baseline exactly at 377 pre-existing failures; mypy clean on every
changed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* docs: trim comments and docstrings on tool-failure signalling

Prose only -- no behavior change. Cut the module docstring, the longer
class and method docstrings, the multi-line inline comments, and the
verbose Field descriptions down to what actually earns its place. Net 87
lines lighter.

Kept the "why" in every case where the reason is non-obvious (why the
event fires before a raise, why the policy reads through the tool wrapper,
why the bus needs draining in tests) and dropped the restatements of what
the code already says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* fix(tools): make ignore truly silent, stop caching failures, close 4 gaps

Six findings from the latest review round, all verified against the code
before touching it.

`ignore` was not silent. `ToolUsageFinishedEvent.failure` was set before
the policy ran, so traces still saw a failed call under a policy documented
as surfacing nothing. Worse, the console then showed *no* panel at all:
green was suppressed because `failure` was present, red was skipped because
`ignore` never emits `ToolFailureDetectedEvent`. New `reportable_failure()`
resolves the policy before the finished event and drops the flag under
`ignore`; wired into all four execution paths.

Failures were being cached. `CacheHandler.add` stored a `ToolFailure` like
any other result, so a transient error became permanent for the rest of the
run and every later hit re-reported a call that never re-ran. The cache now
refuses to store declared failures -- fixed at the single choke point rather
than at each of the four call sites.

A spent `max_usage_count` was invisible on the shared native path.
`BaseTool._claim_usage` returned a bare string that only the executors
recognising that exact message treated as a failure. It now returns a
`ToolFailure` with `USAGE_LIMIT`, so every path records it.

A guardrail returning a whole `TaskOutput` replaced the output without
carrying accumulated failures over, so earlier attempts vanished from
`CrewOutput.tool_failures`. New `merge_tool_failures()` combines and
deduplicates, and the retry-rebuild path uses it too.

A hook-blocked call inherited a cached failure and attributed it to a call
that never ran. Now cleared. Not reachable through the built-in cache once
failures stop being cached, so the test injects a custom cache handler that
does retain them -- verified to fail without the guard.

Also removed a `datetime` import left unused by the earlier console-test
rewrite.

Testing: 13 further tests, 73 total. Full suite matches baseline exactly at
377 pre-existing failures; the usage-limit suites that `_claim_usage`
touches pass unchanged; mypy clean on every changed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* fix(tools): let raise through the parallel native path, guard all handlers

Chasing down CodeRabbit's note about callers of
execute_single_native_tool_call turned up a fifth place this exception was
being downgraded: the experimental executor's parallel branch wrapped
future.result() in a broad except and folded the abort into a fake tool
result, so the remaining parallel calls carried on. The sequential path and
crew_agent_executor's parallel branch were already fine.

Five separate handlers have swallowed this during review, so added a guard
test asserting the passthrough at every site rather than trusting the next
one gets spotted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* fix(tools): keep a failed tool out of the final answer, finish crew scope

Three more findings, all confirmed against the code.

A failed `result_as_answer` tool still became the task's output. The native
paths already excluded raised errors and hook blocks from short-circuiting,
but not declared failures -- so an error message silently became the answer,
which is the exact shape of bug this PR exists to prevent. Fixed on all
paths, and there were three independent override points, not one:
`ToolResult.result_as_answer` in tool_utils, the `execution_result`
finality checks in both executors, and `process_tool_results()`, which
reads `agent.tools_results` back separately. The first two fixes alone left
the behavior unchanged; only the third made the test pass.

`ToolUsage` never received a crew, so a crew-level `ignore` half-applied:
recording and `ToolFailureDetectedEvent` stayed quiet, but the flag was
still attached to `ToolUsageFinishedEvent`. It now takes and stores `crew`.

`CrewAgentExecutor.invoke`/`ainvoke` routed a deliberate stop through
`handle_unknown_error`, printing "An unknown error occurred" on verbose
runs. LiteAgent already special-cased this; both now do.

Testing: 5 further tests, 79 total, including that a *successful*
`result_as_answer` tool still short-circuits. Full suite matches baseline
exactly at 377 pre-existing failures; mypy clean on every changed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* fix(tools): report malformed tool args, correlate the failure event

Two findings from the latest round.

Malformed native tool arguments returned early with a plain error dict and
never reported a failure, so `ToolFailureReason.INVALID_INPUT` was declared
but unreferenced -- a bad tool call was absent from records, events and
`raise` aborts. `parse_tool_call_args` now carries an INVALID_INPUT failure
on the error dict and both executors report it before returning.

`ToolFailureDetectedEvent` never set `agent_id`, so a trace could not tie it
to a specific agent instance. Fixing that exposed the same gap running the
other way: `ToolUsage`'s own started/finished/error events never set
`agent_id` either, so on the ReAct path the paired finished event had
nothing to correlate against. Both now set it.

Set explicitly rather than via `from_agent`, which would also overwrite
`agent_role` and lose the `_original_role` preference those events already
apply -- a behavior change that has nothing to do with correlation.

Testing: 5 further tests, 84 total, asserting the ids match between the
failure event and its paired finished event. One existing test pinned the
exact key set of the parse-error dict and was updated for the new key. Full
suite matches baseline at 377 pre-existing failures; the one apparent
addition was the known `test_trace_enable_disable` order-flake, confirmed by
re-running rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* fix(tools): scope failure accumulation per execution, drop deprecated executor

Two review requests from @lorenzejay.

Accumulation no longer lives as mutable state on the shared agent. A
ContextVar collector is opened around each execution -- task, kickoff, and
each guardrail retry -- and the output reads that collector directly instead
of copying the agent's list. ContextVars are copied per asyncio task and per
thread, so concurrent executions cannot see each other's records, and
nesting is safe for retries. `last_tool_failures` prefers the active
collector and falls back to the last completed execution, so the accessor is
correct during a run too. The per-execution reset that caused the erasure is
gone.

Reproducing this took some digging and the finding is worth recording: crew
tasks *cannot* hit it, because `AgentExecutor` refuses concurrent reuse of
one instance and raises. `agent.kickoff()` has no such guard, and there the
bug reproduces exactly as reported -- two concurrent kickoffs each returned
two records. The regression test forces the overlap with a barrier so it is
deterministic rather than timing-dependent, and I verified it reports [2, 2]
against the old behavior and [1, 1] now.

Removed the tool-failure integration from `CrewAgentExecutor` entirely; that
file is back to its state on main. Note the shared ReAct helper it calls
still records failures, since that is common code rather than new behavior in
the deprecated file -- so a `raise` policy will be swallowed by that
executor's generic handler. Flagged on the PR rather than papered over.

Testing: 89 total. Two tests I wrote for this were vacuous on the first
attempt -- they passed against the simulated pre-fix code -- so each
concurrency test was checked against the old behavior before being kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* fix(tools): report malformed calls everywhere, drop the unused block reason

Four findings.

`execute_single_native_tool_call` swallowed a JSON decode error into an empty
args dict and ran the tool with no input at all -- worse than not reporting
it. It now routes through `parse_tool_call_args` like the executors do, so
the StepExecutor/planning path reports INVALID_INPUT and returns instead of
executing. That also removes a duplicated inline parse.

The ReAct path returned a `ToolUsageError` message as an ordinary result
without reporting it, so a malformed call there was invisible while the
equivalent native failure was recorded. Now reported as INVALID_INPUT too.

`Agent.kickoff` opened a collector but no longer reset the agent-level list,
so `last_tool_failures` grew across kickoffs. Reset restored, matching task
execution.

`ToolFailureReason.BLOCKED_BY_HOOK` was declared and never produced. Rather
than start reporting hook blocks as failures, the member is removed: a block
is a deliberate decision by the hook author, and treating it as a failure
would make `raise` abort on an intentional veto. Added a guard test that every
remaining reason is actually produced somewhere, so a dead member cannot
reappear -- the same smell that flagged INVALID_INPUT last round.

Also switched the deprecation guard test to a single import style.

Testing: 6 further tests, 95 total, including that the tool does not run when
its args fail to parse. Full suite matches baseline at 377 pre-existing
failures; mypy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

* fix(tools): merge failures across kickoff guardrail retries, cancel siblings

Kickoff guardrail retries discarded the blocked attempt's failures. Each
retry calls `_execute_and_build_output`, which opens a fresh collector and
builds a new output, so a run could report zero failures having demonstrably
failed one -- the same bug already fixed on the task guardrail path, which
merges. Now merged there too. Verified the test fails without the fix.

Under `raise`, one parallel native tool aborting left its siblings running:
the pool waited for them and pending ones still started. It now shuts the
pool down with `cancel_futures=True` so a not-yet-started sibling never runs.
Threads already in flight cannot be interrupted in Python, so a concurrent
tool may still complete before the abort surfaces; that is noted at the call
site rather than left implied.

Also satisfied CodeQL by materialising the enum in the guard test's loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-07-29 19:30:14 -07:00
Lucas Gomide
d52d0a1628 feat: emit FlowFailedEvent when a flow execution fails (#6718)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
* feat: emit FlowFailedEvent when a flow execution fails

A failed flow never emitted a terminal lifecycle event, so the
`flow_started` scope stayed open and consumers such as tracing closed the
root span with a generic orphaned message instead of the real error.
`kickoff_async` and the resume path now emit `FlowFailedEvent`, paired
with `flow_started` and carrying the exception, after draining pending
handlers and background memory writes. The resume path also emits the
`MethodExecutionStartedEvent` it was missing for the method being resumed,
so its finished or failed event pairs with its own scope instead of
popping the flow's.

* fix: skip FlowFailedEvent when the run never opened a scope

The `kickoff_async` try block starts before `FlowStartedEvent` is emitted,
so an abort in the execution-start hooks, in input handling or in state
restore emitted a `flow_failed` with no opener, which pops an unrelated
scope and warns about an empty scope stack. The failure event is now
gated on the flow scope actually being open, either from this kickoff's
`flow_started` or from a restored deferred session scope.
2026-07-29 14:44:55 -04:00
Lorenze Jay
f15844b219 Lorenze/imp/skills progressive disclosure (#6675)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
* skills progressive disclosure

* skills progressive disclosure

* improving progressive disclosure

* addressed comment

* fix test

---------

Co-authored-by: João Moura <joaomdmoura@gmail.com>
2026-07-28 09:33:43 -07:00
54 changed files with 5201 additions and 165 deletions

View File

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

View File

@@ -256,6 +256,7 @@ 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,7 +9,10 @@ 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.
When activated, a skill's instructions are injected directly into the agent's task prompt — giving the agent expertise without requiring any code changes.
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.
<Note type="info" title="Skills vs Tools — The Key Distinction">
**Skills are NOT tools.** This is the most common point of confusion.
@@ -79,12 +82,13 @@ 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"], # Injects review guidelines
skills=["./skills"], # Discovers review skills
tools=[GithubSearchTool(), FileReadTool()], # Lets agent read code
)
```
The agent now has both **expertise** (from the skill) and **capabilities** (from the tools).
The agent now has both **expertise** (loaded from the relevant skill when
needed) and **capabilities** (from the tools).
---
@@ -324,7 +328,8 @@ 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:
For more control, you can discover and activate skills programmatically.
Passing an activated skill makes its instructions always-on:
```python
from pathlib import Path
@@ -351,12 +356,21 @@ agent = Agent(
Skills use **progressive disclosure** — only loading what's needed at each stage:
| Stage | What's loaded | When |
| :--------- | :------------------------------------ | :------------------ |
| Discovery | Name, description, frontmatter fields | `discover_skills()` |
| Activation | Full SKILL.md body text | `activate_skill()` |
| 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 |
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.
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.
---

View File

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

View File

@@ -256,6 +256,7 @@ 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

@@ -5,6 +5,7 @@ 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
@@ -49,7 +50,7 @@ class CrewAIPlatformActionTool(BaseTool):
self.action_name = action_name
self.action_schema = action_schema
def _run(self, **kwargs: Any) -> str:
def _run(self, **kwargs: Any) -> Any:
try:
cleaned_kwargs = {
key: value for key, value in kwargs.items() if value is not None
@@ -85,9 +86,20 @@ class CrewAIPlatformActionTool(BaseTool):
error_message = str(error_info)
else:
error_message = str(data)
return f"API request failed: {error_message}"
# 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 json.dumps(data, indent=2)
except Exception as e:
return f"Error executing action {self.action_name}: {e!s}"
return ToolFailure(
message=f"Error executing action {self.action_name}: {e!s}",
code=e.__class__.__name__,
details={"action": self.action_name},
)

File diff suppressed because it is too large Load Diff

View File

@@ -83,9 +83,15 @@ 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 Skill as SkillModel
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
from crewai.state.checkpoint_config import CheckpointConfig, apply_checkpoint
from crewai.tools.agent_tools.agent_tools import AgentTools
from crewai.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 (
@@ -131,7 +137,9 @@ if TYPE_CHECKING:
from crewai.utilities.types import LLMMessage
_passthrough_exceptions: tuple[type[Exception], ...] = ()
# Deliberate stops, not transient errors: never swallowed into the
# max_retry_limit loop.
_passthrough_exceptions: tuple[type[Exception], ...] = (ToolExecutionFailedError,)
_EXECUTOR_CLASS_MAP: dict[str, type] = {
"CrewAgentExecutor": CrewAgentExecutor,
@@ -480,9 +488,32 @@ class Agent(BaseAgent):
self.skills = cast(
list[Path | SkillModel | str] | None,
load_skills(items, source=self) or None,
load_skills(items, source=self, activate=False) 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):
@@ -527,6 +558,8 @@ class Agent(BaseAgent):
self._inject_date_to_task(task)
self.reset_tool_failures()
if self.tools_handler:
self.tools_handler.last_used_tool = None
@@ -557,11 +590,11 @@ class Agent(BaseAgent):
return apply_training_data(self, task_prompt)
def _emit_skill_usage(self, task: Task) -> None:
"""Emit one SkillUsedEvent per skill injected into this task's prompt.
"""Emit usage for always-on skills injected into this task's prompt.
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.
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.
Args:
task: The task whose prompt the skills are being applied to.
@@ -570,7 +603,10 @@ class Agent(BaseAgent):
return
for skill in self.skills:
if not isinstance(skill, SkillModel):
if (
not isinstance(skill, SkillModel)
or skill.disclosure_level < INSTRUCTIONS
):
continue
crewai_event_bus.emit(
self,
@@ -888,6 +924,11 @@ 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
@@ -1050,6 +1091,8 @@ 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(
@@ -1060,6 +1103,10 @@ 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")]
@@ -1082,7 +1129,8 @@ class Agent(BaseAgent):
Returns:
An instance of the CrewAgentExecutor class.
"""
raw_tools: list[BaseTool] = tools or self.tools or []
configured_tools = tools if tools is not None else self.tools or []
raw_tools = self._add_skill_loader_tool(list(configured_tools), task=task)
parsed_tools = parse_tools(raw_tools)
prompt, stop_words, rpm_limit_fn = self._build_execution_prompt(raw_tools)
@@ -1421,6 +1469,11 @@ 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:
@@ -1434,7 +1487,7 @@ class Agent(BaseAgent):
self.tools = []
self.tools.extend(mcps)
raw_tools: list[BaseTool] = self.tools or []
raw_tools = list(self.tools or [])
agent_memory = getattr(self, "memory", None)
if agent_memory is not None:
@@ -1447,6 +1500,7 @@ 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 = {
@@ -1749,6 +1803,7 @@ 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.
@@ -1829,6 +1884,7 @@ 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(
@@ -1839,9 +1895,10 @@ class Agent(BaseAgent):
usage_baseline: UsageMetrics | None = None,
) -> LiteAgentOutput:
"""Execute the agent synchronously and build the output object."""
result = cast(dict[str, Any], executor.invoke(inputs))
with tool_failure_collector() as kickoff_failures:
result = cast(dict[str, Any], executor.invoke(inputs))
return self._build_output_from_result(
result, executor, response_format, usage_baseline
result, executor, response_format, usage_baseline, kickoff_failures
)
async def _execute_and_build_output_async(
@@ -1852,9 +1909,10 @@ class Agent(BaseAgent):
usage_baseline: UsageMetrics | None = None,
) -> LiteAgentOutput:
"""Execute the agent asynchronously and build the output object."""
result = await executor.invoke_async(inputs)
with tool_failure_collector() as kickoff_failures:
result = await executor.invoke_async(inputs)
return self._build_output_from_result(
result, executor, response_format, usage_baseline
result, executor, response_format, usage_baseline, kickoff_failures
)
def _process_kickoff_guardrail(
@@ -1914,9 +1972,15 @@ class Agent(BaseAgent):
role="user",
)
output = self._execute_and_build_output(
retried = 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,6 +44,11 @@ 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
@@ -264,6 +269,7 @@ 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")
@@ -298,6 +304,15 @@ 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),
@@ -652,6 +667,22 @@ 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,6 +23,10 @@ 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.
@@ -31,6 +35,11 @@ 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,6 +28,7 @@ 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,
@@ -180,6 +181,11 @@ 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:
@@ -218,6 +224,11 @@ 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,6 +116,7 @@ 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
@@ -231,6 +232,13 @@ 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,6 +7,7 @@ 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
@@ -31,6 +32,20 @@ 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 activate_skill, load_skills
from crewai.skills.models import INSTRUCTIONS, Skill as SkillModel
from crewai.skills.loader import load_skills
from crewai.skills.models import Skill as SkillModel
from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput
from crewai.utilities.file_store import store_files
from crewai.utilities.streaming import (
@@ -59,13 +59,7 @@ def _resolve_crew_skills(crew: Crew) -> list[SkillModel] | None:
if not isinstance(crew.skills, list) or not crew.skills:
return 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
]
return load_skills(crew.skills, activate=False) or None
def setup_agents(

View File

@@ -68,6 +68,7 @@ if TYPE_CHECKING:
ConversationTurnStartedEvent,
FlowCreatedEvent,
FlowEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowPlotEvent,
FlowStartedEvent,
@@ -147,6 +148,7 @@ if TYPE_CHECKING:
)
from crewai.events.types.tool_usage_events import (
ToolExecutionErrorEvent,
ToolFailureDetectedEvent,
ToolSelectionErrorEvent,
ToolUsageErrorEvent,
ToolUsageEvent,
@@ -194,6 +196,7 @@ _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",
@@ -251,6 +254,7 @@ _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",
@@ -329,6 +333,7 @@ __all__ = [
"Depends",
"FlowCreatedEvent",
"FlowEvent",
"FlowFailedEvent",
"FlowFinishedEvent",
"FlowPlotEvent",
"FlowStartedEvent",
@@ -384,6 +389,7 @@ __all__ = [
"TaskFailedEvent",
"TaskStartedEvent",
"ToolExecutionErrorEvent",
"ToolFailureDetectedEvent",
"ToolSelectionErrorEvent",
"ToolUsageErrorEvent",
"ToolUsageEvent",

View File

@@ -269,6 +269,7 @@ SCOPE_STARTING_EVENTS: frozenset[str] = frozenset(
SCOPE_ENDING_EVENTS: frozenset[str] = frozenset(
{
"flow_finished",
"flow_failed",
"flow_paused",
"method_execution_finished",
"method_execution_failed",
@@ -320,6 +321,7 @@ 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,6 +43,7 @@ from crewai.events.types.env_events import (
from crewai.events.types.flow_events import (
ConversationTurnCompletedEvent,
FlowCreatedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowPausedEvent,
FlowStartedEvent,
@@ -113,6 +114,7 @@ from crewai.events.types.task_events import (
TaskStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolFailureDetectedEvent,
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
@@ -318,6 +320,15 @@ 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
@@ -414,6 +425,8 @@ 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,
@@ -439,6 +452,18 @@ 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,6 +58,7 @@ from crewai.events.types.flow_events import (
ConversationTurnCompletedEvent,
ConversationTurnFailedEvent,
ConversationTurnStartedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowStartedEvent,
MethodExecutionFailedEvent,
@@ -117,6 +118,7 @@ from crewai.events.types.task_events import (
TaskStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolFailureDetectedEvent,
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
@@ -170,12 +172,14 @@ EventTypes = (
| ConversationTurnStartedEvent
| FlowStartedEvent
| FlowFinishedEvent
| FlowFailedEvent
| MethodExecutionStartedEvent
| MethodExecutionFinishedEvent
| MethodExecutionFailedEvent
| AgentExecutionErrorEvent
| ToolUsageFinishedEvent
| ToolUsageErrorEvent
| ToolFailureDetectedEvent
| ToolUsageStartedEvent
| LLMCallStartedEvent
| LLMCallCompletedEvent

View File

@@ -66,6 +66,7 @@ from crewai.events.types.flow_events import (
ConversationMessageAddedEvent,
ConversationRouteSelectedEvent,
FlowCreatedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowPlotEvent,
FlowStartedEvent,
@@ -126,6 +127,7 @@ from crewai.events.types.task_events import (
TaskStartedEvent,
)
from crewai.events.types.tool_usage_events import (
ToolFailureDetectedEvent,
ToolUsageErrorEvent,
ToolUsageFinishedEvent,
ToolUsageStartedEvent,
@@ -274,6 +276,10 @@ 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)
@@ -410,6 +416,12 @@ 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,6 +94,24 @@ 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 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.
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.
"""
type: Literal["skill_used"] = "skill_used"

View File

@@ -5,6 +5,7 @@ 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):
@@ -66,6 +67,11 @@ 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"
@@ -76,6 +82,20 @@ 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,6 +12,7 @@ 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
@@ -492,6 +493,55 @@ 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,6 +73,15 @@ 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,
@@ -1634,6 +1643,11 @@ 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")
@@ -1753,6 +1767,15 @@ 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)
@@ -1799,6 +1822,8 @@ 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",
@@ -1837,6 +1862,8 @@ 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(
@@ -1904,6 +1931,14 @@ 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 {}
@@ -1949,6 +1984,7 @@ 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(
@@ -1957,6 +1993,7 @@ 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
@@ -1988,6 +2025,9 @@ 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:
@@ -2010,9 +2050,11 @@ 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
@@ -2028,6 +2070,8 @@ 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:
@@ -2035,6 +2079,11 @@ 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(
@@ -2063,17 +2112,50 @@ 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,6 +67,7 @@ from crewai.events.listeners.tracing.utils import (
)
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowPausedEvent,
FlowPlotEvent,
@@ -1341,6 +1342,7 @@ 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
@@ -1382,35 +1384,68 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
)
emit = context.emit
# 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
)
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
)
self._completed_methods.add(FlowMethodName(context.method_name))
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
)
await asyncio.to_thread(
self._persist_method_completion, FlowMethodName(context.method_name)
)
self._completed_methods.add(FlowMethodName(context.method_name))
self._pending_feedback_context = None
await asyncio.to_thread(
self._persist_method_completion, FlowMethodName(context.method_name)
)
if self.persistence is not None:
self.persistence.clear_pending_feedback(context.flow_id)
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 not self.suppress_flow_events:
crewai_event_bus.emit(
@@ -2096,6 +2131,11 @@ 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 (
@@ -2235,6 +2275,7 @@ 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()
@@ -2249,6 +2290,7 @@ 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)
@@ -2440,6 +2482,8 @@ 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
@@ -2487,6 +2531,67 @@ 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,6 +23,7 @@ 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
@@ -55,7 +56,7 @@ class ToolCallHookContext:
tool_name: str,
tool_input: dict[str, Any],
tool: CrewStructuredTool,
agent: Agent | BaseAgent | None = None,
agent: Agent | BaseAgent | LiteAgent | None = None,
task: Task | None = None,
crew: Crew | None = None,
tool_result: str | None = None,

View File

@@ -72,6 +72,12 @@ 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,
@@ -222,6 +228,14 @@ 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"
)
@@ -289,6 +303,8 @@ 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)
@@ -450,6 +466,14 @@ 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,
@@ -519,15 +543,34 @@ 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()
return self._execute_core(
agent_info=agent_info, response_format=response_format
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),
),
)
raise
except Exception as e:
if self.verbose:
@@ -691,6 +734,9 @@ 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:
@@ -916,7 +962,9 @@ class LiteAgent(FlowTrackable, BaseModel):
tools=self._parsed_tools,
agent_key=self.key,
agent_role=self.role,
agent=self.original_agent,
# Fall back to self so a standalone LiteAgent still
# resolves a policy and records failures.
agent=self.original_agent or self,
crew=None,
)
except Exception as e:
@@ -929,6 +977,10 @@ 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,6 +6,7 @@ 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
@@ -50,6 +51,17 @@ 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,7 +430,27 @@ class MCPClient:
arguments: Tool arguments.
Returns:
Tool execution result.
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.
"""
if not self.connected:
await self.connect()
@@ -492,7 +512,7 @@ class MCPClient:
),
)
return tool_result.content
return tool_result
except Exception as e:
failed_at = datetime.now()
error_type = (

View File

@@ -6,6 +6,7 @@ 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
@@ -19,7 +20,12 @@ 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,
@@ -148,25 +154,39 @@ 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. Path and inline inputs are activated immediately; pre-loaded Skill
objects keep their disclosure level.
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.
"""
if isinstance(skill, Skill):
return [skill]
if isinstance(skill, Path):
return [
activate_skill(s, source=source)
for s in discover_skills(skill, source=source)
]
discovered = discover_skills(skill, source=source)
if not activate:
return discovered
return [activate_skill(s, source=source) for s in discovered]
if isinstance(skill, str) and skill.startswith("@"):
from crewai.skills.registry import resolve_registry_ref
return [resolve_registry_ref(skill, source=source)]
if activate:
return [resolve_registry_ref(skill, source=source)]
return [resolve_registry_ref(skill, source=source, activate=False)]
if isinstance(skill, str) and skill.lstrip().startswith("---\n"):
frontmatter_dict, body = parse_frontmatter(skill.strip())
return [
@@ -178,10 +198,10 @@ def load_skill(
)
]
if isinstance(skill, str):
return [
activate_skill(s, source=source)
for s in discover_skills(Path(skill), source=source)
]
discovered = discover_skills(Path(skill), source=source)
if not activate:
return discovered
return [activate_skill(s, source=source) for s in discovered]
msg = f"Unsupported skill input: {skill!r}"
raise TypeError(msg)
@@ -190,16 +210,27 @@ 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):
for skill in load_skill(skill_input, source=source, activate=activate):
dedup_key = skill.name
if isinstance(skill_input, str) and skill_input.startswith("@"):
from crewai.skills.registry import parse_registry_ref
@@ -211,6 +242,42 @@ 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.
@@ -223,7 +290,7 @@ def load_resources(skill: Skill) -> Skill:
return load_skill_resources(skill)
def format_skill_context(skill: Skill) -> str:
def format_skill_context(skill: Skill, *, label: str | None = None) -> str:
"""Format skill information for agent prompt injection.
At METADATA level: returns name and description only.
@@ -234,13 +301,16 @@ def format_skill_context(skill: Skill) -> 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="{skill.name}">',
f'<skill name="{name}">',
skill.description,
"",
skill.instructions,
@@ -253,4 +323,4 @@ def format_skill_context(skill: Skill) -> str:
parts.append(f"- **{dir_name}/**: {', '.join(files)}")
parts.append("</skill>")
return "\n".join(parts)
return f'<skill name="{skill.name}">\n{skill.description}\n</skill>'
return f'<skill name="{name}">\n{skill.description}\n</skill>'

View File

@@ -114,6 +114,8 @@ 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.
@@ -129,9 +131,11 @@ 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 disclosure level.
A Skill loaded at INSTRUCTIONS level by default, or METADATA level
when ``activate`` is false.
"""
from crewai.skills.loader import activate_skill
@@ -143,7 +147,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)
return activate_skill(skill, source=source) if activate else skill
cache = SkillCacheManager()
cached_path = cache.get_cached_path(org, name, version=version)
@@ -154,9 +158,15 @@ 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)
return activate_skill(skill, source=source) if activate else skill
return download_skill(org, name, source=source, version=version)
return download_skill(
org,
name,
source=source,
version=version,
activate=activate,
)
def _load_skill(path: Path) -> Skill | None: # type: ignore[name-defined] # noqa: F821
@@ -268,6 +278,7 @@ 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.
@@ -276,9 +287,11 @@ 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.
The downloaded Skill at INSTRUCTIONS level by default, or METADATA
level when ``activate`` is false.
Raises:
ValueError: If *version* is given but blank.
@@ -376,4 +389,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)
return activate_skill(skill, source=source) if activate else skill

View File

@@ -0,0 +1,116 @@
"""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,6 +38,7 @@ CheckpointEventType = Literal[
"flow_created",
"flow_started",
"flow_finished",
"flow_failed",
"flow_paused",
"method_execution_started",
"method_execution_finished",

View File

@@ -52,6 +52,12 @@ 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 (
@@ -274,6 +280,13 @@ 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"
)
@@ -677,11 +690,12 @@ class Task(BaseModel):
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
context = pre_step_ctx.payload
result = await agent.aexecute_task(
task=self,
context=context,
tools=tools,
)
with tool_failure_collector() as execution_failures:
result = await agent.aexecute_task(
task=self,
context=context,
tools=tools,
)
self._post_agent_execution(agent)
@@ -713,6 +727,7 @@ 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:
@@ -831,11 +846,12 @@ class Task(BaseModel):
dispatch(InterceptionPoint.PRE_STEP, pre_step_ctx)
context = pre_step_ctx.payload
result = agent.execute_task(
task=self,
context=context,
tools=tools,
)
with tool_failure_collector() as execution_failures:
result = agent.execute_task(
task=self,
context=context,
tools=tools,
)
self._post_agent_execution(agent)
@@ -867,6 +883,7 @@ 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:
@@ -1319,6 +1336,10 @@ 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,
@@ -1343,7 +1364,12 @@ 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
@@ -1374,11 +1400,12 @@ 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",
)
result = agent.execute_task(
task=self,
context=context,
tools=tools,
)
with tool_failure_collector() as retry_failures:
result = agent.execute_task(
task=self,
context=context,
tools=tools,
)
if isinstance(result, BaseModel):
raw = result.model_dump_json()
@@ -1405,7 +1432,9 @@ 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
@@ -1428,6 +1457,10 @@ 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,
@@ -1452,7 +1485,12 @@ 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
@@ -1483,11 +1521,12 @@ 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",
)
result = await agent.aexecute_task(
task=self,
context=context,
tools=tools,
)
with tool_failure_collector() as retry_failures:
result = await agent.aexecute_task(
task=self,
context=context,
tools=tools,
)
if isinstance(result, BaseModel):
raw = result.model_dump_json()
@@ -1514,6 +1553,8 @@ 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,6 +8,7 @@ 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
@@ -46,6 +47,18 @@ 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,8 +1,20 @@
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,6 +38,7 @@ 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
@@ -184,6 +185,13 @@ 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.",
@@ -291,21 +299,26 @@ class BaseTool(BaseModel, ABC):
) from e
return kwargs
def _claim_usage(self) -> str | None:
def _claim_usage(self) -> ToolFailure | None:
"""Atomically check max usage and increment the counter.
Returns:
None if usage was claimed successfully, or an error message
string if the tool has reached its usage limit.
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.
"""
with self._usage_lock:
if (
self.max_usage_count is not None
and self.current_usage_count >= self.max_usage_count
):
return (
f"Tool '{self.name}' has reached its usage limit of "
f"{self.max_usage_count} times and cannot be used anymore."
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,
)
self.current_usage_count += 1
return None
@@ -402,6 +415,7 @@ 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,6 +11,7 @@ import contextvars
from typing import Any
from crewai.tools import BaseTool
from crewai.tools.tool_failure import ToolFailure, ToolFailureReason
class MCPNativeTool(BaseTool):
@@ -70,14 +71,15 @@ class MCPNativeTool(BaseTool):
"""Get the server name."""
return self._server_name
def _run(self, **kwargs: Any) -> str:
def _run(self, **kwargs: Any) -> Any:
"""Execute tool using the MCP client session.
Args:
**kwargs: Arguments to pass to the MCP tool.
Returns:
Result from the MCP tool execution.
The tool's text result, or a :class:`ToolFailure` when the server
answered with ``isError: true``.
"""
try:
try:
@@ -98,7 +100,7 @@ class MCPNativeTool(BaseTool):
f"Error executing MCP tool {self.original_tool_name}: {e!s}"
) from e
async def _run_async(self, **kwargs: Any) -> str:
async def _run_async(self, **kwargs: Any) -> Any:
"""Async implementation of tool execution.
A fresh ``MCPClient`` is created for every invocation so that
@@ -108,16 +110,36 @@ class MCPNativeTool(BaseTool):
**kwargs: Arguments to pass to the MCP tool.
Returns:
Result from the MCP tool execution.
The tool's text result, or a :class:`ToolFailure` when the server
answered with ``isError: true``.
"""
client = self._client_factory()
await client.connect()
try:
result = await client.call_tool(self.original_tool_name, kwargs)
tool_result = await client.call_tool_result(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,6 +21,7 @@ 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,
@@ -56,6 +57,11 @@ 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))
@@ -205,6 +211,7 @@ 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

@@ -0,0 +1,384 @@
"""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,6 +24,13 @@ 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,
@@ -36,6 +43,7 @@ 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
@@ -95,6 +103,7 @@ 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
@@ -106,10 +115,17 @@ 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
@@ -265,8 +281,10 @@ class ToolUsage:
"run_attempts": self._run_attempts,
}
if self.agent.fingerprint: # type: ignore
event_data.update(self.agent.fingerprint) # type: ignore
# 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.task:
event_data["task_name"] = self.task.name or self.task.description
event_data["task_id"] = str(self.task.id)
@@ -309,6 +327,10 @@ 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:
@@ -371,6 +393,7 @@ 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)
)
@@ -383,6 +406,9 @@ 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
@@ -436,6 +462,7 @@ 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:
@@ -446,6 +473,7 @@ 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)
)
@@ -504,9 +532,10 @@ class ToolUsage:
"run_attempts": self._run_attempts,
}
# TODO: Investigate fingerprint attribute availability on BaseAgent/LiteAgent
if self.agent.fingerprint: # type: ignore
event_data.update(self.agent.fingerprint) # type: ignore
# 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.task:
event_data["task_name"] = self.task.name or self.task.description
event_data["task_id"] = str(self.task.id)
@@ -549,6 +578,10 @@ 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:
@@ -611,6 +644,7 @@ 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)
)
@@ -623,6 +657,9 @@ 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
@@ -676,6 +713,7 @@ 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:
@@ -686,6 +724,7 @@ 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)
)
@@ -988,6 +1027,13 @@ 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:
@@ -998,9 +1044,13 @@ 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,6 +31,14 @@ 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 (
@@ -1532,6 +1540,9 @@ 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)
@@ -1600,13 +1611,30 @@ def execute_single_native_tool_call(
call_id, func_name, func_args = info
if isinstance(func_args, str):
try:
args_dict = json.loads(func_args)
except json.JSONDecodeError:
args_dict = {}
else:
args_dict = func_args
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 {}
agent_key = getattr(agent, "key", "unknown") if agent else "unknown"
@@ -1628,12 +1656,14 @@ 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()
@@ -1666,6 +1696,9 @@ 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:
@@ -1685,9 +1718,11 @@ 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(
@@ -1704,6 +1739,14 @@ 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,
@@ -1733,9 +1776,29 @@ 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,
@@ -1756,6 +1819,9 @@ 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(
@@ -1779,21 +1845,28 @@ 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.
with the same shape as ``_execute_single_native_tool_call`` return
values, carrying an ``INVALID_INPUT`` failure for the caller to report.
"""
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": (
f"Error: Failed to parse tool arguments as JSON: {e}. "
f"Please provide valid JSON arguments for the '{func_name}' tool."
),
"result": message,
"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,6 +70,10 @@ 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.
@@ -115,22 +119,50 @@ class Prompts(BaseModel):
)
def _build_skill_block(self) -> str:
"""Render the agent's activated skills as a stable XML block.
"""Render always-on instructions and the available skill catalog.
Skills are agent-scoped (do not change per task), so they live in the
system prompt where prompt-cache prefixes can survive across calls.
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 = getattr(self.agent, "skills", None)
if not skills:
return ""
from crewai.skills.loader import format_skill_context
from crewai.skills.models import Skill
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
sections = [format_skill_context(s) for s in skills if isinstance(s, Skill)]
if not sections:
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:
return ""
return "\n\n<skills>\n" + "\n\n".join(sections) + "\n</skills>"
return "\n\n" + "\n\n".join(blocks)
def _build_prompt(
self,

View File

@@ -11,6 +11,11 @@ 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
@@ -21,6 +26,7 @@ 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
@@ -33,7 +39,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 | None = None,
agent: Agent | BaseAgent | LiteAgent | None = None,
function_calling_llm: BaseLLM | LLM | None = None,
fingerprint_context: dict[str, str] | None = None,
crew: Crew | None = None,
@@ -80,11 +86,25 @@ 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)
@@ -138,15 +158,42 @@ 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,
tool.result_as_answer,
# 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 = 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)
@@ -157,7 +204,7 @@ def execute_tool_and_check_finality(
agent_role: str | None = None,
tools_handler: ToolsHandler | None = None,
task: Task | None = None,
agent: Agent | BaseAgent | None = None,
agent: Agent | BaseAgent | LiteAgent | None = None,
function_calling_llm: BaseLLM | LLM | None = None,
fingerprint_context: dict[str, str] | None = None,
crew: Crew | None = None,
@@ -202,11 +249,25 @@ 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)
@@ -260,13 +321,40 @@ 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,
tool.result_as_answer,
# 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 = 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,6 +12,8 @@ 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
@@ -29,12 +31,13 @@ def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path:
def _agent_with_skills(search_path: Path) -> Agent:
# discover_skills scans the SUBdirectories of the given path.
"""Create an agent with explicitly activated, always-on skills."""
skills = [activate_skill(skill) for skill in discover_skills(search_path)]
return Agent(
role="Analyst",
goal="Analyze things",
backstory="Experienced",
skills=[str(search_path)],
skills=skills,
llm="gpt-4o-mini",
)
@@ -44,6 +47,43 @@ 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:
@@ -109,7 +149,7 @@ class TestSkillUsedEvent:
assert len(received) == 2
def test_reports_disclosure_level(self, tmp_path: Path) -> None:
"""Path-loaded skills are activated, so they report INSTRUCTIONS level."""
"""Explicitly activated skills report INSTRUCTIONS level."""
_create_skill_dir(tmp_path, "alpha")
agent = _agent_with_skills(tmp_path)
task = _task_for(agent)

View File

@@ -4,6 +4,7 @@ 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
@@ -39,6 +40,9 @@ 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
@@ -227,9 +231,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 f"result-{name}"
return _MCPToolResult(f"result-{name}", False)
client.call_tool = AsyncMock(side_effect=_call_tool)
client.call_tool_result = AsyncMock(side_effect=_call_tool)
return client
with patch("crewai.mcp.tool_resolver.MCPClient", side_effect=_make_client):
@@ -273,9 +277,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 f"result-{name}"
return _MCPToolResult(f"result-{name}", False)
client.call_tool = AsyncMock(side_effect=_call_tool)
client.call_tool_result = AsyncMock(side_effect=_call_tool)
return client
with patch("crewai.mcp.tool_resolver.MCPClient", side_effect=_make_client):

View File

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

View File

@@ -0,0 +1,337 @@
"""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,6 +10,7 @@ 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,
@@ -181,6 +182,17 @@ 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,6 +162,7 @@ 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,7 +1031,14 @@ 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"}
assert set(error.keys()) == {
"call_id",
"func_name",
"result",
"from_cache",
"original_tool",
"tool_failure",
}
class TestExecuteSingleNativeToolCall:

View File

@@ -23,6 +23,7 @@ from crewai.events.types.crew_events import (
)
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowFailedEvent,
FlowFinishedEvent,
FlowStartedEvent,
HumanFeedbackReceivedEvent,
@@ -46,8 +47,11 @@ 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
@@ -556,6 +560,274 @@ 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()