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
@@ -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__":