diff --git a/lib/crewai/src/crewai/agents/crew_agent_executor.py b/lib/crewai/src/crewai/agents/crew_agent_executor.py index 52998aacf..1273a4854 100644 --- a/lib/crewai/src/crewai/agents/crew_agent_executor.py +++ b/lib/crewai/src/crewai/agents/crew_agent_executor.py @@ -1030,6 +1030,16 @@ class CrewAgentExecutor(BaseAgentExecutor): ), ) error_event_emitted = True + elif not from_cache: + # Not cached and not executable: the model asked for a tool that + # does not exist. The ReAct path reports this as a failure, so the + # native paths must too, or the same miss is silent on one and + # loud on the other. + tool_failure = ToolFailure( + message=result, + reason=ToolFailureReason.UNKNOWN_TOOL, + code=func_name, + ) after_hook_context = ToolCallHookContext( tool_name=func_name, diff --git a/lib/crewai/src/crewai/events/event_listener.py b/lib/crewai/src/crewai/events/event_listener.py index a1258235d..13c330bfd 100644 --- a/lib/crewai/src/crewai/events/event_listener.py +++ b/lib/crewai/src/crewai/events/event_listener.py @@ -415,6 +415,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, @@ -444,6 +446,8 @@ class EventListener(BaseEventListener): 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, diff --git a/lib/crewai/src/crewai/events/utils/console_formatter.py b/lib/crewai/src/crewai/events/utils/console_formatter.py index dd5891207..849521846 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,26 @@ 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 call that reported failure must not read as successful, so the + green panel is suppressed and the red one takes its place. + """ + 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 produced a ``ToolUsageErrorEvent`` and + its own red panel, so printing a second one for the same exception is + pure noise. The event itself is still emitted -- only the duplicate + console output is skipped. + """ + return getattr(failure, "reason", None) is not ToolFailureReason.EXCEPTION + def handle_tool_failure_detected( 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 2cece31c9..196da2fcd 100644 --- a/lib/crewai/src/crewai/experimental/agent_executor.py +++ b/lib/crewai/src/crewai/experimental/agent_executor.py @@ -2046,6 +2046,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: @@ -2056,6 +2058,8 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): 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( @@ -2109,6 +2113,19 @@ class AgentExecutor(Flow[AgentExecutorState], BaseAgentExecutor): "original_tool": original_tool, } + @staticmethod + def _unknown_tool_failure(func_name: str, result: str) -> ToolFailure: + """Build the failure for a tool the model asked for but we do not have. + + The ReAct path reports this as a failure, so the native path must too, + or the same miss is silent on one and loud on the other. + """ + 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/lite_agent_output.py b/lib/crewai/src/crewai/lite_agent_output.py index d0dbf7b5a..09ff00f42 100644 --- a/lib/crewai/src/crewai/lite_agent_output.py +++ b/lib/crewai/src/crewai/lite_agent_output.py @@ -59,6 +59,15 @@ class LiteAgentOutput(BaseModel): ), ) + @property + def has_tool_failures(self) -> bool: + """Whether any tool reported a failure while producing this output. + + Same name and meaning as on ``TaskOutput`` and ``CrewOutput``, so a + check written for one result type works on all three. + """ + 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/tools/base_tool.py b/lib/crewai/src/crewai/tools/base_tool.py index eb941bc26..44f8d69fe 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 ToolFailurePolicy from crewai.types.callback import SerializableCallable, _resolve_dotted_path from crewai.utilities.string_utils import sanitize_tool_name @@ -184,6 +185,14 @@ 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. Leave None to inherit. Use to tighten a single destructive " + "tool to 'raise', or to exempt a chatty one with 'ignore'." + ), + ) current_usage_count: int = Field( default=0, description="Current number of times this tool has been used.", @@ -402,6 +411,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/structured_tool.py b/lib/crewai/src/crewai/tools/structured_tool.py index 6e2e0debc..6f741b040 100644 --- a/lib/crewai/src/crewai/tools/structured_tool.py +++ b/lib/crewai/src/crewai/tools/structured_tool.py @@ -21,7 +21,7 @@ from pydantic import ( ) from typing_extensions import Self -from crewai.tools.tool_failure import ToolFailure +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, @@ -212,6 +212,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 index 99eca4b88..02e68920b 100644 --- a/lib/crewai/src/crewai/tools/tool_failure.py +++ b/lib/crewai/src/crewai/tools/tool_failure.py @@ -204,8 +204,15 @@ def resolve_tool_failure_policy( Most specific wins: tool, then task, then agent, then crew, then :attr:`ToolFailurePolicy.WARN`. + + Execution paths hand over either a :class:`~crewai.tools.base_tool.BaseTool` + or the ``CrewStructuredTool`` that wraps it, so the tool scope is read + through the wrapper as well -- otherwise a tool-scoped policy would be + silently ignored on every native function-calling path. """ - for source in (tool, task, agent, crew): + 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) diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py index 40b9b4f93..ecddcc41c 100644 --- a/lib/crewai/src/crewai/utilities/agent_utils.py +++ b/lib/crewai/src/crewai/utilities/agent_utils.py @@ -33,6 +33,7 @@ from crewai.tools.structured_tool import ( ) from crewai.tools.tool_failure import ( ToolFailure, + ToolFailureReason, detect_tool_failure, failure_from_exception, handle_tool_failure, @@ -1717,6 +1718,16 @@ def execute_single_native_tool_call( ), ) error_event_emitted = True + else: + # Not cached and not executable: the model asked for a tool that + # does not exist. The ReAct path reports this as a failure, so the + # native paths must too, or the same miss is silent on one and + # loud on the other. + tool_failure = ToolFailure( + message=result, + reason=ToolFailureReason.UNKNOWN_TOOL, + code=func_name, + ) after_hook_context = ToolCallHookContext( tool_name=func_name, diff --git a/lib/crewai/tests/tools/test_tool_failure.py b/lib/crewai/tests/tools/test_tool_failure.py index da0a703f3..98eefa722 100644 --- a/lib/crewai/tests/tools/test_tool_failure.py +++ b/lib/crewai/tests/tools/test_tool_failure.py @@ -1,5 +1,7 @@ """Tests for structured tool-failure signalling and the per-agent policy.""" +from datetime import datetime +from types import SimpleNamespace from typing import Any import pytest @@ -66,10 +68,13 @@ class ScriptedLLM(LLM): def _slack_steps() -> list[str]: - return [ + call_step = ( "Thought: posting\n" - "Action: slackbot_send_message\n" - 'Action Input: {"channel": "#joao-message"}', + + "Action: slackbot_send_message\n" + + 'Action Input: {"channel": "#joao-message"}' + ) + return [ + call_step, "Thought: it failed\nFinal Answer: I could not post the message.", ] @@ -320,6 +325,243 @@ class TestEndToEndPolicies: assert tool_messages +class TestToolScopedPolicyReachesTheExecutor: + """A tool-scoped policy must survive the CrewStructuredTool wrapper. + + The executors hand ``handle_tool_failure`` the wrapper, not the authored + BaseTool, so a policy set on the tool 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 "Tool Execution Completed" panel -- + the terminal equivalent of the green checkmarks in the bug report. + """ + + @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 sync handlers on a thread pool, so drain + # before asserting on what subscribers saw. + 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 TestMCPIsErrorPlumbing: """An MCP server flags a failed tool with isError on a 200 response.""" @@ -359,11 +601,9 @@ class TestPlatformActionTool: @staticmethod def _tool() -> Any: - from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import ( # noqa: E501 - CrewAIPlatformActionTool, - ) + import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod - return CrewAIPlatformActionTool( + return mod.CrewAIPlatformActionTool( description="Send a Slack message", action_name="slackbot_send_message", action_schema={