mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29ea514de9 | ||
|
|
1d0e44fb86 | ||
|
|
13e53eec8f | ||
|
|
3248060903 | ||
|
|
8ffe7e6092 | ||
|
|
b62b1f2191 | ||
|
|
e8d0bd9051 |
@@ -113,14 +113,14 @@
|
|||||||
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.1.2.3" />
|
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.1.2.3" />
|
||||||
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
|
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
|
||||||
<!-- Durable Task -->
|
<!-- Durable Task -->
|
||||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
|
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.19.1" />
|
||||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
|
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.19.0" />
|
||||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
|
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.19.0" />
|
||||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
|
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.19.0" />
|
||||||
<!-- Azure Functions -->
|
<!-- Azure Functions -->
|
||||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
|
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
|
||||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.11.0" />
|
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.13.1" />
|
||||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
|
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
|
||||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
|
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
|
||||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||||
|
|||||||
@@ -47,6 +47,18 @@
|
|||||||
<Project Path="samples/Durable/Agents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
|
<Project Path="samples/Durable/Agents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||||
<Project Path="samples/Durable/Agents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
|
<Project Path="samples/Durable/Agents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/Samples/Durable/Workflows/">
|
||||||
|
<Project Path="samples/Durable/Workflow/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
|
||||||
|
<Project Path="samples/Durable/Workflow/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
|
||||||
|
<Project Path="samples/Durable/Workflow/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj" />
|
||||||
|
<Project Path="samples/Durable/Workflow/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj" />
|
||||||
|
<Project Path="samples/Durable/Workflow/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj" />
|
||||||
|
<Project Path="samples/Durable/Workflow/ConsoleApps/07_WorkflowSharedState/07_WorkflowSharedState.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/Samples/Durable/Workflows/AzureFunctions/">
|
||||||
|
<Project Path="samples/Durable/Workflow/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
|
||||||
|
<Project Path="samples/Durable/Workflow/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
|
||||||
|
</Folder>
|
||||||
<Folder Name="/Samples/GettingStarted/">
|
<Folder Name="/Samples/GettingStarted/">
|
||||||
<File Path="samples/GettingStarted/README.md" />
|
<File Path="samples/GettingStarted/README.md" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||||
|
<AssemblyName>SingleAgent</AssemblyName>
|
||||||
|
<RootNamespace>SingleAgent</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Azure Functions packages -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.AI.OpenAI" />
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
</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.Hosting.AzureFunctions" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||||
|
</ItemGroup>
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace SequentialWorkflow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up an order by its ID and return an Order object.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
|
||||||
|
{
|
||||||
|
public override async ValueTask<Order> HandleAsync(
|
||||||
|
string message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
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.FromMicroseconds(100), cancellationToken);
|
||||||
|
|
||||||
|
Order order = new(
|
||||||
|
Id: message,
|
||||||
|
OrderDate: DateTime.UtcNow.AddDays(-1),
|
||||||
|
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.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
|
||||||
|
{
|
||||||
|
public override async ValueTask<Order> HandleAsync(
|
||||||
|
Order message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
// Simulate a slow cancellation process (e.g., calling external payment system)
|
||||||
|
for (int i = 1; i <= 3; i++)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||||
|
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||||
|
Console.WriteLine("│ [Activity] OrderCancel: Processing...");
|
||||||
|
Console.ResetColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
Order cancelledOrder = message with { IsCancelled = true };
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
|
Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return cancelledOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a cancellation confirmation email to the customer.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
|
||||||
|
{
|
||||||
|
public override ValueTask<string> HandleAsync(
|
||||||
|
Order message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'...");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}.";
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, Customer Customer);
|
||||||
|
|
||||||
|
internal sealed record Customer(string Name, string Email);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a batch cancellation request with multiple order IDs and a reason.
|
||||||
|
/// This demonstrates using a complex typed object as workflow input.
|
||||||
|
/// </summary>
|
||||||
|
#pragma warning disable CA1812 // Instantiated via JSON deserialization at runtime
|
||||||
|
internal sealed record BatchCancelRequest(string[] OrderIds, string Reason, bool NotifyCustomers);
|
||||||
|
#pragma warning restore CA1812
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of processing a batch cancellation.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record BatchCancelResult(int TotalOrders, int CancelledCount, string Reason);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a status report for an order.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StatusReport() : Executor<Order, string>("StatusReport")
|
||||||
|
{
|
||||||
|
public override ValueTask<string> HandleAsync(
|
||||||
|
Order message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Green;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine($"│ [Activity] StatusReport: Generating report for order '{message.Id}'");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
string status = message.IsCancelled ? "Cancelled" : "Active";
|
||||||
|
string result = $"Order {message.Id} for {message.Customer.Name}: Status={status}, Date={message.OrderDate:yyyy-MM-dd}";
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Green;
|
||||||
|
Console.WriteLine($"│ [Activity] StatusReport: ✓ {result}");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes a batch cancellation request. Accepts a complex <see cref="BatchCancelRequest"/> object
|
||||||
|
/// as input, demonstrating how workflows can receive structured JSON input.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class BatchCancelProcessor() : Executor<BatchCancelRequest, BatchCancelResult>("BatchCancelProcessor")
|
||||||
|
{
|
||||||
|
public override async ValueTask<BatchCancelResult> HandleAsync(
|
||||||
|
BatchCancelRequest message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine($"│ [Activity] BatchCancelProcessor: Processing {message.OrderIds.Length} orders");
|
||||||
|
Console.WriteLine($"│ [Activity] BatchCancelProcessor: Reason: {message.Reason}");
|
||||||
|
Console.WriteLine($"│ [Activity] BatchCancelProcessor: Notify customers: {message.NotifyCustomers}");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
// Simulate processing each order
|
||||||
|
int cancelledCount = 0;
|
||||||
|
foreach (string orderId in message.OrderIds)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||||
|
cancelledCount++;
|
||||||
|
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||||
|
Console.WriteLine($"│ [Activity] BatchCancelProcessor: ✓ Cancelled order '{orderId}'");
|
||||||
|
Console.ResetColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
BatchCancelResult result = new(message.OrderIds.Length, cancelledCount, message.Reason);
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
|
Console.WriteLine($"│ [Activity] BatchCancelProcessor: ✓ Batch complete: {cancelledCount}/{message.OrderIds.Length} cancelled");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a summary of the batch cancellation.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class BatchCancelSummary() : Executor<BatchCancelResult, string>("BatchCancelSummary")
|
||||||
|
{
|
||||||
|
public override ValueTask<string> HandleAsync(
|
||||||
|
BatchCancelResult message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine("│ [Activity] BatchCancelSummary: Generating summary");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
string result = $"Batch cancellation complete: {message.CancelledCount}/{message.TotalOrders} orders cancelled. Reason: {message.Reason}";
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.WriteLine($"│ [Activity] BatchCancelSummary: ✓ {result}");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample demonstrates three workflows that share executors.
|
||||||
|
// The CancelOrder workflow cancels an order and notifies the customer.
|
||||||
|
// The OrderStatus workflow looks up an order and generates a status report.
|
||||||
|
// The BatchCancelOrders workflow accepts a complex JSON input to cancel multiple orders.
|
||||||
|
// Both CancelOrder and OrderStatus reuse the same OrderLookup executor, demonstrating executor sharing.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.Azure.Functions.Worker.Builder;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using SequentialWorkflow;
|
||||||
|
|
||||||
|
// Define executors for all workflows
|
||||||
|
OrderLookup orderLookup = new();
|
||||||
|
OrderCancel orderCancel = new();
|
||||||
|
SendEmail sendEmail = new();
|
||||||
|
StatusReport statusReport = new();
|
||||||
|
BatchCancelProcessor batchCancelProcessor = new();
|
||||||
|
BatchCancelSummary batchCancelSummary = 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();
|
||||||
|
|
||||||
|
// Build the OrderStatus workflow: OrderLookup -> StatusReport
|
||||||
|
// This workflow shares the OrderLookup executor with the CancelOrder workflow.
|
||||||
|
Workflow orderStatus = new WorkflowBuilder(orderLookup)
|
||||||
|
.WithName("OrderStatus")
|
||||||
|
.WithDescription("Look up an order and generate a status report")
|
||||||
|
.AddEdge(orderLookup, statusReport)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Build the BatchCancelOrders workflow: BatchCancelProcessor -> BatchCancelSummary
|
||||||
|
// This workflow demonstrates using a complex JSON object as the workflow input.
|
||||||
|
Workflow batchCancelOrders = new WorkflowBuilder(batchCancelProcessor)
|
||||||
|
.WithName("BatchCancelOrders")
|
||||||
|
.WithDescription("Cancel multiple orders in a batch using a complex JSON input")
|
||||||
|
.AddEdge(batchCancelProcessor, batchCancelSummary)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
using IHost app = FunctionsApplication
|
||||||
|
.CreateBuilder(args)
|
||||||
|
.ConfigureFunctionsWebApplication()
|
||||||
|
.ConfigureDurableWorkflows(workflows => workflows.AddWorkflows(cancelOrder, orderStatus, batchCancelOrders))
|
||||||
|
.Build();
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Sequential Workflow Sample
|
||||||
|
|
||||||
|
This sample demonstrates how to use the Microsoft Agent Framework to create an Azure Functions app that hosts durable workflows with sequential executor chains. It showcases two workflows that share a common executor, demonstrating executor reuse across workflows.
|
||||||
|
|
||||||
|
## Key Concepts Demonstrated
|
||||||
|
|
||||||
|
- Defining workflows with sequential executor chains using `WorkflowBuilder`
|
||||||
|
- Sharing executors across multiple workflows (the `OrderLookup` executor is used by both workflows)
|
||||||
|
- Registering workflows with the Function app using `ConfigureDurableWorkflows`
|
||||||
|
- Durable orchestration ensuring workflows survive process restarts and failures
|
||||||
|
- Starting workflows via HTTP requests
|
||||||
|
- Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
This sample defines two workflows:
|
||||||
|
|
||||||
|
1. **CancelOrder**: `OrderLookup` → `OrderCancel` → `SendEmail` — Looks up an order, cancels it, and sends a confirmation email.
|
||||||
|
2. **OrderStatus**: `OrderLookup` → `StatusReport` — Looks up an order and generates a status report.
|
||||||
|
|
||||||
|
Both workflows share the `OrderLookup` executor, which is registered only once by the framework.
|
||||||
|
|
||||||
|
## 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 and function app running, you can test the sample by sending HTTP requests to the workflow endpoints.
|
||||||
|
|
||||||
|
You can use the `demo.http` file to trigger the workflows, or a command line tool like `curl` as shown below:
|
||||||
|
|
||||||
|
### Cancel an Order
|
||||||
|
|
||||||
|
Bash (Linux/macOS/WSL):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
-d "12345"
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod -Method Post `
|
||||||
|
-Uri http://localhost:7071/api/workflows/CancelOrder/run `
|
||||||
|
-ContentType text/plain `
|
||||||
|
-Body "12345"
|
||||||
|
```
|
||||||
|
|
||||||
|
The response will confirm the workflow orchestration has started:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Workflow orchestration started for CancelOrder. Orchestration runId: abc123def456
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** You can provide a custom run ID by appending a `runId` query parameter:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> curl -X POST "http://localhost:7071/api/workflows/CancelOrder/run?runId=my-order-123" \
|
||||||
|
> -H "Content-Type: text/plain" \
|
||||||
|
> -d "12345"
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> If not provided, a unique run ID is auto-generated.
|
||||||
|
|
||||||
|
In the function app logs, you will see the sequential execution of each executor:
|
||||||
|
|
||||||
|
```text
|
||||||
|
│ [Activity] OrderLookup: Starting lookup for order '12345'
|
||||||
|
│ [Activity] OrderLookup: Found order '12345' for customer 'Jerry'
|
||||||
|
│ [Activity] OrderCancel: Starting cancellation for order '12345'
|
||||||
|
│ [Activity] OrderCancel: ✓ Order '12345' has been cancelled
|
||||||
|
│ [Activity] SendEmail: Sending email to 'jerry@example.com'...
|
||||||
|
│ [Activity] SendEmail: ✓ Email sent successfully!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Order Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7071/api/workflows/OrderStatus/run \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
-d "12345"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `OrderStatus` workflow reuses the same `OrderLookup` executor and then generates a status report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
│ [Activity] OrderLookup: Starting lookup for order '12345'
|
||||||
|
│ [Activity] OrderLookup: Found order '12345' for customer 'Jerry'
|
||||||
|
│ [Activity] StatusReport: Generating report for order '12345'
|
||||||
|
│ [Activity] StatusReport: ✓ Order 12345 for Jerry: Status=Active, Date=2025-01-01
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Workflows in the DTS Dashboard
|
||||||
|
|
||||||
|
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history.
|
||||||
|
|
||||||
|
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Default endpoint address for local testing
|
||||||
|
@authority=http://localhost:7071
|
||||||
|
|
||||||
|
### Cancel an order
|
||||||
|
POST {{authority}}/api/workflows/CancelOrder/run
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
12345
|
||||||
|
|
||||||
|
### Cancel an order with a custom run ID
|
||||||
|
POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
99999
|
||||||
|
|
||||||
|
### Get order status (shares OrderLookup executor with CancelOrder)
|
||||||
|
POST {{authority}}/api/workflows/OrderStatus/run
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
12345
|
||||||
|
|
||||||
|
### Batch cancel orders with a complex JSON input
|
||||||
|
POST {{authority}}/api/workflows/BatchCancelOrders/run
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"orderIds": ["1001", "1002", "1003"], "reason": "Customer requested cancellation", "notifyCustomers": true}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0",
|
||||||
|
"logging": {
|
||||||
|
"logLevel": {
|
||||||
|
"Microsoft.Agents.AI.DurableTask": "Information",
|
||||||
|
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
|
||||||
|
"DurableTask": "Information",
|
||||||
|
"Microsoft.DurableTask": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"durableTask": {
|
||||||
|
"hubName": "default",
|
||||||
|
"storageProvider": {
|
||||||
|
"type": "AzureManaged",
|
||||||
|
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||||
|
<AssemblyName>SingleAgent</AssemblyName>
|
||||||
|
<RootNamespace>SingleAgent</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Azure Functions packages -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||||
|
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.AI.OpenAI" />
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
</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.Hosting.AzureFunctions" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||||
|
</ItemGroup>
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace WorkflowConcurrency;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses and validates the incoming question before sending to AI agents.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
|
||||||
|
{
|
||||||
|
public override ValueTask<string> HandleAsync(
|
||||||
|
string message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
|
||||||
|
|
||||||
|
string formattedQuestion = message.Trim();
|
||||||
|
if (!formattedQuestion.EndsWith('?'))
|
||||||
|
{
|
||||||
|
formattedQuestion += "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
|
||||||
|
Console.WriteLine("│ [ParseQuestion] → Sending to 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,45 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Azure;
|
||||||
|
using Azure.AI.OpenAI;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.Azure.Functions.Worker.Builder;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using OpenAI.Chat;
|
||||||
|
using WorkflowConcurrency;
|
||||||
|
|
||||||
|
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||||
|
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||||
|
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||||
|
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||||
|
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||||
|
|
||||||
|
// Create Azure OpenAI client
|
||||||
|
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||||
|
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||||
|
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||||
|
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
|
||||||
|
|
||||||
|
// Define the 4 executors for the workflow
|
||||||
|
ParseQuestionExecutor parseQuestion = new();
|
||||||
|
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist");
|
||||||
|
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist");
|
||||||
|
AggregatorExecutor aggregator = new();
|
||||||
|
|
||||||
|
// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator
|
||||||
|
Workflow workflow = new WorkflowBuilder(parseQuestion)
|
||||||
|
.WithName("ExpertReview")
|
||||||
|
.AddFanOutEdge(parseQuestion, [physicist, chemist])
|
||||||
|
.AddFanInEdge([physicist, chemist], aggregator)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
using IHost app = FunctionsApplication
|
||||||
|
.CreateBuilder(args)
|
||||||
|
.ConfigureFunctionsWebApplication()
|
||||||
|
.ConfigureDurableWorkflows(workflows => workflows.AddWorkflows(workflow))
|
||||||
|
.Build();
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Concurrent Workflow Sample
|
||||||
|
|
||||||
|
This sample demonstrates how to use the Microsoft Agent Framework to create an Azure Functions app that orchestrates concurrent execution of multiple AI agents using the fan-out/fan-in pattern within a durable workflow.
|
||||||
|
|
||||||
|
## Key Concepts Demonstrated
|
||||||
|
|
||||||
|
- Defining workflows with fan-out/fan-in edges for parallel execution using `WorkflowBuilder`
|
||||||
|
- Mixing custom executors with AI agents in a single workflow
|
||||||
|
- Concurrent execution of multiple AI agents (physics and chemistry experts)
|
||||||
|
- Response aggregation from parallel branches into a unified result
|
||||||
|
- Durable orchestration with automatic checkpointing and resumption from failures
|
||||||
|
- Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
This sample defines a single workflow:
|
||||||
|
|
||||||
|
**ExpertReview**: `ParseQuestion` → [`Physicist`, `Chemist`] (parallel) → `Aggregator`
|
||||||
|
|
||||||
|
1. **ParseQuestion** — A custom executor that validates and formats the incoming question.
|
||||||
|
2. **Physicist** and **Chemist** — AI agents that run concurrently, each providing an expert perspective.
|
||||||
|
3. **Aggregator** — A custom executor that combines the parallel responses into a comprehensive answer.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
This sample requires Azure OpenAI. Set the following environment variables:
|
||||||
|
|
||||||
|
- `AZURE_OPENAI_ENDPOINT` — Your Azure OpenAI endpoint URL.
|
||||||
|
- `AZURE_OPENAI_DEPLOYMENT` — The name of your chat model deployment.
|
||||||
|
- `AZURE_OPENAI_KEY` (optional) — Your Azure OpenAI API key. If not set, Azure CLI credentials are used.
|
||||||
|
|
||||||
|
## Running the Sample
|
||||||
|
|
||||||
|
With the environment setup and function app running, you can test the sample by sending an HTTP request with a science question to the workflow endpoint.
|
||||||
|
|
||||||
|
You can use the `demo.http` file to trigger the workflow, or a command line tool like `curl` as shown below:
|
||||||
|
|
||||||
|
Bash (Linux/macOS/WSL):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:7071/api/workflows/ExpertReview/run \
|
||||||
|
-H "Content-Type: text/plain" \
|
||||||
|
-d "What is temperature?"
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod -Method Post `
|
||||||
|
-Uri http://localhost:7071/api/workflows/ExpertReview/run `
|
||||||
|
-ContentType text/plain `
|
||||||
|
-Body "What is temperature?"
|
||||||
|
```
|
||||||
|
|
||||||
|
The response will confirm the workflow orchestration has started:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Workflow orchestration started for ExpertReview. Orchestration runId: abc123def456
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** You can provide a custom run ID by appending a `runId` query parameter:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> curl -X POST "http://localhost:7071/api/workflows/ExpertReview/run?runId=my-review-123" \
|
||||||
|
> -H "Content-Type: text/plain" \
|
||||||
|
> -d "What is temperature?"
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> If not provided, a unique run ID is auto-generated.
|
||||||
|
|
||||||
|
In the function app logs, you will see the fan-out/fan-in execution pattern:
|
||||||
|
|
||||||
|
```text
|
||||||
|
│ [ParseQuestion] Preparing question for AI agents...
|
||||||
|
│ [ParseQuestion] Question: "What is temperature?"
|
||||||
|
│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL...
|
||||||
|
│ [Aggregator] 📋 Received 2 AI agent responses
|
||||||
|
│ [Aggregator] Combining into comprehensive answer...
|
||||||
|
│ [Aggregator] ✓ Aggregation complete!
|
||||||
|
```
|
||||||
|
|
||||||
|
The Physicist and Chemist AI agents execute concurrently, and the Aggregator combines their responses into a formatted expert panel result.
|
||||||
|
|
||||||
|
### Viewing Workflows in the DTS Dashboard
|
||||||
|
|
||||||
|
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history.
|
||||||
|
|
||||||
|
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Default endpoint address for local testing
|
||||||
|
@authority=http://localhost:7071
|
||||||
|
|
||||||
|
### Prompt the agent
|
||||||
|
POST {{authority}}/api/workflows/ExpertReview/run
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
What is temperature?
|
||||||
|
|
||||||
|
### Start with a custom run ID
|
||||||
|
POST {{authority}}/api/workflows/ExpertReview/run?runId=my-review-123
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
What is gravity?
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0",
|
||||||
|
"logging": {
|
||||||
|
"logLevel": {
|
||||||
|
"Microsoft.Agents.AI.DurableTask": "Information",
|
||||||
|
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
|
||||||
|
"DurableTask": "Information",
|
||||||
|
"Microsoft.DurableTask": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"durableTask": {
|
||||||
|
"hubName": "default",
|
||||||
|
"storageProvider": {
|
||||||
|
"type": "AzureManaged",
|
||||||
|
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>SequentialWorkflow</AssemblyName>
|
||||||
|
<RootNamespace>SequentialWorkflow</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||||
|
<!--
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||||
|
</ItemGroup>
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace SequentialWorkflow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to cancel an order.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="OrderId">The ID of the order to cancel.</param>
|
||||||
|
/// <param name="Reason">The reason for cancellation.</param>
|
||||||
|
internal sealed record OrderCancelRequest(string OrderId, string Reason);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up an order by its ID and return an Order object.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OrderLookup() : Executor<OrderCancelRequest, Order>("OrderLookup")
|
||||||
|
{
|
||||||
|
public override async ValueTask<Order> HandleAsync(
|
||||||
|
OrderCancelRequest message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message.OrderId}'");
|
||||||
|
Console.WriteLine($"│ [Activity] OrderLookup: Cancellation reason: '{message.Reason}'");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
// Simulate database lookup with delay
|
||||||
|
await Task.Delay(TimeSpan.FromMicroseconds(100), cancellationToken);
|
||||||
|
|
||||||
|
Order order = new(
|
||||||
|
Id: message.OrderId,
|
||||||
|
OrderDate: DateTime.UtcNow.AddDays(-1),
|
||||||
|
IsCancelled: false,
|
||||||
|
CancelReason: message.Reason,
|
||||||
|
Customer: new Customer(Name: "Jerry", Email: "jerry@example.com"));
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||||
|
Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message.OrderId}' for customer '{order.Customer.Name}'");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels an order.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
|
||||||
|
{
|
||||||
|
public override async ValueTask<Order> HandleAsync(
|
||||||
|
Order message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Log that this activity is executing (not replaying)
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
// Simulate a slow cancellation process (e.g., calling external payment system)
|
||||||
|
for (int i = 1; i <= 3; i++)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||||
|
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||||
|
Console.WriteLine("│ [Activity] OrderCancel: Processing...");
|
||||||
|
Console.ResetColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
Order cancelledOrder = message with { IsCancelled = true };
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||||
|
Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return cancelledOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a cancellation confirmation email to the customer.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
|
||||||
|
{
|
||||||
|
public override ValueTask<string> HandleAsync(
|
||||||
|
Order message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'...");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}.";
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer);
|
||||||
|
|
||||||
|
internal sealed record Customer(string Name, string Email);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask.Client.AzureManaged;
|
||||||
|
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SequentialWorkflow;
|
||||||
|
|
||||||
|
// Get DTS connection string from environment variable
|
||||||
|
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||||
|
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||||
|
|
||||||
|
// Define executors for the workflow
|
||||||
|
OrderLookup orderLookup = new();
|
||||||
|
OrderCancel orderCancel = new();
|
||||||
|
SendEmail sendEmail = new();
|
||||||
|
|
||||||
|
// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail
|
||||||
|
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
|
||||||
|
.WithName("CancelOrder")
|
||||||
|
.WithDescription("Cancel an order and notify the customer")
|
||||||
|
.AddEdge(orderLookup, orderCancel)
|
||||||
|
.AddEdge(orderCancel, sendEmail)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
IHost host = Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||||
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.ConfigureDurableWorkflows(
|
||||||
|
workflowOptions => workflowOptions.AddWorkflow(cancelOrder),
|
||||||
|
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||||
|
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await host.StartAsync();
|
||||||
|
|
||||||
|
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||||
|
|
||||||
|
Console.WriteLine("Durable Workflow Sample");
|
||||||
|
Console.WriteLine("Workflow: OrderLookup -> OrderCancel -> SendEmail");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Enter an order ID (or 'exit'):");
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
Console.Write("> ");
|
||||||
|
string? input = Console.ReadLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
OrderCancelRequest request = new(OrderId: input, Reason: "Customer requested cancellation");
|
||||||
|
await StartNewWorkflowAsync(request, cancelOrder, workflowClient);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.StopAsync();
|
||||||
|
|
||||||
|
// Start a new workflow using IWorkflowClient with typed input
|
||||||
|
static async Task StartNewWorkflowAsync(OrderCancelRequest request, Workflow workflow, IWorkflowClient client)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Starting workflow for order '{request.OrderId}' (Reason: {request.Reason})...");
|
||||||
|
|
||||||
|
// RunAsync returns IWorkflowRun, cast to IAwaitableWorkflowRun for completion waiting
|
||||||
|
IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, request);
|
||||||
|
Console.WriteLine($"Run ID: {run.RunId}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.WriteLine("Waiting for workflow to complete...");
|
||||||
|
string? result = await run.WaitForCompletionAsync<string>();
|
||||||
|
Console.WriteLine($"Workflow completed. {result}");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Sequential Workflow Sample
|
||||||
|
|
||||||
|
This sample demonstrates how to run a sequential workflow as a durable orchestration from a console application using the Durable Task Framework. It showcases the **durability** aspect - if the process crashes mid-execution, the workflow automatically resumes without re-executing completed activities.
|
||||||
|
|
||||||
|
## Key Concepts Demonstrated
|
||||||
|
|
||||||
|
- Building a sequential workflow with the `WorkflowBuilder` API
|
||||||
|
- Using `ConfigureDurableWorkflows` to register workflows with dependency injection
|
||||||
|
- Running workflows with `IWorkflowClient`
|
||||||
|
- **Durability**: Automatic resume of interrupted workflows
|
||||||
|
- **Activity caching**: Completed activities are not re-executed on replay
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The sample implements an order cancellation workflow with three executors:
|
||||||
|
|
||||||
|
```
|
||||||
|
OrderLookup --> OrderCancel --> SendEmail
|
||||||
|
```
|
||||||
|
|
||||||
|
| Executor | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| OrderLookup | Looks up an order by ID |
|
||||||
|
| OrderCancel | Marks the order as cancelled |
|
||||||
|
| SendEmail | Sends a cancellation confirmation email |
|
||||||
|
|
||||||
|
## Durability Demonstration
|
||||||
|
|
||||||
|
The key feature of Durable Task Framework is **durability**:
|
||||||
|
|
||||||
|
- **Activity results are persisted**: When an activity completes, its result is saved
|
||||||
|
- **Orchestrations replay**: On restart, the orchestration replays from the beginning
|
||||||
|
- **Completed activities skip execution**: The framework uses cached results
|
||||||
|
- **Automatic resume**: The worker automatically picks up pending work on startup
|
||||||
|
|
||||||
|
### Try It Yourself
|
||||||
|
|
||||||
|
> **Tip:** To give yourself more time to stop the application during `OrderCancel`, consider increasing the loop iteration count or `Task.Delay` duration in the `OrderCancel` executor in `OrderCancelExecutors.cs`.
|
||||||
|
|
||||||
|
1. Start the application and enter an order ID (e.g., `12345`)
|
||||||
|
2. Wait for `OrderLookup` to complete, then stop the app (Ctrl+C) during `OrderCancel`
|
||||||
|
3. Restart the application
|
||||||
|
4. Observe:
|
||||||
|
- `OrderLookup` is **NOT** re-executed (result was cached)
|
||||||
|
- `OrderCancel` **restarts** (it didn't complete before the interruption)
|
||||||
|
- `SendEmail` runs after `OrderCancel` completes
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
See the [README.md](../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
|
||||||
|
|
||||||
|
## Running the Sample
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dotnet/samples/Durable/Workflow/ConsoleApps/01_SequentialWorkflow
|
||||||
|
dotnet run --framework net10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample Output
|
||||||
|
|
||||||
|
```text
|
||||||
|
Durable Workflow Sample
|
||||||
|
Workflow: OrderLookup -> OrderCancel -> SendEmail
|
||||||
|
|
||||||
|
Enter an order ID (or 'exit'):
|
||||||
|
> 12345
|
||||||
|
Starting workflow for order: 12345
|
||||||
|
Run ID: abc123...
|
||||||
|
|
||||||
|
[OrderLookup] Looking up order '12345'...
|
||||||
|
[OrderLookup] Found order for customer 'Jerry'
|
||||||
|
|
||||||
|
[OrderCancel] Cancelling order '12345'...
|
||||||
|
[OrderCancel] Order cancelled successfully
|
||||||
|
|
||||||
|
[SendEmail] Sending email to 'jerry@example.com'...
|
||||||
|
[SendEmail] Email sent successfully
|
||||||
|
|
||||||
|
Workflow completed!
|
||||||
|
|
||||||
|
> exit
|
||||||
|
```
|
||||||
|
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>WorkflowConcurrency</AssemblyName>
|
||||||
|
<RootNamespace>WorkflowConcurrency</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
<PackageReference Include="Azure.AI.OpenAI" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||||
|
<!--
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||||
|
</ItemGroup>
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace WorkflowConcurrency;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses and validates the incoming question before sending to AI agents.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
|
||||||
|
{
|
||||||
|
public override ValueTask<string> HandleAsync(
|
||||||
|
string message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
|
||||||
|
|
||||||
|
string formattedQuestion = message.Trim();
|
||||||
|
if (!formattedQuestion.EndsWith('?'))
|
||||||
|
{
|
||||||
|
formattedQuestion += "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
|
||||||
|
Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL...");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(formattedQuestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregates responses from all AI agents into a comprehensive answer.
|
||||||
|
/// This is the Fan-in point where parallel results are collected.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AggregatorExecutor() : Executor<string[], string>("Aggregator")
|
||||||
|
{
|
||||||
|
public override ValueTask<string> HandleAsync(
|
||||||
|
string[] message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
|
||||||
|
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
|
||||||
|
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
|
||||||
|
" AI EXPERT PANEL RESPONSES\n" +
|
||||||
|
"═══════════════════════════════════════════════════════════════\n\n";
|
||||||
|
|
||||||
|
for (int i = 0; i < message.Length; i++)
|
||||||
|
{
|
||||||
|
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
|
||||||
|
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
|
||||||
|
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
|
||||||
|
"═══════════════════════════════════════════════════════════════";
|
||||||
|
|
||||||
|
return ValueTask.FromResult(aggregatedResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample demonstrates the Fan-out/Fan-in pattern in a durable workflow.
|
||||||
|
// The workflow uses 4 executors: 2 class-based executors and 2 AI agents.
|
||||||
|
//
|
||||||
|
// WORKFLOW PATTERN:
|
||||||
|
//
|
||||||
|
// ParseQuestion (class-based)
|
||||||
|
// |
|
||||||
|
// +----------+----------+
|
||||||
|
// | |
|
||||||
|
// Physicist Chemist
|
||||||
|
// (AI Agent) (AI Agent)
|
||||||
|
// | |
|
||||||
|
// +----------+----------+
|
||||||
|
// |
|
||||||
|
// Aggregator (class-based)
|
||||||
|
|
||||||
|
using Azure;
|
||||||
|
using Azure.AI.OpenAI;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask.Client.AzureManaged;
|
||||||
|
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OpenAI.Chat;
|
||||||
|
using WorkflowConcurrency;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||||
|
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||||
|
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||||
|
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||||
|
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||||
|
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||||
|
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||||
|
|
||||||
|
// Create Azure OpenAI client
|
||||||
|
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||||
|
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||||
|
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||||
|
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
|
||||||
|
|
||||||
|
// Define the 4 executors for the workflow
|
||||||
|
ParseQuestionExecutor parseQuestion = new();
|
||||||
|
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist");
|
||||||
|
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist");
|
||||||
|
AggregatorExecutor aggregator = new();
|
||||||
|
|
||||||
|
// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator
|
||||||
|
Workflow workflow = new WorkflowBuilder(parseQuestion)
|
||||||
|
.WithName("ExpertReview")
|
||||||
|
.AddFanOutEdge(parseQuestion, [physicist, chemist])
|
||||||
|
.AddFanInEdge([physicist, chemist], aggregator)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Configure and start the host
|
||||||
|
IHost host = Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||||
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.ConfigureDurableOptions(
|
||||||
|
options => options.Workflows.AddWorkflow(workflow),
|
||||||
|
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||||
|
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await host.StartAsync();
|
||||||
|
|
||||||
|
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||||
|
|
||||||
|
Console.WriteLine("Fan-out/Fan-in Workflow Sample");
|
||||||
|
Console.WriteLine("ParseQuestion -> [Physicist, Chemist] -> Aggregator");
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Enter a science question (or 'exit' to quit):");
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
Console.Write("> ");
|
||||||
|
string? input = Console.ReadLine();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IWorkflowRun run = await workflowClient.RunAsync(workflow, input);
|
||||||
|
Console.WriteLine($"Run ID: {run.RunId}");
|
||||||
|
|
||||||
|
if (run is IAwaitableWorkflowRun awaitableRun)
|
||||||
|
{
|
||||||
|
string? result = await awaitableRun.WaitForCompletionAsync<string>();
|
||||||
|
|
||||||
|
Console.WriteLine("Workflow completed!");
|
||||||
|
Console.WriteLine(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.StopAsync();
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Concurrent Workflow Sample (Fan-Out/Fan-In)
|
||||||
|
|
||||||
|
This sample demonstrates the **fan-out/fan-in** pattern in a durable workflow, combining class-based executors with AI agents running in parallel.
|
||||||
|
|
||||||
|
## Key Concepts Demonstrated
|
||||||
|
|
||||||
|
- **Fan-out/Fan-in pattern**: Parallel execution with result aggregation
|
||||||
|
- **Mixed executor types**: Class-based executors and AI agents in the same workflow
|
||||||
|
- **AI agents as executors**: Using `ChatClient.AsAIAgent()` to create workflow-compatible agents
|
||||||
|
- **Workflow registration**: Auto-registration of agents used within workflows
|
||||||
|
- **Standalone agents**: Registering agents outside of workflows
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The sample implements an expert review workflow with four executors:
|
||||||
|
|
||||||
|
```
|
||||||
|
ParseQuestion
|
||||||
|
|
|
||||||
|
+----------+----------+
|
||||||
|
| |
|
||||||
|
Physicist Chemist
|
||||||
|
(AI Agent) (AI Agent)
|
||||||
|
| |
|
||||||
|
+----------+----------+
|
||||||
|
|
|
||||||
|
Aggregator
|
||||||
|
```
|
||||||
|
|
||||||
|
| Executor | Type | Description |
|
||||||
|
|----------|------|-------------|
|
||||||
|
| ParseQuestion | Class-based | Parses the user's question for expert review |
|
||||||
|
| Physicist | AI Agent | Provides physics perspective (runs in parallel) |
|
||||||
|
| Chemist | AI Agent | Provides chemistry perspective (runs in parallel) |
|
||||||
|
| Aggregator | Class-based | Combines expert responses into a final answer |
|
||||||
|
|
||||||
|
## Fan-Out/Fan-In Pattern
|
||||||
|
|
||||||
|
The workflow demonstrates the fan-out/fan-in pattern:
|
||||||
|
|
||||||
|
1. **Fan-out**: `ParseQuestion` sends the question to both `Physicist` and `Chemist` simultaneously
|
||||||
|
2. **Parallel execution**: Both AI agents process the question concurrently
|
||||||
|
3. **Fan-in**: `Aggregator` waits for both agents to complete, then combines their responses
|
||||||
|
|
||||||
|
This pattern is useful for:
|
||||||
|
- Gathering multiple perspectives on a problem
|
||||||
|
- Parallel processing of independent tasks
|
||||||
|
- Reducing overall execution time through concurrency
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
See the [README.md](../README.md) file in the parent directory for information on configuring the environment.
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Durable Task Scheduler (optional, defaults to localhost)
|
||||||
|
DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||||
|
|
||||||
|
# Azure OpenAI (required)
|
||||||
|
AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||||
|
AZURE_OPENAI_DEPLOYMENT="gpt-4o"
|
||||||
|
AZURE_OPENAI_KEY="your-key" # Optional if using Azure CLI credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Sample
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dotnet/samples/Durable/Workflow/ConsoleApps/02_ConcurrentWorkflow
|
||||||
|
dotnet run --framework net10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample Output
|
||||||
|
|
||||||
|
```text
|
||||||
|
+-----------------------------------------------------------------------+
|
||||||
|
| Fan-out/Fan-in Workflow Sample (4 Executors) |
|
||||||
|
| |
|
||||||
|
| ParseQuestion -> [Physicist, Chemist] -> Aggregator |
|
||||||
|
| (class-based) (AI agents, parallel) (class-based) |
|
||||||
|
+-----------------------------------------------------------------------+
|
||||||
|
|
||||||
|
Enter a science question (or 'exit' to quit):
|
||||||
|
|
||||||
|
Question: Why is the sky blue?
|
||||||
|
Instance: abc123...
|
||||||
|
|
||||||
|
[ParseQuestion] Parsing question for expert review...
|
||||||
|
[Physicist] Analyzing from physics perspective...
|
||||||
|
[Chemist] Analyzing from chemistry perspective...
|
||||||
|
[Aggregator] Combining expert responses...
|
||||||
|
|
||||||
|
Workflow completed!
|
||||||
|
|
||||||
|
Physics perspective: The sky appears blue due to Rayleigh scattering...
|
||||||
|
Chemistry perspective: The molecular composition of our atmosphere...
|
||||||
|
Combined answer: ...
|
||||||
|
|
||||||
|
Question: exit
|
||||||
|
```
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>ConditionalEdges</AssemblyName>
|
||||||
|
<RootNamespace>ConditionalEdges</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||||
|
<!--
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||||
|
</ItemGroup>
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace ConditionalEdges;
|
||||||
|
|
||||||
|
internal sealed class Order
|
||||||
|
{
|
||||||
|
public Order(string id, decimal amount)
|
||||||
|
{
|
||||||
|
this.Id = id;
|
||||||
|
this.Amount = amount;
|
||||||
|
}
|
||||||
|
public string Id { get; }
|
||||||
|
public decimal Amount { get; }
|
||||||
|
public Customer? Customer { get; set; }
|
||||||
|
public string? PaymentReferenceNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record Customer(int Id, string Name, bool IsBlocked);
|
||||||
|
|
||||||
|
internal sealed class OrderIdParser() : Executor<string, Order>("OrderIdParser")
|
||||||
|
{
|
||||||
|
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return GetOrder(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Order GetOrder(string id)
|
||||||
|
{
|
||||||
|
// Simulate fetching order details
|
||||||
|
return new Order(id, 100.0m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class OrderEnrich() : Executor<Order, Order>("EnrichOrder")
|
||||||
|
{
|
||||||
|
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
message.Customer = GetCustomerForOrder(message.Id);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Customer GetCustomerForOrder(string orderId)
|
||||||
|
{
|
||||||
|
if (orderId.Contains('B'))
|
||||||
|
{
|
||||||
|
return new Customer(101, "George", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Customer(201, "Jerry", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class PaymentProcesser() : Executor<Order, Order>("PaymentProcesser")
|
||||||
|
{
|
||||||
|
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Call payment gateway.
|
||||||
|
message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class NotifyFraud() : Executor<Order, string>("NotifyFraud")
|
||||||
|
{
|
||||||
|
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Notify fraud team.
|
||||||
|
return $"Order {message.Id} flagged as fraudulent for customer {message.Customer?.Name}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class OrderRouteConditions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a condition that evaluates to true when the customer is blocked.
|
||||||
|
/// </summary>
|
||||||
|
internal static Func<Order?, bool> WhenBlocked() => order => order?.Customer?.IsBlocked == true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a condition that evaluates to true when the customer is not blocked.
|
||||||
|
/// </summary>
|
||||||
|
internal static Func<Order?, bool> WhenNotBlocked() => order => order?.Customer?.IsBlocked == false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample demonstrates conditional edges in a workflow.
|
||||||
|
// Orders are routed to different executors based on customer status:
|
||||||
|
// - Blocked customers → NotifyFraud
|
||||||
|
// - Valid customers → PaymentProcessor
|
||||||
|
|
||||||
|
using ConditionalEdges;
|
||||||
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask.Client.AzureManaged;
|
||||||
|
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||||
|
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||||
|
|
||||||
|
// Create executor instances
|
||||||
|
OrderIdParser orderParser = new();
|
||||||
|
OrderEnrich orderEnrich = new();
|
||||||
|
PaymentProcesser paymentProcessor = new();
|
||||||
|
NotifyFraud notifyFraud = new();
|
||||||
|
|
||||||
|
// Build workflow with conditional edges
|
||||||
|
// The condition functions evaluate the Order output from OrderEnrich
|
||||||
|
WorkflowBuilder builder = new(orderParser);
|
||||||
|
builder
|
||||||
|
.AddEdge(orderParser, orderEnrich)
|
||||||
|
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
|
||||||
|
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
|
||||||
|
|
||||||
|
Workflow auditOrder = builder.WithName("AuditOrder").Build();
|
||||||
|
|
||||||
|
IHost host = Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||||
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.ConfigureDurableWorkflows(
|
||||||
|
workflowOptions => workflowOptions.AddWorkflow(auditOrder),
|
||||||
|
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||||
|
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await host.StartAsync();
|
||||||
|
|
||||||
|
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||||
|
|
||||||
|
Console.WriteLine("Enter an order ID (or 'exit'):");
|
||||||
|
Console.WriteLine("Tip: Order IDs containing 'B' are flagged as blocked customers.\n");
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
Console.Write("> ");
|
||||||
|
string? input = Console.ReadLine();
|
||||||
|
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await StartNewWorkflowAsync(input, auditOrder, workflowClient);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.StopAsync();
|
||||||
|
|
||||||
|
// Start a new workflow and wait for completion
|
||||||
|
static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Starting workflow for order '{orderId}'...");
|
||||||
|
|
||||||
|
// Cast to IAwaitableWorkflowRun to access WaitForCompletionAsync
|
||||||
|
IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, orderId);
|
||||||
|
Console.WriteLine($"Run ID: {run.RunId}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Console.WriteLine("Waiting for workflow to complete...");
|
||||||
|
string? result = await run.WaitForCompletionAsync<string>();
|
||||||
|
Console.WriteLine($"Workflow completed. {result}");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Conditional Edges Workflow Sample
|
||||||
|
|
||||||
|
This sample demonstrates how to build a workflow with **conditional edges** that route execution to different paths based on runtime conditions. The workflow evaluates conditions on the output of an executor to determine which downstream executor to run.
|
||||||
|
|
||||||
|
## Key Concepts Demonstrated
|
||||||
|
|
||||||
|
- Building workflows with **conditional edges** using `AddEdge` with a `condition` parameter
|
||||||
|
- Defining reusable condition functions for routing logic
|
||||||
|
- Branching workflow execution based on data-driven decisions
|
||||||
|
- Using `ConfigureDurableWorkflows` to register workflows with dependency injection
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The sample implements an order audit workflow that routes orders differently based on whether the customer is blocked (flagged for fraud):
|
||||||
|
|
||||||
|
```
|
||||||
|
OrderIdParser --> OrderEnrich --[IsBlocked]--> NotifyFraud
|
||||||
|
|
|
||||||
|
+--[NotBlocked]--> PaymentProcessor
|
||||||
|
```
|
||||||
|
|
||||||
|
| Executor | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| OrderIdParser | Parses the order ID and retrieves order details |
|
||||||
|
| OrderEnrich | Enriches the order with customer information |
|
||||||
|
| PaymentProcessor | Processes payment for valid orders |
|
||||||
|
| NotifyFraud | Notifies the fraud team for blocked customers |
|
||||||
|
|
||||||
|
## How Conditional Edges Work
|
||||||
|
|
||||||
|
Conditional edges allow you to specify a condition function that determines whether the edge should be traversed:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
builder
|
||||||
|
.AddEdge(orderParser, orderEnrich)
|
||||||
|
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
|
||||||
|
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
|
||||||
|
```
|
||||||
|
|
||||||
|
The condition functions receive the output of the source executor and return a boolean:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
internal static class OrderRouteConditions
|
||||||
|
{
|
||||||
|
// Routes to NotifyFraud when customer is blocked
|
||||||
|
internal static Func<Order?, bool> WhenBlocked() =>
|
||||||
|
order => order?.Customer?.IsBlocked == true;
|
||||||
|
|
||||||
|
// Routes to PaymentProcessor when customer is not blocked
|
||||||
|
internal static Func<Order?, bool> WhenNotBlocked() =>
|
||||||
|
order => order?.Customer?.IsBlocked == false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routing Logic
|
||||||
|
|
||||||
|
In this sample, the routing is based on the order ID:
|
||||||
|
- Order IDs containing the letter **'B'** are associated with blocked customers ? routed to `NotifyFraud`
|
||||||
|
- All other order IDs are associated with valid customers ? routed to `PaymentProcessor`
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
See the [README.md](../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
|
||||||
|
|
||||||
|
## Running the Sample
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dotnet/samples/Durable/Workflow/ConsoleApps/03_ConditionalEdges
|
||||||
|
dotnet run --framework net10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sample Output
|
||||||
|
|
||||||
|
**Valid order (routes to PaymentProcessor):**
|
||||||
|
```text
|
||||||
|
Enter an order ID (or 'exit'):
|
||||||
|
> 12345
|
||||||
|
Starting workflow for order '12345'...
|
||||||
|
Run ID: abc123...
|
||||||
|
Waiting for workflow to complete...
|
||||||
|
Workflow completed. {"Id":"12345","Amount":100.0,"Customer":{"Id":201,"Name":"Jerry","IsBlocked":false},"PaymentReferenceNumber":"a1b2"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Blocked order (routes to NotifyFraud):**
|
||||||
|
```text
|
||||||
|
Enter an order ID (or 'exit'):
|
||||||
|
> 12345B
|
||||||
|
Starting workflow for order '12345B'...
|
||||||
|
Run ID: def456...
|
||||||
|
Waiting for workflow to complete...
|
||||||
|
Workflow completed. Order 12345B flagged as fraudulent for customer George.
|
||||||
|
```
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>WorkflowConcurrency</AssemblyName>
|
||||||
|
<RootNamespace>WorkflowConcurrency</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
<PackageReference Include="Azure.AI.OpenAI" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||||
|
<!--
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||||
|
</ItemGroup>
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace WorkflowConcurrency;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses and validates the incoming question before sending to AI agents.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
|
||||||
|
{
|
||||||
|
public override ValueTask<string> HandleAsync(
|
||||||
|
string message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
|
||||||
|
|
||||||
|
string formattedQuestion = message.Trim();
|
||||||
|
if (!formattedQuestion.EndsWith('?'))
|
||||||
|
{
|
||||||
|
formattedQuestion += "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
|
||||||
|
Console.WriteLine("│ [ParseQuestion] → Sending to experts...");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(formattedQuestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregates responses from multiple AI agents into a unified response.
|
||||||
|
/// This executor collects all expert opinions and synthesizes them.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ResponseAggregatorExecutor() : Executor<string[], string>("ResponseAggregator")
|
||||||
|
{
|
||||||
|
public override ValueTask<string> HandleAsync(
|
||||||
|
string[] message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||||
|
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||||
|
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
|
||||||
|
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
|
||||||
|
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
|
||||||
|
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
|
||||||
|
" AI EXPERT PANEL RESPONSES\n" +
|
||||||
|
"═══════════════════════════════════════════════════════════════\n\n";
|
||||||
|
|
||||||
|
for (int i = 0; i < message.Length; i++)
|
||||||
|
{
|
||||||
|
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
|
||||||
|
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
|
||||||
|
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
|
||||||
|
"═══════════════════════════════════════════════════════════════";
|
||||||
|
|
||||||
|
return ValueTask.FromResult(aggregatedResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// This sample demonstrates the THREE ways to configure durable agents and workflows:
|
||||||
|
//
|
||||||
|
// 1. ConfigureDurableAgents() - For standalone agents only
|
||||||
|
// 2. ConfigureDurableWorkflows() - For workflows only
|
||||||
|
// 3. ConfigureDurableOptions() - For both agents AND workflows
|
||||||
|
//
|
||||||
|
// KEY: All methods can be called MULTIPLE times - configurations are ADDITIVE.
|
||||||
|
|
||||||
|
using Azure;
|
||||||
|
using Azure.AI.OpenAI;
|
||||||
|
using Azure.Identity;
|
||||||
|
using Microsoft.Agents.AI;
|
||||||
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask.Client.AzureManaged;
|
||||||
|
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OpenAI.Chat;
|
||||||
|
using WorkflowConcurrency;
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||||
|
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||||
|
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||||
|
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||||
|
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||||
|
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||||
|
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||||
|
|
||||||
|
// Create AI agents
|
||||||
|
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||||
|
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||||
|
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||||
|
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
|
||||||
|
|
||||||
|
AIAgent biologist = chatClient.AsAIAgent("You are a biology expert. Explain concepts clearly in 2-3 sentences.", "Biologist");
|
||||||
|
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Explain concepts clearly in 2-3 sentences.", "Physicist");
|
||||||
|
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Explain concepts clearly in 2-3 sentences.", "Chemist");
|
||||||
|
|
||||||
|
// Create workflows
|
||||||
|
ParseQuestionExecutor questionParser = new();
|
||||||
|
ResponseAggregatorExecutor responseAggregator = new();
|
||||||
|
|
||||||
|
Workflow physicsWorkflow = new WorkflowBuilder(questionParser)
|
||||||
|
.WithName("PhysicsExpertReview")
|
||||||
|
.AddEdge(questionParser, physicist)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Workflow expertTeamWorkflow = new WorkflowBuilder(questionParser)
|
||||||
|
.WithName("ExpertTeamReview")
|
||||||
|
.AddFanOutEdge(questionParser, [biologist, physicist])
|
||||||
|
.AddFanInEdge([biologist, physicist], responseAggregator)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Workflow chemistryWorkflow = new WorkflowBuilder(questionParser)
|
||||||
|
.WithName("ChemistryExpertReview")
|
||||||
|
.AddEdge(questionParser, chemist)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Configure services - demonstrating all 3 methods (each can be called multiple times)
|
||||||
|
IHost host = Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||||
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// METHOD 1: ConfigureDurableAgents - for standalone agents only
|
||||||
|
services.ConfigureDurableAgents(
|
||||||
|
options => options.AddAIAgent(biologist),
|
||||||
|
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||||
|
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||||
|
|
||||||
|
// METHOD 2: ConfigureDurableWorkflows - for workflows only
|
||||||
|
services.ConfigureDurableWorkflows(options => options.AddWorkflow(physicsWorkflow));
|
||||||
|
|
||||||
|
// METHOD 3: ConfigureDurableOptions - for both agents AND workflows
|
||||||
|
services.ConfigureDurableOptions(options =>
|
||||||
|
{
|
||||||
|
options.Agents.AddAIAgent(chemist);
|
||||||
|
options.Workflows.AddWorkflow(expertTeamWorkflow);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second call to ConfigureDurableOptions (additive - adds to existing config)
|
||||||
|
services.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(chemistryWorkflow));
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await host.StartAsync();
|
||||||
|
IServiceProvider services = host.Services;
|
||||||
|
IWorkflowClient workflowClient = services.GetRequiredService<IWorkflowClient>();
|
||||||
|
|
||||||
|
// DEMO 1: Direct agent conversation (standalone agents)
|
||||||
|
Console.WriteLine("\n═══ DEMO 1: Direct Agent Conversation ═══\n");
|
||||||
|
|
||||||
|
AIAgent biologistProxy = services.GetRequiredKeyedService<AIAgent>("Biologist");
|
||||||
|
AgentSession session = await biologistProxy.GetNewSessionAsync();
|
||||||
|
AgentResponse response = await biologistProxy.RunAsync("What is photosynthesis?", session);
|
||||||
|
Console.WriteLine($"🧬 Biologist: {response.Text}\n");
|
||||||
|
|
||||||
|
AIAgent chemistProxy = services.GetRequiredKeyedService<AIAgent>("Chemist");
|
||||||
|
session = await chemistProxy.GetNewSessionAsync();
|
||||||
|
response = await chemistProxy.RunAsync("What is a chemical bond?", session);
|
||||||
|
Console.WriteLine($"🧪 Chemist: {response.Text}\n");
|
||||||
|
|
||||||
|
// DEMO 2: Single-agent workflow
|
||||||
|
Console.WriteLine("═══ DEMO 2: Single-Agent Workflow ═══\n");
|
||||||
|
await RunWorkflowAsync(workflowClient, physicsWorkflow, "What is the relationship between energy and mass?");
|
||||||
|
|
||||||
|
// DEMO 3: Multi-agent workflow
|
||||||
|
Console.WriteLine("═══ DEMO 3: Multi-Agent Workflow ═══\n");
|
||||||
|
await RunWorkflowAsync(workflowClient, expertTeamWorkflow, "How does radiation affect living cells?");
|
||||||
|
|
||||||
|
// DEMO 4: Workflow from second ConfigureDurableOptions call
|
||||||
|
Console.WriteLine("═══ DEMO 4: Workflow (added via 2nd ConfigureDurableOptions) ═══\n");
|
||||||
|
await RunWorkflowAsync(workflowClient, chemistryWorkflow, "What happens during combustion?");
|
||||||
|
|
||||||
|
Console.WriteLine("\n✅ All demos completed!");
|
||||||
|
await host.StopAsync();
|
||||||
|
|
||||||
|
// Helper method
|
||||||
|
static async Task RunWorkflowAsync(IWorkflowClient client, Workflow workflow, string question)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"📋 {workflow.Name}: \"{question}\"");
|
||||||
|
IWorkflowRun run = await client.RunAsync(workflow, question);
|
||||||
|
if (run is IAwaitableWorkflowRun awaitable)
|
||||||
|
{
|
||||||
|
string? result = await awaitable.WaitForCompletionAsync<string>();
|
||||||
|
Console.WriteLine($"✅ {result}\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>WorkflowEvents</AssemblyName>
|
||||||
|
<RootNamespace>WorkflowEvents</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" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace WorkflowEvents;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Custom event types - callers observe these via WatchStreamAsync
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
internal sealed class OrderLookupStartedEvent(string orderId) : WorkflowEvent(orderId)
|
||||||
|
{
|
||||||
|
public string OrderId { get; } = orderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class OrderFoundEvent(string customerName) : WorkflowEvent(customerName)
|
||||||
|
{
|
||||||
|
public string CustomerName { get; } = customerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status)
|
||||||
|
{
|
||||||
|
public int PercentComplete { get; } = percentComplete;
|
||||||
|
public string Status { get; } = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class OrderCancelledEvent() : WorkflowEvent("Order cancelled");
|
||||||
|
|
||||||
|
internal sealed class EmailSentEvent(string email) : WorkflowEvent(email)
|
||||||
|
{
|
||||||
|
public string Email { get; } = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Domain models
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer);
|
||||||
|
|
||||||
|
internal sealed record Customer(string Name, string Email);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Executors - emit events via AddEventAsync and YieldOutputAsync
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up an order by ID, emitting progress 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);
|
||||||
|
|
||||||
|
// Simulate database lookup
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||||
|
|
||||||
|
Order order = new(
|
||||||
|
Id: message,
|
||||||
|
OrderDate: DateTime.UtcNow.AddDays(-1),
|
||||||
|
IsCancelled: false,
|
||||||
|
CancelReason: "Customer requested cancellation",
|
||||||
|
Customer: new Customer(Name: "Jerry", Email: "jerry@example.com"));
|
||||||
|
|
||||||
|
await context.AddEventAsync(new OrderFoundEvent(order.Customer.Name), cancellationToken);
|
||||||
|
|
||||||
|
// YieldOutputAsync emits a WorkflowOutputEvent observable via streaming
|
||||||
|
await context.YieldOutputAsync(order, cancellationToken);
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancels an order, emitting progress events during the multi-step process.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
|
||||||
|
{
|
||||||
|
public override async ValueTask<Order> HandleAsync(
|
||||||
|
Order message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await context.AddEventAsync(new CancellationProgressEvent(0, "Starting cancellation"), cancellationToken);
|
||||||
|
|
||||||
|
// Simulate a multi-step cancellation process
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||||
|
await context.AddEventAsync(new CancellationProgressEvent(33, "Contacting payment provider"), cancellationToken);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||||
|
await context.AddEventAsync(new CancellationProgressEvent(66, "Processing refund"), cancellationToken);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||||
|
|
||||||
|
Order cancelledOrder = message with { IsCancelled = true };
|
||||||
|
await context.AddEventAsync(new CancellationProgressEvent(100, "Complete"), cancellationToken);
|
||||||
|
await context.AddEventAsync(new OrderCancelledEvent(), cancellationToken);
|
||||||
|
|
||||||
|
await context.YieldOutputAsync(cancelledOrder, cancellationToken);
|
||||||
|
|
||||||
|
return cancelledOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a cancellation confirmation email, emitting an event on completion.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
|
||||||
|
{
|
||||||
|
public override async ValueTask<string> HandleAsync(
|
||||||
|
Order message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Simulate sending email
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||||
|
|
||||||
|
string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}.";
|
||||||
|
|
||||||
|
await context.AddEventAsync(new EmailSentEvent(message.Customer.Email), cancellationToken);
|
||||||
|
|
||||||
|
await context.YieldOutputAsync(result, cancellationToken);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// SAMPLE: Workflow Events and Streaming
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// This sample demonstrates how to use IWorkflowContext event methods in executors
|
||||||
|
// and stream events from the caller side:
|
||||||
|
//
|
||||||
|
// 1. AddEventAsync - Emit custom events that callers can observe in real-time
|
||||||
|
// 2. StreamAsync - Start a workflow and obtain a streaming handle
|
||||||
|
// 3. WatchStreamAsync - Observe events as they occur (custom, framework, and terminal)
|
||||||
|
//
|
||||||
|
// The sample uses IWorkflowClient.StreamAsync to start a workflow and
|
||||||
|
// WatchStreamAsync to observe events as they occur in real-time.
|
||||||
|
//
|
||||||
|
// Workflow: OrderLookup -> OrderCancel -> SendEmail
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask.Client.AzureManaged;
|
||||||
|
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using WorkflowEvents;
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
workflowOptions => workflowOptions.AddWorkflow(cancelOrder),
|
||||||
|
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||||
|
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await host.StartAsync();
|
||||||
|
|
||||||
|
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||||
|
|
||||||
|
Console.WriteLine("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, workflowClient);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.StopAsync();
|
||||||
|
|
||||||
|
// Runs a workflow and streams events as they occur
|
||||||
|
static async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, IWorkflowClient client)
|
||||||
|
{
|
||||||
|
// StreamAsync starts the workflow and returns a streaming handle for observing events
|
||||||
|
IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId);
|
||||||
|
Console.WriteLine($"Started run: {run.RunId}");
|
||||||
|
|
||||||
|
// WatchStreamAsync yields events as they're emitted by executors
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
Console.WriteLine($" New event received at {DateTime.Now:HH:mm:ss.ffff} ({evt.GetType().Name})");
|
||||||
|
|
||||||
|
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.CustomerName}", ConsoleColor.Cyan);
|
||||||
|
break;
|
||||||
|
case CancellationProgressEvent e:
|
||||||
|
WriteColored($" [Cancel] {e.PercentComplete}% - {e.Status}", ConsoleColor.Yellow);
|
||||||
|
break;
|
||||||
|
case OrderCancelledEvent:
|
||||||
|
WriteColored(" [Cancel] Done", ConsoleColor.Yellow);
|
||||||
|
break;
|
||||||
|
case EmailSentEvent e:
|
||||||
|
WriteColored($" [Email] Sent to {e.Email}", ConsoleColor.Magenta);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WorkflowOutputEvent e:
|
||||||
|
WriteColored($" [Output] {e.SourceId}", 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void WriteColored(string message, ConsoleColor color)
|
||||||
|
{
|
||||||
|
Console.ForegroundColor = color;
|
||||||
|
Console.WriteLine(message);
|
||||||
|
Console.ResetColor();
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Workflow Events Sample
|
||||||
|
|
||||||
|
This sample demonstrates how to use workflow events and streaming in durable workflows.
|
||||||
|
|
||||||
|
## What it demonstrates
|
||||||
|
|
||||||
|
1. **Custom Events** (`AddEventAsync`) — Executors emit domain-specific events during execution
|
||||||
|
2. **Event Streaming** (`StreamAsync` / `WatchStreamAsync`) — Callers observe events in real-time as the workflow progresses
|
||||||
|
3. **Framework Events** — Automatic `ExecutorInvokedEvent`, `ExecutorCompletedEvent`, and `WorkflowOutputEvent` events emitted by the framework
|
||||||
|
|
||||||
|
## Emitting Custom Events
|
||||||
|
|
||||||
|
Executors can emit custom domain events during execution using the `IWorkflowContext` instance passed to `HandleAsync`. These events are streamed to callers in real-time via `WatchStreamAsync`.
|
||||||
|
|
||||||
|
### Defining a custom event
|
||||||
|
|
||||||
|
Create a class that inherits from `WorkflowEvent`. Pass any data payload to the base constructor:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status)
|
||||||
|
{
|
||||||
|
public int PercentComplete { get; } = percentComplete;
|
||||||
|
public string Status { get; } = status;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Emitting the event from an executor
|
||||||
|
|
||||||
|
Call `AddEventAsync` on the `IWorkflowContext` inside your executor's `HandleAsync` method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public override async ValueTask<Order> HandleAsync(
|
||||||
|
Order message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await context.AddEventAsync(new CancellationProgressEvent(33, "Processing refund"), cancellationToken);
|
||||||
|
// ... rest of the executor logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Observing events from the caller
|
||||||
|
|
||||||
|
Use `StreamAsync` to start the workflow and `WatchStreamAsync` to observe events. Pattern match on your custom event types:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
IStreamingWorkflowRun run = await workflowClient.StreamAsync(workflow, input);
|
||||||
|
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
switch (evt)
|
||||||
|
{
|
||||||
|
case CancellationProgressEvent e:
|
||||||
|
Console.WriteLine($"{e.PercentComplete}% - {e.Status}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
OrderLookup → OrderCancel → SendEmail
|
||||||
|
```
|
||||||
|
|
||||||
|
Each executor emits custom events during execution:
|
||||||
|
- `OrderLookup` emits `OrderLookupStartedEvent` and `OrderFoundEvent`
|
||||||
|
- `OrderCancel` emits `CancellationProgressEvent` (with percentage) and `OrderCancelledEvent`
|
||||||
|
- `SendEmail` emits `EmailSentEvent`
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler) running locally or in Azure
|
||||||
|
- Set the `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` environment variable (defaults to local emulator)
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter an order ID at the prompt to start a workflow and watch events stream in real-time:
|
||||||
|
|
||||||
|
```text
|
||||||
|
> order-42
|
||||||
|
Started run: b6ba4d19...
|
||||||
|
New event received at 13:27:41.4956 (ExecutorInvokedEvent)
|
||||||
|
New event received at 13:27:41.5019 (OrderLookupStartedEvent)
|
||||||
|
[Lookup] Looking up order order-42
|
||||||
|
New event received at 13:27:41.5025 (OrderFoundEvent)
|
||||||
|
[Lookup] Found: Jerry
|
||||||
|
New event received at 13:27:41.5026 (ExecutorCompletedEvent)
|
||||||
|
New event received at 13:27:41.5026 (WorkflowOutputEvent)
|
||||||
|
[Output] OrderLookup
|
||||||
|
New event received at 13:27:43.0772 (ExecutorInvokedEvent)
|
||||||
|
New event received at 13:27:43.0773 (CancellationProgressEvent)
|
||||||
|
[Cancel] 0% - Starting cancellation
|
||||||
|
New event received at 13:27:43.0775 (CancellationProgressEvent)
|
||||||
|
[Cancel] 33% - Contacting payment provider
|
||||||
|
New event received at 13:27:43.0776 (CancellationProgressEvent)
|
||||||
|
[Cancel] 66% - Processing refund
|
||||||
|
New event received at 13:27:43.0777 (CancellationProgressEvent)
|
||||||
|
[Cancel] 100% - Complete
|
||||||
|
New event received at 13:27:43.0779 (OrderCancelledEvent)
|
||||||
|
[Cancel] Done
|
||||||
|
New event received at 13:27:43.0780 (ExecutorCompletedEvent)
|
||||||
|
New event received at 13:27:43.0780 (WorkflowOutputEvent)
|
||||||
|
[Output] OrderCancel
|
||||||
|
New event received at 13:27:43.6610 (ExecutorInvokedEvent)
|
||||||
|
New event received at 13:27:43.6611 (EmailSentEvent)
|
||||||
|
[Email] Sent to jerry@example.com
|
||||||
|
New event received at 13:27:43.6613 (ExecutorCompletedEvent)
|
||||||
|
New event received at 13:27:43.6613 (WorkflowOutputEvent)
|
||||||
|
[Output] SendEmail
|
||||||
|
New event received at 13:27:43.6619 (DurableWorkflowCompletedEvent)
|
||||||
|
Completed: Cancellation email sent for order order-42 to jerry@example.com.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Workflows in the DTS Dashboard
|
||||||
|
|
||||||
|
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the workflow execution and events.
|
||||||
|
|
||||||
|
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>WorkflowSharedState</AssemblyName>
|
||||||
|
<RootNamespace>WorkflowSharedState</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||||
|
<!--
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||||
|
</ItemGroup>
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace WorkflowSharedState;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Domain models
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The primary order data passed through the pipeline via return values.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record OrderDetails(string OrderId, string CustomerName, decimal Amount, DateTime OrderDate);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-cutting audit trail accumulated in shared state across executors.
|
||||||
|
/// Each executor appends its step name and timestamp. This data does not flow
|
||||||
|
/// through return values — it lives only in shared state.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed record AuditEntry(string Step, string Timestamp, string Detail);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Executors
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the order and writes the initial audit entry and tax rate to shared state.
|
||||||
|
/// The order details are returned as the executor output (normal message flow),
|
||||||
|
/// while the audit trail and tax rate are stored in shared state (side-channel).
|
||||||
|
/// If the order ID starts with "INVALID", the executor halts the workflow early
|
||||||
|
/// using <see cref="IWorkflowContext.RequestHaltAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ValidateOrder() : Executor<string, OrderDetails>("ValidateOrder")
|
||||||
|
{
|
||||||
|
public override async ValueTask<OrderDetails> HandleAsync(
|
||||||
|
string message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
|
||||||
|
|
||||||
|
// Halt the workflow early if the order ID is invalid.
|
||||||
|
// No downstream executors will run after this.
|
||||||
|
if (message.StartsWith("INVALID", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await context.YieldOutputAsync($"Order '{message}' failed validation. Halting workflow.", cancellationToken);
|
||||||
|
await context.RequestHaltAsync();
|
||||||
|
return new OrderDetails(message, "Unknown", 0, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
OrderDetails details = new(message, "Jerry", 249.99m, DateTime.UtcNow);
|
||||||
|
|
||||||
|
// Store the tax rate in shared state — downstream ProcessPayment reads it
|
||||||
|
// without needing it in the message chain.
|
||||||
|
await context.QueueStateUpdateAsync("taxRate", 0.085m, cancellationToken: cancellationToken);
|
||||||
|
Console.WriteLine(" Wrote to shared state: taxRate = 8.5%");
|
||||||
|
|
||||||
|
// Start the audit trail in shared state
|
||||||
|
AuditEntry audit = new("ValidateOrder", DateTime.UtcNow.ToString("o"), $"Validated order {message}");
|
||||||
|
await context.QueueStateUpdateAsync("audit:validate", audit, cancellationToken: cancellationToken);
|
||||||
|
Console.WriteLine(" Wrote to shared state: audit:validate");
|
||||||
|
|
||||||
|
await context.YieldOutputAsync($"Order '{message}' validated. Customer: {details.CustomerName}, Amount: {details.Amount:C}", cancellationToken);
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enriches the order with shipping information.
|
||||||
|
/// Reads the audit trail from shared state and appends its own entry.
|
||||||
|
/// Uses ReadOrInitStateAsync to lazily initialize a shipping tier.
|
||||||
|
/// Demonstrates custom scopes by writing shipping details under the "shipping" scope.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class EnrichOrder() : Executor<OrderDetails, OrderDetails>("EnrichOrder")
|
||||||
|
{
|
||||||
|
public override async ValueTask<OrderDetails> HandleAsync(
|
||||||
|
OrderDetails message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
|
||||||
|
|
||||||
|
// Use ReadOrInitStateAsync — only initializes if no value exists yet
|
||||||
|
string shippingTier = await context.ReadOrInitStateAsync(
|
||||||
|
"shippingTier",
|
||||||
|
() => "Express",
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
Console.WriteLine($" Read from shared state: shippingTier = {shippingTier}");
|
||||||
|
|
||||||
|
// Write shipping details under a custom "shipping" scope.
|
||||||
|
// Scoped keys are isolated from the default namespace, so "carrier" here
|
||||||
|
// won't collide with a "carrier" key in the default scope.
|
||||||
|
await context.QueueStateUpdateAsync("carrier", "Contoso Express", scopeName: "shipping", cancellationToken: cancellationToken);
|
||||||
|
await context.QueueStateUpdateAsync("estimatedDays", 2, scopeName: "shipping", cancellationToken: cancellationToken);
|
||||||
|
Console.WriteLine(" Wrote to shared state: shipping:carrier = Contoso Express");
|
||||||
|
Console.WriteLine(" Wrote to shared state: shipping:estimatedDays = 2");
|
||||||
|
|
||||||
|
// Verify we can read the audit entry from the previous step
|
||||||
|
AuditEntry? previousAudit = await context.ReadStateAsync<AuditEntry>("audit:validate", cancellationToken: cancellationToken);
|
||||||
|
string auditStatus = previousAudit is not null ? $"(previous step: {previousAudit.Step})" : "(no prior audit)";
|
||||||
|
Console.WriteLine($" Read from shared state: audit:validate {auditStatus}");
|
||||||
|
|
||||||
|
// Append our own audit entry
|
||||||
|
AuditEntry audit = new("EnrichOrder", DateTime.UtcNow.ToString("o"), $"Enriched with {shippingTier} shipping {auditStatus}");
|
||||||
|
await context.QueueStateUpdateAsync("audit:enrich", audit, cancellationToken: cancellationToken);
|
||||||
|
Console.WriteLine(" Wrote to shared state: audit:enrich");
|
||||||
|
|
||||||
|
await context.YieldOutputAsync($"Order enriched. Shipping: {shippingTier} {auditStatus}", cancellationToken);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes payment using the tax rate from shared state (written by ValidateOrder).
|
||||||
|
/// The tax rate is side-channel data — it doesn't flow through return values.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ProcessPayment() : Executor<OrderDetails, string>("ProcessPayment")
|
||||||
|
{
|
||||||
|
public override async ValueTask<string> HandleAsync(
|
||||||
|
OrderDetails message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(300), cancellationToken);
|
||||||
|
|
||||||
|
// Read tax rate written by ValidateOrder — not available in the message chain
|
||||||
|
decimal taxRate = await context.ReadOrInitStateAsync("taxRate", () => 0.0m, cancellationToken: cancellationToken);
|
||||||
|
Console.WriteLine($" Read from shared state: taxRate = {taxRate:P1}");
|
||||||
|
|
||||||
|
decimal tax = message.Amount * taxRate;
|
||||||
|
decimal total = message.Amount + tax;
|
||||||
|
string paymentRef = $"PAY-{Guid.NewGuid():N}"[..16];
|
||||||
|
|
||||||
|
// Append audit entry
|
||||||
|
AuditEntry audit = new("ProcessPayment", DateTime.UtcNow.ToString("o"), $"Charged {total:C} (tax: {tax:C})");
|
||||||
|
await context.QueueStateUpdateAsync("audit:payment", audit, cancellationToken: cancellationToken);
|
||||||
|
Console.WriteLine(" Wrote to shared state: audit:payment");
|
||||||
|
|
||||||
|
await context.YieldOutputAsync($"Payment processed. Total: {total:C} (tax: {tax:C}). Ref: {paymentRef}", cancellationToken);
|
||||||
|
|
||||||
|
return paymentRef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates the final invoice by reading the full audit trail from shared state.
|
||||||
|
/// Demonstrates reading multiple state entries written by different executors
|
||||||
|
/// and clearing a scope with <see cref="IWorkflowContext.QueueClearScopeAsync(string?, CancellationToken)"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GenerateInvoice() : Executor<string, string>("GenerateInvoice")
|
||||||
|
{
|
||||||
|
public override async ValueTask<string> HandleAsync(
|
||||||
|
string message,
|
||||||
|
IWorkflowContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||||
|
|
||||||
|
// Read the full audit trail from shared state — each step wrote its own entry
|
||||||
|
AuditEntry? validateAudit = await context.ReadStateAsync<AuditEntry>("audit:validate", cancellationToken: cancellationToken);
|
||||||
|
AuditEntry? enrichAudit = await context.ReadStateAsync<AuditEntry>("audit:enrich", cancellationToken: cancellationToken);
|
||||||
|
AuditEntry? paymentAudit = await context.ReadStateAsync<AuditEntry>("audit:payment", cancellationToken: cancellationToken);
|
||||||
|
int auditCount = new[] { validateAudit, enrichAudit, paymentAudit }.Count(a => a is not null);
|
||||||
|
Console.WriteLine($" Read from shared state: {auditCount} audit entries");
|
||||||
|
|
||||||
|
// Clear the "shipping" scope — no longer needed after invoice generation.
|
||||||
|
// This removes all keys under that scope (carrier, estimatedDays).
|
||||||
|
await context.QueueClearScopeAsync("shipping", cancellationToken);
|
||||||
|
Console.WriteLine(" Cleared shared state scope: shipping");
|
||||||
|
|
||||||
|
string auditSummary = string.Join(" → ", new[]
|
||||||
|
{
|
||||||
|
validateAudit?.Step, enrichAudit?.Step, paymentAudit?.Step
|
||||||
|
}.Where(s => s is not null));
|
||||||
|
|
||||||
|
string invoice = $"Invoice complete. Payment: {message}. Audit trail: [{auditSummary}]";
|
||||||
|
|
||||||
|
await context.YieldOutputAsync(invoice, cancellationToken);
|
||||||
|
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// SAMPLE: Shared State During Workflow Execution
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// This sample demonstrates how executors in a durable workflow can share state
|
||||||
|
// via IWorkflowContext. State is persisted across supersteps and survives
|
||||||
|
// process restarts because the orchestration passes it to each activity.
|
||||||
|
//
|
||||||
|
// Key concepts:
|
||||||
|
// 1. QueueStateUpdateAsync - Write a value to shared state
|
||||||
|
// 2. ReadStateAsync - Read a value written by a previous executor
|
||||||
|
// 3. ReadOrInitStateAsync - Read or lazily initialize a state value
|
||||||
|
// 4. QueueClearScopeAsync - Clear all entries under a scope
|
||||||
|
// 5. RequestHaltAsync - Stop the workflow early (e.g., validation failure)
|
||||||
|
//
|
||||||
|
// Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice
|
||||||
|
//
|
||||||
|
// Return values carry primary business data through the pipeline (OrderDetails,
|
||||||
|
// payment ref). Shared state carries side-channel data that doesn't belong in
|
||||||
|
// the message chain: a tax rate (set by ValidateOrder, read by ProcessPayment)
|
||||||
|
// and an audit trail (each executor appends its own entry).
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask.Client.AzureManaged;
|
||||||
|
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using WorkflowSharedState;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
ValidateOrder validateOrder = new();
|
||||||
|
EnrichOrder enrichOrder = new();
|
||||||
|
ProcessPayment processPayment = new();
|
||||||
|
GenerateInvoice generateInvoice = new();
|
||||||
|
|
||||||
|
// Build the workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice
|
||||||
|
Workflow orderPipeline = new WorkflowBuilder(validateOrder)
|
||||||
|
.WithName("OrderPipeline")
|
||||||
|
.WithDescription("Order processing pipeline with shared state across executors")
|
||||||
|
.AddEdge(validateOrder, enrichOrder)
|
||||||
|
.AddEdge(enrichOrder, processPayment)
|
||||||
|
.AddEdge(processPayment, generateInvoice)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Configure host with durable workflow support
|
||||||
|
IHost host = Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||||
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.ConfigureDurableWorkflows(
|
||||||
|
workflowOptions => workflowOptions.AddWorkflow(orderPipeline),
|
||||||
|
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||||
|
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await host.StartAsync();
|
||||||
|
|
||||||
|
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||||
|
|
||||||
|
Console.WriteLine("Shared State Workflow Demo");
|
||||||
|
Console.WriteLine("Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice");
|
||||||
|
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
|
||||||
|
{
|
||||||
|
// Start the workflow and stream events to see shared state in action
|
||||||
|
IStreamingWorkflowRun run = await workflowClient.StreamAsync(orderPipeline, input);
|
||||||
|
Console.WriteLine($"Started run: {run.RunId}");
|
||||||
|
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
switch (evt)
|
||||||
|
{
|
||||||
|
case WorkflowOutputEvent e:
|
||||||
|
Console.WriteLine($" [Output] {e.SourceId}: {e.Data}");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DurableWorkflowCompletedEvent e:
|
||||||
|
Console.WriteLine($" Completed: {e.Result}");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DurableWorkflowFailedEvent e:
|
||||||
|
Console.WriteLine($" Failed: {e.ErrorMessage}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.StopAsync();
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Shared State Workflow Sample
|
||||||
|
|
||||||
|
This sample demonstrates how executors in a durable workflow can share state via `IWorkflowContext`. State written by one executor is accessible to all downstream executors, persisted across supersteps, and survives process restarts.
|
||||||
|
|
||||||
|
## Key Concepts Demonstrated
|
||||||
|
|
||||||
|
- Writing state with `QueueStateUpdateAsync` — executors store data for downstream executors
|
||||||
|
- Reading state with `ReadStateAsync` — executors access data written by earlier executors
|
||||||
|
- Lazy initialization with `ReadOrInitStateAsync` — initialize state only if not already present
|
||||||
|
- Custom scopes with `scopeName` — partition state into isolated namespaces (e.g., `"shipping"`)
|
||||||
|
- Clearing scopes with `QueueClearScopeAsync` — remove all entries under a scope when no longer needed
|
||||||
|
- Early termination with `RequestHaltAsync` — halt the workflow when validation fails
|
||||||
|
- State persistence across supersteps — the orchestration passes shared state to each activity
|
||||||
|
- Event streaming with `IStreamingWorkflowRun` — observe executor progress in real time
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
**OrderPipeline**: `ValidateOrder` → `EnrichOrder` → `ProcessPayment` → `GenerateInvoice`
|
||||||
|
|
||||||
|
Return values carry primary business data through the pipeline (`OrderDetails` → `OrderDetails` → payment ref → invoice string). Shared state carries side-channel data that doesn't belong in the message chain:
|
||||||
|
|
||||||
|
| Executor | Returns (message flow) | Reads from State | Writes to State |
|
||||||
|
|----------|----------------------|-----------------|-----------------|
|
||||||
|
| **ValidateOrder** | `OrderDetails` | — | `taxRate`, `audit:validate` |
|
||||||
|
| **EnrichOrder** | `OrderDetails` (pass-through) | `audit:validate` | `shippingTier`, `audit:enrich`, `shipping:carrier`, `shipping:estimatedDays` |
|
||||||
|
| **ProcessPayment** | payment ref string | `taxRate` | `audit:payment` |
|
||||||
|
| **GenerateInvoice** | invoice string | `audit:validate`, `audit:enrich`, `audit:payment` | clears `shipping` scope |
|
||||||
|
|
||||||
|
> **Note:** `EnrichOrder` writes `carrier` and `estimatedDays` under the `"shipping"` scope using `scopeName: "shipping"`. Scoped keys are isolated from the default namespace, so a key like `"carrier"` in the `"shipping"` scope won't collide with a `"carrier"` key in the default scope.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter an order ID when prompted. The workflow will process the order through all four executors, streaming events as they occur:
|
||||||
|
|
||||||
|
```text
|
||||||
|
> ORD-001
|
||||||
|
Started run: abc123
|
||||||
|
Wrote to shared state: taxRate = 8.5%
|
||||||
|
Wrote to shared state: audit:validate
|
||||||
|
[Output] ValidateOrder: Order 'ORD-001' validated. Customer: Jerry, Amount: $249.99
|
||||||
|
Read from shared state: shippingTier = Express
|
||||||
|
Wrote to shared state: shipping:carrier = Contoso Express
|
||||||
|
Wrote to shared state: shipping:estimatedDays = 2
|
||||||
|
Read from shared state: audit:validate (previous step: ValidateOrder)
|
||||||
|
Wrote to shared state: audit:enrich
|
||||||
|
[Output] EnrichOrder: Order enriched. Shipping: Express (previous step: ValidateOrder)
|
||||||
|
Read from shared state: taxRate = 8.5%
|
||||||
|
Wrote to shared state: audit:payment
|
||||||
|
[Output] ProcessPayment: Payment processed. Total: $271.24 (tax: $21.25). Ref: PAY-abc123def456
|
||||||
|
Read from shared state: 3 audit entries
|
||||||
|
Cleared shared state scope: shipping
|
||||||
|
[Output] GenerateInvoice: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment]
|
||||||
|
Completed: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Viewing Workflows in the DTS Dashboard
|
||||||
|
|
||||||
|
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the shared state being passed between activities.
|
||||||
|
|
||||||
|
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
|
||||||
@@ -141,4 +141,15 @@ public sealed class DurableAgentsOptions
|
|||||||
{
|
{
|
||||||
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
|
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether an agent with the specified name is registered.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="agentName">The name of the agent to locate. Cannot be null.</param>
|
||||||
|
/// <returns>true if an agent with the specified name is registered; otherwise, false.</returns>
|
||||||
|
internal bool ContainsAgent(string agentName)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(agentName);
|
||||||
|
return this._agentFactories.ContainsKey(agentName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// 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.DurableTask;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom data converter for durable agents and workflows that ensures proper JSON serialization.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This converter handles special cases like <see cref="DurableAgentState"/> using source-generated
|
||||||
|
/// JSON contexts for AOT compatibility, and falls back to reflection-based serialization for other types.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DurableDataConverter : DataConverter
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions)
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")]
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback 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);
|
||||||
|
return typeInfo is not null
|
||||||
|
? JsonSerializer.Deserialize(data, typeInfo)
|
||||||
|
: JsonSerializer.Deserialize(data, targetType, s_options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[return: NotNullIfNotNull(nameof(value))]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")]
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback 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());
|
||||||
|
return typeInfo is not null
|
||||||
|
? JsonSerializer.Serialize(value, typeInfo)
|
||||||
|
: JsonSerializer.Serialize(value, s_options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides configuration options for durable agents and workflows.
|
||||||
|
/// </summary>
|
||||||
|
[DebuggerDisplay("Workflows = {Workflows.Workflows.Count}, Agents = {Agents.AgentCount}")]
|
||||||
|
public sealed class DurableOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DurableOptions"/> class.
|
||||||
|
/// </summary>
|
||||||
|
internal DurableOptions()
|
||||||
|
{
|
||||||
|
this.Workflows = new DurableWorkflowOptions(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the configuration options for durable agents.
|
||||||
|
/// </summary>
|
||||||
|
public DurableAgentsOptions Agents { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the configuration options for durable workflows.
|
||||||
|
/// </summary>
|
||||||
|
public DurableWorkflowOptions Workflows { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marker class used to track whether core durable task services have been registered.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Problem it solves:</b> Users may call configuration methods multiple times:
|
||||||
|
/// <code>
|
||||||
|
/// services.ConfigureDurableOptions(...); // 1st call - registers agent A
|
||||||
|
/// services.ConfigureDurableOptions(...); // 2nd call - registers workflow X
|
||||||
|
/// services.ConfigureDurableOptions(...); // 3rd call - registers agent B and workflow Y
|
||||||
|
/// </code>
|
||||||
|
/// Each call invokes <c>EnsureDurableServicesRegistered</c>. Without this marker, core services like
|
||||||
|
/// <c>AddDurableTaskWorker</c> and <c>AddDurableTaskClient</c> would be registered multiple times,
|
||||||
|
/// causing runtime errors or unexpected behavior.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>How it works:</b>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>First call: No marker in services → register marker + all core services</description></item>
|
||||||
|
/// <item><description>Subsequent calls: Marker exists → early return, skip core service registration</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why not use TryAddSingleton for everything?</b>
|
||||||
|
/// While <c>TryAddSingleton</c> prevents duplicate simple service registrations, it doesn't work for
|
||||||
|
/// complex registrations like <c>AddDurableTaskWorker</c> which have side effects and configure
|
||||||
|
/// internal builders. The marker pattern provides a clean, explicit guard for the entire registration block.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DurableServicesMarker;
|
||||||
@@ -100,4 +100,115 @@ internal static partial class Logs
|
|||||||
public static partial void LogTTLExpirationTimeCleared(
|
public static partial void LogTTLExpirationTimeCleared(
|
||||||
this ILogger logger,
|
this ILogger logger,
|
||||||
AgentSessionId sessionId);
|
AgentSessionId sessionId);
|
||||||
|
|
||||||
|
// Durable workflow logs (EventIds 100-199)
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 100,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Starting workflow '{WorkflowName}' with instance '{InstanceId}'")]
|
||||||
|
public static partial void LogWorkflowStarting(
|
||||||
|
this ILogger logger,
|
||||||
|
string workflowName,
|
||||||
|
string instanceId);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 101,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Superstep {Step}: {Count} active executor(s)")]
|
||||||
|
public static partial void LogSuperstepStarting(
|
||||||
|
this ILogger logger,
|
||||||
|
int step,
|
||||||
|
int count);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 102,
|
||||||
|
Level = LogLevel.Debug,
|
||||||
|
Message = "Superstep {Step} executors: [{Executors}]")]
|
||||||
|
public static partial void LogSuperstepExecutors(
|
||||||
|
this ILogger logger,
|
||||||
|
int step,
|
||||||
|
string executors);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 103,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Workflow completed")]
|
||||||
|
public static partial void LogWorkflowCompleted(
|
||||||
|
this ILogger logger);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 104,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Workflow '{InstanceId}' terminated early: reached maximum superstep limit ({MaxSupersteps}) with {RemainingExecutors} executor(s) still queued")]
|
||||||
|
public static partial void LogWorkflowMaxSuperstepsExceeded(
|
||||||
|
this ILogger logger,
|
||||||
|
string instanceId,
|
||||||
|
int maxSupersteps,
|
||||||
|
int remainingExecutors);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 105,
|
||||||
|
Level = LogLevel.Debug,
|
||||||
|
Message = "Fan-In executor {ExecutorId}: aggregated {Count} messages from [{Sources}]")]
|
||||||
|
public static partial void LogFanInAggregated(
|
||||||
|
this ILogger logger,
|
||||||
|
string executorId,
|
||||||
|
int count,
|
||||||
|
string sources);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 106,
|
||||||
|
Level = LogLevel.Debug,
|
||||||
|
Message = "Executor '{ExecutorId}' returned result (length: {Length}, messages: {MessageCount})")]
|
||||||
|
public static partial void LogExecutorResultReceived(
|
||||||
|
this ILogger logger,
|
||||||
|
string executorId,
|
||||||
|
int length,
|
||||||
|
int messageCount);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 107,
|
||||||
|
Level = LogLevel.Debug,
|
||||||
|
Message = "Dispatching executor '{ExecutorId}' (agentic: {IsAgentic})")]
|
||||||
|
public static partial void LogDispatchingExecutor(
|
||||||
|
this ILogger logger,
|
||||||
|
string executorId,
|
||||||
|
bool isAgentic);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 108,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Agent '{AgentName}' not found")]
|
||||||
|
public static partial void LogAgentNotFound(
|
||||||
|
this ILogger logger,
|
||||||
|
string agentName);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 109,
|
||||||
|
Level = LogLevel.Debug,
|
||||||
|
Message = "Edge {Source} -> {Sink}: condition returned false, skipping")]
|
||||||
|
public static partial void LogEdgeConditionFalse(
|
||||||
|
this ILogger logger,
|
||||||
|
string source,
|
||||||
|
string sink);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 110,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Failed to evaluate condition for edge {Source} -> {Sink}, skipping")]
|
||||||
|
public static partial void LogEdgeConditionEvaluationFailed(
|
||||||
|
this ILogger logger,
|
||||||
|
Exception ex,
|
||||||
|
string source,
|
||||||
|
string sink);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 111,
|
||||||
|
Level = LogLevel.Debug,
|
||||||
|
Message = "Edge {Source} -> {Sink}: routing message")]
|
||||||
|
public static partial void LogEdgeRoutingMessage(
|
||||||
|
this ILogger logger,
|
||||||
|
string source,
|
||||||
|
string sink);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
using System.Text.Json;
|
using Microsoft.Agents.AI.Workflows;
|
||||||
using System.Text.Json.Serialization.Metadata;
|
|
||||||
using Microsoft.Agents.AI.DurableTask.State;
|
|
||||||
using Microsoft.DurableTask;
|
using Microsoft.DurableTask;
|
||||||
using Microsoft.DurableTask.Client;
|
using Microsoft.DurableTask.Client;
|
||||||
using Microsoft.DurableTask.Worker;
|
using Microsoft.DurableTask.Worker;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.DurableTask;
|
namespace Microsoft.Agents.AI.DurableTask;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Agent-specific extension methods for the <see cref="IServiceCollection"/> class.
|
/// Extension methods for configuring durable agents and workflows with dependency injection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
@@ -30,77 +30,328 @@ public static class ServiceCollectionExtensions
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures the Durable Agents services via the service collection.
|
/// Configures durable agents, automatically registering agent entities.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This method provides an agent-focused configuration experience.
|
||||||
|
/// If you need to configure both agents and workflows, consider using
|
||||||
|
/// <see cref="ConfigureDurableOptions"/> instead.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Multiple calls to this method are supported and configurations are composed additively.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
/// <param name="services">The service collection.</param>
|
/// <param name="services">The service collection.</param>
|
||||||
/// <param name="configure">A delegate to configure the durable agents.</param>
|
/// <param name="configure">A delegate to configure the durable agents.</param>
|
||||||
/// <param name="workerBuilder">A delegate to configure the Durable Task worker.</param>
|
/// <param name="workerBuilder">Optional delegate to configure the Durable Task worker.</param>
|
||||||
/// <param name="clientBuilder">A delegate to configure the Durable Task client.</param>
|
/// <param name="clientBuilder">Optional delegate to configure the Durable Task client.</param>
|
||||||
/// <returns>The service collection.</returns>
|
/// <returns>The service collection for chaining.</returns>
|
||||||
public static IServiceCollection ConfigureDurableAgents(
|
public static IServiceCollection ConfigureDurableAgents(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
Action<DurableAgentsOptions> configure,
|
Action<DurableAgentsOptions> configure,
|
||||||
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
|
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
|
||||||
Action<IDurableTaskClientBuilder>? clientBuilder = null)
|
Action<IDurableTaskClientBuilder>? clientBuilder = null)
|
||||||
{
|
{
|
||||||
|
return services.ConfigureDurableOptions(
|
||||||
|
options => configure(options.Agents),
|
||||||
|
workerBuilder,
|
||||||
|
clientBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures durable workflows, automatically registering orchestrations and activities.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This method provides a workflow-focused configuration experience.
|
||||||
|
/// If you need to configure both agents and workflows, consider using
|
||||||
|
/// <see cref="ConfigureDurableOptions"/> instead.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Multiple calls to this method are supported and configurations are composed additively.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="services">The service collection to configure.</param>
|
||||||
|
/// <param name="configure">A delegate to configure the workflow options.</param>
|
||||||
|
/// <param name="workerBuilder">Optional delegate to configure the durable task worker.</param>
|
||||||
|
/// <param name="clientBuilder">Optional delegate to configure the durable task client.</param>
|
||||||
|
/// <returns>The service collection for chaining.</returns>
|
||||||
|
public static IServiceCollection ConfigureDurableWorkflows(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<DurableWorkflowOptions> configure,
|
||||||
|
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
|
||||||
|
Action<IDurableTaskClientBuilder>? clientBuilder = null)
|
||||||
|
{
|
||||||
|
return services.ConfigureDurableOptions(
|
||||||
|
options => configure(options.Workflows),
|
||||||
|
workerBuilder,
|
||||||
|
clientBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures durable agents and workflows, automatically registering orchestrations, activities, and agent entities.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the recommended entry point for configuring durable functionality. It provides unified configuration
|
||||||
|
/// for both agents and workflows through a single <see cref="DurableOptions"/> instance, ensuring agents
|
||||||
|
/// referenced in workflows are automatically registered.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Multiple calls to this method (or to <see cref="ConfigureDurableAgents"/>
|
||||||
|
/// and <see cref="ConfigureDurableWorkflows"/>) are supported and configurations are composed additively.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="services">The service collection to configure.</param>
|
||||||
|
/// <param name="configure">A delegate to configure the durable options for both agents and workflows.</param>
|
||||||
|
/// <param name="workerBuilder">Optional delegate to configure the durable task worker.</param>
|
||||||
|
/// <param name="clientBuilder">Optional delegate to configure the durable task client.</param>
|
||||||
|
/// <returns>The service collection for chaining.</returns>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// services.ConfigureDurableOptions(options =>
|
||||||
|
/// {
|
||||||
|
/// // Register agents not part of workflows
|
||||||
|
/// options.Agents.AddAIAgent(standaloneAgent);
|
||||||
|
///
|
||||||
|
/// // Register workflows - agents in workflows are auto-registered
|
||||||
|
/// options.Workflows.AddWorkflow(myWorkflow);
|
||||||
|
/// },
|
||||||
|
/// workerBuilder: builder => builder.UseDurableTaskScheduler(connectionString),
|
||||||
|
/// clientBuilder: builder => builder.UseDurableTaskScheduler(connectionString));
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public static IServiceCollection ConfigureDurableOptions(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<DurableOptions> configure,
|
||||||
|
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
|
||||||
|
Action<IDurableTaskClientBuilder>? clientBuilder = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
ArgumentNullException.ThrowIfNull(configure);
|
ArgumentNullException.ThrowIfNull(configure);
|
||||||
|
|
||||||
DurableAgentsOptions options = services.ConfigureDurableAgents(configure);
|
// Get or create the shared DurableOptions instance for configuration
|
||||||
|
DurableOptions sharedOptions = GetOrCreateSharedOptions(services);
|
||||||
|
|
||||||
// A worker is required to run the agent entities
|
// Apply the configuration immediately to capture agent names for keyed service registration
|
||||||
services.AddDurableTaskWorker(builder =>
|
configure(sharedOptions);
|
||||||
{
|
|
||||||
workerBuilder?.Invoke(builder);
|
|
||||||
|
|
||||||
builder.AddTasks(registry =>
|
// Register keyed services for any new agents
|
||||||
{
|
RegisterAgentKeyedServices(services, sharedOptions);
|
||||||
foreach (string name in options.GetAgentFactories().Keys)
|
|
||||||
{
|
|
||||||
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(name));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The client is needed to send notifications to the agent entities from non-orchestrator code
|
// Register core services only once
|
||||||
if (clientBuilder != null)
|
EnsureDurableServicesRegistered(services, sharedOptions, workerBuilder, clientBuilder);
|
||||||
{
|
|
||||||
services.AddDurableTaskClient(clientBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
services.AddSingleton<IDurableAgentClient, DefaultDurableAgentClient>();
|
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is internal because it's also used by Microsoft.Azure.Functions.DurableAgents, which is a friend assembly project.
|
private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services)
|
||||||
internal static DurableAgentsOptions ConfigureDurableAgents(
|
|
||||||
this IServiceCollection services,
|
|
||||||
Action<DurableAgentsOptions> configure)
|
|
||||||
{
|
{
|
||||||
DurableAgentsOptions options = new();
|
// Look for an existing DurableOptions registration
|
||||||
configure(options);
|
ServiceDescriptor? existingDescriptor = services.FirstOrDefault(
|
||||||
|
d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null);
|
||||||
|
|
||||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = options.GetAgentFactories();
|
if (existingDescriptor?.ImplementationInstance is DurableOptions existing)
|
||||||
|
|
||||||
// The agent dictionary contains the real agent factories, which is used by the agent entities.
|
|
||||||
services.AddSingleton(agents);
|
|
||||||
|
|
||||||
// Register the options so AgentEntity can access TTL configuration
|
|
||||||
services.AddSingleton(options);
|
|
||||||
|
|
||||||
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
|
|
||||||
foreach (var factory in agents)
|
|
||||||
{
|
{
|
||||||
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A custom data converter is needed because the default chat client uses camel case for JSON properties,
|
// Create a new shared options instance
|
||||||
// which is not the default behavior for the Durable Task SDK.
|
DurableOptions options = new();
|
||||||
services.AddSingleton<DataConverter, DefaultDataConverter>();
|
services.AddSingleton(options);
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void RegisterAgentKeyedServices(IServiceCollection services, DurableOptions options)
|
||||||
|
{
|
||||||
|
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> factory in options.Agents.GetAgentFactories())
|
||||||
|
{
|
||||||
|
// Only add if not already registered (to support multiple Configure* calls)
|
||||||
|
if (!services.Any(d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService && Equals(d.ServiceKey, factory.Key)))
|
||||||
|
{
|
||||||
|
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures that the core durable services are registered only once, regardless of how many
|
||||||
|
/// times the configuration methods are called.
|
||||||
|
/// </summary>
|
||||||
|
private static void EnsureDurableServicesRegistered(
|
||||||
|
IServiceCollection services,
|
||||||
|
DurableOptions sharedOptions,
|
||||||
|
Action<IDurableTaskWorkerBuilder>? workerBuilder,
|
||||||
|
Action<IDurableTaskClientBuilder>? clientBuilder)
|
||||||
|
{
|
||||||
|
// Use a marker to ensure we only register core services once
|
||||||
|
if (services.Any(d => d.ServiceType == typeof(DurableServicesMarker)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddSingleton<DurableServicesMarker>();
|
||||||
|
|
||||||
|
services.TryAddSingleton<DurableWorkflowRunner>();
|
||||||
|
|
||||||
|
// Configure Durable Task Worker - capture sharedOptions reference in closure.
|
||||||
|
// The options object is populated by all Configure* calls before the worker starts.
|
||||||
|
|
||||||
|
if (workerBuilder is not null)
|
||||||
|
{
|
||||||
|
services.AddDurableTaskWorker(builder =>
|
||||||
|
{
|
||||||
|
workerBuilder?.Invoke(builder);
|
||||||
|
|
||||||
|
builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure Durable Task Client
|
||||||
|
if (clientBuilder is not null)
|
||||||
|
{
|
||||||
|
services.AddDurableTaskClient(clientBuilder);
|
||||||
|
services.TryAddSingleton<IWorkflowClient, DurableWorkflowClient>();
|
||||||
|
services.TryAddSingleton<IDurableAgentClient, DefaultDurableAgentClient>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register workflow and agent services
|
||||||
|
services.TryAddSingleton<DataConverter, DurableDataConverter>();
|
||||||
|
|
||||||
|
// Register agent factories resolver - returns factories from the shared options
|
||||||
|
services.TryAddSingleton(
|
||||||
|
sp => sp.GetRequiredService<DurableOptions>().Agents.GetAgentFactories());
|
||||||
|
|
||||||
|
// Register DurableAgentsOptions resolver
|
||||||
|
services.TryAddSingleton(sp => sp.GetRequiredService<DurableOptions>().Agents);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterTasksFromOptions(DurableTaskRegistry registry, DurableOptions durableOptions)
|
||||||
|
{
|
||||||
|
// Build registrations for all workflows including sub-workflows
|
||||||
|
List<WorkflowRegistrationInfo> registrations = [];
|
||||||
|
HashSet<string> registeredActivities = [];
|
||||||
|
HashSet<string> registeredOrchestrations = [];
|
||||||
|
|
||||||
|
foreach (Workflow workflow in durableOptions.Workflows.Workflows.Values.ToList())
|
||||||
|
{
|
||||||
|
BuildWorkflowRegistrationRecursive(
|
||||||
|
workflow,
|
||||||
|
durableOptions.Workflows,
|
||||||
|
registrations,
|
||||||
|
registeredActivities,
|
||||||
|
registeredOrchestrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agentFactories =
|
||||||
|
durableOptions.Agents.GetAgentFactories();
|
||||||
|
|
||||||
|
// Register orchestrations and activities
|
||||||
|
foreach (WorkflowRegistrationInfo registration in registrations)
|
||||||
|
{
|
||||||
|
// Register with DurableWorkflowInput<object> - the DataConverter handles serialization/deserialization
|
||||||
|
registry.AddOrchestratorFunc<DurableWorkflowInput<object>, string>(
|
||||||
|
registration.OrchestrationName,
|
||||||
|
(context, input) => RunWorkflowOrchestrationAsync(context, input, durableOptions));
|
||||||
|
|
||||||
|
foreach (ActivityRegistrationInfo activity in registration.Activities)
|
||||||
|
{
|
||||||
|
ExecutorBinding binding = activity.Binding;
|
||||||
|
registry.AddActivityFunc<string, string>(
|
||||||
|
activity.ActivityName,
|
||||||
|
(context, input) => DurableActivityExecutor.ExecuteAsync(binding, input));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register agent entities
|
||||||
|
foreach (string agentName in agentFactories.Keys)
|
||||||
|
{
|
||||||
|
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(agentName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildWorkflowRegistrationRecursive(
|
||||||
|
Workflow workflow,
|
||||||
|
DurableWorkflowOptions workflowOptions,
|
||||||
|
List<WorkflowRegistrationInfo> registrations,
|
||||||
|
HashSet<string> registeredActivities,
|
||||||
|
HashSet<string> registeredOrchestrations)
|
||||||
|
{
|
||||||
|
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!);
|
||||||
|
|
||||||
|
if (!registeredOrchestrations.Add(orchestrationName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registrations.Add(BuildWorkflowRegistration(workflow, registeredActivities));
|
||||||
|
|
||||||
|
// Process subworkflows recursively to register them as separate orchestrations
|
||||||
|
foreach (SubworkflowBinding subworkflowBinding in workflow.ReflectExecutors()
|
||||||
|
.Select(e => e.Value)
|
||||||
|
.OfType<SubworkflowBinding>())
|
||||||
|
{
|
||||||
|
Workflow subWorkflow = subworkflowBinding.WorkflowInstance;
|
||||||
|
workflowOptions.AddWorkflow(subWorkflow);
|
||||||
|
|
||||||
|
BuildWorkflowRegistrationRecursive(
|
||||||
|
subWorkflow,
|
||||||
|
workflowOptions,
|
||||||
|
registrations,
|
||||||
|
registeredActivities,
|
||||||
|
registeredOrchestrations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowRegistrationInfo BuildWorkflowRegistration(
|
||||||
|
Workflow workflow,
|
||||||
|
HashSet<string> registeredActivities)
|
||||||
|
{
|
||||||
|
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!);
|
||||||
|
Dictionary<string, ExecutorBinding> executorBindings = workflow.ReflectExecutors();
|
||||||
|
List<ActivityRegistrationInfo> activities = [];
|
||||||
|
|
||||||
|
foreach (KeyValuePair<string, ExecutorBinding> entry in executorBindings
|
||||||
|
.Where(e => IsActivityBinding(e.Value)))
|
||||||
|
{
|
||||||
|
string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key);
|
||||||
|
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
|
||||||
|
|
||||||
|
if (registeredActivities.Add(activityName))
|
||||||
|
{
|
||||||
|
activities.Add(new ActivityRegistrationInfo(activityName, entry.Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorkflowRegistrationInfo(orchestrationName, activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns <see langword="true"/> for bindings that should be registered as Durable Task activities.
|
||||||
|
/// <see cref="AIAgentBinding"/> (Durable Entities) and <see cref="SubworkflowBinding"/> (sub-orchestrations)
|
||||||
|
/// use specialized dispatch and are excluded.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsActivityBinding(ExecutorBinding binding)
|
||||||
|
=> binding is not AIAgentBinding
|
||||||
|
and not SubworkflowBinding;
|
||||||
|
|
||||||
|
private static async Task<string> RunWorkflowOrchestrationAsync(
|
||||||
|
TaskOrchestrationContext context,
|
||||||
|
DurableWorkflowInput<object> workflowInput,
|
||||||
|
DurableOptions durableOptions)
|
||||||
|
{
|
||||||
|
ILogger logger = context.CreateReplaySafeLogger("DurableWorkflow");
|
||||||
|
DurableWorkflowRunner runner = new(durableOptions);
|
||||||
|
|
||||||
|
// ConfigureAwait(true) is required in orchestration code for deterministic replay.
|
||||||
|
return await runner.RunWorkflowOrchestrationAsync(context, workflowInput, logger).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record WorkflowRegistrationInfo(string OrchestrationName, List<ActivityRegistrationInfo> Activities);
|
||||||
|
|
||||||
|
private sealed record ActivityRegistrationInfo(string ActivityName, ExecutorBinding Binding);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates that an agent with the specified name has been registered.
|
/// Validates that an agent with the specified name has been registered.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -124,63 +375,4 @@ public static class ServiceCollectionExtensions
|
|||||||
throw new AgentNotRegisteredException(agentName);
|
throw new AgentNotRegisteredException(agentName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class DefaultDataConverter : DataConverter
|
|
||||||
{
|
|
||||||
// Use durable agent options (web defaults + camel case by default) with case-insensitive matching.
|
|
||||||
// We clone to apply naming/casing tweaks while retaining source-generated metadata where available.
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback (may trigger trimming/AOT warnings for unsupported dynamic types).
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A workflow context for durable activity execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// State is passed in from the orchestration and updates are collected for return.
|
||||||
|
/// Events emitted during execution are collected and returned to the orchestration
|
||||||
|
/// as part of the activity output for streaming to callers.
|
||||||
|
/// </remarks>
|
||||||
|
[DebuggerDisplay("Executor = {_executor.Id}, StateEntries = {_initialState.Count}")]
|
||||||
|
internal sealed class DurableActivityContext : IWorkflowContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The default scope name used when no explicit scope is specified.
|
||||||
|
/// Scopes partition shared state into logical namespaces so that different
|
||||||
|
/// parts of a workflow can manage their state keys independently.
|
||||||
|
/// </summary>
|
||||||
|
private const string DefaultScopeName = "__default__";
|
||||||
|
|
||||||
|
private readonly Dictionary<string, string> _initialState;
|
||||||
|
private readonly Executor _executor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DurableActivityContext"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="initialState">The shared state passed from the orchestration.</param>
|
||||||
|
/// <param name="executor">The executor running in this context.</param>
|
||||||
|
internal DurableActivityContext(Dictionary<string, string>? initialState, Executor executor)
|
||||||
|
{
|
||||||
|
this._executor = executor;
|
||||||
|
this._initialState = initialState ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the messages sent during activity execution via <see cref="SendMessageAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal List<TypedPayload> SentMessages { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the events that were added during activity execution.
|
||||||
|
/// </summary>
|
||||||
|
internal List<WorkflowEvent> Events { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the state updates made during activity execution.
|
||||||
|
/// </summary>
|
||||||
|
internal Dictionary<string, string?> StateUpdates { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the scopes that were cleared during activity execution.
|
||||||
|
/// </summary>
|
||||||
|
internal HashSet<string> ClearedScopes { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the executor requested a workflow halt.
|
||||||
|
/// </summary>
|
||||||
|
internal bool HaltRequested { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask AddEventAsync(
|
||||||
|
WorkflowEvent workflowEvent,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (workflowEvent is not null)
|
||||||
|
{
|
||||||
|
this.Events.Add(workflowEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow message types registered at startup.")]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow message types registered at startup.")]
|
||||||
|
public ValueTask SendMessageAsync(
|
||||||
|
object message,
|
||||||
|
string? targetId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (message is not null)
|
||||||
|
{
|
||||||
|
Type messageType = message.GetType();
|
||||||
|
this.SentMessages.Add(new TypedPayload
|
||||||
|
{
|
||||||
|
Data = JsonSerializer.Serialize(message, messageType, DurableSerialization.Options),
|
||||||
|
TypeName = messageType.AssemblyQualifiedName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask YieldOutputAsync(
|
||||||
|
object output,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (output is not null)
|
||||||
|
{
|
||||||
|
Type outputType = output.GetType();
|
||||||
|
if (!this._executor.CanOutput(outputType))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Cannot output object of type {outputType.Name}. " +
|
||||||
|
$"Expecting one of [{string.Join(", ", this._executor.OutputTypes)}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.Events.Add(new WorkflowOutputEvent(output, this._executor.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask RequestHaltAsync()
|
||||||
|
{
|
||||||
|
this.HaltRequested = true;
|
||||||
|
this.Events.Add(new DurableHaltRequestedEvent(this._executor.Id));
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask<T?> ReadStateAsync<T>(
|
||||||
|
string key,
|
||||||
|
string? scopeName = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||||
|
|
||||||
|
string scopeKey = GetScopeKey(scopeName, key);
|
||||||
|
string normalizedScope = scopeName ?? DefaultScopeName;
|
||||||
|
bool scopeCleared = this.ClearedScopes.Contains(normalizedScope);
|
||||||
|
|
||||||
|
// Local updates take priority over initial state.
|
||||||
|
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
|
||||||
|
{
|
||||||
|
return DeserializeStateAsync<T>(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If scope was cleared, ignore initial state
|
||||||
|
if (scopeCleared)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult<T?>(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to initial state passed from orchestration
|
||||||
|
if (this._initialState.TryGetValue(scopeKey, out string? initial))
|
||||||
|
{
|
||||||
|
return DeserializeStateAsync<T>(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.FromResult<T?>(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<T> ReadOrInitStateAsync<T>(
|
||||||
|
string key,
|
||||||
|
Func<T> initialStateFactory,
|
||||||
|
string? scopeName = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||||
|
ArgumentNullException.ThrowIfNull(initialStateFactory);
|
||||||
|
|
||||||
|
// Cannot rely on `value is not null` because T? on an unconstrained generic
|
||||||
|
// parameter does not become Nullable<T> for value types — the null check is
|
||||||
|
// always true for types like int. Instead, check key existence directly.
|
||||||
|
if (this.HasStateKey(key, scopeName))
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask<HashSet<string>> ReadStateKeysAsync(
|
||||||
|
string? scopeName = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string scopePrefix = GetScopePrefix(scopeName);
|
||||||
|
int scopePrefixLength = scopePrefix.Length;
|
||||||
|
HashSet<string> keys = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
bool scopeCleared = scopeName is null
|
||||||
|
? this.ClearedScopes.Contains(DefaultScopeName)
|
||||||
|
: this.ClearedScopes.Contains(scopeName);
|
||||||
|
|
||||||
|
// Start with keys from initial state (skip if scope was cleared)
|
||||||
|
if (!scopeCleared)
|
||||||
|
{
|
||||||
|
foreach (string stateKey in this._initialState.Keys)
|
||||||
|
{
|
||||||
|
if (stateKey.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
keys.Add(stateKey[scopePrefixLength..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge local updates: add if non-null, remove if null (deleted)
|
||||||
|
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
|
||||||
|
{
|
||||||
|
if (!update.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = update.Key[scopePrefixLength..];
|
||||||
|
if (update.Value is not null)
|
||||||
|
{
|
||||||
|
keys.Add(key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
keys.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.FromResult(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask QueueStateUpdateAsync<T>(
|
||||||
|
string key,
|
||||||
|
T? value,
|
||||||
|
string? scopeName = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||||
|
|
||||||
|
string scopeKey = GetScopeKey(scopeName, key);
|
||||||
|
this.StateUpdates[scopeKey] = value is null ? null : SerializeState(value);
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask QueueClearScopeAsync(
|
||||||
|
string? scopeName = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
this.ClearedScopes.Add(scopeName ?? DefaultScopeName);
|
||||||
|
|
||||||
|
// Remove any pending updates in this scope (snapshot keys to allow removal during iteration)
|
||||||
|
string scopePrefix = GetScopePrefix(scopeName);
|
||||||
|
foreach (string key in this.StateUpdates.Keys.ToArray())
|
||||||
|
{
|
||||||
|
if (key.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
this.StateUpdates.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public IReadOnlyDictionary<string, string>? TraceContext => null;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool ConcurrentRunsEnabled => false;
|
||||||
|
|
||||||
|
private static string GetScopeKey(string? scopeName, string key)
|
||||||
|
=> $"{GetScopePrefix(scopeName)}{key}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the given key exists in local updates or initial state,
|
||||||
|
/// respecting cleared scopes.
|
||||||
|
/// </summary>
|
||||||
|
private bool HasStateKey(string key, string? scopeName)
|
||||||
|
{
|
||||||
|
string scopeKey = GetScopeKey(scopeName, key);
|
||||||
|
|
||||||
|
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
|
||||||
|
{
|
||||||
|
return updated is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string normalizedScope = scopeName ?? DefaultScopeName;
|
||||||
|
if (this.ClearedScopes.Contains(normalizedScope))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._initialState.ContainsKey(scopeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the key prefix for the given scope. Scopes partition shared state
|
||||||
|
/// into logical namespaces, allowing different workflow executors to manage
|
||||||
|
/// their state keys independently. When no scope is specified, the
|
||||||
|
/// <see cref="DefaultScopeName"/> is used.
|
||||||
|
/// </summary>
|
||||||
|
private static string GetScopePrefix(string? scopeName)
|
||||||
|
=> scopeName is null ? $"{DefaultScopeName}:" : $"{scopeName}:";
|
||||||
|
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow state types.")]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow state types.")]
|
||||||
|
private static string SerializeState<T>(T value)
|
||||||
|
=> JsonSerializer.Serialize(value, DurableSerialization.Options);
|
||||||
|
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
|
||||||
|
private static ValueTask<T?> DeserializeStateAsync<T>(string? json)
|
||||||
|
{
|
||||||
|
if (json is null)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult<T?>(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(json, DurableSerialization.Options));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes workflow activities by invoking executor bindings and handling serialization.
|
||||||
|
/// </summary>
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Workflow and executor types are registered at startup.")]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Workflow and executor types are registered at startup.")]
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Workflow and executor types are registered at startup.")]
|
||||||
|
internal static class DurableActivityExecutor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Executes an activity using the provided executor binding.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="binding">The executor binding to invoke.</param>
|
||||||
|
/// <param name="input">The serialized input string.</param>
|
||||||
|
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||||
|
/// <returns>The serialized activity output.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="binding"/> is null.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown when the executor factory is not configured.</exception>
|
||||||
|
internal static async Task<string> ExecuteAsync(
|
||||||
|
ExecutorBinding binding,
|
||||||
|
string input,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(binding);
|
||||||
|
|
||||||
|
if (binding.FactoryAsync is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Executor binding for '{binding.Id}' does not have a factory configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
DurableActivityInput? inputWithState = TryDeserializeActivityInput(input);
|
||||||
|
string executorInput = inputWithState?.Input ?? input;
|
||||||
|
Dictionary<string, string> sharedState = inputWithState?.State ?? [];
|
||||||
|
|
||||||
|
Executor executor = await binding.FactoryAsync(binding.Id).ConfigureAwait(false);
|
||||||
|
Type inputType = ResolveInputType(inputWithState?.InputTypeName, executor.InputTypes);
|
||||||
|
object typedInput = DeserializeInput(executorInput, inputType);
|
||||||
|
|
||||||
|
DurableActivityContext workflowContext = new(sharedState, executor);
|
||||||
|
object? result = await executor.ExecuteAsync(
|
||||||
|
typedInput,
|
||||||
|
new TypeId(inputType),
|
||||||
|
workflowContext,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return SerializeActivityOutput(result, workflowContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeActivityOutput(object? result, DurableActivityContext context)
|
||||||
|
{
|
||||||
|
DurableActivityOutput output = new()
|
||||||
|
{
|
||||||
|
Result = SerializeResult(result),
|
||||||
|
StateUpdates = context.StateUpdates,
|
||||||
|
ClearedScopes = [.. context.ClearedScopes],
|
||||||
|
Events = context.Events.ConvertAll(SerializeEvent),
|
||||||
|
SentMessages = context.SentMessages,
|
||||||
|
HaltRequested = context.HaltRequested
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(output, DurableWorkflowJsonContext.Default.DurableActivityOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes a workflow event with type information for proper deserialization.
|
||||||
|
/// </summary>
|
||||||
|
private static string SerializeEvent(WorkflowEvent evt)
|
||||||
|
{
|
||||||
|
Type eventType = evt.GetType();
|
||||||
|
TypedPayload wrapper = new()
|
||||||
|
{
|
||||||
|
TypeName = eventType.AssemblyQualifiedName,
|
||||||
|
Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options)
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(), DurableSerialization.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DurableActivityInput? TryDeserializeActivityInput(string input)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize(input, DurableWorkflowJsonContext.Default.DurableActivityInput);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object DeserializeInput(string input, Type targetType)
|
||||||
|
{
|
||||||
|
if (targetType == typeof(string))
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options)
|
||||||
|
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type ResolveInputType(string? inputTypeName, ISet<Type> supportedTypes)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(inputTypeName))
|
||||||
|
{
|
||||||
|
return supportedTypes.FirstOrDefault() ?? typeof(string);
|
||||||
|
}
|
||||||
|
|
||||||
|
Type? matchedType = supportedTypes.FirstOrDefault(t =>
|
||||||
|
t.AssemblyQualifiedName == inputTypeName ||
|
||||||
|
t.FullName == inputTypeName ||
|
||||||
|
t.Name == inputTypeName);
|
||||||
|
|
||||||
|
if (matchedType is not null)
|
||||||
|
{
|
||||||
|
return matchedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
Type? loadedType = Type.GetType(inputTypeName);
|
||||||
|
|
||||||
|
// Fall back if type is string but executor doesn't support string
|
||||||
|
if (loadedType == typeof(string) && !supportedTypes.Contains(typeof(string)))
|
||||||
|
{
|
||||||
|
return supportedTypes.FirstOrDefault() ?? typeof(string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Input payload for activity execution, containing the input and other metadata.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DurableActivityInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the serialized executor input.
|
||||||
|
/// </summary>
|
||||||
|
public string? Input { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the assembly-qualified type name of the input, used for proper deserialization.
|
||||||
|
/// </summary>
|
||||||
|
public string? InputTypeName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the shared state dictionary (scope-prefixed key -> serialized value).
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> State { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Output payload from activity execution, containing the result, state updates, and emitted events.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DurableActivityOutput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the executor result.
|
||||||
|
/// </summary>
|
||||||
|
public string? Result { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the state updates (scope-prefixed key to value; null indicates deletion).
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string?> StateUpdates { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the scope names that were cleared.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> ClearedScopes { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the workflow events emitted during execution.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Events { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the typed messages sent to downstream executors.
|
||||||
|
/// </summary>
|
||||||
|
public List<TypedPayload> SentMessages { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the executor requested a workflow halt.
|
||||||
|
/// </summary>
|
||||||
|
public bool HaltRequested { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// ConfigureAwait Usage in Orchestration Code:
|
||||||
|
// This file uses ConfigureAwait(true) because it runs within orchestration context.
|
||||||
|
// Durable Task orchestrations require deterministic replay - the same code must execute
|
||||||
|
// identically across replays. ConfigureAwait(true) ensures continuations run on the
|
||||||
|
// orchestration's synchronization context, which is essential for replay correctness.
|
||||||
|
// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay.
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.DurableTask;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatches workflow executors to either activities or AI agents.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called during the dispatch phase of each superstep by
|
||||||
|
/// <c>DurableWorkflowRunner.DispatchExecutorsInParallelAsync</c>. For each executor that has
|
||||||
|
/// pending input, this dispatcher determines whether the executor is an AI agent (stateful,
|
||||||
|
/// backed by Durable Entities) or a regular activity, and invokes the appropriate Durable Task API.
|
||||||
|
/// The serialised string result is returned to the runner for the routing phase.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class DurableExecutorDispatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Dispatches an executor based on its type (activity or AI agent).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The task orchestration context.</param>
|
||||||
|
/// <param name="executorInfo">Information about the executor to dispatch.</param>
|
||||||
|
/// <param name="envelope">The message envelope containing input and type information.</param>
|
||||||
|
/// <param name="sharedState">The shared state dictionary to pass to the executor.</param>
|
||||||
|
/// <param name="logger">The logger for tracing.</param>
|
||||||
|
/// <returns>The result from the executor.</returns>
|
||||||
|
internal static async Task<string> DispatchAsync(
|
||||||
|
TaskOrchestrationContext context,
|
||||||
|
WorkflowExecutorInfo executorInfo,
|
||||||
|
DurableMessageEnvelope envelope,
|
||||||
|
Dictionary<string, string> sharedState,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor);
|
||||||
|
|
||||||
|
if (executorInfo.IsAgenticExecutor)
|
||||||
|
{
|
||||||
|
return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName, sharedState).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ExecuteActivityAsync(
|
||||||
|
TaskOrchestrationContext context,
|
||||||
|
WorkflowExecutorInfo executorInfo,
|
||||||
|
string input,
|
||||||
|
string? inputTypeName,
|
||||||
|
Dictionary<string, string> sharedState)
|
||||||
|
{
|
||||||
|
string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
|
||||||
|
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
|
||||||
|
|
||||||
|
DurableActivityInput activityInput = new()
|
||||||
|
{
|
||||||
|
Input = input,
|
||||||
|
InputTypeName = inputTypeName,
|
||||||
|
State = sharedState
|
||||||
|
};
|
||||||
|
|
||||||
|
string serializedInput = JsonSerializer.Serialize(activityInput, DurableWorkflowJsonContext.Default.DurableActivityInput);
|
||||||
|
|
||||||
|
return await context.CallActivityAsync<string>(activityName, serializedInput).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes an AI agent executor through Durable Entities.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// AI agents are stateful and maintain conversation history. They use Durable Entities
|
||||||
|
/// to persist state across orchestration replays.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<string> ExecuteAgentAsync(
|
||||||
|
TaskOrchestrationContext context,
|
||||||
|
WorkflowExecutorInfo executorInfo,
|
||||||
|
ILogger logger,
|
||||||
|
string input)
|
||||||
|
{
|
||||||
|
string agentName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
|
||||||
|
DurableAIAgent agent = context.GetAgent(agentName);
|
||||||
|
|
||||||
|
if (agent is null)
|
||||||
|
{
|
||||||
|
logger.LogAgentNotFound(agentName);
|
||||||
|
return $"Agent '{agentName}' not found";
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentSession session = await agent.GetNewSessionAsync().ConfigureAwait(true);
|
||||||
|
AgentResponse response = await agent.RunAsync(input, session).ConfigureAwait(true);
|
||||||
|
|
||||||
|
return response.Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event raised when an executor requests the workflow to halt via <see cref="IWorkflowContext.RequestHaltAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
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,51 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a message envelope for durable workflow message passing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the durable equivalent of <c>MessageEnvelope</c> in the in-process runner.
|
||||||
|
/// Unlike the in-process version which holds native .NET objects, this envelope
|
||||||
|
/// contains serialized JSON strings suitable for Durable Task activities.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DurableMessageEnvelope
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the serialized JSON message content.
|
||||||
|
/// </summary>
|
||||||
|
public required string Message { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the full type name of the message for deserialization.
|
||||||
|
/// </summary>
|
||||||
|
public string? InputTypeName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the executor that produced this message.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Used for tracing and debugging. Null for initial workflow input.
|
||||||
|
/// </remarks>
|
||||||
|
public string? SourceExecutorId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new message envelope.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The serialized JSON message content.</param>
|
||||||
|
/// <param name="inputTypeName">The full type name of the message for deserialization.</param>
|
||||||
|
/// <param name="sourceExecutorId">The ID of the executor that produced this message, or null for initial input.</param>
|
||||||
|
/// <returns>A new <see cref="DurableMessageEnvelope"/> instance.</returns>
|
||||||
|
internal static DurableMessageEnvelope Create(string message, string? inputTypeName, string? sourceExecutorId = null)
|
||||||
|
{
|
||||||
|
return new DurableMessageEnvelope
|
||||||
|
{
|
||||||
|
Message = message,
|
||||||
|
InputTypeName = inputTypeName,
|
||||||
|
SourceExecutorId = sourceExecutorId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <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,22 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared serialization options for user-defined workflow types that are not known at compile time
|
||||||
|
/// and therefore cannot use the source-generated <see cref="DurableWorkflowJsonContext"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DurableSerialization
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the shared <see cref="JsonSerializerOptions"/> for workflow serialization
|
||||||
|
/// with camelCase naming and case-insensitive deserialization.
|
||||||
|
/// </summary>
|
||||||
|
internal static JsonSerializerOptions Options { get; } = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
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.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a durable workflow run that supports streaming workflow events as they occur.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Events are detected by monitoring the orchestration's custom status at regular intervals.
|
||||||
|
/// When executors emit events via <see cref="IWorkflowContext.AddEventAsync"/> or
|
||||||
|
/// <see cref="IWorkflowContext.YieldOutputAsync"/>, they are written to the orchestration's
|
||||||
|
/// custom status and picked up by this streaming run.
|
||||||
|
/// </remarks>
|
||||||
|
[DebuggerDisplay("{WorkflowName} ({RunId})")]
|
||||||
|
internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||||
|
{
|
||||||
|
private readonly DurableTaskClient _client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DurableStreamingWorkflowRun"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||||
|
/// <param name="instanceId">The unique instance ID for this orchestration run.</param>
|
||||||
|
/// <param name="workflow">The workflow being executed.</param>
|
||||||
|
internal DurableStreamingWorkflowRun(DurableTaskClient client, string instanceId, Workflow workflow)
|
||||||
|
{
|
||||||
|
this._client = client;
|
||||||
|
this.RunId = instanceId;
|
||||||
|
this.WorkflowName = workflow.Name ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string RunId { 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.RunId,
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> this.WatchStreamAsync(pollingInterval: null, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asynchronously streams workflow events as they occur during workflow execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pollingInterval">The interval between status checks. Defaults to 100ms.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||||
|
/// <returns>An asynchronous stream of <see cref="WorkflowEvent"/> objects.</returns>
|
||||||
|
private async IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
|
||||||
|
TimeSpan? pollingInterval,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
TimeSpan minInterval = pollingInterval ?? TimeSpan.FromMilliseconds(100);
|
||||||
|
TimeSpan maxInterval = TimeSpan.FromSeconds(2);
|
||||||
|
TimeSpan currentInterval = minInterval;
|
||||||
|
|
||||||
|
// Track how many events we've already read from custom status
|
||||||
|
int lastReadEventIndex = 0;
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Poll with getInputsAndOutputs: true because SerializedCustomStatus
|
||||||
|
// (used for event streaming) is only populated when this flag is set.
|
||||||
|
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
|
||||||
|
this.RunId,
|
||||||
|
getInputsAndOutputs: true,
|
||||||
|
cancellation: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (metadata is null)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasNewEvents = false;
|
||||||
|
|
||||||
|
// Always drain any unread events from custom status before checking terminal states.
|
||||||
|
// The orchestration may complete before the next poll, so events would be lost if we
|
||||||
|
// check terminal status first.
|
||||||
|
if (metadata.SerializedCustomStatus is not null)
|
||||||
|
{
|
||||||
|
DurableWorkflowCustomStatus? customStatus = TryParseCustomStatus(metadata.SerializedCustomStatus);
|
||||||
|
if (customStatus is not null)
|
||||||
|
{
|
||||||
|
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(customStatus.Events, lastReadEventIndex);
|
||||||
|
foreach (WorkflowEvent evt in events)
|
||||||
|
{
|
||||||
|
hasNewEvents = true;
|
||||||
|
yield return evt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check terminal states after draining events from custom status
|
||||||
|
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||||
|
{
|
||||||
|
// The framework clears custom status on completion, so events may be in
|
||||||
|
// SerializedOutput as a DurableWorkflowResult wrapper.
|
||||||
|
DurableWorkflowResult? outputResult = TryParseWorkflowResult(metadata.SerializedOutput);
|
||||||
|
if (outputResult is not null)
|
||||||
|
{
|
||||||
|
(List<WorkflowEvent> events, _) = DrainNewEvents(outputResult.Events, lastReadEventIndex);
|
||||||
|
foreach (WorkflowEvent evt in events)
|
||||||
|
{
|
||||||
|
yield return evt;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return new DurableWorkflowCompletedEvent(outputResult.Result);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptive backoff: reset to minimum when events were found, increase otherwise
|
||||||
|
currentInterval = hasNewEvents
|
||||||
|
? minInterval
|
||||||
|
: TimeSpan.FromMilliseconds(Math.Min(currentInterval.TotalMilliseconds * 2, maxInterval.TotalMilliseconds));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(currentInterval, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.RunId,
|
||||||
|
getInputsAndOutputs: true,
|
||||||
|
cancellation: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||||
|
{
|
||||||
|
return ExtractResult<TResult>(metadata.SerializedOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
/// Deserializes and returns any events beyond <paramref name="lastReadIndex"/> from the list.
|
||||||
|
/// </summary>
|
||||||
|
private static (List<WorkflowEvent> Events, int UpdatedIndex) DrainNewEvents(List<string> serializedEvents, int lastReadIndex)
|
||||||
|
{
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
while (lastReadIndex < serializedEvents.Count)
|
||||||
|
{
|
||||||
|
string serializedEvent = serializedEvents[lastReadIndex];
|
||||||
|
lastReadIndex++;
|
||||||
|
|
||||||
|
WorkflowEvent? workflowEvent = TryDeserializeEvent(serializedEvent);
|
||||||
|
if (workflowEvent is not null)
|
||||||
|
{
|
||||||
|
events.Add(workflowEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (events, lastReadIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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(serializedStatus, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse the orchestration output as a <see cref="DurableWorkflowResult"/> wrapper.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The orchestration wraps its output in a <see cref="DurableWorkflowResult"/> to include
|
||||||
|
/// accumulated events alongside the result. The Durable Task framework's <c>DataConverter</c>
|
||||||
|
/// serializes the string output with an extra layer of JSON encoding, so we first unwrap that.
|
||||||
|
/// </remarks>
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")]
|
||||||
|
private static DurableWorkflowResult? TryParseWorkflowResult(string? serializedOutput)
|
||||||
|
{
|
||||||
|
if (serializedOutput is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The DurableDataConverter wraps string results in JSON quotes, so
|
||||||
|
// SerializedOutput is a JSON-encoded string like "\"{ ... }\"".
|
||||||
|
// We need to unwrap the outer JSON string first.
|
||||||
|
string? innerJson = JsonSerializer.Deserialize<string>(serializedOutput);
|
||||||
|
if (innerJson is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize(innerJson, DurableWorkflowJsonContext.Default.DurableWorkflowResult);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts a typed result from the orchestration output, unwrapping the
|
||||||
|
/// <see cref="DurableWorkflowResult"/> wrapper if present.
|
||||||
|
/// Falls back to deserializing the raw output when the wrapper is absent
|
||||||
|
/// (e.g., runs started before the wrapper was introduced).
|
||||||
|
/// </summary>
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")]
|
||||||
|
internal static TResult? ExtractResult<TResult>(string? serializedOutput)
|
||||||
|
{
|
||||||
|
if (serializedOutput is null)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
DurableWorkflowResult? workflowResult = TryParseWorkflowResult(serializedOutput);
|
||||||
|
string? resultJson = workflowResult?.Result;
|
||||||
|
|
||||||
|
if (resultJson is not null)
|
||||||
|
{
|
||||||
|
if (typeof(TResult) == typeof(string))
|
||||||
|
{
|
||||||
|
return (TResult)(object)resultJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<TResult>(resultJson, DurableSerialization.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: the output is not wrapped in DurableWorkflowResult.
|
||||||
|
// The DurableDataConverter wraps string results in JSON quotes, so
|
||||||
|
// we unwrap the outer JSON string first.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? innerString = JsonSerializer.Deserialize<string>(serializedOutput);
|
||||||
|
if (typeof(TResult) == typeof(string) && innerString is not null)
|
||||||
|
{
|
||||||
|
return (TResult)(object)innerString;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (innerString is not null)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<TResult>(innerString, DurableSerialization.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Not a JSON-encoded string; try direct deserialization below.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof(TResult) == typeof(string))
|
||||||
|
{
|
||||||
|
return (TResult)(object)serializedOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<TResult>(serializedOutput, DurableSerialization.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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.")]
|
||||||
|
private static WorkflowEvent? TryDeserializeEvent(string serializedEvent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TypedPayload? wrapper = JsonSerializer.Deserialize(
|
||||||
|
serializedEvent,
|
||||||
|
DurableWorkflowJsonContext.Default.TypedPayload);
|
||||||
|
|
||||||
|
if (wrapper?.TypeName is not null && wrapper.Data is not null)
|
||||||
|
{
|
||||||
|
Type? eventType = Type.GetType(wrapper.TypeName);
|
||||||
|
if (eventType is not null)
|
||||||
|
{
|
||||||
|
return DeserializeEventByType(eventType, wrapper.Data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
|
||||||
|
private static WorkflowEvent? DeserializeEventByType(Type eventType, string json)
|
||||||
|
{
|
||||||
|
// Types with internal constructors need manual deserialization
|
||||||
|
if (eventType == typeof(ExecutorInvokedEvent)
|
||||||
|
|| eventType == typeof(ExecutorCompletedEvent)
|
||||||
|
|| eventType == typeof(WorkflowOutputEvent))
|
||||||
|
{
|
||||||
|
using JsonDocument doc = JsonDocument.Parse(json);
|
||||||
|
JsonElement root = doc.RootElement;
|
||||||
|
|
||||||
|
if (eventType == typeof(ExecutorInvokedEvent))
|
||||||
|
{
|
||||||
|
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
|
||||||
|
JsonElement? data = GetDataProperty(root);
|
||||||
|
return new ExecutorInvokedEvent(executorId, data!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventType == typeof(ExecutorCompletedEvent))
|
||||||
|
{
|
||||||
|
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
|
||||||
|
JsonElement? data = GetDataProperty(root);
|
||||||
|
return new ExecutorCompletedEvent(executorId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkflowOutputEvent
|
||||||
|
string sourceId = root.GetProperty("sourceId").GetString() ?? string.Empty;
|
||||||
|
object? outputData = GetDataProperty(root);
|
||||||
|
return new WorkflowOutputEvent(outputData!, sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize(json, eventType, DurableSerialization.Options) as WorkflowEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement? GetDataProperty(JsonElement root)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("data", out JsonElement dataElement))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask;
|
||||||
|
using Microsoft.DurableTask.Client;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides a durable task-based implementation of <see cref="IWorkflowClient"/> for running
|
||||||
|
/// workflows as durable orchestrations.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DurableWorkflowClient : IWorkflowClient
|
||||||
|
{
|
||||||
|
private readonly DurableTaskClient _client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DurableWorkflowClient"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is null.</exception>
|
||||||
|
public DurableWorkflowClient(DurableTaskClient client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
this._client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<IWorkflowRun> RunAsync<TInput>(
|
||||||
|
Workflow workflow,
|
||||||
|
TInput input,
|
||||||
|
string? runId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
where TInput : notnull
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflow);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(workflow.Name))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
DurableWorkflowInput<TInput> workflowInput = new() { Input = input };
|
||||||
|
|
||||||
|
string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync(
|
||||||
|
orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name),
|
||||||
|
input: workflowInput,
|
||||||
|
options: runId is not null ? new StartOrchestrationOptions(runId) : null,
|
||||||
|
cancellation: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new DurableWorkflowRun(this._client, instanceId, workflow.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask<IWorkflowRun> RunAsync(
|
||||||
|
Workflow workflow,
|
||||||
|
string input,
|
||||||
|
string? runId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> this.RunAsync<string>(workflow, input, runId, cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async ValueTask<IStreamingWorkflowRun> StreamAsync<TInput>(
|
||||||
|
Workflow workflow,
|
||||||
|
TInput input,
|
||||||
|
string? runId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
where TInput : notnull
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflow);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(workflow.Name))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
DurableWorkflowInput<TInput> workflowInput = new() { Input = input };
|
||||||
|
|
||||||
|
string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync(
|
||||||
|
orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name),
|
||||||
|
input: workflowInput,
|
||||||
|
options: runId is not null ? new StartOrchestrationOptions(runId) : null,
|
||||||
|
cancellation: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new DurableStreamingWorkflowRun(this._client, instanceId, workflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public ValueTask<IStreamingWorkflowRun> StreamAsync(
|
||||||
|
Workflow workflow,
|
||||||
|
string input,
|
||||||
|
string? runId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> this.StreamAsync<string>(workflow, input, runId, cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event raised when a durable workflow completes successfully.
|
||||||
|
/// </summary>
|
||||||
|
[DebuggerDisplay("Completed: {Result}")]
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the custom status written by the orchestration for streaming consumption.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The Durable Task framework exposes <c>SerializedCustomStatus</c> on orchestration metadata,
|
||||||
|
/// which is the only orchestration state readable by external clients while the orchestration
|
||||||
|
/// is still running. The orchestrator writes this object via <c>SetCustomStatus</c> after each
|
||||||
|
/// superstep so that <see cref="DurableStreamingWorkflowRun"/> can poll for new events.
|
||||||
|
/// On orchestration completion the framework clears custom status, so events are also
|
||||||
|
/// embedded in the output via <see cref="DurableWorkflowResult"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DurableWorkflowCustomStatus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the serialized workflow events emitted so far.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Events { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event raised when a durable workflow fails.
|
||||||
|
/// </summary>
|
||||||
|
[DebuggerDisplay("Failed: {ErrorMessage}")]
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the input envelope for a durable workflow orchestration.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TInput">The type of the workflow input.</typeparam>
|
||||||
|
internal sealed class DurableWorkflowInput<TInput>
|
||||||
|
where TInput : notnull
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the workflow input data.
|
||||||
|
/// </summary>
|
||||||
|
public required TInput Input { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Source-generated JSON serialization context for durable workflow types.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This context provides AOT-compatible and trimmer-safe JSON serialization for the
|
||||||
|
/// internal data transfer types used by the durable workflow infrastructure:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><see cref="DurableActivityInput"/>: Activity input wrapper with state</description></item>
|
||||||
|
/// <item><description><see cref="DurableActivityOutput"/>: Activity output wrapper with results, events, and state updates</description></item>
|
||||||
|
/// <item><description><see cref="TypedPayload"/>: Serialized payload wrapper with type info (events and messages)</description></item>
|
||||||
|
/// <item><description><see cref="DurableWorkflowCustomStatus"/>: Custom status for streaming consumption</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// Note: User-defined executor input/output types still use reflection-based serialization
|
||||||
|
/// since their types are not known at compile time.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
WriteIndented = false,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||||
|
[JsonSerializable(typeof(DurableActivityInput))]
|
||||||
|
[JsonSerializable(typeof(DurableActivityOutput))]
|
||||||
|
[JsonSerializable(typeof(TypedPayload))]
|
||||||
|
[JsonSerializable(typeof(List<TypedPayload>))]
|
||||||
|
[JsonSerializable(typeof(DurableWorkflowCustomStatus))]
|
||||||
|
[JsonSerializable(typeof(DurableWorkflowResult))]
|
||||||
|
[JsonSerializable(typeof(List<string>))]
|
||||||
|
[JsonSerializable(typeof(Dictionary<string, string>))]
|
||||||
|
[JsonSerializable(typeof(Dictionary<string, string?>))]
|
||||||
|
internal partial class DurableWorkflowJsonContext : JsonSerializerContext;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides configuration options for managing durable workflows within an application.
|
||||||
|
/// </summary>
|
||||||
|
[DebuggerDisplay("Workflows = {Workflows.Count}")]
|
||||||
|
public sealed class DurableWorkflowOptions
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, Workflow> _workflows = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly DurableOptions? _parentOptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DurableWorkflowOptions"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="parentOptions">Optional parent options container for accessing related configuration.</param>
|
||||||
|
internal DurableWorkflowOptions(DurableOptions? parentOptions = null)
|
||||||
|
{
|
||||||
|
this._parentOptions = parentOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the collection of workflows available in the current context, keyed by their unique names.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, Workflow> Workflows => this._workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the executor registry for direct executor lookup.
|
||||||
|
/// </summary>
|
||||||
|
internal ExecutorRegistry Executors { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a workflow to the collection for processing or execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workflow">The workflow instance to add. Cannot be null.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// When a workflow is added, all executors are registered in the executor registry.
|
||||||
|
/// Any AI agent executors will also be automatically registered with the
|
||||||
|
/// <see cref="DurableAgentsOptions"/> if available.
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflow"/> is null.</exception>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
|
||||||
|
public void AddWorkflow(Workflow workflow)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflow);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(workflow.Name))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
this._workflows[workflow.Name] = workflow;
|
||||||
|
this.RegisterWorkflowExecutors(workflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a collection of workflows to the current instance.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workflows">The collection of <see cref="Workflow"/> objects to add.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflows"/> is null.</exception>
|
||||||
|
public void AddWorkflows(params Workflow[] workflows)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflows);
|
||||||
|
|
||||||
|
foreach (Workflow workflow in workflows)
|
||||||
|
{
|
||||||
|
this.AddWorkflow(workflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers all executors from a workflow, including AI agents if agent options are available.
|
||||||
|
/// </summary>
|
||||||
|
private void RegisterWorkflowExecutors(Workflow workflow)
|
||||||
|
{
|
||||||
|
DurableAgentsOptions? agentOptions = this._parentOptions?.Agents;
|
||||||
|
|
||||||
|
foreach ((string executorId, ExecutorBinding binding) in workflow.ReflectExecutors())
|
||||||
|
{
|
||||||
|
string executorName = WorkflowNamingHelper.GetExecutorName(executorId);
|
||||||
|
this.Executors.Register(executorName, executorId, workflow);
|
||||||
|
|
||||||
|
TryRegisterAgent(binding, agentOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers an AI agent with the agent options if the binding contains an unregistered agent.
|
||||||
|
/// </summary>
|
||||||
|
private static void TryRegisterAgent(ExecutorBinding binding, DurableAgentsOptions? agentOptions)
|
||||||
|
{
|
||||||
|
if (agentOptions is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.RawValue is AIAgent { Name: not null } agent
|
||||||
|
&& !agentOptions.ContainsAgent(agent.Name))
|
||||||
|
{
|
||||||
|
agentOptions.AddAIAgent(agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps the orchestration output to include both the workflow result and accumulated events.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The Durable Task framework clears <c>SerializedCustomStatus</c> when an orchestration
|
||||||
|
/// completes. To ensure streaming clients can retrieve events even after completion,
|
||||||
|
/// the accumulated events are embedded in the orchestration output alongside the result.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DurableWorkflowResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the serialized result of the workflow execution.
|
||||||
|
/// </summary>
|
||||||
|
public string? Result { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the serialized workflow events emitted during execution.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Events { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask;
|
||||||
|
using Microsoft.DurableTask.Client;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a durable workflow run that tracks execution status and provides access to workflow events.
|
||||||
|
/// </summary>
|
||||||
|
[DebuggerDisplay("{WorkflowName} ({RunId})")]
|
||||||
|
internal sealed class DurableWorkflowRun : IAwaitableWorkflowRun
|
||||||
|
{
|
||||||
|
private readonly DurableTaskClient _client;
|
||||||
|
private readonly List<WorkflowEvent> _eventSink = [];
|
||||||
|
private int _lastBookmark;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DurableWorkflowRun"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The durable task client for orchestration operations.</param>
|
||||||
|
/// <param name="instanceId">The unique instance ID for this orchestration run.</param>
|
||||||
|
/// <param name="workflowName">The name of the workflow being executed.</param>
|
||||||
|
internal DurableWorkflowRun(DurableTaskClient client, string instanceId, string workflowName)
|
||||||
|
{
|
||||||
|
this._client = client;
|
||||||
|
this.RunId = instanceId;
|
||||||
|
this.WorkflowName = workflowName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string RunId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the workflow being executed.
|
||||||
|
/// </summary>
|
||||||
|
public string WorkflowName { get; }
|
||||||
|
|
||||||
|
/// <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="TaskFailedException">Thrown when the workflow failed.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown when the workflow was terminated or ended with an unexpected status.</exception>
|
||||||
|
public async ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync(
|
||||||
|
this.RunId,
|
||||||
|
getInputsAndOutputs: true,
|
||||||
|
cancellation: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||||
|
{
|
||||||
|
return DurableStreamingWorkflowRun.ExtractResult<TResult>(metadata.SerializedOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||||
|
{
|
||||||
|
if (metadata.FailureDetails is not null)
|
||||||
|
{
|
||||||
|
// Use TaskFailedException to preserve full failure details including stack trace and inner exceptions
|
||||||
|
throw new TaskFailedException(
|
||||||
|
taskName: this.WorkflowName,
|
||||||
|
taskId: 0,
|
||||||
|
failureDetails: metadata.FailureDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) failed without failure details.");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) 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>
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// ConfigureAwait Usage in Orchestration Code:
|
||||||
|
// This file uses ConfigureAwait(true) because it runs within orchestration context.
|
||||||
|
// Durable Task orchestrations require deterministic replay - the same code must execute
|
||||||
|
// identically across replays. ConfigureAwait(true) ensures continuations run on the
|
||||||
|
// orchestration's synchronization context, which is essential for replay correctness.
|
||||||
|
// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay.
|
||||||
|
|
||||||
|
// Superstep execution walkthrough for a workflow like below:
|
||||||
|
//
|
||||||
|
// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview)
|
||||||
|
// │ ▲
|
||||||
|
// └──► [D] ──────┘
|
||||||
|
//
|
||||||
|
// Superstep 1 — A runs
|
||||||
|
// Queues before: A:[input] Results: {}
|
||||||
|
// Dispatch: A executes, returns resultA
|
||||||
|
// Route: EdgeMap routes A's output → B's queue
|
||||||
|
// Queues after: B:[resultA] Results: {A: resultA}
|
||||||
|
//
|
||||||
|
// Superstep 2 — B runs
|
||||||
|
// Queues before: B:[resultA] Results: {A: resultA}
|
||||||
|
// Dispatch: B executes, returns resultB (type: Order)
|
||||||
|
// Route: FanOutRouter sends resultB to:
|
||||||
|
// C's queue (unconditional)
|
||||||
|
// D's queue (only if resultB.NeedsReview == true)
|
||||||
|
// Queues after: C:[resultB], D:[resultB] Results: {A: .., B: resultB}
|
||||||
|
// (D may be empty if condition was false)
|
||||||
|
//
|
||||||
|
// Superstep 3 — C and D run in parallel
|
||||||
|
// Queues before: C:[resultB], D:[resultB]
|
||||||
|
// Dispatch: C and D execute concurrently via Task.WhenAll
|
||||||
|
// Route: Both route output → E's queue
|
||||||
|
// Queues after: E:[resultC, resultD] Results: {.., C: resultC, D: resultD}
|
||||||
|
//
|
||||||
|
// Superstep 4 — E runs (fan-in)
|
||||||
|
// Queues before: E:[resultC, resultD] ◄── IsFanInExecutor("E") = true
|
||||||
|
// Collect: AggregateQueueMessages merges into JSON array ["resultC","resultD"]
|
||||||
|
// Dispatch: E executes with aggregated input
|
||||||
|
// Route: E has no successors → nothing enqueued
|
||||||
|
// Queues after: (all empty) Results: {.., E: resultE}
|
||||||
|
//
|
||||||
|
// Superstep 5 — loop exits (no pending messages)
|
||||||
|
// GetFinalResult returns resultE
|
||||||
|
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
// Superstep loop:
|
||||||
|
//
|
||||||
|
// ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐
|
||||||
|
// │ Collect │───►│ Dispatch │───►│ Process Results │
|
||||||
|
// │ Executor │ │ Executors │ │ & Route Messages │
|
||||||
|
// │ Inputs │ │ in Parallel │ │ │
|
||||||
|
// └───────────────┘ └───────────────┘ └───────────────────┘
|
||||||
|
// ▲ │
|
||||||
|
// └───────────────────────────────────────────┘
|
||||||
|
// (repeat until no pending messages)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs workflow orchestrations using message-driven superstep execution with Durable Task.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class DurableWorkflowRunner
|
||||||
|
{
|
||||||
|
private const int MaxSupersteps = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DurableWorkflowRunner"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
|
||||||
|
public DurableWorkflowRunner(DurableOptions durableOptions)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(durableOptions);
|
||||||
|
|
||||||
|
this.Options = durableOptions.Workflows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the workflow options.
|
||||||
|
/// </summary>
|
||||||
|
private DurableWorkflowOptions Options { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a workflow orchestration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The task orchestration context.</param>
|
||||||
|
/// <param name="workflowInput">The workflow input envelope containing workflow input and metadata.</param>
|
||||||
|
/// <param name="logger">The replay-safe logger for orchestration logging.</param>
|
||||||
|
/// <returns>The result of the workflow execution.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown when the specified workflow is not found.</exception>
|
||||||
|
internal async Task<string> RunWorkflowOrchestrationAsync(
|
||||||
|
TaskOrchestrationContext context,
|
||||||
|
DurableWorkflowInput<object> workflowInput,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
ArgumentNullException.ThrowIfNull(workflowInput);
|
||||||
|
|
||||||
|
Workflow workflow = this.GetWorkflowOrThrow(context.Name);
|
||||||
|
|
||||||
|
string workflowName = context.Name;
|
||||||
|
string instanceId = context.InstanceId;
|
||||||
|
logger.LogWorkflowStarting(workflowName, instanceId);
|
||||||
|
|
||||||
|
WorkflowGraphInfo graphInfo = WorkflowAnalyzer.BuildGraphInfo(workflow);
|
||||||
|
DurableEdgeMap edgeMap = new(graphInfo);
|
||||||
|
|
||||||
|
// Extract input - the start executor determines the expected input type from its own InputTypes
|
||||||
|
object input = workflowInput.Input;
|
||||||
|
|
||||||
|
return await RunSuperstepLoopAsync(context, workflow, edgeMap, input, logger).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Workflow GetWorkflowOrThrow(string orchestrationName)
|
||||||
|
{
|
||||||
|
string workflowName = WorkflowNamingHelper.ToWorkflowName(orchestrationName);
|
||||||
|
|
||||||
|
if (!this.Options.Workflows.TryGetValue(workflowName, out Workflow? workflow))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Workflow '{workflowName}' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the workflow execution loop using superstep-based processing.
|
||||||
|
/// </summary>
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "Input types are preserved by the Durable Task framework's DataConverter.")]
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Input types are preserved by the Durable Task framework's DataConverter.")]
|
||||||
|
private static async Task<string> RunSuperstepLoopAsync(
|
||||||
|
TaskOrchestrationContext context,
|
||||||
|
Workflow workflow,
|
||||||
|
DurableEdgeMap edgeMap,
|
||||||
|
object initialInput,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
SuperstepState state = new(workflow, edgeMap);
|
||||||
|
|
||||||
|
// Convert input to string for the message queue.
|
||||||
|
// When DurableWorkflowInput<string> is deserialized as DurableWorkflowInput<object>,
|
||||||
|
// the Input property becomes a JsonElement instead of a string.
|
||||||
|
// We must extract the raw string value to avoid double-serialization.
|
||||||
|
string inputString = initialInput switch
|
||||||
|
{
|
||||||
|
string s => s,
|
||||||
|
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString() ?? string.Empty,
|
||||||
|
_ => JsonSerializer.Serialize(initialInput)
|
||||||
|
};
|
||||||
|
|
||||||
|
edgeMap.EnqueueInitialInput(inputString, state.MessageQueues);
|
||||||
|
|
||||||
|
for (int superstep = 1; superstep <= MaxSupersteps; superstep++)
|
||||||
|
{
|
||||||
|
List<ExecutorInput> executorInputs = CollectExecutorInputs(state, logger);
|
||||||
|
if (executorInputs.Count == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogSuperstepStarting(superstep, executorInputs.Count);
|
||||||
|
if (logger.IsEnabled(LogLevel.Debug))
|
||||||
|
{
|
||||||
|
logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state.SharedState, logger).ConfigureAwait(true);
|
||||||
|
|
||||||
|
bool haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger);
|
||||||
|
|
||||||
|
if (haltRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've reached the limit and still have work remaining
|
||||||
|
int remainingExecutors = CountRemainingExecutors(state.MessageQueues);
|
||||||
|
if (superstep == MaxSupersteps && remainingExecutors > 0)
|
||||||
|
{
|
||||||
|
logger.LogWorkflowMaxSuperstepsExceeded(context.InstanceId, MaxSupersteps, remainingExecutors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish final events for live streaming (skip during replay)
|
||||||
|
if (!context.IsReplaying)
|
||||||
|
{
|
||||||
|
PublishEventsToCustomStatus(context, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
string finalResult = GetFinalResult(state.LastResults);
|
||||||
|
logger.LogWorkflowCompleted();
|
||||||
|
|
||||||
|
// Return wrapper with both result and events so streaming clients can
|
||||||
|
// retrieve events from SerializedOutput after the orchestration completes
|
||||||
|
// (SerializedCustomStatus is cleared by the framework on completion).
|
||||||
|
DurableWorkflowResult workflowResult = new()
|
||||||
|
{
|
||||||
|
Result = finalResult,
|
||||||
|
Events = state.AccumulatedEvents
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts the number of executors with pending messages in their queues.
|
||||||
|
/// </summary>
|
||||||
|
private static int CountRemainingExecutors(Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues)
|
||||||
|
{
|
||||||
|
return messageQueues.Count(kvp => kvp.Value.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string[]> DispatchExecutorsInParallelAsync(
|
||||||
|
TaskOrchestrationContext context,
|
||||||
|
List<ExecutorInput> executorInputs,
|
||||||
|
Dictionary<string, string> sharedState,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
Task<string>[] dispatchTasks = executorInputs
|
||||||
|
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, sharedState, logger))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return await Task.WhenAll(dispatchTasks).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds state that accumulates and changes across superstep iterations during workflow execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>MessageQueues</c> starts with one entry (the start executor's queue, seeded by
|
||||||
|
/// <see cref="DurableEdgeMap.EnqueueInitialInput"/>). After each superstep, <c>RouteOutputToSuccessors</c>
|
||||||
|
/// adds entries for successor executors that receive routed messages. Queues are drained during
|
||||||
|
/// <c>CollectExecutorInputs</c>; empty queues are skipped.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>LastResults</c> is updated after every superstep with the result of each executor that ran.
|
||||||
|
/// At workflow completion, the last non-empty value is returned as the workflow's final result.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class SuperstepState
|
||||||
|
{
|
||||||
|
public SuperstepState(Workflow workflow, DurableEdgeMap edgeMap)
|
||||||
|
{
|
||||||
|
this.EdgeMap = edgeMap;
|
||||||
|
this.ExecutorBindings = workflow.ReflectExecutors();
|
||||||
|
}
|
||||||
|
|
||||||
|
public DurableEdgeMap EdgeMap { get; }
|
||||||
|
|
||||||
|
public Dictionary<string, ExecutorBinding> ExecutorBindings { get; }
|
||||||
|
|
||||||
|
public Dictionary<string, Queue<DurableMessageEnvelope>> MessageQueues { get; } = [];
|
||||||
|
|
||||||
|
public Dictionary<string, string> LastResults { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared state dictionary across supersteps (scope-prefixed key -> serialized value).
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> SharedState { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accumulated workflow events for custom status (streaming consumption).
|
||||||
|
/// </summary>
|
||||||
|
public List<string> AccumulatedEvents { get; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents prepared input for an executor ready for dispatch.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record ExecutorInput(string ExecutorId, DurableMessageEnvelope Envelope, WorkflowExecutorInfo Info);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Collects inputs for all active executors, applying Fan-In aggregation where needed.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ExecutorInput> CollectExecutorInputs(
|
||||||
|
SuperstepState state,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
List<ExecutorInput> inputs = [];
|
||||||
|
|
||||||
|
// Only process queues that have pending messages
|
||||||
|
foreach ((string executorId, Queue<DurableMessageEnvelope> queue) in state.MessageQueues
|
||||||
|
.Where(kvp => kvp.Value.Count > 0))
|
||||||
|
{
|
||||||
|
DurableMessageEnvelope envelope = GetNextEnvelope(executorId, queue, state.EdgeMap, logger);
|
||||||
|
WorkflowExecutorInfo executorInfo = CreateExecutorInfo(executorId, state.ExecutorBindings);
|
||||||
|
|
||||||
|
inputs.Add(new ExecutorInput(executorId, envelope, executorInfo));
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DurableMessageEnvelope GetNextEnvelope(
|
||||||
|
string executorId,
|
||||||
|
Queue<DurableMessageEnvelope> queue,
|
||||||
|
DurableEdgeMap edgeMap,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
bool shouldAggregate = edgeMap.IsFanInExecutor(executorId) && queue.Count > 1;
|
||||||
|
|
||||||
|
return shouldAggregate
|
||||||
|
? AggregateQueueMessages(queue, executorId, logger)
|
||||||
|
: queue.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregates all messages in a queue into a JSON array for Fan-In executors.
|
||||||
|
/// </summary>
|
||||||
|
private static DurableMessageEnvelope AggregateQueueMessages(
|
||||||
|
Queue<DurableMessageEnvelope> queue,
|
||||||
|
string executorId,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
List<string> messages = [];
|
||||||
|
List<string> sourceIds = [];
|
||||||
|
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
DurableMessageEnvelope envelope = queue.Dequeue();
|
||||||
|
messages.Add(envelope.Message);
|
||||||
|
|
||||||
|
if (envelope.SourceExecutorId is not null)
|
||||||
|
{
|
||||||
|
sourceIds.Add(envelope.SourceExecutorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logger.IsEnabled(LogLevel.Debug))
|
||||||
|
{
|
||||||
|
logger.LogFanInAggregated(executorId, messages.Count, string.Join(", ", sourceIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DurableMessageEnvelope
|
||||||
|
{
|
||||||
|
Message = SerializeToJsonArray(messages),
|
||||||
|
InputTypeName = typeof(string[]).FullName,
|
||||||
|
SourceExecutorId = sourceIds.Count > 0 ? string.Join(",", sourceIds) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes results from a superstep, updating state and routing messages to successors.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><c>true</c> if a halt was requested by any executor; otherwise, <c>false</c>.</returns>
|
||||||
|
private static bool ProcessSuperstepResults(
|
||||||
|
List<ExecutorInput> inputs,
|
||||||
|
string[] rawResults,
|
||||||
|
SuperstepState state,
|
||||||
|
TaskOrchestrationContext context,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
bool haltRequested = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < inputs.Count; i++)
|
||||||
|
{
|
||||||
|
string executorId = inputs[i].ExecutorId;
|
||||||
|
ExecutorResultInfo resultInfo = ParseActivityResult(rawResults[i]);
|
||||||
|
|
||||||
|
logger.LogExecutorResultReceived(executorId, resultInfo.Result.Length, resultInfo.SentMessages.Count);
|
||||||
|
|
||||||
|
state.LastResults[executorId] = resultInfo.Result;
|
||||||
|
|
||||||
|
// Merge state updates from activity into shared state
|
||||||
|
MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes);
|
||||||
|
|
||||||
|
// Accumulate events for custom status (streaming)
|
||||||
|
state.AccumulatedEvents.AddRange(resultInfo.Events);
|
||||||
|
|
||||||
|
// Check for halt request
|
||||||
|
haltRequested |= resultInfo.HaltRequested;
|
||||||
|
|
||||||
|
// Publish events for live streaming (skip during replay)
|
||||||
|
if (!context.IsReplaying)
|
||||||
|
{
|
||||||
|
PublishEventsToCustomStatus(context, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
return haltRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges state updates from an executor into the shared state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// When concurrent executors in the same superstep modify keys in the same scope,
|
||||||
|
/// last-write-wins semantics apply.
|
||||||
|
/// </remarks>
|
||||||
|
private static void MergeStateUpdates(
|
||||||
|
SuperstepState state,
|
||||||
|
Dictionary<string, string?> stateUpdates,
|
||||||
|
List<string> clearedScopes)
|
||||||
|
{
|
||||||
|
Dictionary<string, string> shared = state.SharedState;
|
||||||
|
|
||||||
|
ApplyClearedScopes(shared, clearedScopes);
|
||||||
|
|
||||||
|
// Apply individual state updates
|
||||||
|
foreach ((string key, string? value) in stateUpdates)
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
shared.Remove(key);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
shared[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes all keys belonging to the specified scopes from the shared state dictionary.
|
||||||
|
/// </summary>
|
||||||
|
private static void ApplyClearedScopes(Dictionary<string, string> shared, List<string> clearedScopes)
|
||||||
|
{
|
||||||
|
if (clearedScopes.Count == 0 || shared.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> keysToRemove = [];
|
||||||
|
|
||||||
|
foreach (string clearedScope in clearedScopes)
|
||||||
|
{
|
||||||
|
string scopePrefix = string.Concat(clearedScope, ":");
|
||||||
|
keysToRemove.Clear();
|
||||||
|
|
||||||
|
foreach (string key in shared.Keys)
|
||||||
|
{
|
||||||
|
if (key.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
keysToRemove.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string key in keysToRemove)
|
||||||
|
{
|
||||||
|
shared.Remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shared.Count == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes accumulated workflow events to the orchestration's custom status,
|
||||||
|
/// making them available to <see cref="DurableStreamingWorkflowRun"/> for live streaming.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Custom status is the only orchestration metadata readable by external clients while
|
||||||
|
/// the orchestration is still running. It is cleared by the framework on completion,
|
||||||
|
/// so events are also included in <see cref="DurableWorkflowResult"/> for final retrieval.
|
||||||
|
/// </remarks>
|
||||||
|
private static void PublishEventsToCustomStatus(TaskOrchestrationContext context, SuperstepState state)
|
||||||
|
{
|
||||||
|
DurableWorkflowCustomStatus customStatus = new()
|
||||||
|
{
|
||||||
|
Events = state.AccumulatedEvents
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pass the object directly — the framework's DataConverter handles serialization.
|
||||||
|
// Pre-serializing would cause double-serialization (string wrapped in JSON quotes).
|
||||||
|
context.SetCustomStatus(customStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes executor output (explicit messages or return value) to successor executors.
|
||||||
|
/// </summary>
|
||||||
|
private static void RouteOutputToSuccessors(
|
||||||
|
string executorId,
|
||||||
|
string result,
|
||||||
|
List<TypedPayload> sentMessages,
|
||||||
|
SuperstepState state,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
if (sentMessages.Count > 0)
|
||||||
|
{
|
||||||
|
// Only route messages that have content
|
||||||
|
foreach (TypedPayload message in sentMessages.Where(m => !string.IsNullOrEmpty(m.Data)))
|
||||||
|
{
|
||||||
|
state.EdgeMap.RouteMessage(executorId, message.Data!, message.TypeName, state.MessageQueues, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(result))
|
||||||
|
{
|
||||||
|
state.EdgeMap.RouteMessage(executorId, result, inputTypeName: null, state.MessageQueues, logger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes a list of messages into a JSON array.
|
||||||
|
/// </summary>
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing string array.")]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing string array.")]
|
||||||
|
private static string SerializeToJsonArray(List<string> messages)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a <see cref="WorkflowExecutorInfo"/> for the given executor ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown when the executor ID is not found in bindings.</exception>
|
||||||
|
private static WorkflowExecutorInfo CreateExecutorInfo(
|
||||||
|
string executorId,
|
||||||
|
Dictionary<string, ExecutorBinding> executorBindings)
|
||||||
|
{
|
||||||
|
if (!executorBindings.TryGetValue(executorId, out ExecutorBinding? binding))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Executor '{executorId}' not found in workflow bindings.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isAgentic = WorkflowAnalyzer.IsAgentExecutorType(binding.ExecutorType);
|
||||||
|
RequestPort? requestPort = (binding is RequestPortBinding rpb) ? rpb.Port : null;
|
||||||
|
Workflow? subWorkflow = (binding is SubworkflowBinding swb) ? swb.WorkflowInstance : null;
|
||||||
|
|
||||||
|
return new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the last non-empty result from executed steps, or empty string if none.
|
||||||
|
/// </summary>
|
||||||
|
private static string GetFinalResult(Dictionary<string, string> lastResults)
|
||||||
|
{
|
||||||
|
return lastResults.Values.LastOrDefault(value => !string.IsNullOrEmpty(value)) ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Output from an executor invocation, including its result,
|
||||||
|
/// messages, state updates, and emitted workflow events.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record ExecutorResultInfo(
|
||||||
|
string Result,
|
||||||
|
List<TypedPayload> SentMessages,
|
||||||
|
Dictionary<string, string?> StateUpdates,
|
||||||
|
List<string> ClearedScopes,
|
||||||
|
List<string> Events,
|
||||||
|
bool HaltRequested);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the raw activity result to extract result, messages, events, and state updates.
|
||||||
|
/// </summary>
|
||||||
|
private static ExecutorResultInfo ParseActivityResult(string rawResult)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(rawResult))
|
||||||
|
{
|
||||||
|
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DurableActivityOutput? output = JsonSerializer.Deserialize(
|
||||||
|
rawResult,
|
||||||
|
DurableWorkflowJsonContext.Default.DurableActivityOutput);
|
||||||
|
|
||||||
|
if (output is null || !HasMeaningfulContent(output))
|
||||||
|
{
|
||||||
|
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExecutorResultInfo(
|
||||||
|
output.Result ?? string.Empty,
|
||||||
|
output.SentMessages,
|
||||||
|
output.StateUpdates,
|
||||||
|
output.ClearedScopes,
|
||||||
|
output.Events,
|
||||||
|
output.HaltRequested);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the activity output contains meaningful content.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Distinguishes actual activity output from arbitrary JSON that deserialized
|
||||||
|
/// successfully but with all default/empty values.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool HasMeaningfulContent(DurableActivityOutput output)
|
||||||
|
{
|
||||||
|
return output.Result is not null
|
||||||
|
|| output.SentMessages.Count > 0
|
||||||
|
|| output.Events.Count > 0
|
||||||
|
|| output.StateUpdates.Count > 0
|
||||||
|
|| output.ClearedScopes.Count > 0
|
||||||
|
|| output.HaltRequested;
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// Routing decision flow for a single edge.
|
||||||
|
// Example: the B→D edge from a workflow like below:
|
||||||
|
//
|
||||||
|
// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview)
|
||||||
|
// │ ▲
|
||||||
|
// └──► [D] ──────┘
|
||||||
|
//
|
||||||
|
// (condition: x => x.NeedsReview, _sourceOutputType: typeof(Order))
|
||||||
|
//
|
||||||
|
// RouteMessage(envelope) envelope.Message = "{\"NeedsReview\":true, ...}"
|
||||||
|
// │
|
||||||
|
// ▼
|
||||||
|
// Has condition? ──── No ────► Enqueue to sink's queue
|
||||||
|
// │
|
||||||
|
// Yes (B→D has one)
|
||||||
|
// │
|
||||||
|
// ▼
|
||||||
|
// Deserialize message JSON string → Order object using _sourceOutputType
|
||||||
|
// │
|
||||||
|
// ▼
|
||||||
|
// Evaluate _condition(order) order => order.NeedsReview
|
||||||
|
// │
|
||||||
|
// ┌──┴──┐
|
||||||
|
// true false
|
||||||
|
// │ │
|
||||||
|
// ▼ └──► Skip (log and return, D will not run)
|
||||||
|
// Enqueue to
|
||||||
|
// D's queue
|
||||||
|
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes messages from a source executor to a single target executor with optional condition evaluation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Created by <see cref="DurableEdgeMap"/> during construction — one instance per (source, sink) edge.
|
||||||
|
/// When an edge has a condition (e.g., <c>order => order.Total > 1000</c>), the router deserialises
|
||||||
|
/// the serialised JSON message back to the source executor's output type so the condition delegate
|
||||||
|
/// can evaluate it against strongly-typed properties. If the condition returns <c>false</c>, the
|
||||||
|
/// message is not forwarded and the target executor will not run for this edge.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// For sources with multiple successors, individual <see cref="DurableDirectEdgeRouter"/> instances
|
||||||
|
/// are wrapped in a <see cref="DurableFanOutEdgeRouter"/> so a single <c>RouteMessage</c> call
|
||||||
|
/// fans the same message out to all targets, each evaluating its own condition independently.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DurableDirectEdgeRouter : IDurableEdgeRouter
|
||||||
|
{
|
||||||
|
private readonly string _sourceId;
|
||||||
|
private readonly string _sinkId;
|
||||||
|
private readonly Func<object?, bool>? _condition;
|
||||||
|
private readonly Type? _sourceOutputType;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="DurableDirectEdgeRouter"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourceId">The source executor ID.</param>
|
||||||
|
/// <param name="sinkId">The target executor ID.</param>
|
||||||
|
/// <param name="condition">Optional condition function to evaluate before routing.</param>
|
||||||
|
/// <param name="sourceOutputType">The output type of the source executor for deserialization.</param>
|
||||||
|
internal DurableDirectEdgeRouter(
|
||||||
|
string sourceId,
|
||||||
|
string sinkId,
|
||||||
|
Func<object?, bool>? condition,
|
||||||
|
Type? sourceOutputType)
|
||||||
|
{
|
||||||
|
this._sourceId = sourceId;
|
||||||
|
this._sinkId = sinkId;
|
||||||
|
this._condition = condition;
|
||||||
|
this._sourceOutputType = sourceOutputType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RouteMessage(
|
||||||
|
DurableMessageEnvelope envelope,
|
||||||
|
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
if (this._condition is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object? messageObj = DeserializeForCondition(envelope.Message, this._sourceOutputType);
|
||||||
|
if (!this._condition(messageObj))
|
||||||
|
{
|
||||||
|
logger.LogEdgeConditionFalse(this._sourceId, this._sinkId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogEdgeConditionEvaluationFailed(ex, this._sourceId, this._sinkId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogEdgeRoutingMessage(this._sourceId, this._sinkId);
|
||||||
|
EnqueueMessage(messageQueues, this._sinkId, envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes a JSON message to an object for condition evaluation.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Messages travel through the durable workflow as serialized JSON strings, but condition
|
||||||
|
/// delegates need typed objects to evaluate (e.g., order => order.Status == "Approved").
|
||||||
|
/// This method converts the JSON back to an object the condition delegate can evaluate.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="json">The JSON string representation of the message.</param>
|
||||||
|
/// <param name="targetType">
|
||||||
|
/// The expected type of the message. When provided, enables strongly-typed deserialization
|
||||||
|
/// so the condition function receives the correct type to evaluate against.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// The deserialized object, or null if the JSON is empty.
|
||||||
|
/// </returns>
|
||||||
|
/// <exception cref="JsonException">Thrown when the JSON is invalid or cannot be deserialized to the target type.</exception>
|
||||||
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
|
||||||
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
|
||||||
|
private static object? DeserializeForCondition(string json, Type? targetType)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(json))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we know the source executor's output type, deserialize to that specific type
|
||||||
|
// so the condition function can access strongly-typed properties.
|
||||||
|
// Otherwise, deserialize as a generic object for basic inspection.
|
||||||
|
return targetType is null
|
||||||
|
? JsonSerializer.Deserialize<object>(json)
|
||||||
|
: JsonSerializer.Deserialize(json, targetType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnqueueMessage(
|
||||||
|
Dictionary<string, Queue<DurableMessageEnvelope>> queues,
|
||||||
|
string executorId,
|
||||||
|
DurableMessageEnvelope envelope)
|
||||||
|
{
|
||||||
|
if (!queues.TryGetValue(executorId, out Queue<DurableMessageEnvelope>? queue))
|
||||||
|
{
|
||||||
|
queue = new Queue<DurableMessageEnvelope>();
|
||||||
|
queues[executorId] = queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.Enqueue(envelope);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// How WorkflowGraphInfo maps to DurableEdgeMap at runtime.
|
||||||
|
// For a workflow like below:
|
||||||
|
//
|
||||||
|
// [A] ──► [B] ──► [C] ──► [E]
|
||||||
|
// │ ▲
|
||||||
|
// └──► [D] ──────┘
|
||||||
|
// (condition: x => x.NeedsReview)
|
||||||
|
//
|
||||||
|
// WorkflowGraphInfo DurableEdgeMap
|
||||||
|
// ┌──────────────────────────┐ ┌──────────────────────────────────────┐
|
||||||
|
// │ Successors: │ │ _routersBySource: │
|
||||||
|
// │ A → [B] │──constructs──►│ A → [DirectRouter(A→B)] │
|
||||||
|
// │ B → [C, D] │ │ B → [FanOutRouter([C, D])] │
|
||||||
|
// │ C → [E] │ │ C → [DirectRouter(C→E)] │
|
||||||
|
// │ D → [E] │ │ D → [DirectRouter(D→E)] │
|
||||||
|
// └──────────────────────────┘ │ │
|
||||||
|
// ┌──────────────────────────┐ │ _predecessorCounts: │
|
||||||
|
// │ Predecessors: │ │ A → 0 │
|
||||||
|
// │ E → [C, D] (fan-in!) │──constructs──►│ B → 1, C → 1, D → 1 │
|
||||||
|
// └──────────────────────────┘ │ E → 2 ◄── IsFanInExecutor = true │
|
||||||
|
// └──────────────────────────────────────┘
|
||||||
|
//
|
||||||
|
// Usage during superstep execution (continuing the example):
|
||||||
|
//
|
||||||
|
// 1. EnqueueInitialInput(msg) ──► MessageQueues["A"].Enqueue(envelope)
|
||||||
|
//
|
||||||
|
// 2. After B completes, RouteMessage("B", resultB) ──► _routersBySource["B"]
|
||||||
|
// │
|
||||||
|
// ▼
|
||||||
|
// FanOutRouter (B has 2 successors)
|
||||||
|
// ├─► DirectRouter(B→C) ──► no condition ──► enqueue to C
|
||||||
|
// └─► DirectRouter(B→D) ──► evaluate x => x.NeedsReview ──► enqueue to D (or skip)
|
||||||
|
//
|
||||||
|
// 3. Before superstep 4, IsFanInExecutor("E") returns true (count=2)
|
||||||
|
// → CollectExecutorInputs aggregates C and D results into ["resultC","resultD"]
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages message routing through workflow edges for durable orchestrations.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the durable equivalent of <c>EdgeMap</c> in the in-process runner.
|
||||||
|
/// It is constructed from <see cref="WorkflowGraphInfo"/> (produced by <see cref="WorkflowAnalyzer.BuildGraphInfo"/>)
|
||||||
|
/// and converts the static graph structure into an active routing layer used during superstep execution.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What it stores:</b>
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description><c>_routersBySource</c> — For each source executor, a list of <see cref="IDurableEdgeRouter"/> instances
|
||||||
|
/// that know how to deliver messages to successor executors. When a source has multiple successors, a single
|
||||||
|
/// <see cref="DurableFanOutEdgeRouter"/> wraps the individual <see cref="DurableDirectEdgeRouter"/> instances.</description></item>
|
||||||
|
/// <item><description><c>_predecessorCounts</c> — The number of predecessors for each executor, used to detect
|
||||||
|
/// fan-in points where multiple incoming messages should be aggregated before execution.</description></item>
|
||||||
|
/// <item><description><c>_startExecutorId</c> — The entry-point executor that receives the initial workflow input.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// <b>How it is used during execution:</b>
|
||||||
|
/// </para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description><see cref="EnqueueInitialInput"/> seeds the start executor's queue before the first superstep.</description></item>
|
||||||
|
/// <item><description>After each superstep, <c>DurableWorkflowRunner.RouteOutputToSuccessors</c> calls
|
||||||
|
/// <see cref="RouteMessage"/> which looks up the routers for the completed executor and forwards the
|
||||||
|
/// result to successor queues. Each router may evaluate an edge condition before enqueueing.</description></item>
|
||||||
|
/// <item><description><see cref="IsFanInExecutor"/> is checked during input collection to decide whether
|
||||||
|
/// to aggregate multiple queued messages into a single JSON array before dispatching.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DurableEdgeMap
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, List<IDurableEdgeRouter>> _routersBySource = [];
|
||||||
|
private readonly Dictionary<string, int> _predecessorCounts = [];
|
||||||
|
private readonly string _startExecutorId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="DurableEdgeMap"/> from workflow graph info.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="graphInfo">The workflow graph information containing routing structure.</param>
|
||||||
|
internal DurableEdgeMap(WorkflowGraphInfo graphInfo)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(graphInfo);
|
||||||
|
|
||||||
|
this._startExecutorId = graphInfo.StartExecutorId;
|
||||||
|
|
||||||
|
// Build edge routers for each source executor
|
||||||
|
foreach (KeyValuePair<string, List<string>> entry in graphInfo.Successors)
|
||||||
|
{
|
||||||
|
string sourceId = entry.Key;
|
||||||
|
List<string> successorIds = entry.Value;
|
||||||
|
|
||||||
|
if (successorIds.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
graphInfo.ExecutorOutputTypes.TryGetValue(sourceId, out Type? sourceOutputType);
|
||||||
|
|
||||||
|
List<IDurableEdgeRouter> routers = [];
|
||||||
|
foreach (string sinkId in successorIds)
|
||||||
|
{
|
||||||
|
graphInfo.EdgeConditions.TryGetValue((sourceId, sinkId), out Func<object?, bool>? condition);
|
||||||
|
|
||||||
|
routers.Add(new DurableDirectEdgeRouter(sourceId, sinkId, condition, sourceOutputType));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If multiple successors, wrap in a fan-out router
|
||||||
|
if (routers.Count > 1)
|
||||||
|
{
|
||||||
|
this._routersBySource[sourceId] = [new DurableFanOutEdgeRouter(sourceId, routers)];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._routersBySource[sourceId] = routers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store predecessor counts for fan-in detection
|
||||||
|
foreach (KeyValuePair<string, List<string>> entry in graphInfo.Predecessors)
|
||||||
|
{
|
||||||
|
this._predecessorCounts[entry.Key] = entry.Value.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes a message from a source executor to its successors.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called by <c>DurableWorkflowRunner.RouteOutputToSuccessors</c> after each superstep.
|
||||||
|
/// Wraps the message in a <see cref="DurableMessageEnvelope"/> and delegates to the
|
||||||
|
/// appropriate <see cref="IDurableEdgeRouter"/>(s) for the source executor. Each router
|
||||||
|
/// may evaluate an edge condition and, if satisfied, enqueue the envelope into the
|
||||||
|
/// target executor's message queue for the next superstep.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="sourceId">The source executor ID.</param>
|
||||||
|
/// <param name="message">The serialized message to route.</param>
|
||||||
|
/// <param name="inputTypeName">The type name of the message.</param>
|
||||||
|
/// <param name="messageQueues">The message queues to enqueue messages into.</param>
|
||||||
|
/// <param name="logger">The logger for tracing.</param>
|
||||||
|
internal void RouteMessage(
|
||||||
|
string sourceId,
|
||||||
|
string message,
|
||||||
|
string? inputTypeName,
|
||||||
|
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
if (!this._routersBySource.TryGetValue(sourceId, out List<IDurableEdgeRouter>? routers))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DurableMessageEnvelope envelope = DurableMessageEnvelope.Create(message, inputTypeName, sourceId);
|
||||||
|
|
||||||
|
foreach (IDurableEdgeRouter router in routers)
|
||||||
|
{
|
||||||
|
router.RouteMessage(envelope, messageQueues, logger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues the initial workflow input to the start executor.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The serialized initial input message.</param>
|
||||||
|
/// <param name="messageQueues">The message queues to enqueue into.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method is used only at workflow startup to provide input to the first executor.
|
||||||
|
/// No input type hint is required because the start executor determines its expected input type from its own <c>InputTypes</c> configuration.
|
||||||
|
/// </remarks>
|
||||||
|
internal void EnqueueInitialInput(
|
||||||
|
string message,
|
||||||
|
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues)
|
||||||
|
{
|
||||||
|
DurableMessageEnvelope envelope = DurableMessageEnvelope.Create(message, inputTypeName: null);
|
||||||
|
EnqueueMessage(messageQueues, this._startExecutorId, envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if an executor is a fan-in point (has multiple predecessors).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="executorId">The executor ID to check.</param>
|
||||||
|
/// <returns><c>true</c> if the executor has multiple predecessors; otherwise, <c>false</c>.</returns>
|
||||||
|
internal bool IsFanInExecutor(string executorId)
|
||||||
|
{
|
||||||
|
return this._predecessorCounts.TryGetValue(executorId, out int count) && count > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnqueueMessage(
|
||||||
|
Dictionary<string, Queue<DurableMessageEnvelope>> queues,
|
||||||
|
string executorId,
|
||||||
|
DurableMessageEnvelope envelope)
|
||||||
|
{
|
||||||
|
if (!queues.TryGetValue(executorId, out Queue<DurableMessageEnvelope>? queue))
|
||||||
|
{
|
||||||
|
queue = new Queue<DurableMessageEnvelope>();
|
||||||
|
queues[executorId] = queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.Enqueue(envelope);
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// Fan-out routing: one source message is forwarded to multiple targets.
|
||||||
|
// Example from a workflow like below:
|
||||||
|
//
|
||||||
|
// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview)
|
||||||
|
// │ ▲
|
||||||
|
// └──► [D] ──────┘
|
||||||
|
//
|
||||||
|
// B has two successors (C and D), so DurableEdgeMap wraps them:
|
||||||
|
//
|
||||||
|
// Executor B completes with resultB (type: Order)
|
||||||
|
// │
|
||||||
|
// ▼
|
||||||
|
// FanOutRouter(B)
|
||||||
|
// ├──► DirectRouter(B→C) ──► no condition ──► enqueue to C
|
||||||
|
// └──► DirectRouter(B→D) ──► x => x.NeedsReview ──► enqueue to D (or skip)
|
||||||
|
//
|
||||||
|
// Each DirectRouter independently evaluates its condition,
|
||||||
|
// so resultB always reaches C, but only reaches D if NeedsReview is true.
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes messages from a source executor to multiple target executors (fan-out pattern).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Created by <see cref="DurableEdgeMap"/> when a source executor has more than one successor.
|
||||||
|
/// Wraps the individual <see cref="DurableDirectEdgeRouter"/> instances and delegates
|
||||||
|
/// <see cref="RouteMessage"/> to each of them, so the same message is evaluated and
|
||||||
|
/// potentially enqueued for every target independently.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DurableFanOutEdgeRouter : IDurableEdgeRouter
|
||||||
|
{
|
||||||
|
private readonly string _sourceId;
|
||||||
|
private readonly List<IDurableEdgeRouter> _targetRouters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="DurableFanOutEdgeRouter"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sourceId">The source executor ID.</param>
|
||||||
|
/// <param name="targetRouters">The routers for each target executor.</param>
|
||||||
|
internal DurableFanOutEdgeRouter(string sourceId, List<IDurableEdgeRouter> targetRouters)
|
||||||
|
{
|
||||||
|
this._sourceId = sourceId;
|
||||||
|
this._targetRouters = targetRouters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void RouteMessage(
|
||||||
|
DurableMessageEnvelope envelope,
|
||||||
|
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
if (logger.IsEnabled(LogLevel.Debug))
|
||||||
|
{
|
||||||
|
logger.LogDebug("Fan-Out from {Source}: routing to {Count} targets", this._sourceId, this._targetRouters.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (IDurableEdgeRouter targetRouter in this._targetRouters)
|
||||||
|
{
|
||||||
|
targetRouter.RouteMessage(envelope, messageQueues, logger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the contract for routing messages through workflow edges in durable orchestrations.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Implementations include <see cref="DurableDirectEdgeRouter"/> for single-target routing
|
||||||
|
/// and <see cref="DurableFanOutEdgeRouter"/> for multi-target fan-out patterns.
|
||||||
|
/// </remarks>
|
||||||
|
internal interface IDurableEdgeRouter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Routes a message from the source executor to its target(s).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelope">The message envelope containing the message and metadata.</param>
|
||||||
|
/// <param name="messageQueues">The message queues to enqueue messages into.</param>
|
||||||
|
/// <param name="logger">The logger for tracing.</param>
|
||||||
|
void RouteMessage(
|
||||||
|
DurableMessageEnvelope envelope,
|
||||||
|
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
|
||||||
|
ILogger logger);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides a registry for executor bindings used in durable workflow orchestrations.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This registry enables lookup of executors by name, decoupled from specific workflow instances.
|
||||||
|
/// Executors are registered when workflows are added to <see cref="DurableWorkflowOptions"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ExecutorRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, ExecutorRegistration> _executors = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of registered executors.
|
||||||
|
/// </summary>
|
||||||
|
internal int Count => this._executors.Count;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to get an executor registration by name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="executorName">The executor name to look up.</param>
|
||||||
|
/// <param name="registration">When this method returns, contains the registration if found; otherwise, null.</param>
|
||||||
|
/// <returns><see langword="true"/> if the executor was found; otherwise, <see langword="false"/>.</returns>
|
||||||
|
internal bool TryGetExecutor(string executorName, [NotNullWhen(true)] out ExecutorRegistration? registration)
|
||||||
|
{
|
||||||
|
return this._executors.TryGetValue(executorName, out registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers an executor binding from a workflow.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="executorName">The executor name (without GUID suffix).</param>
|
||||||
|
/// <param name="executorId">The full executor ID (may include GUID suffix).</param>
|
||||||
|
/// <param name="workflow">The workflow containing the executor.</param>
|
||||||
|
internal void Register(string executorName, string executorId, Workflow workflow)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(executorName);
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(executorId);
|
||||||
|
ArgumentNullException.ThrowIfNull(workflow);
|
||||||
|
|
||||||
|
Dictionary<string, ExecutorBinding> bindings = workflow.ReflectExecutors();
|
||||||
|
if (!bindings.TryGetValue(executorId, out ExecutorBinding? binding))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Executor '{executorId}' not found in workflow.");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._executors.TryAdd(executorName, new ExecutorRegistration(executorId, binding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a registered executor with its binding information.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The <paramref name="ExecutorId"/> may differ from the registered name when the executor
|
||||||
|
/// ID includes an instance suffix (e.g., "ExecutorName_Guid").
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="ExecutorId">The full executor ID (may include instance suffix).</param>
|
||||||
|
/// <param name="Binding">The executor binding containing the factory and configuration.</param>
|
||||||
|
internal sealed record ExecutorRegistration(string ExecutorId, ExecutorBinding Binding)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an instance of the executor.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="runId">A unique identifier for the run context.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The created executor instance.</returns>
|
||||||
|
internal async ValueTask<Executor> CreateExecutorInstanceAsync(string runId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (this.Binding.FactoryAsync is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Cannot create executor '{this.ExecutorId}': Binding is a placeholder.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.Binding.FactoryAsync(runId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a workflow run that can be awaited for completion.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This interface extends <see cref="IWorkflowRun"/> to provide methods for waiting
|
||||||
|
/// until the workflow execution completes. Not all workflow runners support this capability.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Use pattern matching to check if a workflow run supports awaiting:
|
||||||
|
/// <code>
|
||||||
|
/// IWorkflowRun run = await client.RunAsync(workflow, input);
|
||||||
|
/// if (run is IAwaitableWorkflowRun awaitableRun)
|
||||||
|
/// {
|
||||||
|
/// string? result = await awaitableRun.WaitForCompletionAsync<string>();
|
||||||
|
/// }
|
||||||
|
/// </code>
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IAwaitableWorkflowRun : IWorkflowRun
|
||||||
|
{
|
||||||
|
/// <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>
|
||||||
|
ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a workflow run that supports streaming workflow events as they occur.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This interface defines the contract for streaming workflow runs in durable execution
|
||||||
|
/// environments. Implementations provide real-time access to workflow events.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IStreamingWorkflowRun
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique identifier for the run.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This identifier can be provided at the start of the run, or auto-generated.
|
||||||
|
/// For durable runs, this corresponds to the orchestration instance ID.
|
||||||
|
/// </remarks>
|
||||||
|
string RunId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asynchronously streams workflow events as they occur during workflow execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method yields <see cref="WorkflowEvent"/> instances in real time as the workflow
|
||||||
|
/// progresses. The stream completes when the workflow completes, fails, or is terminated.
|
||||||
|
/// Events are delivered in the order they are raised.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="cancellationToken">
|
||||||
|
/// A <see cref="CancellationToken"/> that can be used to cancel the streaming operation.
|
||||||
|
/// If cancellation is requested, the stream will end and no further events will be yielded.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>
|
||||||
|
/// An asynchronous stream of <see cref="WorkflowEvent"/> objects representing significant
|
||||||
|
/// workflow state changes.
|
||||||
|
/// </returns>
|
||||||
|
IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines a client for running and managing workflow executions.
|
||||||
|
/// </summary>
|
||||||
|
public interface IWorkflowClient
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a workflow 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="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||||
|
/// <returns>An <see cref="IWorkflowRun"/> that can be used to monitor the workflow execution.</returns>
|
||||||
|
ValueTask<IWorkflowRun> RunAsync<TInput>(
|
||||||
|
Workflow workflow,
|
||||||
|
TInput input,
|
||||||
|
string? runId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
where TInput : notnull;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a workflow with string input and returns a handle to monitor its execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workflow">The workflow to execute.</param>
|
||||||
|
/// <param name="input">The string input to pass to the workflow.</param>
|
||||||
|
/// <param name="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||||
|
/// <returns>An <see cref="IWorkflowRun"/> that can be used to monitor the workflow execution.</returns>
|
||||||
|
ValueTask<IWorkflowRun> RunAsync(
|
||||||
|
Workflow workflow,
|
||||||
|
string input,
|
||||||
|
string? runId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a workflow and returns a streaming handle to watch events in real-time.
|
||||||
|
/// </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="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||||
|
/// <returns>An <see cref="IStreamingWorkflowRun"/> that can be used to stream workflow events.</returns>
|
||||||
|
ValueTask<IStreamingWorkflowRun> StreamAsync<TInput>(
|
||||||
|
Workflow workflow,
|
||||||
|
TInput input,
|
||||||
|
string? runId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
where TInput : notnull;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a workflow with string input and returns a streaming handle to watch events in real-time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workflow">The workflow to execute.</param>
|
||||||
|
/// <param name="input">The string input to pass to the workflow.</param>
|
||||||
|
/// <param name="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
||||||
|
/// <returns>An <see cref="IStreamingWorkflowRun"/> that can be used to stream workflow events.</returns>
|
||||||
|
ValueTask<IStreamingWorkflowRun> StreamAsync(
|
||||||
|
Workflow workflow,
|
||||||
|
string input,
|
||||||
|
string? runId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a running instance of a workflow.
|
||||||
|
/// </summary>
|
||||||
|
public interface IWorkflowRun
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique identifier for the run.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This identifier can be provided at the start of the run, or auto-generated.
|
||||||
|
/// For durable runs, this corresponds to the orchestration instance ID.
|
||||||
|
/// </remarks>
|
||||||
|
string RunId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all events that have been emitted by the workflow.
|
||||||
|
/// </summary>
|
||||||
|
IEnumerable<WorkflowEvent> OutgoingEvents { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the number of events emitted since the last access to <see cref="NewEvents"/>.
|
||||||
|
/// </summary>
|
||||||
|
int NewEventCount { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all events emitted by the workflow since the last access to this property.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Each access to this property advances the bookmark, so subsequent accesses
|
||||||
|
/// will only return events emitted after the previous access.
|
||||||
|
/// </remarks>
|
||||||
|
IEnumerable<WorkflowEvent> NewEvents { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pairs a JSON-serialized payload with its assembly-qualified type name
|
||||||
|
/// for type-safe deserialization across activity boundaries.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class TypedPayload
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the assembly-qualified type name of the payload.
|
||||||
|
/// </summary>
|
||||||
|
public string? TypeName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the serialized payload data as JSON.
|
||||||
|
/// </summary>
|
||||||
|
public string? Data { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes workflow structure to extract executor metadata and build graph information
|
||||||
|
/// for message-driven execution.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WorkflowAnalyzer
|
||||||
|
{
|
||||||
|
private const string AgentExecutorTypeName = "AIAgentHostExecutor";
|
||||||
|
private const string AgentAssemblyPrefix = "Microsoft.Agents.AI";
|
||||||
|
private const string ExecutorTypePrefix = "Executor";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a workflow instance and returns a list of executors with their metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workflow">The workflow instance to analyze.</param>
|
||||||
|
/// <returns>A list of executor information in workflow order.</returns>
|
||||||
|
internal static List<WorkflowExecutorInfo> GetExecutorsFromWorkflowInOrder(Workflow workflow)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflow);
|
||||||
|
|
||||||
|
return workflow.ReflectExecutors()
|
||||||
|
.Select(kvp => CreateExecutorInfo(kvp.Key, kvp.Value))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the workflow graph information needed for message-driven execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Extracts routing information including successors, predecessors, edge conditions,
|
||||||
|
/// and output types. Supports cyclic workflows through message-driven superstep execution.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The returned <see cref="WorkflowGraphInfo"/> is consumed by <c>DurableEdgeMap</c>
|
||||||
|
/// to build the runtime routing layer:
|
||||||
|
/// <c>Successors</c> become <c>IDurableEdgeRouter</c> instances,
|
||||||
|
/// <c>Predecessors</c> become fan-in counts, and
|
||||||
|
/// <c>EdgeConditions</c> / <c>ExecutorOutputTypes</c> are passed into
|
||||||
|
/// <c>DurableDirectEdgeRouter</c> for conditional routing with typed deserialization.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="workflow">The workflow instance to analyze.</param>
|
||||||
|
/// <returns>A graph info object containing routing information.</returns>
|
||||||
|
internal static WorkflowGraphInfo BuildGraphInfo(Workflow workflow)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflow);
|
||||||
|
|
||||||
|
Dictionary<string, ExecutorBinding> executors = workflow.ReflectExecutors();
|
||||||
|
|
||||||
|
WorkflowGraphInfo graphInfo = new()
|
||||||
|
{
|
||||||
|
StartExecutorId = workflow.StartExecutorId
|
||||||
|
};
|
||||||
|
|
||||||
|
InitializeExecutorMappings(graphInfo, executors);
|
||||||
|
PopulateGraphFromEdges(graphInfo, workflow.Edges);
|
||||||
|
|
||||||
|
return graphInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the specified executor type is an agentic executor.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="executorType">The executor type to check.</param>
|
||||||
|
/// <returns><c>true</c> if the executor is an agentic executor; otherwise, <c>false</c>.</returns>
|
||||||
|
internal static bool IsAgentExecutorType(Type executorType)
|
||||||
|
{
|
||||||
|
string typeName = executorType.FullName ?? executorType.Name;
|
||||||
|
string assemblyName = executorType.Assembly.GetName().Name ?? string.Empty;
|
||||||
|
|
||||||
|
return typeName.Contains(AgentExecutorTypeName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& assemblyName.Contains(AgentAssemblyPrefix, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a <see cref="WorkflowExecutorInfo"/> from an executor binding.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="executorId">The unique identifier of the executor.</param>
|
||||||
|
/// <param name="binding">The executor binding containing type and configuration information.</param>
|
||||||
|
/// <returns>A new <see cref="WorkflowExecutorInfo"/> instance with extracted metadata.</returns>
|
||||||
|
private static WorkflowExecutorInfo CreateExecutorInfo(string executorId, ExecutorBinding binding)
|
||||||
|
{
|
||||||
|
bool isAgentic = IsAgentExecutorType(binding.ExecutorType);
|
||||||
|
RequestPort? requestPort = (binding is RequestPortBinding rpb) ? rpb.Port : null;
|
||||||
|
Workflow? subWorkflow = (binding is SubworkflowBinding swb) ? swb.WorkflowInstance : null;
|
||||||
|
|
||||||
|
return new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the graph info with empty collections for each executor.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="graphInfo">The graph info to initialize.</param>
|
||||||
|
/// <param name="executors">The dictionary of executor bindings.</param>
|
||||||
|
private static void InitializeExecutorMappings(WorkflowGraphInfo graphInfo, Dictionary<string, ExecutorBinding> executors)
|
||||||
|
{
|
||||||
|
foreach ((string executorId, ExecutorBinding binding) in executors)
|
||||||
|
{
|
||||||
|
graphInfo.Successors[executorId] = [];
|
||||||
|
graphInfo.Predecessors[executorId] = [];
|
||||||
|
graphInfo.ExecutorOutputTypes[executorId] = GetExecutorOutputType(binding.ExecutorType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Populates the graph info with successor/predecessor relationships and edge conditions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="graphInfo">The graph info to populate.</param>
|
||||||
|
/// <param name="edges">The dictionary of edges grouped by source executor ID.</param>
|
||||||
|
private static void PopulateGraphFromEdges(WorkflowGraphInfo graphInfo, Dictionary<string, HashSet<Edge>> edges)
|
||||||
|
{
|
||||||
|
foreach ((string sourceId, HashSet<Edge> edgeSet) in edges)
|
||||||
|
{
|
||||||
|
List<string> successors = graphInfo.Successors[sourceId];
|
||||||
|
|
||||||
|
foreach (Edge edge in edgeSet)
|
||||||
|
{
|
||||||
|
AddSuccessorsFromEdge(graphInfo, sourceId, edge, successors);
|
||||||
|
TryAddEdgeCondition(graphInfo, edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds successor relationships from an edge to the graph info.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="graphInfo">The graph info to update.</param>
|
||||||
|
/// <param name="sourceId">The source executor ID.</param>
|
||||||
|
/// <param name="edge">The edge containing connection information.</param>
|
||||||
|
/// <param name="successors">The list of successors to append to.</param>
|
||||||
|
private static void AddSuccessorsFromEdge(
|
||||||
|
WorkflowGraphInfo graphInfo,
|
||||||
|
string sourceId,
|
||||||
|
Edge edge,
|
||||||
|
List<string> successors)
|
||||||
|
{
|
||||||
|
foreach (string sinkId in edge.Data.Connection.SinkIds)
|
||||||
|
{
|
||||||
|
if (!graphInfo.Successors.ContainsKey(sinkId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
successors.Add(sinkId);
|
||||||
|
graphInfo.Predecessors[sinkId].Add(sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts and adds an edge condition to the graph info if present.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="graphInfo">The graph info to update.</param>
|
||||||
|
/// <param name="edge">The edge that may contain a condition.</param>
|
||||||
|
private static void TryAddEdgeCondition(WorkflowGraphInfo graphInfo, Edge edge)
|
||||||
|
{
|
||||||
|
DirectEdgeData? directEdge = edge.DirectEdgeData;
|
||||||
|
|
||||||
|
if (directEdge?.Condition is not null)
|
||||||
|
{
|
||||||
|
graphInfo.EdgeConditions[(directEdge.SourceId, directEdge.SinkId)] = directEdge.Condition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the output type from an executor type by walking the inheritance chain.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="executorType">The executor type to analyze.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The TOutput type for Executor<TInput, TOutput>,
|
||||||
|
/// or <c>null</c> for Executor<TInput> (void output) or non-executor types.
|
||||||
|
/// </returns>
|
||||||
|
private static Type? GetExecutorOutputType(Type executorType)
|
||||||
|
{
|
||||||
|
Type? currentType = executorType;
|
||||||
|
|
||||||
|
while (currentType is not null)
|
||||||
|
{
|
||||||
|
Type? outputType = TryExtractOutputTypeFromGeneric(currentType);
|
||||||
|
if (outputType is not null || IsVoidExecutorType(currentType))
|
||||||
|
{
|
||||||
|
return outputType;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentType = currentType.BaseType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to extract the output type from a generic executor type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The type to inspect.</param>
|
||||||
|
/// <returns>The TOutput type if this is an Executor<TInput, TOutput>; otherwise, <c>null</c>.</returns>
|
||||||
|
private static Type? TryExtractOutputTypeFromGeneric(Type type)
|
||||||
|
{
|
||||||
|
if (!type.IsGenericType)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Type genericDefinition = type.GetGenericTypeDefinition();
|
||||||
|
Type[] genericArgs = type.GetGenericArguments();
|
||||||
|
|
||||||
|
bool isExecutorType = genericDefinition.Name.StartsWith(ExecutorTypePrefix, StringComparison.Ordinal);
|
||||||
|
if (!isExecutorType)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executor<TInput, TOutput> - return TOutput
|
||||||
|
if (genericArgs.Length == 2)
|
||||||
|
{
|
||||||
|
return genericArgs[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the type is a void-returning executor (Executor<TInput>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The type to check.</param>
|
||||||
|
/// <returns><c>true</c> if this is an Executor with a single type parameter; otherwise, <c>false</c>.</returns>
|
||||||
|
private static bool IsVoidExecutorType(Type type)
|
||||||
|
{
|
||||||
|
if (!type.IsGenericType)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Type genericDefinition = type.GetGenericTypeDefinition();
|
||||||
|
Type[] genericArgs = type.GetGenericArguments();
|
||||||
|
|
||||||
|
// Executor<TInput> with 1 type parameter indicates void return
|
||||||
|
return genericArgs.Length == 1
|
||||||
|
&& genericDefinition.Name.StartsWith(ExecutorTypePrefix, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an executor in the workflow with its metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ExecutorId">The unique identifier of the executor.</param>
|
||||||
|
/// <param name="IsAgenticExecutor">Indicates whether this executor is an agentic executor.</param>
|
||||||
|
/// <param name="RequestPort">The request port if this executor is a request port executor; otherwise, null.</param>
|
||||||
|
/// <param name="SubWorkflow">The sub-workflow if this executor is a sub-workflow executor; otherwise, null.</param>
|
||||||
|
internal sealed record WorkflowExecutorInfo(
|
||||||
|
string ExecutorId,
|
||||||
|
bool IsAgenticExecutor,
|
||||||
|
RequestPort? RequestPort = null,
|
||||||
|
Workflow? SubWorkflow = 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>
|
||||||
|
/// Gets a value indicating whether this executor is a sub-workflow executor.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsSubworkflowExecutor => this.SubWorkflow is not null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
// Example: Given this workflow graph with a fan-out from B and a fan-in at E,
|
||||||
|
// plus a conditional edge from B to D:
|
||||||
|
//
|
||||||
|
// [A] ──► [B] ──► [C] ──► [E]
|
||||||
|
// │ ▲
|
||||||
|
// └──► [D] ──────┘
|
||||||
|
// (condition:
|
||||||
|
// x => x.NeedsReview)
|
||||||
|
//
|
||||||
|
// WorkflowAnalyzer.BuildGraphInfo() produces:
|
||||||
|
//
|
||||||
|
// StartExecutorId = "A"
|
||||||
|
//
|
||||||
|
// Successors (who does each executor send output to?):
|
||||||
|
// ┌──────────┬──────────────┐
|
||||||
|
// │ "A" │ ["B"] │
|
||||||
|
// │ "B" │ ["C", "D"] │ ◄── fan-out: B sends to both C and D
|
||||||
|
// │ "C" │ ["E"] │
|
||||||
|
// │ "D" │ ["E"] │
|
||||||
|
// │ "E" │ [] │ ◄── terminal: no successors
|
||||||
|
// └──────────┴──────────────┘
|
||||||
|
//
|
||||||
|
// Predecessors (who feeds into each executor?):
|
||||||
|
// ┌──────────┬──────────────┐
|
||||||
|
// │ "A" │ [] │ ◄── start: no predecessors
|
||||||
|
// │ "B" │ ["A"] │
|
||||||
|
// │ "C" │ ["B"] │
|
||||||
|
// │ "D" │ ["B"] │
|
||||||
|
// │ "E" │ ["C", "D"] │ ◄── fan-in: count=2, messages will be aggregated
|
||||||
|
// └──────────┴──────────────┘
|
||||||
|
//
|
||||||
|
// EdgeConditions (which edges have routing conditions?):
|
||||||
|
// ┌──────────────────┬──────────────────────────┐
|
||||||
|
// │ ("B", "D") │ x => x.NeedsReview │ ◄── D only receives if condition is true
|
||||||
|
// └──────────────────┴──────────────────────────┘
|
||||||
|
// (The B→C edge has no condition, so C always receives B's output.)
|
||||||
|
//
|
||||||
|
// ExecutorOutputTypes (what type does each executor return?):
|
||||||
|
// ┌──────────┬──────────────────┐
|
||||||
|
// │ "A" │ typeof(string) │ ◄── used by DurableDirectEdgeRouter to deserialize
|
||||||
|
// │ "B" │ typeof(Order) │ the JSON message for condition evaluation
|
||||||
|
// │ "C" │ typeof(Report) │
|
||||||
|
// │ "D" │ typeof(Report) │
|
||||||
|
// │ "E" │ typeof(string) │
|
||||||
|
// └──────────┴──────────────────┘
|
||||||
|
//
|
||||||
|
// DurableEdgeMap then consumes this to build the runtime routing layer.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the workflow graph structure needed for message-driven execution.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is a simplified representation that contains only the information needed
|
||||||
|
/// for routing messages between executors during superstep execution:
|
||||||
|
/// </para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>Successors for routing messages forward</description></item>
|
||||||
|
/// <item><description>Predecessors for detecting fan-in points</description></item>
|
||||||
|
/// <item><description>Edge conditions for conditional routing</description></item>
|
||||||
|
/// <item><description>Output types for deserialization during condition evaluation</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
[DebuggerDisplay("Start = {StartExecutorId}, Executors = {Successors.Count}")]
|
||||||
|
internal sealed class WorkflowGraphInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the starting executor ID for the workflow.
|
||||||
|
/// </summary>
|
||||||
|
public string StartExecutorId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps each executor ID to its successors (for message routing).
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, List<string>> Successors { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps each executor ID to its predecessors (for fan-in detection).
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, List<string>> Predecessors { 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; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides helper methods for workflow naming conventions used in durable orchestrations.
|
||||||
|
/// </summary>
|
||||||
|
internal static class WorkflowNamingHelper
|
||||||
|
{
|
||||||
|
internal const string OrchestrationFunctionPrefix = "dafx-";
|
||||||
|
private const char ExecutorIdSuffixSeparator = '_';
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a workflow name to its corresponding orchestration function name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="workflowName">The workflow name.</param>
|
||||||
|
/// <returns>The orchestration function name.</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when the workflow name is null or empty.</exception>
|
||||||
|
internal static string ToOrchestrationFunctionName(string workflowName)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(workflowName);
|
||||||
|
return string.Concat(OrchestrationFunctionPrefix, workflowName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts an orchestration function name back to its workflow name.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="orchestrationFunctionName">The orchestration function name.</param>
|
||||||
|
/// <returns>The workflow name.</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when the orchestration function name is null, empty, or doesn't have the expected prefix.</exception>
|
||||||
|
internal static string ToWorkflowName(string orchestrationFunctionName)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(orchestrationFunctionName);
|
||||||
|
|
||||||
|
if (!TryGetWorkflowName(orchestrationFunctionName, out string? workflowName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Orchestration function name '{orchestrationFunctionName}' does not have the expected '{OrchestrationFunctionPrefix}' prefix or is missing a workflow name.",
|
||||||
|
nameof(orchestrationFunctionName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflowName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the executor name from an executor ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For non-agentic executors, the executor ID is the same as the executor name (e.g., "OrderParser").
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// For agentic executors, the workflow builder appends a GUID suffix separated by an underscore
|
||||||
|
/// (e.g., "Physicist_8884e71021334ce49517fa2b17b1695b"). This method extracts just the name portion.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="executorId">The executor ID, which may contain a GUID suffix.</param>
|
||||||
|
/// <returns>The executor name without any GUID suffix.</returns>
|
||||||
|
/// <exception cref="ArgumentException">Thrown when the executor ID is null or empty.</exception>
|
||||||
|
internal static string GetExecutorName(string executorId)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(executorId);
|
||||||
|
|
||||||
|
int separatorIndex = executorId.IndexOf(ExecutorIdSuffixSeparator);
|
||||||
|
return separatorIndex > 0 ? executorId[..separatorIndex] : executorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetWorkflowName(string? orchestrationFunctionName, [NotNullWhen(true)] out string? workflowName)
|
||||||
|
{
|
||||||
|
workflowName = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(orchestrationFunctionName) ||
|
||||||
|
!orchestrationFunctionName.StartsWith(OrchestrationFunctionPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowName = orchestrationFunctionName[OrchestrationFunctionPrefix.Length..];
|
||||||
|
return workflowName.Length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,15 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
|
// Orchestration triggers use a different input binding mechanism than other triggers.
|
||||||
|
// The encoded orchestrator state is retrieved via BindInputAsync on the orchestration trigger binding,
|
||||||
|
// not through IFunctionInputBindingFeature. Handle this case first to avoid unnecessary binding work.
|
||||||
|
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint)
|
||||||
|
{
|
||||||
|
await ExecuteOrchestrationAsync(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Acquire the input binding feature (fail fast if missing rather than null-forgiving operator).
|
// Acquire the input binding feature (fail fast if missing rather than null-forgiving operator).
|
||||||
IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get<IFunctionInputBindingFeature>() ??
|
IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get<IFunctionInputBindingFeature>() ??
|
||||||
throw new InvalidOperationException("Function input binding feature is not available on the current context.");
|
throw new InvalidOperationException("Function input binding feature is not available on the current context.");
|
||||||
@@ -57,11 +66,39 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
|||||||
|
|
||||||
if (durableTaskClient is null)
|
if (durableTaskClient is null)
|
||||||
{
|
{
|
||||||
// This is not expected to happen since all built-in functions are
|
// This is not expected to happen since all built-in functions (other than orchestration triggers)
|
||||||
// expected to have a Durable Task client binding.
|
// are expected to have a Durable Task client binding.
|
||||||
throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}.");
|
throw new InvalidOperationException($"Durable Task client binding is missing for the invocation {context.InvocationId}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint)
|
||||||
|
{
|
||||||
|
if (httpRequestData == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrchestrationHttpTriggerAsync(
|
||||||
|
httpRequestData,
|
||||||
|
durableTaskClient,
|
||||||
|
context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint)
|
||||||
|
{
|
||||||
|
if (encodedEntityRequest is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Activity trigger input binding is missing for the invocation {context.InvocationId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync(
|
||||||
|
encodedEntityRequest,
|
||||||
|
durableTaskClient,
|
||||||
|
context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentHttpFunctionEntryPoint)
|
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentHttpFunctionEntryPoint)
|
||||||
{
|
{
|
||||||
if (httpRequestData == null)
|
if (httpRequestData == null)
|
||||||
@@ -70,9 +107,9 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync(
|
context.GetInvocationResult().Value = await BuiltInFunctions.RunAgentHttpAsync(
|
||||||
httpRequestData,
|
httpRequestData,
|
||||||
durableTaskClient,
|
durableTaskClient,
|
||||||
context);
|
context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,4 +141,32 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
|||||||
|
|
||||||
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
|
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async ValueTask ExecuteOrchestrationAsync(FunctionContext context)
|
||||||
|
{
|
||||||
|
BindingMetadata? orchestrationBinding = null;
|
||||||
|
foreach (BindingMetadata binding in context.FunctionDefinition.InputBindings.Values)
|
||||||
|
{
|
||||||
|
if (string.Equals(binding.Type, "orchestrationTrigger", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
orchestrationBinding = binding;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orchestrationBinding is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Orchestration trigger binding is missing for the invocation {context.InvocationId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
InputBindingData<object> triggerInputData = await context.BindInputAsync<object>(orchestrationBinding);
|
||||||
|
if (triggerInputData?.Value is not string encodedOrchestratorState)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Orchestration history state was either missing from the input or not a string value for invocation {context.InvocationId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.GetInvocationResult().Value = BuiltInFunctions.RunWorkflowOrchestration(
|
||||||
|
encodedOrchestratorState,
|
||||||
|
context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Agents.AI.DurableTask;
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
using Microsoft.Azure.Functions.Worker;
|
using Microsoft.Azure.Functions.Worker;
|
||||||
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
|
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
|
||||||
using Microsoft.Azure.Functions.Worker.Http;
|
using Microsoft.Azure.Functions.Worker.Http;
|
||||||
|
using Microsoft.DurableTask;
|
||||||
using Microsoft.DurableTask.Client;
|
using Microsoft.DurableTask.Client;
|
||||||
using Microsoft.DurableTask.Worker.Grpc;
|
using Microsoft.DurableTask.Worker.Grpc;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
@@ -21,6 +23,85 @@ internal static class BuiltInFunctions
|
|||||||
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
||||||
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
||||||
|
internal static readonly string RunWorkflowOrchestrationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestrationHttpTriggerAsync)}";
|
||||||
|
internal static readonly string RunWorkflowOrchestrationFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestration)}";
|
||||||
|
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
|
||||||
|
|
||||||
|
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
|
||||||
|
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
|
||||||
|
#pragma warning restore IL3000
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a workflow orchestration in response to an HTTP request.
|
||||||
|
/// The workflow name is derived from the function name by stripping the <see cref="HttpPrefix"/>.
|
||||||
|
/// Callers can optionally provide a custom run ID via the <c>runId</c> query string parameter
|
||||||
|
/// (e.g., <c>/api/workflows/MyWorkflow/run?runId=my-id</c>). If not provided, one is auto-generated.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<HttpResponseData> RunWorkflowOrchestrationHttpTriggerAsync(
|
||||||
|
[HttpTrigger] HttpRequestData req,
|
||||||
|
[DurableClient] DurableTaskClient client,
|
||||||
|
FunctionContext context)
|
||||||
|
{
|
||||||
|
string workflowName = context.FunctionDefinition.Name.Replace(HttpPrefix, string.Empty);
|
||||||
|
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
|
||||||
|
string? inputMessage = await req.ReadAsStringAsync();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(inputMessage))
|
||||||
|
{
|
||||||
|
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Workflow input cannot be empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
DurableWorkflowInput<string> orchestrationInput = new() { Input = inputMessage };
|
||||||
|
|
||||||
|
// Allow users to provide a custom run ID via query string; otherwise, auto-generate one.
|
||||||
|
string? instanceId = req.Query["runId"];
|
||||||
|
StartOrchestrationOptions? options = instanceId is not null ? new StartOrchestrationOptions(instanceId) : null;
|
||||||
|
string resolvedInstanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, orchestrationInput, options);
|
||||||
|
|
||||||
|
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||||
|
await response.WriteStringAsync($"Workflow orchestration started for {workflowName}. Orchestration runId: {resolvedInstanceId}");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes a workflow activity by looking up the registered executor and delegating to it.
|
||||||
|
/// The executor name is derived from the activity function name via <see cref="WorkflowNamingHelper"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static Task<string> InvokeWorkflowActivityAsync(
|
||||||
|
[ActivityTrigger] string input,
|
||||||
|
[DurableClient] DurableTaskClient durableTaskClient,
|
||||||
|
FunctionContext functionContext)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(input);
|
||||||
|
ArgumentNullException.ThrowIfNull(durableTaskClient);
|
||||||
|
ArgumentNullException.ThrowIfNull(functionContext);
|
||||||
|
|
||||||
|
string activityFunctionName = functionContext.FunctionDefinition.Name;
|
||||||
|
string executorName = WorkflowNamingHelper.ToWorkflowName(activityFunctionName);
|
||||||
|
|
||||||
|
DurableOptions durableOptions = functionContext.InstanceServices.GetRequiredService<DurableOptions>();
|
||||||
|
if (!durableOptions.Workflows.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Executor '{executorName}' not found in workflow options.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return DurableActivityExecutor.ExecuteAsync(registration.Binding, input, functionContext.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a workflow orchestration by delegating to <see cref="WorkflowOrchestrator"/>
|
||||||
|
/// via <see cref="GrpcOrchestrationRunner"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static string RunWorkflowOrchestration(
|
||||||
|
string encodedOrchestratorRequest,
|
||||||
|
FunctionContext functionContext)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(encodedOrchestratorRequest);
|
||||||
|
ArgumentNullException.ThrowIfNull(functionContext);
|
||||||
|
|
||||||
|
WorkflowOrchestrator orchestrator = new(functionContext.InstanceServices);
|
||||||
|
return GrpcOrchestrationRunner.LoadAndRun(encodedOrchestratorRequest, orchestrator, functionContext.InstanceServices);
|
||||||
|
}
|
||||||
|
|
||||||
// Exposed as an entity trigger via AgentFunctionsProvider
|
// Exposed as an entity trigger via AgentFunctionsProvider
|
||||||
public static Task<string> InvokeAgentAsync(
|
public static Task<string> InvokeAgentAsync(
|
||||||
|
|||||||
+3
-41
@@ -1,6 +1,5 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
using Microsoft.Agents.AI.DurableTask;
|
|
||||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -17,10 +16,6 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider;
|
private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider;
|
||||||
|
|
||||||
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
|
|
||||||
private static readonly string s_builtInFunctionsScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
|
|
||||||
#pragma warning restore IL3000
|
|
||||||
|
|
||||||
public DurableAgentFunctionMetadataTransformer(
|
public DurableAgentFunctionMetadataTransformer(
|
||||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents,
|
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents,
|
||||||
ILogger<DurableAgentFunctionMetadataTransformer> logger,
|
ILogger<DurableAgentFunctionMetadataTransformer> logger,
|
||||||
@@ -45,14 +40,14 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
|||||||
|
|
||||||
this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
|
this._logger.LogRegisteringTriggerForAgent(agentName, "entity");
|
||||||
|
|
||||||
original.Add(CreateAgentTrigger(agentName));
|
original.Add(FunctionMetadataFactory.CreateEntityTrigger(agentName));
|
||||||
|
|
||||||
if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
|
if (this._functionsAgentOptionsProvider.TryGet(agentName, out FunctionsAgentOptions? agentTriggerOptions))
|
||||||
{
|
{
|
||||||
if (agentTriggerOptions.HttpTrigger.IsEnabled)
|
if (agentTriggerOptions.HttpTrigger.IsEnabled)
|
||||||
{
|
{
|
||||||
this._logger.LogRegisteringTriggerForAgent(agentName, "http");
|
this._logger.LogRegisteringTriggerForAgent(agentName, "http");
|
||||||
original.Add(CreateHttpTrigger(agentName, $"agents/{agentName}/run"));
|
original.Add(FunctionMetadataFactory.CreateHttpTrigger(agentName, $"agents/{agentName}/run", BuiltInFunctions.RunAgentHttpFunctionEntryPoint));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agentTriggerOptions.McpToolTrigger.IsEnabled)
|
if (agentTriggerOptions.McpToolTrigger.IsEnabled)
|
||||||
@@ -65,39 +60,6 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DefaultFunctionMetadata CreateAgentTrigger(string name)
|
|
||||||
{
|
|
||||||
return new DefaultFunctionMetadata()
|
|
||||||
{
|
|
||||||
Name = AgentSessionId.ToEntityName(name),
|
|
||||||
Language = "dotnet-isolated",
|
|
||||||
RawBindings =
|
|
||||||
[
|
|
||||||
"""{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""",
|
|
||||||
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
|
||||||
],
|
|
||||||
EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
|
|
||||||
ScriptFile = s_builtInFunctionsScriptFile,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route)
|
|
||||||
{
|
|
||||||
return new DefaultFunctionMetadata()
|
|
||||||
{
|
|
||||||
Name = $"{BuiltInFunctions.HttpPrefix}{name}",
|
|
||||||
Language = "dotnet-isolated",
|
|
||||||
RawBindings =
|
|
||||||
[
|
|
||||||
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}",
|
|
||||||
"{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}",
|
|
||||||
"{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}"
|
|
||||||
],
|
|
||||||
EntryPoint = BuiltInFunctions.RunAgentHttpFunctionEntryPoint,
|
|
||||||
ScriptFile = s_builtInFunctionsScriptFile,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DefaultFunctionMetadata CreateMcpToolTrigger(string agentName, string? description)
|
private static DefaultFunctionMetadata CreateMcpToolTrigger(string agentName, string? description)
|
||||||
{
|
{
|
||||||
return new DefaultFunctionMetadata
|
return new DefaultFunctionMetadata
|
||||||
@@ -112,7 +74,7 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
|||||||
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
||||||
],
|
],
|
||||||
EntryPoint = BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint,
|
EntryPoint = BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint,
|
||||||
ScriptFile = s_builtInFunctionsScriptFile,
|
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides factory methods for creating common <see cref="DefaultFunctionMetadata"/> instances
|
||||||
|
/// used by function metadata transformers.
|
||||||
|
/// </summary>
|
||||||
|
internal static class FunctionMetadataFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates function metadata for an entity trigger function.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The base name used to derive the entity function name.</param>
|
||||||
|
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an entity trigger.</returns>
|
||||||
|
internal static DefaultFunctionMetadata CreateEntityTrigger(string name)
|
||||||
|
{
|
||||||
|
return new DefaultFunctionMetadata()
|
||||||
|
{
|
||||||
|
Name = AgentSessionId.ToEntityName(name),
|
||||||
|
Language = "dotnet-isolated",
|
||||||
|
RawBindings =
|
||||||
|
[
|
||||||
|
"""{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""",
|
||||||
|
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
||||||
|
],
|
||||||
|
EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
|
||||||
|
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates function metadata for an HTTP trigger function.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The base name used to derive the HTTP function name.</param>
|
||||||
|
/// <param name="route">The HTTP route for the trigger.</param>
|
||||||
|
/// <param name="entryPoint">The entry point method for the HTTP trigger.</param>
|
||||||
|
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an HTTP trigger.</returns>
|
||||||
|
internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint)
|
||||||
|
{
|
||||||
|
return new DefaultFunctionMetadata()
|
||||||
|
{
|
||||||
|
Name = $"{BuiltInFunctions.HttpPrefix}{name}",
|
||||||
|
Language = "dotnet-isolated",
|
||||||
|
RawBindings =
|
||||||
|
[
|
||||||
|
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}",
|
||||||
|
"{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}",
|
||||||
|
"{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}"
|
||||||
|
],
|
||||||
|
EntryPoint = entryPoint,
|
||||||
|
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates function metadata for an activity trigger function.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="functionName">The name of the activity function.</param>
|
||||||
|
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an activity trigger.</returns>
|
||||||
|
internal static DefaultFunctionMetadata CreateActivityTrigger(string functionName)
|
||||||
|
{
|
||||||
|
return new DefaultFunctionMetadata()
|
||||||
|
{
|
||||||
|
Name = functionName,
|
||||||
|
Language = "dotnet-isolated",
|
||||||
|
RawBindings =
|
||||||
|
[
|
||||||
|
"""{"name":"input","type":"activityTrigger","direction":"In","dataType":"String"}""",
|
||||||
|
"""{"name":"durableTaskClient","type":"durableClient","direction":"In"}"""
|
||||||
|
],
|
||||||
|
EntryPoint = BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint,
|
||||||
|
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates function metadata for an orchestration trigger function.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="functionName">The name of the orchestration function.</param>
|
||||||
|
/// <param name="entryPoint">The entry point method for the orchestration trigger.</param>
|
||||||
|
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an orchestration trigger.</returns>
|
||||||
|
internal static DefaultFunctionMetadata CreateOrchestrationTrigger(string functionName, string entryPoint)
|
||||||
|
{
|
||||||
|
return new DefaultFunctionMetadata()
|
||||||
|
{
|
||||||
|
Name = functionName,
|
||||||
|
Language = "dotnet-isolated",
|
||||||
|
RawBindings =
|
||||||
|
[
|
||||||
|
"""{"name":"context","type":"orchestrationTrigger","direction":"In"}"""
|
||||||
|
],
|
||||||
|
EntryPoint = entryPoint,
|
||||||
|
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -1,6 +1,7 @@
|
|||||||
// Copyright (c) Microsoft. All rights reserved.
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
using Microsoft.Agents.AI.DurableTask;
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
using Microsoft.Azure.Functions.Worker.Builder;
|
using Microsoft.Azure.Functions.Worker.Builder;
|
||||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -43,4 +44,87 @@ public static class FunctionsApplicationBuilderExtensions
|
|||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures durable options for the functions application, allowing customization of Durable Task framework
|
||||||
|
/// settings.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This method ensures that a single shared <see cref="DurableOptions"/> instance is used across all
|
||||||
|
/// configuration calls. If any workflows have been added, it configures the necessary orchestrations and registers
|
||||||
|
/// required middleware.</remarks>
|
||||||
|
/// <param name="builder">The functions application builder to configure. Cannot be null.</param>
|
||||||
|
/// <param name="configure">An action that configures the <see cref="DurableOptions"/> instance. Cannot be null.</param>
|
||||||
|
/// <returns>The updated <see cref="FunctionsApplicationBuilder"/> instance, enabling method chaining.</returns>
|
||||||
|
public static FunctionsApplicationBuilder ConfigureDurableOptions(
|
||||||
|
this FunctionsApplicationBuilder builder,
|
||||||
|
Action<DurableOptions> configure)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(builder);
|
||||||
|
ArgumentNullException.ThrowIfNull(configure);
|
||||||
|
|
||||||
|
builder.Services.ConfigureDurableOptions(configure);
|
||||||
|
|
||||||
|
// Read the shared options to check if workflows were added
|
||||||
|
DurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
|
||||||
|
|
||||||
|
if (sharedOptions.Workflows.Workflows.Count > 0)
|
||||||
|
{
|
||||||
|
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableWorkflowsFunctionMetadataTransformer>());
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureMiddlewareRegistered(builder);
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures durable workflow support for the specified Azure Functions application builder.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">The <see cref="FunctionsApplicationBuilder"/> instance to configure for durable workflows.</param>
|
||||||
|
/// <param name="configure">An action that configures the <see cref="DurableWorkflowOptions"/>, allowing customization of durable workflow behavior.</param>
|
||||||
|
/// <returns>The updated <see cref="FunctionsApplicationBuilder"/> instance, enabling method chaining.</returns>
|
||||||
|
public static FunctionsApplicationBuilder ConfigureDurableWorkflows(
|
||||||
|
this FunctionsApplicationBuilder builder,
|
||||||
|
Action<DurableWorkflowOptions> configure)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(configure);
|
||||||
|
|
||||||
|
return builder.ConfigureDurableOptions(options => configure(options.Workflows));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureMiddlewareRegistered(FunctionsApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
// Guard against registering the middleware filter multiple times in the pipeline.
|
||||||
|
if (builder.Services.Any(d => d.ServiceType == typeof(BuiltInFunctionExecutor)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
|
||||||
|
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal)
|
||||||
|
);
|
||||||
|
builder.Services.TryAddSingleton<BuiltInFunctionExecutor>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or creates a shared <see cref="DurableOptions"/> instance from the service collection.
|
||||||
|
/// </summary>
|
||||||
|
private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services)
|
||||||
|
{
|
||||||
|
ServiceDescriptor? existingDescriptor = services.FirstOrDefault(
|
||||||
|
d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null);
|
||||||
|
|
||||||
|
if (existingDescriptor?.ImplementationInstance is DurableOptions existing)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
DurableOptions options = new();
|
||||||
|
services.AddSingleton(options);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ internal static partial class Logs
|
|||||||
Level = LogLevel.Information,
|
Level = LogLevel.Information,
|
||||||
Message = "Registering {TriggerType} function for agent '{AgentName}'")]
|
Message = "Registering {TriggerType} function for agent '{AgentName}'")]
|
||||||
public static partial void LogRegisteringTriggerForAgent(this ILogger logger, string agentName, string triggerType);
|
public static partial void LogRegisteringTriggerForAgent(this ILogger logger, string agentName, string triggerType);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 102,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Registering {TriggerType} trigger function '{FunctionName}' for workflow '{WorkflowKey}'")]
|
||||||
|
public static partial void LogRegisteringWorkflowTrigger(this ILogger logger, string workflowKey, string functionName, string triggerType);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 103,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Function metadata transformation complete. Added {AddedCount} workflow function(s). Total function count: {TotalCount}")]
|
||||||
|
public static partial void LogTransformationComplete(this ILogger logger, int addedCount, int totalCount);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<!-- CA2007: This rule should generally be suppressed in Durable Task libraries. Also, this is not library code. -->
|
<!-- CA2007: This rule should generally be suppressed in Durable Task libraries. Also, this is not library code. -->
|
||||||
<NoWarn>$(NoWarn);CA2007</NoWarn>
|
<!-- AD0001: Temporary workaround for Microsoft.DurableTask.Analyzers v0.2.0 bug (ArgumentNullException on 'node'). Remove when upgrading to a fixed analyzer version. -->
|
||||||
|
<NoWarn>$(NoWarn);CA2007;AD0001</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.DurableTask;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transforms function metadata by dynamically registering Azure Functions triggers
|
||||||
|
/// for each configured durable workflow and its executors.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For each workflow, this transformer registers:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>An HTTP trigger function to start the workflow orchestration via HTTP.</description></item>
|
||||||
|
/// <item><description>An orchestration trigger function to run the workflow orchestration.</description></item>
|
||||||
|
/// <item><description>An activity trigger function for each non-agent executor in the workflow.</description></item>
|
||||||
|
/// <item><description>An entity trigger function for each AI agent executor in the workflow.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// When multiple workflows share the same executor, the corresponding function is registered only once.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMetadataTransformer
|
||||||
|
{
|
||||||
|
private readonly ILogger<DurableWorkflowsFunctionMetadataTransformer> _logger;
|
||||||
|
private readonly DurableWorkflowOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DurableWorkflowsFunctionMetadataTransformer"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger instance for diagnostic output.</param>
|
||||||
|
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
|
||||||
|
public DurableWorkflowsFunctionMetadataTransformer(ILogger<DurableWorkflowsFunctionMetadataTransformer> logger, DurableOptions durableOptions)
|
||||||
|
{
|
||||||
|
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
ArgumentNullException.ThrowIfNull(durableOptions);
|
||||||
|
this._options = durableOptions.Workflows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Name => nameof(DurableWorkflowsFunctionMetadataTransformer);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Transform(IList<IFunctionMetadata> original)
|
||||||
|
{
|
||||||
|
int initialCount = original.Count;
|
||||||
|
this._logger.LogTransformingFunctionMetadata(initialCount);
|
||||||
|
|
||||||
|
// Track registered function names to avoid duplicates when workflows share executors.
|
||||||
|
HashSet<string> registeredFunctions = [];
|
||||||
|
|
||||||
|
foreach (var workflow in this._options.Workflows)
|
||||||
|
{
|
||||||
|
string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}";
|
||||||
|
|
||||||
|
if (this._logger.IsEnabled(LogLevel.Information))
|
||||||
|
{
|
||||||
|
this._logger.LogInformation("Registering durable workflow functions for workflow '{WorkflowKey}' with HTTP trigger function name '{HttpFunctionName}'", workflow.Key, httpFunctionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register an orchestration function for the workflow.
|
||||||
|
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Key);
|
||||||
|
if (registeredFunctions.Add(orchestrationFunctionName))
|
||||||
|
{
|
||||||
|
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, orchestrationFunctionName, "orchestration");
|
||||||
|
original.Add(FunctionMetadataFactory.CreateOrchestrationTrigger(
|
||||||
|
orchestrationFunctionName,
|
||||||
|
BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register an HTTP trigger so users can start this workflow via HTTP.
|
||||||
|
if (registeredFunctions.Add(httpFunctionName))
|
||||||
|
{
|
||||||
|
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, httpFunctionName, "http");
|
||||||
|
original.Add(FunctionMetadataFactory.CreateHttpTrigger(
|
||||||
|
workflow.Key,
|
||||||
|
$"workflows/{workflow.Key}/run",
|
||||||
|
BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register activity or entity functions for each executor in the workflow.
|
||||||
|
// ReflectExecutors() returns all executors across the graph; no need to manually traverse edges.
|
||||||
|
foreach (KeyValuePair<string, ExecutorBinding> entry in workflow.Value.ReflectExecutors())
|
||||||
|
{
|
||||||
|
// Sub-workflow bindings are handled as separate orchestrations, not activities.
|
||||||
|
if (entry.Value is SubworkflowBinding)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key);
|
||||||
|
|
||||||
|
// AI agent executors are backed by durable entities; other executors use activity triggers.
|
||||||
|
if (entry.Value is AIAgentBinding)
|
||||||
|
{
|
||||||
|
string entityName = AgentSessionId.ToEntityName(executorName);
|
||||||
|
if (registeredFunctions.Add(entityName))
|
||||||
|
{
|
||||||
|
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, entityName, "entity");
|
||||||
|
original.Add(FunctionMetadataFactory.CreateEntityTrigger(executorName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string functionName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
|
||||||
|
if (registeredFunctions.Add(functionName))
|
||||||
|
{
|
||||||
|
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, functionName, "activity");
|
||||||
|
original.Add(FunctionMetadataFactory.CreateActivityTrigger(functionName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.LogTransformationComplete(original.Count - initialCount, original.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.DurableTask;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A custom <see cref="ITaskOrchestrator"/> implementation that delegates workflow orchestration
|
||||||
|
/// execution to the <see cref="DurableWorkflowRunner"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WorkflowOrchestrator : ITaskOrchestrator
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="WorkflowOrchestrator"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serviceProvider">The service provider used to resolve workflow dependencies.</param>
|
||||||
|
public WorkflowOrchestrator(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
this._serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Type InputType => typeof(DurableWorkflowInput<object>);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Type OutputType => typeof(string);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<object?> RunAsync(TaskOrchestrationContext context, object? input)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
|
DurableWorkflowRunner runner = this._serviceProvider.GetRequiredService<DurableWorkflowRunner>();
|
||||||
|
ILogger logger = context.CreateReplaySafeLogger(context.Name);
|
||||||
|
|
||||||
|
DurableWorkflowInput<object> workflowInput = input switch
|
||||||
|
{
|
||||||
|
DurableWorkflowInput<object> existing => existing,
|
||||||
|
_ => new DurableWorkflowInput<object> { Input = input! }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ConfigureAwait(true) is required to preserve the orchestration context
|
||||||
|
// across awaits, which the Durable Task framework uses for replay.
|
||||||
|
return await runner.RunWorkflowOrchestrationAsync(context, workflowInput, logger).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Microsoft.Agents.AI.DurableTask" />
|
||||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.UnitTests" />
|
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.UnitTests" />
|
||||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.Generators.UnitTests" />
|
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.Generators.UnitTests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
+20
-396
@@ -2,47 +2,32 @@
|
|||||||
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Reflection;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Xunit.Abstractions;
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for validating the durable agent console app samples
|
||||||
|
/// located in samples/Durable/Agents/ConsoleApps.
|
||||||
|
/// </summary>
|
||||||
[Collection("Samples")]
|
[Collection("Samples")]
|
||||||
[Trait("Category", "SampleValidation")]
|
[Trait("Category", "SampleValidation")]
|
||||||
public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime
|
public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper)
|
||||||
{
|
{
|
||||||
private const string DtsPort = "8080";
|
|
||||||
private const string RedisPort = "6379";
|
|
||||||
|
|
||||||
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
|
|
||||||
private static readonly IConfiguration s_configuration =
|
|
||||||
new ConfigurationBuilder()
|
|
||||||
.AddUserSecrets(Assembly.GetExecutingAssembly())
|
|
||||||
.AddEnvironmentVariables()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
private static bool s_infrastructureStarted;
|
|
||||||
private static readonly string s_samplesPath = Path.GetFullPath(
|
private static readonly string s_samplesPath = Path.GetFullPath(
|
||||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "Durable", "Agents", "ConsoleApps"));
|
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "Durable", "Agents", "ConsoleApps"));
|
||||||
|
|
||||||
private readonly ITestOutputHelper _outputHelper = outputHelper;
|
/// <inheritdoc />
|
||||||
|
protected override string SamplesPath => s_samplesPath;
|
||||||
|
|
||||||
async Task IAsyncLifetime.InitializeAsync()
|
/// <inheritdoc />
|
||||||
{
|
protected override bool RequiresRedis => true;
|
||||||
if (!s_infrastructureStarted)
|
|
||||||
{
|
|
||||||
await this.StartSharedInfrastructureAsync();
|
|
||||||
s_infrastructureStarted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task IAsyncLifetime.DisposeAsync()
|
/// <inheritdoc />
|
||||||
|
protected override void ConfigureAdditionalEnvironmentVariables(ProcessStartInfo startInfo, Action<string, string> setEnvVar)
|
||||||
{
|
{
|
||||||
// Nothing to clean up
|
setEnvVar("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}");
|
||||||
await Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -475,7 +460,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
|||||||
// (streams can complete very quickly, so we need to interrupt early)
|
// (streams can complete very quickly, so we need to interrupt early)
|
||||||
if (foundConversationStart && !interrupted && contentLinesBeforeInterrupt >= 2)
|
if (foundConversationStart && !interrupted && contentLinesBeforeInterrupt >= 2)
|
||||||
{
|
{
|
||||||
this._outputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines");
|
this.OutputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines");
|
||||||
interrupted = true;
|
interrupted = true;
|
||||||
interruptTime = DateTime.Now;
|
interruptTime = DateTime.Now;
|
||||||
|
|
||||||
@@ -493,7 +478,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
|||||||
foundLastCursor = true;
|
foundLastCursor = true;
|
||||||
|
|
||||||
// Send Enter again to resume
|
// Send Enter again to resume
|
||||||
this._outputHelper.WriteLine("Resuming stream from last cursor");
|
this.OutputHelper.WriteLine("Resuming stream from last cursor");
|
||||||
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
|
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
|
||||||
resumed = true;
|
resumed = true;
|
||||||
}
|
}
|
||||||
@@ -521,7 +506,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
|||||||
if (timeSinceInterrupt < TimeSpan.FromSeconds(2))
|
if (timeSinceInterrupt < TimeSpan.FromSeconds(2))
|
||||||
{
|
{
|
||||||
// Continue reading for a bit more to catch the cancellation message
|
// Continue reading for a bit more to catch the cancellation message
|
||||||
this._outputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt...");
|
this.OutputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt...");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,7 +521,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
|||||||
// Stop once we've verified the interrupt/resume flow works
|
// Stop once we've verified the interrupt/resume flow works
|
||||||
if (resumed && foundResumeMessage && contentLinesAfterResume >= 5)
|
if (resumed && foundResumeMessage && contentLinesAfterResume >= 5)
|
||||||
{
|
{
|
||||||
this._outputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after");
|
this.OutputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,7 +532,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
|||||||
TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value;
|
TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value;
|
||||||
if (timeSinceInterrupt < TimeSpan.FromSeconds(3))
|
if (timeSinceInterrupt < TimeSpan.FromSeconds(3))
|
||||||
{
|
{
|
||||||
this._outputHelper.WriteLine("Waiting for Last cursor message after interrupt...");
|
this.OutputHelper.WriteLine("Waiting for Last cursor message after interrupt...");
|
||||||
using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(2));
|
using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(2));
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -558,7 +543,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
|||||||
foundLastCursor = true;
|
foundLastCursor = true;
|
||||||
if (!resumed)
|
if (!resumed)
|
||||||
{
|
{
|
||||||
this._outputHelper.WriteLine("Resuming stream from last cursor");
|
this.OutputHelper.WriteLine("Resuming stream from last cursor");
|
||||||
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
|
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
|
||||||
resumed = true;
|
resumed = true;
|
||||||
}
|
}
|
||||||
@@ -576,7 +561,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
|||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Timeout - check if we got enough to verify the flow
|
// Timeout - check if we got enough to verify the flow
|
||||||
this._outputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}");
|
this.OutputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.True(foundConversationStart, "Conversation start message not found.");
|
Assert.True(foundConversationStart, "Conversation start message not found.");
|
||||||
@@ -586,7 +571,7 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
|||||||
// but we should still verify we got the conversation started
|
// but we should still verify we got the conversation started
|
||||||
if (!interrupted)
|
if (!interrupted)
|
||||||
{
|
{
|
||||||
this._outputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast.");
|
this.OutputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.True(interrupted, "Stream was not interrupted (may have completed too quickly).");
|
Assert.True(interrupted, "Stream was not interrupted (may have completed too quickly).");
|
||||||
@@ -596,365 +581,4 @@ public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper)
|
|||||||
Assert.True(contentLinesAfterResume > 0, "No content received after resume (expected to continue from cursor, not restart).");
|
Assert.True(contentLinesAfterResume > 0, "No content received after resume (expected to continue from cursor, not restart).");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetTargetFramework()
|
|
||||||
{
|
|
||||||
string filePath = new Uri(typeof(ConsoleAppSamplesValidation).Assembly.Location).LocalPath;
|
|
||||||
string directory = Path.GetDirectoryName(filePath)!;
|
|
||||||
string tfm = Path.GetFileName(directory);
|
|
||||||
if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return tfm;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidOperationException($"Unable to find target framework in path: {filePath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StartSharedInfrastructureAsync()
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine("Starting shared infrastructure for console app samples...");
|
|
||||||
|
|
||||||
// Start DTS emulator
|
|
||||||
await this.StartDtsEmulatorAsync();
|
|
||||||
|
|
||||||
// Start Redis
|
|
||||||
await this.StartRedisAsync();
|
|
||||||
|
|
||||||
// Wait for infrastructure to be ready
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StartDtsEmulatorAsync()
|
|
||||||
{
|
|
||||||
// Start DTS emulator if it's not already running
|
|
||||||
if (!await this.IsDtsEmulatorRunningAsync())
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine("Starting DTS emulator...");
|
|
||||||
await this.RunCommandAsync("docker", [
|
|
||||||
"run", "-d",
|
|
||||||
"--name", "dts-emulator",
|
|
||||||
"-p", $"{DtsPort}:8080",
|
|
||||||
"-e", "DTS_USE_DYNAMIC_TASK_HUBS=true",
|
|
||||||
"mcr.microsoft.com/dts/dts-emulator:latest"
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StartRedisAsync()
|
|
||||||
{
|
|
||||||
if (!await this.IsRedisRunningAsync())
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine("Starting Redis...");
|
|
||||||
await this.RunCommandAsync("docker", [
|
|
||||||
"run", "-d",
|
|
||||||
"--name", "redis",
|
|
||||||
"-p", $"{RedisPort}:6379",
|
|
||||||
"redis:latest"
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> IsDtsEmulatorRunningAsync()
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz...");
|
|
||||||
|
|
||||||
// DTS emulator doesn't support HTTP/1.1, so we need to use HTTP/2.0
|
|
||||||
using HttpClient http2Client = new()
|
|
||||||
{
|
|
||||||
DefaultRequestVersion = new Version(2, 0),
|
|
||||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
|
||||||
using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token);
|
|
||||||
if (response.Content.Headers.ContentLength > 0)
|
|
||||||
{
|
|
||||||
string content = await response.Content.ReadAsStringAsync(timeoutCts.Token);
|
|
||||||
this._outputHelper.WriteLine($"DTS emulator health check response: {content}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine("DTS emulator is running");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (HttpRequestException ex)
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> IsRedisRunningAsync()
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}...");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
|
||||||
ProcessStartInfo startInfo = new()
|
|
||||||
{
|
|
||||||
FileName = "docker",
|
|
||||||
Arguments = "exec redis redis-cli ping",
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
using Process process = new() { StartInfo = startInfo };
|
|
||||||
if (!process.Start())
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine("Failed to start docker exec command");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
|
|
||||||
await process.WaitForExitAsync(timeoutCts.Token);
|
|
||||||
|
|
||||||
if (process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine("Redis is running");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._outputHelper.WriteLine($"Redis is not running. Exit code: {process.ExitCode}, Output: {output}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine($"Redis is not running: {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunSampleTestAsync(string samplePath, Func<Process, BlockingCollection<OutputLog>, Task> testAction)
|
|
||||||
{
|
|
||||||
// Generate a unique TaskHub name for this sample test to prevent cross-test interference
|
|
||||||
// when multiple tests run together and share the same DTS emulator.
|
|
||||||
string uniqueTaskHubName = $"sample-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
|
|
||||||
|
|
||||||
// Start the console app
|
|
||||||
// Use BlockingCollection to safely read logs asynchronously captured from the process
|
|
||||||
using BlockingCollection<OutputLog> logsContainer = [];
|
|
||||||
using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Run the test
|
|
||||||
await testAction(appProcess, logsContainer);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException e)
|
|
||||||
{
|
|
||||||
throw new TimeoutException("Core test logic timed out!", e);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
logsContainer.CompleteAdding();
|
|
||||||
await this.StopProcessAsync(appProcess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes a line to the process's stdin and flushes it.
|
|
||||||
/// Logs the input being sent for debugging purposes.
|
|
||||||
/// </summary>
|
|
||||||
private async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}");
|
|
||||||
await process.StandardInput.WriteLineAsync(input);
|
|
||||||
await process.StandardInput.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a line from the logs queue, filtering for Information level logs (stdout).
|
|
||||||
/// Returns null if the collection is completed and empty, or if cancellation is requested.
|
|
||||||
/// </summary>
|
|
||||||
private string? ReadLogLine(BlockingCollection<OutputLog> logs, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (!cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// Block until a log entry is available or cancellation is requested
|
|
||||||
// Take will throw OperationCanceledException if cancelled, or InvalidOperationException if collection is completed
|
|
||||||
OutputLog log = logs.Take(cancellationToken);
|
|
||||||
|
|
||||||
// Check for unhandled exceptions in the logs, which are never expected (but can happen)
|
|
||||||
if (log.Message.Contains("Unhandled exception"))
|
|
||||||
{
|
|
||||||
Assert.Fail("Console app encountered an unhandled exception.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only return Information level logs (stdout), skip Error logs (stderr)
|
|
||||||
if (log.Level == LogLevel.Information)
|
|
||||||
{
|
|
||||||
return log.Message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// Cancellation requested
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
// Collection is completed and empty
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Process StartConsoleApp(string samplePath, BlockingCollection<OutputLog> logs, string taskHubName)
|
|
||||||
{
|
|
||||||
ProcessStartInfo startInfo = new()
|
|
||||||
{
|
|
||||||
FileName = "dotnet",
|
|
||||||
Arguments = $"run --framework {s_dotnetTargetFramework}",
|
|
||||||
WorkingDirectory = samplePath,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
RedirectStandardInput = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ??
|
|
||||||
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
|
|
||||||
string openAiDeployment = s_configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
|
|
||||||
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
|
|
||||||
|
|
||||||
void SetAndLogEnvironmentVariable(string key, string value)
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}");
|
|
||||||
startInfo.EnvironmentVariables[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set required environment variables for the app
|
|
||||||
SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint);
|
|
||||||
SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT", openAiDeployment);
|
|
||||||
SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
|
|
||||||
$"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None");
|
|
||||||
SetAndLogEnvironmentVariable("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}");
|
|
||||||
|
|
||||||
Process process = new() { StartInfo = startInfo };
|
|
||||||
|
|
||||||
// Capture the output and error streams asynchronously
|
|
||||||
// These events fire asynchronously, so we add to the blocking collection which is thread-safe
|
|
||||||
process.ErrorDataReceived += (sender, e) =>
|
|
||||||
{
|
|
||||||
if (e.Data != null)
|
|
||||||
{
|
|
||||||
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(err)]: {e.Data}";
|
|
||||||
this._outputHelper.WriteLine(logMessage);
|
|
||||||
Debug.WriteLine(logMessage);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data));
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
// Collection is completed, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.OutputDataReceived += (sender, e) =>
|
|
||||||
{
|
|
||||||
if (e.Data != null)
|
|
||||||
{
|
|
||||||
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(out)]: {e.Data}";
|
|
||||||
this._outputHelper.WriteLine(logMessage);
|
|
||||||
Debug.WriteLine(logMessage);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data));
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
// Collection is completed, ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!process.Start())
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Failed to start the console app");
|
|
||||||
}
|
|
||||||
|
|
||||||
process.BeginErrorReadLine();
|
|
||||||
process.BeginOutputReadLine();
|
|
||||||
|
|
||||||
return process;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunCommandAsync(string command, string[] args)
|
|
||||||
{
|
|
||||||
await this.RunCommandAsync(command, workingDirectory: null, args: args);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RunCommandAsync(string command, string? workingDirectory, string[] args)
|
|
||||||
{
|
|
||||||
ProcessStartInfo startInfo = new()
|
|
||||||
{
|
|
||||||
FileName = command,
|
|
||||||
Arguments = string.Join(" ", args),
|
|
||||||
WorkingDirectory = workingDirectory,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true
|
|
||||||
};
|
|
||||||
|
|
||||||
this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}");
|
|
||||||
|
|
||||||
using Process process = new() { StartInfo = startInfo };
|
|
||||||
process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}");
|
|
||||||
process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}");
|
|
||||||
if (!process.Start())
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Failed to start the command");
|
|
||||||
}
|
|
||||||
process.BeginErrorReadLine();
|
|
||||||
process.BeginOutputReadLine();
|
|
||||||
|
|
||||||
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1));
|
|
||||||
await process.WaitForExitAsync(cancellationTokenSource.Token);
|
|
||||||
|
|
||||||
this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task StopProcessAsync(Process process)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!process.HasExited)
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}");
|
|
||||||
process.Kill(entireProcessTree: true);
|
|
||||||
|
|
||||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10));
|
|
||||||
await process.WaitForExitAsync(timeoutCts.Token);
|
|
||||||
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null)
|
|
||||||
{
|
|
||||||
TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60);
|
|
||||||
return new CancellationTokenSource(testTimeout);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+449
@@ -0,0 +1,449 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for sample validation integration tests providing shared infrastructure
|
||||||
|
/// setup and utility methods for running console app samples.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class SamplesValidationBase : IAsyncLifetime
|
||||||
|
{
|
||||||
|
protected const string DtsPort = "8080";
|
||||||
|
protected const string RedisPort = "6379";
|
||||||
|
|
||||||
|
protected static readonly string DotnetTargetFramework = GetTargetFramework();
|
||||||
|
protected static readonly IConfiguration Configuration =
|
||||||
|
new ConfigurationBuilder()
|
||||||
|
.AddUserSecrets(Assembly.GetExecutingAssembly())
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Semaphores for thread-safe initialization of shared infrastructure.
|
||||||
|
// xUnit may run tests in parallel, so we need to ensure that DTS emulator and Redis
|
||||||
|
// are started only once across all test instances. Using SemaphoreSlim allows async-safe
|
||||||
|
// locking, and the double-check pattern (check flag, acquire lock, check flag again)
|
||||||
|
// minimizes lock contention after initialization is complete.
|
||||||
|
private static readonly SemaphoreSlim s_dtsInitLock = new(1, 1);
|
||||||
|
private static readonly SemaphoreSlim s_redisInitLock = new(1, 1);
|
||||||
|
private static bool s_dtsInfrastructureStarted;
|
||||||
|
private static bool s_redisInfrastructureStarted;
|
||||||
|
|
||||||
|
protected SamplesValidationBase(ITestOutputHelper outputHelper)
|
||||||
|
{
|
||||||
|
this.OutputHelper = outputHelper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the test output helper for logging.
|
||||||
|
/// </summary>
|
||||||
|
protected ITestOutputHelper OutputHelper { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the base path to the samples directory for this test class.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string SamplesPath { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets whether this test class requires Redis infrastructure.
|
||||||
|
/// </summary>
|
||||||
|
protected virtual bool RequiresRedis => false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the task hub name prefix for this test class.
|
||||||
|
/// </summary>
|
||||||
|
protected virtual string TaskHubPrefix => "sample";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await EnsureDtsInfrastructureStartedAsync(this.OutputHelper, this.StartDtsEmulatorAsync);
|
||||||
|
|
||||||
|
if (this.RequiresRedis)
|
||||||
|
{
|
||||||
|
await EnsureRedisInfrastructureStartedAsync(this.OutputHelper, this.StartRedisAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures DTS infrastructure is started exactly once across all test instances.
|
||||||
|
/// Static method writes to static field to avoid the code smell of instance methods modifying shared state.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task EnsureDtsInfrastructureStartedAsync(ITestOutputHelper outputHelper, Func<Task> startAction)
|
||||||
|
{
|
||||||
|
if (s_dtsInfrastructureStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await s_dtsInitLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!s_dtsInfrastructureStarted)
|
||||||
|
{
|
||||||
|
outputHelper.WriteLine("Starting shared DTS infrastructure...");
|
||||||
|
await startAction();
|
||||||
|
s_dtsInfrastructureStarted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
s_dtsInitLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures Redis infrastructure is started exactly once across all test instances.
|
||||||
|
/// Static method writes to static field to avoid the code smell of instance methods modifying shared state.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task EnsureRedisInfrastructureStartedAsync(ITestOutputHelper outputHelper, Func<Task> startAction)
|
||||||
|
{
|
||||||
|
if (s_redisInfrastructureStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await s_redisInitLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!s_redisInfrastructureStarted)
|
||||||
|
{
|
||||||
|
outputHelper.WriteLine("Starting shared Redis infrastructure...");
|
||||||
|
await startAction();
|
||||||
|
s_redisInfrastructureStarted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
s_redisInitLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
protected sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a sample test by starting the console app and executing the provided test action.
|
||||||
|
/// </summary>
|
||||||
|
protected async Task RunSampleTestAsync(string samplePath, Func<Process, BlockingCollection<OutputLog>, Task> testAction)
|
||||||
|
{
|
||||||
|
string uniqueTaskHubName = $"{this.TaskHubPrefix}-{Guid.NewGuid():N}"[..^26];
|
||||||
|
|
||||||
|
using BlockingCollection<OutputLog> logsContainer = [];
|
||||||
|
using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await testAction(appProcess, logsContainer);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException e)
|
||||||
|
{
|
||||||
|
throw new TimeoutException("Core test logic timed out!", e);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
logsContainer.CompleteAdding();
|
||||||
|
await this.StopProcessAsync(appProcess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a line to the process's stdin and flushes it.
|
||||||
|
/// </summary>
|
||||||
|
protected async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}");
|
||||||
|
await process.StandardInput.WriteLineAsync(input);
|
||||||
|
await process.StandardInput.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the next Information-level log line from the queue.
|
||||||
|
/// Returns null if cancelled or collection is completed.
|
||||||
|
/// </summary>
|
||||||
|
protected string? ReadLogLine(BlockingCollection<OutputLog> logs, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
OutputLog log = logs.Take(cancellationToken);
|
||||||
|
|
||||||
|
if (log.Message.Contains("Unhandled exception"))
|
||||||
|
{
|
||||||
|
Assert.Fail("Console app encountered an unhandled exception.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.Level == LogLevel.Information)
|
||||||
|
{
|
||||||
|
return log.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a cancellation token source with the specified timeout for test operations.
|
||||||
|
/// </summary>
|
||||||
|
protected CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null)
|
||||||
|
{
|
||||||
|
TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60);
|
||||||
|
return new CancellationTokenSource(testTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allows derived classes to set additional environment variables for the console app process.
|
||||||
|
/// </summary>
|
||||||
|
protected virtual void ConfigureAdditionalEnvironmentVariables(ProcessStartInfo startInfo, Action<string, string> setEnvVar)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTargetFramework()
|
||||||
|
{
|
||||||
|
string filePath = new Uri(typeof(SamplesValidationBase).Assembly.Location).LocalPath;
|
||||||
|
string directory = Path.GetDirectoryName(filePath)!;
|
||||||
|
string tfm = Path.GetFileName(directory);
|
||||||
|
if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return tfm;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Unable to find target framework in path: {filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartDtsEmulatorAsync()
|
||||||
|
{
|
||||||
|
if (!await this.IsDtsEmulatorRunningAsync())
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine("Starting DTS emulator...");
|
||||||
|
await this.RunCommandAsync("docker", "run", "-d",
|
||||||
|
"--name", "dts-emulator",
|
||||||
|
"-p", $"{DtsPort}:8080",
|
||||||
|
"-e", "DTS_USE_DYNAMIC_TASK_HUBS=true",
|
||||||
|
"mcr.microsoft.com/dts/dts-emulator:latest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartRedisAsync()
|
||||||
|
{
|
||||||
|
if (!await this.IsRedisRunningAsync())
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine("Starting Redis...");
|
||||||
|
await this.RunCommandAsync("docker", "run", "-d",
|
||||||
|
"--name", "redis",
|
||||||
|
"-p", $"{RedisPort}:6379",
|
||||||
|
"redis:latest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> IsDtsEmulatorRunningAsync()
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz...");
|
||||||
|
|
||||||
|
using HttpClient http2Client = new()
|
||||||
|
{
|
||||||
|
DefaultRequestVersion = new Version(2, 0),
|
||||||
|
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
||||||
|
using HttpResponseMessage response = await http2Client.GetAsync(
|
||||||
|
new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token);
|
||||||
|
|
||||||
|
if (response.Content.Headers.ContentLength > 0)
|
||||||
|
{
|
||||||
|
string content = await response.Content.ReadAsStringAsync(timeoutCts.Token);
|
||||||
|
this.OutputHelper.WriteLine($"DTS emulator health check response: {content}");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isRunning = response.IsSuccessStatusCode;
|
||||||
|
this.OutputHelper.WriteLine(isRunning ? "DTS emulator is running" : $"DTS emulator not running. Status: {response.StatusCode}");
|
||||||
|
return isRunning;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine($"DTS emulator is not running: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> IsRedisRunningAsync()
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
||||||
|
ProcessStartInfo startInfo = new()
|
||||||
|
{
|
||||||
|
FileName = "docker",
|
||||||
|
Arguments = "exec redis redis-cli ping",
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
using Process process = new() { StartInfo = startInfo };
|
||||||
|
if (!process.Start())
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine("Failed to start docker exec command");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
|
||||||
|
await process.WaitForExitAsync(timeoutCts.Token);
|
||||||
|
|
||||||
|
bool isRunning = process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase);
|
||||||
|
this.OutputHelper.WriteLine(isRunning ? "Redis is running" : $"Redis not running. Exit: {process.ExitCode}, Output: {output}");
|
||||||
|
return isRunning;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine($"Redis is not running: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Process StartConsoleApp(string samplePath, BlockingCollection<OutputLog> logs, string taskHubName)
|
||||||
|
{
|
||||||
|
ProcessStartInfo startInfo = new()
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
Arguments = $"run --framework {DotnetTargetFramework}",
|
||||||
|
WorkingDirectory = samplePath,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
string openAiEndpoint = Configuration["AZURE_OPENAI_ENDPOINT"] ??
|
||||||
|
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
|
||||||
|
string openAiDeployment = Configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
|
||||||
|
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
|
||||||
|
|
||||||
|
void SetAndLogEnvironmentVariable(string key, string value)
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}");
|
||||||
|
startInfo.EnvironmentVariables[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint);
|
||||||
|
SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT", openAiDeployment);
|
||||||
|
SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
|
||||||
|
$"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None");
|
||||||
|
|
||||||
|
this.ConfigureAdditionalEnvironmentVariables(startInfo, SetAndLogEnvironmentVariable);
|
||||||
|
|
||||||
|
Process process = new() { StartInfo = startInfo };
|
||||||
|
|
||||||
|
process.ErrorDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "err", LogLevel.Error, logs);
|
||||||
|
process.OutputDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "out", LogLevel.Information, logs);
|
||||||
|
|
||||||
|
if (!process.Start())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to start the console app");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
process.BeginOutputReadLine();
|
||||||
|
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleProcessOutput(string? data, string processName, string stream, LogLevel level, BlockingCollection<OutputLog> logs)
|
||||||
|
{
|
||||||
|
if (data is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{processName}({stream})]: {data}";
|
||||||
|
this.OutputHelper.WriteLine(logMessage);
|
||||||
|
Debug.WriteLine(logMessage);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logs.Add(new OutputLog(DateTime.Now, level, data));
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Collection completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunCommandAsync(string command, params string[] args)
|
||||||
|
{
|
||||||
|
ProcessStartInfo startInfo = new()
|
||||||
|
{
|
||||||
|
FileName = command,
|
||||||
|
Arguments = string.Join(" ", args),
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.OutputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}");
|
||||||
|
|
||||||
|
using Process process = new() { StartInfo = startInfo };
|
||||||
|
process.ErrorDataReceived += (sender, e) => this.OutputHelper.WriteLine($"[{command}(err)]: {e.Data}");
|
||||||
|
process.OutputDataReceived += (sender, e) => this.OutputHelper.WriteLine($"[{command}(out)]: {e.Data}");
|
||||||
|
|
||||||
|
if (!process.Start())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to start the command");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
process.BeginOutputReadLine();
|
||||||
|
|
||||||
|
using CancellationTokenSource cts = new(TimeSpan.FromMinutes(1));
|
||||||
|
await process.WaitForExitAsync(cts.Token);
|
||||||
|
|
||||||
|
this.OutputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopProcessAsync(Process process)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}");
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
|
||||||
|
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
|
||||||
|
await process.WaitForExitAsync(cts.Token);
|
||||||
|
this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.OutputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+436
@@ -0,0 +1,436 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for validating the durable workflow console app samples
|
||||||
|
/// located in samples/Durable/Workflow/ConsoleApps.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Samples")]
|
||||||
|
[Trait("Category", "SampleValidation")]
|
||||||
|
public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : SamplesValidationBase(outputHelper)
|
||||||
|
{
|
||||||
|
private static readonly string s_samplesPath = Path.GetFullPath(
|
||||||
|
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "Durable", "Workflow", "ConsoleApps"));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string SamplesPath => s_samplesPath;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override string TaskHubPrefix => "workflow";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SequentialWorkflowSampleValidationAsync()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||||
|
string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow");
|
||||||
|
|
||||||
|
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||||
|
{
|
||||||
|
bool inputSent = false;
|
||||||
|
bool workflowCompleted = false;
|
||||||
|
bool foundOrderLookup = false;
|
||||||
|
bool foundOrderCancel = false;
|
||||||
|
bool foundSendEmail = false;
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||||
|
{
|
||||||
|
if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await this.WriteInputAsync(process, "12345", testTimeoutCts.Token);
|
||||||
|
inputSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputSent)
|
||||||
|
{
|
||||||
|
foundOrderLookup |= line.Contains("[Activity] OrderLookup:", StringComparison.Ordinal);
|
||||||
|
foundOrderCancel |= line.Contains("[Activity] OrderCancel:", StringComparison.Ordinal);
|
||||||
|
foundSendEmail |= line.Contains("[Activity] SendEmail:", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (line.Contains("Workflow completed. Cancellation email sent for order 12345", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
workflowCompleted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.AssertNoError(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(inputSent, "Input was not sent to the workflow.");
|
||||||
|
Assert.True(foundOrderLookup, "OrderLookup executor log entry not found.");
|
||||||
|
Assert.True(foundOrderCancel, "OrderCancel executor log entry not found.");
|
||||||
|
Assert.True(foundSendEmail, "SendEmail executor log entry not found.");
|
||||||
|
Assert.True(workflowCompleted, "Workflow did not complete successfully.");
|
||||||
|
|
||||||
|
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentWorkflowSampleValidationAsync()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||||
|
string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow");
|
||||||
|
|
||||||
|
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||||
|
{
|
||||||
|
bool inputSent = false;
|
||||||
|
bool workflowCompleted = false;
|
||||||
|
bool foundParseQuestion = false;
|
||||||
|
bool foundAggregator = false;
|
||||||
|
bool foundAggregatorReceived2Responses = false;
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||||
|
{
|
||||||
|
if (!inputSent && line.Contains("Enter a science question", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await this.WriteInputAsync(process, "What is gravity?", testTimeoutCts.Token);
|
||||||
|
inputSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputSent)
|
||||||
|
{
|
||||||
|
foundParseQuestion |= line.Contains("[ParseQuestion]", StringComparison.Ordinal);
|
||||||
|
foundAggregator |= line.Contains("[Aggregator]", StringComparison.Ordinal);
|
||||||
|
foundAggregatorReceived2Responses |= line.Contains("Received 2 AI agent responses", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (line.Contains("Aggregation complete", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
workflowCompleted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.AssertNoError(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(inputSent, "Input was not sent to the workflow.");
|
||||||
|
Assert.True(foundParseQuestion, "ParseQuestion executor log entry not found.");
|
||||||
|
Assert.True(foundAggregator, "Aggregator executor log entry not found.");
|
||||||
|
Assert.True(foundAggregatorReceived2Responses, "Aggregator did not receive 2 AI agent responses.");
|
||||||
|
Assert.True(workflowCompleted, "Workflow did not complete successfully.");
|
||||||
|
|
||||||
|
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConditionalEdgesWorkflowSampleValidationAsync()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||||
|
string samplePath = Path.Combine(s_samplesPath, "03_ConditionalEdges");
|
||||||
|
|
||||||
|
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||||
|
{
|
||||||
|
bool validOrderSent = false;
|
||||||
|
bool blockedOrderSent = false;
|
||||||
|
bool validOrderCompleted = false;
|
||||||
|
bool blockedOrderCompleted = false;
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||||
|
{
|
||||||
|
// Send a valid order first (no 'B' in ID)
|
||||||
|
if (!validOrderSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await this.WriteInputAsync(process, "12345", testTimeoutCts.Token);
|
||||||
|
validOrderSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check valid order completed (routed to PaymentProcessor)
|
||||||
|
if (validOrderSent && !validOrderCompleted &&
|
||||||
|
line.Contains("PaymentReferenceNumber", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
validOrderCompleted = true;
|
||||||
|
|
||||||
|
// Send a blocked order (contains 'B')
|
||||||
|
await this.WriteInputAsync(process, "ORDER-B-999", testTimeoutCts.Token);
|
||||||
|
blockedOrderSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check blocked order completed (routed to NotifyFraud)
|
||||||
|
if (blockedOrderSent && line.Contains("flagged as fraudulent", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
blockedOrderCompleted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.AssertNoError(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(validOrderSent, "Valid order input was not sent.");
|
||||||
|
Assert.True(validOrderCompleted, "Valid order did not complete (PaymentProcessor path).");
|
||||||
|
Assert.True(blockedOrderSent, "Blocked order input was not sent.");
|
||||||
|
Assert.True(blockedOrderCompleted, "Blocked order did not complete (NotifyFraud path).");
|
||||||
|
|
||||||
|
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AssertNoError(string line)
|
||||||
|
{
|
||||||
|
if (line.Contains("Failed:", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
line.Contains("Error:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Assert.Fail($"Workflow failed: {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WorkflowEventsSampleValidationAsync()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||||
|
string samplePath = Path.Combine(s_samplesPath, "05_WorkflowEvents");
|
||||||
|
|
||||||
|
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||||
|
{
|
||||||
|
bool inputSent = false;
|
||||||
|
bool foundStartedRun = false;
|
||||||
|
bool foundExecutorInvoked = false;
|
||||||
|
bool foundExecutorCompleted = false;
|
||||||
|
bool foundLookupStarted = false;
|
||||||
|
bool foundOrderFound = false;
|
||||||
|
bool foundCancelProgress = false;
|
||||||
|
bool foundOrderCancelled = false;
|
||||||
|
bool foundEmailSent = false;
|
||||||
|
bool foundYieldedOutput = false;
|
||||||
|
bool foundWorkflowCompleted = false;
|
||||||
|
bool foundCompletionResult = false;
|
||||||
|
List<string> eventLines = [];
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||||
|
{
|
||||||
|
if (!inputSent && line.Contains("Enter order ID", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await this.WriteInputAsync(process, "12345", testTimeoutCts.Token);
|
||||||
|
inputSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputSent)
|
||||||
|
{
|
||||||
|
foundStartedRun |= line.Contains("Started run:", StringComparison.Ordinal);
|
||||||
|
foundExecutorInvoked |= line.Contains("ExecutorInvokedEvent", StringComparison.Ordinal);
|
||||||
|
foundExecutorCompleted |= line.Contains("ExecutorCompletedEvent", StringComparison.Ordinal);
|
||||||
|
foundLookupStarted |= line.Contains("[Lookup] Looking up order", StringComparison.Ordinal);
|
||||||
|
foundOrderFound |= line.Contains("[Lookup] Found:", StringComparison.Ordinal);
|
||||||
|
foundCancelProgress |= line.Contains("[Cancel]", StringComparison.Ordinal) && line.Contains('%');
|
||||||
|
foundOrderCancelled |= line.Contains("[Cancel] Done", StringComparison.Ordinal);
|
||||||
|
foundEmailSent |= line.Contains("[Email] Sent to", StringComparison.Ordinal);
|
||||||
|
foundYieldedOutput |= line.Contains("[Output]", StringComparison.Ordinal);
|
||||||
|
foundWorkflowCompleted |= line.Contains("DurableWorkflowCompletedEvent", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (line.Contains("Completed:", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
foundCompletionResult = line.Contains("12345", StringComparison.Ordinal);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect event lines for ordering verification
|
||||||
|
if (line.Contains("[Lookup]", StringComparison.Ordinal)
|
||||||
|
|| line.Contains("[Cancel]", StringComparison.Ordinal)
|
||||||
|
|| line.Contains("[Email]", StringComparison.Ordinal)
|
||||||
|
|| line.Contains("[Output]", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
eventLines.Add(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.AssertNoError(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(inputSent, "Input was not sent to the workflow.");
|
||||||
|
Assert.True(foundStartedRun, "Streaming run was not started.");
|
||||||
|
Assert.True(foundExecutorInvoked, "ExecutorInvokedEvent not found in stream.");
|
||||||
|
Assert.True(foundExecutorCompleted, "ExecutorCompletedEvent not found in stream.");
|
||||||
|
Assert.True(foundLookupStarted, "OrderLookupStartedEvent not found in stream.");
|
||||||
|
Assert.True(foundOrderFound, "OrderFoundEvent not found in stream.");
|
||||||
|
Assert.True(foundCancelProgress, "CancellationProgressEvent not found in stream.");
|
||||||
|
Assert.True(foundOrderCancelled, "OrderCancelledEvent not found in stream.");
|
||||||
|
Assert.True(foundEmailSent, "EmailSentEvent not found in stream.");
|
||||||
|
Assert.True(foundYieldedOutput, "WorkflowOutputEvent not found in stream.");
|
||||||
|
Assert.True(foundWorkflowCompleted, "DurableWorkflowCompletedEvent not found in stream.");
|
||||||
|
Assert.True(foundCompletionResult, "Completion result does not contain the order ID.");
|
||||||
|
|
||||||
|
// Verify event ordering: lookup events appear before cancel events, which appear before email events
|
||||||
|
int lastLookupIndex = eventLines.FindLastIndex(l => l.Contains("[Lookup]", StringComparison.Ordinal));
|
||||||
|
int firstCancelIndex = eventLines.FindIndex(l => l.Contains("[Cancel]", StringComparison.Ordinal));
|
||||||
|
int lastCancelIndex = eventLines.FindLastIndex(l => l.Contains("[Cancel]", StringComparison.Ordinal));
|
||||||
|
int firstEmailIndex = eventLines.FindIndex(l => l.Contains("[Email]", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
if (lastLookupIndex >= 0 && firstCancelIndex >= 0)
|
||||||
|
{
|
||||||
|
Assert.True(lastLookupIndex < firstCancelIndex, "Lookup events should appear before cancel events.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastCancelIndex >= 0 && firstEmailIndex >= 0)
|
||||||
|
{
|
||||||
|
Assert.True(lastCancelIndex < firstEmailIndex, "Cancel events should appear before email events.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WorkflowSharedStateSampleValidationAsync()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||||
|
string samplePath = Path.Combine(s_samplesPath, "07_WorkflowSharedState");
|
||||||
|
|
||||||
|
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||||
|
{
|
||||||
|
bool inputSent = false;
|
||||||
|
bool foundStartedRun = false;
|
||||||
|
bool foundValidateOutput = false;
|
||||||
|
bool foundEnrichOutput = false;
|
||||||
|
bool foundPaymentOutput = false;
|
||||||
|
bool foundInvoiceOutput = false;
|
||||||
|
bool foundTaxCalculation = false;
|
||||||
|
bool foundAuditTrail = false;
|
||||||
|
bool foundWorkflowCompleted = false;
|
||||||
|
List<string> outputLines = [];
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||||
|
{
|
||||||
|
if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await this.WriteInputAsync(process, "ORD-001", testTimeoutCts.Token);
|
||||||
|
inputSent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputSent)
|
||||||
|
{
|
||||||
|
foundStartedRun |= line.Contains("Started run:", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (line.Contains("[Output]", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
foundValidateOutput |= line.Contains("ValidateOrder:", StringComparison.Ordinal) && line.Contains("validated", StringComparison.OrdinalIgnoreCase);
|
||||||
|
foundEnrichOutput |= line.Contains("EnrichOrder:", StringComparison.Ordinal) && line.Contains("enriched", StringComparison.OrdinalIgnoreCase);
|
||||||
|
foundPaymentOutput |= line.Contains("ProcessPayment:", StringComparison.Ordinal) && line.Contains("Payment processed", StringComparison.OrdinalIgnoreCase);
|
||||||
|
foundInvoiceOutput |= line.Contains("GenerateInvoice:", StringComparison.Ordinal) && line.Contains("Invoice complete", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Verify shared state: tax rate was read by ProcessPayment
|
||||||
|
foundTaxCalculation |= line.Contains("tax:", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Verify shared state: audit trail was accumulated across executors
|
||||||
|
foundAuditTrail |= line.Contains("Audit trail:", StringComparison.Ordinal)
|
||||||
|
&& line.Contains("ValidateOrder", StringComparison.Ordinal)
|
||||||
|
&& line.Contains("EnrichOrder", StringComparison.Ordinal)
|
||||||
|
&& line.Contains("ProcessPayment", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
outputLines.Add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
foundWorkflowCompleted |= line.Contains("DurableWorkflowCompletedEvent", StringComparison.Ordinal)
|
||||||
|
|| line.Contains("Completed:", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (line.Contains("Completed:", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.AssertNoError(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(inputSent, "Input was not sent to the workflow.");
|
||||||
|
Assert.True(foundStartedRun, "Streaming run was not started.");
|
||||||
|
Assert.True(foundValidateOutput, "ValidateOrder output not found in stream.");
|
||||||
|
Assert.True(foundEnrichOutput, "EnrichOrder output not found in stream.");
|
||||||
|
Assert.True(foundPaymentOutput, "ProcessPayment output not found in stream.");
|
||||||
|
Assert.True(foundInvoiceOutput, "GenerateInvoice output not found in stream.");
|
||||||
|
Assert.True(foundTaxCalculation, "Tax calculation (shared state read) not found.");
|
||||||
|
Assert.True(foundAuditTrail, "Audit trail (shared state accumulation) not found.");
|
||||||
|
Assert.True(foundWorkflowCompleted, "Workflow completion not found in stream.");
|
||||||
|
|
||||||
|
// Verify output ordering: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice
|
||||||
|
int validateIndex = outputLines.FindIndex(l => l.Contains("ValidateOrder:", StringComparison.Ordinal) && l.Contains("validated", StringComparison.OrdinalIgnoreCase));
|
||||||
|
int enrichIndex = outputLines.FindIndex(l => l.Contains("EnrichOrder:", StringComparison.Ordinal));
|
||||||
|
int paymentIndex = outputLines.FindIndex(l => l.Contains("ProcessPayment:", StringComparison.Ordinal));
|
||||||
|
int invoiceIndex = outputLines.FindIndex(l => l.Contains("GenerateInvoice:", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
if (validateIndex >= 0 && enrichIndex >= 0)
|
||||||
|
{
|
||||||
|
Assert.True(validateIndex < enrichIndex, "ValidateOrder output should appear before EnrichOrder.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enrichIndex >= 0 && paymentIndex >= 0)
|
||||||
|
{
|
||||||
|
Assert.True(enrichIndex < paymentIndex, "EnrichOrder output should appear before ProcessPayment.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentIndex >= 0 && invoiceIndex >= 0)
|
||||||
|
{
|
||||||
|
Assert.True(paymentIndex < invoiceIndex, "ProcessPayment output should appear before GenerateInvoice.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WorkflowAndAgentsSampleValidationAsync()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||||
|
string samplePath = Path.Combine(s_samplesPath, "04_WorkflowAndAgents");
|
||||||
|
|
||||||
|
await this.RunSampleTestAsync(samplePath, (process, logs) =>
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
bool foundDemo1 = false;
|
||||||
|
bool foundBiologistResponse = false;
|
||||||
|
bool foundChemistResponse = false;
|
||||||
|
bool foundDemo2 = false;
|
||||||
|
bool foundPhysicsWorkflow = false;
|
||||||
|
bool foundDemo3 = false;
|
||||||
|
bool foundExpertTeamWorkflow = false;
|
||||||
|
bool foundDemo4 = false;
|
||||||
|
bool foundChemistryWorkflow = false;
|
||||||
|
bool allDemosCompleted = false;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string? line;
|
||||||
|
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||||
|
{
|
||||||
|
foundDemo1 |= line.Contains("DEMO 1:", StringComparison.Ordinal);
|
||||||
|
foundBiologistResponse |= line.Contains("Biologist:", StringComparison.Ordinal);
|
||||||
|
foundChemistResponse |= line.Contains("Chemist:", StringComparison.Ordinal);
|
||||||
|
foundDemo2 |= line.Contains("DEMO 2:", StringComparison.Ordinal);
|
||||||
|
foundPhysicsWorkflow |= line.Contains("PhysicsExpertReview", StringComparison.Ordinal);
|
||||||
|
foundDemo3 |= line.Contains("DEMO 3:", StringComparison.Ordinal);
|
||||||
|
foundExpertTeamWorkflow |= line.Contains("ExpertTeamReview", StringComparison.Ordinal);
|
||||||
|
foundDemo4 |= line.Contains("DEMO 4:", StringComparison.Ordinal);
|
||||||
|
foundChemistryWorkflow |= line.Contains("ChemistryExpertReview", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (line.Contains("All demos completed", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
allDemosCompleted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.AssertNoError(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(foundDemo1, "DEMO 1 (Direct Agent Conversation) not found.");
|
||||||
|
Assert.True(foundBiologistResponse, "Biologist agent response not found.");
|
||||||
|
Assert.True(foundChemistResponse, "Chemist agent response not found.");
|
||||||
|
Assert.True(foundDemo2, "DEMO 2 (Single-Agent Workflow) not found.");
|
||||||
|
Assert.True(foundPhysicsWorkflow, "PhysicsExpertReview workflow not found.");
|
||||||
|
Assert.True(foundDemo3, "DEMO 3 (Multi-Agent Workflow) not found.");
|
||||||
|
Assert.True(foundExpertTeamWorkflow, "ExpertTeamReview workflow not found.");
|
||||||
|
Assert.True(foundDemo4, "DEMO 4 (Chemistry Workflow) not found.");
|
||||||
|
Assert.True(foundChemistryWorkflow, "ChemistryExpertReview workflow not found.");
|
||||||
|
Assert.True(allDemosCompleted, "Sample did not complete all demos successfully.");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
<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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
+504
@@ -0,0 +1,504 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
|
||||||
|
|
||||||
|
public sealed class DurableActivityContextTests
|
||||||
|
{
|
||||||
|
private static FunctionExecutor<string> CreateTestExecutor(string id = "test-executor")
|
||||||
|
=> new(id, (_, _, _) => default);
|
||||||
|
|
||||||
|
#region ReadStateAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadStateAsync_KeyExistsInInitialState_ReturnsValue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new() { ["__default__:counter"] = "42" };
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
int? result = await context.ReadStateAsync<int>("counter");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(42, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadStateAsync_KeyDoesNotExist_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string? result = await context.ReadStateAsync<string>("missing");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadStateAsync_LocalUpdateTakesPriorityOverInitialState()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new() { ["__default__:key"] = "\"old\"" };
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
await context.QueueStateUpdateAsync("key", "new");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string? result = await context.ReadStateAsync<string>("key");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("new", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadStateAsync_ScopeCleared_IgnoresInitialState()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new() { ["__default__:key"] = "\"value\"" };
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
await context.QueueClearScopeAsync();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string? result = await context.ReadStateAsync<string>("key");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadStateAsync_WithNamedScope_ReadsFromCorrectScope()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new()
|
||||||
|
{
|
||||||
|
["scopeA:key"] = "\"fromA\"",
|
||||||
|
["scopeB:key"] = "\"fromB\""
|
||||||
|
};
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string? resultA = await context.ReadStateAsync<string>("key", "scopeA");
|
||||||
|
string? resultB = await context.ReadStateAsync<string>("key", "scopeB");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("fromA", resultA);
|
||||||
|
Assert.Equal("fromB", resultB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public async Task ReadStateAsync_NullOrEmptyKey_ThrowsArgumentException(string? key)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await Assert.ThrowsAnyAsync<ArgumentException>(() => context.ReadStateAsync<string>(key!).AsTask());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ReadOrInitStateAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadOrInitStateAsync_KeyDoesNotExist_CallsFactoryAndQueuesUpdate()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string result = await context.ReadOrInitStateAsync("key", () => "initialized");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("initialized", result);
|
||||||
|
Assert.True(context.StateUpdates.ContainsKey("__default__:key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadOrInitStateAsync_KeyExists_ReturnsExistingValue()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new() { ["__default__:key"] = "\"existing\"" };
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
bool factoryCalled = false;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string result = await context.ReadOrInitStateAsync("key", () =>
|
||||||
|
{
|
||||||
|
factoryCalled = true;
|
||||||
|
return "should-not-be-used";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("existing", result);
|
||||||
|
Assert.False(factoryCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public async Task ReadOrInitStateAsync_NullOrEmptyKey_ThrowsArgumentException(string? key)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||||
|
() => context.ReadOrInitStateAsync(key!, () => "value").AsTask());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadOrInitStateAsync_ValueType_MissingKey_CallsFactory()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
// Validates that ReadStateAsync<int> returns null (not 0) for missing keys,
|
||||||
|
// because the return type is int? (Nullable<int>). This ensures the factory
|
||||||
|
// is correctly invoked for value types when the key does not exist.
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
int result = await context.ReadOrInitStateAsync("counter", () => 42);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(42, result);
|
||||||
|
Assert.True(context.StateUpdates.ContainsKey("__default__:counter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadOrInitStateAsync_NullFactory_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||||
|
() => context.ReadOrInitStateAsync<string>("key", null!).AsTask());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region QueueStateUpdateAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueStateUpdateAsync_SetsValue_VisibleToSubsequentRead()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await context.QueueStateUpdateAsync("key", "hello");
|
||||||
|
string? result = await context.ReadStateAsync<string>("key");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("hello", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueStateUpdateAsync_NullValue_RecordsDeletion()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new() { ["__default__:key"] = "\"value\"" };
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await context.QueueStateUpdateAsync<string>("key", null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(context.StateUpdates.ContainsKey("__default__:key"));
|
||||||
|
Assert.Null(context.StateUpdates["__default__:key"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public async Task QueueStateUpdateAsync_NullOrEmptyKey_ThrowsArgumentException(string? key)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||||
|
() => context.QueueStateUpdateAsync(key!, "value").AsTask());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region QueueClearScopeAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueClearScopeAsync_DefaultScope_ClearsStateAndPendingUpdates()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new() { ["__default__:key"] = "\"value\"" };
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
await context.QueueStateUpdateAsync("pending", "data");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await context.QueueClearScopeAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Contains("__default__", context.ClearedScopes);
|
||||||
|
Assert.Empty(context.StateUpdates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueueClearScopeAsync_NamedScope_OnlyClearsThatScope()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
await context.QueueStateUpdateAsync("keyA", "valueA", scopeName: "scopeA");
|
||||||
|
await context.QueueStateUpdateAsync("keyB", "valueB", scopeName: "scopeB");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await context.QueueClearScopeAsync("scopeA");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.DoesNotContain("scopeA:keyA", context.StateUpdates.Keys);
|
||||||
|
Assert.Contains("scopeB:keyB", context.StateUpdates.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ReadStateKeysAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadStateKeysAsync_ReturnsKeysFromInitialState()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new()
|
||||||
|
{
|
||||||
|
["__default__:alpha"] = "\"a\"",
|
||||||
|
["__default__:beta"] = "\"b\""
|
||||||
|
};
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
HashSet<string> keys = await context.ReadStateKeysAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, keys.Count);
|
||||||
|
Assert.Contains("alpha", keys);
|
||||||
|
Assert.Contains("beta", keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadStateKeysAsync_MergesLocalUpdatesAndDeletions()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new()
|
||||||
|
{
|
||||||
|
["__default__:existing"] = "\"val\"",
|
||||||
|
["__default__:toDelete"] = "\"val\""
|
||||||
|
};
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
await context.QueueStateUpdateAsync("newKey", "value");
|
||||||
|
await context.QueueStateUpdateAsync<string>("toDelete", null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
HashSet<string> keys = await context.ReadStateKeysAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Contains("existing", keys);
|
||||||
|
Assert.Contains("newKey", keys);
|
||||||
|
Assert.DoesNotContain("toDelete", keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadStateKeysAsync_AfterClearScope_ExcludesInitialState()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new() { ["__default__:old"] = "\"val\"" };
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
await context.QueueClearScopeAsync();
|
||||||
|
await context.QueueStateUpdateAsync("new", "value");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
HashSet<string> keys = await context.ReadStateKeysAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.DoesNotContain("old", keys);
|
||||||
|
Assert.Contains("new", keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadStateKeysAsync_WithNamedScope_OnlyReturnsKeysFromThatScope()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Dictionary<string, string> state = new()
|
||||||
|
{
|
||||||
|
["scopeA:key1"] = "\"val\"",
|
||||||
|
["scopeB:key2"] = "\"val\""
|
||||||
|
};
|
||||||
|
DurableActivityContext context = new(state, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
HashSet<string> keysA = await context.ReadStateKeysAsync("scopeA");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(keysA);
|
||||||
|
Assert.Contains("key1", keysA);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region AddEventAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddEventAsync_AddsEventToCollection()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
WorkflowEvent evt = new ExecutorInvokedEvent("test", "test-data");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await context.AddEventAsync(evt);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(context.Events);
|
||||||
|
Assert.Same(evt, context.Events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddEventAsync_NullEvent_DoesNotAdd()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
|
||||||
|
await context.AddEventAsync(null);
|
||||||
|
#pragma warning restore CS8625
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(context.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region SendMessageAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessageAsync_SerializesMessageWithTypeName()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await context.SendMessageAsync("hello");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(context.SentMessages);
|
||||||
|
Assert.Equal(typeof(string).AssemblyQualifiedName, context.SentMessages[0].TypeName);
|
||||||
|
Assert.NotNull(context.SentMessages[0].Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessageAsync_NullMessage_DoesNotAdd()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
|
||||||
|
await context.SendMessageAsync(null);
|
||||||
|
#pragma warning restore CS8625
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(context.SentMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region YieldOutputAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task YieldOutputAsync_AddsWorkflowOutputEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await context.YieldOutputAsync("result");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(context.Events);
|
||||||
|
WorkflowOutputEvent outputEvent = Assert.IsType<WorkflowOutputEvent>(context.Events[0]);
|
||||||
|
Assert.Equal("result", outputEvent.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task YieldOutputAsync_NullOutput_DoesNotAdd()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
|
||||||
|
await context.YieldOutputAsync(null);
|
||||||
|
#pragma warning restore CS8625
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(context.Events);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region RequestHaltAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestHaltAsync_SetsHaltRequestedAndAddsEvent()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await context.RequestHaltAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(context.HaltRequested);
|
||||||
|
Assert.Single(context.Events);
|
||||||
|
Assert.IsType<DurableHaltRequestedEvent>(context.Events[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Properties
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TraceContext_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(context.TraceContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConcurrentRunsEnabled_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(context.ConcurrentRunsEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_NullInitialState_CreatesEmptyState()
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
DurableActivityContext context = new(null, CreateTestExecutor());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
string? result = await context.ReadStateAsync<string>("anything");
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
+615
@@ -0,0 +1,615 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
using Microsoft.DurableTask;
|
||||||
|
using Microsoft.DurableTask.Client;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
|
||||||
|
|
||||||
|
public sealed class DurableStreamingWorkflowRunTests
|
||||||
|
{
|
||||||
|
private const string InstanceId = "test-instance-123";
|
||||||
|
private const string WorkflowTestName = "TestWorkflow";
|
||||||
|
|
||||||
|
private static Workflow CreateTestWorkflow() =>
|
||||||
|
new WorkflowBuilder(new FunctionExecutor<string>("start", (_, _, _) => default))
|
||||||
|
.WithName(WorkflowTestName)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static OrchestrationMetadata CreateMetadata(
|
||||||
|
OrchestrationRuntimeStatus status,
|
||||||
|
string? serializedCustomStatus = null,
|
||||||
|
string? serializedOutput = null,
|
||||||
|
TaskFailureDetails? failureDetails = null)
|
||||||
|
{
|
||||||
|
return new OrchestrationMetadata(WorkflowTestName, InstanceId)
|
||||||
|
{
|
||||||
|
RuntimeStatus = status,
|
||||||
|
SerializedCustomStatus = serializedCustomStatus,
|
||||||
|
SerializedOutput = serializedOutput,
|
||||||
|
FailureDetails = failureDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeCustomStatus(List<string> events)
|
||||||
|
{
|
||||||
|
DurableWorkflowCustomStatus status = new() { Events = events };
|
||||||
|
return JsonSerializer.Serialize(status, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeWorkflowResult(string? result, List<string> events)
|
||||||
|
{
|
||||||
|
DurableWorkflowResult workflowResult = new() { Result = result, Events = events };
|
||||||
|
string inner = JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult);
|
||||||
|
return JsonSerializer.Serialize(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeEvent(WorkflowEvent evt)
|
||||||
|
{
|
||||||
|
Type eventType = evt.GetType();
|
||||||
|
TypedPayload wrapper = new()
|
||||||
|
{
|
||||||
|
TypeName = eventType.AssemblyQualifiedName,
|
||||||
|
Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options)
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Constructor and Properties
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_SetsRunIdAndWorkflowName()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(InstanceId, run.RunId);
|
||||||
|
Assert.Equal(WorkflowTestName, run.WorkflowName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_NoWorkflowName_SetsEmptyString()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
Workflow workflow = new WorkflowBuilder(new FunctionExecutor<string>("start", (_, _, _) => default)).Build();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(string.Empty, run.WorkflowName);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region GetStatusAsync
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(OrchestrationRuntimeStatus.Pending, DurableRunStatus.Pending)]
|
||||||
|
[InlineData(OrchestrationRuntimeStatus.Running, DurableRunStatus.Running)]
|
||||||
|
[InlineData(OrchestrationRuntimeStatus.Completed, DurableRunStatus.Completed)]
|
||||||
|
[InlineData(OrchestrationRuntimeStatus.Failed, DurableRunStatus.Failed)]
|
||||||
|
[InlineData(OrchestrationRuntimeStatus.Terminated, DurableRunStatus.Terminated)]
|
||||||
|
[InlineData(OrchestrationRuntimeStatus.Suspended, DurableRunStatus.Suspended)]
|
||||||
|
|
||||||
|
public async Task GetStatusAsync_MapsRuntimeStatusCorrectlyAsync(
|
||||||
|
OrchestrationRuntimeStatus runtimeStatus,
|
||||||
|
DurableRunStatus expectedStatus)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, false, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(runtimeStatus));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
DurableRunStatus status = await run.GetStatusAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(expectedStatus, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusAsync_InstanceNotFound_ReturnsNotFoundAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, false, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((OrchestrationMetadata?)null);
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
DurableRunStatus status = await run.GetStatusAsync();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(DurableRunStatus.NotFound, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region WatchStreamAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_InstanceNotFound_YieldsNoEventsAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((OrchestrationMetadata?)null);
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Empty(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_CompletedWithResult_YieldsCompletedEventAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
string serializedOutput = SerializeWorkflowResult("done", []);
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(events);
|
||||||
|
DurableWorkflowCompletedEvent completedEvent = Assert.IsType<DurableWorkflowCompletedEvent>(events[0]);
|
||||||
|
Assert.Equal("done", completedEvent.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_CompletedWithEventsInOutput_YieldsEventsAndCompletionAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableHaltRequestedEvent haltEvent = new("executor-1");
|
||||||
|
string serializedEvent = SerializeEvent(haltEvent);
|
||||||
|
string serializedOutput = SerializeWorkflowResult("result", [serializedEvent]);
|
||||||
|
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, events.Count);
|
||||||
|
Assert.IsType<DurableHaltRequestedEvent>(events[0]);
|
||||||
|
Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_CompletedWithoutWrapper_YieldsRawOutputAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: "\"raw output\""));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(events);
|
||||||
|
DurableWorkflowCompletedEvent completedEvent = Assert.IsType<DurableWorkflowCompletedEvent>(events[0]);
|
||||||
|
Assert.Equal("\"raw output\"", completedEvent.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_Failed_YieldsFailedEventAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(
|
||||||
|
OrchestrationRuntimeStatus.Failed,
|
||||||
|
failureDetails: new TaskFailureDetails("ErrorType", "Something went wrong", null, null, null)));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(events);
|
||||||
|
DurableWorkflowFailedEvent failedEvent = Assert.IsType<DurableWorkflowFailedEvent>(events[0]);
|
||||||
|
Assert.Equal("Something went wrong", failedEvent.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_FailedWithNoDetails_YieldsDefaultMessageAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Failed));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(events);
|
||||||
|
DurableWorkflowFailedEvent failedEvent = Assert.IsType<DurableWorkflowFailedEvent>(events[0]);
|
||||||
|
Assert.Equal("Workflow execution failed.", failedEvent.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_Terminated_YieldsFailedEventAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Terminated));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(events);
|
||||||
|
DurableWorkflowFailedEvent failedEvent = Assert.IsType<DurableWorkflowFailedEvent>(events[0]);
|
||||||
|
Assert.Equal("Workflow was terminated.", failedEvent.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_EventsInCustomStatus_YieldsEventsBeforeCompletionAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
DurableHaltRequestedEvent haltEvent = new("exec-1");
|
||||||
|
string serializedEvent = SerializeEvent(haltEvent);
|
||||||
|
string customStatus = SerializeCustomStatus([serializedEvent]);
|
||||||
|
string serializedOutput = SerializeWorkflowResult("final", []);
|
||||||
|
|
||||||
|
int callCount = 0;
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(() =>
|
||||||
|
{
|
||||||
|
callCount++;
|
||||||
|
if (callCount == 1)
|
||||||
|
{
|
||||||
|
return CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, events.Count);
|
||||||
|
Assert.IsType<DurableHaltRequestedEvent>(events[0]);
|
||||||
|
Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_IncrementalEvents_YieldsOnlyNewEventsPerPollAsync()
|
||||||
|
{
|
||||||
|
// Arrange — simulate 3 poll cycles where events accumulate in custom status,
|
||||||
|
// then a final completion poll. This validates:
|
||||||
|
// 1. Events arriving across multiple poll cycles are yielded incrementally
|
||||||
|
// 2. Already-seen events are not re-yielded (lastReadEventIndex dedup)
|
||||||
|
// 3. Completion event follows all streamed events
|
||||||
|
DurableHaltRequestedEvent event1 = new("executor-1");
|
||||||
|
DurableHaltRequestedEvent event2 = new("executor-2");
|
||||||
|
DurableHaltRequestedEvent event3 = new("executor-3");
|
||||||
|
|
||||||
|
string serializedEvent1 = SerializeEvent(event1);
|
||||||
|
string serializedEvent2 = SerializeEvent(event2);
|
||||||
|
string serializedEvent3 = SerializeEvent(event3);
|
||||||
|
|
||||||
|
// Poll 1: 1 event in custom status
|
||||||
|
string customStatus1 = SerializeCustomStatus([serializedEvent1]);
|
||||||
|
// Poll 2: same event + 1 new event (accumulating list)
|
||||||
|
string customStatus2 = SerializeCustomStatus([serializedEvent1, serializedEvent2]);
|
||||||
|
// Poll 3: all 3 events accumulated
|
||||||
|
string customStatus3 = SerializeCustomStatus([serializedEvent1, serializedEvent2, serializedEvent3]);
|
||||||
|
// Poll 4: completed, all events also in output
|
||||||
|
string serializedOutput = SerializeWorkflowResult("done", [serializedEvent1, serializedEvent2, serializedEvent3]);
|
||||||
|
|
||||||
|
int callCount = 0;
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(() =>
|
||||||
|
{
|
||||||
|
callCount++;
|
||||||
|
return callCount switch
|
||||||
|
{
|
||||||
|
1 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus1),
|
||||||
|
2 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus2),
|
||||||
|
3 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus3),
|
||||||
|
_ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — exactly 4 events: 3 incremental halt events + 1 completion
|
||||||
|
Assert.Equal(4, events.Count);
|
||||||
|
DurableHaltRequestedEvent halt1 = Assert.IsType<DurableHaltRequestedEvent>(events[0]);
|
||||||
|
DurableHaltRequestedEvent halt2 = Assert.IsType<DurableHaltRequestedEvent>(events[1]);
|
||||||
|
DurableHaltRequestedEvent halt3 = Assert.IsType<DurableHaltRequestedEvent>(events[2]);
|
||||||
|
Assert.Equal("executor-1", halt1.ExecutorId);
|
||||||
|
Assert.Equal("executor-2", halt2.ExecutorId);
|
||||||
|
Assert.Equal("executor-3", halt3.ExecutorId);
|
||||||
|
DurableWorkflowCompletedEvent completed = Assert.IsType<DurableWorkflowCompletedEvent>(events[3]);
|
||||||
|
Assert.Equal("done", completed.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_NoNewEventsOnRepoll_DoesNotDuplicateAsync()
|
||||||
|
{
|
||||||
|
// Arrange — simulate polling where custom status doesn't change between polls,
|
||||||
|
// validating that events are not duplicated when the list is unchanged.
|
||||||
|
DurableHaltRequestedEvent event1 = new("executor-1");
|
||||||
|
string serializedEvent1 = SerializeEvent(event1);
|
||||||
|
string customStatus = SerializeCustomStatus([serializedEvent1]);
|
||||||
|
string serializedOutput = SerializeWorkflowResult("result", [serializedEvent1]);
|
||||||
|
|
||||||
|
int callCount = 0;
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(() =>
|
||||||
|
{
|
||||||
|
callCount++;
|
||||||
|
return callCount switch
|
||||||
|
{
|
||||||
|
// First 3 polls return the same custom status (no new events after first)
|
||||||
|
<= 3 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus),
|
||||||
|
_ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — event1 appears exactly once despite 3 polls with the same status
|
||||||
|
Assert.Equal(2, events.Count);
|
||||||
|
Assert.IsType<DurableHaltRequestedEvent>(events[0]);
|
||||||
|
Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchStreamAsync_Cancellation_EndsGracefullyAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using CancellationTokenSource cts = new();
|
||||||
|
int pollCount = 0;
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(() =>
|
||||||
|
{
|
||||||
|
if (++pollCount >= 2)
|
||||||
|
{
|
||||||
|
cts.Cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateMetadata(OrchestrationRuntimeStatus.Running);
|
||||||
|
});
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
List<WorkflowEvent> events = [];
|
||||||
|
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cts.Token))
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — no exception thrown, stream ends cleanly
|
||||||
|
Assert.Empty(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region WaitForCompletionAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WaitForCompletionAsync_Completed_ReturnsResultAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
string serializedOutput = SerializeWorkflowResult("hello world", []);
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string? result = await run.WaitForCompletionAsync<string>();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("hello world", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WaitForCompletionAsync_Failed_ThrowsWithErrorMessageAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(
|
||||||
|
OrchestrationRuntimeStatus.Failed,
|
||||||
|
failureDetails: new TaskFailureDetails("Error", "kaboom", null, null, null)));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => run.WaitForCompletionAsync<string>().AsTask());
|
||||||
|
Assert.Equal("kaboom", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WaitForCompletionAsync_UnexpectedStatus_ThrowsAsync()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
Mock<DurableTaskClient> mockClient = new("test");
|
||||||
|
mockClient.Setup(c => c.WaitForInstanceCompletionAsync(InstanceId, true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(CreateMetadata(OrchestrationRuntimeStatus.Terminated));
|
||||||
|
|
||||||
|
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => run.WaitForCompletionAsync<string>().AsTask());
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ExtractResult
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractResult_NullOutput_ReturnsDefault()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
string? result = DurableStreamingWorkflowRun.ExtractResult<string>(null);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractResult_WrappedStringResult_ReturnsUnwrappedString()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
string serializedOutput = SerializeWorkflowResult("hello", []);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string? result = DurableStreamingWorkflowRun.ExtractResult<string>(serializedOutput);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("hello", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractResult_UnwrappedStringOutput_FallsBackToDirectDeserialization()
|
||||||
|
{
|
||||||
|
// Arrange — raw DurableDataConverter-style output (JSON-encoded string)
|
||||||
|
string serializedOutput = JsonSerializer.Serialize("raw value");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
string? result = DurableStreamingWorkflowRun.ExtractResult<string>(serializedOutput);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("raw value", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractResult_WrappedObjectResult_DeserializesCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
TestPayload original = new() { Name = "test", Value = 42 };
|
||||||
|
string resultJson = JsonSerializer.Serialize(original);
|
||||||
|
string serializedOutput = SerializeWorkflowResult(resultJson, []);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
TestPayload? result = DurableStreamingWorkflowRun.ExtractResult<TestPayload>(serializedOutput);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("test", result.Name);
|
||||||
|
Assert.Equal(42, result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractResult_CamelCaseSerializedObject_DeserializesToPascalCaseMembers()
|
||||||
|
{
|
||||||
|
// Arrange — executor outputs are serialized with DurableSerialization.Options (camelCase)
|
||||||
|
TestPayload original = new() { Name = "camel", Value = 99 };
|
||||||
|
string resultJson = JsonSerializer.Serialize(original, DurableSerialization.Options);
|
||||||
|
string serializedOutput = SerializeWorkflowResult(resultJson, []);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
TestPayload? result = DurableStreamingWorkflowRun.ExtractResult<TestPayload>(serializedOutput);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("camel", result.Name);
|
||||||
|
Assert.Equal(99, result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private sealed class TestPayload
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public int Value { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
|
||||||
|
|
||||||
|
public sealed class WorkflowNamingHelperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ToOrchestrationFunctionName_ValidWorkflowName_ReturnsPrefixedName()
|
||||||
|
{
|
||||||
|
string result = WorkflowNamingHelper.ToOrchestrationFunctionName("MyWorkflow");
|
||||||
|
|
||||||
|
Assert.Equal("dafx-MyWorkflow", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void ToOrchestrationFunctionName_NullOrEmpty_ThrowsArgumentException(string? workflowName)
|
||||||
|
{
|
||||||
|
Assert.ThrowsAny<ArgumentException>(() => WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToWorkflowName_ValidOrchestrationFunctionName_ReturnsWorkflowName()
|
||||||
|
{
|
||||||
|
string result = WorkflowNamingHelper.ToWorkflowName("dafx-MyWorkflow");
|
||||||
|
|
||||||
|
Assert.Equal("MyWorkflow", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void ToWorkflowName_NullOrEmpty_ThrowsArgumentException(string? orchestrationFunctionName)
|
||||||
|
{
|
||||||
|
Assert.ThrowsAny<ArgumentException>(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("MyWorkflow")]
|
||||||
|
[InlineData("invalid-prefix-MyWorkflow")]
|
||||||
|
[InlineData("dafx")]
|
||||||
|
[InlineData("dafx-")]
|
||||||
|
public void ToWorkflowName_InvalidOrMissingPrefix_ThrowsArgumentException(string orchestrationFunctionName)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => WorkflowNamingHelper.ToWorkflowName(orchestrationFunctionName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetExecutorName_SimpleExecutorId_ReturnsSameName()
|
||||||
|
{
|
||||||
|
string result = WorkflowNamingHelper.GetExecutorName("OrderParser");
|
||||||
|
|
||||||
|
Assert.Equal("OrderParser", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetExecutorName_ExecutorIdWithGuidSuffix_ReturnsNameWithoutSuffix()
|
||||||
|
{
|
||||||
|
string result = WorkflowNamingHelper.GetExecutorName("Physicist_8884e71021334ce49517fa2b17b1695b");
|
||||||
|
|
||||||
|
Assert.Equal("Physicist", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void GetExecutorName_NullOrEmpty_ThrowsArgumentException(string? executorId)
|
||||||
|
{
|
||||||
|
Assert.ThrowsAny<ArgumentException>(() => WorkflowNamingHelper.GetExecutorName(executorId!));
|
||||||
|
}
|
||||||
|
}
|
||||||
+475
@@ -0,0 +1,475 @@
|
|||||||
|
// Copyright (c) Microsoft. All rights reserved.
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for validating the durable workflow Azure Functions samples
|
||||||
|
/// located in samples/Durable/Workflow/AzureFunctions.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Samples")]
|
||||||
|
[Trait("Category", "SampleValidation")]
|
||||||
|
public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string AzureFunctionsPort = "7071";
|
||||||
|
private const string AzuritePort = "10000";
|
||||||
|
private const string DtsPort = "8080";
|
||||||
|
|
||||||
|
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
|
||||||
|
private static readonly HttpClient s_sharedHttpClient = new();
|
||||||
|
private static readonly IConfiguration s_configuration =
|
||||||
|
new ConfigurationBuilder()
|
||||||
|
.AddUserSecrets(Assembly.GetExecutingAssembly())
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static bool s_infrastructureStarted;
|
||||||
|
private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1);
|
||||||
|
private static readonly string s_samplesPath = Path.GetFullPath(
|
||||||
|
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "Durable", "Workflow", "AzureFunctions"));
|
||||||
|
|
||||||
|
private readonly ITestOutputHelper _outputHelper = outputHelper;
|
||||||
|
|
||||||
|
async Task IAsyncLifetime.InitializeAsync()
|
||||||
|
{
|
||||||
|
if (!s_infrastructureStarted)
|
||||||
|
{
|
||||||
|
await this.StartSharedInfrastructureAsync();
|
||||||
|
s_infrastructureStarted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task IAsyncLifetime.DisposeAsync()
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SequentialWorkflowSampleValidationAsync()
|
||||||
|
{
|
||||||
|
string samplePath = Path.Combine(s_samplesPath, "01_SequentialWorkflow");
|
||||||
|
await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) =>
|
||||||
|
{
|
||||||
|
// Test the CancelOrder workflow
|
||||||
|
Uri cancelOrderUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/CancelOrder/run");
|
||||||
|
this._outputHelper.WriteLine($"Starting CancelOrder workflow via POST request to {cancelOrderUri}...");
|
||||||
|
|
||||||
|
using HttpContent cancelContent = new StringContent("12345", Encoding.UTF8, "text/plain");
|
||||||
|
using HttpResponseMessage cancelResponse = await s_sharedHttpClient.PostAsync(cancelOrderUri, cancelContent);
|
||||||
|
|
||||||
|
Assert.True(cancelResponse.IsSuccessStatusCode, $"CancelOrder request failed with status: {cancelResponse.StatusCode}");
|
||||||
|
string cancelResponseText = await cancelResponse.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("CancelOrder", cancelResponseText);
|
||||||
|
this._outputHelper.WriteLine($"CancelOrder response: {cancelResponseText}");
|
||||||
|
|
||||||
|
// Wait for the CancelOrder workflow to complete by checking logs
|
||||||
|
await this.WaitForConditionAsync(
|
||||||
|
condition: () =>
|
||||||
|
{
|
||||||
|
lock (logs)
|
||||||
|
{
|
||||||
|
bool exists = logs.Any(log => log.Message.Contains("Workflow completed"));
|
||||||
|
return Task.FromResult(exists);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "CancelOrder workflow completed",
|
||||||
|
timeout: s_orchestrationTimeout);
|
||||||
|
|
||||||
|
// Verify the executor activities ran in sequence
|
||||||
|
lock (logs)
|
||||||
|
{
|
||||||
|
Assert.True(logs.Any(log => log.Message.Contains("[Activity] OrderLookup:")), "OrderLookup activity not found in logs.");
|
||||||
|
Assert.True(logs.Any(log => log.Message.Contains("[Activity] OrderCancel:")), "OrderCancel activity not found in logs.");
|
||||||
|
Assert.True(logs.Any(log => log.Message.Contains("[Activity] SendEmail:")), "SendEmail activity not found in logs.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the OrderStatus workflow (shares OrderLookup executor with CancelOrder)
|
||||||
|
Uri orderStatusUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/OrderStatus/run");
|
||||||
|
this._outputHelper.WriteLine($"Starting OrderStatus workflow via POST request to {orderStatusUri}...");
|
||||||
|
|
||||||
|
using HttpContent statusContent = new StringContent("67890", Encoding.UTF8, "text/plain");
|
||||||
|
using HttpResponseMessage statusResponse = await s_sharedHttpClient.PostAsync(orderStatusUri, statusContent);
|
||||||
|
|
||||||
|
Assert.True(statusResponse.IsSuccessStatusCode, $"OrderStatus request failed with status: {statusResponse.StatusCode}");
|
||||||
|
string statusResponseText = await statusResponse.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("OrderStatus", statusResponseText);
|
||||||
|
this._outputHelper.WriteLine($"OrderStatus response: {statusResponseText}");
|
||||||
|
|
||||||
|
// Wait for the OrderStatus workflow to complete
|
||||||
|
await this.WaitForConditionAsync(
|
||||||
|
condition: () =>
|
||||||
|
{
|
||||||
|
lock (logs)
|
||||||
|
{
|
||||||
|
// Look for StatusReport activity which is unique to OrderStatus workflow
|
||||||
|
bool exists = logs.Any(log => log.Message.Contains("[Activity] StatusReport:"));
|
||||||
|
return Task.FromResult(exists);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "OrderStatus workflow completed",
|
||||||
|
timeout: s_orchestrationTimeout);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConcurrentWorkflowSampleValidationAsync()
|
||||||
|
{
|
||||||
|
string samplePath = Path.Combine(s_samplesPath, "02_ConcurrentWorkflow");
|
||||||
|
await this.RunSampleTestAsync(samplePath, requiresOpenAI: true, async (logs) =>
|
||||||
|
{
|
||||||
|
// Start the ExpertReview workflow with a science question
|
||||||
|
const string RequestBody = "What is temperature?";
|
||||||
|
using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain");
|
||||||
|
|
||||||
|
Uri startUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpertReview/run");
|
||||||
|
this._outputHelper.WriteLine($"Starting ExpertReview workflow via POST request to {startUri}...");
|
||||||
|
using HttpResponseMessage startResponse = await s_sharedHttpClient.PostAsync(startUri, content);
|
||||||
|
|
||||||
|
Assert.True(startResponse.IsSuccessStatusCode, $"ExpertReview request failed with status: {startResponse.StatusCode}");
|
||||||
|
string startResponseText = await startResponse.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("ExpertReview", startResponseText);
|
||||||
|
this._outputHelper.WriteLine($"ExpertReview response: {startResponseText}");
|
||||||
|
|
||||||
|
// Wait for the ParseQuestion executor to run
|
||||||
|
await this.WaitForConditionAsync(
|
||||||
|
condition: () =>
|
||||||
|
{
|
||||||
|
lock (logs)
|
||||||
|
{
|
||||||
|
bool exists = logs.Any(log => log.Message.Contains("[ParseQuestion]"));
|
||||||
|
return Task.FromResult(exists);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "ParseQuestion executor ran",
|
||||||
|
timeout: s_orchestrationTimeout);
|
||||||
|
|
||||||
|
// Wait for the Aggregator to complete (indicates fan-in from parallel agents)
|
||||||
|
await this.WaitForConditionAsync(
|
||||||
|
condition: () =>
|
||||||
|
{
|
||||||
|
lock (logs)
|
||||||
|
{
|
||||||
|
bool exists = logs.Any(log => log.Message.Contains("Aggregation complete"));
|
||||||
|
return Task.FromResult(exists);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Aggregator completed with parallel agent responses",
|
||||||
|
timeout: s_orchestrationTimeout);
|
||||||
|
|
||||||
|
// Verify the aggregator received responses from both AI agents
|
||||||
|
lock (logs)
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
logs.Any(log => log.Message.Contains("AI agent responses")),
|
||||||
|
"Aggregator did not log receiving AI agent responses.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartSharedInfrastructureAsync()
|
||||||
|
{
|
||||||
|
// Start Azurite if it's not already running
|
||||||
|
if (!await this.IsAzuriteRunningAsync())
|
||||||
|
{
|
||||||
|
await this.StartDockerContainerAsync(
|
||||||
|
containerName: "azurite",
|
||||||
|
image: "mcr.microsoft.com/azure-storage/azurite",
|
||||||
|
ports: ["-p", "10000:10000", "-p", "10001:10001", "-p", "10002:10002"]);
|
||||||
|
|
||||||
|
await this.WaitForConditionAsync(this.IsAzuriteRunningAsync, "Azurite is running", TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start DTS emulator if it's not already running
|
||||||
|
if (!await this.IsDtsEmulatorRunningAsync())
|
||||||
|
{
|
||||||
|
await this.StartDockerContainerAsync(
|
||||||
|
containerName: "dts-emulator",
|
||||||
|
image: "mcr.microsoft.com/dts/dts-emulator:latest",
|
||||||
|
ports: ["-p", "8080:8080", "-p", "8082:8082"]);
|
||||||
|
|
||||||
|
await this.WaitForConditionAsync(
|
||||||
|
condition: this.IsDtsEmulatorRunningAsync,
|
||||||
|
message: "DTS emulator is running",
|
||||||
|
timeout: TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> IsAzuriteRunningAsync()
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine(
|
||||||
|
$"Checking if Azurite is running at http://localhost:{AzuritePort}/devstoreaccount1...");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
||||||
|
using HttpResponseMessage response = await s_sharedHttpClient.GetAsync(
|
||||||
|
requestUri: new Uri($"http://localhost:{AzuritePort}/devstoreaccount1?comp=list"),
|
||||||
|
cancellationToken: timeoutCts.Token);
|
||||||
|
if (response.Headers.TryGetValues(
|
||||||
|
"Server",
|
||||||
|
out IEnumerable<string>? serverValues) && serverValues.Any(s => s.StartsWith("Azurite", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine($"Azurite is running, server: {string.Join(", ", serverValues)}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._outputHelper.WriteLine($"Azurite is not running. Status code: {response.StatusCode}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine($"Azurite is not running: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> IsDtsEmulatorRunningAsync()
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz...");
|
||||||
|
|
||||||
|
using HttpClient http2Client = new()
|
||||||
|
{
|
||||||
|
DefaultRequestVersion = new Version(2, 0),
|
||||||
|
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
||||||
|
using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token);
|
||||||
|
if (response.Content.Headers.ContentLength > 0)
|
||||||
|
{
|
||||||
|
string content = await response.Content.ReadAsStringAsync(timeoutCts.Token);
|
||||||
|
this._outputHelper.WriteLine($"DTS emulator health check response: {content}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine("DTS emulator is running");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartDockerContainerAsync(string containerName, string image, string[] ports)
|
||||||
|
{
|
||||||
|
await this.RunCommandAsync("docker", ["stop", containerName]);
|
||||||
|
await this.RunCommandAsync("docker", ["rm", containerName]);
|
||||||
|
|
||||||
|
List<string> args = ["run", "-d", "--name", containerName];
|
||||||
|
args.AddRange(ports);
|
||||||
|
args.Add(image);
|
||||||
|
|
||||||
|
this._outputHelper.WriteLine(
|
||||||
|
$"Starting new container: {containerName} with image: {image} and ports: {string.Join(", ", ports)}");
|
||||||
|
await this.RunCommandAsync("docker", args.ToArray());
|
||||||
|
this._outputHelper.WriteLine($"Container started: {containerName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WaitForConditionAsync(Func<Task<bool>> condition, string message, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine($"Waiting for '{message}'...");
|
||||||
|
|
||||||
|
using CancellationTokenSource cancellationTokenSource = new(timeout);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (await condition())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(1), cancellationTokenSource.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationTokenSource.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TimeoutException($"Timeout waiting for '{message}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
|
||||||
|
|
||||||
|
private async Task RunSampleTestAsync(string samplePath, bool requiresOpenAI, Func<IReadOnlyList<OutputLog>, Task> testAction)
|
||||||
|
{
|
||||||
|
List<OutputLog> logsContainer = [];
|
||||||
|
using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer, requiresOpenAI);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await this.WaitForAzureFunctionsAsync();
|
||||||
|
await testAction(logsContainer);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await this.StopProcessAsync(funcProcess);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Process StartFunctionApp(string samplePath, List<OutputLog> logs, bool requiresOpenAI)
|
||||||
|
{
|
||||||
|
ProcessStartInfo startInfo = new()
|
||||||
|
{
|
||||||
|
FileName = "dotnet",
|
||||||
|
Arguments = $"run -f {s_dotnetTargetFramework} --port {AzureFunctionsPort}",
|
||||||
|
WorkingDirectory = samplePath,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requiresOpenAI)
|
||||||
|
{
|
||||||
|
string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ??
|
||||||
|
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
|
||||||
|
string openAiDeployment = s_configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
|
||||||
|
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
|
||||||
|
|
||||||
|
this._outputHelper.WriteLine($"Using Azure OpenAI endpoint: {openAiEndpoint}, deployment: {openAiDeployment}");
|
||||||
|
|
||||||
|
startInfo.EnvironmentVariables["AZURE_OPENAI_ENDPOINT"] = openAiEndpoint;
|
||||||
|
startInfo.EnvironmentVariables["AZURE_OPENAI_DEPLOYMENT"] = openAiDeployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
startInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] =
|
||||||
|
$"Endpoint=http://localhost:{DtsPort};TaskHub=default;Authentication=None";
|
||||||
|
startInfo.EnvironmentVariables["AzureWebJobsStorage"] = "UseDevelopmentStorage=true";
|
||||||
|
|
||||||
|
Process process = new() { StartInfo = startInfo };
|
||||||
|
|
||||||
|
process.ErrorDataReceived += (sender, e) =>
|
||||||
|
{
|
||||||
|
if (e.Data != null)
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine($"[{startInfo.FileName}(err)]: {e.Data}");
|
||||||
|
lock (logs)
|
||||||
|
{
|
||||||
|
logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.OutputDataReceived += (sender, e) =>
|
||||||
|
{
|
||||||
|
if (e.Data != null)
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine($"[{startInfo.FileName}(out)]: {e.Data}");
|
||||||
|
lock (logs)
|
||||||
|
{
|
||||||
|
logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!process.Start())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to start the function app");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
process.BeginOutputReadLine();
|
||||||
|
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WaitForAzureFunctionsAsync()
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine(
|
||||||
|
$"Waiting for Azure Functions Core Tools to be ready at http://localhost:{AzureFunctionsPort}/...");
|
||||||
|
await this.WaitForConditionAsync(
|
||||||
|
condition: async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{AzureFunctionsPort}/");
|
||||||
|
using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(request);
|
||||||
|
this._outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}");
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Azure Functions Core Tools is ready",
|
||||||
|
timeout: TimeSpan.FromSeconds(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunCommandAsync(string command, string[] args)
|
||||||
|
{
|
||||||
|
ProcessStartInfo startInfo = new()
|
||||||
|
{
|
||||||
|
FileName = command,
|
||||||
|
Arguments = string.Join(" ", args),
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}");
|
||||||
|
|
||||||
|
using Process process = new() { StartInfo = startInfo };
|
||||||
|
process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}");
|
||||||
|
process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}");
|
||||||
|
if (!process.Start())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to start the command");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
process.BeginOutputReadLine();
|
||||||
|
|
||||||
|
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1));
|
||||||
|
await process.WaitForExitAsync(cancellationTokenSource.Token);
|
||||||
|
|
||||||
|
this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopProcessAsync(Process process)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine($"Killing process {process.ProcessName}#{process.Id}");
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
|
||||||
|
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10));
|
||||||
|
await process.WaitForExitAsync(timeoutCts.Token);
|
||||||
|
this._outputHelper.WriteLine($"Process exited: {process.Id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this._outputHelper.WriteLine($"Failed to stop process: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTargetFramework()
|
||||||
|
{
|
||||||
|
string filePath = new Uri(typeof(WorkflowSamplesValidation).Assembly.Location).LocalPath;
|
||||||
|
string directory = Path.GetDirectoryName(filePath)!;
|
||||||
|
string tfm = Path.GetFileName(directory);
|
||||||
|
if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return tfm;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Unable to find target framework in path: {filePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user