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, } ],