Python: [Breaking] Remove WorkflowCompletedEvent, introduce workflow output and migrate to ctx.yield_output() + a huge refactoring (#845)

* Introduce input and output types for executor and workflow

* WorkflowOutputContext handles two types

* Remove can_handle_types from Executor

* Update validation

* Move workflow executor

* Move workflow executor

* Fix issues in WorkflowExecutor

* refactor executor

* update execute signature to create workflow context within Executor

* fix simple sub workflow test; fix validation

* fix output types in WorkflowExecutor

* fix issue in Executor handling of SubWorkflowRequestInfo

* update tests to use proper workflow output

* update orchestration patterns to use output

* Update sample -- not finished

* Update python/packages/main/tests/workflow/test_workflow_states.py

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

* Update python/packages/main/tests/workflow/test_concurrent.py

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

* address comments

* WorkflowOutputContext --> WorkflowContext

* remove WorkflowCompletedEvent

* update samples

* Update doc string for important classes; update WorkflowExecutor to support concurrent execution

* use Never instead of None for default type

* Update usage of WorkflowContext[None to WorkflowContext[Never

* address comments

* remove filter for None

* address comments, minor fixes

* quality of life improvement on interceptor types

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Eric Zhu
2025-09-23 13:52:53 -07:00
committed by GitHub
Unverified
parent 0f913bcdeb
commit 2133043f11
67 changed files with 2564 additions and 1648 deletions
@@ -33,12 +33,12 @@ from ._events import (
ExecutorFailedEvent,
ExecutorInvokedEvent,
RequestInfoEvent,
WorkflowCompletedEvent,
WorkflowErrorDetails,
WorkflowEvent,
WorkflowEventSource,
WorkflowFailedEvent,
WorkflowLifecycleEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStartedEvent,
WorkflowStatusEvent,
@@ -53,7 +53,6 @@ from ._executor import (
RequestResponse,
SubWorkflowRequestInfo,
SubWorkflowResponse,
WorkflowExecutor,
handler,
intercepts_request,
)
@@ -92,7 +91,6 @@ from ._validation import (
EdgeDuplicationError,
ExecutorDuplicationError,
GraphConnectivityError,
HandlerOutputAnnotationError,
TypeCompatibilityError,
ValidationTypeEnum,
WorkflowValidationError,
@@ -101,6 +99,7 @@ from ._validation import (
from ._viz import WorkflowViz
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
from ._workflow_context import WorkflowContext
from ._workflow_executor import WorkflowExecutor
__all__ = [
"DEFAULT_MAX_ITERATIONS",
@@ -126,7 +125,6 @@ __all__ = [
"FileCheckpointStorage",
"FunctionExecutor",
"GraphConnectivityError",
"HandlerOutputAnnotationError",
"InMemoryCheckpointStorage",
"InProcRunnerContext",
"MagenticAgentDeltaEvent",
@@ -170,7 +168,6 @@ __all__ = [
"WorkflowAgent",
"WorkflowBuilder",
"WorkflowCheckpoint",
"WorkflowCompletedEvent",
"WorkflowContext",
"WorkflowErrorDetails",
"WorkflowEvent",
@@ -178,6 +175,7 @@ __all__ = [
"WorkflowExecutor",
"WorkflowFailedEvent",
"WorkflowLifecycleEvent",
"WorkflowOutputEvent",
"WorkflowRunResult",
"WorkflowRunState",
"WorkflowStartedEvent",
@@ -29,12 +29,12 @@ from ._events import (
ExecutorFailedEvent,
ExecutorInvokedEvent,
RequestInfoEvent,
WorkflowCompletedEvent,
WorkflowErrorDetails,
WorkflowEvent,
WorkflowEventSource,
WorkflowFailedEvent,
WorkflowLifecycleEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStartedEvent,
WorkflowStatusEvent,
@@ -49,7 +49,6 @@ from ._executor import (
RequestResponse,
SubWorkflowRequestInfo,
SubWorkflowResponse,
WorkflowExecutor,
handler,
intercepts_request,
)
@@ -88,7 +87,6 @@ from ._validation import (
EdgeDuplicationError,
ExecutorDuplicationError,
GraphConnectivityError,
HandlerOutputAnnotationError,
TypeCompatibilityError,
ValidationTypeEnum,
WorkflowValidationError,
@@ -97,6 +95,7 @@ from ._validation import (
from ._viz import WorkflowViz
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
from ._workflow_context import WorkflowContext
from ._workflow_executor import WorkflowExecutor
__all__ = [
"DEFAULT_MAX_ITERATIONS",
@@ -122,7 +121,6 @@ __all__ = [
"FileCheckpointStorage",
"FunctionExecutor",
"GraphConnectivityError",
"HandlerOutputAnnotationError",
"InMemoryCheckpointStorage",
"InProcRunnerContext",
"MagenticAgentDeltaEvent",
@@ -166,7 +164,6 @@ __all__ = [
"WorkflowAgent",
"WorkflowBuilder",
"WorkflowCheckpoint",
"WorkflowCompletedEvent",
"WorkflowContext",
"WorkflowErrorDetails",
"WorkflowEvent",
@@ -174,6 +171,7 @@ __all__ = [
"WorkflowExecutor",
"WorkflowFailedEvent",
"WorkflowLifecycleEvent",
"WorkflowOutputEvent",
"WorkflowRunResult",
"WorkflowRunState",
"WorkflowStartedEvent",
@@ -79,7 +79,7 @@ class WorkflowAgent(BaseAgent):
except KeyError as exc: # Defensive: workflow lacks a configured entry point
raise ValueError("Workflow's start executor is not defined.") from exc
if not start_executor.can_handle_type(list[ChatMessage]):
if list[ChatMessage] not in start_executor.input_types:
raise ValueError("Workflow's start executor cannot handle list[ChatMessage]")
super().__init__(id=id, name=name, description=description, **kwargs)
@@ -6,9 +6,10 @@ import logging
from collections.abc import Callable, Sequence
from typing import Any
from typing_extensions import Never
from agent_framework import AgentProtocol, ChatMessage, Role
from ._events import WorkflowCompletedEvent
from ._executor import AgentExecutorRequest, AgentExecutorResponse, Executor, handler
from ._workflow import Workflow, WorkflowBuilder
from ._workflow_context import WorkflowContext
@@ -25,12 +26,14 @@ parallel workflow with:
Notes:
- Participants should be AgentProtocol instances or Executors.
- A custom aggregator can be provided as:
- an Executor instance (it should handle list[AgentExecutorResponse] and add a WorkflowCompletedEvent), or
- an Executor instance (it should handle list[AgentExecutorResponse],
yield output), or
- a callback function with signature:
def cb(results: list[AgentExecutorResponse]) -> Any | None
def cb(results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> Any | None
If the callback returns a non-None value, it is sent as the data of a WorkflowCompletedEvent.
If it returns None, the callback may have already emitted a completion event via ctx.
def cb(results: list[AgentExecutorResponse], ctx: WorkflowContext) -> Any | None
The callback is wrapped in _CallbackAggregator.
If the callback returns a non-None value, _CallbackAggregator yields that as output.
If it returns None, the callback may have already yielded an output via ctx, so no further action is taken.
"""
@@ -70,7 +73,9 @@ class _AggregateAgentConversations(Executor):
"""
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
async def aggregate(
self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, list[ChatMessage]]
) -> None:
if not results:
logger.error("Concurrent aggregator received empty results list")
raise ValueError("Aggregation failed: no results provided")
@@ -128,7 +133,7 @@ class _AggregateAgentConversations(Executor):
logger.warning("No user prompt found in any conversation; emitting assistants only")
output.extend(assistant_replies)
await ctx.add_event(WorkflowCompletedEvent(data=output))
await ctx.yield_output(output)
class _CallbackAggregator(Executor):
@@ -141,7 +146,7 @@ class _CallbackAggregator(Executor):
Notes:
- Async callbacks are awaited directly.
- Sync callbacks are executed via asyncio.to_thread to avoid blocking the event loop.
- If the callback returns a non-None value, it is wrapped in a WorkflowCompletedEvent.
- If the callback returns a non-None value, it is yielded as an output.
"""
def __init__(self, callback: Callable[..., Any], id: str | None = None) -> None:
@@ -153,7 +158,7 @@ class _CallbackAggregator(Executor):
self._param_count = len(inspect.signature(callback).parameters)
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, Any]) -> None:
# Call according to provided signature, always non-blocking for sync callbacks
if self._param_count >= 2:
if inspect.iscoroutinefunction(self._callback):
@@ -168,7 +173,7 @@ class _CallbackAggregator(Executor):
# If the callback returned a value, finalize the workflow with it
if ret is not None:
await ctx.add_event(WorkflowCompletedEvent(ret))
await ctx.yield_output(ret)
class ConcurrentBuilder:
@@ -187,8 +192,7 @@ class ConcurrentBuilder:
# Custom aggregator via callback (sync or async). The callback receives
# list[AgentExecutorResponse] and its return value becomes
# WorkflowCompletedEvent.data
# list[AgentExecutorResponse] and its return value becomes the workflow's output.
def summarize(results):
return " | ".join(r.agent_run_response.messages[-1].text for r in results)
@@ -245,13 +249,13 @@ class ConcurrentBuilder:
def with_aggregator(self, aggregator: Executor | Callable[..., Any]) -> "ConcurrentBuilder":
r"""Override the default aggregator with an Executor or a callback.
- Executor: must handle `list[AgentExecutorResponse]` and add a
`WorkflowCompletedEvent` to the context.
- Executor: must handle `list[AgentExecutorResponse]` and
yield output using `ctx.yield_output(...)` and add a
output and the workflow becomes idle.
- Callback: sync or async callable with one of the signatures:
`(results: list[AgentExecutorResponse]) -> Any | None` or
`(results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> Any | None`.
If the callback returns a non-None value, it becomes the
`WorkflowCompletedEvent.data`.
`(results: list[AgentExecutorResponse], ctx: WorkflowContext) -> Any | None`.
If the callback returns a non-None value, it becomes the workflow's output.
Example:
```python
@@ -277,7 +281,7 @@ class ConcurrentBuilder:
Wiring pattern:
- Dispatcher (internal) fans out the input to all `participants`
- Fan-in aggregator collects `AgentExecutorResponse` objects
- Aggregator emits a `WorkflowCompletedEvent` with either:
- Aggregator yields output and the workflow becomes idle. The output is either:
- list[ChatMessage] (default aggregator: one user + one assistant per agent)
- custom payload from the provided callback/executor
@@ -11,7 +11,6 @@ from ._edge import Edge, EdgeGroup, FanInEdgeGroup, FanOutEdgeGroup, SingleEdgeG
from ._executor import Executor
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
from ._workflow_context import WorkflowContext
logger = logging.getLogger(__name__)
@@ -64,19 +63,16 @@ class EdgeRunner(ABC):
target_executor = self._executors[target_id]
# Create WorkflowContext with trace contexts from message
workflow_context: WorkflowContext[Any] = WorkflowContext(
target_id,
source_ids,
shared_state,
ctx,
trace_contexts=message.trace_contexts, # Pass trace contexts to WorkflowContext
# Execute with trace context parameters
await target_executor.execute(
message.data,
source_ids, # source_executor_ids
shared_state, # shared_state
ctx, # runner_context
trace_contexts=message.trace_contexts, # Pass trace contexts
source_span_ids=message.source_span_ids, # Pass source span IDs for linking
)
# Execute with trace context in WorkflowContext
await target_executor.execute(message.data, workflow_context)
class SingleEdgeRunner(EdgeRunner):
"""Runner for single edge groups."""
@@ -66,16 +66,6 @@ class WorkflowStartedEvent(WorkflowEvent):
...
class WorkflowCompletedEvent(WorkflowEvent):
"""Built-in lifecycle event emitted when a workflow run completes successfully.
Unlike the framework-only `WorkflowLifecycleEvent` union, this event can be
emitted by developer-provided executors to return final workflow output.
"""
...
class WorkflowWarningEvent(WorkflowEvent):
"""Executor-origin event signaling a warning surfaced by user code."""
@@ -120,16 +110,14 @@ class WorkflowRunState(str, Enum):
request-for-information operations are outstanding. New work may still
be scheduled while requests are in flight.
- IDLE: The workflow is quiescent with no outstanding requests, but has
not yet emitted a terminal result. Rare in practice but provided for
orchestration integrations that distinguish a quiescent state.
- IDLE: The workflow is quiescent with no outstanding requests and no more
work to do. This is the normal terminal state for workflows that have
finished executing, potentially having produced outputs along the way.
- IDLE_WITH_PENDING_REQUESTS: The workflow is paused awaiting external
input (e.g., emitted a `RequestInfoEvent`). This is a non-terminal
state; the workflow can resume when responses are supplied.
- COMPLETED: Normal terminal state indicating successful completion.
- FAILED: Terminal state indicating an error surfaced. Accompanied by a
`WorkflowFailedEvent` with structured error details.
@@ -143,7 +131,6 @@ class WorkflowRunState(str, Enum):
IN_PROGRESS_PENDING_REQUESTS = "IN_PROGRESS_PENDING_REQUESTS" # Active execution with outstanding requests
IDLE = "IDLE" # No active work and no outstanding requests
IDLE_WITH_PENDING_REQUESTS = "IDLE_WITH_PENDING_REQUESTS" # Paused awaiting external responses
COMPLETED = "COMPLETED" # Finished successfully
FAILED = "FAILED" # Finished with an error
CANCELLED = "CANCELLED" # Finished due to cancellation
@@ -250,6 +237,28 @@ class RequestInfoEvent(WorkflowEvent):
)
class WorkflowOutputEvent(WorkflowEvent):
"""Event triggered when a workflow executor yields output."""
def __init__(
self,
data: Any,
source_executor_id: str,
):
"""Initialize the workflow output event.
Args:
data: The output yielded by the executor.
source_executor_id: ID of the executor that yielded the output.
"""
super().__init__(data)
self.source_executor_id = source_executor_id
def __repr__(self) -> str:
"""Return a string representation of the workflow output event."""
return f"{self.__class__.__name__}(data={self.data}, source_executor_id={self.source_executor_id})"
class ExecutorEvent(WorkflowEvent):
"""Base class for executor events."""
File diff suppressed because it is too large Load Diff
@@ -11,67 +11,11 @@ This module provides:
"""
import asyncio
import inspect
from collections.abc import Awaitable, Callable
from types import UnionType
from typing import Any, Union, get_args, get_origin, overload
from typing import Any, overload
from ._executor import Executor
from ._workflow_context import WorkflowContext
def _is_workflow_context_type(annotation: Any) -> bool:
"""Check if an annotation represents WorkflowContext[T]."""
origin = get_origin(annotation)
if origin is WorkflowContext:
return True
# Also handle the case where the raw WorkflowContext class is used
return annotation is WorkflowContext
def _infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> list[type]:
"""Infer output types list from the WorkflowContext generic parameter.
Examples:
- WorkflowContext[str] -> [str]
- WorkflowContext[str | int] -> [str, int]
- WorkflowContext[Union[str, int]] -> [str, int]
- WorkflowContext[Any] -> [] (unknown)
- WorkflowContext[None] -> []
"""
# If no annotation or not parameterized, return empty list
try:
origin = get_origin(ctx_annotation)
except Exception:
origin = None
# If annotation is unsubscripted WorkflowContext, nothing to infer
if origin is None:
return []
# Expecting WorkflowContext[T]
if origin is not WorkflowContext:
return []
args = get_args(ctx_annotation)
if not args:
return []
t = args[0]
# If t is a Union, flatten it
t_origin = get_origin(t)
# If Any, treat as unknown -> no output types inferred
if t is Any:
return []
if t_origin in (Union, UnionType):
# Return all union args as-is (may include generic aliases like list[str])
return [arg for arg in get_args(t) if arg is not Any and arg is not type(None)]
# Single concrete or generic alias type (e.g., str, int, list[str])
if t is Any or t is type(None):
return []
return [t]
from ._workflow_context import WorkflowContext, validate_function_signature
class FunctionExecutor(Executor):
@@ -85,61 +29,19 @@ class FunctionExecutor(Executor):
"""
@staticmethod
def _validate_function(func: Callable[..., Any]) -> None:
def _validate_function(func: Callable[..., Any]) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
"""Validate that the function has the correct signature for an executor.
Args:
func: The function to validate (can be sync or async)
Returns:
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
Raises:
ValueError: If the function signature is incorrect
"""
signature = inspect.signature(func)
params = list(signature.parameters.values())
if len(params) not in (1, 2):
raise ValueError(
f"Function {func.__name__} must have one or two parameters: "
f"(message: T) or (message: T, ctx: WorkflowContext[U]). Got {len(params)} parameters."
)
message_param = params[0]
# Check message parameter has type annotation
if message_param.annotation == inspect.Parameter.empty:
raise ValueError(f"Function {func.__name__} must have a type annotation for the message parameter")
# If there's a second parameter, validate it's WorkflowContext[T]
if len(params) == 2:
ctx_param = params[1]
# Check ctx parameter has proper type annotation
if ctx_param.annotation == inspect.Parameter.empty:
raise ValueError(f"Function {func.__name__} second parameter must be annotated as WorkflowContext[T]")
# Validate that ctx parameter is WorkflowContext[T]
if not _is_workflow_context_type(ctx_param.annotation):
raise ValueError(
f"Function {func.__name__} second parameter must be annotated as WorkflowContext[T], "
f"got {ctx_param.annotation}"
)
# Check that WorkflowContext has a concrete type parameter
if ctx_param.annotation is WorkflowContext:
# This is unparameterized WorkflowContext
raise ValueError(
f"Function {func.__name__} WorkflowContext must be parameterized with a concrete T. "
f"Use WorkflowContext[str], WorkflowContext[int], etc."
)
if hasattr(ctx_param.annotation, "__args__") and ctx_param.annotation.__args__:
# This is WorkflowContext[T] with a concrete T
pass
else:
raise ValueError(
f"Function {func.__name__} WorkflowContext must be parameterized with a concrete T. "
f"Use WorkflowContext[str], WorkflowContext[int], etc."
)
return validate_function_signature(func, "Function")
def __init__(self, func: Callable[..., Any], id: str | None = None):
"""Initialize the FunctionExecutor with a user-defined function.
@@ -148,27 +50,13 @@ class FunctionExecutor(Executor):
func: The function to wrap as an executor (can be sync or async)
id: Optional executor ID. If None, uses the function name.
"""
# Validate function signature first
self._validate_function(func)
# Extract types from function signature
signature = inspect.signature(func)
params = list(signature.parameters.values())
message_type = params[0].annotation
# Validate function signature and extract types
message_type, ctx_annotation, output_types, workflow_output_types = self._validate_function(func)
# Determine if function has WorkflowContext parameter
has_context = len(params) == 2
has_context = ctx_annotation is not None
is_async = asyncio.iscoroutinefunction(func)
if has_context:
ctx_annotation = params[1].annotation
output_types = _infer_output_types_from_ctx_annotation(ctx_annotation)
else:
# For single-parameter functions, we can't infer output types
ctx_annotation = None
output_types = []
# Initialize parent WITHOUT calling _discover_handlers yet
# We'll manually set up the attributes first
executor_id = id or getattr(func, "__name__", "FunctionExecutor")
@@ -181,7 +69,7 @@ class FunctionExecutor(Executor):
self._handlers: dict[type, Callable[[Any, WorkflowContext[Any]], Any]] = {}
self._request_interceptors: dict[type | str, list[dict[str, Any]]] = {}
self._instance_handler_specs: list[dict[str, Any]] = []
self._handler_specs: list[dict[str, Any]] = []
# Store the original function and whether it has context
self._original_func = func
@@ -211,12 +99,13 @@ class FunctionExecutor(Executor):
return await asyncio.to_thread(func, message) # type: ignore
# Now register our instance handler
self.register_instance_handler(
self._register_instance_handler(
name=func.__name__,
func=wrapped_func,
message_type=message_type,
ctx_annotation=ctx_annotation,
output_types=output_types,
workflow_output_types=workflow_output_types,
)
# Now we can safely call _discover_handlers (it won't find any class-level handlers)
@@ -28,7 +28,7 @@ from agent_framework import (
from agent_framework._agents import BaseAgent
from agent_framework._pydantic import AFBaseModel
from ._events import WorkflowCompletedEvent, WorkflowEvent
from ._events import WorkflowEvent
from ._executor import Executor, RequestInfoMessage, RequestResponse, handler
from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult
from ._workflow_context import WorkflowContext
@@ -840,7 +840,9 @@ class MagenticOrchestratorExecutor(Executor):
async def handle_start_message(
self,
message: MagenticStartMessage,
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest],
context: WorkflowContext[
MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage
],
) -> None:
"""Handle the initial start message to begin orchestration."""
if getattr(self, "_terminated", False):
@@ -877,7 +879,7 @@ class MagenticOrchestratorExecutor(Executor):
# Start the inner loop
ctx2 = cast(
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
context,
)
await self._run_inner_loop(ctx2)
@@ -886,7 +888,7 @@ class MagenticOrchestratorExecutor(Executor):
async def handle_response_message(
self,
message: MagenticResponseMessage,
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
) -> None:
"""Handle responses from agents."""
if getattr(self, "_terminated", False):
@@ -916,7 +918,7 @@ class MagenticOrchestratorExecutor(Executor):
response: RequestResponse[MagenticPlanReviewRequest, MagenticPlanReviewReply],
context: WorkflowContext[
# may broadcast ledger next, or ask for another round of review
MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest
MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage
],
) -> None:
if getattr(self, "_terminated", False):
@@ -968,7 +970,7 @@ class MagenticOrchestratorExecutor(Executor):
# Enter the normal coordination loop
ctx2 = cast(
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
context,
)
await self._run_inner_loop(ctx2)
@@ -997,7 +999,7 @@ class MagenticOrchestratorExecutor(Executor):
self._context.chat_history.append(self._task_ledger)
# No further review requests; proceed directly into coordination
ctx2 = cast(
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
context,
)
await self._run_inner_loop(ctx2)
@@ -1032,7 +1034,7 @@ class MagenticOrchestratorExecutor(Executor):
async def _run_outer_loop(
self,
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
) -> None:
"""Run the outer orchestration loop - planning phase."""
if self._context is None:
@@ -1056,7 +1058,7 @@ class MagenticOrchestratorExecutor(Executor):
async def _run_inner_loop(
self,
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
) -> None:
"""Run the inner orchestration loop. Coordination phase. Serialized with a lock."""
if self._context is None or self._task_ledger is None:
@@ -1066,7 +1068,7 @@ class MagenticOrchestratorExecutor(Executor):
async def _run_inner_loop_locked(
self,
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
) -> None:
"""Run inner loop with exclusive access."""
# Narrow optional context for the remainder of this method
@@ -1154,7 +1156,7 @@ class MagenticOrchestratorExecutor(Executor):
async def _reset_and_replan(
self,
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
) -> None:
"""Reset context and replan."""
if self._context is None:
@@ -1178,7 +1180,7 @@ class MagenticOrchestratorExecutor(Executor):
async def _prepare_final_answer(
self,
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
) -> None:
"""Prepare the final answer using the manager."""
if self._context is None:
@@ -1188,14 +1190,14 @@ class MagenticOrchestratorExecutor(Executor):
final_answer = await self._manager.prepare_final_answer(self._context.model_copy(deep=True))
# Emit a completed event for the workflow
await context.add_event(WorkflowCompletedEvent(final_answer))
await context.yield_output(final_answer)
if self._result_callback:
await self._result_callback(final_answer)
async def _check_within_limits_or_complete(
self,
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage],
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage],
) -> bool:
"""Check if orchestrator is within operational limits."""
if self._context is None:
@@ -1221,8 +1223,8 @@ class MagenticOrchestratorExecutor(Executor):
author_name=MAGENTIC_MANAGER_NAME,
)
# Emit a completed event with the partial result
await context.add_event(WorkflowCompletedEvent(partial_result))
# Yield the partial result and signal completion
await context.yield_output(partial_result)
if self._result_callback:
await self._result_callback(partial_result)
@@ -1232,7 +1234,9 @@ class MagenticOrchestratorExecutor(Executor):
async def _send_plan_review_request(
self,
context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest],
context: WorkflowContext[
MagenticResponseMessage | MagenticRequestMessage | MagenticPlanReviewRequest, ChatMessage
],
) -> None:
"""Emit a PlanReviewRequest via RequestInfoExecutor."""
# If plan sign-off is disabled (e.g., ran out of review rounds), do nothing
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
from ._events import WorkflowCompletedEvent, WorkflowEvent, _framework_event_origin
from ._events import WorkflowEvent, WorkflowOutputEvent, _framework_event_origin
from ._executor import Executor
from ._runner_context import (
_DATACLASS_MARKER,
@@ -23,7 +23,6 @@ from ._runner_context import (
)
from ._shared_state import SharedState
from ._typing_utils import is_instance_of
from ._workflow_context import WorkflowContext
logger = logging.getLogger(__name__)
@@ -204,16 +203,14 @@ class Runner:
f"from sub-workflow '{sub_request.sub_workflow_id}' "
f"to executor '{executor.id}' for interception."
)
# Create WorkflowContext with trace context from message
workflow_ctx: WorkflowContext[Any] = WorkflowContext(
executor.id,
[message.source_id],
self._shared_state,
self._ctx,
await executor.execute(
sub_request,
[message.source_id], # source_executor_ids
self._shared_state, # shared_state
self._ctx, # runner_context
trace_contexts=[message.trace_context] if message.trace_context else None,
source_span_ids=[message.source_span_id] if message.source_span_id else None,
)
await executor.execute(sub_request, workflow_ctx)
interceptor_found = True
break
if interceptor_found:
@@ -226,20 +223,19 @@ class Runner:
request_info_executor = self._find_request_info_executor()
if request_info_executor:
request_info_workflow_ctx: WorkflowContext[None] = WorkflowContext(
request_info_executor.id,
[message.source_id],
self._shared_state,
self._ctx,
trace_contexts=[message.trace_context] if message.trace_context else None,
source_span_ids=[message.source_span_id] if message.source_span_id else None,
)
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, request_info_workflow_ctx)
await request_info_executor.execute(
sub_request,
[message.source_id], # source_executor_ids
self._shared_state, # shared_state
self._ctx, # runner_context
trace_contexts=[message.trace_context] if message.trace_context else None,
source_span_ids=[message.source_span_id] if message.source_span_id else None,
)
else:
logger.warning(
f"Sub-workflow request of type '{sub_request.data.__class__.__name__}' "
@@ -303,8 +299,9 @@ class Runner:
final_messages = message.data.agent_run_response.messages
final_text = final_messages[-1].text if final_messages else "(no content)"
with _framework_event_origin():
completion_event = WorkflowCompletedEvent(final_text)
await self._ctx.add_event(completion_event)
# TODO(moonbox3): does user expect this event to contain the final text?
output_event = WorkflowOutputEvent(data=final_text, source_executor_id="<Runner>")
await self._ctx.add_event(output_event)
continue # Terminal handled
except Exception as exc: # pragma: no cover - defensive
logger.debug("Suppressed exception during terminal message type check: %s", exc)
@@ -326,8 +323,9 @@ class Runner:
final_messages = message.data.agent_run_response.messages
final_text = final_messages[-1].text if final_messages else "(no content)"
with _framework_event_origin():
completion_event = WorkflowCompletedEvent(final_text)
await self._ctx.add_event(completion_event)
# TODO(moonbox3): does user expect this event to contain the final text?
output_event = WorkflowOutputEvent(data=final_text, source_executor_id="<Runner>")
await self._ctx.add_event(output_event)
continue
except Exception as exc: # pragma: no cover
logger.debug("Terminal completion emission failed: %s", exc)
@@ -8,10 +8,10 @@ workflow where:
- A shared conversation context (list[ChatMessage]) is passed along the chain
- Agents append their assistant messages to the context
- Custom executors can transform or summarize and return a refined context
- The workflow completes with the final context produced by the last participant
- The workflow finishes with the final context produced by the last participant
Typical wiring:
input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> ... -> participantN -> _CompleteWithConversation
input -> _InputToConversation -> participant1 -> (agent? -> _ResponseToConversation) -> ... -> participantN -> _EndWithConversation
Notes:
- Participants can mix AgentProtocol and Executor objects
@@ -27,9 +27,8 @@ Why include the small internal adapter executors?
- Agent response adaptation ("to-conversation:<participant>"): agents (via AgentExecutor)
emit `AgentExecutorResponse`. The adapter converts that to a `list[ChatMessage]`
using `full_conversation` so original prompts aren't lost when chaining.
- Explicit completion ("complete"): emits a `WorkflowCompletedEvent` with the final
conversation list, giving a consistent terminal payload shape for both agents and
custom executors.
- Result output ("end"): yields the final conversation list and the workflow becomes idle
giving a consistent terminal payload shape for both agents and custom executors.
These adapters are first-class executors by design so they are type-checked at edges,
observable (ExecutorInvoke/Completed events), and easily testable/reusable. Their IDs are
@@ -43,7 +42,6 @@ from typing import Any
from agent_framework import AgentProtocol, ChatMessage, Role
from ._events import WorkflowCompletedEvent
from ._executor import (
AgentExecutor,
AgentExecutorResponse,
@@ -84,12 +82,12 @@ class _ResponseToConversation(Executor):
await ctx.send_message(list(response.full_conversation))
class _CompleteWithConversation(Executor):
class _EndWithConversation(Executor):
"""Terminates the workflow by emitting the final conversation context."""
@handler
async def complete(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any]) -> None:
await ctx.add_event(WorkflowCompletedEvent(data=list(conversation)))
async def end(self, conversation: list[ChatMessage], ctx: WorkflowContext[Any, list[ChatMessage]]) -> None:
await ctx.yield_output(list(conversation))
class SequentialBuilder:
@@ -148,14 +146,14 @@ class SequentialBuilder:
- If Agent (or AgentExecutor): pass conversation to the agent, then convert response
to conversation via _ResponseToConversation
- Else (custom Executor): pass conversation directly to the executor
- _CompleteWithConversation emits WorkflowCompletedEvent with the final conversation
- _EndWithConversation yields the final conversation and the workflow becomes idle
"""
if not self._participants:
raise ValueError("No participants provided. Call .participants([...]) first.")
# Internal nodes
input_conv = _InputToConversation(id="input-conversation")
complete = _CompleteWithConversation(id="complete")
end = _EndWithConversation(id="end")
builder = WorkflowBuilder()
builder.set_start_executor(input_conv)
@@ -182,6 +180,6 @@ class SequentialBuilder:
raise TypeError(f"Unsupported participant type: {type(p).__name__}")
# Terminate with the final conversation
builder.add_edge(prior, complete)
builder.add_edge(prior, end)
return builder.build()
@@ -9,26 +9,12 @@ from types import UnionType
from typing import Any, Union, get_args, get_origin
from ._edge import Edge, EdgeGroup, FanInEdgeGroup
from ._executor import Executor
from ._executor import Executor, RequestInfoExecutor
from ._workflow_executor import WorkflowExecutor
logger = logging.getLogger(__name__)
def _is_type_like(x: Any) -> bool:
"""Check if a value is a type-like entity.
A "type-like" entry is either a class/type or a typing alias
(e.g., list[str] has an origin and args).
Args:
x: The value to check
Returns:
True if the value is type-like, False otherwise
"""
return isinstance(x, type) or get_origin(x) is not None
# region Enums and Base Classes
class ValidationTypeEnum(Enum):
"""Enumeration of workflow validation types."""
@@ -108,23 +94,6 @@ class GraphConnectivityError(WorkflowValidationError):
super().__init__(message, validation_type=ValidationTypeEnum.GRAPH_CONNECTIVITY)
class HandlerOutputAnnotationError(WorkflowValidationError):
"""Exception raised when a handler's WorkflowContext output annotation is invalid or missing."""
def __init__(self, executor_id: str, handler_name: str, reason: str):
super().__init__(
message=(
"Invalid WorkflowContext output annotation in handler "
f"'{handler_name}' of executor '{executor_id}': {reason}. "
"Handlers must annotate their third parameter as WorkflowContext[T]. "
"Use WorkflowContext[None] if the handler emits no messages."
),
validation_type=ValidationTypeEnum.HANDLER_OUTPUT_ANNOTATION,
)
self.executor_id = executor_id
self.handler_name = handler_name
class InterceptorConflictError(WorkflowValidationError):
"""Exception raised when multiple executors intercept the same request type from the same sub-workflow."""
@@ -216,7 +185,6 @@ class WorkflowGraphValidator:
self._validate_type_compatibility()
self._validate_graph_connectivity(start_executor_id)
self._validate_self_loops()
self._validate_handler_ambiguity()
self._validate_dead_ends()
self._validate_cycles()
self._validate_interceptor_uniqueness()
@@ -224,158 +192,18 @@ class WorkflowGraphValidator:
def _validate_handler_output_annotations(self) -> None:
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
Requirements:
- WorkflowContext annotation must be present
- T_Out must be provided; if no outputs, it must be None
- T_Out elements must be valid types (class) or typing generics (e.g., list[str]);
values like int() or 123 are invalid
Note: This validation is now primarily handled at handler registration time
via the unified validation functions in _workflow_context.py when the @handler
decorator is applied. This method is kept minimal for any edge cases.
"""
from ._workflow_context import WorkflowContext # Local import to avoid cycles
# Iterate over all registered executors in the workflow graph
for executor_id, executor in self._executors.items():
for attr_name in dir(executor.__class__):
if attr_name.startswith("_"):
continue
# Retrieve attributes without binding (so the first parameter remains 'self').
# This ensures inspect.signature sees all three parameters: (self, message, ctx).
attr = None
from contextlib import suppress
with suppress(Exception):
attr = inspect.getattr_static(executor.__class__, attr_name)
if attr is None:
continue
# Consider only callables that were decorated with @handler
if not callable(attr) or not hasattr(attr, "_handler_spec"):
continue
handler_spec = attr._handler_spec # type: ignore[attr-defined]
handler_name = handler_spec.get("name", attr_name)
try:
# Inspect the function signature of the unbound function
sig = inspect.signature(attr)
except (TypeError, ValueError):
continue
params = list(sig.parameters.values())
# Handlers must have exactly three parameters: (self, message, ctx)
if len(params) != 3:
continue
ctx_param = params[2]
ctx_ann = ctx_param.annotation
# If ctx lacks an annotation entirely, fail fast with a clear message
if ctx_ann is inspect.Parameter.empty:
raise HandlerOutputAnnotationError(executor_id, handler_name, "missing type annotation for ctx")
# Validate that the ctx annotation is WorkflowContext[...] and is properly parameterized
ctx_origin = get_origin(ctx_ann)
if ctx_origin is None:
# If it's exactly the WorkflowContext class, T_Out is missing (e.g., WorkflowContext)
if ctx_ann is WorkflowContext:
raise HandlerOutputAnnotationError(
executor_id,
handler_name,
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
)
else:
# The annotation is parameterized, but must be for WorkflowContext
if ctx_origin is not WorkflowContext:
raise HandlerOutputAnnotationError(
executor_id, handler_name, f"ctx must be WorkflowContext[T], got {ctx_ann}"
)
# Extract and validate T_Out
type_args = get_args(ctx_ann)
if not type_args:
raise HandlerOutputAnnotationError(
executor_id,
handler_name,
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
)
t_out = type_args[0]
# Allow Any for T_Out (unspecified outputs). We accept this here and
# skip type compatibility later, but still enforce shape validity elsewhere.
if t_out is Any:
continue
# Allow None (no outputs) explicitly declared
if t_out is type(None):
continue
# If T_Out is a union, validate each member (e.g., str | int)
union_origin = get_origin(t_out)
type_items: list[Any]
type_items = list(get_args(t_out)) if union_origin in (Union, UnionType) else [t_out]
invalid = [x for x in type_items if not _is_type_like(x) and x is not type(None)]
if invalid:
raise HandlerOutputAnnotationError(
executor_id,
handler_name,
f"T_Out contains invalid entries: {invalid}. Use proper types or typing generics",
)
# Also validate instance-level handler specs if present
if hasattr(executor, "_instance_handler_specs"):
for spec in executor._instance_handler_specs:
handler_name = spec.get("name", "unknown")
ctx_ann = spec.get("ctx_annotation")
if ctx_ann is None:
continue # Skip if no annotation stored
# Validate that the ctx annotation is WorkflowContext[...] and is properly parameterized
ctx_origin = get_origin(ctx_ann)
if ctx_origin is None:
if ctx_ann is WorkflowContext:
raise HandlerOutputAnnotationError(
executor_id,
handler_name,
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
)
else:
if ctx_origin is not WorkflowContext:
raise HandlerOutputAnnotationError(
executor_id, handler_name, f"ctx must be WorkflowContext[T], got {ctx_ann}"
)
# Extract and validate T_Out
type_args = get_args(ctx_ann)
if not type_args:
raise HandlerOutputAnnotationError(
executor_id,
handler_name,
"T_Out is missing; use WorkflowContext[None] or specify concrete types",
)
t_out = type_args[0]
# Allow Any for T_Out (unspecified outputs)
if t_out is Any:
continue
# Allow None (no outputs) explicitly declared
if t_out is type(None):
continue
# If T_Out is a union, validate each member
union_origin = get_origin(t_out)
instance_type_items: list[Any]
instance_type_items = list(get_args(t_out)) if union_origin in (Union, UnionType) else [t_out]
invalid = [x for x in instance_type_items if not _is_type_like(x) and x is not type(None)]
if invalid:
raise HandlerOutputAnnotationError(
executor_id,
handler_name,
f"T_Out contains invalid entries: {invalid}. Use proper types or typing generics",
)
# The comprehensive validation is already done during handler registration:
# 1. @handler decorator calls validate_function_signature()
# 2. FunctionExecutor constructor calls validate_function_signature()
# 3. Both use validate_workflow_context_annotation() for WorkflowContext validation
#
# All executors in the workflow must have gone through one of these paths,
# so redundant validation here is unnecessary and has been removed.
pass
# endregion
@@ -444,29 +272,25 @@ class WorkflowGraphValidator:
target_executor = self._executors[edge.target_id]
# Get output types from source executor
source_output_types = self._get_executor_output_types(source_executor)
source_output_types = list(source_executor.output_types)
# Also include intercepted request types as potential outputs
# since @intercepts_request methods can forward requests
source_output_types.extend(source_executor.request_types)
# Get input types from target executor
target_input_types = self._get_executor_input_types(target_executor)
target_input_types = target_executor.input_types
# 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:
# 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):
# Suppress warnings for RequestInfoExecutor where dynamic typing is expected
if not source_output_types and not isinstance(source_executor, RequestInfoExecutor):
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 and not isinstance(target_executor, builtin_types):
if not target_input_types and not isinstance(target_executor, RequestInfoExecutor):
logger.warning(
f"Executor '{target_executor.id}' has no input type annotations. "
f"Type compatibility validation will be skipped for edges to this executor. "
@@ -506,62 +330,6 @@ class WorkflowGraphValidator:
target_input_types,
)
def _get_executor_output_types(self, executor: Executor) -> list[type[Any]]:
"""Extract output types from an executor's message handlers.
Args:
executor: The executor to analyze
Returns:
list of types that this executor can output
"""
output_types: list[type[Any]] = []
for attr_name in dir(executor.__class__):
if attr_name.startswith("_"):
continue
try:
attr = getattr(executor.__class__, attr_name)
if callable(attr) and hasattr(attr, "_handler_spec"):
handler_spec = attr._handler_spec # type: ignore
handler_output_types = handler_spec.get("output_types", [])
output_types.extend(handler_output_types)
except AttributeError:
# 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)
# Include output types from instance-level handler specs
if hasattr(executor, "_instance_handler_specs"):
for spec in executor._instance_handler_specs:
handler_output_types = spec.get("output_types", [])
output_types.extend(handler_output_types)
return output_types
def _get_executor_input_types(self, executor: Executor) -> list[type[Any]]:
"""Extract input types from an executor's message handlers.
Args:
executor: The executor to analyze
Returns:
list of types that this executor can handle as input
"""
input_types: list[type[Any]] = []
# Access the private _handlers attribute to get input types
if hasattr(executor, "_handlers"):
input_types.extend(executor._handlers.keys()) # type: ignore
return input_types
# endregion
# region Graph Connectivity Validation
@@ -650,30 +418,6 @@ class WorkflowGraphValidator:
f"This may cause infinite recursion if not properly handled with conditions."
)
def _validate_handler_ambiguity(self) -> None:
"""Check for potential ambiguity in message handlers.
Warns when executors have multiple handlers that could handle the same type,
which might lead to unexpected behavior.
"""
for executor_id, executor in self._executors.items():
input_types = self._get_executor_input_types(executor)
# Check for duplicate input types
seen_types: set[type[Any]] = set()
duplicate_types: set[type[Any]] = set()
for input_type in input_types:
if input_type in seen_types:
duplicate_types.add(input_type)
seen_types.add(input_type)
if duplicate_types:
logger.warning(
f"Executor '{executor_id}' has multiple handlers for the same input types: "
f"{[str(t) for t in duplicate_types]}. This may lead to ambiguous message routing."
)
def _validate_dead_ends(self) -> None:
"""Identify executors that have no outgoing edges (potential dead ends).
@@ -744,8 +488,6 @@ class WorkflowGraphValidator:
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():
@@ -253,7 +253,7 @@ class WorkflowViz:
"""Emit DOT subgraphs for any WorkflowExecutor instances found in the workflow."""
# Lazy import to avoid any potential import cycles
try:
from ._executor import WorkflowExecutor # type: ignore
from ._workflow_executor import WorkflowExecutor # type: ignore
except ImportError: # pragma: no cover - best-effort; if unavailable, skip subgraphs
return
@@ -327,7 +327,7 @@ class WorkflowViz:
def _emit_sub_workflows_mermaid(self, wf: Workflow, lines: list[str], indent: str) -> None:
try:
from ._executor import WorkflowExecutor # type: ignore
from ._workflow_executor import WorkflowExecutor # type: ignore
except ImportError: # pragma: no cover
return
@@ -30,10 +30,10 @@ from ._edge import (
)
from ._events import (
RequestInfoEvent,
WorkflowCompletedEvent,
WorkflowErrorDetails,
WorkflowEvent,
WorkflowFailedEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStartedEvent,
WorkflowStatusEvent,
@@ -56,32 +56,36 @@ logger = logging.getLogger(__name__)
class WorkflowRunResult(list[WorkflowEvent]):
"""A list of events generated during the workflow execution in non-streaming mode.
"""Container for events generated during non-streaming workflow execution.
Preserves the historical contract that the list contains data-plane events
only (executor invoke/complete, completed, requests), while exposing the
control-plane status timeline via accessors.
## Overview
Represents the complete execution results of a workflow run, containing all events
generated from start to idle state. Workflows produce outputs incrementally through
ctx.yield_output() calls during execution.
## Event Structure
Maintains separation between data-plane and control-plane events:
- Data-plane events: Executor invocations, completions, outputs, and requests (in main list)
- Control-plane events: Status timeline accessible via status_timeline() method
## Key Methods
- get_outputs(): Extract all workflow outputs from the execution
- get_request_info_events(): Retrieve external input requests made during execution
- get_final_state(): Get the final workflow state (IDLE, IDLE_WITH_PENDING_REQUESTS, etc.)
- status_timeline(): Access the complete status event history
"""
def __init__(self, events: list[WorkflowEvent], status_events: list[WorkflowStatusEvent] | None = None) -> None:
super().__init__(events)
self._status_events: list[WorkflowStatusEvent] = status_events or []
def get_completed_event(self) -> WorkflowCompletedEvent | None:
"""Get the completed event from the workflow run result.
def get_outputs(self) -> list[Any]:
"""Get all outputs from the workflow run result.
Returns:
A completed WorkflowEvent instance if the workflow has a completed event, otherwise None.
Raises:
ValueError: If there are multiple completed events in the workflow run result.
A list of outputs produced by the workflow during its execution.
"""
completed_events = [event for event in self if isinstance(event, WorkflowCompletedEvent)]
if not completed_events:
return None
if len(completed_events) > 1:
raise ValueError("Multiple completed events found.")
return completed_events[0]
return [event.data for event in self if isinstance(event, WorkflowOutputEvent)]
def get_request_info_events(self) -> list[RequestInfoEvent]:
"""Get all request info events from the workflow run result.
@@ -113,10 +117,54 @@ class WorkflowRunResult(list[WorkflowEvent]):
class Workflow(AFBaseModel):
"""A class representing a workflow that can be executed.
"""A graph-based execution engine that orchestrates connected executors.
This class is a placeholder for the workflow logic and does not implement any specific functionality.
It serves as a base class for more complex workflows that can be defined in subclasses.
## Overview
A workflow executes a directed graph of executors connected via edge groups using a Pregel-like model,
running in supersteps until the graph becomes idle. Workflows are created using the
WorkflowBuilder class - do not instantiate this class directly.
## Execution Model
Executors run in synchronized supersteps where each executor:
- Is invoked when it receives messages from connected edge groups
- Can send messages to downstream executors via ctx.send_message()
- Can yield workflow-level outputs via ctx.yield_output()
- Can emit custom events via ctx.add_event()
Messages between executors are delivered at the end of each superstep and are not
visible in the event stream. Only workflow-level events (outputs, custom events)
and status events are observable to callers.
## Input/Output Types
Workflow types are discovered at runtime by inspecting:
- Input types: From the start executor's input types
- Output types: Union of all executors' workflow output types
Access these via the input_types and output_types properties.
## Execution Methods
- run(): Execute to completion, returns WorkflowRunResult with all events
- run_stream(): Returns async generator yielding events as they occur
- run_from_checkpoint(): Resume from a saved checkpoint
- run_stream_from_checkpoint(): Resume from checkpoint with streaming
## External Input Requests
Workflows can request external input using a RequestInfoExecutor:
1. Executor connects to RequestInfoExecutor via edge group and back to itself
2. Executor sends RequestInfoMessage to RequestInfoExecutor
3. RequestInfoExecutor emits RequestInfoEvent and workflow enters IDLE_WITH_PENDING_REQUESTS
4. Caller handles requests and uses send_responses()/send_responses_streaming() to continue
## Checkpointing
When enabled, checkpoints are created at the end of each superstep, capturing:
- Executor states
- Messages in transit
- Shared state
Workflows can be paused and resumed across process restarts using checkpoint storage.
## Composition
Workflows can be nested using WorkflowExecutor, which wraps a child workflow as an executor.
The nested workflow's input/output types become part of the WorkflowExecutor's types.
When invoked, the WorkflowExecutor runs the nested workflow to completion and processes its outputs.
"""
edge_groups: list[EdgeGroup] = Field(
@@ -202,7 +250,7 @@ class Workflow(AFBaseModel):
# Get the original executor object and serialize its workflow
original_executor = self.executors.get(executor_id)
if original_executor and hasattr(original_executor, "workflow"):
from ._executor import WorkflowExecutor
from ._workflow_executor import WorkflowExecutor
if isinstance(original_executor, WorkflowExecutor):
executor_data["workflow"] = original_executor.workflow.model_dump(**kwargs)
@@ -249,7 +297,6 @@ class Workflow(AFBaseModel):
OtelAttr.WORKFLOW_ID: self.id,
},
) as span:
saw_completed = False
saw_request = False
emitted_in_progress_pending = False
try:
@@ -273,25 +320,19 @@ class Workflow(AFBaseModel):
# All executor executions happen within workflow span
async for event in self._runner.run_until_convergence():
# Track terminal indicators while forwarding events
if isinstance(event, WorkflowCompletedEvent):
saw_completed = True
elif isinstance(event, RequestInfoEvent):
# Track request events for final status determination
if isinstance(event, RequestInfoEvent):
saw_request = True
yield event
if isinstance(event, RequestInfoEvent) and not emitted_in_progress_pending and not saw_completed:
if isinstance(event, RequestInfoEvent) and not emitted_in_progress_pending:
emitted_in_progress_pending = True
with _framework_event_origin():
pending_status = WorkflowStatusEvent(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
yield pending_status
# Success path: emit a final status based on observed terminal signals
if saw_completed:
with _framework_event_origin():
terminal_status = WorkflowStatusEvent(WorkflowRunState.COMPLETED)
yield terminal_status
elif saw_request:
# Workflow runs until idle - emit final status based on whether requests are pending
if saw_request:
with _framework_event_origin():
terminal_status = WorkflowStatusEvent(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS)
yield terminal_status
@@ -334,14 +375,11 @@ class Workflow(AFBaseModel):
executor = self.get_start_executor()
await executor.execute(
message,
WorkflowContext(
executor.id,
[self.__class__.__name__],
self._shared_state,
self._runner.context,
trace_contexts=None, # No parent trace context for workflow start
source_span_ids=None, # No source span for workflow start
),
[self.__class__.__name__], # source_executor_ids
self._shared_state, # shared_state
self._runner.context, # runner_context
trace_contexts=None, # No parent trace context for workflow start
source_span_ids=None, # No source span for workflow start
)
async for event in self._run_workflow_with_tracing(initial_executor_fn=initial_execution, reset_context=True):
@@ -774,6 +812,36 @@ class Workflow(AFBaseModel):
def graph_signature_hash(self) -> str:
return self._graph_signature_hash
@property
def input_types(self) -> list[type[Any]]:
"""Get the input types of the workflow.
The input types are the list of input types of the start executor.
Returns:
A list of input types that the workflow can accept.
"""
start_executor = self.get_start_executor()
return start_executor.input_types
@property
def output_types(self) -> list[type[Any]]:
"""Get the output types of the workflow.
The output types are the list of all workflow output types from executors
that have workflow output types.
Returns:
A list of output types that the workflow can produce.
"""
output_types: set[type[Any]] = set()
for executor in self.executors.values():
workflow_output_types = executor.workflow_output_types
output_types.update(workflow_output_types)
return list(output_types)
def as_agent(self, name: str | None = None) -> WorkflowAgent:
"""Create a WorkflowAgent that wraps this workflow.
@@ -1,10 +1,16 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import inspect
import logging
from typing import Any, Generic, TypeVar, cast, get_args
from collections.abc import Callable
from types import UnionType
from typing import Any, Generic, Union, cast, get_args, get_origin
from opentelemetry.propagate import inject
from opentelemetry.trace import SpanKind
from typing_extensions import Never, TypeVar
from ..observability import OtelAttr, create_workflow_span
from ._events import (
@@ -12,19 +18,249 @@ from ._events import (
WorkflowEventSource,
WorkflowFailedEvent,
WorkflowLifecycleEvent,
WorkflowOutputEvent,
WorkflowStartedEvent,
WorkflowStatusEvent,
WorkflowWarningEvent,
_framework_event_origin,
)
from ._runner_context import Message, RunnerContext
from ._shared_state import SharedState
T_Out = TypeVar("T_Out")
T_Out = TypeVar("T_Out", default=Never)
T_W_Out = TypeVar("T_W_Out", default=Never)
logger = logging.getLogger(__name__)
def infer_output_types_from_ctx_annotation(ctx_annotation: Any) -> tuple[list[type[Any]], list[type[Any]]]:
"""Infer message types and workflow output types from the WorkflowContext generic parameters.
Examples:
- WorkflowContext -> ([], [])
- WorkflowContext[str] -> ([str], [])
- WorkflowContext[str, int] -> ([str], [int])
- WorkflowContext[str | int, bool | int] -> ([str, int], [bool, int])
- WorkflowContext[Union[str, int], Union[bool, int]] -> ([str, int], [bool, int])
- WorkflowContext[Any] -> ([Any], [])
- WorkflowContext[Any, Any] -> ([Any], [Any])
- WorkflowContext[Never, Never] -> ([], [])
- WorkflowContext[Never, int] -> ([], [int])
Returns:
Tuple of (message_types, workflow_output_types)
"""
# If no annotation or not parameterized, return empty lists
try:
origin = get_origin(ctx_annotation)
except Exception:
origin = None
# If annotation is unsubscripted WorkflowContext, nothing to infer
if origin is None:
return [], []
# Expecting WorkflowContext[T_Out, T_W_Out]
if origin is not WorkflowContext:
return [], []
args = list(get_args(ctx_annotation))
if not args:
return [], []
# WorkflowContext[T_Out] -> message_types from T_Out, no workflow output types
if len(args) == 1:
t = args[0]
t_origin = get_origin(t)
if t is Any:
return [cast(type[Any], Any)], []
if t_origin in (Union, UnionType):
message_types = [arg for arg in get_args(t) if arg is not Any and arg is not Never]
return message_types, []
if t is Never:
return [], []
return [t], []
# WorkflowContext[T_Out, T_W_Out] -> message_types from T_Out, workflow_output_types from T_W_Out
t_out, t_w_out = args[:2] # Take first two args in case there are more
# Process T_Out for message_types
message_types = []
t_out_origin = get_origin(t_out)
if t_out is Any:
message_types = [cast(type[Any], Any)]
elif t_out is not Never:
if t_out_origin in (Union, UnionType):
message_types = [arg for arg in get_args(t_out) if arg is not Any and arg is not Never]
else:
message_types = [t_out]
# Process T_W_Out for workflow_output_types
workflow_output_types = []
t_w_out_origin = get_origin(t_w_out)
if t_w_out is Any:
workflow_output_types = [cast(type[Any], Any)]
elif t_w_out is not Never:
if t_w_out_origin in (Union, UnionType):
workflow_output_types = [arg for arg in get_args(t_w_out) if arg is not Any and arg is not Never]
else:
workflow_output_types = [t_w_out]
return message_types, workflow_output_types
def _is_workflow_context_type(annotation: Any) -> bool:
"""Check if an annotation represents WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U]."""
origin = get_origin(annotation)
if origin is WorkflowContext:
return True
# Also handle the case where the raw class is used
return annotation is WorkflowContext
def validate_workflow_context_annotation(
annotation: Any,
parameter_name: str,
context_description: str,
) -> tuple[list[type[Any]], list[type[Any]]]:
"""Validate a WorkflowContext annotation and return inferred types.
Args:
annotation: The type annotation to validate
parameter_name: Name of the parameter (for error messages)
context_description: Description of the context (e.g., "Function func1", "Handler method")
Returns:
Tuple of (output_types, workflow_output_types)
Raises:
ValueError: If the annotation is invalid
"""
if annotation == inspect.Parameter.empty:
raise ValueError(
f"{context_description} {parameter_name} must have a WorkflowContext, "
f"WorkflowContext[T] or WorkflowContext[T, U] type annotation, "
f"where T is output message type and U is workflow output type"
)
if not _is_workflow_context_type(annotation):
raise ValueError(
f"{context_description} {parameter_name} must be annotated as "
f"WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U], "
f"got {annotation}"
)
# Validate type arguments for WorkflowContext[T] or WorkflowContext[T, U]
type_args = get_args(annotation)
if len(type_args) > 2:
raise ValueError(
f"{context_description} {parameter_name} must have at most 2 type arguments, "
"WorkflowContext, WorkflowContext[T], or WorkflowContext[T, U], "
f"got {len(type_args)} arguments"
)
if type_args:
# Helper function to check if a value is a valid type annotation
def _is_type_like(x: Any) -> bool:
"""Check if a value is a type-like entity (class, type, or typing construct)."""
return isinstance(x, type) or get_origin(x) is not None or x is Never
for i, type_arg in enumerate(type_args):
param_description = "T_Out" if i == 0 else "T_W_Out"
# Allow Any explicitly
if type_arg is Any:
continue
# Check if it's a union type and validate each member
union_origin = get_origin(type_arg)
if union_origin in (Union, UnionType):
union_members = get_args(type_arg)
invalid_members = [m for m in union_members if not _is_type_like(m) and m is not Any]
if invalid_members:
raise ValueError(
f"{context_description} {parameter_name} {param_description} "
f"contains invalid type entries: {invalid_members}. "
f"Use proper types or typing generics"
)
else:
# Check if it's a valid type
if not _is_type_like(type_arg):
raise ValueError(
f"{context_description} {parameter_name} {param_description} "
f"contains invalid type entry: {type_arg}. "
f"Use proper types or typing generics"
)
return infer_output_types_from_ctx_annotation(annotation)
def validate_function_signature(
func: Callable[..., Any], context_description: str
) -> tuple[type, Any, list[type[Any]], list[type[Any]]]:
"""Validate function signature for executor functions.
Args:
func: The function to validate
context_description: Description for error messages (e.g., "Function", "Handler method")
Returns:
Tuple of (message_type, ctx_annotation, output_types, workflow_output_types)
Raises:
ValueError: If the function signature is invalid
"""
signature = inspect.signature(func)
params = list(signature.parameters.values())
# Determine expected parameter count based on context
expected_counts: tuple[int, ...]
if context_description.startswith("Function"):
# Function executor: (message) or (message, ctx)
expected_counts = (1, 2)
param_description = "(message: T) or (message: T, ctx: WorkflowContext[U])"
else:
# Handler method: (self, message, ctx)
expected_counts = (3,)
param_description = "(self, message: T, ctx: WorkflowContext[U])"
if len(params) not in expected_counts:
raise ValueError(
f"{context_description} {func.__name__} must have {param_description}. Got {len(params)} parameters."
)
# Extract message parameter (index 0 for functions, index 1 for methods)
message_param_idx = 0 if context_description.startswith("Function") else 1
message_param = params[message_param_idx]
# Check message parameter has type annotation
if message_param.annotation == inspect.Parameter.empty:
raise ValueError(f"{context_description} {func.__name__} must have a type annotation for the message parameter")
message_type = message_param.annotation
# Check if there's a context parameter
ctx_param_idx = message_param_idx + 1
if len(params) > ctx_param_idx:
ctx_param = params[ctx_param_idx]
output_types, workflow_output_types = validate_workflow_context_annotation(
ctx_param.annotation, f"parameter '{ctx_param.name}'", context_description
)
ctx_annotation = ctx_param.annotation
else:
# No context parameter (only valid for function executors)
if not context_description.startswith("Function"):
raise ValueError(f"{context_description} {func.__name__} must have a WorkflowContext parameter")
output_types, workflow_output_types = [], []
ctx_annotation = None
return message_type, ctx_annotation, output_types, workflow_output_types
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: tuple[type[WorkflowEvent], ...] = cast(
tuple[type[WorkflowEvent], ...],
tuple(get_args(WorkflowLifecycleEvent))
@@ -36,11 +272,47 @@ _FRAMEWORK_LIFECYCLE_EVENT_TYPES: tuple[type[WorkflowEvent], ...] = cast(
)
class WorkflowContext(Generic[T_Out]):
"""Context for executors in a workflow.
class WorkflowContext(Generic[T_Out, T_W_Out]):
"""Execution context that enables executors to interact with workflows and other executors.
This class is used to provide a way for executors to interact with the workflow
context and shared state, while preventing direct access to the runtime context.
## Overview
WorkflowContext provides a controlled interface for executors to send messages, yield outputs,
manage state, and interact with the broader workflow ecosystem. It enforces type safety through
generic parameters while preventing direct access to internal runtime components.
## Type Parameters
The context is parameterized to enforce type safety for different operations:
### WorkflowContext (no parameters)
For executors that only perform side effects without sending messages or yielding outputs:
```python
async def log_handler(message: str, ctx: WorkflowContext) -> None:
print(f"Received: {message}") # Only side effects
```
### WorkflowContext[T_Out]
Enables sending messages of type T_Out to other executors:
```python
async def processor(message: str, ctx: WorkflowContext[int]) -> None:
result = len(message)
await ctx.send_message(result) # Send int to downstream executors
```
### WorkflowContext[T_Out, T_W_Out]
Enables both sending messages (T_Out) and yielding workflow outputs (T_W_Out):
```python
async def dual_output(message: str, ctx: WorkflowContext[int, str]) -> None:
await ctx.send_message(42) # Send int message
await ctx.yield_output("complete") # Yield str workflow output
```
### Union Types
Multiple types can be specified using union notation:
```python
async def flexible(message: str, ctx: WorkflowContext[int | str, bool | dict]) -> None:
await ctx.send_message("text") # or send 42
await ctx.yield_output(True) # or yield {"status": "done"}
```
"""
def __init__(
@@ -105,6 +377,17 @@ class WorkflowContext(Generic[T_Out]):
await self._runner_context.send_message(msg)
async def yield_output(self, output: T_W_Out) -> None:
"""Set the output of the workflow.
Args:
output: The output to yield. This must conform to the workflow output type(s)
declared on this context.
"""
with _framework_event_origin():
event = WorkflowOutputEvent(data=output, source_executor_id=self._executor_id)
await self._runner_context.add_event(event)
async def add_event(self, event: WorkflowEvent) -> None:
"""Add an event to the workflow context."""
if event.origin == WorkflowEventSource.EXECUTOR and isinstance(event, _FRAMEWORK_LIFECYCLE_EVENT_TYPES):
@@ -0,0 +1,437 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ._workflow import Workflow
from pydantic import Field
from ._events import (
WorkflowErrorEvent,
WorkflowFailedEvent,
WorkflowRunState,
)
from ._executor import (
Executor,
RequestInfoExecutor,
RequestInfoMessage,
SubWorkflowRequestInfo,
SubWorkflowResponse,
handler,
)
from ._workflow_context import WorkflowContext
logger = logging.getLogger(__name__)
@dataclass
class ExecutionContext:
"""Context for tracking a single sub-workflow execution."""
execution_id: str
collected_responses: dict[str, Any] # request_id -> response_data
expected_response_count: int
pending_requests: dict[str, Any] # request_id -> original request data
class WorkflowExecutor(Executor):
"""An executor that wraps a workflow to enable hierarchical workflow composition.
## Overview
WorkflowExecutor makes a workflow behave as a single executor within a parent workflow,
enabling nested workflow architectures. It handles the complete lifecycle of sub-workflow
execution including event processing, output forwarding, and request/response coordination
between parent and child workflows.
## Execution Model
When invoked, WorkflowExecutor:
1. Starts the wrapped workflow with the input message
2. Runs the sub-workflow to completion or until it needs external input
3. Processes the sub-workflow's complete event stream after execution
4. Forwards outputs to the parent workflow's event stream
5. Handles external requests by routing them to the parent workflow
6. Accumulates responses and resumes sub-workflow execution
## Event Stream Processing
WorkflowExecutor processes events after sub-workflow completion:
### Output Forwarding
All outputs from the sub-workflow are automatically forwarded to the parent:
```python
# Sub-workflow yields outputs
await ctx.yield_output("sub-workflow result")
# WorkflowExecutor forwards to parent via ctx.send_message()
# Parent receives the output as a regular message
```
### Request/Response Coordination
When sub-workflows need external information:
```python
# Sub-workflow makes request
request = MyDataRequest(query="user info")
# RequestInfoExecutor emits RequestInfoEvent
# WorkflowExecutor wraps and forwards to parent
wrapped = SubWorkflowRequestInfo(request_id="...", sub_workflow_id="child_workflow", data=request)
# Parent workflow can intercept via @intercepts_request
```
### State Management
WorkflowExecutor maintains execution state across request/response cycles:
- Tracks pending requests by request_id
- Accumulates responses until all expected responses are received
- Resumes sub-workflow execution with complete response batch
- Handles concurrent executions and multiple pending requests
## Type System Integration
WorkflowExecutor inherits its type signature from the wrapped workflow:
### Input Types
Matches the wrapped workflow's start executor input types:
```python
# If sub-workflow accepts str, WorkflowExecutor accepts str
workflow_executor = WorkflowExecutor(my_workflow, id="wrapper")
assert workflow_executor.input_types == my_workflow.input_types
```
### Output Types
Combines sub-workflow outputs with request coordination types:
```python
# Includes all sub-workflow output types
# Plus SubWorkflowRequestInfo if sub-workflow can make requests
output_types = workflow.output_types + [SubWorkflowRequestInfo] # if applicable
```
## Error Handling
WorkflowExecutor propagates sub-workflow failures:
- Captures WorkflowFailedEvent from sub-workflow
- Converts to WorkflowErrorEvent in parent context
- Provides detailed error information including sub-workflow ID
## Concurrent Execution Support
WorkflowExecutor fully supports multiple concurrent sub-workflow executions:
### Per-Execution State Isolation
Each sub-workflow invocation creates an isolated ExecutionContext:
```python
# Multiple concurrent invocations are supported
workflow_executor = WorkflowExecutor(my_workflow, id="concurrent_executor")
# Each invocation gets its own execution context
# Execution 1: processes input_1 independently
# Execution 2: processes input_2 independently
# No state interference between executions
```
### Request/Response Coordination
Responses are correctly routed to the originating execution:
- Each execution tracks its own pending requests and expected responses
- Request-to-execution mapping ensures responses reach the correct sub-workflow
- Response accumulation is isolated per execution
- Automatic cleanup when execution completes
### Memory Management
- Unlimited concurrent executions supported
- Each execution has unique UUID-based identification
- Cleanup of completed execution contexts
- Thread-safe state management for concurrent access
### Important Considerations
**Shared Workflow Instance**: All concurrent executions use the same underlying workflow instance.
For proper isolation, ensure that:
- The wrapped workflow and its executors are stateless
- Executors use WorkflowContext state management instead of instance variables
- Any shared state is managed through WorkflowContext.get_shared_state/set_shared_state
```python
# Good: Stateless executor using context state
class StatelessExecutor(Executor):
@handler
async def process(self, data: str, ctx: WorkflowContext[str]) -> None:
# Use context state instead of instance variables
state = await ctx.get_state() or {}
state["processed"] = data
await ctx.set_state(state)
# Avoid: Stateful executor with instance variables
class StatefulExecutor(Executor):
def __init__(self):
super().__init__(id="stateful")
self.data = [] # This will be shared across concurrent executions!
```
## Integration with Parent Workflows
Parent workflows can intercept sub-workflow requests:
```python
class ParentExecutor(Executor):
@intercepts_request
async def handle_child_request(
self, request: MyDataRequest, ctx: WorkflowContext[Any]
) -> RequestResponse[MyDataRequest, str]:
# Handle request locally or forward to external source
if self.can_handle_locally(request):
return RequestResponse.handled("local result")
return RequestResponse.forward() # Send to external handler
```
## Implementation Notes
- Sub-workflows run to completion before processing their results
- Event processing is atomic - all outputs are forwarded before requests
- Response accumulation ensures sub-workflows receive complete response batches
- Execution state is maintained for proper resumption after external requests
- Concurrent executions are fully isolated and do not interfere with each other
"""
workflow: "Workflow" = Field(description="The workflow to execute as a sub-workflow")
def __init__(self, workflow: "Workflow", id: str, **kwargs: Any):
"""Initialize the WorkflowExecutor.
Args:
workflow: The workflow to execute as a sub-workflow.
id: Unique identifier for this executor.
**kwargs: Additional keyword arguments passed to the parent constructor.
"""
kwargs.update({"workflow": workflow})
super().__init__(id, **kwargs)
# Track execution contexts for concurrent sub-workflow executions
self._execution_contexts: dict[str, ExecutionContext] = {} # execution_id -> ExecutionContext
# Map request_id to execution_id for response routing
self._request_to_execution: dict[str, str] = {} # request_id -> execution_id
self._active_executions: int = 0 # Count of active sub-workflow executions
@property
def input_types(self) -> list[type[Any]]:
"""Get the input types based on the underlying workflow's input types.
Returns:
A list of input types that the underlying workflow can accept.
"""
return self.workflow.input_types
@property
def output_types(self) -> list[type[Any]]:
"""Get the output types based on the underlying workflow's output types.
Returns:
A list of output types that the underlying workflow can produce.
Includes SubWorkflowRequestInfo if the sub-workflow contains RequestInfoExecutor.
"""
output_types = list(self.workflow.output_types)
# Check if the sub-workflow contains a RequestInfoExecutor
# If so, this WorkflowExecutor can also output SubWorkflowRequestInfo messages
for executor in self.workflow.executors.values():
if isinstance(executor, RequestInfoExecutor):
if SubWorkflowRequestInfo not in output_types:
output_types.append(SubWorkflowRequestInfo)
break
return output_types
@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)):
logger.debug(f"WorkflowExecutor {self.id} ignoring input of type {type(input_data)}")
return
# Create execution context for this sub-workflow run
execution_id = str(uuid.uuid4())
execution_context = ExecutionContext(
execution_id=execution_id,
collected_responses={},
expected_response_count=0,
pending_requests={},
)
self._execution_contexts[execution_id] = execution_context
# Track this execution
self._active_executions += 1
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id} execution {execution_id}")
try:
# Run the sub-workflow and collect all events
result = await self.workflow.run(input_data)
logger.debug(
f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} "
f"execution {execution_id} completed with {len(result)} events"
)
# Process the workflow result using shared logic
await self._process_workflow_result(result, execution_context, ctx)
finally:
# Clean up execution context if it's completed (no pending requests)
if execution_id in self._execution_contexts:
exec_ctx = self._execution_contexts[execution_id]
if not exec_ctx.pending_requests:
del self._execution_contexts[execution_id]
self._active_executions -= 1
async def _process_workflow_result(
self, result: Any, execution_context: ExecutionContext, ctx: WorkflowContext[Any]
) -> None:
"""Process the result from a workflow execution.
This method handles the common logic for processing outputs, request info events,
and final states that is shared between process_workflow and handle_response.
Args:
result: The workflow execution result.
execution_context: The execution context for this sub-workflow run.
ctx: The workflow context.
"""
# Collect all events from the workflow
request_info_events = result.get_request_info_events()
outputs = result.get_outputs()
final_state = result.get_final_state()
logger.debug(
f"WorkflowExecutor {self.id} processing workflow result with "
f"{len(outputs)} outputs and {len(request_info_events)} request info events, "
f"final state: {final_state}"
)
# Process outputs
for output in outputs:
await ctx.send_message(output)
# Process request info events
for event in request_info_events:
# Track the pending request in execution context
execution_context.pending_requests[event.request_id] = event.data
# Map request to execution for response routing
self._request_to_execution[event.request_id] = execution_context.execution_id
# Wrap request with routing context and send to parent
if not isinstance(event.data, RequestInfoMessage):
raise TypeError(f"Expected RequestInfoMessage, got {type(event.data)}")
wrapped_request = SubWorkflowRequestInfo(
request_id=event.request_id,
sub_workflow_id=self.id,
data=event.data,
)
await ctx.send_message(wrapped_request)
# Update expected response count for this execution
execution_context.expected_response_count = len(request_info_events)
# Handle final state
if final_state == WorkflowRunState.FAILED:
# Find the WorkflowFailedEvent.
failed_events = [e for e in result if isinstance(e, WorkflowFailedEvent)]
if failed_events:
failed_event = failed_events[0]
error_type = failed_event.details.error_type
error_message = failed_event.details.message
exception = Exception(
f"Sub-workflow {self.workflow.id} failed with error: {error_type} - {error_message}"
)
error_event = WorkflowErrorEvent(
data=exception,
)
await ctx.add_event(error_event)
self._active_executions -= 1
elif final_state == WorkflowRunState.IDLE:
# Sub-workflow is idle - nothing more to do now
logger.debug(f"Sub-workflow {self.workflow.id} is idle with {self._active_executions} active executions")
self._active_executions -= 1 # Treat idle as completion for now
elif final_state == WorkflowRunState.CANCELLED:
# Sub-workflow was cancelled - treat as completion
logger.debug(
f"Sub-workflow {self.workflow.id} was cancelled with {self._active_executions} active executions"
)
self._active_executions -= 1
elif final_state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
# Sub-workflow is still running with pending requests
logger.debug(
f"Sub-workflow {self.workflow.id} is still in progress with {len(request_info_events)} "
f"pending requests with {self._active_executions} active executions"
)
elif final_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
# Sub-workflow is idle but has pending requests
logger.debug(
f"Sub-workflow {self.workflow.id} is idle with pending requests: "
f"{len(request_info_events)} with {self._active_executions} active executions"
)
else:
raise RuntimeError(f"Unexpected final state: {final_state}")
@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 for that execution.
Args:
response: The response to a previous request.
ctx: The workflow context.
"""
# Find the execution context for this request
execution_id = self._request_to_execution.get(response.request_id)
if not execution_id or execution_id not in self._execution_contexts:
logger.warning(
f"WorkflowExecutor {self.id} received response for unknown request_id: {response.request_id}, ignoring"
)
return
execution_context = self._execution_contexts[execution_id]
# Check if we have this pending request in the execution context
if response.request_id not in execution_context.pending_requests:
logger.warning(
f"WorkflowExecutor {self.id} received response for unknown request_id: "
f"{response.request_id} in execution {execution_id}, ignoring"
)
return
# Remove the request from pending list and request mapping
execution_context.pending_requests.pop(response.request_id, None)
self._request_to_execution.pop(response.request_id, None)
# Accumulate the response in this execution's context
execution_context.collected_responses[response.request_id] = response.data
# Check if we have all expected responses for this execution
if len(execution_context.collected_responses) < execution_context.expected_response_count:
logger.debug(
f"WorkflowExecutor {self.id} execution {execution_id} waiting for more responses: "
f"{len(execution_context.collected_responses)}/{execution_context.expected_response_count} received"
)
return # Wait for more responses
# Send all collected responses to the sub-workflow
responses_to_send = dict(execution_context.collected_responses)
execution_context.collected_responses.clear() # Clear for next batch
try:
# Resume the sub-workflow with all collected responses
result = await self.workflow.send_responses(responses_to_send)
# Process the workflow result using shared logic
await self._process_workflow_result(result, execution_context, ctx)
finally:
# Clean up execution context if it's completed (no pending requests)
if not execution_context.pending_requests:
del self._execution_contexts[execution_id]
self._active_executions -= 1