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
@@ -104,11 +104,14 @@ async def main():
# provides the WorkflowCompletedEvent emitted by the terminal node.
events = await workflow.run("hello world")
print(events.get_completed_event())
# Summarize the final run state (e.g., COMPLETED)
print("Final state:", events.get_final_state())
"""
Sample Output:
WorkflowCompletedEvent(data=DLROW OLLEH)
Final state: WorkflowRunState.COMPLETED
"""
@@ -59,6 +59,8 @@ async def main():
print(f"{event.executor_id}: {event.data}")
print(f"{'=' * 60}\n{events.get_completed_event()}")
# Summarize the final run state (e.g., COMPLETED)
print("Final state:", events.get_final_state())
"""
Sample Output:
@@ -4,7 +4,17 @@ import asyncio
from agent_framework import ChatAgent, ChatMessage
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import Executor, WorkflowBuilder, WorkflowCompletedEvent, WorkflowContext, handler
from agent_framework.workflow import (
Executor,
ExecutorFailedEvent,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowFailedEvent,
WorkflowRunState,
WorkflowStatusEvent,
handler,
)
from azure.identity import AzureCliCredential
"""
@@ -107,20 +117,41 @@ async def main():
workflow = WorkflowBuilder().set_start_executor(writer).add_edge(writer, reviewer).build()
# Run the workflow with the user's initial message and stream events as they occur.
# Events include executor invoke and completion, as well as the terminal WorkflowCompletedEvent.
# In addition to executor events and WorkflowCompletedEvent, this also surfaces run-state and errors.
async for event in workflow.run_stream(
ChatMessage(role="user", text="Create a slogan for a new electric SUV that is affordable and fun to drive.")
):
print(event)
if isinstance(event, WorkflowStatusEvent):
if event.state == WorkflowRunState.IN_PROGRESS:
print("State: IN_PROGRESS")
elif event.state == WorkflowRunState.COMPLETED:
print("State: COMPLETED")
elif event.state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
print("State: IN_PROGRESS_PENDING_REQUESTS (requests in flight)")
elif event.state == WorkflowRunState.IDLE:
print("State: IDLE (no active work)")
elif event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
print("State: IDLE_WITH_PENDING_REQUESTS (prompt user or UI now)")
else:
print(f"State: {event.state}")
elif isinstance(event, ExecutorFailedEvent):
print(f"Executor failed: {event.executor_id} {event.details.error_type}: {event.details.message}")
elif isinstance(event, WorkflowFailedEvent):
details = event.details
print(f"Workflow failed: {details.error_type}: {details.message}")
else:
print(event)
"""
Sample Output:
State: IN_PROGRESS
ExecutorInvokeEvent(executor_id=writer)
ExecutorCompletedEvent(executor_id=writer)
ExecutorInvokeEvent(executor_id=reviewer)
WorkflowCompletedEvent(data=Drive the Future. Affordable Adventure, Electrified.)
ExecutorCompletedEvent(executor_id=reviewer)
State: COMPLETED
"""
@@ -3,12 +3,13 @@
import asyncio
from dataclasses import dataclass
from agent_framework import AgentProtocol, ChatMessage, Role
from agent_framework import ChatMessage, Role
from agent_framework.azure import AzureChatClient
from agent_framework.workflow import (
AgentExecutor, # Wraps an agent so it can run inside a workflow
AgentExecutor, # Executor that runs the agent
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
AgentExecutorResponse, # Result returned by an AgentExecutor
Executor,
RequestInfoEvent, # Event emitted when human input is requested
RequestInfoExecutor, # Special executor that collects human input out of band
RequestInfoMessage, # Base class for request payloads sent to RequestInfoExecutor
@@ -16,6 +17,8 @@ from agent_framework.workflow import (
WorkflowBuilder, # Fluent builder for assembling the graph
WorkflowCompletedEvent, # Terminal event used to finish the workflow
WorkflowContext, # Per run context and event bus
WorkflowRunState, # Enum of workflow run states
WorkflowStatusEvent, # Event emitted on run state changes
handler, # Decorator to expose an Executor method as a step
)
from azure.identity import AzureCliCredential
@@ -73,7 +76,7 @@ class GuessOutput(BaseModel):
guess: int
class TurnManager(AgentExecutor):
class TurnManager(Executor):
"""Coordinates turns between the agent and the human.
Responsibilities:
@@ -82,8 +85,8 @@ class TurnManager(AgentExecutor):
- After each human reply, either finish the game or prompt the agent again with feedback.
"""
def __init__(self, agent: AgentProtocol, id: str | None = None):
super().__init__(agent, id=id)
def __init__(self, id: str | None = None):
super().__init__(id=id)
@handler
async def start(self, _: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
@@ -166,9 +169,10 @@ async def main() -> None:
response_format=GuessOutput,
)
# Build a simple loop: TurnManager <-> RequestInfoExecutor.
# TurnManager runs the agent, asks the human, processes feedback, and either finishes or repeats.
turn_manager = TurnManager(agent=agent, id="turn_manager")
# Build a simple loop: TurnManager <-> AgentExecutor <-> RequestInfoExecutor.
# TurnManager coordinates, AgentExecutor runs the model, RequestInfoExecutor gathers human replies.
turn_manager = TurnManager(id="turn_manager")
agent_exec = AgentExecutor(agent=agent, id="agent")
# Naming note:
# This variable is currently named hitl for historical reasons. The name can feel ambiguous or magical.
@@ -179,9 +183,10 @@ async def main() -> None:
top_builder = (
WorkflowBuilder()
.set_start_executor(turn_manager)
.add_edge(turn_manager, turn_manager) # TurnManager executes its own agent step
.add_edge(turn_manager, agent_exec) # Ask agent to make/adjust a guess
.add_edge(agent_exec, turn_manager) # Agent's response comes back to coordinator
.add_edge(turn_manager, hitl) # Ask human for guidance
.add_edge(hitl, turn_manager) # Feed human guidance back to the agent turn manager
.add_edge(hitl, turn_manager) # Feed human guidance back to coordinator
)
# Build the workflow (no checkpointing in this minimal sample).
@@ -206,6 +211,10 @@ async def main() -> None:
stream = (
workflow.send_responses_streaming(pending_responses) if pending_responses else workflow.run_stream("start")
)
# Collect events for this turn. Among these you may see WorkflowStatusEvent
# with state IDLE_WITH_PENDING_REQUESTS when the workflow pauses for
# human input, preceded by IN_PROGRESS_PENDING_REQUESTS as requests are
# emitted.
events = [event async for event in stream]
pending_responses = None
@@ -219,6 +228,22 @@ async def main() -> None:
requests.append((event.request_id, event.data.prompt))
# Other events are ignored for brevity.
# Detect run state transitions for a better developer experience.
pending_status = any(
isinstance(e, WorkflowStatusEvent)
and e.state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS
for e in events
)
idle_with_requests = any(
isinstance(e, WorkflowStatusEvent)
and e.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
for e in events
)
if pending_status:
print("State: IN_PROGRESS_PENDING_REQUESTS (requests outstanding)")
if idle_with_requests:
print("State: IDLE_WITH_PENDING_REQUESTS (awaiting human input)")
# If we have any human requests, prompt the user and prepare responses.
if requests and not completed:
responses: dict[str, str] = {}
@@ -227,7 +252,7 @@ async def main() -> None:
print(f"HITL> {prompt}")
# Instructional print already appears above. The input line below is the user entry point.
# If desired, you can add more guidance here, but keep it concise.
answer = input("Enter higher/lower/correct/exit: ").lower()
answer = input("Enter higher/lower/correct/exit: ").lower() # noqa: ASYNC250
if answer == "exit":
print("Exiting...")
return