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:
Evan Mattson
2025-09-16 10:36:25 +09:00
committed by GitHub
Unverified
parent e7cd03b32e
commit 68b76e6726
12 changed files with 463 additions and 26 deletions
@@ -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