fix: inject tool errors as observations and resolve name collisions#4656
Merged
greysonlalonde merged 1 commit intoMar 1, 2026
Merged
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Free Tier Details
Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.
To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Renamed tools cannot be matched back to originals
- AgentExecutor now resolves tool calls via schema-name mappings built during native tool setup so renamed tools correctly map back to original and structured tool objects.
- ✅ Fixed: Collision check verifies different name than produced
- Tool schema deduplication now checks collisions against the sanitized candidate name that is actually used, including truncation/hash transformations.
Or push these changes by commenting:
@cursor push e6ad7e7698
Preview (e6ad7e7698)
diff --git a/lib/crewai/src/crewai/experimental/agent_executor.py b/lib/crewai/src/crewai/experimental/agent_executor.py
--- a/lib/crewai/src/crewai/experimental/agent_executor.py
+++ b/lib/crewai/src/crewai/experimental/agent_executor.py
@@ -201,6 +201,8 @@
self._flow_initialized: bool = False
self._instance_id = str(uuid4())[:8]
+ self._original_tools_by_function_name: dict[str, BaseTool] = {}
+ self._structured_tools_by_function_name: dict[str, CrewStructuredTool] = {}
self.before_llm_call_hooks: list[
BeforeLLMCallHookType | BeforeLLMCallHookCallable
@@ -324,7 +326,44 @@
self._openai_tools, self._available_functions = (
convert_tools_to_openai_schema(self.original_tools)
)
+ self._original_tools_by_function_name = {}
+ for original_tool, schema in zip(self.original_tools, self._openai_tools):
+ function_info = schema.get("function", {})
+ function_name = function_info.get("name")
+ if isinstance(function_name, str):
+ self._original_tools_by_function_name[function_name] = original_tool
+ self._structured_tools_by_function_name = {}
+ for structured_tool, schema in zip(self.tools or [], self._openai_tools):
+ function_info = schema.get("function", {})
+ function_name = function_info.get("name")
+ if isinstance(function_name, str):
+ self._structured_tools_by_function_name[function_name] = (
+ structured_tool
+ )
+
+ def _get_original_tool_for_function_name(self, func_name: str) -> BaseTool | None:
+ """Resolve original tool by function-call name."""
+ if func_name in self._original_tools_by_function_name:
+ return self._original_tools_by_function_name[func_name]
+
+ for tool in self.original_tools or []:
+ if sanitize_tool_name(tool.name) == func_name:
+ return tool
+ return None
+
+ def _get_structured_tool_for_function_name(
+ self, func_name: str
+ ) -> CrewStructuredTool | None:
+ """Resolve structured tool by function-call name."""
+ if func_name in self._structured_tools_by_function_name:
+ return self._structured_tools_by_function_name[func_name]
+
+ for structured_tool in self.tools or []:
+ if sanitize_tool_name(structured_tool.name) == func_name:
+ return structured_tool
+ return None
+
def _is_tool_call_list(self, response: list[Any]) -> bool:
"""Check if a response is a list of tool calls.
@@ -853,11 +892,7 @@
continue
_, func_name, _ = info
- original_tool = None
- for tool in self.original_tools or []:
- if sanitize_tool_name(tool.name) == func_name:
- original_tool = tool
- break
+ original_tool = self._get_original_tool_for_function_name(func_name)
if not original_tool:
continue
@@ -888,21 +923,19 @@
call_id, func_name, func_args = info
+ # Find original tool by function name (supports renamed schema tools)
+ original_tool = self._get_original_tool_for_function_name(func_name)
+
# Parse arguments
- args_dict, parse_error = parse_tool_call_args(func_args, func_name, call_id)
+ args_dict, parse_error = parse_tool_call_args(
+ func_args, func_name, call_id, original_tool=original_tool
+ )
if parse_error is not None:
return parse_error
# Get agent_key for event tracking
agent_key = getattr(self.agent, "key", "unknown") if self.agent else "unknown"
- # Find original tool by matching sanitized name (needed for cache_function and result_as_answer)
- original_tool = None
- for tool in self.original_tools or []:
- if sanitize_tool_name(tool.name) == func_name:
- original_tool = tool
- break
-
# Check if tool has reached max usage count
max_usage_reached = False
if (
@@ -943,11 +976,7 @@
track_delegation_if_needed(func_name, args_dict, self.task)
- structured_tool: CrewStructuredTool | None = None
- for structured in self.tools or []:
- if sanitize_tool_name(structured.name) == func_name:
- structured_tool = structured
- break
+ structured_tool = self._get_structured_tool_for_function_name(func_name)
hook_blocked = False
before_hook_context = ToolCallHookContext(
diff --git a/lib/crewai/src/crewai/utilities/agent_utils.py b/lib/crewai/src/crewai/utilities/agent_utils.py
--- a/lib/crewai/src/crewai/utilities/agent_utils.py
+++ b/lib/crewai/src/crewai/utilities/agent_utils.py
@@ -188,9 +188,11 @@
if sanitized_name in available_functions:
counter = 2
- while f"{sanitized_name}_{counter}" in available_functions:
+ candidate_name = sanitized_name
+ while candidate_name in available_functions:
+ candidate_name = sanitize_tool_name(f"{sanitized_name}_{counter}")
counter += 1
- sanitized_name = sanitize_tool_name(f"{sanitized_name}_{counter}")
+ sanitized_name = candidate_name
schema: dict[str, Any] = {
"type": "function",
diff --git a/lib/crewai/tests/agents/test_agent_executor.py b/lib/crewai/tests/agents/test_agent_executor.py
--- a/lib/crewai/tests/agents/test_agent_executor.py
+++ b/lib/crewai/tests/agents/test_agent_executor.py
@@ -636,3 +636,88 @@
tool_messages = [m for m in executor.state.messages if m.get("role") == "tool"]
assert len(tool_messages) == 1
assert tool_messages[0]["tool_call_id"] == "call_1"
+
+ def test_should_not_parallelize_when_renamed_tool_is_result_as_answer(
+ self, mock_dependencies
+ ):
+ executor = AgentExecutor(**mock_dependencies)
+
+ first_tool = Mock()
+ first_tool.name = "duplicate_tool"
+ first_tool.description = "first duplicate"
+ first_tool.args_schema = None
+ first_tool.run = Mock(return_value="first")
+ first_tool.result_as_answer = False
+ first_tool.max_usage_count = None
+ first_tool.current_usage_count = 0
+
+ second_tool = Mock()
+ second_tool.name = "duplicate_tool"
+ second_tool.description = "second duplicate"
+ second_tool.args_schema = None
+ second_tool.run = Mock(return_value="second")
+ second_tool.result_as_answer = True
+ second_tool.max_usage_count = None
+ second_tool.current_usage_count = 0
+
+ executor.original_tools = [first_tool, second_tool]
+ executor._setup_native_tools()
+
+ first_func_name = executor._openai_tools[0]["function"]["name"]
+ second_func_name = executor._openai_tools[1]["function"]["name"]
+ assert second_func_name != first_func_name
+
+ should_parallelize = executor._should_parallelize_native_tool_calls(
+ [
+ {
+ "id": "call_1",
+ "function": {"name": first_func_name, "arguments": "{}"},
+ },
+ {
+ "id": "call_2",
+ "function": {"name": second_func_name, "arguments": "{}"},
+ },
+ ]
+ )
+
+ assert should_parallelize is False
+
+ def test_execute_single_native_tool_call_resolves_renamed_tool_for_usage_limits(
+ self, mock_dependencies
+ ):
+ executor = AgentExecutor(**mock_dependencies)
+
+ first_tool = Mock()
+ first_tool.name = "duplicate_tool"
+ first_tool.description = "first duplicate"
+ first_tool.args_schema = None
+ first_tool.run = Mock(return_value="first")
+ first_tool.result_as_answer = False
+ first_tool.max_usage_count = None
+ first_tool.current_usage_count = 0
+
+ second_tool = Mock()
+ second_tool.name = "duplicate_tool"
+ second_tool.description = "second duplicate"
+ second_tool.args_schema = None
+ second_tool.run = Mock(return_value="second")
+ second_tool.result_as_answer = False
+ second_tool.max_usage_count = 0
+ second_tool.current_usage_count = 0
+
+ executor.original_tools = [first_tool, second_tool]
+ executor._setup_native_tools()
+
+ renamed_func_name = executor._openai_tools[1]["function"]["name"]
+ assert renamed_func_name != executor._openai_tools[0]["function"]["name"]
+
+ result = executor._execute_single_native_tool_call(
+ {
+ "id": "call_limit",
+ "function": {"name": renamed_func_name, "arguments": "{}"},
+ }
+ )
+
+ assert result["original_tool"] is second_tool
+ assert "has reached its usage limit" in result["result"]
+ second_tool.run.assert_not_called()
diff --git a/lib/crewai/tests/utilities/test_agent_utils.py b/lib/crewai/tests/utilities/test_agent_utils.py
--- a/lib/crewai/tests/utilities/test_agent_utils.py
+++ b/lib/crewai/tests/utilities/test_agent_utils.py
@@ -224,7 +224,39 @@
assert "default" in max_results_prop
assert max_results_prop["default"] == 10
+ def test_collision_resolution_checks_sanitized_candidate_name(self) -> None:
+ """Ensure collision resolution validates the final sanitized candidate."""
+ tool_one = MagicMock()
+ tool_one.name = "dup_tool"
+ tool_one.description = "first tool"
+ tool_one.args_schema = None
+ tool_one.run = MagicMock(return_value="first")
+ tool_two = MagicMock()
+ tool_two.name = "dup_tool"
+ tool_two.description = "second tool"
+ tool_two.args_schema = None
+ tool_two.run = MagicMock(return_value="second")
+
+ def _fake_sanitize(name: str) -> str:
+ if name == "dup_tool":
+ return "dup"
+ if name == "dup_2":
+ return "dup"
+ if name == "dup_3":
+ return "dup_3"
+ return name
+
+ with patch(
+ "crewai.utilities.agent_utils.sanitize_tool_name",
+ side_effect=_fake_sanitize,
+ ):
+ schemas, functions = convert_tools_to_openai_schema([tool_one, tool_two])
+
+ assert [schema["function"]["name"] for schema in schemas] == ["dup", "dup_3"]
+ assert set(functions.keys()) == {"dup", "dup_3"}
+
+
def _make_mock_i18n() -> MagicMock:
"""Create a mock i18n with the new structured prompt keys."""
mock_i18n = MagicMock()1265625 to
324f353
Compare
324f353 to
85aad47
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
85aad47 to
89a31c1
Compare
joaomdmoura
approved these changes
Mar 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Note
Medium Risk
Medium risk because it changes tool name sanitization and native tool-call execution/error handling, which can affect tool lookup, caching keys, and agent control flow when tools fail or names collide.
Overview
Improves native tool calling robustness and debuggability.
convert_tools_to_openai_schemanow returns a sanitized-name→original-tool mapping and auto-disambiguates name collisions by suffixing (_2,_3, …); bothCrewAgentExecutorand the experimentalAgentExecutoruse this mapping to correctly associate tool calls back to the right original/structured tool.Better failure behavior and errors. Tool execution failures are captured and surfaced as tool results/observations (including in parallel batches) rather than raising, invalid native tool-call payloads return a structured error result, and argument-validation errors now append a schema-derived hint of expected/required fields. Tool name truncation in
sanitize_tool_namenow adds a short hash suffix to reduce collisions when exceeding provider length limits.Written by Cursor Bugbot for commit 89a31c1. This will update automatically on new commits. Configure here.