sub workflow support

This commit is contained in:
Shyju Krishnankutty
2026-01-30 18:11:46 -08:00
Unverified
parent d8a1a3977f
commit 5519533c60
9 changed files with 730 additions and 11 deletions
+1
View File
@@ -51,6 +51,7 @@
<Project Path="samples/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/08_SingleWorkflow/08_SingleWorkflow.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/09_SubWorkflows/09_SubWorkflows.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/09_Workflow_Concurrency/09_Workflow_Concurrency.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/10_Workflow_HITL/10_Workflow_HITL.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/11_WorkflowEvents/11_WorkflowEvents.csproj" />
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SubWorkflows</AssemblyName>
<RootNamespace>SubWorkflows</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,218 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SubWorkflows;
// ============================================
// Order Processing Models
// ============================================
/// <summary>
/// Represents an order being processed.
/// </summary>
internal sealed class OrderInfo
{
public required string OrderId { get; set; }
public decimal Amount { get; set; }
public string? PaymentTransactionId { get; set; }
public string? InventoryReservationId { get; set; }
public string? TrackingNumber { get; set; }
public string? Carrier { get; set; }
}
// ============================================
// Main Workflow Executors
// ============================================
/// <summary>
/// Entry point executor that receives the order ID and creates an OrderInfo object.
/// </summary>
internal sealed class OrderReceived() : Executor<string, OrderInfo>("OrderReceived")
{
public override ValueTask<OrderInfo> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"[OrderReceived] Processing order '{message}'");
Console.ResetColor();
OrderInfo order = new()
{
OrderId = message,
Amount = 99.99m // Simulated order amount
};
return ValueTask.FromResult(order);
}
}
/// <summary>
/// Final executor that outputs the completed order summary.
/// </summary>
internal sealed class OrderCompleted() : Executor<OrderInfo, string>("OrderCompleted")
{
public override ValueTask<string> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [OrderCompleted] Order '{message.OrderId}' successfully processed!");
Console.WriteLine($"│ Payment: {message.PaymentTransactionId}");
Console.WriteLine($"│ Inventory: {message.InventoryReservationId}");
Console.WriteLine($"│ Shipping: {message.Carrier} - {message.TrackingNumber}");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult($"Order {message.OrderId} completed. Tracking: {message.TrackingNumber}");
}
}
// ============================================
// Payment Sub-Workflow Executors
// ============================================
/// <summary>
/// Validates payment information for an order.
/// </summary>
internal sealed class ValidatePayment() : Executor<OrderInfo, OrderInfo>("ValidatePayment")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Payment/ValidatePayment] Validating payment for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Payment/ValidatePayment] Payment validated for ${message.Amount}");
Console.ResetColor();
return message;
}
}
/// <summary>
/// Charges the payment for an order.
/// </summary>
internal sealed class ChargePayment() : Executor<OrderInfo, OrderInfo>("ChargePayment")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Payment/ChargePayment] Charging ${message.Amount} for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
message.PaymentTransactionId = $"TXN-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Payment/ChargePayment] ✓ Payment processed: {message.PaymentTransactionId}");
Console.ResetColor();
return message;
}
}
// ============================================
// Inventory Sub-Workflow Executors
// ============================================
/// <summary>
/// Checks inventory availability for an order.
/// </summary>
internal sealed class CheckInventory() : Executor<OrderInfo, OrderInfo>("CheckInventory")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($" [Inventory/CheckInventory] Checking inventory for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine(" [Inventory/CheckInventory] ✓ Items available in stock");
Console.ResetColor();
return message;
}
}
/// <summary>
/// Reserves inventory for an order.
/// </summary>
internal sealed class ReserveInventory() : Executor<OrderInfo, OrderInfo>("ReserveInventory")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($" [Inventory/ReserveInventory] Reserving items for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
message.InventoryReservationId = $"RES-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($" [Inventory/ReserveInventory] ✓ Reserved: {message.InventoryReservationId}");
Console.ResetColor();
return message;
}
}
// ============================================
// Shipping Sub-Workflow Executors
// ============================================
/// <summary>
/// Selects a shipping carrier for an order.
/// </summary>
internal sealed class SelectCarrier() : Executor<OrderInfo, OrderInfo>("SelectCarrier")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($" [Shipping/SelectCarrier] Selecting carrier for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
message.Carrier = message.Amount > 50 ? "Express" : "Standard";
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($" [Shipping/SelectCarrier] ✓ Selected carrier: {message.Carrier}");
Console.ResetColor();
return message;
}
}
/// <summary>
/// Creates shipment and generates tracking number.
/// </summary>
internal sealed class CreateShipment() : Executor<OrderInfo, OrderInfo>("CreateShipment")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($" [Shipping/CreateShipment] Creating shipment for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
message.TrackingNumber = $"TRACK-{Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()}";
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($" [Shipping/CreateShipment] ✓ Shipment created: {message.TrackingNumber}");
Console.ResetColor();
return message;
}
}
@@ -0,0 +1,175 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use sub-workflows within a durable orchestration.
// Sub-workflows allow you to compose complex workflows from simpler, reusable components.
//
// The sample implements an order processing workflow with three sub-workflows:
// 1. PaymentProcessing - Validates and processes payment
// 2. InventoryManagement - Checks and reserves inventory
// 3. ShippingArrangement - Arranges shipping and generates tracking
//
// Each sub-workflow runs as a separate orchestration instance, visible in the DTS dashboard.
// This provides:
// - Modular, reusable workflow components
// - Independent checkpointing and replay
// - Hierarchical visualization in the dashboard
// - Failure isolation between parent and child workflows
using Microsoft.Agents.AI.DurableTask;
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 SubWorkflows;
// Get DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// ============================================
// Step 1: Build the Payment Processing sub-workflow
// ============================================
ValidatePayment validatePayment = new();
ChargePayment chargePayment = new();
Workflow paymentWorkflow = new WorkflowBuilder(validatePayment)
.WithName("SubPaymentProcessing")
.WithDescription("Validates and processes payment for an order")
.AddEdge(validatePayment, chargePayment)
.Build();
// ============================================
// Step 2: Build the Inventory Management sub-workflow
// ============================================
CheckInventory checkInventory = new();
ReserveInventory reserveInventory = new();
Workflow inventoryWorkflow = new WorkflowBuilder(checkInventory)
.WithName("SubInventoryManagement")
.WithDescription("Checks availability and reserves inventory")
.AddEdge(checkInventory, reserveInventory)
.Build();
// ============================================
// Step 3: Build the Shipping Arrangement sub-workflow
// ============================================
SelectCarrier selectCarrier = new();
CreateShipment createShipment = new();
Workflow shippingWorkflow = new WorkflowBuilder(selectCarrier)
.WithName("SubShippingArrangement")
.WithDescription("Selects carrier and creates shipment")
.AddEdge(selectCarrier, createShipment)
.Build();
// ============================================
// Step 4: Build the Main Order Processing workflow using sub-workflows
// ============================================
// Bind sub-workflows as executors for use in the main workflow
ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment");
ExecutorBinding inventoryExecutor = inventoryWorkflow.BindAsExecutor("Inventory");
ExecutorBinding shippingExecutor = shippingWorkflow.BindAsExecutor("Shipping");
// Create entry and exit executors for the main workflow
OrderReceived orderReceived = new();
OrderCompleted orderCompleted = new();
// Build the main workflow: OrderReceived -> Payment -> Inventory -> Shipping -> OrderCompleted
Workflow orderProcessingWorkflow = new WorkflowBuilder(orderReceived)
.WithName("OrderProcessing")
.WithDescription("Processes an order through payment, inventory, and shipping")
.AddEdge(orderReceived, paymentExecutor)
.AddEdge(paymentExecutor, inventoryExecutor)
.AddEdge(inventoryExecutor, shippingExecutor)
.AddEdge(shippingExecutor, orderCompleted)
.Build();
// ============================================
// Step 5: Configure and start the host
// ============================================
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
// Register only the main workflow - sub-workflows are discovered automatically!
services.ConfigureDurableWorkflows(
options => options.Workflows.AddWorkflow(orderProcessingWorkflow),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
// Get the IWorkflowClient from DI
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("╔══════════════════════════════════════════════════════════════════╗");
Console.WriteLine("║ Durable Sub-Workflows Sample ║");
Console.WriteLine("╠══════════════════════════════════════════════════════════════════╣");
Console.WriteLine("║ Main Workflow: OrderProcessing ║");
Console.WriteLine("║ ├── Payment (sub-workflow) ║");
Console.WriteLine("║ │ ├── ValidatePayment (1s) ║");
Console.WriteLine("║ │ └── ChargePayment (2s) ║");
Console.WriteLine("║ ├── Inventory (sub-workflow) ║");
Console.WriteLine("║ │ ├── CheckInventory (1s) ║");
Console.WriteLine("║ │ └── ReserveInventory (2s) ║");
Console.WriteLine("║ └── Shipping (sub-workflow) ║");
Console.WriteLine("║ ├── SelectCarrier (1s) ║");
Console.WriteLine("║ └── CreateShipment (2s) ║");
Console.WriteLine("╚══════════════════════════════════════════════════════════════════╝");
Console.WriteLine();
Console.WriteLine("Open the DTS dashboard at http://localhost:8080 to see the");
Console.WriteLine("parent-child orchestration hierarchy in the Timeline view!");
Console.WriteLine();
Console.WriteLine("Enter an order ID (or 'exit'):");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
await StartNewWorkflowAsync(input, orderProcessingWorkflow, workflowClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
// Start a new workflow using IWorkflowClient
async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client)
{
Console.WriteLine($"\nStarting order processing for '{orderId}'...");
await using DurableRun run = (DurableRun)await client.RunAsync(workflow, orderId);
Console.WriteLine($"Instance ID: {run.InstanceId}");
Console.WriteLine("Check the DTS dashboard Timeline tab to see sub-orchestrations!");
Console.WriteLine();
try
{
string? result = await run.WaitForCompletionAsync();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"✓ Order completed: {result}");
Console.ResetColor();
}
catch (InvalidOperationException ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"✗ Failed: {ex.Message}");
Console.ResetColor();
}
}
@@ -0,0 +1,153 @@
# Sub-Workflows Console Sample
This sample demonstrates how to compose workflows hierarchically by using sub-workflows within a durable orchestration. Sub-workflows are executed as separate orchestration instances, providing modularity, reusability, and excellent visibility in the Durable Task dashboard.
## Overview
The sample implements an order processing system with three sub-workflows:
```
OrderProcessing (Main Workflow)
??? OrderReceived
??? Payment (Sub-Workflow)
? ??? ValidatePayment (1s)
? ??? ChargePayment (2s)
??? Inventory (Sub-Workflow)
? ??? CheckInventory (1s)
? ??? ReserveInventory (2s)
??? Shipping (Sub-Workflow)
? ??? SelectCarrier (1s)
? ??? CreateShipment (2s)
??? OrderCompleted
```
## Key Concepts
### Sub-Workflow Benefits
1. **Modularity**: Each sub-workflow encapsulates related logic (payment, inventory, shipping)
2. **Reusability**: Sub-workflows can be used in multiple parent workflows
3. **Independent Execution**: Each sub-workflow runs as a separate orchestration instance
4. **Dashboard Visibility**: Sub-workflows appear in the Timeline view with parent-child relationships
5. **Failure Isolation**: A failure in a sub-workflow doesn't corrupt the parent's state
### How Sub-Workflows Work
```csharp
// Step 1: Build a sub-workflow
Workflow paymentWorkflow = new WorkflowBuilder(validatePayment)
.WithName("PaymentProcessing")
.AddEdge(validatePayment, chargePayment)
.Build();
// Step 2: Bind it as an executor for use in a parent workflow
ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment");
// Step 3: Use the sub-workflow executor in the main workflow
Workflow mainWorkflow = new WorkflowBuilder(orderReceived)
.AddEdge(orderReceived, paymentExecutor) // Sub-workflow as an edge target
.AddEdge(paymentExecutor, inventoryExecutor)
.Build();
// Step 4: Register only the main workflow - sub-workflows are discovered automatically!
services.ConfigureDurableWorkflows(
options => options.Workflows.AddWorkflow(mainWorkflow),
...);
```
### Dashboard Visualization
Open the DTS dashboard at `http://localhost:8080` after running a workflow:
1. Click on the main orchestration instance
2. Switch to the **Timeline** tab
3. You'll see a hierarchical view showing:
- `OrderProcessing` (parent orchestration)
- `PaymentProcessing` (sub-orchestration)
- `InventoryManagement` (sub-orchestration)
- `ShippingArrangement` (sub-orchestration)
Each sub-orchestration has its own instance ID and can be inspected independently.
## Environment Setup
See the [README.md](../README.md) file in the parent directory for information on:
- Installing prerequisites (.NET 10+, Docker)
- Starting the Durable Task Scheduler emulator
- Configuring environment variables
## Running the Sample
```bash
# Start the DTS emulator (if not already running)
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
# Run the sample
cd dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows
dotnet run --framework net10.0
```
### Sample Session
```text
????????????????????????????????????????????????????????????????????
? Durable Sub-Workflows Sample ?
????????????????????????????????????????????????????????????????????
? Main Workflow: OrderProcessing ?
? ??? Payment (sub-workflow) ?
? ? ??? ValidatePayment (1s) ?
? ? ??? ChargePayment (2s) ?
? ??? Inventory (sub-workflow) ?
? ? ??? CheckInventory (1s) ?
? ? ??? ReserveInventory (2s) ?
? ??? Shipping (sub-workflow) ?
? ??? SelectCarrier (1s) ?
? ??? CreateShipment (2s) ?
????????????????????????????????????????????????????????????????????
Open the DTS dashboard at http://localhost:8080 to see the
parent-child orchestration hierarchy in the Timeline view!
Enter an order ID (or 'exit'):
> ORD-12345
Starting order processing for 'ORD-12345'...
Instance ID: abc123def456
Check the DTS dashboard Timeline tab to see sub-orchestrations!
[OrderReceived] Processing order 'ORD-12345'
[Payment/ValidatePayment] Validating payment for order 'ORD-12345'...
[Payment/ValidatePayment] Payment validated for $99.99
[Payment/ChargePayment] Charging $99.99 for order 'ORD-12345'...
[Payment/ChargePayment] ? Payment processed: TXN-A1B2C3D4
[Inventory/CheckInventory] Checking inventory for order 'ORD-12345'...
[Inventory/CheckInventory] ? Items available in stock
[Inventory/ReserveInventory] Reserving items for order 'ORD-12345'...
[Inventory/ReserveInventory] ? Reserved: RES-E5F6G7H8
[Shipping/SelectCarrier] Selecting carrier for order 'ORD-12345'...
[Shipping/SelectCarrier] ? Selected carrier: Express
[Shipping/CreateShipment] Creating shipment for order 'ORD-12345'...
[Shipping/CreateShipment] ? Shipment created: TRACK-I9J0K1L2M3
???????????????????????????????????????????????????????????????????
? [OrderCompleted] Order 'ORD-12345' successfully processed!
? Payment: TXN-A1B2C3D4
? Inventory: RES-E5F6G7H8
? Shipping: Express - TRACK-I9J0K1L2M3
???????????????????????????????????????????????????????????????????
? Order completed. Tracking: TRACK-I9J0K1L2M3
```
## Comparison with In-Process Sub-Workflows
| Feature | In-Process | Durable |
|---------|------------|---------|
| Execution | Same process, synchronized supersteps | Separate orchestration instances |
| Visibility | Single workflow view | Hierarchical dashboard view |
| Checkpointing | Parent checkpoints include child state | Independent checkpoints per sub-workflow |
| Failure Recovery | Parent must handle child failures | Automatic retry with state preservation |
| Scalability | Single process | Can scale across workers |
## Related Samples
- [06_SubWorkflows (In-Process)](../../../GettingStarted/Workflows/_Foundational/06_SubWorkflows) - In-process sub-workflow execution
- [08_SingleWorkflow](../08_SingleWorkflow) - Basic durable workflow without sub-workflows
@@ -9,6 +9,8 @@ This directory contains samples for console app hosting of durable agents. These
- **[05_AgentOrchestration_HITL](05_AgentOrchestration_HITL)**: A sample that demonstrates how to implement a human-in-the-loop workflow using durable orchestration, including interactive approval prompts.
- **[06_LongRunningTools](06_LongRunningTools)**: A sample that demonstrates how agents can start and interact with durable orchestrations from tool calls to enable long-running tool scenarios.
- **[07_ReliableStreaming](07_ReliableStreaming)**: A sample that demonstrates how to implement reliable streaming for durable agents using Redis Streams, enabling clients to disconnect and reconnect without losing messages.
- **[08_SingleWorkflow](08_SingleWorkflow)**: A sample that demonstrates how to run a simple workflow as a durable orchestration, showcasing activity durability and automatic resume on restart.
- **[09_SubWorkflows](09_SubWorkflows)**: A sample that demonstrates how to compose workflows hierarchically using sub-workflows, which run as separate orchestration instances visible in the DTS dashboard.
## Running the Samples
@@ -565,8 +565,8 @@ internal class DurableWorkflowRunner
/// <returns>A <see cref="WorkflowExecutorInfo"/> containing metadata about the executor.</returns>
/// <exception cref="InvalidOperationException">Thrown when the executor ID is not found in bindings.</exception>
/// <remarks>
/// This method determines the executor type (agentic vs regular) and extracts request port
/// information for human-in-the-loop executors.
/// This method determines the executor type (agentic, sub-workflow, request port, or regular)
/// and extracts the appropriate metadata for each type.
/// </remarks>
private static WorkflowExecutorInfo CreateExecutorInfo(
string executorId,
@@ -579,8 +579,9 @@ internal class DurableWorkflowRunner
bool isAgentic = WorkflowHelper.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);
return new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow);
}
/// <summary>
@@ -1092,6 +1093,12 @@ internal class DurableWorkflowRunner
/// </item>
/// <item>
/// <description>
/// <strong>Sub-Workflow Executors:</strong> Executed as sub-orchestrations.
/// The child workflow runs as a separate orchestration instance with its own instance ID.
/// </description>
/// </item>
/// <item>
/// <description>
/// <strong>Regular Executors:</strong> Invoked as Durable Task activities. Input is wrapped
/// with state and type information via <see cref="ActivityInputWithState"/>.
/// </description>
@@ -1119,6 +1126,12 @@ internal class DurableWorkflowRunner
return await ExecuteRequestPortAsync(context, executorInfo, input, logger, customStatus).ConfigureAwait(true);
}
// Handle Sub-Workflow executors by calling as sub-orchestrations
if (executorInfo.IsSubworkflowExecutor)
{
return await ExecuteSubWorkflowAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
if (!executorInfo.IsAgenticExecutor)
{
string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
@@ -1139,6 +1152,54 @@ internal class DurableWorkflowRunner
return await ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
}
/// <summary>
/// Executes a sub-workflow as a sub-orchestration.
/// </summary>
/// <param name="context">The orchestration context for calling sub-orchestrations.</param>
/// <param name="executorInfo">The executor info containing the sub-workflow reference.</param>
/// <param name="input">The input to pass to the sub-workflow.</param>
/// <param name="logger">The logger for tracing.</param>
/// <returns>The result of the sub-workflow execution.</returns>
/// <remarks>
/// <para>
/// Sub-workflows are executed as separate orchestration instances.
/// This provides:
/// </para>
/// <list type="bullet">
/// <item><description>Separate instance ID for the child workflow (visible in dashboard)</description></item>
/// <item><description>Independent checkpointing and replay</description></item>
/// <item><description>Failure isolation (child failure doesn't corrupt parent state)</description></item>
/// <item><description>Hierarchical visualization in the Durable Task dashboard</description></item>
/// </list>
/// </remarks>
private static async Task<string> ExecuteSubWorkflowAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
ILogger logger)
{
Workflow subWorkflow = executorInfo.SubWorkflow!;
string subOrchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(subWorkflow.Name!);
logger.LogDebug(
"Calling sub-orchestration '{SubOrchestrationName}' for sub-workflow '{SubWorkflowName}'",
subOrchestrationName,
subWorkflow.Name);
// Call the sub-workflow as a sub-orchestration
// The Durable Task Framework handles checkpointing, replay, and failure isolation
string result = await context.CallSubOrchestratorAsync<string>(
subOrchestrationName,
input).ConfigureAwait(true);
logger.LogDebug(
"Sub-orchestration '{SubOrchestrationName}' completed with result length: {ResultLength}",
subOrchestrationName,
result?.Length ?? 0);
return result ?? string.Empty;
}
/// <summary>
/// Executes a request port executor by waiting for an external event (human-in-the-loop).
/// </summary>
@@ -49,13 +49,21 @@ public static class DurableWorkflowServiceCollectionExtensions
// Register the workflow runner
services.AddSingleton<DurableWorkflowRunner>();
// Build registration info for all workflows
// Build registration info for all workflows (including sub-workflows)
List<WorkflowRegistrationInfo> registrations = [];
HashSet<string> registeredActivities = [];
HashSet<string> registeredOrchestrations = [];
foreach (KeyValuePair<string, Workflow> workflowEntry in durableOptions.Workflows.Workflows)
// Take a snapshot of the workflows to avoid collection modified during enumeration
// (sub-workflows are added to the collection during recursive registration)
foreach (Workflow workflow in durableOptions.Workflows.Workflows.Values.ToList())
{
registrations.Add(BuildWorkflowRegistration(workflowEntry.Value, registeredActivities));
BuildWorkflowRegistrationRecursive(
workflow,
durableOptions.Workflows,
registrations,
registeredActivities,
registeredOrchestrations);
}
// Get any AI agents that were auto-registered from workflows
@@ -121,6 +129,53 @@ public static class DurableWorkflowServiceCollectionExtensions
return services;
}
/// <summary>
/// Recursively builds workflow registrations, including any sub-workflows.
/// Also adds sub-workflows to the workflow options so they can be looked up at runtime.
/// </summary>
/// <param name="workflow">The workflow to register.</param>
/// <param name="workflowOptions">The workflow options to add sub-workflows to.</param>
/// <param name="registrations">The list to add registrations to.</param>
/// <param name="registeredActivities">Set of already registered activity names to avoid duplicates.</param>
/// <param name="registeredOrchestrations">Set of already registered orchestration names to avoid duplicates.</param>
private static void BuildWorkflowRegistrationRecursive(
Workflow workflow,
DurableWorkflowOptions workflowOptions,
List<WorkflowRegistrationInfo> registrations,
HashSet<string> registeredActivities,
HashSet<string> registeredOrchestrations)
{
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!);
// Skip if this workflow is already registered (handles circular references)
if (!registeredOrchestrations.Add(orchestrationName))
{
return;
}
// Build registration for this workflow
registrations.Add(BuildWorkflowRegistration(workflow, registeredActivities));
// Recursively register any sub-workflows
foreach (KeyValuePair<string, ExecutorBinding> entry in workflow.ReflectExecutors())
{
if (entry.Value is SubworkflowBinding subworkflowBinding)
{
Workflow subWorkflow = subworkflowBinding.WorkflowInstance;
// Add sub-workflow to options so it can be looked up by the runner at runtime
workflowOptions.AddWorkflow(subWorkflow);
BuildWorkflowRegistrationRecursive(
subWorkflow,
workflowOptions,
registrations,
registeredActivities,
registeredOrchestrations);
}
}
}
private static WorkflowRegistrationInfo BuildWorkflowRegistration(
Workflow workflow,
HashSet<string> registeredActivities)
@@ -139,6 +194,12 @@ public static class DurableWorkflowServiceCollectionExtensions
continue;
}
// Skip sub-workflow executors - they're handled as sub-orchestrations
if (entry.Value is SubworkflowBinding)
{
continue;
}
string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key);
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
@@ -237,7 +298,18 @@ public static class DurableWorkflowServiceCollectionExtensions
}
// Try to load the type directly (for types not in supported types)
return Type.GetType(inputTypeName) ?? supportedTypes.FirstOrDefault() ?? typeof(string);
Type? loadedType = Type.GetType(inputTypeName);
// If the loaded type is string but the executor doesn't support string,
// fall back to the first supported type. This handles the case where
// serialized JSON objects are passed with type "System.String" but need
// to be deserialized to the actual expected type (e.g., OrderInfo).
if (loadedType == typeof(string) && !supportedTypes.Contains(typeof(string)))
{
return supportedTypes.FirstOrDefault() ?? typeof(string);
}
return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string);
}
/// <summary>
@@ -11,13 +11,19 @@ namespace Microsoft.Agents.AI.DurableTask;
/// <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>
[DebuggerDisplay("{ExecutorId}, Agentic = {IsAgenticExecutor}, HITL = {IsRequestPortExecutor}")]
internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor, RequestPort? RequestPort = null)
/// <param name="SubWorkflow">The sub-workflow if this executor is a sub-workflow executor; otherwise, null.</param>
[DebuggerDisplay("{ExecutorId}, Agentic = {IsAgenticExecutor}, HITL = {IsRequestPortExecutor}, SubWorkflow = {IsSubworkflowExecutor}")]
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;
}
/// <summary>
@@ -176,7 +182,8 @@ internal static class WorkflowHelper
ExecutorBinding executorBinding = executors[executorId];
bool isAgentic = IsAgentExecutorType(executorBinding.ExecutorType);
RequestPort? requestPort = (executorBinding is RequestPortBinding rpb) ? rpb.Port : null;
levelExecutors.Add(new WorkflowExecutorInfo(executorId, isAgentic, requestPort));
Workflow? subWorkflow = (executorBinding is SubworkflowBinding swb) ? swb.WorkflowInstance : null;
levelExecutors.Add(new WorkflowExecutorInfo(executorId, isAgentic, requestPort, subWorkflow));
// Check Fan-In for this executor (excluding back-edges)
int nonBackEdgePredecessors = predecessors[executorId]
@@ -218,7 +225,8 @@ internal static class WorkflowHelper
{
bool isAgentic = IsAgentExecutorType(executor.Value.ExecutorType);
RequestPort? requestPort = (executor.Value is RequestPortBinding rpb) ? rpb.Port : null;
remainingExecutors.Add(new WorkflowExecutorInfo(executor.Key, isAgentic, requestPort));
Workflow? subWorkflow = (executor.Value is SubworkflowBinding swb) ? swb.WorkflowInstance : null;
remainingExecutors.Add(new WorkflowExecutorInfo(executor.Key, isAgentic, requestPort, subWorkflow));
}
}