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
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user