[BREAKING] Python: Replace RequestInfoExecutor with request_info API and @response_handler (#1466)

* Prototype: Add request_info API and @response_handler

* Add original_request as a parameter to the response handler

* Prototype: request interception in sub workflows

* Prototype: request interception in sub workflows 2

* WIP: Make checkpointing work

* checkpointing with sub workflow

* Fix function executor

* Allow sub-workflow to output directly

* Remove ReqeustInfoExecutor and related classes; Debugging checkpoint_with_human_in_the_loop

* Fix Handoff and sample

* fix pending requests in checkpoint

* Fix unit tests

* Fix formatting

* Resolve comments

* Address comment

* Add checkpoint tests

* Add tests

* misc

* fix mypy

* fix mypy

* Use request type as part of the key

* Log warning if there is not response handler for a request

* Update Internal edge group comments

* REcord message type in executor processing span

* Update sample

* Improve tests
This commit is contained in:
Tao Chen
2025-10-29 16:31:23 -07:00
committed by GitHub
Unverified
parent f6eadd412e
commit 943d92674e
54 changed files with 7532 additions and 6684 deletions
@@ -20,6 +20,7 @@ def test_workflow_checkpoint_default_values():
assert checkpoint.timestamp != ""
assert checkpoint.messages == {}
assert checkpoint.shared_state == {}
assert checkpoint.pending_request_info_events == {}
assert checkpoint.iteration_count == 0
assert checkpoint.metadata == {}
assert checkpoint.version == "1.0"
@@ -32,6 +33,7 @@ def test_workflow_checkpoint_custom_values():
workflow_id="test-workflow-456",
timestamp=custom_timestamp,
messages={"executor1": [{"data": "test"}]},
pending_request_info_events={"req123": {"data": "test"}},
shared_state={"key": "value"},
iteration_count=5,
metadata={"test": True},
@@ -43,6 +45,7 @@ def test_workflow_checkpoint_custom_values():
assert checkpoint.timestamp == custom_timestamp
assert checkpoint.messages == {"executor1": [{"data": "test"}]}
assert checkpoint.shared_state == {"key": "value"}
assert checkpoint.pending_request_info_events == {"req123": {"data": "test"}}
assert checkpoint.iteration_count == 5
assert checkpoint.metadata == {"test": True}
assert checkpoint.version == "2.0"
@@ -50,7 +53,11 @@ def test_workflow_checkpoint_custom_values():
async def test_memory_checkpoint_storage_save_and_load():
storage = InMemoryCheckpointStorage()
checkpoint = WorkflowCheckpoint(workflow_id="test-workflow", messages={"executor1": [{"data": "hello"}]})
checkpoint = WorkflowCheckpoint(
workflow_id="test-workflow",
messages={"executor1": [{"data": "hello"}]},
pending_request_info_events={"req123": {"data": "test"}},
)
# Save checkpoint
saved_id = await storage.save_checkpoint(checkpoint)
@@ -62,6 +69,7 @@ async def test_memory_checkpoint_storage_save_and_load():
assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id
assert loaded_checkpoint.workflow_id == checkpoint.workflow_id
assert loaded_checkpoint.messages == checkpoint.messages
assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events
async def test_memory_checkpoint_storage_load_nonexistent():
@@ -152,6 +160,7 @@ async def test_file_checkpoint_storage_save_and_load():
workflow_id="test-workflow",
messages={"executor1": [{"data": "hello", "source_id": "test", "target_id": None}]},
shared_state={"key": "value"},
pending_request_info_events={"req123": {"data": "test"}},
)
# Save checkpoint
@@ -169,6 +178,7 @@ async def test_file_checkpoint_storage_save_and_load():
assert loaded_checkpoint.workflow_id == checkpoint.workflow_id
assert loaded_checkpoint.messages == checkpoint.messages
assert loaded_checkpoint.shared_state == checkpoint.shared_state
assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events
async def test_file_checkpoint_storage_load_nonexistent():
@@ -284,6 +294,7 @@ async def test_file_checkpoint_storage_json_serialization():
workflow_id="complex-workflow",
messages={"executor1": [{"data": {"nested": {"value": 42}}, "source_id": "test", "target_id": None}]},
shared_state={"list": [1, 2, 3], "dict": {"a": "b", "c": {"d": "e"}}, "bool": True, "null": None},
pending_request_info_events={"req123": {"data": "test"}},
)
# Save and load
@@ -303,6 +314,7 @@ async def test_file_checkpoint_storage_json_serialization():
assert data["shared_state"]["list"] == [1, 2, 3]
assert data["shared_state"]["bool"] is True
assert data["shared_state"]["null"] is None
assert data["pending_request_info_events"]["req123"]["data"] == "test"
def test_checkpoint_storage_protocol_compliance():
@@ -3,7 +3,6 @@
from dataclasses import dataclass # noqa: I001
from typing import Any, cast
from agent_framework._workflows._request_info_executor import RequestInfoMessage, RequestResponse
from agent_framework._workflows._checkpoint_encoding import (
decode_checkpoint_value,
encode_checkpoint_value,
@@ -11,30 +10,45 @@ from agent_framework._workflows._checkpoint_encoding import (
from agent_framework._workflows._typing_utils import is_instance_of
@dataclass(kw_only=True)
class SampleRequest(RequestInfoMessage):
@dataclass
class SampleRequest:
"""Sample request message for testing checkpoint encoding/decoding."""
request_id: str
prompt: str
@dataclass
class SampleResponse:
"""Sample response message for testing checkpoint encoding/decoding."""
data: str
original_request: SampleRequest
request_id: str
def test_decode_dataclass_with_nested_request() -> None:
original = RequestResponse[SampleRequest, str](
"""Test that dataclass with nested dataclass fields can be encoded and decoded correctly."""
original = SampleResponse(
data="approve",
original_request=SampleRequest(request_id="abc", prompt="prompt"),
request_id="abc",
)
encoded = encode_checkpoint_value(original)
decoded = cast(RequestResponse[SampleRequest, str], decode_checkpoint_value(encoded))
decoded = cast(SampleResponse, decode_checkpoint_value(encoded))
assert isinstance(decoded, RequestResponse)
assert isinstance(decoded, SampleResponse)
assert decoded.data == "approve"
assert decoded.request_id == "abc"
assert isinstance(decoded.original_request, SampleRequest)
assert decoded.original_request.prompt == "prompt"
assert decoded.original_request.request_id == "abc"
def test_is_instance_of_coerces_request_response_original_request_dict() -> None:
response = RequestResponse[SampleRequest, str](
def test_is_instance_of_coerces_nested_dataclass_dict() -> None:
"""Test that is_instance_of can handle nested structures with dict conversion."""
response = SampleResponse(
data="approve",
original_request=SampleRequest(request_id="req-1", prompt="prompt"),
request_id="req-1",
@@ -49,5 +63,66 @@ def test_is_instance_of_coerces_request_response_original_request_dict() -> None
},
)
assert is_instance_of(response, RequestResponse[SampleRequest, str])
assert is_instance_of(response, SampleResponse)
assert isinstance(response.original_request, dict)
# Verify the dict contains expected values
dict_request = cast(dict[str, Any], response.original_request)
assert dict_request["request_id"] == "req-1"
assert dict_request["prompt"] == "prompt"
def test_encode_decode_simple_dataclass() -> None:
"""Test encoding and decoding of a simple dataclass."""
original = SampleRequest(request_id="test-123", prompt="test prompt")
encoded = encode_checkpoint_value(original)
decoded = cast(SampleRequest, decode_checkpoint_value(encoded))
assert isinstance(decoded, SampleRequest)
assert decoded.request_id == "test-123"
assert decoded.prompt == "test prompt"
def test_encode_decode_nested_structures() -> None:
"""Test encoding and decoding of complex nested structures."""
nested_data = {
"requests": [
SampleRequest(request_id="req-1", prompt="first prompt"),
SampleRequest(request_id="req-2", prompt="second prompt"),
],
"responses": {
"req-1": SampleResponse(
data="first response",
original_request=SampleRequest(request_id="req-1", prompt="first prompt"),
request_id="req-1",
),
},
}
encoded = encode_checkpoint_value(nested_data)
decoded = decode_checkpoint_value(encoded)
assert isinstance(decoded, dict)
assert "requests" in decoded
assert "responses" in decoded
# Check the requests list
requests = cast(list[Any], decoded["requests"])
assert isinstance(requests, list)
assert len(requests) == 2
assert all(isinstance(req, SampleRequest) for req in requests)
first_request = cast(SampleRequest, requests[0])
second_request = cast(SampleRequest, requests[1])
assert first_request.request_id == "req-1"
assert second_request.request_id == "req-2"
# Check the responses dict
responses = cast(dict[str, Any], decoded["responses"])
assert isinstance(responses, dict)
assert "req-1" in responses
response = cast(SampleResponse, responses["req-1"])
assert isinstance(response, SampleResponse)
assert response.data == "first response"
assert isinstance(response.original_request, SampleRequest)
assert response.original_request.request_id == "req-1"
@@ -2,7 +2,7 @@
import pytest
from agent_framework import Executor, WorkflowContext, handler
from agent_framework import Executor, Message, WorkflowContext, handler
def test_executor_without_id():
@@ -64,9 +64,9 @@ def test_executor_with_valid_handlers():
executor = MockExecutorWithValidHandlers(id="test")
assert executor.id is not None
assert len(executor._handlers) == 2 # type: ignore
assert executor.can_handle("text") is True
assert executor.can_handle(42) is True
assert executor.can_handle(3.14) is False
assert executor.can_handle(Message(data="text", source_id="mock")) is True
assert executor.can_handle(Message(data=42, source_id="mock")) is True
assert executor.can_handle(Message(data=3.14, source_id="mock")) is False
def test_executor_handlers_with_output_types():
@@ -7,6 +7,7 @@ from typing_extensions import Never
from agent_framework import (
FunctionExecutor,
Message,
WorkflowBuilder,
WorkflowContext,
executor,
@@ -230,9 +231,9 @@ class TestFunctionExecutor:
async def string_processor(text: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(text)
assert string_processor.can_handle("hello")
assert not string_processor.can_handle(123)
assert not string_processor.can_handle([])
assert string_processor.can_handle(Message(data="hello", source_id="Mock"))
assert not string_processor.can_handle(Message(data=123, source_id="Mock"))
assert not string_processor.can_handle(Message(data=[], source_id="Mock"))
def test_duplicate_handler_registration(self):
"""Test that registering duplicate handlers raises an error."""
@@ -309,9 +310,9 @@ class TestFunctionExecutor:
async def int_processor(value: int):
return value * 2
assert int_processor.can_handle(42)
assert not int_processor.can_handle("hello")
assert not int_processor.can_handle([])
assert int_processor.can_handle(Message(data=42, source_id="mock"))
assert not int_processor.can_handle(Message(data="hello", source_id="mock"))
assert not int_processor.can_handle(Message(data=[], source_id="mock"))
async def test_single_parameter_execution(self):
"""Test that single-parameter functions can be executed properly."""
@@ -325,7 +326,7 @@ class TestFunctionExecutor:
WorkflowBuilder().set_start_executor(double_value).build()
# For testing purposes, we can check that the handler is registered correctly
assert double_value.can_handle(5)
assert double_value.can_handle(Message(data=5, source_id="mock"))
assert int in double_value._handlers
def test_sync_function_basic(self):
@@ -369,9 +370,9 @@ class TestFunctionExecutor:
def string_handler(text: str):
return text.strip()
assert string_handler.can_handle("hello")
assert not string_handler.can_handle(123)
assert not string_handler.can_handle([])
assert string_handler.can_handle(Message(data="hello", source_id="mock"))
assert not string_handler.can_handle(Message(data=123, source_id="mock"))
assert not string_handler.can_handle(Message(data=[], source_id="mock"))
def test_sync_function_validation(self):
"""Test validation for synchronous functions."""
@@ -413,8 +414,8 @@ class TestFunctionExecutor:
assert isinstance(async_func, FunctionExecutor)
# Both should handle strings
assert sync_func.can_handle("test")
assert async_func.can_handle("test")
assert sync_func.can_handle(Message(data="test", source_id="mock"))
assert async_func.can_handle(Message(data="test", source_id="mock"))
# Both should be different instances
assert sync_func is not async_func
@@ -443,8 +444,8 @@ class TestFunctionExecutor:
assert async_spec["workflow_output_types"] == [str] # Second parameter is str
# Verify the executors can handle their input types
assert to_upper_sync.can_handle("hello")
assert reverse_async.can_handle("HELLO")
assert to_upper_sync.can_handle(Message(data="hello", source_id="mock"))
assert reverse_async.can_handle(Message(data="HELLO", source_id="mock"))
# For integration testing, we mainly verify that the handlers are properly registered
# and the functions are wrapped correctly
@@ -312,7 +312,6 @@ async def test_magentic_checkpoint_resume_round_trip():
async for ev in wf.run_stream(task_text):
if isinstance(ev, RequestInfoEvent) and ev.request_type is MagenticPlanReviewRequest:
req_event = ev
break
assert req_event is not None
checkpoints = await storage.list_checkpoints()
@@ -334,10 +333,16 @@ async def test_magentic_checkpoint_resume_round_trip():
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
completed: WorkflowOutputEvent | None = None
req_event = None
async for event in wf_resume.run_stream_from_checkpoint(
resume_checkpoint.checkpoint_id,
responses={req_event.request_id: reply},
):
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
req_event = event
assert req_event is not None
responses = {req_event.request_id: reply}
async for event in wf_resume.send_responses_streaming(responses=responses):
if isinstance(event, WorkflowOutputEvent):
completed = event
assert completed is not None
@@ -666,7 +671,6 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
async for event in workflow.run_stream("task"):
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
req_event = event
break
assert req_event is not None
@@ -685,7 +689,6 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
with pytest.raises(ValueError, match="Workflow graph has changed"):
async for _ in renamed_workflow.run_stream_from_checkpoint(
target_checkpoint.checkpoint_id, # type: ignore[reportUnknownMemberType]
responses={req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)},
):
pass
@@ -0,0 +1,413 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from agent_framework import (
FileCheckpointStorage,
RequestInfoEvent,
WorkflowBuilder,
WorkflowContext,
WorkflowRunState,
WorkflowStatusEvent,
handler,
response_handler,
)
from agent_framework._workflows._executor import Executor
from agent_framework._workflows._request_info_mixin import RequestInfoMixin
@dataclass
class UserApprovalRequest:
"""A request for user approval with context."""
prompt: str
context: str
request_id: str = ""
def __post_init__(self):
if not self.request_id:
import uuid
self.request_id = str(uuid.uuid4())
@dataclass
class CalculationRequest:
"""A request for a complex calculation."""
operation: str
operands: list[float]
request_id: str = ""
def __post_init__(self):
if not self.request_id:
import uuid
self.request_id = str(uuid.uuid4())
class ApprovalRequiredExecutor(Executor, RequestInfoMixin):
"""Executor that requires approval before proceeding."""
def __init__(self, id: str):
super().__init__(id=id)
self.approval_received = False
self.final_result = None
@handler
async def start_process(self, message: str, ctx: WorkflowContext) -> None:
"""Start a process that requires approval."""
# Request approval from external system
approval_request = UserApprovalRequest(
prompt=f"Please approve the operation: {message}",
context="This is a critical operation that requires human approval.",
)
await ctx.request_info(approval_request, UserApprovalRequest, bool)
@response_handler
async def handle_approval_response(
self, original_request: UserApprovalRequest, approved: bool, ctx: WorkflowContext[str]
) -> None:
"""Handle the approval response."""
self.approval_received = True
if approved:
self.final_result = f"Operation approved: {original_request.prompt}"
await ctx.send_message(f"APPROVED: {original_request.context}")
else:
self.final_result = "Operation denied by user"
await ctx.send_message("DENIED: Operation was not approved")
class CalculationExecutor(Executor, RequestInfoMixin):
"""Executor that delegates complex calculations to external services."""
def __init__(self, id: str):
super().__init__(id=id)
self.calculations_performed: list[tuple[str, list[float], float]] = []
@handler
async def process_calculation(self, message: str, ctx: WorkflowContext[str]) -> None:
"""Process a calculation request."""
# Parse the message to extract operation
parts = message.split()
if len(parts) >= 3:
operation = parts[0]
try:
operands = [float(x) for x in parts[1:]]
calc_request = CalculationRequest(operation=operation, operands=operands)
await ctx.request_info(calc_request, CalculationRequest, float)
except ValueError:
await ctx.send_message("Invalid calculation format")
else:
await ctx.send_message("Insufficient parameters for calculation")
@response_handler
async def handle_calculation_response(
self, original_request: CalculationRequest, result: float, ctx: WorkflowContext[str]
) -> None:
"""Handle the calculation response."""
self.calculations_performed.append((original_request.operation, original_request.operands, result))
operands_str = ", ".join(map(str, original_request.operands))
await ctx.send_message(f"Calculation complete: {original_request.operation}({operands_str}) = {result}")
class MultiRequestExecutor(Executor, RequestInfoMixin):
"""Executor that makes multiple requests and waits for all responses."""
def __init__(self, id: str):
super().__init__(id=id)
self.responses_received: list[tuple[str, bool | float]] = []
@handler
async def start_multi_request(self, message: str, ctx: WorkflowContext) -> None:
"""Start multiple requests simultaneously."""
# Request approval
approval_request = UserApprovalRequest(
prompt="Approve batch operation", context="Multiple operations will be performed"
)
await ctx.request_info(approval_request, UserApprovalRequest, bool)
# Request calculation
calc_request = CalculationRequest(operation="multiply", operands=[10.0, 5.0])
await ctx.request_info(calc_request, CalculationRequest, float)
@response_handler
async def handle_approval_response(
self, original_request: UserApprovalRequest, approved: bool, ctx: WorkflowContext[str]
) -> None:
"""Handle approval response."""
self.responses_received.append(("approval", approved))
await self._check_completion(ctx)
@response_handler
async def handle_calculation_response(
self, original_request: CalculationRequest, result: float, ctx: WorkflowContext[str]
) -> None:
"""Handle calculation response."""
self.responses_received.append(("calculation", result))
await self._check_completion(ctx)
async def _check_completion(self, ctx: WorkflowContext[str]) -> None:
"""Check if all responses are received and send final result."""
if len(self.responses_received) == 2:
approval_result = next((r[1] for r in self.responses_received if r[0] == "approval"), None)
calc_result = next((r[1] for r in self.responses_received if r[0] == "calculation"), None)
if approval_result and calc_result is not None:
await ctx.send_message(f"All operations complete. Calculation result: {calc_result}")
else:
await ctx.send_message("Operations completed with mixed results")
class OutputCollector(Executor):
"""Simple executor that collects outputs for testing."""
def __init__(self, id: str):
super().__init__(id=id)
self.collected_outputs: list[str] = []
@handler
async def collect_output(self, message: str, ctx: WorkflowContext) -> None:
"""Collect output messages."""
self.collected_outputs.append(message)
class TestRequestInfoAndResponse:
"""Test cases for end-to-end request info and response handling at the workflow level."""
async def test_approval_workflow(self):
"""Test end-to-end workflow with approval request."""
executor = ApprovalRequiredExecutor(id="approval_executor")
workflow = WorkflowBuilder().set_start_executor(executor).build()
# First run the workflow until it emits a request
request_info_event: RequestInfoEvent | None = None
async for event in workflow.run_stream("test operation"):
if isinstance(event, RequestInfoEvent):
request_info_event = event
assert request_info_event is not None
assert isinstance(request_info_event.data, UserApprovalRequest)
assert request_info_event.data.prompt == "Please approve the operation: test operation"
# Send response and continue workflow
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
assert executor.approval_received is True
assert executor.final_result == "Operation approved: Please approve the operation: test operation"
async def test_calculation_workflow(self):
"""Test end-to-end workflow with calculation request."""
executor = CalculationExecutor(id="calc_executor")
workflow = WorkflowBuilder().set_start_executor(executor).build()
# First run the workflow until it emits a calculation request
request_info_event: RequestInfoEvent | None = None
async for event in workflow.run_stream("multiply 15.5 2.0"):
if isinstance(event, RequestInfoEvent):
request_info_event = event
assert request_info_event is not None
assert isinstance(request_info_event.data, CalculationRequest)
assert request_info_event.data.operation == "multiply"
assert request_info_event.data.operands == [15.5, 2.0]
# Send response with calculated result
calculated_result = 31.0
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: calculated_result}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
assert len(executor.calculations_performed) == 1
assert executor.calculations_performed[0] == ("multiply", [15.5, 2.0], calculated_result)
async def test_multiple_requests_workflow(self):
"""Test workflow with multiple concurrent requests."""
executor = MultiRequestExecutor(id="multi_executor")
workflow = WorkflowBuilder().set_start_executor(executor).build()
# Collect all request events by running the full stream
request_events: list[RequestInfoEvent] = []
async for event in workflow.run_stream("start batch"):
if isinstance(event, RequestInfoEvent):
request_events.append(event)
assert len(request_events) == 2
# Find the approval and calculation requests
approval_event: RequestInfoEvent | None = next(
(e for e in request_events if isinstance(e.data, UserApprovalRequest)), None
)
calc_event: RequestInfoEvent | None = next(
(e for e in request_events if isinstance(e.data, CalculationRequest)), None
)
assert approval_event is not None
assert calc_event is not None
# Send responses for both requests
responses = {approval_event.request_id: True, calc_event.request_id: 50.0}
completed = False
async for event in workflow.send_responses_streaming(responses):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
assert len(executor.responses_received) == 2
async def test_denied_approval_workflow(self):
"""Test workflow when approval is denied."""
executor = ApprovalRequiredExecutor(id="approval_executor")
workflow = WorkflowBuilder().set_start_executor(executor).build()
# First run the workflow until it emits a request
request_info_event: RequestInfoEvent | None = None
async for event in workflow.run_stream("sensitive operation"):
if isinstance(event, RequestInfoEvent):
request_info_event = event
assert request_info_event is not None
# Deny the request
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: False}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
assert executor.approval_received is True
assert executor.final_result == "Operation denied by user"
async def test_workflow_state_with_pending_requests(self):
"""Test workflow state when waiting for responses."""
executor = ApprovalRequiredExecutor(id="approval_executor")
workflow = WorkflowBuilder().set_start_executor(executor).build()
# Run workflow until idle with pending requests
request_info_event: RequestInfoEvent | None = None
idle_with_pending = False
async for event in workflow.run_stream("test operation"):
if isinstance(event, RequestInfoEvent):
request_info_event = event
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
idle_with_pending = True
assert request_info_event is not None
assert idle_with_pending
# Continue with response
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
async def test_invalid_calculation_input(self):
"""Test workflow handling of invalid calculation input."""
executor = CalculationExecutor(id="calc_executor")
workflow = WorkflowBuilder().set_start_executor(executor).build()
# Send invalid input (no numbers)
completed = False
async for event in workflow.run_stream("invalid input"):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
# Should not have any calculations performed due to invalid input
assert len(executor.calculations_performed) == 0
async def test_checkpoint_with_pending_request_info_events(self):
"""Test that request info events are properly serialized in checkpoints and can be restored."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir:
# Use file-based storage to test full serialization
storage = FileCheckpointStorage(temp_dir)
# Create workflow with checkpointing enabled
executor = ApprovalRequiredExecutor(id="approval_executor")
workflow = WorkflowBuilder().set_start_executor(executor).with_checkpointing(storage).build()
# Step 1: Run workflow to completion to ensure checkpoints are created
request_info_event: RequestInfoEvent | None = None
async for event in workflow.run_stream("checkpoint test operation"):
if isinstance(event, RequestInfoEvent):
request_info_event = event
# Verify request was emitted
assert request_info_event is not None
assert isinstance(request_info_event.data, UserApprovalRequest)
assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation"
assert request_info_event.source_executor_id == "approval_executor"
# Step 2: List checkpoints to find the one with our pending request
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) > 0, "No checkpoints were created during workflow execution"
# Find the checkpoint with our pending request
checkpoint_with_request = None
for checkpoint in checkpoints:
if request_info_event.request_id in checkpoint.pending_request_info_events:
checkpoint_with_request = checkpoint
break
assert checkpoint_with_request is not None, "No checkpoint found with pending request info event"
# Step 3: Verify the pending request info event was properly serialized
serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id]
assert "data" in serialized_event
assert "request_id" in serialized_event
assert "source_executor_id" in serialized_event
assert "request_type" in serialized_event
assert serialized_event["request_id"] == request_info_event.request_id
assert serialized_event["source_executor_id"] == "approval_executor"
# Step 4: Create a fresh workflow and restore from checkpoint
new_executor = ApprovalRequiredExecutor(id="approval_executor")
restored_workflow = WorkflowBuilder().set_start_executor(new_executor).with_checkpointing(storage).build()
# Step 5: Resume from checkpoint and verify the request can be continued
completed = False
restored_request_event: RequestInfoEvent | None = None
async for event in restored_workflow.run_stream_from_checkpoint(checkpoint_with_request.checkpoint_id):
# Should re-emit the pending request info event
if isinstance(event, RequestInfoEvent) and event.request_id == request_info_event.request_id:
restored_request_event = event
elif (
isinstance(event, WorkflowStatusEvent)
and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
):
completed = True
assert completed, "Workflow should reach idle with pending requests state after restoration"
assert restored_request_event is not None, "Restored request info event should be emitted"
# Verify the restored event matches the original
assert restored_request_event.source_executor_id == request_info_event.source_executor_id
assert isinstance(restored_request_event.data, UserApprovalRequest)
assert restored_request_event.data.prompt == request_info_event.data.prompt
assert restored_request_event.data.context == request_info_event.data.context
# Step 6: Provide response to the restored request and complete the workflow
final_completed = False
async for event in restored_workflow.send_responses_streaming({
request_info_event.request_id: True # Approve the request
}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
final_completed = True
assert final_completed, "Workflow should complete after providing response to restored request"
# Step 7: Verify the executor state was properly restored and response was processed
assert new_executor.approval_received is True
expected_result = "Operation approved: Please approve the operation: checkpoint test operation"
assert new_executor.final_result == expected_result
@@ -0,0 +1,169 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
import pytest
from agent_framework import InMemoryCheckpointStorage, InProcRunnerContext
from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value
from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
from agent_framework._workflows._events import RequestInfoEvent
from agent_framework._workflows._shared_state import SharedState
@dataclass
class MockRequest: ...
@dataclass(kw_only=True)
class SimpleApproval:
prompt: str = ""
draft: str = ""
iteration: int = 0
@dataclass(slots=True)
class SlottedApproval:
note: str = ""
@dataclass
class TimedApproval:
issued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
async def test_rehydrate_request_info_event() -> None:
"""Rehydration should succeed for valid request info events."""
request_info_event = RequestInfoEvent(
request_id="request-123",
source_executor_id="review_gateway",
request_type=MockRequest,
request_data=MockRequest(),
response_type=bool,
)
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
assert checkpoint.pending_request_info_events
assert "request-123" in checkpoint.pending_request_info_events
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
# Rehydrate the context
await runner_context.apply_checkpoint(checkpoint)
pending_requests = await runner_context.get_pending_request_info_events()
assert "request-123" in pending_requests
rehydrated_event = pending_requests["request-123"]
assert rehydrated_event.request_id == "request-123"
assert rehydrated_event.source_executor_id == "review_gateway"
assert rehydrated_event.request_type is MockRequest
assert rehydrated_event.response_type is bool
assert isinstance(rehydrated_event.data, MockRequest)
async def test_rehydrate_fails_when_request_type_missing() -> None:
"""Rehydration should fail is the request type is missing or fails to import."""
request_info_event = RequestInfoEvent(
request_id="request-123",
source_executor_id="review_gateway",
request_type=MockRequest,
request_data=MockRequest(),
response_type=bool,
)
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
assert checkpoint.pending_request_info_events
assert "request-123" in checkpoint.pending_request_info_events
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
# Modify the checkpoint to simulate missing request type
checkpoint.pending_request_info_events["request-123"]["request_type"] = "nonexistent.module:MissingRequest"
# Rehydrate the context
with pytest.raises(ImportError):
await runner_context.apply_checkpoint(checkpoint)
async def test_pending_requests_in_summary() -> None:
"""Test that pending requests are correctly summarized in the checkpoint summary."""
request_info_event = RequestInfoEvent(
request_id="request-123",
source_executor_id="review_gateway",
request_type=MockRequest,
request_data=MockRequest(),
response_type=bool,
)
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
summary = get_checkpoint_summary(checkpoint)
assert summary.checkpoint_id == checkpoint_id
assert summary.status == "awaiting request response"
assert len(summary.pending_request_info_events) == 1
pending_event = summary.pending_request_info_events[0]
assert isinstance(pending_event, RequestInfoEvent)
assert pending_event.request_id == "request-123"
assert pending_event.source_executor_id == "review_gateway"
assert pending_event.request_type is MockRequest
assert pending_event.response_type is bool
assert isinstance(pending_event.data, MockRequest)
async def test_request_info_event_serializes_non_json_payloads() -> None:
req_1 = RequestInfoEvent(
request_id="req-1",
source_executor_id="source",
request_type=TimedApproval,
request_data=TimedApproval(issued_at=datetime(2024, 5, 4, 12, 30, 45)),
response_type=bool,
)
req_2 = RequestInfoEvent(
request_id="req-2",
source_executor_id="source",
request_type=SlottedApproval,
request_data=SlottedApproval(note="slot-based"),
response_type=bool,
)
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(req_1)
await runner_context.add_request_info_event(req_2)
checkpoint_id = await runner_context.create_checkpoint(SharedState(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
# Should be JSON serializable despite datetime/slots
serialized = json.dumps(encode_checkpoint_value(checkpoint))
deserialized = json.loads(serialized)
assert "value" in deserialized
deserialized = deserialized["value"]
assert "pending_request_info_events" in deserialized
pending_request_info_events = deserialized["pending_request_info_events"]
assert "req-1" in pending_request_info_events
assert isinstance(pending_request_info_events["req-1"]["data"]["value"]["issued_at"], str)
assert "req-2" in pending_request_info_events
assert pending_request_info_events["req-2"]["data"]["value"]["note"] == "slot-based"
@@ -1,285 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from agent_framework._workflows._checkpoint import WorkflowCheckpoint
from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value
from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
from agent_framework._workflows._const import EXECUTOR_STATE_KEY
from agent_framework._workflows._events import RequestInfoEvent, WorkflowEvent
from agent_framework._workflows._request_info_executor import (
PendingRequestDetails,
PendingRequestSnapshot,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
)
from agent_framework._workflows._runner_context import Message
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._workflow_context import WorkflowContext
PENDING_STATE_KEY = RequestInfoExecutor._PENDING_SHARED_STATE_KEY # pyright: ignore[reportPrivateUsage]
class _StubRunnerContext:
"""Minimal runner context stub for exercising WorkflowContext helpers."""
async def send_message(self, message: Message) -> None: # pragma: no cover - unused in tests
return None
async def drain_messages(self) -> dict[str, list[Message]]: # pragma: no cover - unused
return {}
async def has_messages(self) -> bool: # pragma: no cover - unused
return False
async def add_event(self, event: WorkflowEvent) -> None: # pragma: no cover - unused
return None
async def drain_events(self) -> list[WorkflowEvent]: # pragma: no cover - unused
return []
async def has_events(self) -> bool: # pragma: no cover - unused
return False
async def next_event(self) -> WorkflowEvent: # pragma: no cover - unused
raise RuntimeError("Not implemented in stub context")
def has_checkpointing(self) -> bool: # pragma: no cover - unused
return False
def set_workflow_id(self, workflow_id: str) -> None: # pragma: no cover - unused
pass
def reset_for_new_run(self) -> None: # pragma: no cover - unused
pass
async def create_checkpoint(
self,
shared_state: SharedState,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str: # pragma: no cover - unused
raise RuntimeError("Checkpointing not supported in stub context")
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: # pragma: no cover - unused
return None
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None: # pragma: no cover - unused
pass
def set_streaming(self, streaming: bool) -> None: # pragma: no cover - unused
pass
def is_streaming(self) -> bool: # pragma: no cover - unused
return False
@dataclass(kw_only=True)
class SimpleApproval(RequestInfoMessage):
prompt: str = ""
draft: str = ""
iteration: int = 0
@dataclass(slots=True)
class SlottedApproval(RequestInfoMessage):
note: str = ""
@dataclass
class TimedApproval(RequestInfoMessage):
issued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
async def test_rehydrate_falls_back_when_request_type_missing() -> None:
"""Rehydration should succeed even if the original request type cannot be imported.
This simulates resuming a workflow where the HumanApprovalRequest class is unavailable
in the current process (e.g., defined in __main__ during the original run).
"""
request_id = "request-123"
snapshot = PendingRequestSnapshot(
request_id=request_id,
source_executor_id="review_gateway",
request_type="nonexistent.module:MissingRequest",
request_as_json_safe_dict={
"request_id": request_id,
},
)
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), _StubRunnerContext())
await ctx.set_executor_state({PENDING_STATE_KEY: {request_id: snapshot}})
executor = RequestInfoExecutor(id="request_info")
event = await executor._rehydrate_request_event(request_id, ctx) # pyright: ignore[reportPrivateUsage]
assert event is not None
assert event.request_id == request_id
assert isinstance(event.data, RequestInfoMessage)
async def test_has_pending_request_detects_snapshot() -> None:
request_id = "request-123"
snapshot = PendingRequestSnapshot(
request_id=request_id,
source_executor_id="review_gateway",
request_type="nonexistent.module:MissingRequest",
request_as_json_safe_dict={
"request_id": request_id,
},
)
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), _StubRunnerContext())
await ctx.set_executor_state({PENDING_STATE_KEY: {request_id: snapshot}})
executor = RequestInfoExecutor(id="request_info")
assert await executor.has_pending_request(request_id, ctx)
async def test_has_pending_request_false_when_snapshot_absent() -> None:
ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), _StubRunnerContext())
await ctx.set_executor_state({PENDING_STATE_KEY: {}})
executor = RequestInfoExecutor(id="request_info")
assert not await executor.has_pending_request("missing", ctx)
def test_pending_requests_from_checkpoint_and_summary() -> None:
request = SimpleApproval(prompt="Review draft", draft="Draft text", iteration=3)
request.request_id = "req-42"
response = RequestResponse[SimpleApproval, str](
data="approve",
original_request=request,
request_id=request.request_id,
)
encoded_response = encode_checkpoint_value(response)
checkpoint = WorkflowCheckpoint(
checkpoint_id="cp-1",
workflow_id="wf",
messages={
"request_info": [
{
"data": encoded_response,
"source_id": "request_info",
"target_id": "review_gateway",
}
]
},
shared_state={
PENDING_STATE_KEY: {
request.request_id: {
"request_id": request.request_id,
"prompt": request.prompt,
"draft": request.draft,
"iteration": request.iteration,
"source_executor_id": "review_gateway",
}
}
},
iteration_count=1,
)
summary = get_checkpoint_summary(checkpoint)
assert summary.checkpoint_id == "cp-1"
assert summary.status == "awaiting request response"
assert summary.pending_requests[0].request_id == "req-42"
pending = summary.pending_requests
assert len(pending) == 1
entry = pending[0]
assert isinstance(entry, PendingRequestDetails)
assert entry.request_id == "req-42"
assert entry.prompt == "Review draft"
assert entry.draft == "Draft text"
assert entry.iteration == 3
assert entry.original_request is not None
def test_snapshot_state_serializes_non_json_payloads() -> None:
executor = RequestInfoExecutor(id="request_info")
timed = TimedApproval(issued_at=datetime(2024, 5, 4, 12, 30, 45))
timed.request_id = "timed"
slotted = SlottedApproval(note="slot-based")
slotted.request_id = "slotted"
executor._request_events = { # pyright: ignore[reportPrivateUsage]
timed.request_id: RequestInfoEvent(
request_id=timed.request_id,
source_executor_id="source",
request_type=TimedApproval,
request_data=timed,
),
slotted.request_id: RequestInfoEvent(
request_id=slotted.request_id,
source_executor_id="source",
request_type=SlottedApproval,
request_data=slotted,
),
}
state = executor.snapshot_state()
# Should be JSON serializable despite datetime/slots
serialized = json.dumps(state)
assert "timed" in serialized
timed_payload = state["request_events"][timed.request_id]["request_data"]["value"]
assert isinstance(timed_payload["issued_at"], str)
def test_restore_state_falls_back_to_base_request_type() -> None:
executor = RequestInfoExecutor(id="request_info")
approval = SimpleApproval(prompt="Review", draft="Draft", iteration=1)
approval.request_id = "req"
executor._request_events = { # pyright: ignore[reportPrivateUsage]
approval.request_id: RequestInfoEvent(
request_id=approval.request_id,
source_executor_id="source",
request_type=SimpleApproval,
request_data=approval,
)
}
state = executor.snapshot_state()
state["request_events"][approval.request_id]["request_type"] = "missing.module:GhostRequest"
executor.restore_state(state)
restored = executor._request_events[approval.request_id] # pyright: ignore[reportPrivateUsage]
assert restored.request_type is RequestInfoMessage
assert isinstance(restored.data, RequestInfoMessage)
async def test_run_persists_pending_requests_in_runner_state() -> None:
shared_state = SharedState()
runner_ctx = _StubRunnerContext()
ctx: WorkflowContext[None] = WorkflowContext("request_info", ["source"], shared_state, runner_ctx)
executor = RequestInfoExecutor(id="request_info")
approval = SimpleApproval(prompt="Review", draft="Draft", iteration=1)
approval.request_id = "req-123"
await executor.execute(approval, ctx.source_executor_ids, shared_state, runner_ctx)
# Runner state should include both pending snapshot and serialized request events
assert await shared_state.has(EXECUTOR_STATE_KEY)
executor_state = await shared_state.get(EXECUTOR_STATE_KEY)
assert executor.id in executor_state
assert PENDING_STATE_KEY in executor_state[executor.id]
assert approval.request_id in executor_state[executor.id][PENDING_STATE_KEY]
response_ctx: WorkflowContext[None] = WorkflowContext("request_info", ["source"], shared_state, runner_ctx)
await executor.handle_response("approved", approval.request_id, response_ctx) # type: ignore
assert executor_state[executor.id][PENDING_STATE_KEY] == {}
@@ -0,0 +1,788 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import inspect
from typing import Any
import pytest
from agent_framework._workflows._executor import Executor, handler
from agent_framework._workflows._request_info_mixin import response_handler
from agent_framework._workflows._workflow_context import WorkflowContext
class TestRequestInfoMixin:
"""Test cases for RequestInfoMixin functionality."""
def test_request_info_mixin_initialization(self):
"""Test that RequestInfoMixin can be initialized."""
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test")
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
executor = TestExecutor()
# After calling _discover_response_handlers, it should have the attributes
assert hasattr(executor, "_response_handlers")
assert hasattr(executor, "_response_handler_specs")
assert hasattr(executor, "is_request_response_capable")
assert executor.is_request_response_capable is False
def test_response_handler_decorator_creates_metadata(self):
"""Test that the response_handler decorator creates proper metadata."""
@response_handler
async def test_handler(self: Any, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
"""Test handler docstring."""
pass
# Check that the decorator preserves function attributes
assert test_handler.__name__ == "test_handler"
assert test_handler.__doc__ == "Test handler docstring."
assert hasattr(test_handler, "_response_handler_spec")
# Check the spec attributes
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
assert spec["name"] == "test_handler"
assert spec["response_type"] is int
assert spec["request_type"] is str
def test_response_handler_with_workflow_context_types(self):
"""Test response handler with different WorkflowContext type parameters."""
@response_handler
async def handler_with_output_types(
self: Any, original_request: str, response: int, ctx: WorkflowContext[str, bool]
) -> None:
pass
spec = handler_with_output_types._response_handler_spec # type: ignore[reportAttributeAccessIssue]
assert "output_types" in spec
assert "workflow_output_types" in spec
def test_response_handler_preserves_signature(self):
"""Test that response_handler preserves the original function signature."""
async def original_handler(self: Any, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
decorated = response_handler(original_handler)
# Check that signature is preserved
original_sig = inspect.signature(original_handler)
decorated_sig = inspect.signature(decorated)
# Both should have the same parameter names and types
assert list(original_sig.parameters.keys()) == list(decorated_sig.parameters.keys())
def test_executor_with_response_handlers(self):
"""Test an executor with valid 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
async def handle_string_response(
self, original_request: str, response: int, ctx: WorkflowContext[str]
) -> None:
pass
@response_handler
async def handle_dict_response(
self, original_request: dict[str, Any], response: bool, ctx: WorkflowContext[bool]
) -> None:
pass
executor = TestExecutor()
# Should be request-response capable
assert executor.is_request_response_capable is True
# Should have registered handlers
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 2
assert (str, int) in response_handlers
assert (dict[str, Any], bool) in response_handlers
def test_executor_without_response_handlers(self):
"""Test an executor without response handlers."""
class PlainExecutor(Executor):
def __init__(self):
super().__init__(id="plain_executor")
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
executor = PlainExecutor()
# Should not be request-response capable
assert executor.is_request_response_capable is False
# Should have empty handlers
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 0
def test_duplicate_response_handlers_raise_error(self):
"""Test that duplicate response handlers for the same message type raise an error."""
class DuplicateExecutor(Executor):
def __init__(self):
super().__init__(id="duplicate_executor")
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler
async def handle_first(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
@response_handler
async def handle_second(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
with pytest.raises(
ValueError,
match="Duplicate response handler for request type <class 'str'> and response type <class 'int'>",
):
DuplicateExecutor()
def test_response_handler_function_callable(self):
"""Test that response handlers can actually 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
async def handle_response(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> 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]
# Create a mock context - we'll just use None since the handler doesn't use it
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_inheritance_with_response_handlers(self):
"""Test that response handlers work correctly with inheritance."""
class BaseExecutor(Executor):
def __init__(self):
super().__init__(id="base_executor")
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler
async def base_handler(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
class ChildExecutor(BaseExecutor):
def __init__(self):
super().__init__()
self.id = "child_executor"
@response_handler
async def child_handler(self, original_request: str, response: bool, ctx: WorkflowContext[str]) -> None:
pass
child = ChildExecutor()
# Should have both handlers
response_handlers = child._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 2
assert (str, int) in response_handlers
assert (str, bool) in response_handlers
assert child.is_request_response_capable is True
def test_response_handler_spec_attributes(self):
"""Test that response handler specs contain expected attributes."""
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
async def test_handler(self, original_request: str, response: int, ctx: WorkflowContext[str, bool]) -> None:
pass
executor = TestExecutor()
specs = executor._response_handler_specs # type: ignore[reportAttributeAccessIssue]
assert len(specs) == 1
spec = specs[0]
assert spec["name"] == "test_handler"
assert spec["request_type"] is str
assert spec["response_type"] is int
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."""
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
async def test_handler(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
executor = TestExecutor()
# First call should work fine
first_handlers = len(executor._response_handlers) # type: ignore[reportAttributeAccessIssue]
# Second call should raise an error due to duplicate registration
with pytest.raises(
ValueError,
match="Duplicate response handler for request type <class 'str'> and response type <class 'int'>",
):
executor._discover_response_handlers() # type: ignore[reportAttributeAccessIssue]
# Handlers count should remain the same
assert first_handlers == 1
def test_non_callable_attributes_ignored(self):
"""Test that non-callable attributes are ignored during discovery."""
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test_executor")
some_variable = "not_a_function"
another_attr = 42
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler
async def valid_handler(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
executor = TestExecutor()
# Should only have one handler despite other attributes
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 1
assert (str, int) in response_handlers
def test_same_request_type_different_response_types(self):
"""Test that handlers with same request type but different response types are distinct."""
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test_executor")
self.str_int_handler_called = False
self.str_bool_handler_called = False
self.str_dict_handler_called = False
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
self.str_int_handler_called = True
@response_handler
async def handle_str_bool(self, original_request: str, response: bool, ctx: WorkflowContext[str]) -> None:
self.str_bool_handler_called = True
@response_handler
async def handle_str_dict(
self, original_request: str, response: dict[str, Any], ctx: WorkflowContext[str]
) -> None:
self.str_dict_handler_called = True
executor = TestExecutor()
# Should have three distinct handlers
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 3
assert (str, int) in response_handlers
assert (str, bool) in response_handlers
assert (str, dict[str, Any]) in response_handlers
# Test that each handler can be found correctly
str_int_handler = executor._find_response_handler("test", 42) # pyright: ignore[reportPrivateUsage]
str_bool_handler = executor._find_response_handler("test", True) # pyright: ignore[reportPrivateUsage]
str_dict_handler = executor._find_response_handler("test", {"key": "value"}) # pyright: ignore[reportPrivateUsage]
assert str_int_handler is not None
assert str_bool_handler is not None
assert str_dict_handler is not None
# Test that handlers are called correctly
asyncio.run(str_int_handler(42, None)) # type: ignore[reportArgumentType]
asyncio.run(str_bool_handler(True, None)) # type: ignore[reportArgumentType]
asyncio.run(str_dict_handler({"key": "value"}, None)) # type: ignore[reportArgumentType]
assert executor.str_int_handler_called
assert executor.str_bool_handler_called
assert executor.str_dict_handler_called
def test_different_request_types_same_response_type(self):
"""Test that handlers with different request types but same response type are distinct."""
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test_executor")
self.str_int_handler_called = False
self.dict_int_handler_called = False
self.list_int_handler_called = False
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
self.str_int_handler_called = True
@response_handler
async def handle_dict_int(
self, original_request: dict[str, Any], response: int, ctx: WorkflowContext[str]
) -> None:
self.dict_int_handler_called = True
@response_handler
async def handle_list_int(
self, original_request: list[str], response: int, ctx: WorkflowContext[str]
) -> None:
self.list_int_handler_called = True
executor = TestExecutor()
# Should have three distinct handlers
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 3
assert (str, int) in response_handlers
assert (dict[str, Any], int) in response_handlers
assert (list[str], int) in response_handlers
# Test that each handler can be found correctly
str_int_handler = executor._find_response_handler("test", 42) # pyright: ignore[reportPrivateUsage]
dict_int_handler = executor._find_response_handler({"key": "value"}, 42) # pyright: ignore[reportPrivateUsage]
list_int_handler = executor._find_response_handler(["test"], 42) # pyright: ignore[reportPrivateUsage]
assert str_int_handler is not None
assert dict_int_handler is not None
assert list_int_handler is not None
# Test that handlers are called correctly
asyncio.run(str_int_handler(42, None)) # type: ignore[reportArgumentType]
asyncio.run(dict_int_handler(42, None)) # type: ignore[reportArgumentType]
asyncio.run(list_int_handler(42, None)) # type: ignore[reportArgumentType]
assert executor.str_int_handler_called
assert executor.dict_int_handler_called
assert executor.list_int_handler_called
def test_complex_type_combinations(self):
"""Test response handlers with complex type combinations."""
class CustomRequest:
pass
class CustomResponse:
pass
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test_executor")
self.custom_custom_called = False
self.custom_str_called = False
self.str_custom_called = False
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler
async def handle_custom_custom(
self, original_request: CustomRequest, response: CustomResponse, ctx: WorkflowContext[str]
) -> None:
self.custom_custom_called = True
@response_handler
async def handle_custom_str(
self, original_request: CustomRequest, response: str, ctx: WorkflowContext[str]
) -> None:
self.custom_str_called = True
@response_handler
async def handle_str_custom(
self, original_request: str, response: CustomResponse, ctx: WorkflowContext[str]
) -> None:
self.str_custom_called = True
executor = TestExecutor()
# Should have three distinct handlers
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 3
assert (CustomRequest, CustomResponse) in response_handlers
assert (CustomRequest, str) in response_handlers
assert (str, CustomResponse) in response_handlers
# Test that each handler can be found correctly
custom_request = CustomRequest()
custom_response = CustomResponse()
custom_custom_handler = executor._find_response_handler(custom_request, custom_response) # pyright: ignore[reportPrivateUsage]
custom_str_handler = executor._find_response_handler(custom_request, "test") # pyright: ignore[reportPrivateUsage]
str_custom_handler = executor._find_response_handler("test", custom_response) # pyright: ignore[reportPrivateUsage]
assert custom_custom_handler is not None
assert custom_str_handler is not None
assert str_custom_handler is not None
def test_handler_key_uniqueness(self):
"""Test that handler keys (request_type, response_type) are truly unique."""
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
async def handle1(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
@response_handler
async def handle2(self, original_request: int, response: str, ctx: WorkflowContext[str]) -> None:
pass
@response_handler
async def handle3(self, original_request: str, response: str, ctx: WorkflowContext[str]) -> None:
pass
@response_handler
async def handle4(self, original_request: int, response: int, ctx: WorkflowContext[str]) -> None:
pass
executor = TestExecutor()
# Should have four distinct handlers based on different combinations
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 4
# Verify all expected combinations exist
expected_keys = {
(str, int), # handle1
(int, str), # handle2
(str, str), # handle3
(int, int), # handle4
}
actual_keys = set(response_handlers.keys())
assert actual_keys == expected_keys
def test_no_false_matches_with_similar_types(self):
"""Test that handlers don't match with similar but different types."""
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
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
@response_handler
async def handle_list_str_float(
self, original_request: list[str], response: float, ctx: WorkflowContext[str]
) -> None:
pass
executor = TestExecutor()
# Test that wrong combinations don't match
assert executor._find_response_handler("test", 3.14) is None # pyright: ignore[reportPrivateUsage] # str request, float response - no handler
assert executor._find_response_handler(["test"], 42) is None # pyright: ignore[reportPrivateUsage] # list request, int response - no handler
assert executor._find_response_handler(42, "test") is None # pyright: ignore[reportPrivateUsage] # int request, str response - no handler
# Test that correct combinations do match
assert executor._find_response_handler("test", 42) is not None # pyright: ignore[reportPrivateUsage] # str request, int response - has handler
assert executor._find_response_handler(["test"], 3.14) is not None # pyright: ignore[reportPrivateUsage] # list request, float response - has handler
def test_is_request_supported_with_exact_matches(self):
"""Test is_request_supported with exact type matches."""
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
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
@response_handler
async def handle_dict_bool(
self, original_request: dict[str, Any], response: bool, ctx: WorkflowContext[str]
) -> None:
pass
executor = TestExecutor()
# Test exact matches
assert executor.is_request_supported(str, int) is True
assert executor.is_request_supported(str, bool) is True # bool and int are compatible
assert executor.is_request_supported(dict[str, Any], bool) is True
# Test non-matches
assert executor.is_request_supported(int, str) is False
assert executor.is_request_supported(list[str], int) is False
def test_is_request_supported_without_handlers(self):
"""Test is_request_supported when no handlers are registered."""
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test_executor")
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
executor = TestExecutor()
# Should return False for any type combination
assert executor.is_request_supported(str, int) is False
assert executor.is_request_supported(dict[str, Any], bool) is False
assert executor.is_request_supported(int, str) is False
def test_is_request_supported_before_discovery(self):
"""Test is_request_supported before response handlers are discovered."""
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test_executor", defer_discovery=True)
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
executor = TestExecutor()
# Don't call _discover_response_handlers()
# Should return False when _response_handlers attribute doesn't exist
assert executor.is_request_supported(str, int) is False
assert executor.is_request_supported(dict[str, Any], bool) is False
def test_is_request_supported_with_compatible_types(self):
"""Test is_request_supported with type-compatible scenarios."""
class BaseRequest:
pass
class DerivedRequest(BaseRequest):
pass
class BaseResponse:
pass
class DerivedResponse(BaseResponse):
pass
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
async def handle_base_base(
self, original_request: BaseRequest, response: BaseResponse, ctx: WorkflowContext[str]
) -> None:
pass
@response_handler
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
executor = TestExecutor()
# Test exact matches
assert executor.is_request_supported(BaseRequest, BaseResponse) is True
assert executor.is_request_supported(str, int) is True
# Test compatible derived types (depends on is_type_compatible implementation)
# These should return True if the type compatibility function supports inheritance
result_derived_request = executor.is_request_supported(DerivedRequest, BaseResponse)
result_derived_response = executor.is_request_supported(BaseRequest, DerivedResponse)
result_both_derived = executor.is_request_supported(DerivedRequest, DerivedResponse)
# The actual result depends on the is_type_compatible implementation
# We'll just assert that the method doesn't raise an exception
assert isinstance(result_derived_request, bool)
assert isinstance(result_derived_response, bool)
assert isinstance(result_both_derived, bool)
def test_is_request_supported_with_multiple_handlers(self):
"""Test is_request_supported when multiple handlers are registered."""
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
async def handle_str_int(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
@response_handler
async def handle_str_bool(self, original_request: str, response: bool, ctx: WorkflowContext[str]) -> None:
pass
@response_handler
async def handle_dict_str(
self, original_request: dict[str, Any], response: str, ctx: WorkflowContext[str]
) -> None:
pass
@response_handler
async def handle_list_float(
self, original_request: list[str], response: float, ctx: WorkflowContext[str]
) -> None:
pass
executor = TestExecutor()
# Test all registered combinations
assert executor.is_request_supported(str, int) is True
assert executor.is_request_supported(str, bool) is True
assert executor.is_request_supported(dict[str, Any], str) is True
assert executor.is_request_supported(list[str], float) is True
# Test combinations that don't exist
assert executor.is_request_supported(str, float) is False
assert executor.is_request_supported(int, str) is False
assert executor.is_request_supported(dict[str, Any], int) is False
assert executor.is_request_supported(list[str], bool) is False
def test_is_request_supported_with_complex_types(self):
"""Test is_request_supported with complex generic types."""
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
async def handle_dict_list(
self, original_request: dict[str, Any], response: list[int], ctx: WorkflowContext[str]
) -> None:
pass
@response_handler
async def handle_list_dict(
self, original_request: list[str], response: dict[str, bool], ctx: WorkflowContext[str]
) -> None:
pass
executor = TestExecutor()
# Test complex type matches
assert executor.is_request_supported(dict[str, Any], list[int]) is True
assert executor.is_request_supported(list[str], dict[str, bool]) is True
# Test non-matches with similar but different complex types
assert executor.is_request_supported(dict[str, Any], list[str]) is False
assert executor.is_request_supported(list[int], dict[str, bool]) is False
assert executor.is_request_supported(dict[int, Any], list[int]) is False
def test_is_request_supported_with_inheritance(self):
"""Test is_request_supported with inherited response handlers."""
class BaseExecutor(Executor):
def __init__(self):
super().__init__(id="base_executor")
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler
async def base_handler(self, original_request: str, response: int, ctx: WorkflowContext[str]) -> None:
pass
class ChildExecutor(BaseExecutor):
def __init__(self):
super().__init__()
self.id = "child_executor"
@response_handler
async def child_handler(self, original_request: str, response: bool, ctx: WorkflowContext[str]) -> None:
pass
child = ChildExecutor()
# Should support both inherited and child-defined handlers
assert child.is_request_supported(str, int) is True # From base class
assert child.is_request_supported(str, bool) is True # From child class
# Should not support unregistered combinations
assert child.is_request_supported(str, str) is False
assert child.is_request_supported(int, str) is False
@@ -6,12 +6,14 @@ from typing import Any
import pytest
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
from agent_framework._workflows._const import INTERNAL_SOURCE_ID
from agent_framework._workflows._edge import (
Case,
Default,
Edge,
FanInEdgeGroup,
FanOutEdgeGroup,
InternalEdgeGroup,
SingleEdgeGroup,
SwitchCaseEdgeGroup,
SwitchCaseEdgeGroupCase,
@@ -557,16 +559,32 @@ class TestSerializationWorkflowClasses:
# Verify edge groups contain edges
edge_groups = data["edge_groups"]
assert len(edge_groups) == 1, "Should have exactly one edge group"
edge_group = edge_groups[0]
assert "edges" in edge_group, "Edge group should contain 'edges' field"
assert len(edge_group["edges"]) == 1, "Should have exactly one edge"
edge = edge_group["edges"][0]
assert "source_id" in edge, "Edge should have source_id"
assert "target_id" in edge, "Edge should have target_id"
assert edge["source_id"] == "executor1", f"Expected source_id 'executor1', got {edge['source_id']}"
assert edge["target_id"] == "executor2", f"Expected target_id 'executor2', got {edge['target_id']}"
single_edge_groups = [SingleEdgeGroup.from_dict(eg) for eg in edge_groups if eg["type"] == "SingleEdgeGroup"]
internal_edge_groups = [
InternalEdgeGroup.from_dict(eg) for eg in edge_groups if eg["type"] == "InternalEdgeGroup"
]
assert len(single_edge_groups) == 1, "Should have exactly one SingleEdgeGroup for the added edge"
assert len(internal_edge_groups) == 2, (
"Should have exactly two (one per executor) InternalEdgeGroups for request/response handling"
)
for edge_group in single_edge_groups:
assert len(edge_group.edges) == 1, "Should have exactly one edge"
edge = edge_group.edges[0]
assert edge.source_id == "executor1", f"Expected source_id 'executor1', got {edge.source_id}"
assert edge.target_id == "executor2", f"Expected target_id 'executor2', got {edge.target_id}"
for edge_group in internal_edge_groups:
assert len(edge_group.edges) == 1, "Each InternalEdgeGroup should have exactly one edge"
edge = edge_group.edges[0]
assert edge.source_id == INTERNAL_SOURCE_ID(edge.target_id)
assert edge.target_id in [executor1.id, executor2.id]
# Test model_dump_json
json_str = workflow.to_json()
@@ -577,12 +595,21 @@ class TestSerializationWorkflowClasses:
# Verify edges are preserved in JSON serialization
json_edge_groups = parsed["edge_groups"]
assert len(json_edge_groups) == 1, "JSON should have exactly one edge group"
json_edge_group = json_edge_groups[0]
assert "edges" in json_edge_group, "JSON edge group should contain 'edges' field"
json_edge = json_edge_group["edges"][0]
assert json_edge["source_id"] == "executor1", "JSON should preserve edge source_id"
assert json_edge["target_id"] == "executor2", "JSON should preserve edge target_id"
assert len(json_edge_groups) == 1 + 2, "JSON should have exactly one SingleEdgeGroup and two InternalEdgeGroups"
for json_edge_group in json_edge_groups:
assert "edges" in json_edge_group, "JSON edge group should contain 'edges' field"
assert len(json_edge_group["edges"]) == 1, "Each JSON edge group should have exactly one edge"
if json_edge_group["type"] == "SingleEdgeGroup":
json_edge = json_edge_group["edges"][0]
assert json_edge["source_id"] == "executor1", "JSON should preserve edge source_id"
assert json_edge["target_id"] == "executor2", "JSON should preserve edge target_id"
elif json_edge_group["type"] == "InternalEdgeGroup":
json_edge = json_edge_group["edges"][0]
assert json_edge["source_id"] == INTERNAL_SOURCE_ID(json_edge["target_id"])
assert json_edge["target_id"] in [executor1.id, executor2.id]
else:
pytest.fail(f"Unexpected edge group type: {json_edge_group['type']}")
def test_workflow_serialization_excludes_non_serializable_fields(self) -> None:
"""Test that non-serializable fields are excluded from serialization."""
@@ -1,20 +1,20 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from typing import Any
from dataclasses import dataclass, field
from uuid import uuid4
from typing_extensions import Never
from agent_framework import (
Executor,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
SubWorkflowRequestMessage,
SubWorkflowResponseMessage,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
handler,
response_handler,
)
@@ -27,9 +27,10 @@ class EmailValidationRequest:
@dataclass
class DomainCheckRequest(RequestInfoMessage):
class DomainCheckRequest:
"""Request to check if a domain is approved."""
id: str = field(default_factory=lambda: str(uuid4()))
domain: str = ""
email: str = "" # Include original email for correlation
@@ -43,72 +44,93 @@ class ValidationResult:
reason: str
# Test helper functions
def create_email_validation_workflow() -> Workflow:
"""Create a standard email validation workflow."""
email_validator = EmailValidator()
email_request_info = RequestInfoExecutor(id="email_request_info")
return (
WorkflowBuilder()
.set_start_executor(email_validator)
.add_edge(email_validator, email_request_info)
.add_edge(email_request_info, email_validator)
.build()
)
class BasicParent(Executor):
"""Basic parent executor for simple sub-workflow tests."""
class Coordinator(Executor):
"""Coordinator executor in the parent workflow for simple sub-workflow tests."""
def __init__(self, cache: dict[str, bool] | None = None) -> None:
super().__init__(id="basic_parent")
self.result: ValidationResult | None = None
self.cache: dict[str, bool] = dict(cache) if cache is not None else {}
self._pending_sub_workflow_requests: dict[str, SubWorkflowRequestMessage] = {}
@handler
async def start(self, email: str, ctx: WorkflowContext[EmailValidationRequest]) -> None:
request = EmailValidationRequest(email=email)
await ctx.send_message(request, target_id="email_workflow")
await ctx.send_message(request)
@handler
async def handle_domain_request(
self,
request: DomainCheckRequest,
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, Any] | DomainCheckRequest],
sub_workflow_request: SubWorkflowRequestMessage,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
"""Handle requests from sub-workflows with optional caching."""
domain_request = request
if not isinstance(sub_workflow_request.source_event.data, DomainCheckRequest):
raise ValueError("Unexpected request type")
domain_request = sub_workflow_request.source_event.data
if domain_request.domain in self.cache:
# Return cached result
response = RequestResponse(
data=self.cache[domain_request.domain], original_request=request, request_id=request.request_id
)
await ctx.send_message(response, target_id=request.source_executor_id)
await ctx.send_message(sub_workflow_request.create_response(self.cache[domain_request.domain]))
else:
# Not in cache, forward to external
await ctx.send_message(request)
self._pending_sub_workflow_requests[domain_request.id] = sub_workflow_request
await ctx.request_info(domain_request, DomainCheckRequest, bool)
@response_handler
async def handle_domain_response(
self,
original_request: DomainCheckRequest,
is_approved: bool,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
"""Handle domain check response with correlation and send the response back to the sub-workflow."""
if original_request.id not in self._pending_sub_workflow_requests:
raise ValueError("No pending sub-workflow request for the given domain check response")
sub_workflow_request = self._pending_sub_workflow_requests.pop(original_request.id)
await ctx.send_message(sub_workflow_request.create_response(is_approved))
@handler
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
self.result = result
# Test executors
class EmailValidator(Executor):
class EmailFormatValidator(Executor):
"""Validates the format of an email address."""
def __init__(self):
super().__init__(id="email_format_validator")
@handler
async def validate(
self, request: EmailValidationRequest, ctx: WorkflowContext[DomainCheckRequest, ValidationResult]
) -> None:
"""Validate email format and extract domain."""
email = request.email
if "@" not in email:
result = ValidationResult(email=email, is_valid=False, reason="Invalid email format")
await ctx.yield_output(result)
return
domain = email.split("@")[1]
domain_check = DomainCheckRequest(domain=domain, email=email)
await ctx.send_message(domain_check)
class EmailDomainValidator(Executor):
"""Validates email addresses in a sub-workflow."""
def __init__(self):
super().__init__(id="email_validator")
super().__init__(id="email_domain_validator")
@handler
async def validate_request(
self, request: EmailValidationRequest, ctx: WorkflowContext[DomainCheckRequest, ValidationResult]
self, request: DomainCheckRequest, ctx: WorkflowContext[DomainCheckRequest, ValidationResult]
) -> None:
"""Validate an email address."""
# Extract domain and check if it's approved
domain = request.email.split("@")[1] if "@" in request.email else ""
domain = request.domain
if not domain:
result = ValidationResult(email=request.email, is_valid=False, reason="Invalid email format")
@@ -116,62 +138,37 @@ class EmailValidator(Executor):
return
# Request domain check from external source
domain_check = DomainCheckRequest(domain=domain, email=request.email)
await ctx.send_message(domain_check)
await ctx.request_info(request, DomainCheckRequest, bool)
@handler
@response_handler
async def handle_domain_response(
self, response: RequestResponse[DomainCheckRequest, bool], ctx: WorkflowContext[Never, ValidationResult]
self,
original_request: DomainCheckRequest,
is_approved: bool,
ctx: WorkflowContext[Never, ValidationResult],
) -> None:
"""Handle domain check response with correlation."""
# Use the original email from the correlated response
result = ValidationResult(
email=response.original_request.email,
is_valid=response.data or False,
reason="Domain approved" if response.data else "Domain not approved",
email=original_request.email,
is_valid=is_approved,
reason="Domain approved" if is_approved else "Domain not approved",
)
await ctx.yield_output(result)
class ParentOrchestrator(Executor):
"""Parent workflow orchestrator with domain knowledge."""
# Test helper functions
def create_email_validation_workflow() -> Workflow:
"""Create a standard email validation workflow."""
email_format_validator = EmailFormatValidator()
email_domain_validator = EmailDomainValidator()
def __init__(self, approved_domains: set[str] | None = None) -> None:
super().__init__(id="parent_orchestrator")
self.approved_domains: set[str] = (
set(approved_domains) if approved_domains is not None else {"example.com", "test.org"}
)
self.results: list[ValidationResult] = []
@handler
async def start(self, emails: list[str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
"""Start processing emails."""
for email in emails:
request = EmailValidationRequest(email=email)
await ctx.send_message(request, target_id="email_workflow")
@handler
async def handle_domain_request(
self,
request: DomainCheckRequest,
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, Any] | DomainCheckRequest],
) -> None:
"""Handle requests from sub-workflows."""
domain_request = request
# Check if we know this domain
if domain_request.domain in self.approved_domains:
# Send response back to sub-workflow
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
await ctx.send_message(response, target_id=request.source_executor_id)
else:
# We don't know this domain, forward to external
await ctx.send_message(request)
@handler
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
"""Collect validation results."""
self.results.append(result)
return (
WorkflowBuilder()
.set_start_executor(email_format_validator)
.add_edge(email_format_validator, email_domain_validator)
.build()
)
async def test_basic_sub_workflow() -> None:
@@ -180,17 +177,14 @@ async def test_basic_sub_workflow() -> None:
validation_workflow = create_email_validation_workflow()
# Create parent workflow without interception
parent = BasicParent()
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
main_request_info = RequestInfoExecutor(id="main_request_info")
parent = Coordinator()
workflow_executor = WorkflowExecutor(validation_workflow, "email_validation_workflow")
main_workflow = (
WorkflowBuilder()
.set_start_executor(parent)
.add_edge(parent, workflow_executor)
.add_edge(workflow_executor, parent)
.add_edge(workflow_executor, main_request_info)
.add_edge(main_request_info, workflow_executor) # CRITICAL: For RequestResponse routing
.build()
)
@@ -220,17 +214,14 @@ async def test_sub_workflow_with_interception():
validation_workflow = create_email_validation_workflow()
# Create parent workflow with interception cache
parent = BasicParent(cache={"example.com": True, "internal.org": True})
parent = Coordinator(cache={"example.com": True, "internal.org": True})
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
parent_request_info = RequestInfoExecutor(id="request_info")
main_workflow = (
WorkflowBuilder()
.set_start_executor(parent)
.add_edge(parent, workflow_executor)
.add_edge(workflow_executor, parent)
.add_edge(parent, parent_request_info) # For forwarded requests
.add_edge(parent_request_info, workflow_executor) # For RequestResponse routing
.build()
)
@@ -276,6 +267,7 @@ async def test_workflow_scoped_interception() -> None:
def __init__(self) -> None:
super().__init__(id="multi_parent")
self.results: dict[str, ValidationResult] = {}
self._pending_sub_workflow_requests: dict[str, SubWorkflowRequestMessage] = {}
@handler
async def start(self, data: dict[str, str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
@@ -286,30 +278,47 @@ async def test_workflow_scoped_interception() -> None:
@handler
async def handle_domain_request(
self,
request: DomainCheckRequest,
ctx: WorkflowContext[RequestResponse[DomainCheckRequest, Any] | DomainCheckRequest],
sub_workflow_request: SubWorkflowRequestMessage,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
domain_request = request
"""Handle requests from sub-workflows with optional caching."""
if not isinstance(sub_workflow_request.source_event.data, DomainCheckRequest):
raise ValueError("Unexpected request type")
if request.source_executor_id == "workflow_a":
domain_request = sub_workflow_request.source_event.data
if sub_workflow_request.executor_id == "workflow_a" and domain_request.domain == "strict.com":
# Strict rules for workflow A
if domain_request.domain == "strict.com":
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
await ctx.send_message(response, target_id=request.source_executor_id)
else:
# Forward to external
await ctx.send_message(request)
elif request.source_executor_id == "workflow_b":
await ctx.send_message(
sub_workflow_request.create_response(True), target_id=sub_workflow_request.executor_id
)
return
if sub_workflow_request.executor_id == "workflow_b" and domain_request.domain.endswith(".com"):
# Lenient rules for workflow B
if domain_request.domain.endswith(".com"):
response = RequestResponse(data=True, original_request=request, request_id=request.request_id)
await ctx.send_message(response, target_id=request.source_executor_id)
else:
# Forward to external
await ctx.send_message(request)
else:
# Unknown source, forward to external
await ctx.send_message(request)
await ctx.send_message(
sub_workflow_request.create_response(True), target_id=sub_workflow_request.executor_id
)
return
# Unknown source, forward to external
self._pending_sub_workflow_requests[domain_request.id] = sub_workflow_request
await ctx.request_info(domain_request, DomainCheckRequest, bool)
@response_handler
async def handle_domain_response(
self,
original_request: DomainCheckRequest,
is_approved: bool,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
"""Handle domain check response with correlation and send the response back to the sub-workflow."""
if original_request.id not in self._pending_sub_workflow_requests:
raise ValueError("No pending sub-workflow request for the given domain check response")
sub_workflow_request = self._pending_sub_workflow_requests.pop(original_request.id)
await ctx.send_message(
sub_workflow_request.create_response(is_approved), target_id=sub_workflow_request.executor_id
)
@handler
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None:
@@ -322,7 +331,6 @@ async def test_workflow_scoped_interception() -> None:
parent = MultiWorkflowParent()
executor_a = WorkflowExecutor(workflow_a, "workflow_a")
executor_b = WorkflowExecutor(workflow_b, "workflow_b")
parent_request_info = RequestInfoExecutor(id="request_info")
main_workflow = (
WorkflowBuilder()
@@ -331,9 +339,6 @@ async def test_workflow_scoped_interception() -> None:
.add_edge(parent, executor_b)
.add_edge(executor_a, parent)
.add_edge(executor_b, parent)
.add_edge(parent, parent_request_info)
.add_edge(parent_request_info, executor_a) # For RequestResponse routing
.add_edge(parent_request_info, executor_b) # For RequestResponse routing
.build()
)
@@ -359,6 +364,7 @@ async def test_concurrent_sub_workflow_execution() -> None:
def __init__(self) -> None:
super().__init__(id="concurrent_processor")
self.results: list[ValidationResult] = []
self._pending_sub_workflow_requests: dict[str, SubWorkflowRequestMessage] = {}
@handler
async def start(self, emails: list[str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
@@ -366,7 +372,35 @@ async def test_concurrent_sub_workflow_execution() -> None:
# Send all requests concurrently to the same workflow executor
for email in emails:
request = EmailValidationRequest(email=email)
await ctx.send_message(request, target_id="email_workflow")
await ctx.send_message(request)
@handler
async def handle_domain_request(
self,
sub_workflow_request: SubWorkflowRequestMessage,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
"""Handle requests from sub-workflows with optional caching."""
if not isinstance(sub_workflow_request.source_event.data, DomainCheckRequest):
raise ValueError("Unexpected request type")
domain_request = sub_workflow_request.source_event.data
self._pending_sub_workflow_requests[domain_request.id] = sub_workflow_request
await ctx.request_info(domain_request, DomainCheckRequest, bool)
@response_handler
async def handle_domain_response(
self,
original_request: DomainCheckRequest,
is_approved: bool,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
"""Handle domain check response with correlation and send the response back to the sub-workflow."""
if original_request.id not in self._pending_sub_workflow_requests:
raise ValueError("No pending sub-workflow request for the given domain check response")
sub_workflow_request = self._pending_sub_workflow_requests.pop(original_request.id)
await ctx.send_message(sub_workflow_request.create_response(is_approved))
@handler
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
@@ -379,15 +413,12 @@ async def test_concurrent_sub_workflow_execution() -> None:
# Create parent workflow
processor = ConcurrentProcessor()
workflow_executor = WorkflowExecutor(validation_workflow, "email_workflow")
parent_request_info = RequestInfoExecutor(id="request_info")
main_workflow = (
WorkflowBuilder()
.set_start_executor(processor)
.add_edge(processor, workflow_executor)
.add_edge(workflow_executor, processor)
.add_edge(workflow_executor, parent_request_info) # For external requests
.add_edge(parent_request_info, workflow_executor) # For RequestResponse routing
.build()
)
@@ -3,8 +3,13 @@
from dataclasses import dataclass
from typing import Any, Generic, TypeVar, Union
from agent_framework._workflows import RequestInfoMessage, RequestResponse
from agent_framework._workflows._typing_utils import is_instance_of, is_type_compatible
from agent_framework import RequestInfoEvent
from agent_framework._workflows._typing_utils import (
deserialize_type,
is_instance_of,
is_type_compatible,
serialize_type,
)
def test_basic_types() -> None:
@@ -91,22 +96,6 @@ def test_custom_type() -> None:
assert not is_instance_of(instance, dict)
def test_request_response_type() -> None:
"""Test RequestResponse generic type checking."""
request_instance = RequestResponse[RequestInfoMessage, str](
data="approve",
request_id="req-1",
original_request=RequestInfoMessage(),
)
class CustomRequestInfoMessage(RequestInfoMessage):
info: str
assert is_instance_of(request_instance, RequestResponse[RequestInfoMessage, str])
assert not is_instance_of(request_instance, RequestResponse[CustomRequestInfoMessage, str])
def test_custom_generic_type() -> None:
"""Test custom generic type checking."""
@@ -135,12 +124,90 @@ def test_edge_cases() -> None:
assert not is_instance_of(5, str | None) # Optional type without matching type
def test_serialize_type() -> None:
"""Test serialization of types to strings."""
# Test built-in types
assert serialize_type(int) == "builtins.int"
assert serialize_type(str) == "builtins.str"
assert serialize_type(float) == "builtins.float"
assert serialize_type(bool) == "builtins.bool"
assert serialize_type(list) == "builtins.list"
assert serialize_type(dict) == "builtins.dict"
assert serialize_type(tuple) == "builtins.tuple"
assert serialize_type(set) == "builtins.set"
# Test custom class
@dataclass
class TestClass:
value: int
# The custom class will be in the test module
expected = f"{TestClass.__module__}.{TestClass.__qualname__}"
assert serialize_type(TestClass) == expected
def test_deserialize_type() -> None:
"""Test deserialization of type strings back to types."""
# Test built-in types
assert deserialize_type("builtins.int") is int
assert deserialize_type("builtins.str") is str
assert deserialize_type("builtins.float") is float
assert deserialize_type("builtins.bool") is bool
assert deserialize_type("builtins.list") is list
assert deserialize_type("builtins.dict") is dict
assert deserialize_type("builtins.tuple") is tuple
assert deserialize_type("builtins.set") is set
def test_serialize_deserialize_roundtrip() -> None:
"""Test that serialization and deserialization are inverse operations."""
# Test built-in types
types_to_test = [int, str, float, bool, list, dict, tuple, set]
for type_to_test in types_to_test:
serialized = serialize_type(type_to_test)
deserialized = deserialize_type(serialized)
assert deserialized is type_to_test
# Test agent framework type roundtrip
serialized = serialize_type(RequestInfoEvent)
deserialized = deserialize_type(serialized)
assert deserialized is RequestInfoEvent
# Verify we can instantiate the deserialized type
instance = deserialized(
request_id="request-123",
source_executor_id="executor_1",
request_type=str,
request_data="test",
response_type=str,
)
assert isinstance(instance, RequestInfoEvent)
def test_deserialize_type_error_handling() -> None:
"""Test error handling in deserialize_type function."""
import pytest
# Test with non-existent module
with pytest.raises(ModuleNotFoundError):
deserialize_type("nonexistent.module.Type")
# Test with non-existent type in existing module
with pytest.raises(AttributeError):
deserialize_type("builtins.NonExistentType")
def test_type_compatibility_basic() -> None:
"""Test basic type compatibility scenarios."""
# Exact type match
assert is_type_compatible(str, str)
assert is_type_compatible(int, int)
# bool is a subtype of int
assert is_type_compatible(bool, int)
# Any compatibility
assert is_type_compatible(str, Any)
assert is_type_compatible(list[int], Any)
@@ -8,7 +8,6 @@ import pytest
from agent_framework import (
EdgeDuplicationError,
Executor,
ExecutorDuplicationError,
GraphConnectivityError,
TypeCompatibilityError,
ValidationTypeEnum,
@@ -83,11 +82,10 @@ def test_duplicate_executor_ids_fail_validation():
executor1 = StringExecutor(id="dup")
executor2 = IntExecutor(id="dup")
with pytest.raises(ExecutorDuplicationError) as exc_info:
with pytest.raises(ValueError) as exc_info:
(WorkflowBuilder().add_edge(executor1, executor2).set_start_executor(executor1).build())
assert exc_info.value.executor_id == "dup"
assert exc_info.value.validation_type == ValidationTypeEnum.EXECUTOR_DUPLICATION
assert str(exc_info.value) == "Duplicate executor ID 'dup' detected in workflow."
def test_edge_duplication_validation_fails():
@@ -185,7 +183,7 @@ def test_graph_connectivity_isolated_executors():
assert "executor3" in str(exc_info.value)
def test_start_executor_not_in_graph():
def test_disconnected_start_executor_not_in_graph():
executor1 = StringExecutor(id="executor1")
executor2 = StringExecutor(id="executor2")
executor3 = StringExecutor(id="executor3") # Not in graph
@@ -193,7 +191,7 @@ def test_start_executor_not_in_graph():
with pytest.raises(GraphConnectivityError) as exc_info:
WorkflowBuilder().add_edge(executor1, executor2).set_start_executor(executor3).build()
assert "not present in the workflow graph" in str(exc_info.value)
assert "The following executors are unreachable from the start executor 'executor3'" in str(exc_info.value)
def test_missing_start_executor():
@@ -3,8 +3,9 @@
import asyncio
import tempfile
from collections.abc import AsyncIterable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
import pytest
@@ -21,9 +22,6 @@ from agent_framework import (
FileCheckpointStorage,
Message,
RequestInfoEvent,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
Role,
TextContent,
WorkflowBuilder,
@@ -33,6 +31,7 @@ from agent_framework import (
WorkflowRunState,
WorkflowStatusEvent,
handler,
response_handler,
)
@@ -68,6 +67,14 @@ class AggregatorExecutor(Executor):
await ctx.yield_output(sum(msg.data for msg in messages))
@dataclass
class MockRequest:
"""A mock request message for testing purposes."""
request_id: str = field(default_factory=lambda: str(uuid4()))
prompt: str = ""
@dataclass
class ApprovalMessage:
"""A mock message for approval requests."""
@@ -79,22 +86,22 @@ class MockExecutorRequestApproval(Executor):
"""A mock executor that simulates a request for approval."""
@handler
async def mock_handler_a(self, message: NumberMessage, ctx: WorkflowContext[RequestInfoMessage]) -> None:
async def mock_handler_a(self, message: NumberMessage, ctx: WorkflowContext) -> None:
"""A mock handler that requests approval."""
await ctx.set_shared_state(self.id, message.data)
await ctx.send_message(RequestInfoMessage())
await ctx.request_info(MockRequest(prompt="Mock approval request"), MockRequest, ApprovalMessage)
@handler
@response_handler
async def mock_handler_b(
self,
message: RequestResponse[RequestInfoMessage, ApprovalMessage],
original_request: MockRequest,
response: ApprovalMessage,
ctx: WorkflowContext[NumberMessage, int],
) -> None:
"""A mock handler that processes the approval response."""
data = await ctx.get_shared_state(self.id)
assert isinstance(data, int)
assert isinstance(message.data, ApprovalMessage)
if message.data.approved:
if response.approved:
await ctx.yield_output(data)
else:
await ctx.send_message(NumberMessage(data=data))
@@ -182,15 +189,12 @@ async def test_workflow_send_responses_streaming():
"""Test the workflow run with approval."""
executor_a = IncrementExecutor(id="executor_a")
executor_b = MockExecutorRequestApproval(id="executor_b")
request_info_executor = RequestInfoExecutor(id="request_info")
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_a)
.add_edge(executor_b, request_info_executor)
.add_edge(request_info_executor, executor_b)
.build()
)
@@ -219,15 +223,12 @@ async def test_workflow_send_responses():
"""Test the workflow run with approval."""
executor_a = IncrementExecutor(id="executor_a")
executor_b = MockExecutorRequestApproval(id="executor_b")
request_info_executor = RequestInfoExecutor(id="request_info")
workflow = (
WorkflowBuilder()
.set_start_executor(executor_a)
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_a)
.add_edge(executor_b, request_info_executor)
.add_edge(request_info_executor, executor_b)
.build()
)
@@ -480,6 +481,15 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executo
workflow_id="test-workflow",
messages={},
shared_state={},
pending_request_info_events={
"request_123": RequestInfoEvent(
request_id="request_123",
source_executor_id=simple_executor.id,
request_type=str,
request_data="Mock",
response_type=str,
).to_dict(),
},
iteration_count=0,
)
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
@@ -494,17 +504,20 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(simple_executo
)
# Test that run_stream_from_checkpoint accepts responses parameter
responses = {"request_123": {"data": "test_response"}}
responses = {"request_123": "test_response"}
try:
events: list[WorkflowEvent] = []
async for event in workflow.run_stream_from_checkpoint(checkpoint_id, responses=responses):
events.append(event)
if len(events) >= 2: # Limit to avoid infinite loops
break
except Exception:
# Expected since we have minimal setup, but method should accept the parameters
pass
events: list[WorkflowEvent] = []
async for event in workflow.run_stream_from_checkpoint(checkpoint_id):
events.append(event)
assert next(
event for event in events if isinstance(event, RequestInfoEvent) and event.request_id == "request_123"
)
async for event in workflow.send_responses_streaming(responses):
events.append(event)
assert len(events) > 0 # Just ensure we processed some events
@dataclass
@@ -737,7 +750,7 @@ async def test_workflow_concurrent_execution_prevention_streaming():
# Create an async generator that will consume the stream slowly
async def consume_stream_slowly():
result = []
result: list[WorkflowEvent] = []
async for event in workflow.run_stream(NumberMessage(data=0)):
result.append(event)
await asyncio.sleep(0.01) # Slow consumption
@@ -770,7 +783,7 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
# Start a streaming execution
async def consume_stream():
result = []
result: list[WorkflowEvent] = []
async for event in workflow.run_stream(NumberMessage(data=0)):
result.append(event)
await asyncio.sleep(0.01)
@@ -846,6 +859,7 @@ async def test_agent_streaming_vs_non_streaming() -> None:
assert len(agent_run_events) == 1, "Expected exactly one AgentRunEvent in non-streaming mode"
assert len(agent_update_events) == 0, "Expected no AgentRunUpdateEvent in non-streaming mode"
assert agent_run_events[0].executor_id == "agent_exec"
assert agent_run_events[0].data is not None
assert agent_run_events[0].data.messages[0].text == "Hello World"
# Test streaming mode with run_stream()
@@ -866,6 +880,8 @@ async def test_agent_streaming_vs_non_streaming() -> None:
# Verify the updates build up to the full message
accumulated_text = "".join(
e.data.contents[0].text for e in stream_agent_update_events if e.data.contents and e.data.contents[0].text
e.data.contents[0].text
for e in stream_agent_update_events
if e.data and e.data.contents and e.data.contents[0].text
)
assert accumulated_text == "Hello World", f"Expected 'Hello World', got '{accumulated_text}'"
@@ -14,8 +14,6 @@ from agent_framework import (
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
RequestInfoExecutor,
RequestInfoMessage,
Role,
TextContent,
UsageContent,
@@ -24,6 +22,7 @@ from agent_framework import (
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
@@ -56,15 +55,17 @@ class SimpleExecutor(Executor):
class RequestingExecutor(Executor):
"""Executor that sends RequestInfoMessage to trigger RequestInfoEvent."""
"""Executor that requests info."""
@handler
async def handle_message(self, _: list[ChatMessage], ctx: WorkflowContext[RequestInfoMessage]) -> None:
async def handle_message(self, _: list[ChatMessage], ctx: WorkflowContext) -> None:
# Send a RequestInfoMessage to trigger the request info process
await ctx.send_message(RequestInfoMessage())
await ctx.request_info("Mock request data", str, str)
@handler
async def handle_request_response(self, _: Any, ctx: WorkflowContext[ChatMessage]) -> None:
@response_handler
async def handle_request_response(
self, original_request: str, response: str, ctx: WorkflowContext[ChatMessage]
) -> None:
# Handle the response and emit completion response
update = AgentRunResponseUpdate(
contents=[TextContent(text="Request completed successfully")],
@@ -148,14 +149,11 @@ class TestWorkflowAgent:
async def test_end_to_end_request_info_handling(self):
"""Test end-to-end workflow with RequestInfoEvent handling."""
# Create workflow with requesting executor -> request info executor (no cycle)
simple_executor = SimpleExecutor(id="simple", response_text="SimpleResponse", emit_streaming=False)
requesting_executor = RequestingExecutor(id="requester")
request_info_executor = RequestInfoExecutor(id="request_info")
workflow = (
WorkflowBuilder()
.set_start_executor(requesting_executor)
.add_edge(requesting_executor, request_info_executor)
.build()
WorkflowBuilder().set_start_executor(simple_executor).add_edge(simple_executor, requesting_executor).build()
)
agent = WorkflowAgent(workflow=workflow, name="Request Test Agent")
@@ -100,7 +100,7 @@ def test_workflow_builder_fluent_api():
.build()
)
assert len(workflow.edge_groups) == 4
assert len(workflow.edge_groups) == 4 + 6 # 4 defined edges + 6 internal edges for request-response handling
assert workflow.start_executor_id == executor_a.id
assert len(workflow.executors) == 6
@@ -24,6 +24,18 @@ if TYPE_CHECKING:
from agent_framework._workflows._runner_context import InProcRunnerContext
class MockExecutor(Executor):
"""Mock executor for testing."""
def __init__(self, id: str) -> None:
super().__init__(id=id)
@handler
async def handle_message(self, message: str, ctx: WorkflowContext[str]) -> None:
"""Handle string messages."""
...
@asynccontextmanager
async def make_context(
executor_id: str = "exec",
@@ -31,10 +43,11 @@ async def make_context(
from agent_framework._workflows._runner_context import InProcRunnerContext
from agent_framework._workflows._shared_state import SharedState
mock_executor = MockExecutor(executor_id)
runner_ctx = InProcRunnerContext()
shared_state = SharedState()
workflow_ctx: WorkflowContext[object] = WorkflowContext(
executor_id,
mock_executor,
["source"],
shared_state,
runner_ctx,
@@ -8,7 +8,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE
from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder
from agent_framework._workflows._executor import Executor, handler
from agent_framework._workflows._runner_context import InProcRunnerContext, Message
from agent_framework._workflows._runner_context import InProcRunnerContext, Message, MessageType
from agent_framework._workflows._shared_state import SharedState
from agent_framework._workflows._workflow import Workflow
from agent_framework._workflows._workflow_context import WorkflowContext
@@ -127,7 +127,9 @@ async def test_span_creation_and_attributes(span_exporter: InMemorySpanExporter)
OtelAttr.MESSAGE_DESTINATION_EXECUTOR_ID: "target-789",
}
with (
create_processing_span("executor-456", "TestExecutor", "TestMessage") as processing_span,
create_processing_span(
"executor-456", "TestExecutor", str(MessageType.STANDARD), "TestMessage"
) as processing_span,
create_workflow_span(
OtelAttr.MESSAGE_SEND_SPAN, sending_attributes, kind=trace.SpanKind.PRODUCER
) as sending_span,
@@ -155,7 +157,8 @@ async def test_span_creation_and_attributes(span_exporter: InMemorySpanExporter)
assert processing_span.attributes is not None
assert processing_span.attributes.get("executor.id") == "executor-456"
assert processing_span.attributes.get("executor.type") == "TestExecutor"
assert processing_span.attributes.get("message.type") == "TestMessage"
assert processing_span.attributes.get("message.type") == str(MessageType.STANDARD)
assert processing_span.attributes.get("message.payload_type") == "TestMessage"
# Check sending span
sending_span = next(s for s in spans if s.name == "message.send")
@@ -175,7 +178,7 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No
# Test trace context propagation in messages
workflow_ctx: WorkflowContext[str] = WorkflowContext(
"test-executor",
executor,
["source"],
shared_state,
ctx,
@@ -218,18 +221,20 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No
assert processing_span.attributes is not None
assert processing_span.attributes.get("executor.id") == "test-executor"
assert processing_span.attributes.get("executor.type") == "MockExecutor"
assert processing_span.attributes.get("message.type") == "str"
assert processing_span.attributes.get("message.type") == str(MessageType.STANDARD)
assert processing_span.attributes.get("message.payload_type") == "str"
@pytest.mark.parametrize("enable_otel", [False], indirect=True)
async def test_trace_context_disabled_when_tracing_disabled(enable_otel, span_exporter: InMemorySpanExporter) -> None:
"""Test that no trace context is added when tracing is disabled."""
# Tracing should be disabled by default
executor = MockExecutor("test-executor")
shared_state = SharedState()
ctx = InProcRunnerContext()
workflow_ctx: WorkflowContext[str] = WorkflowContext(
"test-executor",
executor,
["source"],
shared_state,
ctx,
@@ -1,7 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
import pytest
from typing_extensions import Never
@@ -10,8 +8,6 @@ from agent_framework import (
ExecutorFailedEvent,
InProcRunnerContext,
RequestInfoEvent,
RequestInfoExecutor,
RequestInfoMessage,
SharedState,
Workflow,
WorkflowBuilder,
@@ -69,18 +65,26 @@ async def test_executor_failed_event_emitted_on_direct_execute():
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed)
class SimpleExecutor(Executor):
"""Executor that does nothing, for testing."""
@handler
async def run(self, msg: str, ctx: WorkflowContext[str]) -> None: # pragma: no cover
await ctx.send_message(msg)
class Requester(Executor):
"""Executor that always requests external info to test idle-with-requests state."""
@handler
async def ask(self, _: str, ctx: WorkflowContext[RequestInfoMessage]) -> None: # pragma: no cover
await ctx.send_message(RequestInfoMessage())
async def ask(self, _: str, ctx: WorkflowContext) -> None: # pragma: no cover
await ctx.request_info("Mock request data", str, str)
async def test_idle_with_pending_requests_status_streaming():
req = Requester(id="req")
rie = RequestInfoExecutor(id="rie")
wf = WorkflowBuilder().set_start_executor(req).add_edge(req, rie).build()
simple_executor = SimpleExecutor(id="simple")
requester = Requester(id="req")
wf = WorkflowBuilder().set_start_executor(simple_executor).add_edge(simple_executor, requester).build()
events = [ev async for ev in wf.run_stream("start")] # Consume stream fully
@@ -134,9 +138,9 @@ async def test_non_streaming_final_state_helpers():
assert result1.get_final_state() == WorkflowRunState.IDLE
# Idle-with-pending-request case
req = Requester(id="req")
rie = RequestInfoExecutor(id="rie")
wf2 = WorkflowBuilder().set_start_executor(req).add_edge(req, rie).build()
simple_executor = SimpleExecutor(id="simple")
requester = Requester(id="req")
wf2 = WorkflowBuilder().set_start_executor(simple_executor).add_edge(simple_executor, requester).build()
result2: WorkflowRunResult = await wf2.run("start")
assert result2.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
@@ -151,32 +155,12 @@ async def test_run_includes_status_events_completed():
async def test_run_includes_status_events_idle_with_requests():
req = Requester(id="req2")
rie = RequestInfoExecutor(id="rie2")
wf = WorkflowBuilder().set_start_executor(req).add_edge(req, rie).build()
simple_executor = SimpleExecutor(id="simple")
requester = Requester(id="req2")
wf = WorkflowBuilder().set_start_executor(simple_executor).add_edge(simple_executor, requester).build()
result: WorkflowRunResult = await wf.run("start")
timeline = result.status_timeline()
assert timeline, "Expected status timeline in non-streaming run() results"
assert len(timeline) >= 3
assert timeline[-2].state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS
assert timeline[-1].state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
@dataclass
class SnapshotRequest(RequestInfoMessage):
prompt: str = ""
draft: str = ""
iteration: int = 0
class SnapshotRequester(Executor):
"""Executor that emits a rich RequestInfoMessage for persistence tests."""
def __init__(self, id: str, prompt: str, draft: str) -> None:
super().__init__(id=id)
self._prompt = prompt
self._draft = draft
@handler
async def ask(self, _: str, ctx: WorkflowContext[SnapshotRequest]) -> None: # pragma: no cover - simple helper
await ctx.send_message(SnapshotRequest(prompt=self._prompt, draft=self._draft, iteration=1))