mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
0f913bcdeb
commit
2133043f11
@@ -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()
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+31
-27
@@ -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:")
|
||||
|
||||
+18
-16
@@ -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)...")
|
||||
|
||||
|
||||
+10
-9
@@ -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
|
||||
|
||||
|
||||
|
||||
+19
-16
@@ -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
-15
@@ -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:
|
||||
|
||||
+5
-7
@@ -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}")
|
||||
|
||||
+8
-9
@@ -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}")
|
||||
|
||||
+51
-40
@@ -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}")
|
||||
|
||||
|
||||
+11
-11
@@ -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__":
|
||||
|
||||
+10
-13
@@ -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__":
|
||||
|
||||
+17
-14
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+8
-11
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user