Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions src/anthropic/lib/tools/_beta_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: ...
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]):
Expand All @@ -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:
Expand Down
58 changes: 55 additions & 3 deletions tests/lib/tools/test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion tests/lib/tools/test_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
],
Expand Down