Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
import sys
import types
import typing
from builtins import type as builtin_type
from collections.abc import Awaitable, Callable
from types import UnionType
Expand Down Expand Up @@ -348,20 +349,32 @@ def _validate_response_handler_signature(
if not skip_annotations and response_param.annotation == inspect.Parameter.empty:
raise ValueError(f"Response handler {func.__name__} must have a type annotation for the response parameter")

# 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, RecursionError):
type_hints = {p.name: p.annotation for p in params}
Comment on lines +355 to +358

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in commit cf3e412. Added regression coverage for the unresolved-annotation fallback path and verified that it produces the existing ValueError diagnostic. The postponed-annotation test now uses WorkflowContext[str, bool] and asserts both output_types and workflow_output_types. The targeted executor tests and full workflow test suite pass.


# Validate ctx parameter is WorkflowContext and extract type args (if annotated)
ctx_param = params[3]
ctx_annotation = type_hints.get(ctx_param.name, ctx_param.annotation)
if ctx_param.annotation != inspect.Parameter.empty:
output_types, workflow_output_types = validate_workflow_context_annotation(
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Response handler"
ctx_annotation, f"parameter '{ctx_param.name}'", "Response handler"
)
else:
output_types, workflow_output_types = [], []

request_type = (
original_request_param.annotation if original_request_param.annotation != inspect.Parameter.empty else None
)
response_type = response_param.annotation if response_param.annotation != inspect.Parameter.empty else None
ctx_annotation = ctx_param.annotation if ctx_param.annotation != inspect.Parameter.empty else None
request_type = type_hints.get(original_request_param.name, original_request_param.annotation)
if request_type == inspect.Parameter.empty:
request_type = None
response_type = type_hints.get(response_param.name, response_param.annotation)
if response_type == inspect.Parameter.empty:
response_type = None
if ctx_annotation == inspect.Parameter.empty:
ctx_annotation = None

return request_type, response_type, ctx_annotation, output_types, workflow_output_types

Expand Down
38 changes: 37 additions & 1 deletion python/packages/core/tests/workflow/test_executor_future.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest
from pydantic import BaseModel

from agent_framework import Executor, WorkflowContext, handler
from agent_framework import Executor, WorkflowContext, handler, response_handler


class MyTypeA(BaseModel):
Expand Down Expand Up @@ -109,6 +109,42 @@ async def example(self, input: str, ctx: WorkflowContext[MyTypeA | MyTypeB, MyTy
assert spec["output_types"] == [MyTypeA, MyTypeB]
assert spec["workflow_output_types"] == [MyTypeC]

def test_response_handler_decorator_future_annotations(self):
"""Test @response_handler with stringified annotations and future annotations."""

class MyExecutor(Executor):
@handler
async def example(self, input: str, ctx: WorkflowContext) -> None:
pass

@response_handler
async def handle_response(
self, original_request: str, response: int, ctx: WorkflowContext[str, bool]
) -> None:
pass

exec_instance = MyExecutor(id="test")
assert (str, int) in exec_instance._response_handlers # pyright: ignore[reportPrivateUsage]
spec = exec_instance._response_handler_specs[0] # pyright: ignore[reportPrivateUsage]
assert spec["request_type"] is str
assert spec["response_type"] is int
assert spec["output_types"] == [str]
assert spec["workflow_output_types"] == [bool]

def test_response_handler_unresolvable_annotation_raises(self):
"""Test that an unresolvable response-handler annotation raises ValueError."""
with pytest.raises(ValueError, match="Response handler parameter 'ctx' must be annotated as"):

class BadResponseHandler(Executor): # pyright: ignore[reportUnusedClass]
@response_handler # pyright: ignore[reportUnknownArgumentType]
async def handle_response(
self,
original_request: NonExistentType, # type: ignore[name-defined] # noqa: F821
response: int,
ctx: WorkflowContext[MyTypeA, MyTypeB],
) -> None:
pass

def test_handler_unresolvable_annotation_raises(self):
"""Test that an unresolvable forward-reference annotation raises ValueError.

Expand Down
Loading