Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions lib/crewai/src/crewai/tools/tool_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,15 @@ def _check_usage_limit(tool: Any, tool_name: str) -> str | None:

def _select_tool(self, tool_name: str) -> Any:
sanitized_input = sanitize_tool_name(tool_name)
# Fast path: an exact (sanitized) name match is the overwhelmingly
# common case — the LLM emitted a valid tool name — so resolve it in a
# single O(n) scan and skip the O(n log n) fuzzy SequenceMatcher sort
# below. An exact match has ratio 1.0, so it would always win that sort
# anyway; scanning self.tools in order preserves the same tie-break.
for tool in self.tools:
if sanitize_tool_name(tool.name) == sanitized_input:
return tool
# Fuzzy fallback: tolerate minor typos / near-misses in the tool name.
order_tools = sorted(
self.tools,
key=lambda tool: SequenceMatcher(
Expand Down
87 changes: 87 additions & 0 deletions lib/crewai/tests/tools/test_tool_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,93 @@ def test_random_number_tool_schema():
)


def _make_tool_usage(tools):
return ToolUsage(
tools_handler=MagicMock(),
tools=tools,
task=MagicMock(),
function_calling_llm=MagicMock(),
agent=MagicMock(),
action=MagicMock(),
)


def _legacy_select_tool(tool_usage, tool_name):
"""Reference implementation of the pre-fast-path _select_tool matching
logic (fuzzy sort + threshold), used to prove behavior is preserved."""
from difflib import SequenceMatcher

from crewai.utilities.string_utils import sanitize_tool_name

sanitized_input = sanitize_tool_name(tool_name)
order_tools = sorted(
tool_usage.tools,
key=lambda tool: SequenceMatcher(
None, sanitize_tool_name(tool.name), sanitized_input
).ratio(),
reverse=True,
)
for tool in order_tools:
sanitized_tool = sanitize_tool_name(tool.name)
if (
sanitized_tool == sanitized_input
or SequenceMatcher(None, sanitized_tool, sanitized_input).ratio() > 0.85
):
return tool
return None


class _NamedTool(BaseTool):
description: str = "A tool"

def _run(self) -> str:
return "ok"


def test_select_tool_exact_match_by_raw_and_sanitized_name():
calc = _NamedTool(name="Calculator Tool")
weather = _NamedTool(name="Weather Lookup")
tool_usage = _make_tool_usage([calc, weather])

# Raw name and its sanitized form both resolve to the same tool.
assert tool_usage._select_tool("Calculator Tool") is calc
assert tool_usage._select_tool("calculator_tool") is calc
assert tool_usage._select_tool("Weather Lookup") is weather


def test_select_tool_fuzzy_fallback_for_typo():
calc = _NamedTool(name="Calculator Tool")
weather = _NamedTool(name="Weather Lookup")
tool_usage = _make_tool_usage([calc, weather])

# A small typo (no exact match) still resolves via the fuzzy fallback.
assert tool_usage._select_tool("calculater_tool") is calc


def test_select_tool_matches_legacy_behavior():
tools = [
_NamedTool(name="Calculator Tool"),
_NamedTool(name="Weather Lookup"),
_NamedTool(name="Search The Web"),
]
tool_usage = _make_tool_usage(tools)

# Across exact names, sanitized names, and near-miss typos, the fast-path
# selection must return exactly what the legacy fuzzy-sort logic returned.
for query in [
"Calculator Tool",
"calculator_tool",
"Weather Lookup",
"weather_lookup",
"Search The Web",
"search_the_webb", # typo, fuzzy
"calculater_tool", # typo, fuzzy
]:
assert tool_usage._select_tool(query) is _legacy_select_tool(
tool_usage, query
), f"mismatch for {query!r}"


def test_tool_usage_render():
tool = RandomNumberTool()

Expand Down