Skip to content

Commit ad7d03c

Browse files
jamesaudWork
andauthored
Set ToolRetryError message (#3718)
Co-authored-by: Work <[email protected]>
1 parent 1da30bc commit ad7d03c

File tree

2 files changed

+76
-1
lines changed

2 files changed

+76
-1
lines changed

pydantic_ai_slim/pydantic_ai/exceptions.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
import sys
55
from typing import TYPE_CHECKING, Any
66

7+
import pydantic_core
78
from pydantic_core import core_schema
89

910
if sys.version_info < (3, 11):
1011
from exceptiongroup import ExceptionGroup as ExceptionGroup # pragma: lax no cover
1112
else:
1213
ExceptionGroup = ExceptionGroup # pragma: lax no cover
1314

15+
1416
if TYPE_CHECKING:
1517
from .messages import RetryPromptPart
1618

@@ -188,7 +190,31 @@ class ToolRetryError(Exception):
188190

189191
def __init__(self, tool_retry: RetryPromptPart):
190192
self.tool_retry = tool_retry
191-
super().__init__()
193+
message = (
194+
tool_retry.content
195+
if isinstance(tool_retry.content, str)
196+
else self._format_error_details(tool_retry.content, tool_retry.tool_name)
197+
)
198+
super().__init__(message)
199+
200+
@staticmethod
201+
def _format_error_details(errors: list[pydantic_core.ErrorDetails], tool_name: str | None) -> str:
202+
"""Format ErrorDetails as a human-readable message.
203+
204+
We format manually rather than using ValidationError.from_exception_data because
205+
some error types (value_error, assertion_error, etc.) require an 'error' key in ctx,
206+
but when ErrorDetails are serialized, exception objects are stripped from ctx.
207+
The 'msg' field already contains the human-readable message, so we use that directly.
208+
"""
209+
error_count = len(errors)
210+
lines = [
211+
f'{error_count} validation error{"" if error_count == 1 else "s"}{f" for {tool_name!r}" if tool_name else ""}'
212+
]
213+
for e in errors:
214+
loc = '.'.join(str(x) for x in e['loc']) if e['loc'] else '__root__'
215+
lines.append(loc)
216+
lines.append(f' {e["msg"]} [type={e["type"]}, input_value={e["input"]!r}]')
217+
return '\n'.join(lines)
192218

193219

194220
class IncompleteToolCall(UnexpectedModelBehavior):

tests/test_exceptions.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from typing import Any
55

66
import pytest
7+
from pydantic import ValidationError
8+
from pydantic_core import ErrorDetails
79

810
from pydantic_ai import ModelRetry
911
from pydantic_ai.exceptions import (
@@ -13,10 +15,12 @@
1315
IncompleteToolCall,
1416
ModelAPIError,
1517
ModelHTTPError,
18+
ToolRetryError,
1619
UnexpectedModelBehavior,
1720
UsageLimitExceeded,
1821
UserError,
1922
)
23+
from pydantic_ai.messages import RetryPromptPart
2024

2125

2226
@pytest.mark.parametrize(
@@ -32,6 +36,7 @@
3236
lambda: ModelAPIError('model', 'test message'),
3337
lambda: ModelHTTPError(500, 'model'),
3438
lambda: IncompleteToolCall('test'),
39+
lambda: ToolRetryError(RetryPromptPart(content='test', tool_name='test')),
3540
],
3641
ids=[
3742
'ModelRetry',
@@ -44,6 +49,7 @@
4449
'ModelAPIError',
4550
'ModelHTTPError',
4651
'IncompleteToolCall',
52+
'ToolRetryError',
4753
],
4854
)
4955
def test_exceptions_hashable(exc_factory: Callable[[], Any]):
@@ -59,3 +65,46 @@ def test_exceptions_hashable(exc_factory: Callable[[], Any]):
5965

6066
assert exc in s
6167
assert d[exc] == 'value'
68+
69+
70+
def test_tool_retry_error_str_with_string_content():
71+
"""Test that ToolRetryError uses string content as message automatically."""
72+
part = RetryPromptPart(content='error from tool', tool_name='my_tool')
73+
error = ToolRetryError(part)
74+
assert str(error) == 'error from tool'
75+
76+
77+
def test_tool_retry_error_str_with_error_details():
78+
"""Test that ToolRetryError formats ErrorDetails automatically."""
79+
validation_error = ValidationError.from_exception_data(
80+
'Test', [{'type': 'string_type', 'loc': ('name',), 'input': 123}]
81+
)
82+
part = RetryPromptPart(content=validation_error.errors(include_url=False), tool_name='my_tool')
83+
error = ToolRetryError(part)
84+
85+
assert str(error) == (
86+
"1 validation error for 'my_tool'\nname\n Input should be a valid string [type=string_type, input_value=123]"
87+
)
88+
89+
90+
def test_tool_retry_error_str_with_value_error_type():
91+
"""Test that ToolRetryError handles value_error type without ctx.error.
92+
93+
When ErrorDetails are serialized, the exception object in ctx is stripped.
94+
This test ensures we handle error types that normally require ctx.error.
95+
"""
96+
# Simulate serialized ErrorDetails where ctx.error has been stripped
97+
error_details: list[ErrorDetails] = [
98+
{
99+
'type': 'value_error',
100+
'loc': ('field',),
101+
'msg': 'Value error, must not be foo',
102+
'input': 'foo',
103+
}
104+
]
105+
part = RetryPromptPart(content=error_details, tool_name='my_tool')
106+
error = ToolRetryError(part)
107+
108+
assert str(error) == (
109+
"1 validation error for 'my_tool'\nfield\n Value error, must not be foo [type=value_error, input_value='foo']"
110+
)

0 commit comments

Comments
 (0)