From c9db50e78e94f915e10658b571cf50af315d00c9 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Thu, 24 Sep 2026 22:41:23 +0530 Subject: [PATCH 1/5] Python: fix(core): accept a bare container type as an edge target for a parameterized output --- .../_workflows/_typing_utils.py | 7 +++++++ .../core/tests/workflow/test_typing_utils.py | 15 +++++++++++++++ .../core/tests/workflow/test_validation.py | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index 6496716f1ec..ba6b83f7f79 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -512,6 +512,13 @@ def is_type_compatible(source_type: type | UnionType | Any, target_type: type | return False return source_type == target_type + # A bare container class on one side (list[str] -> list, or list -> list[str]): + # the unparameterized side accepts any element type, so compare the containers. + if target_origin is None and isinstance(target_type, type) and isinstance(source_origin, type): + return issubclass(source_origin, target_type) + if source_origin is None and isinstance(source_type, type) and isinstance(target_origin, type): + return issubclass(source_type, target_origin) + # Case 5: different container types are not compatible if source_origin != target_origin: return False diff --git a/python/packages/core/tests/workflow/test_typing_utils.py b/python/packages/core/tests/workflow/test_typing_utils.py index 805ad197107..52734e63496 100644 --- a/python/packages/core/tests/workflow/test_typing_utils.py +++ b/python/packages/core/tests/workflow/test_typing_utils.py @@ -441,6 +441,21 @@ class Dog(Animal): assert not is_type_compatible(Animal, Dog) +def test_type_compatibility_bare_container() -> None: + """A bare container class accepts any parameterization of it, in either direction.""" + from collections.abc import Sequence + + assert is_type_compatible(list[str], list) + assert is_type_compatible(dict[str, int], dict) + assert is_type_compatible(tuple[int, int], tuple) + assert is_type_compatible(list[int], Sequence) + assert is_type_compatible(list, list[str]) + + assert not is_type_compatible(list[str], dict) + assert not is_type_compatible(dict, list[str]) + assert not is_type_compatible(list[str], str) + + def test_type_compatibility_unions() -> None: """Test type compatibility with Union types.""" # Source matches target union member diff --git a/python/packages/core/tests/workflow/test_validation.py b/python/packages/core/tests/workflow/test_validation.py index 5798c5ec778..82ca127bcd0 100644 --- a/python/packages/core/tests/workflow/test_validation.py +++ b/python/packages/core/tests/workflow/test_validation.py @@ -98,6 +98,25 @@ def test_edge_duplication_validation_fails(): assert exc_info.value.validation_type == ValidationTypeEnum.EDGE_DUPLICATION +def test_parameterized_output_to_bare_container_input_builds(): + # A list[str] output going to a handler typed with plain `list` used to fail + # validation, although the message is a list at runtime. + class ListProducer(Executor): + @handler + async def produce(self, message: str, ctx: WorkflowContext[list[str]]) -> None: + await ctx.send_message([message]) + + class ListConsumer(Executor): + @handler + async def consume(self, messages: list, ctx: WorkflowContext[None, list]) -> None: # type: ignore[type-arg] + await ctx.yield_output(messages) + + producer = ListProducer(id="producer") + consumer = ListConsumer(id="consumer") + + WorkflowBuilder(start_executor=producer).add_edge(producer, consumer).build() + + def test_type_compatibility_validation_fails(): string_executor = StringExecutor(id="string_executor") int_executor = IntExecutor(id="int_executor") From 854e165aa4ecd587f2d3365c836d27300644c031 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Tue, 29 Sep 2026 10:05:58 +0530 Subject: [PATCH 2/5] Treat bare typing aliases like typing.Sequence as bare containers --- .../core/agent_framework/_workflows/_typing_utils.py | 7 +++++++ .../packages/core/tests/workflow/test_typing_utils.py | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index ba6b83f7f79..f50f33f6397 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -485,6 +485,13 @@ def is_type_compatible(source_type: type | UnionType | Any, target_type: type | target_origin = get_origin(target_type) target_args = get_args(target_type) + # A bare alias from `typing` (typing.Sequence, typing.List) has an origin but no type + # arguments; treat it as the bare container class it stands for. + if source_origin is not None and not source_args and isinstance(source_origin, type): + source_type, source_origin = source_origin, None + if target_origin is not None and not target_args and isinstance(target_origin, type): + target_type, target_origin = target_origin, None + # Case 2: target is Union/Optional - source is compatible if it matches any target member if target_origin is Union or target_origin is UnionType: # Special case: if source is also a Union, check that each source member diff --git a/python/packages/core/tests/workflow/test_typing_utils.py b/python/packages/core/tests/workflow/test_typing_utils.py index 52734e63496..eb2fcce85d1 100644 --- a/python/packages/core/tests/workflow/test_typing_utils.py +++ b/python/packages/core/tests/workflow/test_typing_utils.py @@ -443,6 +443,7 @@ class Dog(Animal): def test_type_compatibility_bare_container() -> None: """A bare container class accepts any parameterization of it, in either direction.""" + import typing from collections.abc import Sequence assert is_type_compatible(list[str], list) @@ -451,6 +452,15 @@ def test_type_compatibility_bare_container() -> None: assert is_type_compatible(list[int], Sequence) assert is_type_compatible(list, list[str]) + # Bare aliases from `typing` carry an origin but no arguments. + assert is_type_compatible(list[str], typing.Sequence) + assert is_type_compatible(list[str], typing.List) # noqa: UP006 + assert is_type_compatible(dict[str, int], typing.Mapping) + assert is_type_compatible(typing.List, typing.Sequence) # noqa: UP006 + assert is_type_compatible(typing.List, list[str]) # noqa: UP006 + assert not is_type_compatible(list[str], typing.Mapping) + assert not is_type_compatible(typing.Dict, list[str]) # noqa: UP006 + assert not is_type_compatible(list[str], dict) assert not is_type_compatible(dict, list[str]) assert not is_type_compatible(list[str], str) From d0e82d589e4c6bba085d9f76ccdd1d522b030e11 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Sun, 4 Oct 2026 18:51:04 +0530 Subject: [PATCH 3/5] Keep typing.List and typing.Dict in the bare alias test from pyupgrade Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/tests/workflow/test_typing_utils.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/python/packages/core/tests/workflow/test_typing_utils.py b/python/packages/core/tests/workflow/test_typing_utils.py index eb2fcce85d1..555e351a6cb 100644 --- a/python/packages/core/tests/workflow/test_typing_utils.py +++ b/python/packages/core/tests/workflow/test_typing_utils.py @@ -452,14 +452,17 @@ def test_type_compatibility_bare_container() -> None: assert is_type_compatible(list[int], Sequence) assert is_type_compatible(list, list[str]) - # Bare aliases from `typing` carry an origin but no arguments. + # Bare aliases from `typing` carry an origin but no arguments. getattr keeps + # pyupgrade from rewriting typing.List and typing.Dict to the builtins. + typing_list = getattr(typing, "List") # noqa: B009 + typing_dict = getattr(typing, "Dict") # noqa: B009 assert is_type_compatible(list[str], typing.Sequence) - assert is_type_compatible(list[str], typing.List) # noqa: UP006 + assert is_type_compatible(list[str], typing_list) assert is_type_compatible(dict[str, int], typing.Mapping) - assert is_type_compatible(typing.List, typing.Sequence) # noqa: UP006 - assert is_type_compatible(typing.List, list[str]) # noqa: UP006 + assert is_type_compatible(typing_list, typing.Sequence) + assert is_type_compatible(typing_list, list[str]) assert not is_type_compatible(list[str], typing.Mapping) - assert not is_type_compatible(typing.Dict, list[str]) # noqa: UP006 + assert not is_type_compatible(typing_dict, list[str]) assert not is_type_compatible(list[str], dict) assert not is_type_compatible(dict, list[str]) From f8b34ac95ed935039d548f232616f22d66a42da9 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Mon, 5 Oct 2026 22:39:01 +0530 Subject: [PATCH 4/5] Python: guard the bare container issubclass checks against non-runtime Protocols --- .../agent_framework/_workflows/_typing_utils.py | 11 +++++++++-- .../core/tests/workflow/test_typing_utils.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index f50f33f6397..d19348a203c 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -521,10 +521,17 @@ def is_type_compatible(source_type: type | UnionType | Any, target_type: type | # A bare container class on one side (list[str] -> list, or list -> list[str]): # the unparameterized side accepts any element type, so compare the containers. + # issubclass raises TypeError for a Protocol that is not runtime_checkable. if target_origin is None and isinstance(target_type, type) and isinstance(source_origin, type): - return issubclass(source_origin, target_type) + try: + return issubclass(source_origin, target_type) + except TypeError: + return False if source_origin is None and isinstance(source_type, type) and isinstance(target_origin, type): - return issubclass(source_type, target_origin) + try: + return issubclass(source_type, target_origin) + except TypeError: + return False # Case 5: different container types are not compatible if source_origin != target_origin: diff --git a/python/packages/core/tests/workflow/test_typing_utils.py b/python/packages/core/tests/workflow/test_typing_utils.py index 555e351a6cb..26ecfe1f36e 100644 --- a/python/packages/core/tests/workflow/test_typing_utils.py +++ b/python/packages/core/tests/workflow/test_typing_utils.py @@ -469,6 +469,22 @@ def test_type_compatibility_bare_container() -> None: assert not is_type_compatible(list[str], str) +def test_type_compatibility_bare_container_and_protocol() -> None: + """A Protocol that is not runtime_checkable is reported as incompatible, not raised.""" + from typing import Generic, Protocol, TypeVar + + T = TypeVar("T") + + class SupportsClose(Protocol): + def close(self) -> None: ... + + class Box(Protocol, Generic[T]): + def get(self) -> T: ... + + assert not is_type_compatible(list[str], SupportsClose) + assert not is_type_compatible(list, Box[int]) + + def test_type_compatibility_unions() -> None: """Test type compatibility with Union types.""" # Source matches target union member From 326cafba55f3766ac27c7a3006574f4e0041f894 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Wed, 7 Oct 2026 04:24:09 +0530 Subject: [PATCH 5/5] Python: use a covariant type variable in the test Protocol --- python/packages/core/tests/workflow/test_typing_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/packages/core/tests/workflow/test_typing_utils.py b/python/packages/core/tests/workflow/test_typing_utils.py index 26ecfe1f36e..ef86137bb92 100644 --- a/python/packages/core/tests/workflow/test_typing_utils.py +++ b/python/packages/core/tests/workflow/test_typing_utils.py @@ -473,13 +473,13 @@ def test_type_compatibility_bare_container_and_protocol() -> None: """A Protocol that is not runtime_checkable is reported as incompatible, not raised.""" from typing import Generic, Protocol, TypeVar - T = TypeVar("T") + T_co = TypeVar("T_co", covariant=True) class SupportsClose(Protocol): def close(self) -> None: ... - class Box(Protocol, Generic[T]): - def get(self) -> T: ... + class Box(Protocol, Generic[T_co]): + def get(self) -> T_co: ... assert not is_type_compatible(list[str], SupportsClose) assert not is_type_compatible(list, Box[int])