diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index bbd71d6fba..ddcd9f4080 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -54,6 +54,7 @@
+
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/11_WorkflowEvents/Program.cs b/dotnet/samples/DurableAgents/ConsoleApps/11_WorkflowEvents/Program.cs
index b96c77f053..22487b4e14 100644
--- a/dotnet/samples/DurableAgents/ConsoleApps/11_WorkflowEvents/Program.cs
+++ b/dotnet/samples/DurableAgents/ConsoleApps/11_WorkflowEvents/Program.cs
@@ -92,36 +92,39 @@ async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, Dura
// WatchStreamAsync yields events as they're emitted by executors
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
+ // Always print the event type name
+ Console.WriteLine($" Event: {evt.GetType().Name}");
+
switch (evt)
{
// Custom domain events (emitted via AddEventAsync)
case OrderLookupStartedEvent e:
- WriteColored($" [Lookup] Looking up order {e.OrderId}", ConsoleColor.Cyan);
+ WriteColored($" [Lookup] Looking up order {e.OrderId}", ConsoleColor.Cyan);
break;
case OrderFoundEvent e:
- WriteColored($" [Lookup] Found: {e.Order.Customer.Name}", ConsoleColor.Cyan);
+ WriteColored($" [Lookup] Found: {e.Order.Customer.Name}", ConsoleColor.Cyan);
break;
case CancellationProgressEvent e:
- WriteColored($" [Cancel] {e.PercentComplete}% - {e.Status}", ConsoleColor.Yellow);
+ WriteColored($" [Cancel] {e.PercentComplete}% - {e.Status}", ConsoleColor.Yellow);
break;
case OrderCancelledEvent e:
- WriteColored(" [Cancel] Done", ConsoleColor.Yellow);
+ WriteColored(" [Cancel] Done", ConsoleColor.Yellow);
break;
case EmailSentEvent e:
- WriteColored($" [Email] Sent to {e.Email}", ConsoleColor.Magenta);
+ WriteColored($" [Email] Sent to {e.Email}", ConsoleColor.Magenta);
break;
// Yielded outputs (emitted via YieldOutputAsync)
case DurableYieldedOutputEvent e:
- WriteColored($" [Output] {e.ExecutorId}", ConsoleColor.DarkGray);
+ WriteColored($" [Output] {e.ExecutorId}", ConsoleColor.DarkGray);
break;
// Workflow completion
case DurableWorkflowCompletedEvent e:
- WriteColored($"Completed: {e.Result}", ConsoleColor.Green);
+ WriteColored($" Completed: {e.Result}", ConsoleColor.Green);
break;
case DurableWorkflowFailedEvent e:
- WriteColored($"Failed: {e.ErrorMessage}", ConsoleColor.Red);
+ WriteColored($" Failed: {e.ErrorMessage}", ConsoleColor.Red);
break;
}
}
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/12_WorkflowLoop.csproj b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/12_WorkflowLoop.csproj
new file mode 100644
index 0000000000..523c6b3612
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/12_WorkflowLoop.csproj
@@ -0,0 +1,31 @@
+
+
+ net10.0
+ Exe
+ enable
+ enable
+ SingleWorkflow
+ SingleAgent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/FeedbackExecutor.cs b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/FeedbackExecutor.cs
new file mode 100644
index 0000000000..84f59f4ac6
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/FeedbackExecutor.cs
@@ -0,0 +1,68 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace SingleAgent;
+
+internal sealed class FeedbackExecutor : Executor
+{
+ private readonly AIAgent _agent;
+ private AgentThread? _thread;
+
+ public int MinimumRating { get; init; } = 9;
+
+ public int MaxAttempts { get; init; } = 3;
+
+ private int _attempts;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A unique identifier for the executor.
+ /// The chat client to use for the AI agent.
+ public FeedbackExecutor(string id, IChatClient chatClient) : base(id)
+ {
+ ChatClientAgentOptions agentOptions = new()
+ {
+ ChatOptions = new()
+ {
+ Instructions = "You are a professional editor. You will be given a slogan and the task it is meant to accomplish.",
+ ResponseFormat = ChatResponseFormat.ForJsonSchema()
+ }
+ };
+
+ this._agent = new ChatClientAgent(chatClient, agentOptions);
+ }
+
+ public override async ValueTask HandleAsync(SloganResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken);
+
+ var sloganMessage = $"""
+ Here is a slogan for the task '{message.Task}':
+ Slogan: {message.Slogan}
+ Please provide feedback on this slogan, including comments, a rating from 1 to 10, and suggested actions for improvement.
+ """;
+
+ var response = await this._agent.RunAsync(sloganMessage, this._thread, cancellationToken: cancellationToken);
+ var feedback = JsonSerializer.Deserialize(response.Text) ?? throw new InvalidOperationException("Failed to deserialize feedback.");
+
+ if (feedback.Rating >= this.MinimumRating)
+ {
+ await context.YieldOutputAsync($"The following slogan was accepted:\n\n{message.Slogan}", cancellationToken);
+ return;
+ }
+
+ if (this._attempts >= this.MaxAttempts)
+ {
+ await context.YieldOutputAsync($"The slogan was rejected after {this.MaxAttempts} attempts. Final slogan:\n\n{message.Slogan}", cancellationToken);
+ return;
+ }
+
+ await context.SendMessageAsync(feedback, cancellationToken: cancellationToken);
+ this._attempts++;
+ }
+}
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/Program.cs b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/Program.cs
new file mode 100644
index 0000000000..a5723c59d1
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/Program.cs
@@ -0,0 +1,112 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to run a CYCLIC WORKFLOW as a durable orchestration.
+// The workflow contains a loop: SloganWriter ⟷ FeedbackProvider
+//
+// WORKFLOW LOOP PATTERN:
+// 1. SloganWriter generates a slogan based on user input
+// 2. FeedbackProvider evaluates the slogan and provides feedback
+// 3. If the rating is below threshold, FeedbackProvider sends feedback back to SloganWriter
+// 4. SloganWriter improves the slogan based on feedback
+// 5. Loop continues until FeedbackProvider accepts the slogan (rating >= threshold)
+//
+// This demonstrates:
+// - Cyclic workflow support (back-edges in the graph)
+// - Multi-type executor handlers (SloganWriter handles both string and FeedbackResult)
+// - Message routing via SendMessageAsync for void-returning executors
+// - YieldOutputAsync for final output when the loop completes
+
+using Azure.Identity;
+using Microsoft.Agents.AI.DurableTask;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.DurableTask.Client;
+using Microsoft.DurableTask.Client.AzureManaged;
+using Microsoft.DurableTask.Worker.AzureManaged;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SingleAgent;
+using Azure.AI.OpenAI;
+
+// Get DTS connection string from environment variable
+string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
+ ?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
+
+var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
+var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
+
+// Define executors for the workflow
+var sloganWriter = new SloganWriterExecutor("SloganWriter", chatClient);
+var feedbackProvider = new FeedbackExecutor("FeedbackProvider", chatClient);
+
+// Build the workflow by adding executors and connecting them
+var workflow = new WorkflowBuilder(sloganWriter)
+ .WithName("SloganCreationWorkflow")
+ .AddEdge(sloganWriter, feedbackProvider)
+ .AddEdge(feedbackProvider, sloganWriter)
+ .WithOutputFrom(feedbackProvider)
+ .Build();
+
+IHost host = Host.CreateDefaultBuilder(args)
+ .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
+ .ConfigureServices(services =>
+ {
+ services.ConfigureDurableWorkflows(
+ options => options.Workflows.AddWorkflow(workflow),
+ workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
+ clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
+ })
+ .Build();
+
+await host.StartAsync();
+
+DurableTaskClient durableClient = host.Services.GetRequiredService();
+
+Console.WriteLine("Workflow Events Demo - Enter input for slogan generation (or 'exit'):");
+
+while (true)
+{
+ Console.Write("> ");
+ string? input = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
+ {
+ break;
+ }
+
+ try
+ {
+ await RunWorkflowWithStreamingAsync(input, workflow, durableClient);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error: {ex.Message}");
+ }
+
+ Console.WriteLine();
+}
+
+await host.StopAsync();
+
+// Runs a workflow and streams events as they occur
+async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, DurableTaskClient client)
+{
+ // StreamAsync starts the workflow and returns a handle for observing events
+ await using DurableStreamingRun run = await DurableWorkflow.StreamAsync(workflow, orderId, client);
+ Console.WriteLine($"Started: {run.InstanceId}");
+
+ // WatchStreamAsync yields events as they're emitted by executors
+ await foreach (WorkflowEvent evt in run.WatchStreamAsync())
+ {
+ // Always print the event type name
+ WriteColored($" Event: {evt.GetType().Name}", ConsoleColor.Gray);
+ }
+}
+
+void WriteColored(string message, ConsoleColor color)
+{
+ Console.ForegroundColor = color;
+ Console.WriteLine(message);
+ Console.ResetColor();
+}
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/README.md b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/README.md
new file mode 100644
index 0000000000..b03b8cbd73
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/README.md
@@ -0,0 +1,23 @@
+# Workflow Loop Sample
+
+This sample demonstrates how to run a cyclic workflow (containing loops) as a durable orchestration.
+
+## Overview
+
+The workflow iteratively improves a slogan based on AI feedback until it meets quality criteria.
+
+### Executors
+
+- **SloganWriter** - Generates slogans using AI (handles string and FeedbackResult)
+- **FeedbackProvider** - Evaluates slogans (calls YieldOutput to accept, SendMessage to loop)
+
+## Key Concepts
+
+- Cyclic Workflow Support (back-edges)
+- Multi-Type Executor Handlers
+- Message Routing via SendMessageAsync
+- Workflow Termination via YieldOutputAsync
+
+## Running
+
+Set AZURE_OPENAI_ENDPOINT and run: dotnet run
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/SloganResult.cs b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/SloganResult.cs
new file mode 100644
index 0000000000..1e2f054fe0
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/SloganResult.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json.Serialization;
+
+namespace SingleAgent;
+
+public sealed class SloganResult
+{
+ [JsonPropertyName("task")]
+ public required string Task { get; set; }
+
+ [JsonPropertyName("slogan")]
+ public required string Slogan { get; set; }
+}
+
+public sealed class FeedbackResult
+{
+ [JsonPropertyName("comments")]
+ public string Comments { get; set; } = string.Empty;
+
+ [JsonPropertyName("rating")]
+ public int Rating { get; set; }
+
+ [JsonPropertyName("actions")]
+ public string Actions { get; set; } = string.Empty;
+}
diff --git a/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/SloganWriterExecutor.cs b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/SloganWriterExecutor.cs
new file mode 100644
index 0000000000..c33942ed79
--- /dev/null
+++ b/dotnet/samples/DurableAgents/ConsoleApps/12_WorkflowLoop/SloganWriterExecutor.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Workflows;
+using Microsoft.Extensions.AI;
+
+namespace SingleAgent;
+
+internal sealed class SloganWriterExecutor : Executor
+{
+ private readonly AIAgent _agent;
+ private AgentThread? _thread;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A unique identifier for the executor.
+ /// The chat client to use for the AI agent.
+ public SloganWriterExecutor(string id, IChatClient chatClient) : base(id)
+ {
+ ChatClientAgentOptions agentOptions = new()
+ {
+ ChatOptions = new()
+ {
+ Instructions = "You are a professional slogan writer. You will be given a task to create a slogan.",
+ ResponseFormat = ChatResponseFormat.ForJsonSchema()
+ }
+ };
+
+ this._agent = new ChatClientAgent(chatClient, agentOptions);
+ }
+
+ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
+ routeBuilder.AddHandler(this.HandleAsync)
+ .AddHandler(this.HandleAsync);
+
+ public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken);
+
+ var result = await this._agent.RunAsync(message, this._thread, cancellationToken: cancellationToken);
+
+ return JsonSerializer.Deserialize(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result.");
+ }
+
+ public async ValueTask HandleAsync(FeedbackResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
+ {
+ var feedbackMessage = $"""
+ Here is the feedback on your previous slogan:
+ Comments: {message.Comments}
+ Rating: {message.Rating}
+ Suggested Actions: {message.Actions}
+
+ Please use this feedback to improve your slogan.
+ """;
+
+ var result = await this._agent.RunAsync(feedbackMessage, this._thread, cancellationToken: cancellationToken);
+ return JsonSerializer.Deserialize(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result.");
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs
index 4e1cbd559d..7623252a63 100644
--- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs
+++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableWorkflowRunner.cs
@@ -191,69 +191,434 @@ public class DurableWorkflowRunner
ILogger logger)
{
WorkflowExecutionPlan plan = WorkflowHelper.GetExecutionPlan(workflow);
- Dictionary results = new(plan.Levels.Sum(l => l.Executors.Count));
+
+ // Use message-driven execution for all workflows
+ // This approach naturally handles both DAGs and cyclic workflows
+ return await this.ExecuteMessageDrivenAsync(context, workflow, plan, initialInput, logger).ConfigureAwait(true);
+ }
+
+ ///
+ /// Executes a workflow using message-driven execution.
+ /// Messages are routed through edges dynamically, naturally supporting both DAGs and cycles.
+ ///
+ [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
+ private async Task ExecuteMessageDrivenAsync(
+ TaskOrchestrationContext context,
+ Workflow workflow,
+ WorkflowExecutionPlan plan,
+ string initialInput,
+ ILogger logger)
+ {
+ const int MaxSupersteps = 100;
+
+ // Message queues per executor - stores (message, inputTypeName) tuples
+ Dictionary> messageQueues = [];
+
+ // Last result from each executor (for edge condition evaluation and final result)
+ Dictionary lastResults = [];
// Track accumulated events and shared state
DurableWorkflowCustomStatus customStatus = new();
Dictionary sharedState = [];
- foreach (WorkflowExecutionLevel level in plan.Levels)
+ // Get executor bindings for creating WorkflowExecutorInfo
+ Dictionary executorBindings = workflow.ReflectExecutors();
+
+ // Initialize: queue input to start executor (initial input is a string)
+ EnqueueMessage(messageQueues, plan.StartExecutorId, initialInput, typeof(string).FullName);
+
+ int superstep = 0;
+ bool haltRequested = false;
+ string? finalOutput = null;
+
+ while (superstep < MaxSupersteps && !haltRequested)
{
- // Filter executors based on edge conditions from their predecessors
- List eligibleExecutors = GetEligibleExecutors(level.Executors, results, plan, logger);
+ superstep++;
- if (eligibleExecutors.Count == 0)
+ // Collect all executors with pending messages
+ List activeExecutors = messageQueues
+ .Where(kv => kv.Value.Count > 0)
+ .Select(kv => kv.Key)
+ .ToList();
+
+ if (activeExecutors.Count == 0)
{
- // No eligible executors at this level, continue to next level
- continue;
+ break; // No more work
}
- if (eligibleExecutors.Count == 1)
- {
- WorkflowExecutorInfo executorInfo = eligibleExecutors[0];
- string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
- string rawResult = await this.ExecuteExecutorAsync(context, executorInfo, input, logger, customStatus, sharedState).ConfigureAwait(true);
- results[executorInfo.ExecutorId] = UnwrapActivityResult(rawResult, customStatus, sharedState);
+#pragma warning disable CA1848, CA1873 // Use LoggerMessage delegates, expensive evaluation
+ logger.LogDebug("Superstep {Step}: {Count} active executor(s): {Executors}",
+ superstep, activeExecutors.Count, string.Join(", ", activeExecutors));
+#pragma warning restore CA1848, CA1873
- // Update custom status with any new events
- UpdateCustomStatus(context, customStatus);
- }
- else
+ // Process each active executor
+ foreach (string executorId in activeExecutors)
{
- // For parallel execution, each activity gets a snapshot of the current state
- // State updates are merged after all activities complete
- Task<(string Id, string Result)>[] tasks = new Task<(string Id, string Result)>[eligibleExecutors.Count];
- for (int i = 0; i < eligibleExecutors.Count; i++)
+ Queue<(string Message, string? InputTypeName)> queue = messageQueues[executorId];
+
+ // Process all messages for this executor in this superstep
+ while (queue.Count > 0)
{
- WorkflowExecutorInfo executorInfo = eligibleExecutors[i];
- string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
- tasks[i] = this.ExecuteExecutorWithIdAsync(context, executorInfo, input, logger, customStatus, sharedState);
+ (string input, string? inputTypeName) = queue.Dequeue();
+
+ // Create executor info
+ WorkflowExecutorInfo executorInfo = CreateExecutorInfo(executorId, executorBindings);
+
+ // Execute the activity with type information
+ string rawResult = await this.ExecuteExecutorAsync(
+ context, executorInfo, input, inputTypeName, logger, customStatus, sharedState).ConfigureAwait(true);
+
+ (string result, List sentMessages) = UnwrapActivityResult(rawResult, customStatus, sharedState);
+ lastResults[executorId] = result;
+
+ // Check for explicit halt request (via RequestHaltAsync)
+ if (CheckForHalt(customStatus, executorId))
+ {
+ haltRequested = true;
+ finalOutput = result;
+#pragma warning disable CA1848, CA1873 // Use LoggerMessage delegates
+ logger.LogDebug("Halt requested by executor {ExecutorId}", executorId);
+#pragma warning restore CA1848, CA1873
+ break;
+ }
+
+ // Route messages sent via SendMessageAsync (takes priority for void-returning executors)
+ if (sentMessages.Count > 0)
+ {
+ foreach (SentMessageInfo sentMessage in sentMessages)
+ {
+ if (!string.IsNullOrEmpty(sentMessage.Message))
+ {
+ // Route to successors with the sent message's type
+ RouteMessageToSuccessors(
+ executorId, sentMessage.Message, sentMessage.TypeName, plan, messageQueues, logger);
+ }
+ }
+ }
+ else if (!string.IsNullOrEmpty(result))
+ {
+ // Route executor's return value to successor executors via edges (for non-void executors)
+ RouteMessageToSuccessors(
+ executorId, result, plan, messageQueues, logger);
+ }
}
- (string Id, string Result)[] completedTasks = await Task.WhenAll(tasks).ConfigureAwait(true);
- foreach ((string id, string rawResult) in completedTasks)
+ if (haltRequested)
{
- results[id] = UnwrapActivityResult(rawResult, customStatus, sharedState);
+ break;
}
-
- // Update custom status with any new events
- UpdateCustomStatus(context, customStatus);
}
+
+ UpdateCustomStatus(context, customStatus);
}
- return GetFinalResult(plan, results);
+ if (superstep >= MaxSupersteps)
+ {
+#pragma warning disable CA1848, CA1873 // Use LoggerMessage delegates
+ logger.LogWarning("Workflow reached maximum superstep limit ({MaxSteps})", MaxSupersteps);
+#pragma warning restore CA1848, CA1873
+ }
+
+ // Return final output or last result from output executors
+ return finalOutput ?? GetMessageDrivenFinalResult(workflow, lastResults, customStatus);
}
///
- /// Unwraps an activity result, extracting state updates, events, and returning the actual result.
+ /// Enqueues a message to an executor's message queue with type information.
+ ///
+ private static void EnqueueMessage(
+ Dictionary> queues,
+ string executorId,
+ string message,
+ string? inputTypeName)
+ {
+ if (!queues.TryGetValue(executorId, out Queue<(string, string?)>? queue))
+ {
+ queue = new Queue<(string, string?)>();
+ queues[executorId] = queue;
+ }
+
+ queue.Enqueue((message, inputTypeName));
+ }
+
+ ///
+ /// Creates a WorkflowExecutorInfo for the given executor ID.
+ ///
+ private static WorkflowExecutorInfo CreateExecutorInfo(
+ string executorId,
+ Dictionary executorBindings)
+ {
+ if (!executorBindings.TryGetValue(executorId, out ExecutorBinding? binding))
+ {
+ throw new InvalidOperationException($"Executor '{executorId}' not found in workflow bindings.");
+ }
+
+ bool isAgentic = WorkflowHelper.IsAgentExecutorType(binding.ExecutorType);
+ RequestPort? requestPort = (binding is RequestPortBinding rpb) ? rpb.Port : null;
+
+ return new WorkflowExecutorInfo(executorId, isAgentic, requestPort);
+ }
+
+ ///
+ /// Checks if the workflow should halt based on halt request events.
+ /// Note: YieldOutputAsync does NOT halt the workflow - it just yields intermediate output.
+ /// Only explicit RequestHaltAsync calls should halt the workflow.
+ ///
+ private static bool CheckForHalt(
+ DurableWorkflowCustomStatus customStatus,
+ string executorId)
+ {
+ // Look for explicit halt request events from this executor
+ for (int i = customStatus.Events.Count - 1; i >= 0; i--)
+ {
+ string eventJson = customStatus.Events[i];
+
+ // Check for DurableHaltRequestedEvent - this is the ONLY event that should halt
+ if (eventJson.Contains("DurableHaltRequestedEvent", StringComparison.Ordinal) &&
+ eventJson.Contains(executorId, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Routes a message through edges to successor executors.
+ ///
+ [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
+ private static void RouteMessageToSuccessors(
+ string sourceId,
+ string message,
+ WorkflowExecutionPlan plan,
+ Dictionary> messageQueues,
+ ILogger logger)
+ {
+ if (!plan.Successors.TryGetValue(sourceId, out List? successors))
+ {
+ return; // No outgoing edges
+ }
+
+ // Get the output type of the source executor to pass as input type to successors
+ plan.ExecutorOutputTypes.TryGetValue(sourceId, out Type? sourceOutputType);
+ string? inputTypeName = sourceOutputType?.FullName;
+
+ foreach (string sinkId in successors)
+ {
+ // Check edge condition
+ if (plan.EdgeConditions.TryGetValue((sourceId, sinkId), out Func