mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
0f913bcdeb
commit
2133043f11
@@ -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
|
||||
Reference in New Issue
Block a user