diff --git a/docs/edge/en/concepts/tools.mdx b/docs/edge/en/concepts/tools.mdx index eb2f7426e..e78c4aa44 100644 --- a/docs/edge/en/concepts/tools.mdx +++ b/docs/edge/en/concepts/tools.mdx @@ -388,7 +388,7 @@ tool's `max_usage_count` is spent, or when the agent calls a tool that does not | `raise` | Records and emits, then aborts with `ToolExecutionFailedError`. | ```python Code -from crewai import Agent, Task +from crewai import Agent, Crew, Task from crewai.tools.tool_failure import ToolFailurePolicy agent = Agent( @@ -406,10 +406,18 @@ task = Task( 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, then task, then agent, then crew, then the -`warn` default. +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 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 48c4c61ad..fcf81a053 100644 --- a/lib/crewai/src/crewai/agents/agent_builder/base_agent.py +++ b/lib/crewai/src/crewai/agents/agent_builder/base_agent.py @@ -300,8 +300,8 @@ 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 = Field( - default=ToolFailurePolicy.WARN, + tool_failure_policy: ToolFailurePolicy | None = Field( + default=None, description=( "How to react when a tool runs to completion but reports that it " "failed (an upstream API rejecting the request, an MCP server " @@ -309,7 +309,8 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta): "'ignore' restores pre-1.16 behavior and records nothing; 'warn' " "records the failure, emits ToolFailureDetectedEvent and keeps " "going; 'raise' additionally aborts with ToolExecutionFailedError. " - "A Task or a tool may override this for a narrower scope." + "None inherits from the crew, falling back to 'warn'. A task or a " + "tool may override this for a narrower scope." ), ) agent_executor: Annotated[ diff --git a/lib/crewai/src/crewai/agents/step_executor.py b/lib/crewai/src/crewai/agents/step_executor.py index bf536a878..dbde62959 100644 --- a/lib/crewai/src/crewai/agents/step_executor.py +++ b/lib/crewai/src/crewai/agents/step_executor.py @@ -224,6 +224,12 @@ class StepExecutor: tool_calls_made=tool_calls_made, execution_time=elapsed, ) + except ToolExecutionFailedError: + # Same reason as the outer handler: a deliberate stop must + # not be downgraded into StepResult(success=False), even + # when reached through 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..e0120434e 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,15 @@ class Crew(FlowTrackable, BaseModel): "unless they set a cache_function that prevents caching." ), ) + tool_failure_policy: ToolFailurePolicy | None = Field( + default=None, + description=( + "Baseline reaction for every agent in this crew when a tool runs " + "to completion but reports that it failed. Leave None for the " + "'warn' default. An agent, task, or tool may override it for a " + "narrower scope." + ), + ) tasks: list[Task] = Field(default_factory=list) agents: Annotated[ list[BaseAgent], diff --git a/lib/crewai/src/crewai/lite_agent.py b/lib/crewai/src/crewai/lite_agent.py index bc9f63d63..962c7dc40 100644 --- a/lib/crewai/src/crewai/lite_agent.py +++ b/lib/crewai/src/crewai/lite_agent.py @@ -228,11 +228,12 @@ class LiteAgent(FlowTrackable, BaseModel): max_iterations: int = Field( default=15, description="Maximum number of iterations for tool usage" ) - tool_failure_policy: ToolFailurePolicy = Field( - default=ToolFailurePolicy.WARN, + tool_failure_policy: ToolFailurePolicy | None = Field( + default=None, description=( "How to react when a tool runs to completion but reports that it " - "failed. See BaseAgent.tool_failure_policy." + "failed. None falls back to 'warn'. See " + "BaseAgent.tool_failure_policy." ), ) max_execution_time: int | None = Field( diff --git a/lib/crewai/tests/tools/test_tool_failure.py b/lib/crewai/tests/tools/test_tool_failure.py index 1b4503350..90303af64 100644 --- a/lib/crewai/tests/tools/test_tool_failure.py +++ b/lib/crewai/tests/tools/test_tool_failure.py @@ -173,6 +173,62 @@ class TestPolicyResolution: 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.""" @@ -208,14 +264,28 @@ class TestPolicyResolution: assert resolved is ToolFailurePolicy.RAISE -class TestAgentDefault: - def test_agent_defaults_to_warn(self) -> None: - agent = Agent(role="r", goal="g", backstory="b") - assert agent.tool_failure_policy is ToolFailurePolicy.WARN +class TestDefaults: + """Every scope defaults to None ('inherit'); the resolver owns 'warn'.""" - def test_task_policy_defaults_to_none_so_it_inherits(self) -> None: + 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: @@ -598,6 +668,41 @@ class TestRaisePolicySurvivesEveryWrapper: 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_passthrough_tuple_includes_the_error(self) -> None: from crewai.agent.core import _passthrough_exceptions