From faa456310dff4fa4db815b860050839a4c918890 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Tue, 24 Feb 2026 10:25:34 -0800 Subject: [PATCH 1/5] .NET: [Feature Branch] Add nested sub-workflow support for durable workflows --- dotnet/agent-framework-dotnet.slnx | 1 + .../07_SubWorkflows/07_SubWorkflows.csproj | 28 +++ .../ConsoleApps/07_SubWorkflows/Executors.cs | 233 ++++++++++++++++++ .../ConsoleApps/07_SubWorkflows/Program.cs | 146 +++++++++++ .../ConsoleApps/07_SubWorkflows/README.md | 123 +++++++++ .../Workflows/DurableExecutorDispatcher.cs | 84 ++++++- .../Workflows/DurableWorkflowResult.cs | 9 + .../Workflows/DurableWorkflowRunner.cs | 15 +- .../WorkflowConsoleAppSamplesValidation.cs | 77 ++++++ 9 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj create mode 100644 dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/Executors.cs create mode 100644 dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/Program.cs create mode 100644 dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 2c5ea815c5..246b3e7e7b 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -54,6 +54,7 @@ + diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj new file mode 100644 index 0000000000..d8d36ead01 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj @@ -0,0 +1,28 @@ + + + net10.0 + Exe + enable + enable + SubWorkflows + SubWorkflows + + + + + + + + + + + + + + + diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/Executors.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/Executors.cs new file mode 100644 index 0000000000..25c7228642 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/Executors.cs @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; +using Microsoft.Agents.AI.Workflows.Checkpointing; + +namespace SubWorkflows; + +/// +/// Event emitted when the fraud check risk score is calculated. +/// +internal sealed class FraudRiskAssessedEvent(int riskScore) : WorkflowEvent($"Risk score: {riskScore}/100") +{ + public int RiskScore => riskScore; +} + +/// +/// Represents an order being processed through the workflow. +/// +internal sealed class OrderInfo +{ + public required string OrderId { get; set; } + + public decimal Amount { get; set; } + + public string? PaymentTransactionId { 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($"│ 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.FromMilliseconds(100), 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.FromMilliseconds(100), 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; + } +} + +// FraudCheck sub-sub-workflow executors (nested inside Payment) + +/// +/// Analyzes transaction patterns for potential fraud. +/// +internal sealed class AnalyzePatterns() : Executor("AnalyzePatterns") +{ + public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order '{message.OrderId}'..."); + Console.ResetColor(); + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + // Store analysis results in shared state for the next executor in this sub-workflow + int patternsFound = new Random().Next(0, 5); + await context.QueueStateUpdateAsync("patternsFound", patternsFound, cancellationToken: cancellationToken); + + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete ({patternsFound} suspicious patterns)"); + Console.ResetColor(); + + return message; + } +} + +/// +/// Calculates a risk score for the transaction. +/// +internal sealed class CalculateRiskScore() : Executor("CalculateRiskScore") +{ + public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order '{message.OrderId}'..."); + Console.ResetColor(); + + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + + // Read the pattern count from shared state (written by AnalyzePatterns) + int patternsFound = await context.ReadStateAsync("patternsFound", cancellationToken: cancellationToken); + int riskScore = Math.Min(patternsFound * 20 + new Random().Next(1, 20), 100); + + // Emit a workflow event from within a nested sub-workflow + await context.AddEventAsync(new FraudRiskAssessedEvent(riskScore), cancellationToken); + + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: {riskScore}/100 (based on {patternsFound} patterns)"); + Console.ResetColor(); + + return message; + } +} + +// Shipping sub-workflow executors + +/// +/// Selects a shipping carrier for an order. +/// +/// +/// This executor uses (void return) combined with +/// to forward the order to the next +/// connected executor (CreateShipment). This demonstrates explicit typed message passing +/// as an alternative to returning a value from the handler. +/// +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.FromMilliseconds(100), cancellationToken); + + message.Carrier = message.Amount > 50 ? "Express" : "Standard"; + + Console.ForegroundColor = ConsoleColor.Blue; + Console.WriteLine($" [Shipping/SelectCarrier] ✓ Selected carrier: {message.Carrier}"); + Console.ResetColor(); + + // Use SendMessageAsync to forward the updated order to connected executors. + // With a void-return executor, this is the mechanism for passing data downstream. + await context.SendMessageAsync(message, cancellationToken: cancellationToken); + } +} + +/// +/// 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.FromMilliseconds(100), 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/Durable/Workflow/ConsoleApps/07_SubWorkflows/Program.cs b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/Program.cs new file mode 100644 index 0000000000..d542f4aba5 --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/Program.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates nested sub-workflows. A sub-workflow can act as an executor +// within another workflow, including multi-level nesting (sub-workflow within sub-workflow). + +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 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"; + +// Build the FraudCheck sub-workflow (this will be nested inside the Payment sub-workflow) +AnalyzePatterns analyzePatterns = new(); +CalculateRiskScore calculateRiskScore = new(); + +Workflow fraudCheckWorkflow = new WorkflowBuilder(analyzePatterns) + .WithName("SubFraudCheck") + .WithDescription("Analyzes transaction patterns and calculates risk score") + .AddEdge(analyzePatterns, calculateRiskScore) + .Build(); + +// Build the Payment sub-workflow: ValidatePayment -> FraudCheck (sub-workflow) -> ChargePayment +ValidatePayment validatePayment = new(); +ExecutorBinding fraudCheckExecutor = fraudCheckWorkflow.BindAsExecutor("FraudCheck"); +ChargePayment chargePayment = new(); + +Workflow paymentWorkflow = new WorkflowBuilder(validatePayment) + .WithName("SubPaymentProcessing") + .WithDescription("Validates and processes payment for an order") + .AddEdge(validatePayment, fraudCheckExecutor) + .AddEdge(fraudCheckExecutor, chargePayment) + .Build(); + +// Build the Shipping sub-workflow: SelectCarrier -> CreateShipment +SelectCarrier selectCarrier = new(); +CreateShipment createShipment = new(); + +Workflow shippingWorkflow = new WorkflowBuilder(selectCarrier) + .WithName("SubShippingArrangement") + .WithDescription("Selects carrier and creates shipment") + .AddEdge(selectCarrier, createShipment) + .Build(); + +// Build the main workflow using sub-workflows as executors +// OrderReceived -> Payment (sub-workflow) -> Shipping (sub-workflow) -> OrderCompleted +OrderReceived orderReceived = new(); +OrderCompleted orderCompleted = new(); +ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment"); +ExecutorBinding shippingExecutor = shippingWorkflow.BindAsExecutor("Shipping"); + +Workflow orderProcessingWorkflow = new WorkflowBuilder(orderReceived) + .WithName("OrderProcessing") + .WithDescription("Processes an order through payment and shipping") + .AddEdge(orderReceived, paymentExecutor) + .AddEdge(paymentExecutor, shippingExecutor) + .AddEdge(shippingExecutor, orderCompleted) + .Build(); + +// Configure and start the host +// Register only the main workflow - sub-workflows are discovered automatically! +IHost host = Host.CreateDefaultBuilder(args) + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .ConfigureServices(services => + { + services.ConfigureDurableWorkflows( + workflowOptions => workflowOptions.AddWorkflow(orderProcessingWorkflow), + workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString), + clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString)); + }) + .Build(); + +await host.StartAsync(); + +IWorkflowClient workflowClient = host.Services.GetRequiredService(); + +Console.WriteLine("Durable Sub-Workflows Sample"); +Console.WriteLine("Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted"); +Console.WriteLine(" Payment contains nested FraudCheck sub-workflow (Level 2 nesting)"); +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 streaming to observe events (including from sub-workflows) +static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client) +{ + Console.WriteLine($"\nStarting order processing for '{orderId}'..."); + + IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId); + Console.WriteLine($"Run ID: {run.RunId}"); + Console.WriteLine(); + + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + switch (evt) + { + // Custom event emitted from the FraudCheck sub-sub-workflow + case FraudRiskAssessedEvent e: + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Event from sub-workflow] {e.GetType().Name}: Risk score {e.RiskScore}/100"); + Console.ResetColor(); + break; + + case DurableWorkflowCompletedEvent e: + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"✓ Order completed: {e.Result}"); + Console.ResetColor(); + break; + + case DurableWorkflowFailedEvent e: + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"✗ Failed: {e.ErrorMessage}"); + Console.ResetColor(); + break; + } + } +} diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md new file mode 100644 index 0000000000..921458a39b --- /dev/null +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md @@ -0,0 +1,123 @@ +# Sub-Workflows Sample (Nested Workflows) + +This sample demonstrates how to compose complex workflows from simpler, reusable sub-workflows. Sub-workflows are built using `WorkflowBuilder` and embedded as executors via `BindAsExecutor()`. Unlike the in-process workflow runner, the durable workflow backend persists execution state across process restarts — each sub-workflow runs as a separate orchestration instance on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard. + +## Key Concepts Demonstrated + +- **Sub-workflows**: Using `Workflow.BindAsExecutor()` to embed a workflow as an executor in another workflow +- **Multi-level nesting**: Sub-workflows within sub-workflows (Level 2 nesting) +- **Automatic discovery**: Registering only the main workflow; sub-workflows are discovered automatically +- **Failure isolation**: Each sub-workflow runs as a separate orchestration instance on the DTS backend +- **Hierarchical visualization**: Parent-child orchestration hierarchy visible in the DTS dashboard +- **Event propagation**: Custom workflow events (`FraudRiskAssessedEvent`) bubble up from nested sub-workflows to the streaming client +- **Message passing**: Using `Executor` (void return) with `SendMessageAsync` to forward typed messages to connected executors (`SelectCarrier`) +- **Shared state within sub-workflows**: Using `QueueStateUpdateAsync`/`ReadStateAsync` to share data between executors within a sub-workflow (`AnalyzePatterns` → `CalculateRiskScore`) + +## Overview + +The sample implements an order processing workflow composed of two sub-workflows, one of which contains its own nested sub-workflow: + +``` +OrderProcessing (main workflow) +├── OrderReceived +├── Payment (sub-workflow) +│ ├── ValidatePayment +│ ├── FraudCheck (sub-sub-workflow) ← Level 2 nesting! +│ │ ├── AnalyzePatterns +│ │ └── CalculateRiskScore +│ └── ChargePayment +├── Shipping (sub-workflow) +│ ├── SelectCarrier ← Uses SendMessageAsync (void-return executor) +│ └── CreateShipment +└── OrderCompleted +``` + +| Executor | Sub-Workflow | Description | +|----------|-------------|-------------| +| OrderReceived | Main | Receives order ID and creates order info | +| ValidatePayment | Payment | Validates payment information | +| AnalyzePatterns | FraudCheck (nested in Payment) | Analyzes transaction patterns, stores results in shared state | +| CalculateRiskScore | FraudCheck (nested in Payment) | Reads shared state, calculates risk score, emits `FraudRiskAssessedEvent` | +| ChargePayment | Payment | Charges payment amount | +| SelectCarrier | Shipping | Selects carrier using `SendMessageAsync` (void-return executor) | +| CreateShipment | Shipping | Creates shipment with tracking | +| OrderCompleted | Main | Outputs completed order summary | + +## How Sub-Workflows Work + +1. **Build** each sub-workflow as a standalone `Workflow` using `WorkflowBuilder` +2. **Bind** a workflow as an executor using `workflow.BindAsExecutor("name")` +3. **Add** the bound executor as a node in the parent workflow's graph +4. **Register** only the top-level workflow — sub-workflows are discovered and registered automatically + +```csharp +// Build a sub-workflow +Workflow fraudCheckWorkflow = new WorkflowBuilder(analyzePatterns) + .WithName("SubFraudCheck") + .AddEdge(analyzePatterns, calculateRiskScore) + .Build(); + +// Nest it inside another sub-workflow using BindAsExecutor +ExecutorBinding fraudCheckExecutor = fraudCheckWorkflow.BindAsExecutor("FraudCheck"); + +Workflow paymentWorkflow = new WorkflowBuilder(validatePayment) + .WithName("SubPaymentProcessing") + .AddEdge(validatePayment, fraudCheckExecutor) + .AddEdge(fraudCheckExecutor, chargePayment) + .Build(); + +// Use the Payment sub-workflow in the main workflow +ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment"); + +Workflow mainWorkflow = new WorkflowBuilder(orderReceived) + .AddEdge(orderReceived, paymentExecutor) + .AddEdge(paymentExecutor, orderCompleted) + .Build(); +``` + +## 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/07_SubWorkflows +dotnet run --framework net10.0 +``` + +### Sample Output + +```text +Durable Sub-Workflows Sample +Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted + Payment contains nested FraudCheck sub-workflow (Level 2 nesting) + +Enter an order ID (or 'exit'): +> ORD-001 +Starting order processing for 'ORD-001'... +Run ID: abc123... + +[OrderReceived] Processing order 'ORD-001' + [Payment/ValidatePayment] Validating payment for order 'ORD-001'... + [Payment/ValidatePayment] Payment validated for $99.99 + [Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order 'ORD-001'... + [Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete (2 suspicious patterns) + [Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order 'ORD-001'... + [Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: 53/100 (based on 2 patterns) + [Event from sub-workflow] FraudRiskAssessedEvent: Risk score 53/100 + [Payment/ChargePayment] Charging $99.99 for order 'ORD-001'... + [Payment/ChargePayment] ✓ Payment processed: TXN-A1B2C3D4 + [Shipping/SelectCarrier] Selecting carrier for order 'ORD-001'... + [Shipping/SelectCarrier] ✓ Selected carrier: Express + [Shipping/CreateShipment] Creating shipment for order 'ORD-001'... + [Shipping/CreateShipment] ✓ Shipment created: TRACK-I9J0K1L2M3 +┌─────────────────────────────────────────────────────────────────┐ +│ [OrderCompleted] Order 'ORD-001' successfully processed! +│ Payment: TXN-A1B2C3D4 +│ Shipping: Express - TRACK-I9J0K1L2M3 +└─────────────────────────────────────────────────────────────────┘ +✓ Order completed: Order ORD-001 completed. Tracking: TRACK-I9J0K1L2M3 + +> exit +``` diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs index a0257c6d91..32a97b4643 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs @@ -14,19 +14,20 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows; /// -/// Dispatches workflow executors to either activities or AI agents. +/// Dispatches workflow executors to activities, AI agents, or sub-orchestrations. /// /// /// Called during the dispatch phase of each superstep by /// DurableWorkflowRunner.DispatchExecutorsInParallelAsync. 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. +/// backed by Durable Entities), a sub-workflow (dispatched as a sub-orchestration), or a +/// regular activity, and invokes the appropriate Durable Task API. /// The serialised string result is returned to the runner for the routing phase. /// internal static class DurableExecutorDispatcher { /// - /// Dispatches an executor based on its type (activity or AI agent). + /// Dispatches an executor based on its type (activity, AI agent, or sub-workflow). /// /// The task orchestration context. /// Information about the executor to dispatch. @@ -48,6 +49,11 @@ internal static async Task DispatchAsync( return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true); } + if (executorInfo.IsSubworkflowExecutor) + { + return await ExecuteSubWorkflowAsync(context, executorInfo, envelope.Message).ConfigureAwait(true); + } + return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName, sharedState).ConfigureAwait(true); } @@ -100,4 +106,76 @@ private static async Task ExecuteAgentAsync( return response.Text; } + + /// + /// Dispatches a sub-workflow executor as a sub-orchestration. + /// + /// + /// Sub-workflows run as separate orchestration instances, providing independent + /// checkpointing, replay, and hierarchical visualization in the DTS dashboard. + /// The input is wrapped in to match the + /// orchestration's registered input type. The sub-orchestration returns a + /// JSON envelope (same as top-level workflows), + /// which this method converts to a so the parent + /// workflow's result processing picks up both the result and any accumulated events. + /// + private static async Task ExecuteSubWorkflowAsync( + TaskOrchestrationContext context, + WorkflowExecutorInfo executorInfo, + string input) + { + string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorInfo.SubWorkflow!.Name!); + + DurableWorkflowInput workflowInput = new() { Input = input }; + + string? rawOutput = await context.CallSubOrchestratorAsync( + orchestrationName, + workflowInput).ConfigureAwait(true); + + return ConvertWorkflowResultToExecutorOutput(rawOutput); + } + + /// + /// Converts a JSON envelope from a sub-orchestration + /// into a JSON string. This bridges the sub-workflow's + /// output format to the parent workflow's result processing, preserving both the result + /// and any accumulated events from the sub-workflow. + /// + private static string ConvertWorkflowResultToExecutorOutput(string? rawOutput) + { + if (string.IsNullOrEmpty(rawOutput)) + { + return string.Empty; + } + + try + { + DurableWorkflowResult? workflowResult = JsonSerializer.Deserialize( + rawOutput, + DurableWorkflowJsonContext.Default.DurableWorkflowResult); + + if (workflowResult is null) + { + return string.Empty; + } + + // Propagate the result, events, and sent messages from the sub-workflow. + // SentMessages carry the sub-workflow's output for typed routing in the parent, + // matching the in-process WorkflowHostExecutor behavior. + // Shared state is not included because each workflow instance maintains its own + // independent shared state; it is not shared between parent and sub-workflows. + DurableExecutorOutput executorOutput = new() + { + Result = workflowResult.Result, + Events = workflowResult.Events ?? [], + SentMessages = workflowResult.SentMessages ?? [], + }; + + return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput); + } + catch (JsonException) + { + return rawOutput; + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs index 933fd74c62..ee5cf39878 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs @@ -21,4 +21,13 @@ internal sealed class DurableWorkflowResult /// Gets or sets the serialized workflow events emitted during execution. /// public List Events { get; set; } = []; + + /// + /// Gets or sets the typed messages to forward to connected executors in the parent workflow. + /// + /// + /// When this workflow runs as a sub-orchestration, these messages are propagated to the + /// parent workflow and routed to successor executors via the edge map. + /// + public List SentMessages { get; set; } = []; } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs index d133d16919..8402b91a8d 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs @@ -200,10 +200,15 @@ private static async Task RunSuperstepLoopAsync( // 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). + // SentMessages carries the final result so parent workflows can route it + // to connected executors, matching the in-process WorkflowHostExecutor behavior. DurableWorkflowResult workflowResult = new() { Result = finalResult, - Events = state.AccumulatedEvents + Events = state.AccumulatedEvents, + SentMessages = !string.IsNullOrEmpty(finalResult) + ? [new TypedPayload { Data = finalResult }] + : [] }; return JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult); @@ -600,10 +605,10 @@ private static ExecutorResultInfo ParseActivityResult(string rawResult) private static bool HasMeaningfulContent(DurableExecutorOutput output) { return output.Result is not null - || output.SentMessages.Count > 0 - || output.Events.Count > 0 - || output.StateUpdates.Count > 0 - || output.ClearedScopes.Count > 0 + || output.SentMessages?.Count > 0 + || output.Events?.Count > 0 + || output.StateUpdates?.Count > 0 + || output.ClearedScopes?.Count > 0 || output.HaltRequested; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs index 436e9cbc45..9f8cd3ce5a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs @@ -375,6 +375,83 @@ await this.RunSampleTestAsync(samplePath, async (process, logs) => }); } + [Fact] + public async Task SubWorkflowsSampleValidationAsync() + { + using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(); + string samplePath = Path.Combine(s_samplesPath, "07_SubWorkflows"); + + await this.RunSampleTestAsync(samplePath, async (process, logs) => + { + bool inputSent = false; + bool foundOrderReceived = false; + bool foundValidatePayment = false; + bool foundAnalyzePatterns = false; + bool foundCalculateRiskScore = false; + bool foundChargePayment = false; + bool foundSelectCarrier = false; + bool foundCreateShipment = false; + bool foundOrderCompleted = false; + bool foundFraudRiskEvent = false; + bool workflowCompleted = 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, "ORD-001", testTimeoutCts.Token); + inputSent = true; + } + + if (inputSent) + { + // Main workflow executors + foundOrderReceived |= line.Contains("[OrderReceived]", StringComparison.Ordinal); + foundOrderCompleted |= line.Contains("[OrderCompleted]", StringComparison.Ordinal); + + // Payment sub-workflow executors + foundValidatePayment |= line.Contains("[Payment/ValidatePayment]", StringComparison.Ordinal); + foundChargePayment |= line.Contains("[Payment/ChargePayment]", StringComparison.Ordinal); + + // FraudCheck sub-sub-workflow executors (nested inside Payment) + foundAnalyzePatterns |= line.Contains("[Payment/FraudCheck/AnalyzePatterns]", StringComparison.Ordinal); + foundCalculateRiskScore |= line.Contains("[Payment/FraudCheck/CalculateRiskScore]", StringComparison.Ordinal); + + // Shipping sub-workflow executors + foundSelectCarrier |= line.Contains("[Shipping/SelectCarrier]", StringComparison.Ordinal); + foundCreateShipment |= line.Contains("[Shipping/CreateShipment]", StringComparison.Ordinal); + + // Custom event from nested sub-workflow + foundFraudRiskEvent |= line.Contains("FraudRiskAssessedEvent", StringComparison.Ordinal) + || line.Contains("Risk score", StringComparison.Ordinal); + + if (line.Contains("Order completed", StringComparison.OrdinalIgnoreCase)) + { + workflowCompleted = true; + break; + } + } + + this.AssertNoError(line); + } + + Assert.True(inputSent, "Input was not sent to the workflow."); + Assert.True(foundOrderReceived, "OrderReceived executor log not found."); + Assert.True(foundValidatePayment, "Payment/ValidatePayment executor log not found."); + Assert.True(foundAnalyzePatterns, "Payment/FraudCheck/AnalyzePatterns executor log not found."); + Assert.True(foundCalculateRiskScore, "Payment/FraudCheck/CalculateRiskScore executor log not found."); + Assert.True(foundChargePayment, "Payment/ChargePayment executor log not found."); + Assert.True(foundSelectCarrier, "Shipping/SelectCarrier executor log not found."); + Assert.True(foundCreateShipment, "Shipping/CreateShipment executor log not found."); + Assert.True(foundOrderCompleted, "OrderCompleted executor log not found."); + Assert.True(foundFraudRiskEvent, "FraudRiskAssessedEvent from nested sub-workflow not found."); + Assert.True(workflowCompleted, "Workflow did not complete successfully."); + + await this.WriteInputAsync(process, "exit", testTimeoutCts.Token); + }); + } + [Fact] public async Task WorkflowAndAgentsSampleValidationAsync() { From f07b176932e2c5c13973c8691f23e16e9e1aca8d Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Tue, 24 Feb 2026 10:37:22 -0800 Subject: [PATCH 2/5] fix readme path --- .../Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md index 921458a39b..bd18ddc44a 100644 --- a/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md @@ -77,7 +77,7 @@ Workflow mainWorkflow = new WorkflowBuilder(orderReceived) ## 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. +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 From 3ecd1ba422e6f29baf43672c5b50cf7e9a0cfa13 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Wed, 25 Feb 2026 13:06:38 -0800 Subject: [PATCH 3/5] Switch Orchestration output from string to DurableWorkflowResult. --- .../ServiceCollectionExtensions.cs | 4 +- .../Workflows/DurableExecutorDispatcher.cs | 52 +++++++------------ .../Workflows/DurableStreamingWorkflowRun.cs | 18 ++----- .../Workflows/DurableWorkflowRunner.cs | 8 ++- .../DurableStreamingWorkflowRunTests.cs | 3 +- 5 files changed, 28 insertions(+), 57 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs index 29e56ea398..2175cf5bb9 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -251,7 +251,7 @@ private static void RegisterTasksFromOptions(DurableTaskRegistry registry, Durab foreach (WorkflowRegistrationInfo registration in registrations) { // Register with DurableWorkflowInput - the DataConverter handles serialization/deserialization - registry.AddOrchestratorFunc, string>( + registry.AddOrchestratorFunc, DurableWorkflowResult>( registration.OrchestrationName, (context, input) => RunWorkflowOrchestrationAsync(context, input, durableOptions)); @@ -336,7 +336,7 @@ private static bool IsActivityBinding(ExecutorBinding binding) => binding is not AIAgentBinding and not SubworkflowBinding; - private static async Task RunWorkflowOrchestrationAsync( + private static async Task RunWorkflowOrchestrationAsync( TaskOrchestrationContext context, DurableWorkflowInput workflowInput, DurableOptions durableOptions) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs index 32a97b4643..3906ef2ce1 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs @@ -115,7 +115,7 @@ private static async Task ExecuteAgentAsync( /// checkpointing, replay, and hierarchical visualization in the DTS dashboard. /// The input is wrapped in to match the /// orchestration's registered input type. The sub-orchestration returns a - /// JSON envelope (same as top-level workflows), + /// directly (deserialized by the Durable Task SDK), /// which this method converts to a so the parent /// workflow's result processing picks up both the result and any accumulated events. /// @@ -128,54 +128,38 @@ private static async Task ExecuteSubWorkflowAsync( DurableWorkflowInput workflowInput = new() { Input = input }; - string? rawOutput = await context.CallSubOrchestratorAsync( + DurableWorkflowResult? workflowResult = await context.CallSubOrchestratorAsync( orchestrationName, workflowInput).ConfigureAwait(true); - return ConvertWorkflowResultToExecutorOutput(rawOutput); + return ConvertWorkflowResultToExecutorOutput(workflowResult); } /// - /// Converts a JSON envelope from a sub-orchestration + /// Converts a from a sub-orchestration /// into a JSON string. This bridges the sub-workflow's /// output format to the parent workflow's result processing, preserving both the result /// and any accumulated events from the sub-workflow. /// - private static string ConvertWorkflowResultToExecutorOutput(string? rawOutput) + private static string ConvertWorkflowResultToExecutorOutput(DurableWorkflowResult? workflowResult) { - if (string.IsNullOrEmpty(rawOutput)) + if (workflowResult is null) { return string.Empty; } - try + // Propagate the result, events, and sent messages from the sub-workflow. + // SentMessages carry the sub-workflow's output for typed routing in the parent, + // matching the in-process WorkflowHostExecutor behavior. + // Shared state is not included because each workflow instance maintains its own + // independent shared state; it is not shared between parent and sub-workflows. + DurableExecutorOutput executorOutput = new() { - DurableWorkflowResult? workflowResult = JsonSerializer.Deserialize( - rawOutput, - DurableWorkflowJsonContext.Default.DurableWorkflowResult); - - if (workflowResult is null) - { - return string.Empty; - } - - // Propagate the result, events, and sent messages from the sub-workflow. - // SentMessages carry the sub-workflow's output for typed routing in the parent, - // matching the in-process WorkflowHostExecutor behavior. - // Shared state is not included because each workflow instance maintains its own - // independent shared state; it is not shared between parent and sub-workflows. - DurableExecutorOutput executorOutput = new() - { - Result = workflowResult.Result, - Events = workflowResult.Events ?? [], - SentMessages = workflowResult.SentMessages ?? [], - }; - - return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput); - } - catch (JsonException) - { - return rawOutput; - } + Result = workflowResult.Result, + Events = workflowResult.Events ?? [], + SentMessages = workflowResult.SentMessages ?? [], + }; + + return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput); } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs index 57a44fc06b..a7ed7b11ce 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs @@ -262,9 +262,9 @@ private static bool TryParseCustomStatus(string serializedStatus, out DurableWor /// Attempts to parse the orchestration output as a wrapper. /// /// - /// The orchestration wraps its output in a to include - /// accumulated events alongside the result. The Durable Task framework's DataConverter - /// serializes the string output with an extra layer of JSON encoding, so we first unwrap that. + /// The orchestration returns a object directly. + /// The Durable Task framework's DataConverter serializes it as a JSON object + /// in SerializedOutput, so we deserialize it directly. /// [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")] [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")] @@ -278,17 +278,7 @@ private static bool TryParseWorkflowResult(string? serializedOutput, [NotNullWhe 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(serializedOutput); - if (innerJson is null) - { - result = default!; - return false; - } - - result = JsonSerializer.Deserialize(innerJson, DurableWorkflowJsonContext.Default.DurableWorkflowResult)!; + result = JsonSerializer.Deserialize(serializedOutput, DurableWorkflowJsonContext.Default.DurableWorkflowResult)!; return result is not null; } catch (JsonException) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs index 8402b91a8d..a38bba1f15 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs @@ -95,7 +95,7 @@ public DurableWorkflowRunner(DurableOptions durableOptions) /// The replay-safe logger for orchestration logging. /// The result of the workflow execution. /// Thrown when the specified workflow is not found. - internal async Task RunWorkflowOrchestrationAsync( + internal async Task RunWorkflowOrchestrationAsync( TaskOrchestrationContext context, DurableWorkflowInput workflowInput, ILogger logger) @@ -135,7 +135,7 @@ private Workflow GetWorkflowOrThrow(string orchestrationName) /// [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 RunSuperstepLoopAsync( + private static async Task RunSuperstepLoopAsync( TaskOrchestrationContext context, Workflow workflow, DurableEdgeMap edgeMap, @@ -202,7 +202,7 @@ private static async Task RunSuperstepLoopAsync( // (SerializedCustomStatus is cleared by the framework on completion). // SentMessages carries the final result so parent workflows can route it // to connected executors, matching the in-process WorkflowHostExecutor behavior. - DurableWorkflowResult workflowResult = new() + return new DurableWorkflowResult { Result = finalResult, Events = state.AccumulatedEvents, @@ -210,8 +210,6 @@ private static async Task RunSuperstepLoopAsync( ? [new TypedPayload { Data = finalResult }] : [] }; - - return JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs index ee91a33a13..c4b9037c94 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs @@ -43,8 +43,7 @@ private static string SerializeCustomStatus(List events) private static string SerializeWorkflowResult(string? result, List events) { DurableWorkflowResult workflowResult = new() { Result = result, Events = events }; - string inner = JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult); - return JsonSerializer.Serialize(inner); + return JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult); } private static string SerializeEvent(WorkflowEvent evt) From 8caf8b24be5fe3cac51dc1db8e72cb7b0f1b931e Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Wed, 25 Feb 2026 14:06:47 -0800 Subject: [PATCH 4/5] PR feedback fixes --- .../ConsoleApps/07_SubWorkflows/README.md | 40 +++++-------------- .../Workflows/DurableExecutorDispatcher.cs | 5 ++- .../Workflows/DurableWorkflowResult.cs | 9 +++++ .../Workflows/DurableWorkflowRunner.cs | 7 +++- 4 files changed, 28 insertions(+), 33 deletions(-) diff --git a/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md index bd18ddc44a..4f7773dd03 100644 --- a/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md +++ b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/README.md @@ -45,35 +45,9 @@ OrderProcessing (main workflow) ## How Sub-Workflows Work -1. **Build** each sub-workflow as a standalone `Workflow` using `WorkflowBuilder` -2. **Bind** a workflow as an executor using `workflow.BindAsExecutor("name")` -3. **Add** the bound executor as a node in the parent workflow's graph -4. **Register** only the top-level workflow — sub-workflows are discovered and registered automatically - -```csharp -// Build a sub-workflow -Workflow fraudCheckWorkflow = new WorkflowBuilder(analyzePatterns) - .WithName("SubFraudCheck") - .AddEdge(analyzePatterns, calculateRiskScore) - .Build(); - -// Nest it inside another sub-workflow using BindAsExecutor -ExecutorBinding fraudCheckExecutor = fraudCheckWorkflow.BindAsExecutor("FraudCheck"); - -Workflow paymentWorkflow = new WorkflowBuilder(validatePayment) - .WithName("SubPaymentProcessing") - .AddEdge(validatePayment, fraudCheckExecutor) - .AddEdge(fraudCheckExecutor, chargePayment) - .Build(); - -// Use the Payment sub-workflow in the main workflow -ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment"); - -Workflow mainWorkflow = new WorkflowBuilder(orderReceived) - .AddEdge(orderReceived, paymentExecutor) - .AddEdge(paymentExecutor, orderCompleted) - .Build(); -``` +For an introduction to sub-workflows and the `BindAsExecutor()` API, see the [Sub-Workflows foundational sample](../../../GettingStarted/Workflows/_Foundational/06_SubWorkflows). + +This durable sample extends the same pattern — the key difference is that each sub-workflow runs as a **separate orchestration instance** on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard. ## Environment Setup @@ -121,3 +95,11 @@ Run ID: abc123... > exit ``` + +### Viewing Workflows in the DTS Dashboard + +After running the workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the orchestration hierarchy, including sub-orchestrations. + +If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`. + +Because each sub-workflow runs as a separate orchestration instance, the dashboard shows a parent-child hierarchy: the top-level `OrderProcessing` orchestration with `Payment` and `Shipping` as child orchestrations, and `FraudCheck` nested under `Payment`. You can click into each orchestration to inspect its executor inputs/outputs, events, and execution timeline independently. diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs index 3906ef2ce1..6f69b923b6 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs @@ -113,8 +113,8 @@ private static async Task ExecuteAgentAsync( /// /// Sub-workflows run as separate orchestration instances, providing independent /// checkpointing, replay, and hierarchical visualization in the DTS dashboard. - /// The input is wrapped in to match the - /// orchestration's registered input type. The sub-orchestration returns a + /// The input is wrapped in so the sub-orchestration + /// can extract it using the same envelope structure. The sub-orchestration returns a /// directly (deserialized by the Durable Task SDK), /// which this method converts to a so the parent /// workflow's result processing picks up both the result and any accumulated events. @@ -158,6 +158,7 @@ private static string ConvertWorkflowResultToExecutorOutput(DurableWorkflowResul Result = workflowResult.Result, Events = workflowResult.Events ?? [], SentMessages = workflowResult.SentMessages ?? [], + HaltRequested = workflowResult.HaltRequested, }; return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs index ee5cf39878..7f63232185 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowResult.cs @@ -30,4 +30,13 @@ internal sealed class DurableWorkflowResult /// parent workflow and routed to successor executors via the edge map. /// public List SentMessages { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the workflow was halted by an executor. + /// + /// + /// When this workflow runs as a sub-orchestration, this flag is propagated to the + /// parent workflow so halt semantics are preserved across nesting levels. + /// + public bool HaltRequested { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs index a38bba1f15..8836a4973a 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs @@ -157,6 +157,8 @@ private static async Task RunSuperstepLoopAsync( edgeMap.EnqueueInitialInput(inputString, state.MessageQueues); + bool haltRequested = false; + for (int superstep = 1; superstep <= MaxSupersteps; superstep++) { List executorInputs = CollectExecutorInputs(state, logger); @@ -173,7 +175,7 @@ private static async Task RunSuperstepLoopAsync( string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state.SharedState, logger).ConfigureAwait(true); - bool haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger); + haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger); if (haltRequested) { @@ -208,7 +210,8 @@ private static async Task RunSuperstepLoopAsync( Events = state.AccumulatedEvents, SentMessages = !string.IsNullOrEmpty(finalResult) ? [new TypedPayload { Data = finalResult }] - : [] + : [], + HaltRequested = haltRequested }; } From 1e476e363cf1eeb1b78db6f93ef8441e2a6547e7 Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Wed, 25 Feb 2026 14:34:18 -0800 Subject: [PATCH 5/5] Minor cleanup based on PR feedback. --- .../Workflows/WorkflowOrchestrator.cs | 2 +- .../WorkflowConsoleAppSamplesValidation.cs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs index f8970c56d2..f89abedc23 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/Workflows/WorkflowOrchestrator.cs @@ -28,7 +28,7 @@ public WorkflowOrchestrator(IServiceProvider serviceProvider) public Type InputType => typeof(DurableWorkflowInput); /// - public Type OutputType => typeof(string); + public Type OutputType => typeof(DurableWorkflowResult); /// public async Task RunAsync(TaskOrchestrationContext context, object? input) diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs index 9f8cd3ce5a..97e2a1ef13 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/WorkflowConsoleAppSamplesValidation.cs @@ -422,9 +422,8 @@ await this.RunSampleTestAsync(samplePath, async (process, logs) => foundSelectCarrier |= line.Contains("[Shipping/SelectCarrier]", StringComparison.Ordinal); foundCreateShipment |= line.Contains("[Shipping/CreateShipment]", StringComparison.Ordinal); - // Custom event from nested sub-workflow - foundFraudRiskEvent |= line.Contains("FraudRiskAssessedEvent", StringComparison.Ordinal) - || line.Contains("Risk score", StringComparison.Ordinal); + // Custom event from nested sub-workflow (streamed to client) + foundFraudRiskEvent |= line.Contains("[Event from sub-workflow] FraudRiskAssessedEvent", StringComparison.Ordinal); if (line.Contains("Order completed", StringComparison.OrdinalIgnoreCase)) {