From c3f8bf09d67d48e7578c0a642f1195529f141c35 Mon Sep 17 00:00:00 2001 From: Vitor <155980720+vitorcomin33@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:47:24 -0300 Subject: [PATCH 1/2] fix(tools): include field details in beta tool argument errors --- src/anthropic/lib/tools/_beta_functions.py | 63 ++++++++++++++++++++-- tests/lib/tools/test_functions.py | 58 ++++++++++++++++++-- tests/lib/tools/test_runners.py | 2 +- 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/src/anthropic/lib/tools/_beta_functions.py b/src/anthropic/lib/tools/_beta_functions.py index fd4540da8..7ddfdaee8 100644 --- a/src/anthropic/lib/tools/_beta_functions.py +++ b/src/anthropic/lib/tools/_beta_functions.py @@ -2,6 +2,7 @@ import sys import logging +import functools from abc import ABC, abstractmethod from typing import Any, Union, Generic, TypeVar, Callable, Iterable, Coroutine, cast, overload from inspect import isawaitable, isasyncgenfunction, iscoroutinefunction, isgeneratorfunction @@ -70,6 +71,58 @@ def __init__(self, content: BetaFunctionToolResultType) -> None: AsyncFunctionT = TypeVar("AsyncFunctionT", bound=AsyncFunction) +class _ToolBodyValidationError(Exception): + """Marker for a `ValidationError` raised by the tool body, not by argument validation. + + `pydantic.validate_call` validates the arguments and then calls the function in one step, so a + `ValidationError` raised inside the body (for example while constructing a model from bad data) is + indistinguishable from one raised because the model's arguments were wrong. Wrapping the body lets us tell + them apart and re-raise the body's error unchanged instead of reporting it as a bad tool call. + """ + + def __init__(self, error: pydantic.ValidationError) -> None: + super().__init__(str(error)) + self.error = error + + +def _wrap_body_validation_errors(func: FunctionT) -> FunctionT: + """Re-raise a `ValidationError` from inside `func` as `_ToolBodyValidationError` so argument validation (which + `validate_call` runs before calling `func`) stays distinguishable from it.""" + if iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except pydantic.ValidationError as e: + raise _ToolBodyValidationError(e) from e + + return cast(FunctionT, async_wrapper) + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except pydantic.ValidationError as e: + raise _ToolBodyValidationError(e) from e + + return cast(FunctionT, wrapper) + + +def _validation_error_summary(exc: pydantic.ValidationError) -> str: + """The field errors of `exc` as one short line, e.g. `celsius: Input should be a valid integer`. + + Only the location and message are included: the input values pydantic reports alongside them are model-supplied + and can be large or contain characters that don't belong in a tool result. + """ + parts: list[str] = [] + for error in exc.errors(): + loc = ".".join(str(part) for part in error["loc"]) + message = error["msg"] + parts.append(f"{loc}: {message}" if loc else message) + return "; ".join(parts) + + class BetaBuiltinFunctionTool(ABC): @abstractmethod def to_dict(self) -> BetaToolUnionParam: ... @@ -165,7 +218,7 @@ def __init__( raise RuntimeError("Tool functions are only supported with Pydantic v2") self.func = func - self._func_with_validate = pydantic.validate_call(func) + self._func_with_validate = pydantic.validate_call(_wrap_body_validation_errors(func)) self.name = name or func.__name__ self._defer_loading = defer_loading self._cache_control = cache_control @@ -279,8 +332,10 @@ def call(self, input: object) -> BetaFunctionToolResultType: try: return self._func_with_validate(**cast(Any, input)) + except _ToolBodyValidationError as e: + raise e.error from None except pydantic.ValidationError as e: - raise ValueError(f"Invalid arguments for function {self.name}") from e + raise ValueError(f"Invalid arguments for function {self.name}: {_validation_error_summary(e)}") from e class BetaAsyncFunctionTool(BaseFunctionTool[AsyncFunctionT]): @@ -293,8 +348,10 @@ async def call(self, input: object) -> BetaFunctionToolResultType: try: return await self._func_with_validate(**cast(Any, input)) + except _ToolBodyValidationError as e: + raise e.error from None except pydantic.ValidationError as e: - raise ValueError(f"Invalid arguments for function {self.name}") from e + raise ValueError(f"Invalid arguments for function {self.name}: {_validation_error_summary(e)}") from e def _is_sync_cm_factory(fn: object) -> bool: diff --git a/tests/lib/tools/test_functions.py b/tests/lib/tools/test_functions.py index 1152f5d85..ab7a7f0c7 100644 --- a/tests/lib/tools/test_functions.py +++ b/tests/lib/tools/test_functions.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Iterator, Awaitable, AsyncIterator import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from anthropic import beta_tool from anthropic._compat import PYDANTIC_V1 @@ -91,13 +91,13 @@ def add_numbers(a: int, b: int) -> str: pytest.param( {"a": "not a number", "b": 3}, ValueError, - "Invalid arguments for function add_numbers", + "Invalid arguments for function add_numbers: a: Input should be a valid integer", id="invalid_argument_type", ), pytest.param( {"b": 3}, ValueError, - "Invalid arguments for function add_numbers", + "Invalid arguments for function add_numbers: a: Missing required argument", id="missing_required_argument", ), pytest.param( @@ -119,6 +119,58 @@ def add_numbers(a: int, b: int) -> str: with pytest.raises(expected_error_type, match=expected_error_msg): function_tool.call(input_data) + def test_invalid_arguments_message_names_the_field_and_reason(self) -> None: + @beta_tool + def set_temp(city: str, celsius: int) -> str: + """Set temperature.""" + return f"{city} {celsius}" + + with pytest.raises( + ValueError, match=r"Invalid arguments for function set_temp: celsius: Input should be a valid integer" + ): + set_temp.call({"city": "Paris", "celsius": "hot"}) + + def test_validation_error_from_the_body_is_not_reported_as_invalid_arguments(self) -> None: + class Payload(BaseModel): + count: int + + @beta_tool + def parse(count: str) -> str: + """Parse a payload.""" + return str(Payload(count=count).count) + + with pytest.raises(ValidationError, match="count"): + parse.call({"count": "not a number"}) + + async def test_async_validation_error_from_the_body_is_not_reported_as_invalid_arguments(self) -> None: + from anthropic.lib.tools._beta_functions import beta_async_tool + + class Payload(BaseModel): + count: int + + async def parse(count: str) -> str: + """Parse a payload.""" + return str(Payload(count=count).count) + + tool = beta_async_tool(parse) + + with pytest.raises(ValidationError, match="count"): + await tool.call({"count": "not a number"}) + + async def test_async_invalid_arguments_message_names_the_field_and_reason(self) -> None: + from anthropic.lib.tools._beta_functions import beta_async_tool + + async def set_temp(city: str, celsius: int) -> str: + """Set temperature.""" + return f"{city} {celsius}" + + tool = beta_async_tool(set_temp) + + with pytest.raises( + ValueError, match=r"Invalid arguments for function set_temp: celsius: Input should be a valid integer" + ): + await tool.call({"city": "Paris", "celsius": "hot"}) + def test_custom_name_and_description(self) -> None: def some_function(x: int) -> str: """Original description.""" diff --git a/tests/lib/tools/test_runners.py b/tests/lib/tools/test_runners.py index d4dac0ead..7188877d4 100644 --- a/tests/lib/tools/test_runners.py +++ b/tests/lib/tools/test_runners.py @@ -2006,7 +2006,7 @@ async def get_weather_async(city: str) -> str: { "type": "tool_result", "tool_use_id": "toolu_weather", - "content": "ValueError('Invalid arguments for function get_weather')", + "content": "ValueError('Invalid arguments for function get_weather: city: Missing required argument')", "is_error": True, } ], From baafb32b1e7ab123b817999fdd7577357df7620c Mon Sep 17 00:00:00 2001 From: Vitor <155980720+vitorcomin33@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:50:24 -0300 Subject: [PATCH 2/2] fix(tools): support callable beta tools --- src/anthropic/lib/tools/_beta_functions.py | 65 +++++++++++++++++++--- tests/lib/tools/test_functions.py | 30 ++++++++++ 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/anthropic/lib/tools/_beta_functions.py b/src/anthropic/lib/tools/_beta_functions.py index 7ddfdaee8..d2cdd99a0 100644 --- a/src/anthropic/lib/tools/_beta_functions.py +++ b/src/anthropic/lib/tools/_beta_functions.py @@ -5,7 +5,7 @@ import functools from abc import ABC, abstractmethod from typing import Any, Union, Generic, TypeVar, Callable, Iterable, Coroutine, cast, overload -from inspect import isawaitable, isasyncgenfunction, iscoroutinefunction, isgeneratorfunction +from inspect import isawaitable, isasyncgenfunction, iscoroutinefunction, isgeneratorfunction, signature from collections.abc import Awaitable from typing_extensions import Literal, TypeAlias, override @@ -71,6 +71,26 @@ def __init__(self, content: BetaFunctionToolResultType) -> None: AsyncFunctionT = TypeVar("AsyncFunctionT", bound=AsyncFunction) +def _is_async_callable(func: object) -> bool: + if isinstance(func, functools.partial): + return _is_async_callable(func.func) + return iscoroutinefunction(func) or iscoroutinefunction(getattr(func, "__call__", None)) + + +def _get_callable_name(func: object) -> str: + if isinstance(func, functools.partial): + return _get_callable_name(func.func) + return cast(str, getattr(func, "__name__", type(func).__name__)) + + +def _get_callable_docstring(func: object) -> str | None: + if isinstance(func, functools.partial): + return _get_callable_docstring(func.func) + if not hasattr(func, "__annotations__"): + func = getattr(func, "__call__", func) + return getattr(func, "__doc__", None) + + class _ToolBodyValidationError(Exception): """Marker for a `ValidationError` raised by the tool body, not by argument validation. @@ -88,9 +108,36 @@ def __init__(self, error: pydantic.ValidationError) -> None: def _wrap_body_validation_errors(func: FunctionT) -> FunctionT: """Re-raise a `ValidationError` from inside `func` as `_ToolBodyValidationError` so argument validation (which `validate_call` runs before calling `func`) stays distinguishable from it.""" - if iscoroutinefunction(func): - - @functools.wraps(func) + # A partial's signature and annotations come from its underlying function. + # Keep a regular wrapper (rather than passing the partial to validate_call), + # since Pydantic reads annotations from the wrapper and partial objects do + # not expose them. Its signature still reflects the bound arguments. + if isinstance(func, functools.partial): + wrapped_func = _wrap_body_validation_errors(func.func) + wrapped_partial = functools.partial(wrapped_func, *func.args, **(func.keywords or {})) + if _is_async_callable(func): + + @functools.wraps(wrapped_func) + async def async_partial_wrapper(*args: Any, **kwargs: Any) -> Any: + return await wrapped_partial(*args, **kwargs) + + async_partial_wrapper.__signature__ = signature(func) # type: ignore[attr-defined] + return cast(FunctionT, async_partial_wrapper) + + @functools.wraps(wrapped_func) + def partial_wrapper(*args: Any, **kwargs: Any) -> Any: + return wrapped_partial(*args, **kwargs) + + partial_wrapper.__signature__ = signature(func) # type: ignore[attr-defined] + return cast(FunctionT, partial_wrapper) + + # `functools.wraps` must use the callable method for callable instances: + # the instance itself has no function annotations or globals for pydantic + # to resolve (and the wrapper's `Any` annotations may then raise NameError). + wrapped_func = func if hasattr(func, "__annotations__") else getattr(func, "__call__", func) + if iscoroutinefunction(wrapped_func): + + @functools.wraps(wrapped_func) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: try: return await func(*args, **kwargs) @@ -99,7 +146,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: return cast(FunctionT, async_wrapper) - @functools.wraps(func) + @functools.wraps(wrapped_func) def wrapper(*args: Any, **kwargs: Any) -> Any: try: return func(*args, **kwargs) @@ -219,7 +266,7 @@ def __init__( self.func = func self._func_with_validate = pydantic.validate_call(_wrap_body_validation_errors(func)) - self.name = name or func.__name__ + self.name = name or _get_callable_name(func) self._defer_loading = defer_loading self._cache_control = cache_control self._allowed_callers = allowed_callers @@ -263,7 +310,7 @@ def to_dict(self) -> BetaToolParam: @cached_property def _parsed_docstring(self) -> docstring_parser.Docstring: - return docstring_parser.parse(self.func.__doc__ or "") + return docstring_parser.parse(_get_callable_docstring(self.func) or "") def _get_description_from_docstring(self) -> str: """Extract description from parsed docstring.""" @@ -324,7 +371,7 @@ def _adapter(self) -> TypeAdapter[Any]: class BetaFunctionTool(BaseFunctionTool[FunctionT]): def call(self, input: object) -> BetaFunctionToolResultType: - if iscoroutinefunction(self.func): + if _is_async_callable(self.func): raise RuntimeError("Cannot call a coroutine function synchronously. Use `@async_tool` instead.") if not is_dict(input): @@ -340,7 +387,7 @@ def call(self, input: object) -> BetaFunctionToolResultType: class BetaAsyncFunctionTool(BaseFunctionTool[AsyncFunctionT]): async def call(self, input: object) -> BetaFunctionToolResultType: - if not iscoroutinefunction(self.func): + if not _is_async_callable(self.func): raise RuntimeError("Cannot call a synchronous function asynchronously. Use `@tool` instead.") if not is_dict(input): diff --git a/tests/lib/tools/test_functions.py b/tests/lib/tools/test_functions.py index ab7a7f0c7..584a01a91 100644 --- a/tests/lib/tools/test_functions.py +++ b/tests/lib/tools/test_functions.py @@ -1,5 +1,6 @@ from __future__ import annotations +import functools from typing import Any, cast from contextlib import contextmanager, asynccontextmanager from collections.abc import Callable, Iterator, Awaitable, AsyncIterator @@ -85,6 +86,35 @@ def add_numbers(a: int, b: int) -> str: assert result == "8" + def test_callable_instance_preserves_annotations_for_validation(self) -> None: + class Adder: + def __call__(self, value: int) -> str: + return str(value + 1) + + function_tool = beta_tool(Adder(), name="adder", description="Add one.") + + assert function_tool.input_schema["properties"] == {"value": {"title": "Value", "type": "integer"}} + assert function_tool.call({"value": 2}) == "3" + with pytest.raises(ValueError, match="value: Input should be a valid integer"): + function_tool.call({"value": "invalid"}) + + def test_partial_preserves_remaining_signature_for_validation(self) -> None: + def add(left: int, right: int) -> str: + """Add two numbers.""" + return str(left + right) + + function_tool = beta_tool(functools.partial(add, right=2)) + + assert function_tool.name == "add" + assert function_tool.description == "Add two numbers." + assert function_tool.input_schema["properties"] == { + "left": {"title": "Left", "type": "integer"}, + "right": {"title": "Right", "type": "integer", "default": 2}, + } + assert function_tool.call({"left": 3}) == "5" + with pytest.raises(ValueError, match="left: Input should be a valid integer"): + function_tool.call({"left": "invalid"}) + @pytest.mark.parametrize( "input_data, expected_error_type, expected_error_msg", [