.NET: [Feature Branch] Add basic durable workflow support (#3648)

* Add basic durable workflow support.

* PR feedback fixes

* Add conditional edge sample.

* PR feedback fixes.

* Minor cleanup.

* Minor cleanup

* Minor formatting improvements.

* Improve comments/documentation on the execution flow.
This commit is contained in:
Shyju Krishnankutty
2026-02-06 16:02:42 -08:00
committed by GitHub
Unverified
parent 98cd72839e
commit e8d0bd9051
54 changed files with 4863 additions and 505 deletions
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SequentialWorkflow</AssemblyName>
<RootNamespace>SequentialWorkflow</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SequentialWorkflow;
/// <summary>
/// Represents a request to cancel an order.
/// </summary>
/// <param name="OrderId">The ID of the order to cancel.</param>
/// <param name="Reason">The reason for cancellation.</param>
internal sealed record OrderCancelRequest(string OrderId, string Reason);
/// <summary>
/// Looks up an order by its ID and return an Order object.
/// </summary>
internal sealed class OrderLookup() : Executor<OrderCancelRequest, Order>("OrderLookup")
{
public override async ValueTask<Order> HandleAsync(
OrderCancelRequest message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message.OrderId}'");
Console.WriteLine($"│ [Activity] OrderLookup: Cancellation reason: '{message.Reason}'");
Console.ResetColor();
// Simulate database lookup with delay
await Task.Delay(TimeSpan.FromMicroseconds(100), cancellationToken);
Order order = new(
Id: message.OrderId,
OrderDate: DateTime.UtcNow.AddDays(-1),
IsCancelled: false,
CancelReason: message.Reason,
Customer: new Customer(Name: "Jerry", Email: "jerry@example.com"));
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message.OrderId}' for customer '{order.Customer.Name}'");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return order;
}
}
/// <summary>
/// Cancels an order.
/// </summary>
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
{
public override async ValueTask<Order> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// Log that this activity is executing (not replaying)
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'");
Console.ResetColor();
// Simulate a slow cancellation process (e.g., calling external payment system)
for (int i = 1; i <= 3; i++)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine("│ [Activity] OrderCancel: Processing...");
Console.ResetColor();
}
Order cancelledOrder = message with { IsCancelled = true };
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return cancelledOrder;
}
}
/// <summary>
/// Sends a cancellation confirmation email to the customer.
/// </summary>
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
{
public override ValueTask<string> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'...");
Console.ResetColor();
string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}.";
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(result);
}
}
internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer);
internal sealed record Customer(string Name, string Email);
@@ -0,0 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SequentialWorkflow;
// Get DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Define executors for the workflow
OrderLookup orderLookup = new();
OrderCancel orderCancel = new();
SendEmail sendEmail = new();
// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
.WithName("CancelOrder")
.WithDescription("Cancel an order and notify the customer")
.AddEdge(orderLookup, orderCancel)
.AddEdge(orderCancel, sendEmail)
.Build();
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
workflowOptions => workflowOptions.AddWorkflow(cancelOrder),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("Durable Workflow Sample");
Console.WriteLine("Workflow: OrderLookup -> OrderCancel -> SendEmail");
Console.WriteLine();
Console.WriteLine("Enter an order ID (or 'exit'):");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
OrderCancelRequest request = new(OrderId: input, Reason: "Customer requested cancellation");
await StartNewWorkflowAsync(request, cancelOrder, workflowClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
// Start a new workflow using IWorkflowClient with typed input
static async Task StartNewWorkflowAsync(OrderCancelRequest request, Workflow workflow, IWorkflowClient client)
{
Console.WriteLine($"Starting workflow for order '{request.OrderId}' (Reason: {request.Reason})...");
// RunAsync returns IWorkflowRun, cast to IAwaitableWorkflowRun for completion waiting
IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, request);
Console.WriteLine($"Run ID: {run.RunId}");
try
{
Console.WriteLine("Waiting for workflow to complete...");
string? result = await run.WaitForCompletionAsync<string>();
Console.WriteLine($"Workflow completed. {result}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Failed: {ex.Message}");
}
}
@@ -0,0 +1,83 @@
# Sequential Workflow Sample
This sample demonstrates how to run a sequential workflow as a durable orchestration from a console application using the Durable Task Framework. It showcases the **durability** aspect - if the process crashes mid-execution, the workflow automatically resumes without re-executing completed activities.
## Key Concepts Demonstrated
- Building a sequential workflow with the `WorkflowBuilder` API
- Using `ConfigureDurableWorkflows` to register workflows with dependency injection
- Running workflows with `IWorkflowClient`
- **Durability**: Automatic resume of interrupted workflows
- **Activity caching**: Completed activities are not re-executed on replay
## Overview
The sample implements an order cancellation workflow with three executors:
```
OrderLookup --> OrderCancel --> SendEmail
```
| Executor | Description |
|----------|-------------|
| OrderLookup | Looks up an order by ID |
| OrderCancel | Marks the order as cancelled |
| SendEmail | Sends a cancellation confirmation email |
## Durability Demonstration
The key feature of Durable Task Framework is **durability**:
- **Activity results are persisted**: When an activity completes, its result is saved
- **Orchestrations replay**: On restart, the orchestration replays from the beginning
- **Completed activities skip execution**: The framework uses cached results
- **Automatic resume**: The worker automatically picks up pending work on startup
### Try It Yourself
> **Tip:** To give yourself more time to stop the application during `OrderCancel`, consider increasing the loop iteration count or `Task.Delay` duration in the `OrderCancel` executor in `OrderCancelExecutors.cs`.
1. Start the application and enter an order ID (e.g., `12345`)
2. Wait for `OrderLookup` to complete, then stop the app (Ctrl+C) during `OrderCancel`
3. Restart the application
4. Observe:
- `OrderLookup` is **NOT** re-executed (result was cached)
- `OrderCancel` **restarts** (it didn't complete before the interruption)
- `SendEmail` runs after `OrderCancel` completes
## Environment Setup
See the [README.md](../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
## Running the Sample
```bash
cd dotnet/samples/Durable/Workflow/ConsoleApps/01_SequentialWorkflow
dotnet run --framework net10.0
```
### Sample Output
```text
Durable Workflow Sample
Workflow: OrderLookup -> OrderCancel -> SendEmail
Enter an order ID (or 'exit'):
> 12345
Starting workflow for order: 12345
Run ID: abc123...
[OrderLookup] Looking up order '12345'...
[OrderLookup] Found order for customer 'Jerry'
[OrderCancel] Cancelling order '12345'...
[OrderCancel] Order cancelled successfully
[SendEmail] Sending email to 'jerry@example.com'...
[SendEmail] Email sent successfully
Workflow completed!
> exit
```
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WorkflowConcurrency</AssemblyName>
<RootNamespace>WorkflowConcurrency</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Azure.AI.OpenAI" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowConcurrency;
/// <summary>
/// Parses and validates the incoming question before sending to AI agents.
/// </summary>
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
{
public override ValueTask<string> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
string formattedQuestion = message.Trim();
if (!formattedQuestion.EndsWith('?'))
{
formattedQuestion += "?";
}
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL...");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(formattedQuestion);
}
}
/// <summary>
/// Aggregates responses from all AI agents into a comprehensive answer.
/// This is the Fan-in point where parallel results are collected.
/// </summary>
internal sealed class AggregatorExecutor() : Executor<string[], string>("Aggregator")
{
public override ValueTask<string> HandleAsync(
string[] message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
" AI EXPERT PANEL RESPONSES\n" +
"═══════════════════════════════════════════════════════════════\n\n";
for (int i = 0; i < message.Length; i++)
{
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
}
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
"═══════════════════════════════════════════════════════════════";
return ValueTask.FromResult(aggregatedResult);
}
}
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates the Fan-out/Fan-in pattern in a durable workflow.
// The workflow uses 4 executors: 2 class-based executors and 2 AI agents.
//
// WORKFLOW PATTERN:
//
// ParseQuestion (class-based)
// |
// +----------+----------+
// | |
// Physicist Chemist
// (AI Agent) (AI Agent)
// | |
// +----------+----------+
// |
// Aggregator (class-based)
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
using WorkflowConcurrency;
// Configuration
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
// Create Azure OpenAI client
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
// Define the 4 executors for the workflow
ParseQuestionExecutor parseQuestion = new();
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist");
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist");
AggregatorExecutor aggregator = new();
// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator
Workflow workflow = new WorkflowBuilder(parseQuestion)
.WithName("ExpertReview")
.AddFanOutEdge(parseQuestion, [physicist, chemist])
.AddFanInEdge([physicist, chemist], aggregator)
.Build();
// Configure and start the host
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableOptions(
options => options.Workflows.AddWorkflow(workflow),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("Fan-out/Fan-in Workflow Sample");
Console.WriteLine("ParseQuestion -> [Physicist, Chemist] -> Aggregator");
Console.WriteLine();
Console.WriteLine("Enter a science question (or 'exit' to quit):");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
IWorkflowRun run = await workflowClient.RunAsync(workflow, input);
Console.WriteLine($"Run ID: {run.RunId}");
if (run is IAwaitableWorkflowRun awaitableRun)
{
string? result = await awaitableRun.WaitForCompletionAsync<string>();
Console.WriteLine("Workflow completed!");
Console.WriteLine(result);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
@@ -0,0 +1,100 @@
# Concurrent Workflow Sample (Fan-Out/Fan-In)
This sample demonstrates the **fan-out/fan-in** pattern in a durable workflow, combining class-based executors with AI agents running in parallel.
## Key Concepts Demonstrated
- **Fan-out/Fan-in pattern**: Parallel execution with result aggregation
- **Mixed executor types**: Class-based executors and AI agents in the same workflow
- **AI agents as executors**: Using `ChatClient.AsAIAgent()` to create workflow-compatible agents
- **Workflow registration**: Auto-registration of agents used within workflows
- **Standalone agents**: Registering agents outside of workflows
## Overview
The sample implements an expert review workflow with four executors:
```
ParseQuestion
|
+----------+----------+
| |
Physicist Chemist
(AI Agent) (AI Agent)
| |
+----------+----------+
|
Aggregator
```
| Executor | Type | Description |
|----------|------|-------------|
| ParseQuestion | Class-based | Parses the user's question for expert review |
| Physicist | AI Agent | Provides physics perspective (runs in parallel) |
| Chemist | AI Agent | Provides chemistry perspective (runs in parallel) |
| Aggregator | Class-based | Combines expert responses into a final answer |
## Fan-Out/Fan-In Pattern
The workflow demonstrates the fan-out/fan-in pattern:
1. **Fan-out**: `ParseQuestion` sends the question to both `Physicist` and `Chemist` simultaneously
2. **Parallel execution**: Both AI agents process the question concurrently
3. **Fan-in**: `Aggregator` waits for both agents to complete, then combines their responses
This pattern is useful for:
- Gathering multiple perspectives on a problem
- Parallel processing of independent tasks
- Reducing overall execution time through concurrency
## Environment Setup
See the [README.md](../README.md) file in the parent directory for information on configuring the environment.
### Required Environment Variables
```bash
# Durable Task Scheduler (optional, defaults to localhost)
DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
# Azure OpenAI (required)
AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
AZURE_OPENAI_DEPLOYMENT="gpt-4o"
AZURE_OPENAI_KEY="your-key" # Optional if using Azure CLI credentials
```
## Running the Sample
```bash
cd dotnet/samples/Durable/Workflow/ConsoleApps/02_ConcurrentWorkflow
dotnet run --framework net10.0
```
### Sample Output
```text
+-----------------------------------------------------------------------+
| Fan-out/Fan-in Workflow Sample (4 Executors) |
| |
| ParseQuestion -> [Physicist, Chemist] -> Aggregator |
| (class-based) (AI agents, parallel) (class-based) |
+-----------------------------------------------------------------------+
Enter a science question (or 'exit' to quit):
Question: Why is the sky blue?
Instance: abc123...
[ParseQuestion] Parsing question for expert review...
[Physicist] Analyzing from physics perspective...
[Chemist] Analyzing from chemistry perspective...
[Aggregator] Combining expert responses...
Workflow completed!
Physics perspective: The sky appears blue due to Rayleigh scattering...
Chemistry perspective: The molecular composition of our atmosphere...
Combined answer: ...
Question: exit
```
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ConditionalEdges</AssemblyName>
<RootNamespace>ConditionalEdges</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace ConditionalEdges;
internal sealed class Order
{
public Order(string id, decimal amount)
{
this.Id = id;
this.Amount = amount;
}
public string Id { get; }
public decimal Amount { get; }
public Customer? Customer { get; set; }
public string? PaymentReferenceNumber { get; set; }
}
public sealed record Customer(int Id, string Name, bool IsBlocked);
internal sealed class OrderIdParser() : Executor<string, Order>("OrderIdParser")
{
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return GetOrder(message);
}
private static Order GetOrder(string id)
{
// Simulate fetching order details
return new Order(id, 100.0m);
}
}
internal sealed class OrderEnrich() : Executor<Order, Order>("EnrichOrder")
{
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
message.Customer = GetCustomerForOrder(message.Id);
return message;
}
private static Customer GetCustomerForOrder(string orderId)
{
if (orderId.Contains('B'))
{
return new Customer(101, "George", true);
}
return new Customer(201, "Jerry", false);
}
}
internal sealed class PaymentProcesser() : Executor<Order, Order>("PaymentProcesser")
{
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Call payment gateway.
message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4);
return message;
}
}
internal sealed class NotifyFraud() : Executor<Order, string>("NotifyFraud")
{
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Notify fraud team.
return $"Order {message.Id} flagged as fraudulent for customer {message.Customer?.Name}.";
}
}
internal static class OrderRouteConditions
{
/// <summary>
/// Returns a condition that evaluates to true when the customer is blocked.
/// </summary>
internal static Func<Order?, bool> WhenBlocked() => order => order?.Customer?.IsBlocked == true;
/// <summary>
/// Returns a condition that evaluates to true when the customer is not blocked.
/// </summary>
internal static Func<Order?, bool> WhenNotBlocked() => order => order?.Customer?.IsBlocked == false;
}
@@ -0,0 +1,97 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates conditional edges in a workflow.
// Orders are routed to different executors based on customer status:
// - Blocked customers → NotifyFraud
// - Valid customers → PaymentProcessor
using ConditionalEdges;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Create executor instances
OrderIdParser orderParser = new();
OrderEnrich orderEnrich = new();
PaymentProcesser paymentProcessor = new();
NotifyFraud notifyFraud = new();
// Build workflow with conditional edges
// The condition functions evaluate the Order output from OrderEnrich
WorkflowBuilder builder = new(orderParser);
builder
.AddEdge(orderParser, orderEnrich)
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
Workflow auditOrder = builder.WithName("AuditOrder").Build();
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
workflowOptions => workflowOptions.AddWorkflow(auditOrder),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("Enter an order ID (or 'exit'):");
Console.WriteLine("Tip: Order IDs containing 'B' are flagged as blocked customers.\n");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
await StartNewWorkflowAsync(input, auditOrder, workflowClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
// Start a new workflow and wait for completion
static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client)
{
Console.WriteLine($"Starting workflow for order '{orderId}'...");
// Cast to IAwaitableWorkflowRun to access WaitForCompletionAsync
IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, orderId);
Console.WriteLine($"Run ID: {run.RunId}");
try
{
Console.WriteLine("Waiting for workflow to complete...");
string? result = await run.WaitForCompletionAsync<string>();
Console.WriteLine($"Workflow completed. {result}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Failed: {ex.Message}");
}
}
@@ -0,0 +1,92 @@
# Conditional Edges Workflow Sample
This sample demonstrates how to build a workflow with **conditional edges** that route execution to different paths based on runtime conditions. The workflow evaluates conditions on the output of an executor to determine which downstream executor to run.
## Key Concepts Demonstrated
- Building workflows with **conditional edges** using `AddEdge` with a `condition` parameter
- Defining reusable condition functions for routing logic
- Branching workflow execution based on data-driven decisions
- Using `ConfigureDurableWorkflows` to register workflows with dependency injection
## Overview
The sample implements an order audit workflow that routes orders differently based on whether the customer is blocked (flagged for fraud):
```
OrderIdParser --> OrderEnrich --[IsBlocked]--> NotifyFraud
|
+--[NotBlocked]--> PaymentProcessor
```
| Executor | Description |
|----------|-------------|
| OrderIdParser | Parses the order ID and retrieves order details |
| OrderEnrich | Enriches the order with customer information |
| PaymentProcessor | Processes payment for valid orders |
| NotifyFraud | Notifies the fraud team for blocked customers |
## How Conditional Edges Work
Conditional edges allow you to specify a condition function that determines whether the edge should be traversed:
```csharp
builder
.AddEdge(orderParser, orderEnrich)
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
```
The condition functions receive the output of the source executor and return a boolean:
```csharp
internal static class OrderRouteConditions
{
// Routes to NotifyFraud when customer is blocked
internal static Func<Order?, bool> WhenBlocked() =>
order => order?.Customer?.IsBlocked == true;
// Routes to PaymentProcessor when customer is not blocked
internal static Func<Order?, bool> WhenNotBlocked() =>
order => order?.Customer?.IsBlocked == false;
}
```
### Routing Logic
In this sample, the routing is based on the order ID:
- Order IDs containing the letter **'B'** are associated with blocked customers ? routed to `NotifyFraud`
- All other order IDs are associated with valid customers ? routed to `PaymentProcessor`
## Environment Setup
See the [README.md](../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
## Running the Sample
```bash
cd dotnet/samples/Durable/Workflow/ConsoleApps/03_ConditionalEdges
dotnet run --framework net10.0
```
### Sample Output
**Valid order (routes to PaymentProcessor):**
```text
Enter an order ID (or 'exit'):
> 12345
Starting workflow for order '12345'...
Run ID: abc123...
Waiting for workflow to complete...
Workflow completed. {"Id":"12345","Amount":100.0,"Customer":{"Id":201,"Name":"Jerry","IsBlocked":false},"PaymentReferenceNumber":"a1b2"}
```
**Blocked order (routes to NotifyFraud):**
```text
Enter an order ID (or 'exit'):
> 12345B
Starting workflow for order '12345B'...
Run ID: def456...
Waiting for workflow to complete...
Workflow completed. Order 12345B flagged as fraudulent for customer George.
```
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WorkflowConcurrency</AssemblyName>
<RootNamespace>WorkflowConcurrency</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Azure.AI.OpenAI" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowConcurrency;
/// <summary>
/// Parses and validates the incoming question before sending to AI agents.
/// </summary>
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
{
public override ValueTask<string> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
string formattedQuestion = message.Trim();
if (!formattedQuestion.EndsWith('?'))
{
formattedQuestion += "?";
}
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
Console.WriteLine("│ [ParseQuestion] → Sending to experts...");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(formattedQuestion);
}
}
/// <summary>
/// Aggregates responses from multiple AI agents into a unified response.
/// This executor collects all expert opinions and synthesizes them.
/// </summary>
internal sealed class ResponseAggregatorExecutor() : Executor<string[], string>("ResponseAggregator")
{
public override ValueTask<string> HandleAsync(
string[] message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
" AI EXPERT PANEL RESPONSES\n" +
"═══════════════════════════════════════════════════════════════\n\n";
for (int i = 0; i < message.Length; i++)
{
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
}
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
"═══════════════════════════════════════════════════════════════";
return ValueTask.FromResult(aggregatedResult);
}
}
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates the THREE ways to configure durable agents and workflows:
//
// 1. ConfigureDurableAgents() - For standalone agents only
// 2. ConfigureDurableWorkflows() - For workflows only
// 3. ConfigureDurableOptions() - For both agents AND workflows
//
// KEY: All methods can be called MULTIPLE times - configurations are ADDITIVE.
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
using WorkflowConcurrency;
// Configuration
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
// Create AI agents
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
AIAgent biologist = chatClient.AsAIAgent("You are a biology expert. Explain concepts clearly in 2-3 sentences.", "Biologist");
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Explain concepts clearly in 2-3 sentences.", "Physicist");
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Explain concepts clearly in 2-3 sentences.", "Chemist");
// Create workflows
ParseQuestionExecutor questionParser = new();
ResponseAggregatorExecutor responseAggregator = new();
Workflow physicsWorkflow = new WorkflowBuilder(questionParser)
.WithName("PhysicsExpertReview")
.AddEdge(questionParser, physicist)
.Build();
Workflow expertTeamWorkflow = new WorkflowBuilder(questionParser)
.WithName("ExpertTeamReview")
.AddFanOutEdge(questionParser, [biologist, physicist])
.AddFanInEdge([biologist, physicist], responseAggregator)
.Build();
Workflow chemistryWorkflow = new WorkflowBuilder(questionParser)
.WithName("ChemistryExpertReview")
.AddEdge(questionParser, chemist)
.Build();
// Configure services - demonstrating all 3 methods (each can be called multiple times)
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
// METHOD 1: ConfigureDurableAgents - for standalone agents only
services.ConfigureDurableAgents(
options => options.AddAIAgent(biologist),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
// METHOD 2: ConfigureDurableWorkflows - for workflows only
services.ConfigureDurableWorkflows(options => options.AddWorkflow(physicsWorkflow));
// METHOD 3: ConfigureDurableOptions - for both agents AND workflows
services.ConfigureDurableOptions(options =>
{
options.Agents.AddAIAgent(chemist);
options.Workflows.AddWorkflow(expertTeamWorkflow);
});
// Second call to ConfigureDurableOptions (additive - adds to existing config)
services.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(chemistryWorkflow));
})
.Build();
await host.StartAsync();
IServiceProvider services = host.Services;
IWorkflowClient workflowClient = services.GetRequiredService<IWorkflowClient>();
// DEMO 1: Direct agent conversation (standalone agents)
Console.WriteLine("\n═══ DEMO 1: Direct Agent Conversation ═══\n");
AIAgent biologistProxy = services.GetRequiredKeyedService<AIAgent>("Biologist");
AgentSession session = await biologistProxy.GetNewSessionAsync();
AgentResponse response = await biologistProxy.RunAsync("What is photosynthesis?", session);
Console.WriteLine($"🧬 Biologist: {response.Text}\n");
AIAgent chemistProxy = services.GetRequiredKeyedService<AIAgent>("Chemist");
session = await chemistProxy.GetNewSessionAsync();
response = await chemistProxy.RunAsync("What is a chemical bond?", session);
Console.WriteLine($"🧪 Chemist: {response.Text}\n");
// DEMO 2: Single-agent workflow
Console.WriteLine("═══ DEMO 2: Single-Agent Workflow ═══\n");
await RunWorkflowAsync(workflowClient, physicsWorkflow, "What is the relationship between energy and mass?");
// DEMO 3: Multi-agent workflow
Console.WriteLine("═══ DEMO 3: Multi-Agent Workflow ═══\n");
await RunWorkflowAsync(workflowClient, expertTeamWorkflow, "How does radiation affect living cells?");
// DEMO 4: Workflow from second ConfigureDurableOptions call
Console.WriteLine("═══ DEMO 4: Workflow (added via 2nd ConfigureDurableOptions) ═══\n");
await RunWorkflowAsync(workflowClient, chemistryWorkflow, "What happens during combustion?");
Console.WriteLine("\n✅ All demos completed!");
await host.StopAsync();
// Helper method
static async Task RunWorkflowAsync(IWorkflowClient client, Workflow workflow, string question)
{
Console.WriteLine($"📋 {workflow.Name}: \"{question}\"");
IWorkflowRun run = await client.RunAsync(workflow, question);
if (run is IAwaitableWorkflowRun awaitable)
{
string? result = await awaitable.WaitForCompletionAsync<string>();
Console.WriteLine($"✅ {result}\n");
}
}