From 63fab0c40700340c6320c8873107eac80471e9eb Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 04:50:49 +0530 Subject: [PATCH 01/12] fix: validate and coerce function tool argument types (#4612) _preprocess_args now uses pydantic.TypeAdapter to validate and coerce all annotated argument types (primitives, enums, containers), not just Pydantic models. Invalid arguments return a descriptive error to the LLM so it can self-correct and retry, matching the existing pattern for missing mandatory args. - Coerces compatible types (e.g. str "42" -> int 42, str "red" -> Color.RED) - Returns validation errors to LLM for incompatible types (e.g. "foobar" -> int) - Existing Pydantic BaseModel handling unchanged (graceful failure) - Updated all 3 call sites (FunctionTool, CrewAITool, sync tool path) --- src/google/adk/flows/llm_flows/functions.py | 11 +- src/google/adk/tools/crewai_tool.py | 12 +- src/google/adk/tools/function_tool.py | 123 ++++++---- .../test_function_tool_arg_validation.py | 211 ++++++++++++++++++ .../tools/test_function_tool_pydantic.py | 14 +- 5 files changed, 316 insertions(+), 55 deletions(-) create mode 100644 tests/unittests/tools/test_function_tool_arg_validation.py diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index 6082e1a745..bc1e792d24 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -147,7 +147,16 @@ async def _call_tool_in_thread_pool( # For sync FunctionTool, call the underlying function directly def run_sync_tool(): if isinstance(tool, FunctionTool): - args_to_call = tool._preprocess_args(args) + args_to_call, validation_errors = tool._preprocess_args(args) + if validation_errors: + validation_errors_str = '\n'.join(validation_errors) + return { + 'error': ( + f'Invoking `{tool.name}()` failed due to argument' + f' validation errors:\n{validation_errors_str}\nYou could' + ' retry calling this tool with corrected argument types.' + ) + } signature = inspect.signature(tool.func) valid_params = {param for param in signature.parameters} if 'tool_context' in valid_params: diff --git a/src/google/adk/tools/crewai_tool.py b/src/google/adk/tools/crewai_tool.py index f8022e117e..d15bdae40f 100644 --- a/src/google/adk/tools/crewai_tool.py +++ b/src/google/adk/tools/crewai_tool.py @@ -73,8 +73,16 @@ async def run_async( duplicates, but is re-added if the function signature explicitly requires it as a parameter. """ - # Preprocess arguments (includes Pydantic model conversion) - args_to_call = self._preprocess_args(args) + # Preprocess arguments (includes Pydantic model conversion and type + # validation) + args_to_call, validation_errors = self._preprocess_args(args) + + if validation_errors: + validation_errors_str = '\n'.join(validation_errors) + error_str = f"""Invoking `{self.name}()` failed due to argument validation errors: +{validation_errors_str} +You could retry calling this tool with corrected argument types.""" + return {'error': error_str} signature = inspect.signature(self.func) valid_params = {param for param in signature.parameters} diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 6b8496dc58..f4b7babf7a 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -97,68 +97,101 @@ def _get_declaration(self) -> Optional[types.FunctionDeclaration]: return function_decl - def _preprocess_args(self, args: dict[str, Any]) -> dict[str, Any]: - """Preprocess and convert function arguments before invocation. + def _preprocess_args( + self, args: dict[str, Any] + ) -> tuple[dict[str, Any], list[str]]: + """Preprocess, validate, and convert function arguments before invocation. - Currently handles: + Handles: - Converting JSON dictionaries to Pydantic model instances where expected - - Future extensions could include: - - Type coercion for other complex types - - Validation and sanitization - - Custom conversion logic + - Validating and coercing primitive types (int, float, str, bool) + - Validating enum values + - Validating container types (list[int], dict[str, float], etc.) Args: args: Raw arguments from the LLM tool call Returns: - Processed arguments ready for function invocation + A tuple of (processed_args, validation_errors). If validation_errors is + non-empty, the caller should return the errors to the LLM instead of + invoking the function. """ signature = inspect.signature(self.func) converted_args = args.copy() + validation_errors = [] for param_name, param in signature.parameters.items(): - if param_name in args and param.annotation != inspect.Parameter.empty: - target_type = param.annotation - - # Handle Optional[PydanticModel] types - if get_origin(param.annotation) is Union: - union_args = get_args(param.annotation) - # Find the non-None type in Optional[T] (which is Union[T, None]) - non_none_types = [arg for arg in union_args if arg is not type(None)] - if len(non_none_types) == 1: - target_type = non_none_types[0] - - # Check if the target type is a Pydantic model - if inspect.isclass(target_type) and issubclass( - target_type, pydantic.BaseModel - ): - # Skip conversion if the value is None and the parameter is Optional - if args[param_name] is None: - continue - - # Convert to Pydantic model if it's not already the correct type - if not isinstance(args[param_name], target_type): - try: - converted_args[param_name] = target_type.model_validate( - args[param_name] - ) - except Exception as e: - logger.warning( - f"Failed to convert argument '{param_name}' to Pydantic model" - f' {target_type.__name__}: {e}' - ) - # Keep the original value if conversion fails - pass - - return converted_args + if param_name not in args or param.annotation is inspect.Parameter.empty: + continue + + target_type = param.annotation + is_optional = False + + # Handle Optional[T] (Union[T, None]) - unwrap to get inner type + if get_origin(param.annotation) is Union: + union_args = get_args(param.annotation) + non_none_types = [arg for arg in union_args if arg is not type(None)] + if len(non_none_types) == 1: + target_type = non_none_types[0] + is_optional = len(union_args) != len(non_none_types) + + # Pydantic models: keep existing graceful-failure behavior + if inspect.isclass(target_type) and issubclass( + target_type, pydantic.BaseModel + ): + if args[param_name] is None: + continue + if not isinstance(args[param_name], target_type): + try: + converted_args[param_name] = target_type.model_validate( + args[param_name] + ) + except Exception as e: + logger.warning( + f"Failed to convert argument '{param_name}' to Pydantic model" + f' {target_type.__name__}: {e}' + ) + continue + + # Skip None values only for Optional params + if args[param_name] is None and is_optional: + continue + + # Validate and coerce all other annotated types using TypeAdapter. + # This handles primitives (int, float, str, bool), enums, and + # container types (list[int], dict[str, float], etc.). + try: + adapter = pydantic.TypeAdapter(target_type) + converted_args[param_name] = adapter.validate_python( + args[param_name] + ) + except pydantic.ValidationError as e: + validation_errors.append( + f"Parameter '{param_name}': expected type '{target_type}'," + f' validation error: {e}' + ) + except Exception: + # TypeAdapter could not handle this annotation (e.g. a forward + # reference string). Skip validation silently. + pass + + return converted_args, validation_errors @override async def run_async( self, *, args: dict[str, Any], tool_context: ToolContext ) -> Any: - # Preprocess arguments (includes Pydantic model conversion) - args_to_call = self._preprocess_args(args) + # Preprocess arguments (includes Pydantic model conversion and type + # validation). Validation errors are returned to the LLM so it can + # self-correct and retry with proper argument types. + args_to_call, validation_errors = self._preprocess_args(args) + + if validation_errors: + validation_errors_str = '\n'.join(validation_errors) + error_str = f"""Invoking `{self.name}()` failed due to argument validation errors: +{validation_errors_str} +You could retry calling this tool with corrected argument types.""" + return {'error': error_str} signature = inspect.signature(self.func) valid_params = {param for param in signature.parameters} diff --git a/tests/unittests/tools/test_function_tool_arg_validation.py b/tests/unittests/tools/test_function_tool_arg_validation.py new file mode 100644 index 0000000000..a45ecfb23e --- /dev/null +++ b/tests/unittests/tools/test_function_tool_arg_validation.py @@ -0,0 +1,211 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. + +"""Tests for FunctionTool argument type validation and coercion.""" + +from enum import Enum +from typing import Optional +from unittest.mock import MagicMock + +from google.adk.agents.invocation_context import InvocationContext +from google.adk.sessions.session import Session +from google.adk.tools.function_tool import FunctionTool +from google.adk.tools.tool_context import ToolContext +import pytest + + +class Color(Enum): + RED = "red" + GREEN = "green" + BLUE = "blue" + + +def int_func(num: int) -> int: + return num + + +def float_func(val: float) -> float: + return val + + +def bool_func(flag: bool) -> bool: + return flag + + +def enum_func(color: Color) -> str: + return color.value + + +def list_int_func(nums: list[int]) -> list[int]: + return nums + + +def optional_int_func(num: Optional[int] = None) -> Optional[int]: + return num + + +def multi_param_func(name: str, count: int, flag: bool) -> dict: + return {"name": name, "count": count, "flag": flag} + + +# --- _preprocess_args coercion tests --- + + +class TestArgCoercion: + + def test_string_to_int(self): + tool = FunctionTool(int_func) + args, errors = tool._preprocess_args({"num": "42"}) + assert errors == [] + assert args["num"] == 42 + assert isinstance(args["num"], int) + + def test_float_to_int(self): + """Pydantic lax mode truncates float to int.""" + tool = FunctionTool(int_func) + args, errors = tool._preprocess_args({"num": 3.0}) + assert errors == [] + assert args["num"] == 3 + assert isinstance(args["num"], int) + + def test_string_to_float(self): + tool = FunctionTool(float_func) + args, errors = tool._preprocess_args({"val": "3.14"}) + assert errors == [] + assert abs(args["val"] - 3.14) < 1e-9 + + def test_int_to_float(self): + tool = FunctionTool(float_func) + args, errors = tool._preprocess_args({"val": 5}) + assert errors == [] + assert args["val"] == 5.0 + assert isinstance(args["val"], float) + + def test_enum_valid_value(self): + tool = FunctionTool(enum_func) + args, errors = tool._preprocess_args({"color": "red"}) + assert errors == [] + assert args["color"] == Color.RED + + def test_enum_invalid_value(self): + tool = FunctionTool(enum_func) + args, errors = tool._preprocess_args({"color": "purple"}) + assert len(errors) == 1 + assert "color" in errors[0] + + def test_list_int_coercion(self): + tool = FunctionTool(list_int_func) + args, errors = tool._preprocess_args({"nums": ["1", "2", "3"]}) + assert errors == [] + assert args["nums"] == [1, 2, 3] + + def test_optional_none_skipped(self): + tool = FunctionTool(optional_int_func) + args, errors = tool._preprocess_args({"num": None}) + assert errors == [] + assert args["num"] is None + + def test_optional_value_coerced(self): + tool = FunctionTool(optional_int_func) + args, errors = tool._preprocess_args({"num": "7"}) + assert errors == [] + assert args["num"] == 7 + + def test_bool_from_int(self): + tool = FunctionTool(bool_func) + args, errors = tool._preprocess_args({"flag": 1}) + assert errors == [] + assert args["flag"] is True + + +# --- _preprocess_args validation error tests --- + + +class TestArgValidationErrors: + + def test_string_for_int_returns_error(self): + tool = FunctionTool(int_func) + args, errors = tool._preprocess_args({"num": "foobar"}) + assert len(errors) == 1 + assert "num" in errors[0] + + def test_none_for_required_int_returns_error(self): + """None for a non-Optional int should be flagged.""" + tool = FunctionTool(int_func) + # None passed for a required int param. The Optional unwrap won't + # trigger because the annotation is plain `int`, not Optional[int]. + # TypeAdapter(int).validate_python(None) raises ValidationError. + args, errors = tool._preprocess_args({"num": None}) + assert len(errors) == 1 + assert "num" in errors[0] + + def test_multiple_param_errors(self): + tool = FunctionTool(multi_param_func) + args, errors = tool._preprocess_args( + {"name": 123, "count": "not_a_number", "flag": "not_a_bool"} + ) + # name: int->str coercion might fail in strict, but lax mode might + # handle it. count: "not_a_number"->int will fail. flag: depends on + # pydantic behavior. + assert any("count" in e for e in errors) + + +# --- run_async integration tests --- + + +def _make_tool_context(): + tool_context_mock = MagicMock(spec=ToolContext) + invocation_context_mock = MagicMock(spec=InvocationContext) + session_mock = MagicMock(spec=Session) + invocation_context_mock.session = session_mock + tool_context_mock.invocation_context = invocation_context_mock + return tool_context_mock + + +class TestRunAsyncValidation: + + @pytest.mark.asyncio + async def test_invalid_arg_returns_error_to_llm(self): + tool = FunctionTool(int_func) + result = await tool.run_async( + args={"num": "foobar"}, tool_context=_make_tool_context() + ) + assert isinstance(result, dict) + assert "error" in result + assert "validation error" in result["error"].lower() + + @pytest.mark.asyncio + async def test_valid_coercion_invokes_function(self): + tool = FunctionTool(int_func) + result = await tool.run_async( + args={"num": "42"}, tool_context=_make_tool_context() + ) + assert result == 42 + + @pytest.mark.asyncio + async def test_enum_invalid_returns_error(self): + tool = FunctionTool(enum_func) + result = await tool.run_async( + args={"color": "purple"}, tool_context=_make_tool_context() + ) + assert isinstance(result, dict) + assert "error" in result + + @pytest.mark.asyncio + async def test_enum_valid_invokes_function(self): + tool = FunctionTool(enum_func) + result = await tool.run_async( + args={"color": "green"}, tool_context=_make_tool_context() + ) + assert result == "green" diff --git a/tests/unittests/tools/test_function_tool_pydantic.py b/tests/unittests/tools/test_function_tool_pydantic.py index 82f5631a35..42a589a1fe 100644 --- a/tests/unittests/tools/test_function_tool_pydantic.py +++ b/tests/unittests/tools/test_function_tool_pydantic.py @@ -97,7 +97,7 @@ def test_preprocess_args_with_dict_to_pydantic_conversion(): "user": {"name": "Alice", "age": 30, "email": "alice@example.com"} } - processed_args = tool._preprocess_args(input_args) + processed_args, _ = tool._preprocess_args(input_args) # Check that the dict was converted to a Pydantic model assert "user" in processed_args @@ -116,7 +116,7 @@ def test_preprocess_args_with_existing_pydantic_model(): existing_user = UserModel(name="Bob", age=25) input_args = {"user": existing_user} - processed_args = tool._preprocess_args(input_args) + processed_args, _ = tool._preprocess_args(input_args) # Check that the existing model was not changed (same object) assert "user" in processed_args @@ -132,7 +132,7 @@ def test_preprocess_args_with_optional_pydantic_model_none(): input_args = {"user": {"name": "Charlie", "age": 35}, "preferences": None} - processed_args = tool._preprocess_args(input_args) + processed_args, _ = tool._preprocess_args(input_args) # Check user conversion assert isinstance(processed_args["user"], UserModel) @@ -151,7 +151,7 @@ def test_preprocess_args_with_optional_pydantic_model_dict(): "preferences": {"theme": "dark", "notifications": False}, } - processed_args = tool._preprocess_args(input_args) + processed_args, _ = tool._preprocess_args(input_args) # Check both conversions assert isinstance(processed_args["user"], UserModel) @@ -172,7 +172,7 @@ def test_preprocess_args_with_mixed_types(): "count": 10, } - processed_args = tool._preprocess_args(input_args) + processed_args, _ = tool._preprocess_args(input_args) # Check that only Pydantic model was converted assert processed_args["name"] == "test_name" # string unchanged @@ -191,7 +191,7 @@ def test_preprocess_args_with_invalid_data_graceful_failure(): # Invalid data that can't be converted to UserModel input_args = {"user": "invalid_string"} # string instead of dict/model - processed_args = tool._preprocess_args(input_args) + processed_args, _ = tool._preprocess_args(input_args) # Should keep original value when conversion fails assert processed_args["user"] == "invalid_string" @@ -206,7 +206,7 @@ def simple_function(name: str, age: int) -> dict: tool = FunctionTool(simple_function) input_args = {"name": "test", "age": 25} - processed_args = tool._preprocess_args(input_args) + processed_args, _ = tool._preprocess_args(input_args) # Should remain unchanged (no Pydantic models to convert) assert processed_args == input_args From 3c29a55182c7d281ed162c7e02767462955857d8 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 04:56:43 +0530 Subject: [PATCH 02/12] refactor: narrow except clause and extract validation error helper --- src/google/adk/flows/llm_flows/functions.py | 9 +-------- src/google/adk/tools/crewai_tool.py | 6 +----- src/google/adk/tools/function_tool.py | 21 +++++++++++++++------ 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/google/adk/flows/llm_flows/functions.py b/src/google/adk/flows/llm_flows/functions.py index bc1e792d24..f50045dddb 100644 --- a/src/google/adk/flows/llm_flows/functions.py +++ b/src/google/adk/flows/llm_flows/functions.py @@ -149,14 +149,7 @@ def run_sync_tool(): if isinstance(tool, FunctionTool): args_to_call, validation_errors = tool._preprocess_args(args) if validation_errors: - validation_errors_str = '\n'.join(validation_errors) - return { - 'error': ( - f'Invoking `{tool.name}()` failed due to argument' - f' validation errors:\n{validation_errors_str}\nYou could' - ' retry calling this tool with corrected argument types.' - ) - } + return tool._build_validation_error_response(validation_errors) signature = inspect.signature(tool.func) valid_params = {param for param in signature.parameters} if 'tool_context' in valid_params: diff --git a/src/google/adk/tools/crewai_tool.py b/src/google/adk/tools/crewai_tool.py index d15bdae40f..28b8c51c00 100644 --- a/src/google/adk/tools/crewai_tool.py +++ b/src/google/adk/tools/crewai_tool.py @@ -78,11 +78,7 @@ async def run_async( args_to_call, validation_errors = self._preprocess_args(args) if validation_errors: - validation_errors_str = '\n'.join(validation_errors) - error_str = f"""Invoking `{self.name}()` failed due to argument validation errors: -{validation_errors_str} -You could retry calling this tool with corrected argument types.""" - return {'error': error_str} + return self._build_validation_error_response(validation_errors) signature = inspect.signature(self.func) valid_params = {param for param in signature.parameters} diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index f4b7babf7a..a0bed509ca 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -170,13 +170,26 @@ def _preprocess_args( f"Parameter '{param_name}': expected type '{target_type}'," f' validation error: {e}' ) - except Exception: + except (TypeError, NameError): # TypeAdapter could not handle this annotation (e.g. a forward # reference string). Skip validation silently. pass return converted_args, validation_errors + def _build_validation_error_response( + self, validation_errors: list[str] + ) -> dict[str, str]: + """Formats validation errors into an error dict for the LLM.""" + validation_errors_str = '\n'.join(validation_errors) + return { + 'error': ( + f'Invoking `{self.name}()` failed due to argument validation' + f' errors:\n{validation_errors_str}\nYou could retry calling' + ' this tool with corrected argument types.' + ) + } + @override async def run_async( self, *, args: dict[str, Any], tool_context: ToolContext @@ -187,11 +200,7 @@ async def run_async( args_to_call, validation_errors = self._preprocess_args(args) if validation_errors: - validation_errors_str = '\n'.join(validation_errors) - error_str = f"""Invoking `{self.name}()` failed due to argument validation errors: -{validation_errors_str} -You could retry calling this tool with corrected argument types.""" - return {'error': error_str} + return self._build_validation_error_response(validation_errors) signature = inspect.signature(self.func) valid_params = {param for param in signature.parameters} From 420253be679b52df3debdf733096f1c597b4b37e Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 05:01:29 +0530 Subject: [PATCH 03/12] refactor: apply Gemini review round 2 feedback --- src/google/adk/tools/function_tool.py | 14 ++++++++++---- .../tools/test_function_tool_arg_validation.py | 8 +++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index a0bed509ca..f0017c755e 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -139,7 +139,7 @@ def _preprocess_args( if inspect.isclass(target_type) and issubclass( target_type, pydantic.BaseModel ): - if args[param_name] is None: + if args[param_name] is None and is_optional: continue if not isinstance(args[param_name], target_type): try: @@ -170,10 +170,16 @@ def _preprocess_args( f"Parameter '{param_name}': expected type '{target_type}'," f' validation error: {e}' ) - except (TypeError, NameError): + except (TypeError, NameError) as e: # TypeAdapter could not handle this annotation (e.g. a forward - # reference string). Skip validation silently. - pass + # reference string). Skip validation but log a warning. + logger.warning( + "Skipping validation for parameter '%s' due to unhandled" + " annotation type '%s': %s", + param_name, + target_type, + e, + ) return converted_args, validation_errors diff --git a/tests/unittests/tools/test_function_tool_arg_validation.py b/tests/unittests/tools/test_function_tool_arg_validation.py index a45ecfb23e..7b9779c619 100644 --- a/tests/unittests/tools/test_function_tool_arg_validation.py +++ b/tests/unittests/tools/test_function_tool_arg_validation.py @@ -155,10 +155,12 @@ def test_multiple_param_errors(self): args, errors = tool._preprocess_args( {"name": 123, "count": "not_a_number", "flag": "not_a_bool"} ) - # name: int->str coercion might fail in strict, but lax mode might - # handle it. count: "not_a_number"->int will fail. flag: depends on - # pydantic behavior. + # All three fail: pydantic rejects int->str, "not_a_number"->int, + # and "not_a_bool"->bool. + assert len(errors) == 3 + assert any("name" in e for e in errors) assert any("count" in e for e in errors) + assert any("flag" in e for e in errors) # --- run_async integration tests --- From a93d71f8a3ea9abcacc3af2d4d0dfb5ab63df2c6 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 05:04:53 +0530 Subject: [PATCH 04/12] style: use __name__ for cleaner type names in error messages --- src/google/adk/tools/function_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index f0017c755e..fe1e2492c5 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -167,7 +167,7 @@ def _preprocess_args( ) except pydantic.ValidationError as e: validation_errors.append( - f"Parameter '{param_name}': expected type '{target_type}'," + f"Parameter '{param_name}': expected type '{getattr(target_type, '__name__', target_type)}'," f' validation error: {e}' ) except (TypeError, NameError) as e: From d4109540d3df369750082b8e42d8cfbe03716dee Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 05:09:39 +0530 Subject: [PATCH 05/12] style: use deferred string formatting in logger.warning --- src/google/adk/tools/function_tool.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index fe1e2492c5..4d1e7235f8 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -148,8 +148,10 @@ def _preprocess_args( ) except Exception as e: logger.warning( - f"Failed to convert argument '{param_name}' to Pydantic model" - f' {target_type.__name__}: {e}' + "Failed to convert argument '%s' to Pydantic model %s: %s", + param_name, + target_type.__name__, + e, ) continue From ab7cb7c56de66abed586cb37f03b5d98b6250eea Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 05:15:31 +0530 Subject: [PATCH 06/12] refactor: hoist Optional None check before Pydantic branch --- src/google/adk/tools/function_tool.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 4d1e7235f8..5e11ee3dbb 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -135,12 +135,14 @@ def _preprocess_args( target_type = non_none_types[0] is_optional = len(union_args) != len(non_none_types) + # Skip None values only for Optional params + if args[param_name] is None and is_optional: + continue + # Pydantic models: keep existing graceful-failure behavior if inspect.isclass(target_type) and issubclass( target_type, pydantic.BaseModel ): - if args[param_name] is None and is_optional: - continue if not isinstance(args[param_name], target_type): try: converted_args[param_name] = target_type.model_validate( @@ -155,10 +157,6 @@ def _preprocess_args( ) continue - # Skip None values only for Optional params - if args[param_name] is None and is_optional: - continue - # Validate and coerce all other annotated types using TypeAdapter. # This handles primitives (int, float, str, bool), enums, and # container types (list[int], dict[str, float], etc.). From 825f3b9b53a514b6f6b2267ddfb25e6510b5073b Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 13:44:37 +0530 Subject: [PATCH 07/12] fix: handle Python 3.10+ union syntax (T | None) in Optional unwrap --- src/google/adk/tools/function_tool.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 5e11ee3dbb..1f5497f04c 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -16,6 +16,7 @@ import inspect import logging +import sys from typing import Any from typing import Callable from typing import get_args @@ -23,6 +24,13 @@ from typing import Optional from typing import Union +if sys.version_info >= (3, 10): + from types import UnionType + + _UNION_TYPES = (Union, UnionType) +else: + _UNION_TYPES = (Union,) + from google.genai import types import pydantic from typing_extensions import override @@ -127,8 +135,8 @@ def _preprocess_args( target_type = param.annotation is_optional = False - # Handle Optional[T] (Union[T, None]) - unwrap to get inner type - if get_origin(param.annotation) is Union: + # Handle Optional[T] (Union[T, None]) and T | None (Python 3.10+) + if get_origin(param.annotation) in _UNION_TYPES: union_args = get_args(param.annotation) non_none_types = [arg for arg in union_args if arg is not type(None)] if len(non_none_types) == 1: From 04c9cabc34531d7d24731a46132df585c3d046c0 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 14:01:09 +0530 Subject: [PATCH 08/12] refactor: let TypeAdapter handle all types uniformly, skip framework params Remove the Pydantic model special case from _preprocess_args so TypeAdapter handles primitives, enums, Pydantic models, and containers in one path. Skip framework-managed params (_ignore_params) to avoid validating injected values like credential=None. --- src/google/adk/tools/function_tool.py | 29 +++++-------------- .../tools/test_function_tool_pydantic.py | 11 +++---- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 1f5497f04c..8bc9e5d9ad 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -129,7 +129,11 @@ def _preprocess_args( validation_errors = [] for param_name, param in signature.parameters.items(): - if param_name not in args or param.annotation is inspect.Parameter.empty: + if ( + param_name not in args + or param.annotation is inspect.Parameter.empty + or param_name in self._ignore_params + ): continue target_type = param.annotation @@ -147,27 +151,8 @@ def _preprocess_args( if args[param_name] is None and is_optional: continue - # Pydantic models: keep existing graceful-failure behavior - if inspect.isclass(target_type) and issubclass( - target_type, pydantic.BaseModel - ): - if not isinstance(args[param_name], target_type): - try: - converted_args[param_name] = target_type.model_validate( - args[param_name] - ) - except Exception as e: - logger.warning( - "Failed to convert argument '%s' to Pydantic model %s: %s", - param_name, - target_type.__name__, - e, - ) - continue - - # Validate and coerce all other annotated types using TypeAdapter. - # This handles primitives (int, float, str, bool), enums, and - # container types (list[int], dict[str, float], etc.). + # Validate and coerce all annotated types using TypeAdapter. + # This handles primitives, enums, Pydantic models, and container types. try: adapter = pydantic.TypeAdapter(target_type) converted_args[param_name] = adapter.validate_python( diff --git a/tests/unittests/tools/test_function_tool_pydantic.py b/tests/unittests/tools/test_function_tool_pydantic.py index 42a589a1fe..8ffebcf21f 100644 --- a/tests/unittests/tools/test_function_tool_pydantic.py +++ b/tests/unittests/tools/test_function_tool_pydantic.py @@ -184,17 +184,18 @@ def test_preprocess_args_with_mixed_types(): assert processed_args["user"].age == 40 -def test_preprocess_args_with_invalid_data_graceful_failure(): - """Test _preprocess_args handles invalid data gracefully.""" +def test_preprocess_args_with_invalid_data_returns_error(): + """Test _preprocess_args returns validation error for invalid Pydantic data.""" tool = FunctionTool(sync_function_with_pydantic_model) # Invalid data that can't be converted to UserModel input_args = {"user": "invalid_string"} # string instead of dict/model - processed_args, _ = tool._preprocess_args(input_args) + _, errors = tool._preprocess_args(input_args) - # Should keep original value when conversion fails - assert processed_args["user"] == "invalid_string" + # Should return a validation error for the LLM to self-correct + assert len(errors) == 1 + assert "user" in errors[0] def test_preprocess_args_with_non_pydantic_parameters(): From 002444112fb22f2aa5b8d5cfaf538e2305b039ee Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 14:10:54 +0530 Subject: [PATCH 09/12] perf: cache TypeAdapter instances across tool invocations --- src/google/adk/tools/function_tool.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 8bc9e5d9ad..ffd96a2ed6 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -90,6 +90,7 @@ def __init__( self.func = func self._ignore_params = ['tool_context', 'input_stream'] self._require_confirmation = require_confirmation + self._type_adapter_cache: dict[Any, pydantic.TypeAdapter] = {} @override def _get_declaration(self) -> Optional[types.FunctionDeclaration]: @@ -154,7 +155,11 @@ def _preprocess_args( # Validate and coerce all annotated types using TypeAdapter. # This handles primitives, enums, Pydantic models, and container types. try: - adapter = pydantic.TypeAdapter(target_type) + if target_type not in self._type_adapter_cache: + self._type_adapter_cache[target_type] = pydantic.TypeAdapter( + target_type + ) + adapter = self._type_adapter_cache[target_type] converted_args[param_name] = adapter.validate_python( args[param_name] ) From 2c808f88dd03c10f81f7d2e4236792a13e31764a Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 14:31:05 +0530 Subject: [PATCH 10/12] refactor: remove manual Optional unwrap, TypeAdapter handles it natively --- src/google/adk/tools/function_tool.py | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index ffd96a2ed6..1eeecd9f06 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -16,21 +16,11 @@ import inspect import logging -import sys from typing import Any from typing import Callable -from typing import get_args -from typing import get_origin from typing import Optional from typing import Union -if sys.version_info >= (3, 10): - from types import UnionType - - _UNION_TYPES = (Union, UnionType) -else: - _UNION_TYPES = (Union,) - from google.genai import types import pydantic from typing_extensions import override @@ -138,22 +128,9 @@ def _preprocess_args( continue target_type = param.annotation - is_optional = False - - # Handle Optional[T] (Union[T, None]) and T | None (Python 3.10+) - if get_origin(param.annotation) in _UNION_TYPES: - union_args = get_args(param.annotation) - non_none_types = [arg for arg in union_args if arg is not type(None)] - if len(non_none_types) == 1: - target_type = non_none_types[0] - is_optional = len(union_args) != len(non_none_types) - - # Skip None values only for Optional params - if args[param_name] is None and is_optional: - continue - # Validate and coerce all annotated types using TypeAdapter. - # This handles primitives, enums, Pydantic models, and container types. + # Validate and coerce using TypeAdapter. Handles primitives, enums, + # Pydantic models, Optional[T], T | None, and container types natively. try: if target_type not in self._type_adapter_cache: self._type_adapter_cache[target_type] = pydantic.TypeAdapter( From f9ce14c724fe3692a89fd173fc1ef06b85ed77f3 Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 14:36:33 +0530 Subject: [PATCH 11/12] fix: handle unhashable types in TypeAdapter cache without skipping validation --- src/google/adk/tools/function_tool.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 1eeecd9f06..5b7b386497 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -132,11 +132,14 @@ def _preprocess_args( # Validate and coerce using TypeAdapter. Handles primitives, enums, # Pydantic models, Optional[T], T | None, and container types natively. try: - if target_type not in self._type_adapter_cache: - self._type_adapter_cache[target_type] = pydantic.TypeAdapter( - target_type - ) - adapter = self._type_adapter_cache[target_type] + try: + adapter = self._type_adapter_cache[target_type] + except (KeyError, TypeError): + adapter = pydantic.TypeAdapter(target_type) + try: + self._type_adapter_cache[target_type] = adapter + except TypeError: + pass converted_args[param_name] = adapter.validate_python( args[param_name] ) From ba3c4ea81b9f36d02f954ff0fb5bc4aae24c882a Mon Sep 17 00:00:00 2001 From: yuvrajangadsingh Date: Sun, 1 Mar 2026 14:43:32 +0530 Subject: [PATCH 12/12] refactor: split cache except into separate TypeError and KeyError handlers --- src/google/adk/tools/function_tool.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 5b7b386497..47bd1716fc 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -134,12 +134,11 @@ def _preprocess_args( try: try: adapter = self._type_adapter_cache[target_type] - except (KeyError, TypeError): + except TypeError: adapter = pydantic.TypeAdapter(target_type) - try: - self._type_adapter_cache[target_type] = adapter - except TypeError: - pass + except KeyError: + adapter = pydantic.TypeAdapter(target_type) + self._type_adapter_cache[target_type] = adapter converted_args[param_name] = adapter.validate_python( args[param_name] )