mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
98cd72839e
commit
e8d0bd9051
+29
@@ -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>
|
||||
+116
@@ -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
|
||||
```
|
||||
|
||||
+30
@@ -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
|
||||
```
|
||||
+29
@@ -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.
|
||||
```
|
||||
+30
@@ -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>
|
||||
+73
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user