Skip to content

Commit 217673e

Browse files
joaomdmouraclaude
andcommitted
fix(tools): make crew-scoped policy real and close the last raise leak
Two findings, and the first was a documented feature that never worked. `resolve_tool_failure_policy` consulted a crew, and the docs advertised crew as a scope, but `Crew` had no `tool_failure_policy` field at all -- and even with one it was unreachable, because `BaseAgent` defaulted the policy to `WARN` rather than `None`, so resolution always stopped at the agent. Crew-level configuration was silently ignored. Fixed by making "inherit" the default everywhere instead of baking `warn` into one layer: `Crew` gains the field, and `BaseAgent`/`LiteAgent` default to `None` like `Task` and `BaseTool` already did. The resolver owns the single fallback, so the chain is genuinely tool > task > agent > crew > warn and the effective default with nothing configured is still `warn`. Reading `agent.tool_failure_policy` now returns `None` (meaning "inherit") rather than `WARN`. The other: `StepExecutor` re-raised `ToolExecutionFailedError` from its outer handler, but the nested handler around the native-to-text tooling fallback still caught it and returned `StepResult(success=False)`. An agent whose LLM lacked native tool calling would therefore not abort under `raise`. That is the third distinct place this exception was being downgraded; it now re-raises there too. Testing: 8 further tests, 60 total, including the full precedence chain walked one level at a time and crew-scoped `raise`/`ignore` driven end-to-end through `kickoff()` rather than only through the resolver -- the gap that let the original crew bug pass review. Full suite matches baseline exactly at 377 pre-existing failures; mypy clean on every changed file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ETacm2dMASfpMAYUiDu5YG
1 parent f7e76a8 commit 217673e

6 files changed

Lines changed: 145 additions & 14 deletions

File tree

docs/edge/en/concepts/tools.mdx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ tool's `max_usage_count` is spent, or when the agent calls a tool that does not
388388
| `raise` | Records and emits, then aborts with `ToolExecutionFailedError`. |
389389

390390
```python Code
391-
from crewai import Agent, Task
391+
from crewai import Agent, Crew, Task
392392
from crewai.tools.tool_failure import ToolFailurePolicy
393393

394394
agent = Agent(
@@ -406,10 +406,18 @@ task = Task(
406406
agent=agent,
407407
tool_failure_policy=ToolFailurePolicy.RAISE,
408408
)
409+
410+
# Or set a baseline once for every agent in the crew.
411+
crew = Crew(
412+
agents=[agent],
413+
tasks=[task],
414+
tool_failure_policy=ToolFailurePolicy.WARN,
415+
)
409416
```
410417

411-
The most specific setting wins: tool, then task, then agent, then crew, then the
412-
`warn` default.
418+
The most specific setting wins: **tool → task → agent → crew → `warn`**. Every
419+
level defaults to `None`, meaning "inherit from the next one out", so the
420+
effective default with nothing configured anywhere is `warn`.
413421

414422
### Inspecting Failures
415423

lib/crewai/src/crewai/agents/agent_builder/base_agent.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,16 +300,17 @@ class BaseAgent(BaseModel, ABC, metaclass=AgentMeta):
300300
max_iter: int = Field(
301301
default=25, description="Maximum iterations for an agent to execute a task"
302302
)
303-
tool_failure_policy: ToolFailurePolicy = Field(
304-
default=ToolFailurePolicy.WARN,
303+
tool_failure_policy: ToolFailurePolicy | None = Field(
304+
default=None,
305305
description=(
306306
"How to react when a tool runs to completion but reports that it "
307307
"failed (an upstream API rejecting the request, an MCP server "
308308
"setting isError, a platform action returning an error payload). "
309309
"'ignore' restores pre-1.16 behavior and records nothing; 'warn' "
310310
"records the failure, emits ToolFailureDetectedEvent and keeps "
311311
"going; 'raise' additionally aborts with ToolExecutionFailedError. "
312-
"A Task or a tool may override this for a narrower scope."
312+
"None inherits from the crew, falling back to 'warn'. A task or a "
313+
"tool may override this for a narrower scope."
313314
),
314315
)
315316
agent_executor: Annotated[

lib/crewai/src/crewai/agents/step_executor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ def execute(
224224
tool_calls_made=tool_calls_made,
225225
execution_time=elapsed,
226226
)
227+
except ToolExecutionFailedError:
228+
# Same reason as the outer handler: a deliberate stop must
229+
# not be downgraded into StepResult(success=False), even
230+
# when reached through the text-tooling fallback.
231+
raise
232+
227233
except Exception as fallback_error:
228234
e = fallback_error
229235

lib/crewai/src/crewai/crew.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def get_supported_content_types(provider: str, api: str | None = None) -> list[s
116116
from crewai.tools.agent_tools.agent_tools import AgentTools
117117
from crewai.tools.agent_tools.read_file_tool import ReadFileTool
118118
from crewai.tools.base_tool import BaseTool
119+
from crewai.tools.tool_failure import ToolFailurePolicy
119120
from crewai.types.callback import SerializableCallable
120121
from crewai.types.streaming import CrewStreamingOutput
121122
from crewai.types.usage_metrics import UsageMetrics
@@ -231,6 +232,15 @@ class Crew(FlowTrackable, BaseModel):
231232
"unless they set a cache_function that prevents caching."
232233
),
233234
)
235+
tool_failure_policy: ToolFailurePolicy | None = Field(
236+
default=None,
237+
description=(
238+
"Baseline reaction for every agent in this crew when a tool runs "
239+
"to completion but reports that it failed. Leave None for the "
240+
"'warn' default. An agent, task, or tool may override it for a "
241+
"narrower scope."
242+
),
243+
)
234244
tasks: list[Task] = Field(default_factory=list)
235245
agents: Annotated[
236246
list[BaseAgent],

lib/crewai/src/crewai/lite_agent.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,12 @@ class LiteAgent(FlowTrackable, BaseModel):
228228
max_iterations: int = Field(
229229
default=15, description="Maximum number of iterations for tool usage"
230230
)
231-
tool_failure_policy: ToolFailurePolicy = Field(
232-
default=ToolFailurePolicy.WARN,
231+
tool_failure_policy: ToolFailurePolicy | None = Field(
232+
default=None,
233233
description=(
234234
"How to react when a tool runs to completion but reports that it "
235-
"failed. See BaseAgent.tool_failure_policy."
235+
"failed. None falls back to 'warn'. See "
236+
"BaseAgent.tool_failure_policy."
236237
),
237238
)
238239
max_execution_time: int | None = Field(

lib/crewai/tests/tools/test_tool_failure.py

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,62 @@ def test_unset_task_policy_falls_through_to_agent(self) -> None:
173173
resolved = resolve_tool_failure_policy(agent=agent, task=task)
174174
assert resolved is ToolFailurePolicy.IGNORE
175175

176+
def test_crew_policy_used_when_agent_inherits(self) -> None:
177+
from crewai import Crew
178+
179+
agent = Agent(role="r", goal="g", backstory="b")
180+
crew = Crew(
181+
agents=[agent], tasks=[], tool_failure_policy=ToolFailurePolicy.RAISE
182+
)
183+
resolved = resolve_tool_failure_policy(agent=agent, crew=crew)
184+
assert resolved is ToolFailurePolicy.RAISE
185+
186+
def test_agent_overrides_crew(self) -> None:
187+
from crewai import Crew
188+
189+
agent = Agent(
190+
role="r",
191+
goal="g",
192+
backstory="b",
193+
tool_failure_policy=ToolFailurePolicy.IGNORE,
194+
)
195+
crew = Crew(
196+
agents=[agent], tasks=[], tool_failure_policy=ToolFailurePolicy.RAISE
197+
)
198+
resolved = resolve_tool_failure_policy(agent=agent, crew=crew)
199+
assert resolved is ToolFailurePolicy.IGNORE
200+
201+
def test_full_precedence_chain(self) -> None:
202+
"""tool > task > agent > crew > warn."""
203+
from crewai import Crew
204+
205+
class ScopedTool(SlackTool):
206+
tool_failure_policy: ToolFailurePolicy | None = None
207+
208+
tool = ScopedTool()
209+
agent = Agent(role="r", goal="g", backstory="b")
210+
task = Task(description="d", expected_output="e")
211+
crew = Crew(agents=[agent], tasks=[])
212+
213+
def resolved() -> ToolFailurePolicy:
214+
return resolve_tool_failure_policy(
215+
tool=tool, agent=agent, task=task, crew=crew
216+
)
217+
218+
assert resolved() is ToolFailurePolicy.WARN
219+
220+
crew.tool_failure_policy = ToolFailurePolicy.IGNORE
221+
assert resolved() is ToolFailurePolicy.IGNORE
222+
223+
agent.tool_failure_policy = ToolFailurePolicy.WARN
224+
assert resolved() is ToolFailurePolicy.WARN
225+
226+
task.tool_failure_policy = ToolFailurePolicy.RAISE
227+
assert resolved() is ToolFailurePolicy.RAISE
228+
229+
tool.tool_failure_policy = ToolFailurePolicy.IGNORE
230+
assert resolved() is ToolFailurePolicy.IGNORE
231+
176232
def test_invalid_policy_is_ignored_rather_than_raising(self) -> None:
177233
"""A bad policy value must never take down a tool call."""
178234

@@ -208,14 +264,28 @@ class StrictTool(WorkingTool):
208264
assert resolved is ToolFailurePolicy.RAISE
209265

210266

211-
class TestAgentDefault:
212-
def test_agent_defaults_to_warn(self) -> None:
213-
agent = Agent(role="r", goal="g", backstory="b")
214-
assert agent.tool_failure_policy is ToolFailurePolicy.WARN
267+
class TestDefaults:
268+
"""Every scope defaults to None ('inherit'); the resolver owns 'warn'."""
215269

216-
def test_task_policy_defaults_to_none_so_it_inherits(self) -> None:
270+
def test_agent_defaults_to_inherit(self) -> None:
271+
assert Agent(role="r", goal="g", backstory="b").tool_failure_policy is None
272+
273+
def test_task_defaults_to_inherit(self) -> None:
217274
assert Task(description="d", expected_output="e").tool_failure_policy is None
218275

276+
def test_crew_defaults_to_inherit(self) -> None:
277+
from crewai import Crew
278+
279+
agent = Agent(role="r", goal="g", backstory="b")
280+
assert Crew(agents=[agent], tasks=[]).tool_failure_policy is None
281+
282+
def test_tool_defaults_to_inherit(self) -> None:
283+
assert SlackTool().tool_failure_policy is None
284+
285+
def test_effective_default_is_warn(self) -> None:
286+
agent = Agent(role="r", goal="g", backstory="b")
287+
assert resolve_tool_failure_policy(agent=agent) is ToolFailurePolicy.WARN
288+
219289

220290
class TestEndToEndPolicies:
221291
def test_warn_records_and_emits_without_stopping(self) -> None:
@@ -598,6 +668,41 @@ def test_retry_limit_does_not_swallow_the_abort(self) -> None:
598668
Crew(agents=[agent], tasks=[task]).kickoff()
599669
assert agent._times_executed == 0, "the abort must not trigger retries"
600670

671+
def test_crew_policy_aborts_end_to_end(self) -> None:
672+
"""Crew scope must actually reach the executor, not just the resolver."""
673+
agent = Agent(
674+
role="Slack Messenger",
675+
goal="post a message",
676+
backstory="b",
677+
llm=ScriptedLLM(_slack_steps()),
678+
tools=[SlackTool()],
679+
)
680+
task = Task(description="post to slack", expected_output="c", agent=agent)
681+
crew = Crew(
682+
agents=[agent],
683+
tasks=[task],
684+
tool_failure_policy=ToolFailurePolicy.RAISE,
685+
)
686+
687+
with pytest.raises(ToolExecutionFailedError):
688+
crew.kickoff()
689+
690+
def test_crew_ignore_suppresses_recording_end_to_end(self) -> None:
691+
agent = Agent(
692+
role="Slack Messenger",
693+
goal="post a message",
694+
backstory="b",
695+
llm=ScriptedLLM(_slack_steps()),
696+
tools=[SlackTool()],
697+
)
698+
task = Task(description="post to slack", expected_output="c", agent=agent)
699+
result = Crew(
700+
agents=[agent],
701+
tasks=[task],
702+
tool_failure_policy=ToolFailurePolicy.IGNORE,
703+
).kickoff()
704+
assert not result.has_tool_failures
705+
601706
def test_passthrough_tuple_includes_the_error(self) -> None:
602707
from crewai.agent.core import _passthrough_exceptions
603708

0 commit comments

Comments
 (0)