Python: [Breaking] Remove WorkflowCompletedEvent, introduce workflow output and migrate to ctx.yield_output() + a huge refactoring (#845)

* Introduce input and output types for executor and workflow

* WorkflowOutputContext handles two types

* Remove can_handle_types from Executor

* Update validation

* Move workflow executor

* Move workflow executor

* Fix issues in WorkflowExecutor

* refactor executor

* update execute signature to create workflow context within Executor

* fix simple sub workflow test; fix validation

* fix output types in WorkflowExecutor

* fix issue in Executor handling of SubWorkflowRequestInfo

* update tests to use proper workflow output

* update orchestration patterns to use output

* Update sample -- not finished

* Update python/packages/main/tests/workflow/test_workflow_states.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update python/packages/main/tests/workflow/test_concurrent.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* address comments

* WorkflowOutputContext --> WorkflowContext

* remove WorkflowCompletedEvent

* update samples

* Update doc string for important classes; update WorkflowExecutor to support concurrent execution

* use Never instead of None for default type

* Update usage of WorkflowContext[None to WorkflowContext[Never

* address comments

* remove filter for None

* address comments, minor fixes

* quality of life improvement on interceptor types

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Eric Zhu
2025-09-23 13:52:53 -07:00
committed by GitHub
Unverified
parent 0f913bcdeb
commit 2133043f11
67 changed files with 2564 additions and 1648 deletions
@@ -2,10 +2,11 @@
import asyncio
from typing_extensions import Never
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
executor,
handler,
@@ -17,18 +18,28 @@ Step 1: Foundational patterns: Executors and edges
What this example shows
- Two ways to define a unit of work (an Executor node):
1) Custom class that subclasses Executor with an async method marked by @handler.
Signature: (text: str, ctx: WorkflowContext[str]) -> None. The typed ctx
advertises the type this node emits via ctx.send_message(...).
Possible handler signatures:
- (text: str, ctx: WorkflowContext) -> None,
- (text: str, ctx: WorkflowContext[str]) -> None, or
- (text: str, ctx: WorkflowContext[Never, str]) -> None.
The first parameter is the typed input to this node, the input type is str here.
The second parameter is a WorkflowContext[T_Out, T_W_Out].
WorkflowContext[T_Out] is used for nodes that send messages to downstream nodes with ctx.send_message(T_Out).
WorkflowContext[T_Out, T_W_Out] is used for nodes that also yield workflow
output with ctx.yield_output(T_W_Out).
WorkflowContext without type parameters is equivalent to WorkflowContext[Never, Never], meaning this node
neither sends messages to downstream nodes nor yields workflow output.
2) Standalone async function decorated with @executor using the same signature.
Simple steps can use this form; a terminal step can emit a
WorkflowCompletedEvent to end the workflow.
Simple steps can use this form; a terminal step can yield output
using ctx.yield_output() to provide workflow results.
- Fluent WorkflowBuilder API:
add_edge(A, B) to connect nodes, set_start_executor(A), then build() -> Workflow.
- Running and results:
workflow.run(initial_input) executes the graph. The last node emits a
WorkflowCompletedEvent that carries the final result.
workflow.run(initial_input) executes the graph. Terminal nodes yield
outputs using ctx.yield_output(). The workflow runs until idle.
Prerequisites
- No external services required.
@@ -43,8 +54,8 @@ Prerequisites
#
# Handler signature contract:
# - First parameter is the typed input to this node (here: text: str)
# - Second parameter is a WorkflowContext[T], where T is the type of data this
# node will emit via ctx.send_message (here: T is str)
# - Second parameter is a WorkflowContext[T_Out], where T_Out is the type of data this
# node will emit via ctx.send_message (here: T_Out is str)
#
# Within a handler you typically:
# - Compute a result
@@ -70,22 +81,25 @@ class UpperCase(Executor):
# -----------------------------------------------
#
# For simple steps you can skip subclassing and define an async function with the
# same signature pattern (typed input + WorkflowContext[T]) and decorate it with
# same signature pattern (typed input + WorkflowContext[T_Out, T_W_Out]) and decorate it with
# @executor. This creates a fully functional node that can be wired into a flow.
@executor(id="reverse_text_executor")
async def reverse_text(text: str, ctx: WorkflowContext[str]) -> None:
"""Reverse the input string and signal workflow completion.
async def reverse_text(text: str, ctx: WorkflowContext[Never, str]) -> None:
"""Reverse the input string and yield the workflow output.
This node emits a terminal event using ctx.add_event(WorkflowCompletedEvent).
The data carried by the WorkflowCompletedEvent becomes the final result of
the workflow (returned by workflow.run(...)).
This node yields the final output using ctx.yield_output(result).
The workflow will complete when it becomes idle (no more work to do).
The WorkflowContext is parameterized with two types:
- T_Out = Never: this node does not send messages to downstream nodes.
- T_W_Out = str: this node yields workflow output of type str.
"""
result = text[::-1]
# Send the result with a workflow completion event.
await ctx.add_event(WorkflowCompletedEvent(result))
# Yield the output - the workflow will complete when idle
await ctx.yield_output(result)
async def main():
@@ -100,17 +114,17 @@ async def main():
workflow = WorkflowBuilder().add_edge(upper_case, reverse_text).set_start_executor(upper_case).build()
# Run the workflow by sending the initial message to the start node.
# The run(...) call returns an event collection; its get_completed_event()
# provides the WorkflowCompletedEvent emitted by the terminal node.
# The run(...) call returns an event collection; its get_outputs() method
# retrieves the outputs yielded by any terminal nodes.
events = await workflow.run("hello world")
print(events.get_completed_event())
print(events.get_outputs())
# Summarize the final run state (e.g., COMPLETED)
print("Final state:", events.get_final_state())
"""
Sample Output:
WorkflowCompletedEvent(data=DLROW OLLEH)
['DLROW OLLEH']
Final state: WorkflowRunState.COMPLETED
"""
@@ -13,9 +13,9 @@ This sample uses two custom executors. A Writer agent creates or edits content,
then hands the conversation to a Reviewer agent which evaluates and finalizes the result.
Purpose:
Show how to wrap chat agents created by AzureChatClient inside workflow executors. Demonstrate the @handler pattern
with typed inputs and typed WorkflowContext[T] outputs, connect executors with the fluent WorkflowBuilder, and finish
by emitting a WorkflowCompletedEvent from the terminal node.
Show how to wrap chat agents created by AzureChatClient inside workflow executors. Demonstrate how agents
automatically yield outputs when they complete, removing the need for explicit completion events.
The workflow completes when it becomes idle.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
@@ -51,14 +51,12 @@ async def main():
# Run the workflow with the user's initial message.
# For foundational clarity, use run (non streaming) and print the terminal event.
events = await workflow.run("Create a slogan for a new electric SUV that is affordable and fun to drive.")
# The terminal node emits a WorkflowCompletedEvent; print its contents.
# Print interim-agent run events
# Print agent run events and final outputs
for event in events:
if isinstance(event, AgentRunEvent):
print(f"{event.executor_id}: {event.data}")
print(f"{'=' * 60}\n{events.get_completed_event()}")
print(f"{'=' * 60}\nWorkflow Outputs: {events.get_outputs()}")
# Summarize the final run state (e.g., COMPLETED)
print("Final state:", events.get_final_state())
@@ -74,14 +72,13 @@ async def main():
- Consider specifying "SUV" for clarity in some uses.
- Strong, upbeat tone suitable for marketing.
============================================================
Workflow Completed Event:
WorkflowCompletedEvent(data=Slogan: "Plug Into Fun—Affordable Adventure, Electrified."
Workflow Outputs: ['Slogan: "Plug Into Fun—Affordable Adventure, Electrified."
**Feedback:**s
**Feedback:**
- Clear focus on affordability and enjoyment.
- "Plug into fun" connects emotionally and highlights electric nature.
- Consider specifying "SUV" for clarity in some uses.
- Strong, upbeat tone suitable for marketing.)
- Strong, upbeat tone suitable for marketing.']
"""
@@ -2,19 +2,21 @@
import asyncio
from typing_extensions import Never
from agent_framework import (
ChatAgent,
ChatMessage,
Executor,
ExecutorFailedEvent,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowFailedEvent,
WorkflowRunState,
WorkflowStatusEvent,
handler,
)
from agent_framework._workflow._events import WorkflowOutputEvent
from agent_framework.azure import AzureChatClient
from azure.identity import AzureCliCredential
@@ -28,9 +30,9 @@ The workflow is invoked with run_stream so you can observe events as they occur.
Purpose:
Show how to wrap chat agents created by AzureChatClient inside workflow executors, wire them with WorkflowBuilder,
and consume streaming events from the workflow. Demonstrate the @handler pattern with typed inputs and typed
WorkflowContext[T] outputs, and finish by emitting a WorkflowCompletedEvent from the terminal node while printing
intermediate events for observability. The streaming loop also surfaces WorkflowEvent.origin so you can
distinguish runner-generated lifecycle events from executor-generated data-plane events.
WorkflowContext[T_Out, T_W_Out] outputs. Agents automatically yield outputs when they complete.
The streaming loop also surfaces WorkflowEvent.origin so you can distinguish runner-generated lifecycle events
from executor-generated data-plane events.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
@@ -96,14 +98,14 @@ class Reviewer(Executor):
super().__init__(agent=agent, id=id)
@handler
async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[str]) -> None:
"""Review the full conversation transcript and complete with a final string.
async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[Never, str]) -> None:
"""Review the full conversation transcript and yield the final output.
This node consumes all messages so far. It uses its agent to produce the final text,
then signals completion by adding a WorkflowCompletedEvent to the event stream.
then yields the output. The workflow completes when it becomes idle.
"""
response = await self.agent.run(messages)
await ctx.add_event(WorkflowCompletedEvent(response.text))
await ctx.yield_output(response.text)
async def main():
@@ -119,7 +121,7 @@ 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.
# In addition to executor events and WorkflowCompletedEvent, this also surfaces run-state and errors.
# This surfaces executor events, workflow outputs, run-state changes, 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.")
):
@@ -127,8 +129,6 @@ async def main():
prefix = f"State ({event.origin.value}): "
if event.state == WorkflowRunState.IN_PROGRESS:
print(prefix + "IN_PROGRESS")
elif event.state == WorkflowRunState.COMPLETED:
print(prefix + "COMPLETED")
elif event.state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
print(prefix + "IN_PROGRESS_PENDING_REQUESTS (requests in flight)")
elif event.state == WorkflowRunState.IDLE:
@@ -137,8 +137,8 @@ async def main():
print(prefix + "IDLE_WITH_PENDING_REQUESTS (prompt user or UI now)")
else:
print(prefix + str(event.state))
elif isinstance(event, WorkflowCompletedEvent):
print(f"Workflow completed ({event.origin.value}): {event.data}")
elif isinstance(event, WorkflowOutputEvent):
print(f"Workflow output ({event.origin.value}): {event.data}")
elif isinstance(event, ExecutorFailedEvent):
print(
f"Executor failed ({event.origin.value}): "
@@ -157,9 +157,9 @@ async def main():
ExecutorInvokeEvent (RUNNER): ExecutorInvokeEvent(executor_id=writer)
ExecutorCompletedEvent (RUNNER): ExecutorCompletedEvent(executor_id=writer)
ExecutorInvokeEvent (RUNNER): ExecutorInvokeEvent(executor_id=reviewer)
Workflow completed (EXECUTOR): Drive the Future. Affordable Adventure, Electrified.
Workflow output (EXECUTOR): Drive the Future. Affordable Adventure, Electrified.
ExecutorCompletedEvent (RUNNER): ExecutorCompletedEvent(executor_id=reviewer)
State (RUNNER): COMPLETED
State (RUNNER): IDLE
"""