Python: Allow union types in FanIn edge group (#868)

* Improve type utils

* Add sample

* Add Union

* Add more test cases

* Add more test cases

* Fix RequestResponse typing to only coerce mapping original_request

---------

Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
This commit is contained in:
Tao Chen
2025-09-23 14:02:00 -07:00
committed by GitHub
Unverified
parent 2133043f11
commit b28e1db478
6 changed files with 363 additions and 11 deletions
@@ -1,7 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import Mapping
from dataclasses import fields, is_dataclass
from types import UnionType
from typing import Any, Union, get_args, get_origin
logger = logging.getLogger(__name__)
@@ -54,7 +56,7 @@ def _coerce_to_type(value: Any, target_type: type) -> Any | None:
return None
def is_instance_of(data: Any, target_type: type) -> bool:
def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool:
"""Check if the data is an instance of the target type.
Args:
@@ -77,17 +79,28 @@ def is_instance_of(data: Any, target_type: type) -> bool:
# Case 2: target_type is Optional[T] or Union[T1, T2, ...]
# Optional[T] is really just as Union[T, None]
if origin is UnionType:
return any(is_instance_of(data, arg) for arg in args)
# Case 2b: Handle typing.Union (legacy Union syntax)
if origin is Union:
return any(is_instance_of(data, arg) for arg in args)
# Case 3: target_type is a generic type
if origin in [list, set]:
return isinstance(data, origin) and all(is_instance_of(item, args[0]) for item in data) # type: ignore
return isinstance(data, origin) and (
not args or all(any(is_instance_of(item, arg) for arg in args) for item in data)
) # type: ignore
# Case 4: target_type is a tuple
if origin is tuple:
if len(args) == 2 and args[1] is Ellipsis: # Tuple[T, ...] case
element_type = args[0]
return isinstance(data, tuple) and all(is_instance_of(item, element_type) for item in data)
if len(args) == 1 and args[0] is Ellipsis: # Tuple[...] case
return isinstance(data, tuple)
if len(args) == 0:
return isinstance(data, tuple)
return (
isinstance(data, tuple)
and len(data) == len(args) # type: ignore
@@ -96,9 +109,12 @@ def is_instance_of(data: Any, target_type: type) -> bool:
# Case 5: target_type is a dict
if origin is dict:
return isinstance(data, dict) and all(
is_instance_of(key, args[0]) and is_instance_of(value, args[1])
for key, value in data.items() # type: ignore
return isinstance(data, dict) and (
not args
or all(
is_instance_of(key, args[0]) and is_instance_of(value, args[1])
for key, value in data.items() # type: ignore
)
)
# Case 6: target_type is RequestResponse[T, U] - validate generic parameters
@@ -114,10 +130,17 @@ def is_instance_of(data: Any, target_type: type) -> bool:
and data.original_request is not None
and not is_instance_of(data.original_request, request_type)
):
coerced = _coerce_to_type(data.original_request, request_type)
if coerced is None:
# Checkpoint decoding can leave original_request as a plain mapping. In that
# case we coerce it back into the expected request type so downstream handlers
# and validators still receive a fully typed RequestResponse instance.
original_request = data.original_request
if isinstance(original_request, Mapping):
coerced = _coerce_to_type(dict(original_request), request_type)
if coerced is None or not isinstance(coerced, request_type):
return False
data.original_request = coerced
else:
return False
data.original_request = coerced
if hasattr(data, "data") and data.data is not None and not is_instance_of(data.data, response_type):
return False
return True
@@ -79,6 +79,33 @@ class MockAggregator(Executor):
self.call_count += 1
self.last_message = message
@handler
async def mock_aggregator_handler_secondary(
self,
message: list[MockMessageSecondary],
ctx: WorkflowContext,
) -> None:
"""A mock aggregator handler that does nothing."""
self.call_count += 1
self.last_message = message
class MockAggregatorSecondary(Executor):
"""A mock aggregator that has a handler for a union type for testing purposes."""
call_count: int = 0
last_message: Any = None
@handler
async def mock_aggregator_handler_combine(
self,
message: list[MockMessage | MockMessageSecondary],
ctx: WorkflowContext,
) -> None:
"""A mock aggregator handler that does nothing."""
self.call_count += 1
self.last_message = message
# region Edge
@@ -1062,6 +1089,73 @@ async def test_fan_in_edge_group_tracing_type_mismatch(span_exporter) -> None:
assert span.attributes.get("edge_group.delivery_status") == EdgeGroupDeliveryStatus.DROPPED_TYPE_MISMATCH.value
async def test_fan_in_edge_group_with_multiple_message_types() -> None:
source1 = MockExecutor(id="source_executor_1")
source2 = MockExecutor(id="source_executor_2")
target = MockAggregatorSecondary(id="target_executor")
edge_group = FanInEdgeGroup(source_ids=[source1.id, source2.id], target_id=target.id)
executors: dict[str, Executor] = {source1.id: source1, source2.id: source2, target.id: target}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
success = await edge_runner.send_message(
Message(data=data, source_id=source1.id),
shared_state,
ctx,
)
assert success
data2 = MockMessageSecondary(data="test")
success = await edge_runner.send_message(
Message(data=data2, source_id=source2.id),
shared_state,
ctx,
)
assert success
async def test_fan_in_edge_group_with_multiple_message_types_failed() -> None:
source1 = MockExecutor(id="source_executor_1")
source2 = MockExecutor(id="source_executor_2")
target = MockAggregator(id="target_executor")
edge_group = FanInEdgeGroup(source_ids=[source1.id, source2.id], target_id=target.id)
executors: dict[str, Executor] = {source1.id: source1, source2.id: source2, target.id: target}
edge_runner = create_edge_runner(edge_group, executors)
shared_state = SharedState()
ctx = InProcRunnerContext()
data = MockMessage(data="test")
success = await edge_runner.send_message(
Message(data=data, source_id=source1.id),
shared_state,
ctx,
)
assert success
with pytest.raises(RuntimeError):
# Although `MockAggregator` can handle `list[MockMessage]` and `list[MockMessageSecondary]`
# separately (i.e., it has handlers for each type individually), it cannot handle
# `list[MockMessage | MockMessageSecondary]` (a list containing a mix of both types).
# With the fan-in edge group, the target executor must handle all message types from the
# source executors as a union.
data2 = MockMessageSecondary(data="test")
_ = await edge_runner.send_message(
Message(data=data2, source_id=source2.id),
shared_state,
ctx,
)
# endregion FanInEdgeGroup
# region SwitchCaseEdgeGroup
@@ -0,0 +1,134 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from typing import Any, Generic, TypeVar, Union
from agent_framework._workflow import RequestInfoMessage, RequestResponse
from agent_framework._workflow._typing_utils import is_instance_of
def test_basic_types() -> None:
"""Test basic built-in types."""
assert is_instance_of(5, int)
assert is_instance_of("hello", str)
assert is_instance_of(None, type(None))
def test_union_types() -> None:
"""Test union types (|) and optional types."""
assert is_instance_of(5, int | str)
assert is_instance_of("hello", int | str)
assert is_instance_of(5, Union[int, str])
assert not is_instance_of(5.0, int | str)
def test_list_types() -> None:
"""Test list types with various element types."""
assert is_instance_of([], list)
assert is_instance_of([1, 2, 3], list)
assert is_instance_of([1, 2, 3], list[int])
assert is_instance_of([1, 2, 3], list[int | str])
assert is_instance_of([1, "a", 3], list[int | str])
assert is_instance_of([1, "a", 3], list[Union[int, str]])
assert not is_instance_of([1, 2.0, 3], dict)
assert not is_instance_of([1, 2.0, 3], list[int | str])
def test_tuple_types() -> None:
"""Test tuple types with fixed and variable lengths."""
assert is_instance_of((1, "a"), tuple)
assert is_instance_of((1, "a"), tuple[int, str])
assert is_instance_of((1, "a", 3), tuple[int | str, ...])
assert is_instance_of((1, 2.0, "a"), tuple[...]) # type: ignore
assert not is_instance_of((1, 2.0, 3), tuple[int | str, ...])
assert not is_instance_of((1, 2.0, 3), dict)
def test_dict_types() -> None:
"""Test dictionary types with typed keys and values."""
assert is_instance_of({"key": "value"}, dict)
assert is_instance_of({"key": "value"}, dict[str, str])
assert is_instance_of({"key": 5, "another_key": "value"}, dict[str, int | str])
assert not is_instance_of({"key": 5, "another_key": 3.0}, dict[str, int | str])
assert not is_instance_of({"key": 5, "another_key": 3.0}, list)
def test_set_types() -> None:
"""Test set types with various element types."""
assert is_instance_of({1, 2, 3}, set)
assert is_instance_of({1, 2, 3}, set[int])
assert is_instance_of({1, 2, 3}, set[int | str])
assert is_instance_of({1, "a", 3}, set[int | str])
assert is_instance_of({1, "a", 3}, set[Union[int, str]])
assert is_instance_of(set(), set[int])
assert not is_instance_of({1, 2.0, 3}, set[int | str])
assert not is_instance_of({1, 2, 3}, list)
assert not is_instance_of({1, 2, 3}, dict)
def test_any_type() -> None:
"""Test Any type - should accept all values."""
assert is_instance_of(5, Any)
assert is_instance_of("hello", Any)
assert is_instance_of([1, 2, 3], Any)
def test_nested_types() -> None:
"""Test complex nested type structures."""
assert is_instance_of([{"key": [1, 2]}, {"another_key": [3]}], list[dict[str, list[int]]])
assert not is_instance_of([{"key": [1, 2]}, {"another_key": [3.0]}], list[dict[str, list[int]]])
def test_custom_type() -> None:
"""Test custom object type checking."""
@dataclass
class CustomClass:
value: int
instance = CustomClass(10)
assert is_instance_of(instance, CustomClass)
assert not is_instance_of(instance, dict)
def test_request_response_type() -> None:
"""Test RequestResponse generic type checking."""
request_instance = RequestResponse[RequestInfoMessage, str](
is_handled=False,
original_request=RequestInfoMessage(),
)
class CustomRequestInfoMessage(RequestInfoMessage):
info: str
assert is_instance_of(request_instance, RequestResponse[RequestInfoMessage, str])
assert not is_instance_of(request_instance, RequestResponse[CustomRequestInfoMessage, str])
def test_custom_generic_type() -> None:
"""Test custom generic type checking."""
T = TypeVar("T")
U = TypeVar("U")
class CustomClass(Generic[T, U]):
def __init__(self, request: T, response: U, extra: Any | None = None) -> None:
self.request = request
self.response = response
self.extra = extra
instance = CustomClass[int, str](request=5, response="response")
assert is_instance_of(instance, CustomClass[int, str])
# Generic parameters are not strictly enforced at runtime
assert is_instance_of(instance, CustomClass[str, str])
def test_edge_cases() -> None:
"""Test edge cases and unusual scenarios."""
assert is_instance_of([], list[int]) # Empty list should be valid
assert is_instance_of((), tuple[int, ...]) # Empty tuple should be valid
assert is_instance_of({}, dict[str, int]) # Empty dict should be valid
assert is_instance_of(None, int | None) # Optional type with None
assert not is_instance_of(5, str | None) # Optional type without matching type