Summary
ToolUsage._use/_ause (crewai/tools/tool_usage.py) has two independent retry paths, not one. The outer loop (_run_attempts / _max_parsing_attempts, default 3) is the retry surface I expected. But inside a single outer attempt, there's a second, inner try/except around the actual tool call:
if calling.arguments:
try:
acceptable_args = tool.args_schema.model_json_schema()["properties"].keys()
arguments = {k: v for k, v in calling.arguments.items() if k in acceptable_args}
result = tool.invoke(input=arguments, config=fingerprint_config)
except Exception:
arguments = calling.arguments
result = tool.invoke(input=arguments, config=fingerprint_config)
(sync path, lines 613-629; the async path in _ause is the same shape, lines 356-372.)
This except Exception catches any exception from the first tool.invoke() call — not just a schema/argument-filtering problem. If the tool's own function body raises (a real runtime error, not an argument mismatch), this block re-invokes tool.invoke() a second time with unfiltered arguments, inside the same outer attempt. So every outer attempt calls the tool's underlying function twice, not once — and with the default _max_parsing_attempts=3, a single tool call that always fails ends up invoking the tool 6 times total, not 3.
For a tool with an idempotent effect this is harmless (just wasted calls). For a tool with a non-idempotent side effect (writes a row, sends a request, increments a counter) that happens to raise after the effect lands, this doubles the duplicate-effect exposure of the outer retry loop.
Repro (crewai==1.15.21, no LLM needed — this reaches ToolUsage directly, downstream of tool-call parsing):
import os
os.environ.setdefault("OTEL_SDK_DISABLED", "true")
os.environ.setdefault("CREWAI_DISABLE_TELEMETRY", "true")
from crewai.tools.structured_tool import CrewStructuredTool
from crewai.tools.tool_calling import ToolCalling
from crewai.tools.tool_usage import ToolUsage
calls = []
def my_tool(logical_id: str) -> str:
calls.append(logical_id)
raise RuntimeError("boom")
tool = CrewStructuredTool.from_function(func=my_tool, name="my_tool", description="demo tool")
class _Action:
tool = "my_tool"
tool_input = {"logical_id": "abc"}
usage = ToolUsage(
tools_handler=None, tools=[tool], task=None,
function_calling_llm=None, agent=None, action=_Action(),
)
usage._max_parsing_attempts = 3
calling = ToolCalling(tool_name="my_tool", arguments={"logical_id": "abc"})
usage.use(calling=calling, tool_string="my_tool(logical_id=abc)")
print(f"python-level invocations: {len(calls)}") # 6
print(f"outer run_attempts reported: {usage._run_attempts}") # 4
Output (independently re-run against a fresh install before filing this):
python-level invocations: 6
outer run_attempts reported: 4
Expected vs actual
I'd expect _max_parsing_attempts=3 to mean the tool function is called at most 3 times for a call that keeps failing (matching what _run_attempts reports). Instead it's called up to 6 times, because the inner fallback fires independently on any exception, not only ones that indicate the first arguments dict was wrong.
Context
Found this while building a standalone worked example reproducing a duplicate-effect scenario for a tool with a real side effect (SQLite write) that fails after the effect commits — the inner fallback showed up as an unexpected 2x multiplier on the duplicate count. Repro and full writeup: https://github.com/giskard09/argentum-core/tree/main/examples/conformance/crewai-unguarded-retry (not required to reproduce this — the snippet above is self-contained).
Not sure if the inner fallback's intent was "retry only on schema-filtering exceptions" and it's accidentally too broad, or if it's deliberate — happy to be pointed at the right context if this is expected behavior.
Summary
ToolUsage._use/_ause(crewai/tools/tool_usage.py) has two independent retry paths, not one. The outer loop (_run_attempts/_max_parsing_attempts, default 3) is the retry surface I expected. But inside a single outer attempt, there's a second, innertry/exceptaround the actual tool call:(sync path, lines 613-629; the async path in
_auseis the same shape, lines 356-372.)This
except Exceptioncatches any exception from the firsttool.invoke()call — not just a schema/argument-filtering problem. If the tool's own function body raises (a real runtime error, not an argument mismatch), this block re-invokestool.invoke()a second time with unfiltered arguments, inside the same outer attempt. So every outer attempt calls the tool's underlying function twice, not once — and with the default_max_parsing_attempts=3, a single tool call that always fails ends up invoking the tool 6 times total, not 3.For a tool with an idempotent effect this is harmless (just wasted calls). For a tool with a non-idempotent side effect (writes a row, sends a request, increments a counter) that happens to raise after the effect lands, this doubles the duplicate-effect exposure of the outer retry loop.
Repro (crewai==1.15.21, no LLM needed — this reaches
ToolUsagedirectly, downstream of tool-call parsing):Output (independently re-run against a fresh install before filing this):
Expected vs actual
I'd expect
_max_parsing_attempts=3to mean the tool function is called at most 3 times for a call that keeps failing (matching what_run_attemptsreports). Instead it's called up to 6 times, because the inner fallback fires independently on any exception, not only ones that indicate the firstargumentsdict was wrong.Context
Found this while building a standalone worked example reproducing a duplicate-effect scenario for a tool with a real side effect (SQLite write) that fails after the effect commits — the inner fallback showed up as an unexpected 2x multiplier on the duplicate count. Repro and full writeup: https://github.com/giskard09/argentum-core/tree/main/examples/conformance/crewai-unguarded-retry (not required to reproduce this — the snippet above is self-contained).
Not sure if the inner fallback's intent was "retry only on schema-filtering exceptions" and it's accidentally too broad, or if it's deliberate — happy to be pointed at the right context if this is expected behavior.