mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add explicit input, output, and workflow_output parameters to @handler, @executor and request_info (#3472)
* Support specifying types via handler and executor decorators * Add handling for string types * Fix typing * Address PR feedback * All or nothing for handler typing approach * Fix mypy issues * type support for request info * Fix naming issue * Fix mypy
This commit is contained in:
committed by
GitHub
Unverified
parent
8d939f8ffa
commit
f56218fa1e
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -110,7 +111,7 @@ class AgentExecutor(Executor):
|
||||
return self._output_response
|
||||
|
||||
@property
|
||||
def workflow_output_types(self) -> list[type[Any]]:
|
||||
def workflow_output_types(self) -> list[type[Any] | types.UnionType]:
|
||||
# Override to declare AgentResponse as a possible output type only if enabled.
|
||||
if self._output_response:
|
||||
return [AgentResponse]
|
||||
|
||||
@@ -5,8 +5,9 @@ import copy
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import types
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, TypeVar
|
||||
from typing import Any, TypeVar, overload
|
||||
|
||||
from ..observability import create_processing_span
|
||||
from ._events import (
|
||||
@@ -20,7 +21,7 @@ from ._model_utils import DictConvertible
|
||||
from ._request_info_mixin import RequestInfoMixin
|
||||
from ._runner_context import Message, MessageType, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
from ._typing_utils import is_instance_of
|
||||
from ._typing_utils import is_instance_of, normalize_type_to_list, resolve_type_annotation
|
||||
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -200,7 +201,9 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
|
||||
from builtins import type as builtin_type
|
||||
|
||||
self._handlers: dict[builtin_type[Any], Callable[[Any, WorkflowContext[Any, Any]], Awaitable[None]]] = {}
|
||||
self._handlers: dict[
|
||||
builtin_type[Any] | types.UnionType, Callable[[Any, WorkflowContext[Any, Any]], Awaitable[None]]
|
||||
] = {}
|
||||
self._handler_specs: list[dict[str, Any]] = []
|
||||
if not defer_discovery:
|
||||
self._discover_handlers()
|
||||
@@ -328,32 +331,26 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
for attr_name in dir(self.__class__):
|
||||
try:
|
||||
attr = getattr(self.__class__, attr_name)
|
||||
# Discover @handler methods
|
||||
if callable(attr) and hasattr(attr, "_handler_spec"):
|
||||
handler_spec = attr._handler_spec # type: ignore
|
||||
message_type = handler_spec["message_type"]
|
||||
|
||||
# Keep full generic types for handler registration to avoid conflicts
|
||||
if self._handlers.get(message_type) is not None:
|
||||
raise ValueError(f"Duplicate handler for type {message_type} in {self.__class__.__name__}")
|
||||
|
||||
# Get the bound method
|
||||
bound_method = getattr(self, attr_name)
|
||||
self._handlers[message_type] = bound_method
|
||||
|
||||
# Add to unified handler specs list
|
||||
self._handler_specs.append({
|
||||
"name": handler_spec["name"],
|
||||
"message_type": message_type,
|
||||
"output_types": handler_spec.get("output_types", []),
|
||||
"workflow_output_types": handler_spec.get("workflow_output_types", []),
|
||||
"ctx_annotation": handler_spec.get("ctx_annotation"),
|
||||
"source": "class_method", # Distinguish from instance handlers if needed
|
||||
})
|
||||
except AttributeError:
|
||||
# Skip attributes that may not be accessible
|
||||
# Skip attributes that may not be accessible (e.g., dynamic descriptors)
|
||||
continue
|
||||
|
||||
# Discover @handler methods
|
||||
if callable(attr) and hasattr(attr, "_handler_spec"):
|
||||
handler_spec = attr._handler_spec # type: ignore
|
||||
message_type = handler_spec["message_type"]
|
||||
|
||||
# Keep full generic types for handler registration to avoid conflicts
|
||||
if self._handlers.get(message_type) is not None:
|
||||
raise ValueError(f"Duplicate handler for type {message_type} in {self.__class__.__name__}")
|
||||
|
||||
# Get the bound method
|
||||
bound_method = getattr(self, attr_name)
|
||||
self._handlers[message_type] = bound_method
|
||||
|
||||
# Add to unified handler specs list
|
||||
self._handler_specs.append({**handler_spec})
|
||||
|
||||
def can_handle(self, message: Message) -> bool:
|
||||
"""Check if the executor can handle a given message type.
|
||||
|
||||
@@ -382,10 +379,10 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
self,
|
||||
name: str,
|
||||
func: Callable[[Any, WorkflowContext[Any]], Awaitable[Any]],
|
||||
message_type: type,
|
||||
message_type: type | types.UnionType,
|
||||
ctx_annotation: Any,
|
||||
output_types: list[type],
|
||||
workflow_output_types: list[type],
|
||||
output_types: list[type[Any] | types.UnionType],
|
||||
workflow_output_types: list[type[Any] | types.UnionType],
|
||||
) -> None:
|
||||
"""Register a handler at instance level.
|
||||
|
||||
@@ -407,11 +404,10 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
"ctx_annotation": ctx_annotation,
|
||||
"output_types": output_types,
|
||||
"workflow_output_types": workflow_output_types,
|
||||
"source": "instance_method", # Distinguish from class handlers if needed
|
||||
})
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any]]:
|
||||
def input_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the list of input types that this executor can handle.
|
||||
|
||||
Returns:
|
||||
@@ -420,13 +416,13 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
return list(self._handlers.keys())
|
||||
|
||||
@property
|
||||
def output_types(self) -> list[type[Any]]:
|
||||
def output_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the list of output types that this executor can produce via send_message().
|
||||
|
||||
Returns:
|
||||
A list of the output types inferred from the handlers' WorkflowContext[T] annotations.
|
||||
"""
|
||||
output_types: set[type[Any]] = set()
|
||||
output_types: set[type[Any] | types.UnionType] = set()
|
||||
|
||||
# Collect output types from all handlers
|
||||
for handler_spec in self._handler_specs + self._response_handler_specs:
|
||||
@@ -436,13 +432,13 @@ class Executor(RequestInfoMixin, DictConvertible):
|
||||
return list(output_types)
|
||||
|
||||
@property
|
||||
def workflow_output_types(self) -> list[type[Any]]:
|
||||
def workflow_output_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the list of workflow output types that this executor can produce via yield_output().
|
||||
|
||||
Returns:
|
||||
A list of the workflow output types inferred from handlers' WorkflowContext[T, U] annotations.
|
||||
"""
|
||||
output_types: set[type[Any]] = set()
|
||||
output_types: set[type[Any] | types.UnionType] = set()
|
||||
|
||||
# Collect workflow output types from all handlers
|
||||
for handler_spec in self._handler_specs + self._response_handler_specs:
|
||||
@@ -529,34 +525,134 @@ ExecutorT = TypeVar("ExecutorT", bound="Executor")
|
||||
ContextT = TypeVar("ContextT", bound="WorkflowContext[Any, Any]")
|
||||
|
||||
|
||||
@overload
|
||||
def handler(
|
||||
func: Callable[[ExecutorT, Any, ContextT], Awaitable[Any]],
|
||||
) -> Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]:
|
||||
) -> Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def handler(
|
||||
*,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> Callable[
|
||||
[Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]],
|
||||
Callable[[ExecutorT, Any, ContextT], Awaitable[Any]],
|
||||
]: ...
|
||||
|
||||
|
||||
def handler(
|
||||
func: Callable[[ExecutorT, Any, ContextT], Awaitable[Any]] | None = None,
|
||||
*,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> (
|
||||
Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]
|
||||
| Callable[
|
||||
[Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]],
|
||||
Callable[[ExecutorT, Any, ContextT], Awaitable[Any]],
|
||||
]
|
||||
):
|
||||
"""Decorator to register a handler for an executor.
|
||||
|
||||
Type information can be provided in two mutually exclusive ways:
|
||||
|
||||
1. **Introspection** (default): Types are inferred from function signature annotations.
|
||||
Use type annotations on the message parameter and WorkflowContext generic parameters.
|
||||
|
||||
2. **Explicit parameters**: Types are specified via decorator parameters (input, output,
|
||||
workflow_output). When ANY explicit parameter is provided, ALL types must come from
|
||||
explicit parameters - introspection is completely disabled. The ``input`` parameter
|
||||
is required; ``output`` and ``workflow_output`` are optional (default to no outputs).
|
||||
|
||||
Args:
|
||||
func: The function to decorate. Can be None when used without parameters.
|
||||
func: The function to decorate. Can be None when used with parameters.
|
||||
input: Explicit input type(s) for this handler. Required when using explicit mode.
|
||||
Supports union types (e.g., ``str | int``) and string forward references.
|
||||
output: Explicit output type(s) that can be sent via ``ctx.send_message()``.
|
||||
Optional; defaults to no outputs if not specified.
|
||||
workflow_output: Explicit output type(s) that can be yielded via ``ctx.yield_output()``.
|
||||
Optional; defaults to no outputs if not specified.
|
||||
|
||||
Returns:
|
||||
The decorated function with handler metadata.
|
||||
|
||||
Example:
|
||||
@handler
|
||||
async def handle_string(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
...
|
||||
.. code-block:: python
|
||||
|
||||
@handler
|
||||
async def handle_data(self, message: dict, ctx: WorkflowContext[str | int]) -> None:
|
||||
...
|
||||
# Mode 1: Introspection - types from annotations
|
||||
@handler
|
||||
async def handle_string(self, message: str, ctx: WorkflowContext[str]) -> None: ...
|
||||
|
||||
|
||||
# Mode 2: Explicit types - ALL types from decorator params
|
||||
# Note: No type annotations on function parameters when using explicit types
|
||||
@handler(input=str | int, output=bool)
|
||||
async def handle_data(self, message, ctx): ...
|
||||
|
||||
|
||||
# Explicit with string forward references
|
||||
@handler(input="MyCustomType | int", output="ResponseType")
|
||||
async def handle_custom(self, message, ctx): ...
|
||||
|
||||
|
||||
# Explicit with all three type parameters
|
||||
@handler(input=str, output=int, workflow_output=bool)
|
||||
async def handle_full(self, message, ctx):
|
||||
await ctx.send_message(42) # int - matches output
|
||||
await ctx.yield_output(True) # bool - matches workflow_output
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[[ExecutorT, Any, ContextT], Awaitable[Any]],
|
||||
) -> Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]:
|
||||
# Extract the message type and validate using unified validation
|
||||
message_type, ctx_annotation, inferred_output_types, inferred_workflow_output_types = (
|
||||
_validate_handler_signature(func)
|
||||
)
|
||||
# Check if ANY explicit type parameter was provided - if so, use ONLY explicit params.
|
||||
# This is "all or nothing" - no mixing of explicit params with introspection.
|
||||
use_explicit_types = input is not None or output is not None or workflow_output is not None
|
||||
|
||||
if use_explicit_types:
|
||||
# Resolve string forward references using the function's globals
|
||||
resolved_input_type = resolve_type_annotation(input, func.__globals__) if input is not None else None
|
||||
resolved_output_type = resolve_type_annotation(output, func.__globals__) if output is not None else None
|
||||
resolved_workflow_output_type = (
|
||||
resolve_type_annotation(workflow_output, func.__globals__) if workflow_output is not None else None
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# Use explicit types only - missing params default to empty
|
||||
message_type = resolved_input_type
|
||||
if message_type is None:
|
||||
raise ValueError(f"Handler {func.__name__} with explicit type parameters must specify 'input' type")
|
||||
|
||||
final_output_types = normalize_type_to_list(resolved_output_type) if resolved_output_type else []
|
||||
final_workflow_output_types = (
|
||||
normalize_type_to_list(resolved_workflow_output_type) if resolved_workflow_output_type else []
|
||||
)
|
||||
# Get ctx_annotation for consistency (even though types come from explicit params)
|
||||
ctx_annotation = (
|
||||
inspect.signature(func).parameters[list(inspect.signature(func).parameters.keys())[2]].annotation
|
||||
)
|
||||
else:
|
||||
# Use introspection for ALL types - no explicit params provided
|
||||
introspected_message_type, ctx_annotation, inferred_output_types, inferred_workflow_output_types = (
|
||||
_validate_handler_signature(func, skip_message_annotation=False)
|
||||
)
|
||||
|
||||
message_type = introspected_message_type
|
||||
if message_type is None:
|
||||
raise ValueError(
|
||||
f"Handler {func.__name__} requires either a message parameter type annotation "
|
||||
"or explicit type parameters (input, output, workflow_output)"
|
||||
)
|
||||
|
||||
final_output_types = inferred_output_types
|
||||
final_workflow_output_types = inferred_workflow_output_types
|
||||
|
||||
# Get signature for preservation
|
||||
sig = inspect.signature(func)
|
||||
@@ -574,14 +670,19 @@ def handler(
|
||||
"name": func.__name__,
|
||||
"message_type": message_type,
|
||||
# Keep output_types and workflow_output_types in spec for validators
|
||||
"output_types": inferred_output_types,
|
||||
"workflow_output_types": inferred_workflow_output_types,
|
||||
"output_types": final_output_types,
|
||||
"workflow_output_types": final_workflow_output_types,
|
||||
"ctx_annotation": ctx_annotation,
|
||||
}
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator(func)
|
||||
# Handle both @handler and @handler(...) usage patterns
|
||||
if func is not None:
|
||||
# Called as @handler without parentheses
|
||||
return decorator(func)
|
||||
# Called as @handler(...) with parentheses
|
||||
return decorator
|
||||
|
||||
|
||||
# endregion: Handler Decorator
|
||||
@@ -589,14 +690,21 @@ def handler(
|
||||
# region Handler Validation
|
||||
|
||||
|
||||
def _validate_handler_signature(func: Callable[..., Any]) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
|
||||
def _validate_handler_signature(
|
||||
func: Callable[..., Any],
|
||||
*,
|
||||
skip_message_annotation: bool = False,
|
||||
) -> tuple[type | None, Any, list[type[Any] | types.UnionType], list[type[Any] | types.UnionType]]:
|
||||
"""Validate function signature for executor functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
skip_message_annotation: If True, skip validation that message parameter has a type
|
||||
annotation. Used when input_type is explicitly provided to the @handler decorator.
|
||||
|
||||
Returns:
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types).
|
||||
message_type may be None if skip_message_annotation is True and no annotation exists.
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
@@ -609,9 +717,9 @@ def _validate_handler_signature(func: Callable[..., Any]) -> tuple[type, Any, li
|
||||
if len(params) != expected_counts:
|
||||
raise ValueError(f"Handler {func.__name__} must have {param_description}. Got {len(params)} parameters.")
|
||||
|
||||
# Check message parameter has type annotation
|
||||
# Check message parameter has type annotation (unless skipped)
|
||||
message_param = params[1]
|
||||
if message_param.annotation == inspect.Parameter.empty:
|
||||
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")
|
||||
|
||||
# Validate ctx parameter is WorkflowContext and extract type args
|
||||
@@ -620,7 +728,7 @@ def _validate_handler_signature(func: Callable[..., Any]) -> tuple[type, Any, li
|
||||
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Handler"
|
||||
)
|
||||
|
||||
message_type = message_param.annotation
|
||||
message_type = message_param.annotation if message_param.annotation != inspect.Parameter.empty else None
|
||||
ctx_annotation = ctx_param.annotation
|
||||
|
||||
return message_type, ctx_annotation, output_types, workflow_output_types
|
||||
|
||||
@@ -18,11 +18,13 @@ Design Pattern:
|
||||
import asyncio
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
import typing
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from ._executor import Executor
|
||||
from ._typing_utils import normalize_type_to_list, resolve_type_annotation
|
||||
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -41,12 +43,32 @@ class FunctionExecutor(Executor):
|
||||
blocking the event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable[..., Any], id: str | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
func: Callable[..., Any],
|
||||
id: str | None = None,
|
||||
*,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
):
|
||||
"""Initialize the FunctionExecutor with a user-defined function.
|
||||
|
||||
Args:
|
||||
func: The function to wrap as an executor (can be sync or async)
|
||||
id: Optional executor ID. If None, uses the function name.
|
||||
input: Optional explicit input type(s) for this executor. Supports union types
|
||||
(e.g., ``str | int``) and string forward references (e.g., ``"MyType | int"``).
|
||||
When provided, takes precedence over introspection from the function's message
|
||||
parameter annotation.
|
||||
output: Optional explicit output type(s) that can be sent via ``ctx.send_message()``.
|
||||
Supports union types (e.g., ``str | int``) and string forward references.
|
||||
When provided, takes precedence over introspection from the ``WorkflowContext``
|
||||
first generic parameter (T_Out).
|
||||
workflow_output: Optional explicit output type(s) that can be yielded via
|
||||
``ctx.yield_output()``. Supports union types (e.g., ``str | int``) and string
|
||||
forward references. When provided, takes precedence over introspection from the
|
||||
``WorkflowContext`` second generic parameter (T_W_Out).
|
||||
|
||||
Raises:
|
||||
ValueError: If func is a staticmethod or classmethod (use @handler on instance methods instead)
|
||||
@@ -60,8 +82,37 @@ class FunctionExecutor(Executor):
|
||||
f"or create an Executor subclass and use @handler on instance methods instead."
|
||||
)
|
||||
|
||||
# Resolve string forward references using the function's globals
|
||||
resolved_input_type = resolve_type_annotation(input, func.__globals__) if input is not None else None
|
||||
resolved_output_type = resolve_type_annotation(output, func.__globals__) if output is not None else None
|
||||
resolved_workflow_output_type = (
|
||||
resolve_type_annotation(workflow_output, func.__globals__) if workflow_output is not None else None
|
||||
)
|
||||
|
||||
# Validate function signature and extract types
|
||||
message_type, ctx_annotation, output_types, workflow_output_types = _validate_function_signature(func)
|
||||
introspected_message_type, ctx_annotation, inferred_output_types, inferred_workflow_output_types = (
|
||||
_validate_function_signature(func, skip_message_annotation=resolved_input_type is not None)
|
||||
)
|
||||
|
||||
# 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] = (
|
||||
normalize_type_to_list(resolved_output_type)
|
||||
if resolved_output_type is not None
|
||||
else list(inferred_output_types)
|
||||
)
|
||||
final_workflow_output_types: list[type[Any] | types.UnionType] = (
|
||||
normalize_type_to_list(resolved_workflow_output_type)
|
||||
if resolved_workflow_output_type is not None
|
||||
else list(inferred_workflow_output_types)
|
||||
)
|
||||
|
||||
# Validate that we have a message type - provides a clear error if type information is missing
|
||||
if message_type is None:
|
||||
raise ValueError(
|
||||
f"Function {func.__name__} requires either a message parameter type annotation "
|
||||
"or an explicit input_type parameter"
|
||||
)
|
||||
|
||||
# Store the original function
|
||||
self._original_func = func
|
||||
@@ -106,7 +157,7 @@ class FunctionExecutor(Executor):
|
||||
message_type=message_type,
|
||||
ctx_annotation=ctx_annotation,
|
||||
output_types=output_types,
|
||||
workflow_output_types=workflow_output_types,
|
||||
workflow_output_types=final_workflow_output_types,
|
||||
)
|
||||
|
||||
# Now we can safely call _discover_handlers (it won't find any class-level handlers)
|
||||
@@ -127,11 +178,22 @@ def executor(func: Callable[..., Any]) -> FunctionExecutor: ...
|
||||
|
||||
|
||||
@overload
|
||||
def executor(*, id: str | None = None) -> Callable[[Callable[..., Any]], FunctionExecutor]: ...
|
||||
def executor(
|
||||
*,
|
||||
id: str | None = None,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> Callable[[Callable[..., Any]], FunctionExecutor]: ...
|
||||
|
||||
|
||||
def executor(
|
||||
func: Callable[..., Any] | None = None, *, id: str | None = None
|
||||
func: Callable[..., Any] | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
input: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> Callable[[Callable[..., Any]], FunctionExecutor] | FunctionExecutor:
|
||||
"""Decorator that converts a standalone function into a FunctionExecutor instance.
|
||||
|
||||
@@ -162,6 +224,25 @@ def executor(
|
||||
return data.upper()
|
||||
|
||||
|
||||
# Using explicit types (takes precedence over introspection):
|
||||
# Note: No type annotations on function parameters when using explicit types
|
||||
@executor(id="my_executor", input=str | int, output=bool)
|
||||
async def process(message, ctx):
|
||||
await ctx.send_message(True)
|
||||
|
||||
|
||||
# Using string forward references:
|
||||
@executor(input="MyCustomType | int", output="ResponseType")
|
||||
async def process(message, ctx): ...
|
||||
|
||||
|
||||
# Specifying both output types (send_message and yield_output):
|
||||
@executor(input=str, output=int, workflow_output=bool)
|
||||
async def process(message, ctx):
|
||||
await ctx.send_message(42) # int - matches output
|
||||
await ctx.yield_output(True) # bool - matches workflow_output
|
||||
|
||||
|
||||
# For class-based executors, use @handler instead:
|
||||
class MyExecutor(Executor):
|
||||
def __init__(self):
|
||||
@@ -174,6 +255,18 @@ def executor(
|
||||
Args:
|
||||
func: The function to decorate (when used without parentheses)
|
||||
id: Optional custom ID for the executor. If None, uses the function name.
|
||||
input: Optional explicit input type(s) for this executor. Supports union types
|
||||
(e.g., ``str | int``) and string forward references (e.g., ``"MyType | int"``).
|
||||
When provided, takes precedence over introspection from the function's message
|
||||
parameter annotation.
|
||||
output: Optional explicit output type(s) that can be sent via ``ctx.send_message()``.
|
||||
Supports union types (e.g., ``str | int``) and string forward references.
|
||||
When provided, takes precedence over introspection from the ``WorkflowContext``
|
||||
first generic parameter (T_Out).
|
||||
workflow_output: Optional explicit output type(s) that can be yielded via
|
||||
``ctx.yield_output()``. Supports union types (e.g., ``str | int``) and string
|
||||
forward references. When provided, takes precedence over introspection from the
|
||||
``WorkflowContext`` second generic parameter (T_W_Out).
|
||||
|
||||
Returns:
|
||||
A FunctionExecutor instance that can be wired into a Workflow.
|
||||
@@ -183,7 +276,7 @@ def executor(
|
||||
"""
|
||||
|
||||
def wrapper(func: Callable[..., Any]) -> FunctionExecutor:
|
||||
return FunctionExecutor(func, id=id)
|
||||
return FunctionExecutor(func, id=id, input=input, output=output, workflow_output=workflow_output)
|
||||
|
||||
# If func is provided, this means @executor was used without parentheses
|
||||
if func is not None:
|
||||
@@ -198,14 +291,21 @@ def executor(
|
||||
# region Function Validation
|
||||
|
||||
|
||||
def _validate_function_signature(func: Callable[..., Any]) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
|
||||
def _validate_function_signature(
|
||||
func: Callable[..., Any],
|
||||
*,
|
||||
skip_message_annotation: bool = False,
|
||||
) -> tuple[type | None, Any, list[type[Any] | types.UnionType], list[type[Any] | types.UnionType]]:
|
||||
"""Validate function signature for executor functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
skip_message_annotation: If True, skip validation that message parameter has a type
|
||||
annotation. Used when input is explicitly provided to the @executor decorator.
|
||||
|
||||
Returns:
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
|
||||
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types).
|
||||
message_type may be None if skip_message_annotation is True and no annotation exists.
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
@@ -220,13 +320,15 @@ def _validate_function_signature(func: Callable[..., Any]) -> tuple[type, Any, l
|
||||
f"Function instance {func.__name__} must have {param_description}. Got {len(params)} parameters."
|
||||
)
|
||||
|
||||
# Check message parameter has type annotation
|
||||
# Check message parameter has type annotation (unless skipped)
|
||||
message_param = params[0]
|
||||
if message_param.annotation == inspect.Parameter.empty:
|
||||
if not skip_message_annotation and message_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Function instance {func.__name__} must have a type annotation for the message parameter")
|
||||
|
||||
type_hints = typing.get_type_hints(func)
|
||||
message_type = type_hints.get(message_param.name, message_param.annotation)
|
||||
if message_type == inspect.Parameter.empty:
|
||||
message_type = None
|
||||
|
||||
# Check if there's a context parameter
|
||||
if len(params) == 2:
|
||||
|
||||
@@ -1367,7 +1367,7 @@ class MagenticBuilder:
|
||||
|
||||
- `.with_plan_review()` - Review and approve/revise plans before execution
|
||||
- `.with_human_input_on_stall()` - Intervene when workflow stalls
|
||||
- Tool approval via `FunctionApprovalRequestContent` - Approve individual tool calls
|
||||
- Tool approval via `function_approval_request` - Approve individual tool calls
|
||||
|
||||
These emit `MagenticHumanInterventionRequest` events that provide structured
|
||||
decision options (APPROVE, REVISE, CONTINUE, REPLAN, GUIDANCE) appropriate
|
||||
|
||||
@@ -23,7 +23,7 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat
|
||||
This creates a cleaned copy removing ALL tool-related content.
|
||||
|
||||
Removes:
|
||||
- FunctionApprovalRequestContent and FunctionCallContent from assistant messages
|
||||
- function_approval_request and function_call from assistant messages
|
||||
- Tool response messages (Role.TOOL)
|
||||
- Messages with only tool calls and no text
|
||||
|
||||
|
||||
@@ -4,13 +4,21 @@ import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
from builtins import type as builtin_type
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
from types import UnionType
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
|
||||
from ._typing_utils import is_instance_of, is_type_compatible
|
||||
from ._typing_utils import is_instance_of, is_type_compatible, normalize_type_to_list, resolve_type_annotation
|
||||
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import overload # pragma: no cover
|
||||
else:
|
||||
from typing_extensions import overload # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._executor import Executor
|
||||
|
||||
@@ -86,15 +94,7 @@ class RequestInfoMixin:
|
||||
)
|
||||
|
||||
self._response_handlers[request_type, response_type] = getattr(self, attr_name)
|
||||
self._response_handler_specs.append({
|
||||
"name": handler_spec["name"],
|
||||
"request_type": request_type,
|
||||
"response_type": response_type,
|
||||
"output_types": handler_spec.get("output_types", []),
|
||||
"workflow_output_types": handler_spec.get("workflow_output_types", []),
|
||||
"ctx_annotation": handler_spec.get("ctx_annotation"),
|
||||
"source": "class_method", # Distinguish from instance handlers if needed
|
||||
})
|
||||
self._response_handler_specs.append({**handler_spec, "source": "class_method"})
|
||||
except AttributeError:
|
||||
continue # Skip non-callable attributes or those without handler spec
|
||||
|
||||
@@ -110,13 +110,64 @@ ContextT = TypeVar("ContextT", bound="WorkflowContext[Any, Any]")
|
||||
# region Handler Decorator
|
||||
|
||||
|
||||
@overload
|
||||
def response_handler(
|
||||
func: Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
) -> Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]:
|
||||
) -> Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def response_handler(
|
||||
func: None = None,
|
||||
*,
|
||||
request: type | types.UnionType | str | None = None,
|
||||
response: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> Callable[
|
||||
[Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]],
|
||||
Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
]: ...
|
||||
|
||||
|
||||
def response_handler(
|
||||
func: Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]] | None = None,
|
||||
*,
|
||||
request: type | types.UnionType | str | None = None,
|
||||
response: type | types.UnionType | str | None = None,
|
||||
output: type | types.UnionType | str | None = None,
|
||||
workflow_output: type | types.UnionType | str | None = None,
|
||||
) -> (
|
||||
Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]
|
||||
| Callable[
|
||||
[Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]],
|
||||
Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
]
|
||||
):
|
||||
"""Decorator to register a handler to handle responses for a request.
|
||||
|
||||
Type information can be provided in two mutually exclusive ways:
|
||||
|
||||
1. **Introspection** (default): Types are inferred from function signature annotations.
|
||||
Use type annotations on the original_request, response parameters and WorkflowContext
|
||||
generic parameters.
|
||||
|
||||
2. **Explicit parameters**: Types are specified via decorator parameters (request, response,
|
||||
output, workflow_output). When ANY explicit parameter is provided, ALL types must come
|
||||
from explicit parameters - introspection is completely disabled. The ``request`` and
|
||||
``response`` parameters are required; ``output`` and ``workflow_output`` are optional
|
||||
(default to no outputs).
|
||||
|
||||
Args:
|
||||
func: The function to decorate.
|
||||
func: The function to decorate. Can be None when used with parameters.
|
||||
request: Explicit request type for this handler (the original_request parameter type).
|
||||
Required when using explicit mode. Supports union types and string forward references.
|
||||
response: Explicit response type for this handler (the response parameter type).
|
||||
Required when using explicit mode. Supports union types and string forward references.
|
||||
output: Explicit output type(s) that can be sent via ``ctx.send_message()``.
|
||||
Optional; defaults to no outputs if not specified.
|
||||
workflow_output: Explicit output type(s) that can be yielded via ``ctx.yield_output()``.
|
||||
Optional; defaults to no outputs if not specified.
|
||||
|
||||
Returns:
|
||||
The decorated function with handler metadata.
|
||||
@@ -124,6 +175,7 @@ def response_handler(
|
||||
Example:
|
||||
.. code-block:: python
|
||||
|
||||
# Mode 1: Introspection - types from annotations
|
||||
@handler
|
||||
async def run(self, message: int, context: WorkflowContext[str]) -> None:
|
||||
# Example of a handler that sends a request
|
||||
@@ -143,31 +195,84 @@ def response_handler(
|
||||
...
|
||||
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: CustomRequest,
|
||||
response: dict,
|
||||
context: WorkflowContext[int],
|
||||
) -> None:
|
||||
# Example of a response handler for a request expecting a dict response
|
||||
...
|
||||
# Mode 2: Explicit types - ALL types from decorator params
|
||||
# Note: No type annotations on function parameters when using explicit types
|
||||
@response_handler(request=CustomRequest, response=dict, output=int)
|
||||
async def handle_response(self, original_request, response, context):
|
||||
# Example of a response handler with explicit types
|
||||
await context.send_message(42)
|
||||
|
||||
|
||||
# Explicit with string forward references
|
||||
@response_handler(request="MyRequest", response="MyResponse")
|
||||
async def handle_response(self, original_request, response, context): ...
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]],
|
||||
) -> Callable[[ExecutorT, Any, Any, ContextT], Awaitable[None]]:
|
||||
request_type, response_type, ctx_annotation, inferred_output_types, inferred_workflow_output_types = (
|
||||
_validate_response_handler_signature(func)
|
||||
# Check if ANY explicit type parameter was provided - if so, use ONLY explicit params.
|
||||
# This is "all or nothing" - no mixing of explicit params with introspection.
|
||||
use_explicit_types = (
|
||||
request is not None or response is not None or output is not None or workflow_output is not None
|
||||
)
|
||||
|
||||
if use_explicit_types:
|
||||
# Resolve string forward references using the function's globals
|
||||
resolved_request_type = resolve_type_annotation(request, func.__globals__) if request is not None else None
|
||||
resolved_response_type = (
|
||||
resolve_type_annotation(response, func.__globals__) if response is not None else None
|
||||
)
|
||||
resolved_output_type = resolve_type_annotation(output, func.__globals__) if output is not None else None
|
||||
resolved_workflow_output_type = (
|
||||
resolve_type_annotation(workflow_output, func.__globals__) if workflow_output is not None else None
|
||||
)
|
||||
|
||||
# Validate signature structure but skip type extraction
|
||||
_validate_response_handler_signature(func, skip_annotations=True)
|
||||
|
||||
# Validate required parameters
|
||||
if resolved_request_type is None:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} with explicit type parameters must specify 'request' type"
|
||||
)
|
||||
if resolved_response_type is None:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} with explicit type parameters must specify 'response' type"
|
||||
)
|
||||
|
||||
final_request_type = resolved_request_type
|
||||
final_response_type = resolved_response_type
|
||||
final_output_types = normalize_type_to_list(resolved_output_type) if resolved_output_type else []
|
||||
final_workflow_output_types = (
|
||||
normalize_type_to_list(resolved_workflow_output_type) if resolved_workflow_output_type else []
|
||||
)
|
||||
# Get ctx_annotation for consistency
|
||||
ctx_annotation = (
|
||||
inspect.signature(func).parameters[list(inspect.signature(func).parameters.keys())[3]].annotation
|
||||
)
|
||||
if ctx_annotation == inspect.Parameter.empty:
|
||||
ctx_annotation = None
|
||||
else:
|
||||
# Use introspection - all types from annotations
|
||||
(
|
||||
inferred_request_type,
|
||||
inferred_response_type,
|
||||
ctx_annotation,
|
||||
final_output_types,
|
||||
final_workflow_output_types,
|
||||
) = _validate_response_handler_signature(func)
|
||||
# In introspection mode, validation ensures these are not None (raises ValueError if missing)
|
||||
final_request_type = cast(type, inferred_request_type)
|
||||
final_response_type = cast(type, inferred_response_type)
|
||||
|
||||
# Get signature for preservation
|
||||
sig = inspect.signature(func)
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self: ExecutorT, original_request: Any, response: Any, ctx: ContextT) -> Any:
|
||||
async def wrapper(self: ExecutorT, original_request: Any, response_msg: Any, ctx: ContextT) -> Any:
|
||||
"""Wrapper function to call the handler."""
|
||||
return await func(self, original_request, response, ctx)
|
||||
return await func(self, original_request, response_msg, ctx)
|
||||
|
||||
# Preserve the original function signature for introspection during validation
|
||||
with contextlib.suppress(AttributeError, TypeError):
|
||||
@@ -175,17 +280,22 @@ def response_handler(
|
||||
|
||||
wrapper._response_handler_spec = { # type: ignore
|
||||
"name": func.__name__,
|
||||
"request_type": request_type,
|
||||
"response_type": response_type,
|
||||
"request_type": final_request_type,
|
||||
"response_type": final_response_type,
|
||||
# Keep output_types and workflow_output_types in spec for validators
|
||||
"output_types": inferred_output_types,
|
||||
"workflow_output_types": inferred_workflow_output_types,
|
||||
"output_types": final_output_types,
|
||||
"workflow_output_types": final_workflow_output_types,
|
||||
"ctx_annotation": ctx_annotation,
|
||||
}
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator(func)
|
||||
# If func is provided, this means @response_handler was used without parentheses
|
||||
if func is not None:
|
||||
return decorator(func)
|
||||
|
||||
# Otherwise, return the wrapper for @response_handler(...) with parameters
|
||||
return decorator
|
||||
|
||||
|
||||
# endregion: Handler Decorator
|
||||
@@ -195,14 +305,19 @@ def response_handler(
|
||||
|
||||
def _validate_response_handler_signature(
|
||||
func: Callable[..., Any],
|
||||
) -> tuple[type, type, Any, list[type[Any]], list[type[Any]]]:
|
||||
"""Validate function signature for executor functions.
|
||||
*,
|
||||
skip_annotations: bool = False,
|
||||
) -> tuple[type | None, type | None, Any, list[type[Any] | UnionType], list[type[Any] | UnionType]]:
|
||||
"""Validate function signature for response handler functions.
|
||||
|
||||
Args:
|
||||
func: The function to validate
|
||||
skip_annotations: If True, skip validation that request/response parameters have type
|
||||
annotations. Used when types are explicitly provided to the @response_handler decorator.
|
||||
|
||||
Returns:
|
||||
Tuple of (request_type, response_type, ctx_annotation, output_types, workflow_output_types)
|
||||
Tuple of (request_type, response_type, ctx_annotation, output_types, workflow_output_types).
|
||||
request_type and response_type may be None if skip_annotations is True and no annotations exist.
|
||||
|
||||
Raises:
|
||||
ValueError: If the function signature is invalid
|
||||
@@ -215,33 +330,38 @@ def _validate_response_handler_signature(
|
||||
# to the original request when registering the handler, while maintaining
|
||||
# the order of parameters as if the response handler is a normal handler.
|
||||
expected_counts = 4 # self, original_request, message, ctx
|
||||
param_description = "(self, original_request: TRequest, message: TResponse, ctx: WorkflowContext[U, V])"
|
||||
param_description = "(self, original_request, response, ctx)"
|
||||
if len(params) != expected_counts:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} must have {param_description}. Got {len(params)} parameters."
|
||||
)
|
||||
|
||||
# Check original_request parameter exists
|
||||
# Check original_request parameter exists and has annotation (unless skipped)
|
||||
original_request_param = params[1]
|
||||
if original_request_param.annotation == inspect.Parameter.empty:
|
||||
if not skip_annotations and original_request_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(
|
||||
f"Response handler {func.__name__} must have a type annotation for the original_request parameter"
|
||||
)
|
||||
|
||||
# Check response parameter has type annotation
|
||||
# Check response parameter has type annotation (unless skipped)
|
||||
response_param = params[2]
|
||||
if response_param.annotation == inspect.Parameter.empty:
|
||||
raise ValueError(f"Response handler {func.__name__} must have a type annotation for the message parameter")
|
||||
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")
|
||||
|
||||
# Validate ctx parameter is WorkflowContext and extract type args
|
||||
# Validate ctx parameter is WorkflowContext and extract type args (if annotated)
|
||||
ctx_param = params[3]
|
||||
output_types, workflow_output_types = validate_workflow_context_annotation(
|
||||
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Response handler"
|
||||
)
|
||||
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"
|
||||
)
|
||||
else:
|
||||
output_types, workflow_output_types = [], []
|
||||
|
||||
request_type = original_request_param.annotation
|
||||
response_type = response_param.annotation
|
||||
ctx_annotation = ctx_param.annotation
|
||||
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
|
||||
|
||||
return request_type, response_type, ctx_annotation, output_types, workflow_output_types
|
||||
|
||||
|
||||
@@ -1,14 +1,99 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from types import UnionType
|
||||
from typing import Any, TypeVar, Union, cast, get_args, get_origin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def resolve_type_annotation(
|
||||
type_annotation: type[Any] | UnionType | str | None,
|
||||
globalns: dict[str, Any] | None = None,
|
||||
localns: dict[str, Any] | None = None,
|
||||
) -> type[Any] | UnionType | None:
|
||||
"""Resolve a type annotation, including string forward references.
|
||||
|
||||
Args:
|
||||
type_annotation: A type, union type, string forward reference, or None
|
||||
globalns: Global namespace for resolving forward references (typically func.__globals__)
|
||||
localns: Local namespace for resolving forward references
|
||||
|
||||
Returns:
|
||||
The resolved type annotation. For string annotations, evaluates them in the
|
||||
provided namespace. Returns None if type_annotation is None.
|
||||
|
||||
Raises:
|
||||
NameError: If a forward reference cannot be resolved in the provided namespaces
|
||||
SyntaxError: If a string annotation contains invalid Python syntax
|
||||
|
||||
Note:
|
||||
This function uses eval() to resolve string type annotations. This is the same
|
||||
approach used by Python's typing.get_type_hints() and typing.ForwardRef internally.
|
||||
Security is managed by: (1) strings come from decorator parameters in source code,
|
||||
not runtime user input, and (2) the eval namespace is restricted to the function's
|
||||
module globals plus Union/Optional from typing.
|
||||
|
||||
Examples:
|
||||
- resolve_type_annotation(str) -> str
|
||||
- resolve_type_annotation("str | int", {"str": str, "int": int}) -> str | int
|
||||
- resolve_type_annotation("MyClass", {"MyClass": MyClass}) -> MyClass
|
||||
"""
|
||||
if type_annotation is None:
|
||||
return None
|
||||
|
||||
if isinstance(type_annotation, str):
|
||||
# Resolve string forward reference by evaluating it.
|
||||
# This uses eval() which is the same approach as Python's typing.get_type_hints()
|
||||
# and typing.ForwardRef._evaluate(). The namespace is restricted to the function's
|
||||
# globals plus typing constructs, and input comes from developer source code.
|
||||
eval_globalns = globalns.copy() if globalns else {}
|
||||
eval_globalns.setdefault("Union", Union)
|
||||
eval_globalns.setdefault("Optional", __import__("typing").Optional)
|
||||
|
||||
try:
|
||||
return cast(
|
||||
"type[Any] | UnionType",
|
||||
eval(type_annotation, eval_globalns, localns), # noqa: S307 # nosec B307
|
||||
)
|
||||
except NameError as e:
|
||||
raise NameError(
|
||||
f"Could not resolve type annotation '{type_annotation}'. "
|
||||
f"Make sure the type is defined or imported. Original error: {e}"
|
||||
) from e
|
||||
|
||||
return type_annotation
|
||||
|
||||
|
||||
def normalize_type_to_list(type_annotation: type[Any] | UnionType | None) -> list[type[Any] | UnionType]:
|
||||
"""Normalize a type annotation (possibly a union) to a list of concrete types.
|
||||
|
||||
Args:
|
||||
type_annotation: A type, union type (using | or Union[]), or None
|
||||
|
||||
Returns:
|
||||
A list of types. For union types, returns all members.
|
||||
For None, returns an empty list.
|
||||
For Optional[T] (Union[T, None]), returns [T, type(None)].
|
||||
|
||||
Examples:
|
||||
- normalize_type_to_list(str) -> [str]
|
||||
- normalize_type_to_list(str | int) -> [str, int]
|
||||
- normalize_type_to_list(Union[str, int]) -> [str, int]
|
||||
- normalize_type_to_list(None) -> []
|
||||
"""
|
||||
if type_annotation is None:
|
||||
return []
|
||||
|
||||
origin = get_origin(type_annotation)
|
||||
|
||||
# Handle Union types (str | int or Union[str, int])
|
||||
if origin is Union or origin is UnionType:
|
||||
return list(get_args(type_annotation))
|
||||
|
||||
# Single type
|
||||
return [type_annotation]
|
||||
|
||||
|
||||
def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool:
|
||||
"""Check if the data is an instance of the target type.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
import types
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from enum import Enum
|
||||
@@ -55,8 +56,8 @@ class TypeCompatibilityError(WorkflowValidationError):
|
||||
self,
|
||||
source_executor_id: str,
|
||||
target_executor_id: str,
|
||||
source_types: list[type[Any]],
|
||||
target_types: list[type[Any]],
|
||||
source_types: list[type[Any] | types.UnionType],
|
||||
target_types: list[type[Any] | types.UnionType],
|
||||
):
|
||||
# Use a placeholder for incompatible types - will be computed in WorkflowGraphValidator
|
||||
super().__init__(
|
||||
@@ -253,7 +254,7 @@ class WorkflowGraphValidator:
|
||||
|
||||
# Check if any source output type is compatible with any target input type
|
||||
compatible = False
|
||||
compatible_pairs: list[tuple[type[Any], type[Any]]] = []
|
||||
compatible_pairs: list[tuple[type[Any] | types.UnionType, type[Any] | types.UnionType]] = []
|
||||
|
||||
for source_type in source_output_types:
|
||||
for target_type in target_input_types:
|
||||
|
||||
@@ -5,6 +5,7 @@ import functools
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import types
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable
|
||||
from typing import Any
|
||||
@@ -815,7 +816,7 @@ class Workflow(DictConvertible):
|
||||
return self._graph_signature_hash
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any]]:
|
||||
def input_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the input types of the workflow.
|
||||
|
||||
The input types are the list of input types of the start executor.
|
||||
@@ -827,7 +828,7 @@ class Workflow(DictConvertible):
|
||||
return start_executor.input_types
|
||||
|
||||
@property
|
||||
def output_types(self) -> list[type[Any]]:
|
||||
def output_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the output types of the workflow.
|
||||
|
||||
The output types are the list of all workflow output types from executors
|
||||
@@ -836,7 +837,7 @@ class Workflow(DictConvertible):
|
||||
Returns:
|
||||
A list of output types that the workflow can produce.
|
||||
"""
|
||||
output_types: set[type[Any]] = set()
|
||||
output_types: set[type[Any] | types.UnionType] = set()
|
||||
|
||||
for executor in self.executors.values():
|
||||
workflow_output_types = executor.workflow_output_types
|
||||
|
||||
@@ -38,7 +38,9 @@ T_W_Out = TypeVar("T_W_Out", default=Never)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> tuple[list[type[Any]], list[type[Any]]]:
|
||||
def infer_output_types_from_ctx_annotation(
|
||||
ctx_annotation: Any,
|
||||
) -> tuple[list[type[Any] | UnionType], list[type[Any] | UnionType]]:
|
||||
"""Infer message types and workflow output types from the WorkflowContext generic parameters.
|
||||
|
||||
Examples:
|
||||
@@ -81,8 +83,8 @@ def infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> tuple[list[ty
|
||||
return [cast(type[Any], Any)], []
|
||||
|
||||
if t_origin in (Union, UnionType):
|
||||
message_types = [arg for arg in get_args(t) if arg is not Any and arg is not Never]
|
||||
return message_types, []
|
||||
msg_types: list[type[Any] | UnionType] = [arg for arg in get_args(t) if arg is not Any and arg is not Never]
|
||||
return msg_types, []
|
||||
|
||||
if t is Never:
|
||||
return [], []
|
||||
@@ -92,7 +94,7 @@ def infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> tuple[list[ty
|
||||
t_out, t_w_out = args[:2] # Take first two args in case there are more
|
||||
|
||||
# Process T_Out for message_types
|
||||
message_types = []
|
||||
message_types: list[type[Any] | UnionType] = []
|
||||
t_out_origin = get_origin(t_out)
|
||||
if t_out is Any:
|
||||
message_types = [cast(type[Any], Any)]
|
||||
@@ -103,7 +105,7 @@ def infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> tuple[list[ty
|
||||
message_types = [t_out]
|
||||
|
||||
# Process T_W_Out for workflow_output_types
|
||||
workflow_output_types = []
|
||||
workflow_output_types: list[type[Any] | UnionType] = []
|
||||
t_w_out_origin = get_origin(t_w_out)
|
||||
if t_w_out is Any:
|
||||
workflow_output_types = [cast(type[Any], Any)]
|
||||
@@ -129,7 +131,7 @@ def validate_workflow_context_annotation(
|
||||
annotation: Any,
|
||||
parameter_name: str,
|
||||
context_description: str,
|
||||
) -> tuple[list[type[Any]], list[type[Any]]]:
|
||||
) -> tuple[list[type[Any] | UnionType], list[type[Any] | UnionType]]:
|
||||
"""Validate a WorkflowContext annotation and return inferred types.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -302,13 +303,13 @@ class WorkflowExecutor(Executor):
|
||||
self._propagate_request = propagate_request
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any]]:
|
||||
def input_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the input types based on the underlying workflow's input types plus WorkflowExecutor-specific types.
|
||||
|
||||
Returns:
|
||||
A list of input types that the WorkflowExecutor can accept.
|
||||
"""
|
||||
input_types = list(self.workflow.input_types)
|
||||
input_types: list[type[Any] | types.UnionType] = list(self.workflow.input_types)
|
||||
|
||||
# WorkflowExecutor can also handle SubWorkflowResponseMessage for sub-workflow responses
|
||||
if SubWorkflowResponseMessage not in input_types:
|
||||
@@ -317,7 +318,7 @@ class WorkflowExecutor(Executor):
|
||||
return input_types
|
||||
|
||||
@property
|
||||
def output_types(self) -> list[type[Any]]:
|
||||
def output_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the output types based on the underlying workflow's output types.
|
||||
|
||||
Returns:
|
||||
@@ -325,7 +326,7 @@ class WorkflowExecutor(Executor):
|
||||
Includes the SubWorkflowRequestMessage type if any executor in the
|
||||
sub-workflow is request-response capable.
|
||||
"""
|
||||
output_types = list(self.workflow.output_types)
|
||||
output_types: list[type[Any] | types.UnionType] = list(self.workflow.output_types)
|
||||
|
||||
is_request_response_capable = any(
|
||||
executor.is_request_response_capable for executor in self.workflow.executors.values()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
@@ -17,6 +19,27 @@ from agent_framework import (
|
||||
)
|
||||
|
||||
|
||||
# Module-level types for string forward reference tests
|
||||
@dataclass
|
||||
class ForwardRefMessage:
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForwardRefTypeA:
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForwardRefTypeB:
|
||||
value: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForwardRefResponse:
|
||||
result: str
|
||||
|
||||
|
||||
def test_executor_without_id():
|
||||
"""Test that an executor without an ID raises an error when trying to run."""
|
||||
|
||||
@@ -537,3 +560,362 @@ async def test_executor_invoked_event_data_not_mutated_by_handler():
|
||||
f"{[m.text for m in mutator_invoked.data]}"
|
||||
)
|
||||
assert mutator_invoked.data[0].text == "hello"
|
||||
|
||||
|
||||
# region: Tests for @handler decorator with explicit input_type and output_type
|
||||
|
||||
|
||||
class TestHandlerExplicitTypes:
|
||||
"""Test suite for @handler decorator with explicit input_type and output_type parameters."""
|
||||
|
||||
def test_handler_with_explicit_input_type(self):
|
||||
"""Test that explicit input_type takes precedence over introspection."""
|
||||
from typing import Any
|
||||
|
||||
class ExplicitInputExecutor(Executor):
|
||||
@handler(input=str)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = ExplicitInputExecutor(id="explicit_input")
|
||||
|
||||
# Handler should be registered for str (explicit), not Any (introspected)
|
||||
assert str in exec_instance._handlers
|
||||
assert len(exec_instance._handlers) == 1
|
||||
|
||||
# Can handle str messages
|
||||
assert exec_instance.can_handle(Message(data="hello", source_id="mock"))
|
||||
# Cannot handle int messages (since explicit type is str)
|
||||
assert not exec_instance.can_handle(Message(data=42, source_id="mock"))
|
||||
|
||||
def test_handler_with_explicit_output_type(self):
|
||||
"""Test that explicit output works when input is also specified."""
|
||||
|
||||
class ExplicitOutputExecutor(Executor):
|
||||
@handler(input=str, output=int)
|
||||
async def handle(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = ExplicitOutputExecutor(id="explicit_output")
|
||||
|
||||
# Handler spec should have int as output type (explicit)
|
||||
handler_func = exec_instance._handlers[str]
|
||||
assert handler_func._handler_spec["output_types"] == [int]
|
||||
|
||||
# Executor output_types property should reflect explicit type
|
||||
assert int in exec_instance.output_types
|
||||
assert str not in exec_instance.output_types
|
||||
|
||||
def test_handler_with_explicit_input_and_output_types(self):
|
||||
"""Test that both explicit input_type and output_type work together."""
|
||||
from typing import Any
|
||||
|
||||
class ExplicitBothExecutor(Executor):
|
||||
@handler(input=dict, output=list)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = ExplicitBothExecutor(id="explicit_both")
|
||||
|
||||
# Handler should be registered for dict (explicit input type)
|
||||
assert dict in exec_instance._handlers
|
||||
assert len(exec_instance._handlers) == 1
|
||||
|
||||
# Output type should be list (explicit)
|
||||
handler_func = exec_instance._handlers[dict]
|
||||
assert handler_func._handler_spec["output_types"] == [list]
|
||||
|
||||
# Verify can_handle
|
||||
assert exec_instance.can_handle(Message(data={"key": "value"}, source_id="mock"))
|
||||
assert not exec_instance.can_handle(Message(data="string", source_id="mock"))
|
||||
|
||||
def test_handler_with_explicit_union_input_type(self):
|
||||
"""Test that explicit union input_type is handled correctly."""
|
||||
from typing import Any
|
||||
|
||||
class UnionInputExecutor(Executor):
|
||||
@handler(input=str | int)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = UnionInputExecutor(id="union_input")
|
||||
|
||||
# Handler should be registered for the union type
|
||||
# The union type itself is stored as the key
|
||||
assert len(exec_instance._handlers) == 1
|
||||
|
||||
# Can handle both str and int messages
|
||||
assert exec_instance.can_handle(Message(data="hello", source_id="mock"))
|
||||
assert exec_instance.can_handle(Message(data=42, source_id="mock"))
|
||||
# Cannot handle float
|
||||
assert not exec_instance.can_handle(Message(data=3.14, source_id="mock"))
|
||||
|
||||
def test_handler_with_explicit_union_output_type(self):
|
||||
"""Test that explicit union output is normalized to a list."""
|
||||
from typing import Any
|
||||
|
||||
class UnionOutputExecutor(Executor):
|
||||
@handler(input=bytes, output=str | int | bool)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = UnionOutputExecutor(id="union_output")
|
||||
|
||||
# Output types should be a list with all union members
|
||||
assert set(exec_instance.output_types) == {str, int, bool}
|
||||
|
||||
def test_handler_explicit_types_precedence_over_introspection(self):
|
||||
"""Test that explicit types always take precedence over introspected types."""
|
||||
|
||||
class PrecedenceExecutor(Executor):
|
||||
# Introspection would give: input=str, output=[int]
|
||||
# Explicit gives: input=bytes, output=[float]
|
||||
@handler(input=bytes, output=float)
|
||||
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = PrecedenceExecutor(id="precedence")
|
||||
|
||||
# Should use explicit input type (bytes), not introspected (str)
|
||||
assert bytes in exec_instance._handlers
|
||||
assert str not in exec_instance._handlers
|
||||
|
||||
# Should use explicit output type (float), not introspected (int)
|
||||
assert float in exec_instance.output_types
|
||||
assert int not in exec_instance.output_types
|
||||
|
||||
def test_handler_fallback_to_introspection_when_no_explicit_types(self):
|
||||
"""Test that introspection is used when no explicit types are provided."""
|
||||
|
||||
class IntrospectedExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = IntrospectedExecutor(id="introspected")
|
||||
|
||||
# Should use introspected types
|
||||
assert str in exec_instance._handlers
|
||||
assert int in exec_instance.output_types
|
||||
|
||||
def test_handler_explicit_mode_requires_input(self):
|
||||
"""Test that using any explicit type param requires input to be specified."""
|
||||
|
||||
# Only explicit input - output defaults to empty (no introspection)
|
||||
class OnlyInputExecutor(Executor):
|
||||
@handler(input=bytes)
|
||||
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
exec_input = OnlyInputExecutor(id="only_input")
|
||||
assert bytes in exec_input._handlers # Explicit
|
||||
assert exec_input.output_types == [] # No output types (not introspected)
|
||||
|
||||
# Only explicit output without input should raise error
|
||||
with pytest.raises(ValueError, match="must specify 'input' type"):
|
||||
|
||||
class OnlyOutputExecutor(Executor):
|
||||
@handler(output=float)
|
||||
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
# Only explicit workflow_output without input should raise error
|
||||
with pytest.raises(ValueError, match="must specify 'input' type"):
|
||||
|
||||
class OnlyWorkflowOutputExecutor(Executor):
|
||||
@handler(workflow_output=bool)
|
||||
async def handle(self, message: str, ctx: WorkflowContext[int, str]) -> None:
|
||||
pass
|
||||
|
||||
def test_handler_explicit_input_type_allows_no_message_annotation(self):
|
||||
"""Test that explicit input_type allows handler without message type annotation."""
|
||||
|
||||
class NoAnnotationExecutor(Executor):
|
||||
@handler(input=str)
|
||||
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
exec_instance = NoAnnotationExecutor(id="no_annotation")
|
||||
|
||||
# Should work with explicit input_type
|
||||
assert str in exec_instance._handlers
|
||||
assert exec_instance.can_handle(Message(data="hello", source_id="mock"))
|
||||
|
||||
def test_handler_multiple_handlers_mixed_explicit_and_introspected(self):
|
||||
"""Test executor with multiple handlers, some with explicit types and some introspected."""
|
||||
|
||||
class MixedExecutor(Executor):
|
||||
@handler(input=str, output=int)
|
||||
async def handle_explicit(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
@handler
|
||||
async def handle_introspected(self, message: float, ctx: WorkflowContext[bool]) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = MixedExecutor(id="mixed")
|
||||
|
||||
# Should have both handlers
|
||||
assert len(exec_instance._handlers) == 2
|
||||
assert str in exec_instance._handlers # Explicit
|
||||
assert float in exec_instance._handlers # Introspected
|
||||
|
||||
# Should have both output types
|
||||
assert int in exec_instance.output_types # Explicit
|
||||
assert bool in exec_instance.output_types # Introspected
|
||||
|
||||
def test_handler_with_string_forward_reference_input_type(self):
|
||||
"""Test that string forward references work for input_type."""
|
||||
|
||||
class StringRefExecutor(Executor):
|
||||
@handler(input="ForwardRefMessage")
|
||||
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
exec_instance = StringRefExecutor(id="string_ref")
|
||||
|
||||
# Should resolve the string to the actual type
|
||||
assert ForwardRefMessage in exec_instance._handlers
|
||||
assert exec_instance.can_handle(Message(data=ForwardRefMessage("hello"), source_id="mock"))
|
||||
|
||||
def test_handler_with_string_forward_reference_union(self):
|
||||
"""Test that string forward references work with union types."""
|
||||
|
||||
class StringUnionExecutor(Executor):
|
||||
@handler(input="ForwardRefTypeA | ForwardRefTypeB")
|
||||
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
exec_instance = StringUnionExecutor(id="string_union")
|
||||
|
||||
# Should handle both types
|
||||
assert exec_instance.can_handle(Message(data=ForwardRefTypeA("hello"), source_id="mock"))
|
||||
assert exec_instance.can_handle(Message(data=ForwardRefTypeB(42), source_id="mock"))
|
||||
|
||||
def test_handler_with_string_forward_reference_output_type(self):
|
||||
"""Test that string forward references work for output_type."""
|
||||
|
||||
class StringOutputExecutor(Executor):
|
||||
@handler(input=str, output="ForwardRefResponse")
|
||||
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
exec_instance = StringOutputExecutor(id="string_output")
|
||||
|
||||
# Should resolve the string output type
|
||||
assert ForwardRefResponse in exec_instance.output_types
|
||||
|
||||
def test_handler_with_explicit_workflow_output_type(self):
|
||||
"""Test that explicit workflow_output works when input is also specified."""
|
||||
|
||||
class ExplicitWorkflowOutputExecutor(Executor):
|
||||
@handler(input=str, workflow_output=bool)
|
||||
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = ExplicitWorkflowOutputExecutor(id="explicit_workflow_output")
|
||||
|
||||
# Handler spec should have bool as workflow_output_type (explicit)
|
||||
handler_func = exec_instance._handlers[str]
|
||||
assert handler_func._handler_spec["workflow_output_types"] == [bool]
|
||||
|
||||
# Executor workflow_output_types property should reflect explicit type
|
||||
assert bool in exec_instance.workflow_output_types
|
||||
# output_types should be empty (explicit mode, output not specified)
|
||||
assert exec_instance.output_types == []
|
||||
|
||||
def test_handler_with_explicit_workflow_output_and_output(self):
|
||||
"""Test that explicit workflow_output works alongside explicit output."""
|
||||
|
||||
class PrecedenceExecutor(Executor):
|
||||
@handler(input=int, output=float, workflow_output=str)
|
||||
async def handle(self, message: int, ctx: WorkflowContext[int, bool]) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = PrecedenceExecutor(id="precedence")
|
||||
|
||||
# All types should come from explicit params
|
||||
assert int in exec_instance._handlers
|
||||
assert float in exec_instance.output_types
|
||||
assert str in exec_instance.workflow_output_types
|
||||
# Introspected types should NOT be present
|
||||
assert bool not in exec_instance.workflow_output_types
|
||||
|
||||
def test_handler_with_all_explicit_types(self):
|
||||
"""Test that all three explicit type parameters work together."""
|
||||
from typing import Any
|
||||
|
||||
class AllExplicitExecutor(Executor):
|
||||
@handler(input=str, output=int, workflow_output=bool)
|
||||
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = AllExplicitExecutor(id="all_explicit")
|
||||
|
||||
# Check input type
|
||||
assert str in exec_instance._handlers
|
||||
assert exec_instance.can_handle(Message(data="hello", source_id="mock"))
|
||||
|
||||
# Check output_type
|
||||
assert int in exec_instance.output_types
|
||||
|
||||
# Check workflow_output_type
|
||||
assert bool in exec_instance.workflow_output_types
|
||||
|
||||
def test_handler_with_union_workflow_output_type(self):
|
||||
"""Test that union types work for workflow_output."""
|
||||
|
||||
class UnionWorkflowOutputExecutor(Executor):
|
||||
@handler(input=str, workflow_output=str | int)
|
||||
async def handle(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = UnionWorkflowOutputExecutor(id="union_workflow_output")
|
||||
|
||||
# Should include both types from union
|
||||
assert str in exec_instance.workflow_output_types
|
||||
assert int in exec_instance.workflow_output_types
|
||||
|
||||
def test_handler_with_string_forward_reference_workflow_output_type(self):
|
||||
"""Test that string forward references work for workflow_output_type."""
|
||||
|
||||
class StringWorkflowOutputExecutor(Executor):
|
||||
@handler(input=str, workflow_output="ForwardRefResponse")
|
||||
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
exec_instance = StringWorkflowOutputExecutor(id="string_workflow_output")
|
||||
|
||||
# Should resolve the string workflow_output_type
|
||||
assert ForwardRefResponse in exec_instance.workflow_output_types
|
||||
|
||||
def test_handler_with_string_forward_reference_union_workflow_output_type(self):
|
||||
"""Test that string forward reference union types work for workflow_output_type."""
|
||||
|
||||
class StringUnionWorkflowOutputExecutor(Executor):
|
||||
@handler(input=str, workflow_output="ForwardRefTypeA | ForwardRefTypeB")
|
||||
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
exec_instance = StringUnionWorkflowOutputExecutor(id="string_union_workflow_output")
|
||||
|
||||
# Should resolve both types from string union
|
||||
assert ForwardRefTypeA in exec_instance.workflow_output_types
|
||||
assert ForwardRefTypeB in exec_instance.workflow_output_types
|
||||
|
||||
def test_handler_fallback_to_introspection_for_workflow_output_type(self):
|
||||
"""Test that workflow_output_type falls back to introspection when not explicitly provided."""
|
||||
|
||||
class IntrospectedWorkflowOutputExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, message: str, ctx: WorkflowContext[int, bool]) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = IntrospectedWorkflowOutputExecutor(id="introspected_workflow_output")
|
||||
|
||||
# Should use introspected types from WorkflowContext[int, bool]
|
||||
assert int in exec_instance.output_types
|
||||
assert bool in exec_instance.workflow_output_types
|
||||
|
||||
|
||||
# endregion: Tests for @handler decorator with explicit input_type and output_type
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -14,6 +15,27 @@ from agent_framework import (
|
||||
)
|
||||
|
||||
|
||||
# Module-level types for string forward reference tests
|
||||
@dataclass
|
||||
class FuncExecForwardRefMessage:
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FuncExecForwardRefTypeA:
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FuncExecForwardRefTypeB:
|
||||
value: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class FuncExecForwardRefResponse:
|
||||
result: str
|
||||
|
||||
|
||||
class TestFunctionExecutor:
|
||||
"""Test suite for FunctionExecutor and @executor decorator."""
|
||||
|
||||
@@ -535,3 +557,341 @@ class TestFunctionExecutor:
|
||||
async_static = static_wrapped
|
||||
|
||||
assert asyncio.iscoroutinefunction(C.async_static) # Works via descriptor protocol
|
||||
|
||||
|
||||
class TestExecutorExplicitTypes:
|
||||
"""Test suite for @executor decorator with explicit input_type and output_type parameters."""
|
||||
|
||||
def test_executor_with_explicit_input_type(self):
|
||||
"""Test that explicit input_type takes precedence over introspection."""
|
||||
|
||||
@executor(input=str)
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Handler should be registered for str (explicit)
|
||||
assert str in process._handlers
|
||||
assert len(process._handlers) == 1
|
||||
|
||||
# Can handle str messages
|
||||
assert process.can_handle(Message(data="hello", source_id="mock"))
|
||||
# Cannot handle int messages
|
||||
assert not process.can_handle(Message(data=42, source_id="mock"))
|
||||
|
||||
def test_executor_with_explicit_output_type(self):
|
||||
"""Test that explicit output_type takes precedence over introspection."""
|
||||
|
||||
@executor(output=int)
|
||||
async def process(message: str, ctx: WorkflowContext[str]) -> None:
|
||||
pass
|
||||
|
||||
# Handler spec should have int as output type (explicit), not str (introspected)
|
||||
spec = process._handler_specs[0]
|
||||
assert spec["output_types"] == [int]
|
||||
|
||||
# Executor output_types property should reflect explicit type
|
||||
assert int in process.output_types
|
||||
assert str not in process.output_types
|
||||
|
||||
def test_executor_with_explicit_input_and_output_types(self):
|
||||
"""Test that both explicit input_type and output_type work together."""
|
||||
|
||||
@executor(id="explicit_both", input=dict, output=list)
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Handler should be registered for dict (explicit input type)
|
||||
assert dict in process._handlers
|
||||
assert len(process._handlers) == 1
|
||||
|
||||
# Output type should be list (explicit)
|
||||
spec = process._handler_specs[0]
|
||||
assert spec["output_types"] == [list]
|
||||
|
||||
# Verify can_handle
|
||||
assert process.can_handle(Message(data={"key": "value"}, source_id="mock"))
|
||||
assert not process.can_handle(Message(data="string", source_id="mock"))
|
||||
|
||||
def test_executor_with_explicit_union_input_type(self):
|
||||
"""Test that explicit union input_type is handled correctly."""
|
||||
|
||||
@executor(input=str | int)
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Handler should be registered for the union type
|
||||
assert len(process._handlers) == 1
|
||||
|
||||
# Can handle both str and int messages
|
||||
assert process.can_handle(Message(data="hello", source_id="mock"))
|
||||
assert process.can_handle(Message(data=42, source_id="mock"))
|
||||
# Cannot handle float
|
||||
assert not process.can_handle(Message(data=3.14, source_id="mock"))
|
||||
|
||||
def test_executor_with_explicit_union_output_type(self):
|
||||
"""Test that explicit union output_type is normalized to a list."""
|
||||
|
||||
@executor(output=str | int | bool)
|
||||
async def process(message: Any, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
# Output types should be a list with all union members
|
||||
assert set(process.output_types) == {str, int, bool}
|
||||
|
||||
def test_executor_explicit_types_precedence_over_introspection(self):
|
||||
"""Test that explicit types always take precedence over introspected types."""
|
||||
|
||||
# Introspection would give: input=str, output=[int]
|
||||
# Explicit gives: input=bytes, output=[float]
|
||||
@executor(input=bytes, output=float)
|
||||
async def process(message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
# Should use explicit input type (bytes), not introspected (str)
|
||||
assert bytes in process._handlers
|
||||
assert str not in process._handlers
|
||||
|
||||
# Should use explicit output type (float), not introspected (int)
|
||||
assert float in process.output_types
|
||||
assert int not in process.output_types
|
||||
|
||||
def test_executor_fallback_to_introspection_when_no_explicit_types(self):
|
||||
"""Test that introspection is used when no explicit types are provided."""
|
||||
|
||||
@executor
|
||||
async def process(message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
# Should use introspected types
|
||||
assert str in process._handlers
|
||||
assert int in process.output_types
|
||||
|
||||
def test_executor_partial_explicit_types(self):
|
||||
"""Test that partial explicit types work (only input_type or only output_type)."""
|
||||
|
||||
# Only explicit input_type, introspect output_type
|
||||
@executor(input=bytes)
|
||||
async def process_input(message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
assert bytes in process_input._handlers # Explicit
|
||||
assert int in process_input.output_types # Introspected
|
||||
|
||||
# Only explicit output_type, introspect input_type
|
||||
@executor(output=float)
|
||||
async def process_output(message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
assert str in process_output._handlers # Introspected
|
||||
assert float in process_output.output_types # Explicit
|
||||
assert int not in process_output.output_types # Not introspected when explicit provided
|
||||
|
||||
def test_executor_explicit_input_type_allows_no_message_annotation(self):
|
||||
"""Test that explicit input_type allows function without message type annotation."""
|
||||
|
||||
@executor(input=str)
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Should work with explicit input_type
|
||||
assert str in process._handlers
|
||||
assert process.can_handle(Message(data="hello", source_id="mock"))
|
||||
|
||||
def test_executor_explicit_types_with_id(self):
|
||||
"""Test that explicit types work together with id parameter."""
|
||||
|
||||
@executor(id="custom_id", input=bytes, output=int)
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
assert process.id == "custom_id"
|
||||
assert bytes in process._handlers
|
||||
assert int in process.output_types
|
||||
|
||||
def test_executor_explicit_types_with_single_param_function(self):
|
||||
"""Test that explicit input_type works with single-parameter functions."""
|
||||
|
||||
@executor(input=str)
|
||||
async def process(message): # type: ignore[no-untyped-def]
|
||||
return message.upper()
|
||||
|
||||
# Should work with explicit input_type
|
||||
assert str in process._handlers
|
||||
assert process.can_handle(Message(data="hello", source_id="mock"))
|
||||
assert not process.can_handle(Message(data=42, source_id="mock"))
|
||||
|
||||
def test_executor_explicit_types_with_sync_function(self):
|
||||
"""Test that explicit types work with synchronous functions."""
|
||||
|
||||
@executor(input=int, output=str)
|
||||
def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
assert int in process._handlers
|
||||
assert str in process.output_types
|
||||
|
||||
def test_function_executor_constructor_with_explicit_types(self):
|
||||
"""Test FunctionExecutor constructor with explicit input_type and output_type."""
|
||||
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
func_exec = FunctionExecutor(process, id="test", input=dict, output=list)
|
||||
|
||||
assert dict in func_exec._handlers
|
||||
spec = func_exec._handler_specs[0]
|
||||
assert spec["message_type"] is dict
|
||||
assert spec["output_types"] == [list]
|
||||
|
||||
def test_executor_explicit_union_types_via_typing_union(self):
|
||||
"""Test that Union[] syntax also works for explicit types."""
|
||||
from typing import Union
|
||||
|
||||
@executor(input=Union[str, int], output=Union[bool, float])
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Can handle both str and int
|
||||
assert process.can_handle(Message(data="hello", source_id="mock"))
|
||||
assert process.can_handle(Message(data=42, source_id="mock"))
|
||||
|
||||
# Output types should include both
|
||||
assert set(process.output_types) == {bool, float}
|
||||
|
||||
def test_executor_with_string_forward_reference_input_type(self):
|
||||
"""Test that string forward references work for input_type."""
|
||||
|
||||
@executor(input="FuncExecForwardRefMessage")
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Should resolve the string to the actual type
|
||||
assert FuncExecForwardRefMessage in process._handlers
|
||||
assert process.can_handle(Message(data=FuncExecForwardRefMessage("hello"), source_id="mock"))
|
||||
|
||||
def test_executor_with_string_forward_reference_union(self):
|
||||
"""Test that string forward references work with union types."""
|
||||
|
||||
@executor(input="FuncExecForwardRefTypeA | FuncExecForwardRefTypeB")
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Should handle both types
|
||||
assert process.can_handle(Message(data=FuncExecForwardRefTypeA("hello"), source_id="mock"))
|
||||
assert process.can_handle(Message(data=FuncExecForwardRefTypeB(42), source_id="mock"))
|
||||
|
||||
def test_executor_with_string_forward_reference_output_type(self):
|
||||
"""Test that string forward references work for output_type."""
|
||||
|
||||
@executor(input=str, output="FuncExecForwardRefResponse")
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Should resolve the string output type
|
||||
assert FuncExecForwardRefResponse in process.output_types
|
||||
|
||||
def test_executor_with_explicit_workflow_output_type(self):
|
||||
"""Test that explicit workflow_output_type takes precedence over introspection."""
|
||||
|
||||
@executor(workflow_output=bool)
|
||||
async def process(message: str, ctx: WorkflowContext[int]) -> None:
|
||||
pass
|
||||
|
||||
# Handler spec should have bool as workflow_output_type (explicit)
|
||||
spec = process._handler_specs[0]
|
||||
assert spec["workflow_output_types"] == [bool]
|
||||
|
||||
# Executor workflow_output_types property should reflect explicit type
|
||||
assert bool in process.workflow_output_types
|
||||
# output_types should still come from introspection (int from WorkflowContext[int])
|
||||
assert int in process.output_types
|
||||
|
||||
def test_executor_with_explicit_workflow_output_type_precedence(self):
|
||||
"""Test that explicit workflow_output_type overrides introspected WorkflowContext second param."""
|
||||
|
||||
@executor(workflow_output=str)
|
||||
async def process(message: int, ctx: WorkflowContext[int, bool]) -> None:
|
||||
pass
|
||||
|
||||
# workflow_output_types should be str (explicit), not bool (introspected from ctx)
|
||||
assert str in process.workflow_output_types
|
||||
assert bool not in process.workflow_output_types
|
||||
|
||||
def test_executor_with_all_explicit_types(self):
|
||||
"""Test that all three explicit type parameters work together."""
|
||||
from typing import Any
|
||||
|
||||
@executor(input=str, output=int, workflow_output=bool)
|
||||
async def process(message: Any, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
# Check input type
|
||||
assert str in process._handlers
|
||||
assert process.can_handle(Message(data="hello", source_id="mock"))
|
||||
|
||||
# Check output_type
|
||||
assert int in process.output_types
|
||||
|
||||
# Check workflow_output_type
|
||||
assert bool in process.workflow_output_types
|
||||
|
||||
def test_executor_with_union_workflow_output_type(self):
|
||||
"""Test that union types work for workflow_output_type."""
|
||||
|
||||
@executor(workflow_output=str | int)
|
||||
async def process(message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
# Should include both types from union
|
||||
assert str in process.workflow_output_types
|
||||
assert int in process.workflow_output_types
|
||||
|
||||
def test_executor_with_string_forward_reference_workflow_output_type(self):
|
||||
"""Test that string forward references work for workflow_output_type."""
|
||||
|
||||
@executor(input=str, workflow_output="FuncExecForwardRefResponse")
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Should resolve the string workflow_output_type
|
||||
assert FuncExecForwardRefResponse in process.workflow_output_types
|
||||
|
||||
def test_executor_with_string_forward_reference_union_workflow_output_type(self):
|
||||
"""Test that string forward reference union types work for workflow_output_type."""
|
||||
|
||||
@executor(input=str, workflow_output="FuncExecForwardRefTypeA | FuncExecForwardRefTypeB")
|
||||
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
|
||||
pass
|
||||
|
||||
# Should resolve both types from string union
|
||||
assert FuncExecForwardRefTypeA in process.workflow_output_types
|
||||
assert FuncExecForwardRefTypeB in process.workflow_output_types
|
||||
|
||||
def test_executor_fallback_to_introspection_for_workflow_output_type(self):
|
||||
"""Test that workflow_output_type falls back to introspection when not explicitly provided."""
|
||||
|
||||
@executor
|
||||
async def process(message: str, ctx: WorkflowContext[int, bool]) -> None:
|
||||
pass
|
||||
|
||||
# Should use introspected types from WorkflowContext[int, bool]
|
||||
assert int in process.output_types
|
||||
assert bool in process.workflow_output_types
|
||||
|
||||
def test_function_executor_constructor_with_workflow_output_type(self):
|
||||
"""Test FunctionExecutor constructor accepts workflow_output_type parameter."""
|
||||
|
||||
async def my_func(message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
exec_instance = FunctionExecutor(
|
||||
my_func,
|
||||
id="test_constructor",
|
||||
input=str,
|
||||
output=int,
|
||||
workflow_output=bool,
|
||||
)
|
||||
|
||||
assert str in exec_instance._handlers
|
||||
assert int in exec_instance.output_types
|
||||
assert bool in exec_instance.workflow_output_types
|
||||
|
||||
@@ -247,7 +247,6 @@ class TestRequestInfoMixin:
|
||||
assert "output_types" in spec
|
||||
assert "workflow_output_types" in spec
|
||||
assert "ctx_annotation" in spec
|
||||
assert spec["source"] == "class_method"
|
||||
|
||||
def test_multiple_discovery_calls_raise_error(self):
|
||||
"""Test that multiple calls to _discover_response_handlers raise an error for duplicates."""
|
||||
@@ -786,3 +785,170 @@ class TestRequestInfoMixin:
|
||||
# Should not support unregistered combinations
|
||||
assert child.is_request_supported(str, str) is False
|
||||
assert child.is_request_supported(int, str) is False
|
||||
|
||||
|
||||
class TestResponseHandlerExplicitTypes:
|
||||
"""Test cases for response_handler with explicit type parameters."""
|
||||
|
||||
def test_response_handler_with_explicit_types(self):
|
||||
"""Test response_handler with explicit request and response types."""
|
||||
|
||||
@response_handler(request=str, response=int)
|
||||
async def test_handler(self, original_request, response, ctx) -> None:
|
||||
pass
|
||||
|
||||
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
|
||||
assert spec["name"] == "test_handler"
|
||||
assert spec["request_type"] is str
|
||||
assert spec["response_type"] is int
|
||||
|
||||
def test_response_handler_with_explicit_output_types(self):
|
||||
"""Test response_handler with explicit output and workflow_output types."""
|
||||
|
||||
@response_handler(request=str, response=int, output=bool, workflow_output=float)
|
||||
async def test_handler(self, original_request, response, ctx) -> None:
|
||||
pass
|
||||
|
||||
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
|
||||
assert spec["request_type"] is str
|
||||
assert spec["response_type"] is int
|
||||
assert bool in spec["output_types"]
|
||||
assert float in spec["workflow_output_types"]
|
||||
|
||||
def test_response_handler_with_union_types(self):
|
||||
"""Test response_handler with union types."""
|
||||
|
||||
@response_handler(request=str | int, response=bool | float)
|
||||
async def test_handler(self, original_request, response, ctx) -> None:
|
||||
pass
|
||||
|
||||
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
|
||||
assert spec["request_type"] == str | int
|
||||
assert spec["response_type"] == bool | float
|
||||
|
||||
def test_response_handler_with_string_forward_references(self):
|
||||
"""Test response_handler with string forward references."""
|
||||
|
||||
@response_handler(request="str", response="int")
|
||||
async def test_handler(self, original_request, response, ctx) -> None:
|
||||
pass
|
||||
|
||||
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
|
||||
assert spec["request_type"] is str
|
||||
assert spec["response_type"] is int
|
||||
|
||||
def test_response_handler_explicit_missing_request_raises_error(self):
|
||||
"""Test that using explicit types without request raises an error."""
|
||||
with pytest.raises(ValueError, match="must specify 'request' type"):
|
||||
|
||||
@response_handler(response=int)
|
||||
async def test_handler(self, original_request, response, ctx) -> None:
|
||||
pass
|
||||
|
||||
def test_response_handler_explicit_missing_response_raises_error(self):
|
||||
"""Test that using explicit types without response raises an error."""
|
||||
with pytest.raises(ValueError, match="must specify 'response' type"):
|
||||
|
||||
@response_handler(request=str)
|
||||
async def test_handler(self, original_request, response, ctx) -> None:
|
||||
pass
|
||||
|
||||
def test_response_handler_explicit_only_output_raises_error(self):
|
||||
"""Test that using only output without request/response raises an error."""
|
||||
with pytest.raises(ValueError, match="must specify 'request' type"):
|
||||
|
||||
@response_handler(output=bool)
|
||||
async def test_handler(self, original_request, response, ctx) -> None:
|
||||
pass
|
||||
|
||||
def test_executor_with_explicit_response_handlers(self):
|
||||
"""Test an executor with explicit type response handlers."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler(request=str, response=int, output=bool)
|
||||
async def handle_explicit(self, original_request, response, ctx) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Should be request-response capable
|
||||
assert executor.is_request_response_capable is True
|
||||
|
||||
# Should have registered handler
|
||||
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 1
|
||||
assert (str, int) in response_handlers
|
||||
|
||||
# Check specs
|
||||
specs = executor._response_handler_specs # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(specs) == 1
|
||||
assert specs[0]["request_type"] is str
|
||||
assert specs[0]["response_type"] is int
|
||||
assert bool in specs[0]["output_types"]
|
||||
|
||||
def test_response_handler_explicit_callable(self):
|
||||
"""Test that explicit type response handlers can be called."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
self.handled_request = None
|
||||
self.handled_response = None
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
@response_handler(request=str, response=int)
|
||||
async def handle_response(self, original_request, response, ctx) -> None:
|
||||
self.handled_request = original_request
|
||||
self.handled_response = response
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Get the handler
|
||||
response_handler_func = executor._response_handlers[(str, int)] # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
# Call the handler
|
||||
asyncio.run(response_handler_func("test_request", 42, None)) # type: ignore[reportArgumentType]
|
||||
|
||||
assert executor.handled_request == "test_request"
|
||||
assert executor.handled_response == 42
|
||||
|
||||
def test_mixed_introspection_and_explicit_handlers(self):
|
||||
"""Test executor with both introspection and explicit type handlers."""
|
||||
|
||||
class TestExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="test_executor")
|
||||
|
||||
@handler
|
||||
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
|
||||
pass
|
||||
|
||||
# Introspection-based handler
|
||||
@response_handler
|
||||
async def handle_introspection(
|
||||
self, original_request: str, response: int, ctx: WorkflowContext[str]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
# Explicit type handler
|
||||
@response_handler(request=dict, response=bool)
|
||||
async def handle_explicit(self, original_request, response, ctx) -> None:
|
||||
pass
|
||||
|
||||
executor = TestExecutor()
|
||||
|
||||
# Should have both handlers
|
||||
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
|
||||
assert len(response_handlers) == 2
|
||||
assert (str, int) in response_handlers
|
||||
assert (dict, bool) in response_handlers
|
||||
|
||||
@@ -1,16 +1,153 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, TypeVar, Union
|
||||
from typing import Any, Generic, Optional, TypeVar, Union
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import RequestInfoEvent
|
||||
from agent_framework._workflows._typing_utils import (
|
||||
deserialize_type,
|
||||
is_instance_of,
|
||||
is_type_compatible,
|
||||
normalize_type_to_list,
|
||||
resolve_type_annotation,
|
||||
serialize_type,
|
||||
)
|
||||
|
||||
# region: normalize_type_to_list tests
|
||||
|
||||
|
||||
def test_normalize_type_to_list_single_type() -> None:
|
||||
"""Test normalize_type_to_list with single types."""
|
||||
assert normalize_type_to_list(str) == [str]
|
||||
assert normalize_type_to_list(int) == [int]
|
||||
assert normalize_type_to_list(float) == [float]
|
||||
assert normalize_type_to_list(bool) == [bool]
|
||||
assert normalize_type_to_list(list) == [list]
|
||||
assert normalize_type_to_list(dict) == [dict]
|
||||
|
||||
|
||||
def test_normalize_type_to_list_none() -> None:
|
||||
"""Test normalize_type_to_list with None returns empty list."""
|
||||
assert normalize_type_to_list(None) == []
|
||||
|
||||
|
||||
def test_normalize_type_to_list_union_pipe_syntax() -> None:
|
||||
"""Test normalize_type_to_list with union types using | syntax."""
|
||||
result = normalize_type_to_list(str | int)
|
||||
assert set(result) == {str, int}
|
||||
|
||||
result = normalize_type_to_list(str | int | bool)
|
||||
assert set(result) == {str, int, bool}
|
||||
|
||||
|
||||
def test_normalize_type_to_list_union_typing_syntax() -> None:
|
||||
"""Test normalize_type_to_list with Union[] from typing module."""
|
||||
result = normalize_type_to_list(Union[str, int])
|
||||
assert set(result) == {str, int}
|
||||
|
||||
result = normalize_type_to_list(Union[str, int, bool])
|
||||
assert set(result) == {str, int, bool}
|
||||
|
||||
|
||||
def test_normalize_type_to_list_optional() -> None:
|
||||
"""Test normalize_type_to_list with Optional types (Union[T, None])."""
|
||||
# Optional[str] is Union[str, None]
|
||||
result = normalize_type_to_list(Optional[str])
|
||||
assert str in result
|
||||
assert type(None) in result
|
||||
assert len(result) == 2
|
||||
|
||||
# str | None is equivalent
|
||||
result = normalize_type_to_list(str | None)
|
||||
assert str in result
|
||||
assert type(None) in result
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_normalize_type_to_list_custom_types() -> None:
|
||||
"""Test normalize_type_to_list with custom class types."""
|
||||
|
||||
@dataclass
|
||||
class CustomMessage:
|
||||
content: str
|
||||
|
||||
result = normalize_type_to_list(CustomMessage)
|
||||
assert result == [CustomMessage]
|
||||
|
||||
result = normalize_type_to_list(CustomMessage | str)
|
||||
assert set(result) == {CustomMessage, str}
|
||||
|
||||
|
||||
# endregion: normalize_type_to_list tests
|
||||
|
||||
|
||||
# region: resolve_type_annotation tests
|
||||
|
||||
|
||||
def test_resolve_type_annotation_none() -> None:
|
||||
"""Test resolve_type_annotation with None returns None."""
|
||||
assert resolve_type_annotation(None) is None
|
||||
|
||||
|
||||
def test_resolve_type_annotation_actual_types() -> None:
|
||||
"""Test resolve_type_annotation passes through actual types unchanged."""
|
||||
assert resolve_type_annotation(str) is str
|
||||
assert resolve_type_annotation(int) is int
|
||||
assert resolve_type_annotation(str | int) == str | int
|
||||
|
||||
|
||||
def test_resolve_type_annotation_string_builtin() -> None:
|
||||
"""Test resolve_type_annotation resolves string references to builtin types."""
|
||||
result = resolve_type_annotation("str", {"str": str})
|
||||
assert result is str
|
||||
|
||||
result = resolve_type_annotation("int", {"int": int})
|
||||
assert result is int
|
||||
|
||||
|
||||
def test_resolve_type_annotation_string_union() -> None:
|
||||
"""Test resolve_type_annotation resolves string union types."""
|
||||
result = resolve_type_annotation("str | int", {"str": str, "int": int})
|
||||
assert result == str | int
|
||||
|
||||
|
||||
def test_resolve_type_annotation_string_custom_type() -> None:
|
||||
"""Test resolve_type_annotation resolves string references to custom types."""
|
||||
|
||||
@dataclass
|
||||
class MyCustomType:
|
||||
value: int
|
||||
|
||||
result = resolve_type_annotation("MyCustomType", {"MyCustomType": MyCustomType})
|
||||
assert result is MyCustomType
|
||||
|
||||
result = resolve_type_annotation("MyCustomType | str", {"MyCustomType": MyCustomType, "str": str})
|
||||
assert set(result.__args__) == {MyCustomType, str} # type: ignore[union-attr]
|
||||
|
||||
|
||||
def test_resolve_type_annotation_string_typing_union() -> None:
|
||||
"""Test resolve_type_annotation resolves Union[] syntax in strings."""
|
||||
result = resolve_type_annotation("Union[str, int]", {"str": str, "int": int})
|
||||
assert set(result.__args__) == {str, int} # type: ignore[union-attr]
|
||||
|
||||
|
||||
def test_resolve_type_annotation_string_optional() -> None:
|
||||
"""Test resolve_type_annotation resolves Optional[] syntax in strings."""
|
||||
result = resolve_type_annotation("Optional[str]", {"str": str})
|
||||
assert str in result.__args__ # type: ignore[union-attr]
|
||||
assert type(None) in result.__args__ # type: ignore[union-attr]
|
||||
|
||||
|
||||
def test_resolve_type_annotation_unresolvable_raises() -> None:
|
||||
"""Test resolve_type_annotation raises NameError for unresolvable types."""
|
||||
with pytest.raises(NameError, match="Could not resolve type annotation"):
|
||||
resolve_type_annotation("NonExistentType", {})
|
||||
|
||||
|
||||
# endregion: resolve_type_annotation tests
|
||||
|
||||
|
||||
def test_basic_types() -> None:
|
||||
"""Test basic built-in types."""
|
||||
|
||||
@@ -34,6 +34,18 @@ What this example shows
|
||||
Simple steps can use this form; a terminal step can yield output
|
||||
using ctx.yield_output() to provide workflow results.
|
||||
|
||||
- Explicit type parameters with @handler:
|
||||
Instead of relying on type introspection from function signatures, you can explicitly
|
||||
specify `input`, `output`, and/or `workflow_output` on the @handler decorator.
|
||||
This is "all or nothing": when ANY explicit parameter is provided, ALL types come
|
||||
from explicit parameters (introspection is disabled). The `input` parameter is
|
||||
required; `output` and `workflow_output` are optional.
|
||||
|
||||
Examples:
|
||||
@handler(input=str | int) # Accepts str or int, no outputs
|
||||
@handler(input=str, output=int) # Accepts str, outputs int
|
||||
@handler(input=str, output=int, workflow_output=bool) # All three specified
|
||||
|
||||
- Fluent WorkflowBuilder API:
|
||||
add_edge(A, B) to connect nodes, set_start_executor(A), then build() -> Workflow.
|
||||
|
||||
@@ -46,8 +58,8 @@ Prerequisites
|
||||
"""
|
||||
|
||||
|
||||
# Example 1: A custom Executor subclass
|
||||
# ------------------------------------
|
||||
# Example 1: A custom Executor subclass using introspection (traditional approach)
|
||||
# ---------------------------------------------------------------------------------
|
||||
#
|
||||
# Subclassing Executor lets you define a named node with lifecycle hooks if needed.
|
||||
# The work itself is implemented in an async method decorated with @handler.
|
||||
@@ -71,14 +83,15 @@ class UpperCase(Executor):
|
||||
Note: The WorkflowContext is parameterized with the type this handler will
|
||||
emit. Here WorkflowContext[str] means downstream nodes should expect str.
|
||||
"""
|
||||
|
||||
result = text.upper()
|
||||
|
||||
# Send the result to the next executor in the workflow.
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
# Example 2: A standalone function-based executor
|
||||
# -----------------------------------------------
|
||||
# Example 2: A standalone function-based executor using introspection
|
||||
# --------------------------------------------------------------------
|
||||
#
|
||||
# For simple steps you can skip subclassing and define an async function with the
|
||||
# same signature pattern (typed input + WorkflowContext[T_Out, T_W_Out]) and decorate it with
|
||||
@@ -102,30 +115,95 @@ async def reverse_text(text: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(result)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Build and run a simple 2-step workflow using the fluent builder API."""
|
||||
# Example 3: Using explicit type parameters on @handler
|
||||
# -----------------------------------------------------
|
||||
#
|
||||
# Instead of relying on type introspection, you can explicitly specify input,
|
||||
# output, and/or workflow_output on the @handler decorator. This is "all or nothing":
|
||||
# when ANY explicit parameter is provided, ALL types come from explicit parameters
|
||||
# (introspection is completely disabled). The input parameter is required.
|
||||
#
|
||||
# This is useful when:
|
||||
# - You want to accept multiple types (union types) without complex type annotations
|
||||
# - The function signature uses Any or a base type for flexibility
|
||||
# - You want to decouple the runtime type routing from the static type annotations
|
||||
|
||||
|
||||
class ExclamationAdder(Executor):
|
||||
"""An executor that adds exclamation marks, demonstrating explicit @handler types.
|
||||
|
||||
This example shows how to use explicit input and output parameters
|
||||
on the @handler decorator instead of relying on introspection from the function
|
||||
signature. This approach is especially useful for union types.
|
||||
"""
|
||||
|
||||
def __init__(self, id: str):
|
||||
super().__init__(id=id)
|
||||
|
||||
@handler(input=str, output=str)
|
||||
async def add_exclamation(self, message: str, ctx: WorkflowContext) -> None:
|
||||
"""Add exclamation marks to the input.
|
||||
|
||||
Note: The input=str and output=str are explicitly specified on @handler,
|
||||
so the framework uses those instead of introspecting the function signature.
|
||||
The WorkflowContext here has no type parameters because the explicit types
|
||||
on @handler take precedence.
|
||||
"""
|
||||
result = f"{message}!!!"
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Build and run workflows using the fluent builder API."""
|
||||
|
||||
# Workflow 1: Using introspection-based type detection
|
||||
# -----------------------------------------------------
|
||||
upper_case = UpperCase(id="upper_case_executor")
|
||||
|
||||
# Build the workflow using a fluent pattern:
|
||||
# 1) add_edge(from_node, to_node) defines a directed edge upper_case -> reverse_text
|
||||
# 2) set_start_executor(node) declares the entry point
|
||||
# 3) build() finalizes and returns an immutable Workflow object
|
||||
workflow = WorkflowBuilder().add_edge(upper_case, reverse_text).set_start_executor(upper_case).build()
|
||||
workflow1 = WorkflowBuilder().add_edge(upper_case, reverse_text).set_start_executor(upper_case).build()
|
||||
|
||||
# Run the workflow by sending the initial message to the start node.
|
||||
# The run(...) call returns an event collection; its get_outputs() method
|
||||
# retrieves the outputs yielded by any terminal nodes.
|
||||
events = await workflow.run("hello world")
|
||||
print(events.get_outputs())
|
||||
# Summarize the final run state (e.g., IDLE)
|
||||
print("Final state:", events.get_final_state())
|
||||
print("Workflow 1 (introspection-based types):")
|
||||
events1 = await workflow1.run("hello world")
|
||||
print(events1.get_outputs())
|
||||
print("Final state:", events1.get_final_state())
|
||||
|
||||
# Workflow 2: Using explicit type parameters on @handler
|
||||
# -------------------------------------------------------
|
||||
exclamation_adder = ExclamationAdder(id="exclamation_adder")
|
||||
|
||||
# This workflow demonstrates the explicit input/output feature:
|
||||
# exclamation_adder uses @handler(input=str, output=str) to
|
||||
# explicitly declare types instead of relying on introspection.
|
||||
workflow2 = (
|
||||
WorkflowBuilder()
|
||||
.add_edge(upper_case, exclamation_adder)
|
||||
.add_edge(exclamation_adder, reverse_text)
|
||||
.set_start_executor(upper_case)
|
||||
.build()
|
||||
)
|
||||
|
||||
print("\nWorkflow 2 (explicit @handler types):")
|
||||
events2 = await workflow2.run("hello world")
|
||||
print(events2.get_outputs())
|
||||
print("Final state:", events2.get_final_state())
|
||||
|
||||
"""
|
||||
Sample Output:
|
||||
|
||||
Workflow 1 (introspection-based types):
|
||||
['DLROW OLLEH']
|
||||
Final state: WorkflowRunState.IDLE
|
||||
|
||||
Workflow 2 (explicit @handler types):
|
||||
['!!!DLROW OLLEH']
|
||||
Final state: WorkflowRunState.IDLE
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+27
-23
@@ -11,8 +11,8 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
Content,
|
||||
FileCheckpointStorage,
|
||||
HandoffAgentUserRequest,
|
||||
HandoffBuilder,
|
||||
HandoffUserInputRequest,
|
||||
RequestInfoEvent,
|
||||
Workflow,
|
||||
WorkflowOutputEvent,
|
||||
@@ -102,7 +102,7 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> tuple[Workflow
|
||||
name="checkpoint_handoff_demo",
|
||||
participants=[triage, refund, order],
|
||||
)
|
||||
.set_coordinator("triage_agent")
|
||||
.with_start_agent(triage)
|
||||
.with_checkpointing(checkpoint_storage)
|
||||
.with_termination_condition(
|
||||
# Terminate after 5 user messages for this demo
|
||||
@@ -114,25 +114,27 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> tuple[Workflow
|
||||
return workflow, triage, refund, order
|
||||
|
||||
|
||||
def _print_handoff_request(request: HandoffUserInputRequest, request_id: str) -> None:
|
||||
def _print_handoff_agent_user_request(response: AgentResponse) -> None:
|
||||
"""Display the agent's response messages when requesting user input."""
|
||||
if not response.messages:
|
||||
print("(No agent messages)")
|
||||
return
|
||||
|
||||
print("\n[Agent is requesting your input...]")
|
||||
for message in response.messages:
|
||||
if not message.text:
|
||||
continue
|
||||
speaker = message.author_name or message.role.value
|
||||
print(f" {speaker}: {message.text}")
|
||||
|
||||
|
||||
def _print_handoff_request(request: HandoffAgentUserRequest, request_id: str) -> None:
|
||||
"""Log pending handoff request details for debugging."""
|
||||
print(f"\n{'=' * 60}")
|
||||
print("WORKFLOW PAUSED - User input needed")
|
||||
print(f"Request ID: {request_id}")
|
||||
print(f"Awaiting agent: {request.awaiting_agent_id}")
|
||||
print(f"Prompt: {request.prompt}")
|
||||
|
||||
# Note: After checkpoint restore, conversation may be empty because it's not serialized
|
||||
# to prevent duplication (the conversation is preserved in the coordinator's state).
|
||||
# See issue #2667.
|
||||
if request.conversation:
|
||||
print("\nConversation so far:")
|
||||
for msg in request.conversation[-3:]:
|
||||
author = msg.author_name or msg.role.value
|
||||
snippet = msg.text[:120] + "..." if len(msg.text) > 120 else msg.text
|
||||
print(f" {author}: {snippet}")
|
||||
else:
|
||||
print("\n(Conversation restored from checkpoint - context preserved in workflow state)")
|
||||
_print_handoff_agent_user_request(request.agent_response)
|
||||
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
@@ -157,7 +159,7 @@ def _build_responses_for_requests(
|
||||
"""Create response payloads for each pending request."""
|
||||
responses: dict[str, object] = {}
|
||||
for request in pending_requests:
|
||||
if isinstance(request.data, HandoffUserInputRequest):
|
||||
if isinstance(request.data, HandoffAgentUserRequest):
|
||||
if user_response is None:
|
||||
raise ValueError("User response is required for HandoffUserInputRequest")
|
||||
responses[request.request_id] = user_response
|
||||
@@ -199,7 +201,7 @@ async def run_until_user_input_needed(
|
||||
|
||||
elif isinstance(event, RequestInfoEvent):
|
||||
pending_requests.append(event)
|
||||
if isinstance(event.data, HandoffUserInputRequest):
|
||||
if isinstance(event.data, HandoffAgentUserRequest):
|
||||
_print_handoff_request(event.data, event.request_id)
|
||||
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
|
||||
_print_function_approval_request(event.data, event.request_id)
|
||||
@@ -256,7 +258,7 @@ async def resume_with_responses(
|
||||
async for event in workflow.run_stream(checkpoint_id=latest_checkpoint.checkpoint_id): # type: ignore[attr-defined]
|
||||
if isinstance(event, RequestInfoEvent):
|
||||
restored_requests.append(event)
|
||||
if isinstance(event.data, HandoffUserInputRequest):
|
||||
if isinstance(event.data, HandoffAgentUserRequest):
|
||||
_print_handoff_request(event.data, event.request_id)
|
||||
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
|
||||
_print_function_approval_request(event.data, event.request_id)
|
||||
@@ -289,7 +291,7 @@ async def resume_with_responses(
|
||||
|
||||
elif isinstance(event, RequestInfoEvent):
|
||||
new_pending_requests.append(event)
|
||||
if isinstance(event.data, HandoffUserInputRequest):
|
||||
if isinstance(event.data, HandoffAgentUserRequest):
|
||||
_print_handoff_request(event.data, event.request_id)
|
||||
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
|
||||
_print_function_approval_request(event.data, event.request_id)
|
||||
@@ -302,7 +304,7 @@ async def main() -> None:
|
||||
Demonstrate the checkpoint-based pause/resume pattern for handoff workflows.
|
||||
|
||||
This sample shows:
|
||||
1. Starting a workflow and getting a HandoffUserInputRequest
|
||||
1. Starting a workflow and getting a HandoffAgentUserRequest
|
||||
2. Pausing (checkpoint is saved automatically)
|
||||
3. Resuming from checkpoint with a user response or tool approval (two-step pattern)
|
||||
4. Continuing the conversation until completion
|
||||
@@ -361,8 +363,10 @@ async def main() -> None:
|
||||
print("\n>>> Simulating process restart...\n")
|
||||
workflow_step, _, _, _ = create_workflow(checkpoint_storage=storage)
|
||||
|
||||
needs_user_input = any(isinstance(req.data, HandoffUserInputRequest) for req in pending_requests)
|
||||
needs_tool_approval = any(isinstance(req.data, Content) and req.data.type == "function_approval_request" for req in pending_requests)
|
||||
needs_user_input = any(isinstance(req.data, HandoffAgentUserRequest) for req in pending_requests)
|
||||
needs_tool_approval = any(
|
||||
isinstance(req.data, Content) and req.data.type == "function_approval_request" for req in pending_requests
|
||||
)
|
||||
|
||||
user_response = None
|
||||
if needs_user_input:
|
||||
|
||||
Reference in New Issue
Block a user