[BREAKING] Python: Remove workflow register factory methods. Update tests and samples (#3781)

* Remove workflow register factory methods. Update tests and samples

* Address Copilot feedback
This commit is contained in:
Evan Mattson
2026-02-11 07:16:17 +09:00
committed by GitHub
Unverified
parent f407f726a7
commit a4c9e43afb
46 changed files with 650 additions and 3660 deletions
@@ -1,168 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any
from agent_framework import (
ChatAgent,
ChatMessage,
Executor,
Workflow,
WorkflowContext,
handler,
)
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.orchestrations import ConcurrentBuilder
from azure.identity import AzureCliCredential
from typing_extensions import Never
"""
Sample: Concurrent Orchestration with participant factories and Custom Aggregator
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 Executor class that uses
AzureOpenAIChatClient.get_response() to synthesize a concise, consolidated summary
from the experts' outputs.
All participants and the aggregator are created via factory functions that return
their respective ChatAgent or Executor instances.
Using participant factories allows you to set up proper state isolation between workflow
instances created by the same builder. This is particularly useful when you need to handle
requests or tasks in parallel with stateful participants.
Demonstrates:
- ConcurrentBuilder(participant_factories=[...]).with_aggregator(callback)
- Fan-out to agents and fan-in at an aggregator
- Aggregation implemented via an LLM call (chat_client.get_response)
- Workflow output yielded with the synthesized summary string
Prerequisites:
- Azure OpenAI configured for AzureOpenAIChatClient (az login + required env vars)
"""
def create_researcher() -> ChatAgent:
"""Factory function to create a researcher agent instance."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions=(
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
" opportunities, and risks."
),
name="researcher",
)
def create_marketer() -> ChatAgent:
"""Factory function to create a marketer agent instance."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions=(
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
" aligned to the prompt."
),
name="marketer",
)
def create_legal() -> ChatAgent:
"""Factory function to create a legal/compliance agent instance."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions=(
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
" based on the prompt."
),
name="legal",
)
class SummarizationExecutor(Executor):
"""Custom aggregator executor that synthesizes expert outputs into a concise summary."""
def __init__(self) -> None:
super().__init__(id="summarization_executor")
self.chat_client = AzureOpenAIChatClient(credential=AzureCliCredential())
@handler
async def summarize_results(self, results: list[Any], ctx: WorkflowContext[Never, str]) -> None:
expert_sections: list[str] = []
for r in results:
try:
messages = getattr(r.agent_response, "messages", [])
final_text = messages[-1].text if messages and hasattr(messages[-1], "text") else "(no content)"
expert_sections.append(f"{getattr(r, 'executor_id', 'expert')}:\n{final_text}")
except Exception as e:
expert_sections.append(f"{getattr(r, 'executor_id', 'expert')}: (error: {type(e).__name__}: {e})")
# Ask the model to synthesize a concise summary of the experts' outputs
system_msg = ChatMessage(
"system",
text=(
"You are a helpful assistant that consolidates multiple domain expert outputs "
"into one cohesive, concise summary with clear takeaways. Keep it under 200 words."
),
)
user_msg = ChatMessage("user", text="\n\n".join(expert_sections))
response = await self.chat_client.get_response([system_msg, user_msg])
await ctx.yield_output(response.messages[-1].text if response.messages else "")
async def run_workflow(workflow: Workflow, query: str) -> None:
events = await workflow.run(query)
outputs = events.get_outputs()
if outputs:
print(outputs[0]) # Get the first (and typically only) output
else:
raise RuntimeError("No outputs received from the workflow.")
async def main() -> None:
# Create a concurrent builder with participant factories and a custom aggregator
# - register_participants([...]) accepts factory functions that return
# SupportsAgentRun (agents) or Executor instances.
# - register_aggregator(...) takes a factory function that returns an Executor instance.
concurrent_builder = (
ConcurrentBuilder(participant_factories=[create_researcher, create_marketer, create_legal])
.register_aggregator(SummarizationExecutor)
)
# Build workflow_a
workflow_a = concurrent_builder.build()
# Run workflow_a
# Context is maintained across runs
print("=== First Run on workflow_a ===")
await run_workflow(workflow_a, "We are launching a new budget-friendly electric bike for urban commuters.")
print("\n=== Second Run on workflow_a ===")
await run_workflow(workflow_a, "Refine your response to focus on the California market.")
# Build workflow_b
# This will create new instances of all participants and the aggregator
# The agents will also get new threads
workflow_b = concurrent_builder.build()
# Run workflow_b
# Context is not maintained across instances
# Should not expect mentions of electric bikes in the results
print("\n=== First Run on workflow_b ===")
await run_workflow(workflow_b, "Refine your response to focus on the California market.")
"""
Sample Output:
=== First Run on workflow_a ===
The budget-friendly electric bike market is poised for significant growth, driven by urbanization, ...
=== Second Run on workflow_a ===
Launching a budget-friendly electric bike in California presents significant opportunities, driven ...
=== First Run on workflow_b ===
To successfully penetrate the California market, consider these tailored strategies focused on ...
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -1,271 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from typing import Annotated, cast
from agent_framework import (
AgentResponse,
ChatAgent,
ChatMessage,
Workflow,
WorkflowEvent,
WorkflowRunState,
tool,
)
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
from azure.identity import AzureCliCredential
logging.basicConfig(level=logging.ERROR)
"""Sample: Handoff workflow with participant factories for state isolation.
This sample demonstrates how to use participant factories in HandoffBuilder to create
agents dynamically.
Using participant factories allows you to set up proper state isolation between workflow
instances created by the same builder. This is particularly useful when you need to handle
requests or tasks in parallel with stateful participants.
Routing Pattern:
User -> Triage Agent -> Specialist (Refund/Order Status/Return) -> User
Prerequisites:
- `az login` (Azure CLI authentication)
- Environment variables for AzureOpenAIChatClient (AZURE_OPENAI_ENDPOINT, etc.)
Key Concepts:
- Participant factories: create agents via factory functions for isolation
- State isolation: each workflow instance gets its own agent instances
"""
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# See:
# samples/getting_started/tools/function_tool_with_approval.py
# samples/getting_started/tools/function_tool_with_approval_and_threads.py.
@tool(approval_mode="never_require")
def process_refund(order_number: Annotated[str, "Order number to process refund for"]) -> str:
"""Simulated function to process a refund for a given order number."""
return f"Refund processed successfully for order {order_number}."
@tool(approval_mode="never_require")
def check_order_status(order_number: Annotated[str, "Order number to check status for"]) -> str:
"""Simulated function to check the status of a given order number."""
return f"Order {order_number} is currently being processed and will ship in 2 business days."
@tool(approval_mode="never_require")
def process_return(order_number: Annotated[str, "Order number to process return for"]) -> str:
"""Simulated function to process a return for a given order number."""
return f"Return initiated successfully for order {order_number}. You will receive return instructions via email."
def create_triage_agent() -> ChatAgent:
"""Factory function to create a triage agent instance."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions=(
"You are frontline support triage. Route customer issues to the appropriate specialist agents "
"based on the problem described."
),
name="triage_agent",
)
def create_refund_agent() -> ChatAgent:
"""Factory function to create a refund agent instance."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions="You process refund requests.",
name="refund_agent",
# In a real application, an agent can have multiple tools; here we keep it simple
tools=[process_refund],
)
def create_order_status_agent() -> ChatAgent:
"""Factory function to create an order status agent instance."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions="You handle order and shipping inquiries.",
name="order_agent",
# In a real application, an agent can have multiple tools; here we keep it simple
tools=[check_order_status],
)
def create_return_agent() -> ChatAgent:
"""Factory function to create a return agent instance."""
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions="You manage product return requests.",
name="return_agent",
# In a real application, an agent can have multiple tools; here we keep it simple
tools=[process_return],
)
def _handle_events(events: list[WorkflowEvent]) -> list[WorkflowEvent[HandoffAgentUserRequest]]:
"""Process workflow events and extract any pending user input requests.
This function inspects each event type and:
- Prints workflow status changes (IDLE, IDLE_WITH_PENDING_REQUESTS, etc.)
- Displays final conversation snapshots when workflow completes
- Prints user input request prompts
- Collects all request_info events for response handling
Args:
events: List of WorkflowEvent to process
Returns:
List of WorkflowEvent[HandoffAgentUserRequest] representing pending user input requests
"""
requests: list[WorkflowEvent[HandoffAgentUserRequest]] = []
for event in events:
if event.type == "handoff_sent":
# handoff_sent event: Indicates a handoff has been initiated
print(f"\n[Handoff from {event.data.source} to {event.data.target} initiated.]")
elif event.type == "status" and event.state in {
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
}:
# Status event: Indicates workflow state changes
print(f"\n[Workflow Status] {event.state.name}")
elif event.type == "output":
# Output event: Contains contents generated by the workflow
data = event.data
if isinstance(data, AgentResponse):
for message in data.messages:
if not message.text:
# Skip messages without text (e.g., tool calls)
continue
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text}")
elif event.type == "output":
# The output of the handoff workflow is a collection of chat messages from all participants
conversation = cast(list[ChatMessage], event.data)
if isinstance(conversation, list):
print("\n=== Final Conversation Snapshot ===")
for message in conversation:
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text or [content.type for content in message.contents]}")
print("===================================")
elif event.type == "request_info" and isinstance(event.data, HandoffAgentUserRequest):
# Request info event: Workflow is requesting user input
_print_handoff_agent_user_request(event.data.agent_response)
requests.append(cast(WorkflowEvent[HandoffAgentUserRequest], event))
return requests
def _print_handoff_agent_user_request(response: AgentResponse) -> None:
"""Display the agent's response messages when requesting user input.
This will happen when an agent generates a response that doesn't trigger
a handoff, i.e., the agent is asking the user for more information.
Args:
response: The AgentResponse from the agent requesting user input
"""
if not response.messages:
raise RuntimeError("Cannot print agent responses: response has no messages.")
print("\n[Agent is requesting your input...]")
# Print agent responses
for message in response.messages:
if not message.text:
# Skip messages without text (e.g., tool calls)
continue
speaker = message.author_name or message.role
print(f"- {speaker}: {message.text}")
async def _run_workflow(workflow: Workflow, user_inputs: list[str]) -> None:
"""Run the workflow with the given user input and display events."""
print(f"- User: {user_inputs[0]}")
workflow_result = await workflow.run(user_inputs[0])
pending_requests = _handle_events(workflow_result)
# Process the request/response cycle
# The workflow will continue requesting input until:
# 1. The termination condition is met (4 user messages in this case), OR
# 2. We run out of scripted responses
while pending_requests:
if user_inputs[1:]:
# Get the next scripted response
user_response = user_inputs.pop(1)
print(f"\n- User: {user_response}")
# Send response(s) to all pending requests
# In this demo, there's typically one request per cycle, but the API supports multiple
responses = {
req.request_id: HandoffAgentUserRequest.create_response(user_response) for req in pending_requests
}
else:
# No more scripted responses; terminate the workflow
responses = {req.request_id: HandoffAgentUserRequest.terminate() for req in pending_requests}
# Send responses and get new events
# We use run(responses=...) to get events, allowing us to
# display agent responses and handle new requests as they arrive
workflow_result = await workflow.run(responses=responses)
pending_requests = _handle_events(workflow_result)
async def main() -> None:
"""Run the autonomous handoff workflow with participant factories."""
# Build the handoff workflow using participant factories
# termination_condition: Custom termination that checks if the triage agent has provided a closing message.
# This looks for the last message being from triage_agent and containing "welcome",
# which indicates the conversation has concluded naturally.
workflow_builder = (
HandoffBuilder(
name="Autonomous Handoff with Participant Factories",
participant_factories={
"triage": create_triage_agent,
"refund": create_refund_agent,
"order_status": create_order_status_agent,
"return": create_return_agent,
},
termination_condition=lambda conversation: (
len(conversation) > 0
and conversation[-1].author_name == "triage_agent"
and "welcome" in conversation[-1].text.lower()
),
)
.with_start_agent("triage")
)
# Scripted user responses for reproducible demo
# In a console application, replace this with:
# user_input = input("Your response: ")
# or integrate with a UI/chat interface
user_inputs = [
"Hello, I need assistance with my recent purchase.",
"My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.",
"Is my return being processed?",
"Thanks for resolving this.",
]
workflow_a = workflow_builder.build()
print("=== Running workflow_a ===")
await _run_workflow(workflow_a, list(user_inputs))
workflow_b = workflow_builder.build()
print("=== Running workflow_b ===")
# Only provide the last two inputs to workflow_b to demonstrate state isolation
# The agents in this workflow have no prior context thus should not have knowledge of
# order 1234 or previous interactions.
await _run_workflow(workflow_b, user_inputs[2:])
"""
Expected behavior:
- workflow_a and workflow_b maintain separate states for their participants.
- Each workflow processes its requests independently without interference.
- workflow_a will answer the follow-up request based on its own conversation history,
while workflow_b will provide a general answer without prior context.
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -1,126 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import (
ChatAgent,
ChatMessage,
Executor,
Workflow,
WorkflowContext,
handler,
)
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.orchestrations import SequentialBuilder
from azure.identity import AzureCliCredential
"""
Sample: Sequential workflow with participant factories
This sample demonstrates how to create a sequential workflow with participant factories.
Using participant factories allows you to set up proper state isolation between workflow
instances created by the same builder. This is particularly useful when you need to handle
requests or tasks in parallel with stateful participants.
In this example, we create a sequential workflow with two participants: an accumulator
and a content producer. The accumulator is stateful and maintains a list of all messages it has
received. Context is maintained across runs of the same workflow instance but not across different
workflow instances.
"""
class Accumulate(Executor):
"""Simple accumulator.
Accumulates all messages from the conversation and prints them out.
"""
def __init__(self, id: str):
super().__init__(id)
# Some internal state to accumulate messages
self._accumulated: list[str] = []
@handler
async def accumulate(self, conversation: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
self._accumulated.extend([msg.text for msg in conversation])
print(f"Number of queries received so far: {len(self._accumulated)}")
await ctx.send_message(conversation)
def create_agent() -> ChatAgent:
return AzureOpenAIChatClient(credential=AzureCliCredential()).as_agent(
instructions="Produce a concise paragraph answering the user's request.",
name="ContentProducer",
)
async def run_workflow(workflow: Workflow, query: str) -> None:
events = await workflow.run(query)
outputs = events.get_outputs()
if outputs:
messages: list[ChatMessage] = outputs[0]
for message in messages:
name = message.author_name or ("assistant" if message.role == "assistant" else "user")
print(f"{name}: {message.text}")
else:
raise RuntimeError("No outputs received from the workflow.")
async def main() -> None:
# 1) Create a builder with participant factories
builder = SequentialBuilder(participant_factories=[
lambda: Accumulate("accumulator"),
create_agent,
])
# 2) Build workflow_a
workflow_a = builder.build()
# 3) Run workflow_a
# Context is maintained across runs
print("=== First Run on workflow_a ===")
await run_workflow(workflow_a, "Why is the sky blue?")
print("\n=== Second Run on workflow_a ===")
await run_workflow(workflow_a, "Repeat my previous question.")
# 4) Build workflow_b
# This will create a new instance of the accumulator and content producer
# using the same workflow builder
workflow_b = builder.build()
# 5) Run workflow_b
# Context is not maintained across instances
print("\n=== First Run on workflow_b ===")
await run_workflow(workflow_b, "Repeat my previous question.")
"""
Sample Output:
=== First Run on workflow_a ===
Number of queries received so far: 1
user: Why is the sky blue?
ContentProducer: The sky appears blue due to a phenomenon called Rayleigh scattering.
When sunlight enters the Earth's atmosphere, it collides with gases
and particles, scattering shorter wavelengths of light (blue and violet)
more than the longer wavelengths (red and yellow). Although violet light
is scattered even more than blue, our eyes are more sensitive to blue
light, and some violet light is absorbed by the ozone layer. As a result,
we perceive the sky as predominantly blue during the day.
=== Second Run on workflow_a ===
Number of queries received so far: 2
user: Repeat my previous question.
ContentProducer: Why is the sky blue?
=== First Run on workflow_b ===
Number of queries received so far: 1
user: Repeat my previous question.
ContentProducer: I'm sorry, but I can't repeat your previous question as I don't have
access to your past queries. However, feel free to ask anything again,
and I'll be happy to help!
"""
if __name__ == "__main__":
asyncio.run(main())