-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Description
ADK does not support Python 3.10+ union type syntax (str | None)
Summary
The Google ADK's automatic function calling feature fails to parse function parameters that use Python 3.10+ union type syntax with the pipe operator (|). When a function tool uses modern type hints like str | None, the ADK raises a ValueError and refuses to create the function declaration. This forces developers to use the older typing.Optional or typing.Union syntax instead of the modern, PEP 604-compliant syntax that is now the recommended standard in Python documentation.
This is a compatibility issue that affects any Python 3.10+ project using ADK function tools with optional parameters or union types.
Environment
- Python Version: 3.11.14
- ADK Version: (installed from google-adk package)
- Operating System: macOS
Steps to Reproduce
1. Create a function tool with modern union syntax
Create a simple agent with a function tool that uses Python 3.10+ pipe union syntax:
# my_agent/tools.py
from google.adk.tools import FunctionTool
async def list_components(category: str | None = None) -> list[dict]:
"""
Lists components filtered by category.
Args:
category: Optional category to filter components. If None, returns all components.
Returns:
A list of component dictionaries.
"""
# Simplified implementation for reproduction
if category is None:
return [{"name": "component1"}, {"name": "component2"}]
return [{"name": f"{category}_component"}]
# Create the function tool
list_components_tool = FunctionTool(list_components)2. Register the tool in your agent
# my_agent/agent.py
from google.adk.agents import Agent
from .tools import list_components_tool
my_agent = Agent(
name="my_agent",
model="gemini-2.0-flash-exp",
tools=[list_components_tool],
description="An agent with a component listing tool"
)3. Run the agent
Execute the agent with the ADK CLI:
adk run my_agent4. Observe the error
The agent will fail to initialize with the following error:
ValueError: Failed to parse the parameter category: str | None = None of function list_components for automatic function calling. Automatic function calling works best with simpler function signature schema, consider manually parsing your function declaration for function list_components.
The full stack trace shows the error originates from google/adk/tools/_function_parameter_parse_util.py when it tries to parse the function signature.
Expected Behavior
The ADK should support both syntaxes:
- Modern Python 3.10+ syntax:
str | None - Legacy typing module syntax:
Optional[str]orUnion[str, None]
Both represent the same type and should be handled identically by the function declaration parser.
Actual Behavior
The ADK only recognizes typing.Union and fails to parse types.UnionType created by the pipe operator.
Root Cause Analysis
The issue is located in /google/adk/tools/_function_parameter_parse_util.py in the _parse_schema_from_parameter function, specifically at lines 148-156. The problematic code is:
if (
get_origin(param.annotation) is Union
# only parse simple UnionType, example int | str | float | bool
# complex types.UnionType will be invoked in raise branch
and all(
(_is_builtin_primitive_or_compound(arg) or arg is type(None))
for arg in get_args(param.annotation)
)
):
# ... handle union typeThe Problem
The condition get_origin(param.annotation) is Union only matches typing.Union objects. However, Python 3.10 introduced two different ways to represent union types, and they create different type objects at runtime:
-
Legacy
typing.Unionsyntax (Python 3.5+):Optional[str]→ creates atyping.Union[str, None]objectUnion[str, int]→ creates atyping.Union[str, int]objectget_origin()returnstyping.Union→ Check passes ✅
-
Modern pipe operator syntax (Python 3.10+):
str | None→ creates atypes.UnionTypeobjectstr | int→ creates atypes.UnionTypeobjectget_origin()returnstypes.UnionType→ Check fails ❌
Demonstration
You can verify this behavior with the following test script:
from typing import get_origin, get_args, Union, Optional
import types
import sys
print(f"Python version: {sys.version}")
print()
# Test 1: Legacy Optional syntax
legacy_optional = Optional[str]
print("Legacy syntax: Optional[str]")
print(f" Type: {type(legacy_optional)}")
print(f" get_origin(): {get_origin(legacy_optional)}")
print(f" get_origin() is Union: {get_origin(legacy_optional) is Union}")
print(f" get_args(): {get_args(legacy_optional)}")
print()
# Test 2: Legacy Union syntax
legacy_union = Union[str, int, None]
print("Legacy syntax: Union[str, int, None]")
print(f" Type: {type(legacy_union)}")
print(f" get_origin(): {get_origin(legacy_union)}")
print(f" get_origin() is Union: {get_origin(legacy_union) is Union}")
print(f" get_args(): {get_args(legacy_union)}")
print()
# Test 3: Modern pipe syntax (Python 3.10+)
if sys.version_info >= (3, 10):
modern_optional = str | None
print("Modern syntax: str | None")
print(f" Type: {type(modern_optional)}")
print(f" get_origin(): {get_origin(modern_optional)}")
print(f" get_origin() is Union: {get_origin(modern_optional) is Union}")
print(f" isinstance(types.UnionType): {isinstance(modern_optional, types.UnionType)}")
print(f" get_args(): {get_args(modern_optional)}")
print()
modern_union = str | int | None
print("Modern syntax: str | int | None")
print(f" Type: {type(modern_union)}")
print(f" get_origin(): {get_origin(modern_union)}")
print(f" get_origin() is Union: {get_origin(modern_union) is Union}")
print(f" isinstance(types.UnionType): {isinstance(modern_union, types.UnionType)}")
print(f" get_args(): {get_args(modern_union)}")Output on Python 3.10+:
Python version: 3.11.14 (main, ...)
Legacy syntax: Optional[str]
Type: <class 'typing._UnionGenericAlias'>
get_origin(): <class 'typing.Union'>
get_origin() is Union: True
get_args(): (<class 'str'>, <class 'NoneType'>)
Legacy syntax: Union[str, int, None]
Type: <class 'typing._UnionGenericAlias'>
get_origin(): <class 'typing.Union'>
get_origin() is Union: True
get_args(): (<class 'str'>, <class 'int'>, <class 'NoneType'>)
Modern syntax: str | None
Type: <class 'types.UnionType'>
get_origin(): <class 'types.UnionType'>
get_origin() is Union: False
isinstance(types.UnionType): True
get_args(): (<class 'str'>, <class 'NoneType'>)
Modern syntax: str | int | None
Type: <class 'types.UnionType'>
get_origin(): <class 'types.UnionType'>
get_origin() is Union: False
isinstance(types.UnionType): True
get_args(): (<class 'str'>, <class 'int'>, <class 'NoneType'>)
As you can see, both syntaxes represent semantically identical types with the same arguments (accessible via get_args()), but they have different runtime representations. The ADK's check only recognizes typing.Union and therefore rejects the modern syntax.
Workaround
Replace all pipe union syntax with Optional or Union from the typing module:
from typing import Optional
# Instead of:
async def list_components(category: str | None = None) -> list[dict]:
...
# Use:
async def list_components(category: Optional[str] = None) -> list[dict]:
...Proposed Solution
The fix is straightforward: update the condition in _function_parameter_parse_util.py to also check for types.UnionType. Here's the recommended change:
Current Code (lines 148-156)
if (
get_origin(param.annotation) is Union
# only parse simple UnionType, example int | str | float | bool
# complex types.UnionType will be invoked in raise branch
and all(
(_is_builtin_primitive_or_compound(arg) or arg is type(None))
for arg in get_args(param.annotation)
)
):
# ... handle union typeProposed Fix
import types as types_module # Add this import at the top of the file
# Then modify the condition to:
if (
(get_origin(param.annotation) is Union or isinstance(param.annotation, types_module.UnionType))
# only parse simple UnionType, example int | str | float | bool
# complex types.UnionType will be invoked in raise branch
and all(
(_is_builtin_primitive_or_compound(arg) or arg is type(None))
for arg in get_args(param.annotation)
)
):
# ... handle union type (existing code remains unchanged)Alternative Implementation (for Python 3.10+ compatibility check)
If you want to ensure compatibility with Python <3.10 (where types.UnionType doesn't exist), you can use a helper function:
import sys
import types as types_module
from typing import Union, get_origin
def _is_union_type(annotation) -> bool:
"""Check if annotation is a Union type (either typing.Union or types.UnionType)."""
if get_origin(annotation) is Union:
return True
# types.UnionType only exists in Python 3.10+
if sys.version_info >= (3, 10) and isinstance(annotation, types_module.UnionType):
return True
return False
# Then use it in the condition:
if (
_is_union_type(param.annotation)
# only parse simple UnionType, example int | str | float | bool
# complex types.UnionType will be invoked in raise branch
and all(
(_is_builtin_primitive_or_compound(arg) or arg is type(None))
for arg in get_args(param.annotation)
)
):
# ... handle union typeWhy This Works
Both typing.Union and types.UnionType expose their type arguments through get_args(), which returns the same tuple of types regardless of which syntax was used. This means:
get_args(Optional[str])→(<class 'str'>, <class 'NoneType'>)get_args(str | None)→(<class 'str'>, <class 'NoneType'>)
The rest of the logic in the function (checking if all args are primitive types, extracting the arguments, building the schema) works identically for both union type representations. Only the initial type check needs to be updated.
Impact
This limitation has significant implications for modern Python development:
Developer Experience Impact
-
Forces outdated syntax: Developers using Python 3.10+ are forced to use legacy
typing.Optionalandtyping.Unionsyntax, even though the pipe operator is now the standard and recommended approach in Python's official documentation. -
Inconsistent codebases: Projects using modern Python type hints throughout their codebase must revert to old-style type hints specifically for ADK function tools, creating inconsistency and confusion.
-
Surprising failures: The error message suggests the function signature is "complex," but
str | Noneis one of the simplest possible type hints. Developers may not realize the issue is a syntax compatibility problem rather than actual complexity. -
Migration barriers: Existing projects that have already migrated to Python 3.10+ type hints cannot use ADK function tools without reverting their type annotations.
Technical Impact
-
Python version compatibility: Python 3.10 was released on October 4, 2021 (over 4 years ago). Python 3.11 (released October 2022) and Python 3.12 (released October 2023) also support and recommend this syntax.
-
PEP 604 compliance: PEP 604 – Allow writing union types as X | Y is the accepted standard for union types. The ADK's lack of support means it's not fully compatible with modern Python language features.
-
Tooling ecosystem: Modern Python tools (mypy, pylance, ruff, etc.) all fully support and often prefer the
X | Ysyntax. The ADK's limitation is out of step with the broader ecosystem. -
Future-proofing: As Python continues to evolve and more projects adopt Python 3.10+, this limitation will affect an increasing number of users.
Real-World Example
Consider a project with consistent modern type hints:
# user_service.py - Modern Python 3.10+ project
def get_user(user_id: str) -> dict | None:
"""Fetch user by ID."""
...
def search_users(
query: str,
limit: int = 10,
category: str | None = None
) -> list[dict]:
"""Search users with optional filters."""
...
def update_user(
user_id: str,
name: str | None = None,
email: str | None = None
) -> dict:
"""Update user with optional fields."""
...To use these functions as ADK tools, developers must either:
-
Rewrite all type hints (introducing inconsistency):
from typing import Optional def search_users( query: str, limit: int = 10, category: Optional[str] = None ) -> list[dict]: ...
-
Create wrapper functions (adding boilerplate):
from typing import Optional # Original function with modern types def _search_users_impl(query: str, limit: int = 10, category: str | None = None) -> list[dict]: ... # Wrapper for ADK with legacy types def search_users_tool(query: str, limit: int = 10, category: Optional[str] = None) -> list[dict]: return _search_users_impl(query, limit, category)
Both approaches are suboptimal and create maintenance burden.
Additional Context
Python Enhancement Proposal
- PEP 604 – Allow writing union types as X | Y - The official Python Enhancement Proposal that introduced this syntax
- Status: Accepted and implemented in Python 3.10
- Rationale: "This syntax is shorter and more readable, especially for complex types"
Python Release Timeline
- Python 3.10 (October 4, 2021): Introduced
X | Yunion syntax - Python 3.11 (October 24, 2022): Further optimizations
- Python 3.12 (October 2, 2023): Current stable version
- Python 3.13 (October 2024): Latest version
The pipe union syntax has been available for over 4 years and is now the de facto standard in the Python community.
Official Python Documentation
From the official Python typing documentation:
Changed in version 3.10: Unions can now be written as
X | Y. See union type expressions.
The documentation now recommends using X | Y instead of Union[X, Y]:
# Recommended (Python 3.10+)
def handle_data(value: int | str) -> int | str: ...
# Legacy (still supported but not preferred)
from typing import Union
def handle_data(value: Union[int, str]) -> Union[int, str]: ...Comparison with Other Frameworks
Other major Python frameworks and libraries have already adopted support for Python 3.10+ union syntax:
- Pydantic v2: Full support for
X | Ysyntax - FastAPI: Full support for
X | Ysyntax in endpoint parameters - SQLAlchemy 2.0: Full support for
X | Ysyntax in type annotations - Django 4.x: Full support in type hints and model fields
- OpenAI Python SDK: Full support in function definitions
The ADK is one of the few modern Python frameworks that doesn't support this standard syntax.
Related Issues
This may also affect other modern Python type hint features:
- Generic types with
list[T]instead ofList[T] - Literal types
- Type guards and narrowing
It would be worth auditing the ADK's type parsing to ensure full compatibility with all modern Python typing features introduced in Python 3.9-3.12.
Testing the Fix
After implementing the proposed solution, the following test cases should pass:
import pytest
from google.adk.tools import FunctionTool
# Test 1: Modern optional syntax
async def func_modern_optional(param: str | None = None) -> str:
return param or "default"
# Test 2: Modern union syntax
async def func_modern_union(param: str | int | float) -> str:
return str(param)
# Test 3: Legacy optional syntax (should continue working)
from typing import Optional
async def func_legacy_optional(param: Optional[str] = None) -> str:
return param or "default"
# Test 4: Legacy union syntax (should continue working)
from typing import Union
async def func_legacy_union(param: Union[str, int, float]) -> str:
return str(param)
# All should create tools without errors
def test_all_union_syntaxes():
tool1 = FunctionTool(func_modern_optional) # Should work after fix
tool2 = FunctionTool(func_modern_union) # Should work after fix
tool3 = FunctionTool(func_legacy_optional) # Already works
tool4 = FunctionTool(func_legacy_union) # Already works
assert tool1 is not None
assert tool2 is not None
assert tool3 is not None
assert tool4 is not NoneThank you for considering this issue. Supporting modern Python syntax will greatly improve the developer experience for ADK users and align the framework with current Python best practices.