Python: Adding support for nested workflows (#460)

* Adding design documents and data flow descriptions for sub-workflows

* Updating docs.

* Sub-workflow implementation #1. Stuck because of singleton RequestInfoExecutor, going to make a change to remove that restrivtion.

* Removed the singleton restriction on RequestInfoExecutor so enable sub-workflows.

* Scenarios seem to be working.

* Sample improved.

* going to have intern add generic response wrappers.

* Wrapped responses working.

* Non-hardcoded routing is working.

* Sample showing external approved and not approved.

* Cleaning up.

* Updating some samples and user guide.

* Removing old design doc.

* Cleaning up.

* Adding python-package-setup.md back.

* Update python/packages/workflow/agent_framework_workflow/_executor.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/workflow/agent_framework_workflow/_validation.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Removing prints.

* Fixing lint and type issues.

* Fixing lint and type issues.

* Update python/packages/workflow/agent_framework_workflow/_executor.py

Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>

* Adding type hints to intercepts decorator.

* Removing unused files.

* Fixing issue with sample 5 groupchat with hil.

* Removing redundent samples.

* Updates to ensure no conflicting request interceptors and to support a subflow with multiple requests in a single super step.

* Fixing pypi errors.

* clean up samples

* update samples to make it more clear

* warning for unhandled request info from sub workflow

* add logger info

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
This commit is contained in:
Ben Thomas
2025-08-22 20:21:37 -07:00
committed by GitHub
Unverified
parent b26d9c95fe
commit b0b3fd151c
16 changed files with 2335 additions and 39 deletions
@@ -35,6 +35,11 @@ _IMPORTS = [
"WorkflowCheckpoint",
"Case",
"Default",
"RequestResponse",
"SubWorkflowRequestInfo",
"SubWorkflowResponse",
"WorkflowExecutor",
"intercepts_request",
]
@@ -18,17 +18,22 @@ from agent_framework_workflow import (
RequestInfoEvent,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
SubWorkflowRequestInfo,
SubWorkflowResponse,
Workflow,
WorkflowBuilder,
WorkflowCheckpoint,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowEvent,
WorkflowExecutor,
WorkflowRunResult,
WorkflowStartedEvent,
WorkflowViz,
__version__,
handler,
intercepts_request,
)
__all__ = [
@@ -49,15 +54,20 @@ __all__ = [
"RequestInfoEvent",
"RequestInfoExecutor",
"RequestInfoMessage",
"RequestResponse",
"SubWorkflowRequestInfo",
"SubWorkflowResponse",
"Workflow",
"WorkflowBuilder",
"WorkflowCheckpoint",
"WorkflowCompletedEvent",
"WorkflowContext",
"WorkflowEvent",
"WorkflowExecutor",
"WorkflowRunResult",
"WorkflowStartedEvent",
"WorkflowViz",
"__version__",
"handler",
"intercepts_request",
]
@@ -30,7 +30,12 @@ from ._executor import (
Executor,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
SubWorkflowRequestInfo,
SubWorkflowResponse,
WorkflowExecutor,
handler,
intercepts_request,
)
from ._runner_context import (
InProcRunnerContext,
@@ -76,11 +81,12 @@ __all__ = [
"InProcRunnerContext",
"Message",
"RequestInfoEvent",
"RequestInfoEvent",
"RequestInfoExecutor",
"RequestInfoExecutor",
"RequestInfoMessage",
"RequestResponse",
"RunnerContext",
"SubWorkflowRequestInfo",
"SubWorkflowResponse",
"TypeCompatibilityError",
"ValidationTypeEnum",
"Workflow",
@@ -89,11 +95,13 @@ __all__ = [
"WorkflowCompletedEvent",
"WorkflowContext",
"WorkflowEvent",
"WorkflowExecutor",
"WorkflowRunResult",
"WorkflowStartedEvent",
"WorkflowValidationError",
"WorkflowViz",
"__version__",
"handler",
"intercepts_request",
"validate_workflow_graph",
]
@@ -5,9 +5,12 @@ import functools
import inspect
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from types import UnionType
from typing import Any, ClassVar, TypeVar, Union, get_args, get_origin, overload
from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, get_args, get_origin, overload
if TYPE_CHECKING:
from ._workflow import Workflow
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, AgentThread, AIAgent, ChatMessage
from agent_framework._pydantic import AFBaseModel
@@ -55,12 +58,14 @@ class Executor(AFBaseModel):
super().__init__(**kwargs)
self._handlers: dict[type, Callable[[Any, WorkflowContext[Any]], Any]] = {}
self._request_interceptors: dict[type | str, list[dict[str, Any]]] = {}
self._discover_handlers()
if not self._handlers:
if not self._handlers and not self._request_interceptors:
raise ValueError(
f"Executor {self.__class__.__name__} has no handlers defined. "
"Please define at least one handler using the @handler decorator."
"Please define at least one handler using the @handler decorator "
"or @intercepts_request decorator."
)
async def execute(self, message: Any, context: WorkflowContext[Any]) -> None:
@@ -73,6 +78,16 @@ class Executor(AFBaseModel):
Returns:
An awaitable that resolves to the result of the execution.
"""
# Handle case where Message wrapper is passed instead of raw data
# Lazy registration for SubWorkflowRequestInfo if we have interceptors
if self._request_interceptors and message.__class__.__name__ == "SubWorkflowRequestInfo":
# Directly handle SubWorkflowRequestInfo
await context.add_event(ExecutorInvokeEvent(self.id))
await self._handle_sub_workflow_request(message, context)
await context.add_event(ExecutorCompletedEvent(self.id))
return
handler: Callable[[Any, WorkflowContext[Any]], Any] | None = None
for message_type in self._handlers:
if is_instance_of(message, message_type):
@@ -81,30 +96,147 @@ class Executor(AFBaseModel):
if handler is None:
raise RuntimeError(f"Executor {self.__class__.__name__} cannot handle message of type {type(message)}.")
await context.add_event(ExecutorInvokeEvent(self.id))
await handler(message, context)
await context.add_event(ExecutorCompletedEvent(self.id))
def _discover_handlers(self) -> None:
"""Discover message handlers in the executor class."""
"""Discover message handlers and request interceptors in the executor class."""
# Use __class__.__dict__ to avoid accessing pydantic's dynamic attributes
for attr_name in dir(self.__class__):
try:
attr = getattr(self.__class__, attr_name)
if callable(attr) and hasattr(attr, "_handler_spec"):
handler_spec = attr._handler_spec # type: ignore
if self._handlers.get(handler_spec["message_type"]) is not None:
raise ValueError(
f"Duplicate handler for type {handler_spec['message_type']} in {self.__class__.__name__}"
)
# Get the bound method
bound_method = getattr(self, attr_name)
self._handlers[handler_spec["message_type"]] = bound_method
if callable(attr):
# Discover @handler methods
if hasattr(attr, "_handler_spec"):
handler_spec = attr._handler_spec # type: ignore
message_type = handler_spec["message_type"]
# Keep full generic types for handler registration to avoid conflicts
# Different RequestResponse[T, U] specializations are distinct handler types
if self._handlers.get(message_type) is not None:
raise ValueError(f"Duplicate handler for type {message_type} in {self.__class__.__name__}")
# Get the bound method
bound_method = getattr(self, attr_name)
self._handlers[message_type] = bound_method
# Discover @intercepts_request methods
if hasattr(attr, "_intercepts_request"):
# Get the bound method for interceptors
bound_method = getattr(self, attr_name)
interceptor_info = {
"method": bound_method,
"from_workflow": getattr(attr, "_from_workflow", None),
"condition": getattr(attr, "_intercept_condition", None),
}
request_type = attr._intercepts_request # type: ignore
if request_type not in self._request_interceptors:
self._request_interceptors[request_type] = []
self._request_interceptors[request_type].append(interceptor_info)
except AttributeError:
# Skip attributes that may not be accessible
continue
def _register_sub_workflow_handler(self) -> None:
"""Register automatic handler for SubWorkflowRequestInfo messages."""
# We need to use a string reference until the class is defined
# This will be resolved later when the class is actually used
pass # Will be registered lazily when needed
async def _handle_sub_workflow_request(
self,
request: "SubWorkflowRequestInfo",
ctx: WorkflowContext[Any],
) -> None:
"""Automatic routing to @intercepts_request methods.
This is only active for executors that have @intercepts_request methods.
"""
# Try to match against registered interceptors
for request_type, interceptor_list in self._request_interceptors.items():
matched = False
# Check type matching
if isinstance(request_type, type) and is_instance_of(request.data, request_type):
matched = True
elif (
isinstance(request_type, str)
and hasattr(request.data, "__class__")
and request.data.__class__.__name__ == request_type
):
# String matching - could check against type name or other attributes
matched = True
if matched:
# Check each interceptor in the list for this request type
for interceptor_info in interceptor_list:
# Check workflow scope if specified
from_workflow = interceptor_info["from_workflow"]
if from_workflow and request.sub_workflow_id != from_workflow:
continue # Skip this interceptor, wrong workflow
# Check additional condition
condition = interceptor_info["condition"]
if condition and not condition(request):
continue
# Call the interceptor method
method = interceptor_info["method"]
response = await method(request.data, ctx)
# Check if interceptor handled it or needs to forward
if isinstance(response, RequestResponse):
# Add automatic correlation info to the response
correlated_response = RequestResponse._with_correlation(
response, request.data, request.request_id
)
if correlated_response.is_handled:
# Send response back to sub-workflow
from ._runner_context import Message
response_message = Message(
source_id=self.id,
target_id=request.sub_workflow_id,
data=SubWorkflowResponse(
request_id=request.request_id,
data=correlated_response.data,
),
)
await ctx.send_message(response_message)
else:
# Forward WITH CONTEXT PRESERVED
# Update the data if interceptor provided a modified request
if correlated_response.forward_request:
request.data = correlated_response.forward_request
# Send the inner request to RequestInfoExecutor to create external request
from ._runner_context import Message
forward_message = Message(
source_id=self.id,
data=request,
)
await ctx.send_message(forward_message)
else:
# Legacy support: direct return means handled
await ctx.send_message(
SubWorkflowResponse(
request_id=request.request_id,
data=response,
),
target_id=request.sub_workflow_id,
)
return
# No interceptor found - forward inner request to RequestInfoExecutor
# This sends the original request to RequestInfoExecutor
from ._runner_context import Message
passthrough_message = Message(source_id=self.id, data=request.data)
await ctx.send_message(passthrough_message)
def can_handle(self, message: Any) -> bool:
"""Check if the executor can handle a given message type.
@@ -257,6 +389,212 @@ def handler(
# endregion: Handler Decorator
# region Request/Response Types
TRequest = TypeVar("TRequest", bound="RequestInfoMessage")
TResponse = TypeVar("TResponse")
@dataclass
class RequestResponse(Generic[TRequest, TResponse]):
"""Response from @intercepts_request methods with automatic correlation support.
This type allows intercepting executors to indicate whether they handled
a request or whether it should be forwarded to external sources. When handled,
the framework automatically adds correlation info to link responses to requests.
"""
is_handled: bool
data: TResponse | None = None
forward_request: TRequest | None = None
original_request: TRequest | None = None # Added for automatic correlation
request_id: str | None = None # Added for tracking
@classmethod
def handled(cls, data: TResponse) -> "RequestResponse[Any, TResponse]":
"""Create a response indicating the request was handled.
Correlation info (original_request, request_id) will be added automatically
by the framework when processing intercepted requests.
"""
return cls(is_handled=True, data=data)
@classmethod
def forward(cls, modified_request: Any = None) -> "RequestResponse[Any, Any]":
"""Create a response indicating the request should be forwarded."""
return cls(is_handled=False, forward_request=modified_request)
@staticmethod
def _with_correlation(
original_response: "RequestResponse[Any, TResponse]", original_request: TRequest, request_id: str
) -> "RequestResponse[TRequest, TResponse]":
"""Internal method to add correlation info to a response.
This is called automatically by the framework and should not be used directly.
"""
return RequestResponse(
is_handled=original_response.is_handled,
data=original_response.data,
forward_request=original_response.forward_request,
original_request=original_request,
request_id=request_id,
)
@dataclass
class SubWorkflowRequestInfo:
"""Routes requests from sub-workflows to parent workflows.
This message type wraps requests from sub-workflows to add routing context,
allowing parent workflows to intercept and potentially handle the request.
"""
request_id: str # Original request ID from sub-workflow
sub_workflow_id: str # ID of the WorkflowExecutor that sent this
data: Any # The actual request data
@dataclass
class SubWorkflowResponse:
"""Routes responses back to sub-workflows.
This message type is used to send responses back to sub-workflows that
made requests, ensuring the response reaches the correct sub-workflow.
"""
request_id: str # Matches the original request ID
data: Any # The actual response data
# endregion: Request/Response Types
# region Intercepts Request Decorator
# TypeVar for request type that must be a RequestInfoMessage subclass
RequestInfoMessageT = TypeVar("RequestInfoMessageT", bound="RequestInfoMessage")
# Type alias for interceptor functions
InterceptorFunc = Callable[
[Any, RequestInfoMessageT, WorkflowContext[Any]], Awaitable[RequestResponse[RequestInfoMessageT, Any]]
]
@overload
def intercepts_request(
func: Callable[..., Any],
) -> Callable[..., Any]: ...
@overload
def intercepts_request(
*,
from_workflow: str | None = None,
condition: Callable[[Any], bool] | None = None,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
def intercepts_request(
func: Callable[..., Any] | None = None,
*,
from_workflow: str | None = None,
condition: Callable[[Any], bool] | None = None,
) -> Callable[..., Any]:
"""Decorator to mark methods that intercept sub-workflow requests.
The request type is automatically inferred from the method's second parameter type hint.
The type must be a subclass of RequestInfoMessage.
This decorator allows executors in parent workflows to intercept and handle requests from
sub-workflows before they are sent to external sources.
Args:
func: The function being decorated (automatically passed when used without parentheses).
from_workflow: Optional ID of specific sub-workflow to intercept from.
condition: Optional callable that must return True for interception.
Returns:
The decorated function with interception metadata.
Example:
@intercepts_request
async def check_domain(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[DomainCheckRequest, bool]:
# Type automatically inferred as DomainCheckRequest from parameter annotation
if request.domain in self.approved_domains:
return RequestResponse.handled(True)
return RequestResponse.forward()
@intercepts_request(from_workflow="email_validator")
async def handle_specific(
self, request: EmailRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[EmailRequest, str]:
# Only intercepts EmailRequest from the "email_validator" workflow
return RequestResponse.handled("handled by parent")
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
# Extract request type from method signature
sig = inspect.signature(func)
params = list(sig.parameters.values())
if len(params) < 2:
raise ValueError(f"Interceptor method '{func.__name__}' must have at least 2 parameters (self, request)")
request_param = params[1] # Second parameter (after self)
request_type = request_param.annotation
if request_type is inspect.Parameter.empty:
raise ValueError(f"Interceptor method '{func.__name__}' request parameter must have a type annotation")
# Runtime validation that it's a RequestInfoMessage subclass
if isinstance(request_type, type):
# We need to check if RequestInfoMessage is defined yet, since this runs at import time
try:
# Try to get RequestInfoMessage from the current module's globals
request_info_message_class = None
func_module = inspect.getmodule(func)
if func_module and hasattr(func_module, "RequestInfoMessage"):
request_info_message_class = func_module.RequestInfoMessage
else:
# Look in the current module (where this decorator is defined)
import sys
current_module = sys.modules[__name__]
if hasattr(current_module, "RequestInfoMessage"):
request_info_message_class = current_module.RequestInfoMessage
if request_info_message_class and not issubclass(request_type, request_info_message_class):
raise TypeError(
f"Interceptor method '{func.__name__}' can only handle RequestInfoMessage subclasses, "
f"got {request_type}. Make sure your request type inherits from RequestInfoMessage."
)
except (AttributeError, NameError):
# RequestInfoMessage might not be defined yet at import time, skip validation
# This will be caught later when the interceptor is actually called
pass
@functools.wraps(func)
async def wrapper(self: Any, request: Any, ctx: WorkflowContext[Any]) -> Any:
return await func(self, request, ctx)
# Add metadata for discovery - store the inferred type
wrapper._intercepts_request = request_type # type: ignore
wrapper._from_workflow = from_workflow # type: ignore
wrapper._intercept_condition = condition # type: ignore
return wrapper
# If func is provided, we're being called without parentheses: @intercepts_request
if func is not None:
return decorator(func)
# Otherwise, we're being called with parentheses: @intercepts_request(from_workflow="...")
return decorator
# endregion: Intercepts Request Decorator
# region Agent Executor
@@ -354,7 +692,11 @@ class RequestInfoMessage:
the request/response pattern explicit.
"""
request_id: str = str(uuid.uuid4())
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
# Note: SubWorkflowRequestInfo, SubWorkflowResponse, and RequestResponse
# have been moved before intercepts_request decorator
class RequestInfoExecutor(Executor):
@@ -365,13 +707,19 @@ class RequestInfoExecutor(Executor):
a response is provided externally, it emits the response as a message.
"""
# Well-known ID for the request info executor
EXECUTOR_ID: ClassVar[str] = "request_info"
def __init__(self, id: str | None = None):
"""Initialize the RequestInfoExecutor with an optional custom ID.
def __init__(self) -> None:
"""Initialize the RequestInfoExecutor with its well-known ID."""
super().__init__(id=self.EXECUTOR_ID)
Args:
id: Optional custom ID for this RequestInfoExecutor. If not provided,
a unique ID will be generated.
"""
import uuid
executor_id = id or f"request_info_{uuid.uuid4().hex[:8]}"
super().__init__(id=executor_id)
self._request_events: dict[str, RequestInfoEvent] = {}
self._sub_workflow_contexts: dict[str, dict[str, str]] = {}
@handler
async def run(self, message: RequestInfoMessage, ctx: WorkflowContext[None]) -> None:
@@ -387,6 +735,36 @@ class RequestInfoExecutor(Executor):
self._request_events[message.request_id] = event
await ctx.add_event(event)
@handler
async def handle_sub_workflow_request(
self,
message: SubWorkflowRequestInfo,
ctx: WorkflowContext[None],
) -> None:
"""Handle forwarded sub-workflow request.
This method handles requests that were forwarded from parent workflows
because they couldn't be handled locally.
"""
# When called directly from runner, we need to use the sub_workflow_id as the source
source_executor_id = message.sub_workflow_id
# Store context for routing response back
self._sub_workflow_contexts[message.request_id] = {
"sub_workflow_id": message.sub_workflow_id,
"parent_executor_id": source_executor_id,
}
# Create event for external handling - preserve the SubWorkflowRequestInfo wrapper
event = RequestInfoEvent(
request_id=message.request_id, # Use original request ID
source_executor_id=source_executor_id,
request_type=type(message.data), # SubWorkflowRequestInfo type
request_data=message.data, # The full SubWorkflowRequestInfo
)
self._request_events[message.request_id] = event
await ctx.add_event(event)
async def handle_response(
self,
response_data: Any,
@@ -404,7 +782,190 @@ class RequestInfoExecutor(Executor):
raise ValueError(f"No request found with ID: {request_id}")
event = self._request_events.pop(request_id)
await ctx.send_message(response_data, target_id=event.source_executor_id)
# Check if this was a forwarded sub-workflow request
if request_id in self._sub_workflow_contexts:
context = self._sub_workflow_contexts.pop(request_id)
# Send back to sub-workflow that made the original request
await ctx.send_message(
SubWorkflowResponse(
request_id=request_id,
data=response_data,
),
target_id=context["sub_workflow_id"],
)
else:
# Regular response - send directly back to source
# Create a correlated response that includes both the response data and original request
correlated_response = RequestResponse.handled(response_data)
correlated_response = RequestResponse._with_correlation(correlated_response, event.data, request_id)
await ctx.send_message(correlated_response, target_id=event.source_executor_id)
# endregion: Request Info Executor
# region Workflow Executor
class WorkflowExecutor(Executor):
"""An executor that runs another workflow as its execution logic.
This executor wraps a workflow to make it behave as an executor, enabling
hierarchical workflow composition. Sub-workflows can send requests that
are intercepted by parent workflows.
"""
def __init__(self, workflow: "Workflow", id: str | None = None):
"""Initialize the WorkflowExecutor.
Args:
workflow: The workflow to execute as a sub-workflow.
id: Optional unique identifier for this executor.
"""
super().__init__(id)
self._workflow = workflow
# Track pending external responses by request_id
self._pending_responses: dict[str, Any] = {} # request_id -> response_data
# Track workflow state for proper resumption - support multiple concurrent requests
self._pending_requests: dict[str, Any] = {} # request_id -> original request data
self._active_executions: int = 0 # Count of active sub-workflow executions
# Response accumulation for multiple concurrent responses
self._collected_responses: dict[str, Any] = {} # Accumulate responses
self._expected_response_count: int = 0 # Track how many responses we're waiting for
@handler # No output_types - can send any completion data type
async def process_workflow(self, input_data: object, ctx: WorkflowContext[Any]) -> None:
"""Execute the sub-workflow with raw input data.
This handler starts a new sub-workflow execution. When the sub-workflow
needs external information, it pauses and sends a request to the parent.
Args:
input_data: The input data to send to the sub-workflow.
ctx: The workflow context from the parent.
"""
# Skip SubWorkflowResponse and SubWorkflowRequestInfo - they have specific handlers
if isinstance(input_data, (SubWorkflowResponse, SubWorkflowRequestInfo)):
return
from ._events import RequestInfoEvent, WorkflowCompletedEvent
# Track this execution
self._active_executions += 1
try:
# Run the sub-workflow and collect all events
events = [event async for event in self._workflow.run_streaming(input_data)]
# Count requests and initialize response tracking
request_count = 0
for event in events:
if isinstance(event, RequestInfoEvent):
request_count += 1
# Initialize response accumulation for this execution
# For sequential workflows (like step_08), expect only current requests
# For parallel workflows (like step_09), expect all requests at once
self._expected_response_count = request_count
self._collected_responses = {}
# If no requests in initial run, handle completion immediately
if request_count == 0:
self._expected_response_count = 0
# Process events to check for completion or requests
for event in events:
if isinstance(event, WorkflowCompletedEvent):
# Sub-workflow completed normally - send result to parent
await ctx.send_message(event.data)
self._active_executions -= 1
return # Exit after completion
if isinstance(event, RequestInfoEvent):
# Sub-workflow needs external information
# Track the pending request
self._pending_requests[event.request_id] = event.data
# Wrap request with routing context and send to parent
wrapped_request = SubWorkflowRequestInfo(
request_id=event.request_id,
sub_workflow_id=self.id,
data=event.data,
)
await ctx.send_message(wrapped_request)
# Continue processing remaining events (no return)
except Exception as e:
from ._events import ExecutorEvent
# Sub-workflow failed - create error event
error_event = ExecutorEvent(executor_id=self.id, data={"error": str(e), "type": "sub_workflow_error"})
await ctx.add_event(error_event)
self._active_executions -= 1
raise
@handler
async def handle_response(
self,
response: SubWorkflowResponse,
ctx: WorkflowContext[Any],
) -> None:
"""Handle response from parent for a forwarded request.
This handler accumulates responses and only resumes the sub-workflow
when all expected responses have been received.
Args:
response: The response to a previous request.
ctx: The workflow context.
"""
# Check if we have this pending request
pending_requests = getattr(self, "_pending_requests", {})
if response.request_id not in pending_requests:
return
# Remove the request from pending list
pending_requests.pop(response.request_id, None)
# Accumulate the response
self._collected_responses[response.request_id] = response.data
# Check if we have all expected responses for current batch
if len(self._collected_responses) >= self._expected_response_count:
from ._events import RequestInfoEvent, WorkflowCompletedEvent
# Send all collected responses to the sub-workflow
responses_to_send = dict(self._collected_responses)
self._collected_responses.clear() # Clear for next batch
result_events = [event async for event in self._workflow.send_responses_streaming(responses_to_send)]
# Process the result events
new_request_count = 0
for event in result_events:
if isinstance(event, WorkflowCompletedEvent):
# Sub-workflow completed - send result to parent
await ctx.send_message(event.data)
self._active_executions -= 1
return
if isinstance(event, RequestInfoEvent):
# Sub-workflow sent more requests - prepare for next batch
new_request_count += 1
self._pending_requests[event.request_id] = event.data
# Send the new request to parent
wrapped_request = SubWorkflowRequestInfo(
request_id=event.request_id,
sub_workflow_id=self.id,
data=event.data,
)
await ctx.send_message(wrapped_request)
# Update expected count for next batch of requests
self._expected_response_count = new_request_count
# endregion: Workflow Executor
@@ -4,7 +4,10 @@ import asyncio
import logging
from collections import defaultdict
from collections.abc import AsyncIterable, Sequence
from typing import Any
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ._executor import RequestInfoExecutor
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
@@ -12,6 +15,8 @@ from ._events import WorkflowEvent
from ._executor import Executor
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
from ._typing_utils import is_instance_of
from ._workflow_context import WorkflowContext
logger = logging.getLogger(__name__)
@@ -132,12 +137,107 @@ class Runner:
async def _deliver_messages(source_executor_id: str, messages: list[Message]) -> None:
"""Outer loop to concurrently deliver messages from all sources to their targets."""
# Special handling for SubWorkflowRequestInfo messages
async def _deliver_sub_workflow_requests(messages: list[Message]) -> None:
from ._executor import SubWorkflowRequestInfo
# Handle SubWorkflowRequestInfo messages - only process those not already targeted
sub_workflow_messages = []
for msg in messages:
# Skip messages sent directly to RequestInfoExecutor - they are already forwarded
if self._is_message_to_request_info_executor(msg):
continue
if isinstance(msg.data, SubWorkflowRequestInfo):
sub_workflow_messages.append(msg)
for message in sub_workflow_messages:
sub_request = message.data
# Find executor that can intercept the wrapped type
interceptor_found = False
for executor in self._executors.values():
if hasattr(executor, "_request_interceptors") and executor.id != message.source_id:
# Check if any registered interceptor can handle this request type
for registered_type in executor._request_interceptors:
# Check type matching - handle both type and string cases
matched = False
if (
isinstance(registered_type, type)
and is_instance_of(sub_request.data, registered_type)
) or (
isinstance(registered_type, str)
and hasattr(sub_request.data, "__class__")
and sub_request.data.__class__.__name__ == registered_type
):
matched = True
if matched:
# Send directly to the intercepting executor
logger.info(
f"Sending sub-workflow request of type '{sub_request.data.__class__.__name__}' "
f"from sub-workflow '{sub_request.sub_workflow_id}' "
f"to executor '{executor.id}' for interception."
)
await executor.execute(sub_request, self._ctx) # type: ignore[arg-type]
interceptor_found = True
break
if interceptor_found:
break
if not interceptor_found:
# No interceptor found - send directly to RequestInfoExecutor if available.
# Find the RequestInfoExecutor instance
request_info_executor = self._find_request_info_executor()
if request_info_executor:
workflow_ctx: WorkflowContext[None] = WorkflowContext(
request_info_executor.id,
["Runner"],
self._shared_state,
self._ctx,
)
logger.info(
f"Sending sub-workflow request of type '{sub_request.data.__class__.__name__}' "
f"from sub-workflow '{sub_request.sub_workflow_id}' to RequestInfoExecutor "
f"'{request_info_executor.id}'"
)
await request_info_executor.execute(sub_request, workflow_ctx)
else:
logger.warning(
f"Sub-workflow request of type '{sub_request.data.__class__.__name__}' "
f"from sub-workflow '{sub_request.sub_workflow_id}' could not be handled: "
f"no RequestInfoExecutor found in the workflow. Add a RequestInfoExecutor "
f"to handle external requests or add an @intercepts_request handler."
)
async def _deliver_message_inner(edge_runner: EdgeRunner, message: Message) -> bool:
"""Inner loop to deliver a single message through an edge runner."""
return await edge_runner.send_message(message, self._shared_state, self._ctx)
# Handle SubWorkflowRequestInfo messages specially
await _deliver_sub_workflow_requests(messages)
# Filter out SubWorkflowRequestInfo messages from normal edge routing
# since they were handled specially
from ._executor import SubWorkflowRequestInfo
non_sub_workflow_messages = []
for msg in messages:
# Keep messages sent directly to RequestInfoExecutor (forwarded messages)
if self._is_message_to_request_info_executor(msg):
non_sub_workflow_messages.append(msg)
continue
# Skip SubWorkflowRequestInfo messages (handled by special routing)
if isinstance(msg.data, SubWorkflowRequestInfo):
continue
non_sub_workflow_messages.append(msg)
associated_edge_runners = self._edge_runner_map.get(source_executor_id, [])
for message in messages:
for message in non_sub_workflow_messages:
# Deliver a message through all edge runners associated with the source executor concurrently.
tasks = [_deliver_message_inner(edge_runner, message) for edge_runner in associated_edge_runners]
results = await asyncio.gather(*tasks)
@@ -282,3 +382,36 @@ class Runner:
parsed[source_executor_id].append(runner)
return parsed
def _find_request_info_executor(self) -> "RequestInfoExecutor | None":
"""Find the RequestInfoExecutor instance in this workflow.
Returns:
The RequestInfoExecutor instance if found, None otherwise.
"""
from ._executor import RequestInfoExecutor
for executor in self._executors.values():
if isinstance(executor, RequestInfoExecutor):
return executor
return None
def _is_message_to_request_info_executor(self, msg: "Message") -> bool:
"""Check if message targets any RequestInfoExecutor in this workflow.
Args:
msg: The message to check.
Returns:
True if the message targets a RequestInfoExecutor, False otherwise.
"""
from ._executor import RequestInfoExecutor
if not msg.target_id:
return False
# Check all executors to see if target_id matches a RequestInfoExecutor
for executor in self._executors.values():
if executor.id == msg.target_id and isinstance(executor, RequestInfoExecutor):
return True
return False
@@ -50,5 +50,29 @@ def is_instance_of(data: Any, target_type: type) -> bool:
for key, value in data.items() # type: ignore
)
# Case 6: target_type is RequestResponse[T, U] - validate generic parameters
if origin and hasattr(origin, "__name__") and origin.__name__ == "RequestResponse":
if not isinstance(data, origin):
return False
# Validate generic parameters for RequestResponse[TRequest, TResponse]
if len(args) >= 2:
request_type, response_type = args[0], args[1]
# Check if the original_request matches TRequest and data matches TResponse
if (
hasattr(data, "original_request")
and data.original_request is not None
and not is_instance_of(data.original_request, request_type)
):
return False
if hasattr(data, "data") and data.data is not None and not is_instance_of(data.data, response_type):
return False
return True
# Case 7: Other custom generic classes - check origin type only
# For generic classes, we check if data is an instance of the origin type
# We don't validate the generic parameters at runtime since that's handled by type system
if origin and hasattr(origin, "__name__"):
return isinstance(data, origin)
# Fallback: if we reach here, we assume data is an instance of the target_type
return isinstance(data, target_type)
@@ -22,6 +22,7 @@ class ValidationTypeEnum(Enum):
TYPE_COMPATIBILITY = "TYPE_COMPATIBILITY"
GRAPH_CONNECTIVITY = "GRAPH_CONNECTIVITY"
HANDLER_OUTPUT_ANNOTATION = "HANDLER_OUTPUT_ANNOTATION"
INTERCEPTOR_CONFLICT = "INTERCEPTOR_CONFLICT"
class WorkflowValidationError(Exception):
@@ -94,6 +95,13 @@ class HandlerOutputAnnotationError(WorkflowValidationError):
self.handler_name = handler_name
class InterceptorConflictError(WorkflowValidationError):
"""Exception raised when multiple executors intercept the same request type from the same sub-workflow."""
def __init__(self, message: str):
super().__init__(message, validation_type=ValidationTypeEnum.INTERCEPTOR_CONFLICT)
# endregion
@@ -129,6 +137,14 @@ class WorkflowGraphValidator:
self._edges = [edge for group in edge_groups for edge in group.edges]
self._edge_groups = edge_groups
# If only the start executor exists, add it to the executor map
# Handle the special case where the workflow consists of only a single executor and no edges.
# In this scenario, the executor map will be empty because there are no edge groups to reference executors.
# Adding the start executor to the map ensures that single-executor workflows (without any edges) are supported,
# allowing validation and execution to proceed for workflows that do not require inter-executor communication.
if not self._executors and start_executor and isinstance(start_executor, Executor):
self._executors[start_executor.id] = start_executor
# Validate that start_executor exists in the graph
# It should because we check for it in the WorkflowBuilder
# but we do it here for completeness.
@@ -145,6 +161,7 @@ class WorkflowGraphValidator:
self._validate_handler_ambiguity()
self._validate_dead_ends()
self._validate_cycles()
self._validate_interceptor_uniqueness()
def _validate_handler_output_annotations(self) -> None:
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
@@ -306,13 +323,21 @@ class WorkflowGraphValidator:
# If either executor has no type information, log warning and skip validation
# This allows for dynamic typing scenarios but warns about reduced validation coverage
if not source_output_types or not target_input_types:
if not source_output_types:
# Suppress warnings for built-in workflow components where dynamic typing is expected
try:
from ._executor import RequestInfoExecutor, WorkflowExecutor # local import to avoid cycles
builtin_types = (RequestInfoExecutor, WorkflowExecutor)
except Exception:
builtin_types = tuple() # type: ignore[assignment]
if not source_output_types and not isinstance(source_executor, builtin_types):
logger.warning(
f"Executor '{source_executor.id}' has no output type annotations. "
f"Type compatibility validation will be skipped for edges from this executor. "
f"Consider adding WorkflowContext[T] generics in handlers for better validation."
)
if not target_input_types:
if not target_input_types and not isinstance(target_executor, builtin_types):
logger.warning(
f"Executor '{target_executor.id}' has no input type annotations. "
f"Type compatibility validation will be skipped for edges to this executor. "
@@ -376,6 +401,13 @@ class WorkflowGraphValidator:
# Skip attributes that may not be accessible
continue
# Also include intercepted request types as potential outputs
# since @intercepts_request methods can forward requests
if hasattr(executor, "_request_interceptors"):
for request_type in executor._request_interceptors:
if isinstance(request_type, type):
output_types.append(request_type)
return output_types
def _get_executor_input_types(self, executor: Executor) -> list[type[Any]]:
@@ -571,6 +603,54 @@ class WorkflowGraphValidator:
"Ensure proper termination conditions exist to prevent infinite loops."
)
def _validate_interceptor_uniqueness(self) -> None:
"""Validate that only one executor intercepts a given request type from a specific sub-workflow.
This prevents non-deterministic behavior where multiple executors could intercept
the same request type from the same sub-workflow.
"""
from ._executor import WorkflowExecutor
# Find all WorkflowExecutor instances in the workflow
workflow_executors: dict[str, WorkflowExecutor] = {}
for executor_id, executor in self._executors.items():
if isinstance(executor, WorkflowExecutor):
workflow_executors[executor_id] = executor
# For each WorkflowExecutor, check which executors can intercept its requests
for workflow_id, _workflow_executor in workflow_executors.items():
# Map of request_type -> list of intercepting executor IDs
interceptors_by_type: dict[type | str, list[str]] = {}
# Find all executors that have edges from this WorkflowExecutor
# These are potential interceptors
for edge in self._edges:
if edge.source_id == workflow_id:
target_executor = self._executors.get(edge.target_id)
if target_executor and hasattr(target_executor, "_request_interceptors"):
# Check what request types this executor intercepts
for request_type, interceptor_list in target_executor._request_interceptors.items():
# Check if any interceptor is scoped to this workflow or unscoped
for interceptor_info in interceptor_list:
from_workflow = interceptor_info.get("from_workflow")
# If unscoped or specifically scoped to this workflow
if from_workflow is None or from_workflow == workflow_id:
if request_type not in interceptors_by_type:
interceptors_by_type[request_type] = []
interceptors_by_type[request_type].append(edge.target_id)
# Check for duplicates
for request_type, executor_ids in interceptors_by_type.items():
unique_executors = list(set(executor_ids)) # Remove duplicates from same executor
if len(unique_executors) > 1:
type_name = request_type.__name__ if isinstance(request_type, type) else str(request_type)
raise InterceptorConflictError(
f"Multiple executors intercept the same request type '{type_name}' "
f"from sub-workflow '{workflow_id}': {', '.join(unique_executors)}. "
f"Only one executor should intercept a given request type from a specific sub-workflow "
f"to ensure deterministic behavior."
)
# endregion
# region Type Compatibility Utilities
@@ -219,8 +219,8 @@ class Workflow(AFBaseModel):
raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}")
if responses:
request_info_executor = self._get_executor_by_id(RequestInfoExecutor.EXECUTOR_ID)
if isinstance(request_info_executor, RequestInfoExecutor):
request_info_executor = self._find_request_info_executor()
if request_info_executor:
for request_id, response_data in responses.items():
await request_info_executor.handle_response(
response_data,
@@ -246,9 +246,9 @@ class Workflow(AFBaseModel):
Yields:
WorkflowEvent: The events generated during the workflow execution after sending the responses.
"""
request_info_executor = self._get_executor_by_id(RequestInfoExecutor.EXECUTOR_ID)
if not isinstance(request_info_executor, RequestInfoExecutor):
raise ValueError(f"Executor with ID {RequestInfoExecutor.EXECUTOR_ID} is not a RequestInfoExecutor.")
request_info_executor = self._find_request_info_executor()
if not request_info_executor:
raise ValueError("No RequestInfoExecutor found in workflow.")
async def _handle_response(response: Any, request_id: str) -> None:
"""Handle the response from the RequestInfoExecutor."""
@@ -332,6 +332,19 @@ class Workflow(AFBaseModel):
raise ValueError(f"Executor with ID {executor_id} not found.")
return self.executors[executor_id]
def _find_request_info_executor(self) -> "RequestInfoExecutor | None":
"""Find the RequestInfoExecutor instance in this workflow.
Returns:
The RequestInfoExecutor instance if found, None otherwise.
"""
from ._executor import RequestInfoExecutor
for executor in self.executors.values():
if isinstance(executor, RequestInfoExecutor):
return executor
return None
async def _restore_from_external_checkpoint(
self, checkpoint_id: str, checkpoint_storage: CheckpointStorage
) -> bool:
@@ -0,0 +1,111 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
import pytest
from agent_framework_workflow import (
Executor,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
handler,
)
@dataclass
class SimpleRequest:
"""Simple request for testing."""
text: str
@dataclass
class SimpleResponse:
"""Simple response for testing."""
result: str
class SimpleSubExecutor(Executor):
"""Simple executor for sub-workflow."""
def __init__(self):
super().__init__(id="simple_sub")
@handler
async def process(self, request: SimpleRequest, ctx: WorkflowContext[None]) -> None:
"""Process a simple request."""
from agent_framework_workflow import WorkflowCompletedEvent
# Just echo back with prefix and complete
response = SimpleResponse(result=f"processed: {request.text}")
await ctx.add_event(WorkflowCompletedEvent(data=response))
class SimpleParent(Executor):
"""Simple parent executor."""
result: SimpleResponse | None = None
def __init__(self):
super().__init__(id="simple_parent")
@handler
async def start(self, text: str, ctx: WorkflowContext[SimpleRequest]) -> None:
"""Start the process."""
request = SimpleRequest(text=text)
await ctx.send_message(request, target_id="sub_workflow")
@handler
async def collect(self, response: SimpleResponse, ctx: WorkflowContext[None]) -> None:
"""Collect the result."""
self.result = response
@pytest.mark.asyncio
async def test_simple_sub_workflow():
"""Test the simplest possible sub-workflow."""
# Create sub-workflow with dummy executor to satisfy validation
sub_executor = SimpleSubExecutor()
class DummyExecutor(Executor):
def __init__(self):
super().__init__(id="dummy")
@handler
async def process(self, message: object, ctx: WorkflowContext[None]) -> None:
pass # Do nothing
dummy = DummyExecutor()
sub_workflow = (
WorkflowBuilder()
.set_start_executor(sub_executor)
.add_edge(sub_executor, dummy) # Add edge to satisfy validation
.build()
)
# Create parent workflow
parent = SimpleParent()
workflow_executor = WorkflowExecutor(sub_workflow, id="sub_workflow")
main_workflow = (
WorkflowBuilder()
.set_start_executor(parent)
.add_edge(parent, workflow_executor)
.add_edge(workflow_executor, parent)
.build()
)
# Run the workflow
await main_workflow.run("hello world")
# Check result
assert parent.result is not None
assert parent.result.result == "processed: hello world"
if __name__ == "__main__":
# Run the simple test
asyncio.run(test_simple_sub_workflow())
@@ -0,0 +1,420 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from typing import Any
import pytest
from pydantic import Field
from agent_framework_workflow import (
Executor,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowExecutor,
handler,
intercepts_request,
)
# Test message types
@dataclass
class EmailValidationRequest:
"""Request to validate an email address."""
email: str
@dataclass
class DomainCheckRequest(RequestInfoMessage):
"""Request to check if a domain is approved."""
domain: str = ""
email: str = "" # Include original email for correlation
@dataclass
class ValidationResult:
"""Result of email validation."""
email: str
is_valid: bool
reason: str
# Test executors
class EmailValidator(Executor):
"""Validates email addresses in a sub-workflow."""
def __init__(self):
super().__init__(id="email_validator")
@handler
async def validate_request(
self, request: EmailValidationRequest, ctx: WorkflowContext[RequestInfoMessage | ValidationResult]
) -> None:
"""Validate an email address."""
# Extract domain and check if it's approved
domain = request.email.split("@")[1] if "@" in request.email else ""
if not domain:
result = ValidationResult(email=request.email, is_valid=False, reason="Invalid email format")
await ctx.add_event(WorkflowCompletedEvent(data=result))
return
# Request domain check from external source
domain_check = DomainCheckRequest(domain=domain, email=request.email)
await ctx.send_message(domain_check)
@handler
async def handle_domain_response(
self, response: RequestResponse[DomainCheckRequest, bool], ctx: WorkflowContext[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",
)
await ctx.add_event(WorkflowCompletedEvent(data=result))
class ParentOrchestrator(Executor):
"""Parent workflow orchestrator with domain knowledge."""
approved_domains: set[str] = Field(default_factory=lambda: {"example.com", "test.org"})
results: list[ValidationResult] = Field(default_factory=list)
def __init__(self, approved_domains: set[str] | None = None, **kwargs: Any):
if approved_domains is not None:
kwargs["approved_domains"] = approved_domains
super().__init__(id="parent_orchestrator", **kwargs)
@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")
@intercepts_request
async def check_domain(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[DomainCheckRequest, bool]:
"""Intercept domain check requests from sub-workflows."""
# Check if we know this domain
if request.domain in self.approved_domains:
return RequestResponse[DomainCheckRequest, bool].handled(True)
# We don't know this domain, forward to external
return RequestResponse[DomainCheckRequest, bool].forward()
@handler
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
"""Collect validation results."""
self.results.append(result)
@pytest.mark.asyncio
async def test_basic_sub_workflow() -> None:
"""Test basic sub-workflow execution without interception."""
# Create sub-workflow
email_validator = EmailValidator()
email_request_info = RequestInfoExecutor(id="email_request_info")
validation_workflow = (
WorkflowBuilder()
.set_start_executor(email_validator)
.add_edge(email_validator, email_request_info)
.add_edge(email_request_info, email_validator)
.build()
)
# Create parent workflow without interception
class SimpleParent(Executor):
result: ValidationResult | None = Field(default=None)
def __init__(self, **kwargs: Any):
super().__init__(id="simple_parent", **kwargs)
@handler
async def start(self, email: str, ctx: WorkflowContext[EmailValidationRequest]) -> None:
request = EmailValidationRequest(email=email)
await ctx.send_message(request, target_id="email_workflow")
@handler
async def collect(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
self.result = result
parent = SimpleParent()
workflow_executor = WorkflowExecutor(validation_workflow, id="email_workflow")
main_request_info = RequestInfoExecutor(id="main_request_info")
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 SubWorkflowResponse routing
.build()
)
# Run workflow with mocked external response
result = await main_workflow.run("test@example.com")
# Get request event and respond
request_events = result.get_request_info_events()
assert len(request_events) == 1
assert isinstance(request_events[0].data, DomainCheckRequest)
assert request_events[0].data.domain == "example.com"
# Send response through the main workflow
await main_workflow.send_responses({
request_events[0].request_id: True # Domain is approved
})
# Check result
assert parent.result is not None
assert parent.result.email == "test@example.com"
assert parent.result.is_valid is True
@pytest.mark.asyncio
async def test_sub_workflow_with_interception():
"""Test sub-workflow with parent interception of requests."""
# Create sub-workflow
email_validator = EmailValidator()
email_request_info = RequestInfoExecutor(id="email_request_info")
validation_workflow = (
WorkflowBuilder()
.set_start_executor(email_validator)
.add_edge(email_validator, email_request_info)
.add_edge(email_request_info, email_validator)
.build()
)
# Create parent workflow with interception
parent = ParentOrchestrator(approved_domains={"example.com", "internal.org"})
workflow_executor = WorkflowExecutor(validation_workflow, id="email_workflow")
parent_request_info = RequestInfoExecutor()
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 SubWorkflowResponse routing
.build()
)
# Test 1: Email with known domain (intercepted)
result = await main_workflow.run(["user@example.com"])
# Should complete without external requests
request_events = result.get_request_info_events()
assert len(request_events) == 0 # No external requests, handled internally
assert len(parent.results) == 1
assert parent.results[0].email == "user@example.com"
assert parent.results[0].is_valid is True
assert parent.results[0].reason == "Domain approved"
# Test 2: Email with unknown domain (forwarded)
parent.results.clear()
result = await main_workflow.run(["user@unknown.com"])
# Should have external request
request_events = result.get_request_info_events()
assert len(request_events) == 1
assert isinstance(request_events[0].data, DomainCheckRequest)
assert request_events[0].data.domain == "unknown.com"
# Send external response
await main_workflow.send_responses({
request_events[0].request_id: False # Domain not approved
})
assert len(parent.results) == 1
assert parent.results[0].email == "user@unknown.com"
assert parent.results[0].is_valid is False
assert parent.results[0].reason == "Domain not approved"
@pytest.mark.asyncio
async def test_conditional_forwarding() -> None:
"""Test conditional forwarding with RequestResponse.forward()."""
class ConditionalParent(Executor):
"""Parent that conditionally handles requests."""
cache: dict[str, bool] = Field(default_factory=lambda: {"cached.com": True})
result: ValidationResult | None = Field(default=None)
def __init__(self, **kwargs: Any):
super().__init__(id="conditional_parent", **kwargs)
@handler
async def start(self, email: str, ctx: WorkflowContext[EmailValidationRequest]) -> None:
request = EmailValidationRequest(email=email)
await ctx.send_message(request, target_id="email_workflow")
@intercepts_request
async def check_domain(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[DomainCheckRequest, bool]:
"""Check cache first, then forward if not found."""
if request.domain in self.cache:
# Return cached result
return RequestResponse[DomainCheckRequest, bool].handled(self.cache[request.domain])
# Not in cache, forward to external
return RequestResponse[DomainCheckRequest, bool].forward()
@handler
async def collect(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
self.result = result
# Setup workflows
email_validator = EmailValidator()
request_info = RequestInfoExecutor()
validation_workflow = (
WorkflowBuilder()
.set_start_executor(email_validator)
.add_edge(email_validator, request_info)
.add_edge(request_info, email_validator)
.build()
)
parent = ConditionalParent()
workflow_executor = WorkflowExecutor(validation_workflow, id="email_workflow")
parent_request_info = RequestInfoExecutor()
main_workflow = (
WorkflowBuilder()
.set_start_executor(parent)
.add_edge(parent, workflow_executor)
.add_edge(workflow_executor, parent)
.add_edge(parent, parent_request_info)
.add_edge(parent_request_info, workflow_executor) # For SubWorkflowResponse routing
.build()
)
# Test cached domain
result = await main_workflow.run("user@cached.com")
request_events = result.get_request_info_events()
assert len(request_events) == 0 # Handled from cache
assert parent.result is not None
assert parent.result.is_valid is True
# Test uncached domain
parent.result = None
result = await main_workflow.run("user@new.com")
request_events = result.get_request_info_events()
assert len(request_events) == 1 # Forwarded to external
await main_workflow.send_responses({request_events[0].request_id: True})
assert parent.result is not None
assert parent.result.is_valid is True
@pytest.mark.asyncio
async def test_workflow_scoped_interception() -> None:
"""Test interception scoped to specific sub-workflows."""
class MultiWorkflowParent(Executor):
"""Parent handling multiple sub-workflows."""
results: dict[str, ValidationResult] = Field(default_factory=dict)
def __init__(self, **kwargs: Any):
super().__init__(id="multi_parent", **kwargs)
@handler
async def start(self, data: dict[str, str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
# Send to different sub-workflows
await ctx.send_message(EmailValidationRequest(email=data["email1"]), target_id="workflow_a")
await ctx.send_message(EmailValidationRequest(email=data["email2"]), target_id="workflow_b")
@intercepts_request(from_workflow="workflow_a")
async def check_domain_a(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[DomainCheckRequest, bool]:
"""Strict rules for workflow A."""
if request.domain == "strict.com":
return RequestResponse[DomainCheckRequest, bool].handled(True)
return RequestResponse[DomainCheckRequest, bool].forward()
@intercepts_request(from_workflow="workflow_b")
async def check_domain_b(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[DomainCheckRequest, bool]:
"""Lenient rules for workflow B."""
if request.domain.endswith(".com"):
return RequestResponse[DomainCheckRequest, bool].handled(True)
return RequestResponse[DomainCheckRequest, bool].forward()
@handler
async def collect(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
self.results[result.email] = result
# Create two identical sub-workflows
def create_validation_workflow():
validator = EmailValidator()
request_info = RequestInfoExecutor()
return (
WorkflowBuilder()
.set_start_executor(validator)
.add_edge(validator, request_info)
.add_edge(request_info, validator)
.build()
)
workflow_a = create_validation_workflow()
workflow_b = create_validation_workflow()
parent = MultiWorkflowParent()
executor_a = WorkflowExecutor(workflow_a, id="workflow_a")
executor_b = WorkflowExecutor(workflow_b, id="workflow_b")
parent_request_info = RequestInfoExecutor()
main_workflow = (
WorkflowBuilder()
.set_start_executor(parent)
.add_edge(parent, executor_a)
.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 SubWorkflowResponse routing
.add_edge(parent_request_info, executor_b) # For SubWorkflowResponse routing
.build()
)
# Run test
result = await main_workflow.run({"email1": "user@strict.com", "email2": "user@random.com"})
# Workflow A should handle strict.com
# Workflow B should handle any .com domain
request_events = result.get_request_info_events()
assert len(request_events) == 0 # Both handled internally
assert len(parent.results) == 2
assert parent.results["user@strict.com"].is_valid is True
assert parent.results["user@random.com"].is_valid is True
if __name__ == "__main__":
# Run tests
asyncio.run(test_basic_sub_workflow())
asyncio.run(test_sub_workflow_with_interception())
asyncio.run(test_conditional_forwarding())
asyncio.run(test_workflow_scoped_interception())
@@ -11,6 +11,7 @@ from agent_framework.workflow import (
RequestInfoEvent,
RequestInfoExecutor,
RequestInfoMessage,
RequestResponse,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
@@ -68,10 +69,13 @@ class MockExecutorRequestApproval(Executor):
await ctx.send_message(RequestInfoMessage())
@handler
async def mock_handler_b(self, message: ApprovalMessage, ctx: WorkflowContext[NumberMessage]) -> None:
async def mock_handler_b(
self, message: RequestResponse[RequestInfoMessage, ApprovalMessage], ctx: WorkflowContext[NumberMessage]
) -> None:
"""A mock handler that processes the approval response."""
data = await ctx.get_shared_state(self.id)
if message.approved:
assert isinstance(message.data, ApprovalMessage)
if message.data.approved:
await ctx.add_event(WorkflowCompletedEvent(data=data))
else:
await ctx.send_message(NumberMessage(data=data))