[BREAKING] Python: Refactor workflow events to unified discriminated union pattern (#3690)

* Refactor events

* Merge main

* Fixes

* Cleanup

* Update samples and tests

* Remove unused imports

* PR feedback

* Merge main. Add properties for events to help typing

* Formatting

* Cleanup

* use builtins.type to avoid shadowing by WorkflowEvent.type attribute

* Final improvements
This commit is contained in:
Evan Mattson
2026-02-06 16:47:20 +09:00
committed by GitHub
Unverified
parent 09f59b21ad
commit 0f3f4dbcaf
127 changed files with 1646 additions and 1703 deletions
@@ -2135,6 +2135,9 @@ class FunctionInvocationLayer(Generic[TOptions_co]):
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
# Make options mutable so we can update conversation_id during function invocation loop
mutable_options: dict[str, Any] = dict(options) if options else {}
# Remove additional_function_arguments from options passed to underlying chat client
# It's for tool invocation only and not recognized by chat service APIs
mutable_options.pop("additional_function_arguments", None)
if not stream:
@@ -31,22 +31,11 @@ from ._edge import (
)
from ._edge_runner import create_edge_runner
from ._events import (
ExecutorCompletedEvent,
ExecutorEvent,
ExecutorFailedEvent,
ExecutorInvokedEvent,
RequestInfoEvent,
SuperStepCompletedEvent,
SuperStepStartedEvent,
WorkflowErrorDetails,
WorkflowEvent,
WorkflowEventSource,
WorkflowFailedEvent,
WorkflowLifecycleEvent,
WorkflowOutputEvent,
WorkflowEventType,
WorkflowRunState,
WorkflowStartedEvent,
WorkflowStatusEvent,
)
from ._exceptions import (
WorkflowCheckpointException,
@@ -96,10 +85,6 @@ __all__ = [
"EdgeCondition",
"EdgeDuplicationError",
"Executor",
"ExecutorCompletedEvent",
"ExecutorEvent",
"ExecutorFailedEvent",
"ExecutorInvokedEvent",
"FanInEdgeGroup",
"FanOutEdgeGroup",
"FileCheckpointStorage",
@@ -108,14 +93,11 @@ __all__ = [
"InMemoryCheckpointStorage",
"InProcRunnerContext",
"Message",
"RequestInfoEvent",
"Runner",
"RunnerContext",
"SingleEdgeGroup",
"SubWorkflowRequestMessage",
"SubWorkflowResponseMessage",
"SuperStepCompletedEvent",
"SuperStepStartedEvent",
"SwitchCaseEdgeGroup",
"SwitchCaseEdgeGroupCase",
"SwitchCaseEdgeGroupDefault",
@@ -132,16 +114,12 @@ __all__ = [
"WorkflowErrorDetails",
"WorkflowEvent",
"WorkflowEventSource",
"WorkflowEventType",
"WorkflowException",
"WorkflowExecutor",
"WorkflowFailedEvent",
"WorkflowLifecycleEvent",
"WorkflowOutputEvent",
"WorkflowRunResult",
"WorkflowRunState",
"WorkflowRunnerException",
"WorkflowStartedEvent",
"WorkflowStatusEvent",
"WorkflowValidationError",
"WorkflowViz",
"create_edge_runner",
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
import logging
import sys
@@ -23,9 +25,7 @@ from .._types import add_usage_details
from ..exceptions import AgentExecutionException
from ._checkpoint import CheckpointStorage
from ._events import (
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
)
from ._message_utils import normalize_messages_input
from ._typing_utils import is_instance_of, is_type_compatible
@@ -59,11 +59,11 @@ class WorkflowAgent(BaseAgent):
return json.dumps(self.to_dict())
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "WorkflowAgent.RequestInfoFunctionArgs":
def from_dict(cls, payload: dict[str, Any]) -> WorkflowAgent.RequestInfoFunctionArgs:
return cls(request_id=payload.get("request_id", ""), data=payload.get("data"))
@classmethod
def from_json(cls, raw: str) -> "WorkflowAgent.RequestInfoFunctionArgs":
def from_json(cls, raw: str) -> WorkflowAgent.RequestInfoFunctionArgs:
try:
parsed: Any = json.loads(raw)
except json.JSONDecodeError as exc:
@@ -74,7 +74,7 @@ class WorkflowAgent(BaseAgent):
def __init__(
self,
workflow: "Workflow",
workflow: Workflow,
*,
id: str | None = None,
name: str | None = None,
@@ -93,10 +93,10 @@ class WorkflowAgent(BaseAgent):
**kwargs: Additional keyword arguments passed to BaseAgent.
Note:
Only WorkflowOutputEvents and RequestInfoEvents from the workflow are considered and
converted to agent responses of the WorkflowAgent. Other workflow events are ignored.
Use `with_output_from` in WorkflowBuilder to control which executors' outputs are surfaced
as agent responses.
Only output events (type='output') and request_info events (type='request_info') from
the workflow are considered and converted to agent responses of the WorkflowAgent.
Other workflow events are ignored. Use `with_output_from` in WorkflowBuilder to control
which executors' outputs are surfaced as agent responses.
"""
if id is None:
id = f"WorkflowAgent_{uuid.uuid4().hex[:8]}"
@@ -111,15 +111,15 @@ class WorkflowAgent(BaseAgent):
raise ValueError("Workflow's start executor cannot handle list[ChatMessage]")
super().__init__(id=id, name=name, description=description, **kwargs)
self._workflow: "Workflow" = workflow
self._pending_requests: dict[str, RequestInfoEvent] = {}
self._workflow: Workflow = workflow
self._pending_requests: dict[str, WorkflowEvent[Any]] = {}
@property
def workflow(self) -> "Workflow":
def workflow(self) -> Workflow:
return self._workflow
@property
def pending_requests(self) -> dict[str, RequestInfoEvent]:
def pending_requests(self) -> dict[str, WorkflowEvent[Any]]:
return self._pending_requests
# region Run Methods
@@ -179,6 +179,10 @@ class WorkflowAgent(BaseAgent):
Returns:
When stream=True: An AsyncIterable[AgentResponseUpdate] for streaming updates.
When stream=False: An Awaitable[AgentResponse] with the complete response.
Output events (type='output') from the workflow will be converted to ChatMessages
or AgentResponseUpdate objects. Request info events (type='request_info') will be
converted to function call and approval request contents.
"""
if stream:
return self._run_streaming(
@@ -228,7 +232,12 @@ class WorkflowAgent(BaseAgent):
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentResponseUpdate]:
"""Internal streaming implementation."""
"""Internal streaming implementation.
Yields AgentResponseUpdate objects. Output events (type='output') from the workflow
are converted to updates. Request info events (type='request_info') are converted
to function call and approval request contents.
"""
input_messages = normalize_messages_input(messages)
thread = thread or self.get_new_thread()
response_updates: list[AgentResponseUpdate] = []
@@ -269,11 +278,11 @@ class WorkflowAgent(BaseAgent):
Returns:
An AgentResponse representing the workflow execution results.
"""
output_events: list[WorkflowOutputEvent | RequestInfoEvent] = []
output_events: list[WorkflowEvent[Any]] = []
async for event in self._run_core(
input_messages, thread, checkpoint_id, checkpoint_storage, streaming=False, **kwargs
):
if isinstance(event, WorkflowOutputEvent | RequestInfoEvent):
if event.type == "output" or event.type == "request_info":
output_events.append(event)
return self._convert_workflow_events_to_agent_response(response_id, output_events)
@@ -304,7 +313,7 @@ class WorkflowAgent(BaseAgent):
async for event in self._run_core(
input_messages, thread, checkpoint_id, checkpoint_storage, streaming=True, **kwargs
):
updates = self._convert_workflow_event_to_agent_response_update(response_id, event)
updates = self._convert_workflow_event_to_agent_response_updates(response_id, event)
for update in updates:
yield update
@@ -440,7 +449,7 @@ class WorkflowAgent(BaseAgent):
def _convert_workflow_events_to_agent_response(
self,
response_id: str,
output_events: list[WorkflowOutputEvent | RequestInfoEvent],
output_events: list[WorkflowEvent[Any]],
) -> AgentResponse:
"""Convert a list of workflow output events to an AgentResponse."""
messages: list[ChatMessage] = []
@@ -449,7 +458,7 @@ class WorkflowAgent(BaseAgent):
latest_created_at: str | None = None
for output_event in output_events:
if isinstance(output_event, RequestInfoEvent):
if output_event.type == "request_info":
function_call, approval_request = self._process_request_info_event(output_event)
messages.append(
ChatMessage(
@@ -468,7 +477,7 @@ class WorkflowAgent(BaseAgent):
# sequence cannot be guaranteed when there are streaming updates in between non-streaming
# responses.
raise AgentExecutionException(
"WorkflowOutputEvent with AgentResponseUpdate data cannot be emitted in non-streaming mode. "
"Output event with AgentResponseUpdate data cannot be emitted in non-streaming mode. "
"Please ensure executors emit AgentResponse for non-streaming workflows."
)
@@ -514,115 +523,160 @@ class WorkflowAgent(BaseAgent):
raw_representation=raw_representations,
)
def _convert_workflow_event_to_agent_response_update(
def _process_request_info_event(
self,
event: WorkflowEvent[Any],
) -> tuple[Content, Content]:
"""Convert a request_info event to FunctionCallContent and FunctionApprovalRequestContent.
Args:
event: A WorkflowEvent with type='request_info'.
Returns:
A tuple of (FunctionCallContent, FunctionApprovalRequestContent).
"""
request_id = event.request_id
if not request_id:
raise ValueError("request_info event must have a request_id")
self.pending_requests[request_id] = event
args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict()
function_call = Content.from_function_call(
call_id=request_id,
name=self.REQUEST_INFO_FUNCTION_NAME,
arguments=args,
)
approval_request = Content.from_function_approval_request(
id=request_id,
function_call=function_call,
additional_properties={"request_id": request_id},
)
return function_call, approval_request
def _convert_workflow_event_to_agent_response_updates(
self,
response_id: str,
event: WorkflowEvent,
event: WorkflowEvent[Any],
) -> list[AgentResponseUpdate]:
"""Convert a workflow event to an AgentResponseUpdate.
"""Convert a workflow event to a list of AgentResponseUpdate objects.
Only WorkflowOutputEvent and RequestInfoEvent are processed.
Events with type='output' and type='request_info' are processed.
Other workflow events are ignored as they are workflow-internal.
For 'output' events, AgentExecutor yields AgentResponseUpdate for streaming updates
via ctx.yield_output(). This method converts those to agent response updates.
Returns:
A list of AgentResponseUpdate objects. Empty list if the event is not relevant.
"""
match event:
# Convert workflow output to an agent response update.
case WorkflowOutputEvent(data=data, executor_id=executor_id):
# Handle different data types appropriately.
if isinstance(data, AgentResponse):
return [
AgentResponseUpdate(
contents=[content for message in data.messages for content in message.contents],
role="assistant",
author_name=executor_id,
response_id=response_id,
created_at=data.created_at,
raw_representation=data,
)
]
if event.type == "output":
# Convert workflow output to agent response updates.
# Handle different data types appropriately.
data = event.data
executor_id = event.executor_id
if isinstance(data, AgentResponseUpdate):
return [data]
if isinstance(data, ChatMessage):
return [
AgentResponseUpdate(
contents=list(data.contents),
role=data.role,
author_name=data.author_name,
response_id=response_id,
message_id=data.message_id or str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=data,
)
]
if is_instance_of(data, list[ChatMessage]):
chat_messages = cast(list[ChatMessage], data)
return [
if isinstance(data, AgentResponseUpdate):
# Pass through AgentResponseUpdate directly (streaming from AgentExecutor)
if not data.author_name:
data.author_name = executor_id
return [data]
if isinstance(data, AgentResponse):
# Convert each message in AgentResponse to an AgentResponseUpdate
updates: list[AgentResponseUpdate] = []
for msg in data.messages:
updates.append(
AgentResponseUpdate(
contents=list(msg.contents),
role=msg.role,
author_name=msg.author_name,
response_id=response_id,
author_name=msg.author_name or executor_id,
response_id=data.response_id or response_id,
message_id=msg.message_id or str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
created_at=data.created_at
or datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=msg,
)
for msg in chat_messages
]
contents = self._extract_contents(data)
if not contents:
return []
)
return updates
if isinstance(data, ChatMessage):
return [
AgentResponseUpdate(
contents=contents,
role="assistant",
author_name=executor_id,
contents=list(data.contents),
role=data.role,
author_name=data.author_name or executor_id,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=data,
)
]
case RequestInfoEvent():
function_call, approval_request = self._process_request_info_event(event)
return [
AgentResponseUpdate(
contents=[function_call, approval_request],
role="assistant",
author_name=self.name,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
if is_instance_of(data, list[ChatMessage]):
# Convert each ChatMessage to an AgentResponseUpdate
chat_messages = cast(list[ChatMessage], data)
updates = []
for msg in chat_messages:
updates.append(
AgentResponseUpdate(
contents=list(msg.contents),
role=msg.role,
author_name=msg.author_name or executor_id,
response_id=response_id,
message_id=msg.message_id or str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=msg,
)
)
]
case _:
# Ignore workflow-internal events
pass
return updates
contents = self._extract_contents(data)
if not contents:
return []
return [
AgentResponseUpdate(
contents=contents,
role="assistant",
author_name=executor_id,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=data,
)
]
if event.type == "request_info":
# Store the pending request for later correlation
request_id = event.request_id
if not request_id:
raise ValueError("request_info event must have a request_id")
self.pending_requests[request_id] = event
args = self.RequestInfoFunctionArgs(request_id=request_id, data=event.data).to_dict()
function_call = Content.from_function_call(
call_id=request_id,
name=self.REQUEST_INFO_FUNCTION_NAME,
arguments=args,
)
approval_request = Content.from_function_approval_request(
id=request_id,
function_call=function_call,
additional_properties={"request_id": request_id},
)
return [
AgentResponseUpdate(
contents=[function_call, approval_request],
role="assistant",
author_name=self.name,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
)
]
# Ignore workflow-internal events
return []
def _process_request_info_event(self, event: RequestInfoEvent) -> tuple[Content, Content]:
"""Process a RequestInfoEvent by adding it to pending requests."""
# Store the pending request for later correlation
self.pending_requests[event.request_id] = event
args = self.RequestInfoFunctionArgs(request_id=event.request_id, data=event.data).to_dict()
function_call = Content.from_function_call(
call_id=event.request_id,
name=self.REQUEST_INFO_FUNCTION_NAME,
arguments=args,
)
approval_request = Content.from_function_approval_request(
id=event.request_id,
function_call=function_call,
additional_properties={"request_id": event.request_id},
)
return function_call, approval_request
def _extract_function_responses(self, input_messages: list[ChatMessage]) -> dict[str, Any]:
"""Extract function responses from input messages."""
function_responses: dict[str, Any] = {}
@@ -65,8 +65,8 @@ class AgentExecutor(Executor):
"""built-in executor that wraps an agent for handling messages.
AgentExecutor adapts its behavior based on the workflow execution mode:
- run(stream=True): Emits incremental WorkflowOutputEvents as the agent produces tokens
- run(): Emits a single WorkflowOutputEvent containing the complete response
- run(stream=True): Emits incremental output events (type='output') as the agent produces tokens
- run(): Emits a single output event (type='output') containing the complete response
Use `with_output_from` in WorkflowBuilder to control whether the AgentResponse
or AgentResponseUpdate objects are yielded as workflow outputs.
@@ -296,8 +296,8 @@ class AgentExecutor(Executor):
) -> None:
"""Execute the underlying agent, emit events, and enqueue response.
Checks ctx.is_streaming() to determine whether to emit WorkflowOutputEvents
containing incremental updates (streaming mode) or a single WorkflowOutputEvent
Checks ctx.is_streaming() to determine whether to emit output events (type='output')
containing incremental updates (streaming mode) or a single output event (type='output')
containing the complete response (non-streaming mode).
"""
if ctx.is_streaming():
@@ -332,10 +332,16 @@ class AgentExecutor(Executor):
"""
run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
# Build options dict with additional_function_arguments for tool kwargs propagation
options: dict[str, Any] | None = None
if run_kwargs:
options = {"additional_function_arguments": run_kwargs}
response = await self._agent.run(
self._cache,
stream=False,
thread=self._agent_thread,
options=options,
**run_kwargs,
)
await ctx.yield_output(response)
@@ -360,12 +366,18 @@ class AgentExecutor(Executor):
"""
run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY) or {}
# Build options dict with additional_function_arguments for tool kwargs propagation
options: dict[str, Any] | None = None
if run_kwargs:
options = {"additional_function_arguments": run_kwargs}
updates: list[AgentResponseUpdate] = []
user_input_requests: list[Content] = []
async for update in self._agent.run(
self._cache,
stream=True,
thread=self._agent_thread,
options=options,
**run_kwargs,
):
updates.append(update)
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import json
import logging
@@ -59,7 +61,7 @@ class WorkflowCheckpoint:
return asdict(self)
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "WorkflowCheckpoint":
def from_dict(cls, data: Mapping[str, Any]) -> WorkflowCheckpoint:
return cls(**data)
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from ._checkpoint import WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
from ._events import RequestInfoEvent
from ._events import WorkflowEvent
logger = logging.getLogger(__name__)
@@ -20,14 +20,14 @@ class WorkflowCheckpointSummary:
targets: list[str]
executor_ids: list[str]
status: str
pending_request_info_events: list[RequestInfoEvent]
pending_request_info_events: list[WorkflowEvent]
def get_checkpoint_summary(checkpoint: WorkflowCheckpoint) -> WorkflowCheckpointSummary:
targets = sorted(checkpoint.messages.keys())
executor_ids = sorted(checkpoint.state.get(EXECUTOR_STATE_KEY, {}).keys())
pending_request_info_events = [
RequestInfoEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values()
WorkflowEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values()
]
status = "idle"
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import inspect
import logging
import uuid
@@ -214,7 +216,7 @@ class Edge(DictConvertible):
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Edge":
def from_dict(cls, data: dict[str, Any]) -> Edge:
"""Reconstruct an `Edge` from its serialised dictionary form.
The deserialised edge will lack the executable predicate because we do
@@ -311,7 +313,7 @@ class EdgeGroup(DictConvertible):
from builtins import type as builtin_type
_TYPE_REGISTRY: ClassVar[dict[str, builtin_type["EdgeGroup"]]] = {}
_TYPE_REGISTRY: ClassVar[dict[str, builtin_type[EdgeGroup]]] = {}
def __init__(
self,
@@ -415,7 +417,7 @@ class EdgeGroup(DictConvertible):
return subclass
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "EdgeGroup":
def from_dict(cls, data: dict[str, Any]) -> EdgeGroup:
"""Hydrate the correct `EdgeGroup` subclass from serialised state.
The method inspects the `type` field, allocates the corresponding class
@@ -735,7 +737,7 @@ class SwitchCaseEdgeGroupCase(DictConvertible):
return payload
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SwitchCaseEdgeGroupCase":
def from_dict(cls, data: dict[str, Any]) -> SwitchCaseEdgeGroupCase:
"""Instantiate a case from its serialised dictionary payload.
Examples:
@@ -789,7 +791,7 @@ class SwitchCaseEdgeGroupDefault(DictConvertible):
return {"target_id": self.target_id, "type": self.type}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SwitchCaseEdgeGroupDefault":
def from_dict(cls, data: dict[str, Any]) -> SwitchCaseEdgeGroupDefault:
"""Recreate the default branch from its persisted form.
Examples:
@@ -1,16 +1,27 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import builtins
import sys
import traceback as _traceback
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from enum import Enum
from typing import Any, TypeAlias
from typing import Any, Generic, Literal, cast
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._typing_utils import deserialize_type, serialize_type
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore[import] # pragma: no cover
DataT = TypeVar("DataT", default=Any)
class WorkflowEventSource(str, Enum):
"""Identifies whether a workflow event came from the framework or an executor.
@@ -44,114 +55,16 @@ def _framework_event_origin() -> Iterator[None]: # pyright: ignore[reportUnused
_event_origin_context.reset(token)
class WorkflowEvent:
"""Base class for workflow events."""
def __init__(self, data: Any | None = None):
"""Initialize the workflow event with optional data."""
self.data = data
self.origin = _current_event_origin()
def __repr__(self) -> str:
"""Return a string representation of the workflow event."""
data_repr = self.data if self.data is not None else "None"
return f"{self.__class__.__name__}(origin={self.origin}, data={data_repr})"
class WorkflowStartedEvent(WorkflowEvent):
"""Built-in lifecycle event emitted when a workflow run begins."""
...
class WorkflowWarningEvent(WorkflowEvent):
"""Executor-origin event signaling a warning surfaced by user code."""
def __init__(self, data: str):
"""Initialize the workflow warning event with optional data and warning message."""
super().__init__(data)
def __repr__(self) -> str:
"""Return a string representation of the workflow warning event."""
return f"{self.__class__.__name__}(message={self.data}, origin={self.origin})"
class WorkflowErrorEvent(WorkflowEvent):
"""Executor-origin event signaling an error surfaced by user code."""
def __init__(self, data: Exception):
"""Initialize the workflow error event with optional data and error message."""
super().__init__(data)
def __repr__(self) -> str:
"""Return a string representation of the workflow error event."""
return f"{self.__class__.__name__}(exception={self.data}, origin={self.origin})"
class WorkflowRunState(str, Enum):
"""Run-level state of a workflow execution.
"""Run-level state of a workflow execution."""
Semantics:
- STARTED: Run has been initiated and the workflow context has been created.
This is an initial state before any meaningful work is performed. In this
codebase we emit a dedicated `WorkflowStartedEvent` for telemetry, and
typically advance the status directly to `IN_PROGRESS`. Consumers may
still rely on `STARTED` for state machines that need an explicit pre-work
phase.
- IN_PROGRESS: The workflow is actively executing (e.g., the initial
message has been delivered to the start executor or a superstep is
running). This status is emitted at the beginning of a run and can be
followed by other statuses as the run progresses.
- IN_PROGRESS_PENDING_REQUESTS: Active execution while one or more
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 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.
- FAILED: Terminal state indicating an error surfaced. Accompanied by a
`WorkflowFailedEvent` with structured error details.
- CANCELLED: Terminal state indicating the run was cancelled by a caller
or orchestrator. Not currently emitted by default runner paths but
included for integrators/orchestrators that support cancellation.
"""
STARTED = "STARTED" # Explicit pre-work phase (rarely emitted as status; see note above)
IN_PROGRESS = "IN_PROGRESS" # Active execution is underway
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
FAILED = "FAILED" # Finished with an error
CANCELLED = "CANCELLED" # Finished due to cancellation
class WorkflowStatusEvent(WorkflowEvent):
"""Built-in lifecycle event emitted for workflow run state transitions."""
def __init__(
self,
state: WorkflowRunState,
data: Any | None = None,
):
"""Initialize the workflow status event with a new state and optional data.
Args:
state: The new state of the workflow run.
data: Optional additional data associated with the state change.
"""
super().__init__(data)
self.state = state
def __repr__(self) -> str: # pragma: no cover - representation only
return f"{self.__class__.__name__}(state={self.state}, data={self.data!r}, origin={self.origin})"
STARTED = "STARTED"
IN_PROGRESS = "IN_PROGRESS"
IN_PROGRESS_PENDING_REQUESTS = "IN_PROGRESS_PENDING_REQUESTS"
IDLE = "IDLE"
IDLE_WITH_PENDING_REQUESTS = "IDLE_WITH_PENDING_REQUESTS"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
@dataclass
@@ -171,7 +84,7 @@ class WorkflowErrorDetails:
*,
executor_id: str | None = None,
extra: dict[str, Any] | None = None,
) -> "WorkflowErrorDetails":
) -> WorkflowErrorDetails:
tb = None
try:
tb = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__))
@@ -186,180 +99,328 @@ class WorkflowErrorDetails:
)
class WorkflowFailedEvent(WorkflowEvent):
"""Built-in lifecycle event emitted when a workflow run terminates with an error."""
# Type discriminator for workflow events.
# Includes both framework lifecycle types and well-known orchestration types.
WorkflowEventType = Literal[
# Lifecycle events (workflow-level)
"started", # Workflow run began
"status", # Workflow state changed (use .state)
"failed", # Workflow terminated with error (use .details)
# Data events
"output", # Executor yielded final output (use .executor_id, .data)
"data", # Executor emitted data during execution (use .executor_id, .data)
# Request events (human-in-the-loop)
"request_info", # Executor requests external info (use .request_id, .source_executor_id)
# Diagnostic events (warnings/errors from user code)
"warning", # Warning from user code (use .data as str)
"error", # Error from user code, non-fatal (use .data as Exception)
# Iteration events (supersteps)
"superstep_started", # Superstep began (use .iteration)
"superstep_completed", # Superstep ended (use .iteration)
# Executor lifecycle events
"executor_invoked", # Executor handler was called (use .executor_id, .data)
"executor_completed", # Executor handler completed (use .executor_id, .data)
"executor_failed", # Executor handler raised error (use .executor_id, .details)
# Orchestration event types (use .data for typed payload)
"group_chat", # Group chat orchestrator events (use .data as GroupChatRequestSentEvent | GroupChatResponseReceivedEvent) # noqa: E501
"handoff_sent", # Handoff routing events (use .data as HandoffSentEvent)
"magentic_orchestrator", # Magentic orchestrator events (use .data as MagenticOrchestratorEvent)
]
class WorkflowEvent(Generic[DataT]):
"""Unified event for all workflow emissions.
This single generic class handles all workflow events through a `type` discriminator,
following the same pattern as the `Content` class.
Use factory methods for convenient construction:
- `WorkflowEvent.started()` - workflow run began
- `WorkflowEvent.status(state)` - workflow state changed
- `WorkflowEvent.failed(details)` - workflow terminated with error
- `WorkflowEvent.warning(message)` - warning from user code
- `WorkflowEvent.error(exception)` - error from user code
- `WorkflowEvent.output(executor_id, data)` - executor yielded final output
- `WorkflowEvent.data(executor_id, data)` - executor emitted data (e.g., AgentResponse)
- `WorkflowEvent.request_info(...)` - executor requests external info
- `WorkflowEvent.superstep_started(iteration)` - superstep began
- `WorkflowEvent.superstep_completed(iteration)` - superstep ended
- `WorkflowEvent.executor_invoked(executor_id)` - executor handler called
- `WorkflowEvent.executor_completed(executor_id)` - executor handler completed
- `WorkflowEvent.executor_failed(executor_id, details)` - executor handler failed
The generic parameter DataT represents the type of the event's data payload:
- Lifecycle events: `WorkflowEvent[None]` (data is None)
- Data events: `WorkflowEvent[DataT]` where DataT is the payload type (e.g., AgentResponse)
Examples:
.. code-block:: python
# Create events via factory methods
started = WorkflowEvent.started()
status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
output = WorkflowEvent.output("agent1", result_data)
# Emit typed data from executor
event: WorkflowEvent[AgentResponse] = WorkflowEvent.data("agent1", response)
data: AgentResponse = event.data # Type-safe access
# Check event type
if event.type == "status":
print(f"State: {event.state}")
elif event.type == "output":
print(f"Output from {event.executor_id}: {event.data}")
elif event.type == "data":
if isinstance(event.data, AgentResponse):
print(f"Agent response: {event.data.text}")
"""
type: WorkflowEventType
data: DataT
def __init__(
self,
details: WorkflowErrorDetails,
data: Any | None = None,
):
super().__init__(data)
type: WorkflowEventType,
data: DataT | None = None,
*,
# Event context fields
origin: WorkflowEventSource | None = None,
# STATUS event fields
state: WorkflowRunState | None = None,
# FAILED event fields
details: WorkflowErrorDetails | None = None,
# OUTPUT/DATA event fields
executor_id: str | None = None,
# REQUEST_INFO event fields
request_id: str | None = None,
source_executor_id: str | None = None,
request_type: builtins.type[Any] | None = None,
response_type: builtins.type[Any] | None = None,
# SUPERSTEP event fields
iteration: int | None = None,
) -> None:
"""Initialize the workflow event.
Prefer using factory methods like `WorkflowEvent.started()` instead of __init__ directly.
"""
self.type = type
self.data = data # type: ignore[assignment]
self.origin = origin if origin is not None else _current_event_origin()
# Event-specific fields
self.state = state
self.details = details
def __repr__(self) -> str: # pragma: no cover - representation only
return f"{self.__class__.__name__}(details={self.details}, data={self.data!r}, origin={self.origin})"
class RequestInfoEvent(WorkflowEvent):
"""Event triggered when a workflow executor requests external information."""
def __init__(
self,
request_id: str,
source_executor_id: str,
request_data: Any,
response_type: type[Any],
):
"""Initialize the request info event.
Args:
request_id: Unique identifier for the request.
source_executor_id: ID of the executor that made the request.
request_data: The data associated with the request.
response_type: Expected type of the response.
"""
super().__init__(request_data)
self.request_id = request_id
self.source_executor_id = source_executor_id
self.request_type: type[Any] = type(request_data)
self.response_type = response_type
def __repr__(self) -> str:
"""Return a string representation of the request info event."""
return (
f"{self.__class__.__name__}("
f"request_id={self.request_id}, "
f"source_executor_id={self.source_executor_id}, "
f"request_type={self.request_type.__name__}, "
f"data={self.data}, "
f"response_type={self.response_type.__name__})"
)
def to_dict(self) -> dict[str, Any]:
"""Convert the request info event to a dictionary for serialization."""
return {
"data": encode_checkpoint_value(self.data),
"request_id": self.request_id,
"source_executor_id": self.source_executor_id,
"request_type": serialize_type(self.request_type),
"response_type": serialize_type(self.response_type),
}
@staticmethod
def from_dict(data: dict[str, Any]) -> "RequestInfoEvent":
"""Create a RequestInfoEvent from a dictionary."""
# Validation
for property in ["data", "request_id", "source_executor_id", "request_type", "response_type"]:
if property not in data:
raise KeyError(f"Missing '{property}' field in RequestInfoEvent dictionary.")
request_info_event = RequestInfoEvent(
request_id=data["request_id"],
source_executor_id=data["source_executor_id"],
request_data=decode_checkpoint_value(data["data"]),
response_type=deserialize_type(data["response_type"]),
)
# Verify that the deserialized request_data matches the declared request_type
if deserialize_type(data["request_type"]) is not type(request_info_event.data):
raise TypeError(
"Mismatch between deserialized request_data type and request_type field in RequestInfoEvent dictionary."
)
return request_info_event
class WorkflowOutputEvent(WorkflowEvent):
"""Event triggered when a workflow executor yields output."""
def __init__(
self,
data: Any,
executor_id: str,
):
"""Initialize the workflow output event.
Args:
data: The output yielded by the executor.
executor_id: ID of the executor that yielded the output.
"""
super().__init__(data)
self.executor_id = executor_id
def __repr__(self) -> str:
"""Return a string representation of the workflow output event."""
return f"{self.__class__.__name__}(data={self.data}, executor_id={self.executor_id})"
class SuperStepEvent(WorkflowEvent):
"""Event triggered when a superstep starts or ends."""
def __init__(self, iteration: int, data: Any | None = None):
"""Initialize the superstep event.
Args:
iteration: The number of the superstep (1-based index).
data: Optional data associated with the superstep event.
"""
super().__init__(data)
self._request_id = request_id
self._source_executor_id = source_executor_id
self._request_type = request_type
self._response_type = response_type
self.iteration = iteration
def __repr__(self) -> str:
"""Return a string representation of the superstep event."""
return f"{self.__class__.__name__}(iteration={self.iteration}, data={self.data})"
"""Return a string representation of the workflow event."""
parts = [f"type={self.type!r}"]
if self.state is not None:
parts.append(f"state={self.state.value}")
if self.executor_id is not None:
parts.append(f"executor_id={self.executor_id!r}")
if self.iteration is not None:
parts.append(f"iteration={self.iteration}")
if self._request_id is not None:
parts.append(f"request_id={self._request_id!r}")
if self.data is not None:
parts.append(f"data={self.data!r}")
return f"WorkflowEvent({', '.join(parts)})" # pragma: no cover
# ==========================================================================
# Factory methods
# ==========================================================================
class SuperStepStartedEvent(SuperStepEvent):
"""Event triggered when a superstep starts."""
@classmethod
def started(cls, data: DataT | None = None) -> WorkflowEvent[DataT]:
"""Create a 'started' event when a workflow run begins."""
return cls("started", data=data)
...
@classmethod
def status(cls, state: WorkflowRunState, data: DataT | None = None) -> WorkflowEvent[DataT]:
"""Create a 'status' event for workflow state transitions."""
return cls("status", data=data, state=state)
@classmethod
def failed(cls, details: WorkflowErrorDetails, data: DataT | None = None) -> WorkflowEvent[DataT]:
"""Create a 'failed' event when a workflow terminates with error."""
return cls("failed", data=data, details=details)
class SuperStepCompletedEvent(SuperStepEvent):
"""Event triggered when a superstep ends."""
@classmethod
def warning(cls, message: str) -> WorkflowEvent[str]:
"""Create a 'warning' event from user code."""
return WorkflowEvent("warning", data=message)
...
@classmethod
def error(cls, exception: Exception) -> WorkflowEvent[Exception]:
"""Create an 'error' event from user code."""
return WorkflowEvent("error", data=exception)
@classmethod
def output(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
"""Create an 'output' event when an executor yields final output."""
return cls("output", executor_id=executor_id, data=data)
class ExecutorEvent(WorkflowEvent):
"""Base class for executor events."""
@classmethod
def emit(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
"""Create a 'data' event when an executor emits data during execution.
def __init__(self, executor_id: str, data: Any | None = None):
"""Initialize the executor event with an executor ID and optional data."""
super().__init__(data)
self.executor_id = executor_id
This is the primary method for executors to emit typed data
(e.g., AgentResponse, AgentResponseUpdate, custom data).
"""
return cls("data", executor_id=executor_id, data=data)
def __repr__(self) -> str:
"""Return a string representation of the executor event."""
return f"{self.__class__.__name__}(executor_id={self.executor_id}, data={self.data})"
@classmethod
def request_info(
cls,
request_id: str,
source_executor_id: str,
request_data: DataT,
response_type: builtins.type[Any],
) -> WorkflowEvent[DataT]:
"""Create a 'request_info' event when an executor requests external information."""
return cls(
"request_info",
data=request_data,
request_id=request_id,
source_executor_id=source_executor_id,
request_type=type(request_data),
response_type=response_type,
)
@classmethod
def superstep_started(cls, iteration: int, data: DataT | None = None) -> WorkflowEvent[DataT]:
"""Create a 'superstep_started' event when a superstep begins."""
return cls("superstep_started", iteration=iteration, data=data)
class ExecutorInvokedEvent(ExecutorEvent):
"""Event triggered when an executor handler is invoked."""
@classmethod
def superstep_completed(cls, iteration: int, data: DataT | None = None) -> WorkflowEvent[DataT]:
"""Create a 'superstep_completed' event when a superstep ends."""
return cls("superstep_completed", iteration=iteration, data=data)
...
@classmethod
def executor_invoked(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]:
"""Create an 'executor_invoked' event when an executor handler is called."""
return cls("executor_invoked", executor_id=executor_id, data=data)
@classmethod
def executor_completed(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]:
"""Create an 'executor_completed' event when an executor handler completes."""
return cls("executor_completed", executor_id=executor_id, data=data)
class ExecutorCompletedEvent(ExecutorEvent):
"""Event triggered when an executor handler is completed."""
@classmethod
def executor_failed(cls, executor_id: str, details: WorkflowErrorDetails) -> WorkflowEvent[WorkflowErrorDetails]:
"""Create an 'executor_failed' event when an executor handler raises an error."""
return WorkflowEvent("executor_failed", executor_id=executor_id, data=details, details=details)
...
# ==========================================================================
# Property for type-safe access
# ==========================================================================
@property
def request_id(self) -> str:
"""Get request_id for request_info events.
class ExecutorFailedEvent(ExecutorEvent):
"""Event triggered when an executor handler raises an error."""
Returns:
The request ID as a non-None string.
def __init__(
self,
executor_id: str,
details: WorkflowErrorDetails,
):
super().__init__(executor_id, details)
self.details = details
Raises:
RuntimeError: If called on an event that is not a request_info event,
or if the event is malformed (request_info without request_id).
"""
if self.type != "request_info" or self._request_id is None:
raise RuntimeError(f"request_id is only available for request_info events, got type={self.type!r}")
return self._request_id
def __repr__(self) -> str: # pragma: no cover - representation only
return f"{self.__class__.__name__}(executor_id={self.executor_id}, details={self.details})"
@property
def source_executor_id(self) -> str:
"""Get source_executor_id for request_info events.
Returns:
The source executor ID as a non-None string.
WorkflowLifecycleEvent: TypeAlias = WorkflowStartedEvent | WorkflowStatusEvent | WorkflowFailedEvent
Raises:
RuntimeError: If called on an event that is not a request_info event,
or if the event is malformed (request_info without source_executor_id).
"""
if self.type != "request_info" or self._source_executor_id is None:
raise RuntimeError(f"source_executor_id is only available for request_info events, got type={self.type!r}")
return self._source_executor_id
@property
def request_type(self) -> builtins.type[Any]:
"""Get request_type for request_info events.
Returns:
The request data type as a non-None type object.
Raises:
RuntimeError: If called on an event that is not a request_info event,
or if the event is malformed (request_info without request_type).
"""
if self.type != "request_info" or self._request_type is None:
raise RuntimeError(f"request_type is only available for request_info events, got type={self.type!r}")
return self._request_type
@property
def response_type(self) -> builtins.type[Any]:
"""Get response_type for request_info events.
Returns:
The response data type as a non-None type object.
Raises:
RuntimeError: If called on an event that is not a request_info event,
or if the event is malformed (request_info without response_type).
"""
if self.type != "request_info" or self._response_type is None:
raise RuntimeError(f"response_type is only available for request_info events, got type={self.type!r}")
return self._response_type
# ==========================================================================
# Serialization methods (primarily for REQUEST_INFO events)
# ==========================================================================
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for serialization.
Currently only implemented for 'request_info' events for checkpoint storage.
"""
if self.type != "request_info":
raise ValueError(f"to_dict() only supported for 'request_info' events, got '{self.type}'")
return {
"type": self.type,
"data": encode_checkpoint_value(self.data),
"request_id": self._request_id,
"source_executor_id": self._source_executor_id,
"request_type": serialize_type(self._request_type) if self._request_type else None,
"response_type": serialize_type(self._response_type) if self._response_type else None,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> WorkflowEvent[Any]:
"""Create a REQUEST_INFO event from a dictionary."""
for prop in ["data", "request_id", "source_executor_id", "request_type", "response_type"]:
if prop not in data:
raise KeyError(f"Missing '{prop}' field in WorkflowEvent dictionary.")
request_data = decode_checkpoint_value(data["data"])
request_type = deserialize_type(data["request_type"])
if request_type is not type(request_data):
raise TypeError(
"Mismatch between deserialized request_data type and request_type field in WorkflowEvent dictionary."
)
return cls.request_info(
request_id=data["request_id"],
source_executor_id=data["source_executor_id"],
request_data=cast(Any, request_data), # type: ignore
response_type=deserialize_type(data["response_type"]),
)
@@ -11,10 +11,8 @@ from typing import Any, TypeVar, overload
from ..observability import create_processing_span
from ._events import (
ExecutorCompletedEvent,
ExecutorFailedEvent,
ExecutorInvokedEvent,
WorkflowErrorDetails,
WorkflowEvent,
_framework_event_origin, # type: ignore[reportPrivateUsage]
)
from ._model_utils import DictConvertible
@@ -274,14 +272,14 @@ class Executor(RequestInfoMixin, DictConvertible):
# Invoke the handler with the message and context
# Use deepcopy to capture original input state before handler can mutate it
with _framework_event_origin():
invoke_event = ExecutorInvokedEvent(self.id, copy.deepcopy(message))
invoke_event = WorkflowEvent.executor_invoked(self.id, copy.deepcopy(message))
await context.add_event(invoke_event)
try:
await handler(message, context)
except Exception as exc:
# Surface structured executor failure before propagating
with _framework_event_origin():
failure_event = ExecutorFailedEvent(self.id, WorkflowErrorDetails.from_exception(exc))
failure_event = WorkflowEvent.executor_failed(self.id, WorkflowErrorDetails.from_exception(exc))
await context.add_event(failure_event)
raise
with _framework_event_origin():
@@ -289,7 +287,9 @@ class Executor(RequestInfoMixin, DictConvertible):
sent_messages = context.get_sent_messages()
yielded_outputs = context.get_yielded_outputs()
completion_data = sent_messages + yielded_outputs
completed_event = ExecutorCompletedEvent(self.id, completion_data if completion_data else None)
completed_event = WorkflowEvent.executor_completed(
self.id, completion_data if completion_data else None
)
await context.add_event(completed_event)
def _create_context_for_handler(
@@ -538,8 +538,8 @@ def handler(
output: type | types.UnionType | str | None = None,
workflow_output: type | types.UnionType | str | None = None,
) -> Callable[
[Callable[[ExecutorT, Any, ContextT], Awaitable[Any]]],
Callable[[ExecutorT, Any, ContextT], Awaitable[Any]],
[Callable[..., Awaitable[Any]]],
Callable[..., Awaitable[Any]],
]: ...
@@ -724,9 +724,15 @@ def _validate_handler_signature(
# Validate ctx parameter is WorkflowContext and extract type args
ctx_param = params[2]
output_types, workflow_output_types = validate_workflow_context_annotation(
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Handler"
)
if skip_message_annotation and ctx_param.annotation == inspect.Parameter.empty:
# When explicit types are provided via @handler(input=..., output=...),
# the ctx parameter doesn't need a type annotation - types come from the decorator.
output_types: list[type[Any] | types.UnionType] = []
workflow_output_types: list[type[Any] | types.UnionType] = []
else:
output_types, workflow_output_types = validate_workflow_context_annotation(
ctx_param.annotation, f"parameter '{ctx_param.name}'", "Handler"
)
message_type = message_param.annotation if message_param.annotation != inspect.Parameter.empty else None
ctx_annotation = ctx_param.annotation
@@ -16,7 +16,7 @@ from ._checkpoint_encoding import (
from ._const import EXECUTOR_STATE_KEY
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
from ._events import SuperStepCompletedEvent, SuperStepStartedEvent, WorkflowEvent
from ._events import WorkflowEvent
from ._exceptions import (
WorkflowCheckpointException,
WorkflowConvergenceException,
@@ -102,7 +102,7 @@ class Runner:
while self._iteration < self._max_iterations:
logger.info(f"Starting superstep {self._iteration + 1}")
yield SuperStepStartedEvent(iteration=self._iteration + 1)
yield WorkflowEvent.superstep_started(iteration=self._iteration + 1)
# Run iteration concurrently with live event streaming: we poll
# for new events while the iteration coroutine progresses.
@@ -147,7 +147,7 @@ class Runner:
# Create checkpoint after each superstep iteration
await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
yield SuperStepCompletedEvent(iteration=self._iteration)
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
# Check for convergence: no more messages to process
if not await self._ctx.has_messages():
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import logging
import sys
@@ -12,7 +14,7 @@ from typing import Any, Protocol, TypeVar, runtime_checkable
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._const import INTERNAL_SOURCE_ID
from ._events import RequestInfoEvent, WorkflowEvent
from ._events import WorkflowEvent
from ._state import State
from ._typing_utils import is_instance_of
@@ -51,7 +53,7 @@ class Message:
source_span_ids: list[str] | None = None # Publishing span IDs for linking from multiple sources
# For response messages, the original request data
original_request_info_event: RequestInfoEvent | None = None
original_request_info_event: WorkflowEvent[Any] | None = None
# Backward compatibility properties
@property
@@ -77,7 +79,7 @@ class Message:
}
@staticmethod
def from_dict(data: dict[str, Any]) -> "Message":
def from_dict(data: dict[str, Any]) -> Message:
"""Create a Message from a dictionary."""
# Validation
if "data" not in data:
@@ -254,11 +256,11 @@ class RunnerContext(Protocol):
"""
...
async def add_request_info_event(self, event: RequestInfoEvent) -> None:
"""Add a RequestInfoEvent to the context and track it for correlation.
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
"""Add a request_info event to the context and track it for correlation.
Args:
event: The RequestInfoEvent to be added.
event: The WorkflowEvent with type='request_info' to be added.
"""
...
@@ -271,11 +273,11 @@ class RunnerContext(Protocol):
"""
...
async def get_pending_request_info_events(self) -> dict[str, RequestInfoEvent]:
"""Get the mapping of request IDs to their corresponding RequestInfoEvent.
async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]:
"""Get the mapping of request IDs to their corresponding request_info events.
Returns:
A dictionary mapping request IDs to their corresponding RequestInfoEvent.
A dictionary mapping request IDs to their corresponding WorkflowEvent (type='request_info').
"""
...
@@ -294,7 +296,7 @@ class InProcRunnerContext:
self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue()
# An additional storage for pending request info events
self._pending_request_info_events: dict[str, RequestInfoEvent] = {}
self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {}
# Checkpointing configuration/state
self._checkpoint_storage = checkpoint_storage
@@ -426,7 +428,7 @@ class InProcRunnerContext:
self._pending_request_info_events.clear()
pending_requests_data = checkpoint.pending_request_info_events
for request_id, request_data in pending_requests_data.items():
request_info_event = RequestInfoEvent.from_dict(request_data)
request_info_event = WorkflowEvent.from_dict(request_data)
self._pending_request_info_events[request_id] = request_info_event
await self.add_event(request_info_event)
@@ -470,12 +472,14 @@ class InProcRunnerContext:
"pending_request_info_events": serialized_pending_request_info_events,
}
async def add_request_info_event(self, event: RequestInfoEvent) -> None:
"""Add a RequestInfoEvent to the context and track it for correlation.
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
"""Add a request_info event to the context and track it for correlation.
Args:
event: The RequestInfoEvent to be added.
event: The WorkflowEvent with type='request_info' to be added.
"""
if event.request_id is None:
raise ValueError("request_info event must have a request_id")
self._pending_request_info_events[event.request_id] = event
await self.add_event(event)
@@ -497,21 +501,23 @@ class InProcRunnerContext:
f"expected {event.response_type.__name__}, got {type(response).__name__}"
)
source_executor_id = event.source_executor_id
# Create ResponseMessage instance
response_msg = Message(
data=response,
source_id=INTERNAL_SOURCE_ID(event.source_executor_id),
target_id=event.source_executor_id,
source_id=INTERNAL_SOURCE_ID(source_executor_id),
target_id=source_executor_id,
type=MessageType.RESPONSE,
original_request_info_event=event,
)
await self.send_message(response_msg)
async def get_pending_request_info_events(self) -> dict[str, RequestInfoEvent]:
"""Get the mapping of request IDs to their corresponding RequestInfoEvent.
async def get_pending_request_info_events(self) -> dict[str, WorkflowEvent[Any]]:
"""Get the mapping of request IDs to their corresponding request_info events.
Returns:
A dictionary mapping request IDs to their corresponding RequestInfoEvent.
A dictionary mapping request IDs to their corresponding WorkflowEvent (type='request_info').
"""
return dict(self._pending_request_info_events)
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import functools
import hashlib
@@ -19,14 +21,9 @@ from ._edge import (
FanOutEdgeGroup,
)
from ._events import (
RequestInfoEvent,
WorkflowErrorDetails,
WorkflowEvent,
WorkflowFailedEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStartedEvent,
WorkflowStatusEvent,
_framework_event_origin, # type: ignore
)
from ._executor import Executor
@@ -59,9 +56,9 @@ class WorkflowRunResult(list[WorkflowEvent]):
- status_timeline(): Access the complete status event history
"""
def __init__(self, events: list[WorkflowEvent], status_events: list[WorkflowStatusEvent] | None = None) -> None:
def __init__(self, events: list[WorkflowEvent[Any]], status_events: list[WorkflowEvent[Any]] | None = None) -> None:
super().__init__(events)
self._status_events: list[WorkflowStatusEvent] = status_events or []
self._status_events: list[WorkflowEvent[Any]] = status_events or []
def get_outputs(self) -> list[Any]:
"""Get all outputs from the workflow run result.
@@ -69,30 +66,30 @@ class WorkflowRunResult(list[WorkflowEvent]):
Returns:
A list of outputs produced by the workflow during its execution.
"""
return [event.data for event in self if isinstance(event, WorkflowOutputEvent)]
return [event.data for event in self if event.type == "output"]
def get_request_info_events(self) -> list[RequestInfoEvent]:
def get_request_info_events(self) -> list[WorkflowEvent[Any]]:
"""Get all request info events from the workflow run result.
Returns:
A list of RequestInfoEvent instances found in the workflow run result.
A list of WorkflowEvent instances with type='request_info' found in the workflow run result.
"""
return [event for event in self if isinstance(event, RequestInfoEvent)]
return [event for event in self if event.type == "request_info"]
def get_final_state(self) -> WorkflowRunState:
"""Return the final run state based on explicit status events.
Returns the last WorkflowStatusEvent.state observed. Raises if none were emitted.
Returns the last status event's state observed. Raises if none were emitted.
"""
if self._status_events:
return self._status_events[-1].state # type: ignore[return-value]
raise RuntimeError(
"Final state is unknown because no WorkflowStatusEvent was emitted. "
"Final state is unknown because no status event was emitted. "
"Ensure your workflow entry points are used (which emit status events) "
"or handle the absence of status explicitly."
)
def status_timeline(self) -> list[WorkflowStatusEvent]:
def status_timeline(self) -> list[WorkflowEvent[Any]]:
"""Return the list of status events emitted during the run (control-plane)."""
return list(self._status_events)
@@ -145,7 +142,7 @@ class Workflow(DictConvertible):
Executors within a workflow can request external input using `ctx.request_info()`:
1. Executor calls `ctx.request_info()` to request input
2. Executor implements `response_handler()` to process the response
3. Requests are emitted as RequestInfoEvent instances in the event stream
3. Requests are emitted as request_info events (WorkflowEvent with type='request_info') in the event stream
4. Workflow enters IDLE_WITH_PENDING_REQUESTS state
5. Caller handles requests and provides responses via the `send_responses` or `send_responses_streaming` methods
6. Responses are routed to the requesting executors and response handlers are invoked
@@ -205,7 +202,7 @@ class Workflow(DictConvertible):
self.name = name
self.description = description
# `WorkflowOutputEvent`s from these executors are treated as workflow outputs.
# Output events (WorkflowEvent with type='output') from these executors are treated as workflow outputs.
# If None or empty, all executor outputs are considered workflow outputs.
self._output_executors = list(output_executors) if output_executors else list(self.executors.keys())
@@ -332,10 +329,10 @@ class Workflow(DictConvertible):
span.add_event(OtelAttr.WORKFLOW_STARTED)
# Emit explicit start/status events to the stream
with _framework_event_origin():
started = WorkflowStartedEvent()
started = WorkflowEvent.started()
yield started
with _framework_event_origin():
in_progress = WorkflowStatusEvent(WorkflowRunState.IN_PROGRESS)
in_progress = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
yield in_progress
# Reset context for a new run if supported
@@ -359,39 +356,39 @@ class Workflow(DictConvertible):
# All executor executions happen within workflow span
async for event in self._runner.run_until_convergence():
# Track request events for final status determination
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
saw_request = True
yield event
if isinstance(event, RequestInfoEvent) and not emitted_in_progress_pending:
if event.type == "request_info" and not emitted_in_progress_pending:
emitted_in_progress_pending = True
with _framework_event_origin():
pending_status = WorkflowStatusEvent(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
pending_status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
yield pending_status
# 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)
terminal_status = WorkflowEvent.status(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS)
yield terminal_status
else:
with _framework_event_origin():
terminal_status = WorkflowStatusEvent(WorkflowRunState.IDLE)
terminal_status = WorkflowEvent.status(WorkflowRunState.IDLE)
yield terminal_status
span.add_event(OtelAttr.WORKFLOW_COMPLETED)
except Exception as exc:
# Drain any pending events (for example, ExecutorFailedEvent) before yielding WorkflowFailedEvent
# Drain any pending events (for example, executor_failed) before yielding failed event
for event in await self._runner.context.drain_events():
yield event
# Surface structured failure details before propagating exception
details = WorkflowErrorDetails.from_exception(exc)
with _framework_event_origin():
failed_event = WorkflowFailedEvent(details)
failed_event = WorkflowEvent.failed(details)
yield failed_event
with _framework_event_origin():
failed_status = WorkflowStatusEvent(WorkflowRunState.FAILED)
failed_status = WorkflowEvent.status(WorkflowRunState.FAILED)
yield failed_status
span.add_event(
name=OtelAttr.WORKFLOW_ERROR,
@@ -554,7 +551,7 @@ class Workflow(DictConvertible):
streaming=True,
run_kwargs=kwargs if kwargs else None,
):
if isinstance(event, WorkflowOutputEvent) and not self._should_yield_output_event(event):
if event.type == "output" and not self._should_yield_output_event(event):
continue
yield event
finally:
@@ -579,7 +576,7 @@ class Workflow(DictConvertible):
reset_context=False, # Don't reset context when sending responses
streaming=True,
):
if isinstance(event, WorkflowOutputEvent) and not self._should_yield_output_event(event):
if event.type == "output" and not self._should_yield_output_event(event):
continue
yield event
finally:
@@ -628,20 +625,20 @@ class Workflow(DictConvertible):
self._reset_running_flag()
# Filter events for non-streaming mode
filtered: list[WorkflowEvent] = []
status_events: list[WorkflowStatusEvent] = []
filtered: list[WorkflowEvent[Any]] = []
status_events: list[WorkflowEvent[Any]] = []
for ev in raw_events:
# Omit WorkflowStartedEvent from non-streaming (telemetry-only)
if isinstance(ev, WorkflowStartedEvent):
# Omit started events from non-streaming (telemetry-only)
if ev.type == "started":
continue
# Track status; include inline only if explicitly requested
if isinstance(ev, WorkflowStatusEvent):
if ev.type == "status":
status_events.append(ev)
if include_status_events:
filtered.append(ev)
continue
if isinstance(ev, WorkflowOutputEvent) and not self._should_yield_output_event(ev):
if ev.type == "output" and not self._should_yield_output_event(ev):
continue
filtered.append(ev)
@@ -665,12 +662,12 @@ class Workflow(DictConvertible):
reset_context=False, # Don't reset context when sending responses
)
]
status_events = [e for e in events if isinstance(e, WorkflowStatusEvent)]
filtered_events: list[WorkflowEvent] = []
status_events = [e for e in events if e.type == "status"]
filtered_events: list[WorkflowEvent[Any]] = []
for e in events:
if isinstance(e, WorkflowOutputEvent) and not self._should_yield_output_event(e):
if e.type == "output" and not self._should_yield_output_event(e):
continue
if isinstance(e, (WorkflowStatusEvent, WorkflowStartedEvent)):
if e.type in ("status", "started"):
continue
filtered_events.append(e)
return WorkflowRunResult(filtered_events, status_events)
@@ -712,11 +709,11 @@ class Workflow(DictConvertible):
raise ValueError(f"Executor with ID {executor_id} not found.")
return self.executors[executor_id]
def _should_yield_output_event(self, event: WorkflowOutputEvent) -> bool:
"""Determine if a WorkflowOutputEvent should be yielded as a workflow output.
def _should_yield_output_event(self, event: WorkflowEvent[Any]) -> bool:
"""Determine if an output event should be yielded as a workflow output.
Args:
event: The WorkflowOutputEvent to evaluate.
event: The WorkflowEvent with type='output' to evaluate.
Returns:
True if the event should be yielded as a workflow output, False otherwise.
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import copy
import inspect
import logging
@@ -13,15 +15,8 @@ from typing_extensions import Never, TypeVar
from ..observability import OtelAttr, create_workflow_span
from ._events import (
RequestInfoEvent,
WorkflowEvent,
WorkflowEventSource,
WorkflowFailedEvent,
WorkflowLifecycleEvent,
WorkflowOutputEvent,
WorkflowStartedEvent,
WorkflowStatusEvent,
WorkflowWarningEvent,
_framework_event_origin, # type: ignore
)
from ._runner_context import Message, RunnerContext
@@ -204,15 +199,8 @@ def validate_workflow_context_annotation(
return infer_output_types_from_ctx_annotation(annotation)
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: tuple[type[WorkflowEvent], ...] = cast(
tuple[type[WorkflowEvent], ...],
tuple(get_args(WorkflowLifecycleEvent))
or (
WorkflowStartedEvent,
WorkflowStatusEvent,
WorkflowFailedEvent,
),
)
# Event types reserved for framework lifecycle (not allowed from user code)
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: frozenset[str] = frozenset({"started", "status", "failed"})
class WorkflowContext(Generic[OutT, W_OutT]):
@@ -264,7 +252,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
def __init__(
self,
executor: "Executor",
executor: Executor,
source_executor_ids: list[str],
state: State,
runner_context: RunnerContext,
@@ -291,10 +279,10 @@ class WorkflowContext(Generic[OutT, W_OutT]):
self._runner_context = runner_context
self._state = state
# Track messages sent via send_message() for ExecutorCompletedEvent
# Track messages sent via send_message() for executor_completed event (type='executor_completed')
self._sent_messages: list[Any] = []
# Track outputs yielded via yield_output() for ExecutorCompletedEvent
# Track outputs yielded via yield_output() for executor_completed event (type='executor_completed')
self._yielded_outputs: list[Any] = []
# Store trace contexts and source span IDs for linking (supporting multiple sources)
@@ -335,7 +323,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
# Create Message wrapper
msg = Message(data=message, source_id=self._executor_id, target_id=target_id)
# Track sent message for ExecutorCompletedEvent
# Track sent message for executor_completed event (type='executor_completed')
self._sent_messages.append(message)
# Inject current trace context if tracing enabled
@@ -355,31 +343,31 @@ class WorkflowContext(Generic[OutT, W_OutT]):
output: The output to yield. This must conform to the workflow output type(s)
declared on this context.
"""
# Track yielded output for ExecutorCompletedEvent (deepcopy to capture state at yield time)
# Track yielded output for executor_completed event (type='executor_completed')
# (deepcopy to capture state at yield time)
self._yielded_outputs.append(copy.deepcopy(output))
with _framework_event_origin():
event = WorkflowOutputEvent(data=output, executor_id=self._executor_id)
event = WorkflowEvent.output(self._executor_id, output)
await self._runner_context.add_event(event)
async def add_event(self, event: WorkflowEvent) -> None:
async def add_event(self, event: WorkflowEvent[Any]) -> None:
"""Add an event to the workflow context."""
if event.origin == WorkflowEventSource.EXECUTOR and isinstance(event, _FRAMEWORK_LIFECYCLE_EVENT_TYPES):
event_name = event.__class__.__name__
if event.origin == WorkflowEventSource.EXECUTOR and event.type in _FRAMEWORK_LIFECYCLE_EVENT_TYPES:
warning_msg = (
f"Executor '{self._executor_id}' attempted to emit {event_name}, "
f"Executor '{self._executor_id}' attempted to emit a '{event.type}' event, "
"which is reserved for framework lifecycle notifications. The "
"event was ignored."
)
logger.warning(warning_msg)
await self._runner_context.add_event(WorkflowWarningEvent(warning_msg))
await self._runner_context.add_event(WorkflowEvent.warning(warning_msg))
return
await self._runner_context.add_event(event)
async def request_info(self, request_data: object, response_type: type, *, request_id: str | None = None) -> None:
"""Request information from outside of the workflow.
Calling this method will cause the workflow to emit a RequestInfoEvent, carrying the
Calling this method will cause the workflow to emit a request_info event (type='request_info'), carrying the
provided request_data and request_type. External systems listening for such events
can then process the request and respond accordingly.
@@ -401,7 +389,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
"not be processed. Please define a response handler using the @response_handler decorator."
)
request_info_event = RequestInfoEvent(
request_info_event = WorkflowEvent.request_info(
request_id=request_id or str(uuid.uuid4()),
source_executor_id=self._executor_id,
request_data=request_data,
@@ -14,9 +14,7 @@ if TYPE_CHECKING:
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._const import WORKFLOW_RUN_KWARGS_KEY
from ._events import (
RequestInfoEvent,
WorkflowErrorEvent,
WorkflowFailedEvent,
WorkflowEvent,
WorkflowRunState,
)
from ._executor import Executor, handler
@@ -52,38 +50,38 @@ class ExecutionContext:
# Pending requests to be fulfilled. This will get updated as the
# WorkflowExecutor receives responses.
pending_requests: dict[str, RequestInfoEvent] # request_id -> request_info_event
pending_requests: dict[str, WorkflowEvent] # request_id -> request_info_event
@dataclass
class SubWorkflowResponseMessage:
"""Message sent from a parent workflow to a sub-workflow via WorkflowExecutor to provide requested information.
This message wraps the response data along with the original RequestInfoEvent emitted by the sub-workflow executor.
This message wraps the response data along with the original WorkflowEvent emitted by the sub-workflow executor.
Attributes:
data: The response data to the original request.
source_event: The original RequestInfoEvent emitted by the sub-workflow executor.
source_event: The original WorkflowEvent emitted by the sub-workflow executor.
"""
data: Any
source_event: RequestInfoEvent
source_event: WorkflowEvent
@dataclass
class SubWorkflowRequestMessage:
"""Message sent from a sub-workflow to an executor in the parent workflow to request information.
This message wraps a RequestInfoEvent emitted by the executor in the sub-workflow.
This message wraps a WorkflowEvent emitted by the executor in the sub-workflow.
Attributes:
source_event: The original RequestInfoEvent emitted by the sub-workflow executor.
source_event: The original WorkflowEvent emitted by the sub-workflow executor.
executor_id: The ID of the WorkflowExecutor in the parent workflow that is
responsible for this sub-workflow. This can be used to ensure that the response
is sent back to the correct sub-workflow instance.
"""
source_event: RequestInfoEvent
source_event: WorkflowEvent
executor_id: str
def create_response(self, data: Any) -> SubWorkflowResponseMessage:
@@ -153,7 +151,7 @@ class WorkflowExecutor(Executor):
# An executor in the sub-workflow makes request
request = MyDataRequest(query="user info")
# WorkflowExecutor captures RequestInfoEvent and wraps it in a SubWorkflowRequestMessage
# WorkflowExecutor captures WorkflowEvent and wraps it in a SubWorkflowRequestMessage
# then send it to the receiving executor in parent workflow. The executor in parent workflow
# can handle the request locally or forward it to an external source.
# The WorkflowExecutor tracks the pending request, and implements a response handler.
@@ -191,8 +189,8 @@ class WorkflowExecutor(Executor):
## Error Handling
WorkflowExecutor propagates sub-workflow failures:
- Captures WorkflowFailedEvent from sub-workflow
- Converts to WorkflowErrorEvent in parent context
- Captures failed event (type='failed') from sub-workflow
- Converts to error event in parent context
- Provides detailed error information including sub-workflow ID
## Concurrent Execution Support
@@ -285,7 +283,7 @@ class WorkflowExecutor(Executor):
workflow's event stream.
propagate_request: Whether to propagate requests from the sub-workflow to the
parent workflow. If set to true, requests from the sub-workflow
will be propagated as the original RequestInfoEvent to the parent
will be propagated as the original WorkflowEvent to the parent
workflow. Otherwise, they will be wrapped in a SubWorkflowRequestMessage,
which should be handled by an executor in the parent workflow.
@@ -421,8 +419,9 @@ class WorkflowExecutor(Executor):
response: The response to a previous request.
ctx: The workflow context.
"""
request_id = response.source_event.request_id
await self._handle_response(
request_id=response.source_event.request_id,
request_id=request_id,
response=response.data,
ctx=ctx,
)
@@ -437,7 +436,7 @@ class WorkflowExecutor(Executor):
"""Handle response for a request that was propagated to the parent workflow.
Args:
original_request: The original RequestInfoEvent.
original_request: The original WorkflowEvent.
response: The response data.
ctx: The workflow context.
"""
@@ -550,15 +549,17 @@ class WorkflowExecutor(Executor):
# Process request info events
for event in request_info_events:
request_id = event.request_id
response_type = event.response_type
# Track the pending request in execution context
execution_context.pending_requests[event.request_id] = event
execution_context.pending_requests[request_id] = event
# Map request to execution for response routing
self._request_to_execution[event.request_id] = execution_context.execution_id
self._request_to_execution[request_id] = execution_context.execution_id
if self._propagate_request:
# In a workflow where the parent workflow does not handle the request, the request
# should be propagated via the `request_info` mechanism to an external source. And
# a @response_handler would be required in the WorkflowExecutor to handle the response.
await ctx.request_info(event.data, event.response_type, request_id=event.request_id)
await ctx.request_info(event.data, response_type, request_id=request_id)
else:
# In a workflow where the parent workflow has an executor that may intercept the
# request and handle it directly, a message should be sent.
@@ -569,18 +570,19 @@ class WorkflowExecutor(Executor):
# Handle final state
if workflow_run_state == WorkflowRunState.FAILED:
# Find the WorkflowFailedEvent.
failed_events = [e for e in result if isinstance(e, WorkflowFailedEvent)]
# Find the failed event (type='failed').
failed_events = [e for e in result if isinstance(e, WorkflowEvent) and e.type == "failed"]
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,
)
if failed_event.details is not None:
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}"
)
else:
exception = Exception(f"Sub-workflow {self.workflow.id} failed with unknown error")
error_event = WorkflowEvent.error(exception)
await ctx.add_event(error_event)
elif workflow_run_state == WorkflowRunState.IDLE:
# Sub-workflow is idle - nothing more to do now
@@ -661,11 +663,7 @@ class WorkflowExecutor(Executor):
# requesting the same information again.
for request_id in responses_to_send:
event_to_remove = next(
(
event
for event in result
if isinstance(event, RequestInfoEvent) and event.request_id == request_id
),
(event for event in result if event.type == "request_info" and event.request_id == request_id),
None,
)
if event_to_remove:
@@ -12,6 +12,8 @@ from agent_framework_orchestrations import (
ConcurrentBuilder,
GroupChatBuilder,
GroupChatOrchestrator,
GroupChatRequestMessage,
GroupChatRequestSentEvent,
GroupChatSelectionFunction,
GroupChatState,
HandoffAgentExecutor,
@@ -48,6 +50,8 @@ __all__ = [
"ConcurrentBuilder",
"GroupChatBuilder",
"GroupChatOrchestrator",
"GroupChatRequestMessage",
"GroupChatRequestSentEvent",
"GroupChatSelectionFunction",
"GroupChatState",
"HandoffAgentExecutor",
@@ -13,9 +13,7 @@ from agent_framework import (
ChatMessageStore,
Content,
ResponseStream,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
)
from agent_framework._workflows._agent_executor import AgentExecutorResponse
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
@@ -77,9 +75,9 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
# Run the workflow with a user message
first_run_output: AgentExecutorResponse | None = None
async for ev in wf.run("First workflow run", stream=True):
if isinstance(ev, WorkflowOutputEvent):
if ev.type == "output":
first_run_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
assert first_run_output is not None
@@ -131,9 +129,9 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
# Resume from checkpoint
resumed_output: AgentExecutorResponse | None = None
async for ev in wf_resume.run(checkpoint_id=restore_checkpoint.checkpoint_id, stream=True):
if isinstance(ev, WorkflowOutputEvent):
if ev.type == "output":
resumed_output = ev.data # type: ignore[assignment]
if isinstance(ev, WorkflowStatusEvent) and ev.state in (
if ev.type == "status" and ev.state in (
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
):
@@ -19,11 +19,10 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
Content,
RequestInfoEvent,
ResponseStream,
WorkflowBuilder,
WorkflowContext,
WorkflowOutputEvent,
WorkflowEvent,
executor,
tool,
)
@@ -100,9 +99,9 @@ async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None:
workflow = WorkflowBuilder().set_start_executor(agent_exec).build()
# Act: run in streaming mode
events: list[WorkflowOutputEvent] = []
events: list[WorkflowEvent[AgentResponseUpdate]] = []
async for event in workflow.run("What's the weather?", stream=True):
if isinstance(event, WorkflowOutputEvent):
if event.type == "output" and isinstance(event.data, AgentResponseUpdate):
events.append(event)
# Assert: we should receive 4 events (text, function call, function result, text)
@@ -290,9 +289,9 @@ async def test_agent_executor_tool_call_with_approval_streaming() -> None:
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
# Act
request_info_events: list[RequestInfoEvent] = []
request_info_events: list[WorkflowEvent] = []
async for event in workflow.run("Invoke tool requiring approval", stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
request_info_events.append(event)
# Assert
@@ -307,7 +306,7 @@ async def test_agent_executor_tool_call_with_approval_streaming() -> None:
async for event in workflow.send_responses_streaming({
approval_request.request_id: approval_request.data.to_function_approval_response(True)
}):
if isinstance(event, WorkflowOutputEvent):
if event.type == "output":
output = event.data
# Assert
@@ -367,9 +366,9 @@ async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> No
workflow = WorkflowBuilder().set_start_executor(agent).add_edge(agent, test_executor).build()
# Act
request_info_events: list[RequestInfoEvent] = []
request_info_events: list[WorkflowEvent] = []
async for event in workflow.run("Invoke tool requiring approval", stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
request_info_events.append(event)
# Assert
@@ -387,7 +386,7 @@ async def test_agent_executor_parallel_tool_call_with_approval_streaming() -> No
output: str | None = None
async for event in workflow.send_responses_streaming(responses):
if isinstance(event, WorkflowOutputEvent):
if event.type == "output":
output = event.data
# Assert
@@ -1,27 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for agent run event typing."""
"""Tests for WorkflowEvent[T] generic type annotations."""
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage
from agent_framework._workflows._events import WorkflowOutputEvent
from agent_framework._workflows._events import WorkflowEvent
def test_agent_run_event_data_type() -> None:
"""Verify WorkflowOutputEvent.data is typed as AgentResponse | None."""
def test_workflow_event_with_agent_response_data_type() -> None:
"""Verify WorkflowEvent[AgentResponse].data is typed as AgentResponse."""
response = AgentResponse(messages=[ChatMessage(role="assistant", text="Hello")])
event = WorkflowOutputEvent(data=response, executor_id="test")
event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response)
# This assignment should pass type checking without a cast
data: AgentResponse | None = event.data
data: AgentResponse = event.data
assert data is not None
assert data.text == "Hello"
def test_agent_run_update_event_data_type() -> None:
"""Verify WorkflowOutputEvent.data is typed as AgentResponseUpdate | None."""
def test_workflow_event_with_agent_response_update_data_type() -> None:
"""Verify WorkflowEvent[AgentResponseUpdate].data is typed as AgentResponseUpdate."""
update = AgentResponseUpdate()
event = WorkflowOutputEvent(data=update, executor_id="test")
event: WorkflowEvent[AgentResponseUpdate] = WorkflowEvent.emit(executor_id="test", data=update)
# This assignment should pass type checking without a cast
data: AgentResponseUpdate | None = event.data
data: AgentResponseUpdate = event.data
assert data is not None
def test_workflow_event_repr() -> None:
"""Verify WorkflowEvent.__repr__ uses consistent format."""
response = AgentResponse(messages=[ChatMessage(role="assistant", text="Hello")])
event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response)
repr_str = repr(event)
assert "WorkflowEvent" in repr_str
assert "executor_id='test'" in repr_str
assert "data=" in repr_str
@@ -8,7 +8,6 @@ from agent_framework import (
WorkflowCheckpointException,
WorkflowContext,
WorkflowRunState,
WorkflowStatusEvent,
handler,
)
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
@@ -80,4 +79,4 @@ async def test_resume_succeeds_when_graph_matches() -> None:
)
]
assert any(isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE for event in events)
assert any(event.type == "status" and event.state == WorkflowRunState.IDLE for event in events)
@@ -8,11 +8,10 @@ from typing_extensions import Never
from agent_framework import (
ChatMessage,
Executor,
ExecutorCompletedEvent,
ExecutorInvokedEvent,
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
executor,
handler,
response_handler,
@@ -139,7 +138,7 @@ def test_executor_handlers_with_output_types():
async def test_executor_invoked_event_contains_input_data():
"""Test that ExecutorInvokedEvent contains the input message data."""
"""Test that executor_invoked event (type='executor_invoked') contains the input message data."""
class UpperCaseExecutor(Executor):
@handler
@@ -157,7 +156,7 @@ async def test_executor_invoked_event_contains_input_data():
workflow = WorkflowBuilder().add_edge(upper, collector).set_start_executor(upper).build()
events = await workflow.run("hello world")
invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)]
invoked_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"]
assert len(invoked_events) == 2
@@ -171,7 +170,7 @@ async def test_executor_invoked_event_contains_input_data():
async def test_executor_completed_event_contains_sent_messages():
"""Test that ExecutorCompletedEvent contains the messages sent via ctx.send_message()."""
"""Test that event (type='executor_completed') contains the messages sent via ctx.send_message()."""
class MultiSenderExecutor(Executor):
@handler
@@ -194,7 +193,7 @@ async def test_executor_completed_event_contains_sent_messages():
workflow = WorkflowBuilder().add_edge(sender, collector).set_start_executor(sender).build()
events = await workflow.run("hello")
completed_events = [e for e in events if isinstance(e, ExecutorCompletedEvent)]
completed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
# Sender should have completed with the sent messages
sender_completed = next(e for e in completed_events if e.executor_id == "sender")
@@ -210,9 +209,7 @@ async def test_executor_completed_event_contains_sent_messages():
async def test_executor_completed_event_includes_yielded_outputs():
"""Test that ExecutorCompletedEvent.data includes yielded outputs."""
from agent_framework import WorkflowOutputEvent
"""Test that WorkflowEvent(type='executor_completed').data includes yielded outputs."""
class YieldOnlyExecutor(Executor):
@handler
@@ -223,15 +220,15 @@ async def test_executor_completed_event_includes_yielded_outputs():
workflow = WorkflowBuilder().set_start_executor(executor).build()
events = await workflow.run("test")
completed_events = [e for e in events if isinstance(e, ExecutorCompletedEvent)]
completed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
assert len(completed_events) == 1
assert completed_events[0].executor_id == "yielder"
# Yielded outputs are now included in ExecutorCompletedEvent.data
# Yielded outputs are now included in executor_completed event (type='executor_completed').data
assert completed_events[0].data == ["TEST"]
# Verify the output was also yielded as WorkflowOutputEvent
output_events = [e for e in events if isinstance(e, WorkflowOutputEvent)]
# Verify the output was also yielded as an output event (type='output')
output_events = [e for e in events if e.type == "output"]
assert len(output_events) == 1
assert output_events[0].data == "TEST"
@@ -268,8 +265,8 @@ async def test_executor_events_with_complex_message_types():
input_request = Request(query="hello", limit=3)
events = await workflow.run(input_request)
invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)]
completed_events = [e for e in events if isinstance(e, ExecutorCompletedEvent)]
invoked_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"]
completed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"]
# Check processor invoked event has the Request object
processor_invoked = next(e for e in invoked_events if e.executor_id == "processor")
@@ -531,7 +528,7 @@ def test_executor_response_handler_union_output_types():
async def test_executor_invoked_event_data_not_mutated_by_handler():
"""Test that ExecutorInvokedEvent.data captures original input, not mutated input."""
"""Test that executor_invoked event (type='executor_invoked').data captures original input, not mutated input."""
@executor(id="Mutator")
async def mutator(messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
@@ -549,7 +546,7 @@ async def test_executor_invoked_event_data_not_mutated_by_handler():
events = await workflow.run(input_messages)
# Find the invoked event for the Mutator executor
invoked_events = [e for e in events if isinstance(e, ExecutorInvokedEvent)]
invoked_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_invoked"]
assert len(invoked_events) == 1
mutator_invoked = invoked_events[0]
@@ -20,7 +20,6 @@ from agent_framework import (
WorkflowBuilder,
WorkflowContext,
WorkflowRunState,
WorkflowStatusEvent,
handler,
)
from agent_framework.orchestrations import SequentialBuilder
@@ -149,7 +148,7 @@ async def test_sequential_adapter_uses_full_conversation() -> None:
# Act
async for ev in wf.run("hello seq", stream=True):
if isinstance(ev, WorkflowStatusEvent) and ev.state == WorkflowRunState.IDLE:
if ev.type == "status" and ev.state == WorkflowRunState.IDLE:
break
# Assert: second agent should have seen the user prompt and A1's assistant reply
@@ -4,11 +4,10 @@ from dataclasses import dataclass
from agent_framework import (
FileCheckpointStorage,
RequestInfoEvent,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
WorkflowRunState,
WorkflowStatusEvent,
handler,
response_handler,
)
@@ -182,9 +181,9 @@ class TestRequestInfoAndResponse:
workflow = WorkflowBuilder().set_start_executor(executor).build()
# First run the workflow until it emits a request
request_info_event: RequestInfoEvent | None = None
request_info_event: WorkflowEvent | None = None
async for event in workflow.run("test operation", stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
request_info_event = event
assert request_info_event is not None
@@ -194,7 +193,7 @@ class TestRequestInfoAndResponse:
# Send response and continue workflow
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
@@ -207,9 +206,9 @@ class TestRequestInfoAndResponse:
workflow = WorkflowBuilder().set_start_executor(executor).build()
# First run the workflow until it emits a calculation request
request_info_event: RequestInfoEvent | None = None
request_info_event: WorkflowEvent | None = None
async for event in workflow.run("multiply 15.5 2.0", stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
request_info_event = event
assert request_info_event is not None
@@ -221,7 +220,7 @@ class TestRequestInfoAndResponse:
calculated_result = 31.0
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: calculated_result}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
@@ -234,18 +233,18 @@ class TestRequestInfoAndResponse:
workflow = WorkflowBuilder().set_start_executor(executor).build()
# Collect all request events by running the full stream
request_events: list[RequestInfoEvent] = []
request_events: list[WorkflowEvent] = []
async for event in workflow.run("start batch", stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
request_events.append(event)
assert len(request_events) == 2
# Find the approval and calculation requests
approval_event: RequestInfoEvent | None = next(
approval_event: WorkflowEvent | None = next(
(e for e in request_events if isinstance(e.data, UserApprovalRequest)), None
)
calc_event: RequestInfoEvent | None = next(
calc_event: WorkflowEvent | None = next(
(e for e in request_events if isinstance(e.data, CalculationRequest)), None
)
@@ -256,7 +255,7 @@ class TestRequestInfoAndResponse:
responses = {approval_event.request_id: True, calc_event.request_id: 50.0}
completed = False
async for event in workflow.send_responses_streaming(responses):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
@@ -268,9 +267,9 @@ class TestRequestInfoAndResponse:
workflow = WorkflowBuilder().set_start_executor(executor).build()
# First run the workflow until it emits a request
request_info_event: RequestInfoEvent | None = None
request_info_event: WorkflowEvent | None = None
async for event in workflow.run("sensitive operation", stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
request_info_event = event
assert request_info_event is not None
@@ -278,7 +277,7 @@ class TestRequestInfoAndResponse:
# Deny the request
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: False}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
@@ -291,12 +290,12 @@ class TestRequestInfoAndResponse:
workflow = WorkflowBuilder().set_start_executor(executor).build()
# Run workflow until idle with pending requests
request_info_event: RequestInfoEvent | None = None
request_info_event: WorkflowEvent | None = None
idle_with_pending = False
async for event in workflow.run("test operation", stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
request_info_event = event
elif isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
idle_with_pending = True
assert request_info_event is not None
@@ -305,7 +304,7 @@ class TestRequestInfoAndResponse:
# Continue with response
completed = False
async for event in workflow.send_responses_streaming({request_info_event.request_id: True}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
@@ -318,7 +317,7 @@ class TestRequestInfoAndResponse:
# Send invalid input (no numbers)
completed = False
async for event in workflow.run("invalid input", stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
completed = True
assert completed
@@ -338,9 +337,9 @@ class TestRequestInfoAndResponse:
workflow = WorkflowBuilder().set_start_executor(executor).with_checkpointing(storage).build()
# Step 1: Run workflow to completion to ensure checkpoints are created
request_info_event: RequestInfoEvent | None = None
request_info_event: WorkflowEvent | None = None
async for event in workflow.run("checkpoint test operation", stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
request_info_event = event
# Verify request was emitted
@@ -377,15 +376,12 @@ class TestRequestInfoAndResponse:
# Step 5: Resume from checkpoint and verify the request can be continued
completed = False
restored_request_event: RequestInfoEvent | None = None
restored_request_event: WorkflowEvent | None = None
async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True):
# Should re-emit the pending request info event
if isinstance(event, RequestInfoEvent) and event.request_id == request_info_event.request_id:
if event.type == "request_info" and event.request_id == request_info_event.request_id:
restored_request_event = event
elif (
isinstance(event, WorkflowStatusEvent)
and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
):
elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
completed = True
assert completed, "Workflow should reach idle with pending requests state after restoration"
@@ -402,7 +398,7 @@ class TestRequestInfoAndResponse:
async for event in restored_workflow.send_responses_streaming({
request_info_event.request_id: True # Approve the request
}):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
final_completed = True
assert final_completed, "Workflow should complete after providing response to restored request"
@@ -9,7 +9,7 @@ import pytest
from agent_framework import InMemoryCheckpointStorage, InProcRunnerContext
from agent_framework._workflows._checkpoint_encoding import DATACLASS_MARKER, encode_checkpoint_value
from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
from agent_framework._workflows._events import RequestInfoEvent
from agent_framework._workflows._events import WorkflowEvent
from agent_framework._workflows._state import State
@@ -36,7 +36,7 @@ class TimedApproval:
async def test_rehydrate_request_info_event() -> None:
"""Rehydration should succeed for valid request info events."""
request_info_event = RequestInfoEvent(
request_info_event = WorkflowEvent.request_info(
request_id="request-123",
source_executor_id="review_gateway",
request_data=MockRequest(),
@@ -69,7 +69,7 @@ async def test_rehydrate_request_info_event() -> None:
async def test_rehydrate_fails_when_request_type_missing() -> None:
"""Rehydration should fail is the request type is missing or fails to import."""
request_info_event = RequestInfoEvent(
request_info_event = WorkflowEvent.request_info(
request_id="request-123",
source_executor_id="review_gateway",
request_data=MockRequest(),
@@ -97,7 +97,7 @@ async def test_rehydrate_fails_when_request_type_missing() -> None:
async def test_rehydrate_fails_when_request_type_mismatch() -> None:
"""Rehydration should fail if the request type is mismatched."""
request_info_event = RequestInfoEvent(
request_info_event = WorkflowEvent.request_info(
request_id="request-123",
source_executor_id="review_gateway",
request_data=MockRequest(),
@@ -127,7 +127,7 @@ async def test_rehydrate_fails_when_request_type_mismatch() -> None:
async def test_pending_requests_in_summary() -> None:
"""Test that pending requests are correctly summarized in the checkpoint summary."""
request_info_event = RequestInfoEvent(
request_info_event = WorkflowEvent.request_info(
request_id="request-123",
source_executor_id="review_gateway",
request_data=MockRequest(),
@@ -148,7 +148,8 @@ async def test_pending_requests_in_summary() -> None:
assert len(summary.pending_request_info_events) == 1
pending_event = summary.pending_request_info_events[0]
assert isinstance(pending_event, RequestInfoEvent)
assert isinstance(pending_event, WorkflowEvent)
assert pending_event.type == "request_info"
assert pending_event.request_id == "request-123"
assert pending_event.source_executor_id == "review_gateway"
@@ -158,13 +159,13 @@ async def test_pending_requests_in_summary() -> None:
async def test_request_info_event_serializes_non_json_payloads() -> None:
req_1 = RequestInfoEvent(
req_1 = WorkflowEvent.request_info(
request_id="req-1",
source_executor_id="source",
request_data=TimedApproval(issued_at=datetime(2024, 5, 4, 12, 30, 45)),
response_type=bool,
)
req_2 = RequestInfoEvent(
req_2 = WorkflowEvent.request_info(
request_id="req-2",
source_executor_id="source",
request_data=SlottedApproval(note="slot-based"),
@@ -12,10 +12,8 @@ from agent_framework import (
WorkflowContext,
WorkflowConvergenceException,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunnerException,
WorkflowRunState,
WorkflowStatusEvent,
handler,
)
from agent_framework._workflows._edge import SingleEdgeGroup
@@ -97,7 +95,7 @@ async def test_runner_run_until_convergence():
)
async for event in runner.run_until_convergence():
assert isinstance(event, WorkflowEvent)
if isinstance(event, WorkflowOutputEvent):
if event.type == "output":
result = event.data
assert result is not None and result == 10
@@ -137,7 +135,7 @@ async def test_runner_run_until_convergence_not_completed():
match="Runner did not converge after 5 iterations.",
):
async for event in runner.run_until_convergence():
assert not isinstance(event, WorkflowStatusEvent) or event.state != WorkflowRunState.IDLE
assert event.type != "status" or event.state != WorkflowRunState.IDLE
async def test_runner_already_running():
@@ -8,12 +8,12 @@ from typing_extensions import Never
from agent_framework import (
Executor,
RequestInfoEvent,
SubWorkflowRequestMessage,
SubWorkflowResponseMessage,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
WorkflowExecutor,
handler,
response_handler,
@@ -592,7 +592,7 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
first_request_id: str | None = None
async for event in workflow1.run("test_value", stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
first_request_id = event.request_id
assert first_request_id is not None
@@ -606,15 +606,15 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
resumed_first_request_id: str | None = None
async for event in workflow2.run(checkpoint_id=checkpoint_id, stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
resumed_first_request_id = event.request_id
assert resumed_first_request_id is not None
assert resumed_first_request_id == first_request_id
request_events: list[RequestInfoEvent] = []
request_events: list[WorkflowEvent] = []
async for event in workflow2.send_responses_streaming({resumed_first_request_id: "first_answer"}):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
request_events.append(event)
# Key assertion: Only the second request should be received, not a duplicate of the first
@@ -5,7 +5,7 @@ from typing import Any, Generic, Optional, TypeVar, Union
import pytest
from agent_framework import RequestInfoEvent
from agent_framework import WorkflowEvent
from agent_framework._workflows._typing_utils import (
deserialize_type,
is_instance_of,
@@ -308,18 +308,19 @@ def test_serialize_deserialize_roundtrip() -> None:
# Test agent framework type roundtrip
serialized = serialize_type(RequestInfoEvent)
serialized = serialize_type(WorkflowEvent)
deserialized = deserialize_type(serialized)
assert deserialized is RequestInfoEvent
assert deserialized is WorkflowEvent
# Verify we can instantiate the deserialized type
instance = deserialized(
# Verify we can instantiate the deserialized type via factory method
instance = WorkflowEvent.request_info(
request_id="request-123",
source_executor_id="executor_1",
request_data="test",
response_type=str,
)
assert isinstance(instance, RequestInfoEvent)
assert isinstance(instance, WorkflowEvent)
assert instance.type == "request_info"
def test_deserialize_type_error_handling() -> None:
@@ -20,16 +20,13 @@ from agent_framework import (
Executor,
FileCheckpointStorage,
Message,
RequestInfoEvent,
ResponseStream,
WorkflowBuilder,
WorkflowCheckpointException,
WorkflowContext,
WorkflowConvergenceException,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
handler,
response_handler,
)
@@ -123,7 +120,7 @@ async def test_workflow_run_streaming() -> None:
result: int | None = None
async for event in workflow.run(NumberMessage(data=0), stream=True):
assert isinstance(event, WorkflowEvent)
if isinstance(event, WorkflowOutputEvent):
if event.type == "output":
result = event.data
assert result is not None and result == 10
@@ -197,9 +194,10 @@ async def test_fan_out():
events = await workflow.run(NumberMessage(data=0))
# Each executor will emit two events: ExecutorInvokedEvent and ExecutorCompletedEvent
# executor_b will also emit a WorkflowOutputEvent (no WorkflowCompletedEvent anymore)
# Each superstep will emit also emit a WorkflowStartedEvent and WorkflowCompletedEvent
# Each executor will emit two events: executor_invoked (type='executor_invoked')
# and executor_completed (type='executor_completed')
# executor_b will also emit an output event (type='output')
# Each superstep will emit a started event (type='started') and status event (type='status')
# This workflow will converge in 2 supersteps because executor_c will send one more message
# after executor_b completes
assert len(events) == 11
@@ -221,9 +219,10 @@ async def test_fan_out_multiple_completed_events():
events = await workflow.run(NumberMessage(data=0))
# Each executor will emit two events: ExecutorInvokedEvent and ExecutorCompletedEvent
# executor_b and executor_c will also emit a WorkflowOutputEvent (no WorkflowCompletedEvent anymore)
# Each superstep will emit also emit a WorkflowStartedEvent and WorkflowCompletedEvent
# Each executor will emit two events: executor_invoked (type='executor_invoked')
# and executor_completed (type='executor_completed')
# executor_b and executor_c will also emit an output event (type='output')
# Each superstep will emit a started event (type='started') and status event (type='status')
# This workflow will converge in 1 superstep because executor_a and executor_b will not send further messages
assert len(events) == 10
@@ -249,9 +248,10 @@ async def test_fan_in():
events = await workflow.run(NumberMessage(data=0))
# Each executor will emit two events: ExecutorInvokedEvent and ExecutorCompletedEvent
# aggregator will also emit a WorkflowOutputEvent (no WorkflowCompletedEvent anymore)
# Each superstep will emit also emit a WorkflowStartedEvent and WorkflowCompletedEvent
# Each executor will emit two events: executor_invoked (type='executor_invoked')
# and executor_completed (type='executor_completed')
# aggregator will also emit an output event (type='output')
# Each superstep will emit a started event (type='started') and status event (type='status')
assert len(events) == 13
assert events.get_final_state() == WorkflowRunState.IDLE
@@ -427,7 +427,7 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
async def test_workflow_run_stream_from_checkpoint_with_responses(
simple_executor: Executor,
):
"""Test that workflow can be resumed from checkpoint with pending RequestInfoEvents."""
"""Test that workflow can be resumed from checkpoint with pending request_info events."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
@@ -439,7 +439,7 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
messages={},
state={},
pending_request_info_events={
"request_123": RequestInfoEvent(
"request_123": WorkflowEvent.request_info(
request_id="request_123",
source_executor_id=simple_executor.id,
request_data="Mock",
@@ -465,9 +465,7 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
events.append(event)
# Verify that the pending request event was emitted
assert next(
event for event in events if isinstance(event, RequestInfoEvent) and event.request_id == "request_123"
)
assert next(event for event in events if event.type == "request_info" and event.request_id == "request_123")
assert len(events) > 0 # Just ensure we processed some events
@@ -730,10 +728,12 @@ async def test_workflow_with_simple_cycle_and_exit_condition():
assert outputs[0] is not None and outputs[0] >= 6 # Should complete when executor_a reaches its limit
# Verify cycling occurred (should have events from both executors)
# Check for ExecutorInvokedEvent and ExecutorCompletedEvent types that have executor_id
from agent_framework import ExecutorCompletedEvent, ExecutorInvokedEvent
# Check for executor events that have executor_id
from agent_framework import WorkflowEvent
executor_events = [e for e in events if isinstance(e, (ExecutorInvokedEvent, ExecutorCompletedEvent))]
executor_events = [
e for e in events if isinstance(e, WorkflowEvent) and e.type in ("executor_invoked", "executor_completed")
]
executor_ids = {e.executor_id for e in executor_events}
assert "exec_a" in executor_ids, "Should have events from executor A"
assert "exec_b" in executor_ids, "Should have events from executor B"
@@ -880,7 +880,7 @@ class _StreamingTestAgent(BaseAgent):
async def test_agent_streaming_vs_non_streaming() -> None:
"""Test that stream=True/False both emits WorkflowOutputEvents correctly with the right data types."""
"""Test that stream=True/False both emit output events (type='output') with the right data types."""
agent = _StreamingTestAgent(id="test_agent", name="TestAgent", reply_text="Hello World")
agent_exec = AgentExecutor(agent, id="agent_exec")
@@ -890,17 +890,15 @@ async def test_agent_streaming_vs_non_streaming() -> None:
result = await workflow.run("test message")
# Filter for agent events (result is a list of events)
agent_response = [e for e in result if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponse)]
agent_response_updates = [
e for e in result if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponseUpdate)
]
agent_run_events = [e for e in result if e.type == "output" and isinstance(e.data, AgentResponse)]
agent_update_events = [e for e in result if e.type == "output" and isinstance(e.data, AgentResponseUpdate)]
# In non-streaming mode, should have AgentResponse, no AgentResponseUpdate
assert len(agent_response) == 1, "Expected exactly one AgentResponse in non-streaming mode"
assert len(agent_response_updates) == 0, "Expected no AgentResponseUpdate in non-streaming mode"
assert agent_response[0].executor_id == "agent_exec"
assert agent_response[0].data is not None
assert agent_response[0].data.messages[0].text == "Hello World"
# In non-streaming mode, should have output event with AgentResponse, no AgentResponseUpdate
assert len(agent_run_events) == 1, "Expected exactly one output event with AgentResponse in non-streaming mode"
assert len(agent_update_events) == 0, "Expected no output event with AgentResponseUpdate in non-streaming mode"
assert agent_run_events[0].executor_id == "agent_exec"
assert agent_run_events[0].data is not None
assert agent_run_events[0].data.messages[0].text == "Hello World"
# Test streaming mode with run(stream=True)
stream_events: list[WorkflowEvent] = []
@@ -909,12 +907,10 @@ async def test_agent_streaming_vs_non_streaming() -> None:
# Filter for agent events
agent_response = [
cast(AgentResponse, e.data) # type: ignore
for e in stream_events
if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponse)
cast(AgentResponse, e.data) for e in stream_events if e.type == "output" and isinstance(e.data, AgentResponse)
]
agent_response_updates = [
e.data for e in stream_events if isinstance(e, WorkflowOutputEvent) and isinstance(e.data, AgentResponseUpdate)
e.data for e in stream_events if e.type == "output" and isinstance(e.data, AgentResponseUpdate)
]
# In streaming mode, should have AgentResponseUpdate, no AgentResponse
@@ -977,7 +973,7 @@ async def test_workflow_run_stream_parameter_validation(
events: list[WorkflowEvent] = []
async for event in workflow.run(test_message, stream=True):
events.append(event)
assert any(isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE for e in events)
assert any(e.type == "status" and e.state == WorkflowRunState.IDLE for e in events)
# Invalid combinations already tested in test_workflow_run_parameter_validation
# This test ensures streaming works correctly for valid parameters
@@ -1027,7 +1023,7 @@ async def test_output_executors_empty_yields_all_outputs() -> None:
assert len(outputs) == 2
assert outputs == [10, 20]
output_events = [event for event in result if isinstance(event, WorkflowOutputEvent)]
output_events = [event for event in result if event.type == "output"]
assert len(output_events) == 2
assert output_events[0].executor_id == "executor_a"
assert output_events[1].executor_id == "executor_b"
@@ -1055,7 +1051,7 @@ async def test_output_executors_filters_outputs_non_streaming() -> None:
assert len(outputs) == 1
assert outputs[0] == 20
output_events = [event for event in result if isinstance(event, WorkflowOutputEvent)]
output_events = [event for event in result if event.type == "output"]
assert len(output_events) == 1
assert output_events[0].executor_id == "executor_b"
@@ -1076,9 +1072,9 @@ async def test_output_executors_filters_outputs_streaming() -> None:
)
# Collect outputs from streaming
output_events: list[WorkflowOutputEvent] = []
output_events: list[WorkflowEvent] = []
async for event in workflow.run(NumberMessage(data=0), stream=True):
if isinstance(event, WorkflowOutputEvent):
if event.type == "output":
output_events.append(event)
# Only executor_a's output should be present
@@ -1213,7 +1209,7 @@ async def test_output_executors_filtering_with_send_responses_streaming() -> Non
events_list.append(event)
# Get request info events
request_events = [e for e in events_list if isinstance(e, RequestInfoEvent)]
request_events = [e for e in events_list if e.type == "request_info"]
assert len(request_events) == 1
# Set output_executors to exclude the approval executor
@@ -1221,9 +1217,9 @@ async def test_output_executors_filtering_with_send_responses_streaming() -> Non
# Send approval response via streaming
responses = {request_events[0].request_id: ApprovalMessage(approved=True)}
output_events: list[WorkflowOutputEvent] = []
output_events: list[WorkflowEvent] = []
async for event in workflow.send_responses_streaming(responses):
if isinstance(event, WorkflowOutputEvent):
if event.type == "output":
output_events.append(event)
# No outputs should be yielded since approval_executor is not in output_executors
@@ -218,7 +218,7 @@ class TestWorkflowAgent:
assert "Streaming2: Streaming1: Test input" in second_content.text
async def test_end_to_end_request_info_handling(self):
"""Test end-to-end workflow with RequestInfoEvent handling."""
"""Test end-to-end workflow with request_info event (type='request_info') handling."""
# Create workflow with requesting executor -> request info executor (no cycle)
simple_executor = SimpleExecutor(id="simple", response_text="SimpleResponse", streaming=False)
requesting_executor = RequestingExecutor(id="requester", streaming=False)
@@ -331,7 +331,7 @@ class TestWorkflowAgent:
async def test_workflow_as_agent_yield_output_surfaces_as_agent_response(self) -> None:
"""Test that ctx.yield_output() in a workflow executor surfaces as agent output when using .as_agent().
This validates the fix for issue #2813: WorkflowOutputEvent should be converted to
This validates the fix for issue #2813: output event (type='output') should be converted to
AgentResponseUpdate when the workflow is wrapped via .as_agent().
"""
@@ -343,7 +343,7 @@ class TestWorkflowAgent:
workflow = WorkflowBuilder().set_start_executor(yielding_executor).build()
# Run directly - should return WorkflowOutputEvent in result
# Run directly - should return output event (type='output') in result
direct_result = await workflow.run([ChatMessage(role="user", text="hello")])
direct_outputs = direct_result.get_outputs()
assert len(direct_outputs) == 1
@@ -779,7 +779,7 @@ class TestWorkflowAgent:
# Count occurrences of the unique response text
unique_text_count = sum(1 for msg in result.messages if msg.text and "Unique response text" in msg.text)
# Should appear exactly once (not duplicated from both streaming and WorkflowOutputEvent)
# Should appear exactly once (not duplicated from both streaming and output event)
assert unique_text_count == 1, f"Response should appear exactly once, but appeared {unique_text_count} times"
@@ -793,7 +793,7 @@ class TestWorkflowAgentAuthorName:
identification of which agent produced them in multi-agent workflows.
"""
# Create workflow with executor that emits AgentResponseUpdate without author_name
executor1 = SimpleExecutor(id="my_executor_id", response_text="Response")
executor1 = SimpleExecutor(id="my_executor_id", response_text="Response", streaming=True)
workflow = WorkflowBuilder().set_start_executor(executor1).build()
agent = WorkflowAgent(workflow=workflow, name="Test Agent")
@@ -13,7 +13,6 @@ from agent_framework import (
WorkflowContext,
WorkflowEvent,
WorkflowRunState,
WorkflowStatusEvent,
executor,
handler,
)
@@ -62,15 +61,15 @@ async def test_executor_cannot_emit_framework_lifecycle_event(caplog: "LogCaptur
async with make_context() as (ctx, runner_ctx):
caplog.clear()
with caplog.at_level("WARNING"):
await ctx.add_event(WorkflowStatusEvent(state=WorkflowRunState.IN_PROGRESS))
await ctx.add_event(WorkflowEvent.status(state=WorkflowRunState.IN_PROGRESS))
events: list[WorkflowEvent] = await runner_ctx.drain_events()
assert len(events) == 1
assert type(events[0]).__name__ == "WorkflowWarningEvent"
data = getattr(events[0], "data", None)
assert events[0].type == "warning"
data = events[0].data
assert isinstance(data, str)
assert "reserved for framework lifecycle notifications" in data
assert any("attempted to emit WorkflowStatusEvent" in message for message in list(caplog.messages))
assert any("attempted to emit" in message and "'status'" in message for message in list(caplog.messages))
async def test_executor_emits_normal_event() -> None:
@@ -84,7 +83,8 @@ async def test_executor_emits_normal_event() -> None:
class _TestEvent(WorkflowEvent):
pass
def __init__(self, data: Any = None) -> None:
super().__init__("test_event", data=data)
async def test_workflow_context_type_annotations_no_parameter() -> None:
@@ -14,7 +14,6 @@ from agent_framework import (
Content,
ResponseStream,
WorkflowRunState,
WorkflowStatusEvent,
tool,
)
from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
@@ -90,7 +89,7 @@ async def test_sequential_kwargs_flow_to_agent() -> None:
custom_data=custom_data,
user_token=user_token,
):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# Verify agent received kwargs
@@ -111,7 +110,7 @@ async def test_sequential_kwargs_flow_to_multiple_agents() -> None:
custom_data = {"key": "value"}
async for event in workflow.run("test", custom_data=custom_data, stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# Both agents should have received kwargs
@@ -153,7 +152,7 @@ async def test_concurrent_kwargs_flow_to_agents() -> None:
custom_data=custom_data,
user_token=user_token,
):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# Both agents should have received kwargs
@@ -200,7 +199,7 @@ async def test_groupchat_kwargs_flow_to_agents() -> None:
custom_data = {"session_id": "group123"}
async for event in workflow.run("group chat test", custom_data=custom_data, stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# At least one agent should have received kwargs
@@ -234,7 +233,7 @@ async def test_kwargs_stored_in_state() -> None:
workflow = SequentialBuilder().participants([inspector]).build()
async for event in workflow.run("test", my_kwarg="my_value", another=123, stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
assert stored_kwargs is not None, "kwargs should be stored in State"
@@ -260,7 +259,7 @@ async def test_empty_kwargs_stored_as_empty_dict() -> None:
# Run without any kwargs
async for event in workflow.run("test", stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# State should have empty dict when no kwargs provided
@@ -279,7 +278,7 @@ async def test_kwargs_with_none_values() -> None:
workflow = SequentialBuilder().participants([agent]).build()
async for event in workflow.run("test", optional_param=None, other_param="value", stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
assert len(agent.captured_kwargs) >= 1
@@ -306,7 +305,7 @@ async def test_kwargs_with_complex_nested_data() -> None:
}
async for event in workflow.run("test", complex_data=complex_data, stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
assert len(agent.captured_kwargs) >= 1
@@ -324,12 +323,12 @@ async def test_kwargs_preserved_across_workflow_reruns() -> None:
# First run
async for event in workflow1.run("run1", run_id="first", stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# Second run with different kwargs (using fresh workflow)
async for event in workflow2.run("run2", run_id="second", stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
assert len(agent.captured_kwargs) >= 2
@@ -361,7 +360,7 @@ async def test_handoff_kwargs_flow_to_agents() -> None:
custom_data = {"session_id": "handoff123"}
async for event in workflow.run("handoff test", custom_data=custom_data, stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# Coordinator agent should have received kwargs
@@ -419,7 +418,7 @@ async def test_magentic_kwargs_flow_to_agents() -> None:
custom_data = {"session_id": "magentic123"}
async for event in workflow.run("magentic test", custom_data=custom_data, stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# The workflow completes immediately via prepare_final_answer without invoking agents
@@ -470,7 +469,7 @@ async def test_magentic_kwargs_stored_in_state() -> None:
custom_data = {"magentic_key": "magentic_value"}
async for event in magentic_workflow.run("test task", custom_data=custom_data, stream=True):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# Verify the workflow completed (kwargs were stored, even if agent wasn't invoked)
@@ -626,7 +625,7 @@ async def test_subworkflow_kwargs_propagation() -> None:
custom_data=custom_data,
user_token=user_token,
):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# Verify that the inner agent was called
@@ -686,7 +685,7 @@ async def test_subworkflow_kwargs_accessible_via_state() -> None:
my_custom_kwarg="should_be_propagated",
another_kwarg=42,
):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# Verify the state reader was invoked
@@ -732,7 +731,7 @@ async def test_nested_subworkflow_kwargs_propagation() -> None:
stream=True,
deep_kwarg="should_reach_inner",
):
if isinstance(event, WorkflowStatusEvent) and event.state == WorkflowRunState.IDLE:
if event.type == "status" and event.state == WorkflowRunState.IDLE:
break
# Verify inner agent was called
@@ -5,18 +5,14 @@ from typing_extensions import Never
from agent_framework import (
Executor,
ExecutorFailedEvent,
InProcRunnerContext,
RequestInfoEvent,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
WorkflowEventSource,
WorkflowFailedEvent,
WorkflowRunResult,
WorkflowRunState,
WorkflowStartedEvent,
WorkflowStatusEvent,
handler,
)
from agent_framework._workflows._state import State
@@ -39,24 +35,26 @@ async def test_executor_failed_and_workflow_failed_events_streaming():
async for ev in wf.run(0, stream=True):
events.append(ev)
# ExecutorFailedEvent should be emitted before WorkflowFailedEvent
executor_failed_events = [e for e in events if isinstance(e, ExecutorFailedEvent)]
assert executor_failed_events, "ExecutorFailedEvent should be emitted when start executor fails"
# executor_failed event (type='executor_failed') should be emitted before workflow failed event
executor_failed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_failed"]
assert executor_failed_events, "executor_failed event should be emitted when start executor fails"
assert executor_failed_events[0].executor_id == "f"
assert executor_failed_events[0].origin is WorkflowEventSource.FRAMEWORK
# Workflow-level failure and FAILED status should be surfaced
failed_events = [e for e in events if isinstance(e, WorkflowFailedEvent)]
failed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "failed"]
assert failed_events
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed_events)
status = [e for e in events if isinstance(e, WorkflowStatusEvent)]
status = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "status"]
assert status and status[-1].state == WorkflowRunState.FAILED
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in status)
# Verify ExecutorFailedEvent comes before WorkflowFailedEvent
# Verify executor_failed event comes before workflow failed event
executor_failed_idx = events.index(executor_failed_events[0])
workflow_failed_idx = events.index(failed_events[0])
assert executor_failed_idx < workflow_failed_idx, "ExecutorFailedEvent should be emitted before WorkflowFailedEvent"
assert executor_failed_idx < workflow_failed_idx, (
"executor_failed event should be emitted before workflow failed event"
)
async def test_executor_failed_event_emitted_on_direct_execute():
@@ -71,7 +69,7 @@ async def test_executor_failed_event_emitted_on_direct_execute():
ctx,
)
drained = await ctx.drain_events()
failed = [e for e in drained if isinstance(e, ExecutorFailedEvent)]
failed = [e for e in drained if isinstance(e, WorkflowEvent) and e.type == "executor_failed"]
assert failed
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed)
@@ -85,7 +83,7 @@ class PassthroughExecutor(Executor):
async def test_executor_failed_event_from_second_executor_in_chain():
"""Test that ExecutorFailedEvent is emitted when a non-start executor fails."""
"""Test that executor_failed event is emitted when a non-start executor fails."""
passthrough = PassthroughExecutor(id="passthrough")
failing = FailingExecutor(id="failing")
wf: Workflow = WorkflowBuilder().set_start_executor(passthrough).add_edge(passthrough, failing).build()
@@ -95,21 +93,23 @@ async def test_executor_failed_event_from_second_executor_in_chain():
async for ev in wf.run(0, stream=True):
events.append(ev)
# ExecutorFailedEvent should be emitted for the failing executor
executor_failed_events = [e for e in events if isinstance(e, ExecutorFailedEvent)]
assert executor_failed_events, "ExecutorFailedEvent should be emitted when second executor fails"
# executor_failed event should be emitted for the failing executor
executor_failed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_failed"]
assert executor_failed_events, "executor_failed event should be emitted when second executor fails"
assert executor_failed_events[0].executor_id == "failing"
assert executor_failed_events[0].origin is WorkflowEventSource.FRAMEWORK
# Workflow-level failure should also be surfaced
failed_events = [e for e in events if isinstance(e, WorkflowFailedEvent)]
failed_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "failed"]
assert failed_events
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in failed_events)
# Verify ExecutorFailedEvent comes before WorkflowFailedEvent
# Verify executor_failed event comes before workflow failed event
executor_failed_idx = events.index(executor_failed_events[0])
workflow_failed_idx = events.index(failed_events[0])
assert executor_failed_idx < workflow_failed_idx, "ExecutorFailedEvent should be emitted before WorkflowFailedEvent"
assert executor_failed_idx < workflow_failed_idx, (
"executor_failed event should be emitted before workflow failed event"
)
class SimpleExecutor(Executor):
@@ -136,8 +136,8 @@ async def test_idle_with_pending_requests_status_streaming():
events = [ev async for ev in wf.run("start", stream=True)] # Consume stream fully
# Ensure a request was emitted
assert any(isinstance(e, RequestInfoEvent) for e in events)
status_events = [e for e in events if isinstance(e, WorkflowStatusEvent)]
assert any(isinstance(e, WorkflowEvent) and e.type == "request_info" for e in events)
status_events = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "status"]
assert len(status_events) >= 3
assert status_events[-2].state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS
assert status_events[-1].state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
@@ -156,7 +156,7 @@ async def test_completed_status_streaming():
wf = WorkflowBuilder().set_start_executor(c).build()
events = [ev async for ev in wf.run("ok", stream=True)] # no raise
# Last status should be IDLE
status = [e for e in events if isinstance(e, WorkflowStatusEvent)]
status = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "status"]
assert status and status[-1].state == WorkflowRunState.IDLE
assert all(e.origin is WorkflowEventSource.FRAMEWORK for e in status)
@@ -166,12 +166,13 @@ async def test_started_and_completed_event_origins():
wf = WorkflowBuilder().set_start_executor(c).build()
events = [ev async for ev in wf.run("payload", stream=True)]
started = next(e for e in events if isinstance(e, WorkflowStartedEvent))
started = next(e for e in events if isinstance(e, WorkflowEvent) and e.type == "started")
assert started.origin is WorkflowEventSource.FRAMEWORK
# Check for IDLE status indicating completion
idle_status = next(
(e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE), None
(e for e in events if isinstance(e, WorkflowEvent) and e.type == "status" and e.state == WorkflowRunState.IDLE),
None,
)
assert idle_status is not None
assert idle_status.origin is WorkflowEventSource.FRAMEWORK