From 2580c9d7a9224632e43b6abda1ec28332d69c35c Mon Sep 17 00:00:00 2001 From: Mac Howe Date: Tue, 10 Feb 2026 20:49:08 -0500 Subject: [PATCH 1/5] Fix streaming tools support for string annotations and serialization --- .../tools/_automatic_function_calling_util.py | 4 ++ src/google/adk/tools/function_tool.py | 11 +++++ .../tools/test_build_function_declaration.py | 23 ++++++++++ tests/unittests/tools/test_function_tool.py | 42 ++++++++++++++++--- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/google/adk/tools/_automatic_function_calling_util.py b/src/google/adk/tools/_automatic_function_calling_util.py index a3097ad40c..6d946a1819 100644 --- a/src/google/adk/tools/_automatic_function_calling_util.py +++ b/src/google/adk/tools/_automatic_function_calling_util.py @@ -394,6 +394,10 @@ def from_function_with_options( return_annotation = inspect.signature(func).return_annotation + # Resolve deferred type hints. + if 'return' in annotation_under_future: + return_annotation = annotation_under_future['return'] + # Handle AsyncGenerator and Generator return types (streaming tools) # AsyncGenerator[YieldType, SendType] -> use YieldType as response schema # Generator[YieldType, SendType, ReturnType] -> use YieldType as response schema diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index de59755365..9c76b9fa5f 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -222,6 +222,17 @@ async def _invoke_callable( ) -> Any: """Invokes a callable, handling both sync and async cases.""" + # Handle async generator functions (streaming tools) + is_async_gen = inspect.isasyncgenfunction(target) or ( + hasattr(target, '__call__') + and inspect.isasyncgenfunction(target.__call__) + ) + if is_async_gen: + results = [] + async for item in target(**args_to_call): + results.append(item) + return results + # Functions are callable objects, but not all callable objects are functions # checking coroutine function is not enough. We also need to check whether # Callable's __call__ function is a coroutine function diff --git a/tests/unittests/tools/test_build_function_declaration.py b/tests/unittests/tools/test_build_function_declaration.py index 1c9bf245f1..5d1725c838 100644 --- a/tests/unittests/tools/test_build_function_declaration.py +++ b/tests/unittests/tools/test_build_function_declaration.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import collections.abc from enum import Enum from google.adk.features import FeatureName @@ -661,3 +662,25 @@ def greet(name: str = 'World') -> str: schema = decl.parameters_json_schema assert schema['properties']['name']['default'] == 'World' assert 'name' not in schema.get('required', []) + + +def test_schema_generation_for_streaming_tool_with_string_annotations(): + """Test schema generation for AsyncGenerator with string annotations.""" + + # Simulate string annotation by using forward reference string + # This mimics "from __future__ import annotations" behavior + async def streaming_tool( + param: str, + ) -> 'collections.abc.AsyncGenerator[str, None]': + """A streaming tool.""" + yield f'result {param}' + + function_decl = _automatic_function_calling_util.build_function_declaration( + func=streaming_tool, variant=GoogleLLMVariant.VERTEX_AI + ) + + assert function_decl.name == 'streaming_tool' + assert function_decl.parameters.type == 'OBJECT' + # VERTEX_AI should have response schema for string return (yield type) + assert function_decl.response is not None + assert function_decl.response.type == types.Type.STRING diff --git a/tests/unittests/tools/test_function_tool.py b/tests/unittests/tools/test_function_tool.py index 40e7e2673c..cb1b31cd92 100644 --- a/tests/unittests/tools/test_function_tool.py +++ b/tests/unittests/tools/test_function_tool.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import collections.abc +from typing import AsyncGenerator from unittest.mock import MagicMock from google.adk.agents.invocation_context import InvocationContext @@ -200,9 +202,11 @@ async def test_run_async_1_missing_arg_sync_func(): args = {"arg1": "test_value_1"} result = await tool.run_async(args=args, tool_context=MagicMock()) assert result == { - "error": """Invoking `function_for_testing_with_2_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: + "error": ( + """Invoking `function_for_testing_with_2_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: arg2 You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters.""" + ) } @@ -213,9 +217,11 @@ async def test_run_async_1_missing_arg_async_func(): args = {"arg2": "test_value_1"} result = await tool.run_async(args=args, tool_context=MagicMock()) assert result == { - "error": """Invoking `async_function_for_testing_with_2_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: + "error": ( + """Invoking `async_function_for_testing_with_2_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: arg1 You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters.""" + ) } @@ -226,11 +232,13 @@ async def test_run_async_3_missing_arg_sync_func(): args = {"arg2": "test_value_1"} result = await tool.run_async(args=args, tool_context=MagicMock()) assert result == { - "error": """Invoking `function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: + "error": ( + """Invoking `function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: arg1 arg3 arg4 You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters.""" + ) } @@ -241,11 +249,13 @@ async def test_run_async_3_missing_arg_async_func(): args = {"arg3": "test_value_1"} result = await tool.run_async(args=args, tool_context=MagicMock()) assert result == { - "error": """Invoking `async_function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: + "error": ( + """Invoking `async_function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: arg1 arg2 arg4 You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters.""" + ) } @@ -256,12 +266,14 @@ async def test_run_async_missing_all_arg_sync_func(): args = {} result = await tool.run_async(args=args, tool_context=MagicMock()) assert result == { - "error": """Invoking `function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: + "error": ( + """Invoking `function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: arg1 arg2 arg3 arg4 You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters.""" + ) } @@ -272,12 +284,14 @@ async def test_run_async_missing_all_arg_async_func(): args = {} result = await tool.run_async(args=args, tool_context=MagicMock()) assert result == { - "error": """Invoking `async_function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: + "error": ( + """Invoking `async_function_for_testing_with_4_arg_and_no_tool_context()` failed as the following mandatory input parameters are not present: arg1 arg2 arg3 arg4 You could retry calling this tool, but it is IMPORTANT for you to provide all the mandatory parameters.""" + ) } @@ -428,3 +442,19 @@ def explicit_params_func(arg1: str, arg2: int): assert result == {"arg1": "test", "arg2": 42} # Explicitly verify that unexpected_param was filtered out and not passed to the function assert "unexpected_param" not in result + + +@pytest.mark.asyncio +async def test_run_async_streaming_generator(): + """Test that run_async consumes the async generator and returns a list.""" + + async def streaming_tool(param: str) -> AsyncGenerator[str, None]: + yield f"part 1 {param}" + yield f"part 2 {param}" + + tool = FunctionTool(streaming_tool) + + result = await tool.run_async(args={"param": "test"}, tool_context=None) + + assert isinstance(result, list) + assert result == ["part 1 test", "part 2 test"] From 55f502d942c1e8c60d93e9aa23e775c529acb205 Mon Sep 17 00:00:00 2001 From: Mac Howe <69370250+ItsMacto@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:37:53 -0500 Subject: [PATCH 2/5] Update src/google/adk/tools/function_tool.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/google/adk/tools/function_tool.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 9c76b9fa5f..085f187d03 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -228,10 +228,7 @@ async def _invoke_callable( and inspect.isasyncgenfunction(target.__call__) ) if is_async_gen: - results = [] - async for item in target(**args_to_call): - results.append(item) - return results + return [item async for item in target(**args_to_call)] # Functions are callable objects, but not all callable objects are functions # checking coroutine function is not enough. We also need to check whether From 96f3c86c94d4a9d337ad560fd942ba81e590698a Mon Sep 17 00:00:00 2001 From: Mac Howe <69370250+ItsMacto@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:38:21 -0500 Subject: [PATCH 3/5] Update tests/unittests/tools/test_function_tool.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- tests/unittests/tools/test_function_tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/tools/test_function_tool.py b/tests/unittests/tools/test_function_tool.py index cb1b31cd92..02c9017c7d 100644 --- a/tests/unittests/tools/test_function_tool.py +++ b/tests/unittests/tools/test_function_tool.py @@ -454,7 +454,7 @@ async def streaming_tool(param: str) -> AsyncGenerator[str, None]: tool = FunctionTool(streaming_tool) - result = await tool.run_async(args={"param": "test"}, tool_context=None) + result = await tool.run_async(args={"param": "test"}, tool_context=MagicMock()) assert isinstance(result, list) assert result == ["part 1 test", "part 2 test"] From 8a73a5e60df086f0f8b41124c58aa9bdaefbe816 Mon Sep 17 00:00:00 2001 From: Mac Howe Date: Wed, 11 Feb 2026 09:52:47 -0500 Subject: [PATCH 4/5] Refactored `_invoke_callable` in `function_tool.py` to use a result-based duck typing approach. Traced all callers (FunctionTool.run_async, CrewaiTool.run_async, McpTool override) --- src/google/adk/tools/function_tool.py | 30 +++++++-------------- tests/unittests/tools/test_function_tool.py | 23 +++++++++++++++- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 085f187d03..4c89c7714f 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -221,26 +221,16 @@ async def _invoke_callable( self, target: Callable[..., Any], args_to_call: dict[str, Any] ) -> Any: """Invokes a callable, handling both sync and async cases.""" - - # Handle async generator functions (streaming tools) - is_async_gen = inspect.isasyncgenfunction(target) or ( - hasattr(target, '__call__') - and inspect.isasyncgenfunction(target.__call__) - ) - if is_async_gen: - return [item async for item in target(**args_to_call)] - - # Functions are callable objects, but not all callable objects are functions - # checking coroutine function is not enough. We also need to check whether - # Callable's __call__ function is a coroutine function - is_async = inspect.iscoroutinefunction(target) or ( - hasattr(target, '__call__') - and inspect.iscoroutinefunction(target.__call__) - ) - if is_async: - return await target(**args_to_call) - else: - return target(**args_to_call) + # Call first, then dispatch based on the result type to handle + result = target(**args_to_call) + + if inspect.isasyncgen(result): + return [item async for item in result] + if inspect.isgenerator(result): + return list(result) + if inspect.isawaitable(result): + return await result + return result # TODO(hangfei): fix call live for function stream. async def _call_live( diff --git a/tests/unittests/tools/test_function_tool.py b/tests/unittests/tools/test_function_tool.py index 02c9017c7d..d0b2c6d173 100644 --- a/tests/unittests/tools/test_function_tool.py +++ b/tests/unittests/tools/test_function_tool.py @@ -14,6 +14,7 @@ import collections.abc from typing import AsyncGenerator +from typing import Generator from unittest.mock import MagicMock from google.adk.agents.invocation_context import InvocationContext @@ -454,7 +455,27 @@ async def streaming_tool(param: str) -> AsyncGenerator[str, None]: tool = FunctionTool(streaming_tool) - result = await tool.run_async(args={"param": "test"}, tool_context=MagicMock()) + result = await tool.run_async( + args={"param": "test"}, tool_context=MagicMock() + ) + + assert isinstance(result, list) + assert result == ["part 1 test", "part 2 test"] + + +@pytest.mark.asyncio +async def test_run_async_sync_generator(): + """Test that run_async consumes the sync generator and returns a list.""" + + def sync_generator_tool(param: str) -> Generator[str, None, None]: + yield f"part 1 {param}" + yield f"part 2 {param}" + + tool = FunctionTool(sync_generator_tool) + + result = await tool.run_async( + args={"param": "test"}, tool_context=MagicMock() + ) assert isinstance(result, list) assert result == ["part 1 test", "part 2 test"] From 664ac5a1a950be9740a344cb35be7a210ac4578f Mon Sep 17 00:00:00 2001 From: Mac Howe <69370250+ItsMacto@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:51:15 -0500 Subject: [PATCH 5/5] Update src/google/adk/tools/function_tool.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/google/adk/tools/function_tool.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/google/adk/tools/function_tool.py b/src/google/adk/tools/function_tool.py index 4c89c7714f..75064abd71 100644 --- a/src/google/adk/tools/function_tool.py +++ b/src/google/adk/tools/function_tool.py @@ -224,12 +224,13 @@ async def _invoke_callable( # Call first, then dispatch based on the result type to handle result = target(**args_to_call) + if inspect.isawaitable(result): + result = await result + if inspect.isasyncgen(result): - return [item async for item in result] + return [item async for item in result] if inspect.isgenerator(result): - return list(result) - if inspect.isawaitable(result): - return await result + return list(result) return result # TODO(hangfei): fix call live for function stream.