fix(core): type structured tool error handler output (#38003)

`handle_tool_error` callables can already return structured message
content at runtime, but the public typing only allowed strings. The tool
error handling API now reflects the existing output formatting path,
including clearer docs for how handled errors become
`ToolMessage(status="error")` results.
This commit is contained in:
Mason Daugherty
2026-06-09 21:18:19 -04:00
committed by GitHub
parent 77bbf8ba39
commit 0f1b291f42
2 changed files with 67 additions and 7 deletions

View File

@@ -398,6 +398,13 @@ class ToolException(Exception): # noqa: N818
ArgsSchema = TypeBaseModel | dict[str, Any]
ToolExceptionHandlerOutput = str | list[str | dict[str, Any]]
"""Content returned by a `handle_tool_error` callable.
Error handlers may return plain text or structured message content blocks.
When the original tool call includes a `tool_call_id`, this content is used
as the content of a `ToolMessage` with `status="error"`.
"""
_EMPTY_SET: frozenset[str] = frozenset()
@@ -496,8 +503,20 @@ class ChildTool(BaseTool):
You can use these to, e.g., identify a specific instance of a tool with its usecase.
"""
handle_tool_error: bool | str | Callable[[ToolException], str] | None = False
"""Handle the content of the `ToolException` thrown."""
handle_tool_error: (
bool | str | Callable[[ToolException], ToolExceptionHandlerOutput] | None
) = False
"""Handle `ToolException` raised by tool execution.
If `False`, the exception is re-raised. If `True`, the exception message is
returned as tool output. If a string is passed, that string is returned
as tool output. If a callable is passed, it receives the exception and
its return value is used as the tool output.
Callable handlers may return either a string or a list of message
content blocks. If the tool was invoked with a `tool_call_id`, the handled
content is wrapped in a `ToolMessage` with `status="error"`.
"""
handle_validation_error: (
bool | str | Callable[[ValidationError | ValidationErrorV1], str] | None
@@ -1182,16 +1201,23 @@ def _handle_validation_error(
def _handle_tool_error(
e: ToolException,
*,
flag: Literal[True] | str | Callable[[ToolException], str] | None,
) -> str:
"""Handle tool execution errors based on the configured flag.
flag: Literal[True]
| str
| Callable[[ToolException], ToolExceptionHandlerOutput]
| None,
) -> ToolExceptionHandlerOutput:
"""Convert a `ToolException` into handled tool output content.
Args:
e: The tool exception that occurred.
flag: How to handle the error (`bool`, `str`, or `Callable`).
flag: How to handle the error. `True` uses the exception message, a string
replaces the message, and a callable computes replacement content from
the exception.
Returns:
The error message to return.
The handled error content. This may be plain text or structured message
content blocks; callers pass it through normal tool
output formatting.
Raises:
ValueError: If the flag type is unexpected.

View File

@@ -826,6 +826,23 @@ def test_exception_handling_callable() -> None:
assert expected == actual
def test_exception_handling_callable_message_content_blocks() -> None:
expected: list[str | dict[str, Any]] = [{"type": "text", "text": "handled error"}]
def handling(e: ToolException) -> list[str | dict[str, Any]]:
return expected
tool_ = _FakeExceptionTool(handle_tool_error=handling)
actual = tool_.invoke(
{"type": "tool_call", "args": {}, "name": "exception", "id": "call_1"}
)
assert isinstance(actual, ToolMessage)
assert actual.content == expected
assert actual.status == "error"
assert actual.tool_call_id == "call_1"
def test_exception_handling_non_tool_exception() -> None:
tool_ = _FakeExceptionTool(exception=ValueError("some error"))
with pytest.raises(ValueError, match="some error"):
@@ -857,6 +874,23 @@ async def test_async_exception_handling_callable() -> None:
assert expected == actual
async def test_async_exception_handling_callable_message_content_blocks() -> None:
expected: list[str | dict[str, Any]] = [{"type": "text", "text": "handled error"}]
def handling(e: ToolException) -> list[str | dict[str, Any]]:
return expected
tool_ = _FakeExceptionTool(handle_tool_error=handling)
actual = await tool_.ainvoke(
{"type": "tool_call", "args": {}, "name": "exception", "id": "call_1"}
)
assert isinstance(actual, ToolMessage)
assert actual.content == expected
assert actual.status == "error"
assert actual.tool_call_id == "call_1"
async def test_async_exception_handling_non_tool_exception() -> None:
tool_ = _FakeExceptionTool(exception=ValueError("some error"))
with pytest.raises(ValueError, match="some error"):