From 15667cca0ee5c79ecfcfd8e52b06c7432348b3cd Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 26 Feb 2026 21:30:13 +0900 Subject: [PATCH 1/7] Python: Fix Executor handler type checking with __future__ annotations (#3898) Use typing.get_type_hints() in _validate_handler_signature to resolve string annotations from `from __future__ import annotations`. This mirrors the fix applied to FunctionExecutor in #2308. When __future__ annotations are enabled, type annotations are stored as strings. The handler decorator was passing these strings directly to validate_workflow_context_annotation, which uses typing.get_origin and returns None for strings, causing a ValueError. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework/_workflows/_executor.py | 14 +++- .../tests/workflow/test_executor_future.py | 74 +++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 python/packages/core/tests/workflow/test_executor_future.py diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index f219c0c28f..aa1410a2cf 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -6,6 +6,7 @@ import inspect import logging import types +import typing from collections.abc import Awaitable, Callable from typing import Any, TypeVar, overload @@ -722,20 +723,25 @@ def _validate_handler_signature( if not skip_message_annotation and message_param.annotation == inspect.Parameter.empty: raise ValueError(f"Handler {func.__name__} must have a type annotation for the message parameter") + # Resolve string annotations from `from __future__ import annotations` + type_hints = typing.get_type_hints(func) + # Validate ctx parameter is WorkflowContext and extract type args ctx_param = params[2] - if skip_message_annotation and ctx_param.annotation == inspect.Parameter.empty: + ctx_annotation = type_hints.get(ctx_param.name, ctx_param.annotation) + if skip_message_annotation and ctx_annotation == inspect.Parameter.empty: # When explicit types are provided via @handler(input=..., output=...), # the ctx parameter doesn't need a type annotation - types come from the decorator. output_types: list[type[Any] | types.UnionType] = [] workflow_output_types: list[type[Any] | types.UnionType] = [] else: output_types, workflow_output_types = validate_workflow_context_annotation( - ctx_param.annotation, f"parameter '{ctx_param.name}'", "Handler" + ctx_annotation, f"parameter '{ctx_param.name}'", "Handler" ) - message_type = message_param.annotation if message_param.annotation != inspect.Parameter.empty else None - ctx_annotation = ctx_param.annotation + message_type = type_hints.get(message_param.name, message_param.annotation) + if message_type == inspect.Parameter.empty: + message_type = None return message_type, ctx_annotation, output_types, workflow_output_types diff --git a/python/packages/core/tests/workflow/test_executor_future.py b/python/packages/core/tests/workflow/test_executor_future.py new file mode 100644 index 0000000000..a564fb13fd --- /dev/null +++ b/python/packages/core/tests/workflow/test_executor_future.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + +from agent_framework import Executor, WorkflowContext, handler + + +class MyTypeA(BaseModel): + pass + + +class MyTypeB(BaseModel): + pass + + +class TestExecutorFutureAnnotations: + """Test suite for Executor with from __future__ import annotations.""" + + def test_handler_decorator_future_annotations(self): + """Test @handler decorator works with stringified annotations (issue #3898).""" + + class MyExecutor(Executor): + @handler + async def example(self, input: str, ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert str in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["message_type"] is str + assert spec["output_types"] == [MyTypeA] + assert spec["workflow_output_types"] == [MyTypeB] + + def test_handler_decorator_future_annotations_single_type_arg(self): + """Test @handler with single type argument and future annotations.""" + + class MyExecutor(Executor): + @handler + async def example(self, input: int, ctx: WorkflowContext[MyTypeA]) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert int in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["message_type"] is int + assert spec["output_types"] == [MyTypeA] + + def test_handler_decorator_future_annotations_complex(self): + """Test @handler with complex type annotations and future annotations.""" + + class MyExecutor(Executor): + @handler + async def example(self, data: dict[str, Any], ctx: WorkflowContext[list[str]]) -> None: + pass + + exec_instance = MyExecutor(id="test") + spec = exec_instance._handler_specs[0] + assert spec["message_type"] == dict[str, Any] + assert spec["output_types"] == [list[str]] + + def test_handler_decorator_future_annotations_bare_context(self): + """Test @handler with bare WorkflowContext and future annotations.""" + + class MyExecutor(Executor): + @handler + async def example(self, input: str, ctx: WorkflowContext) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert str in exec_instance._handlers From c90b1ca7b846be844e804765dbf432cd3fd96ac2 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 26 Feb 2026 21:37:02 +0900 Subject: [PATCH 2/7] Address PR review feedback for #3898: improve error handling and test coverage - Wrap typing.get_type_hints() in try/except to provide a descriptive ValueError mentioning the handler name when annotations cannot be resolved - Strengthen bare context test to assert output_types and workflow_output_types - Add test for @handler(input=..., output=...) with future annotations covering the skip_message_annotation branch - Add test for union-type context annotations with future annotations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework/_workflows/_executor.py | 8 ++++- .../tests/workflow/test_executor_future.py | 35 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index aa1410a2cf..054555b294 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -724,7 +724,13 @@ def _validate_handler_signature( raise ValueError(f"Handler {func.__name__} must have a type annotation for the message parameter") # Resolve string annotations from `from __future__ import annotations` - type_hints = typing.get_type_hints(func) + try: + type_hints = typing.get_type_hints(func) + except Exception as e: + raise ValueError( + f"Failed to resolve type annotations for handler '{func.__name__}': {e}. " + f"Ensure all annotated types are importable and resolvable at handler registration time." + ) from e # Validate ctx parameter is WorkflowContext and extract type args ctx_param = params[2] diff --git a/python/packages/core/tests/workflow/test_executor_future.py b/python/packages/core/tests/workflow/test_executor_future.py index a564fb13fd..c42aa3aa27 100644 --- a/python/packages/core/tests/workflow/test_executor_future.py +++ b/python/packages/core/tests/workflow/test_executor_future.py @@ -17,6 +17,10 @@ class MyTypeB(BaseModel): pass +class MyTypeC(BaseModel): + pass + + class TestExecutorFutureAnnotations: """Test suite for Executor with from __future__ import annotations.""" @@ -72,3 +76,34 @@ async def example(self, input: str, ctx: WorkflowContext) -> None: exec_instance = MyExecutor(id="test") assert str in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["output_types"] == [] + assert spec["workflow_output_types"] == [] + + def test_handler_decorator_future_annotations_explicit_types(self): + """Test @handler with explicit type parameters under future annotations.""" + + class MyExecutor(Executor): + @handler(input=str, output=MyTypeA) + async def example(self, input, ctx) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert str in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["message_type"] is str + assert spec["output_types"] == [MyTypeA] + + def test_handler_decorator_future_annotations_union_context(self): + """Test @handler with union type context annotations and future annotations.""" + + class MyExecutor(Executor): + @handler + async def example(self, input: str, ctx: WorkflowContext[MyTypeA | MyTypeB, MyTypeC]) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert str in exec_instance._handlers + spec = exec_instance._handler_specs[0] + assert spec["output_types"] == [MyTypeA, MyTypeB] + assert spec["workflow_output_types"] == [MyTypeC] From 2bb413a05a6fe88318acfbf02a98a34db939441e Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Thu, 26 Feb 2026 21:38:58 +0900 Subject: [PATCH 3/7] Narrow exception catch and add test for unresolvable annotations (#3898) - Narrow except clause from bare Exception to (NameError, AttributeError, TypeError) to avoid masking unexpected errors. - Add test_handler_unresolvable_annotation_raises to verify that a handler with a forward-reference to a non-existent type raises ValueError with the expected message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_workflows/_executor.py | 2 +- .../core/tests/workflow/test_executor_future.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index 054555b294..740999f994 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -726,7 +726,7 @@ def _validate_handler_signature( # Resolve string annotations from `from __future__ import annotations` try: type_hints = typing.get_type_hints(func) - except Exception as e: + except (NameError, AttributeError, TypeError) as e: raise ValueError( f"Failed to resolve type annotations for handler '{func.__name__}': {e}. " f"Ensure all annotated types are importable and resolvable at handler registration time." diff --git a/python/packages/core/tests/workflow/test_executor_future.py b/python/packages/core/tests/workflow/test_executor_future.py index c42aa3aa27..7e814ccd11 100644 --- a/python/packages/core/tests/workflow/test_executor_future.py +++ b/python/packages/core/tests/workflow/test_executor_future.py @@ -6,6 +6,8 @@ from pydantic import BaseModel +import pytest + from agent_framework import Executor, WorkflowContext, handler @@ -107,3 +109,12 @@ async def example(self, input: str, ctx: WorkflowContext[MyTypeA | MyTypeB, MyTy spec = exec_instance._handler_specs[0] assert spec["output_types"] == [MyTypeA, MyTypeB] assert spec["workflow_output_types"] == [MyTypeC] + + def test_handler_unresolvable_annotation_raises(self): + """Test that an unresolvable forward-reference annotation raises ValueError.""" + with pytest.raises(ValueError, match="Failed to resolve type annotations"): + + class Bad(Executor): + @handler + async def example(self, input: "NonExistentType", ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: + pass From 8cdc36ab31c3cbfc48394c7064857889c9133b85 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 27 Feb 2026 07:48:32 +0900 Subject: [PATCH 4/7] Fix #3898: fall back to raw annotations when get_type_hints fails When typing.get_type_hints(func) raises NameError (unresolvable forward ref), AttributeError, RecursionError, or any other exception, fall back to the raw parameter annotations instead of raising a ValueError. This matches the suggestion from @moonbox3 on PR #4317. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_workflows/_executor.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index 740999f994..d2bb2ac598 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -723,14 +723,13 @@ def _validate_handler_signature( if not skip_message_annotation and message_param.annotation == inspect.Parameter.empty: raise ValueError(f"Handler {func.__name__} must have a type annotation for the message parameter") - # Resolve string annotations from `from __future__ import annotations` + # Resolve string annotations from `from __future__ import annotations`. + # Fall back to raw annotations if resolution fails (e.g. unresolvable forward refs, + # AttributeError, or RecursionError), so registration failures are easier to diagnose. try: type_hints = typing.get_type_hints(func) - except (NameError, AttributeError, TypeError) as e: - raise ValueError( - f"Failed to resolve type annotations for handler '{func.__name__}': {e}. " - f"Ensure all annotated types are importable and resolvable at handler registration time." - ) from e + except Exception: + type_hints = {p.name: p.annotation for p in params} # Validate ctx parameter is WorkflowContext and extract type args ctx_param = params[2] From 0e61c0033fcfa3b3c5a14c256b97fc0ad1420101 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 27 Feb 2026 07:51:11 +0900 Subject: [PATCH 5/7] Fix test to match new fallback behavior when get_type_hints fails (#3898) The code now falls back to raw string annotations instead of raising 'Failed to resolve type annotations'. A ValueError is still raised when the raw string ctx annotation is not a valid WorkflowContext type, so update the test to match on ValueError without checking the message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/tests/workflow/test_executor_future.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/python/packages/core/tests/workflow/test_executor_future.py b/python/packages/core/tests/workflow/test_executor_future.py index 7e814ccd11..8b46177428 100644 --- a/python/packages/core/tests/workflow/test_executor_future.py +++ b/python/packages/core/tests/workflow/test_executor_future.py @@ -111,8 +111,13 @@ async def example(self, input: str, ctx: WorkflowContext[MyTypeA | MyTypeB, MyTy assert spec["workflow_output_types"] == [MyTypeC] def test_handler_unresolvable_annotation_raises(self): - """Test that an unresolvable forward-reference annotation raises ValueError.""" - with pytest.raises(ValueError, match="Failed to resolve type annotations"): + """Test that an unresolvable forward-reference annotation raises ValueError. + + When get_type_hints fails (e.g. NameError for NonExistentType), the code falls back + to raw string annotations. The ctx parameter's raw string annotation is then not + recognised as a valid WorkflowContext type, so a ValueError is still raised. + """ + with pytest.raises(ValueError): class Bad(Executor): @handler From b66344e693b4f10254977ad4be2e93a8efc45b66 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 27 Feb 2026 09:12:21 +0900 Subject: [PATCH 6/7] Apply pyupgrade: remove unnecessary string annotation quote --- python/packages/core/tests/workflow/test_executor_future.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/core/tests/workflow/test_executor_future.py b/python/packages/core/tests/workflow/test_executor_future.py index 8b46177428..8a1a546415 100644 --- a/python/packages/core/tests/workflow/test_executor_future.py +++ b/python/packages/core/tests/workflow/test_executor_future.py @@ -121,5 +121,5 @@ def test_handler_unresolvable_annotation_raises(self): class Bad(Executor): @handler - async def example(self, input: "NonExistentType", ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: + async def example(self, input: NonExistentType, ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: pass From 269aca874cf9011a14723e668e1028f1531ec3e4 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Fri, 27 Feb 2026 09:16:48 +0900 Subject: [PATCH 7/7] Add noqa for intentionally undefined name in annotation test --- python/packages/core/tests/workflow/test_executor_future.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/python/packages/core/tests/workflow/test_executor_future.py b/python/packages/core/tests/workflow/test_executor_future.py index 8a1a546415..c0916b9cf7 100644 --- a/python/packages/core/tests/workflow/test_executor_future.py +++ b/python/packages/core/tests/workflow/test_executor_future.py @@ -4,9 +4,8 @@ from typing import Any -from pydantic import BaseModel - import pytest +from pydantic import BaseModel from agent_framework import Executor, WorkflowContext, handler @@ -121,5 +120,5 @@ def test_handler_unresolvable_annotation_raises(self): class Bad(Executor): @handler - async def example(self, input: NonExistentType, ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: + async def example(self, input: NonExistentType, ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: # noqa: F821 pass