diff --git a/docs/edge/en/concepts/tools.mdx b/docs/edge/en/concepts/tools.mdx index da41da3b1..e78c4aa44 100644 --- a/docs/edge/en/concepts/tools.mdx +++ b/docs/edge/en/concepts/tools.mdx @@ -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. diff --git a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_action_tool.py b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_action_tool.py index 3a3ae3be9..9ef21b30c 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_action_tool.py +++ b/lib/crewai-tools/src/crewai_tools/tools/crewai_platform_tools/crewai_platform_action_tool.py @@ -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}, + ) diff --git a/lib/crewai-tools/tool.specs.json b/lib/crewai-tools/tool.specs.json index 87319d6ba..dd5178360 100644 --- a/lib/crewai-tools/tool.specs.json +++ b/lib/crewai-tools/tool.specs.json @@ -47,6 +47,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -147,6 +157,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "additionalProperties": true, @@ -245,6 +265,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that performs image searches using the Brave Search API.", @@ -429,6 +459,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that retrieves context for LLM usage from the Brave Search API.", @@ -709,6 +749,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that retrieves AI-generated descriptions for local POIs using the Brave Search API.", @@ -823,6 +873,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that retrieves local POIs using the Brave Search API.", @@ -982,6 +1042,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that performs news searches using the Brave Search API.", @@ -1277,6 +1347,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that performs web searches using the Brave Search API.", @@ -1635,6 +1715,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that performs video searches using the Brave Search API.", @@ -1898,6 +1988,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that performs web searches using the Brave Search API.", @@ -2276,6 +2376,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "CrewAI-compatible tool for scraping structured data using Bright Data Datasets.\n\nAttributes:\n name (str): Tool name displayed in the CrewAI environment.\n description (str): Tool description shown to agents or users.\n args_schema (Type[BaseModel]): Pydantic schema for validating input arguments.", @@ -2450,6 +2560,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A web search tool that utilizes Bright Data's SERP API to perform queries and return either structured results\nor raw page content from search engines like Google or Bing.\n\nAttributes:\n name (str): Tool name used by the agent.\n description (str): A brief explanation of what the tool does.\n args_schema (Type[BaseModel]): Schema class for validating tool arguments.\n base_url (str): The Bright Data API endpoint used for making the POST request.\n api_key (str): Bright Data API key loaded from the environment variable 'BRIGHT_DATA_API_KEY'.\n zone (str): Zone identifier from Bright Data, loaded from the environment variable 'BRIGHT_DATA_ZONE'.\n\nRaises:\n ValueError: If API key or zone environment variables are not set.", @@ -2666,6 +2786,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool for performing web scraping using the Bright Data Web Unlocker API.\n\nThis tool allows automated and programmatic access to web pages by routing requests\nthrough Bright Data's unlocking and proxy infrastructure, which can bypass bot\nprotection mechanisms like CAPTCHA, geo-restrictions, and anti-bot detection.\n\nAttributes:\n name (str): Name of the tool.\n description (str): Description of what the tool does.\n args_schema (Type[BaseModel]): Pydantic model schema for expected input arguments.\n base_url (str): Base URL of the Bright Data Web Unlocker API.\n api_key (str): Bright Data API key (must be set in the BRIGHT_DATA_API_KEY environment variable).\n zone (str): Bright Data zone identifier (must be set in the BRIGHT_DATA_ZONE environment variable).\n\nMethods:\n _run(**kwargs: Any) -> Any:\n Sends a scraping request to Bright Data's Web Unlocker API and returns the result.", @@ -2809,6 +2939,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -3591,6 +3731,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -4640,6 +4790,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -5051,6 +5211,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Wrapper for composio tools.", @@ -5107,6 +5277,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool to create Contextual AI RAG agents with documents.", @@ -5207,6 +5387,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool to parse documents using Contextual AI's parser.", @@ -5324,6 +5514,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool to query Contextual AI RAG agents.", @@ -5422,6 +5622,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool to rerank documents using Contextual AI's instruction-following reranker.", @@ -5541,6 +5751,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool to search the Couchbase database.", @@ -6314,6 +6534,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -6732,6 +6962,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -6869,6 +7109,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool for querying Databricks workspace tables using SQL.\n\nThis tool executes SQL queries against Databricks tables and returns the results.\nIt requires Databricks authentication credentials to be set as environment variables.\n\nAuthentication can be provided via:\n- Databricks CLI profile: Set DATABRICKS_CONFIG_PROFILE environment variable\n- Direct credentials: Set DATABRICKS_HOST and DATABRICKS_TOKEN environment variables\n\nExample:\n >>> tool = DatabricksQueryTool()\n >>> results = tool.run(query=\"SELECT * FROM my_table LIMIT 10\")", @@ -7045,6 +7295,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Run a shell command inside a Daytona sandbox.", @@ -7252,6 +7512,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Read, write, and manage files inside a Daytona sandbox.\n\nNotes:\n - Most useful with `persistent=True` or an explicit `sandbox_id`. With the\n default ephemeral mode, files disappear when this tool call finishes.", @@ -7682,6 +7952,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Run Python source inside a Daytona sandbox.", @@ -7873,6 +8153,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -8596,6 +8886,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -9013,6 +9313,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Run a shell command inside an E2B sandbox.", @@ -9234,6 +9544,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Read, write, and manage files inside an E2B sandbox.\n\nNotes:\n - Most useful with `persistent=True` or an explicit `sandbox_id`. With\n the default ephemeral mode, files disappear when this tool call\n finishes.", @@ -9454,6 +9774,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Run Python code inside an E2B code interpreter sandbox.\n\nUses `e2b_code_interpreter`, which runs cells in a persistent Jupyter-style\nkernel so state (imports, variables) carries across calls when\n`persistent=True`.", @@ -9675,6 +10005,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -9880,6 +10220,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": {}, @@ -9970,6 +10320,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool for reading file contents.\n\nThis tool inherits its schema handling from BaseTool to avoid recursive schema\ndefinition issues. The args_schema is set to FileReadToolSchema, whose\nfile_path parameter is optional so the tool's default file can be read by\nomitting it. The schema should not be overridden in the constructor as it\nwould break the inheritance chain and cause infinite loops.\n\nThe tool supports two ways of specifying the file path:\n1. At construction time via the file_path parameter\n2. At runtime via the file_path parameter in the tool's input\n\nPaths supplied at runtime must resolve inside ``base_dir`` (the current\nworking directory by default), since they are typically chosen by an LLM.\nA ``file_path`` given at construction time is developer-declared intent and\nis always allowed past the containment check, even when it lives outside\n``base_dir`` (the read itself can still fail). It is pinned at\nconstruction, so a later chdir cannot repoint it, and it can be addressed\neither by omitting ``file_path`` or by the label shown in the description.\n\nArgs:\n file_path (Optional[str]): Path to the file to be read. If provided,\n this becomes the default file path for the tool.\n base_dir (Optional[str]): Directory that runtime paths must stay inside.\n Defaults to the current working directory.\n encoding (str): Text encoding used to decode the file. Defaults to UTF-8.\n **kwargs: Additional keyword arguments passed to BaseTool.\n\nExample:\n >>> tool = FileReadTool(file_path=\"/path/to/file.txt\")\n >>> content = tool.run() # Reads /path/to/file.txt\n >>> content = tool.run(file_path=\"/path/to/other.txt\") # Reads other.txt\n >>> content = tool.run(\n ... file_path=\"/path/to/file.txt\", start_line=100, line_count=50\n ... ) # Reads lines 100-149\n >>> # Widen the sandbox so the agent may read anything under /data:\n >>> tool = FileReadTool(base_dir=\"/data\")", @@ -10097,6 +10457,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool for writing text content to a file.\n\nWrites are confined to ``base_dir`` (the current working directory by\ndefault), because the target directory and filename are typically chosen by\nan LLM at runtime. Set ``base_dir`` to widen that sandbox deliberately.\n\nArgs:\n base_dir (Optional[str]): Directory that writes must stay inside.\n Defaults to the current working directory.\n encoding (str): Text encoding used to write the file. Defaults to UTF-8.\n\nExample:\n >>> tool = FileWriterTool()\n >>> tool.run(filename=\"report.md\", content=\"# Report\", overwrite=True)\n >>> # Allow the agent to write anywhere under /var/output:\n >>> tool = FileWriterTool(base_dir=\"/var/output\")", @@ -10220,6 +10590,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool for crawling websites using Firecrawl v2 API. To run this tool, you need to have a Firecrawl API key.\n\nArgs:\n api_key (str): Your Firecrawl API key.\n config (dict): Optional. It contains Firecrawl v2 API parameters.\n\nDefault configuration options (Firecrawl v2 API):\n max_discovery_depth (int): Maximum depth for discovering pages. Default: 2\n ignore_sitemap (bool): Whether to ignore sitemap. Default: True\n limit (int): Maximum number of pages to crawl. Default: 10\n allow_external_links (bool): Allow crawling external links. Default: False\n allow_subdomains (bool): Allow crawling subdomains. Default: False\n delay (int): Delay between requests in milliseconds. Default: None\n scrape_options (dict): Options for scraping content\n - formats (list[str]): Content formats to return. Default: [\"markdown\"]\n - only_main_content (bool): Only return main content. Default: True\n - timeout (int): Timeout in milliseconds. Default: 10000", @@ -10319,6 +10699,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool for scraping webpages using Firecrawl v2 API. To run this tool, you need to have a Firecrawl API key.\n\nArgs:\n api_key (str): Your Firecrawl API key.\n config (dict): Optional. It contains Firecrawl v2 API parameters.\n\nDefault configuration options (Firecrawl v2 API):\n formats (list[str]): Content formats to return. Default: [\"markdown\"]\n only_main_content (bool): Only return main content excluding headers, navs, footers, etc. Default: True\n include_tags (list[str]): Tags to include in the output. Default: []\n exclude_tags (list[str]): Tags to exclude from the output. Default: []\n max_age (int): Returns cached version if younger than this age in milliseconds. Default: 172800000 (2 days)\n headers (dict): Headers to send with the request (e.g., cookies, user-agent). Default: {}\n wait_for (int): Delay in milliseconds before fetching content. Default: 0\n mobile (bool): Emulate scraping from a mobile device. Default: False\n skip_tls_verification (bool): Skip TLS certificate verification. Default: True\n timeout (int): Request timeout in milliseconds. Default: None\n remove_base64_images (bool): Remove base64 images from output. Default: True\n block_ads (bool): Enable ad-blocking and cookie popup blocking. Default: True\n proxy (str): Proxy type (\"basic\", \"stealth\", \"auto\"). Default: \"auto\"\n store_in_cache (bool): Store page in Firecrawl index and cache. Default: True", @@ -10411,6 +10801,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool for searching webpages using Firecrawl v2 API. To run this tool, you need to have a Firecrawl API key.\n\nArgs:\n api_key (str): Your Firecrawl API key.\n config (dict): Optional. It contains Firecrawl v2 API parameters.\n\nDefault configuration options (Firecrawl v2 API):\n limit (int): Maximum number of search results to return. Default: 5\n tbs (str): Time-based search filter (e.g., \"qdr:d\" for past day). Default: None\n location (str): Location for search results. Default: None\n timeout (int): Request timeout in milliseconds. Default: None\n scrape_options (dict): Options for scraping the search results. Default: {\"formats\": [\"markdown\"]}\n - formats (list[str]): Content formats to return. Default: [\"markdown\"]\n - only_main_content (bool): Only return main content. Default: True\n - include_tags (list[str]): Tags to include. Default: []\n - exclude_tags (list[str]): Tags to exclude. Default: []\n - wait_for (int): Delay before fetching content in ms. Default: 0\n - timeout (int): Request timeout in milliseconds. Default: None", @@ -10516,6 +10916,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -11256,6 +11666,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -11690,6 +12110,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "HyperbrowserLoadTool.\n\nScrape or crawl web pages and load the contents with optional parameters for configuring content extraction.\nRequires the `hyperbrowser` package.\nGet your API Key from https://app.hyperbrowser.ai/\n\nArgs:\n api_key: The Hyperbrowser API key, can be set as an environment variable `HYPERBROWSER_API_KEY` or passed directly", @@ -11804,6 +12234,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A CrewAI tool for invoking external crew/flows APIs.\n\nThis tool provides CrewAI Platform API integration with external crew services, supporting:\n- Dynamic input schema configuration\n- Automatic polling for task completion\n- Bearer token authentication\n- Comprehensive error handling\n\nExample:\n Basic usage:\n >>> tool = InvokeCrewAIAutomationTool(\n ... crew_api_url=\"https://api.example.com\",\n ... crew_bearer_token=\"your_token\",\n ... crew_name=\"My Crew\",\n ... crew_description=\"Description of what the crew does\",\n ... )\n\n With custom inputs:\n >>> custom_inputs = {\n ... \"param1\": Field(..., description=\"Description of param1\"),\n ... \"param2\": Field(\n ... default=\"default_value\", description=\"Description of param2\"\n ... ),\n ... }\n >>> tool = InvokeCrewAIAutomationTool(\n ... crew_api_url=\"https://api.example.com\",\n ... crew_bearer_token=\"your_token\",\n ... crew_name=\"My Crew\",\n ... crew_description=\"Description of what the crew does\",\n ... crew_inputs=custom_inputs,\n ... )\n\nExample:\n >>> tools = [\n ... InvokeCrewAIAutomationTool(\n ... crew_api_url=\"https://canary-crew-[...].crewai.com\",\n ... crew_bearer_token=\"[Your token: abcdef012345]\",\n ... crew_name=\"State of AI Report\",\n ... crew_description=\"Retrieves a report on state of AI for a given year.\",\n ... crew_inputs={\n ... \"year\": Field(\n ... ..., description=\"Year to retrieve the report for (integer)\"\n ... )\n ... },\n ... )\n ... ]", @@ -12532,6 +12972,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -12936,6 +13386,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -13040,6 +13500,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": {}, @@ -13097,6 +13567,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool to wrap LlamaIndex tools/query engines.", @@ -13804,6 +14284,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -14215,6 +14705,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Wrapper for Merge Agent Handler tools.\n\nThis tool allows CrewAI agents to execute tools from Merge Agent Handler,\nwhich provides secure access to third-party integrations via the Model Context Protocol (MCP).\n\nAgent Handler manages authentication, permissions, and monitoring of all tool interactions.", @@ -14386,6 +14886,16 @@ }, "title": "MongoDBVectorSearchConfig", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool to perfrom a vector search the MongoDB database.", @@ -14523,6 +15033,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool to wrap MultiOn Browse Capabilities.", @@ -15259,6 +15779,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -15664,6 +16194,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool that converts natural language to SQL and executes it against a database.\n\nBy default the tool operates in **read-only mode**: only SELECT, SHOW,\nDESCRIBE, EXPLAIN, and read-only CTEs (WITH \u2026 SELECT) are permitted. Write\noperations (INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, TRUNCATE, \u2026) are\nblocked unless ``allow_dml=True`` is set explicitly or the environment\nvariable ``CREWAI_NL2SQL_ALLOW_DML=true`` is present.\n\nWritable CTEs (``WITH d AS (DELETE \u2026) SELECT \u2026``) and\n``EXPLAIN ANALYZE `` are treated as write operations and are\nblocked in read-only mode.\n\nThe ``_fetch_all_available_columns`` helper uses parameterised queries so\nthat table names coming from the database catalogue cannot be used as an\ninjection vector.", @@ -16085,6 +16625,16 @@ ], "title": "LLM", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool for performing Optical Character Recognition on images.\n\nThis tool leverages LLMs to extract text from images. It can process\nboth local image files and images available via URLs.\n\nAttributes:\n name (str): Name of the tool.\n description (str): Description of the tool's functionality.\n args_schema (Type[BaseModel]): Pydantic schema for input validation.\n\nPrivate Attributes:\n _llm (Optional[LLM]): Language model instance for making API calls.", @@ -16281,6 +16831,16 @@ }, "title": "OxylabsAmazonProductScraperConfig", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Scrape Amazon product pages with OxylabsAmazonProductScraperTool.\n\nGet Oxylabs account:\nhttps://dashboard.oxylabs.io/en\n\nArgs:\n username (str): Oxylabs username.\n password (str): Oxylabs password.\n config: Configuration options. See ``OxylabsAmazonProductScraperConfig``", @@ -16510,6 +17070,16 @@ }, "title": "OxylabsAmazonSearchScraperConfig", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Scrape Amazon search results with OxylabsAmazonSearchScraperTool.\n\nGet Oxylabs account:\nhttps://dashboard.oxylabs.io/en\n\nArgs:\n username (str): Oxylabs username.\n password (str): Oxylabs password.\n config: Configuration options. See ``OxylabsAmazonSearchScraperConfig``", @@ -16752,6 +17322,16 @@ }, "title": "OxylabsGoogleSearchScraperConfig", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Scrape Google Search results with OxylabsGoogleSearchScraperTool.\n\nGet Oxylabs account:\nhttps://dashboard.oxylabs.io/en\n\nArgs:\n username (str): Oxylabs username.\n password (str): Oxylabs password.\n config: Configuration options. See ``OxylabsGoogleSearchScraperConfig``", @@ -16942,6 +17522,16 @@ }, "title": "OxylabsUniversalScraperConfig", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Scrape any website with OxylabsUniversalScraperTool.\n\nGet Oxylabs account:\nhttps://dashboard.oxylabs.io/en\n\nArgs:\n username (str): Oxylabs username.\n password (str): Oxylabs password.\n config: Configuration options. See ``OxylabsUniversalScraperConfig``", @@ -17664,6 +18254,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -18087,6 +18687,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -18228,6 +18838,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -18309,6 +18929,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -18415,6 +19045,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "PatronusEvalTool is a tool to automatically evaluate and score agent interactions.\n\nResults are logged to the Patronus platform at app.patronus.ai", @@ -18589,6 +19229,16 @@ ], "title": "QdrantConfig", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Vector search tool for Qdrant.", @@ -19360,6 +20010,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -19748,6 +20408,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -19873,6 +20543,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -19987,6 +20667,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that uses Scrapegraph AI to intelligently scrape website content.\n\nRaises:\n ValueError: If API key is missing or URL format is invalid\n RateLimitError: If API rate limits are exceeded\n RuntimeError: If scraping operation fails", @@ -20110,6 +20800,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -20244,6 +20944,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -20397,6 +21107,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -20495,6 +21215,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -20593,6 +21323,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -20722,6 +21462,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": {}, @@ -21445,6 +22195,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -21881,6 +22641,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -21997,6 +22767,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -22113,6 +22893,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -22910,6 +23700,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -23399,6 +24199,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool for performing semantic searches on SingleStore database tables.\n\nThis tool provides a safe interface for executing SELECT and SHOW queries\nagainst a SingleStore database with connection pooling for optimal performance.", @@ -23599,6 +24409,16 @@ ], "title": "SnowflakeConfig", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool for executing queries and semantic search on Snowflake.", @@ -23773,6 +24593,16 @@ }, "title": "SpiderToolConfig", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool for scraping and crawling websites.\nThis tool provides functionality to either scrape a single webpage or crawl multiple\npages, returning content in a format suitable for LLM processing.", @@ -23920,6 +24750,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "A tool that uses Stagehand to automate web browser interactions using natural language with atomic action handling.\n\nStagehand allows AI agents to interact with websites through a browser,\nperforming actions like clicking buttons, filling forms, and extracting data.\n\nThe tool supports four main command types:\n1. act - Perform actions like clicking, typing, scrolling, or navigating\n2. navigate - Specifically navigate to a URL (shorthand for act with navigation)\n3. extract - Extract structured data from web pages\n4. observe - Identify and analyze elements on a page\n\nUsage examples:\n- Navigate to a website: instruction=\"Go to the homepage\", url=\"https://example.com\"\n- Click a button: instruction=\"Click the login button\"\n- Fill a form: instruction=\"Fill the login form with username 'user' and password 'pass'\"\n- Extract data: instruction=\"Extract all product prices and names\", command_type=\"extract\"\n- Observe elements: instruction=\"Find all navigation menu items\", command_type=\"observe\"\n- Complex tasks: instruction=\"Step 1: Navigate to https://example.com; Step 2: Scroll down to the 'Features' section; Step 3: Click 'Learn More'\", command_type=\"act\"\n\nExample of breaking down \"Search for OpenAI\" into multiple steps:\n1. First navigation: instruction=\"Go to Google\", url=\"https://google.com\", command_type=\"navigate\"\n2. Enter search term: instruction=\"Type 'OpenAI' in the search box\", command_type=\"act\"\n3. Submit search: instruction=\"Press the Enter key or click the search button\", command_type=\"act\"\n4. Click on result: instruction=\"Click on the OpenAI website link in the search results\", command_type=\"act\"", @@ -24752,6 +25592,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -25175,6 +26025,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "properties": { @@ -25330,6 +26190,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool that uses the Tavily Research status endpoint to retrieve results.", @@ -25405,6 +26275,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool that uses the Tavily Research API to create research tasks.", @@ -25567,6 +26447,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool that uses the Tavily Search API to perform web searches.\n\nAttributes:\n client: An instance of TavilyClient.\n async_client: An instance of AsyncTavilyClient.\n name: The name of the tool.\n description: A description of the tool's purpose.\n args_schema: The schema for the tool's arguments.\n api_key: The Tavily API key.\n proxies: Optional proxies for the API requests.\n search_depth: The depth of the search.\n topic: The topic to focus the search on.\n time_range: The time range for the search.\n days: The number of days to search back.\n max_results: The maximum number of results to return.\n include_domains: A list of domains to include in the search.\n exclude_domains: A list of domains to exclude from the search.\n include_answer: Whether to include a direct answer to the query.\n include_raw_content: Whether to include the raw content of the search results.\n include_images: Whether to include images in the search results.\n timeout: The timeout for the search request in seconds.\n max_content_length_per_result: Maximum length for the 'content' of each search result.", @@ -25816,6 +26706,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool for analyzing images using vision models.\n\nArgs:\n llm: Optional LLM instance to use\n model: Model identifier to use if no LLM is provided", @@ -25879,6 +26779,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Pause execution so a long-running job elsewhere has time to progress.\n\nAgents that kick off out-of-band work (a sandbox build, a deployment, an async\nAPI job) have no other way to let clock time pass: without this tool they either\npoll in a tight loop or give up before the work finishes.\n\nA single call waits at most ``max_seconds``. Longer requests are clamped to that\ncap and the result says so, so the model can call again instead of failing.\n\nThe generated description advertises the cap to the model, and is kept in sync\nwith ``max_seconds``. A description supplied by the caller is left alone.", @@ -25974,6 +26884,16 @@ ], "title": "EnvVar", "type": "object" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" } }, "description": "Tool to search the Weaviate database.", @@ -26757,6 +27677,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -27806,6 +28736,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -28855,6 +29795,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { @@ -29904,6 +30854,16 @@ "title": "Text2VecProviderSpec", "type": "object" }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + }, "VectorDbConfig": { "description": "Configuration for vector database provider.\n\nAttributes:\n provider: RAG provider literal.\n config: RAG configuration options.", "properties": { diff --git a/lib/crewai/src/crewai/agent/core.py b/lib/crewai/src/crewai/agent/core.py index 64e74c02d..ac98c20f4 100644 --- a/lib/crewai/src/crewai/agent/core.py +++ b/lib/crewai/src/crewai/agent/core.py @@ -86,6 +86,12 @@ from crewai.skills.loader import load_skills 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, @@ -550,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 @@ -914,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 @@ -1454,6 +1469,8 @@ 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 @@ -1786,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. @@ -1866,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( @@ -1876,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( @@ -1889,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( @@ -1951,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, diff --git a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py index 4530f9527..49fad6aac 100644 --- a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py +++ b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py @@ -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, diff --git a/lib/crewai/src/crewai/agents/cache/cache_handler.py b/lib/crewai/src/crewai/agents/cache/cache_handler.py index 368bcfa20..95da07f6d 100644 --- a/lib/crewai/src/crewai/agents/cache/cache_handler.py +++ b/lib/crewai/src/crewai/agents/cache/cache_handler.py @@ -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 diff --git a/lib/crewai/src/crewai/agents/step_executor.py b/lib/crewai/src/crewai/agents/step_executor.py index 81238f473..6bc57ae8e 100644 --- a/lib/crewai/src/crewai/agents/step_executor.py +++ b/lib/crewai/src/crewai/agents/step_executor.py @@ -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 diff --git a/lib/crewai/src/crewai/crew.py b/lib/crewai/src/crewai/crew.py index ed9b77ebd..5b7908cff 100644 --- a/lib/crewai/src/crewai/crew.py +++ b/lib/crewai/src/crewai/crew.py @@ -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], diff --git a/lib/crewai/src/crewai/crews/crew_output.py b/lib/crewai/src/crewai/crews/crew_output.py index 13b431feb..7807f12d9 100644 --- a/lib/crewai/src/crewai/crews/crew_output.py +++ b/lib/crewai/src/crewai/crews/crew_output.py @@ -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. diff --git a/lib/crewai/src/crewai/events/__init__.py b/lib/crewai/src/crewai/events/__init__.py index 3a9ab7e2b..6b44e988f 100644 --- a/lib/crewai/src/crewai/events/__init__.py +++ b/lib/crewai/src/crewai/events/__init__.py @@ -148,6 +148,7 @@ if TYPE_CHECKING: ) from crewai.events.types.tool_usage_events import ( ToolExecutionErrorEvent, + ToolFailureDetectedEvent, ToolSelectionErrorEvent, ToolUsageErrorEvent, ToolUsageEvent, @@ -253,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", @@ -387,6 +389,7 @@ __all__ = [ "TaskFailedEvent", "TaskStartedEvent", "ToolExecutionErrorEvent", + "ToolFailureDetectedEvent", "ToolSelectionErrorEvent", "ToolUsageErrorEvent", "ToolUsageEvent", diff --git a/lib/crewai/src/crewai/events/event_listener.py b/lib/crewai/src/crewai/events/event_listener.py index 537d5edc4..1ee18bc4f 100644 --- a/lib/crewai/src/crewai/events/event_listener.py +++ b/lib/crewai/src/crewai/events/event_listener.py @@ -114,6 +114,7 @@ from crewai.events.types.task_events import ( TaskStartedEvent, ) from crewai.events.types.tool_usage_events import ( + ToolFailureDetectedEvent, ToolUsageErrorEvent, ToolUsageFinishedEvent, ToolUsageStartedEvent, @@ -424,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, @@ -449,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() diff --git a/lib/crewai/src/crewai/events/event_types.py b/lib/crewai/src/crewai/events/event_types.py index d0b9d8bd8..abb5a3464 100644 --- a/lib/crewai/src/crewai/events/event_types.py +++ b/lib/crewai/src/crewai/events/event_types.py @@ -118,6 +118,7 @@ from crewai.events.types.task_events import ( TaskStartedEvent, ) from crewai.events.types.tool_usage_events import ( + ToolFailureDetectedEvent, ToolUsageErrorEvent, ToolUsageFinishedEvent, ToolUsageStartedEvent, @@ -178,6 +179,7 @@ EventTypes = ( | AgentExecutionErrorEvent | ToolUsageFinishedEvent | ToolUsageErrorEvent + | ToolFailureDetectedEvent | ToolUsageStartedEvent | LLMCallStartedEvent | LLMCallCompletedEvent diff --git a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py index 0db7bcf03..e1fb2024c 100644 --- a/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py +++ b/lib/crewai/src/crewai/events/listeners/tracing/trace_listener.py @@ -127,6 +127,7 @@ from crewai.events.types.task_events import ( TaskStartedEvent, ) from crewai.events.types.tool_usage_events import ( + ToolFailureDetectedEvent, ToolUsageErrorEvent, ToolUsageFinishedEvent, ToolUsageStartedEvent, @@ -415,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 diff --git a/lib/crewai/src/crewai/events/types/tool_usage_events.py b/lib/crewai/src/crewai/events/types/tool_usage_events.py index 86e0a3087..7e15a16c1 100644 --- a/lib/crewai/src/crewai/events/types/tool_usage_events.py +++ b/lib/crewai/src/crewai/events/types/tool_usage_events.py @@ -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""" diff --git a/lib/crewai/src/crewai/events/utils/console_formatter.py b/lib/crewai/src/crewai/events/utils/console_formatter.py index 858dde0ac..4aefb8e47 100644 --- a/lib/crewai/src/crewai/events/utils/console_formatter.py +++ b/lib/crewai/src/crewai/events/utils/console_formatter.py @@ -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, diff --git a/lib/crewai/src/crewai/experimental/agent_executor.py b/lib/crewai/src/crewai/experimental/agent_executor.py index 96308f9c9..54fea7ca0 100644 --- a/lib/crewai/src/crewai/experimental/agent_executor.py +++ b/lib/crewai/src/crewai/experimental/agent_executor.py @@ -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"): diff --git a/lib/crewai/src/crewai/hooks/tool_hooks.py b/lib/crewai/src/crewai/hooks/tool_hooks.py index a4509bce7..0e6cec0be 100644 --- a/lib/crewai/src/crewai/hooks/tool_hooks.py +++ b/lib/crewai/src/crewai/hooks/tool_hooks.py @@ -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, diff --git a/lib/crewai/src/crewai/lite_agent.py b/lib/crewai/src/crewai/lite_agent.py index bc79c590c..373b774e4 100644 --- a/lib/crewai/src/crewai/lite_agent.py +++ b/lib/crewai/src/crewai/lite_agent.py @@ -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( diff --git a/lib/crewai/src/crewai/lite_agent_output.py b/lib/crewai/src/crewai/lite_agent_output.py index 4b4bec446..7c7f8382d 100644 --- a/lib/crewai/src/crewai/lite_agent_output.py +++ b/lib/crewai/src/crewai/lite_agent_output.py @@ -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" diff --git a/lib/crewai/src/crewai/mcp/client.py b/lib/crewai/src/crewai/mcp/client.py index adf1afb8c..90303ba47 100644 --- a/lib/crewai/src/crewai/mcp/client.py +++ b/lib/crewai/src/crewai/mcp/client.py @@ -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 = ( diff --git a/lib/crewai/src/crewai/task.py b/lib/crewai/src/crewai/task.py index b6d3b8acc..fb9bfb5c0 100644 --- a/lib/crewai/src/crewai/task.py +++ b/lib/crewai/src/crewai/task.py @@ -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 diff --git a/lib/crewai/src/crewai/tasks/task_output.py b/lib/crewai/src/crewai/tasks/task_output.py index 3bfd4d33d..3bb9eb876 100644 --- a/lib/crewai/src/crewai/tasks/task_output.py +++ b/lib/crewai/src/crewai/tasks/task_output.py @@ -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: diff --git a/lib/crewai/src/crewai/tools/__init__.py b/lib/crewai/src/crewai/tools/__init__.py index a2415b1b2..b77b9dcf5 100644 --- a/lib/crewai/src/crewai/tools/__init__.py +++ b/lib/crewai/src/crewai/tools/__init__.py @@ -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", ] diff --git a/lib/crewai/src/crewai/tools/base_tool.py b/lib/crewai/src/crewai/tools/base_tool.py index eb941bc26..83986c9b8 100644 --- a/lib/crewai/src/crewai/tools/base_tool.py +++ b/lib/crewai/src/crewai/tools/base_tool.py @@ -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 diff --git a/lib/crewai/src/crewai/tools/mcp_native_tool.py b/lib/crewai/src/crewai/tools/mcp_native_tool.py index 94bff3993..f039e7444 100644 --- a/lib/crewai/src/crewai/tools/mcp_native_tool.py +++ b/lib/crewai/src/crewai/tools/mcp_native_tool.py @@ -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 diff --git a/lib/crewai/src/crewai/tools/structured_tool.py b/lib/crewai/src/crewai/tools/structured_tool.py index e868cd7da..c7191de40 100644 --- a/lib/crewai/src/crewai/tools/structured_tool.py +++ b/lib/crewai/src/crewai/tools/structured_tool.py @@ -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) diff --git a/lib/crewai/src/crewai/tools/tool_failure.py b/lib/crewai/src/crewai/tools/tool_failure.py new file mode 100644 index 000000000..839a62e92 --- /dev/null +++ b/lib/crewai/src/crewai/tools/tool_failure.py @@ -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 diff --git a/lib/crewai/src/crewai/tools/tool_usage.py b/lib/crewai/src/crewai/tools/tool_usage.py index 764f82bd9..f87a170ac 100644 --- a/lib/crewai/src/crewai/tools/tool_usage.py +++ b/lib/crewai/src/crewai/tools/tool_usage.py @@ -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__, diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index 6a1d2fcf5..a3fbe43ce 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -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 diff --git a/lib/crewai/src/crewai/utilities/tool_utils.py b/lib/crewai/src/crewai/utilities/tool_utils.py index 2d6e3c142..90de17ca6 100644 --- a/lib/crewai/src/crewai/utilities/tool_utils.py +++ b/lib/crewai/src/crewai/utilities/tool_utils.py @@ -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) diff --git a/lib/crewai/tests/mcp/test_mcp_config.py b/lib/crewai/tests/mcp/test_mcp_config.py index ce123be6b..607d9d3e6 100644 --- a/lib/crewai/tests/mcp/test_mcp_config.py +++ b/lib/crewai/tests/mcp/test_mcp_config.py @@ -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): diff --git a/lib/crewai/tests/test_task.py b/lib/crewai/tests/test_task.py index a6525f8cb..583df5db0 100644 --- a/lib/crewai/tests/test_task.py +++ b/lib/crewai/tests/test_task.py @@ -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 diff --git a/lib/crewai/tests/tools/test_tool_failure.py b/lib/crewai/tests/tools/test_tool_failure.py new file mode 100644 index 000000000..a44b6916c --- /dev/null +++ b/lib/crewai/tests/tools/test_tool_failure.py @@ -0,0 +1,1777 @@ +"""Tests for structured tool-failure signalling and the per-agent policy.""" + +import json +from types import SimpleNamespace +from typing import Any + +import pytest + +from crewai import Agent, Crew, Task +from crewai.events.event_bus import crewai_event_bus +from crewai.events.types.tool_usage_events import ( + ToolFailureDetectedEvent, + ToolUsageFinishedEvent, +) +from crewai.llm import LLM +from crewai.tools import BaseTool +from crewai.tools.tool_failure import ( + ToolExecutionFailedError, + ToolFailure, + ToolFailurePolicy, + ToolFailureReason, + ToolFailureRecord, + detect_tool_failure, + failure_from_exception, + resolve_tool_failure_policy, +) + + +class SlackTool(BaseTool): + """Mirrors an upstream API that answers 200 with an error body.""" + + name: str = "slackbot_send_message" + description: str = "Post a message to a Slack channel." + + def _run(self, channel: str) -> Any: + return ToolFailure( + message=f"Slack rejected the message to {channel}", + code="channel_not_found", + ) + + +class WorkingTool(BaseTool): + name: str = "echo" + description: str = "Echo the input back." + + def _run(self, text: str) -> Any: + return f"echoed: {text}" + + +class ScriptedLLM(LLM): + """Emits a fixed sequence of ReAct steps without touching a provider.""" + + def __new__(cls, *args: Any, **kwargs: Any) -> "ScriptedLLM": + return object.__new__(cls) + + def __init__(self, steps: list[str]) -> None: + super().__init__(model="gpt-4o") + self._steps = steps + self._index = 0 + + def call(self, messages, tools=None, callbacks=None, available_functions=None, **kw): # noqa: ANN001, ANN003 + step = self._steps[min(self._index, len(self._steps) - 1)] + self._index += 1 + return step + + def supports_function_calling(self) -> bool: + return False + + +class StatelessToolLLM(LLM): + """Calls one tool, then answers -- decided from the messages, not a counter. + + Stateless so concurrent executions sharing one agent cannot interleave into + each other's script. + """ + + def __new__(cls, *args: Any, **kwargs: Any) -> "StatelessToolLLM": + return object.__new__(cls) + + def __init__( + self, + tool_name: str, + tool_args: dict[str, Any], + done_marker: str = "rejected the message", + ) -> None: + super().__init__(model="gpt-4o") + self._tool_name = tool_name + self._tool_args = tool_args + # A sentinel from the tool's own output. Not "Observation" -- the ReAct + # prompt itself contains that word, so the stub would answer before + # ever calling the tool. + self._done_marker = done_marker + + def call(self, messages, tools=None, callbacks=None, available_functions=None, **kw): # noqa: ANN001, ANN003 + if self._done_marker in str(messages): + return "Thought: it failed\nFinal Answer: could not post." + return ( + "Thought: posting\n" + + f"Action: {self._tool_name}\n" + + f"Action Input: {json.dumps(self._tool_args)}" + ) + + def supports_function_calling(self) -> bool: + return False + + +def _slack_steps() -> list[str]: + call_step = ( + "Thought: posting\n" + + "Action: slackbot_send_message\n" + + 'Action Input: {"channel": "#joao-message"}' + ) + return [ + call_step, + "Thought: it failed\nFinal Answer: I could not post the message.", + ] + + +def _build_crew(policy: ToolFailurePolicy | None = None, **task_kwargs: Any): + agent_kwargs: dict[str, Any] = { + "role": "Slack Messenger", + "goal": "post a message", + "backstory": "b", + "llm": ScriptedLLM(_slack_steps()), + "tools": [SlackTool()], + } + if policy is not None: + agent_kwargs["tool_failure_policy"] = policy + agent = Agent(**agent_kwargs) + task = Task( + description="post to slack", + expected_output="confirmation", + agent=agent, + **task_kwargs, + ) + return Crew(agents=[agent], tasks=[task]), agent + + +class TestToolFailureModel: + def test_as_agent_message_includes_code(self) -> None: + failure = ToolFailure(message="nope", code="channel_not_found") + assert failure.as_agent_message() == "nope (code: channel_not_found)" + + def test_as_agent_message_without_code(self) -> None: + assert ToolFailure(message="nope").as_agent_message() == "nope" + + def test_default_reason_is_tool_reported(self) -> None: + assert ToolFailure(message="x").reason is ToolFailureReason.TOOL_REPORTED + + def test_detection_is_declarative_only(self) -> None: + """A string that merely looks like an error is not a failure.""" + assert detect_tool_failure("Error: something went wrong") is None + assert detect_tool_failure({"ok": False}) is None + assert detect_tool_failure(ToolFailure(message="x")) is not None + + def test_failure_from_exception(self) -> None: + failure = failure_from_exception(ValueError("bad input")) + assert failure.reason is ToolFailureReason.EXCEPTION + assert failure.code == "ValueError" + assert "bad input" in failure.message + + def test_record_summary_mentions_tool_and_task(self) -> None: + record = ToolFailureRecord( + tool_name="slackbot_send_message", + failure=ToolFailure(message="nope", code="channel_not_found"), + task_name="post to slack", + ) + summary = record.summary() + assert "slackbot_send_message" in summary + assert "post to slack" in summary + assert "channel_not_found" in summary + + +class TestPolicyResolution: + def test_defaults_to_warn(self) -> None: + assert resolve_tool_failure_policy() is ToolFailurePolicy.WARN + + def test_agent_policy_used_when_no_narrower_scope(self) -> None: + agent = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.RAISE, + ) + assert resolve_tool_failure_policy(agent=agent) is ToolFailurePolicy.RAISE + + def test_task_overrides_agent(self) -> None: + agent = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.WARN, + ) + task = Task( + description="d", + expected_output="e", + tool_failure_policy=ToolFailurePolicy.RAISE, + ) + resolved = resolve_tool_failure_policy(agent=agent, task=task) + assert resolved is ToolFailurePolicy.RAISE + + def test_unset_task_policy_falls_through_to_agent(self) -> None: + agent = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.IGNORE, + ) + task = Task(description="d", expected_output="e") + resolved = resolve_tool_failure_policy(agent=agent, task=task) + assert resolved is ToolFailurePolicy.IGNORE + + def test_crew_policy_used_when_agent_inherits(self) -> None: + from crewai import Crew + + agent = Agent(role="r", goal="g", backstory="b") + crew = Crew( + agents=[agent], tasks=[], tool_failure_policy=ToolFailurePolicy.RAISE + ) + resolved = resolve_tool_failure_policy(agent=agent, crew=crew) + assert resolved is ToolFailurePolicy.RAISE + + def test_agent_overrides_crew(self) -> None: + from crewai import Crew + + agent = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.IGNORE, + ) + crew = Crew( + agents=[agent], tasks=[], tool_failure_policy=ToolFailurePolicy.RAISE + ) + resolved = resolve_tool_failure_policy(agent=agent, crew=crew) + assert resolved is ToolFailurePolicy.IGNORE + + def test_full_precedence_chain(self) -> None: + """tool > task > agent > crew > warn.""" + from crewai import Crew + + class ScopedTool(SlackTool): + tool_failure_policy: ToolFailurePolicy | None = None + + tool = ScopedTool() + agent = Agent(role="r", goal="g", backstory="b") + task = Task(description="d", expected_output="e") + crew = Crew(agents=[agent], tasks=[]) + + def resolved() -> ToolFailurePolicy: + return resolve_tool_failure_policy( + tool=tool, agent=agent, task=task, crew=crew + ) + + assert resolved() is ToolFailurePolicy.WARN + + crew.tool_failure_policy = ToolFailurePolicy.IGNORE + assert resolved() is ToolFailurePolicy.IGNORE + + agent.tool_failure_policy = ToolFailurePolicy.WARN + assert resolved() is ToolFailurePolicy.WARN + + task.tool_failure_policy = ToolFailurePolicy.RAISE + assert resolved() is ToolFailurePolicy.RAISE + + tool.tool_failure_policy = ToolFailurePolicy.IGNORE + assert resolved() is ToolFailurePolicy.IGNORE + + def test_invalid_policy_is_ignored_rather_than_raising(self) -> None: + """A bad policy value must never take down a tool call.""" + + class Bogus: + tool_failure_policy = "not-a-policy" + + assert resolve_tool_failure_policy(agent=Bogus()) is ToolFailurePolicy.WARN + + def test_invalid_policy_falls_through_to_next_scope(self) -> None: + class Bogus: + tool_failure_policy = object() + + agent = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.IGNORE, + ) + resolved = resolve_tool_failure_policy(tool=Bogus(), agent=agent) + assert resolved is ToolFailurePolicy.IGNORE + + def test_tool_overrides_everything(self) -> None: + class StrictTool(WorkingTool): + tool_failure_policy: ToolFailurePolicy = ToolFailurePolicy.RAISE + + agent = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.IGNORE, + ) + resolved = resolve_tool_failure_policy(tool=StrictTool(), agent=agent) + assert resolved is ToolFailurePolicy.RAISE + + +class TestDefaults: + """Every scope defaults to None ('inherit'); the resolver owns 'warn'.""" + + def test_agent_defaults_to_inherit(self) -> None: + assert Agent(role="r", goal="g", backstory="b").tool_failure_policy is None + + def test_task_defaults_to_inherit(self) -> None: + assert Task(description="d", expected_output="e").tool_failure_policy is None + + def test_crew_defaults_to_inherit(self) -> None: + from crewai import Crew + + agent = Agent(role="r", goal="g", backstory="b") + assert Crew(agents=[agent], tasks=[]).tool_failure_policy is None + + def test_tool_defaults_to_inherit(self) -> None: + assert SlackTool().tool_failure_policy is None + + def test_effective_default_is_warn(self) -> None: + agent = Agent(role="r", goal="g", backstory="b") + assert resolve_tool_failure_policy(agent=agent) is ToolFailurePolicy.WARN + + +class TestEndToEndPolicies: + def test_warn_records_and_emits_without_stopping(self) -> None: + crew, agent = _build_crew(ToolFailurePolicy.WARN) + events: list[ToolFailureDetectedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolFailureDetectedEvent) + def _(source: Any, event: ToolFailureDetectedEvent) -> None: + events.append(event) + + result = crew.kickoff() + + assert len(events) == 1 + assert events[0].tool_name == "slackbot_send_message" + assert events[0].failure.code == "channel_not_found" + assert events[0].policy is ToolFailurePolicy.WARN + + assert result.has_tool_failures + assert len(result.tool_failures) == 1 + assert result.tool_failures[0].failure.code == "channel_not_found" + assert result.tasks_output[0].has_tool_failures + + def test_ignore_restores_previous_behaviour(self) -> None: + crew, _ = _build_crew(ToolFailurePolicy.IGNORE) + events: list[ToolFailureDetectedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolFailureDetectedEvent) + def _(source: Any, event: ToolFailureDetectedEvent) -> None: + events.append(event) + + result = crew.kickoff() + + assert events == [] + assert not result.has_tool_failures + assert result.tool_failures == [] + + def test_raise_aborts_the_run(self) -> None: + crew, _ = _build_crew(ToolFailurePolicy.RAISE) + + with pytest.raises(ToolExecutionFailedError) as exc_info: + crew.kickoff() + + record = exc_info.value.record + assert record.tool_name == "slackbot_send_message" + assert record.failure.code == "channel_not_found" + + def test_event_is_emitted_before_raise(self) -> None: + """Subscribers must observe the failure even on an aborting run.""" + crew, _ = _build_crew(ToolFailurePolicy.RAISE) + events: list[ToolFailureDetectedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolFailureDetectedEvent) + def _(source: Any, event: ToolFailureDetectedEvent) -> None: + events.append(event) + + with pytest.raises(ToolExecutionFailedError): + crew.kickoff() + + assert len(events) == 1 + + def test_task_policy_overrides_agent_end_to_end(self) -> None: + crew, _ = _build_crew( + ToolFailurePolicy.WARN, + tool_failure_policy=ToolFailurePolicy.RAISE, + ) + with pytest.raises(ToolExecutionFailedError): + crew.kickoff() + + def test_default_agent_warns(self) -> None: + """No explicit policy anywhere still records the failure.""" + crew, _ = _build_crew() + result = crew.kickoff() + assert result.has_tool_failures + + def test_finished_event_carries_the_failure(self) -> None: + crew, _ = _build_crew(ToolFailurePolicy.WARN) + finished: list[ToolUsageFinishedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolUsageFinishedEvent) + def _(source: Any, event: ToolUsageFinishedEvent) -> None: + finished.append(event) + + crew.kickoff() + + slack_events = [e for e in finished if e.tool_name == "slackbot_send_message"] + assert slack_events + assert slack_events[0].failure is not None + assert slack_events[0].failure.code == "channel_not_found" + + def test_agent_sees_the_failure_message_as_plain_text(self) -> None: + """Model-facing behavior is unchanged: it still reads prose.""" + crew, _ = _build_crew(ToolFailurePolicy.WARN) + result = crew.kickoff() + tool_messages = [ + m + for m in result.tasks_output[0].messages + if "Slack rejected the message" in str(m.get("content", "")) + ] + assert tool_messages + + +class TestToolScopedPolicyReachesTheExecutor: + """A tool-scoped policy must survive the CrewStructuredTool wrapper. + + Executors pass the wrapper, not the authored BaseTool, so a tool-scoped + policy used to be silently dropped. + """ + + def test_policy_survives_to_structured_tool(self) -> None: + class StrictSlack(SlackTool): + tool_failure_policy: ToolFailurePolicy | None = ToolFailurePolicy.RAISE + + wrapper = StrictSlack().to_structured_tool() + assert wrapper.tool_failure_policy is ToolFailurePolicy.RAISE + assert resolve_tool_failure_policy(tool=wrapper) is ToolFailurePolicy.RAISE + + def test_policy_resolves_through_original_tool_reference(self) -> None: + """Even a wrapper that never copied the field resolves via _original_tool.""" + + class StrictSlack(SlackTool): + tool_failure_policy: ToolFailurePolicy | None = ToolFailurePolicy.RAISE + + wrapper = StrictSlack().to_structured_tool() + wrapper.tool_failure_policy = None + assert resolve_tool_failure_policy(tool=wrapper) is ToolFailurePolicy.RAISE + + def test_tool_policy_aborts_a_warn_agent_end_to_end(self) -> None: + class StrictSlack(SlackTool): + tool_failure_policy: ToolFailurePolicy | None = ToolFailurePolicy.RAISE + + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[StrictSlack()], + tool_failure_policy=ToolFailurePolicy.WARN, + ) + task = Task(description="post to slack", expected_output="c", agent=agent) + with pytest.raises(ToolExecutionFailedError): + Crew(agents=[agent], tasks=[task]).kickoff() + + def test_tool_policy_can_exempt_a_raising_agent(self) -> None: + class ChattySlack(SlackTool): + tool_failure_policy: ToolFailurePolicy | None = ToolFailurePolicy.IGNORE + + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[ChattySlack()], + tool_failure_policy=ToolFailurePolicy.RAISE, + ) + task = Task(description="post to slack", expected_output="c", agent=agent) + result = Crew(agents=[agent], tasks=[task]).kickoff() + assert not result.has_tool_failures + + def test_plain_tools_default_to_inheriting(self) -> None: + assert SlackTool().tool_failure_policy is None + + +class TestConsolePanels: + """Exactly one panel per failed call, and never a green one. + + A failed call used to print the green "Completed" panel. + """ + + @staticmethod + def _formatter(): + from crewai.events.utils.console_formatter import ConsoleFormatter + + return ConsoleFormatter(verbose=True) + + def test_success_panel_suppressed_when_the_call_failed(self) -> None: + failure = ToolFailure(message="nope", code="channel_not_found") + assert self._formatter().should_render_success_panel(failure) is False + + def test_success_panel_still_shown_for_a_working_call(self) -> None: + assert self._formatter().should_render_success_panel(None) is True + + def test_exception_failures_do_not_double_print(self) -> None: + """ToolUsageErrorEvent already prints; the failure panel must not repeat it.""" + failure = failure_from_exception(ValueError("kaboom")) + assert self._formatter().should_render_failure_panel(failure) is False + + def test_tool_reported_failures_do_print(self) -> None: + failure = ToolFailure(message="nope", code="channel_not_found") + assert self._formatter().should_render_failure_panel(failure) is True + + def test_mcp_failures_do_print(self) -> None: + failure = ToolFailure(message="nope", reason=ToolFailureReason.MCP_ERROR) + assert self._formatter().should_render_failure_panel(failure) is True + + def test_failure_panel_renders_without_raising(self) -> None: + """The real formatter must handle the payload it is given.""" + self._formatter().handle_tool_failure_detected( + "slackbot_send_message", + ToolFailure(message="nope", code="channel_not_found"), + ToolFailurePolicy.WARN, + ) + + def test_listener_consults_the_predicates(self) -> None: + """The listener must route through the predicates, not its own logic.""" + import inspect + + from crewai.events.event_listener import EventListener + + source = inspect.getsource(EventListener.setup_listeners) + assert "should_render_success_panel" in source + assert "should_render_failure_panel" in source + + +class TestUnknownToolOnNativePaths: + """The ReAct path reported unknown tools; the native paths did not.""" + + def test_native_path_records_unknown_tool(self) -> None: + from crewai.utilities.agent_utils import execute_single_native_tool_call + + agent = Agent(role="r", goal="g", backstory="b") + recorded: list[ToolFailureDetectedEvent] = [] + + tool_call = SimpleNamespace( + id="call_1", + function=SimpleNamespace(name="does_not_exist", arguments="{}"), + ) + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolFailureDetectedEvent) + def _(source: Any, event: ToolFailureDetectedEvent) -> None: + recorded.append(event) + + execute_single_native_tool_call( + tool_call, + available_functions={}, + original_tools=[], + structured_tools=[], + tools_handler=None, + agent=agent, + task=None, + crew=None, + event_source=agent, + printer=None, + verbose=False, + ) + # emit() dispatches on a thread pool; drain before asserting. + crewai_event_bus.flush(timeout=10.0) + + # The record is written synchronously, before the event is emitted. + assert len(agent.last_tool_failures) == 1 + record = agent.last_tool_failures[0] + assert record.tool_name == "does_not_exist" + assert record.failure.reason is ToolFailureReason.UNKNOWN_TOOL + assert record.failure.code == "does_not_exist" + + assert len(recorded) == 1 + assert recorded[0].failure.reason is ToolFailureReason.UNKNOWN_TOOL + + def test_unknown_tool_can_abort_under_raise(self) -> None: + from crewai.utilities.agent_utils import execute_single_native_tool_call + + agent = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.RAISE, + ) + tool_call = SimpleNamespace( + id="call_1", + function=SimpleNamespace(name="does_not_exist", arguments="{}"), + ) + + with pytest.raises(ToolExecutionFailedError): + execute_single_native_tool_call( + tool_call, + available_functions={}, + original_tools=[], + structured_tools=[], + tools_handler=None, + agent=agent, + task=None, + crew=None, + event_source=agent, + printer=None, + verbose=False, + ) + + +class TestExceptionFailuresStillRecorded: + def test_raised_tool_produces_a_failure_record(self) -> None: + class BoomTool(BaseTool): + name: str = "boom" + description: str = "Always explodes." + + def _run(self, x: str) -> Any: + raise ValueError("kaboom") + + agent = Agent( + role="Breaker", + goal="break", + backstory="b", + llm=ScriptedLLM( + [ + 'Thought: go\nAction: boom\nAction Input: {"x": "1"}', + "Thought: it broke\nFinal Answer: it broke.", + ] + ), + tools=[BoomTool()], + ) + task = Task(description="break it", expected_output="e", agent=agent) + result = Crew(agents=[agent], tasks=[task]).kickoff() + + assert result.has_tool_failures + reasons = {f.failure.reason for f in result.tool_failures} + assert ToolFailureReason.EXCEPTION in reasons + + +class TestLiteAgentOutputParity: + def test_has_tool_failures_exists_on_all_output_types(self) -> None: + from crewai.crews.crew_output import CrewOutput + from crewai.lite_agent_output import LiteAgentOutput + from crewai.tasks.task_output import TaskOutput + + record = ToolFailureRecord( + tool_name="t", failure=ToolFailure(message="nope") + ) + assert LiteAgentOutput(agent_role="r").has_tool_failures is False + assert ( + LiteAgentOutput(agent_role="r", tool_failures=[record]).has_tool_failures + is True + ) + assert TaskOutput(description="d", agent="a").has_tool_failures is False + assert CrewOutput().has_tool_failures is False + + +class TestRaisePolicySurvivesEveryWrapper: + """`raise` must abort, not get downgraded by an enclosing handler.""" + + def test_timeout_wrapper_preserves_the_error_type(self) -> None: + """max_execution_time wraps failures in RuntimeError; not this one.""" + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[SlackTool()], + tool_failure_policy=ToolFailurePolicy.RAISE, + max_execution_time=30, + ) + task = Task(description="post to slack", expected_output="c", agent=agent) + + with pytest.raises(ToolExecutionFailedError): + Crew(agents=[agent], tasks=[task]).kickoff() + + def test_retry_limit_does_not_swallow_the_abort(self) -> None: + """A deliberate stop must not be retried as a transient error.""" + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[SlackTool()], + tool_failure_policy=ToolFailurePolicy.RAISE, + max_retry_limit=3, + ) + task = Task(description="post to slack", expected_output="c", agent=agent) + + with pytest.raises(ToolExecutionFailedError): + Crew(agents=[agent], tasks=[task]).kickoff() + assert agent._times_executed == 0, "the abort must not trigger retries" + + def test_crew_policy_aborts_end_to_end(self) -> None: + """Crew scope must actually reach the executor, not just the resolver.""" + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[SlackTool()], + ) + task = Task(description="post to slack", expected_output="c", agent=agent) + crew = Crew( + agents=[agent], + tasks=[task], + tool_failure_policy=ToolFailurePolicy.RAISE, + ) + + with pytest.raises(ToolExecutionFailedError): + crew.kickoff() + + def test_crew_ignore_suppresses_recording_end_to_end(self) -> None: + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[SlackTool()], + ) + task = Task(description="post to slack", expected_output="c", agent=agent) + result = Crew( + agents=[agent], + tasks=[task], + tool_failure_policy=ToolFailurePolicy.IGNORE, + ).kickoff() + assert not result.has_tool_failures + + def test_every_broad_handler_around_tool_execution_lets_it_through(self) -> None: + """Guard against a new `except Exception` quietly downgrading an abort. + + Five separate handlers have swallowed this exception during review of + this PR, so assert the passthrough at each site rather than trusting + that the next one will be spotted. + """ + import inspect + + from crewai.agent.core import Agent as AgentCls + from crewai.agents.step_executor import StepExecutor + from crewai.experimental.agent_executor import AgentExecutor + + # CrewAgentExecutor is deprecated and deliberately excluded. + sites = [ + (AgentCls._execute_with_timeout, "_passthrough_exceptions"), + (StepExecutor.execute, "ToolExecutionFailedError"), + (AgentExecutor.execute_tool_action, "ToolExecutionFailedError"), + (AgentExecutor.execute_native_tool, "ToolExecutionFailedError"), + ] + for func, expected in sites: + source = inspect.getsource(func) + assert expected in source, f"{func.__qualname__} lost its passthrough" + + def test_passthrough_tuple_includes_the_error(self) -> None: + from crewai.agent.core import _passthrough_exceptions + + assert ToolExecutionFailedError in _passthrough_exceptions + + +class TestFailureRecordsResetAndAccumulate: + def test_kickoff_resets_between_runs(self) -> None: + """Agent.kickoff() goes through _prepare_kickoff, not task execution.""" + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[SlackTool()], + ) + + first = agent.kickoff("post it") + assert len(first.tool_failures) == 1 + assert first.has_tool_failures + + agent.llm = ScriptedLLM(_slack_steps()) + second = agent.kickoff("post it again") + assert len(second.tool_failures) == 1, "records must not accumulate" + + def test_kickoff_output_sees_failures_recorded_on_the_agent(self) -> None: + """The LiteAgent under kickoff records against the owning Agent.""" + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[SlackTool()], + ) + result = agent.kickoff("post it") + assert [f.failure.code for f in result.tool_failures] == ["channel_not_found"] + + def test_last_tool_failures_returns_a_copy(self) -> None: + agent = Agent(role="r", goal="g", backstory="b") + agent._tool_failures.append( + ToolFailureRecord(tool_name="t", failure=ToolFailure(message="nope")) + ) + snapshot = agent.last_tool_failures + snapshot.clear() + assert len(agent.last_tool_failures) == 1 + + def test_guardrail_retry_preserves_earlier_failures(self) -> None: + """A blocked attempt's failures must survive into the final output. + + The retry resets the agent's record, so without accumulation this would + report zero failures despite one demonstrably happening. + """ + attempts: list[int] = [] + + def guardrail(output: Any) -> tuple[bool, Any]: + attempts.append(1) + if len(attempts) == 1: + return (False, "needs another pass") + return (True, output.raw) + + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[SlackTool()], + ) + task = Task( + description="post to slack", + expected_output="c", + agent=agent, + guardrail=guardrail, + ) + result = Crew(agents=[agent], tasks=[task]).kickoff() + + assert len(attempts) == 2, "guardrail should have blocked once" + # The scripted LLM answers directly on the retry, so the survivor is + # the blocked first attempt's record. + assert len(result.tool_failures) == 1 + assert result.tool_failures[0].failure.code == "channel_not_found" + + +class TestIgnoreSurfacesNothing: + """`ignore` must suppress the flag on the finished event too. + + Leaving `failure` set made traces treat the call as failed and left the + console with no panel at all: green suppressed, red skipped. + """ + + def test_finished_event_carries_no_failure_under_ignore(self) -> None: + crew, _ = _build_crew(ToolFailurePolicy.IGNORE) + finished: list[ToolUsageFinishedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolUsageFinishedEvent) + def _(source: Any, event: ToolUsageFinishedEvent) -> None: + finished.append(event) + + crew.kickoff() + crewai_event_bus.flush(timeout=10.0) + + slack = [e for e in finished if e.tool_name == "slackbot_send_message"] + assert slack, "the tool call should still report as finished" + assert all(e.failure is None for e in slack) + + def test_finished_event_carries_failure_under_warn(self) -> None: + crew, _ = _build_crew(ToolFailurePolicy.WARN) + finished: list[ToolUsageFinishedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolUsageFinishedEvent) + def _(source: Any, event: ToolUsageFinishedEvent) -> None: + finished.append(event) + + crew.kickoff() + crewai_event_bus.flush(timeout=10.0) + + slack = [e for e in finished if e.tool_name == "slackbot_send_message"] + assert any(e.failure is not None for e in slack) + + def test_reportable_failure_helper(self) -> None: + from crewai.tools.tool_failure import reportable_failure + + failure = ToolFailure(message="nope") + ignoring = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.IGNORE, + ) + warning = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.WARN, + ) + assert reportable_failure(failure, agent=ignoring) is None + assert reportable_failure(failure, agent=warning) is failure + assert reportable_failure(None, agent=warning) is None + + def test_ignore_still_shows_a_console_panel(self) -> None: + """With no failure flag, the ordinary green panel is restored.""" + from crewai.events.utils.console_formatter import ConsoleFormatter + + formatter = ConsoleFormatter(verbose=True) + assert formatter.should_render_success_panel(None) is True + + +class TestFailuresAreNotCached: + """A cached failure would make a transient error permanent.""" + + def test_cache_handler_refuses_to_store_a_failure(self) -> None: + from crewai.agents.cache.cache_handler import CacheHandler + + cache = CacheHandler() + cache.add(tool="t", input="{}", output=ToolFailure(message="nope")) + assert cache.read(tool="t", input="{}") is None + + def test_cache_handler_still_stores_successes(self) -> None: + from crewai.agents.cache.cache_handler import CacheHandler + + cache = CacheHandler() + cache.add(tool="t", input="{}", output="fine") + assert cache.read(tool="t", input="{}") == "fine" + + def test_repeated_failures_are_recorded_once_each(self) -> None: + """Two failing calls give two records, not a replayed cache hit.""" + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM( + [ + 'Thought: a\nAction: slackbot_send_message\nAction Input: {"channel": "#c"}', + 'Thought: b\nAction: slackbot_send_message\nAction Input: {"channel": "#c"}', + "Thought: done\nFinal Answer: could not post.", + ] + ), + tools=[SlackTool()], + cache=True, + ) + task = Task(description="post twice", expected_output="c", agent=agent) + result = Crew(agents=[agent], tasks=[task], cache=True).kickoff() + assert len(result.tool_failures) >= 1 + assert all( + f.failure.code == "channel_not_found" for f in result.tool_failures + ) + + +class TestUsageLimitIsStructured: + """A spent max_usage_count must be a ToolFailure, not a bare string.""" + + def test_claim_usage_returns_a_failure(self) -> None: + tool = WorkingTool(max_usage_count=1) + assert tool.run(text="first") == "echoed: first" + + second = tool.run(text="second") + assert isinstance(second, ToolFailure) + assert second.reason is ToolFailureReason.USAGE_LIMIT + assert "usage limit" in second.message + + def test_spent_limit_is_recorded_on_every_path(self) -> None: + agent = Agent( + role="Echoer", + goal="echo", + backstory="b", + llm=ScriptedLLM( + [ + 'Thought: a\nAction: echo\nAction Input: {"text": "one"}', + 'Thought: b\nAction: echo\nAction Input: {"text": "two"}', + "Thought: done\nFinal Answer: done.", + ] + ), + tools=[WorkingTool(max_usage_count=1)], + ) + task = Task(description="echo twice", expected_output="c", agent=agent) + result = Crew(agents=[agent], tasks=[task]).kickoff() + + reasons = {f.failure.reason for f in result.tool_failures} + assert ToolFailureReason.USAGE_LIMIT in reasons + + +class TestGuardrailReturningTaskOutput: + def test_replacement_output_keeps_earlier_failures(self) -> None: + """A guardrail may return a whole new TaskOutput; failures must survive.""" + from crewai.tasks.task_output import TaskOutput + + attempts: list[int] = [] + + def guardrail(output: TaskOutput) -> tuple[bool, Any]: + attempts.append(1) + replacement = TaskOutput( + description=output.description, + raw="rewritten by guardrail", + agent=output.agent, + ) + return (True, replacement) + + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[SlackTool()], + ) + task = Task( + description="post to slack", + expected_output="c", + agent=agent, + guardrail=guardrail, + ) + result = Crew(agents=[agent], tasks=[task]).kickoff() + + assert attempts, "guardrail should have run" + assert result.raw == "rewritten by guardrail" + assert len(result.tool_failures) == 1 + assert result.tool_failures[0].failure.code == "channel_not_found" + + +class TestMergeToolFailures: + def test_deduplicates_equivalent_records(self) -> None: + from crewai.tools.tool_failure import merge_tool_failures + + record = ToolFailureRecord( + tool_name="t", failure=ToolFailure(message="nope", code="c") + ) + same = ToolFailureRecord( + tool_name="t", failure=ToolFailure(message="nope", code="c") + ) + other = ToolFailureRecord(tool_name="t2", failure=ToolFailure(message="nope")) + + merged = merge_tool_failures([record], [same, other]) + assert len(merged) == 2 + assert merged[0] is record + + def test_preserves_order(self) -> None: + from crewai.tools.tool_failure import merge_tool_failures + + first = ToolFailureRecord(tool_name="a", failure=ToolFailure(message="1")) + second = ToolFailureRecord(tool_name="b", failure=ToolFailure(message="2")) + assert merge_tool_failures([first], [second]) == [first, second] + + +class TestHookBlockDoesNotInheritCachedFailure: + """A blocked call must not be attributed a failure it did not produce. + + CacheHandler no longer stores failures, so this is unreachable through the + built-in cache -- the guard covers a custom cache handler that does. + """ + + def test_blocked_call_reports_no_failure(self) -> None: + from crewai.agents.tools_handler import ToolsHandler + from crewai.hooks import ( + clear_before_tool_call_hooks, + register_before_tool_call_hook, + ) + from crewai.utilities.agent_utils import execute_single_native_tool_call + + class FailureReplayingCache: + """Stands in for a custom cache that does retain failures.""" + + def read(self, tool: str, input: str) -> Any: + return ToolFailure(message="stale cached failure", code="cached") + + def add(self, tool: str, input: str, output: Any) -> None: + pass + + agent = Agent(role="r", goal="g", backstory="b") + recorded: list[ToolFailureDetectedEvent] = [] + tool = SlackTool() + structured = tool.to_structured_tool() + handler = ToolsHandler() + handler.cache = FailureReplayingCache() # type: ignore[assignment] + + tool_call = SimpleNamespace( + id="c1", + function=SimpleNamespace( + name="slackbot_send_message", arguments='{"channel": "#c"}' + ), + ) + + register_before_tool_call_hook(lambda ctx: False) + try: + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolFailureDetectedEvent) + def _(source: Any, event: ToolFailureDetectedEvent) -> None: + recorded.append(event) + + result = execute_single_native_tool_call( + tool_call, + available_functions={"slackbot_send_message": tool.run}, + original_tools=[tool], + structured_tools=[structured], + tools_handler=handler, + agent=agent, + task=None, + crew=None, + event_source=agent, + printer=None, + verbose=False, + ) + crewai_event_bus.flush(timeout=10.0) + finally: + clear_before_tool_call_hooks() + + assert "blocked by hook" in str(result.result) + assert recorded == [], "a blocked call must not report a tool failure" + assert agent.last_tool_failures == [] + + +class TestCrewScopeReachesTheFinishedEvent: + """`ToolUsage` needs the crew, or crew-level ignore only half applies.""" + + def test_crew_ignore_suppresses_the_finished_event_flag(self) -> None: + agent = Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[SlackTool()], + ) + task = Task(description="post to slack", expected_output="c", agent=agent) + crew = Crew( + agents=[agent], + tasks=[task], + tool_failure_policy=ToolFailurePolicy.IGNORE, + ) + + finished: list[ToolUsageFinishedEvent] = [] + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolUsageFinishedEvent) + def _(source: Any, event: ToolUsageFinishedEvent) -> None: + finished.append(event) + + crew.kickoff() + crewai_event_bus.flush(timeout=10.0) + + slack = [e for e in finished if e.tool_name == "slackbot_send_message"] + assert slack + assert all(e.failure is None for e in slack) + + def test_tool_usage_accepts_and_stores_crew(self) -> None: + from crewai.tools.tool_usage import ToolUsage + + agent = Agent(role="r", goal="g", backstory="b") + crew = Crew(agents=[agent], tasks=[]) + usage = ToolUsage( + tools_handler=None, + tools=[], + task=None, + function_calling_llm=None, # type: ignore[arg-type] + agent=agent, + crew=crew, + ) + assert usage.crew is crew + + +class TestFailedToolIsNotTheFinalAnswer: + """result_as_answer must not turn an error into the task's output.""" + + @staticmethod + def _agent(policy: ToolFailurePolicy) -> Agent: + class AnswerSlack(SlackTool): + result_as_answer: bool = True + + return Agent( + role="Slack Messenger", + goal="post a message", + backstory="b", + llm=ScriptedLLM(_slack_steps()), + tools=[AnswerSlack()], + tool_failure_policy=policy, + ) + + def test_failure_does_not_short_circuit_under_warn(self) -> None: + agent = self._agent(ToolFailurePolicy.WARN) + task = Task(description="post to slack", expected_output="c", agent=agent) + result = Crew(agents=[agent], tasks=[task]).kickoff() + + assert "Slack rejected the message" not in result.raw + assert result.raw == "I could not post the message." + assert result.has_tool_failures + + def test_successful_result_as_answer_still_short_circuits(self) -> None: + class AnswerEcho(WorkingTool): + result_as_answer: bool = True + + agent = Agent( + role="Echoer", + goal="echo", + backstory="b", + llm=ScriptedLLM( + [ + 'Thought: go\nAction: echo\nAction Input: {"text": "hi"}', + "Thought: done\nFinal Answer: unused.", + ] + ), + tools=[AnswerEcho()], + ) + task = Task(description="echo", expected_output="c", agent=agent) + result = Crew(agents=[agent], tasks=[task]).kickoff() + + assert result.raw == "echoed: hi" + assert not result.has_tool_failures + + +class TestEventCarriesCorrelationIds: + """The failure event must be correlatable with the call it describes.""" + + def test_agent_and_task_ids_are_populated(self) -> None: + crew, agent = _build_crew(ToolFailurePolicy.WARN) + events: list[ToolFailureDetectedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolFailureDetectedEvent) + def _(source: Any, event: ToolFailureDetectedEvent) -> None: + events.append(event) + + crew.kickoff() + crewai_event_bus.flush(timeout=10.0) + + assert events + event = events[0] + assert event.agent_id == str(agent.id) + assert event.agent_role == agent.role + assert event.task_id is not None + assert event.task_name == "post to slack" + + def test_ids_match_the_paired_finished_event(self) -> None: + crew, _ = _build_crew(ToolFailurePolicy.WARN) + failures: list[ToolFailureDetectedEvent] = [] + finished: list[ToolUsageFinishedEvent] = [] + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolFailureDetectedEvent) + def _f(source: Any, event: ToolFailureDetectedEvent) -> None: + failures.append(event) + + @crewai_event_bus.on(ToolUsageFinishedEvent) + def _d(source: Any, event: ToolUsageFinishedEvent) -> None: + if event.tool_name == "slackbot_send_message": + finished.append(event) + + crew.kickoff() + crewai_event_bus.flush(timeout=10.0) + + assert failures and finished + assert failures[0].agent_id == finished[0].agent_id + assert failures[0].task_id == finished[0].task_id + + +class TestMalformedArgumentsAreReported: + """A tool call with unparseable JSON args is a failure, not a silent skip.""" + + @staticmethod + def _parse_error() -> dict[str, Any]: + from crewai.utilities.agent_utils import parse_tool_call_args + + args, error = parse_tool_call_args("{not json", "echo", "call_1") + assert args is None + assert error is not None + return error + + def test_parse_error_carries_an_invalid_input_failure(self) -> None: + error = self._parse_error() + failure = error["tool_failure"] + assert isinstance(failure, ToolFailure) + assert failure.reason is ToolFailureReason.INVALID_INPUT + assert failure.code == "json_decode_error" + + def test_valid_args_carry_no_failure(self) -> None: + from crewai.utilities.agent_utils import parse_tool_call_args + + args, error = parse_tool_call_args('{"text": "hi"}', "echo", "call_1") + assert args == {"text": "hi"} + assert error is None + + def test_reason_enum_member_is_used(self) -> None: + """INVALID_INPUT was declared but unreferenced before this.""" + import inspect + + from crewai.utilities import agent_utils + + assert "INVALID_INPUT" in inspect.getsource(agent_utils.parse_tool_call_args) + + +class TestDeprecatedExecutorIsNotIntegrated: + """CrewAgentExecutor is deprecated; the feature must not extend into it.""" + + def test_no_tool_failure_integration(self) -> None: + from importlib import import_module + from pathlib import Path + + # Read the file directly: importing this module by name resolves to a + # different one in this package, so inspect would read the wrong source. + package = import_module(Agent.__module__.split(".")[0]) + source = ( + Path(package.__file__).parent / "agents" / "crew_agent_executor.py" + ).read_text() + assert "tool_failure" not in source + assert "ToolExecutionFailedError" not in source + + +class TestConcurrentExecutionsAreIsolated: + """A shared agent must not leak failures between concurrent executions. + + Accumulating on the agent let one execution reset another's list and both + outputs end up with both records. + """ + + @staticmethod + def _tool(channel_code: str) -> BaseTool: + class NamedSlack(BaseTool): + name: str = f"slack_{channel_code}" + description: str = "Post a message." + + def _run(self, text: str) -> Any: + return ToolFailure(message=f"failed {channel_code}", code=channel_code) + + return NamedSlack() + + def _agent_and_task(self, code: str) -> tuple[Agent, Task]: + agent = Agent( + role=f"Poster {code}", + goal="post", + backstory="b", + llm=ScriptedLLM( + [ + f'Thought: go\nAction: slack_{code}\nAction Input: {{"text": "x"}}', + "Thought: done\nFinal Answer: could not post.", + ] + ), + tools=[self._tool(code)], + ) + task = Task( + description=f"post {code}", expected_output="c", agent=agent + ) + return agent, task + + def test_threads_do_not_cross_contaminate(self) -> None: + import concurrent.futures + + crews = [] + for code in ("aaa", "bbb", "ccc"): + agent, task = self._agent_and_task(code) + crews.append((code, Crew(agents=[agent], tasks=[task]))) + + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool: + futures = { + pool.submit(crew.kickoff): code for code, crew in crews + } + results = { + futures[f]: f.result() + for f in concurrent.futures.as_completed(futures) + } + + for code, result in results.items(): + codes = [f.failure.code for f in result.tool_failures] + assert codes == [code], f"{code} saw {codes}" + + def test_concurrent_kickoffs_on_a_shared_agent(self) -> None: + """The reported repro, made deterministic with a barrier. + + Both kickoffs are held inside their tool call at the same time, so the + old agent-level accumulation had each reset the other's list and both + outputs came back holding two records instead of one. + + Crew tasks cannot hit this -- AgentExecutor refuses concurrent reuse of + one instance -- but ``agent.kickoff()`` has no such guard. + """ + import concurrent.futures + import threading + + barrier = threading.Barrier(2, timeout=30) + + class BlockingFailingTool(BaseTool): + name: str = "poster" + description: str = "Post a message." + + def _run(self, channel: str) -> Any: + barrier.wait() + # Phrasing the LLM stub recognises as "tool already ran". + return ToolFailure(message=f"TOOLRAN {channel}", code=channel) + + agent = Agent( + role="Poster", + goal="post", + backstory="b", + llm=StatelessToolLLM("poster", {"channel": "c1"}, "TOOLRAN"), + tools=[BlockingFailingTool()], + ) + + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(agent.kickoff, f"do {n}") for n in ("A", "B")] + outputs = [f.result() for f in futures] + + counts = [len(o.tool_failures) for o in outputs] + assert counts == [1, 1], f"each kickoff should hold only its own: {counts}" + + def test_shared_agent_across_sequential_tasks(self) -> None: + """One agent, two tasks: each output carries only its own record.""" + agent = Agent( + role="Poster", + goal="post", + backstory="b", + llm=ScriptedLLM(_slack_steps() * 4), + tools=[SlackTool()], + ) + task_a = Task(description="post A", expected_output="c", agent=agent) + task_b = Task(description="post B", expected_output="c", agent=agent) + result = Crew(agents=[agent], tasks=[task_a, task_b]).kickoff() + + for task_output in result.tasks_output: + assert len(task_output.tool_failures) == 1, ( + f"{task_output.name} carried {len(task_output.tool_failures)}" + ) + assert len(result.tool_failures) == 2 + + def test_collector_is_execution_scoped(self) -> None: + from crewai.tools.tool_failure import ( + active_tool_failures, + tool_failure_collector, + ) + + assert active_tool_failures() is None + with tool_failure_collector() as outer: + assert active_tool_failures() is outer + with tool_failure_collector() as inner: + assert active_tool_failures() is inner + assert inner is not outer + assert active_tool_failures() is outer + assert active_tool_failures() is None + + @pytest.mark.asyncio + async def test_async_tasks_do_not_cross_contaminate(self) -> None: + import asyncio + + crews = [] + for code in ("ddd", "eee"): + agent, task = self._agent_and_task(code) + crews.append((code, Crew(agents=[agent], tasks=[task]))) + + outputs = await asyncio.gather( + *(crew.kickoff_async() for _, crew in crews) + ) + + for (code, _), result in zip(crews, outputs, strict=True): + codes = [f.failure.code for f in result.tool_failures] + assert codes == [code], f"{code} saw {codes}" + + +class TestMalformedArgsOnEveryPath: + """A malformed tool call is reported the same way everywhere.""" + + def test_shared_native_helper_reports_instead_of_emptying_args(self) -> None: + """It used to swallow the decode error and run the tool with no input.""" + from crewai.utilities.agent_utils import execute_single_native_tool_call + + agent = Agent(role="r", goal="g", backstory="b") + tool = WorkingTool() + recorded: list[ToolFailureDetectedEvent] = [] + calls: list[str] = [] + + def tracked(**kwargs: Any) -> str: + calls.append("ran") + return "should not happen" + + tool_call = SimpleNamespace( + id="c1", function=SimpleNamespace(name="echo", arguments="{not json") + ) + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolFailureDetectedEvent) + def _(source: Any, event: ToolFailureDetectedEvent) -> None: + recorded.append(event) + + result = execute_single_native_tool_call( + tool_call, + available_functions={"echo": tracked}, + original_tools=[tool], + structured_tools=[tool.to_structured_tool()], + tools_handler=None, + agent=agent, + task=None, + crew=None, + event_source=agent, + printer=None, + verbose=False, + ) + crewai_event_bus.flush(timeout=10.0) + + assert calls == [], "the tool must not run with silently emptied args" + assert "Failed to parse tool arguments" in str(result.result) + assert len(recorded) == 1 + assert recorded[0].failure.reason is ToolFailureReason.INVALID_INPUT + + def test_react_path_reports_a_malformed_call(self) -> None: + from crewai.agents.parser import AgentAction + from crewai.utilities.tool_utils import execute_tool_and_check_finality + + agent = Agent(role="r", goal="g", backstory="b") + tool = WorkingTool().to_structured_tool() + recorded: list[ToolFailureDetectedEvent] = [] + + action = AgentAction( + thought="t", + tool="echo", + tool_input="{not a dict", + text="Action: echo\nAction Input: {not a dict", + ) + + with crewai_event_bus.scoped_handlers(): + + @crewai_event_bus.on(ToolFailureDetectedEvent) + def _(source: Any, event: ToolFailureDetectedEvent) -> None: + recorded.append(event) + + execute_tool_and_check_finality( + agent_action=action, + tools=[tool], + agent=agent, + task=None, + crew=None, + ) + crewai_event_bus.flush(timeout=10.0) + + assert len(recorded) == 1 + assert recorded[0].failure.reason is ToolFailureReason.INVALID_INPUT + + def test_malformed_call_aborts_under_raise(self) -> None: + from crewai.utilities.agent_utils import execute_single_native_tool_call + + agent = Agent( + role="r", + goal="g", + backstory="b", + tool_failure_policy=ToolFailurePolicy.RAISE, + ) + tool = WorkingTool() + tool_call = SimpleNamespace( + id="c1", function=SimpleNamespace(name="echo", arguments="{not json") + ) + + with pytest.raises(ToolExecutionFailedError): + execute_single_native_tool_call( + tool_call, + available_functions={"echo": tool.run}, + original_tools=[tool], + structured_tools=[tool.to_structured_tool()], + tools_handler=None, + agent=agent, + task=None, + crew=None, + event_source=agent, + printer=None, + verbose=False, + ) + + +class TestBlockedCallsAreNotFailures: + """A hook block is a deliberate decision, so it is not reported as one.""" + + def test_no_blocked_by_hook_reason_exists(self) -> None: + assert not hasattr(ToolFailureReason, "BLOCKED_BY_HOOK") + + def test_every_reason_is_actually_produced(self) -> None: + """Guard against another declared-but-unused reason.""" + from pathlib import Path + + from importlib import import_module + + package = Path(import_module(Agent.__module__.split(".")[0]).__file__).parent + sources = "\n".join( + path.read_text() + for path in package.rglob("*.py") + if "tool_failure.py" not in path.name + ) + # TOOL_REPORTED is the field default, so it is produced without ever + # being named; every other member has to be referenced somewhere. + for member in list(ToolFailureReason): + if member is ToolFailureReason.TOOL_REPORTED: + continue + assert f"ToolFailureReason.{member.name}" in sources, ( + f"{member.name} is declared but never produced" + ) + + +class TestKickoffGuardrailRetries: + def test_blocked_attempt_failures_survive_the_retry(self) -> None: + """The retry opens its own collector, so earlier records must be merged.""" + attempts: list[int] = [] + + def guardrail(output: Any) -> tuple[bool, Any]: + attempts.append(1) + if len(attempts) == 1: + return (False, "try again") + return (True, output.raw) + + agent = Agent( + role="Slack Messenger", + goal="post", + backstory="b", + llm=StatelessToolLLM("slackbot_send_message", {"channel": "#c"}), + tools=[SlackTool()], + guardrail=guardrail, + ) + result = agent.kickoff("post it") + + assert len(attempts) == 2, "guardrail should have blocked once" + assert result.has_tool_failures + codes = [f.failure.code for f in result.tool_failures] + assert codes and all(c == "channel_not_found" for c in codes), codes + + +class TestParallelAbortCancelsPendingSiblings: + def test_pool_is_shut_down_with_cancel_futures(self) -> None: + """A pending sibling must never start once an abort is requested. + + In-flight threads cannot be interrupted in Python, so this covers the + not-yet-started ones -- the only ones that can still be prevented. + """ + import inspect + + from crewai.experimental.agent_executor import AgentExecutor + + source = inspect.getsource(AgentExecutor.execute_native_tool) + assert "cancel_futures=True" in source + + +class TestKickoffResetsTheAccessor: + def test_last_tool_failures_does_not_grow_across_kickoffs(self) -> None: + agent = Agent( + role="Slack Messenger", + goal="post", + backstory="b", + llm=StatelessToolLLM("slackbot_send_message", {"channel": "#c"}), + tools=[SlackTool()], + ) + + agent.kickoff("post once") + assert len(agent.last_tool_failures) == 1 + + agent.kickoff("post again") + assert len(agent.last_tool_failures) == 1, "records must not accumulate" + + +class TestMCPIsErrorPlumbing: + """An MCP server flags a failed tool with isError on a 200 response.""" + + @staticmethod + def _tool(is_error: bool) -> Any: + from unittest.mock import AsyncMock + + from crewai.mcp.client import _MCPToolResult + from crewai.tools.mcp_native_tool import MCPNativeTool + + client = AsyncMock() + client.connect = AsyncMock() + client.disconnect = AsyncMock() + client.call_tool_result = AsyncMock( + return_value=_MCPToolResult("channel not found", is_error) + ) + return MCPNativeTool( + client_factory=lambda: client, + tool_name="post", + tool_schema={"description": "post a message"}, + server_name="slack", + ) + + def test_is_error_becomes_a_tool_failure(self) -> None: + result = self._tool(is_error=True).run() + assert isinstance(result, ToolFailure) + assert result.reason is ToolFailureReason.MCP_ERROR + assert result.message == "channel not found" + assert result.details["server"] == "slack" + + def test_successful_call_still_returns_plain_text(self) -> None: + assert self._tool(is_error=False).run() == "channel not found" + + +class TestPlatformActionTool: + """CrewAI AMP agentic-app actions -- the Slack case from the bug report.""" + + @staticmethod + def _tool() -> Any: + import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod + + return mod.CrewAIPlatformActionTool( + description="Send a Slack message", + action_name="slackbot_send_message", + action_schema={ + "function": { + "name": "slackbot_send_message", + "parameters": { + "properties": {"channel": {"type": "string"}}, + "required": [], + }, + } + }, + ) + + def test_non_ok_response_becomes_a_tool_failure(self, monkeypatch) -> None: # noqa: ANN001 + from unittest.mock import Mock + + import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod + + response = Mock() + response.ok = False + response.status_code = 500 + response.json.return_value = { + "error": "Failed to execute action: Slack API error: channel_not_found" + } + monkeypatch.setattr(mod.requests, "post", Mock(return_value=response)) + monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t") + + result = self._tool()._run(channel="#joao-message") + + assert isinstance(result, ToolFailure) + assert "channel_not_found" in result.message + assert result.retryable is True + + def test_ok_response_still_returns_json(self, monkeypatch) -> None: # noqa: ANN001 + from unittest.mock import Mock + + import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod + + response = Mock() + response.ok = True + response.json.return_value = {"ts": "1234.5678"} + monkeypatch.setattr(mod.requests, "post", Mock(return_value=response)) + monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t") + + result = self._tool()._run(channel="#general") + + assert not isinstance(result, ToolFailure) + assert "1234.5678" in result + + +class TestSuccessfulToolsUnaffected: + def test_no_failure_recorded_for_a_working_tool(self) -> None: + agent = Agent( + role="Echoer", + goal="echo", + backstory="b", + llm=ScriptedLLM( + [ + 'Thought: echo\nAction: echo\nAction Input: {"text": "hi"}', + "Thought: done\nFinal Answer: echoed: hi", + ] + ), + tools=[WorkingTool()], + ) + task = Task(description="echo hi", expected_output="hi", agent=agent) + result = Crew(agents=[agent], tasks=[task]).kickoff() + + assert not result.has_tool_failures + assert result.tool_failures == [] + + def test_failures_reset_between_executions(self) -> None: + crew, agent = _build_crew(ToolFailurePolicy.WARN) + crew.kickoff() + assert len(agent.last_tool_failures) == 1 + + agent.llm = ScriptedLLM(_slack_steps()) + crew.kickoff() + assert len(agent.last_tool_failures) == 1, "records must not accumulate" diff --git a/lib/crewai/tests/utilities/test_agent_utils.py b/lib/crewai/tests/utilities/test_agent_utils.py index 0910fb86e..755befdbe 100644 --- a/lib/crewai/tests/utilities/test_agent_utils.py +++ b/lib/crewai/tests/utilities/test_agent_utils.py @@ -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: