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
3 changes: 2 additions & 1 deletion lib/crewai/src/crewai/agents/crew_agent_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
aexecute_tool_and_check_finality,
execute_tool_and_check_finality,
)
from crewai.utilities.tool_errors import format_tool_error
from crewai.utilities.training_handler import CrewTrainingHandler


Expand Down Expand Up @@ -1006,7 +1007,7 @@ def _execute_single_native_tool_call(

result = format_native_tool_output_for_agent(output_tool, raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
result = format_tool_error(e)
raw_tool_result = result
if self.task:
self.task.increment_tools_errors()
Expand Down
10 changes: 6 additions & 4 deletions lib/crewai/src/crewai/experimental/agent_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
)
from crewai.utilities.step_execution_context import StepExecutionContext, StepResult
from crewai.utilities.string_utils import sanitize_tool_name
from crewai.utilities.tool_errors import format_tool_error
from crewai.utilities.tool_utils import execute_tool_and_check_finality
from crewai.utilities.training_handler import CrewTrainingHandler
from crewai.utilities.types import LLMMessage
Expand Down Expand Up @@ -1615,9 +1616,10 @@ def execute_tool_action(self) -> Literal["tool_completed", "tool_result_is_final
if self.task:
self.task.increment_tools_errors()

error_observation = f"\nObservation: Error executing tool: {e}"
structured_error = format_tool_error(e)
error_observation = f"\nObservation: {structured_error}"
action.text += error_observation
action.result = str(e)
action.result = structured_error
self._append_message_to_state(action.text)

reasoning_prompt = I18N_DEFAULT.slice("post_tool_reasoning")
Expand Down Expand Up @@ -1736,7 +1738,7 @@ def execute_native_tool(
ordered_results[idx] = {
"call_id": call_id,
"func_name": func_name,
"result": f"Error executing tool: {e}",
"result": format_tool_error(e),
"from_cache": False,
"original_tool": None,
}
Expand Down Expand Up @@ -1999,7 +2001,7 @@ def _execute_single_native_tool_call(self, tool_call: Any) -> dict[str, Any]:
output_tool, raw_result
)
except Exception as e:
result = f"Error executing tool: {e}"
result = format_tool_error(e)
raw_tool_result = result
if self.task:
self.task.increment_tools_errors()
Expand Down
6 changes: 4 additions & 2 deletions lib/crewai/src/crewai/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from crewai.utilities.logger_utils import suppress_warnings
from crewai.utilities.string_utils import sanitize_tool_name
from crewai.utilities.token_counter_callback import TokenCalcHandler
from crewai.utilities.tool_errors import format_tool_error


try:
Expand Down Expand Up @@ -1755,11 +1756,12 @@ def _handle_tool_call(
return result
except Exception as e:
fn = available_functions.get(function_name, lambda: None)
structured_error = format_tool_error(e)
logging.error(f"Error executing function '{function_name}': {e}")
crewai_event_bus.emit(
self,
event=LLMCallFailedEvent(
error=f"Tool execution error: {e!s}",
error=structured_error,
from_task=from_task,
from_agent=from_agent,
call_id=get_current_call_id(),
Expand All @@ -1770,7 +1772,7 @@ def _handle_tool_call(
event=ToolUsageErrorEvent(
tool_name=function_name,
tool_args=function_args,
error=f"Tool execution error: {e!s}",
error=structured_error,
from_task=from_task,
from_agent=from_agent,
),
Expand Down
3 changes: 2 additions & 1 deletion lib/crewai/src/crewai/utilities/agent_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from crewai.utilities.pydantic_schema_utils import generate_model_description
from crewai.utilities.string_utils import sanitize_tool_name
from crewai.utilities.token_counter_callback import TokenCalcHandler
from crewai.utilities.tool_errors import format_tool_error
from crewai.utilities.types import LLMMessage


Expand Down Expand Up @@ -1546,7 +1547,7 @@ def execute_single_native_tool_call(

result = format_native_tool_output_for_agent(output_tool, raw_result)
except Exception as e:
result = f"Error executing tool: {e}"
result = format_tool_error(e)
raw_tool_result = result
if task:
task.increment_tools_errors()
Expand Down
29 changes: 29 additions & 0 deletions lib/crewai/src/crewai/utilities/tool_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Structured tool error formatting for agent consumption.

When a tool raises an exception, the agent needs structured information
to decide whether to retry, fix its input, or skip the tool entirely.
This module provides a consistent error format across all executors.
"""

import json
import traceback

RETRYABLE_EXCEPTIONS = (TimeoutError, ConnectionError, OSError)


def format_tool_error(exception: Exception, include_traceback: bool = False) -> str:
"""Format a tool execution error as structured JSON for the agent.

Returns a string with the "Error executing tool:" prefix (for backward
compatibility with existing parsing) followed by a JSON object containing
the exception type, message, and retryability hint.
"""
error_data = {
"error": True,
"type": type(exception).__name__,
"message": str(exception),
"retryable": isinstance(exception, RETRYABLE_EXCEPTIONS),
}
if include_traceback:
error_data["traceback"] = traceback.format_exc(limit=3)
Comment on lines +27 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the passed exception traceback instead of ambient exception state.

On Line 28, traceback.format_exc() can emit NoneType: None if this formatter is called outside an active except block. Format from exception.__traceback__ directly to keep traceback accurate.

Proposed fix
-    if include_traceback:
-        error_data["traceback"] = traceback.format_exc(limit=3)
+    if include_traceback:
+        error_data["traceback"] = "".join(
+            traceback.format_exception(
+                type(exception), exception, exception.__traceback__, limit=3
+            )
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if include_traceback:
error_data["traceback"] = traceback.format_exc(limit=3)
if include_traceback:
error_data["traceback"] = "".join(
traceback.format_exception(
type(exception), exception, exception.__traceback__, limit=3
)
)
🧰 Tools
🪛 ast-grep (0.44.0)

[info] 28-28: use jsonify instead of json.dumps for JSON output
Context: json.dumps(error_data)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/utilities/tool_errors.py` around lines 27 - 28, The
traceback handling in tool error formatting should use the passed exception’s
own traceback instead of relying on ambient exception state. Update the logic in
the utility that builds error details (the block guarded by include_traceback in
the tool error formatter) to format from the exception object’s __traceback__
rather than calling traceback.format_exc(), so the recorded traceback is
accurate even when not inside an active except block.

return f"Error executing tool: {json.dumps(error_data)}"
121 changes: 121 additions & 0 deletions lib/crewai/tests/test_tool_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Tests for structured tool error formatting."""

import json

import pytest

from crewai.utilities.tool_errors import RETRYABLE_EXCEPTIONS, format_tool_error


class TestFormatToolError:
"""Tests for the format_tool_error utility function."""

def test_returns_string_with_prefix(self):
err = ValueError("bad input")
result = format_tool_error(err)
assert result.startswith("Error executing tool: ")

def test_contains_valid_json_after_prefix(self):
err = ValueError("bad input")
result = format_tool_error(err)
json_str = result[len("Error executing tool: "):]
parsed = json.loads(json_str)
assert isinstance(parsed, dict)

def test_error_flag_is_true(self):
err = RuntimeError("something broke")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["error"] is True

def test_preserves_exception_type(self):
err = KeyError("missing_key")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["type"] == "KeyError"

def test_preserves_exception_message(self):
err = ValueError("count must be positive")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["message"] == "count must be positive"

def test_retryable_true_for_timeout(self):
err = TimeoutError("connection timed out")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["retryable"] is True

def test_retryable_true_for_connection_error(self):
err = ConnectionError("refused")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["retryable"] is True

def test_retryable_true_for_os_error(self):
err = OSError("disk full")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["retryable"] is True

def test_retryable_false_for_value_error(self):
err = ValueError("invalid")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["retryable"] is False

def test_retryable_false_for_type_error(self):
err = TypeError("wrong type")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["retryable"] is False

def test_retryable_false_for_key_error(self):
err = KeyError("not found")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["retryable"] is False

def test_no_traceback_by_default(self):
err = ValueError("test")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert "traceback" not in parsed

def test_traceback_included_when_requested(self):
try:
raise ValueError("deliberate error")
except ValueError as e:
result = format_tool_error(e, include_traceback=True)
parsed = json.loads(result[len("Error executing tool: "):])
assert "traceback" in parsed
assert "ValueError" in parsed["traceback"]

def test_handles_exception_with_special_characters(self):
err = ValueError('path "C:\\Users\\test" not found')
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert 'C:\\Users\\test' in parsed["message"]

def test_handles_exception_with_empty_message(self):
err = RuntimeError()
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["type"] == "RuntimeError"
assert parsed["message"] == ""

def test_handles_custom_exception(self):
class MyToolError(Exception):
pass

err = MyToolError("custom failure")
result = format_tool_error(err)
parsed = json.loads(result[len("Error executing tool: "):])
assert parsed["type"] == "MyToolError"
assert parsed["message"] == "custom failure"
assert parsed["retryable"] is False

def test_retryable_exceptions_tuple_contains_expected_types(self):
assert TimeoutError in RETRYABLE_EXCEPTIONS
assert ConnectionError in RETRYABLE_EXCEPTIONS
assert OSError in RETRYABLE_EXCEPTIONS