diff --git a/src/smolagents/_function_type_hints_utils.py b/src/smolagents/_function_type_hints_utils.py index 75d3f9165..1220c96de 100644 --- a/src/smolagents/_function_type_hints_utils.py +++ b/src/smolagents/_function_type_hints_utils.py @@ -43,6 +43,103 @@ } +def _is_pydantic_model(type_hint: type) -> bool: + """ + Check if a type hint represents a Pydantic BaseModel. + + Args: + type_hint: The type to check + + Returns: + bool: True if the type is a Pydantic BaseModel, False otherwise + """ + try: + # Check if pydantic is available + import pydantic + + # Check if the type is a class and inherits from BaseModel + return inspect.isclass(type_hint) and issubclass(type_hint, pydantic.BaseModel) + except ImportError: + # pydantic not available + return False + except TypeError: + # Not a class or other type error + return False + + +def _get_pydantic_json_schema(model_class: type) -> dict: + """ + Get JSON schema from a Pydantic BaseModel. + + Args: + model_class: The Pydantic model class + + Returns: + dict: JSON schema for the model + """ + try: + # Get the schema using Pydantic's built-in method + schema = model_class.model_json_schema() + return schema + except Exception as e: + raise TypeHintParsingException(f"Failed to get Pydantic schema for {model_class}: {e}") + + +def _process_pydantic_schema(schema: dict) -> dict: + """ + Process a Pydantic JSON schema to make it compatible with smolagents. + + This function handles: + - Resolving $refs to inline definitions + - Converting enum constraints to proper format + - Ensuring required fields are properly marked + + Args: + schema: Raw Pydantic JSON schema + + Returns: + dict: Processed schema compatible with smolagents + """ + # Make a copy to avoid modifying the original + processed_schema = copy(schema) + + # Get definitions if they exist + definitions = schema.get("$defs", {}) + + def resolve_refs(obj): + """Recursively resolve $ref references in the schema.""" + if isinstance(obj, dict): + if "$ref" in obj: + # Extract the reference path + ref_path = obj["$ref"] + if ref_path.startswith("#/$defs/"): + def_name = ref_path.split("/")[-1] + if def_name in definitions: + # Replace the $ref with the actual definition + resolved = resolve_refs(definitions[def_name]) + return resolved + # If we cannot resolve the ref, return the object as is + return obj + else: + # Recursively process all values in the dictionary + return {k: resolve_refs(v) for k, v in obj.items()} + elif isinstance(obj, list): + # Recursively process all items in the list + return [resolve_refs(item) for item in obj] + else: + # Return primitive values as-is + return obj + + # Resolve all $refs in the schema + processed_schema = resolve_refs(processed_schema) + + # Remove $defs since we have inlined everything + if "$defs" in processed_schema: + del processed_schema["$defs"] + + return processed_schema + + def get_package_name(import_name: str) -> str: """ Return the package name for a given import name. @@ -328,6 +425,13 @@ def _parse_type_hint(hint: type) -> dict: args = get_args(hint) if origin is None: + # Check if this is a Pydantic model before falling back to regular type parsing + if _is_pydantic_model(hint): + # Get the Pydantic schema and process it + pydantic_schema = _get_pydantic_json_schema(hint) + processed_schema = _process_pydantic_schema(pydantic_schema) + return processed_schema + try: return _get_json_schema_type(hint) except KeyError: diff --git a/src/smolagents/tools.py b/src/smolagents/tools.py index 61edf715f..0b4a2a883 100644 --- a/src/smolagents/tools.py +++ b/src/smolagents/tools.py @@ -21,6 +21,7 @@ import json import logging import os +import re import sys import tempfile import textwrap @@ -46,6 +47,7 @@ TypeHintParsingException, _convert_type_hints_to_json_schema, _get_json_schema_type, + _is_pydantic_model, get_imports, get_json_schema, ) @@ -90,11 +92,125 @@ def new_init(self, *args, **kwargs): "object", "any", "null", + # Additional types that can appear in Pydantic schemas + "enum", # For enum constraints ] CONVERSION_DICT = {"str": "string", "int": "integer", "float": "number"} +def _validate_value_against_schema(value: Any, schema: dict, param_name: str) -> None: + """ + Validate a value against a JSON schema definition. + + This function provides validation for complex schemas, + including those generated from Pydantic models. + + Args: + value: The value to validate + schema: The JSON schema to validate against + param_name: Name of the parameter for error messages + + Raises: + ValueError: If the value does not match the schema + TypeError: If the value has an incorrect type + """ + + # Handle nullable values + is_nullable = schema.get("nullable", False) + if value is None: + if is_nullable: + return + else: + raise ValueError(f"Argument '{param_name}' cannot be null") + + # Get the expected type(s) + expected_type = schema.get("type") + if expected_type is None: + # If no type specified, treat as "any" + return + + # Get actual type + actual_type = _get_json_schema_type(type(value))["type"] + + # Handle "any" type + if expected_type == "any": + return + + # Handle array types + if isinstance(expected_type, list): + valid_types = expected_type + else: + valid_types = [expected_type] + + # Check if actual type matches any of the expected types + type_matches = actual_type in valid_types + + # Allow integer to number conversion + if not type_matches and actual_type == "integer" and "number" in valid_types: + type_matches = True + + if not type_matches: + raise TypeError(f"Argument '{param_name}' has type '{actual_type}' but should be one of {valid_types}") + + # Additional validations for specific types + if actual_type == "array" and isinstance(value, list): + # Validate array items if schema is provided + items_schema = schema.get("items") + if items_schema: + for i, item in enumerate(value): + try: + _validate_value_against_schema(item, items_schema, f"{param_name}[{i}]") + except (ValueError, TypeError) as e: + raise type(e)(f"In {param_name}[{i}]: {str(e)}") + + elif actual_type == "object" and isinstance(value, dict): + # Validate object properties if schema is provided + properties = schema.get("properties", {}) + required = schema.get("required", []) + + # Check required properties + for req_prop in required: + if req_prop not in value: + raise ValueError(f"Required property '{req_prop}' missing in argument '{param_name}'") + + # Validate each property + for prop_name, prop_value in value.items(): + if prop_name in properties: + try: + _validate_value_against_schema(prop_value, properties[prop_name], f"{param_name}.{prop_name}") + except (ValueError, TypeError) as e: + raise type(e)(f"In {param_name}.{prop_name}: {str(e)}") + + # Handle enum constraints + if "enum" in schema: + if value not in schema["enum"]: + raise ValueError(f"Argument '{param_name}' value '{value}' is not in allowed values: {schema['enum']}") + + # Handle string constraints + if actual_type == "string" and isinstance(value, str): + if "minLength" in schema and len(value) < schema["minLength"]: + raise ValueError(f"Argument '{param_name}' length {len(value)} is less than minimum {schema['minLength']}") + if "maxLength" in schema and len(value) > schema["maxLength"]: + raise ValueError( + f"Argument '{param_name}' length {len(value)} is greater than maximum {schema['maxLength']}" + ) + if "pattern" in schema: + if not re.match(schema["pattern"], value): + raise ValueError( + f"Argument '{param_name}' value '{value}' does not match pattern '{schema['pattern']}'" + ) + + # Handle numeric constraints + if actual_type in ["integer", "number"] and isinstance(value, (int, float)): + if "minimum" in schema and value < schema["minimum"]: + raise ValueError(f"Argument '{param_name}' value {value} is less than minimum {schema['minimum']}") + if "maximum" in schema and value > schema["maximum"]: + raise ValueError(f"Argument '{param_name}' value {value} is greater than maximum {schema['maximum']}") + if "multipleOf" in schema and value % schema["multipleOf"] != 0: + raise ValueError(f"Argument '{param_name}' value {value} is not a multiple of {schema['multipleOf']}") + + class BaseTool(ABC): name: str @@ -227,9 +343,20 @@ def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): potential_kwargs = args[0] # If the dictionary keys match our input parameters, convert it to kwargs + # This handles the case where the dict keys are the parameter names if all(key in self.inputs for key in potential_kwargs): args = () kwargs = potential_kwargs + # If we have exactly one input parameter and the dict doesn't match parameter names, + # treat the entire dict as the value for that single parameter + elif len(self.inputs) == 1: + param_name = list(self.inputs.keys())[0] + args = () + kwargs = {param_name: potential_kwargs} + + # Convert dictionary arguments to Pydantic model instances if needed + if kwargs: + kwargs = self._convert_dict_args_to_pydantic_models(kwargs) if sanitize_inputs_outputs: args, kwargs = handle_agent_input_types(*args, **kwargs) @@ -238,6 +365,55 @@ def __call__(self, *args, sanitize_inputs_outputs: bool = False, **kwargs): outputs = handle_agent_output_types(outputs, self.output_type) return outputs + def _convert_dict_args_to_pydantic_models(self, kwargs: dict) -> dict: + """ + Convert dictionary arguments to Pydantic model instances when the type hints indicate Pydantic models. + + Args: + kwargs: Dictionary of keyword arguments + + Returns: + dict: Updated kwargs with Pydantic model instances where appropriate + + Raises: + ValueError: If Pydantic model validation fails + TypeError: If conversion fails due to type mismatch + """ + try: + # Get the forward method signature and type hints + signature = inspect.signature(self.forward) + type_hints = getattr(self.forward, "__annotations__", {}) + + # Process each argument + converted_kwargs = {} + for param_name, param_value in kwargs.items(): + if param_name in signature.parameters and param_name in type_hints: + param_type = type_hints[param_name] + + # Check if the parameter type is a Pydantic model and the value is a dict + if _is_pydantic_model(param_type) and isinstance(param_value, dict): + try: + # Instantiate the Pydantic model from the dictionary + converted_kwargs[param_name] = param_type(**param_value) + except Exception as e: + # Re-raise validation errors with more context + raise ValueError( + f"Failed to create {param_type.__name__} from parameter '{param_name}': {str(e)}" + ) from e + else: + converted_kwargs[param_name] = param_value + else: + converted_kwargs[param_name] = param_value + + return converted_kwargs + + except ValueError: + # Re-raise ValueError (Pydantic validation errors) + raise + except Exception as e: + # Convert other errors to ValueError for consistency + raise ValueError(f"Error in Pydantic model conversion: {e}") from e + def setup(self): """ Overwrite this method here for any operation that is expensive and needs to be executed before you start using @@ -1039,6 +1215,11 @@ def wrapped_function(*args, **kwargs): # - Set the signature of the forward method SimpleTool.forward.__signature__ = new_sig + # Preserve the original function's type hints for Pydantic model conversion + SimpleTool.forward.__annotations__ = ( + tool_function.__annotations__.copy() if hasattr(tool_function, "__annotations__") else {} + ) + # Create and attach the source code of the dynamically created tool class and forward method # - Get the source code of tool_function tool_source = textwrap.dedent(inspect.getsource(tool_function)) @@ -1293,14 +1474,14 @@ def validate_tool_arguments(tool: Tool, arguments: Any) -> None: Checks that all provided arguments match the tool's expected input types and that all required arguments are present. Supports both dictionary arguments and single - value arguments for tools with one input parameter. + value arguments for tools with one input parameter. Now includes updated support + for complex schemas including those from Pydantic models. Args: tool (`Tool`): Tool whose input schema will be used for validation. arguments (`Any`): Arguments to validate. Can be a dictionary mapping argument names to values, or a single value for tools with one input. - Raises: ValueError: If an argument is not in the tool's input schema, if a required argument is missing, or if the argument value doesn't match the expected type. @@ -1311,35 +1492,87 @@ def validate_tool_arguments(tool: Tool, arguments: Any) -> None: - Supports type coercion from integer to number - Handles nullable parameters when explicitly marked in the schema - Accepts "any" type as a wildcard that matches all types + - Updated validation for complex schemas (objects, arrays, enums, constraints) """ if isinstance(arguments, dict): for key, value in arguments.items(): if key not in tool.inputs: raise ValueError(f"Argument {key} is not in the tool's input schema") - actual_type = _get_json_schema_type(type(value))["type"] - expected_type = tool.inputs[key]["type"] - expected_type_is_nullable = tool.inputs[key].get("nullable", False) - - # Type is valid if it matches, is "any", or is null for nullable parameters - if ( - (actual_type != expected_type if isinstance(expected_type, str) else actual_type not in expected_type) - and expected_type != "any" - and not (actual_type == "null" and expected_type_is_nullable) + # Get the full schema for this input + input_schema = tool.inputs[key] + + # Use updated validation if the schema is complex + if isinstance(input_schema, dict) and ( + "properties" in input_schema + or "items" in input_schema + or "enum" in input_schema + or any( + constraint in input_schema + for constraint in ["minimum", "maximum", "minLength", "maxLength", "pattern"] + ) ): - if actual_type == "integer" and expected_type == "number": - continue - raise TypeError(f"Argument {key} has type '{actual_type}' but should be '{tool.inputs[key]['type']}'") + # Use updated validation for complex schemas + _validate_value_against_schema(value, input_schema, key) + else: + # Fall back to original simple validation for backward compatibility + actual_type = _get_json_schema_type(type(value))["type"] + expected_type = input_schema.get("type") if isinstance(input_schema, dict) else input_schema + expected_type_is_nullable = ( + input_schema.get("nullable", False) if isinstance(input_schema, dict) else False + ) + + # Type is valid if it matches, is "any", or is null for nullable parameters + if ( + ( + actual_type != expected_type + if isinstance(expected_type, str) + else actual_type not in expected_type + ) + and expected_type != "any" + and not (actual_type == "null" and expected_type_is_nullable) + ): + if actual_type == "integer" and expected_type == "number": + continue + expected_display = expected_type if isinstance(expected_type, str) else str(expected_type) + raise TypeError(f"Argument {key} has type '{actual_type}' but should be '{expected_display}'") + # Check for missing required arguments for key, schema in tool.inputs.items(): - key_is_nullable = schema.get("nullable", False) + if isinstance(schema, dict): + key_is_nullable = schema.get("nullable", False) + else: + key_is_nullable = False if key not in arguments and not key_is_nullable: raise ValueError(f"Argument {key} is required") return None else: - expected_type = list(tool.inputs.values())[0]["type"] - if _get_json_schema_type(type(arguments))["type"] != expected_type and not expected_type == "any": - raise TypeError(f"Argument has type '{type(arguments).__name__}' but should be '{expected_type}'") + # Handle single argument case + if len(tool.inputs) != 1: + raise ValueError("Single argument provided but tool expects multiple arguments") + + input_key = list(tool.inputs.keys())[0] + input_schema = tool.inputs[input_key] + + # Use updated validation for complex schemas + if isinstance(input_schema, dict) and ( + "properties" in input_schema + or "items" in input_schema + or "enum" in input_schema + or any( + constraint in input_schema + for constraint in ["minimum", "maximum", "minLength", "maxLength", "pattern"] + ) + ): + _validate_value_against_schema(arguments, input_schema, input_key) + else: + # Fall back to original simple validation + expected_type = input_schema.get("type") if isinstance(input_schema, dict) else input_schema + actual_type = _get_json_schema_type(type(arguments))["type"] + if actual_type != expected_type and expected_type != "any": + if actual_type == "integer" and expected_type == "number": + return + raise TypeError(f"Argument has type '{actual_type}' but should be '{expected_type}'") __all__ = [ diff --git a/tests/test_function_type_hints_utils.py b/tests/test_function_type_hints_utils.py index 13b279069..43cd69980 100644 --- a/tests/test_function_type_hints_utils.py +++ b/tests/test_function_type_hints_utils.py @@ -12,11 +12,20 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any +from enum import Enum +from typing import Any, List, Literal, Optional import pytest -from smolagents._function_type_hints_utils import DocstringParsingException, get_imports, get_json_schema +from smolagents._function_type_hints_utils import ( + DocstringParsingException, + _get_pydantic_json_schema, + _is_pydantic_model, + _parse_type_hint, + _process_pydantic_schema, + get_imports, + get_json_schema, +) @pytest.fixture @@ -512,3 +521,125 @@ class TestGetCode: ) def test_get_imports(self, code: str, expected: list[str]): assert sorted(get_imports(code)) == sorted(expected) + + +class TestPydanticIntegration: + """Test Pydantic BaseModel integration with type hint parsing.""" + + @pytest.fixture + def pydantic_available(self): + """Check if Pydantic is available for testing.""" + try: + import importlib.util + + if importlib.util.find_spec("pydantic") is None: + pytest.skip("Pydantic not available") + except ImportError: + pytest.skip("Pydantic not available") + + def test_pydantic_model_detection(self, pydantic_available): + """Test that Pydantic models are correctly detected.""" + import pydantic + + class TestModel(pydantic.BaseModel): + name: str + age: int + + # Test detection + assert _is_pydantic_model(TestModel), "Should detect Pydantic model" + assert not _is_pydantic_model(str), "Should not detect regular types as Pydantic" + assert not _is_pydantic_model(dict), "Should not detect regular types as Pydantic" + + def test_pydantic_schema_generation(self, pydantic_available): + """Test that Pydantic schemas are correctly generated.""" + import pydantic + + class TestModel(pydantic.BaseModel): + name: str + age: int + email: str | None = None + + # Test schema generation + schema = _get_pydantic_json_schema(TestModel) + assert isinstance(schema, dict) + assert "type" in schema + assert "properties" in schema + assert "name" in schema["properties"] + assert "age" in schema["properties"] + assert "email" in schema["properties"] + + # Test schema processing + processed_schema = _process_pydantic_schema(schema) + assert isinstance(processed_schema, dict) + # Should not have $defs after processing + assert "$defs" not in processed_schema + + def test_pydantic_type_hint_parsing(self, pydantic_available): + """Test that Pydantic models are correctly parsed as type hints.""" + import pydantic + + class SimpleModel(pydantic.BaseModel): + value: str + count: int + + result = _parse_type_hint(SimpleModel) + assert isinstance(result, dict) + assert "type" in result + assert "properties" in result + assert "value" in result["properties"] + assert "count" in result["properties"] + + def test_pydantic_with_complex_types(self, pydantic_available): + """Test Pydantic models with complex field types.""" + import pydantic + + class ComplexModel(pydantic.BaseModel): + items: List[str] + metadata: Optional[dict] = None + status: Literal["active", "inactive"] = "active" + + result = _parse_type_hint(ComplexModel) + assert isinstance(result, dict) + assert "properties" in result + assert "items" in result["properties"] + assert "metadata" in result["properties"] + assert "status" in result["properties"] + + def test_pydantic_with_enums(self, pydantic_available): + """Test Pydantic models with enum constraints.""" + import pydantic + + class Status(str, Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + class ModelWithEnum(pydantic.BaseModel): + status: Status + name: str + + result = _parse_type_hint(ModelWithEnum) + assert isinstance(result, dict) + assert "properties" in result + assert "status" in result["properties"] + + def test_pydantic_schema_with_refs(self, pydantic_available): + """Test that $refs in Pydantic schemas are properly resolved.""" + import pydantic + + class Address(pydantic.BaseModel): + street: str + city: str + + class Person(pydantic.BaseModel): + name: str + address: Address + + schema = _get_pydantic_json_schema(Person) + processed_schema = _process_pydantic_schema(schema) + + # After processing, refs should be resolved + assert "$defs" not in processed_schema + if "properties" in processed_schema and "address" in processed_schema["properties"]: + address_schema = processed_schema["properties"]["address"] + # Should have properties inlined, not a $ref + assert "$ref" not in address_schema or "properties" in address_schema diff --git a/tests/test_tools.py b/tests/test_tools.py index dcec2c6e5..e616e814b 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -15,6 +15,7 @@ import inspect import os import warnings +from enum import Enum from textwrap import dedent from typing import Any, Literal from unittest.mock import MagicMock, patch @@ -1064,3 +1065,236 @@ def test_tool(param: type_hint = default) -> str: else: # Should not raise any exception validate_tool_arguments(test_tool, input_dict) + + +class TestPydanticToolIntegration: + """Test Pydantic BaseModel integration with Tool creation and validation.""" + + @pytest.fixture + def pydantic_available(self): + """Check if Pydantic is available for testing.""" + try: + import importlib.util + + if importlib.util.find_spec("pydantic") is None: + pytest.skip("Pydantic not available") + except ImportError: + pytest.skip("Pydantic not available") + + def test_tool_with_pydantic_model(self, pydantic_available): + """Test creating a tool that uses Pydantic models as input.""" + import pydantic + + class PersonInfo(pydantic.BaseModel): + """Information about a person.""" + + name: str + age: int + email: str | None = None + + @tool + def process_person(person: PersonInfo) -> str: + """ + Process information about a person. + + Args: + person: Person information to process + + Returns: + A formatted string with the person's information + """ + email_part = f" (email: {person.email})" if person.email else "" + return f"Person: {person.name}, age {person.age}{email_part}" + + # Test tool creation + assert process_person.name == "process_person" + assert isinstance(process_person.inputs, dict) + assert "person" in process_person.inputs + + # Check that the Pydantic schema was properly converted + person_schema = process_person.inputs["person"] + assert isinstance(person_schema, dict) + + def test_pydantic_tool_validation_success(self, pydantic_available): + """Test successful validation of Pydantic tool arguments.""" + import pydantic + + class UserData(pydantic.BaseModel): + username: str + active: bool = True + + @tool + def process_user(user: UserData) -> str: + """ + Process user data. + + Args: + user: User data to process + + Returns: + User summary + """ + return f"User {user.username} is {'active' if user.active else 'inactive'}" + + # Test with valid input + valid_input = {"user": {"username": "alice", "active": True}} + + # Should not raise any exception + validate_tool_arguments(process_user, valid_input) + + def test_pydantic_tool_validation_with_optional_fields(self, pydantic_available): + """Test validation with optional Pydantic fields.""" + import pydantic + + class ProfileData(pydantic.BaseModel): + name: str + bio: str | None = None + age: int | None = None + + @tool + def create_profile(profile: ProfileData) -> str: + """ + Create a user profile. + + Args: + profile: Profile data + + Returns: + Profile summary + """ + return f"Profile for {profile.name}" + + # Test with minimal required fields + minimal_input = {"profile": {"name": "Bob"}} + + # Should not raise any exception + validate_tool_arguments(create_profile, minimal_input) + + # Test with all fields + complete_input = {"profile": {"name": "Alice", "bio": "Software engineer", "age": 30}} + + # Should not raise any exception + validate_tool_arguments(create_profile, complete_input) + + def test_pydantic_tool_validation_failure(self, pydantic_available): + """Test validation failures with Pydantic tool arguments.""" + import pydantic + + class StrictData(pydantic.BaseModel): + count: int + message: str + + @tool + def process_strict(data: StrictData) -> str: + """ + Process strict data. + + Args: + data: Strict data requirements + + Returns: + Processing result + """ + return f"Processed {data.count}: {data.message}" + + # Test with missing required field + invalid_input = { + "data": { + "count": 5 + # missing "message" + } + } + + # Should raise validation error for missing required field + with pytest.raises(ValueError, match="Required property.*missing"): + validate_tool_arguments(process_strict, invalid_input) + + def test_pydantic_tool_with_enum_constraints(self, pydantic_available): + """Test Pydantic tool with enum field constraints.""" + import pydantic + + class Priority(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + class TaskData(pydantic.BaseModel): + title: str + priority: Priority = Priority.MEDIUM + + @tool + def create_task(task: TaskData) -> str: + """ + Create a task. + + Args: + task: Task data + + Returns: + Task summary + """ + return f"Task '{task.title}' with priority {task.priority.value}" + + # Test with valid enum value + valid_input = {"task": {"title": "Fix bug", "priority": "high"}} + + # Should not raise any exception + validate_tool_arguments(create_task, valid_input) + + # Test with invalid enum value + invalid_input = { + "task": { + "title": "Fix bug", + "priority": "urgent", # Not in enum + } + } + + # Should raise validation error for invalid enum value + with pytest.raises(ValueError, match="not in allowed values"): + validate_tool_arguments(create_task, invalid_input) + + def test_pydantic_tool_with_nested_models(self, pydantic_available): + """Test Pydantic tool with nested model structures.""" + import pydantic + + class Address(pydantic.BaseModel): + street: str + city: str + + class Contact(pydantic.BaseModel): + name: str + address: Address + + @tool + def process_contact(contact: Contact) -> str: + """ + Process contact information. + + Args: + contact: Contact data + + Returns: + Contact summary + """ + return f"{contact.name} lives at {contact.address.street}, {contact.address.city}" + + # Test with valid nested structure + valid_input = {"contact": {"name": "John Doe", "address": {"street": "123 Main St", "city": "Anytown"}}} + + # Should not raise any exception + validate_tool_arguments(process_contact, valid_input) + + # Test with missing nested field + invalid_input = { + "contact": { + "name": "John Doe", + "address": { + "street": "123 Main St" + # missing "city" + }, + } + } + + # Should raise validation error for missing nested field + with pytest.raises(ValueError, match="Required property.*missing"): + validate_tool_arguments(process_contact, invalid_input)