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..f8e0eb8896
--- /dev/null
+++ b/dotnet/samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj
@@ -0,0 +1,29 @@
+
+
+ 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..5b1b31c0ee
--- /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 using the Durable Task Framework. Sub-workflows run as separate orchestration instances, providing modular design, independent checkpointing, 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
+- **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 @@ using Microsoft.Extensions.Logging;
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 class DurableExecutorDispatcher
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 @@ internal static class DurableExecutorDispatcher
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 @@ internal sealed class DurableWorkflowRunner
// 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 @@ internal sealed class DurableWorkflowRunner
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 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
+ [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()
{