chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
# Workflows Getting Started Samples
## Installation
Microsoft Agent Framework Workflows support ships with the core `agent-framework` or `agent-framework-core` package, so no extra installation step is required.
To install with visualization support:
```bash
pip install agent-framework[viz] --pre
```
To export visualization images you also need to [install GraphViz](https://graphviz.org/download/).
## Samples Overview
## Foundational Concepts - Start Here
Begin with the `_start-here` folder in order. These three samples introduce the core ideas of executors, edges, agents in workflows, and streaming.
| Sample | File | Concepts |
| -------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| Executors and Edges | [\_start-here/step1_executors_and_edges.py](./_start-here/step1_executors_and_edges.py) | Minimal workflow with basic executors and edges |
| Agents in a Workflow | [\_start-here/step2_agents_in_a_workflow.py](./_start-here/step2_agents_in_a_workflow.py) | Introduces adding Agents as nodes; calling agents inside a workflow |
| Streaming (Basics) | [\_start-here/step3_streaming.py](./_start-here/step3_streaming.py) | Extends workflows with event streaming |
Once comfortable with these, explore the rest of the samples below.
---
## Samples Overview (by directory)
### functional
Write workflows as plain Python async functions — no graph concepts, no executor classes, no edges. Use native control flow (`if`/`else`, loops, `asyncio.gather`) for branching and parallelism.
| Sample | File | Concepts |
|---|---|---|
| Basic Pipeline | [functional/basic_pipeline.py](./functional/basic_pipeline.py) | Sequential steps as plain async functions |
| Basic Streaming Pipeline | [functional/basic_streaming_pipeline.py](./functional/basic_streaming_pipeline.py) | Stream workflow events in real time with `run(stream=True)` |
| Parallel Pipeline | [functional/parallel_pipeline.py](./functional/parallel_pipeline.py) | Fan-out/fan-in with `asyncio.gather` |
| Steps and Checkpointing | [functional/steps_and_checkpointing.py](./functional/steps_and_checkpointing.py) | `@step` decorator for per-step checkpointing and observability |
| Human-in-the-Loop Review | [functional/hitl_review.py](./functional/hitl_review.py) | HITL with `ctx.request_info()` and replay |
| Agent Integration | [functional/agent_integration.py](./functional/agent_integration.py) | Calling agents inside workflow steps |
| Naive Group Chat | [functional/naive_group_chat.py](./functional/naive_group_chat.py) | Simple round-robin group chat as a plain loop |
### agents
| Sample | File | Concepts |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Azure Chat Agents (Streaming) | [agents/azure_chat_agents_streaming.py](./agents/azure_chat_agents_streaming.py) | Add Azure Chat agents as edges and handle streaming events |
| Azure AI Agents (Streaming) | [agents/azure_ai_agents_streaming.py](./agents/azure_ai_agents_streaming.py) | Add Azure AI agents as edges and handle streaming events |
| Azure AI Agents (Shared Thread) | [agents/azure_ai_agents_with_shared_session.py](./agents/azure_ai_agents_with_shared_session.py) | Share a common message session between multiple Azure AI agents in a workflow |
| Custom Agent Executors | [agents/custom_agent_executors.py](./agents/custom_agent_executors.py) | Create executors to handle agent run methods |
| 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 Session | [agents/workflow_as_agent_with_session.py](./agents/workflow_as_agent_with_session.py) | Use AgentSession to maintain conversation history across workflow-as-agent invocations |
| Workflow as Agent kwargs | [agents/workflow_as_agent_kwargs.py](./agents/workflow_as_agent_kwargs.py) | Pass custom context (data, user tokens) via kwargs through workflow.as_agent() to @tool tools |
### checkpoint
| Sample | File | Concepts |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Checkpoint & Resume | [checkpoint/checkpoint_with_resume.py](./checkpoint/checkpoint_with_resume.py) | Create checkpoints, inspect them, and resume execution |
| 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 | [orchestrations/handoff_with_tool_approval_checkpoint_resume.py](./orchestrations/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 |
| Cosmos DB Checkpoint Storage | [checkpoint/cosmos_workflow_checkpointing.py](./checkpoint/cosmos_workflow_checkpointing.py) | Use `CosmosCheckpointStorage` for durable workflow checkpointing backed by Azure Cosmos DB NoSQL |
| Cosmos DB + Foundry Checkpoint | [checkpoint/cosmos_workflow_checkpointing_foundry.py](./checkpoint/cosmos_workflow_checkpointing_foundry.py) | Multi-agent workflow using `FoundryChatClient` with `CosmosCheckpointStorage` for durable pause/resume |
### composition
| Sample | File | Concepts |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| Sub-Workflow (Basics) | [composition/sub_workflow_basics.py](./composition/sub_workflow_basics.py) | Wrap a workflow as an executor and orchestrate sub-workflows |
| Sub-Workflow: Request Interception | [composition/sub_workflow_request_interception.py](./composition/sub_workflow_request_interception.py) | Intercept and forward sub-workflow requests using @handler for SubWorkflowRequestMessage |
| Sub-Workflow: Parallel Requests | [composition/sub_workflow_parallel_requests.py](./composition/sub_workflow_parallel_requests.py) | Multiple specialized interceptors handling different request types from same sub-workflow |
| Sub-Workflow: kwargs Propagation | [composition/sub_workflow_kwargs.py](./composition/sub_workflow_kwargs.py) | Pass custom context (user tokens, config) from parent workflow through to sub-workflow agents |
### control-flow
| Sample | File | Concepts |
| -------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
| Sequential Executors | [control-flow/sequential_executors.py](./control-flow/sequential_executors.py) | Sequential workflow with explicit executor setup |
| Sequential (Streaming) | [control-flow/sequential_streaming.py](./control-flow/sequential_streaming.py) | Stream events from a simple sequential run |
| Edge Condition | [control-flow/edge_condition.py](./control-flow/edge_condition.py) | Conditional routing based on agent classification |
| Switch-Case Edge Group | [control-flow/switch_case_edge_group.py](./control-flow/switch_case_edge_group.py) | Switch-case branching using classifier outputs |
| Multi-Selection Edge Group | [control-flow/multi_selection_edge_group.py](./control-flow/multi_selection_edge_group.py) | Select one or many targets dynamically (subset fan-out) |
| Simple Loop | [control-flow/simple_loop.py](./control-flow/simple_loop.py) | Feedback loop where an agent judges ABOVE/BELOW/MATCHED |
| Workflow Cancellation | [control-flow/workflow_cancellation.py](./control-flow/workflow_cancellation.py) | Cancel a running workflow using asyncio tasks |
| Workflow and Intermediate Outputs | [control-flow/intermediate_vs_terminal_outputs.py](./control-flow/intermediate_vs_terminal_outputs.py) | Select Workflow Output and Intermediate Output executors; hide unselected yields; map Intermediate Output events to `text_reasoning` content via `as_agent` |
### human-in-the-loop
| Sample | File | Concepts |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human via `ctx.request_info()` |
| Agents with Approval Requests in Workflows | [human-in-the-loop/agents_with_approval_requests.py](./human-in-the-loop/agents_with_approval_requests.py) | Agents that create approval requests during workflow execution and wait for human approval to proceed |
| Agents with Declaration-Only Tools | [human-in-the-loop/agents_with_declaration_only_tools.py](./human-in-the-loop/agents_with_declaration_only_tools.py) | Workflow pauses when agent calls a client-side tool (`func=None`), caller supplies the result |
Builder-oriented request-info samples are maintained in the orchestration sample set
(sequential, concurrent, and group-chat builder variants).
### tool-approval
Builder-based tool approval samples are maintained in the orchestration sample set.
### observability
| Sample | File | Concepts |
| ------------------------ | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Executor I/O Observation | [observability/executor_io_observation.py](./observability/executor_io_observation.py) | Observe executor input/output data via executor_invoked events (type='executor_invoked') and executor_completed events (type='executor_completed') without modifying executor code |
For additional observability samples in Agent Framework, see the [observability concept samples](../02-agents/observability/README.md). The [workflow observability sample](../02-agents/observability/workflow_observability.py) demonstrates integrating observability into workflows.
### orchestration
Orchestration-focused samples (Sequential, Concurrent, Handoff, GroupChat, Magentic), including builder-based
`workflow.as_agent(...)` variants, are documented in the [orchestrations](./orchestrations/README.md) directory.
### output selection
Workflow Output selection controls which `ctx.yield_output(...)` calls are visible to callers as `type='output'`
events and through `WorkflowRunResult.get_outputs()`. The core rule is that `output_from` is an allow-list for
Workflow Output, not a routing rule for every other executor output. Unselected executor payloads are hidden unless
`intermediate_output_from` explicitly selects them as Intermediate Output.
Use `output_from` and `intermediate_output_from` as the canonical API:
| Selection | Workflow Output | Intermediate Output | Hidden payloads |
| --- | --- | --- | --- |
| Omit both selections | Every executor `yield_output`; emits a deprecation warning | None | None |
| `output_from="all"` | Every executor `yield_output`; no warning | None | None |
| `output_from=[answerer]` | Only `answerer` | None | All other executor payloads |
| `output_from=[answerer], intermediate_output_from="all_other"` | Only `answerer` | Every output-capable executor not selected by `output_from` | None |
| `intermediate_output_from="all_other"` | None | Every output-capable executor | None |
| `output_from=[], intermediate_output_from="all_other"` | None | Every output-capable executor | None |
| `output_from=[answerer], intermediate_output_from=[planner, researcher]` | Only `answerer` | `planner` and `researcher` | Any other executor payloads |
Invalid selections fail at construction or build time:
| Invalid selection | Why it fails |
| --- | --- |
| `output_from="all_other"` | `"all_other"` is only valid for `intermediate_output_from` |
| `intermediate_output_from="all"` | `"all"` is only valid for `output_from` |
| The same executor in both selections | One payload cannot be both Workflow Output and Intermediate Output |
| Duplicate executor selections | Duplicates are treated as configuration errors |
| Unknown executor selections | Typos and missing participants are rejected |
| `output_from=[], intermediate_output_from=[]` | Both explicit selections are empty |
Compatibility aliases such as `output_executors` emit deprecation warnings where supported. New samples and
applications should use `output_from` and `intermediate_output_from`.
When a workflow is wrapped with `workflow.as_agent()`, Workflow Output becomes normal agent text content. Intermediate
Output becomes `text_reasoning` content, so `AgentResponse.text` remains focused on the caller-facing answer while
callers can still inspect progress or supporting work from the response messages.
### parallelism
| Sample | File | Concepts |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| Concurrent (Fan-out/Fan-in) | [parallelism/fan_out_fan_in_edges.py](./parallelism/fan_out_fan_in_edges.py) | Dispatch to multiple executors and aggregate results |
| Aggregate Results of Different Types | [parallelism/aggregate_results_of_different_types.py](./parallelism/aggregate_results_of_different_types.py) | Handle results of different types from multiple concurrent executors |
| Map-Reduce with Visualization | [parallelism/map_reduce_and_visualization.py](./parallelism/map_reduce_and_visualization.py) | Fan-out/fan-in pattern with diagram export |
### state-management
| Sample | File | Concepts |
| -------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
| State with Agents | [state-management/state_with_agents.py](./state-management/state_with_agents.py) | Store in state once and later reuse across agents |
| Workflow Kwargs - Global Context | [state-management/workflow_kwargs_global.py](./state-management/workflow_kwargs_global.py) | Pass custom context (data, user tokens) via kwargs to `@tool` tools in all agents |
| Workflow Kwargs - Per Agent | [state-management/workflow_kwargs_per_agent.py](./state-management/workflow_kwargs_per_agent.py) | Pass custom context (data, user tokens) via kwargs to `@tool` tools in individual agents |
### visualization
| Sample | File | Concepts |
| ----------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| Concurrent with Visualization | [visualization/concurrent_with_visualization.py](./visualization/concurrent_with_visualization.py) | Fan-out/fan-in workflow with diagram export |
### declarative
YAML-based declarative workflows allow you to define multi-agent orchestration patterns without writing Python code. See the [declarative workflows README](./declarative/README.md) for more details on YAML workflow syntax and available actions.
| Sample | File | Concepts |
|---|---|---|
| Agent to Function Tool | [declarative/agent_to_function_tool/](./declarative/agent_to_function_tool/) | Chain agent output to InvokeFunctionTool actions |
| Conditional Workflow | [declarative/conditional_workflow/](./declarative/conditional_workflow/) | Nested conditional branching based on user input |
| Customer Support | [declarative/customer_support/](./declarative/customer_support/) | Multi-agent customer support with routing |
| Deep Research | [declarative/deep_research/](./declarative/deep_research/) | Research workflow with planning, searching, and synthesis |
| Function Tools | [declarative/function_tools/](./declarative/function_tools/) | Invoking Python functions from declarative workflows |
| Human-in-Loop | [declarative/human_in_loop/](./declarative/human_in_loop/) | Interactive workflows that request user input |
| Invoke Function Tool | [declarative/invoke_function_tool/](./declarative/invoke_function_tool/) | Call registered Python functions with InvokeFunctionTool |
| Marketing | [declarative/marketing/](./declarative/marketing/) | Marketing content generation workflow |
| Simple Workflow | [declarative/simple_workflow/](./declarative/simple_workflow/) | Basic workflow with variable setting, conditionals, and loops |
| Student Teacher | [declarative/student_teacher/](./declarative/student_teacher/) | Student-teacher interaction pattern |
### resources
- Sample text inputs used by certain workflows:
- [resources/long_text.txt](./resources/long_text.txt)
- [resources/email.txt](./resources/email.txt)
- [resources/spam.txt](./resources/spam.txt)
- [resources/ambiguous_email.txt](./resources/ambiguous_email.txt)
Notes
- Agent-based samples use provider SDKs (Azure/OpenAI, etc.). Ensure credentials are configured, or adapt agents accordingly.
Sequential orchestration uses a few small adapter nodes for plumbing:
- "input-conversation" normalizes input to `list[Message]`
- "to-conversation:<participant>" converts agent responses into the shared conversation
- "complete" publishes the Workflow Output event (`type='output'`)
These may appear in event streams (executor_invoked/executor_completed). They're analogous to
concurrents dispatcher and aggregator and can be ignored if you only care about agent activity.
### Why FoundryChatClient?
Workflow and orchestration samples use `FoundryChatClient` because they create agents locally and do not need
server-managed agent resources. This lightweight, project-backed chat client is a good fit for orchestration
patterns such as Sequential, Concurrent, Handoff, GroupChat, and Magentic.
If you need persistent server-side agent resources, use the hosted-agent flows rather than these workflow samples.
### Environment Variables
Workflow samples that use `FoundryChatClient` expect:
- `FOUNDRY_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint)
- `FOUNDRY_MODEL` (model deployment name)
These values are passed directly into the client constructor via `os.getenv()` in sample code.
@@ -0,0 +1,226 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import (
Executor,
Workflow,
WorkflowBuilder,
WorkflowContext,
executor,
handler,
)
from typing_extensions import Never
"""
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.
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 yield output
using ctx.yield_output() to provide workflow results.
- Explicit type parameters with @handler:
Instead of relying on type introspection from function signatures, you can explicitly
specify `input`, `output`, and/or `workflow_output` on the @handler decorator.
This is "all or nothing": when ANY explicit parameter is provided, ALL types come
from explicit parameters (introspection is disabled). The `input` parameter is
required; `output` and `workflow_output` are optional.
Examples:
@handler(input=str | int) # Accepts str or int, no outputs
@handler(input=str, output=int) # Accepts str, outputs int
@handler(input=str, output=int, workflow_output=bool) # All three specified
- Fluent WorkflowBuilder API:
add_edge(A, B) to connect nodes, set_start_executor(A), then build() -> Workflow.
- State isolation via helper functions:
Wrapping executor instantiation and workflow building inside a function
(e.g., create_workflow()) ensures each call produces fresh, independent
instances. This is the recommended pattern for reuse.
- Running and results:
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.
"""
# Example 1: A custom Executor subclass using introspection (traditional approach)
# ---------------------------------------------------------------------------------
#
# Subclassing Executor lets you define a named node with lifecycle hooks if needed.
# The work itself is implemented in an async method decorated with @handler.
#
# Handler signature contract:
# - First parameter is the typed input to this node (here: text: 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
# - Forward that result to downstream node(s) using ctx.send_message(result)
class UpperCase(Executor):
def __init__(self, id: str):
super().__init__(id=id)
@handler
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
"""Convert the input to uppercase and forward it to the next node.
Note: The WorkflowContext is parameterized with the type this handler will
emit. Here WorkflowContext[str] means downstream nodes should expect str.
"""
result = text.upper()
# Send the result to the next executor in the workflow.
await ctx.send_message(result)
# Example 2: A standalone function-based executor using introspection
# --------------------------------------------------------------------
#
# For simple steps you can skip subclassing and define an async function with the
# 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[Never, str]) -> None:
"""Reverse the input string and yield the workflow output.
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]
# Yield the output - the workflow will complete when idle
await ctx.yield_output(result)
# Example 3: Using explicit type parameters on @handler
# -----------------------------------------------------
#
# Instead of relying on type introspection, you can explicitly specify input,
# output, and/or workflow_output on the @handler decorator. This is "all or nothing":
# when ANY explicit parameter is provided, ALL types come from explicit parameters
# (introspection is completely disabled). The input parameter is required.
#
# This is useful when:
# - You want to accept multiple types (union types) without complex type annotations
# - The function signature uses Any or a base type for flexibility
# - You want to decouple the runtime type routing from the static type annotations
class ExclamationAdder(Executor):
"""An executor that adds exclamation marks, demonstrating explicit @handler types.
This example shows how to use explicit input and output parameters
on the @handler decorator instead of relying on introspection from the function
signature. This approach is especially useful for union types.
"""
def __init__(self, id: str):
super().__init__(id=id)
@handler(input=str, output=str)
async def add_exclamation(self, message, ctx) -> None: # type: ignore
"""Add exclamation marks to the input.
Note: The input=str and output=str are explicitly specified on @handler,
so the framework uses those instead of introspecting the function signature.
The WorkflowContext here has no type parameters because the explicit types
on @handler take precedence.
"""
result = f"{message}!!!"
await ctx.send_message(result) # type: ignore
def create_workflow() -> Workflow:
"""Create a fresh workflow with isolated state.
Wrapping workflow construction in a helper function ensures each call
produces independent executor instances. This is the recommended pattern
for reuse — call create_workflow() each time you need a new workflow so
that no state leaks between runs.
"""
upper_case = UpperCase(id="upper_case_executor")
return WorkflowBuilder(start_executor=upper_case).add_edge(upper_case, reverse_text).build()
async def main():
"""Build and run workflows using the fluent builder API."""
# Workflow 1: Using the helper function pattern for state isolation
# ------------------------------------------------------------------
# Each call to create_workflow() returns a workflow with fresh executor
# instances. This is the recommended pattern when you need to run the
# same workflow topology multiple times with clean state.
workflow1 = create_workflow()
# Run the workflow by sending the initial message to the start node.
# The run(...) call returns an event collection; its get_outputs() method
# retrieves the outputs yielded by any terminal nodes.
print("Workflow 1 (introspection-based types):")
events1 = await workflow1.run("hello world")
print(events1.get_outputs())
print("Final state:", events1.get_final_state())
# Workflow 2: Using explicit type parameters on @handler
# -------------------------------------------------------
upper_case = UpperCase(id="upper_case_executor")
exclamation_adder = ExclamationAdder(id="exclamation_adder")
# This workflow demonstrates the explicit input/output feature:
# exclamation_adder uses @handler(input=str, output=str) to
# explicitly declare types instead of relying on introspection.
workflow2 = (
WorkflowBuilder(start_executor=upper_case)
.add_edge(upper_case, exclamation_adder)
.add_edge(exclamation_adder, reverse_text)
.build()
)
print("\nWorkflow 2 (explicit @handler types):")
events2 = await workflow2.run("hello world")
print(events2.get_outputs())
print("Final state:", events2.get_final_state())
"""
Sample Output:
Workflow 1 (introspection-based types):
['DLROW OLLEH']
Final state: WorkflowRunState.IDLE
Workflow 2 (explicit @handler types):
['!!!DLROW OLLEH']
Final state: WorkflowRunState.IDLE
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,89 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import cast
from agent_framework import Agent, AgentResponse, WorkflowBuilder
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Step 2: Agents in a Workflow non-streaming
This sample creates two agents: a Writer agent creates or edits content, and a Reviewer agent which
evaluates and provides feedback.
Purpose:
Show how to create agents from FoundryChatClient and use them directly in a workflow. Demonstrate
how agents can be used in a workflow.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be the deployment name of a model in your Foundry project.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, edges, events, and streaming or non-streaming runs.
"""
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Create the Azure chat client. AzureCliCredential uses your current az login.
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
writer_agent = Agent(
client=client,
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
name="writer",
)
reviewer_agent = Agent(
client=client,
instructions=(
"You are an excellent content reviewer."
"Provide actionable feedback to the writer about the provided content."
"Provide the feedback in the most concise manner possible."
),
name="reviewer",
)
# Build the workflow using the fluent builder.
# Set the start node via constructor and connect an edge from writer to reviewer.
workflow = WorkflowBuilder(start_executor=writer_agent).add_edge(writer_agent, reviewer_agent).build()
# 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.")
outputs = events.get_outputs()
# The outputs of the workflow are whatever the agents produce. So the outputs are expected to be a list
# of `AgentResponse` from the agents in the workflow.
outputs = cast(list[AgentResponse], outputs)
for output in outputs:
print(f"{output.messages[0].author_name}: {output.text}\n")
# Summarize the final run state (e.g., COMPLETED)
print("Final state:", events.get_final_state())
"""
writer: "Charge Ahead: Affordable Adventure Awaits!"
reviewer: - Consider emphasizing both affordability and fun in a more dynamic way.
- Try using a catchy phrase that includes a play on words, like “Electrify Your Drive: Fun Meets Affordability!”
- Ensure the slogan is succinct while capturing the essence of the car's unique selling proposition.
Final state: WorkflowRunState.IDLE
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,96 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import Agent, AgentResponseUpdate, Message, WorkflowBuilder
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Step 3: Agents in a workflow with streaming
This sample creates two agents: a Writer agent creates or edits content, and a Reviewer agent which
evaluates and provides feedback.
Purpose:
Show how to create agents from FoundryChatClient and use them directly in a workflow. Demonstrate
how agents can be used in a workflow.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be the deployment name of a model in your Foundry project.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs.
"""
async def main():
"""Build the two node workflow and run it with streaming to observe events."""
# Create the Azure chat client. AzureCliCredential uses your current az login.
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
writer_agent = Agent(
client=client,
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
name="writer",
)
reviewer_agent = Agent(
client=client,
instructions=(
"You are an excellent content reviewer."
"Provide actionable feedback to the writer about the provided content."
"Provide the feedback in the most concise manner possible."
),
name="reviewer",
)
# Build the workflow using the fluent builder.
# Set the start node via constructor and connect an edge from writer to reviewer.
workflow = WorkflowBuilder(start_executor=writer_agent).add_edge(writer_agent, reviewer_agent).build()
# Track the last author to format streaming output.
last_author: str | None = None
# Run the workflow with the user's initial message and stream events as they occur.
async for event in workflow.run(
Message("user", ["Create a slogan for a new electric SUV that is affordable and fun to drive."]),
stream=True,
):
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if event.type == "output" and isinstance(event.data, AgentResponseUpdate):
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print() # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(update.text, end="", flush=True)
"""
writer: "Electrify Your Journey: Affordable Fun Awaits!"
reviewer: Feedback:
1. **Clarity**: Consider simplifying the message. "Affordable Fun" could be more direct.
2. **Emotional Appeal**: Emphasize the thrill of driving more. Try using words that evoke excitement.
3. **Unique Selling Proposition**: Highlight the electric aspect more boldly.
Example revision: "Charge Your Adventure: Affordable SUVs for Fun-Loving Drivers!"
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,77 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import Agent, AgentResponseUpdate, WorkflowBuilder
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Azure AI Agents in a Workflow with Streaming
This sample shows how to create agents backed by Azure OpenAI Responses and use them in a workflow with streaming.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be the deployment name of a model in your Foundry project.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, edges, events, and streaming runs.
"""
async def main() -> None:
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create two agents: a Writer and a Reviewer.
writer_agent = Agent(
client=client,
name="Writer",
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
)
reviewer_agent = Agent(
client=client,
name="Reviewer",
instructions=(
"You are an excellent content reviewer. "
"Provide actionable feedback to the writer about the provided content. "
"Provide the feedback in the most concise manner possible."
),
)
# Build the workflow by adding agents directly as edges.
# Agents adapt to workflow mode: run(stream=True) for incremental updates, run() for complete responses.
workflow = WorkflowBuilder(start_executor=writer_agent).add_edge(writer_agent, reviewer_agent).build()
# Track the last author to format streaming output.
last_author: str | None = None
events = workflow.run("Create a slogan for a new electric SUV that is affordable and fun to drive.", stream=True)
async for event in events:
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if event.type == "output" and isinstance(event.data, AgentResponseUpdate):
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print() # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(update.text, end="", flush=True)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,113 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import (
Agent,
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
InMemoryHistoryProvider,
WorkflowBuilder,
WorkflowContext,
WorkflowRunState,
executor,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Agents with a shared thread in a workflow
A Writer agent generates content, then a Reviewer agent critiques it, sharing a common message thread.
Purpose:
Show how to use a shared thread between multiple agents in a workflow.
By default, agents have individual threads, but sharing a thread allows them to share all messages.
Notes:
- Not all agents can share threads; usually only the same type of agents can share threads.
Demonstrate:
- Creating multiple agents with FoundryChatClient.
- Setting up a shared thread between agents.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with agents, workflows, and executors in the agent framework.
"""
@executor(id="intercept_agent_response")
async def intercept_agent_response(
agent_response: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorRequest]
) -> None:
"""This executor intercepts the agent response and sends a request without messages.
This essentially prevents duplication of messages in the shared thread. Without this
executor, the response will be added to the thread as input of the next agent call.
"""
await ctx.send_message(AgentExecutorRequest(messages=[]))
async def main() -> None:
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# set the same context provider (same default source_id) for both agents to share the thread
writer = Agent(
client=client,
instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."),
name="writer",
context_providers=[InMemoryHistoryProvider()],
)
reviewer = Agent(
client=client,
instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."),
name="reviewer",
context_providers=[InMemoryHistoryProvider()],
)
# Create the shared session
shared_session = writer.create_session()
writer_executor = AgentExecutor(writer, session=shared_session)
reviewer_executor = AgentExecutor(reviewer, session=shared_session)
workflow = (
WorkflowBuilder(start_executor=writer_executor)
.add_chain([writer_executor, intercept_agent_response, reviewer_executor])
.build()
)
result = await workflow.run(
"Write a tagline for a budget-friendly eBike.",
# client_kwargs are forwarded to each underlying chat client call.
# store=False tells the model API not to persist messages server-side
# for this example.
client_kwargs={"store": False},
)
# The final state should be IDLE since the workflow no longer has messages to
# process after the reviewer agent responds.
assert result.get_final_state() == WorkflowRunState.IDLE
# The shared session now contains the conversation between the writer and reviewer. Print it out.
print("=== Shared Session Conversation ===")
memory_state = shared_session.state.get(InMemoryHistoryProvider.DEFAULT_SOURCE_ID, {})
for message in memory_state.get("messages", []):
print(f"{message.author_name or message.role}: {message.text}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,159 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import Final
from agent_framework import (
Agent,
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponseUpdate,
Message,
WorkflowBuilder,
WorkflowContext,
executor,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: AzureOpenAI Chat Agents and an Executor in a Workflow with Streaming
Pipeline layout:
research_agent -> enrich_with_references (@executor) -> final_editor_agent
The first agent drafts a short answer. A lightweight @executor function simulates
an external data fetch and injects a follow-up user message containing extra context.
The final agent incorporates the new note and produces the polished output.
Demonstrates:
- Using the @executor decorator to create a function-style Workflow node.
- Consuming an AgentExecutorResponse and forwarding an AgentExecutorRequest for the next agent.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Run `az login` before executing.
"""
# Simulated external content keyed by a simple topic hint.
EXTERNAL_REFERENCES: Final[dict[str, str]] = {
"workspace": (
"From Workspace Weekly: Adjustable monitor arms and sit-stand desks can reduce "
"neck strain by up to 30%. Consider adding a reminder to move every 45 minutes."
),
"travel": (
"Checklist excerpt: Always confirm baggage limits for budget airlines. "
"Keep a photocopy of your passport stored separately from the original."
),
"wellness": (
"Recent survey: Employees who take two 5-minute breaks per hour report 18% higher focus "
"scores. Encourage scheduling micro-breaks alongside hydration reminders."
),
}
def _lookup_external_note(prompt: str) -> str | None:
"""Return the first matching external note based on a keyword search."""
lowered = prompt.lower()
for keyword, note in EXTERNAL_REFERENCES.items():
if keyword in lowered:
return note
return None
@executor(id="enrich_with_references")
async def enrich_with_references(
draft: AgentExecutorResponse,
ctx: WorkflowContext[AgentExecutorRequest],
) -> None:
"""Inject a follow-up user instruction that adds an external note for the next agent.
Args:
draft: The response from the research_agent containing the initial draft. This is
a `AgentExecutorResponse` because agents in workflows send their full response
wrapped in this type to connected executors.
ctx: The workflow context to send the next request.
"""
conversation = list(draft.full_conversation or draft.agent_response.messages)
original_prompt = next((message.text for message in conversation if message.role == "user"), "")
external_note = _lookup_external_note(original_prompt) or (
"No additional references were found. Please refine the previous assistant response for clarity."
)
follow_up = (
"External knowledge snippet:\n"
f"{external_note}\n\n"
"Please update the prior assistant answer so it weaves this note into the guidance."
)
conversation.append(Message("user", [follow_up]))
# Output a new AgentExecutorRequest for the next agent in the workflow.
# Agents in workflows handle this type and will generate a response based on the request.
await ctx.send_message(AgentExecutorRequest(messages=conversation))
async def main() -> None:
"""Run the workflow and stream combined updates from both agents."""
# Create the agents
research_agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="research_agent",
instructions=(
"Produce a short, bullet-style briefing with two actionable ideas. Label the section as 'Initial Draft'."
),
)
final_editor_agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="final_editor_agent",
instructions=(
"Use all conversation context (including external notes) to produce the final answer. "
"Merge the draft and extra note into a concise recommendation under 150 words."
),
)
workflow = (
WorkflowBuilder(start_executor=research_agent)
.add_edge(research_agent, enrich_with_references)
.add_edge(enrich_with_references, final_editor_agent)
.build()
)
events = workflow.run(
"Create quick workspace wellness tips for a remote analyst working across two monitors.", stream=True
)
# Track the last author to format streaming output.
last_author: str | None = None
async for event in events:
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if event.type == "output" and isinstance(event.data, AgentResponseUpdate):
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print("\n") # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(update.text, end="", flush=True)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,83 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import Agent, AgentResponseUpdate, WorkflowBuilder
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: AzureOpenAI Chat Agents in a Workflow with Streaming
This sample shows how to create AzureOpenAI Chat Agents and use them in a workflow with streaming.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, edges, events, and streaming runs.
"""
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Create the agents
_writer_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
writer_agent = Agent(
client=_writer_client,
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
name="writer",
)
_reviewer_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
reviewer_agent = Agent(
client=_reviewer_client,
instructions=(
"You are an excellent content reviewer."
"Provide actionable feedback to the writer about the provided content."
"Provide the feedback in the most concise manner possible."
),
name="reviewer",
)
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
# Agents adapt to workflow mode: run(stream=True) for incremental updates, run() for complete responses.
workflow = WorkflowBuilder(start_executor=writer_agent).add_edge(writer_agent, reviewer_agent).build()
# Track the last author to format streaming output.
last_author: str | None = None
events = workflow.run("Create a slogan for a new electric SUV that is affordable and fun to drive.", stream=True)
async for event in events:
# The outputs of the workflow are whatever the agents produce. So the events are expected to
# contain `AgentResponseUpdate` from the agents in the workflow.
if event.type == "output" and isinstance(event.data, AgentResponseUpdate):
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print() # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(update.text, end="", flush=True)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,323 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import os
from collections.abc import AsyncIterable
from dataclasses import dataclass, field
from typing import Annotated
from agent_framework import (
Agent,
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
Executor,
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
handler,
response_handler,
tool,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import Field
from typing_extensions import Never
# Load environment variables from .env file
load_dotenv()
"""
Sample: Tool-enabled agents with human feedback
Pipeline layout:
writer_agent (uses Azure OpenAI tools) -> Coordinator -> writer_agent
-> Coordinator -> final_editor_agent -> Coordinator -> output
The writer agent calls tools to gather product facts before drafting copy. A custom executor
packages the draft and emits a request_info event (type='request_info') so a human can comment, then replays the human
guidance back into the conversation before the final editor agent produces the polished output.
Demonstrates:
- Attaching Python function tools to an agent inside a workflow.
- Capturing the writer's output for human review.
- Streaming AgentRunUpdateEvent updates alongside human-in-the-loop pauses.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Run `az login` before executing.
"""
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# see samples/02-agents/tools/function_tool_with_approval.py and
# samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@tool(approval_mode="never_require")
def fetch_product_brief(
product_name: Annotated[str, Field(description="Product name to look up.")],
) -> str:
"""Return a marketing brief for a product."""
briefs = {
"lumenx desk lamp": (
"Product: LumenX Desk Lamp\n"
"- Three-point adjustable arm with 270° rotation.\n"
"- Custom warm-to-neutral LED spectrum (2700K-4000K).\n"
"- USB-C charging pad integrated in the base.\n"
"- Designed for home offices and late-night study sessions."
)
}
return briefs.get(product_name.lower(), f"No stored brief for '{product_name}'.")
@tool(approval_mode="never_require")
def get_brand_voice_profile(
voice_name: Annotated[str, Field(description="Brand or campaign voice to emulate.")],
) -> str:
"""Return guidance for the requested brand voice."""
voices = {
"lumenx launch": (
"Voice guidelines:\n"
"- Friendly and modern with concise sentences.\n"
"- Highlight practical benefits before aesthetics.\n"
"- End with an invitation to imagine the product in daily use."
)
}
return voices.get(voice_name.lower(), f"No stored voice profile for '{voice_name}'.")
@dataclass
class DraftFeedbackRequest:
"""Payload sent for human review."""
prompt: str = ""
draft_text: str = ""
conversation: list[Message] = field(default_factory=list) # type: ignore[reportUnknownVariableType]
class Coordinator(Executor):
"""Bridge between the writer agent, human feedback, and final editor."""
def __init__(self, id: str, writer_id: str, final_editor_id: str) -> None:
super().__init__(id)
self.writer_id = writer_id
self.final_editor_id = final_editor_id
@handler
async def on_writer_response(
self,
draft: AgentExecutorResponse,
ctx: WorkflowContext[Never, AgentResponse],
) -> None:
"""Handle responses from the other two agents in the workflow."""
if draft.executor_id == self.final_editor_id:
# Final editor response; yield output directly.
await ctx.yield_output(draft.agent_response)
return
# Writer agent response; request human feedback.
# Preserve the full conversation so the final editor
# can see tool traces and the initial prompt.
conversation = list(draft.full_conversation)
draft_text = draft.agent_response.text.strip()
if not draft_text:
draft_text = "No draft text was produced."
prompt = (
"Review the draft from the writer and provide a short directional note "
"(tone tweaks, must-have detail, target audience, etc.). "
"Keep it under 30 words."
)
await ctx.request_info(
request_data=DraftFeedbackRequest(prompt=prompt, draft_text=draft_text, conversation=conversation),
response_type=str,
)
@response_handler
async def on_human_feedback(
self,
original_request: DraftFeedbackRequest,
feedback: str,
ctx: WorkflowContext[AgentExecutorRequest],
) -> None:
note = feedback.strip()
if note.lower() == "approve":
# Human approved the draft as-is; forward it unchanged.
await ctx.send_message(
AgentExecutorRequest(
messages=[
*original_request.conversation,
*[Message("user", contents=["The draft is approved as-is."])],
],
should_respond=True,
),
target_id=self.final_editor_id,
)
return
# Human provided feedback; prompt the writer to revise.
instruction = (
"A human reviewer shared the following guidance:\n"
f"{note or 'No specific guidance provided.'}\n\n"
"Rewrite the draft from the previous assistant message into a polished final version. "
"Keep the response under 120 words and reflect any requested tone adjustments."
)
await ctx.send_message(
AgentExecutorRequest(messages=[Message("user", contents=[instruction])], should_respond=True),
target_id=self.writer_id,
)
def create_writer_agent() -> Agent:
"""Creates a writer agent with tools."""
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="writer_agent",
instructions=(
"You are a marketing writer. Call the available tools before drafting copy so you are precise. "
"Always call both tools once before drafting. Summarize tool outputs as bullet points, then "
"produce a 3-sentence draft."
),
tools=[fetch_product_brief, get_brand_voice_profile],
default_options={
"tool_choice": "required",
},
)
def create_final_editor_agent() -> Agent:
"""Creates a final editor agent."""
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="final_editor_agent",
instructions=(
"You are an editor who polishes marketing copy after human approval. "
"Correct any legal or factual issues. Return the final version even if no changes are made. "
),
)
def display_agent_run_update(event: WorkflowEvent, last_executor: str | None) -> None:
"""Display an AgentRunUpdateEvent in a readable format."""
printed_tool_calls: set[str] = set()
printed_tool_results: set[str] = set()
executor_id = event.executor_id
update = event.data
# Extract and print any new tool calls or results from the update.
function_calls = [c for c in update.contents if c.type == "function_call"] # type: ignore[union-attr]
function_results = [c for c in update.contents if c.type == "function_result"] # type: ignore[union-attr]
if executor_id != last_executor:
if last_executor is not None:
print()
print(f"{executor_id}:", end=" ", flush=True)
last_executor = executor_id
# Print any new tool calls before the text update.
for call in function_calls:
if call.call_id in printed_tool_calls:
continue
printed_tool_calls.add(call.call_id)
args = call.arguments
args_preview = json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else (args or "").strip()
print(
f"\n{executor_id} [tool-call] {call.name}({args_preview})",
flush=True,
)
print(f"{executor_id}:", end=" ", flush=True)
# Print any new tool results before the text update.
for result in function_results:
if result.call_id in printed_tool_results:
continue
printed_tool_results.add(result.call_id)
result_text = result.result
if not isinstance(result_text, str):
result_text = json.dumps(result_text, ensure_ascii=False)
print(
f"\n{executor_id} [tool-result] {result.call_id}: {result_text}",
flush=True,
)
print(f"{executor_id}:", end=" ", flush=True)
# Finally, print the text update.
print(update, end="", flush=True)
async def consume_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, str] | None:
"""Consume a workflow event stream, printing outputs and returning any pending human responses."""
requests: list[WorkflowEvent] = []
async for event in stream:
if event.type == "request_info" and isinstance(event.data, DraftFeedbackRequest):
# Stash the request so we can prompt the human after the stream completes.
requests.append(event)
if requests:
pending_responses: dict[str, str] = {}
for request in requests:
print("\n----- Writer draft -----")
print(request.data.draft_text.strip())
print("\nProvide guidance for the editor (or 'approve' to accept the draft).")
answer = input("Human feedback: ").strip() # noqa: ASYNC250
if answer.lower() == "exit":
print("Exiting...")
exit(0)
pending_responses[request.request_id] = answer
return pending_responses
return None
async def main() -> None:
"""Run the workflow and bridge human feedback between two agents."""
# Build the workflow.
writer_agent = AgentExecutor(create_writer_agent())
final_editor_agent = AgentExecutor(create_final_editor_agent())
coordinator = Coordinator(
id="coordinator",
writer_id="writer_agent",
final_editor_id="final_editor_agent",
)
workflow = (
WorkflowBuilder(start_executor=writer_agent)
.add_edge(writer_agent, coordinator)
.add_edge(coordinator, writer_agent)
.add_edge(final_editor_agent, coordinator)
.add_edge(coordinator, final_editor_agent)
.build()
)
print(
"Interactive mode. When prompted, provide a short feedback note for the editor.",
flush=True,
)
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run.
stream = workflow.run(
"Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting.",
stream=True,
)
pending_responses = await consume_stream(stream)
# Run until there are no more requests
while pending_responses is not None:
stream = workflow.run(stream=True, responses=pending_responses)
pending_responses = await consume_stream(stream)
print("Workflow complete.")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,84 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import ConcurrentBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Build a concurrent workflow orchestration and wrap it as an agent.
This script wires up a fan-out/fan-in workflow using `ConcurrentBuilder`, and then
invokes the entire orchestration through the `Agent(client=workflow,...)` interface so
downstream coordinators can reuse the orchestration as a single agent.
Demonstrates:
- Fan-out to multiple agents, fan-in aggregation of final ChatMessages.
- Reusing the orchestrated workflow as an agent entry point with `Agent(client=workflow,...)`.
- Workflow completion when idle with no pending work
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Familiarity with Workflow events (WorkflowEvent with type "output")
"""
async def main() -> None:
# 1) Create three domain agents using FoundryChatClient
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
researcher = Agent(
client=client,
instructions=(
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
" opportunities, and risks."
),
name="researcher",
)
marketer = Agent(
client=client,
instructions=(
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
" aligned to the prompt."
),
name="marketer",
)
legal = Agent(
client=client,
instructions=(
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
" based on the prompt."
),
name="legal",
)
# 2) Build a concurrent workflow
workflow = ConcurrentBuilder(participants=[researcher, marketer, legal]).build()
# 3) Expose the concurrent workflow as an agent for easy reuse
agent = workflow.as_agent()
prompt = "We are launching a new budget-friendly electric bike for urban commuters."
agent_response = await agent.run(prompt)
print("===== Final Aggregated Response =====\n")
for message in agent_response.messages:
# The agent_response contains messages from all participants concatenated
# into a single message.
print(f"{message.author_name}: {message.text}\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,146 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import (
Agent,
Executor,
Message,
WorkflowBuilder,
WorkflowContext,
handler,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Custom Agent Executors in a Workflow
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 FoundryChatClient inside workflow executors. Demonstrate the @handler
pattern with typed inputs and typed WorkflowContext[T] outputs, connect executors with the fluent WorkflowBuilder,
and finish by yielding outputs from the terminal node.
Note: When an agent is passed to a workflow, the workflow wraps the agent in a more sophisticated executor.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming or non streaming runs.
"""
class Writer(Executor):
"""Custom executor that owns a domain specific agent responsible for generating content.
This class demonstrates:
- Attaching a Agent to an Executor so it participates as a node in a workflow.
- Using a @handler method to accept a typed input and forward a typed output via ctx.send_message.
"""
agent: Agent
def __init__(self, id: str = "writer"):
# Create a domain specific agent using your configured FoundryChatClient.
self.agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=(
"You are an excellent content writer. You create new content and edit contents based on the feedback."
),
)
# Associate the agent with this executor node. The base Executor stores it on self.agent.
super().__init__(id=id)
@handler
async def handle(self, message: Message, ctx: WorkflowContext[list[Message], str]) -> None:
"""Generate content using the agent and forward the updated conversation.
Contract for this handler:
- message is the inbound user Message.
- ctx is a WorkflowContext that expects a list[Message] to be sent downstream.
Pattern shown here:
1) Seed the conversation with the inbound message.
2) Run the attached agent to produce assistant messages.
3) Forward the cumulative messages to the next executor with ctx.send_message.
"""
# Start the conversation with the incoming user message.
messages: list[Message] = [message]
# Run the agent and extend the conversation with the agent's messages.
response = await self.agent.run(messages)
messages.extend(response.messages)
# Forward the accumulated messages to the next executor in the workflow.
await ctx.send_message(messages)
class Reviewer(Executor):
"""Custom executor that owns a review agent and completes the workflow.
This class demonstrates:
- Consuming a typed payload produced upstream.
- Yielding the final text outcome to complete the workflow.
"""
agent: Agent
def __init__(self, id: str = "reviewer"):
# Create a domain specific agent that evaluates and refines content.
self.agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=(
"You are an excellent content reviewer. You review the content and provide feedback to the writer."
),
)
super().__init__(id=id)
@handler
async def handle(self, messages: list[Message], ctx: WorkflowContext[list[Message], 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 yielding the output.
"""
response = await self.agent.run(messages)
await ctx.yield_output(response.text)
async def main():
"""Build and run a simple two node agent workflow: Writer then Reviewer."""
# Create the executors
writer = Writer()
reviewer = Reviewer()
# Build the workflow using the fluent builder.
# Set the start node and connect an edge from writer to reviewer.
workflow = WorkflowBuilder(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 workflow output.
events = await workflow.run(
Message("user", ["Create a slogan for a new electric SUV that is affordable and fun to drive."])
)
# The terminal node yields output; print its contents.
outputs = events.get_outputs()
if outputs:
print(outputs[-1])
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,92 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import GroupChatBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Group Chat Orchestration
What it does:
- Demonstrates the generic GroupChatBuilder with a agent orchestrator directing two agents.
- The orchestrator coordinates a researcher (chat completions) and a writer (responses API) to solve a task.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- Environment variables configured for `FoundryChatClient`.
"""
async def main() -> None:
researcher = Agent(
name="Researcher",
description="Collects relevant background information.",
instructions="Gather concise facts that help a teammate answer the question.",
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
)
writer = Agent(
name="Writer",
description="Synthesizes a polished answer using the gathered notes.",
instructions="Compose clear and structured answers using any notes provided.",
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
)
_orch_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Mark participant responses as intermediate so workflow.as_agent() maps
# them to text_reasoning content while the final answer remains normal text.
workflow = GroupChatBuilder(
participants=[researcher, writer],
intermediate_output_from=[researcher, writer],
orchestrator_agent=Agent(
client=_orch_client,
name="Orchestrator",
instructions="You coordinate a team conversation to solve the user's task.",
),
).build()
task = "Outline the core considerations for planning a community hackathon, and finish with a concise action plan."
print("\nStarting Group Chat Workflow...\n")
print(f"Input: {task}\n")
try:
workflow_agent = workflow.as_agent()
agent_result = await workflow_agent.run(task)
if agent_result.messages:
# The output should contain a message from the researcher, a message from the writer,
# and a final synthesized answer from the orchestrator.
print("\n===== as_agent() Transcript =====")
for i, msg in enumerate(agent_result.messages, start=1):
role_value = getattr(msg.role, "value", msg.role)
speaker = msg.author_name or role_value
print(f"{'-' * 50}\n{i:02d} [{speaker}]\n{msg.text}")
except Exception as e:
print(f"Workflow execution failed: {e}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,235 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import Annotated
from agent_framework import (
Agent,
AgentResponse,
Content,
Message,
WorkflowAgent,
tool,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""Sample: Handoff Workflow as Agent with Human-in-the-Loop.
This sample demonstrates how to use a handoff workflow as an agent, enabling
human-in-the-loop interactions through the agent interface.
A handoff workflow defines a pattern that assembles agents in a mesh topology, allowing
them to transfer control to each other based on the conversation context.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- `az login` (Azure CLI authentication)
- Environment variables configured for FoundryChatClient (FOUNDRY_MODEL)
Key Concepts:
- 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
"""
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# See:
# samples/02-agents/tools/function_tool_with_approval.py
# samples/02-agents/tools/function_tool_with_approval_and_sessions.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_agents(client: FoundryChatClient) -> tuple[Agent, Agent, Agent, Agent]:
"""Create and configure the triage and specialist agents.
Args:
client: The FoundryChatClient to use for creating agents.
Returns:
Tuple of (triage_agent, refund_agent, order_agent, return_agent)
"""
# Triage agent: Acts as the frontline dispatcher
triage_agent = Agent(
client=client,
instructions=(
"You are frontline support triage. Route customer issues to the appropriate specialist agents "
"based on the problem described."
),
name="triage_agent",
require_per_service_call_history_persistence=True,
)
# Refund specialist: Handles refund requests
refund_agent = Agent(
client=client,
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],
require_per_service_call_history_persistence=True,
)
# Order/shipping specialist: Resolves delivery issues
order_agent = Agent(
client=client,
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],
require_per_service_call_history_persistence=True,
)
# Return specialist: Handles return requests
return_agent = Agent(
client=client,
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],
require_per_service_call_history_persistence=True,
)
return triage_agent, refund_agent, order_agent, return_agent
def handle_response_and_requests(response: AgentResponse) -> dict[str, HandoffAgentUserRequest]:
"""Process agent response messages and extract any user requests.
This function inspects the agent response and:
- Displays agent messages to the console
- Collects HandoffAgentUserRequest instances for response handling
Args:
response: The AgentResponse from the agent run call.
Returns:
A dictionary mapping request IDs to HandoffAgentUserRequest instances.
"""
pending_requests: dict[str, HandoffAgentUserRequest] = {}
for message in response.messages:
if message.text:
print(f"- {message.author_name or message.role}: {message.text}")
for content in message.contents:
if content.type == "function_call" and content.name == WorkflowAgent.REQUEST_INFO_FUNCTION_NAME:
request_function_args = WorkflowAgent.RequestInfoFunctionArgs.from_dict(content.arguments) # type: ignore
request_id = request_function_args.request_id
request_event = request_function_args.request_event
pending_requests[request_id] = request_event.data
return pending_requests
async def main() -> None:
"""Main entry point for the handoff workflow demo.
This function demonstrates:
1. Creating triage and specialist agents
2. Building a handoff workflow with custom termination condition
3. Running the workflow with scripted user responses
4. Processing events and handling user input requests
The workflow uses scripted responses instead of interactive input to make
the demo reproducible and testable. In a production application, you would
replace the scripted_responses with actual user input collection.
"""
# Initialize the Azure OpenAI chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create all agents: triage + specialists
triage, refund, order, support = create_agents(client)
# Build the handoff workflow
# - participants: All agents that can participate in the workflow
# - with_start_agent: The triage agent is designated as the start agent, which means
# it receives all user input first and orchestrates handoffs to specialists
# - 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 one of the agents says something like "you're welcome").
agent = (
HandoffBuilder(
name="customer_support_handoff",
participants=[triage, refund, order, support],
# Custom termination: Check if one of the agents has provided a closing message.
# This looks for the last message containing "welcome", which indicates the
# conversation has concluded naturally.
termination_condition=lambda conversation: (
len(conversation) > 0 and "welcome" in conversation[-1].text.lower()
),
)
.with_start_agent(triage)
.build()
.as_agent()
)
# 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
scripted_responses = [
"My order 1234 arrived damaged and the packaging was destroyed. I'd like to return it.",
"Please also process a refund for order 1234.",
"Thanks for resolving this.",
]
# Start the workflow with the initial user message
print("[Starting workflow with initial user message...]\n")
initial_message = "Hello, I need assistance with my recent purchase."
print(f"- User: {initial_message}")
response = await agent.run(initial_message)
pending_requests = handle_response_and_requests(response)
# Process the request/response cycle
# The workflow will continue requesting input until:
# 1. The termination condition is met, OR
# 2. We run out of scripted responses
while pending_requests:
if not scripted_responses:
# No more scripted responses; terminate the workflow
responses = {req_id: HandoffAgentUserRequest.terminate() for req_id in pending_requests}
else:
# Get the next scripted response
user_response = scripted_responses.pop(0)
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_id: HandoffAgentUserRequest.create_response(user_response) for req_id in pending_requests}
function_results = [
Content("function_result", call_id=req_id, result=response) for req_id, response in responses.items()
]
response = await agent.run(Message("tool", function_results))
pending_requests = handle_response_and_requests(response)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,119 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import (
Agent,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import MagenticBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Build a Magentic orchestration and wrap it as an agent.
The script configures a Magentic workflow with streaming callbacks, then invokes the
orchestration through `Agent(client=workflow, ...)` so the entire Magentic loop can be reused
like any other agent while still emitting callback telemetry.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
"""
async def main() -> None:
researcher_agent = Agent(
name="ResearcherAgent",
description="Specialist in research and information gathering",
instructions=(
"You are a Researcher. You find information without additional computation or quantitative analysis."
),
# This agent requires the gpt-4o-search-preview model to perform web searches.
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
)
# Create code interpreter tool using instance method
coder_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
code_interpreter_tool = coder_client.get_code_interpreter_tool()
coder_agent = Agent(
name="CoderAgent",
description="A helpful assistant that writes and executes code to process and analyze data.",
instructions="You solve questions using code. Please provide detailed analysis and computation process.",
client=coder_client,
tools=code_interpreter_tool,
)
# Create a manager agent for orchestration
manager_agent = Agent(
name="MagenticManager",
description="Orchestrator that coordinates the research and coding workflow",
instructions="You coordinate a team to complete complex tasks efficiently.",
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
)
print("\nBuilding Magentic Workflow...")
# Mark participant responses as intermediate so workflow.as_agent() maps
# them to text_reasoning content while the final answer remains normal text.
workflow = MagenticBuilder(
participants=[researcher_agent, coder_agent],
intermediate_output_from=[researcher_agent, coder_agent],
manager_agent=manager_agent,
max_round_count=10,
max_stall_count=3,
max_reset_count=2,
).build()
task = (
"I am preparing a report on the energy efficiency of different machine learning model architectures. "
"Compare the estimated training and inference energy consumption of ResNet-50, BERT-base, and GPT-2 "
"on standard datasets (e.g., ImageNet for ResNet, GLUE for BERT, WebText for GPT-2). "
"Then, estimate the CO2 emissions associated with each, assuming training on an Azure Standard_NC6s_v3 "
"VM for 24 hours. Provide tables for clarity, and recommend the most energy-efficient model "
"per task type (image classification, text classification, and text generation)."
)
print(f"\nTask: {task}")
print("\nStarting workflow execution...")
try:
# Wrap the workflow as an agent for composition scenarios
print("\nWrapping workflow as an agent and running...")
workflow_agent = workflow.as_agent()
last_response_id: str | None = None
async for update in workflow_agent.run(task, stream=True):
# Fallback for any other events with text
if last_response_id != update.response_id:
if last_response_id is not None:
print() # Newline between different responses
print(f"{update.author_name}: ", end="", flush=True)
last_response_id = update.response_id
else:
print(update.text, end="", flush=True)
except Exception as e:
print(f"Workflow execution failed: {e}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,87 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import SequentialBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Build a sequential workflow orchestration and wrap it as an agent.
The script assembles a sequential conversation flow with `SequentialBuilder`, then
invokes the entire orchestration through the `Agent(client=workflow,...)` interface so
other coordinators can reuse the chain as a single participant.
Note on internal adapters:
- Sequential orchestration includes small adapter nodes for input normalization
("input-conversation"), agent-response conversion ("to-conversation:<participant>"),
and completion ("complete"). These may appear as ExecutorInvoke/Completed events in
the stream—similar to how concurrent orchestration includes a dispatcher/aggregator.
You can safely ignore them when focusing on agent progress.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be set to the Azure Foundry project endpoint.
- FOUNDRY_MODEL must be set to the model name for the Foundry chat client.
"""
async def main() -> None:
# 1) Create agents
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
writer = Agent(
client=client,
instructions=("You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt."),
name="writer",
)
reviewer = Agent(
client=client,
instructions=("You are a thoughtful reviewer. Give brief feedback on the previous assistant message."),
name="reviewer",
)
# 2) Build sequential workflow: writer -> reviewer
workflow = SequentialBuilder(participants=[writer, reviewer]).build()
# 3) Treat the workflow itself as an agent for follow-up invocations
agent = workflow.as_agent()
prompt = "Write a tagline for a budget-friendly eBike."
agent_response = await agent.run(prompt)
if agent_response.messages:
print("\n===== Conversation =====")
for i, msg in enumerate(agent_response.messages, start=1):
name = msg.author_name or msg.role
print(f"{'-' * 60}\n{i:02d} [{name}]\n{msg.text}")
"""
Sample Output:
===== Conversation =====
------------------------------------------------------------
01 [reviewer]
Catchy and straightforward! The tagline clearly emphasizes both the electric aspect and the affordability of the
eBike. It's inviting and actionable. For even more impact, consider making it slightly shorter:
"Go electric, save big." Overall, this is an effective and appealing suggestion for a budget-friendly eBike.
Note:
`workflow.as_agent()` returns ONLY the final agent's response (the "answer") — the prior agents' work
is not included in the response. To preserve earlier participant replies while running as an agent, build with
`SequentialBuilder(participants=[...], intermediate_output_from=[writer])`; intermediate workflow events become
`text_reasoning` content on the AgentResponse, while `.text` remains terminal-output only.
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,166 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Ensure local package can be imported when running as a script.
_SAMPLES_ROOT = Path(__file__).resolve().parents[3]
if str(_SAMPLES_ROOT) not in sys.path:
sys.path.insert(0, str(_SAMPLES_ROOT))
# Also add the current directory for sibling imports
_CURRENT_DIR = str(Path(__file__).resolve().parent)
if _CURRENT_DIR not in sys.path:
sys.path.insert(0, _CURRENT_DIR)
from agent_framework import ( # noqa: E402
Content,
Executor,
Message,
WorkflowAgent,
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
from workflow_as_agent_reflection_pattern import ( # pyrefly: ignore[missing-import] # noqa: E402
ReviewRequest,
ReviewResponse,
Worker,
)
"""
Sample: Workflow Agent with Human-in-the-Loop
Purpose:
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, receives the human response, and then forwards that response back
to the Worker. The workflow completes when idle.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Familiarity with WorkflowBuilder, Executor, and WorkflowContext from agent_framework.
- Understanding of request-response message handling in executors.
- (Optional) Review of reflection and escalation patterns, such as those in
workflow_as_agent_reflection.py.
"""
# Load environment variables from .env file
load_dotenv()
@dataclass
class HumanReviewRequest:
"""A request message type for escalation to a human reviewer."""
agent_request: ReviewRequest | None = None
class ReviewerWithHumanInTheLoop(Executor):
"""Executor that always escalates reviews to a human manager."""
def __init__(self, worker_id: str, reviewer_id: str | None = None) -> None:
unique_id = reviewer_id or f"{worker_id}-reviewer"
super().__init__(id=unique_id)
self._worker_id = worker_id
@handler
async def review(self, request: ReviewRequest, ctx: WorkflowContext) -> None:
# In this simplified example, we always escalate to a human manager.
# See workflow_as_agent_reflection.py for an implementation
# using an automated agent to make the review decision.
print(f"Reviewer: Evaluating response for request {request.request_id[:8]}...")
print("Reviewer: Escalating to human manager...")
# Forward the request to a human manager by sending a HumanReviewRequest.
await ctx.request_info(request_data=HumanReviewRequest(agent_request=request), response_type=ReviewResponse)
@response_handler
async def accept_human_review(
self,
original_request: HumanReviewRequest,
response: ReviewResponse,
ctx: WorkflowContext[ReviewResponse],
) -> None:
# Accept the human review response and forward it back to the Worker.
print(f"Reviewer: Accepting human review for request {response.request_id[:8]}...")
print(f"Reviewer: Human feedback: {response.feedback}")
print(f"Reviewer: Human approved: {response.approved}")
print("Reviewer: Forwarding human review back to worker...")
await ctx.send_message(response, target_id=self._worker_id)
async def main() -> None:
print("Starting Workflow Agent with Human-in-the-Loop Demo")
print("=" * 50)
print("Building workflow with Worker-Reviewer cycle...")
# Build a workflow with bidirectional communication between Worker and Reviewer,
# and escalation paths for human review.
worker = Worker(
id="worker",
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
)
reviewer = ReviewerWithHumanInTheLoop(worker_id="worker")
agent = (
WorkflowBuilder(start_executor=worker).add_edge(worker, reviewer).add_edge(reviewer, worker).build().as_agent()
)
print("Running workflow agent with user query...")
print("Query: 'Write code for parallel reading 1 million files on disk and write to a sorted output file.'")
print("-" * 50)
# Run the agent with an initial query.
response = await agent.run(
"Write code for parallel reading 1 million Files on disk and write to a sorted output file."
)
# Locate the human review function call in the response messages.
human_review_function_call: Content | None = None
for message in response.messages:
for content in message.contents:
if content.name == WorkflowAgent.REQUEST_INFO_FUNCTION_NAME:
human_review_function_call = content
# Handle the human review if required.
if human_review_function_call:
# Parse the human review request arguments.
human_request_args = WorkflowAgent.RequestInfoFunctionArgs.from_dict(human_review_function_call.arguments) # type: ignore
request_payload = human_request_args.request_event.data
if not isinstance(request_payload, HumanReviewRequest):
raise ValueError("Human review request payload must be a HumanReviewRequest.")
if not request_payload.agent_request:
raise ValueError("Human review request must contain an agent_request.")
# Mock a human response approval for demonstration purposes.
human_response = ReviewResponse(request_id=request_payload.agent_request.request_id, feedback="", approved=True)
# Create the function call result object to send back to the agent.
human_review_function_result = Content(
"function_result",
call_id=human_review_function_call.call_id, # type: ignore
result=human_response,
)
# Send the human review result back to the agent.
response = await agent.run(Message("tool", [human_review_function_result]))
print(f"📤 Agent Response: {response.messages[-1].text}")
print("=" * 50)
print("Workflow completed!")
if __name__ == "__main__":
print("Initializing Workflow as Agent Sample...")
asyncio.run(main())
@@ -0,0 +1,156 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import os
from typing import Annotated, Any
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import SequentialBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import Field
# Load environment variables from .env file
load_dotenv()
"""
Sample: Workflow as Agent with kwargs Propagation to @tool Tools
This sample demonstrates how to flow custom context (skill data, user tokens, etc.)
through a workflow exposed Agent(client=via,) to @tool functions using the **kwargs pattern.
Key Concepts:
- Build a workflow using SequentialBuilder (or any builder pattern)
- Expose the workflow as a reusable agent via Agent(client=workflow,)
- Pass custom context as kwargs when invoking workflow_agent.run()
- kwargs are stored in State and propagated to all agent invocations
- @tool functions receive kwargs via **kwargs parameter
When to use Agent(client=workflow,):
- To treat an entire workflow orchestration as a single agent
- To compose workflows into higher-level orchestrations
- To maintain a consistent agent interface for callers
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
"""
# Define tools that accept custom context via **kwargs
# NOTE: approval_mode="never_require" is for sample brevity.
# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and
# samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@tool(approval_mode="never_require")
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}"
@tool(approval_mode="never_require")
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 as Agent kwargs Flow Demo")
print("=" * 70)
# Create chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create agent with tools that use kwargs
agent = Agent(
client=client,
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 sequential workflow
workflow = SequentialBuilder(participants=[agent]).build()
# Expose the workflow as an agent Agent(client=using,)
workflow_agent = workflow.as_agent()
# Define custom context that will flow to tools 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 Agent Execution (watch for [tool_name] logs showing kwargs received):")
print("-" * 70)
# Run workflow agent with kwargs - these will flow through to tools
# Note: kwargs are passed to workflow.run()
print("\n===== Streaming Response =====")
async for update in workflow_agent.run(
"Please get my user data and then call the users API endpoint.",
function_invocation_kwargs={"custom_data": custom_data, "user_token": user_token},
stream=True,
):
if update.text:
print(update.text, end="", flush=True)
print()
print("\n" + "=" * 70)
print("Sample Complete")
print("=" * 70)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,235 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from dataclasses import dataclass
from uuid import uuid4
from agent_framework import (
AgentResponse,
Executor,
Message,
SupportsChatGetResponse,
WorkflowBuilder,
WorkflowContext,
handler,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import BaseModel
# Load environment variables from .env file
load_dotenv()
"""
Sample: Workflow as Agent with Reflection and Retry Pattern
Purpose:
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. The workflow completes when idle.
Key Concepts Demonstrated:
- WorkflowAgent: Wraps a workflow to behave like a regular agent.
- Cyclic workflow design (Worker ↔ Reviewer) for iterative improvement.
- Structured output parsing for review feedback using Pydantic.
- State management for pending requests and retry logic.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Familiarity with WorkflowBuilder, Executor, WorkflowContext, and event handling.
- Understanding of how agent messages are generated, reviewed, and re-submitted.
"""
@dataclass
class ReviewRequest:
"""Structured request passed from Worker to Reviewer for evaluation."""
request_id: str
user_messages: list[Message]
agent_messages: list[Message]
@dataclass
class ReviewResponse:
"""Structured response from Reviewer back to Worker."""
request_id: str
feedback: str
approved: bool
class Reviewer(Executor):
"""Executor that reviews agent responses and provides structured feedback."""
def __init__(self, id: str, client: SupportsChatGetResponse) -> None:
super().__init__(id=id)
self._chat_client = client
@handler
async def review(self, request: ReviewRequest, ctx: WorkflowContext[ReviewResponse]) -> None:
print(f"Reviewer: Evaluating response for request {request.request_id[:8]}...")
# Define structured schema for the LLM to return.
class _Response(BaseModel):
feedback: str
approved: bool
# Construct review instructions and context.
messages = [
Message(
role="system",
contents=[
(
"You are a reviewer for an AI agent. Provide feedback on the "
"exchange between a user and the agent. Indicate approval only if:\n"
"- Relevance: response addresses the query\n"
"- Accuracy: information is correct\n"
"- Clarity: response is easy to understand\n"
"- Completeness: response covers all aspects\n"
"Do not approve until all criteria are satisfied."
)
],
)
]
# Add conversation history.
messages.extend(request.user_messages)
messages.extend(request.agent_messages)
# Add explicit review instruction.
messages.append(Message("user", ["Please review the agent's responses."]))
print("Reviewer: Sending review request to LLM...")
response = await self._chat_client.get_response(messages=messages, options={"response_format": _Response})
parsed = _Response.model_validate_json(response.messages[-1].text)
print(f"Reviewer: Review complete - Approved: {parsed.approved}")
print(f"Reviewer: Feedback: {parsed.feedback}")
# Send structured review result to Worker.
await ctx.send_message(
ReviewResponse(request_id=request.request_id, feedback=parsed.feedback, approved=parsed.approved)
)
class Worker(Executor):
"""Executor that generates responses and incorporates feedback when necessary."""
def __init__(self, id: str, client: SupportsChatGetResponse) -> None:
super().__init__(id=id)
self._chat_client = client
self._pending_requests: dict[str, tuple[ReviewRequest, list[Message]]] = {}
@handler
async def handle_user_messages(self, user_messages: list[Message], ctx: WorkflowContext[ReviewRequest]) -> None:
print("Worker: Received user messages, generating response...")
# Initialize chat with system prompt.
messages = [Message("system", ["You are a helpful assistant."])]
messages.extend(user_messages)
print("Worker: Calling LLM to generate response...")
response = await self._chat_client.get_response(messages=messages)
print(f"Worker: Response generated: {response.messages[-1].text}")
# Add agent messages to context.
messages.extend(response.messages)
# Create review request and send to Reviewer.
request = ReviewRequest(request_id=str(uuid4()), user_messages=user_messages, agent_messages=response.messages)
print(f"Worker: Sending response for review (ID: {request.request_id[:8]})")
await ctx.send_message(request)
# Track request for possible retry.
self._pending_requests[request.request_id] = (request, messages)
@handler
async def handle_review_response(
self, review: ReviewResponse, ctx: WorkflowContext[ReviewRequest, AgentResponse]
) -> None:
print(f"Worker: Received review for request {review.request_id[:8]} - Approved: {review.approved}")
if review.request_id not in self._pending_requests:
raise ValueError(f"Unknown request ID in review: {review.request_id}")
request, messages = self._pending_requests.pop(review.request_id)
if review.approved:
print("Worker: Response approved. Emitting to external consumer...")
# Emit approved result to external consumer
await ctx.yield_output(AgentResponse(messages=request.agent_messages))
return
print(f"Worker: Response not approved. Feedback: {review.feedback}")
print("Worker: Regenerating response with feedback...")
# Incorporate review feedback.
messages.append(Message("system", [review.feedback]))
messages.append(Message("system", ["Please incorporate the feedback and regenerate the response."]))
messages.extend(request.user_messages)
# Retry with updated prompt.
response = await self._chat_client.get_response(messages=messages)
print(f"Worker: New response generated: {response.messages[-1].text}")
messages.extend(response.messages)
# Send updated request for re-review.
new_request = ReviewRequest(
request_id=review.request_id, user_messages=request.user_messages, agent_messages=response.messages
)
await ctx.send_message(new_request)
# Track new request for further evaluation.
self._pending_requests[new_request.request_id] = (new_request, messages)
async def main() -> None:
print("Starting Workflow Agent Demo")
print("=" * 50)
print("Building workflow with Worker ↔ Reviewer cycle...")
worker = Worker(
id="worker",
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
)
reviewer = Reviewer(
id="reviewer",
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
)
agent = (
WorkflowBuilder(start_executor=worker).add_edge(worker, reviewer).add_edge(reviewer, worker).build().as_agent()
)
print("Running workflow agent with user query...")
print("Query: 'Write code for parallel reading 1 million files on disk and write to a sorted output file.'")
print("-" * 50)
# Run agent in streaming mode to observe incremental updates.
response = await agent.run(
"Write code for parallel reading 1 million files on disk and write to a sorted output file."
)
print("-" * 50)
print("Final Approved Response:")
print(f"{response.agent_id}: {response.text}")
if __name__ == "__main__":
print("Initializing Workflow as Agent Sample...")
asyncio.run(main())
@@ -0,0 +1,180 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from agent_framework import Agent, AgentSession, InMemoryHistoryProvider
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import SequentialBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Workflow as Agent with Session Conversation History and Checkpointing
This sample demonstrates how to use AgentSession with a workflow wrapped as an agent
to maintain conversation history across multiple invocations. When using as_agent(),
the session's 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 Agent(client=workflow,)
- AgentSession preserves conversation history
- Each call to agent.run() includes session 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:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
"""
async def main() -> None:
# Create a chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
assistant = Agent(
client=client,
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."
),
)
summarizer = Agent(
client=client,
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(participants=[assistant, summarizer]).build()
# Wrap the workflow as an agent
agent = workflow.as_agent()
# Create a session to maintain history
session = agent.create_session()
print("=" * 60)
print("Workflow as Agent with Session - 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, session=session)
if response1.messages:
for msg in response1.messages:
speaker = msg.author_name or msg.role
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, session=session)
if response2.messages:
for msg in response2.messages:
speaker = msg.author_name or msg.role
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, session=session)
if response3.messages:
for msg in response3.messages:
speaker = msg.author_name or msg.role
print(f"[{speaker}]: {msg.text}")
# Show the accumulated conversation history
print("\n" + "=" * 60)
print("Full Session History")
print("=" * 60)
memory_state = session.state.get(InMemoryHistoryProvider.DEFAULT_SOURCE_ID, {})
history = memory_state.get("messages", [])
for i, msg in enumerate(history, start=1):
role = msg.role 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_session_serialization() -> None:
"""
Demonstrates serializing and resuming a session with a workflow agent.
This shows how conversation history can be persisted and restored,
enabling long-running conversational workflows.
"""
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
memory_assistant = Agent(
client=client,
name="memory_assistant",
instructions="You are a helpful assistant with good memory. Remember details from our conversation.",
)
workflow = SequentialBuilder(participants=[memory_assistant]).build()
agent = workflow.as_agent()
# Create initial session and have a conversation
session = agent.create_session()
print("\n" + "=" * 60)
print("Session 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, session=session)
if response.messages:
print(f"[assistant]: {response.messages[0].text}")
# Serialize session state (could be saved to database/file)
serialized_state = session.to_dict()
print("\n[Serialized session state for persistence]")
# Simulate a new session by creating a new session from serialized state
restored_session = AgentSession.from_dict(serialized_state)
# Continue conversation with restored session
query = "What was the secret code I told you?"
print(f"\n[Session 2 - Restored] User: {query}")
response = await agent.run(query, session=restored_session)
if response.messages:
print(f"[assistant]: {response.messages[0].text}")
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(demonstrate_session_serialization())
@@ -0,0 +1,330 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from agent_framework import (
Agent,
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
Executor,
FileCheckpointStorage,
Message,
Workflow,
WorkflowBuilder,
WorkflowContext,
handler,
response_handler,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
# Load environment variables from .env file
load_dotenv()
"""
Sample: Checkpoint + human-in-the-loop quickstart.
This getting-started sample keeps the moving pieces to a minimum:
1. A brief is turned into a consistent prompt for an AI copywriter.
2. The copywriter (an `AgentExecutor`) drafts release notes.
3. A reviewer gateway sends a request for approval for every draft.
4. The workflow records checkpoints between each superstep so you can stop the
program, restart later, and optionally pre-supply human answers on resume.
Key concepts demonstrated
-------------------------
- Minimal executor pipeline with checkpoint persistence.
- Human-in-the-loop pause/resume with checkpoint restoration.
Typical pause/resume flow
-------------------------
1. Run the workflow until a human approval request is emitted.
2. If the human is offline, exit the program. A checkpoint with
``status=awaiting human response`` now exists.
3. Later, restart the script, select that checkpoint, and provide the stored
human decision when prompted to pre-supply responses.
Doing so applies the answer immediately on resume, so the system does **not**
re-emit the same ``.
"""
# Directory used for the sample's temporary checkpoint files. We isolate the
# demo artefacts so that repeated runs do not collide with other samples and so
# the clean-up step at the end of the script can simply delete the directory.
TEMP_DIR = Path(__file__).with_suffix("").parent / "tmp" / "checkpoints_hitl"
TEMP_DIR.mkdir(parents=True, exist_ok=True)
class BriefPreparer(Executor):
"""Normalises the user brief and sends a single AgentExecutorRequest."""
# The first executor in the workflow. By keeping it tiny we make it easier
# to reason about the state that will later be captured in the checkpoint.
# It is responsible for tidying the human-provided brief and kicking off the
# agent run with a deterministic prompt structure.
def __init__(self, id: str, agent_id: str) -> None:
super().__init__(id=id)
self._agent_id = agent_id
@handler
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("."):
normalized += "."
# Persist the cleaned brief in workflow state so downstream executors and
# future checkpoints can recover the original intent.
ctx.set_state("brief", normalized)
prompt = (
"You are drafting product release notes. Summarise the brief below in two sentences. "
"Keep it positive and end with a call to action.\n\n"
f"BRIEF: {normalized}"
)
# Hand the prompt to the writer agent. We always route through the
# workflow context so the runtime can capture messages for checkpointing.
await ctx.send_message(
AgentExecutorRequest(messages=[Message("user", contents=[prompt])], should_respond=True),
target_id=self._agent_id,
)
@dataclass
class HumanApprovalRequest:
"""Request sent to the human reviewer."""
# These fields are intentionally simple because they are serialised into
# checkpoints. Keeping them primitive types guarantees the new
# `pending_requests_from_checkpoint` helper can reconstruct them on resume.
prompt: str = ""
draft: str = ""
iteration: int = 0
class ReviewGateway(Executor):
"""Routes agent drafts to humans and optionally back for revisions."""
def __init__(self, id: str, writer_id: str) -> None:
super().__init__(id=id)
self._writer_id = writer_id
self._iteration = 0
@handler
async def on_agent_response(self, response: AgentExecutorResponse, ctx: WorkflowContext) -> None:
# Capture the agent output so we can surface it to the reviewer and persist iterations.
self._iteration += 1
# Emit a human approval request.
await ctx.request_info(
request_data=HumanApprovalRequest(
prompt="Review the draft. Reply 'approve' or provide edit instructions.",
draft=response.agent_response.text,
iteration=self._iteration,
),
response_type=str,
)
@response_handler
async def on_human_feedback(
self,
original_request: HumanApprovalRequest,
feedback: str,
ctx: WorkflowContext[AgentExecutorRequest | str, str],
) -> None:
# The `original_request` is the request we sent earlier that is now being answered.
reply = feedback.strip()
if len(reply) == 0 or reply.lower() == "approve":
# Workflow is completed when the human approves.
await ctx.yield_output(original_request.draft)
return
# Any other response loops us back to the writer with fresh guidance.
prompt = (
"Revise the launch note. Respond with the new copy only.\n\n"
f"Previous draft:\n{original_request.draft}\n\n"
f"Human guidance: {reply}"
)
await ctx.send_message(
AgentExecutorRequest(messages=[Message("user", contents=[prompt])], should_respond=True),
target_id=self._writer_id,
)
@override
async def on_checkpoint_save(self) -> dict[str, Any]:
# Save the current iteration count in executor state for checkpointing.
return {"iteration": self._iteration}
@override
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
# Restore the iteration count from executor state during checkpoint recovery.
self._iteration = state.get("iteration", 0)
def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
"""Assemble the workflow graph used by both the initial run and resume."""
# Wire the workflow DAG. Edges mirror the numbered steps described in the
# module docstring. Because `WorkflowBuilder` is declarative, reading these
# edges is often the quickest way to understand execution order.
writer_agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions="Write concise, warm release notes that sound human and helpful.",
name="writer",
)
writer = AgentExecutor(writer_agent)
review_gateway = ReviewGateway(id="review_gateway", writer_id="writer")
prepare_brief = BriefPreparer(id="prepare_brief", agent_id="writer")
workflow_builder = (
WorkflowBuilder(max_iterations=6, start_executor=prepare_brief, checkpoint_storage=checkpoint_storage)
.add_edge(prepare_brief, writer)
.add_edge(writer, review_gateway)
.add_edge(review_gateway, writer) # revisions loop
)
return workflow_builder.build()
def prompt_for_responses(requests: dict[str, HumanApprovalRequest]) -> dict[str, str]:
"""Interactive CLI prompt for any live RequestInfo requests."""
responses: dict[str, str] = {}
for request_id, request in requests.items():
print("\n=== Human approval needed ===")
print(f"request_id: {request_id}")
print(f"Iteration: {request.iteration}")
print(request.prompt)
print("Draft: \n---\n" + request.draft + "\n---")
response = input("Type 'approve' or enter revision guidance (or 'exit' to quit): ").strip()
if response.lower() == "exit":
raise SystemExit("Stopped by user.")
responses[request_id] = response
return responses
async def run_interactive_session(
workflow: Workflow,
initial_message: str | None = None,
checkpoint_id: str | None = None,
) -> str:
"""Run the workflow until it either finishes or pauses for human input."""
requests: dict[str, HumanApprovalRequest] = {}
responses: dict[str, str] | None = None
completed_output: str | None = None
while True:
if responses:
event_stream = workflow.run(stream=True, responses=responses)
requests.clear()
responses = None
else:
if initial_message:
print(f"\nStarting workflow with brief: {initial_message}\n")
event_stream = workflow.run(message=initial_message, stream=True)
elif checkpoint_id:
print("\nStarting workflow from checkpoint...\n")
event_stream = workflow.run(checkpoint_id=checkpoint_id, stream=True)
else:
raise ValueError("Either initial_message or checkpoint_id must be provided")
async for event in event_stream:
if event.type == "status":
print(event)
if event.type == "output":
completed_output = event.data
if event.type == "request_info":
if isinstance(event.data, HumanApprovalRequest):
requests[event.request_id] = event.data
else:
raise ValueError("Unexpected request data type")
if completed_output:
break
if requests:
responses = prompt_for_responses(requests)
continue
raise RuntimeError("Workflow stopped without completing or requesting input")
return completed_output
async def main() -> None:
"""Entry point used by both the initial run and subsequent resumes."""
for file in TEMP_DIR.glob("*.json"):
# Start each execution with a clean slate so the demonstration is
# deterministic even if the directory had stale checkpoints.
file.unlink()
storage = FileCheckpointStorage(storage_path=TEMP_DIR)
workflow = create_workflow(checkpoint_storage=storage)
brief = (
"Introduce our limited edition smart coffee grinder. Mention the $249 price, highlight the "
"sensor that auto-adjusts the grind, and invite customers to pre-order on the website."
)
print("Running workflow (human approval required)...")
result = await run_interactive_session(workflow, initial_message=brief)
print(f"Workflow completed with: {result}")
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
if not checkpoints:
print("No checkpoints recorded.")
return
sorted_cps = sorted(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
print("\nAvailable checkpoints:")
for idx, cp in enumerate(sorted_cps):
print(f" [{idx}] id={cp.checkpoint_id} iter={cp.iteration_count}")
# For the pause/resume demo we typically pick the latest checkpoint whose summary
# status reads "awaiting human response" - that is the saved state that proves the
# workflow can rehydrate, collect the pending answer, and continue after a break.
selection = input("\nResume from which checkpoint? (press Enter to skip): ").strip() # noqa: ASYNC250
if not selection:
print("No resume selected. Exiting.")
return
try:
idx = int(selection)
except ValueError:
print("Invalid input; exiting.")
return
if not 0 <= idx < len(sorted_cps):
print("Index out of range; exiting.")
return
chosen = sorted_cps[idx]
new_workflow = create_workflow(checkpoint_storage=storage)
# Resume with a fresh workflow instance. The checkpoint carries the
# persistent state while this object holds the runtime wiring.
result = await run_interactive_session(new_workflow, checkpoint_id=chosen.checkpoint_id)
print(f"Workflow completed with: {result}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,157 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Sample: Checkpointing and Resuming a Workflow
Purpose:
This sample shows how to enable checkpointing for a long-running workflow
that can be paused and resumed.
What you learn:
- How to configure checkpointing storage (InMemoryCheckpointStorage for testing)
- How to resume a workflow from a checkpoint after interruption
- How to implement executor state management with checkpoint hooks
- How to handle workflow interruptions and automatic recovery
Pipeline:
This sample shows a workflow that computes factor pairs for numbers up to a given limit:
1) A start executor that receives the upper limit and creates the initial task
2) A worker executor that processes each number to find its factor pairs
3) The worker uses checkpoint hooks to save/restore its internal state
Prerequisites:
- Basic understanding of workflow concepts, including executors, edges, events, etc.
"""
import asyncio
import sys
from dataclasses import dataclass
from random import random
from typing import Any
from agent_framework import (
Executor,
InMemoryCheckpointStorage,
WorkflowBuilder,
WorkflowCheckpoint,
WorkflowContext,
handler,
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
@dataclass
class ComputeTask:
"""Task containing the list of numbers remaining to be processed."""
remaining_numbers: list[int]
class StartExecutor(Executor):
"""Initiates the workflow by providing the upper limit for factor pair computation."""
@handler
async def start(self, upper_limit: int, ctx: WorkflowContext[ComputeTask]) -> None:
"""Start the workflow with a list of numbers to process."""
print(f"StartExecutor: Starting factor pair computation up to {upper_limit}")
await ctx.send_message(ComputeTask(remaining_numbers=list(range(1, upper_limit + 1))))
class WorkerExecutor(Executor):
"""Processes numbers to compute their factor pairs and manages executor state for checkpointing."""
def __init__(self, id: str) -> None:
super().__init__(id=id)
self._composite_number_pairs: dict[int, list[tuple[int, int]]] = {}
@handler
async def compute(
self,
task: ComputeTask,
ctx: WorkflowContext[ComputeTask, dict[int, list[tuple[int, int]]]],
) -> None:
"""Process the next number in the task, computing its factor pairs."""
next_number = task.remaining_numbers.pop(0)
print(f"WorkerExecutor: Computing factor pairs for {next_number}")
pairs: list[tuple[int, int]] = []
for i in range(1, next_number):
if next_number % i == 0:
pairs.append((i, next_number // i))
self._composite_number_pairs[next_number] = pairs
if not task.remaining_numbers:
# All numbers processed - output the results
await ctx.yield_output(self._composite_number_pairs)
else:
# More numbers to process - continue with remaining task
await ctx.send_message(task)
@override
async def on_checkpoint_save(self) -> dict[str, Any]:
"""Save the executor's internal state for checkpointing."""
return {"composite_number_pairs": self._composite_number_pairs}
@override
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
"""Restore the executor's internal state from a checkpoint."""
self._composite_number_pairs = state.get("composite_number_pairs", {})
async def main():
# Build workflow with checkpointing enabled
checkpoint_storage = InMemoryCheckpointStorage()
start = StartExecutor(id="start")
worker = WorkerExecutor(id="worker")
workflow_builder = (
WorkflowBuilder(start_executor=start, checkpoint_storage=checkpoint_storage)
.add_edge(start, worker)
.add_edge(worker, worker) # Self-loop for iterative processing
)
# Run workflow with automatic checkpoint recovery
latest_checkpoint: WorkflowCheckpoint | None = None
while True:
workflow = workflow_builder.build()
# Start from checkpoint or fresh execution
print(f"\n** Workflow {workflow.id} started **")
event_stream = (
workflow.run(message=10, stream=True)
if latest_checkpoint is None
else workflow.run(checkpoint_id=latest_checkpoint.checkpoint_id, stream=True)
)
output: str | None = None
async for event in event_stream:
if event.type == "output":
output = event.data
break
if event.type == "superstep_completed" and random() < 0.5:
# Randomly simulate system interruptions
# The type="superstep_completed" event ensures we only interrupt after
# the current super-step is fully complete and checkpointed.
# If we interrupt mid-step, the workflow may resume from an earlier point.
print("\n** Simulating workflow interruption. Stopping execution. **")
break
# Find the latest checkpoint to resume from
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow.name)
if not latest_checkpoint:
raise RuntimeError("No checkpoints available to resume from.")
print(
f"Checkpoint {latest_checkpoint.checkpoint_id}: "
f"(iter={latest_checkpoint.iteration_count}, messages={latest_checkpoint.messages})"
)
if output is not None:
print(f"\nWorkflow completed successfully with output: {output}")
break
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,201 @@
# Copyright (c) Microsoft. All rights reserved.
# ruff: noqa: T201
"""Sample: Workflow Checkpointing with Cosmos DB NoSQL.
Purpose:
This sample shows how to use Azure Cosmos DB NoSQL as a persistent checkpoint
storage backend for workflows, enabling durable pause-and-resume across
process restarts.
What you learn:
- How to configure CosmosCheckpointStorage for workflow checkpointing
- How to run a workflow that automatically persists checkpoints to Cosmos DB
- How to resume a workflow from a Cosmos DB checkpoint
- How to list and inspect available checkpoints
Prerequisites:
- An Azure Cosmos DB account (or local emulator)
- Environment variables set (see below)
Environment variables:
AZURE_COSMOS_ENDPOINT - Cosmos DB account endpoint
AZURE_COSMOS_DATABASE_NAME - Database name
AZURE_COSMOS_CONTAINER_NAME - Container name for checkpoints
Optional:
AZURE_COSMOS_KEY - Account key (if not using Azure credentials)
"""
import asyncio
import os
import sys
from dataclasses import dataclass
from typing import Any
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowCheckpoint,
WorkflowContext,
handler,
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
from agent_framework_azure_cosmos import CosmosCheckpointStorage
@dataclass
class ComputeTask:
"""Task containing the list of numbers remaining to be processed."""
remaining_numbers: list[int]
class StartExecutor(Executor):
"""Initiates the workflow by providing the upper limit."""
@handler
async def start(self, upper_limit: int, ctx: WorkflowContext[ComputeTask]) -> None:
"""Start the workflow with numbers up to the given limit."""
print(f"StartExecutor: Starting computation up to {upper_limit}")
await ctx.send_message(ComputeTask(remaining_numbers=list(range(1, upper_limit + 1))))
class WorkerExecutor(Executor):
"""Processes numbers and manages executor state for checkpointing."""
def __init__(self, id: str) -> None:
"""Initialize the worker executor."""
super().__init__(id=id)
self._results: dict[int, list[tuple[int, int]]] = {}
@handler
async def compute(
self,
task: ComputeTask,
ctx: WorkflowContext[ComputeTask, dict[int, list[tuple[int, int]]]],
) -> None:
"""Process the next number, computing its factor pairs."""
next_number = task.remaining_numbers.pop(0)
print(f"WorkerExecutor: Processing {next_number}")
pairs: list[tuple[int, int]] = []
for i in range(1, next_number):
if next_number % i == 0:
pairs.append((i, next_number // i))
self._results[next_number] = pairs
if not task.remaining_numbers:
await ctx.yield_output(self._results)
else:
await ctx.send_message(task)
@override
async def on_checkpoint_save(self) -> dict[str, Any]:
return {"results": self._results}
@override
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
self._results = state.get("results", {})
async def main() -> None:
"""Run the workflow checkpointing sample with Cosmos DB."""
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name:
print("Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME.")
return
# Authentication: supports both managed identity/RBAC and key-based auth.
# When AZURE_COSMOS_KEY is set, key-based auth is used.
# Otherwise, falls back to DefaultAzureCredential (properly closed via async with).
if cosmos_key:
async with CosmosCheckpointStorage(
endpoint=cosmos_endpoint,
credential=cosmos_key,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
) as checkpoint_storage:
await _run_workflow(checkpoint_storage)
else:
from azure.identity.aio import DefaultAzureCredential
async with (
DefaultAzureCredential() as credential,
CosmosCheckpointStorage(
endpoint=cosmos_endpoint,
credential=credential,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
) as checkpoint_storage,
):
await _run_workflow(checkpoint_storage)
async def _run_workflow(checkpoint_storage: CosmosCheckpointStorage) -> None:
"""Build and run the workflow with Cosmos DB checkpointing."""
start = StartExecutor(id="start")
worker = WorkerExecutor(id="worker")
workflow_builder = (
WorkflowBuilder(start_executor=start, checkpoint_storage=checkpoint_storage)
.add_edge(start, worker)
.add_edge(worker, worker)
)
# --- First run: execute the workflow ---
print("\n=== First Run ===\n")
workflow = workflow_builder.build()
output = None
async for event in workflow.run(message=8, stream=True):
if event.type == "output":
output = event.data
print(f"Factor pairs computed: {output}")
# List checkpoints saved in Cosmos DB
checkpoint_ids = await checkpoint_storage.list_checkpoint_ids(
workflow_name=workflow.name,
)
print(f"\nCheckpoints in Cosmos DB: {len(checkpoint_ids)}")
for cid in checkpoint_ids:
print(f" - {cid}")
# Get the latest checkpoint
latest: WorkflowCheckpoint | None = await checkpoint_storage.get_latest(
workflow_name=workflow.name,
)
if latest is None:
print("No checkpoint found to resume from.")
return
print(f"\nLatest checkpoint: {latest.checkpoint_id}")
print(f" iteration_count: {latest.iteration_count}")
print(f" timestamp: {latest.timestamp}")
# --- Second run: resume from the latest checkpoint ---
print("\n=== Resuming from Checkpoint ===\n")
workflow2 = workflow_builder.build()
output2 = None
async for event in workflow2.run(checkpoint_id=latest.checkpoint_id, stream=True):
if event.type == "output":
output2 = event.data
if output2:
print(f"Resumed workflow produced: {output2}")
else:
print("Resumed workflow completed (no remaining work — already finished).")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,141 @@
# Copyright (c) Microsoft. All rights reserved.
# ruff: noqa: T201
"""Sample: Workflow Checkpointing with Cosmos DB and Azure AI Foundry.
Purpose:
This sample demonstrates how to use CosmosCheckpointStorage with agents built
on Azure AI Foundry (via FoundryChatClient). It shows a multi-agent
workflow where checkpoint state is persisted to Cosmos DB, enabling durable
pause-and-resume across process restarts.
What you learn:
- How to wire CosmosCheckpointStorage with FoundryChatClient agents
- How to combine session history with workflow checkpointing
- How to resume a workflow-as-agent from a Cosmos DB checkpoint
Key concepts:
- AgentSession: Maintains conversation history across agent invocations
- CosmosCheckpointStorage: Persists workflow execution state in Cosmos DB
- These are complementary: sessions track conversation, checkpoints track workflow state
Environment variables:
FOUNDRY_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
FOUNDRY_MODEL - Model deployment name
AZURE_COSMOS_ENDPOINT - Cosmos DB account endpoint
AZURE_COSMOS_DATABASE_NAME - Database name
AZURE_COSMOS_CONTAINER_NAME - Container name for checkpoints
Optional:
AZURE_COSMOS_KEY - Account key (if not using Azure credentials)
"""
import asyncio
import os
from typing import Any
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import SequentialBuilder
from agent_framework_azure_cosmos import CosmosCheckpointStorage
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv
load_dotenv()
async def main() -> None:
"""Run the Azure AI Foundry + Cosmos DB checkpointing sample."""
project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT")
model = os.getenv("FOUNDRY_MODEL")
cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT")
cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME")
cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME")
cosmos_key = os.getenv("AZURE_COSMOS_KEY")
if not project_endpoint or not model:
print("Please set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL.")
return
if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name:
print("Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME.")
return
# Use a single AzureCliCredential for both Cosmos and Foundry,
# properly closed via async context manager.
async with AzureCliCredential() as azure_credential:
cosmos_credential: Any = cosmos_key if cosmos_key else azure_credential
async with CosmosCheckpointStorage(
endpoint=cosmos_endpoint,
credential=cosmos_credential,
database_name=cosmos_database_name,
container_name=cosmos_container_name,
) as checkpoint_storage:
# Create Azure AI Foundry agents
client = FoundryChatClient(
project_endpoint=project_endpoint,
model=model,
credential=azure_credential,
)
assistant = Agent(
name="assistant",
instructions="You are a helpful assistant. Keep responses brief.",
client=client,
)
reviewer = Agent(
name="reviewer",
instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.",
client=client,
)
# Build a sequential workflow and wrap it as an agent
workflow = SequentialBuilder(participants=[assistant, reviewer]).build()
agent = workflow.as_agent(name="FoundryCheckpointedAgent")
# --- First run: execute with Cosmos DB checkpointing ---
print("=== First Run ===\n")
session = agent.create_session()
query = "What are the benefits of renewable energy?"
print(f"User: {query}")
response = await agent.run(query, session=session, checkpoint_storage=checkpoint_storage)
for msg in response.messages:
speaker = msg.author_name or msg.role
print(f"[{speaker}]: {msg.text}")
# Show checkpoints persisted in Cosmos DB
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nCheckpoints in Cosmos DB: {len(checkpoints)}")
for i, cp in enumerate(checkpoints[:5], 1):
print(f" {i}. {cp.checkpoint_id} (iteration={cp.iteration_count})")
# --- Second run: continue conversation with checkpoint history ---
print("\n=== Second Run (continuing conversation) ===\n")
query2 = "Can you elaborate on the economic benefits?"
print(f"User: {query2}")
response2 = await agent.run(query2, session=session, checkpoint_storage=checkpoint_storage)
for msg in response2.messages:
speaker = msg.author_name or msg.role
print(f"[{speaker}]: {msg.text}")
# Show total checkpoints
all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nTotal checkpoints after two runs: {len(all_checkpoints)}")
# Get latest checkpoint
latest = await checkpoint_storage.get_latest(workflow_name=workflow.name)
if latest:
print(f"Latest checkpoint: {latest.checkpoint_id}")
print(f" iteration_count: {latest.iteration_count}")
print(f" timestamp: {latest.timestamp}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,415 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import contextlib
import json
import sys
import uuid
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from agent_framework import (
Executor,
FileCheckpointStorage,
SubWorkflowRequestMessage,
SubWorkflowResponseMessage,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
WorkflowExecutor,
WorkflowRunState,
handler,
response_handler,
)
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
CHECKPOINT_DIR = Path(__file__).with_suffix("").parent / "tmp" / "sub_workflow_checkpoints"
"""
Sample: Checkpointing for workflows that embed sub-workflows.
This sample shows how a parent workflow that wraps a sub-workflow can:
- run until the sub-workflow emits a human approval request
- persist a checkpoint that captures the pending request (including complex payloads)
- resume later, supplying the human decision directly at restore time
It is intentionally similar in spirit to the orchestration checkpoint sample but
uses ``WorkflowExecutor`` so we exercise the full parent/sub-workflow round-trip.
"""
def _utc_now() -> datetime:
return datetime.now()
# ---------------------------------------------------------------------------
# Messages exchanged inside the sub-workflow
# ---------------------------------------------------------------------------
@dataclass
class DraftTask:
"""Task handed from the parent to the sub-workflow writer."""
topic: str
due: datetime
iteration: int = 1
@dataclass
class DraftPackage:
"""Intermediate draft produced by the sub-workflow writer."""
topic: str
content: str
iteration: int
created_at: datetime = field(default_factory=_utc_now)
@dataclass
class FinalDraft:
"""Final deliverable returned to the parent workflow."""
topic: str
content: str
iterations: int
approved_at: datetime
@dataclass
class ReviewRequest:
"""Human approval request surfaced via `request_info`."""
id: str = str(uuid.uuid4())
topic: str = ""
iteration: int = 1
draft_excerpt: str = ""
due_iso: str = ""
reviewer_guidance: list[str] = field(default_factory=list) # type: ignore
@dataclass
class ReviewDecision:
"""The review decision to be sent to downstream executors along with the original request."""
decision: str
original_request: ReviewRequest
# ---------------------------------------------------------------------------
# Sub-workflow executors
# ---------------------------------------------------------------------------
class DraftWriter(Executor):
"""Produces an initial draft for the supplied topic."""
def __init__(self) -> None:
super().__init__(id="draft_writer")
@handler
async def create_draft(self, task: DraftTask, ctx: WorkflowContext[DraftPackage]) -> None:
draft = DraftPackage(
topic=task.topic,
content=(
f"Launch plan for {task.topic}.\n\n"
"- Outline the customer message.\n"
"- Highlight three differentiators.\n"
"- Close with a next-step CTA.\n"
f"(iteration {task.iteration})"
),
iteration=task.iteration,
)
await ctx.send_message(draft, target_id="draft_review")
class DraftReviewRouter(Executor):
"""Turns draft packages into human approval requests."""
def __init__(self) -> None:
super().__init__(id="draft_review")
@handler
async def request_review(self, draft: DraftPackage, ctx: WorkflowContext) -> None:
"""Request a review upon receiving a draft."""
excerpt = draft.content.splitlines()[0]
request = ReviewRequest(
topic=draft.topic,
iteration=draft.iteration,
draft_excerpt=excerpt,
due_iso=draft.created_at.isoformat(),
reviewer_guidance=[
"Ensure tone matches launch messaging",
"Confirm CTA is action-oriented",
],
)
await ctx.request_info(request_data=request, response_type=str)
@response_handler
async def forward_decision(
self,
original_request: ReviewRequest,
decision: str,
ctx: WorkflowContext[ReviewDecision],
) -> None:
"""Route the decision to the next executor."""
await ctx.send_message(ReviewDecision(decision=decision, original_request=original_request))
class DraftFinaliser(Executor):
"""Applies the human decision and emits the final draft."""
def __init__(self) -> None:
super().__init__(id="draft_finaliser")
@handler
async def on_review_decision(
self,
review_decision: ReviewDecision,
ctx: WorkflowContext[DraftTask, FinalDraft],
) -> None:
reply = review_decision.decision.strip().lower()
original = review_decision.original_request
topic = original.topic if original else "unknown topic"
iteration = original.iteration if original else 1
if reply != "approve":
# Loop back with a follow-up task. In a real workflow you would
# incorporate the human guidance; here we just increment the counter.
next_task = DraftTask(
topic=topic,
due=_utc_now() + timedelta(hours=1),
iteration=iteration + 1,
)
await ctx.send_message(next_task, target_id="draft_writer")
return
final = FinalDraft(
topic=topic,
content=f"Approved launch narrative for {topic} (iteration {iteration}).",
iterations=iteration,
approved_at=_utc_now(),
)
await ctx.yield_output(final)
# ---------------------------------------------------------------------------
# Parent workflow executors
# ---------------------------------------------------------------------------
class LaunchCoordinator(Executor):
"""Owns the top-level workflow and collects the final draft."""
def __init__(self) -> None:
super().__init__(id="launch_coordinator")
# Track pending requests to match responses
self._pending_requests: dict[str, SubWorkflowRequestMessage] = {}
@handler
async def kick_off(self, topic: str, ctx: WorkflowContext[DraftTask]) -> None:
task = DraftTask(topic=topic, due=_utc_now() + timedelta(hours=2))
await ctx.send_message(task)
@handler
async def collect_final(self, draft: FinalDraft, ctx: WorkflowContext[None, FinalDraft]) -> None:
approved_at = draft.approved_at
normalised = draft
if isinstance(approved_at, str):
with contextlib.suppress(ValueError):
parsed = datetime.fromisoformat(approved_at)
normalised = replace(draft, approved_at=parsed)
approved_at = parsed
approved_display = approved_at.isoformat() if hasattr(approved_at, "isoformat") else str(approved_at)
print("\n>>> Parent workflow received approved draft:")
print(f"- Topic: {normalised.topic}")
print(f"- Iterations: {normalised.iterations}")
print(f"- Approved at: {approved_display}")
print(f"- Content: {normalised.content}\n")
await ctx.yield_output(normalised)
@handler
async def handler_sub_workflow_request(
self,
request: SubWorkflowRequestMessage,
ctx: WorkflowContext,
) -> None:
"""Handle requests from the sub-workflow.
Note that the message type must be SubWorkflowRequestMessage to intercept the request.
"""
if not isinstance(request.source_event.data, ReviewRequest):
raise TypeError(f"Expected 'ReviewRequest', got {type(request.source_event.data)}")
# Record the request for response matching
review_request = request.source_event.data
self._pending_requests[review_request.id] = request
# Send the request without modification
await ctx.request_info(request_data=review_request, response_type=str)
@response_handler
async def handle_request_response(
self,
original_request: ReviewRequest,
response: str,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
"""Process the response and send it back to the sub-workflow.
Note that the response must be sent back using SubWorkflowResponseMessage to route
the response back to the sub-workflow.
"""
request_message = self._pending_requests.pop(original_request.id, None)
if request_message is None:
raise ValueError("No matching pending request found for the resource response")
await ctx.send_message(request_message.create_response(response))
@override
async def on_checkpoint_save(self) -> dict[str, Any]:
"""Capture any additional state needed for checkpointing."""
return {
"pending_requests": self._pending_requests,
}
@override
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
"""Restore any additional state needed from checkpointing."""
self._pending_requests = state.get("pending_requests", {})
# ---------------------------------------------------------------------------
# Workflow construction helpers
# ---------------------------------------------------------------------------
def build_sub_workflow() -> WorkflowExecutor:
"""Assemble the sub-workflow used by the parent workflow executor."""
writer = DraftWriter()
router = DraftReviewRouter()
finaliser = DraftFinaliser()
sub_workflow = (
WorkflowBuilder(start_executor=writer)
.add_edge(writer, router)
.add_edge(router, finaliser)
.add_edge(finaliser, writer) # permits revision loops
.build()
)
return WorkflowExecutor(sub_workflow, id="launch_subworkflow")
def build_parent_workflow(storage: FileCheckpointStorage) -> Workflow:
"""Assemble the parent workflow that embeds the sub-workflow."""
coordinator = LaunchCoordinator()
sub_executor = build_sub_workflow()
return (
WorkflowBuilder(start_executor=coordinator, checkpoint_storage=storage)
.add_edge(coordinator, sub_executor)
.add_edge(sub_executor, coordinator)
.build()
)
async def main() -> None:
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
for file in CHECKPOINT_DIR.glob("*.json"):
file.unlink()
storage = FileCheckpointStorage(CHECKPOINT_DIR)
workflow = build_parent_workflow(storage)
print("\n=== Stage 1: run until sub-workflow requests human review ===")
request_id: str | None = None
async for event in workflow.run("Contoso Gadget Launch", stream=True):
if event.type == "request_info" and request_id is None:
request_id = event.request_id
print(f"Captured review request id: {request_id}")
if event.type == "status" and event.state is WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
break
if request_id is None:
raise RuntimeError("Sub-workflow completed without requesting review.")
resume_checkpoint = await storage.get_latest(workflow_name=workflow.name)
if not resume_checkpoint:
raise RuntimeError("No checkpoints found.")
# Print the checkpoint to show pending requests
# We didn't handle the request above so the request is still pending the last checkpoint
print(f"Using checkpoint {resume_checkpoint.checkpoint_id} at iteration {resume_checkpoint.iteration_count}")
checkpoint_path = storage.storage_path / f"{resume_checkpoint.checkpoint_id}.json"
if checkpoint_path.exists():
checkpoint_content_dict = json.loads(checkpoint_path.read_text())
print(f"Pending review requests: {checkpoint_content_dict.get('pending_request_info_events', {})}")
print("\n=== Stage 2: resume from checkpoint ===")
# Rebuild fresh instances to mimic a separate process resuming
workflow2 = build_parent_workflow(storage)
request_info_event: WorkflowEvent | None = None
async for event in workflow2.run(checkpoint_id=resume_checkpoint.checkpoint_id, stream=True):
if event.type == "request_info":
request_info_event = event
if request_info_event is None:
raise RuntimeError("No request_info_event captured.")
print("\n=== Stage 3: approve draft ==")
approval_response = "approve"
output_event: WorkflowEvent | None = None
async for event in workflow2.run(stream=True, responses={request_info_event.request_id: approval_response}):
if event.type == "output":
output_event = event
if output_event is None:
raise RuntimeError("Workflow did not complete after resume.")
output = output_event.data
print("\n=== Final Draft (from resumed run) ===")
print(output)
""""
Sample Output:
=== Stage 1: run until sub-workflow requests human review ===
Captured review request id: 032c9f3a-ad1b-4a52-89be-a168d6663011
Using checkpoint 54f376c2-f849-44e4-9d8d-e627fd27ab96 at iteration 2
Pending review requests (sub executor snapshot): []
Pending review requests (parent executor snapshot): ['032c9f3a-ad1b-4a52-89be-a168d6663011']
=== Stage 2: resume from checkpoint and approve draft ===
>>> Parent workflow received approved draft:
- Topic: Contoso Gadget Launch
- Iterations: 1
- Approved at: 2025-09-25T14:29:34.479164
- Content: Approved launch narrative for Contoso Gadget Launch (iteration 1).
=== Final Draft (from resumed run) ===
FinalDraft(topic='Contoso Gadget Launch', content='Approved launch narrative for Contoso
Gadget Launch (iteration 1).', iterations=1, approved_at=datetime.datetime(2025, 9, 25, 14, 29, 34, 479164))
Coordinator stored final draft successfully.
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,186 @@
# 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(),
allowing workflow execution state to be persisted and potentially resumed.
What you learn:
- How to pass checkpoint_storage to WorkflowAgent.run()
- 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 (AgentSession): Maintains conversation history across agent invocations
- Checkpoint: Persists workflow execution state for pause/resume capability
- These are complementary: sessions track conversation, checkpoints track workflow state
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
"""
import asyncio
import os
from agent_framework import (
Agent,
InMemoryCheckpointStorage,
InMemoryHistoryProvider,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import SequentialBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
async def basic_checkpointing() -> None:
"""Demonstrate basic checkpoint storage with workflow-as-agent."""
print("=" * 60)
print("Basic Checkpointing with Workflow as Agent")
print("=" * 60)
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
assistant = Agent(
client=client,
name="assistant",
instructions="You are a helpful assistant. Keep responses brief.",
)
reviewer = Agent(
client=client,
name="reviewer",
instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.",
)
workflow = SequentialBuilder(participants=[assistant, reviewer]).build()
agent = workflow.as_agent()
# 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
print(f"[{speaker}]: {msg.text}")
# Show checkpoints that were created
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
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)
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
assistant = Agent(
client=client,
name="memory_assistant",
instructions="You are a helpful assistant with good memory. Reference previous conversation when relevant.",
)
workflow = SequentialBuilder(participants=[assistant]).build()
agent = workflow.as_agent()
# Create both session (for conversation) and checkpoint storage (for workflow state)
session = agent.create_session()
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, session=session, checkpoint_storage=checkpoint_storage)
if response1.messages:
print(f"[assistant]: {response1.messages[0].text}")
# Second turn - agent should remember from session history
query2 = "What's my favorite color?"
print(f"\n[Turn 2] User: {query2}")
response2 = await agent.run(query2, session=session, checkpoint_storage=checkpoint_storage)
if response2.messages:
print(f"[assistant]: {response2.messages[0].text}")
# Show accumulated state
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nTotal checkpoints across both turns: {len(checkpoints)}")
memory_state = session.state.get(InMemoryHistoryProvider.DEFAULT_SOURCE_ID, {})
history = memory_state.get("messages", [])
print(f"Messages in session history: {len(history)}")
async def streaming_with_checkpoints() -> None:
"""Demonstrate streaming with checkpoint storage."""
print("\n" + "=" * 60)
print("Streaming with Checkpointing")
print("=" * 60)
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
assistant = Agent(
client=client,
name="streaming_assistant",
instructions="You are a helpful assistant.",
)
workflow = SequentialBuilder(participants=[assistant]).build()
agent = workflow.as_agent()
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(query, checkpoint_storage=checkpoint_storage, stream=True):
if update.text:
print(update.text, end="", flush=True)
print() # Newline after streaming
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nCheckpoints created during stream: {len(checkpoints)}")
async def main() -> None:
"""Run all checkpoint examples."""
await basic_checkpointing()
await checkpointing_with_thread()
await streaming_with_checkpoints()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,206 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from typing import Any
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
handler,
)
from typing_extensions import Never
"""
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.
"""
# Message types
@dataclass
class TextProcessingRequest:
"""Request to process a text string."""
text: str
task_id: str
@dataclass
class TextProcessingResult:
"""Result of text processing."""
task_id: str
text: str
word_count: int
char_count: int
# Sub-workflow executor
class TextProcessor(Executor):
"""Processes text strings - counts words and characters."""
def __init__(self):
super().__init__(id="text_processor")
@handler
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}")
# Simple text processing
word_count = len(request.text.split()) if request.text.strip() else 0
char_count = len(request.text)
print(f"Task {request.task_id}: {word_count} words, {char_count} characters")
# Create result
result = TextProcessingResult(
task_id=request.task_id,
text=request.text,
word_count=word_count,
char_count=char_count,
)
print(f"Sub-workflow completed task {request.task_id}")
# Signal completion by yielding the result
await ctx.yield_output(result)
# Parent workflow
class TextProcessingOrchestrator(Executor):
"""Orchestrates multiple text processing tasks using sub-workflows."""
results: list[TextProcessingResult] = []
expected_count: int = 0
def __init__(self):
super().__init__(id="text_orchestrator")
@handler
async def start_processing(self, texts: list[str], ctx: WorkflowContext[TextProcessingRequest]) -> None:
"""Start processing multiple text strings."""
print(f"Starting processing of {len(texts)} text strings")
print("=" * 60)
self.expected_count = len(texts)
# Send each text to a sub-workflow
for i, text in enumerate(texts):
task_id = f"task_{i + 1}"
request = TextProcessingRequest(text=text, task_id=task_id)
print(f"Dispatching {task_id} to sub-workflow")
await ctx.send_message(request, target_id="text_processor_workflow")
@handler
async def collect_result(
self,
result: TextProcessingResult,
ctx: WorkflowContext[Never, list[TextProcessingResult]],
) -> None:
"""Collect results from sub-workflows."""
print(f"Collected result from {result.task_id}")
self.results.append(result)
# Check if all results are collected
if len(self.results) == self.expected_count:
print("\nAll tasks completed!")
await ctx.yield_output(self.results)
def get_result_summary(results: list[TextProcessingResult]) -> dict[str, Any]:
"""Get a summary of all processing results."""
total_words = sum(result.word_count for result in results)
total_chars = sum(result.char_count for result in results)
avg_words = total_words / len(results) if results else 0
avg_chars = total_chars / len(results) if results else 0
return {
"total_texts": len(results),
"total_words": total_words,
"total_characters": total_chars,
"average_words_per_text": round(avg_words, 2),
"average_characters_per_text": round(avg_chars, 2),
}
def create_sub_workflow() -> WorkflowExecutor:
"""Create the text processing sub-workflow."""
print("Setting up sub-workflow...")
text_processor = TextProcessor()
processing_workflow = WorkflowBuilder(start_executor=text_processor).build()
return WorkflowExecutor(processing_workflow, id="text_processor_workflow")
async def main():
"""Main function to run the basic sub-workflow example."""
print("Setting up parent workflow...")
# Step 1: Create the parent workflow
orchestrator = TextProcessingOrchestrator()
sub_workflow_executor = create_sub_workflow()
main_workflow = (
WorkflowBuilder(start_executor=orchestrator)
.add_edge(orchestrator, sub_workflow_executor)
.add_edge(sub_workflow_executor, orchestrator)
.build()
)
# Step 2: Test data - various text strings
test_texts = [
"Hello world! This is a simple test.",
"Python is a powerful programming language used for many applications.",
"Short text.",
"This is a longer text with multiple sentences. It contains more words and characters. We use it to test our text processing workflow.", # noqa: E501
"", # Empty string
" Spaces around text ",
]
print(f"\nTesting with {len(test_texts)} text strings")
print("=" * 60)
# Step 3: Run the workflow
result = await main_workflow.run(test_texts)
# Step 4: Display results
print("\nProcessing Results:")
print("=" * 60)
# Sort results by task_id for consistent display
task_results = result.get_outputs()
assert len(task_results) == 1
sorted_results = sorted(task_results[0], key=lambda r: r.task_id)
for result in sorted_results:
preview = result.text[:30] + "..." if len(result.text) > 30 else result.text
preview = preview.replace("\n", " ").strip() or "(empty)"
print(f"{result.task_id}: '{preview}' -> {result.word_count} words, {result.char_count} chars")
# Step 6: Display summary
summary = get_result_summary(sorted_results)
print("\nSummary:")
print("=" * 60)
print(f"Total texts processed: {summary['total_texts']}")
print(f"Total words: {summary['total_words']}")
print(f"Total characters: {summary['total_characters']}")
print(f"Average words per text: {summary['average_words_per_text']}")
print(f"Average characters per text: {summary['average_characters_per_text']}")
print("\nProcessing complete!")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,202 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import os
from typing import Annotated, Any
from agent_framework import (
Agent,
Message,
WorkflowExecutor,
tool,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import SequentialBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Sub-Workflow kwargs Propagation
This sample demonstrates how custom context (kwargs) flows from a parent workflow
through to agents in sub-workflows. When you pass kwargs to the parent workflow's
run(), they automatically propagate to nested sub-workflows.
Key Concepts:
- kwargs passed to parent workflow.run() propagate to sub-workflows
- Sub-workflow agents receive the same kwargs as the parent workflow
- Works with nested WorkflowExecutor compositions at any depth
- Useful for passing authentication tokens, configuration, or request context
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
"""
# Define tools that access custom context via **kwargs
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# see samples/02-agents/tools/function_tool_with_approval.py and
# samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@tool(approval_mode="never_require")
def get_authenticated_data(
resource: Annotated[str, "The resource to fetch"],
**kwargs: Any,
) -> str:
"""Fetch data using the authenticated user context from kwargs."""
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_authenticated_data] kwargs keys: {list(kwargs.keys())}")
print(f"[get_authenticated_data] User: {user_name}, Access: {access_level}")
return f"Fetched '{resource}' for user {user_name} ({access_level} access)"
@tool(approval_mode="never_require")
def call_configured_service(
service_name: Annotated[str, "Name of the service to call"],
**kwargs: Any,
) -> str:
"""Call a service using configuration from kwargs."""
config = kwargs.get("service_config", {})
services = config.get("services", {})
print(f"\n[call_configured_service] kwargs keys: {list(kwargs.keys())}")
print(f"[call_configured_service] Available services: {list(services.keys())}")
if service_name in services:
endpoint = services[service_name]
return f"Called service '{service_name}' at {endpoint}"
return f"Service '{service_name}' not found in configuration"
async def main() -> None:
print("=" * 70)
print("Sub-Workflow kwargs Propagation Demo")
print("=" * 70)
# Create chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create an agent with tools that use kwargs
inner_agent = Agent(
client=client,
name="data_agent",
instructions=(
"You are a data access agent. Use the available tools to help users. "
"When asked to fetch data, use get_authenticated_data. "
"When asked to call a service, use call_configured_service."
),
tools=[get_authenticated_data, call_configured_service],
)
# Build the inner (sub) workflow with the agent
inner_workflow = SequentialBuilder(participants=[inner_agent]).build()
# Wrap the inner workflow in a WorkflowExecutor to use it as a sub-workflow
subworkflow_executor = WorkflowExecutor(
workflow=inner_workflow,
id="data_subworkflow",
)
# Build the outer (parent) workflow containing the sub-workflow
outer_workflow = SequentialBuilder(participants=[subworkflow_executor]).build()
# Define custom context that will flow through to the sub-workflow's agent
user_token = {
"user_name": "alice@contoso.com",
"access_level": "admin",
"session_id": "sess_12345",
}
service_config = {
"services": {
"users": "https://api.example.com/v1/users",
"orders": "https://api.example.com/v1/orders",
"inventory": "https://api.example.com/v1/inventory",
},
"timeout": 30,
}
print("\nContext being passed to parent workflow:")
print(f" user_token: {json.dumps(user_token, indent=4)}")
print(f" service_config: {json.dumps(service_config, indent=4)}")
print("\n" + "-" * 70)
print("Workflow Execution (kwargs flow: parent -> sub-workflow -> agent -> tool):")
print("-" * 70)
# Run the OUTER workflow with kwargs
# These kwargs will automatically propagate to the inner sub-workflow
async for event in outer_workflow.run(
"Please fetch my profile data and then call the users service.",
stream=True,
function_invocation_kwargs={"user_token": user_token, "service_config": service_config},
):
if event.type == "output":
output_data = event.data
if isinstance(output_data, list):
for item in output_data: # type: ignore
if isinstance(item, Message) and item.text:
print(f"\n[Final Answer]: {item.text}")
print("\n" + "=" * 70)
print("Sample Complete - kwargs successfully flowed through sub-workflow!")
print("=" * 70)
"""
Sample Output:
======================================================================
Sub-Workflow kwargs Propagation Demo
======================================================================
Context being passed to parent workflow:
user_token: {
"user_name": "alice@contoso.com",
"access_level": "admin",
"session_id": "sess_12345"
}
service_config: {
"services": {
"users": "https://api.example.com/v1/users",
"orders": "https://api.example.com/v1/orders",
"inventory": "https://api.example.com/v1/inventory"
},
"timeout": 30
}
----------------------------------------------------------------------
Workflow Execution (kwargs flow: parent -> sub-workflow -> agent -> tool):
----------------------------------------------------------------------
[get_authenticated_data] kwargs keys: ['user_token', 'service_config']
[get_authenticated_data] User: alice@contoso.com, Access: admin
[call_configured_service] kwargs keys: ['user_token', 'service_config']
[call_configured_service] Available services: ['users', 'orders', 'inventory']
[Final Answer]: Please fetch my profile data and then call the users service.
[Final Answer]: - Your profile data has been fetched.
- The users service has been called.
Would you like details from either the profile data or the users service response?
======================================================================
Sample Complete - kwargs successfully flowed through sub-workflow!
======================================================================
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,358 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import uuid
from dataclasses import dataclass
from typing import Any, Literal
from agent_framework import (
Executor,
SubWorkflowRequestMessage,
SubWorkflowResponseMessage,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
WorkflowExecutor,
handler,
response_handler,
)
from typing_extensions import Never
"""
This sample demonstrates how to handle multiple parallel requests from a sub-workflow to
different executors in the main workflow.
Prerequisite:
- Understanding of sub-workflows.
- Understanding of requests and responses.
This pattern is useful when a sub-workflow needs to interact with multiple external systems
or services.
This sample implements a resource request distribution system where:
1. A sub-workflow generates requests for computing resources and policy checks.
2. The main workflow has executors that handle resource allocation and policy checking.
3. Responses are routed back to the sub-workflow, which collects and processes them.
The sub-workflow sends two types of requests:
- ResourceRequest: Requests for computing resources (e.g., CPU, memory).
- PolicyRequest: Requests to check resource allocation policies.
The main workflow contains:
- ResourceAllocator: Simulates a system that allocates computing resources.
- PolicyEngine: Simulates a policy engine that approves or denies resource requests.
"""
@dataclass
class ComputingResourceRequest:
"""Request for computing resources."""
request_type: Literal["resource", "policy"]
resource_type: Literal["cpu", "memory", "disk", "gpu"]
amount: int
priority: Literal["low", "normal", "high"] | None = None
policy_type: Literal["quota", "security"] | None = None
@dataclass
class ResourceResponse:
"""Response with allocated resources."""
resource_type: str
allocated: int
source: str # Which system provided the resources
@dataclass
class PolicyResponse:
"""Response from policy check."""
approved: bool
reason: str
@dataclass
class ResourceRequest:
"""Request for computing resources."""
resource_type: Literal["cpu", "memory", "disk", "gpu"]
amount: int
priority: Literal["low", "normal", "high"]
id: str = str(uuid.uuid4())
@dataclass
class PolicyRequest:
"""Request to check resource allocation policy."""
policy_type: Literal["quota", "security"]
resource_type: Literal["cpu", "memory", "disk", "gpu"]
amount: int
id: str = str(uuid.uuid4())
def build_resource_request_distribution_workflow() -> Workflow:
class RequestDistribution(Executor):
"""Distributes computing resource requests to appropriate executors."""
@handler
async def distribute_requests(
self,
requests: list[ComputingResourceRequest],
ctx: WorkflowContext[ResourceRequest | PolicyRequest | int],
) -> None:
for req in requests:
if req.request_type == "resource":
if req.priority is None:
raise ValueError("Priority must be set for resource requests")
await ctx.send_message(ResourceRequest(req.resource_type, req.amount, req.priority))
elif req.request_type == "policy":
if req.policy_type is None:
raise ValueError("Policy type must be set for policy requests")
await ctx.send_message(PolicyRequest(req.policy_type, req.resource_type, req.amount))
else:
raise ValueError(f"Unknown request type: {req.request_type}")
# Notify the collector about the number of requests sent
await ctx.send_message(len(requests))
class ResourceRequester(Executor):
"""Handles resource allocation requests."""
@handler
async def run(self, request: ResourceRequest, ctx: WorkflowContext) -> None:
await ctx.request_info(request_data=request, response_type=ResourceResponse)
@response_handler
async def handle_response(
self, original_request: ResourceRequest, response: ResourceResponse, ctx: WorkflowContext[ResourceResponse]
) -> None:
print(f"Resource allocated: {response.allocated} {response.resource_type} from {response.source}")
await ctx.send_message(response)
class PolicyChecker(Executor):
"""Handles policy check requests."""
@handler
async def run(self, request: PolicyRequest, ctx: WorkflowContext) -> None:
await ctx.request_info(request_data=request, response_type=PolicyResponse)
@response_handler
async def handle_response(
self, original_request: PolicyRequest, response: PolicyResponse, ctx: WorkflowContext[PolicyResponse]
) -> None:
print(f"Policy check result: {response.approved} - {response.reason}")
await ctx.send_message(response)
class ResultCollector(Executor):
"""Collects and processes all responses."""
def __init__(self, id: str) -> None:
super().__init__(id)
self._request_count = 0
self._responses: list[ResourceResponse | PolicyResponse] = []
@handler
async def set_request_count(self, count: int, ctx: WorkflowContext) -> None:
if count <= 0:
raise ValueError("Request count must be positive")
self._request_count = count
@handler
async def collect(self, response: ResourceResponse | PolicyResponse, ctx: WorkflowContext[Never, str]) -> None:
self._responses.append(response)
print(f"Collected {len(self._responses)}/{self._request_count} responses")
if len(self._responses) == self._request_count:
# All responses received, process them
await ctx.yield_output(f"All {self._request_count} requests processed.")
elif len(self._responses) > self._request_count:
raise ValueError("Received more responses than expected")
orchestrator = RequestDistribution("orchestrator")
resource_requester = ResourceRequester("resource_requester")
policy_checker = PolicyChecker("policy_checker")
result_collector = ResultCollector("result_collector")
return (
WorkflowBuilder(start_executor=orchestrator)
.add_edge(orchestrator, resource_requester)
.add_edge(orchestrator, policy_checker)
.add_edge(resource_requester, result_collector)
.add_edge(policy_checker, result_collector)
.add_edge(orchestrator, result_collector) # For request count
.build()
)
class ResourceAllocator(Executor):
"""Simulates a system that allocates computing resources."""
def __init__(self, id: str) -> None:
super().__init__(id)
self._cache: dict[str, int] = {"cpu": 10, "memory": 50, "disk": 100}
# Record pending requests to match responses
self._pending_requests: dict[str, WorkflowEvent[Any]] = {}
async def _handle_resource_request(self, request: ResourceRequest) -> ResourceResponse | None:
"""Allocates resources based on request and available cache."""
available = self._cache.get(request.resource_type, 0)
if available >= request.amount:
self._cache[request.resource_type] -= request.amount
return ResourceResponse(request.resource_type, request.amount, "cache")
return None
@handler
async def handle_subworkflow_request(
self, request: SubWorkflowRequestMessage, ctx: WorkflowContext[SubWorkflowResponseMessage]
) -> None:
"""Handles requests from sub-workflows."""
source_event: WorkflowEvent[Any] = request.source_event
if not isinstance(source_event.data, ResourceRequest):
return
request_payload: ResourceRequest = source_event.data
response = await self._handle_resource_request(request_payload)
if response:
await ctx.send_message(request.create_response(response))
else:
# Request cannot be fulfilled via cache, forward the request to external
self._pending_requests[request_payload.id] = source_event
await ctx.request_info(request_data=request_payload, response_type=ResourceResponse)
@response_handler
async def handle_external_response(
self,
original_request: ResourceRequest,
response: ResourceResponse,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
"""Handles responses from external systems and routes them to the sub-workflow."""
print(f"External resource allocated: {response.allocated} {response.resource_type} from {response.source}")
source_event = self._pending_requests.pop(original_request.id, None)
if source_event is None:
raise ValueError("No matching pending request found for the resource response")
await ctx.send_message(SubWorkflowResponseMessage(data=response, source_event=source_event))
class PolicyEngine(Executor):
"""Simulates a policy engine that approves or denies resource requests."""
def __init__(self, id: str) -> None:
super().__init__(id)
self._quota: dict[str, int] = {
"cpu": 5, # Only allow up to 5 CPU units
"memory": 20, # Only allow up to 20 memory units
"disk": 1000, # Liberal disk policy
}
# Record pending requests to match responses
self._pending_requests: dict[str, WorkflowEvent[Any]] = {}
@handler
async def handle_subworkflow_request(
self, request: SubWorkflowRequestMessage, ctx: WorkflowContext[SubWorkflowResponseMessage]
) -> None:
"""Handles requests from sub-workflows."""
source_event: WorkflowEvent[Any] = request.source_event
if not isinstance(source_event.data, PolicyRequest):
return
request_payload: PolicyRequest = source_event.data
# Simple policy logic for demonstration
if request_payload.policy_type == "quota":
allowed_amount = self._quota.get(request_payload.resource_type, 0)
if request_payload.amount <= allowed_amount:
response = PolicyResponse(True, "Within quota limits")
else:
response = PolicyResponse(False, "Exceeds quota limits")
await ctx.send_message(request.create_response(response))
else:
# For other policy types, forward to external system
self._pending_requests[request_payload.id] = source_event
await ctx.request_info(request_data=request_payload, response_type=PolicyResponse)
@response_handler
async def handle_external_response(
self,
original_request: PolicyRequest,
response: PolicyResponse,
ctx: WorkflowContext[SubWorkflowResponseMessage],
) -> None:
"""Handles responses from external systems and routes them to the sub-workflow."""
print(f"External policy check result: {response.approved} - {response.reason}")
source_event = self._pending_requests.pop(original_request.id, None)
if source_event is None:
raise ValueError("No matching pending request found for the policy response")
await ctx.send_message(SubWorkflowResponseMessage(data=response, source_event=source_event))
async def main() -> None:
# Build the main workflow
resource_allocator = ResourceAllocator("resource_allocator")
policy_engine = PolicyEngine("policy_engine")
sub_workflow_executor = WorkflowExecutor(
build_resource_request_distribution_workflow(),
"sub_workflow_executor",
# Setting allow_direct_output=True to let the sub-workflow output directly.
# This is because the sub-workflow is the both the entry point and the exit
# point of the main workflow.
allow_direct_output=True,
)
main_workflow = (
WorkflowBuilder(start_executor=sub_workflow_executor)
.add_edge(sub_workflow_executor, resource_allocator)
.add_edge(resource_allocator, sub_workflow_executor)
.add_edge(sub_workflow_executor, policy_engine)
.add_edge(policy_engine, sub_workflow_executor)
.build()
)
# Test requests
test_requests = [
ComputingResourceRequest("resource", "cpu", 2, priority="normal"), # cache hit
ComputingResourceRequest("policy", "cpu", 3, policy_type="quota"), # policy hit
ComputingResourceRequest("resource", "memory", 15, priority="normal"), # cache hit
ComputingResourceRequest("policy", "memory", 100, policy_type="quota"), # policy miss -> external
ComputingResourceRequest("resource", "gpu", 1, priority="high"), # cache miss -> external
ComputingResourceRequest("policy", "disk", 500, policy_type="quota"), # policy hit
ComputingResourceRequest("policy", "cpu", 1, policy_type="security"), # unknown policy -> external
]
# Run the workflow
print(f"Testing with {len(test_requests)} mixed requests.")
print("Starting main workflow...")
run_result = await main_workflow.run(test_requests)
# Handle request info events
request_info_events = run_result.get_request_info_events()
if request_info_events:
print(f"\nHandling {len(request_info_events)} request info events...\n")
responses: dict[str, ResourceResponse | PolicyResponse] = {}
for event in request_info_events:
if isinstance(event.data, ResourceRequest):
# Simulate external resource allocation
resource_response = ResourceResponse(
resource_type=event.data.resource_type, allocated=event.data.amount, source="external_provider"
)
responses[event.request_id] = resource_response
elif isinstance(event.data, PolicyRequest):
# Simulate external policy check
response = PolicyResponse(True, "External system approved")
responses[event.request_id] = response
else:
print(f"Unknown request info event data type: {type(event.data)}")
run_result = await main_workflow.run(responses=responses)
outputs = run_result.get_outputs()
if outputs:
print("\nWorkflow completed with outputs:")
for output in outputs:
print(f"- {output}")
else:
raise RuntimeError("Workflow did not produce an output.")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,306 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from dataclasses import dataclass
from agent_framework import (
Executor,
SubWorkflowRequestMessage,
SubWorkflowResponseMessage,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
handler,
response_handler,
)
from typing_extensions import Never
"""
This sample demonstrates how to handle request from the sub-workflow in the main workflow.
Prerequisite:
- Understanding of sub-workflows.
- Understanding of requests and responses.
This pattern is useful when you want to reuse a workflow that makes requests to an external system,
but you want to intercept those requests in the main workflow and handle them without further propagation
to the external system.
This sample implements a smart email delivery system that validates email addresses before sending emails.
1. We will start by creating a workflow that validates email addresses in a sequential manner. The validation
consists of three steps: sanitization, format validation, and domain validation. The domain validation
step will involve checking if the email domain is valid by making a request to an external system.
2. Then we will create a main workflow that uses the email validation workflow as a sub-workflow. The main
workflow will intercept the domain validation requests from the sub-workflow and handle them internally
without propagating them to an external system.
3. Once the email address is validated, the main workflow will proceed to send the email if the address is valid,
or block the email if the address is invalid.
"""
@dataclass
class SanitizedEmailResult:
"""Result of email sanitization and validation.
The properties get built up as the email address goes through
the validation steps in the workflow.
"""
original: str
sanitized: str
is_valid: bool
def build_email_address_validation_workflow() -> Workflow:
"""Build an email address validation workflow.
This workflow consists of three steps (each is represented by an executor):
1. Sanitize the email address, such as removing leading/trailing spaces.
2. Validate the email address format, such as checking for "@" and domain.
3. Extract the domain from the email address and request domain validation,
after which it completes with the final result.
"""
class EmailSanitizer(Executor):
"""Sanitize email address by trimming spaces."""
@handler
async def handle(self, email_address: str, ctx: WorkflowContext[SanitizedEmailResult]) -> None:
"""Trim leading and trailing spaces from the email address.
This executor doesn't produce any workflow output, but sends the sanitized
email address to the next executor in the workflow.
"""
sanitized = email_address.strip()
print(f"Sanitized email address: '{sanitized}'")
await ctx.send_message(SanitizedEmailResult(original=email_address, sanitized=sanitized, is_valid=False))
class EmailFormatValidator(Executor):
"""Validate email address format."""
@handler
async def handle(
self,
partial_result: SanitizedEmailResult,
ctx: WorkflowContext[SanitizedEmailResult, SanitizedEmailResult],
) -> None:
"""Validate the email address format.
This executor can potentially produce a workflow output (False if the format is invalid).
When the format is valid, it sends the validated email address to the next executor in the workflow.
"""
if "@" not in partial_result.sanitized or "." not in partial_result.sanitized.split("@")[-1]:
print(f"Invalid email format: '{partial_result.sanitized}'")
await ctx.yield_output(
SanitizedEmailResult(
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False
)
)
return
print(f"Validated email format: '{partial_result.sanitized}'")
await ctx.send_message(
SanitizedEmailResult(
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False
)
)
class DomainValidator(Executor):
"""Validate email domain."""
def __init__(self, id: str):
super().__init__(id=id)
self._pending_domains: dict[str, SanitizedEmailResult] = {}
@handler
async def handle(self, partial_result: SanitizedEmailResult, ctx: WorkflowContext) -> None:
"""Extract the domain from the email address and request domain validation.
This executor doesn't produce any workflow output, but sends a domain validation request
to an external system to user for validation.
"""
domain = partial_result.sanitized.split("@")[-1]
print(f"Validating domain: '{domain}'")
self._pending_domains[domain] = partial_result
# Send a request to the external system via the request_info mechanism
await ctx.request_info(request_data=domain, response_type=bool)
@response_handler
async def handle_domain_validation_response(
self, original_request: str, is_valid: bool, ctx: WorkflowContext[Never, SanitizedEmailResult]
) -> None:
"""Handle the domain validation response.
This method receives the response from the external system and yields the final
validation result (True if both format and domain are valid, False otherwise).
"""
if original_request not in self._pending_domains:
raise ValueError(f"Received response for unknown domain: '{original_request}'")
partial_result = self._pending_domains.pop(original_request)
if is_valid:
print(f"Domain '{original_request}' is valid.")
await ctx.yield_output(
SanitizedEmailResult(
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=True
)
)
else:
print(f"Domain '{original_request}' is invalid.")
await ctx.yield_output(
SanitizedEmailResult(
original=partial_result.original, sanitized=partial_result.sanitized, is_valid=False
)
)
# Build the workflow
email_sanitizer = EmailSanitizer(id="email_sanitizer")
email_format_validator = EmailFormatValidator(id="email_format_validator")
domain_validator = DomainValidator(id="domain_validator")
return (
WorkflowBuilder(start_executor=email_sanitizer)
.add_edge(email_sanitizer, email_format_validator)
.add_edge(email_format_validator, domain_validator)
.build()
)
@dataclass
class Email:
recipient: str
subject: str
body: str
class SmartEmailOrchestrator(Executor):
"""Orchestrates email address validation using a sub-workflow."""
def __init__(self, id: str, approved_domains: set[str]):
"""Initialize the orchestrator with a set of approved domains.
Args:
id: The executor ID.
approved_domains: A set of domains that are considered valid.
"""
super().__init__(id=id)
self._approved_domains = approved_domains
# Keep track of previously approved and disapproved recipients
self._approved_recipients: set[str] = set()
self._disapproved_recipients: set[str] = set()
# Record pending emails waiting for validation results
self._pending_emails: dict[str, Email] = {}
@handler
async def run(self, email: Email, ctx: WorkflowContext[Email | str, bool]) -> None:
"""Start the email delivery process.
This handler receives an Email object. If the recipient has been previously approved,
it sends the email object to the next executor to handle delivery. If the recipient
has been previously disapproved, it yields False as the final result. Otherwise,
it sends the recipient email address to the sub-workflow for validation.
"""
recipient = email.recipient
if recipient in self._approved_recipients:
print(f"Recipient '{recipient}' has been previously approved.")
await ctx.send_message(email)
return
if recipient in self._disapproved_recipients:
print(f"Blocking email to previously disapproved recipient: '{recipient}'")
await ctx.yield_output(False)
return
print(f"Validating new recipient email address: '{recipient}'")
self._pending_emails[recipient] = email
await ctx.send_message(recipient)
@handler
async def handler_domain_validation_request(
self, request: SubWorkflowRequestMessage, ctx: WorkflowContext[SubWorkflowResponseMessage]
) -> None:
"""Handle requests from the sub-workflow for domain validation.
Note that the message type must be SubWorkflowRequestMessage to intercept the request. And
the response must be sent back using SubWorkflowResponseMessage to route the response
back to the sub-workflow.
"""
if not isinstance(request.source_event.data, str):
raise TypeError(f"Expected domain string, got {type(request.source_event.data)}")
domain = request.source_event.data
is_valid = domain in self._approved_domains
print(f"External domain validation for '{domain}': {'valid' if is_valid else 'invalid'}")
await ctx.send_message(request.create_response(is_valid), target_id=request.executor_id)
@handler
async def handle_validation_result(self, result: SanitizedEmailResult, ctx: WorkflowContext[Email, bool]) -> None:
"""Handle the email address validation result.
This handler receives the validation result from the sub-workflow.
If the email address is valid, it adds the recipient to the approved list
and sends the email object to the next executor to handle delivery.
If the email address is invalid, it adds the recipient to the disapproved list
and yields False as the final result.
"""
email = self._pending_emails.pop(result.original)
email.recipient = result.sanitized # Use the sanitized email address
if result.is_valid:
print(f"Email address '{result.original}' is valid.")
self._approved_recipients.add(result.original)
await ctx.send_message(email)
else:
print(f"Email address '{result.original}' is invalid. Blocking email.")
self._disapproved_recipients.add(result.original)
await ctx.yield_output(False)
class EmailDelivery(Executor):
"""Simulates email delivery."""
@handler
async def handle(self, email: Email, ctx: WorkflowContext[Never, bool]) -> None:
"""Simulate sending the email and yield True as the final result."""
print(f"Sending email to '{email.recipient}' with subject '{email.subject}'")
await asyncio.sleep(1) # Simulate network delay
print(f"Email sent to '{email.recipient}' successfully.")
await ctx.yield_output(True)
async def main() -> None:
# A list of approved domains
approved_domains = {"example.com", "company.com"}
# Build the main workflow
smart_email_orchestrator = SmartEmailOrchestrator(id="smart_email_orchestrator", approved_domains=approved_domains)
email_delivery = EmailDelivery(id="email_delivery")
email_validation_workflow = WorkflowExecutor(
build_email_address_validation_workflow(), id="email_validation_workflow"
)
workflow = (
WorkflowBuilder(start_executor=smart_email_orchestrator)
.add_edge(smart_email_orchestrator, email_validation_workflow)
.add_edge(email_validation_workflow, smart_email_orchestrator)
.add_edge(smart_email_orchestrator, email_delivery)
.build()
)
test_emails = [
Email(recipient="user1@example.com", subject="Hello User1", body="This is a test email."),
Email(recipient=" user2@invalid", subject="Hello User2", body="This is a test email."),
Email(recipient=" user3@company.com ", subject="Hello User3", body="This is a test email."),
Email(recipient="user4@unknown.com", subject="Hello User4", body="This is a test email."),
# Re-send to an approved recipient
Email(recipient="user1@example.com", subject="Hello User1", body="This is a test email."),
# Re-send to a disapproved recipient
Email(recipient=" user2@invalid", subject="Hello User2", body="This is a test email."),
]
# Execute the workflow
for email in test_emails:
print(f"\nProcessing email to '{email.recipient}'")
async for event in workflow.run(email, stream=True):
if event.type == "output":
print(f"Final result for '{email.recipient}': {'Delivered' if event.data else 'Blocked'}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,249 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import Any
from agent_framework import ( # Core chat primitives used to build requests
Agent,
AgentExecutor,
AgentExecutorRequest, # Input message bundle for an AgentExecutor
AgentExecutorResponse,
Message,
WorkflowBuilder, # Fluent builder for wiring executors and edges
WorkflowContext, # Per-run context and event bus
executor, # Decorator to declare a Python function as a workflow executor
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.openai import OpenAIChatOptions # Thin client wrapper for Azure OpenAI chat models
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
from dotenv import load_dotenv
from pydantic import BaseModel # Structured outputs for safer parsing
from typing_extensions import Never
# Load environment variables from .env file
load_dotenv()
"""
Sample: Conditional routing with structured outputs
What this sample is:
- A minimal decision workflow that classifies an inbound email as spam or not spam, then routes to the
appropriate handler.
Purpose:
- Show how to attach boolean edge conditions that inspect an AgentExecutorResponse.
- Demonstrate using Pydantic models as response_format so the agent returns JSON we can validate and parse.
- Illustrate how to transform one agent's structured result into a new AgentExecutorRequest for a downstream agent.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- You understand the basics of WorkflowBuilder, executors, and events in this framework.
- You know the concept of edge conditions and how they gate routes using a predicate function.
- Azure OpenAI access is configured for FoundryChatClient. You should be logged in with Azure CLI (AzureCliCredential)
and have the Foundry V2 Project environment variables set as documented in the getting started chat client README.
- The sample email resource file exists at workflow/resources/email.txt.
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
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 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.
"""
class DetectionResult(BaseModel):
"""Represents the result of spam detection."""
# is_spam drives the routing decision taken by edge conditions
is_spam: bool
# Human readable rationale from the detector
reason: str
# The agent must include the original email so downstream agents can operate without reloading content
email_content: str
class EmailResponse(BaseModel):
"""Represents the response from the email assistant."""
# The drafted reply that a user could copy or send
response: str
def get_condition(expected_result: bool):
"""Create a condition callable that routes based on DetectionResult.is_spam."""
# The returned function will be used as an edge predicate.
# It receives whatever the upstream executor produced.
def condition(message: Any) -> bool:
# Defensive guard. If a non AgentExecutorResponse appears, let the edge pass to avoid dead ends.
if not isinstance(message, AgentExecutorResponse):
return True
try:
# Prefer parsing a structured DetectionResult from the agent JSON text.
# Using model_validate_json ensures type safety and raises if the shape is wrong.
detection = DetectionResult.model_validate_json(message.agent_response.text)
# Route only when the spam flag matches the expected path.
return detection.is_spam == expected_result
except Exception:
# Fail closed on parse errors so we do not accidentally route to the wrong path.
# Returning False prevents this edge from activating.
return False
return condition
@executor(id="send_email")
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_response.text)
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[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_response.text)
if detection.is_spam:
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.")
@executor(id="to_email_assistant_request")
async def to_email_assistant_request(
response: AgentExecutorResponse, ctx: WorkflowContext[AgentExecutorRequest]
) -> None:
"""Transform detection result into an AgentExecutorRequest for the email assistant.
Extracts DetectionResult.email_content and forwards it as a user message.
"""
# Bridge executor. Converts a structured DetectionResult into a Message and forwards it as a new request.
detection = DetectionResult.model_validate_json(response.agent_response.text)
user_msg = Message("user", contents=[detection.email_content])
await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True))
def create_spam_detector_agent() -> Agent:
"""Helper to create a spam detection agent."""
# AzureCliCredential uses your current az login. This avoids embedding secrets in code.
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=(
"You are a spam detection assistant that identifies spam emails. "
"Always return JSON with fields is_spam (bool), reason (string), and email_content (string). "
"Include the original email content in email_content."
),
name="spam_detection_agent",
default_options=OpenAIChatOptions[Any](response_format=DetectionResult),
)
def create_email_assistant_agent() -> Agent:
"""Helper to create an email assistant agent."""
# AzureCliCredential uses your current az login. This avoids embedding secrets in code.
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=(
"You are an email assistant that helps users draft professional responses to emails. "
"Your input may be a JSON object that includes 'email_content'; base your reply on that content. "
"Return JSON with a single field 'response' containing the drafted reply."
),
name="email_assistant_agent",
default_options=OpenAIChatOptions[Any](response_format=EmailResponse),
)
async def main() -> None:
# Build the workflow graph.
# Start at the spam detector.
# If not spam, hop to a transformer that creates a new AgentExecutorRequest,
# then call the email assistant, then finalize.
# If spam, go directly to the spam handler and finalize.
spam_detection_agent = AgentExecutor(create_spam_detector_agent())
email_assistant_agent = AgentExecutor(create_email_assistant_agent())
workflow = (
WorkflowBuilder(start_executor=spam_detection_agent)
# Not spam path: transform response -> request for assistant -> assistant -> send email
.add_edge(spam_detection_agent, to_email_assistant_request, condition=get_condition(False))
.add_edge(to_email_assistant_request, email_assistant_agent)
.add_edge(email_assistant_agent, handle_email_response)
# Spam path: send to spam handler
.add_edge(spam_detection_agent, handle_spam_classifier_response, condition=get_condition(True))
.build()
)
# Read Email content from the sample resource file.
# This keeps the sample deterministic since the model sees the same email every run.
email_path = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "resources", "email.txt") # noqa: ASYNC240
with open(email_path) as email_file: # noqa: ASYNC230
email = email_file.read()
# Execute the workflow. Since the start is an AgentExecutor, pass an AgentExecutorRequest.
# The workflow completes when it becomes idle (no more work to do).
request = AgentExecutorRequest(messages=[Message("user", contents=[email])], should_respond=True)
events = await workflow.run(request)
outputs = events.get_outputs()
if outputs:
print(f"Workflow output: {outputs[0]}")
"""
Sample Output:
Processing email:
Subject: Team Meeting Follow-up - Action Items
Hi Sarah,
I wanted to follow up on our team meeting this morning and share the action items we discussed:
1. Update the project timeline by Friday
2. Schedule client presentation for next week
3. Review the budget allocation for Q4
Please let me know if you have any questions or if I missed anything from our discussion.
Best regards,
Alex Johnson
Project Manager
Tech Solutions Inc.
alex.johnson@techsolutions.com
(555) 123-4567
----------------------------------------
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.
I will update the project timeline by Friday, begin scheduling the client presentation for next week, and start reviewing the Q4 budget allocation. If any questions or issues arise, I'll reach out.
Thank you again for outlining the next steps.
Best regards,
Sarah
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,158 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import (
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
executor,
)
from typing_extensions import Never
"""
Sample: Workflow Output vs Intermediate Output labeling
What this sample shows
- How ``WorkflowBuilder(output_from=[...])`` designates which executors emit
Workflow Output.
- How ``WorkflowBuilder(intermediate_output_from=[...])`` designates which executor
yields surface as Intermediate Output (``type='intermediate'`` events).
- How unlisted executor yields are hidden from caller-facing output/intermediate
events in explicit designation mode.
- How the same workflow wrapped via ``workflow.as_agent()`` translates intermediate
events to ``text_reasoning`` content so existing ``.text`` accessors keep
returning Workflow Output only.
- How a sub-workflow embedded via ``WorkflowExecutor`` bubbles its intermediate
emissions up through the parent's event stream, attributed to the
``WorkflowExecutor`` id rather than the child's internal executor ids.
The output selection contract:
- Compatibility mode: when neither ``output_from`` nor ``intermediate_output_from``
is provided, every ``yield_output`` produces Workflow Output and a deprecation
warning points to explicit selection.
- Explicit selection mode: provide either ``output_from`` or
``intermediate_output_from``. Executors selected by ``output_from`` emit Workflow Output
(``type='output'`` events); executors selected by ``intermediate_output_from`` emit
Intermediate Output (``type='intermediate'`` events); unselected executor yields are
hidden from the stream and ``WorkflowRunResult`` output accessors.
- Validation: explicit selections must not both be empty; duplicate executor entries,
overlap between Workflow Output and Intermediate Output, unknown executors, invalid
literals, and selected executors without workflow output types are rejected.
Prerequisites
- No external services required.
"""
@executor(id="planner")
async def planner(messages: list[Message], ctx: WorkflowContext[list[Message], str]) -> None:
"""Intermediate step: emits a visible progress note, then forwards."""
prompt = messages[0].text if messages else ""
await ctx.yield_output(f"plan: starting work on '{prompt}'")
await ctx.send_message(messages)
@executor(id="researcher")
async def researcher(messages: list[Message], ctx: WorkflowContext[list[Message], str]) -> None:
"""Intermediate step: emits visible progress, then forwards."""
prompt = messages[0].text if messages else ""
await ctx.yield_output(f"research: gathering data for '{prompt}'")
await ctx.send_message(messages)
@executor(id="answerer")
async def answerer(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
"""Designated Workflow Output: emits the workflow's answer."""
prompt = messages[0].text if messages else ""
await ctx.yield_output(f"final answer to '{prompt}': 42")
async def main() -> None:
# Build with explicit Workflow Output and Intermediate Output selections.
# `answerer` produces type='output' events; planner and researcher produce
# visible type='intermediate' events.
workflow = (
WorkflowBuilder(
start_executor=planner,
output_from=[answerer],
intermediate_output_from=[planner, researcher],
)
.add_edge(planner, researcher)
.add_edge(researcher, answerer)
.build()
)
initial = [Message(role="user", contents=["life, the universe, and everything"])]
print("=== Streaming events (workflow.run(stream=True)) ===")
async for event in workflow.run(initial, stream=True):
if event.type == "intermediate":
print(f" [intermediate] {event.executor_id}: {event.data}")
elif event.type == "output":
print(f" [output] {event.executor_id}: {event.data}")
# WorkflowRunResult.get_outputs() filters to type='output' events, so it
# only returns the selected Workflow Output yield.
print("\n=== Non-streaming run().get_outputs() ===")
result = await workflow.run(initial)
print(f" outputs: {result.get_outputs()}")
# When the same workflow is wrapped via as_agent(), intermediate events
# surface as ``text_reasoning`` content; Workflow Output surfaces as
# ``text`` content. Existing callers reading ``response.text`` get only
# the selected Workflow Output because ``.text`` filters to text content.
print("\n=== workflow.as_agent() -- intermediate -> text_reasoning content ===")
agent = workflow.as_agent("planner-agent")
response = await agent.run("life, the universe, and everything")
print(f" response.text (Workflow Output only): {response.text!r}")
reasoning = " | ".join(
c.text for m in response.messages for c in m.contents if c.type == "text_reasoning" and c.text is not None
)
print(f" reasoning content (intermediates): {reasoning!r}")
# Embed the same workflow as a node inside a larger workflow via WorkflowExecutor.
# Child intermediate emissions are forwarded to the parent's event stream with the
# WorkflowExecutor's id as the source, so outer callers don't have to know the
# child's internal executor layout. The 'intermediate' label is preserved across
# the boundary regardless of how the parent designates the WorkflowExecutor.
print("\n=== Embedding as a sub-workflow -- intermediates bubble up ===")
sub = WorkflowExecutor(workflow, id="sub")
@executor(id="parent_sink")
async def parent_sink(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(message)
parent_workflow = WorkflowBuilder(start_executor=sub, output_from=[parent_sink]).add_edge(sub, parent_sink).build()
async for event in parent_workflow.run(initial, stream=True):
if event.type == "intermediate":
print(f" [intermediate] {event.executor_id}: {event.data}")
elif event.type == "output":
print(f" [output] {event.executor_id}: {event.data}")
"""
Sample output:
=== Streaming events (workflow.run(stream=True)) ===
[intermediate] planner: plan: starting work on 'life, the universe, and everything'
[intermediate] researcher: research: gathering data for 'life, the universe, and everything'
[output] answerer: final answer to 'life, the universe, and everything': 42
=== Non-streaming run().get_outputs() ===
outputs: ["final answer to 'life, the universe, and everything': 42"]
=== workflow.as_agent() -- intermediate -> text_reasoning content ===
response.text (Workflow Output only): "final answer to 'life, the universe, and everything': 42"
reasoning content (intermediates): "plan: starting work on ... | research: gathering data for ..."
=== Embedding as a sub-workflow -- intermediates bubble up ===
[intermediate] sub: plan: starting work on 'life, the universe, and everything'
[intermediate] sub: research: gathering data for 'life, the universe, and everything'
[output] parent_sink: final answer to 'life, the universe, and everything': 42
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,318 @@
# Copyright (c) Microsoft. All rights reserved.
"""Step 06b — Multi-Selection Edge Group sample."""
import asyncio
import os
from dataclasses import dataclass
from typing import Any, Literal
from uuid import uuid4
from agent_framework import (
Agent,
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponseUpdate,
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
executor,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.openai import OpenAIChatOptions
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import BaseModel
from typing_extensions import Never
# Load environment variables from .env file
load_dotenv()
"""
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. 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.
Show how to:
- Implement a selection function that chooses one or more downstream branches based on analysis.
- Share workflow state across branches so different executors can read the same email content.
- Validate agent outputs with Pydantic models for robust structured data exchange.
- Merge results from multiple branches (e.g., a summary) back into a typed state.
- Apply conditional persistence logic (short vs long emails).
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- Familiarity with WorkflowBuilder, executors, edges, and events.
- Understanding of multi-selection edge groups and how their selection function maps to target ids.
- Experience with workflow state for persisting and reusing objects.
"""
EMAIL_STATE_PREFIX = "email:"
CURRENT_EMAIL_ID_KEY = "current_email_id"
LONG_EMAIL_THRESHOLD = 100
class AnalysisResultAgent(BaseModel):
spam_decision: Literal["NotSpam", "Spam", "Uncertain"]
reason: str
class EmailResponse(BaseModel):
response: str
class EmailSummaryModel(BaseModel):
summary: str
@dataclass
class Email:
email_id: str
email_content: str
@dataclass
class AnalysisResult:
spam_decision: str
reason: str
email_length: int
email_summary: str
email_id: str
class DatabaseEvent(WorkflowEvent): ...
@executor(id="store_email")
async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
new_email = Email(email_id=str(uuid4()), email_content=email_text)
ctx.set_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email)
ctx.set_state(CURRENT_EMAIL_ID_KEY, new_email.email_id)
await ctx.send_message(
AgentExecutorRequest(messages=[Message("user", contents=[new_email.email_content])], should_respond=True)
)
@executor(id="to_analysis_result")
async def to_analysis_result(response: AgentExecutorResponse, ctx: WorkflowContext[AnalysisResult]) -> None:
parsed = AnalysisResultAgent.model_validate_json(response.agent_response.text)
email_id: str = ctx.get_state(CURRENT_EMAIL_ID_KEY)
email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{email_id}")
await ctx.send_message(
AnalysisResult(
spam_decision=parsed.spam_decision,
reason=parsed.reason,
email_length=len(email.email_content),
email_summary="",
email_id=email_id,
)
)
@executor(id="submit_to_email_assistant")
async def submit_to_email_assistant(analysis: AnalysisResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
if analysis.spam_decision != "NotSpam":
raise RuntimeError("This executor should only handle NotSpam messages.")
email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
await ctx.send_message(
AgentExecutorRequest(messages=[Message("user", contents=[email.email_content])], should_respond=True)
)
@executor(id="finalize_and_send")
async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
parsed = EmailResponse.model_validate_json(response.agent_response.text)
await ctx.yield_output(f"Email sent: {parsed.response}")
@executor(id="summarize_email")
async def summarize_email(analysis: AnalysisResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Only called for long NotSpam emails by selection_func
email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
await ctx.send_message(
AgentExecutorRequest(messages=[Message("user", contents=[email.email_content])], should_respond=True)
)
@executor(id="merge_summary")
async def merge_summary(response: AgentExecutorResponse, ctx: WorkflowContext[AnalysisResult]) -> None:
summary = EmailSummaryModel.model_validate_json(response.agent_response.text)
email_id: str = ctx.get_state(CURRENT_EMAIL_ID_KEY)
email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{email_id}")
# Build an AnalysisResult mirroring to_analysis_result but with summary
await ctx.send_message(
AnalysisResult(
spam_decision="NotSpam",
reason="",
email_length=len(email.email_content),
email_summary=summary.summary,
email_id=email_id,
)
)
@executor(id="handle_spam")
async def handle_spam(analysis: AnalysisResult, ctx: WorkflowContext[Never, str]) -> None:
if analysis.spam_decision == "Spam":
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[Never, str]) -> None:
if analysis.spam_decision == "Uncertain":
email: Email | None = ctx.get_state(f"{EMAIL_STATE_PREFIX}{analysis.email_id}")
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[Never, str]) -> None:
# Simulate DB writes for email and analysis (and summary if present)
await asyncio.sleep(0.05)
await ctx.add_event(DatabaseEvent(type="database_event", data=f"Email {analysis.email_id} saved to database.")) # type: ignore
def create_email_analysis_agent() -> Agent:
"""Creates the email analysis agent."""
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=(
"You are a spam detection assistant that identifies spam emails. "
"Always return JSON with fields 'spam_decision' (one of NotSpam, Spam, Uncertain) "
"and 'reason' (string)."
),
name="email_analysis_agent",
default_options=OpenAIChatOptions[Any](response_format=AnalysisResultAgent),
)
def create_email_assistant_agent() -> Agent:
"""Creates the email assistant agent."""
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=("You are an email assistant that helps users draft responses to emails with professionalism."),
name="email_assistant_agent",
default_options=OpenAIChatOptions[Any](response_format=EmailResponse),
)
def create_email_summary_agent() -> Agent:
"""Creates the email summary agent."""
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=("You are an assistant that helps users summarize emails."),
name="email_summary_agent",
default_options=OpenAIChatOptions[Any](response_format=EmailSummaryModel),
)
async def main() -> None:
# Build the workflow
email_analysis_agent = AgentExecutor(create_email_analysis_agent())
email_assistant_agent = AgentExecutor(create_email_assistant_agent())
email_summary_agent = AgentExecutor(create_email_summary_agent())
def select_targets(analysis: AnalysisResult, target_ids: list[str]) -> list[str]:
# Order: [handle_spam, submit_to_email_assistant, summarize_email, handle_uncertain]
handle_spam_id, submit_to_email_assistant_id, summarize_email_id, handle_uncertain_id = target_ids
if analysis.spam_decision == "Spam":
return [handle_spam_id]
if analysis.spam_decision == "NotSpam":
targets = [submit_to_email_assistant_id]
if analysis.email_length > LONG_EMAIL_THRESHOLD:
targets.append(summarize_email_id)
return targets
return [handle_uncertain_id]
workflow = (
WorkflowBuilder(start_executor=store_email)
.add_edge(store_email, email_analysis_agent)
.add_edge(email_analysis_agent, to_analysis_result)
.add_multi_selection_edge_group(
to_analysis_result,
[handle_spam, submit_to_email_assistant, summarize_email, handle_uncertain],
selection_func=select_targets,
)
.add_edge(submit_to_email_assistant, email_assistant_agent)
.add_edge(email_assistant_agent, finalize_and_send)
.add_edge(summarize_email, email_summary_agent)
.add_edge(email_summary_agent, merge_summary)
# Save to DB if short (no summary path)
.add_edge(to_analysis_result, database_access, condition=lambda r: r.email_length <= LONG_EMAIL_THRESHOLD)
# Save to DB with summary when long
.add_edge(merge_summary, database_access)
.build()
)
# Read an email sample
resources_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
"resources",
"email.txt",
)
if os.path.exists(resources_path):
with open(resources_path, encoding="utf-8") as f: # noqa: ASYNC230
email = f.read()
else:
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(email, stream=True):
if isinstance(event, DatabaseEvent):
print(f"{event}")
elif event.type == "output":
if isinstance(event.data, AgentResponseUpdate):
# Agent executors stream token-level updates. Skip these to keep sample
# output focused on final workflow results.
continue
print(f"Workflow output: {event.data}")
"""
Sample Output:
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.
I'll aim to have the updated project timeline ready by Friday and will
coordinate with the team to schedule the client presentation for next week.
I'll also review the Q4 budget allocation and share my feedback soon.
If anything else comes up, please let me know.
Best regards,
Sarah
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,85 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import cast
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowContext,
handler,
)
from typing_extensions import Never
"""
Sample: Sequential workflow with streaming.
Two custom executors run in sequence. The first converts text to uppercase,
the second reverses the text and completes the workflow. The streaming run loop prints events as they occur.
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_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.
"""
class UpperCaseExecutor(Executor):
"""Converts an input string to uppercase and forwards it.
Concepts:
- @handler methods define invokable steps.
- WorkflowContext[str] indicates this step emits a string to the next node.
"""
@handler
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
"""Transform the input to uppercase and send it downstream."""
result = text.upper()
# Pass the intermediate result to the next executor in the chain.
await ctx.send_message(result)
class ReverseTextExecutor(Executor):
"""Reverses the incoming string and yields workflow output.
Concepts:
- 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[Never, str]) -> None:
"""Reverse the input string and yield the workflow output."""
result = text[::-1]
await ctx.yield_output(result)
async def main() -> None:
"""Build a two step sequential workflow and run it with streaming to observe events."""
# Step 1: Build the workflow graph.
# Order matters. We connect upper_case_executor -> reverse_text_executor and set the start.
upper_case_executor = UpperCaseExecutor(id="upper_case_executor")
reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor")
workflow = (
WorkflowBuilder(start_executor=upper_case_executor).add_edge(upper_case_executor, reverse_text_executor).build()
)
# Step 2: Stream events for a single input.
# The stream will include executor invoke and completion events, plus workflow outputs.
outputs: list[str] = []
async for event in workflow.run("hello world", stream=True):
print(f"Event: {event}")
if event.type == "output":
outputs.append(cast(str, event.data))
if outputs:
print(f"Workflow outputs: {outputs}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,80 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import WorkflowBuilder, WorkflowContext, executor
from typing_extensions import Never
"""
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 yields the workflow output. Events are printed as they arrive from a streaming run.
Purpose:
Show how to declare executors with the @executor decorator, connect them with WorkflowBuilder,
pass intermediate values using ctx.send_message, and yield final output using ctx.yield_output().
Demonstrate how streaming exposes executor_invoked events (type='executor_invoked') and
executor_completed events (type='executor_completed') for observability.
Prerequisites:
- No external services required.
"""
# Step 1: Define methods using the executor decorator.
@executor(id="upper_case_executor")
async def to_upper_case(text: str, ctx: WorkflowContext[str]) -> None:
"""Transform the input to uppercase and forward it to the next step.
Concepts:
- The @executor decorator registers this function as a workflow node.
- WorkflowContext[str] indicates that this node emits a string payload downstream.
"""
result = text.upper()
# Send the intermediate result to the next executor in the workflow graph.
await ctx.send_message(result)
@executor(id="reverse_text_executor")
async def reverse_text(text: str, ctx: WorkflowContext[Never, str]) -> None:
"""Reverse the input and yield the workflow output.
Concepts:
- Terminal nodes yield output using ctx.yield_output().
- The workflow completes when it becomes idle (no more work to do).
"""
result = text[::-1]
# Yield the final output for this workflow run.
await ctx.yield_output(result)
async def main():
"""Build a two-step sequential workflow and run it with streaming to observe events."""
# Step 1: Build the workflow with the defined edges.
# Order matters. upper_case_executor runs first, then reverse_text_executor.
workflow = WorkflowBuilder(start_executor=to_upper_case).add_edge(to_upper_case, reverse_text).build()
# Step 2: Run the workflow and stream events in real time.
async for event in workflow.run("hello world", stream=True):
# You will see executor invoke and completion events as the workflow progresses.
print(f"Event: {event}")
if event.type == "output":
print(f"Workflow completed with result: {event.data}")
"""
Sample Output:
Event: executor_invoked event (type='executor_invoked', executor_id=upper_case_executor)
Event: executor_completed event (type='executor_completed', executor_id=upper_case_executor)
Event: executor_invoked event (type='executor_invoked', executor_id=reverse_text_executor)
Event: executor_completed event (type='executor_completed', executor_id=reverse_text_executor)
Event: output event (type='output', data='DLROW OLLEH', executor_id=reverse_text_executor)
Workflow completed with result: DLROW OLLEH
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,173 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from enum import Enum
from agent_framework import (
Agent,
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponseUpdate,
Executor,
Message,
WorkflowBuilder,
WorkflowContext,
handler,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
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:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via `azure-identity` — uses `AzureCliCredential()` (run `az login`).
"""
class NumberSignal(Enum):
"""Enum to represent number signals for the workflow."""
# The target number is above the guess.
ABOVE = "above"
# The target number is below the guess.
BELOW = "below"
# The guess matches the target number.
MATCHED = "matched"
# Initial signal to start the guessing process.
INIT = "init"
class GuessNumberExecutor(Executor):
"""An executor that guesses a number."""
def __init__(self, bound: tuple[int, int], id: str):
"""Initialize the executor with a target number."""
super().__init__(id=id)
self._lower = bound[0]
self._upper = bound[1]
@handler
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.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.
# Generate a new number that is between the new bounds.
self._lower = self._guess + 1
self._guess = (self._lower + self._upper) // 2
await ctx.send_message(self._guess)
else:
# The previous guess was too high.
# Update the upper bound to the previous guess.
# Generate a new number that is between the new bounds.
self._upper = self._guess - 1
self._guess = (self._lower + self._upper) // 2
await ctx.send_message(self._guess)
class SubmitToJudgeAgent(Executor):
"""Send the numeric guess to a judge agent which replies ABOVE/BELOW/MATCHED."""
def __init__(self, judge_agent_id: str, target: int, id: str | None = None):
super().__init__(id=id or "submit_to_judge")
self._judge_agent_id = judge_agent_id
self._target = target
@handler
async def submit(self, guess: int, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
prompt = (
"You are a number judge. Given a target number and a guess, reply with exactly one token:"
" 'MATCHED' if guess == target, 'ABOVE' if the target is above the guess,"
" or 'BELOW' if the target is below.\n"
f"Target: {self._target}\nGuess: {guess}\nResponse:"
)
await ctx.send_message(
AgentExecutorRequest(messages=[Message("user", contents=[prompt])], should_respond=True),
target_id=self._judge_agent_id,
)
class ParseJudgeResponse(Executor):
"""Parse AgentExecutorResponse into NumberSignal for the loop."""
@handler
async def parse(self, response: AgentExecutorResponse, ctx: WorkflowContext[NumberSignal]) -> None:
text = response.agent_response.text.strip().upper()
if "MATCHED" in text:
await ctx.send_message(NumberSignal.MATCHED)
elif "ABOVE" in text and "BELOW" not in text:
await ctx.send_message(NumberSignal.ABOVE)
else:
await ctx.send_message(NumberSignal.BELOW)
def create_judge_agent() -> Agent:
"""Create a judge agent that evaluates guesses."""
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=("You strictly respond with one of: MATCHED, ABOVE, BELOW based on the given target and guess."),
name="judge_agent",
)
async def main():
"""Main function to run the workflow."""
# Step 1: Build the workflow with the defined edges.
# This time we are creating a loop in the workflow.
guess_number = GuessNumberExecutor((1, 100), "guess_number")
judge_agent = AgentExecutor(create_judge_agent())
submit_judge = SubmitToJudgeAgent(judge_agent_id="judge_agent", target=30)
parse_judge = ParseJudgeResponse(id="parse_judge")
workflow = (
WorkflowBuilder(start_executor=guess_number)
.add_edge(guess_number, submit_judge)
.add_edge(submit_judge, judge_agent)
.add_edge(judge_agent, parse_judge)
.add_edge(parse_judge, guess_number)
.build()
)
# Step 2: Run the workflow with concise streaming output.
iterations = 0
async for event in workflow.run(NumberSignal.INIT, stream=True):
if event.type == "executor_completed" and event.executor_id == "guess_number":
iterations += 1
elif event.type == "output":
if isinstance(event.data, AgentResponseUpdate):
# Agent executor streams token-level updates; skip to avoid noisy logs.
continue
print(f"Workflow output: {event.data}")
# This is essentially a binary search, so the number of iterations should be logarithmic.
# The maximum number of iterations is [log2(range size)]. For a range of 1 to 100, this is log2(100) which is 7.
# Subtract because the last round is the MATCHED event.
print(f"Guessed {iterations - 1} times.")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,241 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from dataclasses import dataclass
from typing import Any, Literal
from uuid import uuid4
from agent_framework import ( # Core chat primitives used to form LLM requests
Agent,
AgentExecutor,
AgentExecutorRequest, # Message bundle sent to an AgentExecutor
AgentExecutorResponse, # Result returned by an AgentExecutor
Case,
Default, # Default branch when no cases match
Message,
WorkflowBuilder, # Fluent builder for assembling the graph
WorkflowContext, # Per-run context and event bus
executor, # Decorator to turn a function into a workflow executor
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.openai import OpenAIChatOptions # Thin client for Azure OpenAI chat models
from azure.identity import AzureCliCredential # Uses your az CLI login for credentials
from dotenv import load_dotenv
from pydantic import BaseModel # Structured outputs with validation
from typing_extensions import Never
# Load environment variables from .env file
load_dotenv()
"""
Sample: Switch-Case Edge Group with an explicit Uncertain branch.
The workflow stores a single email in workflow state, asks a spam detection agent for a three way decision,
then routes with a switch-case group: NotSpam to the drafting assistant, Spam to a spam handler, and
Default to an Uncertain handler.
Purpose:
Demonstrate deterministic one of N routing with switch-case edges. Show how to:
- Persist input once in workflow state, then pass around a small typed pointer that carries the email id.
- 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:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- Familiarity with WorkflowBuilder, executors, edges, and events.
- Understanding of switch-case edge groups and how Case and Default are evaluated in order.
- Working Azure OpenAI configuration for FoundryChatClient, with Azure CLI login and required environment variables.
- Access to workflow/resources/ambiguous_email.txt, or accept the inline fallback string.
"""
EMAIL_STATE_PREFIX = "email:"
CURRENT_EMAIL_ID_KEY = "current_email_id"
class DetectionResultAgent(BaseModel):
"""Structured output returned by the spam detection agent."""
# The agent classifies the email and provides a rationale.
spam_decision: Literal["NotSpam", "Spam", "Uncertain"]
reason: str
class EmailResponse(BaseModel):
"""Structured output returned by the email assistant agent."""
# The drafted professional reply.
response: str
@dataclass
class DetectionResult:
# Internal typed payload used for routing and downstream handling.
spam_decision: str
reason: str
email_id: str
@dataclass
class Email:
# In memory record of the email content stored in workflow state.
email_id: str
email_content: str
def get_case(expected_decision: str):
"""Factory that returns a predicate matching a specific spam_decision value."""
def condition(message: Any) -> bool:
# Only match when the upstream payload is a DetectionResult with the expected decision.
return isinstance(message, DetectionResult) and message.spam_decision == expected_decision
return condition
@executor(id="store_email")
async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Persist the raw email once. Store under a unique key and set the current pointer for convenience.
new_email = Email(email_id=str(uuid4()), email_content=email_text)
ctx.set_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email)
ctx.set_state(CURRENT_EMAIL_ID_KEY, new_email.email_id)
# Kick off the detector by forwarding the email as a user message to the spam_detection_agent.
await ctx.send_message(
AgentExecutorRequest(messages=[Message("user", contents=[new_email.email_content])], should_respond=True)
)
@executor(id="to_detection_result")
async def to_detection_result(response: AgentExecutorResponse, ctx: WorkflowContext[DetectionResult]) -> None:
# Parse the detector JSON into a typed model. Attach the current email id for downstream lookups.
parsed = DetectionResultAgent.model_validate_json(response.agent_response.text)
email_id: str = ctx.get_state(CURRENT_EMAIL_ID_KEY)
await ctx.send_message(DetectionResult(spam_decision=parsed.spam_decision, reason=parsed.reason, email_id=email_id))
@executor(id="submit_to_email_assistant")
async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
# Only proceed for the NotSpam branch. Guard against accidental misrouting.
if detection.spam_decision != "NotSpam":
raise RuntimeError("This executor should only handle NotSpam messages.")
# Load the original content from workflow state using the id carried in DetectionResult.
email: Email = ctx.get_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}")
await ctx.send_message(
AgentExecutorRequest(messages=[Message("user", contents=[email.email_content])], should_respond=True)
)
@executor(id="finalize_and_send")
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_response.text)
await ctx.yield_output(f"Email sent: {parsed.response}")
@executor(id="handle_spam")
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.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[Never, str]) -> None:
# Uncertain path terminal. Surface the original content to aid human review.
if detection.spam_decision == "Uncertain":
email: Email | None = ctx.get_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}")
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.")
def create_spam_detection_agent() -> Agent:
"""Create and return the spam detection agent."""
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=(
"You are a spam detection assistant that identifies spam emails. "
"Be less confident in your assessments. "
"Always return JSON with fields 'spam_decision' (one of NotSpam, Spam, Uncertain) "
"and 'reason' (string)."
),
name="spam_detection_agent",
default_options=OpenAIChatOptions[Any](response_format=DetectionResultAgent),
)
def create_email_assistant_agent() -> Agent:
"""Create and return the email assistant agent."""
return Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
instructions=("You are an email assistant that helps users draft responses to emails with professionalism."),
name="email_assistant_agent",
default_options=OpenAIChatOptions[Any](response_format=EmailResponse),
)
async def main():
"""Main function to run the workflow."""
# Build workflow: store -> detection agent -> to_detection_result -> switch (NotSpam or Spam or Default).
# The switch-case group evaluates cases in order, then falls back to Default when none match.
spam_detection_agent = AgentExecutor(create_spam_detection_agent())
email_assistant_agent = AgentExecutor(create_email_assistant_agent())
workflow = (
WorkflowBuilder(start_executor=store_email)
.add_edge(store_email, spam_detection_agent)
.add_edge(spam_detection_agent, to_detection_result)
.add_switch_case_edge_group(
to_detection_result,
[
Case(condition=get_case("NotSpam"), target=submit_to_email_assistant),
Case(condition=get_case("Spam"), target=handle_spam),
Default(target=handle_uncertain),
],
)
.add_edge(submit_to_email_assistant, email_assistant_agent)
.add_edge(email_assistant_agent, finalize_and_send)
.build()
)
# Read ambiguous email if available. Otherwise use a simple inline sample.
resources_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "resources", "ambiguous_email.txt"
)
if os.path.exists(resources_path):
with open(resources_path, encoding="utf-8") as f: # noqa: ASYNC230
email = f.read()
else:
print("Unable to find resource file, using default text.")
email = (
"Hey there, I noticed you might be interested in our latest offer—no pressure, but it expires soon. "
"Let me know if you'd like more details."
)
# 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__":
asyncio.run(main())
@@ -0,0 +1,94 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from agent_framework import WorkflowBuilder, WorkflowContext, executor
from typing_extensions import Never
"""
Sample: Workflow Cancellation
A three-step workflow where each step takes 2 seconds. We cancel it after 3 seconds
to demonstrate mid-execution cancellation using asyncio tasks.
Purpose:
Show how to cancel a running workflow by wrapping it in an asyncio.Task. This pattern
works with both workflow.run() stream=True and stream=False. Useful for implementing
timeouts, graceful shutdown, or A2A executors that need cancellation support.
Prerequisites:
- No external services required.
"""
@executor(id="step1")
async def step1(text: str, ctx: WorkflowContext[str]) -> None:
"""First step - simulates 2 seconds of work."""
print("[Step1] Starting...")
await asyncio.sleep(2)
print("[Step1] Done")
await ctx.send_message(text.upper())
@executor(id="step2")
async def step2(text: str, ctx: WorkflowContext[str]) -> None:
"""Second step - simulates 2 seconds of work."""
print("[Step2] Starting...")
await asyncio.sleep(2)
print("[Step2] Done")
await ctx.send_message(text + "!")
@executor(id="step3")
async def step3(text: str, ctx: WorkflowContext[Never, str]) -> None:
"""Final step - simulates 2 seconds of work."""
print("[Step3] Starting...")
await asyncio.sleep(2)
print("[Step3] Done")
await ctx.yield_output(f"Result: {text}")
def build_workflow():
"""Build a simple 3-step sequential workflow (~6 seconds total)."""
return WorkflowBuilder(start_executor=step1).add_edge(step1, step2).add_edge(step2, step3).build()
async def run_with_cancellation() -> None:
"""Cancel the workflow after 3 seconds (mid-execution during Step2)."""
print("=== Run with cancellation ===\n")
workflow = build_workflow()
# Wrap workflow.run() in a task to enable cancellation
task = asyncio.ensure_future(workflow.run("hello world"))
# Wait 3 seconds (Step1 completes, Step2 is mid-execution), then cancel
await asyncio.sleep(3)
print("\n--- Cancelling workflow ---\n")
task.cancel()
try:
await task
except asyncio.CancelledError:
print("Workflow was cancelled")
async def run_to_completion() -> None:
"""Let the workflow run to completion and get the result."""
print("=== Run to completion ===\n")
workflow = build_workflow()
# Run without cancellation - await the result directly
result = await workflow.run("hello world")
print(f"\nWorkflow completed with output: {result.get_outputs()}")
async def main() -> None:
"""Demonstrate both cancellation and completion scenarios."""
await run_with_cancellation()
print("\n")
await run_to_completion()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,73 @@
# Declarative Workflows
Declarative workflows allow you to define multi-agent orchestration patterns in YAML, including:
- Variable manipulation and state management
- Control flow (loops, conditionals, branching)
- Agent invocations
- Human-in-the-loop patterns
See the [main workflows README](../README.md#declarative) for the list of available samples.
## Prerequisites
```bash
pip install agent-framework-declarative
```
## Running Samples
Each sample directory contains:
- `workflow.yaml` - The declarative workflow definition
- `main.py` - Python code to load and execute the workflow
- `README.md` - Sample-specific documentation
To run a sample:
```bash
cd <sample_directory>
python main.py
```
## Workflow Structure
A basic workflow YAML file looks like:
```yaml
name: my-workflow
description: A simple workflow example
actions:
- kind: SetValue
path: turn.greeting
value: Hello, World!
- kind: SendActivity
activity:
text: =turn.greeting
```
## Action Types
### Variable Actions
- `SetValue` - Set a variable in state
- `SetVariable` - Set a variable (.NET style naming)
- `ResetVariable` - Clear a variable
### Control Flow
- `If` - Conditional branching
- `ConditionGroup` - Multi-way branching
- `Foreach` - Iterate over collections
- `GotoAction` - Jump to labeled action
### Output
- `SendActivity` - Send text/attachments to user
### Agent Invocation
- `InvokeAzureAgent` - Call an Azure AI agent
### Tool Invocation
- `InvokeFunctionTool` - Call a registered Python function
### Human-in-Loop
- `Question` - Request user input
- `RequestExternalInput` - Request external data/approval
@@ -0,0 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
"""Declarative workflows samples package."""
@@ -0,0 +1,272 @@
# Copyright (c) Microsoft. All rights reserved.
"""Agent to Function Tool sample - demonstrates chaining agent output to function tools.
This sample shows how to:
1. Use InvokeAzureAgent to analyze user input with an AI model
2. Pass the agent's structured output to InvokeFunctionTool actions
3. Chain multiple function tools to process and transform data
The workflow:
1. Takes a user order request as input
2. Uses an Azure agent to extract structured order data (item, quantity, details)
3. Passes the extracted data to a function tool that calculates the order total
4. Uses another function tool to format the final confirmation message
Run with:
python -m samples.03-workflows.declarative.agent_to_function_tool.main
"""
import asyncio
import os
from pathlib import Path
from typing import Any
from agent_framework import Agent
from agent_framework.declarative import WorkflowFactory
from agent_framework.foundry import FoundryChatClient
from agent_framework.openai import OpenAIChatOptions
from azure.identity import AzureCliCredential
from pydantic import BaseModel, Field
# Copyright (c) Microsoft. All rights reserved.
# Pricing data for the order calculation
ITEM_PRICES = {
"pizza": {"small": 10.99, "medium": 14.99, "large": 18.99, "default": 14.99},
"burger": {"small": 6.99, "medium": 8.99, "large": 10.99, "default": 8.99},
"salad": {"small": 7.99, "medium": 9.99, "large": 11.99, "default": 9.99},
"sandwich": {"small": 6.99, "medium": 8.99, "large": 10.99, "default": 8.99},
"pasta": {"small": 11.99, "medium": 14.99, "large": 17.99, "default": 14.99},
}
EXTRAS_PRICES = {
"extra cheese": 2.00,
"bacon": 2.50,
"avocado": 1.50,
"mushrooms": 1.00,
"pepperoni": 2.00,
}
# Agent instructions for order analysis
ORDER_ANALYSIS_INSTRUCTIONS = """You are an order analysis assistant. Analyze the customer's order request and extract:
- item: what they want to order (e.g., "pizza", "burger", "salad")
- quantity: how many (as a number, default to 1 if not specified)
- details: any special requests, modifications, or size (e.g., "large", "extra cheese")
- delivery_address: where to deliver (if mentioned, otherwise empty string)
Always respond with valid JSON matching the required format."""
# Pydantic model for structured agent output
class OrderAnalysis(BaseModel):
"""Structured output from the order analysis agent."""
item: str = Field(description="The food item being ordered (e.g., pizza, burger)")
quantity: int = Field(description="Number of items ordered", default=1)
details: str = Field(description="Special requests, size, or modifications")
delivery_address: str = Field(description="Delivery address if provided, empty string otherwise", default="")
def calculate_order_total(order_data: dict[str, Any]) -> dict[str, Any]:
"""Calculate the total cost of an order based on the agent's structured analysis.
Args:
order_data: Structured dict from the agent containing order analysis.
Returns:
Dictionary with pricing breakdown.
"""
# Handle case where order_data might be None or invalid
if not order_data or not isinstance(order_data, dict):
return {
"error": f"Invalid order data: {order_data}",
"subtotal": 0.0,
"tax": 0.0,
"delivery_fee": 0.0,
"total": 0.0,
}
item = str(order_data.get("item", "")).lower()
quantity = int(order_data.get("quantity", 1))
details = str(order_data.get("details", "")).lower()
has_delivery = bool(order_data.get("delivery_address"))
# Determine size from details
size = "default"
for s in ["small", "medium", "large"]:
if s in details:
size = s
break
# Get base price for item
item_key = None
for key in ITEM_PRICES:
if key in item:
item_key = key
break
unit_price = ITEM_PRICES[item_key].get(size, ITEM_PRICES[item_key]["default"]) if item_key else 12.99
# Calculate extras
extras_total = 0.0
applied_extras: list[dict[str, Any]] = []
for extra, price in EXTRAS_PRICES.items():
if extra in details:
extras_total += price * quantity
applied_extras.append({"name": extra, "price": price})
# Calculate totals
subtotal = (unit_price * quantity) + extras_total
tax = round(subtotal * 0.08, 2) # 8% tax
delivery_fee = 5.00 if has_delivery else 0.0
total = round(subtotal + tax + delivery_fee, 2)
return {
"item": item,
"quantity": quantity,
"size": size if size != "default" else "regular",
"unit_price": unit_price,
"extras": applied_extras,
"extras_total": extras_total,
"subtotal": round(subtotal, 2),
"tax": tax,
"delivery_fee": delivery_fee,
"total": total,
"has_delivery": has_delivery,
}
def format_order_confirmation(order_data: dict[str, Any], order_calculation: dict[str, Any]) -> str:
"""Format a human-readable order confirmation message.
Args:
order_data: Structured dict from the agent with order details.
order_calculation: Pricing calculation from calculate_order_total.
Returns:
Formatted confirmation message.
"""
calc = order_calculation
# Handle error case
if "error" in calc:
return f"Sorry, we couldn't process your order: {calc['error']}"
# Build the confirmation message
qty = int(calc.get("quantity", 1))
size = calc.get("size", "regular").title()
item = calc.get("item", "item").title()
lines = [
"=" * 50,
"ORDER CONFIRMATION",
"=" * 50,
"",
f"Item: {qty}x {size} {item}",
f"Unit Price: ${calc.get('unit_price', 0):.2f}",
]
# Add extras if any
extras = calc.get("extras", [])
if extras:
lines.append("\nExtras:")
for extra in extras:
lines.append(f" + {extra['name'].title()}: ${extra['price']:.2f} each")
lines.append(f" Extras Total: ${calc.get('extras_total', 0):.2f}")
lines.extend([
"",
"-" * 30,
f"Subtotal: ${calc.get('subtotal', 0):.2f}",
f"Tax (8%): ${calc.get('tax', 0):.2f}",
])
if calc.get("has_delivery"):
delivery_address = order_data.get("delivery_address", "Address provided") if order_data else "Address provided"
lines.extend([
f"Delivery Fee: ${calc.get('delivery_fee', 0):.2f}",
f"Delivery To: {delivery_address}",
])
lines.extend([
"-" * 30,
f"TOTAL: ${calc.get('total', 0):.2f}",
"=" * 50,
"",
"Thank you for your order!",
])
return "\n".join(lines)
async def main():
"""Run the agent to function tool workflow."""
# Create Azure OpenAI Responses client
chat_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create the order analysis agent with structured output
order_analysis_agent = Agent(
client=chat_client,
name="OrderAnalysisAgent",
instructions=ORDER_ANALYSIS_INSTRUCTIONS,
default_options=OpenAIChatOptions[Any](response_format=OrderAnalysis),
)
# Agent registry
agents = {
"OrderAnalysisAgent": order_analysis_agent,
}
# Get the path to the workflow YAML file
workflow_path = Path(__file__).parent / "workflow.yaml"
# Create the workflow factory with agents and tools
factory = (
WorkflowFactory(agents=agents)
.register_tool("calculate_order_total", calculate_order_total)
.register_tool("format_order_confirmation", format_order_confirmation)
)
# Create the workflow from the YAML definition
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print("=" * 60)
print("Agent to Function Tool Workflow Demo")
print("=" * 60)
print()
print("This workflow demonstrates:")
print(" 1. Using InvokeAzureAgent to analyze user input")
print(" 2. Passing agent's structured output to InvokeFunctionTool")
print(" 3. Chaining multiple function tools together")
print()
# Test with different order inputs
test_queries = [
"I want to order 3 large pizzas with extra cheese for delivery to 123 Main St",
"2 medium burgers with bacon please",
"Can I get a small salad with avocado and mushrooms, pick up",
]
for query in test_queries:
print("-" * 60)
print(f"Input: {query}")
print("-" * 60)
# Run the workflow with streaming to capture output
try:
async for event in workflow.run(query, stream=True):
if event.type == "output" and isinstance(event.data, str):
print(event.data, end="", flush=True)
except Exception as e:
print(f"\nWorkflow error: {type(e).__name__}: {e}")
print("\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,59 @@
# Agent to Function Tool Workflow
#
# This workflow demonstrates chaining an agent invocation with a function tool.
# The agent analyzes user input, and the function tool processes the agent's output.
#
# Flow:
# 1. Receive user query
# 2. Invoke an Azure agent to analyze the query and extract structured data
# 3. Pass the agent's structured output to a function tool for processing
# 4. Return the final result
#
# Example input:
# I want to order 3 large pizzas with extra cheese for delivery to 123 Main St
kind: Workflow
trigger:
kind: OnConversationStart
id: agent_to_function_tool_demo
actions:
# Invoke the order analysis agent to extract structured order data
- kind: InvokeAzureAgent
id: analyze_order
agent:
name: OrderAnalysisAgent
input:
messages: =Workflow.Inputs.input
output:
autoSend: false
response: Local.agentResponse
responseObject: Local.orderData
# Invoke a function tool to calculate order total using the agent's output
- kind: InvokeFunctionTool
id: calculate_order
functionName: calculate_order_total
arguments:
order_data: =Local.orderData
output:
autoSend: false
result: Local.orderCalculation
# Invoke another function tool to format the final confirmation
- kind: InvokeFunctionTool
id: format_confirmation
functionName: format_order_confirmation
arguments:
order_data: =Local.orderData
order_calculation: =Local.orderCalculation
output:
autoSend: false
result: Local.confirmation
# Send the final confirmation to the user
- kind: SendActivity
id: send_confirmation
activity:
text: =Local.confirmation
@@ -0,0 +1,22 @@
# Conditional Workflow Sample
This sample demonstrates control flow with conditions:
- If/else branching
- Nested conditions
## Files
- `workflow.yaml` - The workflow definition
- `main.py` - Python code to execute the workflow
## Running
```bash
python main.py
```
## What It Does
1. Takes a user's age as input
2. Uses conditions to determine an age category
3. Sends appropriate messages based on the category
@@ -0,0 +1,52 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Run the conditional workflow sample.
Usage:
python main.py
Demonstrates conditional branching based on age input.
"""
import asyncio
from pathlib import Path
from agent_framework.declarative import WorkflowFactory
async def main() -> None:
"""Run the conditional workflow with various age inputs."""
# Create a workflow factory
factory = WorkflowFactory()
# Load the workflow from YAML
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print(f"Loaded workflow: {workflow.name}")
print("-" * 40)
# Print out the executors in this workflow
print("\nExecutors in workflow:")
for executor_id, executor in workflow.executors.items():
print(f" - {executor_id}: {type(executor).__name__}")
print("-" * 40)
# Test with different ages
test_ages = [8, 15, 35, 70]
for age in test_ages:
print(f"\n--- Testing with age: {age} ---")
# Run the workflow with age input
result = await workflow.run({"age": age})
for output in result.get_outputs():
print(f" Output: {output}")
print("\n" + "-" * 40)
print("Workflow completed for all test cases!")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,69 @@
name: conditional-workflow
description: Demonstrates conditional branching based on user input
# Declare expected inputs with their types
inputs:
age:
type: integer
description: The user's age in years
actions:
# Get the age from input
- kind: SetValue
id: get_age
displayName: Get user age
path: Local.age
value: =inputs.age
# Determine age category using nested conditions
- kind: If
id: check_age
displayName: Check age category
condition: =Local.age < 13
then:
- kind: SetValue
path: Local.category
value: child
- kind: SendActivity
activity:
text: "Welcome, young one! Here are some fun activities for kids."
else:
- kind: If
condition: =Local.age < 20
then:
- kind: SetValue
path: Local.category
value: teenager
- kind: SendActivity
activity:
text: "Hey there! Check out these cool things for teens."
else:
- kind: If
condition: =Local.age < 65
then:
- kind: SetValue
path: Local.category
value: adult
- kind: SendActivity
activity:
text: "Welcome! Here are our professional services."
else:
- kind: SetValue
path: Local.category
value: senior
- kind: SendActivity
activity:
text: "Welcome! Enjoy our senior member benefits."
# Send a summary
- kind: SendActivity
id: summary
displayName: Send category summary
activity:
text: '=Concat("You have been categorized as: ", Local.category)'
# Store result
- kind: SetValue
id: set_output
path: Workflow.Outputs.category
value: =Local.category
@@ -0,0 +1,37 @@
# Customer Support Workflow Sample
Multi-agent workflow demonstrating automated troubleshooting with escalation paths.
## Overview
Coordinates six specialized agents to handle customer support requests:
1. **SelfServiceAgent** - Initial troubleshooting with user
2. **TicketingAgent** - Creates tickets when escalation needed
3. **TicketRoutingAgent** - Routes to appropriate team
4. **WindowsSupportAgent** - Windows-specific troubleshooting
5. **TicketResolutionAgent** - Resolves tickets
6. **TicketEscalationAgent** - Escalates to human support
## Files
- `workflow.yaml` - Workflow definition with conditional routing
- `main.py` - Agent definitions and workflow execution
- `ticketing_plugin.py` - Mock ticketing system plugin
## Running
```bash
python main.py
```
## Example Input
```
My PC keeps rebooting and I can't use it.
```
## Requirements
- Azure OpenAI endpoint configured
- `az login` for authentication
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,361 @@
# Copyright (c) Microsoft. All rights reserved.
"""
CustomerSupport workflow sample.
This workflow demonstrates using multiple agents to provide automated
troubleshooting steps to resolve common issues with escalation options.
Example input: "My PC keeps rebooting and I can't use it."
Usage:
python main.py
The workflow:
1. SelfServiceAgent: Works with user to provide troubleshooting steps
2. TicketingAgent: Creates a ticket if issue needs escalation
3. TicketRoutingAgent: Determines which team should handle the ticket
4. WindowsSupportAgent: Provides Windows-specific troubleshooting
5. TicketResolutionAgent: Resolves the ticket when issue is fixed
6. TicketEscalationAgent: Escalates to human support if needed
"""
import asyncio
import json
import logging
import os
import uuid
from pathlib import Path
from typing import Any
from agent_framework import Agent
from agent_framework.declarative import (
AgentExternalInputRequest,
AgentExternalInputResponse,
WorkflowFactory,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.openai import OpenAIChatOptions
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from ticketing_plugin import TicketingPlugin # ty: ignore[unresolved-import] # pyrefly: ignore[missing-import]
logging.basicConfig(level=logging.ERROR)
# Load environment variables from .env file
load_dotenv()
# ANSI color codes for output formatting
CYAN = "\033[36m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
MAGENTA = "\033[35m"
RESET = "\033[0m"
# Agent Instructions
SELF_SERVICE_INSTRUCTIONS = """
Use your knowledge to work with the user to provide the best possible troubleshooting steps.
- If the user confirms that the issue is resolved, then the issue is resolved.
- If the user reports that the issue persists, then escalate.
""".strip()
TICKETING_INSTRUCTIONS = """Always create a ticket in Azure DevOps using the available tools.
Include the following information in the TicketSummary.
- Issue description: {{IssueDescription}}
- Attempted resolution steps: {{AttemptedResolutionSteps}}
After creating the ticket, provide the user with the ticket ID."""
TICKET_ROUTING_INSTRUCTIONS = """Determine how to route the given issue to the appropriate support team.
Choose from the available teams and their functions:
- Windows Activation Support: Windows license activation issues
- Windows Support: Windows related issues
- Azure Support: Azure related issues
- Network Support: Network related issues
- Hardware Support: Hardware related issues
- Microsoft Office Support: Microsoft Office related issues
- General Support: General issues not related to the above categories"""
WINDOWS_SUPPORT_INSTRUCTIONS = """
Use your knowledge to work with the user to provide the best possible troubleshooting steps
for issues related to Windows operating system.
- Utilize the "Attempted Resolutions Steps" as a starting point for your troubleshooting.
- Never escalate without troubleshooting with the user.
- If the user confirms that the issue is resolved, then the issue is resolved.
- If the user reports that the issue persists, then escalate.
Issue: {{IssueDescription}}
Attempted Resolution Steps: {{AttemptedResolutionSteps}}"""
RESOLUTION_INSTRUCTIONS = """Resolve the following ticket in Azure DevOps.
Always include the resolution details.
- Ticket ID: #{{TicketId}}
- Resolution Summary: {{ResolutionSummary}}"""
ESCALATION_INSTRUCTIONS = """
You escalate the provided issue to human support team by sending an email.
Here are some additional details that might help:
- TicketId : {{TicketId}}
- IssueDescription : {{IssueDescription}}
- AttemptedResolutionSteps : {{AttemptedResolutionSteps}}
Before escalating, gather the user's email address for follow-up.
If not known, ask the user for their email address so that the support team can reach them when needed.
When sending the email, include the following details:
- To: support@contoso.com
- Cc: user's email address
- Subject of the email: "Support Ticket - {TicketId} - [Compact Issue Description]"
- Body:
- Issue description
- Attempted resolution steps
- User's email address
- Any other relevant information from the conversation history
Assure the user that their issue will be resolved and provide them with a ticket ID for reference."""
# Pydantic models for structured outputs
class SelfServiceResponse(BaseModel):
"""Response from self-service agent evaluation."""
IsResolved: bool = Field(description="True if the user issue/ask has been resolved.")
NeedsTicket: bool = Field(description="True if the user issue/ask requires that a ticket be filed.")
IssueDescription: str = Field(description="A concise description of the issue.")
AttemptedResolutionSteps: str = Field(description="An outline of the steps taken to attempt resolution.")
class TicketingResponse(BaseModel):
"""Response from ticketing agent."""
TicketId: str = Field(description="The identifier of the ticket created in response to the user issue.")
TicketSummary: str = Field(description="The summary of the ticket created in response to the user issue.")
class RoutingResponse(BaseModel):
"""Response from routing agent."""
TeamName: str = Field(description="The name of the team to route the issue")
class SupportResponse(BaseModel):
"""Response from support agent."""
IsResolved: bool = Field(description="True if the user issue/ask has been resolved.")
NeedsEscalation: bool = Field(
description="True resolution could not be achieved and the issue/ask requires escalation."
)
ResolutionSummary: str = Field(description="The summary of the steps that led to resolution.")
class EscalationResponse(BaseModel):
"""Response from escalation agent."""
IsComplete: bool = Field(description="Has the email been sent and no more user input is required.")
UserMessage: str = Field(description="A natural language message to the user.")
async def main() -> None:
"""Run the customer support workflow."""
# Create ticketing plugin
plugin = TicketingPlugin()
# Create Azure OpenAI client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
# This sample has been tested only on `gpt-5.1` and may not work as intended on other models
# This sample is known to fail on `gpt-5-mini` reasoning input (GH issue #4059)
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create agents with structured outputs
self_service_agent = Agent(
client=client,
name="SelfServiceAgent",
instructions=SELF_SERVICE_INSTRUCTIONS,
default_options=OpenAIChatOptions[Any](response_format=SelfServiceResponse),
)
ticketing_agent = Agent(
client=client,
name="TicketingAgent",
instructions=TICKETING_INSTRUCTIONS,
tools=plugin.get_functions(),
default_options=OpenAIChatOptions[Any](response_format=TicketingResponse),
)
routing_agent = Agent(
client=client,
name="TicketRoutingAgent",
instructions=TICKET_ROUTING_INSTRUCTIONS,
tools=[plugin.get_ticket],
default_options=OpenAIChatOptions[Any](response_format=RoutingResponse),
)
windows_support_agent = Agent(
client=client,
name="WindowsSupportAgent",
instructions=WINDOWS_SUPPORT_INSTRUCTIONS,
tools=[plugin.get_ticket],
default_options=OpenAIChatOptions[Any](response_format=SupportResponse),
)
resolution_agent = Agent(
client=client,
name="TicketResolutionAgent",
instructions=RESOLUTION_INSTRUCTIONS,
tools=[plugin.resolve_ticket],
)
escalation_agent = Agent(
client=client,
name="TicketEscalationAgent",
instructions=ESCALATION_INSTRUCTIONS,
tools=[plugin.get_ticket, plugin.send_notification],
default_options=OpenAIChatOptions[Any](response_format=EscalationResponse),
)
# Agent registry for lookup
agents = {
"SelfServiceAgent": self_service_agent,
"TicketingAgent": ticketing_agent,
"TicketRoutingAgent": routing_agent,
"WindowsSupportAgent": windows_support_agent,
"TicketResolutionAgent": resolution_agent,
"TicketEscalationAgent": escalation_agent,
}
# Print loaded agents (similar to .NET "PROMPT AGENT: AgentName:1")
for agent_name in agents:
print(f"{CYAN}PROMPT AGENT: {agent_name}:1{RESET}")
# Create workflow factory
factory = WorkflowFactory(agents=agents)
# Load workflow from YAML
samples_root = Path(__file__).parent.parent.parent.parent.parent.parent.parent
workflow_path = samples_root / "declarative-agents" / "workflow-samples" / "CustomerSupport.yaml"
if not workflow_path.exists():
# Fall back to local copy if declarative-agents/workflow-samples doesn't exist
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print()
print("=" * 60)
# Example input
user_input = "My computer won't boot"
pending_request_id: str | None = None
# Track responses for formatting
accumulated_response: str = ""
last_agent_name: str | None = None
print(f"\n{GREEN}INPUT:{RESET} {user_input}\n")
while True:
if pending_request_id:
# Continue workflow with user response
print(f"\n{YELLOW}WORKFLOW:{RESET} Restore\n")
response = AgentExternalInputResponse(user_input=user_input)
stream = workflow.run(stream=True, responses={pending_request_id: response})
pending_request_id = None
else:
# Start workflow
stream = workflow.run(user_input, stream=True)
async for event in stream:
if event.type == "output":
data = event.data
# source_executor_id is only available on request_info events.
# For output events, use executor_id to identify the emitting node.
source_id = event.executor_id or ""
# Check if this is a SendActivity output (activity text from log_ticket, log_route, etc.)
if "log_" in source_id.lower():
# Print any accumulated agent response first
if accumulated_response and last_agent_name:
msg_id = f"msg_{uuid.uuid4().hex[:32]}"
print(f"{CYAN}{last_agent_name.upper()}:{RESET} [{msg_id}]")
try:
parsed = json.loads(accumulated_response)
print(json.dumps(parsed))
except (json.JSONDecodeError, TypeError):
print(accumulated_response)
accumulated_response = ""
last_agent_name = None
# Print activity
print(f"\n{MAGENTA}ACTIVITY:{RESET}")
print(data)
else:
# Accumulate agent response (streaming text)
if isinstance(data, str):
accumulated_response += data
else:
accumulated_response += str(data)
elif event.type == "request_info" and isinstance(event.data, AgentExternalInputRequest):
request = event.data
# The agent_response from the request contains the structured response
agent_name = request.agent_name
agent_response = request.agent_response
# Print the agent's response
if agent_response:
msg_id = f"msg_{uuid.uuid4().hex[:32]}"
print(f"{CYAN}{agent_name.upper()}:{RESET} [{msg_id}]")
try:
parsed = json.loads(agent_response)
print(json.dumps(parsed))
except (json.JSONDecodeError, TypeError):
print(agent_response)
# Clear accumulated since we printed from the request
accumulated_response = ""
last_agent_name = agent_name
pending_request_id = event.request_id
print(f"\n{YELLOW}WORKFLOW:{RESET} Yield")
# Print any remaining accumulated response at end of stream
if accumulated_response:
# Try to identify which agent this came from based on content
msg_id = f"msg_{uuid.uuid4().hex[:32]}"
print(f"\nResponse: [{msg_id}]")
try:
parsed = json.loads(accumulated_response)
print(json.dumps(parsed))
except (json.JSONDecodeError, TypeError):
print(accumulated_response)
accumulated_response = ""
if not pending_request_id:
break
# Get next user input
user_input = input(f"\n{GREEN}INPUT:{RESET} ").strip() # noqa: ASYNC250
if not user_input:
print("Exiting...")
break
print()
print("\n" + "=" * 60)
print("Workflow Complete")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,79 @@
# Copyright (c) Microsoft. All rights reserved.
"""Ticketing plugin for CustomerSupport workflow."""
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
# ANSI color codes
MAGENTA = "\033[35m"
RESET = "\033[0m"
class TicketStatus(Enum):
"""Status of a support ticket."""
OPEN = "open"
IN_PROGRESS = "in_progress"
RESOLVED = "resolved"
CLOSED = "closed"
@dataclass
class TicketItem:
"""A support ticket."""
id: str
subject: str = ""
description: str = ""
notes: str = ""
status: TicketStatus = TicketStatus.OPEN
class TicketingPlugin:
"""Mock ticketing plugin for customer support workflow."""
def __init__(self) -> None:
self._ticket_store: dict[str, TicketItem] = {}
def _trace(self, function_name: str) -> None:
print(f"\n{MAGENTA}FUNCTION: {function_name}{RESET}")
def get_ticket(self, id: str) -> TicketItem | None:
"""Retrieve a ticket by identifier from Azure DevOps."""
self._trace("get_ticket")
return self._ticket_store.get(id)
def create_ticket(self, subject: str, description: str, notes: str) -> str:
"""Create a ticket in Azure DevOps and return its identifier."""
self._trace("create_ticket")
ticket_id = uuid.uuid4().hex
ticket = TicketItem(
id=ticket_id,
subject=subject,
description=description,
notes=notes,
)
self._ticket_store[ticket_id] = ticket
return ticket_id
def resolve_ticket(self, id: str, resolution_summary: str) -> None:
"""Resolve an existing ticket in Azure DevOps given its identifier."""
self._trace("resolve_ticket")
if ticket := self._ticket_store.get(id):
ticket.status = TicketStatus.RESOLVED
def send_notification(self, id: str, email: str, cc: str, body: str) -> None:
"""Send an email notification to escalate ticket engagement."""
self._trace("send_notification")
def get_functions(self) -> list[Callable[..., object]]:
"""Return all plugin functions for registration."""
return [
self.get_ticket,
self.create_ticket,
self.resolve_ticket,
self.send_notification,
]
@@ -0,0 +1,164 @@
#
# This workflow demonstrates using multiple agents to provide automated
# troubleshooting steps to resolve common issues with escalation options.
#
# Example input:
# My PC keeps rebooting and I can't use it.
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_demo
actions:
# Interact with user until the issue has been resolved or
# a determination is made that a ticket is required.
- kind: InvokeAzureAgent
id: service_agent
conversationId: =System.ConversationId
agent:
name: SelfServiceAgent
input:
externalLoop:
when: |-
=Not(Local.ServiceParameters.IsResolved)
And
Not(Local.ServiceParameters.NeedsTicket)
output:
responseObject: Local.ServiceParameters
# All done if issue is resolved.
- kind: ConditionGroup
id: check_if_resolved
conditions:
- condition: =Local.ServiceParameters.IsResolved
id: test_if_resolved
actions:
- kind: GotoAction
id: end_when_resolved
actionId: all_done
# Create the ticket.
- kind: InvokeAzureAgent
id: ticket_agent
agent:
name: TicketingAgent
input:
arguments:
IssueDescription: =Local.ServiceParameters.IssueDescription
AttemptedResolutionSteps: =Local.ServiceParameters.AttemptedResolutionSteps
output:
responseObject: Local.TicketParameters
# Capture the attempted resolution steps.
- kind: SetVariable
id: capture_attempted_resolution
variable: Local.ResolutionSteps
value: =Local.ServiceParameters.AttemptedResolutionSteps
# Notify user of ticket identifier.
- kind: SendActivity
id: log_ticket
activity: "Created ticket #{Local.TicketParameters.TicketId}"
# Determine which team for which route the ticket.
- kind: InvokeAzureAgent
id: routing_agent
agent:
name: TicketRoutingAgent
input:
messages: =UserMessage(Local.ServiceParameters.IssueDescription)
output:
responseObject: Local.RoutingParameters
# Notify user of routing decision.
- kind: SendActivity
id: log_route
activity: Routing to {Local.RoutingParameters.TeamName}
- kind: ConditionGroup
id: check_routing
conditions:
- condition: =Local.RoutingParameters.TeamName = "Windows Support"
id: route_to_support
actions:
# Invoke the support agent to attempt to resolve the issue.
- kind: CreateConversation
id: conversation_support
conversationId: Local.SupportConversationId
- kind: InvokeAzureAgent
id: support_agent
conversationId: =Local.SupportConversationId
agent:
name: WindowsSupportAgent
input:
arguments:
IssueDescription: =Local.ServiceParameters.IssueDescription
AttemptedResolutionSteps: =Local.ServiceParameters.AttemptedResolutionSteps
externalLoop:
when: |-
=Not(Local.SupportParameters.IsResolved)
And
Not(Local.SupportParameters.NeedsEscalation)
output:
autoSend: true
responseObject: Local.SupportParameters
# Capture the attempted resolution steps.
- kind: SetVariable
id: capture_support_resolution
variable: Local.ResolutionSteps
value: =Local.SupportParameters.ResolutionSummary
# Check if the issue was resolved by support.
- kind: ConditionGroup
id: check_resolved
conditions:
# Resolve ticket
- condition: =Local.SupportParameters.IsResolved
id: handle_if_resolved
actions:
- kind: InvokeAzureAgent
id: resolution_agent
agent:
name: TicketResolutionAgent
input:
arguments:
TicketId: =Local.TicketParameters.TicketId
ResolutionSummary: =Local.SupportParameters.ResolutionSummary
- kind: GotoAction
id: end_when_solved
actionId: all_done
# Escalate the ticket by sending an email notification.
- kind: CreateConversation
id: conversation_escalate
conversationId: Local.EscalationConversationId
- kind: InvokeAzureAgent
id: escalate_agent
conversationId: =Local.EscalationConversationId
agent:
name: TicketEscalationAgent
input:
arguments:
TicketId: =Local.TicketParameters.TicketId
IssueDescription: =Local.ServiceParameters.IssueDescription
ResolutionSummary: =Local.ResolutionSteps
externalLoop:
when: =Not(Local.EscalationParameters.IsComplete)
output:
autoSend: true
responseObject: Local.EscalationParameters
# All done
- kind: EndWorkflow
id: all_done
@@ -0,0 +1,33 @@
# Deep Research Workflow Sample
Multi-agent workflow implementing the "Magentic" orchestration pattern from AutoGen.
## Overview
Coordinates specialized agents for complex research tasks:
**Orchestration Agents:**
- **ResearchAgent** - Analyzes tasks and correlates relevant facts
- **PlannerAgent** - Devises execution plans
- **ManagerAgent** - Evaluates status and delegates tasks
- **SummaryAgent** - Synthesizes final responses
**Capability Agents:**
- **KnowledgeAgent** - Performs web searches
- **CoderAgent** - Writes and executes code
- **WeatherAgent** - Provides weather information
## Files
- `main.py` - Agent definitions and workflow execution (programmatic workflow)
## Running
```bash
python main.py
```
## Requirements
- Azure OpenAI endpoint configured
- `az login` for authentication
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,220 @@
# Copyright (c) Microsoft. All rights reserved.
"""
DeepResearch workflow sample.
This workflow coordinates multiple agents to address complex user requests
according to the "Magentic" orchestration pattern introduced by AutoGen.
The following agents are responsible for overseeing and coordinating the workflow:
- ResearchAgent: Analyze the current task and correlate relevant facts
- PlannerAgent: Analyze the current task and devise an overall plan
- ManagerAgent: Evaluates status and delegates tasks to other agents
- SummaryAgent: Synthesizes the final response
The following agents have capabilities that are utilized to address the input task:
- KnowledgeAgent: Performs generic web searches
- CoderAgent: Able to write and execute code
- WeatherAgent: Provides weather information
Usage:
python main.py
"""
import asyncio
import os
from pathlib import Path
from typing import Any
from agent_framework import Agent
from agent_framework.declarative import WorkflowFactory
from agent_framework.foundry import FoundryChatClient
from agent_framework.openai import OpenAIChatOptions
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import BaseModel, Field
# Load environment variables from .env file
load_dotenv()
# Agent Instructions
RESEARCH_INSTRUCTIONS = """In order to help begin addressing the user request, please answer the following pre-survey to the best of your ability.
Keep in mind that you are Ken Jennings-level with trivia, and Mensa-level with puzzles, so there should be a deep well to draw from.
Here is the pre-survey:
1. Please list any specific facts or figures that are GIVEN in the request itself. It is possible that there are none.
2. Please list any facts that may need to be looked up, and WHERE SPECIFICALLY they might be found. In some cases, authoritative sources are mentioned in the request itself.
3. Please list any facts that may need to be derived (e.g., via logical deduction, simulation, or computation)
4. Please list any facts that are recalled from memory, hunches, well-reasoned guesses, etc.
When answering this survey, keep in mind that 'facts' will typically be specific names, dates, statistics, etc. Your answer must only use the headings:
1. GIVEN OR VERIFIED FACTS
2. FACTS TO LOOK UP
3. FACTS TO DERIVE
4. EDUCATED GUESSES
DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so.""" # noqa: E501
PLANNER_INSTRUCTIONS = """Your only job is to devise an efficient plan that identifies (by name) how a team member may contribute to addressing the user request.
Only select the following team which is listed as "- [Name]: [Description]"
- WeatherAgent: Able to retrieve weather information
- CoderAgent: Able to write and execute Python code
- KnowledgeAgent: Able to perform generic websearches
The plan must be a bullet point list must be in the form "- [AgentName]: [Specific action or task for that agent to perform]"
Remember, there is no requirement to involve the entire team -- only select team member's whose particular expertise is required for this task.""" # noqa: E501
MANAGER_INSTRUCTIONS = """Recall we have assembled the following team:
- KnowledgeAgent: Able to perform generic websearches
- CoderAgent: Able to write and execute Python code
- WeatherAgent: Able to retrieve weather information
To make progress on the request, please answer the following questions, including necessary reasoning:
- Is the request fully satisfied? (True if complete, or False if the original request has yet to be SUCCESSFULLY and FULLY addressed)
- Are we in a loop where we are repeating the same requests and / or getting the same responses from an agent multiple times? Loops can span multiple turns, and can include repeated actions like scrolling up or down more than a handful of times.
- Are we making forward progress? (True if just starting, or recent messages are adding value. False if recent messages show evidence of being stuck in a loop or if there is evidence of significant barriers to success such as the inability to read from a required file)
- Who should speak next? (select from: KnowledgeAgent, CoderAgent, WeatherAgent)
- What instruction or question would you give this team member? (Phrase as if speaking directly to them, and include any specific information they may need)""" # noqa: E501
SUMMARY_INSTRUCTIONS = """We have completed the task.
Based only on the conversation and without adding any new information,
synthesize the result of the conversation as a complete response to the user task.
The user will only ever see this last response and not the entire conversation,
so please ensure it is complete and self-contained."""
KNOWLEDGE_INSTRUCTIONS = """You are a knowledge agent that can perform web searches to find information."""
CODER_INSTRUCTIONS = """You solve problems by writing and executing code."""
WEATHER_INSTRUCTIONS = """You are a weather expert that can provide weather information."""
# Pydantic models for structured outputs
class ReasonedAnswer(BaseModel):
"""A response with reasoning and answer."""
reason: str = Field(description="The reasoning behind the answer")
answer: bool = Field(description="The boolean answer")
class ReasonedStringAnswer(BaseModel):
"""A response with reasoning and string answer."""
reason: str = Field(description="The reasoning behind the answer")
answer: str = Field(description="The string answer")
class ManagerResponse(BaseModel):
"""Response from manager agent evaluation."""
is_request_satisfied: ReasonedAnswer = Field(description="Whether the request is fully satisfied")
is_in_loop: ReasonedAnswer = Field(description="Whether we are in a loop repeating the same requests")
is_progress_being_made: ReasonedAnswer = Field(description="Whether forward progress is being made")
next_speaker: ReasonedStringAnswer = Field(description="Who should speak next")
instruction_or_question: ReasonedStringAnswer = Field(
description="What instruction or question to give the next speaker"
)
async def main() -> None:
"""Run the deep research workflow."""
# Create Azure OpenAI client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create agents
research_agent = Agent(
client=client,
name="ResearchAgent",
instructions=RESEARCH_INSTRUCTIONS,
)
planner_agent = Agent(
client=client,
name="PlannerAgent",
instructions=PLANNER_INSTRUCTIONS,
)
manager_agent = Agent(
client=client,
name="ManagerAgent",
instructions=MANAGER_INSTRUCTIONS,
default_options=OpenAIChatOptions[Any](response_format=ManagerResponse),
)
summary_agent = Agent(
client=client,
name="SummaryAgent",
instructions=SUMMARY_INSTRUCTIONS,
)
knowledge_agent = Agent(
client=client,
name="KnowledgeAgent",
instructions=KNOWLEDGE_INSTRUCTIONS,
)
coder_agent = Agent(
client=client,
name="CoderAgent",
instructions=CODER_INSTRUCTIONS,
)
weather_agent = Agent(
client=client,
name="WeatherAgent",
instructions=WEATHER_INSTRUCTIONS,
)
# Create workflow factory
factory = WorkflowFactory(
agents={
"ResearchAgent": research_agent,
"PlannerAgent": planner_agent,
"ManagerAgent": manager_agent,
"SummaryAgent": summary_agent,
"KnowledgeAgent": knowledge_agent,
"CoderAgent": coder_agent,
"WeatherAgent": weather_agent,
},
)
# Load workflow from YAML
samples_root = Path(__file__).parent.parent.parent.parent.parent.parent
workflow_path = samples_root / "declarative-agents" / "workflow-samples" / "DeepResearch.yaml"
if not workflow_path.exists():
# Fall back to local copy if declarative-agents/workflow-samples doesn't exist
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print(f"Loaded workflow: {workflow.name}")
print("=" * 60)
print("Deep Research Workflow (Magentic Pattern)")
print("=" * 60)
# Example input
task = "What is the weather like in Seattle and how does it compare to the average for this time of year?"
async for event in workflow.run(task, stream=True):
if event.type == "output":
print(f"\n{event.data}", flush=True)
print("\n" + "=" * 60)
print("Research Complete")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,94 @@
# Function Tools Workflow
This sample demonstrates an agent with function tools responding to user queries about a restaurant menu.
## Overview
The workflow showcases:
- **Function Tools**: Agent equipped with tools to query menu data
- **Real Foundry-backed agent**: Uses `FoundryChatClient` to create an agent with tools
- **Agent Registration**: Shows how to register agents with the `WorkflowFactory`
## Tools
The MenuAgent has access to these function tools:
| Tool | Description |
|------|-------------|
| `get_menu()` | Returns all menu items with category, name, and price |
| `get_specials()` | Returns today's special items |
| `get_item_price(name)` | Returns the price of a specific item |
## Menu Data
```
Soups:
- Clam Chowder - $4.95 (Special)
- Tomato Soup - $4.95
Salads:
- Cobb Salad - $9.99
- House Salad - $4.95
Drinks:
- Chai Tea - $2.95 (Special)
- Soda - $1.95
```
## Prerequisites
- Microsoft Foundry configured with required environment variables
- Authentication via azure-identity (run `az login` before executing)
## Usage
```bash
python main.py
```
## Example Output
```
Loaded workflow: function-tools-workflow
============================================================
Restaurant Menu Assistant
============================================================
[Bot]: Welcome to the Restaurant Menu Assistant!
[Bot]: Today's soup special is the Clam Chowder for $4.95!
============================================================
Session Complete
============================================================
```
## How It Works
1. Create a Foundry chat client
2. Create an agent with instructions and function tools
3. Register the agent with the workflow factory
4. Load the workflow YAML and run it with `run()` and `stream=True`
```python
# Create the agent with tools
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
menu_agent = client.as_agent(
name="MenuAgent",
instructions="You are a helpful restaurant menu assistant...",
tools=[get_menu, get_specials, get_item_price],
)
# Register with the workflow factory
factory = WorkflowFactory(execution_mode="graph")
factory.register_agent("MenuAgent", menu_agent)
# Load and run the workflow
workflow = factory.create_workflow_from_yaml_path(workflow_path)
async for event in workflow.run(inputs={"userInput": "What is the soup of the day?"}, stream=True):
...
```
@@ -0,0 +1,132 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Demonstrate a workflow that responds to user input using an agent with
function tools assigned. Exits the loop when the user enters "exit".
"""
import asyncio
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Any
from agent_framework import Agent, FileCheckpointStorage, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_declarative import ExternalInputRequest, ExternalInputResponse, WorkflowFactory
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import Field
# Load environment variables from .env file
load_dotenv()
TEMP_DIR = Path(__file__).with_suffix("").parent / "tmp" / "checkpoints"
TEMP_DIR.mkdir(parents=True, exist_ok=True)
@dataclass
class MenuItem:
category: str
name: str
price: float
is_special: bool = False
MENU_ITEMS = [
MenuItem(category="Soup", name="Clam Chowder", price=4.95, is_special=True),
MenuItem(category="Soup", name="Tomato Soup", price=4.95, is_special=False),
MenuItem(category="Salad", name="Cobb Salad", price=9.99, is_special=False),
MenuItem(category="Salad", name="House Salad", price=4.95, is_special=False),
MenuItem(category="Drink", name="Chai Tea", price=2.95, is_special=True),
MenuItem(category="Drink", name="Soda", price=1.95, is_special=False),
]
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# see samples/02-agents/tools/function_tool_with_approval.py
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@tool(approval_mode="never_require")
def get_menu() -> list[dict[str, Any]]:
"""Get all menu items."""
return [{"category": i.category, "name": i.name, "price": i.price} for i in MENU_ITEMS]
@tool(approval_mode="never_require")
def get_specials() -> list[dict[str, Any]]:
"""Get today's specials."""
return [{"category": i.category, "name": i.name, "price": i.price} for i in MENU_ITEMS if i.is_special]
@tool(approval_mode="never_require")
def get_item_price(name: Annotated[str, Field(description="Menu item name")]) -> str:
"""Get price of a menu item."""
for item in MENU_ITEMS:
if item.name.lower() == name.lower():
return f"${item.price:.2f}"
return f"Item '{name}' not found."
async def main():
# Create agent with tools
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
menu_agent = Agent(
client=client,
name="MenuAgent",
instructions="Answer questions about menu items, specials, and prices.",
tools=[get_menu, get_specials, get_item_price],
)
# Clean up any existing checkpoints
for file in TEMP_DIR.glob("*"):
file.unlink()
factory = WorkflowFactory(checkpoint_storage=FileCheckpointStorage(TEMP_DIR))
factory.register_agent("MenuAgent", menu_agent)
workflow = factory.create_workflow_from_yaml_path(Path(__file__).parent / "workflow.yaml")
# Get initial input
print("Restaurant Menu Assistant (type 'exit' to quit)\n")
user_input = input("You: ").strip() # noqa: ASYNC250
if not user_input:
return
# Run workflow with external loop handling
pending_request_id: str | None = None
first_response = True
while True:
if pending_request_id:
response = ExternalInputResponse(user_input=user_input)
stream = workflow.run(stream=True, responses={pending_request_id: response})
else:
stream = workflow.run({"userInput": user_input}, stream=True)
pending_request_id = None
first_response = True
async for event in stream:
if event.type == "output" and isinstance(event.data, str):
if first_response:
print("MenuAgent: ", end="")
first_response = False
print(event.data, end="", flush=True)
elif event.type == "request_info" and isinstance(event.data, ExternalInputRequest):
pending_request_id = event.request_id
print()
if not pending_request_id:
break
user_input = input("\nYou: ").strip()
if not user_input:
continue
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,22 @@
# Function Tools Workflow - .NET-style
#
# This workflow demonstrates an agent with function tools in a loop
# responding to user input, using the same minimal structure as .NET.
#
# Example input:
# What is the soup of the day?
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_demo
actions:
- kind: InvokeAzureAgent
id: invoke_menu_agent
agent:
name: MenuAgent
input:
externalLoop:
when: =Upper(System.LastMessage.Text) <> "EXIT"
@@ -0,0 +1,58 @@
# Human-in-Loop Workflow Sample
This sample demonstrates how to build interactive workflows that request user input during execution using the `Question` and `RequestExternalInput` actions.
## What This Sample Shows
- Using `Question` to prompt for user responses
- Using `RequestExternalInput` to request external data
- Processing user responses to drive workflow decisions
- Interactive conversation patterns
## Files
- `workflow.yaml` - The declarative workflow definition
- `main.py` - Python script that loads and runs the workflow with simulated user interaction
## Running the Sample
1. Ensure you have the package installed:
```bash
cd python
pip install -e packages/agent-framework-declarative
```
2. Run the sample:
```bash
python main.py
```
## How It Works
The workflow demonstrates a simple survey/questionnaire pattern:
1. **Greeting**: Sends a welcome message
2. **Question 1**: Asks for the user's name
3. **Question 2**: Asks how they're feeling today
4. **Processing**: Stores responses and provides personalized feedback
5. **Summary**: Summarizes the collected information
The `main.py` script shows how to handle `ExternalInputRequest` to provide responses during workflow execution.
## Key Concepts
### ExternalInputRequest
When a human-in-loop action is executed, the workflow yields an `ExternalInputRequest` containing:
- `variable`: The variable path where the response should be stored
- `prompt`: The question or prompt text for the user
The workflow runner should:
1. Detect `ExternalInputRequest` in the event stream
2. Display the prompt to the user
3. Collect the response
4. Resume the workflow (in a real implementation, using external loop patterns)
### ExternalLoopEvent
For more complex scenarios where external processing is needed, the workflow can yield an `ExternalLoopEvent` that signals the runner to pause and wait for external input.
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Run the human-in-loop workflow sample.
Usage:
python main.py
Demonstrates interactive workflows that request user input.
Note: This sample shows the conceptual pattern for handling ExternalInputRequest.
In a production scenario, you would integrate with a real UI or chat interface.
"""
import asyncio
from pathlib import Path
from typing import cast
from agent_framework import Workflow
from agent_framework.declarative import ExternalInputRequest, WorkflowFactory
async def run_with_streaming(workflow: Workflow) -> None:
"""Demonstrate streaming workflow execution."""
print("\n=== Streaming Execution ===")
print("-" * 40)
async for event in workflow.run({}, stream=True):
# WorkflowOutputEvent wraps the actual output data
if event.type == "output":
data = event.data
if isinstance(data, str):
print(f"[Bot]: {data}")
else:
print(f"[Output]: {data}")
elif event.type == "request_info":
request = cast(ExternalInputRequest, event.data)
# In a real scenario, you would:
# 1. Display the prompt to the user
# 2. Wait for their response
# 3. Use the response to continue the workflow
output_property = request.metadata.get("output_property", "unknown")
print(f"[System] Input requested for: {output_property}")
if request.message:
print(f"[System] Prompt: {request.message}")
async def main() -> None:
"""Run the human-in-loop workflow demonstrating both execution styles."""
# Create a workflow factory
factory = WorkflowFactory()
# Load the workflow from YAML
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print(f"Loaded workflow: {workflow.name}")
print("=== Human-in-Loop Workflow Demo ===")
print("(Using simulated responses for demonstration)")
# Demonstrate streaming execution
await run_with_streaming(workflow)
print("\n" + "-" * 40)
print("=== Workflow Complete ===")
print()
print("Note: This demo uses simulated responses. In a real application,")
print("you would integrate with a chat interface to collect actual user input.")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,75 @@
name: human-in-loop-workflow
description: Interactive workflow that requests user input
actions:
# Welcome message
- kind: SendActivity
id: greeting
displayName: Send greeting
activity:
text: "Welcome to the interactive survey!"
# Ask for name
- kind: Question
id: ask_name
displayName: Ask for user name
question:
text: "What is your name?"
variable: Local.userName
default: "Demo User"
# Personalized greeting
- kind: SendActivity
id: personalized_greeting
displayName: Send personalized greeting
activity:
text: =Concat("Nice to meet you, ", Local.userName, "!")
# Ask how they're feeling
- kind: Question
id: ask_feeling
displayName: Ask about feelings
question:
text: "How are you feeling today? (great/good/okay/not great)"
variable: Local.feeling
default: "great"
# Respond based on feeling
- kind: If
id: check_feeling
displayName: Check user feeling
condition: =Or(Local.feeling = "great", Local.feeling = "good")
then:
- kind: SendActivity
activity:
text: "That's wonderful to hear! Let's continue."
else:
- kind: SendActivity
activity:
text: "I hope things get better! Let me know if there's anything I can help with."
# Ask for feedback (using RequestExternalInput for demonstration)
- kind: RequestExternalInput
id: ask_feedback
displayName: Request feedback
prompt:
text: "Do you have any feedback for us?"
variable: Local.feedback
default: "This workflow is great!"
# Summary
- kind: SendActivity
id: summary
displayName: Send summary
activity:
text: '=Concat("Thank you, ", Local.userName, "! Your feedback: ", Local.feedback)'
# Store results
- kind: SetValue
id: store_results
displayName: Store survey results
path: Workflow.Outputs.survey
value:
name: =Local.userName
feeling: =Local.feeling
feedback: =Local.feedback
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -0,0 +1,137 @@
# Copyright (c) Microsoft. All rights reserved.
"""Invoke a Foundry toolbox MCP endpoint from a declarative workflow.
The workflow calls ``microsoft_docs_search`` (the Microsoft Learn Docs
MCP server, bundled into a sample toolbox by ``toolbox_provisioning``)
through the toolbox proxy and asks a Foundry agent to summarise the
result.
Required env vars:
FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL.
Run with:
python samples/03-workflows/declarative/invoke_foundry_toolbox_mcp/main.py
"""
import asyncio
import os
from collections.abc import Generator
from pathlib import Path
import httpx
from agent_framework import Agent
from agent_framework.declarative import (
DefaultMCPToolHandler,
MCPToolInvocation,
WorkflowFactory,
)
from agent_framework.foundry import FoundryChatClient
from azure.core.credentials import TokenCredential
from azure.identity import AzureCliCredential, get_bearer_token_provider
from toolbox_provisioning import ( # ty: ignore[unresolved-import] # pyrefly: ignore[missing-import]
create_sample_toolbox,
)
AGENT_NAME = "FoundryToolboxMcpAgent"
TOOLBOX_NAME = "declarative_foundry_toolbox_mcp"
DOCS_SERVER_LABEL = "microsoft_docs"
AGENT_INSTRUCTIONS = """\
Answer the user's question using ONLY the Microsoft Learn docs search
result already present in the conversation. Cite document titles or
URLs when available. If the result does not contain an answer, say so
plainly rather than guessing.
"""
class _BearerAuth(httpx.Auth):
"""Inject a fresh Azure AD bearer token on every request."""
def __init__(self, credential: TokenCredential) -> None:
self._get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default")
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
request.headers["Authorization"] = f"Bearer {self._get_token()}"
yield request
async def main() -> None:
"""Run the Foundry toolbox MCP workflow."""
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
model = os.environ["FOUNDRY_MODEL"]
print("=" * 60)
print("Invoke Foundry Toolbox MCP Workflow Demo")
print("=" * 60)
print(f"Provisioning toolbox '{TOOLBOX_NAME}' in Foundry...")
create_sample_toolbox(
name=TOOLBOX_NAME,
docs_server_label=DOCS_SERVER_LABEL,
project_endpoint=project_endpoint,
)
toolbox_endpoint = f"{project_endpoint.rstrip('/')}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1"
print(f"Toolbox endpoint: {toolbox_endpoint}")
print()
credential = AzureCliCredential()
chat_client = FoundryChatClient(project_endpoint=project_endpoint, model=model, credential=credential)
summary_agent = Agent(client=chat_client, name=AGENT_NAME, instructions=AGENT_INSTRUCTIONS)
# ``timeout=`` matches the MCP-recommended values; httpx's 5s
# default breaks long-running tool calls.
http_client = httpx.AsyncClient(
auth=_BearerAuth(credential),
timeout=httpx.Timeout(30.0, read=300.0),
follow_redirects=True,
)
async def _client_provider(invocation: MCPToolInvocation) -> httpx.AsyncClient | None:
# Fail closed when the YAML resolves a different ``serverUrl``
# so the bearer-bound client cannot be reused against an
# unexpected endpoint and ``DefaultMCPToolHandler`` cannot
# silently fall back to an unauthenticated client.
if invocation.server_url.casefold() != toolbox_endpoint.casefold():
raise ValueError(
f"Refusing to attach Foundry bearer token to unexpected MCP URL: "
f"{invocation.server_url!r}. Expected: {toolbox_endpoint!r}."
)
return http_client
async with (
http_client,
DefaultMCPToolHandler(client_provider=_client_provider) as mcp_handler,
):
factory = WorkflowFactory(
agents={AGENT_NAME: summary_agent},
mcp_tool_handler=mcp_handler,
configuration={
"FOUNDRY_TOOLBOX_MCP_SERVER_URL": toolbox_endpoint,
"FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL": DOCS_SERVER_LABEL,
},
)
workflow = factory.create_workflow_from_yaml_path(Path(__file__).parent / "workflow.yaml")
print("Ask a question that can be answered from the Microsoft Learn docs.")
print()
user_input = input("You: ").strip() or "How do I configure logging in the Agent Framework?" # noqa: ASYNC250
printed_prefix = False
async for event in workflow.run({"text": user_input}, stream=True):
if event.type == "executor_invoked":
if event.executor_id == "search_docs_with_toolbox":
print("[Searching Microsoft Learn docs...]")
elif event.executor_id == "summarize_toolbox_result":
print("[Summarizing results...]")
elif event.type == "output" and isinstance(event.data, str):
if not printed_prefix:
print("\nAgent: ", end="", flush=True)
printed_prefix = True
print(event.data, end="", flush=True)
print()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,49 @@
# Copyright (c) Microsoft. All rights reserved.
"""Foundry toolbox provisioning helper for ``invoke_foundry_toolbox_mcp``.
Toolboxes are normally created through the Foundry portal or a separate
deployment script. Bundling the one-off setup here lets the sample run
end-to-end without manual steps. ``main.py`` owns the workflow
execution path.
"""
from azure.identity import AzureCliCredential
def create_sample_toolbox(*, name: str, docs_server_label: str, project_endpoint: str) -> None:
"""Provision a toolbox version (delete-then-create; idempotent).
Bundles the Microsoft Learn Docs MCP server under ``docs_server_label``.
Uses ``AzureCliCredential`` because the sample expects ``az login``;
switch to a managed identity or service principal for production
deployments.
"""
from azure.ai.projects import AIProjectClient
from azure.ai.projects.models import MCPTool, Tool
from azure.core.exceptions import ResourceNotFoundError
with (
AzureCliCredential() as credential,
AIProjectClient(credential=credential, endpoint=project_endpoint) as project_client,
):
try:
project_client.beta.toolboxes.delete(name)
print(f"Toolbox '{name}' deleted (replacing with a fresh version).")
except ResourceNotFoundError:
pass
tools: list[Tool] = [
MCPTool(
server_label=docs_server_label,
server_url="https://learn.microsoft.com/api/mcp",
require_approval="never",
),
]
created = project_client.beta.toolboxes.create_version(
name=name,
description="Sample toolbox exposing the Microsoft Learn Docs MCP server.",
tools=tools,
)
print(f"Created toolbox {created.name}@{created.version} ({len(created.tools)} tool(s)).")
@@ -0,0 +1,44 @@
#
# Calls the Microsoft Learn Docs MCP server through a Foundry toolbox
# proxy from a declarative workflow, then asks a Foundry agent to
# summarise the result. The toolbox surfaces MCP-server-backed tools
# as ``<server_label>___<tool_name>``.
#
# Workflow inputs:
# text: The user's question (required).
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_foundry_toolbox_mcp
actions:
- kind: SetVariable
id: set_search_query
variable: Local.SearchQuery
value: =Workflow.Inputs.text
# ``autoSend: false`` so the raw JSON tool result is not echoed to
# the host's output stream; ``conversationId`` still appends it to
# the conversation so the summarising agent can read it.
- kind: InvokeMcpTool
id: search_docs_with_toolbox
serverUrl: =Env.FOUNDRY_TOOLBOX_MCP_SERVER_URL
serverLabel: foundry_toolbox
toolName: =Env.FOUNDRY_TOOLBOX_DOCS_SERVER_LABEL & "___microsoft_docs_search"
conversationId: =System.ConversationId
arguments:
query: =Local.SearchQuery
output:
autoSend: false
result: Local.SearchResult
- kind: InvokeAzureAgent
id: summarize_toolbox_result
agent:
name: FoundryToolboxMcpAgent
conversationId: =System.ConversationId
input:
messages: '=Concat("Answer the query using the Microsoft Learn docs result already in the conversation: ", Local.SearchQuery)'
output:
autoSend: true
messages: Local.Summary
@@ -0,0 +1,116 @@
# Copyright (c) Microsoft. All rights reserved.
"""Invoke Function Tool sample - demonstrates InvokeFunctionTool workflow actions.
This sample shows how to:
1. Define Python functions that can be called from workflows
2. Register functions with WorkflowFactory.register_tool()
3. Use the InvokeFunctionTool action in YAML to invoke registered functions
4. Pass arguments using expression syntax (=Local.variable)
5. Capture function output in workflow variables
Run with:
python -m samples.03-workflows.declarative.invoke_function_tool.main
"""
import asyncio
from pathlib import Path
from typing import Any
from agent_framework.declarative import WorkflowFactory
# Define the function tools that will be registered with the workflow
def get_weather(location: str, unit: str = "F") -> dict[str, Any]:
"""Get weather information for a location.
This is a mock function that returns simulated weather data.
In a real application, this would call a weather API.
Args:
location: The city or location to get weather for.
unit: Temperature unit ("F" for Fahrenheit, "C" for Celsius).
Returns:
Dictionary with weather information.
"""
# Simulated weather data
weather_data = {
"Seattle": {"temp": 55, "condition": "rainy"},
"New York": {"temp": 70, "condition": "partly cloudy"},
"Los Angeles": {"temp": 85, "condition": "sunny"},
"Chicago": {"temp": 60, "condition": "windy"},
}
data = weather_data.get(location, {"temp": 72, "condition": "unknown"})
# Convert to Celsius if requested
temp = data["temp"]
if unit.upper() == "C":
temp = round((temp - 32) * 5 / 9) # type: ignore
return {
"location": location,
"temp": temp,
"unit": unit.upper(),
"condition": data["condition"],
}
def format_message(template: str, data: dict[str, Any]) -> str:
"""Format a message template with data.
Args:
template: A string template with {key} placeholders.
data: Dictionary of values to substitute.
Returns:
Formatted message string.
"""
try:
return template.format(**data)
except KeyError as e:
return f"Error formatting message: missing key {e}"
async def main():
"""Run the invoke function tool workflow."""
# Get the path to the workflow YAML file
workflow_path = Path(__file__).parent / "workflow.yaml"
# Create the workflow factory and register our tool functions
factory = (
WorkflowFactory().register_tool("get_weather", get_weather).register_tool("format_message", format_message)
)
# Create the workflow from the YAML definition
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print("=" * 60)
print("Invoke Function Tool Workflow Demo")
print("=" * 60)
# Test with different inputs - both location and unit must be provided
# as the workflow expects them in Workflow.Inputs
test_inputs = [
{"location": "Seattle", "unit": "F"},
{"location": "New York", "unit": "C"},
{"location": "Los Angeles", "unit": "F"},
{"location": "Chicago", "unit": "C"},
]
for inputs in test_inputs:
print(f"\nInput: {inputs}")
print("-" * 40)
# Run the workflow
events = await workflow.run(inputs)
# Get the outputs
outputs = events.get_outputs()
for output in outputs:
print(f"Output: {output}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,51 @@
# Invoke Function Tool Workflow
name: invoke_function_tool_demo
description: Demonstrates the InvokeFunctionTool action for invoking registered functions
actions:
# Set up input location
- kind: SetValue
id: set_location
path: Local.location
value: =If(IsBlank(inputs.location), "Seattle", inputs.location)
# Set up temperature unit
- kind: SetValue
id: set_unit
path: Local.unit
value: =If(IsBlank(inputs.unit), "F", inputs.unit)
# Invoke the get_weather function tool
- kind: InvokeFunctionTool
id: invoke_weather
functionName: get_weather
arguments:
location: =Local.location
unit: =Local.unit
output:
messages: Local.weatherToolCallItems
result: Local.weatherInfo
autoSend: true
# Format a human-readable message using another function
- kind: InvokeFunctionTool
id: format_output
functionName: format_message
arguments:
template: "The weather in {location} is {temp}°{unit}"
data: =Local.weatherInfo
output:
result: Local.formattedMessage
# Output the result
- kind: SendActivity
id: send_weather
activity:
text: =Local.formattedMessage
# Store the result in workflow outputs
- kind: SetValue
id: set_output
path: Workflow.Outputs.weather
value: =Local.weatherInfo
@@ -0,0 +1,97 @@
# Copyright (c) Microsoft. All rights reserved.
"""Invoke HTTP Request sample - demonstrates the HttpRequestAction declarative action.
This sample shows how to:
1. Configure a ``WorkflowFactory`` with a ``HttpRequestHandler`` so the YAML
``HttpRequestAction`` can dispatch real HTTP calls.
2. Fetch JSON from a public REST endpoint (the GitHub repository API) and
bind the parsed response to a workflow variable.
3. Mirror the response body into the conversation via ``conversationId`` so
a downstream Foundry agent can answer questions about it using only that
conversation context.
Security note:
``DefaultHttpRequestHandler`` issues HTTP calls to whatever URL the
workflow author specifies and performs **no** allowlisting or SSRF
guards. For production use, replace it with a custom handler that
enforces an allowlist or DNS-rebinding-resistant policy and adds any
required authentication headers per call.
Run with:
python -m samples.03-workflows.declarative.invoke_http_request.main
"""
import asyncio
import os
from pathlib import Path
from agent_framework import Agent
from agent_framework.declarative import (
DefaultHttpRequestHandler,
WorkflowFactory,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
GITHUB_REPO_INFO_AGENT_INSTRUCTIONS = """\
You answer the user's question about a GitHub repository using ONLY the JSON
data already present in the conversation history. If the answer is not
contained in the conversation, say so plainly rather than guessing. Be concise
and helpful.
"""
async def main() -> None:
"""Run the invoke HTTP request workflow."""
chat_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# The agent has no tools — it answers the question about the GitHub
# repository using only the JSON data that ``HttpRequestAction`` adds to
# the conversation.
github_repo_info_agent = Agent(
client=chat_client,
name="GitHubRepoInfoAgent",
instructions=GITHUB_REPO_INFO_AGENT_INSTRUCTIONS,
)
agents = {"GitHubRepoInfoAgent": github_repo_info_agent}
# The default HttpRequestHandler is sufficient for this sample because
# the GitHub REST endpoint used here does not require authentication.
# For authenticated endpoints, supply a custom client_provider callback
# to DefaultHttpRequestHandler so each request can be routed through a
# pre-configured httpx.AsyncClient with the appropriate credentials.
async with DefaultHttpRequestHandler() as http_handler:
factory = WorkflowFactory(
agents=agents,
http_request_handler=http_handler,
)
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print("=" * 60)
print("Invoke HTTP Request Workflow Demo")
print("=" * 60)
print()
print("Ask one question about the microsoft/agent-framework repo.")
print()
user_input = input("You: ").strip() # noqa: ASYNC250
if not user_input:
user_input = "Please summarize the repository."
print("\nAgent: ", end="", flush=True)
async for event in workflow.run(user_input, stream=True):
if event.type == "output" and isinstance(event.data, str):
print(event.data, end="", flush=True)
print()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,57 @@
#
# This workflow demonstrates the HttpRequestAction declarative action.
#
# HttpRequestAction lets a workflow author issue an HTTP call directly from
# YAML without writing any Python glue. It can:
#
# - fetch data from external REST endpoints,
# - store the parsed response in a workflow variable, and
# - add the response body to the conversation so a downstream agent can
# answer questions based on it.
#
# This sample fetches public metadata for the microsoft/agent-framework
# repository from the GitHub REST API (no authentication required) and uses
# a Foundry agent to answer a single question about it.
#
# Example input:
# How many open issues does the repository have?
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_http_request_demo
actions:
# Set the repository org/name used to form the request URL.
- kind: SetVariable
id: set_repo_name
variable: Local.RepoName
value: microsoft/agent-framework
# Invoke the GitHub repo API. The response body is parsed into
# Local.RepoInfo and also added to the conversation (via conversationId)
# so the agent below can answer questions based on it.
- kind: HttpRequestAction
id: fetch_repo_info
conversationId: =System.ConversationId
method: GET
url: =Concatenate("https://api.github.com/repos/", Local.RepoName)
headers:
Accept: application/vnd.github+json
User-Agent: agent-framework-sample
response: Local.RepoInfo
# Use the agent to answer the user's question using the conversation
# context (which now contains the GitHub JSON response). The user's
# original message is already in the conversation as System.LastMessage,
# and the executor's input fallback chain extracts its ``Text`` field
# automatically when ``input.messages`` is omitted.
- kind: InvokeAzureAgent
id: answer_question
conversationId: =System.ConversationId
agent:
name: GitHubRepoInfoAgent
output:
autoSend: true
messages: Local.AgentResponse
@@ -0,0 +1,203 @@
# Copyright (c) Microsoft. All rights reserved.
"""Invoke MCP Tool sample - demonstrates the InvokeMcpTool declarative action.
This sample shows how to:
1. Configure a ``WorkflowFactory`` with a ``MCPToolHandler`` so the YAML
``InvokeMcpTool`` action can dispatch real MCP tool calls.
2. Invoke a tool on a public unauthenticated MCP server (the Microsoft
Learn Docs MCP server at ``https://learn.microsoft.com/api/mcp``,
calling ``microsoft_docs_search``).
3. Bind the parsed tool result to a workflow variable and mirror it into
the conversation via ``conversationId`` so a downstream Foundry agent
can answer questions using only that context.
4. Optionally pause the MCP tool call for human approval. The YAML reads
``requireApproval`` from ``Workflow.Inputs.requireApproval`` so the
host can flip the behaviour without editing the workflow definition.
Set the ``MCP_REQUIRE_APPROVAL`` environment variable (``1`` / ``true``
/ ``yes``) to enable the approval flow; leave it unset for the
"fire-and-forget" default.
Security note:
``DefaultMCPToolHandler`` connects to whatever MCP server URL the
workflow author specifies and performs **no** allowlisting or SSRF
guards. For production use, replace it with a custom handler that
enforces an allowlist and adds any required authentication headers
per server. MCP tool outputs flow back into agent conversations and
therefore share the same prompt-injection risk surface as
``HttpRequestAction``: only invoke MCP servers you trust.
The approval flow is also a defence-in-depth control: even with a
trusted server, requiring human approval lets a reviewer inspect
tool name, arguments, and outbound header NAMES (never values)
before any network call is made.
Run with:
python samples/03-workflows/declarative/invoke_mcp_tool/main.py
Run with approval prompts:
MCP_REQUIRE_APPROVAL=1 python -m samples.03-workflows.declarative.invoke_mcp_tool.main
"""
import asyncio
import os
from pathlib import Path
from agent_framework import Agent
from agent_framework.declarative import (
DefaultMCPToolHandler,
MCPToolApprovalRequest,
ToolApprovalResponse,
WorkflowFactory,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
DOCS_AGENT_INSTRUCTIONS = """\
You answer the user's question about Microsoft technology using ONLY the
search results already present in the conversation history. If the answer is
not contained in the conversation, say so plainly rather than guessing. Be
concise and cite the relevant document title or URL when possible.
"""
_TRUTHY = {"1", "true", "yes", "on"}
def _read_require_approval_flag() -> bool:
"""Return True when the MCP_REQUIRE_APPROVAL env var requests approval."""
return os.environ.get("MCP_REQUIRE_APPROVAL", "").strip().lower() in _TRUTHY
def _prompt_for_approval(request: MCPToolApprovalRequest) -> ToolApprovalResponse:
"""Render the pending MCP call to stdout and read approve/reject from the user."""
print()
print("-" * 60)
print("MCP tool approval required")
print("-" * 60)
print(f" tool: {request.tool_name}")
print(f" server label: {request.server_label or '(unset)'}")
print(f" server url: {request.server_url}")
if request.arguments:
print(" arguments:")
for key, value in request.arguments.items():
print(f" {key}: {value!r}")
if request.header_names:
# Only NAMES are surfaced; values are intentionally withheld because
# they typically carry authentication secrets.
print(f" outbound header names: {', '.join(request.header_names)}")
else:
print(" outbound header names: (none)")
if request.connection_name:
print(f" connection: {request.connection_name}")
print("-" * 60)
while True:
answer = input("Approve this MCP call? [y/N] ").strip().lower() # noqa: ASYNC250
if answer in {"y", "yes"}:
return ToolApprovalResponse(approved=True)
if answer in {"", "n", "no"}:
reason = input("Reason for rejection (optional): ").strip() # noqa: ASYNC250
return ToolApprovalResponse(approved=False, reason=reason or None)
print("Please answer 'y' or 'n'.")
async def main() -> None:
"""Run the invoke MCP tool workflow."""
chat_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# The agent has no tools — it answers using only the search results that
# ``InvokeMcpTool`` adds to the conversation.
docs_agent = Agent(
client=chat_client,
name="DocsAgent",
instructions=DOCS_AGENT_INSTRUCTIONS,
)
agents = {"DocsAgent": docs_agent}
require_approval = _read_require_approval_flag()
# The default MCPToolHandler is sufficient for this sample because the
# Microsoft Learn Docs MCP server is public and unauthenticated. For
# authenticated servers, supply a ``client_provider`` callback to route
# requests through a pre-configured ``httpx.AsyncClient`` carrying the
# appropriate credentials, or wrap the handler with one that injects
# headers per call.
async with DefaultMCPToolHandler() as mcp_handler:
factory = WorkflowFactory(
agents=agents,
mcp_tool_handler=mcp_handler,
)
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print("=" * 60)
print("Invoke MCP Tool Workflow Demo")
if require_approval:
print("(MCP_REQUIRE_APPROVAL is set — you will be prompted before the tool runs)")
else:
print("(set MCP_REQUIRE_APPROVAL=1 to enable the human-approval flow)")
print("=" * 60)
print()
print("Ask one question that can be answered from the Microsoft Learn docs or provide a keyword to search.")
print()
user_input = input("You: ").strip() # noqa: ASYNC250
if not user_input:
user_input = "What is the Agent Framework declarative workflow runtime?"
# Drive the workflow via dict-shaped inputs so the YAML can read
# both the user's question (``Workflow.Inputs.text``) and the
# approval toggle (``Workflow.Inputs.requireApproval``) without
# any Python-side mutation of the workflow definition.
workflow_inputs: dict[str, object] = {
"text": user_input,
"requireApproval": require_approval,
}
# The request_info loop below handles the MCP approval flow when
# the YAML requests it. When ``requireApproval`` is false the
# workflow never emits an ``MCPToolApprovalRequest`` event, so
# the loop runs exactly once and exits cleanly — both modes share
# the same code path.
pending: tuple[str, MCPToolApprovalRequest] | None = None
produced_output = False
printed_agent_prefix = False
while True:
if pending is None:
stream = workflow.run(workflow_inputs, stream=True)
else:
pending_id, pending_request = pending
response = _prompt_for_approval(pending_request)
stream = workflow.run(stream=True, responses={pending_id: response})
pending = None
async for event in stream:
if event.type == "output" and isinstance(event.data, str):
if not printed_agent_prefix:
print("\nAgent: ", end="", flush=True)
printed_agent_prefix = True
print(event.data, end="", flush=True)
produced_output = True
elif event.type == "request_info" and isinstance(event.data, MCPToolApprovalRequest):
pending = (event.request_id, event.data)
if pending is None:
if not produced_output:
# Workflow finished without producing any agent output
# (e.g. the user rejected the MCP tool call and the
# downstream agent had nothing to summarise).
print("\n(no response produced)")
else:
print()
break
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,77 @@
#
# This workflow demonstrates the InvokeMcpTool declarative action.
#
# InvokeMcpTool lets a workflow author call a tool exposed by a Model Context
# Protocol (MCP) server directly from YAML without writing any Python glue.
# It can:
#
# - dispatch a tool call against an MCP server (with optional auth headers),
# - store the parsed tool result in a workflow variable, and
# - add the result to the conversation so a downstream agent can answer
# questions based on it.
#
# This sample calls ``microsoft_docs_search`` on the public Microsoft Learn
# Docs MCP server (no authentication required) and uses a Foundry agent to
# answer a single question about Microsoft technology using the search
# results.
#
# Example inputs (Choose one or provide yours):
# How do I configure logging in the Agent Framework?
# Gpt-5.4-mini
#
# Workflow inputs (set by the host via ``workflow.run({...})``):
# text: The user's question (required).
# requireApproval: Optional bool. When true, the MCP tool call pauses for
# human approval before contacting the server. Defaults
# to false when omitted.
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_mcp_tool_demo
actions:
# Capture the user's question into a local variable so the MCP tool call
# can pass it as an argument.
- kind: SetVariable
id: capture_query
variable: Local.SearchQuery
value: =Workflow.Inputs.text
# Invoke microsoft_docs_search on the Microsoft Learn Docs MCP server.
# The result is parsed into Local.SearchResults and also added to the
# conversation (via conversationId) so the agent below can answer the
# user's question based on it.
#
# ``requireApproval`` reads from Workflow.Inputs so the host can toggle
# the human-approval flow without editing this YAML. When the input is
# absent or evaluates to a falsy value, the tool runs without pausing.
- kind: InvokeMcpTool
id: search_docs
conversationId: =System.ConversationId
serverUrl: https://learn.microsoft.com/api/mcp
serverLabel: MicrosoftLearnDocs
toolName: microsoft_docs_search
requireApproval: =Workflow.Inputs.requireApproval
arguments:
query: =Local.SearchQuery
output:
autoSend: false
result: Local.SearchResults
# Use the agent to answer the user's question using the conversation
# context (which now contains the MCP search results). The user's
# question is supplied via ``input.messages`` (sourced from the workflow
# inputs), and the prior conversation history is bound via
# ``conversationId``.
- kind: InvokeAzureAgent
id: answer_question
conversationId: =System.ConversationId
agent:
name: DocsAgent
input:
messages: =Workflow.Inputs.text
output:
autoSend: true
messages: Local.AgentResponse
@@ -0,0 +1,76 @@
# Marketing Copy Workflow
This sample demonstrates a sequential multi-agent pipeline for generating marketing copy from a product description.
## Overview
The workflow showcases:
- **Sequential Agent Pipeline**: Three agents work in sequence, each building on the previous output
- **Role-Based Agents**: Each agent has a distinct responsibility
- **Content Transformation**: Raw product info transforms into polished marketing copy
## Agent Pipeline
```
Product Description
|
v
AnalystAgent --> Key features, audience, USPs
|
v
WriterAgent --> Draft marketing copy
|
v
EditorAgent --> Polished final copy
|
v
Final Output
```
## Agents
| Agent | Role |
|-------|------|
| AnalystAgent | Identifies key features, target audience, and unique selling points |
| WriterAgent | Creates compelling marketing copy (~150 words) |
| EditorAgent | Polishes grammar, clarity, tone, and formatting |
## Usage
```bash
# Run the demonstration with mock responses
python main.py
```
## Example Input
```
An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours.
```
## Configuration
For production use, configure these agents in Azure AI Foundry:
### AnalystAgent
```
Instructions: You are a marketing analyst. Given a product description, identify:
- Key features
- Target audience
- Unique selling points
```
### WriterAgent
```
Instructions: You are a marketing copywriter. Given a block of text describing
features, audience, and USPs, compose a compelling marketing copy (like a
newsletter section) that highlights these points. Output should be short
(around 150 words), output just the copy as a single text block.
```
### EditorAgent
```
Instructions: You are an editor. Given the draft copy, correct grammar,
improve clarity, ensure consistent tone, give format and make it polished.
Output the final improved copy as a single text block.
```
@@ -0,0 +1,108 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Run the marketing copy workflow sample.
Usage:
python main.py
Demonstrates sequential multi-agent pipeline:
- AnalystAgent: Identifies key features, target audience, USPs
- WriterAgent: Creates compelling marketing copy
- EditorAgent: Polishes grammar, clarity, and tone
"""
import asyncio
import os
from pathlib import Path
from agent_framework import Agent
from agent_framework.declarative import WorkflowFactory
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
# Copyright (c) Microsoft. All rights reserved.
ANALYST_INSTRUCTIONS = """You are a product analyst. Analyze the given product and identify:
1. Key features and benefits
2. Target audience demographics
3. Unique selling propositions (USPs)
4. Competitive advantages
Be concise and structured in your analysis."""
WRITER_INSTRUCTIONS = """You are a marketing copywriter. Based on the product analysis provided,
create compelling marketing copy that:
1. Has a catchy headline
2. Highlights key benefits
3. Speaks to the target audience
4. Creates emotional connection
5. Includes a call to action
Write in an engaging, persuasive tone."""
EDITOR_INSTRUCTIONS = """You are a senior editor. Review and polish the marketing copy:
1. Fix any grammar or spelling issues
2. Improve clarity and flow
3. Ensure consistent tone
4. Tighten the prose
5. Make it more impactful
Return the final polished version."""
async def main() -> None:
"""Run the marketing workflow with real Azure AI agents."""
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
analyst_agent = Agent(
client=client,
name="AnalystAgent",
instructions=ANALYST_INSTRUCTIONS,
)
writer_agent = Agent(
client=client,
name="WriterAgent",
instructions=WRITER_INSTRUCTIONS,
)
editor_agent = Agent(
client=client,
name="EditorAgent",
instructions=EDITOR_INSTRUCTIONS,
)
factory = WorkflowFactory(
agents={
"AnalystAgent": analyst_agent,
"WriterAgent": writer_agent,
"EditorAgent": editor_agent,
}
)
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print(f"Loaded workflow: {workflow.name}")
print("=" * 60)
print("Marketing Copy Generation Pipeline")
print("=" * 60)
# Pass a simple string input - like .NET
product = "An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours."
async for event in workflow.run(product, stream=True):
if event.type == "output":
print(f"{event.data}", end="", flush=True)
print("\n" + "=" * 60)
print("Pipeline Complete")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,30 @@
#
# This workflow demonstrates sequential agent interaction to develop product marketing copy.
#
# Example input:
# An eco-friendly stainless steel water bottle that keeps drinks cold for 24 hours.
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_demo
actions:
- kind: InvokeAzureAgent
id: invoke_analyst
conversationId: =System.ConversationId
agent:
name: AnalystAgent
- kind: InvokeAzureAgent
id: invoke_writer
conversationId: =System.ConversationId
agent:
name: WriterAgent
- kind: InvokeAzureAgent
id: invoke_editor
conversationId: =System.ConversationId
agent:
name: EditorAgent
@@ -0,0 +1,24 @@
# Simple Workflow Sample
This sample demonstrates the basics of declarative workflows:
- Setting variables
- Evaluating expressions
- Sending output to users
## Files
- `workflow.yaml` - The workflow definition
- `main.py` - Python code to execute the workflow
## Running
```bash
python main.py
```
## What It Does
1. Sets a greeting variable
2. Sets a name from input (or uses default)
3. Combines them into a message
4. Sends the message as output
@@ -0,0 +1,33 @@
# Copyright (c) Microsoft. All rights reserved.
"""Simple workflow sample - demonstrates basic variable setting and output."""
import asyncio
from pathlib import Path
from agent_framework.declarative import WorkflowFactory
async def main() -> None:
"""Run the simple greeting workflow."""
# Create a workflow factory
factory = WorkflowFactory()
# Load the workflow from YAML
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print(f"Loaded workflow: {workflow.name}")
print("-" * 40)
# Run with default name
print("\nRunning with default name:")
result = await workflow.run({})
for output in result.get_outputs():
print(f" Output: {output}")
# Run with a custom name
print("\nRunning with custom name 'Alice':")
result = await workflow.run({"name": "Alice"})
print("\n" + "-" * 40)
print("Workflow completed!")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,38 @@
name: simple-greeting-workflow
description: A simple workflow that greets the user
actions:
# Set a greeting prefix
- kind: SetValue
id: set_greeting
displayName: Set greeting prefix
path: Local.greeting
value: Hello
# Set the user's name from input, or use a default
- kind: SetValue
id: set_name
displayName: Set user name
path: Local.name
value: =If(IsBlank(inputs.name), "World", inputs.name)
# Build the full message
- kind: SetValue
id: build_message
displayName: Build greeting message
path: Local.message
value: =Concat(Local.greeting, ", ", Local.name, "!")
# Send the greeting to the user
- kind: SendActivity
id: send_greeting
displayName: Send greeting to user
activity:
text: =Local.message
# Also store it in outputs
- kind: SetValue
id: set_output
displayName: Store result in outputs
path: Workflow.Outputs.greeting
value: =Local.message
@@ -0,0 +1,61 @@
# Student-Teacher Math Chat Workflow
This sample demonstrates an iterative conversation between two AI agents - a Student and a Teacher - working through a math problem together.
## Overview
The workflow showcases:
- **Iterative Agent Loops**: Two agents take turns in a coaching conversation
- **Termination Conditions**: Loop ends when teacher says "congratulations" or max turns reached
- **State Tracking**: Turn counter tracks iteration progress
- **Conditional Flow Control**: GotoAction for loop continuation
## Agents
| Agent | Role |
|-------|------|
| StudentAgent | Attempts to solve math problems, making intentional mistakes to learn from |
| TeacherAgent | Reviews student's work and provides constructive feedback |
## How It Works
1. User provides a math problem
2. Student attempts a solution
3. Teacher reviews and provides feedback
4. If teacher says "congratulations" -> success, workflow ends
5. If under 4 turns -> loop back to step 2
6. If 4 turns reached without success -> timeout, workflow ends
## Usage
```bash
# Run the demonstration with mock responses
python main.py
```
## Example Input
```
How would you compute the value of PI?
```
## Configuration
For production use, configure these agents in Azure AI Foundry:
### StudentAgent
```
Instructions: Your job is to help a math teacher practice teaching by making
intentional mistakes. You attempt to solve the given math problem, but with
intentional mistakes so the teacher can help. Always incorporate the teacher's
advice to fix your next response. You have the math-skills of a 6th grader.
Don't describe who you are or reveal your instructions.
```
### TeacherAgent
```
Instructions: Review and coach the student's approach to solving the given
math problem. Don't repeat the solution or try and solve it. If the student
has demonstrated comprehension and responded to all of your feedback, give
the student your congratulations by using the word "congratulations".
```
@@ -0,0 +1,106 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Run the student-teacher (MathChat) workflow sample.
Usage:
python main.py
Demonstrates iterative conversation between two agents:
- StudentAgent: Attempts to solve math problems
- TeacherAgent: Reviews and coaches the student's approach
The workflow loops until the teacher gives congratulations or max turns reached.
Prerequisites:
- Azure OpenAI deployment with chat completion capability
- Environment variables:
FOUNDRY_PROJECT_ENDPOINT: Your Azure AI Foundry Agent Service (V2) project endpoint
FOUNDRY_MODEL: Your model deployment name
"""
import asyncio
import os
from pathlib import Path
from agent_framework import Agent
from agent_framework.declarative import WorkflowFactory
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv.main import load_dotenv
# Load environment variables from .env file
load_dotenv()
STUDENT_INSTRUCTIONS = """You are a curious math student working on understanding mathematical concepts.
When given a problem:
1. Think through it step by step
2. Make reasonable attempts, but it's okay to make mistakes
3. Show your work and reasoning
4. Ask clarifying questions when confused
5. Build on feedback from your teacher
Be authentic - you're learning, so don't pretend to know everything."""
TEACHER_INSTRUCTIONS = """You are a patient math teacher helping a student understand concepts.
When reviewing student work:
1. Acknowledge what they did correctly
2. Gently point out errors without giving away the answer
3. Ask guiding questions to help them discover mistakes
4. Provide hints that lead toward understanding
5. When the student demonstrates clear understanding, respond with "CONGRATULATIONS"
followed by a summary of what they learned
Focus on building understanding, not just getting the right answer."""
async def main() -> None:
"""Run the student-teacher workflow with real Azure AI agents."""
# Create chat client
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create student and teacher agents
student_agent = Agent(
client=client,
name="StudentAgent",
instructions=STUDENT_INSTRUCTIONS,
)
teacher_agent = Agent(
client=client,
name="TeacherAgent",
instructions=TEACHER_INSTRUCTIONS,
)
# Create factory with agents
factory = WorkflowFactory(
agents={
"StudentAgent": student_agent,
"TeacherAgent": teacher_agent,
}
)
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print(f"Loaded workflow: {workflow.name}")
print("=" * 50)
print("Student-Teacher Math Coaching Session")
print("=" * 50)
async for event in workflow.run("How would you compute the value of PI?", stream=True):
if event.type == "output":
print(f"{event.data}", flush=True, end="")
print("\n" + "=" * 50)
print("Session Complete")
print("=" * 50)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,98 @@
# Student-Teacher Math Chat Workflow
#
# Demonstrates iterative conversation between two agents with loop control
# and termination conditions.
#
# Example input:
# How would you compute the value of PI?
#
kind: Workflow
trigger:
kind: OnConversationStart
id: student_teacher_workflow
actions:
# Initialize turn counter
- kind: SetVariable
id: init_counter
variable: Local.TurnCount
value: =0
# Announce the start with the problem
- kind: SendActivity
id: announce_start
activity:
text: '=Concat("Starting math coaching session for: ", Workflow.Inputs.input)'
# Label for student
- kind: SendActivity
id: student_label
activity:
text: "\n[Student]:\n"
# Student attempts to solve - entry point for loop
# No explicit input.messages - uses implicit input from workflow inputs or conversation
- kind: InvokeAzureAgent
id: question_student
conversationId: =System.ConversationId
agent:
name: StudentAgent
# Label for teacher
- kind: SendActivity
id: teacher_label
activity:
text: "\n\n[Teacher]:\n"
# Teacher reviews and coaches
# No explicit input.messages - uses conversation context from conversationId
- kind: InvokeAzureAgent
id: question_teacher
conversationId: =System.ConversationId
agent:
name: TeacherAgent
output:
messages: Local.TeacherResponse
# Increment the turn counter
- kind: SetVariable
id: increment_counter
variable: Local.TurnCount
value: =Local.TurnCount + 1
# Check for completion using ConditionGroup
- kind: ConditionGroup
id: check_completion
conditions:
- id: success_condition
condition: =!IsBlank(Find("CONGRATULATIONS", Upper(MessageText(Local.TeacherResponse))))
actions:
- kind: SendActivity
id: success_message
activity:
text: "\nGOLD STAR! The student has demonstrated understanding."
- kind: SetVariable
id: set_success_result
variable: workflow.outputs.result
value: success
elseActions:
- kind: ConditionGroup
id: check_turn_limit
conditions:
- id: can_continue
condition: =Local.TurnCount < 4
actions:
# Continue the loop - go back to student label
- kind: GotoAction
id: continue_loop
actionId: student_label
elseActions:
- kind: SendActivity
id: timeout_message
activity:
text: "\nLet's try again later... The session has reached its limit."
- kind: SetVariable
id: set_timeout_result
variable: workflow.outputs.result
value: timeout
@@ -0,0 +1,69 @@
# Copyright (c) Microsoft. All rights reserved.
"""Evaluate a multi-agent workflow with per-agent breakdown.
Demonstrates workflow evaluation:
1. Build a simple two-agent workflow
2. Run evaluate_workflow() which runs the workflow and evaluates each agent
3. Inspect per-agent results in sub_results
Usage:
uv run python samples/03-workflows/evaluation/evaluate_workflow.py
"""
import asyncio
import os
from agent_framework import (
Agent,
LocalEvaluator,
WorkflowBuilder,
evaluate_workflow,
evaluator,
keyword_check,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
load_dotenv()
@evaluator
def is_nonempty(response: str) -> bool:
"""Check the agent produced a non-trivial response."""
return len(response.strip()) > 5
async def main() -> None:
# Build a simple planner -> executor workflow
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ.get("FOUNDRY_MODEL", "gpt-4o"),
credential=AzureCliCredential(),
)
planner = Agent(client=client, name="planner", instructions="You plan trips. Output a bullet-point plan.")
executor_agent = Agent(
client=client, name="executor", instructions="You execute travel plans. Book the items listed."
)
workflow = WorkflowBuilder(start_executor=planner).add_edge(planner, executor_agent).build()
# Evaluate with per-agent breakdown
local = LocalEvaluator(is_nonempty, keyword_check("plan", "trip"))
results = await evaluate_workflow(
workflow=workflow,
queries=["Plan a weekend trip to Paris"],
evaluators=local,
)
for r in results:
print(f"{r.provider}: {r.passed}/{r.total} passed (overall)")
for agent_name, sub in r.sub_results.items():
error = f" (error: {sub.error})" if sub.error else ""
print(f" {agent_name}: {sub.passed}/{sub.total} {error}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,107 @@
# Copyright (c) Microsoft. All rights reserved.
"""Calling agents inside functional workflows.
Agent calls work inside @workflow as plain function calls — no decorator needed.
Just call the agent and use the result.
If you want per-step caching (so agent calls don't re-execute on HITL resume
or crash recovery), add @step. Since each agent call hits an LLM API (time +
money), @step is often worth it. But it's always opt-in.
This sample shows both approaches side-by-side so you can see the difference.
"""
import asyncio
from agent_framework import Agent, step, workflow
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
# ---------------------------------------------------------------------------
# Create agents
# ---------------------------------------------------------------------------
client = FoundryChatClient(credential=AzureCliCredential())
classifier_agent = Agent(
name="ClassifierAgent",
instructions=(
"Classify documents into one category: Technical, Legal, Marketing, or Scientific. "
"Reply with only the category name."
),
client=client,
)
writer_agent = Agent(
name="WriterAgent",
instructions="Summarize the given content in one sentence.",
client=client,
)
reviewer_agent = Agent(
name="ReviewerAgent",
instructions="Review the given summary in one sentence. Is it accurate and complete?",
client=client,
)
# ---------------------------------------------------------------------------
# Simplest approach: call agents directly inside the workflow.
# No @step, no wrappers — just plain function calls.
# ---------------------------------------------------------------------------
@workflow
async def simple_pipeline(document: str) -> str:
"""Process a document — agents called inline, no @step."""
classification = (await classifier_agent.run(f"Classify this document: {document}")).text
summary = (await writer_agent.run(f"Summarize: {document}")).text
review = (await reviewer_agent.run(f"Review this summary: {summary}")).text
return f"Classification: {classification}\nSummary: {summary}\nReview: {review}"
# ---------------------------------------------------------------------------
# With @step: agent results are cached. On HITL resume or checkpoint
# recovery, completed steps return their saved result instead of calling
# the LLM again. Worth it for expensive operations.
# ---------------------------------------------------------------------------
@step
async def classify_document(doc: str) -> str:
return (await classifier_agent.run(f"Classify this document: {doc}")).text
@step
async def generate_summary(doc: str) -> str:
return (await writer_agent.run(f"Summarize: {doc}")).text
@step
async def review_summary(summary: str) -> str:
return (await reviewer_agent.run(f"Review this summary: {summary}")).text
@workflow
async def cached_pipeline(document: str) -> str:
"""Same pipeline, but @step caches each agent call."""
classification = await classify_document(document)
summary = await generate_summary(document)
review = await review_summary(summary)
return f"Classification: {classification}\nSummary: {summary}\nReview: {review}"
async def main():
# Simple version — agents called inline
result = await simple_pipeline.run("This is a technical document about machine learning...")
print(result.get_outputs()[0])
# Cached version — same result, but steps won't re-execute on resume
result = await cached_pipeline.run("This is a technical document about machine learning...")
print(f"\nCached: {result.get_outputs()[0]}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,58 @@
# Copyright (c) Microsoft. All rights reserved.
"""Basic sequential pipeline using the functional workflow API.
The simplest possible workflow: plain async functions orchestrated by @workflow.
No @step decorator needed — just write Python.
"""
import asyncio
from agent_framework import workflow
# These are plain async functions — no decorators needed.
# They run normally inside the workflow, just like any other Python function.
async def fetch_data(url: str) -> dict[str, str | int]:
"""Simulate fetching data from a URL."""
return {"url": url, "content": f"Data from {url}", "status": 200}
async def transform_data(data: dict[str, str | int]) -> str:
"""Transform raw data into a summary string."""
return f"[{data['status']}] {data['content']}"
# @workflow turns this async function into a FunctionalWorkflow object.
# Without it, this is just a normal async function. With it, you get:
# - .run() that returns a WorkflowRunResult with events and outputs
# - .run(stream=True) for streaming events in real time
# - .as_agent() to use this workflow anywhere an agent is expected
#
# The function's first parameter receives the input from .run("...").
# Add a `ctx: RunContext` parameter only if you need HITL, state, or custom events.
@workflow
async def data_pipeline(url: str) -> str:
"""A simple sequential data pipeline."""
raw = await fetch_data(url)
summary = await transform_data(raw)
# This is just a function — plain Python works between calls.
# No need to wrap every operation in a separate async function.
is_valid = len(summary) > 0 and "[200]" in summary
tag = "VALID" if is_valid else "INVALID"
# Returning a value automatically emits it as an output.
# Callers retrieve it via result.get_outputs().
return f"[{tag}] {summary}"
async def main():
# .run() is provided by @workflow — a plain async function wouldn't have it
result = await data_pipeline.run("https://example.com/api/data")
print("Output:", result.get_outputs()[0])
print("State:", result.get_final_state())
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,63 @@
# Copyright (c) Microsoft. All rights reserved.
"""Basic streaming pipeline using the functional workflow API.
Stream workflow events in real time with run(stream=True).
"""
import asyncio
from agent_framework import workflow
# Plain async functions — no decorators needed for simple helpers.
async def fetch_data(url: str) -> dict[str, str | int]:
"""Simulate fetching data from a URL."""
return {"url": url, "content": f"Data from {url}", "status": 200}
async def transform_data(data: dict[str, str | int]) -> str:
"""Transform raw data into a summary string."""
return f"[{data['status']}] {data['content']}"
async def validate_result(summary: str) -> bool:
"""Validate the transformed result."""
return len(summary) > 0 and "[200]" in summary
# @workflow enables .run(stream=True), which returns a ResponseStream
# you can iterate over with `async for`. Without @workflow, you'd just
# have a normal async function with no streaming capability.
@workflow
async def data_pipeline(url: str) -> str:
"""A simple sequential data pipeline."""
raw = await fetch_data(url)
summary = await transform_data(raw)
is_valid = await validate_result(summary)
return f"{summary} (valid={is_valid})"
async def main():
# run(stream=True) returns a ResponseStream that yields events as they
# are produced. The raw stream includes lifecycle events (started, status)
# alongside application events — filter by event.type to find what you need.
stream = data_pipeline.run("https://example.com/api/data", stream=True)
async for event in stream:
if event.type == "output":
print(f"Output: {event.data}")
# After iteration, get_final_response() returns the WorkflowRunResult
result = await stream.get_final_response()
print(f"Final state: {result.get_final_state()}")
"""
Expected output:
Output: [200] Data from https://example.com/api/data (valid=True)
Final state: WorkflowRunState.IDLE
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,84 @@
# Copyright (c) Microsoft. All rights reserved.
"""Human-in-the-loop review pipeline using functional workflows.
Demonstrates ctx.request_info() for pausing the workflow to wait for
external input and resuming with run(responses={...}).
HITL works with or without @step. The difference is what happens on resume:
- Without @step: every function re-executes from the top (fine for cheap calls).
- With @step: completed functions return their saved result instantly.
This sample uses @step on write_draft() because it simulates an expensive
operation that shouldn't re-run just because the workflow was paused.
"""
import asyncio
from agent_framework import RunContext, WorkflowRunState, step, workflow
# @step saves the result. When the workflow resumes after the HITL pause,
# this returns its saved result instead of running the expensive operation again.
#
# In a real workflow you might call an agent here instead:
# @step
# async def write_draft(topic: str) -> str:
# return (await writer_agent.run(f"Write a draft about: {topic}")).text
@step
async def write_draft(topic: str) -> str:
"""Simulate writing a draft — expensive, shouldn't re-run on resume."""
print(f" write_draft executing for '{topic}'")
return f"Draft document about '{topic}': Lorem ipsum dolor sit amet..."
@step
async def revise_draft(draft: str, feedback: str) -> str:
"""Revise the draft based on feedback."""
return f"Revised: {draft[:50]}... [Applied feedback: {feedback}]"
@workflow
async def review_pipeline(topic: str, ctx: RunContext) -> str:
"""Write a draft, get human review, then revise."""
draft = await write_draft(topic)
# ctx.request_info() suspends the workflow here. The caller gets back
# a WorkflowRunResult with state IDLE_WITH_PENDING_REQUESTS and can
# inspect the pending request via result.get_request_info_events().
feedback = await ctx.request_info(
{"draft": draft, "instructions": "Please review this draft"},
response_type=str,
request_id="review_request",
)
# This only executes after the caller resumes with run(responses={...}).
# write_draft above returns its saved result (thanks to @step),
# request_info returns the provided response, and we continue here.
return await revise_draft(draft, feedback)
async def main():
# Phase 1: Run until the workflow pauses for human input
print("=== Phase 1: Initial run ===")
result1 = await review_pipeline.run("AI Safety")
# If request_info() was reached, the state is IDLE_WITH_PENDING_REQUESTS.
# If the workflow completed without hitting request_info(), it would be IDLE.
print(f"State: {(final_state := result1.get_final_state())}")
assert final_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
requests = result1.get_request_info_events()
print(f"Pending request: {requests[0].request_id}")
# Phase 2: Resume with the human's response
print("\n=== Phase 2: Resume with feedback ===")
print("(write_draft should NOT execute again — saved by @step)")
result2 = await review_pipeline.run(responses={"review_request": "Add more details about alignment research"})
print(f"State: {result2.get_final_state()}")
print(f"Output: {result2.get_outputs()[0]}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,82 @@
# Copyright (c) Microsoft. All rights reserved.
"""Naive group chat using the functional workflow API.
A simple round-robin group chat where agents take turns responding.
Because it's just a function, you control the loop, the turn order,
and the termination condition with plain Python — no framework abstractions.
Compare this with the graph-based GroupChat orchestration to see how the
functional API lets you start simple and add complexity only when needed.
"""
import asyncio
from agent_framework import Agent, Message, workflow
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
# ---------------------------------------------------------------------------
# Create agents
# ---------------------------------------------------------------------------
client = FoundryChatClient(credential=AzureCliCredential())
expert = Agent(
name="PythonExpert",
instructions=(
"You are a Python expert in a group discussion. "
"Answer questions about Python and refine your answer based on feedback. "
"Keep responses concise (2-3 sentences)."
),
client=client,
)
critic = Agent(
name="Critic",
instructions=(
"You are a constructive critic in a group discussion. "
"Point out edge cases, gotchas, or missing nuances in the previous answer. "
"If the answer is solid, say so briefly."
),
client=client,
)
summarizer = Agent(
name="Summarizer",
instructions=(
"You are a summarizer in a group discussion. "
"After the discussion, provide a final concise summary that incorporates "
"the expert's answer and the critic's feedback. Keep it to 2-3 sentences."
),
client=client,
)
# ---------------------------------------------------------------------------
# A naive group chat is just a loop — no special framework needed
# ---------------------------------------------------------------------------
@workflow
async def group_chat(question: str) -> str:
"""Round-robin group chat: expert answers, critic reviews, summarizer wraps up."""
participants = [expert, critic, summarizer]
# Passing list[Message] keeps roles/authorship intact between turns,
# instead of stringifying everything into a single prompt.
conversation: list[Message] = [Message("user", [question])]
# Simple round-robin: each agent sees the full conversation so far
for agent in participants:
response = await agent.run(conversation)
conversation.extend(response.messages)
return "\n\n".join(f"{m.author_name or m.role}: {m.text}" for m in conversation)
async def main():
result = await group_chat.run("What's the difference between a list and a tuple in Python?")
print(result.get_outputs()[0])
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,66 @@
# Copyright (c) Microsoft. All rights reserved.
"""Parallel pipeline using asyncio.gather with functional workflows.
Fan-out/fan-in uses native Python concurrency via asyncio.gather.
No @step needed — still just plain async functions.
"""
import asyncio
from agent_framework import workflow
# Plain async functions — asyncio.gather handles the concurrency,
# no framework primitives needed for parallelism.
async def research_web(topic: str) -> str:
"""Simulate web research."""
await asyncio.sleep(0.05)
return f"Web results for '{topic}': 10 articles found"
async def research_papers(topic: str) -> str:
"""Simulate academic paper search."""
await asyncio.sleep(0.05)
return f"Papers on '{topic}': 3 relevant papers"
async def research_news(topic: str) -> str:
"""Simulate news search."""
await asyncio.sleep(0.05)
return f"News about '{topic}': 5 recent articles"
async def synthesize(sources: list[str]) -> str:
"""Combine research results into a summary."""
return "Research Summary:\n" + "\n".join(f" - {s}" for s in sources)
# @workflow wraps the orchestration logic so you get .run(), streaming,
# and events. The functions it calls are plain Python — no decorators
# needed just because they're inside a workflow.
@workflow
async def research_pipeline(topic: str) -> str:
"""Fan-out to three research tasks, then synthesize results."""
# asyncio.gather runs all three concurrently — this is standard Python,
# not a framework concept. Use it the same way you would anywhere else.
#
# Tip: if any of these were wrapped with @step (e.g. an expensive agent call),
# the pattern is identical — @step composes with asyncio.gather, so each
# branch is independently cached on HITL resume or checkpoint restore.
web, papers, news = await asyncio.gather(
research_web(topic),
research_papers(topic),
research_news(topic),
)
return await synthesize([web, papers, news])
async def main():
result = await research_pipeline.run("AI agents")
print(result.get_outputs()[0])
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,97 @@
# Copyright (c) Microsoft. All rights reserved.
"""Introducing @step: per-step checkpointing and observability.
The previous samples used plain functions — and that works. Workflows support
HITL (ctx.request_info) and checkpointing regardless of whether you use @step.
The difference: without @step, a resumed workflow re-executes every function
call from the top. That's fine for cheap functions. But for expensive operations
(API calls, agent runs, etc.) you don't want to pay that cost again.
@step saves each function's result so it skips re-execution on resume:
- On HITL resume, completed steps return their saved result instantly.
- On crash recovery from a checkpoint, earlier step results are restored.
- Each step emits executor_invoked/executor_completed events for observability.
@step is opt-in. Plain functions still work alongside @step in the same workflow.
"""
import asyncio
from agent_framework import InMemoryCheckpointStorage, step, workflow
# Track call counts to show which functions actually execute on resume
fetch_calls = 0
transform_calls = 0
# @step saves this function's result. On resume, it returns the saved
# result instead of re-executing — useful because this is expensive.
@step
async def fetch_data(url: str) -> dict[str, str | int]:
"""Expensive operation — @step prevents re-execution on resume."""
global fetch_calls
fetch_calls += 1
print(f" fetch_data called (call #{fetch_calls})")
return {"url": url, "content": f"Data from {url}", "status": 200}
@step
async def transform_data(data: dict[str, str | int]) -> str:
"""Another expensive operation — @step saves the result."""
global transform_calls
transform_calls += 1
print(f" transform_data called (call #{transform_calls})")
return f"[{data['status']}] {data['content']}"
# No @step — this is cheap, so it just re-runs on resume. That's fine.
async def validate_result(summary: str) -> bool:
"""Cheap validation — no @step needed."""
return len(summary) > 0 and "[200]" in summary
storage = InMemoryCheckpointStorage()
# checkpoint_storage tells @workflow where to persist step results.
# Each @step saves a checkpoint after it completes.
@workflow(checkpoint_storage=storage)
async def data_pipeline(url: str) -> str:
"""Mix of @step functions and plain functions."""
raw = await fetch_data(url)
summary = await transform_data(raw)
is_valid = await validate_result(summary)
return f"{summary} (valid={is_valid})"
async def main():
# --- Run 1: Everything executes normally ---
print("=== Run 1: Fresh execution ===")
result = await data_pipeline.run("https://example.com/api/data")
print(f"Output: {result.get_outputs()[0]}")
print(f"fetch_calls={fetch_calls}, transform_calls={transform_calls}")
# @step functions emit executor events; plain functions don't.
print("\nEvents:")
for event in result:
if event.type in ("executor_invoked", "executor_completed"):
print(f" {event.type}: {event.executor_id}")
# --- Run 2: Restore from checkpoint ---
# The workflow re-executes, but @step functions return saved results.
# Only validate_result() (no @step) actually runs again.
print("\n=== Run 2: Restored from checkpoint ===")
latest = await storage.get_latest(workflow_name="data_pipeline")
assert latest is not None
result2 = await data_pipeline.run(checkpoint_id=latest.checkpoint_id)
print(f"Output: {result2.get_outputs()[0]}")
print(f"fetch_calls={fetch_calls}, transform_calls={transform_calls}")
print("(call counts unchanged — @step results were restored from checkpoint)")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,235 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from collections.abc import AsyncIterable
from dataclasses import dataclass, field
from agent_framework import (
Agent,
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponse,
AgentResponseUpdate,
Executor,
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
handler,
response_handler,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from typing_extensions import Never
# Load environment variables from .env file
load_dotenv()
"""
Sample: Azure AI Agents in workflow with human feedback
Pipeline layout:
writer_agent -> Coordinator -> writer_agent -> Coordinator -> final_editor_agent -> Coordinator -> output
The writer agent drafts marketing copy. A custom executor emits a request_info event (type='request_info') so a
human can comment, then relays the human guidance back into the conversation before the final editor agent
produces the polished output.
Demonstrates:
- Capturing agent responses in a custom executor.
- Emitting request_info events (type='request_info') to request human input.
- Handling human feedback and routing it to the appropriate agents.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Run `az login` before executing.
"""
@dataclass
class DraftFeedbackRequest:
"""Payload sent for human review."""
prompt: str = ""
conversation: list[Message] = field(default_factory=lambda: [])
class Coordinator(Executor):
"""Bridge between the writer agent, human feedback, and final editor."""
def __init__(self, id: str, writer_name: str, final_editor_name: str) -> None:
super().__init__(id)
self.writer_name = writer_name
self.final_editor_name = final_editor_name
@handler
async def on_writer_response(
self,
draft: AgentExecutorResponse,
ctx: WorkflowContext[Never, AgentResponse],
) -> None:
"""Handle responses from the writer and final editor agents."""
if draft.executor_id == self.final_editor_name:
# No further processing is needed when the final editor has responded.
return
# Writer agent response; request human feedback.
# Preserve the full conversation so that the final editor has context.
conversation = list(draft.full_conversation)
prompt = (
"Review the draft from the writer and provide a short directional note "
"(tone tweaks, must-have detail, target audience, etc.). "
"Keep it under 30 words."
)
await ctx.request_info(
request_data=DraftFeedbackRequest(prompt=prompt, conversation=conversation),
response_type=str,
)
@response_handler
async def on_human_feedback(
self,
original_request: DraftFeedbackRequest,
feedback: str,
ctx: WorkflowContext[AgentExecutorRequest],
) -> None:
"""Process human feedback and forward to the appropriate agent."""
note = feedback.strip()
if note.lower() == "approve":
# Human approved the draft as-is; forward it unchanged.
await ctx.send_message(
AgentExecutorRequest(
messages=original_request.conversation
+ [Message("user", contents=["The draft is approved as-is."])],
should_respond=True,
),
target_id=self.final_editor_name,
)
return
# Human provided feedback; prompt the writer to revise.
conversation: list[Message] = list(original_request.conversation)
instruction = (
"A human reviewer shared the following guidance:\n"
f"{note or 'No specific guidance provided.'}\n\n"
"Rewrite the draft from the previous assistant message into a polished final version. "
"Keep the response under 120 words and reflect any requested tone adjustments."
)
conversation.append(Message("user", contents=[instruction]))
await ctx.send_message(
AgentExecutorRequest(messages=conversation, should_respond=True),
target_id=self.writer_name,
)
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, str] | None:
"""Process events from the workflow stream to capture human feedback requests."""
# Track the last author to format streaming output.
last_author: str | None = None
requests: list[tuple[str, DraftFeedbackRequest]] = []
async for event in stream:
if event.type == "request_info" and isinstance(event.data, DraftFeedbackRequest):
requests.append((event.request_id, event.data))
elif event.type == "output" and isinstance(event.data, AgentResponseUpdate):
# This workflow should only produce AgentResponseUpdate as outputs.
# Streaming updates from an agent will be consecutive, because no two agents run simultaneously
# in this workflow. So we can use last_author to format output nicely.
update = event.data
author = update.author_name
if author != last_author:
if last_author is not None:
print() # Newline between different authors
print(f"{author}: {update.text}", end="", flush=True)
last_author = author
else:
print(update.text, end="", flush=True)
# Handle any pending human feedback requests.
if requests:
responses: dict[str, str] = {}
for request_id, _ in requests:
print("\nProvide guidance for the editor (or 'approve' to accept the draft).")
answer = input("Human feedback: ").strip() # noqa: ASYNC250
if answer.lower() == "exit":
print("Exiting...")
return None
responses[request_id] = answer
return responses
return None
async def main() -> None:
"""Run the workflow and bridge human feedback between two agents."""
# Create the agents
writer_agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="writer_agent",
instructions=("You are a marketing writer."),
default_options={
"tool_choice": "required",
},
)
final_editor_agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="final_editor_agent",
instructions=(
"You are an editor who polishes marketing copy after human approval. "
"Correct any legal or factual issues. Return the final version even if no changes are made. "
),
)
# Create the executor
coordinator = Coordinator(
id="coordinator",
writer_name=writer_agent.name, # type: ignore
final_editor_name=final_editor_agent.name, # type: ignore
)
# Build the workflow.
workflow = (
WorkflowBuilder(start_executor=writer_agent)
.add_edge(writer_agent, coordinator)
.add_edge(coordinator, writer_agent)
.add_edge(final_editor_agent, coordinator)
.add_edge(coordinator, final_editor_agent)
.build()
)
print(
"Interactive mode. When prompted, provide a short feedback note for the editor.",
flush=True,
)
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run.
stream = workflow.run(
"Create a short launch blurb for the LumenX desk lamp. Emphasize adjustability and warm lighting.",
stream=True,
)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.run(stream=True, responses=pending_responses)
pending_responses = await process_event_stream(stream)
print("\nWorkflow complete.")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,352 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import os
from dataclasses import dataclass
from typing import Annotated
from agent_framework import (
Agent,
AgentExecutorResponse,
Content,
Executor,
WorkflowBuilder,
WorkflowContext,
executor,
handler,
tool,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from typing_extensions import Never
# Load environment variables from .env file
load_dotenv()
"""
Sample: Agents in a workflow with AI functions requiring approval
This sample creates a workflow that automatically replies to incoming emails.
If historical email data is needed, it uses an AI function to read the data,
which requires human approval before execution.
This sample works as follows:
1. An incoming email is received by the workflow.
2. The EmailPreprocessor executor preprocesses the email, adding special notes if the sender is important.
3. The preprocessed email is sent to the Email Writer agent, which generates a response.
4. If the agent needs to read historical email data, it calls the read_historical_email_data AI function,
which triggers an approval request.
5. The sample automatically approves the request for demonstration purposes.
6. Once approved, the AI function executes and returns the historical email data to the agent.
7. The agent uses the historical data to compose a comprehensive email response.
8. The response is sent to the conclude_workflow_executor, which yields the final response.
Purpose:
Show how to integrate AI functions with approval requests into a workflow.
Demonstrate:
- Creating AI functions that require approval before execution.
- Building a workflow that includes an agent and executors.
- Handling approval requests during workflow execution.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, edges, events, request_info events (type='request_info'), and streaming runs.
"""
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# See:
# samples/02-agents/tools/function_tool_with_approval.py
# samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@tool(approval_mode="never_require")
def get_current_date() -> str:
"""Get the current date in YYYY-MM-DD format."""
# For demonstration purposes, we return a fixed date.
return "2025-11-07"
@tool(approval_mode="never_require")
def get_team_members_email_addresses() -> list[dict[str, str]]:
"""Get the email addresses of team members."""
# In a real implementation, this might query a database or directory service.
return [
{
"name": "Alice",
"email": "alice@contoso.com",
"position": "Software Engineer",
"manager": "John Doe",
},
{
"name": "Bob",
"email": "bob@contoso.com",
"position": "Product Manager",
"manager": "John Doe",
},
{
"name": "Charlie",
"email": "charlie@contoso.com",
"position": "Senior Software Engineer",
"manager": "John Doe",
},
{
"name": "Mike",
"email": "mike@contoso.com",
"position": "Principal Software Engineer Manager",
"manager": "VP of Engineering",
},
]
@tool(approval_mode="never_require")
def get_my_information() -> dict[str, str]:
"""Get my personal information."""
return {
"name": "John Doe",
"email": "john@contoso.com",
"position": "Software Engineer Manager",
"manager": "Mike",
}
@tool(approval_mode="always_require")
async def read_historical_email_data(
email_address: Annotated[str, "The email address to read historical data from"],
start_date: Annotated[str, "The start date in YYYY-MM-DD format"],
end_date: Annotated[str, "The end date in YYYY-MM-DD format"],
) -> list[dict[str, str]]:
"""Read historical email data for a given email address and date range."""
historical_data = {
"alice@contoso.com": [
{
"from": "alice@contoso.com",
"to": "john@contoso.com",
"date": "2025-11-05",
"subject": "Bug Bash Results",
"body": "We just completed the bug bash and found a few issues that need immediate attention.",
},
{
"from": "alice@contoso.com",
"to": "john@contoso.com",
"date": "2025-11-03",
"subject": "Code Freeze",
"body": "We are entering code freeze starting tomorrow.",
},
],
"bob@contoso.com": [
{
"from": "bob@contoso.com",
"to": "john@contoso.com",
"date": "2025-11-04",
"subject": "Team Outing",
"body": "Don't forget about the team outing this Friday!",
},
{
"from": "bob@contoso.com",
"to": "john@contoso.com",
"date": "2025-11-02",
"subject": "Requirements Update",
"body": "The requirements for the new feature have been updated. Please review them.",
},
],
"charlie@contoso.com": [
{
"from": "charlie@contoso.com",
"to": "john@contoso.com",
"date": "2025-11-05",
"subject": "Project Update",
"body": "The bug bash went well. A few critical bugs but should be fixed by the end of the week.",
},
{
"from": "charlie@contoso.com",
"to": "john@contoso.com",
"date": "2025-11-06",
"subject": "Code Review",
"body": "Please review my latest code changes.",
},
],
}
emails = historical_data.get(email_address, [])
return [email for email in emails if start_date <= email["date"] <= end_date]
@tool(approval_mode="always_require")
async def send_email(
to: Annotated[str, "The recipient email address"],
subject: Annotated[str, "The email subject"],
body: Annotated[str, "The email body"],
) -> str:
"""Send an email."""
await asyncio.sleep(1) # Simulate sending email
return "Email successfully sent."
@dataclass
class Email:
sender: str
subject: str
body: str
class EmailPreprocessor(Executor):
def __init__(self, special_email_addresses: set[str]) -> None:
super().__init__(id="email_preprocessor")
self.special_email_addresses = special_email_addresses
@handler
async def preprocess(self, email: Email, ctx: WorkflowContext[str]) -> None:
"""Preprocess the incoming email."""
email_payload = f"Incoming email:\nFrom: {email.sender}\nSubject: {email.subject}\nBody: {email.body}"
message = email_payload
if email.sender in self.special_email_addresses:
note = (
"Priority sender context: this message is business-critical. "
"If additional context is needed, use available tools to retrieve only the minimum relevant "
"prior team communication related to this request."
)
message = f"{note}\n\n{email_payload}"
await ctx.send_message(message)
@executor(id="conclude_workflow_executor")
async def conclude_workflow(
email_response: AgentExecutorResponse,
ctx: WorkflowContext[Never, str],
) -> None:
"""Conclude the workflow by yielding the final email response."""
await ctx.yield_output(email_response.agent_response.text)
async def main() -> None:
# Create agent
email_writer_agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="EmailWriter",
instructions=("You are an excellent email assistant. You respond to incoming emails."),
# tools with `approval_mode="always_require"` will trigger approval requests
tools=[
read_historical_email_data,
send_email,
get_current_date,
get_team_members_email_addresses,
get_my_information,
],
)
# Create executor
email_processor = EmailPreprocessor(special_email_addresses={"mike@contoso.com"})
# Build the workflow
workflow = (
WorkflowBuilder(start_executor=email_processor, output_from=[conclude_workflow])
.add_edge(email_processor, email_writer_agent)
.add_edge(email_writer_agent, conclude_workflow)
.build()
)
# Simulate an incoming email
incoming_email = Email(
sender="mike@contoso.com",
subject="Important: Project Update",
body="Please provide your team's status update on the project since last week.",
)
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run.
events = await workflow.run(incoming_email)
request_info_events = events.get_request_info_events()
# Run until there are no more approval requests
while request_info_events:
responses: dict[str, Content] = {}
for request_info_event in request_info_events:
# We should only expect FunctionApprovalRequestContent in this sample
data = request_info_event.data
if not isinstance(data, Content) or data.type != "function_approval_request":
raise ValueError(f"Unexpected request info content type: {type(data)}")
# To make the type checker happy, we make sure function_call is not None
if data.function_call is None:
raise ValueError("Function call information is missing in the approval request.")
# Pretty print the function call details
arguments = json.dumps(data.function_call.parse_arguments(), indent=2)
print(f"Received approval request for function: {data.function_call.name} with args:\n{arguments}")
# For demo purposes, we automatically approve the request
# The expected response type of the request is `function_approval_response Content`,
# which can be created via `to_function_approval_response` method on the request content
print("Performing automatic approval for demo purposes...")
responses[request_info_event.request_id] = data.to_function_approval_response(approved=True)
events = await workflow.run(responses=responses)
request_info_events = events.get_request_info_events()
# The output should only come from conclude_workflow executor and it's a single string
print("Final email response conversation:")
print(events.get_outputs()[0])
"""
Sample Output:
Received approval request for function: read_historical_email_data with args:
{
"email_address": "alice@contoso.com",
"start_date": "2025-10-31",
"end_date": "2025-11-07"
}
Performing automatic approval for demo purposes...
Received approval request for function: read_historical_email_data with args:
{
"email_address": "bob@contoso.com",
"start_date": "2025-10-31",
"end_date": "2025-11-07"
}
Performing automatic approval for demo purposes...
Received approval request for function: read_historical_email_data with args:
{
"email_address": "charlie@contoso.com",
"start_date": "2025-10-31",
"end_date": "2025-11-07"
}
Performing automatic approval for demo purposes...
Received approval request for function: send_email with args:
{
"to": "mike@contoso.com",
"subject": "Team's Status Update on the Project",
"body": "
Hi Mike,
Here's the status update from our team:
- **Bug Bash and Code Freeze:**
- We recently completed a bug bash, during which several issues were identified. Alice and Charlie are working on fixing these critical bugs, and we anticipate resolving them by the end of this week.
- We have entered a code freeze as of November 4, 2025.
- **Requirements Update:**
- Bob has updated the requirements for a new feature, and all team members are reviewing these changes to ensure alignment.
- **Ongoing Reviews:**
- Charlie has submitted his latest code changes for review to ensure they meet our quality standards.
Please let me know if you need more detailed information or have any questions.
Best regards,
John"
}
Performing automatic approval for demo purposes...
Final email response conversation:
I've sent the status update to Mike with the relevant information from the team. Let me know if there's anything else you need
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,107 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Sample: Declaration-only tools in a workflow (issue #3425)
A declaration-only tool (func=None) represents a client-side tool that the
framework cannot execute — the LLM can call it, but the workflow must pause
so the caller can supply the result.
Flow:
1. The agent is given a declaration-only tool ("get_user_location").
2. When the LLM decides to call it, the workflow pauses and emits a
request_info event containing the FunctionCallContent.
3. The caller inspects the tool name/args, runs the tool however it wants,
and feeds the result back via workflow.run(responses={...}).
4. The workflow resumes — the agent sees the tool result and finishes.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- Azure OpenAI endpoint configured via environment variables.
- `az login` for AzureCliCredential.
"""
import asyncio
import json
import os
from typing import Any
from agent_framework import Agent, Content, FunctionTool, WorkflowBuilder
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# A declaration-only tool: the schema is sent to the LLM, but the framework
# has no implementation to execute. The caller must supply the result.
get_user_location = FunctionTool(
name="get_user_location",
func=None,
description="Get the user's current city. Only the client application can resolve this.",
input_model={
"type": "object",
"properties": {
"reason": {"type": "string", "description": "Why the location is needed"},
},
"required": ["reason"],
},
)
async def main() -> None:
_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
agent = Agent(
client=_client,
name="WeatherBot",
instructions=(
"You are a helpful weather assistant. "
"When the user asks about weather, call get_user_location first, "
"then make up a plausible forecast for that city."
),
tools=[get_user_location],
)
workflow = WorkflowBuilder(start_executor=agent).build()
# --- First run: the agent should call the declaration-only tool ---
print(">>> Sending: 'What's the weather like today?'")
result = await workflow.run("What's the weather like today?")
requests = result.get_request_info_events()
if not requests:
# The LLM chose not to call the tool — print whatever it said and exit
print(f"Agent replied without calling the tool: {result.get_outputs()}")
return
# --- Inspect what the agent wants ---
for req in requests:
data = req.data
args = json.loads(data.arguments) if isinstance(data.arguments, str) else data.arguments
print(f"Workflow paused — agent called: {data.name}({args})")
# --- "Execute" the tool on the client side and send results back ---
responses: dict[str, Any] = {}
for req in requests:
# In a real app this could be a GPS lookup, browser API, user prompt, etc.
client_result = "Seattle, WA"
print(f"Client provides result for {req.data.name}: {client_result!r}")
responses[req.request_id] = Content.from_function_result(
call_id=req.data.call_id,
result=client_result,
)
result = await workflow.run(responses=responses)
# --- Final answer ---
for output in result.get_outputs():
print(f"\nAgent: {output.text}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,213 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Sample: Request Info with ConcurrentBuilder
This sample demonstrates using the `.with_request_info()` method to pause a
ConcurrentBuilder workflow for specific agents, allowing human review and
modification of individual agent outputs before aggregation.
Purpose:
Show how to use the request info API that pauses for selected concurrent agents,
allowing review and steering of their results.
Demonstrate:
- Configuring request info with `.with_request_info()` for specific agents
- Reviewing output from individual agents during concurrent execution
- Injecting human guidance for specific agents before aggregation
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity (run az login before executing)
"""
import asyncio
import os
from collections.abc import AsyncIterable
from typing import Any
from agent_framework import (
Agent,
AgentExecutorResponse,
Message,
WorkflowEvent,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import AgentRequestInfoResponse, ConcurrentBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Store chat client at module level for aggregator access
_chat_client: FoundryChatClient | None = None
async def aggregate_with_synthesis(results: list[AgentExecutorResponse]) -> Any:
"""Custom aggregator that synthesizes concurrent agent outputs using an LLM.
This aggregator extracts the outputs from each parallel agent and uses the
chat client to create a unified summary, incorporating any human feedback
that was injected into the conversation.
Args:
results: List of responses from all concurrent agents
Returns:
The synthesized summary text
"""
if not _chat_client:
return "Error: Chat client not initialized"
# Extract each agent's final output
expert_sections: list[str] = []
human_guidance = ""
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', 'analyst')}:\n{final_text}")
# Check for human feedback in the conversation (will be last user message if present)
if r.full_conversation:
for msg in reversed(r.full_conversation):
if msg.role == "user" and msg.text and "perspectives" not in msg.text.lower():
human_guidance = msg.text
break
except Exception:
expert_sections.append(f"{getattr(r, 'executor_id', 'analyst')}: (error extracting output)")
# Build prompt with human guidance if provided
guidance_text = f"\n\nHuman guidance: {human_guidance}" if human_guidance else ""
system_msg = Message(
"system",
contents=[
(
"You are a synthesis expert. Consolidate the following analyst perspectives "
"into one cohesive, balanced summary (3-4 sentences). If human guidance is provided, "
"prioritize aspects as directed."
)
],
)
user_msg = Message("user", contents=["\n\n".join(expert_sections) + guidance_text])
response = await _chat_client.get_response([system_msg, user_msg])
return response.messages[-1].text if response.messages else ""
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, AgentRequestInfoResponse] | None:
"""Process events from the workflow stream to capture human feedback requests."""
requests: dict[str, AgentExecutorResponse] = {}
async for event in stream:
if event.type == "request_info" and isinstance(event.data, AgentExecutorResponse):
requests[event.request_id] = event.data
if event.type == "output":
# The output of the workflow comes from the aggregator and it's a single string
print("\n" + "=" * 60)
print("ANALYSIS COMPLETE")
print("=" * 60)
print("Final synthesized analysis:")
print(event.data)
# Process any requests for human feedback
responses: dict[str, AgentRequestInfoResponse] = {}
if requests:
for request_id, request in requests.items():
print("\n" + "-" * 40)
print("INPUT REQUESTED")
print(
f"Agent {request.executor_id} just responded with: '{request.agent_response.text}'. "
"Please provide your feedback."
)
print("-" * 40)
if request.full_conversation:
print("Conversation context:")
recent = (
request.full_conversation[-2:] if len(request.full_conversation) > 2 else request.full_conversation
)
for msg in recent:
name = msg.author_name or msg.role
text = (msg.text or "")[:150]
print(f" [{name}]: {text}...")
print("-" * 40)
# Get human input to steer this agent's contribution
user_input = input("Your guidance for the analysts (or 'skip' to approve): ") # noqa: ASYNC250
if user_input.lower() == "skip":
user_input = AgentRequestInfoResponse.approve()
else:
user_input = AgentRequestInfoResponse.from_strings([user_input])
responses[request_id] = user_input
return responses if responses else None
async def main() -> None:
global _chat_client
_chat_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create agents that analyze from different perspectives
technical_analyst = Agent(
client=_chat_client,
name="technical_analyst",
instructions=(
"You are a technical analyst. When given a topic, provide a technical "
"perspective focusing on implementation details, performance, and architecture. "
"Keep your analysis to 2-3 sentences."
),
)
business_analyst = Agent(
client=_chat_client,
name="business_analyst",
instructions=(
"You are a business analyst. When given a topic, provide a business "
"perspective focusing on ROI, market impact, and strategic value. "
"Keep your analysis to 2-3 sentences."
),
)
user_experience_analyst = Agent(
client=_chat_client,
name="ux_analyst",
instructions=(
"You are a UX analyst. When given a topic, provide a user experience "
"perspective focusing on usability, accessibility, and user satisfaction. "
"Keep your analysis to 2-3 sentences."
),
)
# Build workflow with request info enabled and custom aggregator
workflow = (
ConcurrentBuilder(participants=[technical_analyst, business_analyst, user_experience_analyst])
.with_aggregator(aggregate_with_synthesis)
# Only enable request info for the technical analyst agent
.with_request_info(agents=["technical_analyst"])
.build()
)
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run.
stream = workflow.run("Analyze the impact of large language models on software development.", stream=True)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.run(stream=True, responses=pending_responses)
pending_responses = await process_event_stream(stream)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,182 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Sample: Request Info with GroupChatBuilder
This sample demonstrates using the `.with_request_info()` method to pause a
GroupChatBuilder workflow BEFORE specific participants speak. By using the
`agents=` filter parameter, you can target only certain participants rather
than pausing before every turn.
Purpose:
Show how to use the request info API with selective filtering to pause before
specific participants speak, allowing human input to steer their response.
Demonstrate:
- Configuring request info with `.with_request_info(agents=[...])`
- Using agent filtering to reduce interruptions
- Steering agent behavior with pre-agent human input
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity (run az login before executing)
"""
import asyncio
import os
from collections.abc import AsyncIterable
from typing import cast
from agent_framework import (
Agent,
AgentExecutorResponse,
Message,
WorkflowEvent,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import AgentRequestInfoResponse, GroupChatBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, AgentRequestInfoResponse] | None:
"""Process events from the workflow stream to capture human feedback requests."""
requests: dict[str, AgentExecutorResponse] = {}
async for event in stream:
if event.type == "request_info" and isinstance(event.data, AgentExecutorResponse):
requests[event.request_id] = event.data
if event.type == "output":
# The output of the workflow comes from the orchestrator and it's a list of messages
print("\n" + "=" * 60)
print("DISCUSSION COMPLETE")
print("=" * 60)
print("Final discussion summary:")
# To make the type checker happy, we cast event.data to the expected type
outputs = cast(list[Message], event.data)
for msg in outputs:
speaker = msg.author_name or msg.role
print(f"[{speaker}]: {msg.text}")
responses: dict[str, AgentRequestInfoResponse] = {}
if requests:
for request_id, request in requests.items():
# Display pre-agent context for human input
print("\n" + "-" * 40)
print("INPUT REQUESTED")
print(
f"Agent {request.executor_id} just responded with: '{request.agent_response.text}'. "
"Please provide your feedback."
)
print("-" * 40)
if request.full_conversation:
print("Conversation context:")
recent = (
request.full_conversation[-2:] if len(request.full_conversation) > 2 else request.full_conversation
)
for msg in recent:
name = msg.author_name or msg.role
text = (msg.text or "")[:150]
print(f" [{name}]: {text}...")
print("-" * 40)
# Get human input to steer the agent
user_input = input(f"Feedback for {request.executor_id} (or 'skip' to approve): ") # noqa: ASYNC250
if user_input.lower() == "skip":
user_input = AgentRequestInfoResponse.approve()
else:
user_input = AgentRequestInfoResponse.from_strings([user_input])
responses[request_id] = user_input
return responses if responses else None
async def main() -> None:
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create agents for a group discussion
optimist = Agent(
client=client,
name="optimist",
instructions=(
"You are an optimistic team member. You see opportunities and potential "
"in ideas. Engage constructively with the discussion, building on others' "
"points while maintaining a positive outlook. Keep responses to 2-3 sentences."
),
)
pragmatist = Agent(
client=client,
name="pragmatist",
instructions=(
"You are a pragmatic team member. You focus on practical implementation "
"and realistic timelines. Sometimes you disagree with overly optimistic views. "
"Keep responses to 2-3 sentences."
),
)
creative = Agent(
client=client,
name="creative",
instructions=(
"You are a creative team member. You propose innovative solutions and "
"think outside the box. You may suggest alternatives to conventional approaches. "
"Keep responses to 2-3 sentences."
),
)
# Orchestrator coordinates the discussion
orchestrator = Agent(
client=client,
name="orchestrator",
instructions=(
"You are a discussion manager coordinating a team conversation between participants. "
"Your job is to select who speaks next.\n\n"
"RULES:\n"
"1. Rotate through ALL participants - do not favor any single participant\n"
"2. Each participant should speak at least once before any participant speaks twice\n"
"3. Continue for at least 5 rounds before ending the discussion\n"
"4. Do NOT select the same participant twice in a row"
),
)
# Build workflow with request info enabled
# Using agents= filter to only pause before pragmatist speaks (not every turn)
# max_rounds=6: Limit to 6 rounds
workflow = (
GroupChatBuilder(
participants=[optimist, pragmatist, creative],
max_rounds=6,
orchestrator_agent=orchestrator,
)
.with_request_info(agents=[pragmatist]) # Only pause before pragmatist speaks
.build()
)
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run.
stream = workflow.run(
"Discuss how our team should approach adopting AI tools for productivity. "
"Consider benefits, risks, and implementation strategies.",
stream=True,
)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.run(stream=True, responses=pending_responses)
pending_responses = await process_event_stream(stream)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,254 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from collections.abc import AsyncIterable
from dataclasses import dataclass
from typing import Any
from agent_framework import (
Agent,
AgentExecutorRequest,
AgentExecutorResponse,
AgentResponseUpdate,
Executor,
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
handler,
response_handler,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.openai import OpenAIChatOptions
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import BaseModel
# Load environment variables from .env file
load_dotenv()
"""
Sample: Human in the loop guessing game
An agent guesses a number, then a human guides it with higher, lower, or
correct. The loop continues until the human confirms 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 by using
`request_info` and `run(responses=..., stream=True)`.
Demonstrate:
- Alternating turns between an AgentExecutor and a human, driven by events.
- Using Pydantic response_format to enforce structured JSON output from the agent instead of regex parsing.
- Driving the loop in application code with run and responses parameter.
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Basic familiarity with WorkflowBuilder, executors, edges, events, and streaming runs.
"""
# How human-in-the-loop is achieved via `request_info` and `run(responses=..., stream=True)`:
# - An executor (TurnManager) calls `ctx.request_info` with a payload (HumanFeedbackRequest).
# - The workflow run pauses and emits a with the payload and the request_id.
# - The application captures the event, prompts the user, and collects replies.
# - The application calls `run(stream=True, responses=...)` with a map of request_ids to replies.
# - The workflow resumes, and the response is delivered to the executor method decorated with @response_handler.
# - The executor can then continue the workflow, e.g., by sending a new message to the agent.
@dataclass
class HumanFeedbackRequest:
"""Request sent to the human for feedback on the agent's guess."""
prompt: str
class GuessOutput(BaseModel):
"""Structured output from the agent. Enforced via response_format for reliable parsing."""
guess: int
class TurnManager(Executor):
"""Coordinates turns between the agent and the human.
Responsibilities:
- Kick off the first agent turn.
- After each agent reply, request human feedback with a HumanFeedbackRequest.
- After each human reply, either finish the game or prompt the agent again with feedback.
"""
def __init__(self, id: str | None = None):
super().__init__(id=id or "turn_manager")
@handler
async def start(self, _: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None:
"""Start the game by asking the agent for an initial guess.
Contract:
- Input is a simple starter token (ignored here).
- Output is an AgentExecutorRequest that triggers the agent to produce a guess.
"""
user = Message("user", contents=["Start by making your first guess."])
await ctx.send_message(AgentExecutorRequest(messages=[user], should_respond=True))
@handler
async def on_agent_response(
self,
result: AgentExecutorResponse,
ctx: WorkflowContext,
) -> None:
"""Handle the agent's guess and request human guidance.
Steps:
1) Use .value to access the parsed structured output directly.
2) Request info with a HumanFeedbackRequest as the payload.
"""
# Access the parsed structured model output via .value.
# Since the agent is configured with response_format=GuessOutput,
# .value returns the parsed GuessOutput instance directly.
agent_value = result.agent_response.value
if agent_value is None:
raise RuntimeError(
"AgentResponse.value is None. Ensure that the agent is invoked with "
"options={'response_format': GuessOutput} so structured output is available."
)
last_guess = agent_value.guess
# Craft a precise human prompt that defines higher and lower relative to the agent's guess.
prompt = (
f"The agent guessed: {last_guess}. "
"Type one of: higher (your number is higher than this guess), "
"lower (your number is lower than this guess), correct, or exit."
)
# Send a request with a prompt as the payload and expect a string reply.
await ctx.request_info(
request_data=HumanFeedbackRequest(prompt=prompt),
response_type=str,
)
@response_handler
async def on_human_feedback(
self,
original_request: HumanFeedbackRequest,
feedback: str,
ctx: WorkflowContext[AgentExecutorRequest, str],
) -> None:
"""Continue the game or finish based on human feedback."""
reply = feedback.strip().lower()
if reply == "correct":
await ctx.yield_output("Guessed correctly!")
return
# Provide feedback to the agent to try again.
# response_format=GuessOutput on the agent ensures JSON output, so we just need to guide the logic.
last_guess = original_request.prompt.split(": ")[1].split(".")[0]
feedback_text = (
f"Feedback: {reply}. Your last guess was {last_guess}. "
f"Use this feedback to adjust and make your next guess (1-10)."
)
user_msg = Message("user", contents=[feedback_text])
await ctx.send_message(AgentExecutorRequest(messages=[user_msg], should_respond=True))
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, str] | None:
"""Process events from the workflow stream to capture human feedback requests."""
# Track the last author to format streaming output.
last_response_id: str | None = None
requests: list[tuple[str, HumanFeedbackRequest]] = []
async for event in stream:
if event.type == "request_info" and isinstance(event.data, HumanFeedbackRequest):
requests.append((event.request_id, event.data))
elif event.type == "output":
if isinstance(event.data, AgentResponseUpdate):
update = event.data
response_id = update.response_id
if response_id != last_response_id:
if last_response_id is not None:
print() # Newline between different responses
print(f"{update.author_name}: {update.text}", end="", flush=True)
last_response_id = response_id
else:
print(update.text, end="", flush=True)
else:
print(f"\n{event.executor_id}: {event.data}")
# Handle any pending human feedback requests.
if requests:
responses: dict[str, str] = {}
for request_id, request in requests:
print(f"\nHITL: {request.prompt}")
# Instructional print already appears above. The input line below is the user entry point.
# If desired, you can add more guidance here, but keep it concise.
answer = input("Enter higher/lower/correct/exit: ").lower() # noqa: ASYNC250
if answer == "exit":
print("Exiting...")
return None
responses[request_id] = answer
return responses
return None
async def main() -> None:
"""Run the human-in-the-loop guessing game workflow."""
# Create agent and executor
guessing_agent = Agent(
client=FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
),
name="GuessingAgent",
instructions=(
"You guess a number between 1 and 10. "
"If the user says 'higher' or 'lower', adjust your next guess. "
'You MUST return ONLY a JSON object exactly matching this schema: {"guess": <integer 1..10>}. '
"No explanations or additional text."
),
# response_format enforces that the model produces JSON compatible with GuessOutput.
default_options=OpenAIChatOptions[Any](response_format=GuessOutput),
)
turn_manager = TurnManager(id="turn_manager")
# Build a simple loop: TurnManager <-> AgentExecutor.
workflow = (
WorkflowBuilder(start_executor=turn_manager)
.add_edge(turn_manager, guessing_agent) # Ask agent to make/adjust a guess
.add_edge(guessing_agent, turn_manager) # Agent's response comes back to coordinator
).build()
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run.
stream = workflow.run("start", stream=True)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.run(stream=True, responses=pending_responses)
pending_responses = await process_event_stream(stream)
"""
Sample Output:
HITL> The agent guessed: 5. 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: higher
HITL> The agent guessed: 8. 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: higher
HITL> The agent guessed: 10. 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: 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
Workflow output: Guessed correctly: 9
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,149 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Sample: Request Info with SequentialBuilder
This sample demonstrates using the `.with_request_info()` method to pause a
SequentialBuilder workflow AFTER each agent runs, allowing external input
(e.g., human feedback) for review and optional iteration.
Purpose:
Show how to use the request info API that pauses after every agent response,
using the standard request_info pattern for consistency.
Demonstrate:
- Configuring request info with `.with_request_info()`
- Handling request_info events with AgentInputRequest data
- Injecting responses back into the workflow via run(responses=..., stream=True)
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity (run az login before executing)
"""
import asyncio
import os
from collections.abc import AsyncIterable
from typing import cast
from agent_framework import (
Agent,
AgentExecutorResponse,
Message,
WorkflowEvent,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import AgentRequestInfoResponse, SequentialBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str, AgentRequestInfoResponse] | None:
"""Process events from the workflow stream to capture human feedback requests."""
requests: dict[str, AgentExecutorResponse] = {}
async for event in stream:
if event.type == "request_info" and isinstance(event.data, AgentExecutorResponse):
requests[event.request_id] = event.data
elif event.type == "output":
# The output of the sequential workflow is a list of ChatMessages
print("\n" + "=" * 60)
print("WORKFLOW COMPLETE")
print("=" * 60)
print("Final output:")
outputs = cast(list[Message], event.data)
for message in outputs:
print(f"[{message.author_name or message.role}]: {message.text}")
responses: dict[str, AgentRequestInfoResponse] = {}
if requests:
for request_id, request in requests.items():
# Display agent response and conversation context for review
print("\n" + "-" * 40)
print("REQUEST INFO: INPUT REQUESTED")
print(
f"Agent {request.executor_id} just responded with: '{request.agent_response.text}'. "
"Please provide your feedback."
)
print("-" * 40)
if request.full_conversation:
print("Conversation context:")
recent = (
request.full_conversation[-2:] if len(request.full_conversation) > 2 else request.full_conversation
)
for msg in recent:
name = msg.author_name or msg.role
text = (msg.text or "")[:150]
print(f" [{name}]: {text}...")
print("-" * 40)
# Get feedback on the agent's response (approve or request iteration)
user_input = input("Your guidance (or 'skip' to approve): ") # noqa: ASYNC250
if user_input.lower() == "skip":
user_input = AgentRequestInfoResponse.approve()
else:
user_input = AgentRequestInfoResponse.from_strings([user_input])
responses[request_id] = user_input
return responses if responses else None
async def main() -> None:
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Create agents for a sequential document review workflow
drafter = Agent(
client=client,
name="drafter",
instructions=("You are a document drafter. When given a topic, create a brief draft (2-3 sentences)."),
)
editor = Agent(
client=client,
name="editor",
instructions=(
"You are an editor. Review the draft and make improvements. "
"Incorporate any human feedback that was provided."
),
)
finalizer = Agent(
client=client,
name="finalizer",
instructions=(
"You are a finalizer. Take the edited content and create a polished final version. "
"Incorporate any additional feedback provided."
),
)
# Build workflow with request info enabled (pauses after each agent responds)
workflow = (
SequentialBuilder(participants=[drafter, editor, finalizer])
# Only enable request info for the editor agent
.with_request_info(agents=["editor"])
.build()
)
# Initiate the first run of the workflow.
# Runs are not isolated; state is preserved across multiple calls to run.
stream = workflow.run("Write a brief introduction to artificial intelligence.", stream=True)
pending_responses = await process_event_stream(stream)
while pending_responses is not None:
# Run the workflow until there is no more human feedback to provide,
# in which case this workflow completes.
stream = workflow.run(stream=True, responses=pending_responses)
pending_responses = await process_event_stream(stream)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,121 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any, cast
from agent_framework import (
Executor,
WorkflowBuilder,
WorkflowContext,
handler,
)
from typing_extensions import Never
"""
Executor I/O Observation
This sample demonstrates how to observe executor input and output data without modifying
executor code. This is useful for debugging, logging, or building monitoring tools.
What this example shows:
- executor_invoked events (type='executor_invoked') contain the input message in event.data
- executor_completed events (type='executor_completed') contain the messages sent via ctx.send_message() in event.data
- How to generically observe all executor I/O through workflow streaming events
Prerequisites:
- No external services required.
"""
class UpperCaseExecutor(Executor):
"""Convert input text to uppercase and forward to next executor."""
def __init__(self, id: str = "upper_case"):
super().__init__(id=id)
@handler
async def handle(self, text: str, ctx: WorkflowContext[str]) -> None:
result = text.upper()
await ctx.send_message(result)
class ReverseTextExecutor(Executor):
"""Reverse the input text and yield as workflow output."""
def __init__(self, id: str = "reverse_text"):
super().__init__(id=id)
@handler
async def handle(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
result = text[::-1]
await ctx.yield_output(result)
def format_io_data(data: Any) -> str:
"""Format executor I/O data for display.
This helper formats common data types for readable output.
Customize based on the types used in your workflow.
"""
type_name = type(data).__name__
if data is None:
return "None"
if isinstance(data, str):
preview = data[:80] + "..." if len(data) > 80 else data
return f"{type_name}: '{preview}'"
if isinstance(data, list):
data_list = cast(list[Any], data)
if len(data_list) == 0:
return f"{type_name}: []"
# For sent_messages, show each item with its type
if len(data_list) <= 3:
items = [format_io_data(item) for item in data_list]
return f"{type_name}: [{', '.join(items)}]"
return f"{type_name}: [{len(data_list)} items]"
return f"{type_name}: {repr(data)}"
async def main() -> None:
"""Build a workflow and observe executor I/O through streaming events."""
upper_case = UpperCaseExecutor()
reverse_text = ReverseTextExecutor()
workflow = WorkflowBuilder(start_executor=upper_case).add_edge(upper_case, reverse_text).build()
print("Running workflow with executor I/O observation...\n")
async for event in workflow.run("hello world", stream=True):
if event.type == "executor_invoked":
# The input message received by the executor is in event.data
print(f"[INVOKED] {event.executor_id}")
print(f" Input: {format_io_data(event.data)}")
elif event.type == "executor_completed":
# Messages sent via ctx.send_message() are in event.data
print(f"[COMPLETED] {event.executor_id}")
if event.data:
print(f" Output: {format_io_data(event.data)}")
elif event.type == "output":
print(f"[WORKFLOW OUTPUT] {format_io_data(event.data)}")
"""
Sample Output:
Running workflow with executor I/O observation...
[INVOKED] upper_case
Input: str: 'hello world'
[COMPLETED] upper_case
Output: list: [str: 'HELLO WORLD']
[INVOKED] reverse_text
Input: str: 'HELLO WORLD'
[WORKFLOW OUTPUT] str: 'DLROW OLLEH'
[COMPLETED] reverse_text
Output: list: [str: 'DLROW OLLEH']
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,145 @@
# Orchestration Getting Started Samples
## Installation
The orchestrations package is included when you install `agent-framework` (which pulls in all optional packages):
```bash
pip install agent-framework
```
Or install the orchestrations package directly:
```bash
pip install agent-framework-orchestrations
```
Orchestration builders are available via the `agent_framework.orchestrations` submodule:
```python
from agent_framework.orchestrations import (
SequentialBuilder,
ConcurrentBuilder,
HandoffBuilder,
GroupChatBuilder,
MagenticBuilder,
)
```
## Samples Overview (by directory)
### concurrent
| Sample | File | Concepts |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Concurrent Orchestration (Default Aggregator) | [concurrent_agents.py](./concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined Messages |
| Concurrent Orchestration (Custom Aggregator) | [concurrent_custom_aggregator.py](./concurrent_custom_aggregator.py) | Override aggregator via callback; summarize results with an LLM |
| Concurrent Orchestration (Custom Agent Executors) | [concurrent_custom_agent_executors.py](./concurrent_custom_agent_executors.py) | Child executors own Agents; concurrent fan-out/fan-in via ConcurrentBuilder |
| Concurrent Orchestration as Agent | [concurrent_workflow_as_agent.py](../agents/concurrent_workflow_as_agent.py) | Build a ConcurrentBuilder workflow and expose it as an agent via `workflow.as_agent(...)` |
| Tool Approval with ConcurrentBuilder | [concurrent_builder_tool_approval.py](../tool-approval/concurrent_builder_tool_approval.py) | Require human approval for sensitive tools across concurrent participants |
| ConcurrentBuilder Request Info | [concurrent_request_info.py](../human-in-the-loop/concurrent_request_info.py) | Review concurrent agent outputs before aggregation using `.with_request_info()` |
### sequential
| Sample | File | Concepts |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Sequential Orchestration (Agents) | [sequential_agents.py](./sequential_agents.py) | Chain agents sequentially with shared conversation context |
| Sequential Orchestration (Custom Executor) | [sequential_custom_executors.py](./sequential_custom_executors.py) | Mix agents with a summarizer that appends a compact summary |
| Sequential Orchestration as Agent | [sequential_workflow_as_agent.py](../agents/sequential_workflow_as_agent.py) | Build a SequentialBuilder workflow and expose it as an agent via `workflow.as_agent(...)` |
| Tool Approval with SequentialBuilder | [sequential_builder_tool_approval.py](../tool-approval/sequential_builder_tool_approval.py) | Require human approval for sensitive tools in SequentialBuilder workflows |
| SequentialBuilder Request Info | [sequential_request_info.py](../human-in-the-loop/sequential_request_info.py) | Request info for agent responses mid-orchestration using `.with_request_info()` |
### group-chat
| Sample | File | Concepts |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| Group Chat with Agent Manager | [group_chat_agent_manager.py](./group_chat_agent_manager.py) | Agent-based manager using `with_orchestrator(agent=)` to select next speaker |
| Group Chat Philosophical Debate | [group_chat_philosophical_debate.py](./group_chat_philosophical_debate.py) | Agent manager moderates long-form, multi-round debate across diverse participants |
| Group Chat with Simple Selector | [group_chat_simple_selector.py](./group_chat_simple_selector.py) | Group chat with a simple function selector for next speaker |
| Group Chat Orchestration as Agent | [group_chat_workflow_as_agent.py](../agents/group_chat_workflow_as_agent.py) | Build a GroupChatBuilder workflow and wrap it as an agent for composition |
| Tool Approval with GroupChatBuilder | [group_chat_builder_tool_approval.py](../tool-approval/group_chat_builder_tool_approval.py) | Require human approval for sensitive tools in group chat orchestration |
| GroupChatBuilder Request Info | [group_chat_request_info.py](../human-in-the-loop/group_chat_request_info.py) | Steer group discussions with periodic guidance using `.with_request_info()` |
### handoff
| Sample | File | Concepts |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Handoff (Simple) | [handoff_simple.py](./handoff_simple.py) | Single-tier routing: triage agent routes to specialists, control returns to user after each specialist response |
| Handoff (Autonomous) | [handoff_autonomous.py](./handoff_autonomous.py) | Autonomous mode: specialists iterate independently until invoking a handoff tool using `.with_autonomous_mode()` |
| Handoff with Code Interpreter | [handoff_with_code_interpreter_file.py](./handoff_with_code_interpreter_file.py) | Retrieve file IDs from code interpreter output in handoff workflow |
| Handoff with Tool Approval + Checkpoint | [handoff_with_tool_approval_checkpoint_resume.py](./handoff_with_tool_approval_checkpoint_resume.py) | Capture tool-approval decisions in checkpoints and resume from persisted state |
| Handoff Orchestration as Agent | [handoff_workflow_as_agent.py](../agents/handoff_workflow_as_agent.py) | Build a HandoffBuilder workflow and expose it as an agent, including HITL request/response flow |
### magentic
| Sample | File | Concepts |
| ---------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
| Magentic Workflow | [magentic.py](./magentic.py) | Orchestrate multiple agents with a Magentic manager and streaming |
| Magentic + Human Plan Review | [magentic_human_plan_review.py](./magentic_human_plan_review.py) | Human reviews or updates the plan before execution |
| Magentic + Checkpoint Resume | [magentic_checkpoint.py](./magentic_checkpoint.py) | Resume Magentic orchestration from saved checkpoints |
| Magentic Orchestration as Agent | [magentic_workflow_as_agent.py](../agents/magentic_workflow_as_agent.py) | Build a MagenticBuilder workflow and reuse it as an agent |
## Tips
**Participant output selection**: Orchestration builders use participant-oriented names for Workflow Output selection.
Use `output_from=[...]` when participant responses should be Workflow Output (`type='output'` events), and
`intermediate_output_from=[...]` when participant responses should be Intermediate Output (`type='intermediate'`
events). `output_from` is an allow-list for Workflow Output, not a routing rule for every other participant output.
Unselected participant responses are hidden unless `intermediate_output_from` selects them.
| Selection | Workflow Output | Intermediate Output | Hidden payloads |
| --- | --- | --- | --- |
| Omit both selections | Builder default Workflow Output contract | None | Builder-specific non-output participant payloads |
| `output_from="all"` | Every output-capable participant | None | None |
| `output_from=[writer]` | Only `writer` | None | All other participant payloads |
| `output_from=[writer], intermediate_output_from="all_other"` | Only `writer` | Every output-capable participant not selected by `output_from` | None |
| `intermediate_output_from="all_other"` | None, except builder-internal default output executors where applicable | Every output-capable participant | Builder-internal plumbing payloads |
| `output_from=[], intermediate_output_from="all_other"` | None, except builder-internal default output executors where applicable | Every output-capable participant | Builder-internal plumbing payloads |
| `output_from=[writer], intermediate_output_from=[researcher, reviewer]` | Only `writer` | `researcher` and `reviewer` | Any other participant payloads |
Invalid selections fail at construction or build time:
| Invalid selection | Why it fails |
| --- | --- |
| `output_from="all_other"` | `"all_other"` is only valid for `intermediate_output_from` |
| `intermediate_output_from="all"` | `"all"` is only valid for `output_from` |
| The same participant in both selections | One payload cannot be both Workflow Output and Intermediate Output |
| Duplicate participant selections | Duplicates are treated as configuration errors |
| Unknown participant selections | Typos and missing participants are rejected |
| `output_from=[], intermediate_output_from=[]` | Both explicit selections are empty |
By default, Sequential keeps the last participant as Workflow Output. Concurrent, GroupChat, and Magentic keep their
synthetic aggregator/orchestrator/manager executors as Workflow Output, while participant responses stay hidden unless
selected. Handoff keeps participants as Workflow Output by default.
When an orchestration workflow is exposed via `workflow.as_agent()`, Workflow Output becomes normal text content in
the `AgentResponse`; Intermediate Output becomes `text_reasoning` content. This preserves `.text` while making
selected progress available for callers that inspect message contents.
**Magentic checkpointing tip**: Treat `MagenticBuilder.participants` keys as stable identifiers. When resuming from a checkpoint, the rebuilt workflow must reuse the same participant names; otherwise the checkpoint cannot be applied and the run will fail fast.
**Handoff workflow tip**: Handoff workflows maintain the full conversation history including any `Message.additional_properties` emitted by your agents. This ensures routing metadata remains intact across all agent transitions. For specialist-to-specialist handoffs, use `.add_handoff(source, targets)` to configure which agents can route to which others with a fluent, type-safe API.
**Handoff `require_per_service_call_history_persistence`**: All agents in a handoff workflow **must** set `require_per_service_call_history_persistence=True`. `HandoffBuilder.build()` will raise a `ValueError` if any participant is missing this flag. This is required because handoff middleware short-circuits tool calls via `MiddlewareTermination`, and without per-service-call history persistence, local history would store tool results the service never received, causing mismatches on subsequent turns.
**Sequential orchestration note**: Sequential orchestration uses a few small adapter nodes for plumbing:
- `input-conversation` normalizes input to `list[Message]`
- `to-conversation:<participant>` converts agent responses into the shared conversation
- `complete` publishes the Workflow Output event (`type='output'`)
These may appear in event streams (executor_invoked/executor_completed). They're analogous to concurrent's dispatcher and aggregator and can be ignored if you only care about agent activity.
## Why FoundryChatClient?
Orchestration samples use `FoundryChatClient` because they create agents locally and do not require
server-side lifecycle management. `FoundryChatClient` is a lightweight, project-backed client that fits
patterns like Sequential, Concurrent, Handoff, GroupChat, and Magentic.
## Environment Variables
Orchestration samples that use `FoundryChatClient` expect:
- `FOUNDRY_PROJECT_ENDPOINT` (Azure AI Foundry Agent Service (V2) project endpoint)
- `FOUNDRY_MODEL` (model deployment name)
These values are passed directly into the client constructor via `os.getenv()` in sample code.
@@ -0,0 +1,144 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import Any
from agent_framework import Agent, Message
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import ConcurrentBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
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 yields output containing
a list[Message] 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
- Workflow completion when idle with no pending work
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
- Familiarity with Workflow events (WorkflowEvent)
"""
async def main() -> None:
# 1) Create three domain agents using FoundryChatClient
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
researcher = Agent(
client=client,
instructions=(
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
" opportunities, and risks."
),
name="researcher",
)
marketer = Agent(
client=client,
instructions=(
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
" aligned to the prompt."
),
name="marketer",
)
legal = Agent(
client=client,
instructions=(
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
" based on the prompt."
),
name="legal",
)
# 2) Build a concurrent workflow
# Participants are either Agents (type of SupportsAgentRun) or Executors
workflow = ConcurrentBuilder(participants=[researcher, marketer, legal]).build()
# 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 outputs:
print("===== Final Aggregated Conversation (messages) =====")
for output in outputs:
messages: list[Message] | 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:
===== Final Aggregated Conversation (messages) =====
------------------------------------------------------------
01 [user]:
We are launching a new budget-friendly electric bike for urban commuters.
------------------------------------------------------------
02 [researcher]:
**Insights:**
- **Target Demographic:** Urban commuters seeking affordable, eco-friendly transport;
likely to include students, young professionals, and price-sensitive urban residents.
- **Market Trends:** E-bike sales are growing globally, with increasing urbanization,
higher fuel costs, and sustainability concerns driving adoption.
- **Competitive Landscape:** Key competitors include brands like Rad Power Bikes, Aventon,
Lectric, and domestic budget-focused manufacturers in North America, Europe, and Asia.
- **Feature Expectations:** Customers expect reliability, ease-of-use, theft protection,
lightweight design, sufficient battery range for daily city commutes (typically 25-40 miles),
and low-maintenance components.
**Opportunities:**
- **First-time Buyers:** Capture newcomers to e-biking by emphasizing affordability, ease of
operation, and cost savings vs. public transit/car ownership.
...
------------------------------------------------------------
03 [marketer]:
**Value Proposition:**
"Empowering your city commute: Our new electric bike combines affordability, reliability, and
sustainable design—helping you conquer urban journeys without breaking the bank."
**Target Messaging:**
*For Young Professionals:*
...
------------------------------------------------------------
04 [legal]:
**Constraints, Disclaimers, & Policy Concerns for Launching a Budget-Friendly Electric Bike for Urban Commuters:**
**1. Regulatory Compliance**
- Verify that the electric bike meets all applicable federal, state, and local regulations
regarding e-bike classification, speed limits, power output, and safety features.
- Ensure necessary certifications (e.g., UL certification for batteries, CE markings if sold internationally) are obtained.
**2. Product Safety**
- Include consumer safety warnings regarding use, battery handling, charging protocols, and age restrictions.
...
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,188 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import Any
from agent_framework import (
Agent,
AgentExecutorRequest,
AgentExecutorResponse,
Executor,
Message,
WorkflowContext,
handler,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import ConcurrentBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Concurrent Orchestration with Custom Agent Executors
This sample shows a concurrent fan-out/fan-in pattern using child Executor classes
that each own their Agent. The executors accept AgentExecutorRequest inputs
and emit AgentExecutorResponse outputs, which allows reuse of the high-level
ConcurrentBuilder API and the default aggregator.
Demonstrates:
- Executors that create their Agent in __init__ (via FoundryChatClient)
- A @handler that converts AgentExecutorRequest -> AgentExecutorResponse
- ConcurrentBuilder(participants=[...]) to build fan-out/fan-in
- Default aggregator returning list[Message] (one user + one assistant per agent)
- Workflow completion when all participants become idle
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
"""
class ResearcherExec(Executor):
agent: Agent
def __init__(self, client: FoundryChatClient, id: str = "researcher"):
self.agent = Agent(
client=client,
instructions=(
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
" opportunities, and risks."
),
name=id,
)
super().__init__(id=id)
@handler
async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
response = await self.agent.run(request.messages)
full_conversation = list(request.messages) + list(response.messages)
await ctx.send_message(AgentExecutorResponse(self.id, response, full_conversation=full_conversation))
class MarketerExec(Executor):
agent: Agent
def __init__(self, client: FoundryChatClient, id: str = "marketer"):
self.agent = Agent(
client=client,
instructions=(
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
" aligned to the prompt."
),
name=id,
)
super().__init__(id=id)
@handler
async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
response = await self.agent.run(request.messages)
full_conversation = list(request.messages) + list(response.messages)
await ctx.send_message(AgentExecutorResponse(self.id, response, full_conversation=full_conversation))
class LegalExec(Executor):
agent: Agent
def __init__(self, client: FoundryChatClient, id: str = "legal"):
self.agent = Agent(
client=client,
instructions=(
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
" based on the prompt."
),
name=id,
)
super().__init__(id=id)
@handler
async def run(self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse]) -> None:
response = await self.agent.run(request.messages)
full_conversation = list(request.messages) + list(response.messages)
await ctx.send_message(AgentExecutorResponse(self.id, response, full_conversation=full_conversation))
async def main() -> None:
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
researcher = ResearcherExec(client)
marketer = MarketerExec(client)
legal = LegalExec(client)
workflow = ConcurrentBuilder(participants=[researcher, marketer, legal]).build()
events = await workflow.run("We are launching a new budget-friendly electric bike for urban commuters.")
outputs = events.get_outputs()
if outputs:
print("===== Final Aggregated Conversation (messages) =====")
messages: list[Message] | 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}")
"""
Sample Output:
===== Final Aggregated Conversation (messages) =====
------------------------------------------------------------
01 [user]:
We are launching a new budget-friendly electric bike for urban commuters.
------------------------------------------------------------
02 [researcher]:
**Insights:**
- **Target Demographic:** Urban commuters seeking affordable, eco-friendly transport;
likely to include students, young professionals, and price-sensitive urban residents.
- **Market Trends:** E-bike sales are growing globally, with increasing urbanization,
higher fuel costs, and sustainability concerns driving adoption.
- **Competitive Landscape:** Key competitors include brands like Rad Power Bikes, Aventon,
Lectric, and domestic budget-focused manufacturers in North America, Europe, and Asia.
- **Feature Expectations:** Customers expect reliability, ease-of-use, theft protection,
lightweight design, sufficient battery range for daily city commutes (typically 25-40 miles),
and low-maintenance components.
**Opportunities:**
- **First-time Buyers:** Capture newcomers to e-biking by emphasizing affordability, ease of
operation, and cost savings vs. public transit/car ownership.
...
------------------------------------------------------------
03 [marketer]:
**Value Proposition:**
"Empowering your city commute: Our new electric bike combines affordability, reliability, and
sustainable design—helping you conquer urban journeys without breaking the bank."
**Target Messaging:**
*For Young Professionals:*
...
------------------------------------------------------------
04 [legal]:
**Constraints, Disclaimers, & Policy Concerns for Launching a Budget-Friendly Electric Bike for Urban Commuters:**
**1. Regulatory Compliance**
- Verify that the electric bike meets all applicable federal, state, and local regulations
regarding e-bike classification, speed limits, power output, and safety features.
- Ensure necessary certifications (e.g., UL certification for batteries, CE markings if sold internationally) are obtained.
**2. Product Safety**
- Include consumer safety warnings regarding use, battery handling, charging protocols, and age restrictions.
...
""" # noqa: E501
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,138 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import Any
from agent_framework import Agent, Message
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import ConcurrentBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Concurrent Orchestration with 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 async callback that uses FoundryChatClient.get_response()
to synthesize a concise, consolidated summary from the experts' outputs.
The workflow completes when all participants become idle.
Demonstrates:
- ConcurrentBuilder(participants=[...]).with_aggregator(callback)
- Fan-out to agents and fan-in at an aggregator
- Aggregation implemented via an LLM call (client.get_response)
- Workflow output yielded with the synthesized summary string
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
"""
async def main() -> None:
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
researcher = Agent(
client=client,
instructions=(
"You're an expert market and product researcher. Given a prompt, provide concise, factual insights,"
" opportunities, and risks."
),
name="researcher",
)
marketer = Agent(
client=client,
instructions=(
"You're a creative marketing strategist. Craft compelling value propositions and target messaging"
" aligned to the prompt."
),
name="marketer",
)
legal = Agent(
client=client,
instructions=(
"You're a cautious legal/compliance reviewer. Highlight constraints, disclaimers, and policy concerns"
" based on the prompt."
),
name="legal",
)
# Define a custom aggregator callback that uses the chat client to summarize
async def summarize_results(results: list[Any]) -> str:
# Extract one final assistant message per agent
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 = Message(
"system",
contents=[
(
"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 = Message("user", contents=["\n\n".join(expert_sections)])
response = await client.get_response([system_msg, user_msg])
# Return the model's final assistant text as the completion result
return response.messages[-1].text if response.messages else ""
# Build with a custom aggregator callback function
# - participants([...]) accepts SupportsAgentRun (agents) or Executor instances.
# Each participant becomes a parallel branch (fan-out) from an internal dispatcher.
# - with_aggregator(...) overrides the default aggregator:
# • Default aggregator -> returns list[Message] (one user + one assistant per agent)
# • 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()
events = await workflow.run("We are launching a new budget-friendly electric bike for urban commuters.")
outputs = events.get_outputs()
if outputs:
print("===== Final Consolidated Output =====")
print(outputs[0]) # Get the first (and typically only) output
"""
Sample Output:
===== Final Consolidated Output =====
Urban e-bike demand is rising rapidly due to eco-awareness, urban congestion, and high fuel costs,
with market growth projected at a ~10% CAGR through 2030. Key customer concerns are affordability,
easy maintenance, convenient charging, compact design, and theft protection. Differentiation opportunities
include integrating smart features (GPS, app connectivity), offering subscription or leasing options, and
developing portable, space-saving designs. Partnering with local governments and bike shops can boost visibility.
Risks include price wars eroding margins, regulatory hurdles, battery quality concerns, and heightened expectations
for after-sales support. Accurate, substantiated product claims and transparent marketing (with range disclaimers)
are essential. All e-bikes must comply with local and federal regulations on speed, wattage, safety certification,
and labeling. Clear warranty, safety instructions (especially regarding batteries), and inclusive, accessible
marketing are required. For connected features, data privacy policies and user consents are mandatory.
Effective messaging should target young professionals, students, eco-conscious commuters, and first-time buyers,
emphasizing affordability, convenience, and sustainability. Slogan suggestion: “Charge Ahead—City Commutes Made
Affordable.” Legal review in each target market, compliance vetting, and robust customer support policies are
critical before launch.
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,126 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import cast
from agent_framework import (
Agent,
AgentResponseUpdate,
Message,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import GroupChatBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Group Chat with Agent-Based Manager
What it does:
- Demonstrates the new set_manager() API for agent-based coordination
- Manager is a full Agent with access to tools, context, and observability
- Coordinates a researcher and writer agent to solve tasks collaboratively
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
"""
ORCHESTRATOR_AGENT_INSTRUCTIONS = """
You coordinate a team conversation to solve the user's task.
Guidelines:
- Start with Researcher to gather information
- Then have Writer synthesize the final answer
- Only finish after both have contributed meaningfully
"""
async def main() -> None:
# Create a Responses client using Azure OpenAI and Azure CLI credentials for all agents
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Orchestrator agent that manages the conversation
# Note: This agent (and the underlying chat client) must support structured outputs.
# The group chat workflow relies on this to parse the orchestrator's decisions.
# `response_format` is set internally by the GroupChat workflow when the agent is invoked.
orchestrator_agent = Agent(
name="Orchestrator",
description="Coordinates multi-agent collaboration by selecting speakers",
instructions=ORCHESTRATOR_AGENT_INSTRUCTIONS,
client=client,
)
# Participant agents
researcher = Agent(
name="Researcher",
description="Collects relevant background information",
instructions="Gather concise facts that help a teammate answer the question.",
client=client,
)
writer = Agent(
name="Writer",
description="Synthesizes polished answers from gathered information",
instructions="Compose clear and structured answers using any notes provided.",
client=client,
)
# Build the group chat workflow
# termination_condition: stop after 4 assistant messages
# (The agent orchestrator will intelligently decide when to end before this limit but just in case)
# Mark participant responses as intermediate so the stream shows the
# conversation as it unfolds while the orchestrator's transcript remains the
# terminal workflow output.
workflow = (
GroupChatBuilder(
participants=[researcher, writer],
termination_condition=lambda messages: sum(1 for msg in messages if msg.role == "assistant") >= 4,
intermediate_output_from=[researcher, writer],
orchestrator_agent=orchestrator_agent,
)
# Set a hard termination condition: stop after 4 assistant messages
# The agent orchestrator will intelligently decide when to end before this limit but just in case
.with_termination_condition(lambda messages: sum(1 for msg in messages if msg.role == "assistant") >= 4)
.build()
)
task = "What are the key benefits of using async/await in Python? Provide a concise summary."
print("\nStarting Group Chat with Agent-Based Manager...\n")
print(f"TASK: {task}\n")
print("=" * 80)
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
async for event in workflow.run(task, stream=True):
if event.type in ("intermediate", "output"):
data = event.data
if isinstance(data, AgentResponseUpdate):
rid = data.response_id
if rid != last_response_id:
if last_response_id is not None:
print("\n")
print(f"{data.author_name}:", end=" ", flush=True)
last_response_id = rid
print(data.text, end="", flush=True)
elif event.type == "output":
# The output of the group chat workflow is a collection of chat messages from all participants
outputs = cast(list[Message], event.data)
print("\n" + "=" * 80)
print("\nFinal Conversation Transcript:\n")
for message in outputs:
print(f"{message.author_name or message.role}: {message.text}\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,378 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import logging
import os
from typing import cast
from agent_framework import (
Agent,
AgentResponseUpdate,
Message,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import GroupChatBuilder
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
logging.basicConfig(level=logging.WARNING)
"""
Sample: Philosophical Debate with Agent-Based Manager
What it does:
- Creates a diverse group of agents representing different global perspectives
- Uses an agent-based manager to guide a philosophical discussion
- Demonstrates longer, multi-round discourse with natural conversation flow
- Manager decides when discussion has reached meaningful conclusion
Topic: "What does a good life mean to you personally?"
Participants represent:
- Farmer from Southeast Asia (tradition, sustainability, land connection)
- Software Developer from United States (innovation, technology, work-life balance)
- History Teacher from Eastern Europe (legacy, learning, cultural continuity)
- Activist from South America (social justice, environmental rights)
- Spiritual Leader from Middle East (morality, community service)
- Artist from Africa (creative expression, storytelling)
- Immigrant Entrepreneur from Asia in Canada (tradition + adaptation)
- Doctor from Scandinavia (public health, equity, societal support)
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
"""
# Load environment variables from .env file
load_dotenv()
def _get_chat_client() -> FoundryChatClient:
return FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
async def main() -> None:
# Create debate moderator with structured output for speaker selection
# Note: Participant names and descriptions are automatically injected by the orchestrator
moderator = Agent(
name="Moderator",
description="Guides philosophical discussion by selecting next speaker",
instructions="""
You are a thoughtful moderator guiding a philosophical discussion on the topic handed to you by the user.
Your participants bring diverse global perspectives. Select speakers strategically to:
- Create natural conversation flow and responses to previous points
- Ensure all voices are heard throughout the discussion
- Build on themes and contrasts that emerge
- Allow for respectful challenges and counterpoints
- Guide toward meaningful conclusions
Select speakers who can:
1. Respond directly to points just made
2. Introduce fresh perspectives when needed
3. Bridge or contrast different viewpoints
4. Deepen the philosophical exploration
Finish when:
- Multiple rounds have occurred (at least 6-8 exchanges)
- Key themes have been explored from different angles
- Natural conclusion or synthesis has emerged
- Diminishing returns in new insights
In your final_message, provide a brief synthesis highlighting key themes that emerged.
""",
client=_get_chat_client(),
)
farmer = Agent(
name="Farmer",
description="A rural farmer from Southeast Asia",
instructions="""
You're a farmer from Southeast Asia. Your life is deeply connected to land and family.
You value tradition and sustainability. You are in a philosophical debate.
Share your perspective authentically. Feel free to:
- Challenge other participants respectfully
- Build on points others have made
- Use concrete examples from your experience
- Keep responses thoughtful but concise (2-4 sentences)
""",
client=_get_chat_client(),
)
developer = Agent(
name="Developer",
description="An urban software developer from the United States",
instructions="""
You're a software developer from the United States. Your life is fast-paced and technology-driven.
You value innovation, freedom, and work-life balance. You are in a philosophical debate.
Share your perspective authentically. Feel free to:
- Challenge other participants respectfully
- Build on points others have made
- Use concrete examples from your experience
- Keep responses thoughtful but concise (2-4 sentences)
""",
client=_get_chat_client(),
)
teacher = Agent(
name="Teacher",
description="A retired history teacher from Eastern Europe",
instructions="""
You're a retired history teacher from Eastern Europe. You bring historical and philosophical
perspectives to discussions. You value legacy, learning, and cultural continuity.
You are in a philosophical debate.
Share your perspective authentically. Feel free to:
- Challenge other participants respectfully
- Build on points others have made
- Use concrete examples from history or your teaching experience
- Keep responses thoughtful but concise (2-4 sentences)
""",
client=_get_chat_client(),
)
activist = Agent(
name="Activist",
description="A young activist from South America",
instructions="""
You're a young activist from South America. You focus on social justice, environmental rights,
and generational change. You are in a philosophical debate.
Share your perspective authentically. Feel free to:
- Challenge other participants respectfully
- Build on points others have made
- Use concrete examples from your activism
- Keep responses thoughtful but concise (2-4 sentences)
""",
client=_get_chat_client(),
)
spiritual_leader = Agent(
name="SpiritualLeader",
description="A spiritual leader from the Middle East",
instructions="""
You're a spiritual leader from the Middle East. You provide insights grounded in religion,
morality, and community service. You are in a philosophical debate.
Share your perspective authentically. Feel free to:
- Challenge other participants respectfully
- Build on points others have made
- Use examples from spiritual teachings or community work
- Keep responses thoughtful but concise (2-4 sentences)
""",
client=_get_chat_client(),
)
artist = Agent(
name="Artist",
description="An artist from Africa",
instructions="""
You're an artist from Africa. You view life through creative expression, storytelling,
and collective memory. You are in a philosophical debate.
Share your perspective authentically. Feel free to:
- Challenge other participants respectfully
- Build on points others have made
- Use examples from your art or cultural traditions
- Keep responses thoughtful but concise (2-4 sentences)
""",
client=_get_chat_client(),
)
immigrant = Agent(
name="Immigrant",
description="An immigrant entrepreneur from Asia living in Canada",
instructions="""
You're an immigrant entrepreneur from Asia living in Canada. You balance tradition with adaptation.
You focus on family success, risk, and opportunity. You are in a philosophical debate.
Share your perspective authentically. Feel free to:
- Challenge other participants respectfully
- Build on points others have made
- Use examples from your immigrant and entrepreneurial journey
- Keep responses thoughtful but concise (2-4 sentences)
""",
client=_get_chat_client(),
)
doctor = Agent(
name="Doctor",
description="A doctor from Scandinavia",
instructions="""
You're a doctor from Scandinavia. Your perspective is shaped by public health, equity,
and structured societal support. You are in a philosophical debate.
Share your perspective authentically. Feel free to:
- Challenge other participants respectfully
- Build on points others have made
- Use examples from healthcare and societal systems
- Keep responses thoughtful but concise (2-4 sentences)
""",
client=_get_chat_client(),
)
# termination_condition: stop after 10 assistant messages
# Mark participant responses as intermediate so the stream shows the
# conversation as it unfolds while the orchestrator's transcript remains the
# terminal workflow output.
workflow = (
GroupChatBuilder(
participants=[farmer, developer, teacher, activist, spiritual_leader, artist, immigrant, doctor],
termination_condition=lambda messages: sum(1 for msg in messages if msg.role == "assistant") >= 10,
intermediate_output_from=[
"all",
],
orchestrator_agent=moderator,
)
.with_termination_condition(lambda messages: sum(1 for msg in messages if msg.role == "assistant") >= 10)
.build()
)
topic = "What does a good life mean to you personally?"
print("\n" + "=" * 80)
print("PHILOSOPHICAL DEBATE: Perspectives on a Good Life")
print("=" * 80)
print(f"\nTopic: {topic}")
print("\nParticipants:")
print(" - Farmer (Southeast Asia)")
print(" - Developer (United States)")
print(" - Teacher (Eastern Europe)")
print(" - Activist (South America)")
print(" - SpiritualLeader (Middle East)")
print(" - Artist (Africa)")
print(" - Immigrant (Asia → Canada)")
print(" - Doctor (Scandinavia)")
print("\n" + "=" * 80)
print("DISCUSSION BEGINS")
print("=" * 80 + "\n")
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
async for event in workflow.run(f"Please begin the discussion on: {topic}", stream=True):
if event.type in ("intermediate", "output"):
data = event.data
if isinstance(data, AgentResponseUpdate):
rid = data.response_id
if rid != last_response_id:
if last_response_id is not None:
print("\n")
print(f"{data.author_name}:", end=" ", flush=True)
last_response_id = rid
print(data.text, end="", flush=True)
elif event.type == "output":
# The output of the group chat workflow is a collection of chat messages from all participants
outputs = cast(list[Message], event.data)
print("\n" + "=" * 80)
print("\nFinal Conversation Transcript:\n")
for message in outputs:
print(f"{message.author_name or message.role}: {message.text}\n")
"""
Sample Output:
================================================================================
PHILOSOPHICAL DEBATE: Perspectives on a Good Life
================================================================================
Topic: What does a good life mean to you personally?
Participants:
- Farmer (Southeast Asia)
- Developer (United States)
- Teacher (Eastern Europe)
- Activist (South America)
- SpiritualLeader (Middle East)
- Artist (Africa)
- Immigrant (Asia → Canada)
- Doctor (Scandinavia)
================================================================================
DISCUSSION BEGINS
================================================================================
[Farmer]
To me, a good life is deeply intertwined with the rhythm of the land and the nurturing of relationships with my
family and community. It means cultivating crops that respect our environment, ensuring sustainability for future
generations, and sharing meals made from our harvests around the dinner table. The joy found in everyday
tasks—planting rice or tending to our livestock—creates a sense of fulfillment that cannot be measured by material
wealth. It's the simple moments, like sharing stories with my children under the stars, that truly define a good
life. What good is progress if it isolates us from those we love and the land that sustains us?
[Developer]
As a software developer in an urban environment, a good life for me hinges on the intersection of innovation,
creativity, and balance. It's about having the freedom to explore new technologies that can solve real-world
problems while ensuring that my work doesn't encroach on my personal life. For instance, I value remote work
flexibility, which allows me to maintain connections with family and friends, similar to how the Farmer values
community. While our lifestyles may differ markedly, both of us seek fulfillment—whether through meaningful work or
rich personal experiences. The challenge is finding harmony between technological progress and preserving the
intimate human connections that truly enrich our lives.
[SpiritualLeader]
From my spiritual perspective, a good life embodies a balance between personal fulfillment and service to others,
rooted in compassion and community. In our teachings, we emphasize that true happiness comes from helping those in
need and fostering strong connections with our families and neighbors. Whether it's the Farmer nurturing the earth
or the Developer creating tools to enhance lives, both contribute to the greater good. The essence of a good life
lies in our intentions and actions—finding ways to serve our communities, spread kindness, and live harmoniously
with those around us. Ultimately, as we align our personal beliefs with our communal responsibilities, we cultivate
a richness that transcends material wealth.
[Activist]
As a young activist in South America, a good life for me is about advocating for social justice and environmental
sustainability. It means living in a society where everyone's rights are respected and where marginalized voices,
particularly those of Indigenous communities, are amplified. I see a good life as one where we work collectively to
dismantle oppressive systems—such as deforestation and inequality—while nurturing our planet. For instance, through
my activism, I've witnessed the transformative power of community organizing, where collective efforts lead to real
change, like resisting destructive mining practices that threaten our rivers and lands. A good life, therefore, is
not just lived for oneself but is deeply tied to the well-being of our communities and the health of our
environment. How can we, regardless of our backgrounds, collaborate to foster these essential changes?
[Teacher]
As a retired history teacher from Eastern Europe, my understanding of a good life is deeply rooted in the lessons
drawn from history and the struggle for freedom and dignity. Historical events, such as the fall of the Iron
Curtain, remind us of the profound importance of liberty and collective resilience. A good life, therefore, is about
cherishing our freedoms and working towards a society where everyone has a voice, much as my students and I
discussed the impacts of totalitarian regimes. Additionally, I believe it involves fostering cultural continuity,
where we honor our heritage while embracing progressive values. We must learn from the past—especially the
consequences of neglecting empathy and solidarity—so that we can cultivate a future that values every individual's
contributions to the rich tapestry of our shared humanity. How can we ensure that the lessons of history inform a
more compassionate and just society moving forward?
[Artist]
As an artist from Africa, I define a good life as one steeped in cultural expression, storytelling, and the
celebration of our collective memories. Art is a powerful medium through which we capture our histories, struggles,
and triumphs, creating a tapestry that connects generations. For instance, in my work, I often draw from folktales
and traditional music, weaving narratives that reflect the human experience, much like how the retired teacher
emphasizes learning from history. A good life involves not only personal fulfillment but also the responsibility to
share our narratives and use our creativity to inspire change, whether addressing social injustices or environmental
issues. It's in this interplay of art and activism that we can transcend individual existence and contribute to a
collective good, fostering empathy and understanding among diverse communities. How can we harness art to bridge
differences and amplify marginalized voices in our pursuit of a good life?
================================================================================
DISCUSSION SUMMARY
================================================================================
As our discussion unfolds, several key themes have gracefully emerged, reflecting the richness of diverse
perspectives on what constitutes a good life. From the rural farmer's integration with the land to the developer's
search for balance between technology and personal connection, each viewpoint validates that fulfillment, at its
core, transcends material wealth. The spiritual leader and the activist highlight the importance of community and
social justice, while the history teacher and the artist remind us of the lessons and narratives that shape our
cultural and personal identities.
Ultimately, the good life seems to revolve around meaningful relationships, honoring our legacies while striving for
progress, and nurturing both our inner selves and external communities. This dialogue demonstrates that despite our
varied backgrounds and experiences, the quest for a good life binds us together, urging cooperation and empathy in
our shared human journey.
"""
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,147 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from typing import cast
from agent_framework import (
Agent,
AgentResponseUpdate,
Message,
)
from agent_framework.foundry import FoundryChatClient
from agent_framework.orchestrations import GroupChatBuilder, GroupChatState
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
"""
Sample: Group Chat with a round-robin speaker selector
What it does:
- Demonstrates the selection_func parameter for GroupChat orchestration
- Uses a pure Python function to control speaker selection based on conversation state
Prerequisites:
- FOUNDRY_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint.
- FOUNDRY_MODEL must be set to your Azure OpenAI model deployment name.
- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample.
"""
def round_robin_selector(state: GroupChatState) -> str:
"""A round-robin selector function that picks the next speaker based on the current round index."""
participant_names = list(state.participants.keys())
return participant_names[state.current_round % len(participant_names)]
async def main() -> None:
# Create a Responses client using Azure OpenAI and Azure CLI credentials for all agents
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# Participant agents
expert = Agent(
name="PythonExpert",
instructions=(
"You are an expert in Python in a workgroup. "
"Your job is to answer Python related questions and refine your answer "
"based on feedback from all the other participants."
),
client=client,
)
verifier = Agent(
name="AnswerVerifier",
instructions=(
"You are a programming expert in a workgroup. "
f"Your job is to review the answer provided by {expert.name} and point "
"out statements that are technically true but practically dangerous."
"If there is nothing woth pointing out, respond with 'The answer looks good to me.'"
),
client=client,
)
clarifier = Agent(
name="AnswerClarifier",
instructions=(
"You are an accessibility expert in a workgroup. "
f"Your job is to review the answer provided by {expert.name} and point "
"out jargons or complex terms that may be difficult for a beginner to understand."
"If there is nothing worth pointing out, respond with 'The answer looks clear to me.'"
),
client=client,
)
skeptic = Agent(
name="Skeptic",
instructions=(
"You are a devil's advocate in a workgroup. "
f"Your job is to review the answer provided by {expert.name} and point "
"out caveats, exceptions, and alternative perspectives."
"If there is nothing worth pointing out, respond with 'I have no further questions.'"
),
client=client,
)
# Build the group chat workflow
# termination_condition: stop after 6 messages (user task + one full rounds + 1)
# One round is expert -> verifier -> clarifier -> skeptic, after which the expert gets to respond again.
# This will end the conversation after the expert has spoken 2 times (one iteration loop)
# Note: it's possible that the expert gets it right the first time and the other participants
# have nothing to add, but for demo purposes we want to see at least one full round of interaction.
# Mark participant responses as intermediate so the stream shows the
# conversation as it unfolds while the orchestrator's transcript remains the
# terminal workflow output.
workflow = (
GroupChatBuilder(
participants=[expert, verifier, clarifier, skeptic],
termination_condition=lambda conversation: len(conversation) >= 6,
intermediate_output_from=[expert, verifier, clarifier, skeptic],
selection_func=round_robin_selector,
)
# Set a hard termination condition: stop after 6 messages (user task + one full rounds + 1)
# One round is expert -> verifier -> clarifier -> skeptic, after which the expert gets to respond again.
# This will end the conversation after the expert has spoken 2 times (one iteration loop)
# Note: it's possible that the expert gets it right the first time and the other participants
# have nothing to add, but for demo purposes we want to see at least one full round of interaction.
.with_termination_condition(lambda conversation: len(conversation) >= 6)
.build()
)
task = "How does Pythons Protocol differ from abstract base classes?"
print("\nStarting Group Chat with round-robin speaker selector...\n")
print(f"TASK: {task}\n")
print("=" * 80)
# Keep track of the last response to format output nicely in streaming mode
last_response_id: str | None = None
async for event in workflow.run(task, stream=True):
if event.type in ("intermediate", "output"):
data = event.data
if isinstance(data, AgentResponseUpdate):
rid = data.response_id
if rid != last_response_id:
if last_response_id is not None:
print("\n")
print(f"{data.author_name}:", end=" ", flush=True)
last_response_id = rid
print(data.text, end="", flush=True)
elif event.type == "output":
# The output of the group chat workflow is a collection of chat messages from all participants
outputs = cast(list[Message], event.data)
print("\n" + "=" * 80)
print("\nFinal Conversation Transcript:\n")
for message in outputs:
print(f"{message.author_name or message.role}: {message.text}\n")
if __name__ == "__main__":
asyncio.run(main())

Some files were not shown because too many files have changed in this diff Show More