From 82bd4388a9cf24254bcd346146ab9d08cd3365cd Mon Sep 17 00:00:00 2001 From: nileshpatil6 Date: Tue, 1 Sep 2026 20:37:16 +0530 Subject: [PATCH] fix(tools): coerce integral floats inside list[int] tool parameters too --- src/google/adk/tools/function_tool.py | 23 ++++++ tests/unittests/tools/test_function_tool.py | 83 +++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 729e9bb2c3..18140ec731 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -234,6 +234,29 @@ def _preprocess_args(self, args: dict[str, Any]) -> dict[str, Any]: ) continue + # The same round-trip turns every element of a list[int] argument + # into a float, so coerce integral elements back as well. + if ( + get_origin(target_type) is list + and get_args(target_type)[:1] == (int,) + and isinstance(args[param_name], list) + ): + coerced_items = [] + for item in args[param_name]: + if type(item) is float: + if item.is_integer(): + item = int(item) + else: + logger.warning( + "Argument '%s' is typed list[int] but contains" + ' non-integral %r; passing it through unchanged.', + param_name, + item, + ) + coerced_items.append(item) + converted_args[param_name] = coerced_items + continue + # Check if the target type is a Pydantic model if inspect.isclass(target_type) and issubclass( target_type, pydantic.BaseModel diff --git a/tests/unittests/tools/test_function_tool.py b/tests/unittests/tools/test_function_tool.py index eaa97531cd..65b5666925 100644 --- a/tests/unittests/tools/test_function_tool.py +++ b/tests/unittests/tools/test_function_tool.py @@ -756,3 +756,86 @@ async def tool_with_int(flag: int): tool_context=mock_tool_context, ) assert result == {"type": "bool"} + + +@pytest.mark.asyncio +async def test_run_async_coerces_integral_floats_in_list_int_param( + mock_tool_context, +): + """A proto Struct round-trip turns list[int] elements into floats; they are coerced back.""" + + async def tool_with_list_int(component_ids: list[int]): + return {"types": [type(i).__name__ for i in component_ids]} + + tool = FunctionTool(tool_with_list_int) + result = await tool.run_async( + args={"component_ids": [1396683.0, 7.0]}, + tool_context=mock_tool_context, + ) + assert result == {"types": ["int", "int"]} + + +@pytest.mark.asyncio +async def test_run_async_coerces_integral_floats_in_optional_list_int_param( + mock_tool_context, +): + """Optional[list[int]] is unwrapped before the check, so it is coerced too.""" + + async def tool_with_optional_list_int( + component_ids: Optional[list[int]] = None, + ): + return {"types": [type(i).__name__ for i in component_ids]} + + tool = FunctionTool(tool_with_optional_list_int) + result = await tool.run_async( + args={"component_ids": [7.0]}, + tool_context=mock_tool_context, + ) + assert result == {"types": ["int"]} + + +@pytest.mark.asyncio +async def test_run_async_passes_through_non_integral_float_in_list_int_param( + mock_tool_context, +): + """A list element that is not a whole number is not silently truncated.""" + + async def tool_with_list_int(component_ids: list[int]): + return {"got": component_ids} + + tool = FunctionTool(tool_with_list_int) + result = await tool.run_async( + args={"component_ids": [1.5, 2.0]}, + tool_context=mock_tool_context, + ) + assert result == {"got": [1.5, 2]} + + +@pytest.mark.asyncio +async def test_run_async_leaves_list_float_param_alone(mock_tool_context): + """A list[float] parameter keeps its floats, so the coercion is int-only.""" + + async def tool_with_list_float(ratios: list[float]): + return {"types": [type(r).__name__ for r in ratios]} + + tool = FunctionTool(tool_with_list_float) + result = await tool.run_async( + args={"ratios": [2.0]}, + tool_context=mock_tool_context, + ) + assert result == {"types": ["float"]} + + +@pytest.mark.asyncio +async def test_run_async_leaves_untyped_list_param_alone(mock_tool_context): + """A bare list annotation carries no element type, so nothing is coerced.""" + + async def tool_with_bare_list(values: list): + return {"types": [type(v).__name__ for v in values]} + + tool = FunctionTool(tool_with_bare_list) + result = await tool.run_async( + args={"values": [2.0]}, + tool_context=mock_tool_context, + ) + assert result == {"types": ["float"]}