Python: Rebase durable task feature branch with main (#2806)

This commit is contained in:
Laveesh Rohra
2025-12-17 14:02:36 -08:00
committed by GitHub
Unverified
parent a48a8dd524
commit 87a38bc7da
227 changed files with 11969 additions and 2638 deletions
@@ -44,6 +44,7 @@ Once comfortable with these, explore the rest of the samples below.
| Magentic Workflow as Agent | [agents/magentic_workflow_as_agent.py](./agents/magentic_workflow_as_agent.py) | Configure Magentic orchestration with callbacks, then expose the workflow as an agent |
| Workflow as Agent (Reflection Pattern) | [agents/workflow_as_agent_reflection_pattern.py](./agents/workflow_as_agent_reflection_pattern.py) | Wrap a workflow so it can behave like an agent (reflection pattern) |
| Workflow as Agent + HITL | [agents/workflow_as_agent_human_in_the_loop.py](./agents/workflow_as_agent_human_in_the_loop.py) | Extend workflow-as-agent with human-in-the-loop capability |
| Workflow as Agent with Thread | [agents/workflow_as_agent_with_thread.py](./agents/workflow_as_agent_with_thread.py) | Use AgentThread to maintain conversation history across workflow-as-agent invocations |
| Handoff Workflow as Agent | [agents/handoff_workflow_as_agent.py](./agents/handoff_workflow_as_agent.py) | Use a HandoffBuilder workflow as an agent with HITL via FunctionCallContent/FunctionResultContent |
### checkpoint
@@ -54,6 +55,7 @@ Once comfortable with these, explore the rest of the samples below.
| Checkpoint & HITL Resume | [checkpoint/checkpoint_with_human_in_the_loop.py](./checkpoint/checkpoint_with_human_in_the_loop.py) | Combine checkpointing with human approvals and resume pending HITL requests |
| Checkpointed Sub-Workflow | [checkpoint/sub_workflow_checkpoint.py](./checkpoint/sub_workflow_checkpoint.py) | Save and resume a sub-workflow that pauses for human approval |
| Handoff + Tool Approval Resume | [checkpoint/handoff_with_tool_approval_checkpoint_resume.py](./checkpoint/handoff_with_tool_approval_checkpoint_resume.py) | Handoff workflow that captures tool-call approvals in checkpoints and resumes with human decisions |
| Workflow as Agent Checkpoint | [checkpoint/workflow_as_agent_checkpoint.py](./checkpoint/workflow_as_agent_checkpoint.py) | Enable checkpointing when using workflow.as_agent() with checkpoint_storage parameter |
### composition
@@ -84,7 +86,6 @@ Once comfortable with these, explore the rest of the samples below.
| ConcurrentBuilder Request Info | [human-in-the-loop/concurrent_request_info.py](./human-in-the-loop/concurrent_request_info.py) | Review concurrent agent outputs before aggregation using `.with_request_info()` on ConcurrentBuilder |
| GroupChatBuilder Request Info | [human-in-the-loop/group_chat_request_info.py](./human-in-the-loop/group_chat_request_info.py) | Steer group discussions with periodic guidance using `.with_request_info()` on GroupChatBuilder |
### tool-approval
Tool approval samples demonstrate using `@ai_function(approval_mode="always_require")` to gate sensitive tool executions with human approval. These work with the high-level builder APIs.
@@ -110,6 +111,7 @@ For additional observability samples in Agent Framework, see the [observability
| Concurrent Orchestration (Default Aggregator) | [orchestration/concurrent_agents.py](./orchestration/concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined ChatMessages |
| Concurrent Orchestration (Custom Aggregator) | [orchestration/concurrent_custom_aggregator.py](./orchestration/concurrent_custom_aggregator.py) | Override aggregator via callback; summarize results with an LLM |
| Concurrent Orchestration (Custom Agent Executors) | [orchestration/concurrent_custom_agent_executors.py](./orchestration/concurrent_custom_agent_executors.py) | Child executors own ChatAgents; concurrent fan-out/fan-in via ConcurrentBuilder |
| Concurrent Orchestration (Participant Factory) | [orchestration/concurrent_participant_factory.py](./orchestration/concurrent_participant_factory.py) | Use participant factories for state isolation between workflow instances |
| Group Chat with Agent Manager | [orchestration/group_chat_agent_manager.py](./orchestration/group_chat_agent_manager.py) | Agent-based manager using `set_manager()` to select next speaker |
| Group Chat Philosophical Debate | [orchestration/group_chat_philosophical_debate.py](./orchestration/group_chat_philosophical_debate.py) | Agent manager moderates long-form, multi-round debate across diverse participants |
| Group Chat with Simple Function Selector | [orchestration/group_chat_simple_selector.py](./orchestration/group_chat_simple_selector.py) | Group chat with a simple function selector for next speaker |
@@ -117,6 +119,7 @@ For additional observability samples in Agent Framework, see the [observability
| Handoff (Specialist-to-Specialist) | [orchestration/handoff_specialist_to_specialist.py](./orchestration/handoff_specialist_to_specialist.py) | Multi-tier routing: specialists can hand off to other specialists using `.add_handoff()` fluent API |
| Handoff (Return-to-Previous) | [orchestration/handoff_return_to_previous.py](./orchestration/handoff_return_to_previous.py) | Return-to-previous routing: after user input, routes back to the previous specialist instead of coordinator using `.enable_return_to_previous()` |
| Handoff (Autonomous) | [orchestration/handoff_autonomous.py](./orchestration/handoff_autonomous.py) | Autonomous mode: specialists iterate independently until invoking a handoff tool using `.with_interaction_mode("autonomous", autonomous_turn_limit=N)` |
| Handoff (Participant Factory) | [orchestration/handoff_participant_factory.py](./orchestration/handoff_participant_factory.py) | Use participant factories for state isolation between workflow instances |
| Magentic Workflow (Multi-Agent) | [orchestration/magentic.py](./orchestration/magentic.py) | Orchestrate multiple agents with Magentic manager and streaming |
| Magentic + Human Plan Review | [orchestration/magentic_human_plan_update.py](./orchestration/magentic_human_plan_update.py) | Human reviews/updates the plan before execution |
| Magentic + Human Stall Intervention | [orchestration/magentic_human_replan.py](./orchestration/magentic_human_replan.py) | Human intervenes when workflow stalls with `with_human_input_on_stall()` |
@@ -146,6 +149,8 @@ to configure which agents can route to which others with a fluent, type-safe API
| Sample | File | Concepts |
|---|---|---|
| Shared States | [state-management/shared_states_with_agents.py](./state-management/shared_states_with_agents.py) | Store in shared state once and later reuse across agents |
| Workflow Kwargs (Custom Context) | [state-management/workflow_kwargs.py](./state-management/workflow_kwargs.py) | Pass custom context (data, user tokens) via kwargs to `@ai_function` tools |
### visualization
@@ -0,0 +1,167 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import AgentThread, ChatAgent, ChatMessageStore, SequentialBuilder
from agent_framework.openai import OpenAIChatClient
"""
Sample: Workflow as Agent with Thread Conversation History and Checkpointing
This sample demonstrates how to use AgentThread with a workflow wrapped as an agent
to maintain conversation history across multiple invocations. When using as_agent(),
the thread's message store history is included in each workflow run, enabling
the workflow participants to reference prior conversation context.
It also demonstrates how to enable checkpointing for workflow execution state
persistence, allowing workflows to be paused and resumed.
Key concepts:
- Workflows can be wrapped as agents using workflow.as_agent()
- AgentThread with ChatMessageStore preserves conversation history
- Each call to agent.run() includes thread history + new message
- Participants in the workflow see the full conversation context
- checkpoint_storage parameter enables workflow state persistence
Use cases:
- Multi-turn conversations with workflow-based orchestrations
- Stateful workflows that need context from previous interactions
- Building conversational agents that leverage workflow patterns
- Long-running workflows that need pause/resume capability
Prerequisites:
- OpenAI environment variables configured for OpenAIChatClient
"""
async def main() -> None:
# Create a chat client
chat_client = OpenAIChatClient()
# Define factory functions for workflow participants
def create_assistant() -> ChatAgent:
return chat_client.create_agent(
name="assistant",
instructions=(
"You are a helpful assistant. Answer questions based on the conversation "
"history. If the user asks about something mentioned earlier, reference it."
),
)
def create_summarizer() -> ChatAgent:
return chat_client.create_agent(
name="summarizer",
instructions=(
"You are a summarizer. After the assistant responds, provide a brief "
"one-sentence summary of the key point from the conversation so far."
),
)
# Build a sequential workflow: assistant -> summarizer
workflow = SequentialBuilder().register_participants([create_assistant, create_summarizer]).build()
# Wrap the workflow as an agent
agent = workflow.as_agent(name="ConversationalWorkflowAgent")
# Create a thread with a ChatMessageStore to maintain history
message_store = ChatMessageStore()
thread = AgentThread(message_store=message_store)
print("=" * 60)
print("Workflow as Agent with Thread - Multi-turn Conversation")
print("=" * 60)
# First turn: Introduce a topic
query1 = "My name is Alex and I'm learning about machine learning."
print(f"\n[Turn 1] User: {query1}")
response1 = await agent.run(query1, thread=thread)
if response1.messages:
for msg in response1.messages:
speaker = msg.author_name or msg.role.value
print(f"[{speaker}]: {msg.text}")
# Second turn: Reference the previous topic
query2 = "What was my name again, and what am I learning about?"
print(f"\n[Turn 2] User: {query2}")
response2 = await agent.run(query2, thread=thread)
if response2.messages:
for msg in response2.messages:
speaker = msg.author_name or msg.role.value
print(f"[{speaker}]: {msg.text}")
# Third turn: Ask a follow-up question
query3 = "Can you suggest a good first project for me to try?"
print(f"\n[Turn 3] User: {query3}")
response3 = await agent.run(query3, thread=thread)
if response3.messages:
for msg in response3.messages:
speaker = msg.author_name or msg.role.value
print(f"[{speaker}]: {msg.text}")
# Show the accumulated conversation history
print("\n" + "=" * 60)
print("Full Thread History")
print("=" * 60)
if thread.message_store:
history = await thread.message_store.list_messages()
for i, msg in enumerate(history, start=1):
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
speaker = msg.author_name or role
text_preview = msg.text[:80] + "..." if len(msg.text) > 80 else msg.text
print(f"{i:02d}. [{speaker}]: {text_preview}")
async def demonstrate_thread_serialization() -> None:
"""
Demonstrates serializing and resuming a thread with a workflow agent.
This shows how conversation history can be persisted and restored,
enabling long-running conversational workflows.
"""
chat_client = OpenAIChatClient()
def create_assistant() -> ChatAgent:
return chat_client.create_agent(
name="memory_assistant",
instructions="You are a helpful assistant with good memory. Remember details from our conversation.",
)
workflow = SequentialBuilder().register_participants([create_assistant]).build()
agent = workflow.as_agent(name="MemoryWorkflowAgent")
# Create initial thread and have a conversation
thread = AgentThread(message_store=ChatMessageStore())
print("\n" + "=" * 60)
print("Thread Serialization Demo")
print("=" * 60)
# First interaction
query = "Remember this: the secret code is ALPHA-7."
print(f"\n[Session 1] User: {query}")
response = await agent.run(query, thread=thread)
if response.messages:
print(f"[assistant]: {response.messages[0].text}")
# Serialize thread state (could be saved to database/file)
serialized_state = await thread.serialize()
print("\n[Serialized thread state for persistence]")
# Simulate a new session by creating a new thread from serialized state
restored_thread = AgentThread(message_store=ChatMessageStore())
await restored_thread.update_from_thread_state(serialized_state)
# Continue conversation with restored thread
query = "What was the secret code I told you?"
print(f"\n[Session 2 - Restored] User: {query}")
response = await agent.run(query, thread=restored_thread)
if response.messages:
print(f"[assistant]: {response.messages[0].text}")
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(demonstrate_thread_serialization())
@@ -122,11 +122,17 @@ def _print_handoff_request(request: HandoffUserInputRequest, request_id: str) ->
print(f"Awaiting agent: {request.awaiting_agent_id}")
print(f"Prompt: {request.prompt}")
print("\nConversation so far:")
for msg in request.conversation[-3:]:
author = msg.author_name or msg.role.value
snippet = msg.text[:120] + "..." if len(msg.text) > 120 else msg.text
print(f" {author}: {snippet}")
# Note: After checkpoint restore, conversation may be empty because it's not serialized
# to prevent duplication (the conversation is preserved in the coordinator's state).
# See issue #2667.
if request.conversation:
print("\nConversation so far:")
for msg in request.conversation[-3:]:
author = msg.author_name or msg.role.value
snippet = msg.text[:120] + "..." if len(msg.text) > 120 else msg.text
print(f" {author}: {snippet}")
else:
print("\n(Conversation restored from checkpoint - context preserved in workflow state)")
print(f"{'=' * 60}\n")
@@ -273,11 +279,7 @@ async def resume_with_responses(
elif isinstance(event, WorkflowOutputEvent):
print("\n[Workflow Output Event - Conversation Update]")
if (
event.data
and isinstance(event.data, list)
and all(isinstance(msg, ChatMessage) for msg in event.data)
):
if event.data and isinstance(event.data, list) and all(isinstance(msg, ChatMessage) for msg in event.data):
# Now safe to cast event.data to list[ChatMessage]
conversation = cast(list[ChatMessage], event.data)
for msg in conversation[-3:]: # Show last 3 messages
@@ -0,0 +1,163 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Sample: Workflow as Agent with Checkpointing
Purpose:
This sample demonstrates how to use checkpointing with a workflow wrapped as an agent.
It shows how to enable checkpoint storage when calling agent.run() or agent.run_stream(),
allowing workflow execution state to be persisted and potentially resumed.
What you learn:
- How to pass checkpoint_storage to WorkflowAgent.run() and run_stream()
- How checkpoints are created during workflow-as-agent execution
- How to combine thread conversation history with workflow checkpointing
- How to resume a workflow-as-agent from a checkpoint
Key concepts:
- Thread (AgentThread): Maintains conversation history across agent invocations
- Checkpoint: Persists workflow execution state for pause/resume capability
- These are complementary: threads track conversation, checkpoints track workflow state
Prerequisites:
- OpenAI environment variables configured for OpenAIChatClient
"""
import asyncio
from agent_framework import (
AgentThread,
ChatAgent,
ChatMessageStore,
InMemoryCheckpointStorage,
SequentialBuilder,
)
from agent_framework.openai import OpenAIChatClient
async def basic_checkpointing() -> None:
"""Demonstrate basic checkpoint storage with workflow-as-agent."""
print("=" * 60)
print("Basic Checkpointing with Workflow as Agent")
print("=" * 60)
chat_client = OpenAIChatClient()
def create_assistant() -> ChatAgent:
return chat_client.create_agent(
name="assistant",
instructions="You are a helpful assistant. Keep responses brief.",
)
def create_reviewer() -> ChatAgent:
return chat_client.create_agent(
name="reviewer",
instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.",
)
# Build sequential workflow with participant factories
workflow = SequentialBuilder().register_participants([create_assistant, create_reviewer]).build()
agent = workflow.as_agent(name="CheckpointedAgent")
# Create checkpoint storage
checkpoint_storage = InMemoryCheckpointStorage()
# Run with checkpointing enabled
query = "What are the benefits of renewable energy?"
print(f"\nUser: {query}")
response = await agent.run(query, checkpoint_storage=checkpoint_storage)
for msg in response.messages:
speaker = msg.author_name or msg.role.value
print(f"[{speaker}]: {msg.text}")
# Show checkpoints that were created
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
print(f"\nCheckpoints created: {len(checkpoints)}")
for i, cp in enumerate(checkpoints[:5], 1):
print(f" {i}. {cp.checkpoint_id}")
async def checkpointing_with_thread() -> None:
"""Demonstrate combining thread history with checkpointing."""
print("\n" + "=" * 60)
print("Checkpointing with Thread Conversation History")
print("=" * 60)
chat_client = OpenAIChatClient()
def create_assistant() -> ChatAgent:
return chat_client.create_agent(
name="memory_assistant",
instructions="You are a helpful assistant with good memory. Reference previous conversation when relevant.",
)
workflow = SequentialBuilder().register_participants([create_assistant]).build()
agent = workflow.as_agent(name="MemoryAgent")
# Create both thread (for conversation) and checkpoint storage (for workflow state)
thread = AgentThread(message_store=ChatMessageStore())
checkpoint_storage = InMemoryCheckpointStorage()
# First turn
query1 = "My favorite color is blue. Remember that."
print(f"\n[Turn 1] User: {query1}")
response1 = await agent.run(query1, thread=thread, checkpoint_storage=checkpoint_storage)
if response1.messages:
print(f"[assistant]: {response1.messages[0].text}")
# Second turn - agent should remember from thread history
query2 = "What's my favorite color?"
print(f"\n[Turn 2] User: {query2}")
response2 = await agent.run(query2, thread=thread, checkpoint_storage=checkpoint_storage)
if response2.messages:
print(f"[assistant]: {response2.messages[0].text}")
# Show accumulated state
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
print(f"\nTotal checkpoints across both turns: {len(checkpoints)}")
if thread.message_store:
history = await thread.message_store.list_messages()
print(f"Messages in thread history: {len(history)}")
async def streaming_with_checkpoints() -> None:
"""Demonstrate streaming with checkpoint storage."""
print("\n" + "=" * 60)
print("Streaming with Checkpointing")
print("=" * 60)
chat_client = OpenAIChatClient()
def create_assistant() -> ChatAgent:
return chat_client.create_agent(
name="streaming_assistant",
instructions="You are a helpful assistant.",
)
workflow = SequentialBuilder().register_participants([create_assistant]).build()
agent = workflow.as_agent(name="StreamingCheckpointAgent")
checkpoint_storage = InMemoryCheckpointStorage()
query = "List three interesting facts about the ocean."
print(f"\nUser: {query}")
print("[assistant]: ", end="", flush=True)
# Stream with checkpointing
async for update in agent.run_stream(query, checkpoint_storage=checkpoint_storage):
if update.text:
print(update.text, end="", flush=True)
print() # Newline after streaming
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
print(f"\nCheckpoints created during stream: {len(checkpoints)}")
if __name__ == "__main__":
asyncio.run(basic_checkpointing())
asyncio.run(checkpointing_with_thread())
asyncio.run(streaming_with_checkpoints())
@@ -25,7 +25,7 @@ What this example shows:
- ExecutorCompletedEvent.data contains the messages sent via ctx.send_message()
- How to generically observe all executor I/O through workflow streaming events
This approach allows you to instrument any workflow for observability without
This approach allows you to enable_instrumentation any workflow for observability without
changing the executor implementations.
Prerequisites:
@@ -17,7 +17,7 @@ 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)
- ConcurrentBuilder().participants([...]).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
@@ -0,0 +1,169 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any, Never
from agent_framework import (
ChatAgent,
ChatMessage,
ConcurrentBuilder,
Executor,
Role,
Workflow,
WorkflowContext,
handler,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
"""
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().register_participants([...]).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()).create_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()).create_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()).create_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_run_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(
Role.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(Role.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
# AgentProtocol (agents) or Executor instances.
# - register_aggregator(...) takes a factory function that returns an Executor instance.
concurrent_builder = (
ConcurrentBuilder()
.register_participants([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())
@@ -2,14 +2,15 @@
import asyncio
import logging
from collections.abc import AsyncIterable
from typing import cast
from agent_framework import (
AgentRunResponseUpdate,
AgentRunUpdateEvent,
ChatAgent,
ChatMessage,
HandoffBuilder,
HostedWebSearchTool,
WorkflowEvent,
WorkflowOutputEvent,
)
@@ -44,31 +45,29 @@ def create_agents(
"""Create coordinator and specialists for autonomous iteration."""
coordinator = chat_client.create_agent(
instructions=(
"You are a coordinator. Route user requests to either research_agent or summary_agent. "
"Always call exactly one handoff tool with a short routing acknowledgement. "
"If unsure, default to research_agent. Never request information yourself. "
"After a specialist hands off back to you, provide a concise final summary and stop."
"You are a coordinator. You break down a user query into a research task and a summary task. "
"Assign the two tasks to the appropriate specialists, one after the other."
),
name="coordinator",
)
research_agent = chat_client.create_agent(
instructions=(
"You are a research specialist that explores topics thoroughly. "
"You are a research specialist that explores topics thoroughly on the Microsoft Learn Site."
"When given a research task, break it down into multiple aspects and explore each one. "
"Continue your research across multiple responses - don't try to finish everything in one response. "
"After each response, think about what else needs to be explored. "
"When you have covered the topic comprehensively (at least 3-4 different aspects), "
"call the handoff tool to return to coordinator with your findings. "
"Keep each individual response focused on one aspect."
"Continue your research across multiple responses - don't try to finish everything in one "
"response. After each response, think about what else needs to be explored. When you have "
"covered the topic comprehensively (at least 3-4 different aspects), return control to the "
"coordinator. Keep each individual response focused on one aspect."
),
name="research_agent",
tools=[HostedWebSearchTool()],
)
summary_agent = chat_client.create_agent(
instructions=(
"You summarize research findings. Provide a concise, well-organized summary. "
"When done, hand off to coordinator."
"You summarize research findings. Provide a concise, well-organized summary. When done, return "
"control to the coordinator."
),
name="summary_agent",
)
@@ -76,25 +75,29 @@ def create_agents(
return coordinator, research_agent, summary_agent
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
"""Collect all events from an async stream."""
return [event async for event in stream]
last_response_id: str | None = None
def _print_conversation(events: list[WorkflowEvent]) -> None:
def _display_event(event: WorkflowEvent) -> None:
"""Print the final conversation snapshot from workflow output events."""
for event in events:
if isinstance(event, AgentRunUpdateEvent):
print(event.data, flush=True, end="")
elif isinstance(event, WorkflowOutputEvent):
conversation = cast(list[ChatMessage], event.data)
print("\n=== Final Conversation (Autonomous with Iteration) ===")
for message in conversation:
speaker = message.author_name or message.role.value
text_preview = message.text[:200] + "..." if len(message.text) > 200 else message.text
print(f"- {speaker}: {text_preview}")
print(f"\nTotal messages: {len(conversation)}")
print("=====================================================")
if isinstance(event, AgentRunUpdateEvent) and event.data:
update: AgentRunResponseUpdate = event.data
if not update.text:
return
global last_response_id
if update.response_id != last_response_id:
last_response_id = update.response_id
print(f"\n- {update.author_name}: ", flush=True, end="")
print(event.data, flush=True, end="")
elif isinstance(event, WorkflowOutputEvent):
conversation = cast(list[ChatMessage], event.data)
print("\n=== Final Conversation (Autonomous with Iteration) ===")
for message in conversation:
speaker = message.author_name or message.role.value
text_preview = message.text[:200] + "..." if len(message.text) > 200 else message.text
print(f"- {speaker}: {text_preview}")
print(f"\nTotal messages: {len(conversation)}")
print("=====================================================")
async def main() -> None:
@@ -122,12 +125,10 @@ async def main() -> None:
.build()
)
initial_request = "Research the key benefits and challenges of renewable energy adoption."
print("Initial request:", initial_request)
print("\nExpecting multiple iterations from the research agent...\n")
events = await _drain(workflow.run_stream(initial_request))
_print_conversation(events)
request = "Perform a comprehensive research on Microsoft Agent Framework."
print("Request:", request)
async for event in workflow.run_stream(request):
_display_event(event)
"""
Expected behavior:
@@ -0,0 +1,265 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
from collections.abc import AsyncIterable
from typing import cast
from agent_framework import (
ChatAgent,
ChatMessage,
HandoffBuilder,
HandoffUserInputRequest,
RequestInfoEvent,
Role,
Workflow,
WorkflowEvent,
WorkflowOutputEvent,
ai_function,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
from typing import Annotated
logging.basicConfig(level=logging.ERROR)
"""Sample: Autonomous handoff workflow with agent factory.
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 -> Coordinator -> Specialist (iterates N times) -> Handoff -> Final Output
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
"""
@ai_function
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}."
@ai_function
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."
@ai_function
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()).create_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()).create_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()).create_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()).create_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],
)
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
"""Collect all events from an async stream into a list.
This helper drains the workflow's event stream so we can process events
synchronously after each workflow step completes.
Args:
stream: Async iterable of WorkflowEvent
Returns:
List of all events from the stream
"""
return [event async for event in stream]
def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
"""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 RequestInfoEvent instances for response handling
Args:
events: List of WorkflowEvent to process
Returns:
List of RequestInfoEvent representing pending user input requests
"""
requests: list[RequestInfoEvent] = []
for event in events:
# WorkflowOutputEvent: Contains the final conversation when workflow terminates
if isinstance(event, WorkflowOutputEvent):
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.value
print(f"- {speaker}: {message.text}")
print("===================================")
# RequestInfoEvent: Workflow is requesting user input
elif isinstance(event, RequestInfoEvent):
if isinstance(event.data, HandoffUserInputRequest):
_print_agent_responses_since_last_user_message(event.data)
requests.append(event)
return requests
def _print_agent_responses_since_last_user_message(request: HandoffUserInputRequest) -> None:
"""Display agent responses since the last user message in a handoff request.
The HandoffUserInputRequest contains the full conversation history so far,
allowing the user to see what's been discussed before providing their next input.
Args:
request: The user input request containing conversation and prompt
"""
if not request.conversation:
raise RuntimeError("HandoffUserInputRequest missing conversation history.")
# Reverse iterate to collect agent responses since last user message
agent_responses: list[ChatMessage] = []
for message in request.conversation[::-1]:
if message.role == Role.USER:
break
agent_responses.append(message)
# Print agent responses in original order
agent_responses.reverse()
for message in agent_responses:
speaker = message.author_name or message.role.value
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]}")
events = await _drain(workflow.run_stream(user_inputs[0]))
pending_requests = _handle_events(events)
# 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 and 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: user_response for req in pending_requests}
# Send responses and get new events
# We use send_responses_streaming() to get events as they occur, allowing us to
# display agent responses in real-time and handle new requests as they arrive
events = await _drain(workflow.send_responses_streaming(responses))
pending_requests = _handle_events(events)
async def main() -> None:
"""Run the autonomous handoff workflow with participant factories."""
# Build the handoff workflow using participant factories
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,
},
)
.set_coordinator("triage")
.with_termination_condition(
# Custom termination: Check 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.
lambda conversation: len(conversation) > 0
and conversation[-1].author_name == "triage_agent"
and "welcome" in conversation[-1].text.lower()
)
)
# 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())
@@ -2,7 +2,7 @@
import asyncio
from collections.abc import AsyncIterable
from typing import cast
from typing import Annotated, cast
from agent_framework import (
ChatAgent,
@@ -10,10 +10,12 @@ from agent_framework import (
HandoffBuilder,
HandoffUserInputRequest,
RequestInfoEvent,
Role,
WorkflowEvent,
WorkflowOutputEvent,
WorkflowRunState,
WorkflowStatusEvent,
ai_function,
)
from agent_framework.azure import AzureOpenAIChatClient
from azure.identity import AzureCliCredential
@@ -22,10 +24,10 @@ from azure.identity import AzureCliCredential
This sample demonstrates the basic handoff pattern where only the triage agent can
route to specialists. Specialists cannot hand off to other specialists - after any
specialist responds, control returns to the user for the next input.
specialist responds, control returns to the user (via the triage agent) for the next input.
Routing Pattern:
User → Triage Agent → Specialist → Back to User → Triage Agent → ...
User → Triage Agent → Specialist → Triage Agent → User → Triage Agent → ...
This is the simplest handoff configuration, suitable for straightforward support
scenarios where a triage agent dispatches to domain specialists, and each specialist
@@ -39,12 +41,31 @@ Prerequisites:
Key Concepts:
- Single-tier routing: Only triage agent has handoff capabilities
- Auto-registered handoff tools: HandoffBuilder creates tools automatically
- Auto-registered handoff tools: HandoffBuilder automatically creates handoff tools
for each participant, allowing the coordinator to transfer control to specialists
- Termination condition: Controls when the workflow stops requesting user input
- Request/response cycle: Workflow requests input, user responds, cycle continues
"""
@ai_function
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}."
@ai_function
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."
@ai_function
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_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAgent, ChatAgent, ChatAgent]:
"""Create and configure the triage and specialist agents.
@@ -54,51 +75,46 @@ def create_agents(chat_client: AzureOpenAIChatClient) -> tuple[ChatAgent, ChatAg
- Signaling handoff by calling one of the explicit handoff tools exposed to it
Specialist agents are invoked only when the triage agent explicitly hands off to them.
After a specialist responds, control returns to the triage agent.
After a specialist responds, control returns to the triage agent, which then prompts
the user for their next message.
Returns:
Tuple of (triage_agent, refund_agent, order_agent, support_agent)
Tuple of (triage_agent, refund_agent, order_agent, return_agent)
"""
# Triage agent: Acts as the frontline dispatcher
# NOTE: The instructions explicitly tell it to call the correct handoff tool when routing.
# The HandoffBuilder intercepts these tool calls and routes to the matching specialist.
triage = chat_client.create_agent(
triage_agent = chat_client.create_agent(
instructions=(
"You are frontline support triage. Read the latest user message and decide whether "
"to hand off to refund_agent, order_agent, or support_agent. Provide a brief natural-language "
"response for the user. When delegation is required, call the matching handoff tool "
"(`handoff_to_refund_agent`, `handoff_to_order_agent`, or `handoff_to_support_agent`)."
"You are frontline support triage. Route customer issues to the appropriate specialist agents "
"based on the problem described."
),
name="triage_agent",
)
# Refund specialist: Handles refund requests
refund = chat_client.create_agent(
instructions=(
"You handle refund workflows. Ask for any order identifiers you require and outline the refund steps."
),
refund_agent = chat_client.create_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],
)
# Order/shipping specialist: Resolves delivery issues
order = chat_client.create_agent(
instructions=(
"You resolve shipping and fulfillment issues. Clarify the delivery problem and describe the actions "
"you will take to remedy it."
),
order_agent = chat_client.create_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],
)
# General support specialist: Fallback for other issues
support = chat_client.create_agent(
instructions=(
"You are a general support agent. Offer empathetic troubleshooting and gather missing details if the "
"issue does not match other specialists."
),
name="support_agent",
# Return specialist: Handles return requests
return_agent = chat_client.create_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],
)
return triage, refund, order, support
return triage_agent, refund_agent, order_agent, return_agent
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
@@ -139,7 +155,7 @@ def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
WorkflowRunState.IDLE,
WorkflowRunState.IDLE_WITH_PENDING_REQUESTS,
}:
print(f"[status] {event.state.name}")
print(f"\n[Workflow Status] {event.state.name}")
# WorkflowOutputEvent: Contains the final conversation when workflow terminates
elif isinstance(event, WorkflowOutputEvent):
@@ -154,14 +170,14 @@ def _handle_events(events: list[WorkflowEvent]) -> list[RequestInfoEvent]:
# RequestInfoEvent: Workflow is requesting user input
elif isinstance(event, RequestInfoEvent):
if isinstance(event.data, HandoffUserInputRequest):
_print_handoff_request(event.data)
_print_agent_responses_since_last_user_message(event.data)
requests.append(event)
return requests
def _print_handoff_request(request: HandoffUserInputRequest) -> None:
"""Display a user input request prompt with conversation context.
def _print_agent_responses_since_last_user_message(request: HandoffUserInputRequest) -> None:
"""Display agent responses since the last user message in a handoff request.
The HandoffUserInputRequest contains the full conversation history so far,
allowing the user to see what's been discussed before providing their next input.
@@ -169,11 +185,21 @@ def _print_handoff_request(request: HandoffUserInputRequest) -> None:
Args:
request: The user input request containing conversation and prompt
"""
print("\n=== User Input Requested ===")
for message in request.conversation:
if not request.conversation:
raise RuntimeError("HandoffUserInputRequest missing conversation history.")
# Reverse iterate to collect agent responses since last user message
agent_responses: list[ChatMessage] = []
for message in request.conversation[::-1]:
if message.role == Role.USER:
break
agent_responses.append(message)
# Print agent responses in original order
agent_responses.reverse()
for message in agent_responses:
speaker = message.author_name or message.role.value
print(f"- {speaker}: {message.text}")
print("============================")
async def main() -> None:
@@ -196,20 +222,26 @@ async def main() -> None:
triage, refund, order, support = create_agents(chat_client)
# Build the handoff workflow
# - participants: All agents that can participate (triage MUST be first or explicitly set as set_coordinator)
# - set_coordinator: The triage agent receives all user input first
# - with_termination_condition: Custom logic to stop the request/response loop
# Default is 10 user messages; here we terminate after 4 to match our scripted demo
# - participants: All agents that can participate in the workflow
# - set_coordinator: The triage agent is designated as the coordinator, which means
# it receives all user input first and orchestrates handoffs to specialists
# - with_termination_condition: Custom logic to stop the request/response loop.
# Without this, the default behavior continues requesting user input until max_turns
# is reached. Here we use a custom condition that checks if the conversation has ended
# naturally (when triage agent says something like "you're welcome").
workflow = (
HandoffBuilder(
name="customer_support_handoff",
participants=[triage, refund, order, support],
)
.set_coordinator("triage_agent")
.set_coordinator(triage)
.with_termination_condition(
# Terminate after 4 user messages (initial + 3 scripted responses)
# Count only USER role messages to avoid counting agent responses
lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 4
# Custom termination: Check 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.
lambda conversation: len(conversation) > 0
and conversation[-1].author_name == "triage_agent"
and "welcome" in conversation[-1].text.lower()
)
.build()
)
@@ -219,15 +251,16 @@ async def main() -> None:
# user_input = input("Your response: ")
# or integrate with a UI/chat interface
scripted_responses = [
"My order 1234 arrived damaged and the packaging was destroyed.",
"Yes, I'd like a refund if that's possible.",
"My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.",
"Thanks for resolving this.",
]
# Start the workflow with the initial user message
# run_stream() returns an async iterator of WorkflowEvent
print("\n[Starting workflow with initial user message...]")
events = await _drain(workflow.run_stream("Hello, I need assistance with my recent purchase."))
print("[Starting workflow with initial user message...]\n")
initial_message = "Hello, I need assistance with my recent purchase."
print(f"- User: {initial_message}")
events = await _drain(workflow.run_stream(initial_message))
pending_requests = _handle_events(events)
# Process the request/response cycle
@@ -237,13 +270,15 @@ async def main() -> None:
while pending_requests and scripted_responses:
# Get the next scripted response
user_response = scripted_responses.pop(0)
print(f"\n[User responding: {user_response}]")
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: user_response for req in pending_requests}
# Send responses and get new events
# We use send_responses_streaming() to get events as they occur, allowing us to
# display agent responses in real-time and handle new requests as they arrive
events = await _drain(workflow.send_responses_streaming(responses))
pending_requests = _handle_events(events)
@@ -252,84 +287,30 @@ async def main() -> None:
[Starting workflow with initial user message...]
=== User Input Requested ===
- User: Hello, I need assistance with my recent purchase.
- triage_agent: Could you please provide more details about the issue you're experiencing with your recent purchase? This will help me route you to the appropriate specialist.
[Workflow Status] IDLE_WITH_PENDING_REQUESTS
- User: My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.
- triage_agent: I've directed your request to our return agent, who will assist you with returning the damaged order. Thank you for your patience!
- return_agent: The return for your order 1234 has been successfully initiated. You will receive return instructions via email shortly. If you have any other questions or need further assistance, feel free to ask!
[Workflow Status] IDLE_WITH_PENDING_REQUESTS
- User: Thanks for resolving this.
=== Final Conversation Snapshot ===
- user: Hello, I need assistance with my recent purchase.
- triage_agent: I'd be happy to help you with your recent purchase. Could you please provide more details about the issue you're experiencing?
============================
[status] IDLE_WITH_PENDING_REQUESTS
[User responding: My order 1234 arrived damaged and the packaging was destroyed.]
=== User Input Requested ===
- user: Hello, I need assistance with my recent purchase.
- triage_agent: I'd be happy to help you with your recent purchase. Could you please provide more details about the issue you're experiencing?
- user: My order 1234 arrived damaged and the packaging was destroyed.
- triage_agent: I'm sorry to hear that your order arrived damaged and the packaging was destroyed. I will connect you with a specialist who can assist you further with this issue.
Tool Call: handoff_to_support_agent (awaiting approval)
- support_agent: I'm so sorry to hear that your order arrived in such poor condition. I'll help you get this sorted out.
To assist you better, could you please let me know:
- Which item(s) from order 1234 arrived damaged?
- Could you describe the damage, or provide photos if possible?
- Would you prefer a replacement or a refund?
Once I have this information, I can help resolve this for you as quickly as possible.
============================
[status] IDLE_WITH_PENDING_REQUESTS
[User responding: Yes, I'd like a refund if that's possible.]
=== User Input Requested ===
- user: Hello, I need assistance with my recent purchase.
- triage_agent: I'd be happy to help you with your recent purchase. Could you please provide more details about the issue you're experiencing?
- user: My order 1234 arrived damaged and the packaging was destroyed.
- triage_agent: I'm sorry to hear that your order arrived damaged and the packaging was destroyed. I will connect you with a specialist who can assist you further with this issue.
Tool Call: handoff_to_support_agent (awaiting approval)
- support_agent: I'm so sorry to hear that your order arrived in such poor condition. I'll help you get this sorted out.
To assist you better, could you please let me know:
- Which item(s) from order 1234 arrived damaged?
- Could you describe the damage, or provide photos if possible?
- Would you prefer a replacement or a refund?
Once I have this information, I can help resolve this for you as quickly as possible.
- user: Yes, I'd like a refund if that's possible.
- triage_agent: Thank you for letting me know you'd prefer a refund. I'll connect you with a specialist who can process your refund request.
Tool Call: handoff_to_refund_agent (awaiting approval)
- refund_agent: Thank you for confirming that you'd like a refund for order 1234.
Here's what will happen next:
...
Tool Call: handoff_to_refund_agent (awaiting approval)
- refund_agent: Thank you for confirming that you'd like a refund for order 1234.
Here's what will happen next:
**1. Verification:**
I will need to verify a few more details to proceed.
- Can you confirm the items in order 1234 that arrived damaged?
- Do you have any photos of the damaged items/packaging? (Photos help speed up the process.)
**2. Refund Request Submission:**
- Once I have the details, I will submit your refund request for review.
**3. Return Instructions (if needed):**
- In some cases, we may provide instructions on how to return the damaged items.
- You will receive a prepaid return label if necessary.
**4. Refund Processing:**
- After your request is approved (and any returns are received if required), your refund will be processed.
- Refunds usually appear on your original payment method within 5-10 business days.
Could you please reply with the specific item(s) damaged and, if possible, attach photos? This will help me get your refund started right away.
- triage_agent: Could you please provide more details about the issue you're experiencing with your recent purchase? This will help me route you to the appropriate specialist.
- user: My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.
- triage_agent: I've directed your request to our return agent, who will assist you with returning the damaged order. Thank you for your patience!
- return_agent: The return for your order 1234 has been successfully initiated. You will receive return instructions via email shortly. If you have any other questions or need further assistance, feel free to ask!
- user: Thanks for resolving this.
- triage_agent: You're welcome! If you have any more questions or need assistance in the future, feel free to reach out. Have a great day!
===================================
[status] IDLE
[Workflow Status] IDLE
""" # noqa: E501
@@ -0,0 +1,240 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Handoff Workflow with Code Interpreter File Generation Sample
This sample demonstrates retrieving file IDs from code interpreter output
in a handoff workflow context. A triage agent routes to a code specialist
that generates a text file, and we verify the file_id is captured correctly
from the streaming AgentRunUpdateEvent events.
Verifies GitHub issue #2718: files generated by code interpreter in
HandoffBuilder workflows can be properly retrieved.
Toggle USE_V2_CLIENT to switch between:
- V1: AzureAIAgentClient (azure-ai-agents SDK)
- V2: AzureAIClient (azure-ai-projects 2.x with Responses API)
IMPORTANT: When using V2 AzureAIClient with HandoffBuilder, each agent must
have its own client instance. The V2 client binds to a single server-side
agent name, so sharing a client between agents causes routing issues.
Prerequisites:
- `az login` (Azure CLI authentication)
- V1: AZURE_AI_AGENT_PROJECT_CONNECTION_STRING
- V2: AZURE_AI_PROJECT_ENDPOINT, AZURE_AI_MODEL_DEPLOYMENT_NAME
"""
import asyncio
from collections.abc import AsyncIterable, AsyncIterator
from contextlib import asynccontextmanager
from agent_framework import (
AgentRunUpdateEvent,
ChatAgent,
HandoffBuilder,
HandoffUserInputRequest,
HostedCodeInterpreterTool,
HostedFileContent,
RequestInfoEvent,
TextContent,
WorkflowEvent,
WorkflowRunState,
WorkflowStatusEvent,
)
from azure.identity.aio import AzureCliCredential
# Toggle between V1 (AzureAIAgentClient) and V2 (AzureAIClient)
USE_V2_CLIENT = False
async def _drain(stream: AsyncIterable[WorkflowEvent]) -> list[WorkflowEvent]:
"""Collect all events from an async stream."""
return [event async for event in stream]
def _handle_events(events: list[WorkflowEvent]) -> tuple[list[RequestInfoEvent], list[str]]:
"""Process workflow events and extract file IDs and pending requests.
Returns:
Tuple of (pending_requests, file_ids_found)
"""
requests: list[RequestInfoEvent] = []
file_ids: list[str] = []
for event in events:
if isinstance(event, WorkflowStatusEvent):
if event.state in {WorkflowRunState.IDLE, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS}:
print(f"[status] {event.state.name}")
elif isinstance(event, RequestInfoEvent):
if isinstance(event.data, HandoffUserInputRequest):
print("\n=== Conversation So Far ===")
for msg in event.data.conversation:
speaker = msg.author_name or msg.role.value
text = msg.text or ""
txt = text[:200] + "..." if len(text) > 200 else text
print(f"- {speaker}: {txt}")
print("===========================\n")
requests.append(event)
elif isinstance(event, AgentRunUpdateEvent):
update = event.data
if update is None:
continue
for content in update.contents:
if isinstance(content, HostedFileContent):
file_ids.append(content.file_id)
print(f"[Found HostedFileContent: file_id={content.file_id}]")
elif isinstance(content, TextContent) and content.annotations:
for annotation in content.annotations:
if hasattr(annotation, "file_id") and annotation.file_id:
file_ids.append(annotation.file_id)
print(f"[Found file annotation: file_id={annotation.file_id}]")
return requests, file_ids
@asynccontextmanager
async def create_agents_v1(credential: AzureCliCredential) -> AsyncIterator[tuple[ChatAgent, ChatAgent]]:
"""Create agents using V1 AzureAIAgentClient."""
from agent_framework.azure import AzureAIAgentClient
async with AzureAIAgentClient(credential=credential) as client:
triage = client.create_agent(
name="triage_agent",
instructions=(
"You are a triage agent. Route code-related requests to the code_specialist. "
"When the user asks to create or generate files, hand off to code_specialist "
"by calling handoff_to_code_specialist."
),
)
code_specialist = client.create_agent(
name="code_specialist",
instructions=(
"You are a Python code specialist. Use the code interpreter to execute Python code "
"and create files when requested. Always save files to /mnt/data/ directory."
),
tools=[HostedCodeInterpreterTool()],
)
yield triage, code_specialist
@asynccontextmanager
async def create_agents_v2(credential: AzureCliCredential) -> AsyncIterator[tuple[ChatAgent, ChatAgent]]:
"""Create agents using V2 AzureAIClient.
Each agent needs its own client instance because the V2 client binds
to a single server-side agent name.
"""
from agent_framework.azure import AzureAIClient
async with (
AzureAIClient(credential=credential) as triage_client,
AzureAIClient(credential=credential) as code_client,
):
triage = triage_client.create_agent(
name="TriageAgent",
instructions=(
"You are a triage agent. Your ONLY job is to route requests to the appropriate specialist. "
"For code or file creation requests, call handoff_to_CodeSpecialist immediately. "
"Do NOT try to complete tasks yourself. Just hand off."
),
)
code_specialist = code_client.create_agent(
name="CodeSpecialist",
instructions=(
"You are a Python code specialist. You have access to a code interpreter tool. "
"Use the code interpreter to execute Python code and create files. "
"Always save files to /mnt/data/ directory. "
"Do NOT discuss handoffs or routing - just complete the coding task directly."
),
tools=[HostedCodeInterpreterTool()],
)
yield triage, code_specialist
async def main() -> None:
"""Run a simple handoff workflow with code interpreter file generation."""
client_version = "V2 (AzureAIClient)" if USE_V2_CLIENT else "V1 (AzureAIAgentClient)"
print(f"=== Handoff Workflow with Code Interpreter File Generation [{client_version}] ===\n")
async with AzureCliCredential() as credential:
create_agents = create_agents_v2 if USE_V2_CLIENT else create_agents_v1
async with create_agents(credential) as (triage, code_specialist):
workflow = (
HandoffBuilder()
.participants([triage, code_specialist])
.set_coordinator(triage)
.with_termination_condition(lambda conv: sum(1 for msg in conv if msg.role.value == "user") >= 2)
.build()
)
user_inputs = [
"Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it.",
"exit",
]
input_index = 0
all_file_ids: list[str] = []
print(f"User: {user_inputs[0]}")
events = await _drain(workflow.run_stream(user_inputs[0]))
requests, file_ids = _handle_events(events)
all_file_ids.extend(file_ids)
input_index += 1
while requests:
request = requests[0]
if input_index >= len(user_inputs):
break
user_input = user_inputs[input_index]
print(f"\nUser: {user_input}")
responses = {request.request_id: user_input}
events = await _drain(workflow.send_responses_streaming(responses))
requests, file_ids = _handle_events(events)
all_file_ids.extend(file_ids)
input_index += 1
print("\n" + "=" * 50)
if all_file_ids:
print(f"SUCCESS: Found {len(all_file_ids)} file ID(s) in handoff workflow:")
for fid in all_file_ids:
print(f" - {fid}")
else:
print("WARNING: No file IDs captured from the handoff workflow.")
print("=" * 50)
"""
Sample Output:
User: Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it.
[Found HostedFileContent: file_id=assistant-JT1sA...]
=== Conversation So Far ===
- user: Please create a text file called hello.txt with 'Hello from handoff workflow!' inside it.
- triage_agent: I am handing off your request to create the text file "hello.txt" with the specified content to the code specialist. They will assist you shortly.
- code_specialist: The file "hello.txt" has been created with the content "Hello from handoff workflow!". You can download it using the link below:
[hello.txt](sandbox:/mnt/data/hello.txt)
===========================
[status] IDLE_WITH_PENDING_REQUESTS
User: exit
[status] IDLE
==================================================
SUCCESS: Found 1 file ID(s) in handoff workflow:
- assistant-JT1sA...
==================================================
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,132 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
from typing import Annotated, Any
from agent_framework import ChatMessage, SequentialBuilder, WorkflowOutputEvent, ai_function
from agent_framework.openai import OpenAIChatClient
from pydantic import Field
"""
Sample: Workflow kwargs Flow to @ai_function Tools
This sample demonstrates how to flow custom context (skill data, user tokens, etc.)
through any workflow pattern to @ai_function tools using the **kwargs pattern.
Key Concepts:
- Pass custom context as kwargs when invoking workflow.run_stream() or workflow.run()
- kwargs are stored in SharedState and passed to all agent invocations
- @ai_function tools receive kwargs via **kwargs parameter
- Works with Sequential, Concurrent, GroupChat, Handoff, and Magentic patterns
Prerequisites:
- OpenAI environment variables configured
"""
# Define tools that accept custom context via **kwargs
@ai_function
def get_user_data(
query: Annotated[str, Field(description="What user data to retrieve")],
**kwargs: Any,
) -> str:
"""Retrieve user-specific data based on the authenticated context."""
user_token = kwargs.get("user_token", {})
user_name = user_token.get("user_name", "anonymous")
access_level = user_token.get("access_level", "none")
print(f"\n[get_user_data] Received kwargs keys: {list(kwargs.keys())}")
print(f"[get_user_data] User: {user_name}")
print(f"[get_user_data] Access level: {access_level}")
return f"Retrieved data for user {user_name} with {access_level} access: {query}"
@ai_function
def call_api(
endpoint_name: Annotated[str, Field(description="Name of the API endpoint to call")],
**kwargs: Any,
) -> str:
"""Call an API using the configured endpoints from custom_data."""
custom_data = kwargs.get("custom_data", {})
api_config = custom_data.get("api_config", {})
base_url = api_config.get("base_url", "unknown")
endpoints = api_config.get("endpoints", {})
print(f"\n[call_api] Received kwargs keys: {list(kwargs.keys())}")
print(f"[call_api] Base URL: {base_url}")
print(f"[call_api] Available endpoints: {list(endpoints.keys())}")
if endpoint_name in endpoints:
return f"Called {base_url}{endpoints[endpoint_name]} successfully"
return f"Endpoint '{endpoint_name}' not found in configuration"
async def main() -> None:
print("=" * 70)
print("Workflow kwargs Flow Demo (SequentialBuilder)")
print("=" * 70)
# Create chat client
chat_client = OpenAIChatClient()
# Create agent with tools that use kwargs
agent = chat_client.create_agent(
name="assistant",
instructions=(
"You are a helpful assistant. Use the available tools to help users. "
"When asked about user data, use get_user_data. "
"When asked to call an API, use call_api."
),
tools=[get_user_data, call_api],
)
# Build a simple sequential workflow
workflow = SequentialBuilder().participants([agent]).build()
# Define custom context that will flow to ai_functions via kwargs
custom_data = {
"api_config": {
"base_url": "https://api.example.com",
"endpoints": {
"users": "/v1/users",
"orders": "/v1/orders",
"products": "/v1/products",
},
},
}
user_token = {
"user_name": "bob@contoso.com",
"access_level": "admin",
}
print("\nCustom Data being passed:")
print(json.dumps(custom_data, indent=2))
print(f"\nUser: {user_token['user_name']}")
print("\n" + "-" * 70)
print("Workflow Execution (watch for [tool_name] logs showing kwargs received):")
print("-" * 70)
# Run workflow with kwargs - these will flow through to ai_functions
async for event in workflow.run_stream(
"Please get my user data and then call the users API endpoint.",
custom_data=custom_data,
user_token=user_token,
):
if isinstance(event, WorkflowOutputEvent):
output_data = event.data
if isinstance(output_data, list):
for item in output_data:
if isinstance(item, ChatMessage) and item.text:
print(f"\n[Final Answer]: {item.text}")
print("\n" + "=" * 70)
print("Sample Complete")
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())