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
@@ -7,6 +7,7 @@ from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowOutputEvent,
WorkflowContext,
handler,
)
@@ -42,14 +43,15 @@ class ReverseTextExecutor(Executor):
"""An executor that reverses text."""
@handler
async def reverse_text(self, text: str, ctx: WorkflowContext[Any]) -> None:
async def reverse_text(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
"""Execute the task by reversing the input string."""
print(f"ReverseTextExecutor: Processing '{text}'")
result = text[::-1]
print(f"ReverseTextExecutor: Result '{result}'")
# Send the result with a workflow completion event.
await ctx.add_event(WorkflowCompletedEvent(result))
# Yield the output and signal workflow completion.
await ctx.yield_output(result)
await ctx.add_event(WorkflowCompletedEvent())
async def run_sequential_workflow() -> None:
@@ -84,17 +86,17 @@ async def run_sequential_workflow() -> None:
input_text = "hello world"
print(f"Starting workflow with input: '{input_text}'")
completion_event = None
output_event = None
async for event in workflow.run_stream(input_text):
print(f"Event: {event}")
if isinstance(event, WorkflowCompletedEvent):
# The WorkflowCompletedEvent contains the final result.
completion_event = event
if isinstance(event, WorkflowOutputEvent):
# The WorkflowOutputEvent contains the final result.
output_event = event
if completion_event:
print(f"Workflow completed with result: '{completion_event.data}'")
if output_event:
print(f"Workflow completed with result: '{output_event.data}'")
else:
print("Workflow completed without a completion event")
print("Workflow completed without an output event")
except Exception as e:
current_span.record_exception(e)
@@ -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
"""
@@ -2,7 +2,7 @@
import asyncio
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowCompletedEvent
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowOutputEvent
from agent_framework.azure import AzureChatClient
from azure.identity import AzureCliCredential
@@ -18,7 +18,7 @@ Show how to wire chat agents directly into a WorkflowBuilder pipeline where agen
Demonstrate:
- Automatic streaming of agent deltas via AgentRunUpdateEvent.
- A simple console aggregator that groups updates by executor id and prints them as they arrive.
- A final WorkflowCompletedEvent that contains the reviewer outcome after both agents finish.
- The workflow completes when idle and outputs are available in events.get_outputs().
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
@@ -54,12 +54,10 @@ async def main():
workflow = WorkflowBuilder().set_start_executor(writer_agent).add_edge(writer_agent, reviewer_agent).build()
# Stream events from the workflow. We aggregate partial token updates per executor for readable output.
completed_event: WorkflowCompletedEvent | None = None
last_executor_id = None
async for event in workflow.run_stream(
"Create a slogan for a new electric SUV that is affordable and fun to drive."
):
events = workflow.run_stream("Create a slogan for a new electric SUV that is affordable and fun to drive.")
async for event in events:
if isinstance(event, AgentRunUpdateEvent):
# AgentRunUpdateEvent contains incremental text deltas from the underlying agent.
# Print a prefix when the executor changes, then append updates on the same line.
@@ -70,14 +68,9 @@ async def main():
print(f"{eid}:", end=" ", flush=True)
last_executor_id = eid
print(event.data, end="", flush=True)
elif isinstance(event, WorkflowCompletedEvent):
# Terminal event with the final reviewer output.
completed_event = event
# Print the final consolidated reviewer result.
if completed_event:
print("\n===== Final Output =====")
print(completed_event.data)
elif isinstance(event, WorkflowOutputEvent):
print("===== Final Output =====")
print(event.data)
"""
Sample Output:
@@ -7,7 +7,6 @@ from agent_framework import (
ChatMessage,
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
@@ -23,7 +22,7 @@ then hands the conversation to a Reviewer agent which evaluates and finalizes th
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.
by yielding outputs from the terminal node.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
@@ -53,7 +52,7 @@ class Writer(Executor):
super().__init__(agent=agent, id=id)
@handler
async def handle(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage]]) -> None:
async def handle(self, message: ChatMessage, ctx: WorkflowContext[list[ChatMessage], str]) -> None:
"""Generate content using the agent and forward the updated conversation.
Contract for this handler:
@@ -79,7 +78,7 @@ class Reviewer(Executor):
This class demonstrates:
- Consuming a typed payload produced upstream.
- Emitting a terminal WorkflowCompletedEvent with the final text outcome.
- Yielding the final text outcome to complete the workflow.
"""
agent: ChatAgent
@@ -94,14 +93,14 @@ class Reviewer(Executor):
super().__init__(agent=agent, id=id)
@handler
async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[str]) -> None:
async def handle(self, messages: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage], str]) -> None:
"""Review the full conversation transcript and complete with a final string.
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 signals completion by yielding the output.
"""
response = await self.agent.run(messages)
await ctx.add_event(WorkflowCompletedEvent(response.text))
await ctx.yield_output(response.text)
async def main():
@@ -118,12 +117,14 @@ async def main():
workflow = WorkflowBuilder().set_start_executor(writer).add_edge(writer, reviewer).build()
# Run the workflow with the user's initial message.
# For foundational clarity, use run (non streaming) and print the terminal event.
# For foundational clarity, use run (non streaming) and print the workflow output.
events = await workflow.run(
ChatMessage(role="user", text="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(events.get_completed_event())
# The terminal node yields output; print its contents.
outputs = events.get_outputs()
if outputs:
print(outputs[-1])
if __name__ == "__main__":
@@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable
from contextlib import AsyncExitStack
from typing import Any
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowCompletedEvent
from agent_framework import AgentRunUpdateEvent, WorkflowBuilder, WorkflowOutputEvent
from agent_framework.foundry import FoundryChatClient
from azure.identity.aio import AzureCliCredential
@@ -21,7 +21,7 @@ Show how to wire chat agents directly into a WorkflowBuilder pipeline where agen
Demonstrate:
- Automatic streaming of agent deltas via AgentRunUpdateEvent.
- A simple console aggregator that groups updates by executor id and prints them as they arrive.
- A final WorkflowCompletedEvent that contains the reviewer outcome after both agents finish.
- The workflow completes when idle and outputs are available in events.get_outputs().
Prerequisites:
- Foundry Agent Service configured, along with the required environment variables.
@@ -69,12 +69,10 @@ async def main() -> None:
workflow = WorkflowBuilder().set_start_executor(writer).add_edge(writer, reviewer).build()
completed: WorkflowCompletedEvent | None = None
last_executor_id: str | None = None
async for event in workflow.run_stream(
"Create a slogan for a new electric SUV that is affordable and fun to drive."
):
events = workflow.run_stream("Create a slogan for a new electric SUV that is affordable and fun to drive.")
async for event in events:
if isinstance(event, AgentRunUpdateEvent):
eid = event.executor_id
if eid != last_executor_id:
@@ -83,13 +81,9 @@ async def main() -> None:
print(f"{eid}:", end=" ", flush=True)
last_executor_id = eid
print(event.data, end="", flush=True)
elif isinstance(event, WorkflowCompletedEvent):
completed = event
if completed:
print("\n===== Final Output =====")
print(completed.data)
elif isinstance(event, WorkflowOutputEvent):
print("\n===== Final output =====")
print(event.data)
finally:
await close()
@@ -39,7 +39,7 @@ This sample demonstrates how to build a workflow agent that escalates uncertain
decisions to a human manager. A Worker generates results, while a Reviewer
evaluates them. When the Reviewer is not confident, it escalates the decision
to a human via RequestInfoExecutor, receives the human response, and then
forwards that response back to the Worker.
forwards that response back to the Worker. The workflow completes when idle.
Prerequisites:
- OpenAI account configured and accessible for OpenAIChatClient.
@@ -27,7 +27,7 @@ This sample demonstrates how to wrap a workflow as an agent using WorkflowAgent.
It uses a reflection pattern where a Worker executor generates responses and a
Reviewer executor evaluates them. If the response is not approved, the Worker
regenerates the output based on feedback until the Reviewer approves it. Only
approved responses are emitted to the external consumer.
approved responses are emitted to the external consumer. The workflow completes when idle.
Key Concepts Demonstrated:
- WorkflowAgent: Wraps a workflow to behave like a regular agent.
@@ -19,8 +19,8 @@ from agent_framework import (
RequestResponse,
Role,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
handler,
@@ -87,7 +87,7 @@ class BriefPreparer(Executor):
self._agent_id = agent_id
@handler
async def prepare(self, brief: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
async def prepare(self, brief: str, ctx: WorkflowContext[AgentExecutorRequest, str]) -> None:
# Collapse errant whitespace so the prompt is stable between runs.
normalized = " ".join(brief.split()).strip()
if not normalized.endswith("."):
@@ -133,7 +133,7 @@ class ReviewGateway(Executor):
async def on_agent_response(
self,
response: AgentExecutorResponse,
ctx: WorkflowContext[HumanApprovalRequest],
ctx: WorkflowContext[HumanApprovalRequest, str],
) -> None:
# Capture the agent output so we can surface it to the reviewer and
# persist iterations. The `RequestInfoExecutor` relies on this state to
@@ -157,7 +157,7 @@ class ReviewGateway(Executor):
async def on_human_feedback(
self,
feedback: RequestResponse[HumanApprovalRequest, str],
ctx: WorkflowContext[AgentExecutorRequest | str],
ctx: WorkflowContext[AgentExecutorRequest | str, str],
) -> None:
# The RequestResponse wrapper gives us both the human data and the
# original request message, even when resuming from checkpoints.
@@ -190,11 +190,11 @@ class FinaliseExecutor(Executor):
"""Publishes the approved text."""
@handler
async def publish(self, text: str, ctx: WorkflowContext[Any]) -> None:
async def publish(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
# Store the output so diagnostics or a UI could fetch the final copy.
await ctx.set_state({"published_text": text})
# Emit a workflow completion event so the runner stops cleanly.
await ctx.add_event(WorkflowCompletedEvent(text))
# Yield the final output so the workflow completes cleanly.
await ctx.yield_output(text)
def create_workflow(*, checkpoint_storage: FileCheckpointStorage | None = None) -> "Workflow":
@@ -264,17 +264,17 @@ def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
print(line)
def _print_events(events: list[Any]) -> tuple[WorkflowCompletedEvent | None, list[tuple[str, HumanApprovalRequest]]]:
def _print_events(events: list[Any]) -> tuple[str | None, list[tuple[str, HumanApprovalRequest]]]:
"""Echo workflow events to the console and collect outstanding requests."""
completed: WorkflowCompletedEvent | None = None
completed_output: str | None = None
requests: list[tuple[str, HumanApprovalRequest]] = []
for event in events:
print(f"Event: {event}")
if isinstance(event, WorkflowCompletedEvent):
completed = event
elif isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanApprovalRequest):
if isinstance(event, WorkflowOutputEvent):
completed_output = event.data
if isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanApprovalRequest):
# Capture pending human approvals so the caller can ask the user for
# input after the current batch of events is processed.
requests.append((event.request_id, event.data))
@@ -284,7 +284,7 @@ def _print_events(events: list[Any]) -> tuple[WorkflowCompletedEvent | None, lis
}:
print(f"Workflow state: {event.state.name}")
return completed, requests
return completed_output, requests
def _prompt_for_responses(requests: list[tuple[str, HumanApprovalRequest]]) -> dict[str, str] | None:
@@ -350,14 +350,14 @@ async def _consume(stream: AsyncIterable[Any]) -> list[Any]:
return [event async for event in stream]
async def run_interactive_session(workflow: "Workflow", initial_message: str) -> WorkflowCompletedEvent | None:
async def run_interactive_session(workflow: "Workflow", initial_message: str) -> str | None:
"""Run the workflow until it either finishes or pauses for human input."""
pending_responses: dict[str, str] | None = None
completed: WorkflowCompletedEvent | None = None
completed_output: str | None = None
first = True
while completed is None:
while completed_output is None:
if first:
# Kick off the workflow with the initial brief. The returned events
# include RequestInfo events when the agent produces a draft.
@@ -369,10 +369,11 @@ async def run_interactive_session(workflow: "Workflow", initial_message: str) ->
else:
break
completed, requests = _print_events(events)
pending_responses = _prompt_for_responses(requests)
completed_output, requests = _print_events(events)
if completed_output is None:
pending_responses = _prompt_for_responses(requests)
return completed
return completed_output
async def resume_from_checkpoint(
@@ -391,21 +392,24 @@ async def resume_from_checkpoint(
responses=pre_supplied,
)
)
completed, requests = _print_events(events)
if pre_supplied and not requests and completed is None:
completed_output, requests = _print_events(events)
if pre_supplied and not requests and completed_output is None:
# When the checkpoint only needed the provided answers we let the user
# know the workflow is waiting for the next superstep (usually another
# agent response).
print("Pre-supplied responses applied automatically; workflow is now waiting for the next step.")
pending = _prompt_for_responses(requests)
while completed is None and pending:
while completed_output is None and pending:
events = await _consume(workflow.send_responses_streaming(pending))
completed, requests = _print_events(events)
pending = _prompt_for_responses(requests)
completed_output, requests = _print_events(events)
if completed_output is None:
pending = _prompt_for_responses(requests)
else:
break
if completed:
print(f"Workflow completed with: {completed.data}")
if completed_output:
print(f"Workflow completed with: {completed_output}")
async def main() -> None:
@@ -427,7 +431,7 @@ async def main() -> None:
print("Running workflow (human approval required)...")
completed = await run_interactive_session(workflow, initial_message=brief)
if completed:
print(f"Initial run completed with final copy: {completed.data}")
print(f"Initial run completed with final copy: {completed}")
else:
print("Initial run paused for human input.")
@@ -15,7 +15,6 @@ from agent_framework import (
RequestInfoExecutor,
Role,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
@@ -49,6 +48,7 @@ What you learn:
- How to list and inspect checkpoints programmatically.
- How to interactively choose a checkpoint to resume from (instead of always resuming
from the most recent or a hard-coded one) using run_stream_from_checkpoint.
- How workflows complete by yielding outputs when idle, not via explicit completion events.
Prerequisites:
- Azure AI or Azure OpenAI available for AzureChatClient.
@@ -115,10 +115,10 @@ class SubmitToLowerAgent(Executor):
class FinalizeFromAgent(Executor):
"""Consumes the AgentExecutorResponse and emits the terminal WorkflowCompletedEvent."""
"""Consumes the AgentExecutorResponse and yields the final result."""
@handler
async def finalize(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any]) -> None:
async def finalize(self, response: AgentExecutorResponse, ctx: WorkflowContext[Any, str]) -> None:
result = response.agent_run_response.text or ""
# Persist executor-local state for auditability when inspecting checkpoints.
@@ -130,8 +130,8 @@ class FinalizeFromAgent(Executor):
"final": True,
})
# Emit a terminal event so external consumers see the final value.
await ctx.add_event(WorkflowCompletedEvent(result))
# Yield the final result so external consumers see the final value.
await ctx.yield_output(result)
class ReverseTextExecutor(Executor):
@@ -185,6 +185,7 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> "Workflow":
.build()
)
def _render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
"""Display human-friendly checkpoint metadata using framework summaries."""
@@ -297,7 +298,6 @@ async def main():
Event: ExecutorInvokeEvent(executor_id=submit_lower)
Event: ExecutorInvokeEvent(executor_id=lower_agent)
Event: ExecutorInvokeEvent(executor_id=finalize)
Event: WorkflowCompletedEvent(data=dlrow olleh)
Checkpoint summary:
- dfc63e72-8e8d-454f-9b6d-0d740b9062e6 | label='after_initial_execution' | iter=0 | messages=1 | states=['upper_case_executor'] | shared_state: original_input='hello world', upper_output='HELLO WORLD'
@@ -316,7 +316,6 @@ async def main():
Resumed Event: ExecutorInvokeEvent(executor_id=submit_lower)
Resumed Event: ExecutorInvokeEvent(executor_id=lower_agent)
Resumed Event: ExecutorInvokeEvent(executor_id=finalize)
Resumed Event: WorkflowCompletedEvent(data=dlrow olleh)
""" # noqa: E501
@@ -4,10 +4,11 @@ import asyncio
from dataclasses import dataclass
from typing import Any
from typing_extensions import Never
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowEvent,
WorkflowExecutor,
@@ -20,6 +21,7 @@ Sample: Sub-Workflows (Basics)
What it does:
- Shows how a parent workflow invokes a sub-workflow via `WorkflowExecutor` and collects results.
- Example: parent orchestrates multiple text processors that count words/characters.
- Demonstrates how sub-workflows complete by yielding outputs when processing is done.
Prerequisites:
- No external services required.
@@ -60,7 +62,9 @@ class TextProcessor(Executor):
super().__init__(id="text_processor")
@handler
async def process_text(self, request: TextProcessingRequest, ctx: WorkflowContext[TextProcessingResult]) -> None:
async def process_text(
self, request: TextProcessingRequest, ctx: WorkflowContext[Never, TextProcessingResult]
) -> None:
"""Process a text string and return statistics."""
text_preview = f"'{request.text[:50]}{'...' if len(request.text) > 50 else ''}'"
print(f"🔍 Sub-workflow processing text (Task {request.task_id}): {text_preview}")
@@ -80,8 +84,8 @@ class TextProcessor(Executor):
)
print(f"✅ Sub-workflow completed task {request.task_id}")
# Signal completion
await ctx.add_event(WorkflowCompletedEvent(data=result))
# Signal completion by yielding the result
await ctx.yield_output(result)
# Parent workflow
@@ -110,7 +114,7 @@ class TextProcessingOrchestrator(Executor):
await ctx.send_message(request, target_id="text_processor_workflow")
@handler
async def collect_result(self, result: TextProcessingResult, ctx: WorkflowContext[None]) -> None:
async def collect_result(self, result: TextProcessingResult, ctx: WorkflowContext) -> None:
"""Collect results from sub-workflows."""
print(f"📥 Collected result from {result.task_id}")
self.results.append(result)
@@ -173,7 +177,7 @@ async def main():
print("=" * 60)
# Step 4: Run the workflow
result = await main_workflow.run(test_texts)
await main_workflow.run(test_texts)
# Step 5: Display results
print("\n📊 Processing Results:")
@@ -4,11 +4,12 @@ import asyncio
from dataclasses import dataclass
from typing import Any
from typing_extensions import Never
from agent_framework import (
Executor,
RequestInfoExecutor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
@@ -138,6 +139,7 @@ class ResourceRequester(Executor):
for req_data in requests:
req_type = req_data.get("request_type", "resource")
request: ResourceRequest | PolicyCheckRequest
if req_type == "resource":
print(f" 📦 Requesting resource: {req_data.get('type', 'cpu')} x{req_data.get('amount', 1)}")
request = ResourceRequest(
@@ -164,7 +166,7 @@ class ResourceRequester(Executor):
async def handle_resource_response(
self,
response: RequestResponse[ResourceRequest, ResourceResponse],
ctx: WorkflowContext[None],
ctx: WorkflowContext[Never, RequestFinished],
) -> None:
"""Handle resource allocation response."""
if response.data:
@@ -174,12 +176,12 @@ class ResourceRequester(Executor):
f"from {response.data.source}"
)
if self._collect_results():
# Emit completion event and send RequestFinished to the parent workflow.
await ctx.add_event(WorkflowCompletedEvent(RequestFinished()))
# Yield completion result to the parent workflow.
await ctx.yield_output(RequestFinished())
@handler
async def handle_policy_response(
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext[None]
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext[Never, RequestFinished]
) -> None:
"""Handle policy check response."""
if response.data:
@@ -189,8 +191,8 @@ class ResourceRequester(Executor):
f"{response.data.approved} - {response.data.reason}"
)
if self._collect_results():
# Emit completion event and send RequestFinished to the parent workflow.
await ctx.add_event(WorkflowCompletedEvent(RequestFinished()))
# Yield completion result to the parent workflow.
await ctx.yield_output(RequestFinished())
def _collect_results(self) -> bool:
"""Collect and summarize results."""
@@ -213,7 +215,7 @@ class ResourceCache(Executor):
@intercepts_request
async def check_cache(
self, request: ResourceRequest, ctx: WorkflowContext[None]
self, request: ResourceRequest, ctx: WorkflowContext
) -> RequestResponse[ResourceRequest, ResourceResponse]:
"""Intercept RESOURCE requests and check cache first."""
print(f"🏪 CACHE interceptor checking: {request.amount} {request.resource_type}")
@@ -234,7 +236,7 @@ class ResourceCache(Executor):
@handler
async def collect_result(
self, response: RequestResponse[ResourceRequest, ResourceResponse], ctx: WorkflowContext[None]
self, response: RequestResponse[ResourceRequest, ResourceResponse], ctx: WorkflowContext
) -> None:
"""Collect results from external requests that were forwarded."""
if response.data and response.data.source != "cache": # Don't double-count our own results
@@ -263,7 +265,7 @@ class PolicyEngine(Executor):
@intercepts_request
async def check_policy(
self, request: PolicyCheckRequest, ctx: WorkflowContext[None]
self, request: PolicyCheckRequest, ctx: WorkflowContext
) -> RequestResponse[PolicyCheckRequest, PolicyResponse]:
"""Intercept POLICY requests and apply rules."""
print(f"🛡️ POLICY interceptor checking: {request.amount} {request.resource_type}, policy={request.policy_type}")
@@ -286,7 +288,7 @@ class PolicyEngine(Executor):
@handler
async def collect_policy_result(
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext[None]
self, response: RequestResponse[PolicyCheckRequest, PolicyResponse], ctx: WorkflowContext
) -> None:
"""Collect policy results from external requests that were forwarded."""
if response.data:
@@ -299,15 +301,15 @@ class Coordinator(Executor):
super().__init__(id="coordinator")
@handler
async def start(self, requests: list[dict[str, Any]], ctx: WorkflowContext[object]) -> None:
async def start(self, requests: list[dict[str, Any]], ctx: WorkflowContext[list[dict[str, Any]]]) -> None:
"""Start the resource allocation process."""
await ctx.send_message(requests, target_id="resource_workflow")
@handler
async def handle_completion(self, completion: RequestFinished, ctx: WorkflowContext[None]) -> None:
async def handle_completion(self, completion: RequestFinished, ctx: WorkflowContext) -> None:
"""Handle sub-workflow completion.
It comes from the sub-workflow emitted WorkflowCompletionEvent's data field.
It comes from the sub-workflow yielded output.
"""
print("🎯 Main workflow received completion.")
@@ -377,10 +379,10 @@ async def main() -> None:
# 8. Run the workflow
print("🎬 Running workflow...")
result = await main_workflow.run(test_requests)
events = await main_workflow.run(test_requests)
# 9. Handle any external requests that couldn't be intercepted
request_events = result.get_request_info_events()
request_events = events.get_request_info_events()
if request_events:
print(f"\n🌐 Handling {len(request_events)} external request(s)...")
@@ -2,7 +2,6 @@
import asyncio
from dataclasses import dataclass
from typing import Any
from agent_framework import (
Executor,
@@ -10,7 +9,6 @@ from agent_framework import (
RequestInfoMessage,
RequestResponse,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowExecutor,
handler,
@@ -43,6 +41,7 @@ Key concepts demonstrated:
- Concurrent processing: Multiple emails processed simultaneously without interference
- External request routing: RequestInfoExecutor handles forwarded external requests
- Sub-workflow isolation: Sub-workflows work normally without knowing they're nested
- Sub-workflows complete by yielding outputs when validation is finished
Prerequisites:
- No external services required (external calls are simulated via `RequestInfoExecutor`).
@@ -101,7 +100,9 @@ class EmailValidator(Executor):
@handler
async def validate_request(
self, request: EmailValidationRequest, ctx: WorkflowContext[DomainCheckRequest | ValidationResult]
self,
request: EmailValidationRequest,
ctx: WorkflowContext[DomainCheckRequest | ValidationResult, ValidationResult],
) -> None:
"""Validate an email address."""
print(f"🔍 Sub-workflow validating email: {request.email}")
@@ -112,7 +113,7 @@ class EmailValidator(Executor):
if not domain:
print(f"❌ Invalid email format: {request.email}")
result = ValidationResult(email=request.email, is_valid=False, reason="Invalid email format")
await ctx.add_event(WorkflowCompletedEvent(data=result))
await ctx.yield_output(result)
return
print(f"🌐 Sub-workflow requesting domain check for: {domain}")
@@ -126,7 +127,7 @@ class EmailValidator(Executor):
async def handle_domain_response(
self,
response: RequestResponse[DomainCheckRequest, bool],
ctx: WorkflowContext[ValidationResult],
ctx: WorkflowContext[ValidationResult, ValidationResult],
) -> None:
"""Handle domain check response from RequestInfo with correlation."""
approved = bool(response.data)
@@ -151,7 +152,7 @@ class EmailValidator(Executor):
reason="Domain approved" if approved else "Domain not approved",
)
print(f"✅ Sub-workflow completing validation for: {email}")
await ctx.add_event(WorkflowCompletedEvent(data=result))
await ctx.yield_output(result)
# 3. Implement the parent workflow with request interception
@@ -181,7 +182,7 @@ class SmartEmailOrchestrator(Executor):
@intercepts_request
async def check_domain(
self, request: DomainCheckRequest, ctx: WorkflowContext[Any]
self, request: DomainCheckRequest, ctx: WorkflowContext
) -> RequestResponse[DomainCheckRequest, bool]:
"""Intercept domain check requests from sub-workflows."""
print(f"🔍 Parent intercepting domain check for: {request.domain}")
@@ -192,8 +193,8 @@ class SmartEmailOrchestrator(Executor):
return RequestResponse[DomainCheckRequest, bool].forward()
@handler
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext[None]) -> None:
"""Collect validation results. It comes from the sub-workflow emitted WorkflowCompletionEvent's data field."""
async def collect_result(self, result: ValidationResult, ctx: WorkflowContext) -> None:
"""Collect validation results. It comes from the sub-workflow yielded output."""
status_icon = "" if result.is_valid else ""
print(f"📥 {status_icon} Validation result: {result.email} -> {result.reason}")
self._results.append(result)
@@ -4,6 +4,8 @@ import asyncio
import os
from typing import Any
from typing_extensions import Never
from agent_framework import ( # Core chat primitives used to build requests
AgentExecutor, # Wraps an LLM agent that can be invoked inside a workflow
AgentExecutorRequest, # Input message bundle for an AgentExecutor
@@ -11,7 +13,6 @@ from agent_framework import ( # Core chat primitives used to build requests
ChatMessage,
Role,
WorkflowBuilder, # Fluent builder for wiring executors and edges
WorkflowCompletedEvent, # Event we emit at the end to signal completion
WorkflowContext, # Per-run context and event bus
executor, # Decorator to declare a Python function as a workflow executor
)
@@ -41,15 +42,16 @@ and have the Azure OpenAI environment variables set as documented in the getting
High level flow:
1) spam_detection_agent reads an email and returns DetectionResult.
2) If not spam, we transform the detection output into a user message for email_assistant_agent, then finish by
sending the drafted reply.
3) If spam, we short circuit to a spam handler that emits a completion event.
yielding the drafted reply as workflow output.
3) If spam, we short circuit to a spam handler that yields a spam notice as workflow output.
Output:
- The final WorkflowCompletedEvent is printed to stdout, either with a drafted reply or a spam notice.
- The final workflow output is printed to stdout, either with a drafted reply or a spam notice.
Notes:
- Conditions read the agent response text and validate it into DetectionResult for robust routing.
- Executors are small and single purpose to keep control flow easy to follow.
- The workflow completes when it becomes idle, not via explicit completion events.
"""
@@ -96,18 +98,18 @@ def get_condition(expected_result: bool):
@executor(id="send_email")
async def handle_email_response(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
# Downstream of the email assistant. Parse a validated EmailResponse and emit a completion event.
async def handle_email_response(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
# Downstream of the email assistant. Parse a validated EmailResponse and yield the workflow output.
email_response = EmailResponse.model_validate_json(response.agent_run_response.text)
await ctx.add_event(WorkflowCompletedEvent(f"Email sent:\n{email_response.response}"))
await ctx.yield_output(f"Email sent:\n{email_response.response}")
@executor(id="handle_spam")
async def handle_spam_classifier_response(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
# Spam path. Confirm the DetectionResult and finish with the reason. Guard against accidental non spam input.
async def handle_spam_classifier_response(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
# Spam path. Confirm the DetectionResult and yield the workflow output. Guard against accidental non spam input.
detection = DetectionResult.model_validate_json(response.agent_run_response.text)
if detection.is_spam:
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {detection.reason}"))
await ctx.yield_output(f"Email marked as spam: {detection.reason}")
else:
# This indicates the routing predicate and executor contract are out of sync.
raise RuntimeError("This executor should only handle spam messages.")
@@ -184,11 +186,12 @@ async def main() -> None:
email = email_file.read()
# Execute the workflow. Since the start is an AgentExecutor, pass an AgentExecutorRequest.
# run_stream yields events as they occur. We watch for the terminal WorkflowCompletedEvent and print it.
# The workflow completes when it becomes idle (no more work to do).
request = AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email)], should_respond=True)
async for event in workflow.run_stream(request):
if isinstance(event, WorkflowCompletedEvent):
print(f"{event}")
events = await workflow.run(request)
outputs = events.get_outputs()
if outputs:
print(f"Workflow output: {outputs[0]}")
"""
Sample Output:
@@ -214,7 +217,7 @@ async def main() -> None:
(555) 123-4567
----------------------------------------
WorkflowCompletedEvent(data=Email sent:
Workflow output: Email sent:
Hi Alex,
Thank you for the follow-up and for summarizing the action items from this morning's meeting. The points you listed accurately reflect our discussion, and I don't have any additional items to add at this time.
@@ -224,7 +227,7 @@ async def main() -> None:
Thank you again for outlining the next steps.
Best regards,
Sarah)
Sarah
""" # noqa: E501
@@ -8,6 +8,8 @@ from dataclasses import dataclass
from typing import Literal
from uuid import uuid4
from typing_extensions import Never
from agent_framework import (
AgentExecutor,
AgentExecutorRequest,
@@ -15,9 +17,9 @@ from agent_framework import (
ChatMessage,
Role,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowEvent,
WorkflowOutputEvent,
executor,
)
from agent_framework.azure import AzureChatClient
@@ -30,7 +32,7 @@ Sample: Multi-Selection Edge Group for email triage and response.
The workflow stores an email,
classifies it as NotSpam, Spam, or Uncertain, and then routes to one or more branches.
Non-spam emails are drafted into replies, long ones are also summarized, spam is blocked, and uncertain cases are
flagged. Each path ends with simulated database persistence.
flagged. Each path ends with simulated database persistence. The workflow completes when it becomes idle.
Purpose:
Demonstrate how to use a multi-selection edge group to fan out from one executor to multiple possible targets.
@@ -123,9 +125,9 @@ async def submit_to_email_assistant(analysis: AnalysisResult, ctx: WorkflowConte
@executor(id="finalize_and_send")
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
await ctx.yield_output(f"Email sent: {parsed.response}")
@executor(id="summarize_email")
@@ -155,28 +157,26 @@ async def merge_summary(response: AgentExecutorResponse, ctx: WorkflowContext[An
@executor(id="handle_spam")
async def handle_spam(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
async def handle_spam(analysis: AnalysisResult, ctx: WorkflowContext[Never, str]) -> None:
if analysis.spam_decision == "Spam":
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {analysis.reason}"))
await ctx.yield_output(f"Email marked as spam: {analysis.reason}")
else:
raise RuntimeError("This executor should only handle Spam messages.")
@executor(id="handle_uncertain")
async def handle_uncertain(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
async def handle_uncertain(analysis: AnalysisResult, ctx: WorkflowContext[Never, str]) -> None:
if analysis.spam_decision == "Uncertain":
email: Email | None = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
await ctx.add_event(
WorkflowCompletedEvent(
f"Email marked as uncertain: {analysis.reason}. Email content: {getattr(email, 'email_content', '')}"
)
await ctx.yield_output(
f"Email marked as uncertain: {analysis.reason}. Email content: {getattr(email, 'email_content', '')}"
)
else:
raise RuntimeError("This executor should only handle Uncertain messages.")
@executor(id="database_access")
async def database_access(analysis: AnalysisResult, ctx: WorkflowContext[None]) -> None:
async def database_access(analysis: AnalysisResult, ctx: WorkflowContext[Never, str]) -> None:
# Simulate DB writes for email and analysis (and summary if present)
await asyncio.sleep(0.05)
await ctx.add_event(DatabaseEvent(f"Email {analysis.email_id} saved to database."))
@@ -263,14 +263,18 @@ async def main() -> None:
print("Unable to find resource file, using default text.")
email = "Hello team, here are the updates for this week..."
# Print outputs and database events from streaming
async for event in workflow.run_stream(email):
if isinstance(event, (WorkflowCompletedEvent, DatabaseEvent)):
if isinstance(event, DatabaseEvent):
print(f"{event}")
elif isinstance(event, WorkflowOutputEvent):
print(f"Workflow output: {event.data}")
"""
Sample Output:
WorkflowCompletedEvent(data=Email sent: Hi Alex,
DatabaseEvent(data=Email 32021432-2d4e-4c54-b04c-f81b4120340c saved to database.)
Workflow output: Email sent: Hi Alex,
Thank you for summarizing the action items from this morning's meeting.
I have noted the three tasks and will begin working on them right away.
@@ -281,8 +285,7 @@ async def main() -> None:
If anything else comes up, please let me know.
Best regards,
Sarah)
DatabaseEvent(data=Email 32021432-2d4e-4c54-b04c-f81b4120340c saved to database.)
Sarah
""" # noqa: E501
@@ -1,13 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any
from typing import cast
from typing_extensions import Never
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowOutputEvent,
handler,
)
@@ -19,8 +21,8 @@ the second reverses the text and completes the workflow. The run_stream loop pri
Purpose:
Show how to define explicit Executor classes with @handler methods, wire them in order with
WorkflowBuilder, and consume streaming events. Demonstrate typed WorkflowContext[T] for outputs,
ctx.send_message to pass intermediate values, and ctx.add_event to signal completion with a WorkflowCompletedEvent.
WorkflowBuilder, and consume streaming events. Demonstrate typed WorkflowContext[T_Out, T_W_Out] for outputs,
ctx.send_message to pass intermediate values, and ctx.yield_output to provide workflow outputs.
Prerequisites:
- No external services required.
@@ -44,21 +46,21 @@ class UpperCaseExecutor(Executor):
class ReverseTextExecutor(Executor):
"""Reverses the incoming string and completes the workflow.
"""Reverses the incoming string and yields workflow output.
Concepts:
- Use ctx.add_event to publish a WorkflowCompletedEvent when the terminal result is ready.
- Use ctx.yield_output to provide workflow outputs when the terminal result is ready.
- The terminal node does not forward messages further.
"""
@handler
async def reverse_text(self, text: str, ctx: WorkflowContext[Any]) -> None:
"""Reverse the input string and emit a completion event."""
async def reverse_text(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
"""Reverse the input string and yield the workflow output."""
result = text[::-1]
await ctx.add_event(WorkflowCompletedEvent(result))
await ctx.yield_output(result)
async def main():
async def main() -> None:
"""Build a two step sequential workflow and run it with streaming to observe events."""
# Step 1: Create executor instances.
upper_case_executor = UpperCaseExecutor(id="upper_case_executor")
@@ -74,15 +76,15 @@ async def main():
)
# Step 3: Stream events for a single input.
# The stream will include executor invoke and completion events, plus the final WorkflowCompletedEvent.
completion_event = None
# The stream will include executor invoke and completion events, plus workflow outputs.
outputs: list[str] = []
async for event in workflow.run_stream("hello world"):
print(f"Event: {event}")
if isinstance(event, WorkflowCompletedEvent):
completion_event = event
if isinstance(event, WorkflowOutputEvent):
outputs.append(cast(str, event.data))
if completion_event:
print(f"Workflow completed with result: {completion_event.data}")
if outputs:
print(f"Workflow outputs: {outputs}")
if __name__ == "__main__":
@@ -2,19 +2,20 @@
import asyncio
from agent_framework import WorkflowBuilder, WorkflowCompletedEvent, WorkflowContext, executor
from typing_extensions import Never
from agent_framework import WorkflowBuilder, WorkflowContext, WorkflowOutputEvent, executor
"""
Sample: Foundational sequential workflow with streaming using function-style executors.
Two lightweight steps run in order. The first converts text to uppercase.
The second reverses the text and completes the workflow. Events are printed as they arrive from run_stream.
The second reverses the text and yields the workflow output. Events are printed as they arrive from run_stream.
Purpose:
Show how to declare executors with the @executor decorator, connect them with WorkflowBuilder,
pass intermediate values using ctx.send_message, and signal completion with ctx.add_event by emitting a
WorkflowCompletedEvent. Demonstrate how streaming exposes ExecutorInvokedEvent and WorkflowCompletedEvent
for observability.
pass intermediate values using ctx.send_message, and yield final output using ctx.yield_output().
Demonstrate how streaming exposes ExecutorInvokedEvent and ExecutorCompletedEvent for observability.
Prerequisites:
- No external services required.
@@ -37,17 +38,17 @@ async def to_upper_case(text: str, ctx: WorkflowContext[str]) -> None:
@executor(id="reverse_text_executor")
async def reverse_text(text: str, ctx: WorkflowContext[str]) -> None:
"""Reverse the input and complete the workflow with the final result.
async def reverse_text(text: str, ctx: WorkflowContext[Never, str]) -> None:
"""Reverse the input and yield the workflow output.
Concepts:
- Terminal nodes publish a WorkflowCompletedEvent using ctx.add_event.
- No further messages are forwarded after completion.
- Terminal nodes yield output using ctx.yield_output().
- The workflow completes when it becomes idle (no more work to do).
"""
result = text[::-1]
# Emit the terminal event that carries the final output for this run.
await ctx.add_event(WorkflowCompletedEvent(result))
# Yield the final output for this workflow run.
await ctx.yield_output(result)
async def main():
@@ -57,17 +58,11 @@ async def main():
workflow = WorkflowBuilder().add_edge(to_upper_case, reverse_text).set_start_executor(to_upper_case).build()
# Step 3: Run the workflow and stream events in real time.
completion_event = None
async for event in workflow.run_stream("hello world"):
# You will see executor invoke and completion events, and then the final WorkflowCompletedEvent.
# You will see executor invoke and completion events as the workflow progresses.
print(f"Event: {event}")
if isinstance(event, WorkflowCompletedEvent):
# The WorkflowCompletedEvent contains the final result.
completion_event = event
# Print the final result after the streaming loop concludes.
if completion_event:
print(f"Workflow completed with result: {completion_event.data}")
if isinstance(event, WorkflowOutputEvent):
print(f"Workflow completed with result: {event.data}")
"""
Sample Output:
@@ -75,8 +70,8 @@ async def main():
Event: ExecutorInvokedEvent(executor_id=upper_case_executor)
Event: ExecutorCompletedEvent(executor_id=upper_case_executor)
Event: ExecutorInvokedEvent(executor_id=reverse_text_executor)
Event: WorkflowCompletedEvent(data=DLROW OLLEH)
Event: ExecutorCompletedEvent(executor_id=reverse_text_executor)
Event: WorkflowOutputEvent(data='DLROW OLLEH', source_executor_id=reverse_text_executor)
Workflow completed with result: DLROW OLLEH
"""
@@ -12,8 +12,8 @@ from agent_framework import (
ExecutorCompletedEvent,
Role,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowOutputEvent,
handler,
)
from agent_framework.azure import AzureChatClient
@@ -25,6 +25,7 @@ Sample: Simple Loop (with an Agent Judge)
What it does:
- Guesser performs a binary search; judge is an agent that returns ABOVE/BELOW/MATCHED.
- Demonstrates feedback loops in workflows with agent steps.
- The workflow completes when the correct number is guessed.
Prerequisites:
- Azure AI/ Azure OpenAI for `AzureChatClient` agent.
@@ -55,14 +56,14 @@ class GuessNumberExecutor(Executor):
self._upper = bound[1]
@handler
async def guess_number(self, feedback: NumberSignal, ctx: WorkflowContext[int]) -> None:
async def guess_number(self, feedback: NumberSignal, ctx: WorkflowContext[int, str]) -> None:
"""Execute the task by guessing a number."""
if feedback == NumberSignal.INIT:
self._guess = (self._lower + self._upper) // 2
await ctx.send_message(self._guess)
elif feedback == NumberSignal.MATCHED:
# The previous guess was correct.
await ctx.add_event(WorkflowCompletedEvent(f"Guessed the number: {self._guess}"))
await ctx.yield_output(f"Guessed the number: {self._guess}")
elif feedback == NumberSignal.ABOVE:
# The previous guess was too low.
# Update the lower bound to the previous guess.
@@ -150,6 +151,8 @@ async def main():
async for event in workflow.run_stream(NumberSignal.INIT):
if isinstance(event, ExecutorCompletedEvent) and event.executor_id == guess_number_executor.id:
iterations += 1
elif isinstance(event, WorkflowOutputEvent):
print(f"Final result: {event.data}")
print(f"Event: {event}")
# This is essentially a binary search, so the number of iterations should be logarithmic.
@@ -6,6 +6,8 @@ from dataclasses import dataclass
from typing import Any, Literal
from uuid import uuid4
from typing_extensions import Never
from agent_framework import ( # Core chat primitives used to form LLM requests
AgentExecutor, # Wraps an agent so it can run inside a workflow
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
@@ -15,7 +17,6 @@ from agent_framework import ( # Core chat primitives used to form LLM requests
Default, # Default branch when no cases match
Role,
WorkflowBuilder, # Fluent builder for assembling the graph
WorkflowCompletedEvent, # Terminal event for successful completion
WorkflowContext, # Per-run context and event bus
executor, # Decorator to turn a function into a workflow executor
)
@@ -36,6 +37,7 @@ Demonstrate deterministic one of N routing with switch-case edges. Show how to:
- Validate agent JSON with Pydantic models for robust parsing.
- Keep executor responsibilities narrow. Transform model output to a typed DetectionResult, then route based
on that type.
- Use ctx.yield_output() to provide workflow results - the workflow completes when idle with no pending work.
Prerequisites:
- Familiarity with WorkflowBuilder, executors, edges, and events.
@@ -124,30 +126,28 @@ async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowCon
@executor(id="finalize_and_send")
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
# Terminal step for the drafting branch. Emit a completion event with the reply.
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
# Terminal step for the drafting branch. Yield the email response as output.
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
await ctx.yield_output(f"Email sent: {parsed.response}")
@executor(id="handle_spam")
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[None]) -> None:
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, str]) -> None:
# Spam path terminal. Include the detector's rationale.
if detection.spam_decision == "Spam":
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {detection.reason}"))
await ctx.yield_output(f"Email marked as spam: {detection.reason}")
else:
raise RuntimeError("This executor should only handle Spam messages.")
@executor(id="handle_uncertain")
async def handle_uncertain(detection: DetectionResult, ctx: WorkflowContext[None]) -> None:
async def handle_uncertain(detection: DetectionResult, ctx: WorkflowContext[Never, str]) -> None:
# Uncertain path terminal. Surface the original content to aid human review.
if detection.spam_decision == "Uncertain":
email: Email | None = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}")
await ctx.add_event(
WorkflowCompletedEvent(
f"Email marked as uncertain: {detection.reason}. Email content: {getattr(email, 'email_content', '')}"
)
await ctx.yield_output(
f"Email marked as uncertain: {detection.reason}. Email content: {getattr(email, 'email_content', '')}"
)
else:
raise RuntimeError("This executor should only handle Uncertain messages.")
@@ -215,10 +215,12 @@ async def main():
"Let me know if you'd like more details."
)
# Run and print the terminal event for whichever branch completes.
async for event in workflow.run_stream(email):
if isinstance(event, WorkflowCompletedEvent):
print(f"{event}")
# Run and print the outputs from whichever branch completes.
events = await workflow.run(email)
outputs = events.get_outputs()
if outputs:
for output in outputs:
print(f"Workflow output: {output}")
if __name__ == "__main__":
@@ -15,8 +15,8 @@ from agent_framework import (
RequestResponse, # Correlates a human response with the original request
Role, # Enum of chat roles (user, assistant, system)
WorkflowBuilder, # Fluent builder for assembling the graph
WorkflowCompletedEvent, # Terminal event used to finish the workflow
WorkflowContext, # Per run context and event bus
WorkflowOutputEvent, # Event emitted when workflow yields output
WorkflowRunState, # Enum of workflow run states
WorkflowStatusEvent, # Event emitted on run state changes
handler, # Decorator to expose an Executor method as a step
@@ -30,8 +30,7 @@ Sample: Human in the loop guessing game
An agent guesses a number, then a human guides it with higher, lower, or
correct via RequestInfoExecutor. The loop continues until the human confirms
correct, at which point the workflow
completes.
correct, at which point the workflow completes when idle with no pending work.
Purpose:
Show how to integrate a human step in the middle of an LLM workflow using RequestInfoExecutor and correlated
@@ -132,7 +131,7 @@ class TurnManager(Executor):
async def on_human_feedback(
self,
feedback: RequestResponse[HumanFeedbackRequest, str],
ctx: WorkflowContext[AgentExecutorRequest | WorkflowCompletedEvent],
ctx: WorkflowContext[AgentExecutorRequest, str],
) -> None:
"""Continue the game or finish based on human feedback.
@@ -144,7 +143,7 @@ class TurnManager(Executor):
last_guess = getattr(feedback.original_request, "guess", None)
if reply == "correct":
await ctx.add_event(WorkflowCompletedEvent(f"Guessed correctly: {last_guess}"))
await ctx.yield_output(f"Guessed correctly: {last_guess}")
return
# Provide feedback to the agent to try again.
@@ -195,7 +194,8 @@ async def main() -> None:
# Human in the loop run: alternate between invoking the workflow and supplying collected responses.
pending_responses: dict[str, str] | None = None
completed: WorkflowCompletedEvent | None = None
completed = False
workflow_output: str | None = None
# User guidance printing:
# If you want to instruct users up front, print a short banner before the loop.
@@ -219,15 +219,16 @@ async def main() -> None:
events = [event async for event in stream]
pending_responses = None
# Collect human requests and the terminal completion if present.
# Collect human requests, workflow outputs, and check for completion.
requests: list[tuple[str, str]] = [] # (request_id, prompt)
for event in events:
if isinstance(event, WorkflowCompletedEvent):
completed = event
elif isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanFeedbackRequest):
if isinstance(event, RequestInfoEvent) and isinstance(event.data, HumanFeedbackRequest):
# RequestInfoEvent for our HumanFeedbackRequest.
requests.append((event.request_id, event.data.prompt))
# Other events are ignored for brevity.
elif isinstance(event, WorkflowOutputEvent):
# Capture workflow output as they're yielded
workflow_output = str(event.data)
completed = True # In this sample, we finish after one output.
# Detect run state transitions for a better developer experience.
pending_status = any(
@@ -258,9 +259,8 @@ async def main() -> None:
responses[req_id] = answer
pending_responses = responses
# Show final result.
print(completed)
# Show final result from workflow output captured during streaming.
print(f"Workflow output: {workflow_output}")
"""
Sample Output:
@@ -272,7 +272,7 @@ async def main() -> None:
Enter higher/lower/correct/exit: lower
HITL> The agent guessed: 9. Type one of: higher (your number is higher than this guess), lower (your number is lower than this guess), correct, or exit.
Enter higher/lower/correct/exit: correct
WorkflowCompletedEvent(data=Guessed correctly: 9)
Workflow output: Guessed correctly: 9
""" # noqa: E501
@@ -2,7 +2,6 @@
import asyncio
import os
from typing import Any
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, get_logger, handler
@@ -57,8 +56,8 @@ class StartExecutor(Executor):
class EndExecutor(Executor):
@handler # type: ignore[misc]
async def handle_final(self, message: str, ctx: WorkflowContext[Any]) -> None:
# Sink executor. The framework emits WorkflowCompletedEvent automatically after this handler returns.
async def handle_final(self, message: str, ctx: WorkflowContext) -> None:
# Sink executor. The workflow completes when idle with no pending work.
print(f"Final result: {message}")
@@ -3,7 +3,7 @@
import asyncio
from typing import Any
from agent_framework import ChatMessage, ConcurrentBuilder, WorkflowCompletedEvent
from agent_framework import ChatMessage, ConcurrentBuilder
from agent_framework.azure import AzureChatClient
from azure.identity import AzureCliCredential
@@ -12,13 +12,13 @@ Sample: Concurrent fan-out/fan-in (agent-only API) with default aggregator
Build a high-level concurrent workflow using ConcurrentBuilder and three domain agents.
The default dispatcher fans out the same user prompt to all agents in parallel.
The default aggregator fans in their results and emits a WorkflowCompletedEvent whose
data is a list[ChatMessage] representing the concatenated conversations from all agents.
The default aggregator fans in their results and yields output containing
a list[ChatMessage] representing the concatenated conversations from all agents.
Demonstrates:
- Minimal wiring with ConcurrentBuilder().participants([...]).build()
- Fan-out to multiple agents, fan-in aggregation of final ChatMessages
- Streaming of AgentRunEvent for simple progress visibility
- Workflow completion when idle with no pending work
Prerequisites:
- Azure OpenAI access configured for AzureChatClient (use az login + env vars)
@@ -58,18 +58,17 @@ async def main() -> None:
# Participants are either Agents (type of AgentProtocol) or Executors
workflow = ConcurrentBuilder().participants([researcher, marketer, legal]).build()
# 3) Run with a single prompt, stream progress, and pretty-print the final combined messages
completion: WorkflowCompletedEvent | None = None
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
if isinstance(event, WorkflowCompletedEvent):
completion = event
# 3) Run with a single prompt and pretty-print the final combined messages
events = await workflow.run("We are launching a new budget-friendly electric bike for urban commuters.")
outputs = events.get_outputs()
if completion:
if outputs:
print("===== Final Aggregated Conversation (messages) =====")
messages: list[ChatMessage] | Any = completion.data
for i, msg in enumerate(messages, start=1):
name = msg.author_name if msg.author_name else "user"
print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}")
for output in outputs:
messages: list[ChatMessage] | Any = output
for i, msg in enumerate(messages, start=1):
name = msg.author_name if msg.author_name else "user"
print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}")
"""
Sample Output:
@@ -10,7 +10,6 @@ from agent_framework import (
ChatMessage,
ConcurrentBuilder,
Executor,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
@@ -30,6 +29,7 @@ Demonstrates:
- A @handler that converts AgentExecutorRequest -> AgentExecutorResponse
- ConcurrentBuilder().participants([...]) to build fan-out/fan-in
- Default aggregator returning list[ChatMessage] (one user + one assistant per agent)
- Workflow completion when all participants become idle
Prerequisites:
- Azure OpenAI configured for AzureChatClient (az login + required env vars)
@@ -105,14 +105,12 @@ async def main() -> None:
workflow = ConcurrentBuilder().participants([researcher, marketer, legal]).build()
completion: WorkflowCompletedEvent | None = None
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
if isinstance(event, WorkflowCompletedEvent):
completion = event
events = await workflow.run("We are launching a new budget-friendly electric bike for urban commuters.")
outputs = events.get_outputs()
if completion:
if outputs:
print("===== Final Aggregated Conversation (messages) =====")
messages: list[ChatMessage] | Any = completion.data
messages: list[ChatMessage] | Any = outputs[0] # Get the first (and typically only) output
for i, msg in enumerate(messages, start=1):
name = msg.author_name if msg.author_name else "user"
print(f"{'-' * 60}\n\n{i:02d} [{name}]:\n{msg.text}")
@@ -3,7 +3,7 @@
import asyncio
from typing import Any
from agent_framework import ChatMessage, ConcurrentBuilder, Role, WorkflowCompletedEvent
from agent_framework import ChatMessage, ConcurrentBuilder, Role
from agent_framework.azure import AzureChatClient
from azure.identity import AzureCliCredential
@@ -14,12 +14,13 @@ Build a concurrent workflow with ConcurrentBuilder that fans out one prompt to
multiple domain agents and fans in their responses. Override the default
aggregator with a custom async callback that uses AzureChatClient.get_response()
to synthesize a concise, consolidated summary from the experts' outputs.
The workflow completes when all participants become idle.
Demonstrates:
- ConcurrentBuilder().participants([...]).with_custom_aggregator(callback)
- Fan-out to agents and fan-in at an aggregator
- Aggregation implemented via an LLM call (chat_client.get_response)
- WorkflowCompletedEvent carrying the synthesized summary string
- Workflow output yielded with the synthesized summary string
Prerequisites:
- Azure OpenAI configured for AzureChatClient (az login + required env vars)
@@ -82,20 +83,18 @@ async def main() -> None:
# Each participant becomes a parallel branch (fan-out) from an internal dispatcher.
# - with_aggregator(...) overrides the default aggregator:
# • Default aggregator -> returns list[ChatMessage] (one user + one assistant per agent)
# • Custom callback -> return value becomes WorkflowCompletedEvent.data (string here)
# • Custom callback -> return value becomes workflow output (string here)
# The callback can be sync or async; it receives list[AgentExecutorResponse].
workflow = (
ConcurrentBuilder().participants([researcher, marketer, legal]).with_aggregator(summarize_results).build()
)
completion: WorkflowCompletedEvent | None = None
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
if isinstance(event, WorkflowCompletedEvent):
completion = event
events = await workflow.run("We are launching a new budget-friendly electric bike for urban commuters.")
outputs = events.get_outputs()
if completion:
if outputs:
print("===== Final Consolidated Output =====")
print(completion.data)
print(outputs[0]) # Get the first (and typically only) output
"""
Sample Output:
@@ -13,7 +13,7 @@ from agent_framework import (
MagenticCallbackMode,
MagenticFinalResultEvent,
MagenticOrchestratorMessageEvent,
WorkflowCompletedEvent,
WorkflowOutputEvent,
)
from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient
@@ -38,7 +38,7 @@ The workflow is configured with:
When run, the script builds the workflow, submits a task about estimating the
energy efficiency and CO2 emissions of several ML models, streams intermediate
events, and prints the final answer.
events, and prints the final answer. The workflow completes when idle.
Prerequisites:
- OpenAI credentials configured for `OpenAIChatClient` and `OpenAIResponsesClient`.
@@ -132,17 +132,14 @@ async def main() -> None:
print("\nStarting workflow execution...")
try:
completion_event = None
output: str | None = None
async for event in workflow.run_stream(task):
print(f"Event: {event}")
print(event)
if isinstance(event, WorkflowOutputEvent):
output = str(event.data)
if isinstance(event, WorkflowCompletedEvent):
completion_event = event
if completion_event is not None:
data = getattr(completion_event, "data", None)
preview = getattr(data, "text", None) or (str(data) if data is not None else "")
print(f"Workflow completed with result:\n\n{preview}")
if output is not None:
print(f"Workflow completed with result:\n\n{output}")
except Exception as e:
print(f"Workflow execution failed: {e}")
@@ -18,7 +18,7 @@ from agent_framework import (
MagenticPlanReviewReply,
MagenticPlanReviewRequest,
RequestInfoEvent,
WorkflowCompletedEvent,
WorkflowOutputEvent,
)
from agent_framework.openai import OpenAIChatClient, OpenAIResponsesClient
@@ -41,6 +41,7 @@ Key behaviors demonstrated:
replies with PlanReviewReply (here we auto-approve, but you can edit/collect input)
- Callbacks: on_agent_stream (incremental chunks), on_agent_response (final messages),
on_result (final answer), and on_exception
- Workflow completion when idle
Prerequisites:
- OpenAI credentials configured for `OpenAIChatClient` and `OpenAIResponsesClient`.
@@ -73,6 +74,9 @@ async def main() -> None:
print(f"Exception occurred: {exception}")
logger.exception("Workflow exception", exc_info=exception)
last_stream_agent_id: str | None = None
stream_line_open: bool = False
# Unified callback
async def on_event(event: MagenticCallbackEvent) -> None:
nonlocal last_stream_agent_id, stream_line_open
@@ -105,9 +109,6 @@ async def main() -> None:
print("\nBuilding Magentic Workflow...")
last_stream_agent_id: str | None = None
stream_line_open: bool = False
workflow = (
MagenticBuilder()
.participants(researcher=researcher_agent, coder=coder_agent)
@@ -136,51 +137,61 @@ async def main() -> None:
print("\nStarting workflow execution...")
try:
completion_event: WorkflowCompletedEvent | None = None
pending_request: RequestInfoEvent | None = None
pending_responses: dict[str, MagenticPlanReviewReply] | None = None
completed = False
workflow_output: str | None = None
while True:
# Phase 1: run until either completion or a HIL request
if pending_request is None:
async for event in workflow.run_stream(task):
print(f"Event: {event}")
while not completed:
# Use streaming for both initial run and response sending
if pending_responses is not None:
stream = workflow.send_responses_streaming(pending_responses)
else:
stream = workflow.run_stream(task)
if isinstance(event, WorkflowCompletedEvent):
completion_event = event
# Collect events from the stream
events = [event async for event in stream]
pending_responses = None
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
pending_request = event
review_req = cast(MagenticPlanReviewRequest, event.data)
if review_req.plan_text:
print(f"\n=== PLAN REVIEW REQUEST ===\n{review_req.plan_text}\n")
# Process events to find request info events, outputs, and completion status
for event in events:
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
pending_request = event
review_req = cast(MagenticPlanReviewRequest, event.data)
if review_req.plan_text:
print(f"\n=== PLAN REVIEW REQUEST ===\n{review_req.plan_text}\n")
elif isinstance(event, WorkflowOutputEvent):
# Capture workflow output during streaming
workflow_output = str(event.data)
completed = True
# Break if completed
if completion_event is not None:
data = getattr(completion_event, "data", None)
preview = getattr(data, "text", None) or (str(data) if data is not None else "")
print(f"Workflow completed with result:\n\n{preview}")
# Phase 2: respond to the pending plan review (HIL) request
# Handle pending plan review request
if pending_request is not None:
# For demo purposes we approve as-is. Replace this with UI input
# to collect a human decision/comments/edited plan.
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
# Get human input for plan review decision
print("Plan review options:")
print("1. approve - Approve the plan as-is")
print("2. revise - Request revision of the plan")
print("3. exit - Exit the workflow")
async for event in workflow.send_responses_streaming({pending_request.request_id: reply}):
print(f"Event: {event}")
while True:
choice = input("Enter your choice (approve/revise/exit): ").strip().lower() # noqa: ASYNC250
if choice in ["approve", "1"]:
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)
break
if choice in ["revise", "2"]:
reply = MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.REVISE)
break
if choice in ["exit", "3"]:
print("Exiting workflow...")
return
print("Invalid choice. Please enter 'approve', 'revise', or 'exit'.")
if isinstance(event, WorkflowCompletedEvent):
completion_event = event
pending_responses = {pending_request.request_id: reply}
pending_request = None
if isinstance(event, RequestInfoEvent) and event.request_type is MagenticPlanReviewRequest:
# Another review cycle requested; keep pending
pending_request = event
review_req = cast(MagenticPlanReviewRequest, event.data)
if review_req.plan_text:
print(f"\n=== PLAN REVIEW REQUEST ===\n{review_req.plan_text}\n")
else:
# Clear pending if no immediate new request
pending_request = None
# Show final result from captured workflow output
if workflow_output:
print(f"Workflow completed with result:\n\n{workflow_output}")
except Exception as e:
print(f"Workflow execution failed: {e}")
@@ -1,9 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any
from typing import cast
from agent_framework import ChatMessage, Role, SequentialBuilder, WorkflowCompletedEvent
from agent_framework import ChatMessage, Role, SequentialBuilder, WorkflowOutputEvent
from agent_framework.azure import AzureChatClient
from azure.identity import AzureCliCredential
@@ -12,8 +12,8 @@ Sample: Sequential workflow (agent-focused API) with shared conversation context
Build a high-level sequential workflow using SequentialBuilder and two domain agents.
The shared conversation (list[ChatMessage]) flows through each participant. Each agent
appends its assistant message to the context. The final WorkflowCompletedEvent includes
the final conversation list.
appends its assistant message to the context. The workflow outputs the final conversation
list when complete.
Note on internal adapters:
- Sequential orchestration includes small adapter nodes for input normalization
@@ -44,16 +44,15 @@ async def main() -> None:
# 2) Build sequential workflow: writer -> reviewer
workflow = SequentialBuilder().participants([writer, reviewer]).build()
# 3) Run and print final conversation
completion: WorkflowCompletedEvent | None = None
# 3) Run and collect outputs
outputs: list[list[ChatMessage]] = []
async for event in workflow.run_stream("Write a tagline for a budget-friendly eBike."):
if isinstance(event, WorkflowCompletedEvent):
completion = event
if isinstance(event, WorkflowOutputEvent):
outputs.append(cast(list[ChatMessage], event.data))
if completion:
if outputs:
print("===== Final Conversation =====")
messages: list[ChatMessage] | Any = completion.data
for i, msg in enumerate(messages, start=1):
for i, msg in enumerate(outputs[-1], start=1):
name = msg.author_name or ("assistant" if msg.role == Role.ASSISTANT else "user")
print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}")
@@ -3,12 +3,13 @@
import asyncio
from typing import Any
from typing_extensions import Never
from agent_framework import (
ChatMessage,
Executor,
Role,
SequentialBuilder,
WorkflowCompletedEvent,
WorkflowContext,
handler,
)
@@ -20,8 +21,8 @@ Sample: Sequential workflow mixing agents and a custom summarizer executor
This demonstrates how SequentialBuilder chains participants with a shared
conversation context (list[ChatMessage]). An agent produces content; a custom
executor appends a compact summary to the conversation. The final WorkflowCompletedEvent
contains the complete conversation.
executor appends a compact summary to the conversation. The workflow completes
when idle, and the final output contains the complete conversation.
Custom executor contract:
- Provide at least one @handler accepting list[ChatMessage] and a WorkflowContext[list[ChatMessage]]
@@ -42,11 +43,12 @@ class Summarizer(Executor):
"""Simple summarizer: consumes full conversation and appends an assistant summary."""
@handler
async def summarize(self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
async def summarize(self, conversation: list[ChatMessage], ctx: WorkflowContext[Never, list[ChatMessage]]) -> None:
users = sum(1 for m in conversation if m.role == Role.USER)
assistants = sum(1 for m in conversation if m.role == Role.ASSISTANT)
summary = ChatMessage(role=Role.ASSISTANT, text=f"Summary -> users:{users} assistants:{assistants}")
await ctx.send_message(list(conversation) + [summary])
final_conversation = list(conversation) + [summary]
await ctx.yield_output(final_conversation)
async def main() -> None:
@@ -62,14 +64,12 @@ async def main() -> None:
workflow = SequentialBuilder().participants([content, summarizer]).build()
# 3) Run and print final conversation
completion: WorkflowCompletedEvent | None = None
async for event in workflow.run_stream("Explain the benefits of budget eBikes for commuters."):
if isinstance(event, WorkflowCompletedEvent):
completion = event
events = await workflow.run("Explain the benefits of budget eBikes for commuters.")
outputs = events.get_outputs()
if completion:
if outputs:
print("===== Final Conversation =====")
messages: list[ChatMessage] | Any = completion.data
messages: list[ChatMessage] | Any = outputs[0]
for i, msg in enumerate(messages, start=1):
name = msg.author_name or ("assistant" if msg.role == Role.ASSISTANT else "user")
print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}")
@@ -2,7 +2,8 @@
import asyncio
from dataclasses import dataclass
from typing import Any
from typing_extensions import Never
from agent_framework import ( # Core chat primitives to build LLM requests
AgentExecutor, # Wraps an LLM agent for use inside a workflow
@@ -13,8 +14,8 @@ from agent_framework import ( # Core chat primitives to build LLM requests
Executor, # Base class for custom Python executors
Role, # Enum of chat roles (user, assistant, system)
WorkflowBuilder, # Fluent builder for wiring the workflow graph
WorkflowCompletedEvent, # Terminal event carrying the final result
WorkflowContext, # Per run context and event bus
WorkflowOutputEvent, # Event emitted when workflow yields output
handler, # Decorator to mark an Executor method as invokable
)
from agent_framework.azure import AzureChatClient # Client wrapper for Azure OpenAI chat models
@@ -75,7 +76,7 @@ class AggregateInsights(Executor):
self._expert_ids = expert_ids
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
# Map responses to text by executor id for a simple, predictable demo.
by_id: dict[str, str] = {}
for r in results:
@@ -101,7 +102,7 @@ class AggregateInsights(Executor):
f"Legal/Compliance Notes:\n{aggregated.legal}\n"
)
await ctx.add_event(WorkflowCompletedEvent(data=consolidated))
await ctx.yield_output(consolidated)
async def main() -> None:
@@ -151,17 +152,13 @@ async def main() -> None:
)
# 3) Run with a single prompt and print progress plus the final consolidated output
completion: WorkflowCompletedEvent | None = None
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
if isinstance(event, AgentRunEvent):
# Show which agent ran and what step completed for lightweight observability.
print(event)
if isinstance(event, WorkflowCompletedEvent):
completion = event
if completion:
print("===== Final Aggregated Output =====")
print(completion.data)
elif isinstance(event, WorkflowOutputEvent):
print("===== Final Aggregated Output =====")
print(event.data)
if __name__ == "__main__":
@@ -5,14 +5,15 @@ import asyncio
import os
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
import aiofiles
from typing_extensions import Never
from agent_framework import (
Executor, # Base class for custom workflow steps
WorkflowBuilder, # Fluent graph builder for executors and edges
WorkflowCompletedEvent, # Terminal event that carries final output
WorkflowBuilder, # Fluent builder for executors and edges
WorkflowContext, # Per run context with shared state and messaging
WorkflowOutputEvent, # Event emitted when workflow yields output
WorkflowViz, # Utility to visualize a workflow graph
handler, # Decorator to expose an Executor method as a step
)
@@ -246,12 +247,12 @@ class Reduce(Executor):
class CompletionExecutor(Executor):
"""Joins all reducer outputs and emits the final completion event."""
"""Joins all reducer outputs and yields the final output."""
@handler
async def complete(self, data: list[ReduceCompleted], ctx: WorkflowContext[Any]) -> None:
"""Collect reducer output file paths and publish a terminal event."""
await ctx.add_event(WorkflowCompletedEvent(data=[result.file_path for result in data]))
async def complete(self, data: list[ReduceCompleted], ctx: WorkflowContext[Never, list[str]]) -> None:
"""Collect reducer output file paths and yield final output."""
await ctx.yield_output([result.file_path for result in data])
async def main():
@@ -303,14 +304,10 @@ async def main():
raw_text = await f.read()
# Step 4: Run the workflow with the raw text as input.
completion_event = None
async for event in workflow.run_stream(raw_text):
print(f"Event: {event}")
if isinstance(event, WorkflowCompletedEvent):
completion_event = event
if completion_event:
print(f"Completion Event: {completion_event}")
if isinstance(event, WorkflowOutputEvent):
print(f"Final Output: {event.data}")
if __name__ == "__main__":
@@ -6,13 +6,14 @@ from dataclasses import dataclass
from typing import Any
from uuid import uuid4
from typing_extensions import Never
from agent_framework import (
AgentExecutorRequest,
AgentExecutorResponse,
ChatMessage,
Role,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
executor,
)
@@ -31,7 +32,7 @@ Show how to:
- Use shared state to decouple large payloads from messages and pass around lightweight references.
- Enforce structured agent outputs with Pydantic models via response_format for robust parsing.
- Route using conditional edges based on a typed intermediate DetectionResult.
- Compose agent backed executors with function style executors and print a terminal WorkflowCompletedEvent.
- Compose agent backed executors with function style executors and yield the final output when the workflow completes.
Prerequisites:
- Azure OpenAI configured for AzureChatClient with required environment variables.
@@ -139,17 +140,17 @@ async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowCon
@executor(id="finalize_and_send")
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[None]) -> None:
"""Validate the drafted reply and complete the workflow with a terminal event."""
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
"""Validate the drafted reply and yield the final output."""
parsed = EmailResponse.model_validate_json(response.agent_run_response.text)
await ctx.add_event(WorkflowCompletedEvent(f"Email sent: {parsed.response}"))
await ctx.yield_output(f"Email sent: {parsed.response}")
@executor(id="handle_spam")
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[None]) -> None:
"""Emit a completion event describing why the email was marked as spam."""
async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, str]) -> None:
"""Yield output describing why the email was marked as spam."""
if detection.is_spam:
await ctx.add_event(WorkflowCompletedEvent(f"Email marked as spam: {detection.reason}"))
await ctx.yield_output(f"Email marked as spam: {detection.reason}")
else:
raise RuntimeError("This executor should only handle spam messages.")
@@ -206,18 +207,20 @@ async def main() -> None:
print("Unable to find resource file, using default text.")
email = "You are a WINNER! Click here for a free lottery offer!!!"
# Run and print the terminal result. Streaming surfaces intermediate execution events as well.
async for event in workflow.run_stream(email):
if isinstance(event, WorkflowCompletedEvent):
print(event)
# Run and print the final result. Streaming surfaces intermediate execution events as well.
events = await workflow.run(email)
outputs = events.get_outputs()
if outputs:
print(f"Final result: {outputs[0]}")
"""
Sample Output:
WorkflowCompletedEvent(data=Email marked as spam: This email exhibits several common spam and scam characteristics:
Final result: Email marked as spam: This email exhibits several common spam and scam characteristics:
unrealistic claims of large cash winnings, urgent time pressure, requests for sensitive personal and financial
information, and a demand for a processing fee. The sender impersonates a generic lottery commission, and the
message contains a suspicious link. All these are typical of phishing and lottery scam emails.)
message contains a suspicious link. All these are typical of phishing and lottery scam emails.
"""
@@ -2,7 +2,8 @@
import asyncio
from dataclasses import dataclass
from typing import Any
from typing_extensions import Never
from agent_framework import (
AgentExecutor,
@@ -13,8 +14,8 @@ from agent_framework import (
Executor,
Role,
WorkflowBuilder,
WorkflowCompletedEvent,
WorkflowContext,
WorkflowOutputEvent,
WorkflowViz,
handler,
)
@@ -71,7 +72,7 @@ class AggregateInsights(Executor):
self._expert_ids = expert_ids
@handler
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Any]) -> None:
async def aggregate(self, results: list[AgentExecutorResponse], ctx: WorkflowContext[Never, str]) -> None:
# Map responses to text by executor id for a simple, predictable demo.
by_id: dict[str, str] = {}
for r in results:
@@ -97,7 +98,7 @@ class AggregateInsights(Executor):
f"Legal/Compliance Notes:\n{aggregated.legal}\n"
)
await ctx.add_event(WorkflowCompletedEvent(data=consolidated))
await ctx.yield_output(consolidated)
async def main() -> None:
@@ -165,17 +166,13 @@ async def main() -> None:
print("Tip: Install 'viz' extra to export workflow visualization: pip install agent-framework[viz]")
# 3) Run with a single prompt
completion: WorkflowCompletedEvent | None = None
async for event in workflow.run_stream("We are launching a new budget-friendly electric bike for urban commuters."):
if isinstance(event, AgentRunEvent):
# Show which agent ran and what step completed.
print(event)
if isinstance(event, WorkflowCompletedEvent):
completion = event
if completion:
print("===== Final Aggregated Output =====")
print(completion.data)
elif isinstance(event, WorkflowOutputEvent):
print("===== Final Aggregated Output =====")
print(event.data)
if __name__ == "__main__":