From 886ad63f163b316f3e3faafb318221d95d8957f4 Mon Sep 17 00:00:00 2001 From: Kranthi Kumar Manchikanti Date: Mon, 9 Mar 2026 00:17:33 -0400 Subject: [PATCH 1/5] Python: Improve error message when TypeVar is used in handler registration Fixes #4547. Adds early detection of unresolved TypeVar instances in: - @handler decorator (both explicit and introspected type paths) - @executor decorator (both explicit and introspected type paths) - WorkflowContext type argument validation (direct and union members) When a TypeVar is detected, a clear ValueError is raised with actionable guidance to use concrete types via @handler(input=ConcreteType, output=ConcreteType). --- .../agent_framework/_workflows/_executor.py | 23 +++++++++++++++++++ .../_workflows/_function_executor.py | 23 ++++++++++++++++++- .../_workflows/_workflow_context.py | 17 ++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index f57102b2bc..68430e203b 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -622,6 +622,20 @@ def decorator( resolve_type_annotation(workflow_output, func.__globals__) if workflow_output is not None else None ) + # Check for unresolved TypeVars in explicit type parameters + for param_name, param_type in [ + ("input", resolved_input_type), + ("output", resolved_output_type), + ("workflow_output", resolved_workflow_output_type), + ]: + if param_type is not None and isinstance(param_type, TypeVar): + raise ValueError( + f"Handler '{func.__name__}' has an unresolved TypeVar '{param_type}' " + f"as its {param_name} type. " + f"Use @handler(input=ConcreteType, output=ConcreteType) with concrete types " + f"for parameterized executors." + ) + # Validate signature structure (correct number of params, ctx is WorkflowContext) # but skip type extraction since we're using explicit types _validate_handler_signature(func, skip_message_annotation=True) @@ -652,6 +666,15 @@ def decorator( "or explicit type parameters (input, output, workflow_output)" ) + # Check for unresolved TypeVar in introspected message type + if isinstance(message_type, TypeVar): + raise ValueError( + f"Handler '{func.__name__}' has an unresolved TypeVar '{message_type}' " + f"as its message type. " + f"Use @handler(input=ConcreteType, output=ConcreteType) with concrete types " + f"for parameterized executors." + ) + final_output_types = inferred_output_types final_workflow_output_types = inferred_workflow_output_types diff --git a/python/packages/core/agent_framework/_workflows/_function_executor.py b/python/packages/core/agent_framework/_workflows/_function_executor.py index 0d46c0daa3..20225702fb 100644 --- a/python/packages/core/agent_framework/_workflows/_function_executor.py +++ b/python/packages/core/agent_framework/_workflows/_function_executor.py @@ -21,7 +21,7 @@ import types import typing from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, TypeVar from ._executor import Executor from ._typing_utils import normalize_type_to_list, resolve_type_annotation @@ -94,6 +94,19 @@ def __init__( _validate_function_signature(func, skip_message_annotation=resolved_input_type is not None) ) + # Check for unresolved TypeVars in explicit type parameters + for param_name, param_type in [ + ("input", resolved_input_type), + ("output", resolved_output_type), + ("workflow_output", resolved_workflow_output_type), + ]: + if param_type is not None and isinstance(param_type, TypeVar): + raise ValueError( + f"Executor '{func.__name__}' has an unresolved TypeVar '{param_type}' " + f"as its {param_name} type. " + f"Use @executor(input=ConcreteType, output=ConcreteType) with concrete types." + ) + # Use explicit types if provided, otherwise fall back to introspection message_type = resolved_input_type if resolved_input_type is not None else introspected_message_type output_types: list[type[Any] | types.UnionType] = ( @@ -114,6 +127,14 @@ def __init__( "or an explicit input_type parameter" ) + # Check for unresolved TypeVar in introspected message type + if isinstance(message_type, TypeVar): + raise ValueError( + f"Executor '{func.__name__}' has an unresolved TypeVar '{message_type}' " + f"as its message type. " + f"Use @executor(input=ConcreteType, output=ConcreteType) with concrete types." + ) + # Store the original function self._original_func = func # Determine if function has WorkflowContext parameter diff --git a/python/packages/core/agent_framework/_workflows/_workflow_context.py b/python/packages/core/agent_framework/_workflows/_workflow_context.py index bfc8601e5d..6a8ab3190c 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_context.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_context.py @@ -176,10 +176,27 @@ def _is_type_like(x: Any) -> bool: if type_arg is Any: continue + # Check for unresolved TypeVar early with an actionable error message + if isinstance(type_arg, TypeVar): + raise ValueError( + f"{context_description} {parameter_name} {param_description} " + f"has an unresolved TypeVar '{type_arg}'. " + f"Use @handler(input=ConcreteType, output=ConcreteType) with concrete types " + f"for parameterized executors." + ) + # Check if it's a union type and validate each member union_origin = get_origin(type_arg) if union_origin in (Union, UnionType): union_members = get_args(type_arg) + typevar_members = [m for m in union_members if isinstance(m, TypeVar)] + if typevar_members: + raise ValueError( + f"{context_description} {parameter_name} {param_description} " + f"contains unresolved TypeVar(s): {typevar_members}. " + f"Use @handler(input=ConcreteType, output=ConcreteType) with concrete types " + f"for parameterized executors." + ) invalid_members = [m for m in union_members if not _is_type_like(m) and m is not Any] if invalid_members: raise ValueError( From 81a865345902201064da0a39e69183f25da86ebd Mon Sep 17 00:00:00 2001 From: Kranthi Kumar Manchikanti Date: Wed, 11 Mar 2026 23:04:19 -0400 Subject: [PATCH 2/5] Address PR review: runtime-safe TypeVar detection and unit tests - Add shared is_typevar() helper in _typing_utils.py that safely detects TypeVar from both typing and typing_extensions modules - Replace all isinstance(x, TypeVar) calls with is_typevar() in _executor.py, _function_executor.py, and _workflow_context.py - Add 18 unit tests covering TypeVar validation for @handler, @executor, and WorkflowContext[T] (explicit params, introspection, union members) --- .../agent_framework/_workflows/_executor.py | 6 +- .../_workflows/_function_executor.py | 8 +- .../_workflows/_typing_utils.py | 20 ++ .../_workflows/_workflow_context.py | 5 +- .../tests/workflow/test_typevar_validation.py | 190 ++++++++++++++++++ 5 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 python/packages/core/tests/workflow/test_typevar_validation.py diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index 68430e203b..a960619f07 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -20,7 +20,7 @@ from ._request_info_mixin import RequestInfoMixin from ._runner_context import MessageType, RunnerContext, WorkflowMessage from ._state import State -from ._typing_utils import is_instance_of, normalize_type_to_list, resolve_type_annotation +from ._typing_utils import is_instance_of, is_typevar, normalize_type_to_list, resolve_type_annotation from ._workflow_context import WorkflowContext, validate_workflow_context_annotation logger = logging.getLogger(__name__) @@ -628,7 +628,7 @@ def decorator( ("output", resolved_output_type), ("workflow_output", resolved_workflow_output_type), ]: - if param_type is not None and isinstance(param_type, TypeVar): + if param_type is not None and is_typevar(param_type): raise ValueError( f"Handler '{func.__name__}' has an unresolved TypeVar '{param_type}' " f"as its {param_name} type. " @@ -667,7 +667,7 @@ def decorator( ) # Check for unresolved TypeVar in introspected message type - if isinstance(message_type, TypeVar): + if is_typevar(message_type): raise ValueError( f"Handler '{func.__name__}' has an unresolved TypeVar '{message_type}' " f"as its message type. " diff --git a/python/packages/core/agent_framework/_workflows/_function_executor.py b/python/packages/core/agent_framework/_workflows/_function_executor.py index 20225702fb..c0d49cf013 100644 --- a/python/packages/core/agent_framework/_workflows/_function_executor.py +++ b/python/packages/core/agent_framework/_workflows/_function_executor.py @@ -21,10 +21,10 @@ import types import typing from collections.abc import Awaitable, Callable -from typing import Any, TypeVar +from typing import Any from ._executor import Executor -from ._typing_utils import normalize_type_to_list, resolve_type_annotation +from ._typing_utils import is_typevar, normalize_type_to_list, resolve_type_annotation from ._workflow_context import WorkflowContext, validate_workflow_context_annotation if sys.version_info >= (3, 11): @@ -100,7 +100,7 @@ def __init__( ("output", resolved_output_type), ("workflow_output", resolved_workflow_output_type), ]: - if param_type is not None and isinstance(param_type, TypeVar): + if param_type is not None and is_typevar(param_type): raise ValueError( f"Executor '{func.__name__}' has an unresolved TypeVar '{param_type}' " f"as its {param_name} type. " @@ -128,7 +128,7 @@ def __init__( ) # Check for unresolved TypeVar in introspected message type - if isinstance(message_type, TypeVar): + if is_typevar(message_type): raise ValueError( f"Executor '{func.__name__}' has an unresolved TypeVar '{message_type}' " f"as its message type. " diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index 07b6d15bca..1de8c0ee6b 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -1,10 +1,30 @@ # Copyright (c) Microsoft. All rights reserved. +import typing from types import UnionType from typing import Any, TypeGuard, Union, cast, get_args, get_origin +import typing_extensions + from .._agents import Agent +# Pre-compute the TypeVar types for runtime-safe detection. +# isinstance(x, TypeVar) can fail if TypeVar is a factory/callable +# on some Python versions, so we compare against the actual runtime type. +_TYPEVAR_TYPES = (type(typing.TypeVar("_T")), type(typing_extensions.TypeVar("_T"))) + + +def is_typevar(x: Any) -> bool: + """Check if x is an unresolved TypeVar instance (from typing or typing_extensions). + + Args: + x: The value to check. + + Returns: + True if x is a TypeVar instance, False otherwise. + """ + return isinstance(x, _TYPEVAR_TYPES) + def is_chat_agent(agent: Any) -> TypeGuard[Agent]: """Check if the given agent is a Agent. diff --git a/python/packages/core/agent_framework/_workflows/_workflow_context.py b/python/packages/core/agent_framework/_workflows/_workflow_context.py index 6a8ab3190c..4e82ea642e 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_context.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_context.py @@ -21,6 +21,7 @@ ) from ._runner_context import RunnerContext, WorkflowMessage from ._state import State +from ._typing_utils import is_typevar if TYPE_CHECKING: from ._executor import Executor @@ -177,7 +178,7 @@ def _is_type_like(x: Any) -> bool: continue # Check for unresolved TypeVar early with an actionable error message - if isinstance(type_arg, TypeVar): + if is_typevar(type_arg): raise ValueError( f"{context_description} {parameter_name} {param_description} " f"has an unresolved TypeVar '{type_arg}'. " @@ -189,7 +190,7 @@ def _is_type_like(x: Any) -> bool: union_origin = get_origin(type_arg) if union_origin in (Union, UnionType): union_members = get_args(type_arg) - typevar_members = [m for m in union_members if isinstance(m, TypeVar)] + typevar_members = [m for m in union_members if is_typevar(m)] if typevar_members: raise ValueError( f"{context_description} {parameter_name} {param_description} " diff --git a/python/packages/core/tests/workflow/test_typevar_validation.py b/python/packages/core/tests/workflow/test_typevar_validation.py new file mode 100644 index 0000000000..d54fc60222 --- /dev/null +++ b/python/packages/core/tests/workflow/test_typevar_validation.py @@ -0,0 +1,190 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for unresolved TypeVar detection during handler/executor registration.""" + +from typing import TypeVar + +import pytest +from typing_extensions import Never + +from agent_framework import ( + Executor, + FunctionExecutor, + WorkflowContext, + executor, + handler, +) +from agent_framework._workflows._typing_utils import is_typevar + +T = TypeVar("T") +U = TypeVar("U") + + +class TestIsTypevarHelper: + """Tests for the runtime-safe is_typevar helper.""" + + def test_detects_typing_typevar(self): + """is_typevar should detect TypeVar from typing module.""" + import typing + + tv = typing.TypeVar("tv") + assert is_typevar(tv) + + def test_detects_typing_extensions_typevar(self): + """is_typevar should detect TypeVar from typing_extensions module.""" + import typing_extensions + + tv = typing_extensions.TypeVar("tv") + assert is_typevar(tv) + + def test_rejects_concrete_types(self): + """is_typevar should return False for concrete types.""" + assert not is_typevar(str) + assert not is_typevar(int) + assert not is_typevar(None) + assert not is_typevar(Never) + + def test_rejects_non_types(self): + """is_typevar should return False for non-type values.""" + assert not is_typevar("hello") + assert not is_typevar(42) + assert not is_typevar([]) + + +class TestHandlerTypeVarValidation: + """Tests for @handler decorator rejecting unresolved TypeVars.""" + + def test_handler_explicit_input_typevar_raises(self): + """@handler(input=T) with a TypeVar should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class _Bad(Executor): # pyright: ignore[reportUnusedClass] + @handler(input=T) # type: ignore[arg-type] + async def handle(self, message, ctx: WorkflowContext[str]) -> None: # type: ignore[no-untyped-def] + pass + + def test_handler_explicit_output_typevar_raises(self): + """@handler(input=str, output=T) with a TypeVar should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class _Bad(Executor): # pyright: ignore[reportUnusedClass] + @handler(input=str, output=T) # type: ignore[arg-type] + async def handle(self, message: str, ctx: WorkflowContext[str]) -> None: + pass + + def test_handler_explicit_workflow_output_typevar_raises(self): + """@handler(input=str, workflow_output=T) should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class _Bad(Executor): # pyright: ignore[reportUnusedClass] + @handler(input=str, workflow_output=T) # type: ignore[arg-type] + async def handle(self, message: str, ctx: WorkflowContext[str]) -> None: + pass + + def test_handler_introspected_typevar_raises(self): + """@handler with TypeVar in message annotation should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class _Bad(Executor): # pyright: ignore[reportUnusedClass] + @handler # type: ignore[arg-type] + async def handle(self, message: T, ctx: WorkflowContext[str]) -> None: # type: ignore[valid-type] + pass + + def test_handler_concrete_types_work(self): + """@handler with concrete types should succeed.""" + + class Good(Executor): + @handler(input=str, output=str) + async def handle(self, message: str, ctx: WorkflowContext[str]) -> None: + pass + + assert Good is not None + + +class TestExecutorTypeVarValidation: + """Tests for @executor decorator rejecting unresolved TypeVars.""" + + def test_executor_explicit_input_typevar_raises(self): + """@executor(input=T) with a TypeVar should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + @executor(input=T) # type: ignore[arg-type] + async def bad_func(message, ctx: WorkflowContext[str]) -> None: # type: ignore[no-untyped-def] + pass + + def test_executor_explicit_output_typevar_raises(self): + """@executor(input=str, output=T) with a TypeVar should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + @executor(input=str, output=T) # type: ignore[arg-type] + async def bad_func(message: str, ctx: WorkflowContext[str]) -> None: + pass + + def test_executor_introspected_typevar_raises(self): + """@executor with TypeVar in message annotation should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + FunctionExecutor(self._make_typevar_func()) # type: ignore[arg-type] + + def test_executor_concrete_types_work(self): + """@executor with concrete types should succeed.""" + + @executor(input=str, output=str) + async def good_func(message: str, ctx: WorkflowContext[str]) -> None: + pass + + assert good_func is not None + + @staticmethod + def _make_typevar_func(): + """Create a function with TypeVar annotation for testing.""" + + async def func(message: T, ctx: WorkflowContext[str]) -> None: # type: ignore[valid-type] + pass + + return func + + +class TestWorkflowContextTypeVarValidation: + """Tests for WorkflowContext[T] rejecting unresolved TypeVars.""" + + def test_context_direct_typevar_raises(self): + """WorkflowContext[T] with a TypeVar should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + @executor(id="bad") + async def bad_func(message: str, ctx: WorkflowContext[T]) -> None: # type: ignore[valid-type] + pass + + def test_context_union_typevar_raises(self): + """WorkflowContext[T | str] with a TypeVar in union should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + @executor(id="bad") + async def bad_func(message: str, ctx: WorkflowContext[T | str]) -> None: # type: ignore[valid-type] + pass + + def test_context_workflow_output_typevar_raises(self): + """WorkflowContext[str, T] with a TypeVar should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + @executor(id="bad") + async def bad_func(message: str, ctx: WorkflowContext[str, T]) -> None: # type: ignore[valid-type] + pass + + def test_context_concrete_types_work(self): + """WorkflowContext[str] with concrete types should succeed.""" + + @executor(id="good") + async def good_func(message: str, ctx: WorkflowContext[str]) -> None: + pass + + assert good_func is not None + + def test_context_class_handler_typevar_raises(self): + """Class-based handler with WorkflowContext[T] should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class _Bad(Executor): # pyright: ignore[reportUnusedClass] + @handler # pyright: ignore[reportUnknownArgumentType] + async def handle(self, message: str, ctx: WorkflowContext[T]) -> None: # type: ignore[valid-type] + pass From b164376939e36cb14bf1930890478d55190ba870 Mon Sep 17 00:00:00 2001 From: Kranthi Kumar Manchikanti Date: Sun, 29 Mar 2026 22:57:34 -0400 Subject: [PATCH 3/5] Fix pyright error: add type annotation to _TYPEVAR_TYPES Pyright's reportUnknownVariableType flagged the inferred type as partially unknown. Adding an explicit `tuple[type, ...]` annotation resolves the strict-mode check. --- .../packages/core/agent_framework/_workflows/_typing_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index 1de8c0ee6b..f56ab0b2a4 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -11,7 +11,7 @@ # Pre-compute the TypeVar types for runtime-safe detection. # isinstance(x, TypeVar) can fail if TypeVar is a factory/callable # on some Python versions, so we compare against the actual runtime type. -_TYPEVAR_TYPES = (type(typing.TypeVar("_T")), type(typing_extensions.TypeVar("_T"))) +_TYPEVAR_TYPES: tuple[type, ...] = (type(typing.TypeVar("_T")), type(typing_extensions.TypeVar("_T"))) def is_typevar(x: Any) -> bool: From 907c31832eb56083a6929603f0361c30da55fd83 Mon Sep 17 00:00:00 2001 From: Kranthi Kumar Manchikanti Date: Sun, 29 Mar 2026 23:06:45 -0400 Subject: [PATCH 4/5] Suppress pyright reportUnknownVariableType for _TYPEVAR_TYPES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pyright cannot infer the runtime type of TypeVar constructors, so the tuple elements resolve to type[Unknown]. A type annotation alone does not satisfy strict mode — add an inline suppression for this specific diagnostic since the unknown types are intentional (runtime TypeVar class detection). --- .../packages/core/agent_framework/_workflows/_typing_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index f56ab0b2a4..0b61d60f68 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -11,7 +11,7 @@ # Pre-compute the TypeVar types for runtime-safe detection. # isinstance(x, TypeVar) can fail if TypeVar is a factory/callable # on some Python versions, so we compare against the actual runtime type. -_TYPEVAR_TYPES: tuple[type, ...] = (type(typing.TypeVar("_T")), type(typing_extensions.TypeVar("_T"))) +_TYPEVAR_TYPES: tuple[type, ...] = (type(typing.TypeVar("_T")), type(typing_extensions.TypeVar("_T"))) # pyright: ignore[reportUnknownVariableType] def is_typevar(x: Any) -> bool: From b9019ce00d0dd32120a03ca184a0de620c01c34b Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Wed, 8 Jul 2026 10:17:08 +0900 Subject: [PATCH 5/5] Reject nested TypeVars in workflow annotations --- .../agent_framework/_workflows/_executor.py | 8 +- .../_workflows/_function_executor.py | 8 +- .../_workflows/_typing_utils.py | 15 ++++ .../_workflows/_workflow_context.py | 14 +--- .../tests/workflow/test_typevar_validation.py | 80 +++++++++++++++++-- 5 files changed, 100 insertions(+), 25 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index e32e2f3dcd..3571df2557 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -21,7 +21,7 @@ from ._request_info_mixin import RequestInfoMixin from ._runner_context import MessageType, RunnerContext, WorkflowMessage from ._state import State -from ._typing_utils import is_instance_of, is_typevar, normalize_type_to_list, resolve_type_annotation +from ._typing_utils import contains_typevar, is_instance_of, normalize_type_to_list, resolve_type_annotation from ._workflow_context import WorkflowContext, validate_workflow_context_annotation logger = logging.getLogger(__name__) @@ -656,7 +656,7 @@ def decorator( ("output", resolved_output_type), ("workflow_output", resolved_workflow_output_type), ]: - if param_type is not None and is_typevar(param_type): + if param_type is not None and contains_typevar(param_type): raise ValueError( f"Handler '{func.__name__}' has an unresolved TypeVar '{param_type}' " f"as its {param_name} type. " @@ -695,7 +695,7 @@ def decorator( ) # Check for unresolved TypeVar in introspected message type - if is_typevar(message_type): + if contains_typevar(message_type): raise ValueError( f"Handler '{func.__name__}' has an unresolved TypeVar '{message_type}' " f"as its message type. " @@ -788,7 +788,7 @@ def _validate_handler_signature( # Reject unresolved TypeVar in message annotation -- these are not supported # for workflow type validation and must be replaced with concrete types. - if not skip_message_annotation and isinstance(message_type, TypeVar): + if not skip_message_annotation and contains_typevar(message_type): raise ValueError( f"Handler {func.__name__} has an unresolved TypeVar '{message_type}' as its message type annotation. " "Generic TypeVar annotations are not supported for workflow type validation. " diff --git a/python/packages/core/agent_framework/_workflows/_function_executor.py b/python/packages/core/agent_framework/_workflows/_function_executor.py index 46cf4aa0e0..dc641697c1 100644 --- a/python/packages/core/agent_framework/_workflows/_function_executor.py +++ b/python/packages/core/agent_framework/_workflows/_function_executor.py @@ -24,7 +24,7 @@ from typing import Any from ._executor import Executor -from ._typing_utils import is_typevar, normalize_type_to_list, resolve_type_annotation +from ._typing_utils import contains_typevar, normalize_type_to_list, resolve_type_annotation from ._workflow_context import WorkflowContext, validate_workflow_context_annotation if sys.version_info >= (3, 11): @@ -100,7 +100,7 @@ def __init__( ("output", resolved_output_type), ("workflow_output", resolved_workflow_output_type), ]: - if param_type is not None and is_typevar(param_type): + if param_type is not None and contains_typevar(param_type): raise ValueError( f"Executor '{func.__name__}' has an unresolved TypeVar '{param_type}' " f"as its {param_name} type. " @@ -128,7 +128,7 @@ def __init__( ) # Check for unresolved TypeVar in introspected message type - if is_typevar(message_type): + if contains_typevar(message_type): raise ValueError( f"Executor '{func.__name__}' has an unresolved TypeVar '{message_type}' " f"as its message type. " @@ -372,7 +372,7 @@ def _validate_function_signature( # Reject unresolved TypeVar in message annotation -- these are not supported # for workflow type validation and must be replaced with concrete types. - if not skip_message_annotation and isinstance(message_type, typing.TypeVar): + if not skip_message_annotation and contains_typevar(message_type): raise ValueError( f"Function instance {func.__name__} has an unresolved TypeVar '{message_type}' as its message type " "annotation. Generic TypeVar annotations are not supported for workflow type validation. " diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index d05bdcf751..6a0357a941 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -26,6 +26,21 @@ def is_typevar(x: Any) -> bool: return isinstance(x, _TYPEVAR_TYPES) +def contains_typevar(annotation: Any) -> bool: + """Check if an annotation contains an unresolved TypeVar at any nesting level. + + Args: + annotation: The annotation to inspect. + + Returns: + True if the annotation or any nested type argument is a TypeVar, False otherwise. + """ + if is_typevar(annotation): + return True + + return any(contains_typevar(arg) for arg in get_args(annotation)) + + def is_chat_agent(agent: Any) -> TypeGuard[Agent]: """Check if the given agent is a Agent. diff --git a/python/packages/core/agent_framework/_workflows/_workflow_context.py b/python/packages/core/agent_framework/_workflows/_workflow_context.py index 9f1c112e91..18a53e0bfa 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_context.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_context.py @@ -21,7 +21,7 @@ ) from ._runner_context import RunnerContext, WorkflowMessage from ._state import State -from ._typing_utils import is_typevar +from ._typing_utils import contains_typevar if TYPE_CHECKING: from ._executor import Executor @@ -178,10 +178,10 @@ def _is_type_like(x: Any) -> bool: continue # Check for unresolved TypeVar early with an actionable error message - if is_typevar(type_arg): + if contains_typevar(type_arg): raise ValueError( f"{context_description} {parameter_name} {param_description} " - f"has an unresolved TypeVar '{type_arg}'. " + f"contains an unresolved TypeVar in '{type_arg}'. " f"Use @handler(input=ConcreteType, output=ConcreteType) with concrete types " f"for parameterized executors." ) @@ -190,14 +190,6 @@ def _is_type_like(x: Any) -> bool: union_origin = get_origin(type_arg) if union_origin in (Union, UnionType): union_members = get_args(type_arg) - typevar_members = [m for m in union_members if is_typevar(m)] - if typevar_members: - raise ValueError( - f"{context_description} {parameter_name} {param_description} " - f"contains unresolved TypeVar(s): {typevar_members}. " - f"Use @handler(input=ConcreteType, output=ConcreteType) with concrete types " - f"for parameterized executors." - ) invalid_members = [m for m in union_members if not _is_type_like(m) and m is not Any] if invalid_members: raise ValueError( diff --git a/python/packages/core/tests/workflow/test_typevar_validation.py b/python/packages/core/tests/workflow/test_typevar_validation.py index d54fc60222..106b091331 100644 --- a/python/packages/core/tests/workflow/test_typevar_validation.py +++ b/python/packages/core/tests/workflow/test_typevar_validation.py @@ -14,7 +14,7 @@ executor, handler, ) -from agent_framework._workflows._typing_utils import is_typevar +from agent_framework._workflows._typing_utils import contains_typevar, is_typevar T = TypeVar("T") U = TypeVar("U") @@ -50,6 +50,18 @@ def test_rejects_non_types(self): assert not is_typevar(42) assert not is_typevar([]) + def test_contains_typevar_detects_nested_typevars(self): + """contains_typevar should detect TypeVar nested in typing constructs.""" + assert contains_typevar(list[T]) # type: ignore[misc, valid-type] + assert contains_typevar(dict[str, T]) # type: ignore[misc, valid-type] + assert contains_typevar(str | list[T]) # type: ignore[misc, valid-type] + + def test_contains_typevar_rejects_concrete_nested_types(self): + """contains_typevar should return False for concrete nested types.""" + assert not contains_typevar(list[str]) + assert not contains_typevar(dict[str, int]) + assert not contains_typevar(str | None) + class TestHandlerTypeVarValidation: """Tests for @handler decorator rejecting unresolved TypeVars.""" @@ -59,7 +71,7 @@ def test_handler_explicit_input_typevar_raises(self): with pytest.raises(ValueError, match="unresolved TypeVar"): class _Bad(Executor): # pyright: ignore[reportUnusedClass] - @handler(input=T) # type: ignore[arg-type] + @handler(input=T) # type: ignore[arg-type, call-overload] # ty: ignore[invalid-argument-type] async def handle(self, message, ctx: WorkflowContext[str]) -> None: # type: ignore[no-untyped-def] pass @@ -68,7 +80,7 @@ def test_handler_explicit_output_typevar_raises(self): with pytest.raises(ValueError, match="unresolved TypeVar"): class _Bad(Executor): # pyright: ignore[reportUnusedClass] - @handler(input=str, output=T) # type: ignore[arg-type] + @handler(input=str, output=T) # type: ignore[arg-type, call-overload] # ty: ignore[invalid-argument-type] async def handle(self, message: str, ctx: WorkflowContext[str]) -> None: pass @@ -77,10 +89,19 @@ def test_handler_explicit_workflow_output_typevar_raises(self): with pytest.raises(ValueError, match="unresolved TypeVar"): class _Bad(Executor): # pyright: ignore[reportUnusedClass] - @handler(input=str, workflow_output=T) # type: ignore[arg-type] + @handler(input=str, workflow_output=T) # type: ignore[arg-type, call-overload] # ty: ignore[invalid-argument-type] async def handle(self, message: str, ctx: WorkflowContext[str]) -> None: pass + def test_handler_explicit_nested_input_typevar_raises(self): + """@handler(input=list[T]) should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class _Bad(Executor): # pyright: ignore[reportUnusedClass] + @handler(input=list[T]) # type: ignore[arg-type, call-overload, misc, valid-type] + async def handle(self, message, ctx: WorkflowContext[str]) -> None: # type: ignore[no-untyped-def] + pass + def test_handler_introspected_typevar_raises(self): """@handler with TypeVar in message annotation should raise ValueError.""" with pytest.raises(ValueError, match="unresolved TypeVar"): @@ -90,6 +111,15 @@ class _Bad(Executor): # pyright: ignore[reportUnusedClass] async def handle(self, message: T, ctx: WorkflowContext[str]) -> None: # type: ignore[valid-type] pass + def test_handler_introspected_nested_typevar_raises(self): + """@handler with TypeVar nested in message annotation should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class _Bad(Executor): # pyright: ignore[reportUnusedClass] + @handler # type: ignore[arg-type] + async def handle(self, message: list[T], ctx: WorkflowContext[str]) -> None: # type: ignore[valid-type] + pass + def test_handler_concrete_types_work(self): """@handler with concrete types should succeed.""" @@ -108,7 +138,7 @@ def test_executor_explicit_input_typevar_raises(self): """@executor(input=T) with a TypeVar should raise ValueError.""" with pytest.raises(ValueError, match="unresolved TypeVar"): - @executor(input=T) # type: ignore[arg-type] + @executor(input=T) # type: ignore[arg-type, call-overload] # ty: ignore[invalid-argument-type] async def bad_func(message, ctx: WorkflowContext[str]) -> None: # type: ignore[no-untyped-def] pass @@ -116,15 +146,28 @@ def test_executor_explicit_output_typevar_raises(self): """@executor(input=str, output=T) with a TypeVar should raise ValueError.""" with pytest.raises(ValueError, match="unresolved TypeVar"): - @executor(input=str, output=T) # type: ignore[arg-type] + @executor(input=str, output=T) # type: ignore[arg-type, call-overload] # ty: ignore[invalid-argument-type] async def bad_func(message: str, ctx: WorkflowContext[str]) -> None: pass + def test_executor_explicit_nested_input_typevar_raises(self): + """@executor(input=list[T]) should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + @executor(input=list[T]) # type: ignore[arg-type, call-overload, misc, valid-type] + async def bad_func(message, ctx: WorkflowContext[str]) -> None: # type: ignore[no-untyped-def] + pass + def test_executor_introspected_typevar_raises(self): """@executor with TypeVar in message annotation should raise ValueError.""" with pytest.raises(ValueError, match="unresolved TypeVar"): FunctionExecutor(self._make_typevar_func()) # type: ignore[arg-type] + def test_executor_introspected_nested_typevar_raises(self): + """@executor with TypeVar nested in message annotation should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + FunctionExecutor(self._make_nested_typevar_func()) # type: ignore[arg-type] + def test_executor_concrete_types_work(self): """@executor with concrete types should succeed.""" @@ -143,6 +186,15 @@ async def func(message: T, ctx: WorkflowContext[str]) -> None: # type: ignore[v return func + @staticmethod + def _make_nested_typevar_func(): + """Create a function with nested TypeVar annotation for testing.""" + + async def func(message: list[T], ctx: WorkflowContext[str]) -> None: # type: ignore[valid-type] + pass + + return func + class TestWorkflowContextTypeVarValidation: """Tests for WorkflowContext[T] rejecting unresolved TypeVars.""" @@ -163,6 +215,14 @@ def test_context_union_typevar_raises(self): async def bad_func(message: str, ctx: WorkflowContext[T | str]) -> None: # type: ignore[valid-type] pass + def test_context_nested_typevar_raises(self): + """WorkflowContext[list[T]] with a nested TypeVar should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + @executor(id="bad") + async def bad_func(message: str, ctx: WorkflowContext[list[T]]) -> None: # type: ignore[valid-type] + pass + def test_context_workflow_output_typevar_raises(self): """WorkflowContext[str, T] with a TypeVar should raise ValueError.""" with pytest.raises(ValueError, match="unresolved TypeVar"): @@ -171,6 +231,14 @@ def test_context_workflow_output_typevar_raises(self): async def bad_func(message: str, ctx: WorkflowContext[str, T]) -> None: # type: ignore[valid-type] pass + def test_context_nested_workflow_output_typevar_raises(self): + """WorkflowContext[str, dict[str, T]] with a nested TypeVar should raise ValueError.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + @executor(id="bad") + async def bad_func(message: str, ctx: WorkflowContext[str, dict[str, T]]) -> None: # type: ignore[valid-type] + pass + def test_context_concrete_types_work(self): """WorkflowContext[str] with concrete types should succeed."""