This commit is contained in:
Shyju Krishnankutty
2026-01-26 08:21:02 -08:00
parent ffb2468945
commit d98520d0ca
40 changed files with 3867 additions and 556 deletions
+5 -1
View File
@@ -50,6 +50,10 @@
<Project Path="samples/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/08_SingleWorkflow/08_SingleWorkflow.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/09_Workflow_Concurrency/09_Workflow_Concurrency.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/10_Workflow_HITL/10_Workflow_HITL.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/11_WorkflowEvents/11_WorkflowEvents.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
@@ -417,8 +421,8 @@
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
</Folder>
<Folder Name="/Tests/" />
@@ -24,8 +24,8 @@ AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
AIAgent physicist = client.GetChatClient(deploymentName).CreateAIAgent("You are an expert in physics. You answer questions from a physics perspective.", "Physicist");
AIAgent chemist = client.GetChatClient(deploymentName).CreateAIAgent("You are an expert in chemistry. You answer questions from a chemistry perspective.", "Chemist");
AIAgent physicist = client.GetChatClient(deploymentName).AsAIAgent("You are an expert in physics. You answer questions from a physics perspective.", "Physicist");
AIAgent chemist = client.GetChatClient(deploymentName).AsAIAgent("You are an expert in chemistry. You answer questions from a chemistry perspective.", "Chemist");
var startExecutor = new ConcurrentStartExecutor();
var aggregationExecutor = new ResultAggregationExecutor();
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SingleWorkflow</AssemblyName>
<RootNamespace>SingleAgent</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.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,128 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
/// <summary>
/// Represents an order in the system.
/// </summary>
internal sealed class Order
{
public required string Id { get; set; }
public DateTime OrderDate { get; set; }
public bool IsCancelled { get; set; }
public required Customer Customer { get; set; }
}
/// <summary>
/// Represents a customer associated with an order.
/// </summary>
internal sealed class Customer
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
/// <summary>
/// Looks up an order by its ID.
/// This activity simulates a database lookup with a 2 second delay.
/// </summary>
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
{
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Log that this activity is executing (not replaying)
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message}'");
Console.ResetColor();
// Simulate database lookup with delay
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
Order order = new()
{
Id = message,
OrderDate = DateTime.UtcNow.AddDays(-3),
IsCancelled = false,
Customer = new Customer { Name = "Jerry", Email = "jerry@example.com" }
};
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message}' for customer '{order.Customer.Name}'");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return order;
}
}
/// <summary>
/// Cancels an order.
/// This activity simulates a slow cancellation process with a 5 second delay.
/// Try pressing Ctrl+C during this activity to see durability in action!
/// </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.WriteLine("│ [Activity] OrderCancel: ⚠️ This takes 5 seconds - try Ctrl+C!");
Console.ResetColor();
// Simulate a slow cancellation process (e.g., calling external payment system)
// This is where you can kill the process to test durability
for (int i = 1; i <= 5; i++)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($"│ [Activity] OrderCancel: Processing... {i}/5 seconds");
Console.ResetColor();
}
// Mark the order as cancelled
message.IsCancelled = true;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{message.Id}' has been cancelled");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return message;
}
}
/// <summary>
/// Sends a cancellation confirmation email to the customer.
/// This activity simulates sending an email with a 1 second delay.
/// </summary>
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
{
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Log that this activity is executing (not replaying)
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'...");
Console.ResetColor();
// Simulate email sending delay
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
string result = $"Cancellation email sent to {message.Customer.Email} for order {message.Id}.";
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return result;
}
}
@@ -0,0 +1,107 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to run a workflow as a durable orchestration from a console application.
// The workflow consists of three executors: OrderLookup -> OrderCancel -> SendEmail.
// It uses the DurableExecution API similar to InProcessExecution for in-process workflows.
//
// DURABILITY DEMONSTRATION:
// - Each activity has artificial delays to simulate real-world operations
// - Stop the app (Ctrl+C or stop debugging) during the OrderCancel activity (5 seconds)
// - Restart the application - the workflow will automatically resume!
// - The Durable Task Framework will skip already-completed activities (OrderLookup)
// and continue from where it left off (OrderCancel)
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SingleAgent;
// 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(
options => options.Workflows.AddWorkflow(cancelOrder),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
DurableTaskClient durableClient = host.Services.GetRequiredService<DurableTaskClient>();
Console.WriteLine("Durable Workflow Sample");
Console.WriteLine("Workflow: OrderLookup (2s) -> OrderCancel (5s) -> SendEmail (1s)");
Console.WriteLine();
Console.WriteLine("TIP: Stop the app during OrderCancel to test durability.");
Console.WriteLine(" Restart - it will resume from where it left off.");
Console.WriteLine();
Console.WriteLine("Checking for pending workflows...");
await Task.Delay(TimeSpan.FromSeconds(2));
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
{
await StartNewWorkflowAsync(input, cancelOrder, durableClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
// Start a new workflow
async Task StartNewWorkflowAsync(string orderId, Workflow workflow, DurableTaskClient client)
{
Console.WriteLine($"Starting workflow for order '{orderId}'...");
await using DurableRun run = await DurableWorkflow.RunAsync(workflow, orderId, client);
Console.WriteLine($"Instance ID: {run.InstanceId}");
try
{
string? result = await run.WaitForCompletionAsync();
Console.WriteLine($"Completed: {result}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Failed: {ex.Message}");
}
}
@@ -0,0 +1,156 @@
# Single Workflow Console Sample
This sample demonstrates how to run a 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 can be resumed without re-executing completed activities.
## Overview
The sample implements an order cancellation workflow with three executors, each with artificial delays to simulate real-world operations:
1. **OrderLookup** (2 seconds) - Looks up an order by its ID
2. **OrderCancel** (5 seconds) - Marks the order as cancelled
3. **SendEmail** (1 second) - 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 are replayed**: On restart, the orchestration replays from the beginning
- **Completed activities are skipped**: The framework uses cached results for completed activities
- **Failed activities are retried**: If an activity was interrupted, it runs again
- **Automatic resume**: When the worker starts, it automatically picks up any pending work!
### Try It Yourself
1. Start the application and enter an order ID (e.g., `12345`)
2. Stop the app (Ctrl+C or stop debugging) during the `OrderCancel` activity (5 seconds)
3. Restart the application
4. **Watch for automatic resume!** The worker automatically picks up the interrupted workflow
5. Observe that `OrderLookup` is NOT re-executed (its result was cached)
6. `OrderCancel` restarts from the beginning (it didn't complete)
7. `SendEmail` runs after `OrderCancel` completes
The durability is completely automatic - no manual intervention needed!
## Workflow Flow
```
User Input (Order ID)
?
?
???????????????????
? OrderLookup ? ? 2 second delay (database lookup)
? (2 seconds) ?
???????????????????
?
?
???????????????????
? OrderCancel ? ? 5 second delay - TRY INTERRUPTING HERE!
? (5 seconds) ?
???????????????????
?
?
???????????????????
? SendEmail ? ? 1 second delay (email sending)
? (1 second) ?
???????????????????
?
?
Result
```
## Key Concepts Demonstrated
- **ConfigureDurableWorkflows** - Simplified API for registering workflows
- **DurableExecution.RunAsync** - Start a new workflow (similar to InProcessExecution)
- **DurableRun** - Handle to monitor and interact with a running workflow
- **Automatic Resume** - Interrupted workflows continue automatically on restart
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
With the environment setup, you can run the sample:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/08_SingleWorkflow
dotnet run --framework net10.0
```
### Sample Session
```text
??????????????????????????????????????????????????????????????????????
? Durable Workflow Console Sample ?
??????????????????????????????????????????????????????????????????????
? This sample demonstrates durability in workflows. ?
? Workflow: OrderLookup (2s) -> OrderCancel (5s) -> SendEmail (1s) ?
??????????????????????????????????????????????????????????????????????
?? TIP: Stop the app during OrderCancel (5 seconds) to test durability!
Restart the app - it will automatically resume from where it left off.
? Checking for pending workflows...
Enter an order ID to start a new workflow (or 'exit' to quit):
Order ID: 12345
Starting workflow for order '12345'...
Instance ID: abc123-def456-...
???????????????????????????????????????????????????????????????????
? [Activity] OrderLookup: Starting lookup for order '12345'
? [Activity] OrderLookup: Found order '12345' for customer 'Jerry'
???????????????????????????????????????????????????????????????????
???????????????????????????????????????????????????????????????????
? [Activity] OrderCancel: Starting cancellation for order '12345'
? [Activity] OrderCancel: ?? This takes 5 seconds - try Ctrl+C!
? [Activity] OrderCancel: Processing... 1/5 seconds
? [Activity] OrderCancel: Processing... 2/5 seconds
^C <-- User stops the app here
[After restart...]
? Checking for pending workflows...
???????????????????????????????????????????????????????????????????
? [Activity] OrderCancel: Starting cancellation for order '12345' <-- Auto-resumed!
? [Activity] OrderCancel: ?? This takes 5 seconds - try Ctrl+C!
? [Activity] OrderCancel: Processing... 1/5 seconds
...
? [Activity] OrderCancel: ? Order '12345' has been cancelled
???????????????????????????????????????????????????????????????????
???????????????????????????????????????????????????????????????????
? [Activity] SendEmail: Sending email to 'jerry@example.com'...
? [Activity] SendEmail: ? Email sent successfully!
???????????????????????????????????????????????????????????????????
Enter an order ID to start a new workflow (or 'exit' to quit):
Order ID: _
```
Notice that when resumed:
- `OrderLookup` was **NOT re-executed** (result was cached by Durable Task)
- `OrderCancel` **restarted automatically** (it was interrupted before completing)
- `SendEmail` ran normally after `OrderCancel` completed
## Viewing Workflow State
You can view the state of the workflow in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view the state of the orchestration, including activity history and current state
## Related Samples
- [01_SingleAgent](../01_SingleAgent) - Single agent console sample
- [02_AgentOrchestration_Chaining](../02_AgentOrchestration_Chaining) - Agent chaining with durable orchestration
- [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) - Human-in-the-loop orchestration
- [09_Workflow](../../AzureFunctions/09_Workflow) - Azure Functions version of workflow hosting
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SingleWorkflow</AssemblyName>
<RootNamespace>SingleAgent</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.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,67 @@
// 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,134 @@
// 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 (4 Executors):
//
// ┌──────────────────┐
// │ ParseQuestion │ ← Class-based Executor
// └────────┬─────────┘
// │
// ┌────────┴────────┐
// ▼ ▼
// ┌──────────┐ ┌──────────┐
// │ Physicist│ │ Chemist │ ← AI Agents (parallel)
// └────┬─────┘ └────┬─────┘
// │ │
// └──────┬───────┘
// ▼
// ┌──────────────────┐
// │ Aggregator │ ← Class-based Executor
// └──────────────────┘
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
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(); // Executor 1: Class-based
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist"); // Executor 2: AI Agent
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist"); // Executor 3: AI Agent
AggregatorExecutor aggregator = new(); // Executor 4: Class-based
// 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.ConfigureDurableWorkflows(
options => options.Workflows.AddWorkflow(workflow),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
DurableTaskClient durableClient = host.Services.GetRequiredService<DurableTaskClient>();
// Console UI
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("╔═══════════════════════════════════════════════════════════════════════╗");
Console.WriteLine("║ Fan-out/Fan-in Workflow Sample (4 Executors) ║");
Console.WriteLine("║ ║");
Console.WriteLine("║ ParseQuestion → [Physicist, Chemist] → Aggregator ║");
Console.WriteLine("║ (class-based) (AI agents, parallel) (class-based) ║");
Console.WriteLine("╚═══════════════════════════════════════════════════════════════════════╝");
Console.ResetColor();
Console.WriteLine();
await Task.Delay(TimeSpan.FromSeconds(2)); // Allow pending workflows to resume
Console.WriteLine("Enter a science question (or 'exit' to quit):");
Console.WriteLine();
while (true)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write("Question: ");
Console.ResetColor();
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
await using DurableRun run = await DurableWorkflow.RunAsync(workflow, input, durableClient);
Console.ForegroundColor = ConsoleColor.Gray;
Console.WriteLine($"Instance: {run.InstanceId}");
Console.ResetColor();
string? result = await run.WaitForCompletionAsync();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("\n✓ Workflow completed!\n");
Console.ResetColor();
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"✗ Error: {ex.Message}");
Console.ResetColor();
}
Console.WriteLine();
}
await host.StopAsync();
@@ -0,0 +1,160 @@
# Fan-out/Fan-in Workflow with AI Agents
This sample demonstrates the **Fan-out/Fan-in pattern** using real AI agents in a durable workflow. A question is sent to multiple AI "expert" agents in parallel, and their responses are aggregated into a final result.
## Overview
The sample implements an expert consultation workflow using Azure OpenAI:
- A question is parsed and prepared
- The question is sent to **2 AI agents in parallel** (Fan-out)
- AI agent responses are **collected and aggregated** (Fan-in)
### Components
| Component | Type | Description |
|-----------|------|-------------|
| ParseQuestion | Executor | Validates and formats the incoming question |
| Physicist | AI Agent | Azure OpenAI agent with physics expertise |
| Chemist | AI Agent | Azure OpenAI agent with chemistry expertise |
| Aggregator | Executor | Combines all AI agent responses |
## Workflow Pattern
```
???????????????????
? ParseQuestion ?
???????????????????
?
???????????????????????????????
? ?
? ?
??????????????????? ???????????????????
? Physicist ? ? Chemist ?
? (AI Agent) ? ? (AI Agent) ?
??????????????????? ???????????????????
? ?
? PARALLEL EXECUTION ?
? ?
???????????????????????????????
?
?
???????????????????
? Aggregator ?
???????????????????
```
## Key Concepts Demonstrated
- **Fan-out (AddFanOutEdge)**: One executor sends to multiple AI agents in parallel
- **Fan-in (AddFanInEdge)**: Multiple AI agent results are collected into an array
- **AI Agents in Workflows**: Using Azure OpenAI agents as workflow executors
- **Durability**: If interrupted, completed AI agent responses are preserved on restart
## Configuration
When using AI agents in durable workflows, `ConfigureDurableWorkflows` automatically registers any AI agents found in the workflow. You only need a single configuration call:
```csharp
services.ConfigureDurableWorkflows(
options => options.Workflows.AddWorkflow(expertReview),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
```
This is similar to how `ConfigureDurableOptions` works in Azure Functions samples.
## Environment Setup
This sample requires:
1. **Durable Task Scheduler** - See the [parent README](../README.md) for setup instructions
2. **Azure OpenAI** - You need an Azure OpenAI resource with a deployed model
### Environment Variables
| Variable | Description |
|----------|-------------|
| `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` | Connection string for DTS (defaults to local emulator) |
| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL |
| `AZURE_OPENAI_DEPLOYMENT` | Name of your deployed model (e.g., `gpt-4`) |
| `AZURE_OPENAI_KEY` | (Optional) API key - if not set, uses Azure CLI credential |
## Running the Sample
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/09_Workflow_Concurrency
dotnet run --framework net10.0
```
### Sample Session
```text
??????????????????????????????????????????????????????????????????????
? Fan-out/Fan-in Workflow with AI Agents ?
??????????????????????????????????????????????????????????????????????
? This sample demonstrates parallel AI agent consultation. ?
? ?
? Workflow: Question ? [Physicist, Chemist] ? Aggregator ?
? (AI agents run in parallel) ?
??????????????????????????????????????????????????????????????????????
?? TIP: Stop during execution to test durability - completed agent responses are preserved!
? Checking for pending workflows...
Enter a science question (or 'exit' to quit):
Example: "What is water?"
Question: What is water
Starting expert review workflow...
Instance ID: abc123-def456-...
???????????????????????????????????????????????????????????????????
? [ParseQuestion] Preparing question for AI agents...
? [ParseQuestion] Question: "What is water?"
? [ParseQuestion] ? Sending to Physicist and Chemist in PARALLEL...
???????????????????????????????????????????????????????????????????
... (AI agents process in parallel)
???????????????????????????????????????????????????????????????????
? [Aggregator] ?? Received 2 AI agent responses
? [Aggregator] Combining into comprehensive answer...
? [Aggregator] ? Aggregation complete!
???????????????????????????????????????????????????????????????????
??????????????????????????????????????????????????????????????????????
? ? Expert review completed! ?
??????????????????????????????????????????????????????????????????????
???????????????????????????????????????????????????????????????
AI EXPERT PANEL RESPONSES
???????????????????????????????????????????????????????????????
?? PHYSICIST:
Water is H2O - two hydrogen atoms bonded to one oxygen atom. From a physics
perspective, water exhibits unique properties like high specific heat capacity
and surface tension due to hydrogen bonding.
?? CHEMIST:
Water (H2O) is a polar molecule formed by covalent bonds. Its bent molecular
geometry creates a dipole moment, making it an excellent solvent for ionic
and polar compounds.
???????????????????????????????????????????????????????????????
Summary: Received perspectives from 2 AI experts.
???????????????????????????????????????????????????????????????
```
## Durability with AI Agents
If you stop the application while one AI agent is processing:
- The completed agent's response is **preserved** (cached by Durable Task)
- On restart, only the incomplete agent **re-runs**
- The aggregator waits for all agents to complete
## Related Samples
- [08_SingleWorkflow](../08_SingleWorkflow) - Sequential workflow with durability demonstration
- [10_WorkflowConcurrent](../../AzureFunctions/10_WorkflowConcurrent) - Azure Functions version
- [02_AgentOrchestration_Chaining](../02_AgentOrchestration_Chaining) - Agent chaining with durable orchestration
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SingleWorkflow</AssemblyName>
<RootNamespace>SingleAgent</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.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName);
public record ApprovalResponse(bool Approved, string? Comments);
internal sealed class CreateApprovalRequest() : Executor<string, ApprovalRequest>("RetrieveRequest")
{
public override async ValueTask<ApprovalRequest> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Get request details from db.
return new ApprovalRequest(message, 1500.00m, "Jerry");
}
}
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse, string>("Reimburse")
{
public override async ValueTask<string> HandleAsync(ApprovalResponse message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Simulate payment processing.
await Task.Delay(1000, cancellationToken);
return $"Expense reimbursed at {DateTime.Now.ToUniversalTime()}";
}
}
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a Human-in-the-Loop (HITL) workflow using Durable Tasks.
// The workflow creates an expense approval request, waits for manager approval via an external event,
// and then processes the expense reimbursement based on the approval response.
// This sample mirrors the pattern used in the in-process HumanInTheLoopBasic sample.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SingleAgent;
// 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
CreateApprovalRequest createRequest = new();
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
ExpenseReimburse reimburse = new();
Workflow expenseApproval = new WorkflowBuilder(createRequest)
.WithName("ExpenseReImbursement")
.WithDescription("Expense ReImbursement")
.AddEdge(createRequest, managerApproval)
.AddEdge(managerApproval, reimburse)
.Build();
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
options => options.Workflows.AddWorkflow(expenseApproval),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
// Get services
DurableTaskClient durableClient = host.Services.GetRequiredService<DurableTaskClient>();
// Start the workflow with an expense ID as input
string expenseId = "EXP-2025-001";
Console.WriteLine($"Starting expense reimbursement workflow for expense: {expenseId}");
// Start the workflow and get a streaming handle
await using DurableStreamingRun run = await DurableWorkflow.StreamAsync(expenseApproval, expenseId, durableClient);
Console.WriteLine($"Workflow started with instance ID: {run.InstanceId}");
Console.WriteLine("Watching for workflow events...\n");
// Watch for workflow events - similar pattern to InProcessExecution.StreamAsync
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case DurableRequestInfoEvent requestEvent:
// Handle request for external input (human-in-the-loop)
Console.WriteLine($"Workflow is waiting for input at RequestPort: {requestEvent.RequestPortId}");
Console.WriteLine($" Input data: {requestEvent.Input}");
Console.WriteLine($" Expected response type: {requestEvent.ResponseType}");
// Simulate manager approval
ApprovalResponse response = HandleApprovalRequest(requestEvent);
await run.SendResponseAsync(requestEvent, response);
Console.WriteLine($" Response sent: Approved={response.Approved}\n");
break;
case DurableWorkflowCompletedEvent completedEvent:
// The workflow has completed
Console.WriteLine($"Workflow completed with result: {completedEvent.Result}");
break;
case DurableWorkflowFailedEvent failedEvent:
// The workflow has failed
Console.WriteLine($"Workflow failed: {failedEvent.ErrorMessage}");
break;
}
}
Console.ReadLine();
await host.StopAsync();
// Handler for approval requests - similar to HandleExternalRequest in the in-process sample
static ApprovalResponse HandleApprovalRequest(DurableRequestInfoEvent requestEvent)
{
// In a real scenario, this would involve human interaction (e.g., a web UI)
// For this sample, we simulate automatic approval
ApprovalRequest? request = requestEvent.GetInputAs<ApprovalRequest>();
if (request is not null)
{
Console.WriteLine($" Approval request for: {request.EmployeeName}, Amount: {request.Amount:C}");
}
return new ApprovalResponse(Approved: true, Comments: "Approved by manager. Looks good!");
}
@@ -0,0 +1,96 @@
# Workflow Human-in-the-Loop (HITL) Sample
This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution to wait for external input (e.g., manager approval) and resumes when the response is provided.
## Overview
The sample implements an expense approval workflow:
1. **CreateApprovalRequest** - Retrieves expense details and creates an approval request
2. **ManagerApproval** (RequestPort) - Pauses workflow to wait for manager approval
3. **ExpenseReimburse** - Processes the reimbursement based on approval response
## Workflow Flow
```
User Input (Expense ID)
|
v
+---------------------+
| CreateApprovalRequest| Creates ApprovalRequest with expense details
+---------------------+
|
v
+---------------------+
| ManagerApproval | RequestPort - PAUSES here waiting for external input
| (RequestPort) | Workflow is durable while waiting
+---------------------+
|
v (ApprovalResponse)
+---------------------+
| ExpenseReimburse | Processes reimbursement if approved
+---------------------+
|
v
Result
```
## Key Concepts
- **RequestPort** - A special executor that pauses the workflow and waits for external input
- **DurableRequestInfoEvent** - Event emitted when the workflow reaches a RequestPort
- **SendResponseAsync** - Method to provide the response and resume the workflow
- **Durability** - The workflow can survive process restarts while waiting for human input
## Code Highlights
### Defining the RequestPort
```csharp
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval =
RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
```
### Handling the Request Event
```csharp
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case DurableRequestInfoEvent requestEvent:
// Workflow is waiting for input
ApprovalResponse response = HandleApprovalRequest(requestEvent);
await run.SendResponseAsync(requestEvent, response);
break;
// ... other events
}
}
```
## Environment Setup
See the [README.md](../README.md) file in the parent directory for environment configuration.
## Running the Sample
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/10_Workflow_HITL
dotnet run --framework net10.0
```
### Sample Output
```
Starting expense reimbursement workflow for expense: EXP-2025-001
Workflow started with instance ID: abc123...
Watching for workflow events...
Workflow is waiting for input at RequestPort: ManagerApproval
Input data: {"ExpenseId":"EXP-2025-001","Amount":1500.00,"EmployeeName":"Jerry"}
Expected response type: SingleAgent.ApprovalResponse
Approval request for: Jerry, Amount: $1,500.00
Response sent: Approved=True
Workflow completed with result: Expense reimbursed at 1/23/2025 5:30:00 PM
```
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SingleWorkflow</AssemblyName>
<RootNamespace>SingleAgent</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.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
/// <summary>
/// Event emitted to report cancellation progress.
/// </summary>
public sealed class CancellationProgressEvent(string orderId, int percentComplete, string status)
: WorkflowEvent($"Cancellation {percentComplete}%: {status}")
{
public string OrderId { get; } = orderId;
public int PercentComplete { get; } = percentComplete;
public string Status { get; } = status;
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
/// <summary>
/// Event emitted when an email is sent.
/// </summary>
public sealed class EmailSentEvent(string email, string subject) : WorkflowEvent($"Email sent to {email}")
{
public string Email { get; } = email;
public string Subject { get; } = subject;
}
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use IWorkflowContext methods in your executors:
// - AddEventAsync: Emit custom events that can be observed by the workflow caller
// - YieldOutputAsync: Stream intermediate outputs during execution
//
// These features enable rich observability and control over workflow execution.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
#region Domain Models
/// <summary>
/// Represents an order in the system.
/// </summary>
public sealed class Order
{
public required string Id { get; set; }
public DateTime OrderDate { get; set; }
public bool IsCancelled { get; set; }
public required Customer Customer { get; set; }
}
/// <summary>
/// Represents a customer associated with an order.
/// </summary>
public sealed class Customer
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
#endregion
#region Custom Workflow Events
#endregion
#region Executors
/// <summary>
/// Looks up an order by its ID. Demonstrates AddEventAsync for custom events.
/// </summary>
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
{
public override async ValueTask<Order> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
await context.AddEventAsync(new OrderLookupStartedEvent(message), cancellationToken);
await Task.Delay(500, cancellationToken);
Order order = new()
{
Id = message,
OrderDate = DateTime.UtcNow.AddDays(-3),
IsCancelled = false,
Customer = new Customer { Name = "Jerry", Email = "jerry@example.com" }
};
await context.AddEventAsync(new OrderFoundEvent(order), cancellationToken);
return order;
}
}
/// <summary>
/// Cancels an order with progress reporting.
/// Demonstrates AddEventAsync for progress events and YieldOutputAsync for streaming outputs.
/// </summary>
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
{
public override async ValueTask<Order> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// Simulate cancellation steps with progress events
string[] steps = ["Validating", "Processing refund", "Finalizing"];
for (int i = 0; i < steps.Length; i++)
{
await Task.Delay(500, cancellationToken);
int percent = (i + 1) * 33;
// Emit progress event (callers can observe this in real-time)
await context.AddEventAsync(new CancellationProgressEvent(message.Id, percent, steps[i]), cancellationToken);
// YieldOutputAsync streams intermediate results matching the executor's return type
await context.YieldOutputAsync(message, cancellationToken);
}
message.IsCancelled = true;
await context.AddEventAsync(new OrderCancelledEvent(message.Id), cancellationToken);
return message;
}
}
/// <summary>
/// Sends a cancellation confirmation email. Demonstrates AddEventAsync for completion events.
/// </summary>
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
{
public override async ValueTask<string> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
await Task.Delay(500, cancellationToken);
string email = message.Customer.Email;
await context.AddEventAsync(new EmailSentEvent(email, $"Order {message.Id} Cancelled"), cancellationToken);
return $"Email sent to {email}";
}
}
#endregion
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
/// <summary>
/// Event emitted when an order is successfully cancelled.
/// </summary>
public sealed class OrderCancelledEvent(string orderId) : WorkflowEvent($"Order {orderId} has been cancelled")
{
public string OrderId { get; } = orderId;
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
/// <summary>
/// Event emitted when an order is found.
/// </summary>
public sealed class OrderFoundEvent(Order order) : WorkflowEvent($"Found order {order.Id} for {order.Customer.Name}")
{
public Order Order { get; } = order;
}
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SingleAgent;
/// <summary>
/// Event emitted when an order lookup starts.
/// </summary>
public sealed class OrderLookupStartedEvent(string orderId) : WorkflowEvent($"Looking up order {orderId}")
{
public string OrderId { get; } = orderId;
}
@@ -0,0 +1,135 @@
// Copyright (c) Microsoft. All rights reserved.
// ═══════════════════════════════════════════════════════════════════════════════
// SAMPLE: Workflow Events and IWorkflowContext Features
// ═══════════════════════════════════════════════════════════════════════════════
//
// This sample demonstrates how to use IWorkflowContext methods in executors:
//
// 1. AddEventAsync - Emit custom events that callers can observe in real-time
// 2. YieldOutputAsync - Stream intermediate outputs during long-running operations
//
// The sample uses DurableWorkflow.StreamAsync to observe events as they occur,
// showing how callers can receive real-time updates from the workflow.
//
// Workflow: OrderLookup -> OrderCancel -> SendEmail
// ═══════════════════════════════════════════════════════════════════════════════
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SingleAgent;
// 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 and build workflow
OrderLookup orderLookup = new();
OrderCancel orderCancel = new();
SendEmail sendEmail = new();
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
.WithName("CancelOrder")
.WithDescription("Cancel an order and notify the customer")
.AddEdge(orderLookup, orderCancel)
.AddEdge(orderCancel, sendEmail)
.Build();
// Configure host with durable workflow support
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
options => options.Workflows.AddWorkflow(cancelOrder),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
DurableTaskClient durableClient = host.Services.GetRequiredService<DurableTaskClient>();
Console.WriteLine("Workflow Events Demo - Enter order ID (or 'exit'):");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
await RunWorkflowWithStreamingAsync(input, cancelOrder, durableClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
// Runs a workflow and streams events as they occur
async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, DurableTaskClient client)
{
// StreamAsync starts the workflow and returns a handle for observing events
await using DurableStreamingRun run = await DurableWorkflow.StreamAsync(workflow, orderId, client);
Console.WriteLine($"Started: {run.InstanceId}");
// WatchStreamAsync yields events as they're emitted by executors
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
// Custom domain events (emitted via AddEventAsync)
case OrderLookupStartedEvent e:
WriteColored($" [Lookup] Looking up order {e.OrderId}", ConsoleColor.Cyan);
break;
case OrderFoundEvent e:
WriteColored($" [Lookup] Found: {e.Order.Customer.Name}", ConsoleColor.Cyan);
break;
case CancellationProgressEvent e:
WriteColored($" [Cancel] {e.PercentComplete}% - {e.Status}", ConsoleColor.Yellow);
break;
case OrderCancelledEvent e:
WriteColored(" [Cancel] Done", ConsoleColor.Yellow);
break;
case EmailSentEvent e:
WriteColored($" [Email] Sent to {e.Email}", ConsoleColor.Magenta);
break;
// Yielded outputs (emitted via YieldOutputAsync)
case DurableYieldedOutputEvent e:
WriteColored($" [Output] {e.ExecutorId}", ConsoleColor.DarkGray);
break;
// Workflow completion
case DurableWorkflowCompletedEvent e:
WriteColored($"Completed: {e.Result}", ConsoleColor.Green);
break;
case DurableWorkflowFailedEvent e:
WriteColored($"Failed: {e.ErrorMessage}", ConsoleColor.Red);
break;
}
}
}
void WriteColored(string message, ConsoleColor color)
{
Console.ForegroundColor = color;
Console.WriteLine(message);
Console.ResetColor();
}
@@ -0,0 +1,156 @@
# Single Workflow Console Sample
This sample demonstrates how to run a 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 can be resumed without re-executing completed activities.
## Overview
The sample implements an order cancellation workflow with three executors, each with artificial delays to simulate real-world operations:
1. **OrderLookup** (2 seconds) - Looks up an order by its ID
2. **OrderCancel** (5 seconds) - Marks the order as cancelled
3. **SendEmail** (1 second) - 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 are replayed**: On restart, the orchestration replays from the beginning
- **Completed activities are skipped**: The framework uses cached results for completed activities
- **Failed activities are retried**: If an activity was interrupted, it runs again
- **Automatic resume**: When the worker starts, it automatically picks up any pending work!
### Try It Yourself
1. Start the application and enter an order ID (e.g., `12345`)
2. Stop the app (Ctrl+C or stop debugging) during the `OrderCancel` activity (5 seconds)
3. Restart the application
4. **Watch for automatic resume!** The worker automatically picks up the interrupted workflow
5. Observe that `OrderLookup` is NOT re-executed (its result was cached)
6. `OrderCancel` restarts from the beginning (it didn't complete)
7. `SendEmail` runs after `OrderCancel` completes
The durability is completely automatic - no manual intervention needed!
## Workflow Flow
```
User Input (Order ID)
?
?
???????????????????
? OrderLookup ? ? 2 second delay (database lookup)
? (2 seconds) ?
???????????????????
?
?
???????????????????
? OrderCancel ? ? 5 second delay - TRY INTERRUPTING HERE!
? (5 seconds) ?
???????????????????
?
?
???????????????????
? SendEmail ? ? 1 second delay (email sending)
? (1 second) ?
???????????????????
?
?
Result
```
## Key Concepts Demonstrated
- **ConfigureDurableWorkflows** - Simplified API for registering workflows
- **DurableExecution.RunAsync** - Start a new workflow (similar to InProcessExecution)
- **DurableRun** - Handle to monitor and interact with a running workflow
- **Automatic Resume** - Interrupted workflows continue automatically on restart
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
With the environment setup, you can run the sample:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/08_SingleWorkflow
dotnet run --framework net10.0
```
### Sample Session
```text
??????????????????????????????????????????????????????????????????????
? Durable Workflow Console Sample ?
??????????????????????????????????????????????????????????????????????
? This sample demonstrates durability in workflows. ?
? Workflow: OrderLookup (2s) -> OrderCancel (5s) -> SendEmail (1s) ?
??????????????????????????????????????????????????????????????????????
?? TIP: Stop the app during OrderCancel (5 seconds) to test durability!
Restart the app - it will automatically resume from where it left off.
? Checking for pending workflows...
Enter an order ID to start a new workflow (or 'exit' to quit):
Order ID: 12345
Starting workflow for order '12345'...
Instance ID: abc123-def456-...
???????????????????????????????????????????????????????????????????
? [Activity] OrderLookup: Starting lookup for order '12345'
? [Activity] OrderLookup: Found order '12345' for customer 'Jerry'
???????????????????????????????????????????????????????????????????
???????????????????????????????????????????????????????????????????
? [Activity] OrderCancel: Starting cancellation for order '12345'
? [Activity] OrderCancel: ?? This takes 5 seconds - try Ctrl+C!
? [Activity] OrderCancel: Processing... 1/5 seconds
? [Activity] OrderCancel: Processing... 2/5 seconds
^C <-- User stops the app here
[After restart...]
? Checking for pending workflows...
???????????????????????????????????????????????????????????????????
? [Activity] OrderCancel: Starting cancellation for order '12345' <-- Auto-resumed!
? [Activity] OrderCancel: ?? This takes 5 seconds - try Ctrl+C!
? [Activity] OrderCancel: Processing... 1/5 seconds
...
? [Activity] OrderCancel: ? Order '12345' has been cancelled
???????????????????????????????????????????????????????????????????
???????????????????????????????????????????????????????????????????
? [Activity] SendEmail: Sending email to 'jerry@example.com'...
? [Activity] SendEmail: ? Email sent successfully!
???????????????????????????????????????????????????????????????????
Enter an order ID to start a new workflow (or 'exit' to quit):
Order ID: _
```
Notice that when resumed:
- `OrderLookup` was **NOT re-executed** (result was cached by Durable Task)
- `OrderCancel` **restarted automatically** (it was interrupted before completing)
- `SendEmail` ran normally after `OrderCancel` completed
## Viewing Workflow State
You can view the state of the workflow in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view the state of the orchestration, including activity history and current state
## Related Samples
- [01_SingleAgent](../01_SingleAgent) - Single agent console sample
- [02_AgentOrchestration_Chaining](../02_AgentOrchestration_Chaining) - Agent chaining with durable orchestration
- [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) - Human-in-the-loop orchestration
- [09_Workflow](../../AzureFunctions/09_Workflow) - Azure Functions version of workflow hosting
@@ -1,269 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Entities;
using Microsoft.DurableTask.Entities;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// An implementation of <see cref="IWorkflowContext"/> for workflow executors running as durable activities.
/// Provides durable state management using Durable Entities. State is scoped to the orchestration instance
/// and shared between executors running on potentially different compute instances.
/// </summary>
/// <remarks>
/// State operations use GetEntityAsync for reads (fetches current entity state) and SignalEntityAsync
/// for writes. Since activities run sequentially in the orchestration and entity signals are processed
/// in order, state consistency is maintained across executors.
/// </remarks>
[RequiresUnreferencedCode("State serialization uses reflection-based JSON serialization.")]
[RequiresDynamicCode("State serialization uses reflection-based JSON serialization.")]
public sealed class DurableExecutorContext : IWorkflowContext
{
private readonly string _instanceId;
private readonly DurableTaskClient _client;
private readonly Dictionary<string, string?> _pendingUpdates = [];
private readonly HashSet<string> _clearedScopes = [];
/// <summary>
/// Initializes a new instance of the <see cref="DurableExecutorContext"/> class.
/// </summary>
/// <param name="instanceId">The orchestration instance ID used to scope the state entity.</param>
/// <param name="client">The durable task client for entity operations.</param>
public DurableExecutorContext(string instanceId, DurableTaskClient client)
{
this._instanceId = instanceId;
this._client = client;
}
/// <inheritdoc/>
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
{
// In activity context, events are not propagated to the workflow
return default;
}
/// <inheritdoc/>
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
{
// In activity context, messages cannot be routed to other executors
return default;
}
/// <inheritdoc/>
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
{
// In activity context, outputs are not yielded to the workflow
return default;
}
/// <inheritdoc/>
public ValueTask RequestHaltAsync()
{
// Halt requests are not supported in activity context
return default;
}
/// <inheritdoc/>
public async ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
// 1. Check pending updates first (read-your-writes within this activity)
if (this._pendingUpdates.TryGetValue(scopeKey, out string? pendingValue))
{
return pendingValue is null ? default : JsonSerializer.Deserialize<T>(pendingValue);
}
// 2. Check if the scope was cleared in this activity
string normalizedScope = scopeName ?? "__default__";
if (this._clearedScopes.Contains(normalizedScope))
{
return default;
}
// 3. Read from the durable entity
EntityInstanceId entityId = this.GetStateEntityId();
EntityMetadata? metadata = await this._client.Entities
.GetEntityAsync(entityId, includeState: true, cancellation: cancellationToken)
.ConfigureAwait(false);
if (metadata?.IncludesState != true)
{
return default;
}
WorkflowStateData? stateData = metadata.State.ReadAs<WorkflowStateData>();
if (stateData?.Values is null)
{
return default;
}
if (stateData.Values.TryGetValue(scopeKey, out string? serializedValue) && serializedValue is not null)
{
return JsonSerializer.Deserialize<T>(serializedValue);
}
return default;
}
/// <inheritdoc/>
public async ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
T? value = await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false);
if (value is not null)
{
return value;
}
// Initialize with factory value and write to entity
T initialValue = initialStateFactory();
await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false);
return initialValue;
}
/// <inheritdoc/>
public async ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
string normalizedScope = scopeName ?? "__default__";
string scopePrefix = GetScopePrefix(scopeName);
HashSet<string> keys = [];
// If scope was cleared, only return keys from pending updates
if (this._clearedScopes.Contains(normalizedScope))
{
return this.GetPendingKeysForScope(scopeName);
}
// Read keys from the durable entity
EntityInstanceId entityId = this.GetStateEntityId();
EntityMetadata? metadata = await this._client.Entities
.GetEntityAsync(entityId, includeState: true, cancellation: cancellationToken)
.ConfigureAwait(false);
if (metadata?.IncludesState == true)
{
WorkflowStateData? stateData = metadata.State.ReadAs<WorkflowStateData>();
if (stateData?.Values is not null)
{
foreach (string scopeKey in stateData.Values.Keys)
{
if (scopeKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
string foundKey = scopeKey[scopePrefix.Length..];
keys.Add(foundKey);
}
}
}
}
// Merge with pending updates
foreach (KeyValuePair<string, string?> pending in this._pendingUpdates)
{
if (pending.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
{
string foundKey = pending.Key[scopePrefix.Length..];
if (pending.Value is not null)
{
keys.Add(foundKey);
}
else
{
keys.Remove(foundKey);
}
}
}
return keys;
}
/// <inheritdoc/>
public async ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
string? serializedValue = value is null ? null : JsonSerializer.Serialize(value);
// Store locally for read-your-writes within this activity
this._pendingUpdates[scopeKey] = serializedValue;
// Write to the durable entity via signal
// Since activities run sequentially and signals are processed in order,
// the next activity will see this update when it reads from the entity
EntityInstanceId entityId = this.GetStateEntityId();
WorkflowStateWriteRequest request = new() { Key = key, ScopeName = scopeName, Value = serializedValue };
await this._client.Entities
.SignalEntityAsync(entityId, nameof(WorkflowSharedStateEntity.WriteState), request, cancellation: cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
string normalizedScope = scopeName ?? "__default__";
this._clearedScopes.Add(normalizedScope);
// Remove pending updates in this scope
string scopePrefix = GetScopePrefix(scopeName);
List<string> keysToRemove = this._pendingUpdates.Keys
.Where(k => k.StartsWith(scopePrefix, StringComparison.Ordinal))
.ToList();
foreach (string key in keysToRemove)
{
this._pendingUpdates.Remove(key);
}
// Clear in the durable entity via signal
EntityInstanceId entityId = this.GetStateEntityId();
await this._client.Entities
.SignalEntityAsync(entityId, nameof(WorkflowSharedStateEntity.ClearScope), scopeName, cancellation: cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, string>? TraceContext => null;
/// <inheritdoc/>
public bool ConcurrentRunsEnabled => false;
private EntityInstanceId GetStateEntityId()
{
// Entity is keyed by orchestration instance ID for isolation between runs
return new EntityInstanceId(WorkflowSharedStateEntity.EntityName, this._instanceId);
}
private HashSet<string> GetPendingKeysForScope(string? scopeName)
{
string scopePrefix = GetScopePrefix(scopeName);
HashSet<string> keys = [];
foreach (KeyValuePair<string, string?> pending in this._pendingUpdates)
{
if (pending.Key.StartsWith(scopePrefix, StringComparison.Ordinal) && pending.Value is not null)
{
string key = pending.Key[scopePrefix.Length..];
keys.Add(key);
}
}
return keys;
}
private static string GetScopeKey(string? scopeName, string key)
{
return $"{GetScopePrefix(scopeName)}{key}";
}
private static string GetScopePrefix(string? scopeName)
{
return scopeName is null ? "__default__:" : $"{scopeName}:";
}
}
@@ -20,7 +20,7 @@ public sealed class DurableOptions
/// <summary>
/// Initializes a new instance of the <see cref="DurableOptions"/> class.
/// </summary>
internal DurableOptions()
public DurableOptions()
{
this.Workflows = new DurableWorkflowOptions(this);
}
@@ -0,0 +1,283 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Represents a durable workflow run that tracks execution status and provides access to workflow events.
/// </summary>
/// <remarks>
/// This class provides a similar API to <see cref="Run"/> but for workflows executed as durable orchestrations.
/// Events are received by raising external events to the orchestration and can be streamed to the caller.
/// </remarks>
public sealed class DurableRun : IAsyncDisposable
{
private readonly DurableTaskClient _client;
private readonly List<WorkflowEvent> _eventSink = [];
private int _lastBookmark;
internal DurableRun(DurableTaskClient client, string instanceId, string workflowName)
{
this._client = client;
this.InstanceId = instanceId;
this.WorkflowName = workflowName;
}
/// <summary>
/// Gets the unique instance ID for this orchestration run.
/// </summary>
public string InstanceId { get; }
/// <summary>
/// Gets the name of the workflow being executed.
/// </summary>
public string WorkflowName { get; }
/// <summary>
/// Gets the current execution status of the workflow run.
/// </summary>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The current status of the durable run.</returns>
public async ValueTask<DurableRunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
{
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
this.InstanceId,
getInputsAndOutputs: false,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata is null)
{
return DurableRunStatus.NotFound;
}
return metadata.RuntimeStatus switch
{
OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending,
OrchestrationRuntimeStatus.Running => DurableRunStatus.Running,
OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed,
OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed,
OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated,
OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended,
_ => DurableRunStatus.Unknown
};
}
/// <summary>
/// Waits for the workflow to complete and returns the result.
/// </summary>
/// <typeparam name="TResult">The expected result type.</typeparam>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The result of the workflow execution.</returns>
/// <exception cref="InvalidOperationException">Thrown when the workflow failed or was terminated.</exception>
public async ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default)
{
OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync(
this.InstanceId,
getInputsAndOutputs: true,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
return metadata.ReadOutputAs<TResult>();
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
throw new InvalidOperationException(errorMessage);
}
throw new InvalidOperationException($"Workflow ended with unexpected status: {metadata.RuntimeStatus}");
}
/// <summary>
/// Waits for the workflow to complete and returns the string result.
/// </summary>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The string result of the workflow execution.</returns>
public ValueTask<string?> WaitForCompletionAsync(CancellationToken cancellationToken = default)
=> this.WaitForCompletionAsync<string>(cancellationToken);
/// <summary>
/// Sends an external event to the workflow orchestration.
/// </summary>
/// <remarks>
/// This can be used to send responses or messages to the workflow while it's running.
/// The orchestration must be waiting for the event using <c>WaitForExternalEvent</c>.
/// </remarks>
/// <param name="eventName">The name of the event to raise.</param>
/// <param name="eventData">The data to send with the event.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
#pragma warning disable CA1030 // Use events where appropriate - This is intentionally a method that sends events to an orchestration
public async ValueTask SendExternalEventAsync(string eventName, object? eventData = null, CancellationToken cancellationToken = default)
#pragma warning restore CA1030
{
await this._client.RaiseEventAsync(
this.InstanceId,
eventName,
eventData,
cancellation: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a workflow event to the orchestration.
/// </summary>
/// <param name="workflowEvent">The workflow event to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
public ValueTask SendEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
=> this.SendExternalEventAsync("WorkflowEvent", workflowEvent, cancellationToken);
/// <summary>
/// Sends an external response to the workflow.
/// </summary>
/// <param name="response">The external response to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
public ValueTask SendResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default)
=> this.SendExternalEventAsync("ExternalResponse", response, cancellationToken);
/// <summary>
/// Sends a response to a pending request port in the workflow (human-in-the-loop).
/// </summary>
/// <remarks>
/// The response is serialized to JSON before being sent to match what the orchestration expects.
/// Use this method when responding to a <see cref="RequestPort"/> that is waiting for external input.
/// </remarks>
/// <typeparam name="TResponse">The type of the response data.</typeparam>
/// <param name="requestPortId">The ID of the request port to respond to.</param>
/// <param name="response">The response data to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
public ValueTask SendResponseAsync<TResponse>(string requestPortId, TResponse response, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(requestPortId);
// Serialize the response to JSON string - the orchestration expects a string via WaitForExternalEvent<string>
string serializedResponse = JsonSerializer.Serialize(response);
return this.SendExternalEventAsync(requestPortId, serializedResponse, cancellationToken);
}
/// <summary>
/// Gets all events that have been collected from the workflow.
/// </summary>
public IEnumerable<WorkflowEvent> OutgoingEvents => this._eventSink;
/// <summary>
/// Gets the number of events collected since the last access to <see cref="NewEvents"/>.
/// </summary>
public int NewEventCount => this._eventSink.Count - this._lastBookmark;
/// <summary>
/// Gets all events collected since the last access to <see cref="NewEvents"/>.
/// </summary>
public IEnumerable<WorkflowEvent> NewEvents
{
get
{
if (this._lastBookmark >= this._eventSink.Count)
{
return [];
}
int currentBookmark = this._lastBookmark;
this._lastBookmark = this._eventSink.Count;
return this._eventSink.Skip(currentBookmark);
}
}
/// <summary>
/// Adds an event to the local event sink.
/// </summary>
/// <remarks>
/// This is used internally to collect events raised by the orchestration.
/// In the durable scenario, events are typically returned as part of the orchestration output
/// or raised via external events.
/// </remarks>
/// <param name="workflowEvent">The event to add.</param>
internal void AddEvent(WorkflowEvent workflowEvent)
{
this._eventSink.Add(workflowEvent);
}
/// <summary>
/// Terminates the workflow orchestration.
/// </summary>
/// <param name="reason">An optional reason for the termination.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
public async ValueTask TerminateAsync(string? reason = null, CancellationToken cancellationToken = default)
{
await this._client.TerminateInstanceAsync(
this.InstanceId,
reason,
cancellation: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Purges the orchestration instance history.
/// </summary>
/// <param name="cancellationToken">A cancellation token to observe.</param>
public async ValueTask PurgeAsync(CancellationToken cancellationToken = default)
{
await this._client.PurgeInstanceAsync(
this.InstanceId,
cancellation: cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public ValueTask DisposeAsync()
{
// Nothing to dispose for durable runs - the orchestration continues independently
return default;
}
}
/// <summary>
/// Represents the execution status of a durable workflow run.
/// </summary>
public enum DurableRunStatus
{
/// <summary>
/// The orchestration instance was not found.
/// </summary>
NotFound,
/// <summary>
/// The orchestration is pending and has not started.
/// </summary>
Pending,
/// <summary>
/// The orchestration is currently running.
/// </summary>
Running,
/// <summary>
/// The orchestration completed successfully.
/// </summary>
Completed,
/// <summary>
/// The orchestration failed with an error.
/// </summary>
Failed,
/// <summary>
/// The orchestration was terminated.
/// </summary>
Terminated,
/// <summary>
/// The orchestration is suspended.
/// </summary>
Suspended,
/// <summary>
/// The orchestration status is unknown.
/// </summary>
Unknown
}
@@ -0,0 +1,619 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Represents a durable workflow run that supports streaming workflow events as they occur.
/// </summary>
/// <remarks>
/// This class provides a similar API to <see cref="StreamingRun"/> but for workflows executed as durable orchestrations.
/// Events are detected by monitoring the orchestration status for <see cref="RequestPort"/> executors that are waiting
/// for external input (human-in-the-loop scenarios).
/// </remarks>
public sealed class DurableStreamingRun : IAsyncDisposable
{
private readonly DurableTaskClient _client;
private readonly Workflow _workflow;
private readonly List<RequestPort> _requestPorts;
internal DurableStreamingRun(DurableTaskClient client, string instanceId, Workflow workflow)
{
this._client = client;
this.InstanceId = instanceId;
this._workflow = workflow;
// Extract RequestPorts from the workflow for event detection
this._requestPorts = ExtractRequestPorts(workflow);
}
/// <summary>
/// Gets the unique instance ID for this orchestration run.
/// </summary>
public string InstanceId { get; }
/// <summary>
/// Gets the name of the workflow being executed.
/// </summary>
public string WorkflowName => this._workflow.Name ?? string.Empty;
/// <summary>
/// Gets the request ports defined in the workflow.
/// </summary>
public IReadOnlyList<RequestPort> RequestPorts => this._requestPorts;
/// <summary>
/// Gets the current execution status of the workflow run.
/// </summary>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The current status of the durable run.</returns>
public async ValueTask<DurableRunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
{
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
this.InstanceId,
getInputsAndOutputs: false,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata is null)
{
return DurableRunStatus.NotFound;
}
return metadata.RuntimeStatus switch
{
OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending,
OrchestrationRuntimeStatus.Running => DurableRunStatus.Running,
OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed,
OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed,
OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated,
OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended,
_ => DurableRunStatus.Unknown
};
}
/// <summary>
/// Asynchronously streams workflow events as they occur during workflow execution.
/// </summary>
/// <remarks>
/// <para>
/// This method monitors the durable orchestration and yields <see cref="WorkflowEvent"/> instances
/// when the workflow reaches points that require external input (human-in-the-loop scenarios).
/// </para>
/// <para>
/// When the orchestration reaches a <see cref="RequestPort"/> executor, a <see cref="DurableRequestInfoEvent"/>
/// is yielded containing the request data. The caller should then call <see cref="SendResponseAsync{TResponse}(DurableRequestInfoEvent, TResponse, CancellationToken)"/>
/// to provide the response and continue the workflow.
/// </para>
/// </remarks>
/// <param name="pollingInterval">The interval between status checks. Defaults to 500ms.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>An asynchronous stream of <see cref="WorkflowEvent"/> objects.</returns>
public async IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
TimeSpan? pollingInterval = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
TimeSpan interval = pollingInterval ?? TimeSpan.FromMilliseconds(500);
// Track which request ports we've already yielded events for and are waiting for response
// Key: EventName (RequestPort ID), Value: Input data (to detect if we're at a different invocation)
Dictionary<string, string> pendingRequests = [];
// Track how many events we've already read from custom status
int lastReadEventIndex = 0;
while (!cancellationToken.IsCancellationRequested)
{
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
this.InstanceId,
getInputsAndOutputs: true,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata is null)
{
yield break;
}
// Check if the orchestration has completed
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
yield return new DurableWorkflowCompletedEvent(metadata.SerializedOutput);
yield break;
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
yield return new DurableWorkflowFailedEvent(errorMessage);
yield break;
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated)
{
yield return new DurableWorkflowFailedEvent("Workflow was terminated.");
yield break;
}
// Check custom status for events and pending external events
if (metadata.SerializedCustomStatus is not null)
{
DurableWorkflowCustomStatus? customStatus = TryParseCustomStatus(metadata.SerializedCustomStatus);
if (customStatus is not null)
{
// Yield any new events from executors
while (lastReadEventIndex < customStatus.Events.Count)
{
string serializedEvent = customStatus.Events[lastReadEventIndex];
lastReadEventIndex++;
WorkflowEvent? workflowEvent = TryDeserializeEvent(serializedEvent);
if (workflowEvent is not null)
{
yield return workflowEvent;
}
}
// Check for pending external event (HITL)
if (customStatus.PendingEvent is not null)
{
PendingExternalEventStatus pendingStatus = customStatus.PendingEvent;
string eventName = pendingStatus.EventName;
string inputData = pendingStatus.Input;
// Only yield a new event if:
// 1. We haven't seen this event name before, OR
// 2. The input data is different (meaning this is a new invocation of the same RequestPort)
bool shouldYield = !pendingRequests.TryGetValue(eventName, out string? previousInput)
|| previousInput != inputData;
if (shouldYield)
{
pendingRequests[eventName] = inputData;
// Find the matching RequestPort
RequestPort? requestPort = this._requestPorts.Find(p => p.Id == eventName);
yield return new DurableRequestInfoEvent(
RequestPortId: eventName,
Input: inputData,
RequestType: pendingStatus.RequestType,
ResponseType: pendingStatus.ResponseType,
RequestPort: requestPort);
}
}
}
else
{
// Try parsing as legacy PendingExternalEventStatus for backward compatibility
PendingExternalEventStatus? pendingStatus = TryParsePendingStatus(metadata.SerializedCustomStatus);
if (pendingStatus is not null)
{
string eventName = pendingStatus.EventName;
string inputData = pendingStatus.Input;
bool shouldYield = !pendingRequests.TryGetValue(eventName, out string? previousInput)
|| previousInput != inputData;
if (shouldYield)
{
pendingRequests[eventName] = inputData;
RequestPort? requestPort = this._requestPorts.Find(p => p.Id == eventName);
yield return new DurableRequestInfoEvent(
RequestPortId: eventName,
Input: inputData,
RequestType: pendingStatus.RequestType,
ResponseType: pendingStatus.ResponseType,
RequestPort: requestPort);
}
}
}
}
else
{
// Custom status is null - the orchestration is not waiting for external input
// Clear any pending requests that were waiting (they've been processed)
pendingRequests.Clear();
}
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
}
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow custom status.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow custom status.")]
private static DurableWorkflowCustomStatus? TryParseCustomStatus(string serializedStatus)
{
try
{
return JsonSerializer.Deserialize<DurableWorkflowCustomStatus>(serializedStatus);
}
catch (JsonException)
{
return null;
}
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Event types are registered at startup and available at runtime.")]
private static WorkflowEvent? TryDeserializeEvent(string serializedEvent)
{
try
{
// First try to deserialize as SerializedWorkflowEvent (new format with type info)
DurableWorkflowRunner.SerializedWorkflowEvent? wrapper =
JsonSerializer.Deserialize<DurableWorkflowRunner.SerializedWorkflowEvent>(serializedEvent);
if (wrapper?.TypeName is not null && wrapper.Data is not null)
{
Type? eventType = Type.GetType(wrapper.TypeName);
if (eventType is not null)
{
// Use custom deserialization for event types with constructor parameter mismatches
return DeserializeEventByType(eventType, wrapper.Data);
}
}
// Fall back to deserializing as base WorkflowEvent (legacy format)
return JsonSerializer.Deserialize<WorkflowEvent>(serializedEvent);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Deserializes an event by type, handling constructor parameter name mismatches.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
private static WorkflowEvent? DeserializeEventByType(Type eventType, string json)
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
// Handle ExecutorInvokedEvent: constructor expects (executorId, message) but JSON has (ExecutorId, Data)
if (eventType == typeof(ExecutorInvokedEvent))
{
string executorId = root.GetProperty("ExecutorId").GetString() ?? string.Empty;
JsonElement? data = GetDataProperty(root);
return new ExecutorInvokedEvent(executorId, data!);
}
// Handle ExecutorCompletedEvent: constructor expects (executorId, result) but JSON has (ExecutorId, Data)
if (eventType == typeof(ExecutorCompletedEvent))
{
string executorId = root.GetProperty("ExecutorId").GetString() ?? string.Empty;
JsonElement? data = GetDataProperty(root);
return new ExecutorCompletedEvent(executorId, data);
}
// For other event types, try standard deserialization with case-insensitive options
return JsonSerializer.Deserialize(json, eventType, s_caseInsensitiveOptions) as WorkflowEvent;
}
// Cached JsonSerializerOptions for case-insensitive deserialization
private static readonly JsonSerializerOptions s_caseInsensitiveOptions = new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// Gets the Data property from a JSON element.
/// </summary>
private static JsonElement? GetDataProperty(JsonElement root)
{
if (!root.TryGetProperty("Data", out JsonElement dataElement))
{
return null;
}
if (dataElement.ValueKind == JsonValueKind.Null)
{
return null;
}
return dataElement.Clone();
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing known type PendingExternalEventStatus.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing known type PendingExternalEventStatus.")]
private static PendingExternalEventStatus? TryParsePendingStatus(string serializedStatus)
{
try
{
return JsonSerializer.Deserialize<PendingExternalEventStatus>(serializedStatus);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Sends an external event to the workflow orchestration.
/// </summary>
/// <param name="eventName">The name of the event to raise (typically the <see cref="RequestPort.Id"/>).</param>
/// <param name="eventData">The data to send with the event.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
#pragma warning disable CA1030 // Use events where appropriate
public async ValueTask SendExternalEventAsync(string eventName, object? eventData = null, CancellationToken cancellationToken = default)
#pragma warning restore CA1030
{
await this._client.RaiseEventAsync(
this.InstanceId,
eventName,
eventData,
cancellation: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a response to a pending request in the workflow.
/// </summary>
/// <remarks>
/// The response is serialized to JSON before being sent to match what the orchestration expects.
/// </remarks>
/// <typeparam name="TResponse">The type of the response data.</typeparam>
/// <param name="requestPortId">The ID of the request port to respond to.</param>
/// <param name="response">The response data to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
public ValueTask SendResponseAsync<TResponse>(string requestPortId, TResponse response, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(requestPortId);
// Serialize the response to JSON string - the orchestration expects a string via WaitForExternalEvent<string>
string serializedResponse = JsonSerializer.Serialize(response);
return this.SendExternalEventAsync(requestPortId, serializedResponse, cancellationToken);
}
/// <summary>
/// Sends a response to a <see cref="DurableRequestInfoEvent"/>.
/// </summary>
/// <remarks>
/// The response is serialized to JSON before being sent to match what the orchestration expects.
/// </remarks>
/// <typeparam name="TResponse">The type of the response data.</typeparam>
/// <param name="requestEvent">The request event to respond to.</param>
/// <param name="response">The response data to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
public ValueTask SendResponseAsync<TResponse>(DurableRequestInfoEvent requestEvent, TResponse response, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(requestEvent);
// Serialize the response to JSON string - the orchestration expects a string via WaitForExternalEvent<string>
string serializedResponse = JsonSerializer.Serialize(response);
return this.SendExternalEventAsync(requestEvent.RequestPortId, serializedResponse, cancellationToken);
}
/// <summary>
/// Waits for the workflow to complete and returns the result.
/// </summary>
/// <typeparam name="TResult">The expected result type.</typeparam>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The result of the workflow execution.</returns>
public async ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default)
{
OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync(
this.InstanceId,
getInputsAndOutputs: true,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
return metadata.ReadOutputAs<TResult>();
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
throw new InvalidOperationException(errorMessage);
}
throw new InvalidOperationException($"Workflow ended with unexpected status: {metadata.RuntimeStatus}");
}
/// <summary>
/// Waits for the workflow to complete and returns the string result.
/// </summary>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The string result of the workflow execution.</returns>
public ValueTask<string?> WaitForCompletionAsync(CancellationToken cancellationToken = default)
=> this.WaitForCompletionAsync<string>(cancellationToken);
/// <summary>
/// Terminates the workflow orchestration.
/// </summary>
/// <param name="reason">An optional reason for the termination.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
public async ValueTask TerminateAsync(string? reason = null, CancellationToken cancellationToken = default)
{
await this._client.TerminateInstanceAsync(
this.InstanceId,
reason,
cancellation: cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public ValueTask DisposeAsync()
{
// Nothing to dispose for durable runs - the orchestration continues independently
return default;
}
private static List<RequestPort> ExtractRequestPorts(Workflow workflow)
{
List<RequestPort> requestPorts = [];
foreach (WorkflowExecutorInfo executorInfo in WorkflowHelper.GetExecutorsFromWorkflowInOrder(workflow))
{
if (executorInfo.RequestPort is not null)
{
requestPorts.Add(executorInfo.RequestPort);
}
}
return requestPorts;
}
}
/// <summary>
/// Event raised when the durable workflow is waiting for external input at a <see cref="RequestPort"/>.
/// </summary>
/// <param name="RequestPortId">The ID of the request port waiting for input.</param>
/// <param name="Input">The serialized input data that was passed to the RequestPort.</param>
/// <param name="RequestType">The full type name of the request type.</param>
/// <param name="ResponseType">The full type name of the expected response type.</param>
/// <param name="RequestPort">The request port definition, if available.</param>
public sealed class DurableRequestInfoEvent(
string RequestPortId,
string Input,
string RequestType,
string ResponseType,
RequestPort? RequestPort) : WorkflowEvent(Input)
{
/// <summary>
/// Gets the ID of the request port waiting for input.
/// </summary>
public string RequestPortId { get; } = RequestPortId;
/// <summary>
/// Gets the serialized input data that was passed to the RequestPort.
/// </summary>
public string Input { get; } = Input;
/// <summary>
/// Gets the full type name of the request type.
/// </summary>
public string RequestType { get; } = RequestType;
/// <summary>
/// Gets the full type name of the expected response type.
/// </summary>
public string ResponseType { get; } = ResponseType;
/// <summary>
/// Gets the request port definition, if available.
/// </summary>
public RequestPort? RequestPort { get; } = RequestPort;
/// <summary>
/// Attempts to deserialize the input data to the specified type.
/// </summary>
/// <typeparam name="T">The type to deserialize to.</typeparam>
/// <returns>The deserialized input, or default if deserialization fails.</returns>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types provided by the caller.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types provided by the caller.")]
public T? GetInputAs<T>()
{
try
{
return JsonSerializer.Deserialize<T>(this.Input);
}
catch (JsonException)
{
return default;
}
}
}
/// <summary>
/// Event raised when a durable workflow completes successfully.
/// </summary>
public sealed class DurableWorkflowCompletedEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowCompletedEvent"/> class.
/// </summary>
/// <param name="result">The serialized result of the workflow.</param>
public DurableWorkflowCompletedEvent(string? result) : base(result)
{
this.Result = result;
}
/// <summary>
/// Gets the serialized result of the workflow.
/// </summary>
public string? Result { get; }
}
/// <summary>
/// Event raised when a durable workflow fails.
/// </summary>
public sealed class DurableWorkflowFailedEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowFailedEvent"/> class.
/// </summary>
/// <param name="errorMessage">The error message describing the failure.</param>
public DurableWorkflowFailedEvent(string errorMessage) : base(errorMessage)
{
this.ErrorMessage = errorMessage;
}
/// <summary>
/// Gets the error message describing the failure.
/// </summary>
public string ErrorMessage { get; }
}
/// <summary>
/// Event raised when an executor yields intermediate output via <see cref="IWorkflowContext.YieldOutputAsync"/>.
/// </summary>
/// <remarks>
/// This is the durable equivalent of <see cref="WorkflowOutputEvent"/> since that class has an internal
/// constructor not accessible from outside the Workflows assembly.
/// </remarks>
public sealed class DurableYieldedOutputEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableYieldedOutputEvent"/> class.
/// </summary>
/// <param name="executorId">The ID of the executor that yielded the output.</param>
/// <param name="output">The yielded output value.</param>
public DurableYieldedOutputEvent(string executorId, object output) : base(output)
{
this.ExecutorId = executorId;
this.Output = output;
}
/// <summary>
/// Gets the ID of the executor that yielded the output.
/// </summary>
public string ExecutorId { get; }
/// <summary>
/// Gets the yielded output value.
/// </summary>
public object Output { get; }
}
/// <summary>
/// Event raised when an executor requests the workflow to halt via <see cref="IWorkflowContext.RequestHaltAsync"/>.
/// </summary>
/// <remarks>
/// This is the durable equivalent of the internal RequestHaltEvent since that class is not accessible
/// from outside the Workflows assembly.
/// </remarks>
public sealed class DurableHaltRequestedEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableHaltRequestedEvent"/> class.
/// </summary>
/// <param name="executorId">The ID of the executor that requested the halt.</param>
public DurableHaltRequestedEvent(string executorId) : base($"Halt requested by {executorId}")
{
this.ExecutorId = executorId;
}
/// <summary>
/// Gets the ID of the executor that requested the halt.
/// </summary>
public string ExecutorId { get; }
}
@@ -0,0 +1,158 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Provides methods to run workflows as durable orchestrations.
/// </summary>
public static class DurableWorkflow
{
/// <summary>
/// Runs a workflow as a durable orchestration and returns a handle to monitor its execution.
/// </summary>
/// <typeparam name="TInput">The type of the input to the workflow.</typeparam>
/// <param name="workflow">The workflow to execute.</param>
/// <param name="input">The input to pass to the workflow's starting executor.</param>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <param name="instanceId">Optional instance ID for the orchestration. If not provided, a new ID will be generated.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>A <see cref="DurableRun"/> that can be used to monitor the workflow execution.</returns>
/// <exception cref="ArgumentNullException">Thrown when workflow or client is null.</exception>
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
public static async ValueTask<DurableRun> RunAsync<TInput>(
Workflow workflow,
TInput input,
DurableTaskClient client,
string? instanceId = null,
CancellationToken cancellationToken = default)
where TInput : notnull
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(client);
if (string.IsNullOrEmpty(workflow.Name))
{
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
}
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name);
string actualInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: orchestrationName,
input: input,
cancellation: cancellationToken).ConfigureAwait(false);
return new DurableRun(client, actualInstanceId, workflow.Name);
}
/// <summary>
/// Runs a workflow as a durable orchestration with string input.
/// </summary>
/// <param name="workflow">The workflow to execute.</param>
/// <param name="input">The string input to pass to the workflow.</param>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <param name="instanceId">Optional instance ID for the orchestration.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>A <see cref="DurableRun"/> that can be used to monitor the workflow execution.</returns>
public static ValueTask<DurableRun> RunAsync(
Workflow workflow,
string input,
DurableTaskClient client,
string? instanceId = null,
CancellationToken cancellationToken = default)
=> RunAsync<string>(workflow, input, client, instanceId, cancellationToken);
/// <summary>
/// Starts a workflow as a durable orchestration and returns a streaming handle to watch events.
/// </summary>
/// <typeparam name="TInput">The type of the input to the workflow.</typeparam>
/// <param name="workflow">The workflow to execute.</param>
/// <param name="input">The input to pass to the workflow's starting executor.</param>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <param name="instanceId">Optional instance ID for the orchestration. If not provided, a new ID will be generated.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>A <see cref="DurableStreamingRun"/> that can be used to stream workflow events.</returns>
/// <exception cref="ArgumentNullException">Thrown when workflow or client is null.</exception>
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
public static async ValueTask<DurableStreamingRun> StreamAsync<TInput>(
Workflow workflow,
TInput input,
DurableTaskClient client,
string? instanceId = null,
CancellationToken cancellationToken = default)
where TInput : notnull
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(client);
if (string.IsNullOrEmpty(workflow.Name))
{
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
}
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name);
string actualInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: orchestrationName,
input: input,
cancellation: cancellationToken).ConfigureAwait(false);
return new DurableStreamingRun(client, actualInstanceId, workflow);
}
/// <summary>
/// Starts a workflow as a durable orchestration with string input and returns a streaming handle.
/// </summary>
/// <param name="workflow">The workflow to execute.</param>
/// <param name="input">The string input to pass to the workflow.</param>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <param name="instanceId">Optional instance ID for the orchestration.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>A <see cref="DurableStreamingRun"/> that can be used to stream workflow events.</returns>
public static ValueTask<DurableStreamingRun> StreamAsync(
Workflow workflow,
string input,
DurableTaskClient client,
string? instanceId = null,
CancellationToken cancellationToken = default)
=> StreamAsync<string>(workflow, input, client, instanceId, cancellationToken);
/// <summary>
/// Attaches to an existing workflow orchestration instance.
/// </summary>
/// <param name="instanceId">The instance ID of the orchestration to attach to.</param>
/// <param name="workflowName">The name of the workflow being executed.</param>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <returns>A <see cref="DurableRun"/> that can be used to monitor the workflow execution.</returns>
public static DurableRun Attach(
string instanceId,
string workflowName,
DurableTaskClient client)
{
ArgumentException.ThrowIfNullOrEmpty(instanceId);
ArgumentException.ThrowIfNullOrEmpty(workflowName);
ArgumentNullException.ThrowIfNull(client);
return new DurableRun(client, instanceId, workflowName);
}
/// <summary>
/// Attaches to an existing workflow orchestration instance for streaming.
/// </summary>
/// <param name="instanceId">The instance ID of the orchestration to attach to.</param>
/// <param name="workflow">The workflow being executed.</param>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <returns>A <see cref="DurableStreamingRun"/> that can be used to stream workflow events.</returns>
public static DurableStreamingRun AttachStream(
string instanceId,
Workflow workflow,
DurableTaskClient client)
{
ArgumentException.ThrowIfNullOrEmpty(instanceId);
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(client);
return new DurableStreamingRun(client, instanceId, workflow);
}
}
@@ -4,11 +4,39 @@ using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Entities;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Represents the custom status set when the orchestration is waiting for an external event.
/// </summary>
/// <param name="EventName">The name of the event being waited for (the RequestPort ID).</param>
/// <param name="Input">The serialized input data that was passed to the RequestPort.</param>
/// <param name="RequestType">The full type name of the request type.</param>
/// <param name="ResponseType">The full type name of the expected response type.</param>
public sealed record PendingExternalEventStatus(
string EventName,
string Input,
string RequestType,
string ResponseType);
/// <summary>
/// Represents the complete custom status for a durable workflow orchestration.
/// </summary>
public sealed class DurableWorkflowCustomStatus
{
/// <summary>
/// Gets or sets the pending external event status when waiting for HITL input.
/// </summary>
public PendingExternalEventStatus? PendingEvent { get; set; }
/// <summary>
/// Gets or sets the list of serialized workflow events emitted by executors.
/// </summary>
public List<string> Events { get; set; } = [];
}
/// <summary>
/// Core workflow runner that executes workflow orchestrations using Durable Tasks.
/// This class contains the core workflow execution logic independent of the hosting environment.
@@ -64,23 +92,7 @@ public class DurableWorkflowRunner
logger.LogRunningWorkflow(workflow.Name);
string result = await this.ExecuteWorkflowLevelsAsync(context, workflow, input, logger).ConfigureAwait(true);
await CleanupWorkflowStateAsync(context).ConfigureAwait(true);
return result;
}
/// <summary>
/// Cleans up the workflow state entity by signaling it to delete itself.
/// </summary>
private static async Task CleanupWorkflowStateAsync(TaskOrchestrationContext context)
{
EntityInstanceId stateEntityId = new(WorkflowSharedStateEntity.EntityName, context.InstanceId);
// Call the entity's Delete method to clean up state
// Using CallEntityAsync ensures the deletion completes before the orchestration finishes
await context.Entities.CallEntityAsync(stateEntityId, nameof(WorkflowSharedStateEntity.Delete)).ConfigureAwait(true);
return await this.ExecuteWorkflowLevelsAsync(context, workflow, input, logger).ConfigureAwait(true);
}
/// <summary>
@@ -181,6 +193,10 @@ public class DurableWorkflowRunner
WorkflowExecutionPlan plan = WorkflowHelper.GetExecutionPlan(workflow);
Dictionary<string, string> results = new(plan.Levels.Sum(l => l.Executors.Count));
// Track accumulated events and shared state
DurableWorkflowCustomStatus customStatus = new();
Dictionary<string, string> sharedState = [];
foreach (WorkflowExecutionLevel level in plan.Levels)
{
// Filter executors based on edge conditions from their predecessors
@@ -196,29 +212,173 @@ public class DurableWorkflowRunner
{
WorkflowExecutorInfo executorInfo = eligibleExecutors[0];
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
results[executorInfo.ExecutorId] = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
string rawResult = await this.ExecuteExecutorAsync(context, executorInfo, input, logger, customStatus, sharedState).ConfigureAwait(true);
results[executorInfo.ExecutorId] = UnwrapActivityResult(rawResult, customStatus, sharedState);
// Update custom status with any new events
UpdateCustomStatus(context, customStatus);
}
else
{
// For parallel execution, each activity gets a snapshot of the current state
// State updates are merged after all activities complete
Task<(string Id, string Result)>[] tasks = new Task<(string Id, string Result)>[eligibleExecutors.Count];
for (int i = 0; i < eligibleExecutors.Count; i++)
{
WorkflowExecutorInfo executorInfo = eligibleExecutors[i];
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
tasks[i] = this.ExecuteExecutorWithIdAsync(context, executorInfo, input, logger);
tasks[i] = this.ExecuteExecutorWithIdAsync(context, executorInfo, input, logger, customStatus, sharedState);
}
(string Id, string Result)[] completedTasks = await Task.WhenAll(tasks).ConfigureAwait(true);
foreach ((string id, string result) in completedTasks)
foreach ((string id, string rawResult) in completedTasks)
{
results[id] = result;
results[id] = UnwrapActivityResult(rawResult, customStatus, sharedState);
}
// Update custom status with any new events
UpdateCustomStatus(context, customStatus);
}
}
return GetFinalResult(plan, results);
}
/// <summary>
/// Unwraps an activity result, extracting state updates, events, and returning the actual result.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing known wrapper type.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing known wrapper type.")]
private static string UnwrapActivityResult(string rawResult, DurableWorkflowCustomStatus customStatus, Dictionary<string, string> sharedState)
{
if (string.IsNullOrEmpty(rawResult))
{
return rawResult;
}
try
{
// Try to deserialize as DurableActivityOutput
DurableActivityOutput? output = JsonSerializer.Deserialize<DurableActivityOutput>(rawResult);
// Check if this is actually a DurableActivityOutput (has Result property set or state updates)
// This distinguishes it from other JSON objects that would deserialize with default/empty values
if (output is not null && (output.Result is not null || output.StateUpdates.Count > 0 || output.ClearedScopes.Count > 0 || output.Events.Count > 0))
{
// Apply cleared scopes first
foreach (string clearedScope in output.ClearedScopes)
{
string scopePrefix = clearedScope == "__default__" ? "__default__:" : $"{clearedScope}:";
List<string> keysToRemove = sharedState.Keys
.Where(k => k.StartsWith(scopePrefix, StringComparison.Ordinal))
.ToList();
foreach (string key in keysToRemove)
{
sharedState.Remove(key);
}
}
// Apply state updates
foreach (KeyValuePair<string, string?> update in output.StateUpdates)
{
if (update.Value is null)
{
sharedState.Remove(update.Key);
}
else
{
sharedState[update.Key] = update.Value;
}
}
// Add events to the accumulated list
if (output.Events.Count > 0)
{
customStatus.Events.AddRange(output.Events);
}
return output.Result ?? string.Empty;
}
}
catch (JsonException)
{
// Not a wrapped result, return as-is
}
return rawResult;
}
/// <summary>
/// Updates the orchestration custom status with current events and pending event info.
/// </summary>
private static void UpdateCustomStatus(TaskOrchestrationContext context, DurableWorkflowCustomStatus customStatus)
{
// Only update if there are events or a pending event
if (customStatus.Events.Count > 0 || customStatus.PendingEvent is not null)
{
context.SetCustomStatus(customStatus);
}
}
/// <summary>
/// Wrapper for activity output that includes state updates and events.
/// </summary>
internal sealed class ActivityOutputWithState
{
/// <summary>
/// Gets or sets the serialized result of the activity.
/// </summary>
public string? Result { get; set; }
/// <summary>
/// Gets or sets state updates made during activity execution.
/// </summary>
public Dictionary<string, string?> StateUpdates { get; set; } = [];
/// <summary>
/// Gets or sets scopes that were cleared during activity execution.
/// </summary>
public List<string> ClearedScopes { get; set; } = [];
/// <summary>
/// Gets or sets the serialized workflow events emitted during activity execution.
/// </summary>
public List<string> Events { get; set; } = [];
}
/// <summary>
/// Wrapper for serialized workflow events that includes type information for proper deserialization.
/// </summary>
public sealed class SerializedWorkflowEvent
{
/// <summary>
/// Gets or sets the assembly-qualified type name of the event.
/// </summary>
public string? TypeName { get; set; }
/// <summary>
/// Gets or sets the serialized JSON data of the event.
/// </summary>
public string? Data { get; set; }
}
/// <summary>
/// Wrapper for activity input that includes shared state.
/// </summary>
internal sealed class ActivityInputWithState
{
/// <summary>
/// Gets or sets the serialized executor input.
/// </summary>
public string? Input { get; set; }
/// <summary>
/// Gets or sets the shared state dictionary.
/// </summary>
public Dictionary<string, string> State { get; set; } = [];
}
/// <summary>
/// Filters executors based on their incoming edge conditions.
/// An executor is eligible if all its incoming edges have conditions that evaluate to true,
@@ -323,28 +483,84 @@ public class DurableWorkflowRunner
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
ILogger logger,
DurableWorkflowCustomStatus customStatus,
Dictionary<string, string> sharedState)
{
string result = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
string result = await this.ExecuteExecutorAsync(context, executorInfo, input, logger, customStatus, sharedState).ConfigureAwait(true);
return (executorInfo.ExecutorId, result);
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known wrapper type.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known wrapper type.")]
private async Task<string> ExecuteExecutorAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
ILogger logger,
DurableWorkflowCustomStatus customStatus,
Dictionary<string, string> sharedState)
{
// Handle RequestPort executors by waiting for external event (human-in-the-loop)
if (executorInfo.IsRequestPortExecutor)
{
return await ExecuteRequestPortAsync(context, executorInfo, input, logger, customStatus).ConfigureAwait(true);
}
if (!executorInfo.IsAgenticExecutor)
{
string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
string triggerName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
return await context.CallActivityAsync<string>(triggerName, input).ConfigureAwait(true);
// Wrap input with shared state for the activity
ActivityInputWithState inputWithState = new()
{
Input = input,
State = new Dictionary<string, string>(sharedState) // Pass a copy of the state
};
string wrappedInput = JsonSerializer.Serialize(inputWithState);
return await context.CallActivityAsync<string>(triggerName, wrappedInput).ConfigureAwait(true);
}
return await ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
private static async Task<string> ExecuteRequestPortAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger,
DurableWorkflowCustomStatus customStatus)
{
RequestPort requestPort = executorInfo.RequestPort!;
string eventName = requestPort.Id;
logger.LogWaitingForExternalEvent(eventName, input);
// Set custom status to notify clients that we're waiting for external input
// Include any accumulated events
customStatus.PendingEvent = new(
EventName: eventName,
Input: input,
RequestType: requestPort.Request.FullName ?? requestPort.Request.Name,
ResponseType: requestPort.Response.FullName ?? requestPort.Response.Name);
context.SetCustomStatus(customStatus);
// Wait for the external event (human-in-the-loop)
// The event data will be the response from the external actor
string response = await context.WaitForExternalEvent<string>(eventName).ConfigureAwait(true);
// Clear pending event status after receiving the event
customStatus.PendingEvent = null;
context.SetCustomStatus(customStatus.Events.Count > 0 ? customStatus : null);
logger.LogReceivedExternalEvent(eventName, response);
return response;
}
private static async Task<string> ExecuteAgentAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
@@ -360,8 +576,8 @@ public class DurableWorkflowRunner
return $"Agent '{agentName}' not found";
}
AgentThread thread = agent.GetNewThread();
AgentRunResponse response = await agent.RunAsync(input, thread).ConfigureAwait(true);
AgentThread thread = await agent.GetNewThreadAsync();
AgentResponse response = await agent.RunAsync(input, thread);
return response.Text;
}
@@ -0,0 +1,389 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Extension methods for configuring durable workflows with the service collection.
/// </summary>
public static class DurableWorkflowServiceCollectionExtensions
{
/// <summary>
/// Configures durable workflows with the service collection, automatically registering
/// orchestrations and activities for each workflow.
/// </summary>
/// <param name="services">The service collection to configure.</param>
/// <param name="configure">A delegate to configure the durable options.</param>
/// <param name="workerBuilder">An optional delegate to configure the durable task worker.</param>
/// <param name="clientBuilder">An optional delegate to configure the durable task client.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection ConfigureDurableWorkflows(
this IServiceCollection services,
Action<DurableOptions> configure,
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
Action<IDurableTaskClientBuilder>? clientBuilder = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
// Create and configure durable options
DurableOptions durableOptions = new();
configure(durableOptions);
// Register DurableOptions as a singleton
services.AddSingleton(durableOptions);
// Register the workflow runner
services.AddSingleton<DurableWorkflowRunner>();
// Build registration info for all workflows
List<WorkflowRegistrationInfo> registrations = [];
HashSet<string> registeredActivities = [];
foreach (KeyValuePair<string, Workflow> workflowEntry in durableOptions.Workflows.Workflows)
{
registrations.Add(BuildWorkflowRegistration(workflowEntry.Value, registeredActivities));
}
// Get any AI agents that were auto-registered from workflows
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agentFactories = durableOptions.Agents.GetAgentFactories();
// Configure Durable Task Worker with orchestrations and activities
services.AddDurableTaskWorker(builder =>
{
workerBuilder?.Invoke(builder);
builder.AddTasks(registry =>
{
// Register all workflow tasks
foreach (WorkflowRegistrationInfo registration in registrations)
{
// Register orchestration
registry.AddOrchestratorFunc<string, string>(
registration.OrchestrationName,
(context, input) => RunWorkflowOrchestrationAsync(context, input, durableOptions));
// Register activities
foreach (ActivityRegistrationInfo activity in registration.Activities)
{
ExecutorBinding binding = activity.Binding;
registry.AddActivityFunc<string, string>(
activity.ActivityName,
(context, input) => ExecuteActivityAsync(binding, input));
}
}
// Register agent entities for any AI agents used in workflows
foreach (string agentName in agentFactories.Keys)
{
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(agentName));
}
});
});
// Register DurableAgentsOptions and agent factories for entity resolution
if (agentFactories.Count > 0)
{
services.AddSingleton(durableOptions.Agents);
// Register the agent factories dictionary for backward compatibility
services.TryAddSingleton(
sp => sp.GetRequiredService<DurableAgentsOptions>().GetAgentFactories());
// A custom data converter is needed for proper JSON serialization
services.TryAddSingleton<DataConverter, WorkflowDataConverter>();
}
// Configure Durable Task Client if a builder is provided
if (clientBuilder is not null)
{
services.AddDurableTaskClient(clientBuilder);
}
return services;
}
private static WorkflowRegistrationInfo BuildWorkflowRegistration(
Workflow workflow,
HashSet<string> registeredActivities)
{
string workflowName = workflow.Name!;
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
// Get all executor IDs from the workflow
HashSet<string> executorIds = GetAllExecutorIds(workflow);
Dictionary<string, ExecutorBinding> executorBindings = workflow.ReflectExecutors();
List<ActivityRegistrationInfo> activities = [];
foreach (string executorId in executorIds)
{
if (!executorBindings.TryGetValue(executorId, out ExecutorBinding? binding))
{
continue;
}
string executorName = WorkflowNamingHelper.GetExecutorName(executorId);
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
// Skip if already registered (same executor used in multiple workflows)
if (!registeredActivities.Add(activityName))
{
continue;
}
// Skip agent executors - they're handled differently
if (binding is AIAgentBinding)
{
continue;
}
activities.Add(new ActivityRegistrationInfo(activityName, binding));
}
return new WorkflowRegistrationInfo(orchestrationName, activities);
}
private static HashSet<string> GetAllExecutorIds(Workflow workflow)
{
HashSet<string> executorIds = [workflow.StartExecutorId];
foreach (KeyValuePair<string, HashSet<EdgeInfo>> edgeGroup in workflow.ReflectEdges())
{
executorIds.Add(edgeGroup.Key);
foreach (EdgeInfo edge in edgeGroup.Value)
{
foreach (string sinkId in edge.Connection.SinkIds)
{
executorIds.Add(sinkId);
}
}
}
return executorIds;
}
private static async Task<string> RunWorkflowOrchestrationAsync(
TaskOrchestrationContext context,
string input,
DurableOptions durableOptions)
{
ILogger logger = context.CreateReplaySafeLogger("WorkflowOrchestration");
DurableWorkflowRunner runner = new(
NullLoggerFactory.Instance.CreateLogger<DurableWorkflowRunner>(),
durableOptions);
return await runner.RunWorkflowOrchestrationAsync(context, input, logger).ConfigureAwait(true);
}
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Executor types are registered at startup.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Executor types are registered at startup.")]
private static async Task<string> ExecuteActivityAsync(ExecutorBinding binding, string input)
{
// Deserialize the input wrapper that includes state
DurableActivityInput? inputWithState = TryDeserializeActivityInput(input);
string executorInput = inputWithState?.Input ?? input;
Dictionary<string, string> sharedState = inputWithState?.State ?? [];
// Create executor instance from binding
Executor executor = await binding.FactoryAsync!("activity-run").ConfigureAwait(false);
Type inputType = executor.InputTypes.FirstOrDefault() ?? typeof(string);
object typedInput = DeserializeInput(executorInput, inputType);
// Create a pipeline context that has access to shared state and executor
PipelineActivityContext workflowContext = new(sharedState, executor);
object? result = await executor.ExecuteAsync(
typedInput,
new TypeId(inputType),
workflowContext,
CancellationToken.None).ConfigureAwait(false);
// Always return wrapped output with state updates, events, and result
DurableActivityOutput output = new()
{
Result = SerializeResult(result),
StateUpdates = workflowContext.StateUpdates,
ClearedScopes = [.. workflowContext.ClearedScopes],
Events = workflowContext.Events.ConvertAll(SerializeEvent)
};
return JsonSerializer.Serialize(output);
}
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Deserializing known wrapper type.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Deserializing known wrapper type.")]
private static DurableActivityInput? TryDeserializeActivityInput(string input)
{
try
{
return JsonSerializer.Deserialize<DurableActivityInput>(input);
}
catch (JsonException)
{
return null;
}
}
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Serializing workflow event types.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Serializing workflow event types.")]
private static string SerializeEvent(WorkflowEvent evt)
{
// Serialize with type information so we can deserialize to the correct type later
DurableWorkflowRunner.SerializedWorkflowEvent wrapper = new()
{
TypeName = evt.GetType().AssemblyQualifiedName,
Data = JsonSerializer.Serialize(evt, evt.GetType())
};
return JsonSerializer.Serialize(wrapper);
}
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Deserializing workflow types registered at startup.")]
private static object DeserializeInput(string input, Type targetType)
{
if (targetType == typeof(string))
{
return input;
}
return JsonSerializer.Deserialize(input, targetType)
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
}
[UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "Serializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Serializing workflow types registered at startup.")]
private static string SerializeResult(object? result)
{
if (result is null)
{
return string.Empty;
}
if (result is string str)
{
return str;
}
return JsonSerializer.Serialize(result, result.GetType());
}
private sealed record WorkflowRegistrationInfo(string OrchestrationName, List<ActivityRegistrationInfo> Activities);
private sealed record ActivityRegistrationInfo(string ActivityName, ExecutorBinding Binding);
/// <summary>
/// Custom data converter for workflow execution with AI agents.
/// </summary>
private sealed class WorkflowDataConverter : DataConverter
{
private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
public override object? Deserialize(string? data, Type targetType)
{
if (data is null)
{
return null;
}
if (targetType == typeof(DurableAgentState))
{
return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType);
if (typeInfo is JsonTypeInfo typedInfo)
{
return JsonSerializer.Deserialize(data, typedInfo);
}
return JsonSerializer.Deserialize(data, targetType, s_options);
}
[return: NotNullIfNotNull(nameof(value))]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
public override string? Serialize(object? value)
{
if (value is null)
{
return null;
}
if (value is DurableAgentState durableAgentState)
{
return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType());
if (typeInfo is JsonTypeInfo typedInfo)
{
return JsonSerializer.Serialize(value, typedInfo);
}
return JsonSerializer.Serialize(value, s_options);
}
}
}
/// <summary>
/// Input payload for activity execution, containing the executor input and shared workflow state.
/// </summary>
internal sealed class DurableActivityInput
{
/// <summary>
/// Gets or sets the serialized executor input.
/// </summary>
public string? Input { get; set; }
/// <summary>
/// Gets or sets the shared state dictionary (scope-prefixed key -> serialized value).
/// </summary>
public Dictionary<string, string> State { get; set; } = [];
}
/// <summary>
/// Output payload from activity execution, containing the result, state updates, and emitted events.
/// </summary>
internal sealed class DurableActivityOutput
{
/// <summary>
/// Gets or sets the serialized result of the activity.
/// </summary>
public string? Result { get; set; }
/// <summary>
/// Gets or sets state updates made during activity execution (scope-prefixed key -> serialized value, null = delete).
/// </summary>
public Dictionary<string, string?> StateUpdates { get; set; } = [];
/// <summary>
/// Gets or sets scopes that were cleared during activity execution.
/// </summary>
public List<string> ClearedScopes { get; set; } = [];
/// <summary>
/// Gets or sets the serialized workflow events emitted during activity execution.
/// </summary>
public List<string> Events { get; set; } = [];
}
@@ -136,4 +136,16 @@ internal static partial class Logs
Level = LogLevel.Debug,
Message = "Executor '{ExecutorId}' skipped due to edge condition evaluation")]
public static partial void LogExecutorSkipped(this ILogger logger, string executorId);
[LoggerMessage(
EventId = 18,
Level = LogLevel.Debug,
Message = "Waiting for external event '{EventName}' with input: {Input}")]
public static partial void LogWaitingForExternalEvent(this ILogger logger, string eventName, string input);
[LoggerMessage(
EventId = 19,
Level = LogLevel.Debug,
Message = "Received external event '{EventName}' with response: {Response}")]
public static partial void LogReceivedExternalEvent(this ILogger logger, string eventName, string response);
}
@@ -0,0 +1,246 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// A workflow context for activity execution that uses pipeline-based state management.
/// State is passed in from the orchestration and updates are collected for return.
/// </summary>
internal sealed class PipelineActivityContext : IWorkflowContext
{
private readonly Dictionary<string, string> _initialState;
private readonly Executor _executor;
/// <summary>
/// Initializes a new instance of the <see cref="PipelineActivityContext"/> class.
/// </summary>
/// <param name="initialState">The shared state passed from the orchestration.</param>
/// <param name="executor">The executor running in this context.</param>
public PipelineActivityContext(Dictionary<string, string>? initialState, Executor executor)
{
this._initialState = initialState ?? [];
this._executor = executor;
}
/// <summary>
/// Gets the events that were added during activity execution.
/// </summary>
public List<WorkflowEvent> Events { get; } = [];
/// <summary>
/// Gets the state updates made during activity execution.
/// </summary>
public Dictionary<string, string?> StateUpdates { get; } = [];
/// <summary>
/// Gets the scopes that were cleared during activity execution.
/// </summary>
public HashSet<string> ClearedScopes { get; } = [];
/// <inheritdoc/>
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
{
if (workflowEvent is not null)
{
this.Events.Add(workflowEvent);
}
return default;
}
/// <inheritdoc/>
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) => default;
/// <inheritdoc/>
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
{
if (output is not null)
{
// Validate output type matches executor's declared output types (same as in-process execution)
if (!CanOutput(this._executor.OutputTypes, output.GetType()))
{
throw new InvalidOperationException(
$"Cannot output object of type {output.GetType().Name}. " +
$"Expecting one of [{string.Join(", ", this._executor.OutputTypes)}].");
}
this.Events.Add(new DurableYieldedOutputEvent(this._executor.Id, output));
}
return default;
}
/// <inheritdoc/>
public ValueTask RequestHaltAsync()
{
this.Events.Add(new DurableHaltRequestedEvent(this._executor.Id));
return default;
}
/// <inheritdoc/>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
string normalizedScope = scopeName ?? "__default__";
// Check if scope was cleared
if (this.ClearedScopes.Contains(normalizedScope))
{
// Only return from updates made after clear
if (this.StateUpdates.TryGetValue(scopeKey, out string? updatedAfterClear) && updatedAfterClear is not null)
{
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(updatedAfterClear));
}
return ValueTask.FromResult<T?>(default);
}
// Check local updates first (read-your-writes)
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
{
if (updated is null)
{
return ValueTask.FromResult<T?>(default);
}
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(updated));
}
// Fall back to initial state passed from orchestration
if (this._initialState.TryGetValue(scopeKey, out string? initial))
{
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(initial));
}
return ValueTask.FromResult<T?>(default);
}
/// <inheritdoc/>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
public async ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
T? value = await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false);
if (value is not null)
{
return value;
}
// Initialize with factory value
T initialValue = initialStateFactory();
await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false);
return initialValue;
}
/// <inheritdoc/>
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopePrefix = GetScopePrefix(scopeName);
string normalizedScope = scopeName ?? "__default__";
HashSet<string> keys = [];
// If scope was cleared, only return keys from updates made after clear
if (this.ClearedScopes.Contains(normalizedScope))
{
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
{
if (update.Key.StartsWith(scopePrefix, StringComparison.Ordinal) && update.Value is not null)
{
keys.Add(update.Key[scopePrefix.Length..]);
}
}
return ValueTask.FromResult(keys);
}
// Start with keys from initial state
foreach (string stateKey in this._initialState.Keys)
{
if (stateKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
keys.Add(stateKey[scopePrefix.Length..]);
}
}
// Merge with updates
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
{
if (update.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
{
string foundKey = update.Key[scopePrefix.Length..];
if (update.Value is not null)
{
keys.Add(foundKey);
}
else
{
keys.Remove(foundKey);
}
}
}
return ValueTask.FromResult(keys);
}
/// <inheritdoc/>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow state types.")]
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
this.StateUpdates[scopeKey] = value is null ? null : JsonSerializer.Serialize(value);
return default;
}
/// <inheritdoc/>
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
string normalizedScope = scopeName ?? "__default__";
this.ClearedScopes.Add(normalizedScope);
// Remove any pending updates in this scope
string scopePrefix = GetScopePrefix(scopeName);
List<string> keysToRemove = this.StateUpdates.Keys
.Where(k => k.StartsWith(scopePrefix, StringComparison.Ordinal))
.ToList();
foreach (string key in keysToRemove)
{
this.StateUpdates.Remove(key);
}
return default;
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, string>? TraceContext => null;
/// <inheritdoc/>
public bool ConcurrentRunsEnabled => false;
private static bool CanOutput(ISet<Type> outputTypes, Type messageType)
{
foreach (Type type in outputTypes)
{
if (type.IsAssignableFrom(messageType))
{
return true;
}
}
return false;
}
private static string GetScopeKey(string? scopeName, string key)
=> $"{GetScopePrefix(scopeName)}{key}";
private static string GetScopePrefix(string? scopeName)
=> scopeName is null ? "__default__:" : $"{scopeName}:";
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Represents the complete execution plan for a workflow, including parallel execution levels.
/// </summary>
public sealed class WorkflowExecutionPlan
{
/// <summary>
/// The execution levels in order. Each level contains executors that can run in parallel.
/// </summary>
public List<WorkflowExecutionLevel> Levels { get; } = [];
/// <summary>
/// Maps each executor ID to its predecessors (for Fan-In result aggregation).
/// </summary>
public Dictionary<string, List<string>> Predecessors { get; } = [];
/// <summary>
/// Maps each executor ID to its successors (for Fan-Out result distribution).
/// </summary>
public Dictionary<string, List<string>> Successors { get; } = [];
/// <summary>
/// Maps edge connections (sourceId, targetId) to their condition functions.
/// The condition function takes the predecessor's result and returns true if the edge should be followed.
/// </summary>
public Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> EdgeConditions { get; } = [];
/// <summary>
/// Maps executor IDs to their output types (for proper deserialization during condition evaluation).
/// </summary>
public Dictionary<string, Type?> ExecutorOutputTypes { get; } = [];
/// <summary>
/// Gets whether this workflow has any parallel execution opportunities.
/// </summary>
public bool HasParallelism => this.Levels.Any(l => l.Executors.Count > 1);
/// <summary>
/// Gets whether this workflow has any Fan-In points.
/// </summary>
public bool HasFanIn => this.Levels.Any(l => l.IsFanIn);
}
@@ -10,7 +10,14 @@ namespace Microsoft.Agents.AI.DurableTask;
/// </summary>
/// <param name="ExecutorId">The unique identifier of the executor.</param>
/// <param name="IsAgenticExecutor">Indicates whether this executor is an agentic executor.</param>
public sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor);
/// <param name="RequestPort">The request port if this executor is a request port executor; otherwise, null.</param>
public sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor, RequestPort? RequestPort = null)
{
/// <summary>
/// Gets a value indicating whether this executor is a request port executor (human-in-the-loop).
/// </summary>
public bool IsRequestPortExecutor => this.RequestPort is not null;
}
/// <summary>
/// Represents a level of executors that can be executed in parallel (Fan-Out).
@@ -21,48 +28,6 @@ public sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecu
/// <param name="IsFanIn">Indicates if this level is a Fan-In point (has executors with multiple predecessors).</param>
public sealed record WorkflowExecutionLevel(int Level, List<WorkflowExecutorInfo> Executors, bool IsFanIn);
/// <summary>
/// Represents the complete execution plan for a workflow, including parallel execution levels.
/// </summary>
public sealed class WorkflowExecutionPlan
{
/// <summary>
/// The execution levels in order. Each level contains executors that can run in parallel.
/// </summary>
public List<WorkflowExecutionLevel> Levels { get; } = [];
/// <summary>
/// Maps each executor ID to its predecessors (for Fan-In result aggregation).
/// </summary>
public Dictionary<string, List<string>> Predecessors { get; } = [];
/// <summary>
/// Maps each executor ID to its successors (for Fan-Out result distribution).
/// </summary>
public Dictionary<string, List<string>> Successors { get; } = [];
/// <summary>
/// Maps edge connections (sourceId, targetId) to their condition functions.
/// The condition function takes the predecessor's result and returns true if the edge should be followed.
/// </summary>
public Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> EdgeConditions { get; } = [];
/// <summary>
/// Maps executor IDs to their output types (for proper deserialization during condition evaluation).
/// </summary>
public Dictionary<string, Type?> ExecutorOutputTypes { get; } = [];
/// <summary>
/// Gets whether this workflow has any parallel execution opportunities.
/// </summary>
public bool HasParallelism => this.Levels.Any(l => l.Executors.Count > 1);
/// <summary>
/// Gets whether this workflow has any Fan-In points.
/// </summary>
public bool HasFanIn => this.Levels.Any(l => l.IsFanIn);
}
/// <summary>
/// Provides helper methods for analyzing and executing workflows.
/// </summary>
@@ -182,7 +147,8 @@ public static class WorkflowHelper
ExecutorBinding executorBinding = executors[executorId];
bool isAgentic = IsAgentExecutorType(executorBinding.ExecutorType);
levelExecutors.Add(new WorkflowExecutorInfo(executorId, isAgentic));
RequestPort? requestPort = (executorBinding is RequestPortBinding rpb) ? rpb.Port : null;
levelExecutors.Add(new WorkflowExecutorInfo(executorId, isAgentic, requestPort));
// Check Fan-In for this executor
if (predecessors[executorId].Count > 1)
@@ -215,7 +181,8 @@ public static class WorkflowHelper
if (inDegree[executorIndex[executor.Key]] > 0)
{
bool isAgentic = IsAgentExecutorType(executor.Value.ExecutorType);
remainingExecutors.Add(new WorkflowExecutorInfo(executor.Key, isAgentic));
RequestPort? requestPort = (executor.Value is RequestPortBinding rpb) ? rpb.Port : null;
remainingExecutors.Add(new WorkflowExecutorInfo(executor.Key, isAgentic, requestPort));
}
}
@@ -1,171 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.DurableTask.Entities;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Durable entity that manages workflow state across activities within an orchestration run.
/// Each orchestration instance gets its own entity instance (keyed by orchestration instance ID),
/// ensuring state isolation between workflow runs. The entity is automatically cleaned up
/// when the orchestration completes.
/// </summary>
public sealed class WorkflowSharedStateEntity : TaskEntity<WorkflowStateData>
{
/// <summary>
/// The entity name used for registration and lookup.
/// </summary>
public const string EntityName = "workflow-shared-state";
/// <summary>
/// Reads a state value by key and scope.
/// </summary>
/// <param name="request">The read request containing key and optional scope.</param>
/// <returns>The serialized state value, or null if not found.</returns>
public string? ReadState(WorkflowStateReadRequest request)
{
string scopeKey = GetScopeKey(request.ScopeName, request.Key);
return this.State.Values.TryGetValue(scopeKey, out string? value) ? value : null;
}
/// <summary>
/// Reads the entire state dictionary.
/// </summary>
/// <returns>A copy of the current state.</returns>
public Dictionary<string, string> ReadAllState()
{
return new Dictionary<string, string>(this.State.Values);
}
/// <summary>
/// Writes or updates a state value by key and scope.
/// </summary>
/// <param name="request">The write request containing key, scope, and value.</param>
public void WriteState(WorkflowStateWriteRequest request)
{
string scopeKey = GetScopeKey(request.ScopeName, request.Key);
if (request.Value is null)
{
this.State.Values.Remove(scopeKey);
}
else
{
this.State.Values[scopeKey] = request.Value;
}
}
/// <summary>
/// Gets all keys within a specific scope.
/// </summary>
/// <param name="scopeName">The scope name, or null for the default scope.</param>
/// <returns>A collection of keys within the scope.</returns>
public HashSet<string> GetStateKeys(string? scopeName)
{
string scopePrefix = GetScopePrefix(scopeName);
HashSet<string> keys = [];
foreach (string scopeKey in this.State.Values.Keys)
{
if (scopeKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
string key = scopeKey[scopePrefix.Length..];
keys.Add(key);
}
}
return keys;
}
/// <summary>
/// Clears all state entries within a specific scope.
/// </summary>
/// <param name="scopeName">The scope name, or null for the default scope.</param>
public void ClearScope(string? scopeName)
{
string scopePrefix = GetScopePrefix(scopeName);
List<string> keysToRemove = [];
foreach (string scopeKey in this.State.Values.Keys)
{
if (scopeKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
keysToRemove.Add(scopeKey);
}
}
foreach (string key in keysToRemove)
{
this.State.Values.Remove(key);
}
}
/// <summary>
/// Deletes the entity, cleaning up all state.
/// Called by the orchestration when it completes.
/// </summary>
public void Delete()
{
// Setting State to null tells the Durable Task framework to delete the entity.
// The entity will be garbage collected after idle timeout.
this.State = null!;
}
private static string GetScopeKey(string? scopeName, string key)
{
return $"{GetScopePrefix(scopeName)}{key}";
}
private static string GetScopePrefix(string? scopeName)
{
return scopeName is null ? "__default__:" : $"{scopeName}:";
}
}
/// <summary>
/// Represents the internal state data for a workflow state entity.
/// </summary>
public sealed class WorkflowStateData
{
/// <summary>
/// Gets the state dictionary mapping scope-prefixed keys to serialized values.
/// </summary>
public Dictionary<string, string> Values { get; init; } = [];
}
/// <summary>
/// Request model for reading workflow state.
/// </summary>
public sealed class WorkflowStateReadRequest
{
/// <summary>
/// Gets or sets the state key.
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the optional scope name.
/// </summary>
public string? ScopeName { get; set; }
}
/// <summary>
/// Request model for writing workflow state.
/// </summary>
public sealed class WorkflowStateWriteRequest
{
/// <summary>
/// Gets or sets the state key.
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the optional scope name.
/// </summary>
public string? ScopeName { get; set; }
/// <summary>
/// Gets or sets the serialized value, or null to delete the key.
/// </summary>
public string? Value { get; set; }
}
@@ -90,9 +90,6 @@ public static class DurableOptionsExtensions
{
builder.ConfigureDurableWorker().AddTasks(tasks =>
{
// Register the workflow state entity for shared state management within workflows.
tasks.AddEntity<WorkflowSharedStateEntity>(WorkflowSharedStateEntity.EntityName);
foreach (string workflowName in workflows.Workflows.Select(kp => kp.Key))
{
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -30,10 +31,12 @@ internal sealed class FunctionsWorkflowRunner : DurableWorkflowRunner
/// Executes an activity function for a workflow executor.
/// </summary>
/// <param name="activityFunctionName">The name of the activity function to execute.</param>
/// <param name="input">The serialized executor input.</param>
/// <param name="durableTaskClient">The durable task client for entity operations.</param>
/// <param name="input">The serialized executor input (may include state via ActivityInputWithState wrapper).</param>
/// <param name="durableTaskClient">The durable task client (unused in pipeline mode, kept for API compatibility).</param>
/// <param name="functionContext">The function context containing binding data with the orchestration instance ID.</param>
/// <returns>The serialized executor output.</returns>
/// <returns>The serialized executor output (wrapped in ActivityOutputWithState).</returns>
[UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "Executor types are registered at startup.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Executor types are registered at startup.")]
internal async Task<string> ExecuteActivityAsync(
string activityFunctionName,
string input,
@@ -42,7 +45,6 @@ internal sealed class FunctionsWorkflowRunner : DurableWorkflowRunner
{
ArgumentNullException.ThrowIfNull(activityFunctionName);
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(durableTaskClient);
ArgumentNullException.ThrowIfNull(functionContext);
string executorName = ParseExecutorName(activityFunctionName);
@@ -54,20 +56,19 @@ internal sealed class FunctionsWorkflowRunner : DurableWorkflowRunner
this.Logger.LogExecutingActivity(registration.ExecutorId, executorName);
// Deserialize the input wrapper that includes state (pipeline approach)
ActivityInputWithState? inputWithState = TryDeserializeActivityInput(input);
string executorInput = inputWithState?.Input ?? input;
Dictionary<string, string> sharedState = inputWithState?.State ?? [];
Executor executor = await registration.CreateExecutorInstanceAsync("activity-run", CancellationToken.None)
.ConfigureAwait(false);
Type inputType = executor.InputTypes.FirstOrDefault() ?? typeof(string);
object typedInput = DeserializeInput(input, inputType);
object typedInput = DeserializeInput(executorInput, inputType);
// Get the orchestration instance ID from the function context binding data
string instanceId = GetInstanceIdFromContext(functionContext)
?? throw new InvalidOperationException(
"Could not retrieve orchestration instance ID from FunctionContext. " +
"Ensure the activity is being called from within a durable orchestration.");
// Create context with durable entity-backed state
IWorkflowContext context = CreateExecutorContext(instanceId, durableTaskClient);
// Create pipeline context that manages state locally with executor ID
FunctionsPipelineActivityContext context = new(sharedState, executor.Id);
object? result = await executor.ExecuteAsync(
typedInput,
@@ -75,26 +76,265 @@ internal sealed class FunctionsWorkflowRunner : DurableWorkflowRunner
context,
CancellationToken.None).ConfigureAwait(false);
return SerializeResult(result);
// Return wrapped output with state updates, events, and result
ActivityOutputWithState output = new()
{
Result = SerializeResult(result),
StateUpdates = context.StateUpdates,
ClearedScopes = [.. context.ClearedScopes],
Events = context.Events.ConvertAll(SerializeEvent)
};
return JsonSerializer.Serialize(output);
}
private static string? GetInstanceIdFromContext(FunctionContext functionContext)
[UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "Deserializing known wrapper type.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Deserializing known wrapper type.")]
private static ActivityInputWithState? TryDeserializeActivityInput(string input)
{
if (functionContext.BindingContext.BindingData.TryGetValue("instanceId", out object? instanceIdObj) &&
instanceIdObj is string instanceId)
try
{
return instanceId;
return JsonSerializer.Deserialize<ActivityInputWithState>(input);
}
catch (JsonException)
{
return null;
}
}
[UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "Serializing workflow event types.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Serializing workflow event types.")]
private static string SerializeEvent(WorkflowEvent evt)
{
// Serialize with type information so we can deserialize to the correct type later
#pragma warning disable IDE0001 // Simplify name - cannot simplify cross-assembly reference
Microsoft.Agents.AI.DurableTask.DurableWorkflowRunner.SerializedWorkflowEvent wrapper = new()
#pragma warning restore IDE0001
{
TypeName = evt.GetType().AssemblyQualifiedName,
Data = JsonSerializer.Serialize(evt, evt.GetType())
};
return JsonSerializer.Serialize(wrapper);
}
/// <summary>
/// A pipeline-based workflow context for Azure Functions activity execution.
/// State is passed in from the orchestration and updates are collected for return.
/// </summary>
private sealed class FunctionsPipelineActivityContext : IWorkflowContext
{
private readonly Dictionary<string, string> _initialState;
private readonly string _executorId;
public FunctionsPipelineActivityContext(Dictionary<string, string>? initialState, string executorId)
{
this._initialState = initialState ?? [];
this._executorId = executorId;
}
return null;
}
public List<WorkflowEvent> Events { get; } = [];
public Dictionary<string, string?> StateUpdates { get; } = [];
public HashSet<string> ClearedScopes { get; } = [];
[UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "DurableExecutorContext state serialization is done at runtime with user-known types.")]
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "DurableExecutorContext state serialization is done at runtime with user-known types.")]
private static DurableExecutorContext CreateExecutorContext(
string instanceId,
DurableTaskClient client)
{
return new DurableExecutorContext(instanceId, client);
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
{
if (workflowEvent is not null)
{
this.Events.Add(workflowEvent);
}
return default;
}
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) => default;
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
{
// Emit DurableYieldedOutputEvent (WorkflowOutputEvent has internal constructor)
if (output is not null)
{
this.Events.Add(new DurableYieldedOutputEvent(this._executorId, output));
}
return default;
}
public ValueTask RequestHaltAsync()
{
// Emit DurableHaltRequestedEvent (RequestHaltEvent is internal)
this.Events.Add(new DurableHaltRequestedEvent(this._executorId));
return default;
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
public ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
string normalizedScope = scopeName ?? "__default__";
if (this.ClearedScopes.Contains(normalizedScope))
{
if (this.StateUpdates.TryGetValue(scopeKey, out string? updatedAfterClear) && updatedAfterClear is not null)
{
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(updatedAfterClear));
}
return ValueTask.FromResult<T?>(default);
}
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
{
return updated is null
? ValueTask.FromResult<T?>(default)
: ValueTask.FromResult(JsonSerializer.Deserialize<T>(updated));
}
if (this._initialState.TryGetValue(scopeKey, out string? initial))
{
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(initial));
}
return ValueTask.FromResult<T?>(default);
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
public async ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
{
T? value = await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false);
if (value is not null)
{
return value;
}
T initialValue = initialStateFactory();
await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false);
return initialValue;
}
public ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopePrefix = GetScopePrefix(scopeName);
string normalizedScope = scopeName ?? "__default__";
HashSet<string> keys = [];
if (this.ClearedScopes.Contains(normalizedScope))
{
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
{
if (update.Key.StartsWith(scopePrefix, StringComparison.Ordinal) && update.Value is not null)
{
keys.Add(update.Key[scopePrefix.Length..]);
}
}
return ValueTask.FromResult(keys);
}
foreach (string stateKey in this._initialState.Keys)
{
if (stateKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
keys.Add(stateKey[scopePrefix.Length..]);
}
}
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
{
if (update.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
{
string foundKey = update.Key[scopePrefix.Length..];
if (update.Value is not null)
{
keys.Add(foundKey);
}
else
{
keys.Remove(foundKey);
}
}
}
return ValueTask.FromResult(keys);
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow state types.")]
public ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
this.StateUpdates[scopeKey] = value is null ? null : JsonSerializer.Serialize(value);
return default;
}
public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
{
string normalizedScope = scopeName ?? "__default__";
this.ClearedScopes.Add(normalizedScope);
string scopePrefix = GetScopePrefix(scopeName);
List<string> keysToRemove = this.StateUpdates.Keys
.Where(k => k.StartsWith(scopePrefix, StringComparison.Ordinal))
.ToList();
foreach (string key in keysToRemove)
{
this.StateUpdates.Remove(key);
}
return default;
}
public IReadOnlyDictionary<string, string>? TraceContext => null;
public bool ConcurrentRunsEnabled => false;
private static string GetScopeKey(string? scopeName, string key)
=> $"{GetScopePrefix(scopeName)}{key}";
private static string GetScopePrefix(string? scopeName)
=> scopeName is null ? "__default__:" : $"{scopeName}:";
}
}
/// <summary>
/// Wrapper for activity input that includes shared state from the orchestration.
/// </summary>
internal sealed class ActivityInputWithState
{
/// <summary>
/// Gets or sets the serialized executor input.
/// </summary>
public string? Input { get; set; }
/// <summary>
/// Gets or sets the shared state dictionary (scope-prefixed key -> serialized value).
/// </summary>
public Dictionary<string, string> State { get; set; } = [];
}
/// <summary>
/// Wrapper for activity output that includes state updates and events.
/// </summary>
internal sealed class ActivityOutputWithState
{
/// <summary>
/// Gets or sets the serialized result of the activity.
/// </summary>
public string? Result { get; set; }
/// <summary>
/// Gets or sets state updates made during activity execution.
/// </summary>
public Dictionary<string, string?> StateUpdates { get; set; } = [];
/// <summary>
/// Gets or sets scopes that were cleared during activity execution.
/// </summary>
public List<string> ClearedScopes { get; set; } = [];
/// <summary>
/// Gets or sets the serialized workflow events emitted during activity execution.
/// </summary>
public List<string> Events { get; set; } = [];
}
@@ -60,15 +60,6 @@ public class Workflow
return conditions;
}
/// <summary>
/// Gets all executor bindings in the workflow, keyed by their ID.
/// </summary>
/// <returns>A dictionary mapping executor IDs to their <see cref="ExecutorBinding"/>.</returns>
public Dictionary<string, ExecutorBinding> ReflectExecutors()
{
return new Dictionary<string, ExecutorBinding>(this.ExecutorBindings);
}
internal Dictionary<string, RequestPort> Ports { get; init; } = [];
/// <summary>
@@ -94,6 +85,15 @@ public class Workflow
return new Dictionary<string, ExecutorBinding>(this.ExecutorBindings);
}
/// <summary>
/// Gets the set of executor IDs that are registered as output sources via <see cref="WorkflowBuilder.WithOutputFrom"/>.
/// </summary>
/// <returns>A copy of the output executor IDs set. Modifications do not affect the workflow.</returns>
public HashSet<string> ReflectOutputExecutors()
{
return new HashSet<string>(this.OutputExecutors);
}
/// <summary>
/// Gets the identifier of the starting executor of the workflow.
/// </summary>