mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-30 02:59:21 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453676c61a | ||
|
|
d52d0a1628 | ||
|
|
f15844b219 |
@@ -157,6 +157,7 @@ class MyCustomCrew:
|
||||
- **FlowCreatedEvent**: يُرسل عند إنشاء تدفق
|
||||
- **FlowStartedEvent**: يُرسل عند بدء تنفيذ تدفق
|
||||
- **FlowFinishedEvent**: يُرسل عند اكتمال تنفيذ تدفق
|
||||
- **FlowFailedEvent**: يُرسل عند فشل تنفيذ تدفق. يحتوي على اسم التدفق والاستثناء الذي أنهى التنفيذ.
|
||||
- **FlowPausedEvent**: يُرسل عند إيقاف تدفق مؤقتًا بانتظار ملاحظات بشرية
|
||||
|
||||
### أحداث LLM
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -255,6 +255,7 @@ CrewAI는 여러분이 청취할 수 있는 다양한 이벤트를 제공합니
|
||||
- **FlowCreatedEvent**: Flow가 생성될 때 발생
|
||||
- **FlowStartedEvent**: Flow가 실행을 시작할 때 발생
|
||||
- **FlowFinishedEvent**: Flow가 실행을 완료할 때 발생
|
||||
- **FlowFailedEvent**: Flow 실행이 실패할 때 발생합니다. Flow 이름과 실행을 종료시킨 예외를 포함합니다.
|
||||
- **FlowPausedEvent**: 사람의 피드백을 기다리며 Flow가 일시 중지될 때 발생합니다. Flow 이름, Flow ID, 메서드 이름, 현재 상태, 피드백 요청 시 표시되는 메시지, 라우팅을 위한 선택적 결과 목록을 포함합니다.
|
||||
- **FlowPlotEvent**: Flow가 플롯될 때 발생
|
||||
- **MethodExecutionStartedEvent**: Flow 메서드가 실행을 시작할 때 발생
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>'
|
||||
|
||||
@@ -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
|
||||
|
||||
116
lib/crewai/src/crewai/skills/tool.py
Normal file
116
lib/crewai/src/crewai/skills/tool.py
Normal 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,
|
||||
)
|
||||
@@ -38,6 +38,7 @@ CheckpointEventType = Literal[
|
||||
"flow_created",
|
||||
"flow_started",
|
||||
"flow_finished",
|
||||
"flow_failed",
|
||||
"flow_paused",
|
||||
"method_execution_started",
|
||||
"method_execution_finished",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
384
lib/crewai/src/crewai/tools/tool_failure.py
Normal file
384
lib/crewai/src/crewai/tools/tool_failure.py
Normal 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
|
||||
@@ -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__,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
337
lib/crewai/tests/skills/test_progressive_disclosure.py
Normal file
337
lib/crewai/tests/skills/test_progressive_disclosure.py
Normal 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"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
1777
lib/crewai/tests/tools/test_tool_failure.py
Normal file
1777
lib/crewai/tests/tools/test_tool_failure.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user