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
@@ -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}")