mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Workflow run state and structured error events, sample updates, tests (#725)
* Add workflow event types to surface workflow state, status, and failures. * Address PR feedback * Updates to use new workflow run state enums
This commit is contained in:
committed by
GitHub
Unverified
parent
e7cd03b32e
commit
68b76e6726
@@ -40,6 +40,7 @@ _IMPORTS = [
|
||||
"WorkflowAgent",
|
||||
"WorkflowViz",
|
||||
"FileCheckpointStorage",
|
||||
"ExecutorFailedEvent",
|
||||
"InMemoryCheckpointStorage",
|
||||
"CheckpointStorage",
|
||||
"WorkflowCheckpoint",
|
||||
@@ -56,6 +57,10 @@ _IMPORTS = [
|
||||
"PlanReviewRequest",
|
||||
"RequestInfoEvent",
|
||||
"StandardMagenticManager",
|
||||
"WorkflowStatusEvent",
|
||||
"WorkflowRunState",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowFailedEvent",
|
||||
"ConcurrentBuilder",
|
||||
]
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from agent_framework_workflow import (
|
||||
Executor,
|
||||
ExecutorCompletedEvent,
|
||||
ExecutorEvent,
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokeEvent,
|
||||
FileCheckpointStorage,
|
||||
FunctionExecutor,
|
||||
@@ -38,10 +39,14 @@ from agent_framework_workflow import (
|
||||
WorkflowCheckpoint,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowExecutor,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunResult,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
WorkflowViz,
|
||||
__version__,
|
||||
executor,
|
||||
@@ -62,6 +67,7 @@ __all__ = [
|
||||
"Executor",
|
||||
"ExecutorCompletedEvent",
|
||||
"ExecutorEvent",
|
||||
"ExecutorFailedEvent",
|
||||
"ExecutorInvokeEvent",
|
||||
"FileCheckpointStorage",
|
||||
"FunctionExecutor",
|
||||
@@ -87,10 +93,14 @@ __all__ = [
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCompletedEvent",
|
||||
"WorkflowContext",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowExecutor",
|
||||
"WorkflowFailedEvent",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowStartedEvent",
|
||||
"WorkflowStatusEvent",
|
||||
"WorkflowViz",
|
||||
"__version__",
|
||||
"executor",
|
||||
|
||||
@@ -19,11 +19,16 @@ from ._events import (
|
||||
AgentRunUpdateEvent,
|
||||
ExecutorCompletedEvent,
|
||||
ExecutorEvent,
|
||||
ExecutorFailedEvent,
|
||||
ExecutorInvokeEvent,
|
||||
RequestInfoEvent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from ._executor import (
|
||||
AgentExecutor,
|
||||
@@ -100,6 +105,7 @@ __all__ = [
|
||||
"Executor",
|
||||
"ExecutorCompletedEvent",
|
||||
"ExecutorEvent",
|
||||
"ExecutorFailedEvent",
|
||||
"ExecutorInvokeEvent",
|
||||
"FileCheckpointStorage",
|
||||
"FunctionExecutor",
|
||||
@@ -142,10 +148,14 @@ __all__ = [
|
||||
"WorkflowCheckpoint",
|
||||
"WorkflowCompletedEvent",
|
||||
"WorkflowContext",
|
||||
"WorkflowErrorDetails",
|
||||
"WorkflowEvent",
|
||||
"WorkflowExecutor",
|
||||
"WorkflowFailedEvent",
|
||||
"WorkflowRunResult",
|
||||
"WorkflowRunState",
|
||||
"WorkflowStartedEvent",
|
||||
"WorkflowStatusEvent",
|
||||
"WorkflowValidationError",
|
||||
"WorkflowViz",
|
||||
"__version__",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import traceback as _traceback
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agent_framework import AgentRunResponse, AgentRunResponseUpdate
|
||||
@@ -56,6 +59,108 @@ class WorkflowErrorEvent(WorkflowEvent):
|
||||
return f"{self.__class__.__name__}(exception={self.data})"
|
||||
|
||||
|
||||
class WorkflowRunState(str, Enum):
|
||||
"""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, but has
|
||||
not yet emitted a terminal result. Rare in practice but provided for
|
||||
orchestration integrations that distinguish a quiescent state.
|
||||
|
||||
- IDLE_WITH_PENDING_REQUESTS: The workflow is paused awaiting external
|
||||
input (e.g., emitted a `RequestInfoEvent`). This is a non-terminal
|
||||
state; the workflow can resume when responses are supplied.
|
||||
|
||||
- COMPLETED: Normal terminal state indicating successful completion.
|
||||
|
||||
- FAILED: Terminal state indicating an error surfaced. Accompanied by a
|
||||
`WorkflowFailedEvent` with structured error details.
|
||||
|
||||
- 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
|
||||
COMPLETED = "COMPLETED" # Finished successfully
|
||||
FAILED = "FAILED" # Finished with an error
|
||||
CANCELLED = "CANCELLED" # Finished due to cancellation
|
||||
|
||||
|
||||
class WorkflowStatusEvent(WorkflowEvent):
|
||||
"""Event indicating a transition in the workflow run state."""
|
||||
|
||||
def __init__(self, state: WorkflowRunState, data: Any | None = None):
|
||||
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})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowErrorDetails:
|
||||
"""Structured error information to surface in error events/results."""
|
||||
|
||||
error_type: str
|
||||
message: str
|
||||
traceback: str | None = None
|
||||
executor_id: str | None = None
|
||||
extra: dict[str, Any] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_exception(
|
||||
cls,
|
||||
exc: BaseException,
|
||||
*,
|
||||
executor_id: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> "WorkflowErrorDetails":
|
||||
tb = None
|
||||
try:
|
||||
tb = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
except Exception:
|
||||
tb = None
|
||||
return cls(
|
||||
error_type=exc.__class__.__name__,
|
||||
message=str(exc),
|
||||
traceback=tb,
|
||||
executor_id=executor_id,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowFailedEvent(WorkflowEvent):
|
||||
"""Terminal failure event for a workflow run."""
|
||||
|
||||
def __init__(self, details: WorkflowErrorDetails, data: Any | None = None):
|
||||
super().__init__(data)
|
||||
self.details = details
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - representation only
|
||||
return f"{self.__class__.__name__}(details={self.details}, data={self.data!r})"
|
||||
|
||||
|
||||
class RequestInfoEvent(WorkflowEvent):
|
||||
"""Event triggered when a workflow executor requests external information."""
|
||||
|
||||
@@ -119,6 +224,17 @@ class ExecutorCompletedEvent(ExecutorEvent):
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id})"
|
||||
|
||||
|
||||
class ExecutorFailedEvent(ExecutorEvent):
|
||||
"""Event triggered when an executor handler raises an error."""
|
||||
|
||||
def __init__(self, executor_id: str, details: WorkflowErrorDetails):
|
||||
super().__init__(executor_id, details)
|
||||
self.details = details
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - representation only
|
||||
return f"{self.__class__.__name__}(executor_id={self.executor_id}, details={self.details})"
|
||||
|
||||
|
||||
class AgentRunUpdateEvent(ExecutorEvent):
|
||||
"""Event triggered when an agent is streaming messages."""
|
||||
|
||||
|
||||
@@ -102,7 +102,14 @@ class Executor(AFBaseModel):
|
||||
if self._request_interceptors and message.__class__.__name__ == "SubWorkflowRequestInfo":
|
||||
# Directly handle SubWorkflowRequestInfo
|
||||
await context.add_event(ExecutorInvokeEvent(self.id))
|
||||
await self._handle_sub_workflow_request(message, context)
|
||||
try:
|
||||
await self._handle_sub_workflow_request(message, context)
|
||||
except Exception as exc:
|
||||
# Surface structured executor failure before propagating
|
||||
from ._events import ExecutorFailedEvent, WorkflowErrorDetails
|
||||
|
||||
await context.add_event(ExecutorFailedEvent(self.id, WorkflowErrorDetails.from_exception(exc)))
|
||||
raise
|
||||
await context.add_event(ExecutorCompletedEvent(self.id))
|
||||
return
|
||||
|
||||
@@ -115,7 +122,14 @@ class Executor(AFBaseModel):
|
||||
if handler is None:
|
||||
raise RuntimeError(f"Executor {self.__class__.__name__} cannot handle message of type {type(message)}.")
|
||||
await context.add_event(ExecutorInvokeEvent(self.id))
|
||||
await handler(message, context)
|
||||
try:
|
||||
await handler(message, context)
|
||||
except Exception as exc:
|
||||
# Surface structured executor failure before propagating
|
||||
from ._events import ExecutorFailedEvent, WorkflowErrorDetails
|
||||
|
||||
await context.add_event(ExecutorFailedEvent(self.id, WorkflowErrorDetails.from_exception(exc)))
|
||||
raise
|
||||
await context.add_event(ExecutorCompletedEvent(self.id))
|
||||
|
||||
def _discover_handlers(self) -> None:
|
||||
|
||||
@@ -113,8 +113,15 @@ class Runner:
|
||||
# Periodically continue to let iteration advance
|
||||
continue
|
||||
|
||||
# Propagate errors from iteration
|
||||
await iteration_task
|
||||
# Propagate errors from iteration, but first surface any pending events
|
||||
try:
|
||||
await iteration_task
|
||||
except Exception:
|
||||
# Make sure failure-related events (like ExecutorFailedEvent) are surfaced
|
||||
if await self._ctx.has_events():
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
raise
|
||||
self._iteration += 1
|
||||
|
||||
# Drain any straggler events emitted at tail end
|
||||
|
||||
@@ -25,7 +25,16 @@ from ._edge import (
|
||||
SwitchCaseEdgeGroupCase,
|
||||
SwitchCaseEdgeGroupDefault,
|
||||
)
|
||||
from ._events import RequestInfoEvent, WorkflowCompletedEvent, WorkflowEvent
|
||||
from ._events import (
|
||||
RequestInfoEvent,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowErrorDetails,
|
||||
WorkflowEvent,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunState,
|
||||
WorkflowStartedEvent,
|
||||
WorkflowStatusEvent,
|
||||
)
|
||||
from ._executor import AgentExecutor, Executor, RequestInfoExecutor
|
||||
from ._runner import Runner
|
||||
from ._runner_context import CheckpointState, InProcRunnerContext, RunnerContext
|
||||
@@ -43,7 +52,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowRunResult(list[WorkflowEvent]):
|
||||
"""A list of events generated during the workflow execution in non-streaming mode."""
|
||||
"""A list of events generated during the workflow execution in non-streaming mode.
|
||||
|
||||
Preserves the historical contract that the list contains data-plane events
|
||||
only (executor invoke/complete, completed, requests), while exposing the
|
||||
control-plane status timeline via accessors.
|
||||
"""
|
||||
|
||||
def __init__(self, events: list[WorkflowEvent], status_events: list[WorkflowStatusEvent] | None = None) -> None:
|
||||
super().__init__(events)
|
||||
self._status_events: list[WorkflowStatusEvent] = status_events or []
|
||||
|
||||
def get_completed_event(self) -> WorkflowCompletedEvent | None:
|
||||
"""Get the completed event from the workflow run result.
|
||||
@@ -69,6 +87,23 @@ class WorkflowRunResult(list[WorkflowEvent]):
|
||||
"""
|
||||
return [event for event in self if isinstance(event, RequestInfoEvent)]
|
||||
|
||||
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.
|
||||
"""
|
||||
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. "
|
||||
"Ensure your workflow entry points are used (which emit status events) "
|
||||
"or handle the absence of status explicitly."
|
||||
)
|
||||
|
||||
def status_timeline(self) -> list[WorkflowStatusEvent]:
|
||||
"""Return the list of status events emitted during the run (control-plane)."""
|
||||
return list(self._status_events)
|
||||
|
||||
|
||||
# region Workflow
|
||||
|
||||
@@ -202,9 +237,15 @@ class Workflow(AFBaseModel):
|
||||
|
||||
# Create workflow span that encompasses the entire execution
|
||||
with workflow_tracer.create_workflow_run_span(self):
|
||||
saw_completed = False
|
||||
saw_request = False
|
||||
emitted_in_progress_pending = False
|
||||
try:
|
||||
# Add workflow started event
|
||||
# Add workflow started event (telemetry + surface state to consumers)
|
||||
workflow_tracer.add_workflow_event("workflow.started")
|
||||
# Emit explicit start/status events to the stream
|
||||
yield WorkflowStartedEvent()
|
||||
yield WorkflowStatusEvent(WorkflowRunState.IN_PROGRESS)
|
||||
|
||||
# Reset context for a new run if supported
|
||||
if reset_context:
|
||||
@@ -216,11 +257,31 @@ class Workflow(AFBaseModel):
|
||||
|
||||
# All executor executions happen within workflow span
|
||||
async for event in self._runner.run_until_convergence():
|
||||
# Track terminal indicators while forwarding events
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
saw_completed = True
|
||||
elif isinstance(event, RequestInfoEvent):
|
||||
saw_request = True
|
||||
yield event
|
||||
|
||||
# Success
|
||||
if isinstance(event, RequestInfoEvent) and not emitted_in_progress_pending and not saw_completed:
|
||||
emitted_in_progress_pending = True
|
||||
yield WorkflowStatusEvent(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS)
|
||||
|
||||
# Success path: emit a final status based on observed terminal signals
|
||||
if saw_completed:
|
||||
yield WorkflowStatusEvent(WorkflowRunState.COMPLETED)
|
||||
elif saw_request:
|
||||
yield WorkflowStatusEvent(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS)
|
||||
else:
|
||||
yield WorkflowStatusEvent(WorkflowRunState.IDLE)
|
||||
|
||||
workflow_tracer.add_workflow_event("workflow.completed")
|
||||
except Exception as e:
|
||||
# Surface structured failure details before propagating exception
|
||||
details = WorkflowErrorDetails.from_exception(e)
|
||||
yield WorkflowFailedEvent(details)
|
||||
yield WorkflowStatusEvent(WorkflowRunState.FAILED)
|
||||
workflow_tracer.add_workflow_error_event(e)
|
||||
raise
|
||||
|
||||
@@ -358,11 +419,12 @@ class Workflow(AFBaseModel):
|
||||
):
|
||||
yield event
|
||||
|
||||
async def run(self, message: Any) -> WorkflowRunResult:
|
||||
async def run(self, message: Any, *, include_status_events: bool = False) -> WorkflowRunResult:
|
||||
"""Run the workflow with the given message.
|
||||
|
||||
Args:
|
||||
message: The message to be processed by the workflow.
|
||||
include_status_events: Whether to include WorkflowStatusEvent instances in the result list.
|
||||
|
||||
Returns:
|
||||
A WorkflowRunResult instance containing a list of events generated during the workflow execution.
|
||||
@@ -377,6 +439,7 @@ class Workflow(AFBaseModel):
|
||||
coalesced: list[WorkflowEvent] = [] # type: ignore[name-defined]
|
||||
pending_updates: list[AgentRunResponseUpdate] = []
|
||||
pending_executor: str | None = None
|
||||
status_events: list[WorkflowStatusEvent] = []
|
||||
|
||||
def _flush_pending() -> None:
|
||||
nonlocal pending_updates, pending_executor
|
||||
@@ -403,12 +466,22 @@ class Workflow(AFBaseModel):
|
||||
continue
|
||||
# Flush before adding any non-update event
|
||||
_flush_pending()
|
||||
# Omit WorkflowStartedEvent from non-streaming (telemetry-only)
|
||||
if isinstance(ev, WorkflowStartedEvent):
|
||||
continue
|
||||
# Track status; include inline only if explicitly requested
|
||||
if isinstance(ev, WorkflowStatusEvent):
|
||||
status_events.append(ev)
|
||||
if include_status_events:
|
||||
coalesced.append(ev)
|
||||
continue
|
||||
coalesced.append(ev)
|
||||
|
||||
# Flush any trailing updates
|
||||
_flush_pending()
|
||||
|
||||
return WorkflowRunResult(coalesced)
|
||||
# coalesced already excludes start events; includes status events only if opted in
|
||||
return WorkflowRunResult(coalesced, status_events)
|
||||
|
||||
async def run_from_checkpoint(
|
||||
self,
|
||||
@@ -435,7 +508,9 @@ class Workflow(AFBaseModel):
|
||||
events = [
|
||||
event async for event in self.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage, responses)
|
||||
]
|
||||
return WorkflowRunResult(events)
|
||||
status_events = [e for e in events if isinstance(e, WorkflowStatusEvent)]
|
||||
filtered_events = [e for e in events if not isinstance(e, (WorkflowStatusEvent, WorkflowStartedEvent))]
|
||||
return WorkflowRunResult(filtered_events, status_events)
|
||||
|
||||
async def send_responses(self, responses: dict[str, Any]) -> WorkflowRunResult:
|
||||
"""Send responses back to the workflow.
|
||||
@@ -447,7 +522,9 @@ class Workflow(AFBaseModel):
|
||||
A WorkflowRunResult instance containing a list of events generated during the workflow execution.
|
||||
"""
|
||||
events = [event async for event in self.send_responses_streaming(responses)]
|
||||
return WorkflowRunResult(events)
|
||||
status_events = [e for e in events if isinstance(e, WorkflowStatusEvent)]
|
||||
filtered_events = [e for e in events if not isinstance(e, (WorkflowStatusEvent, WorkflowStartedEvent))]
|
||||
return WorkflowRunResult(filtered_events, status_events)
|
||||
|
||||
def _get_executor_by_id(self, executor_id: str) -> Executor:
|
||||
"""Get an executor by its ID.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from agent_framework.workflow import (
|
||||
Executor,
|
||||
ExecutorFailedEvent,
|
||||
RequestInfoEvent,
|
||||
RequestInfoExecutor,
|
||||
RequestInfoMessage,
|
||||
Workflow,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
WorkflowFailedEvent,
|
||||
WorkflowRunResult,
|
||||
WorkflowRunState,
|
||||
WorkflowStatusEvent,
|
||||
handler,
|
||||
)
|
||||
|
||||
from agent_framework_workflow import InProcRunnerContext
|
||||
from agent_framework_workflow._shared_state import SharedState
|
||||
from agent_framework_workflow._workflow_context import WorkflowContext as WFContext
|
||||
|
||||
|
||||
class FailingExecutor(Executor):
|
||||
"""Executor that raises at runtime to test failure signaling."""
|
||||
|
||||
@handler
|
||||
async def fail(self, msg: int, ctx: WorkflowContext[None]) -> None: # pragma: no cover - invoked via workflow
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
async def test_executor_failed_and_workflow_failed_events_streaming():
|
||||
failing = FailingExecutor(id="f")
|
||||
wf: Workflow = WorkflowBuilder().set_start_executor(failing).build()
|
||||
|
||||
events: list[object] = []
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async for ev in wf.run_stream(0):
|
||||
events.append(ev)
|
||||
|
||||
# Workflow-level failure and FAILED status should be surfaced
|
||||
assert any(isinstance(e, WorkflowFailedEvent) for e in events)
|
||||
status = [e for e in events if isinstance(e, WorkflowStatusEvent)]
|
||||
assert status and status[-1].state == WorkflowRunState.FAILED
|
||||
|
||||
|
||||
async def test_executor_failed_event_emitted_on_direct_execute():
|
||||
failing = FailingExecutor(id="f")
|
||||
ctx = InProcRunnerContext()
|
||||
wf_ctx: WFContext[None] = WFContext(
|
||||
executor_id=failing.id,
|
||||
source_executor_ids=["START"],
|
||||
shared_state=SharedState(),
|
||||
runner_context=ctx,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await failing.execute(0, wf_ctx)
|
||||
drained = await ctx.drain_events()
|
||||
assert any(isinstance(e, ExecutorFailedEvent) for e in drained)
|
||||
|
||||
|
||||
class Requester(Executor):
|
||||
"""Executor that always requests external info to test idle-with-requests state."""
|
||||
|
||||
@handler
|
||||
async def ask(self, _: str, ctx: WorkflowContext[RequestInfoMessage]) -> None: # pragma: no cover
|
||||
await ctx.send_message(RequestInfoMessage())
|
||||
|
||||
|
||||
async def test_idle_with_pending_requests_status_streaming():
|
||||
req = Requester(id="req")
|
||||
rie = RequestInfoExecutor(id="rie")
|
||||
wf = WorkflowBuilder().set_start_executor(req).add_edge(req, rie).build()
|
||||
|
||||
events = [ev async for ev in wf.run_stream("start")] # 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 len(status_events) >= 3
|
||||
assert status_events[-2].state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS
|
||||
assert status_events[-1].state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
|
||||
class Completer(Executor):
|
||||
"""Executor that completes immediately with provided data for testing."""
|
||||
|
||||
@handler
|
||||
async def run(self, msg: str, ctx: WorkflowContext[str]) -> None: # pragma: no cover
|
||||
await ctx.add_event(WorkflowCompletedEvent(msg))
|
||||
|
||||
|
||||
async def test_completed_status_streaming():
|
||||
c = Completer(id="c")
|
||||
wf = WorkflowBuilder().set_start_executor(c).build()
|
||||
events = [ev async for ev in wf.run_stream("ok")] # no raise
|
||||
# Last status should be COMPLETED
|
||||
status = [e for e in events if isinstance(e, WorkflowStatusEvent)]
|
||||
assert status and status[-1].state == WorkflowRunState.COMPLETED
|
||||
|
||||
|
||||
async def test_non_streaming_final_state_helpers():
|
||||
# Completed case
|
||||
c = Completer(id="c")
|
||||
wf1 = WorkflowBuilder().set_start_executor(c).build()
|
||||
result1: WorkflowRunResult = await wf1.run("done")
|
||||
assert result1.get_final_state() == WorkflowRunState.COMPLETED
|
||||
|
||||
# Idle-with-pending-request case
|
||||
req = Requester(id="req")
|
||||
rie = RequestInfoExecutor(id="rie")
|
||||
wf2 = WorkflowBuilder().set_start_executor(req).add_edge(req, rie).build()
|
||||
result2: WorkflowRunResult = await wf2.run("start")
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
|
||||
async def test_run_includes_status_events_completed():
|
||||
c = Completer(id="c2")
|
||||
wf = WorkflowBuilder().set_start_executor(c).build()
|
||||
result: WorkflowRunResult = await wf.run("ok")
|
||||
timeline = result.status_timeline()
|
||||
assert timeline, "Expected status timeline in non-streaming run() results"
|
||||
assert timeline[-1].state == WorkflowRunState.COMPLETED
|
||||
|
||||
|
||||
async def test_run_includes_status_events_idle_with_requests():
|
||||
req = Requester(id="req2")
|
||||
rie = RequestInfoExecutor(id="rie2")
|
||||
wf = WorkflowBuilder().set_start_executor(req).add_edge(req, rie).build()
|
||||
result: WorkflowRunResult = await wf.run("start")
|
||||
timeline = result.status_timeline()
|
||||
assert timeline, "Expected status timeline in non-streaming run() results"
|
||||
assert len(timeline) >= 3
|
||||
assert timeline[-2].state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS
|
||||
assert timeline[-1].state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
Reference in New Issue
Block a user