Skip to content

fix: inject tool errors as observations and resolve name collisions#4656

Merged
greysonlalonde merged 1 commit into
mainfrom
gl/fix/tool-error-handling-and-name-collision
Mar 1, 2026
Merged

fix: inject tool errors as observations and resolve name collisions#4656
greysonlalonde merged 1 commit into
mainfrom
gl/fix/tool-error-handling-and-name-collision

Conversation

@greysonlalonde

@greysonlalonde greysonlalonde commented Mar 1, 2026

Copy link
Copy Markdown
Contributor

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_schema now returns a sanitized-name→original-tool mapping and auto-disambiguates name collisions by suffixing (_2, _3, …); both CrewAgentExecutor and the experimental AgentExecutor use 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_name now 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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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()
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Comment thread lib/crewai/src/crewai/utilities/agent_utils.py Outdated
Comment thread lib/crewai/src/crewai/utilities/agent_utils.py Outdated
@greysonlalonde greysonlalonde force-pushed the gl/fix/tool-error-handling-and-name-collision branch 3 times, most recently from 1265625 to 324f353 Compare March 1, 2026 00:37
Comment thread lib/crewai/src/crewai/tools/base_tool.py Outdated
@greysonlalonde greysonlalonde force-pushed the gl/fix/tool-error-handling-and-name-collision branch from 324f353 to 85aad47 Compare March 1, 2026 01:13

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/crewai/src/crewai/experimental/agent_executor.py
@greysonlalonde greysonlalonde force-pushed the gl/fix/tool-error-handling-and-name-collision branch from 85aad47 to 89a31c1 Compare March 1, 2026 01:39
@greysonlalonde greysonlalonde merged commit 1ac5801 into main Mar 1, 2026
46 of 109 checks passed
@greysonlalonde greysonlalonde deleted the gl/fix/tool-error-handling-and-name-collision branch March 1, 2026 05:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants