mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
WIP
This commit is contained in:
@@ -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
|
||||
+31
@@ -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>
|
||||
+16
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+389
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user