Skip to content

ADK does not support Python 3.10+ union type syntax (str | None) #4371

@ecanlar

Description

@ecanlar

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_agent

4. 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] or Union[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 type

The 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:

  1. Legacy typing.Union syntax (Python 3.5+):

    • Optional[str] → creates a typing.Union[str, None] object
    • Union[str, int] → creates a typing.Union[str, int] object
    • get_origin() returns typing.UnionCheck passes
  2. Modern pipe operator syntax (Python 3.10+):

    • str | None → creates a types.UnionType object
    • str | int → creates a types.UnionType object
    • get_origin() returns types.UnionTypeCheck 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 type

Proposed 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 type

Why 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

  1. Forces outdated syntax: Developers using Python 3.10+ are forced to use legacy typing.Optional and typing.Union syntax, even though the pipe operator is now the standard and recommended approach in Python's official documentation.

  2. 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.

  3. Surprising failures: The error message suggests the function signature is "complex," but str | None is one of the simplest possible type hints. Developers may not realize the issue is a syntax compatibility problem rather than actual complexity.

  4. 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

  1. 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.

  2. 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.

  3. Tooling ecosystem: Modern Python tools (mypy, pylance, ruff, etc.) all fully support and often prefer the X | Y syntax. The ADK's limitation is out of step with the broader ecosystem.

  4. 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:

  1. Rewrite all type hints (introducing inconsistency):

    from typing import Optional
    
    def search_users(
        query: str,
        limit: int = 10,
        category: Optional[str] = None
    ) -> list[dict]:
        ...
  2. 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 | Y union 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 | Y syntax
  • FastAPI: Full support for X | Y syntax in endpoint parameters
  • SQLAlchemy 2.0: Full support for X | Y syntax 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 of List[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 None

Thank 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.

Metadata

Metadata

Assignees

Labels

core[Component] This issue is related to the core interface and implementationrequest clarification[Status] The maintainer need clarification or more information from the author

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions