diff --git a/pydantic_ai_slim/pydantic_ai/exceptions.py b/pydantic_ai_slim/pydantic_ai/exceptions.py index 0b4500502c..64cd267aa8 100644 --- a/pydantic_ai_slim/pydantic_ai/exceptions.py +++ b/pydantic_ai_slim/pydantic_ai/exceptions.py @@ -4,6 +4,7 @@ import sys from typing import TYPE_CHECKING, Any +import pydantic_core from pydantic_core import core_schema if sys.version_info < (3, 11): @@ -11,6 +12,7 @@ else: ExceptionGroup = ExceptionGroup # pragma: lax no cover + if TYPE_CHECKING: from .messages import RetryPromptPart @@ -188,7 +190,31 @@ class ToolRetryError(Exception): def __init__(self, tool_retry: RetryPromptPart): self.tool_retry = tool_retry - super().__init__() + message = ( + tool_retry.content + if isinstance(tool_retry.content, str) + else self._format_error_details(tool_retry.content, tool_retry.tool_name) + ) + super().__init__(message) + + @staticmethod + def _format_error_details(errors: list[pydantic_core.ErrorDetails], tool_name: str | None) -> str: + """Format ErrorDetails as a human-readable message. + + We format manually rather than using ValidationError.from_exception_data because + some error types (value_error, assertion_error, etc.) require an 'error' key in ctx, + but when ErrorDetails are serialized, exception objects are stripped from ctx. + The 'msg' field already contains the human-readable message, so we use that directly. + """ + title = tool_name or 'ToolRetryError' + error_count = len(errors) + error_word = 'error' if error_count == 1 else 'errors' + lines = [f'{error_count} validation {error_word} for {title}'] + for e in errors: + loc = '.'.join(str(x) for x in e['loc']) if e['loc'] else '__root__' + lines.append(loc) + lines.append(f' {e["msg"]} [type={e["type"]}, input_value={e["input"]!r}]') + return '\n'.join(lines) class IncompleteToolCall(UnexpectedModelBehavior): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index c5e27b5706..32d8343afe 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -4,6 +4,8 @@ from typing import Any import pytest +from pydantic import ValidationError +from pydantic_core import ErrorDetails from pydantic_ai import ModelRetry from pydantic_ai.exceptions import ( @@ -13,10 +15,12 @@ IncompleteToolCall, ModelAPIError, ModelHTTPError, + ToolRetryError, UnexpectedModelBehavior, UsageLimitExceeded, UserError, ) +from pydantic_ai.messages import RetryPromptPart @pytest.mark.parametrize( @@ -32,6 +36,7 @@ lambda: ModelAPIError('model', 'test message'), lambda: ModelHTTPError(500, 'model'), lambda: IncompleteToolCall('test'), + lambda: ToolRetryError(RetryPromptPart(content='test', tool_name='test')), ], ids=[ 'ModelRetry', @@ -44,6 +49,7 @@ 'ModelAPIError', 'ModelHTTPError', 'IncompleteToolCall', + 'ToolRetryError', ], ) def test_exceptions_hashable(exc_factory: Callable[[], Any]): @@ -59,3 +65,46 @@ def test_exceptions_hashable(exc_factory: Callable[[], Any]): assert exc in s assert d[exc] == 'value' + + +def test_tool_retry_error_str_with_string_content(): + """Test that ToolRetryError uses string content as message automatically.""" + part = RetryPromptPart(content='error from tool', tool_name='my_tool') + error = ToolRetryError(part) + assert str(error) == 'error from tool' + + +def test_tool_retry_error_str_with_error_details(): + """Test that ToolRetryError formats ErrorDetails automatically.""" + validation_error = ValidationError.from_exception_data( + 'Test', [{'type': 'string_type', 'loc': ('name',), 'input': 123}] + ) + part = RetryPromptPart(content=validation_error.errors(include_url=False), tool_name='my_tool') + error = ToolRetryError(part) + + assert str(error) == ( + '1 validation error for my_tool\nname\n Input should be a valid string [type=string_type, input_value=123]' + ) + + +def test_tool_retry_error_str_with_value_error_type(): + """Test that ToolRetryError handles value_error type without ctx.error. + + When ErrorDetails are serialized, the exception object in ctx is stripped. + This test ensures we handle error types that normally require ctx.error. + """ + # Simulate serialized ErrorDetails where ctx.error has been stripped + error_details: list[ErrorDetails] = [ + { + 'type': 'value_error', + 'loc': ('field',), + 'msg': 'Value error, must not be foo', + 'input': 'foo', + } + ] + part = RetryPromptPart(content=error_details, tool_name='my_tool') + error = ToolRetryError(part) + + assert str(error) == ( + "1 validation error for my_tool\nfield\n Value error, must not be foo [type=value_error, input_value='foo']" + )