From 5519533c600b4abc2660fde9ba5a949c18eb33e1 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Fri, 30 Jan 2026 18:11:46 -0800 Subject: [PATCH] sub workflow support --- dotnet/agent-framework-dotnet.slnx | 1 + .../09_SubWorkflows/09_SubWorkflows.csproj | 29 +++ .../ConsoleApps/09_SubWorkflows/Executors.cs | 218 ++++++++++++++++++ .../ConsoleApps/09_SubWorkflows/Program.cs | 175 ++++++++++++++ .../ConsoleApps/09_SubWorkflows/README.md | 153 ++++++++++++ .../DurableAgents/ConsoleApps/README.md | 2 + .../DurableWorkflowRunner.cs | 67 +++++- ...ableWorkflowServiceCollectionExtensions.cs | 80 ++++++- .../WorkflowHelper.cs | 16 +- 9 files changed, 730 insertions(+), 11 deletions(-) create mode 100644 dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/09_SubWorkflows.csproj create mode 100644 dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/Executors.cs create mode 100644 dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/Program.cs create mode 100644 dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 31816b9571..b203ed6345 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -51,6 +51,7 @@ + diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/09_SubWorkflows.csproj b/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/09_SubWorkflows.csproj new file mode 100644 index 0000000000..904a36f129 --- /dev/null +++ b/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/09_SubWorkflows.csproj @@ -0,0 +1,29 @@ + + + net10.0 + Exe + enable + enable + SubWorkflows + SubWorkflows + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/Executors.cs b/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/Executors.cs new file mode 100644 index 0000000000..a56bf73a70 --- /dev/null +++ b/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/Executors.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace SubWorkflows; + +// ============================================ +// Order Processing Models +// ============================================ + +/// +/// Represents an order being processed. +/// +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 +// ============================================ + +/// +/// Entry point executor that receives the order ID and creates an OrderInfo object. +/// +internal sealed class OrderReceived() : Executor("OrderReceived") +{ + public override ValueTask 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); + } +} + +/// +/// Final executor that outputs the completed order summary. +/// +internal sealed class OrderCompleted() : Executor("OrderCompleted") +{ + public override ValueTask 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 +// ============================================ + +/// +/// Validates payment information for an order. +/// +internal sealed class ValidatePayment() : Executor("ValidatePayment") +{ + public override async ValueTask 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; + } +} + +/// +/// Charges the payment for an order. +/// +internal sealed class ChargePayment() : Executor("ChargePayment") +{ + public override async ValueTask 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 +// ============================================ + +/// +/// Checks inventory availability for an order. +/// +internal sealed class CheckInventory() : Executor("CheckInventory") +{ + public override async ValueTask 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; + } +} + +/// +/// Reserves inventory for an order. +/// +internal sealed class ReserveInventory() : Executor("ReserveInventory") +{ + public override async ValueTask 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 +// ============================================ + +/// +/// Selects a shipping carrier for an order. +/// +internal sealed class SelectCarrier() : Executor("SelectCarrier") +{ + public override async ValueTask 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; + } +} + +/// +/// Creates shipment and generates tracking number. +/// +internal sealed class CreateShipment() : Executor("CreateShipment") +{ + public override async ValueTask 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; + } +} diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/Program.cs b/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/Program.cs new file mode 100644 index 0000000000..8fed264c2b --- /dev/null +++ b/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/Program.cs @@ -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(); + +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(); + } +} diff --git a/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/README.md b/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/README.md new file mode 100644 index 0000000000..6be9492867 --- /dev/null +++ b/dotnet/samples/DurableAgents/ConsoleApps/09_SubWorkflows/README.md @@ -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 diff --git a/dotnet/samples/DurableAgents/ConsoleApps/README.md b/dotnet/samples/DurableAgents/ConsoleApps/README.md index 1bd2b0d224..5ad0efc708 100644 --- a/dotnet/samples/DurableAgents/ConsoleApps/README.md +++ b/dotnet/samples/DurableAgents/ConsoleApps/README.md @@ -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 diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs index b210d8b7e2..c7073c2be3 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs @@ -565,8 +565,8 @@ internal class DurableWorkflowRunner /// A containing metadata about the executor. /// Thrown when the executor ID is not found in bindings. /// - /// 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. /// 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); } /// @@ -1092,6 +1093,12 @@ internal class DurableWorkflowRunner /// /// /// + /// Sub-Workflow Executors: Executed as sub-orchestrations. + /// The child workflow runs as a separate orchestration instance with its own instance ID. + /// + /// + /// + /// /// Regular Executors: Invoked as Durable Task activities. Input is wrapped /// with state and type information via . /// @@ -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); } + /// + /// Executes a sub-workflow as a sub-orchestration. + /// + /// The orchestration context for calling sub-orchestrations. + /// The executor info containing the sub-workflow reference. + /// The input to pass to the sub-workflow. + /// The logger for tracing. + /// The result of the sub-workflow execution. + /// + /// + /// Sub-workflows are executed as separate orchestration instances. + /// This provides: + /// + /// + /// Separate instance ID for the child workflow (visible in dashboard) + /// Independent checkpointing and replay + /// Failure isolation (child failure doesn't corrupt parent state) + /// Hierarchical visualization in the Durable Task dashboard + /// + /// + private static async Task 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( + subOrchestrationName, + input).ConfigureAwait(true); + + logger.LogDebug( + "Sub-orchestration '{SubOrchestrationName}' completed with result length: {ResultLength}", + subOrchestrationName, + result?.Length ?? 0); + + return result ?? string.Empty; + } + /// /// Executes a request port executor by waiting for an external event (human-in-the-loop). /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowServiceCollectionExtensions.cs index f083e2b42d..4118bc3b12 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowServiceCollectionExtensions.cs @@ -49,13 +49,21 @@ public static class DurableWorkflowServiceCollectionExtensions // Register the workflow runner services.AddSingleton(); - // Build registration info for all workflows + // Build registration info for all workflows (including sub-workflows) List registrations = []; HashSet registeredActivities = []; + HashSet registeredOrchestrations = []; - foreach (KeyValuePair 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; } + /// + /// Recursively builds workflow registrations, including any sub-workflows. + /// Also adds sub-workflows to the workflow options so they can be looked up at runtime. + /// + /// The workflow to register. + /// The workflow options to add sub-workflows to. + /// The list to add registrations to. + /// Set of already registered activity names to avoid duplicates. + /// Set of already registered orchestration names to avoid duplicates. + private static void BuildWorkflowRegistrationRecursive( + Workflow workflow, + DurableWorkflowOptions workflowOptions, + List registrations, + HashSet registeredActivities, + HashSet 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 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 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); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowHelper.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowHelper.cs index 4fbb5b2368..22b917daa2 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowHelper.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/WorkflowHelper.cs @@ -11,13 +11,19 @@ namespace Microsoft.Agents.AI.DurableTask; /// The unique identifier of the executor. /// Indicates whether this executor is an agentic executor. /// The request port if this executor is a request port executor; otherwise, null. -[DebuggerDisplay("{ExecutorId}, Agentic = {IsAgenticExecutor}, HITL = {IsRequestPortExecutor}")] -internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor, RequestPort? RequestPort = null) +/// The sub-workflow if this executor is a sub-workflow executor; otherwise, null. +[DebuggerDisplay("{ExecutorId}, Agentic = {IsAgenticExecutor}, HITL = {IsRequestPortExecutor}, SubWorkflow = {IsSubworkflowExecutor}")] +internal sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor, RequestPort? RequestPort = null, Workflow? SubWorkflow = null) { /// /// Gets a value indicating whether this executor is a request port executor (human-in-the-loop). /// public bool IsRequestPortExecutor => this.RequestPort is not null; + + /// + /// Gets a value indicating whether this executor is a sub-workflow executor. + /// + public bool IsSubworkflowExecutor => this.SubWorkflow is not null; } /// @@ -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)); } }