From 39e071c4300974b70bc528ef6fa06b724ec2bd9d Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Wed, 24 Sep 2025 22:03:22 -0400 Subject: [PATCH] .NET: Update Workflow Input/Output Redesign (#881) * feat: Make Executor id field mandatory When checkpointing is involved, it is critical to keep executor ids consistent between runs, even when recreating a new object tree for the workflow. The default id-setting mechanism generated a guid for part of the id, making it not work when restoring from a checkpoint. This change prevents this situation from arising. * feat: Enable running untyped Workflows With the change to enable delay-instantiation of executors and support for async Executor factory methods, we must instantiate the starting executor to know what are the valid input types for the workflow. To avoid forcing instantiation every time, and to better support workflows with multiple input types, we enable support for build and interacting with the base Workflow type without type annotations, and remove the requirement to know a valid input type when initiating a run. * feat: Support Output from any executor and multiple outputs. --- .../Agents/CustomAgentExecutors/Program.cs | 21 +- .../Workflows/Agents/FoundryAgent/Program.cs | 2 +- .../Agents/WorkflowAsAnAgent/Program.cs | 2 +- .../WorkflowAsAnAgent/WorkflowHelper.cs | 7 +- .../CheckpointAndRehydrate/Program.cs | 21 +- .../CheckpointAndRehydrate/WorkflowHelper.cs | 8 +- .../Checkpoint/CheckpointAndResume/Program.cs | 10 +- .../CheckpointAndResume/WorkflowHelper.cs | 8 +- .../CheckpointWithHumanInTheLoop/Program.cs | 10 +- .../WorkflowHelper.cs | 7 +- .../Workflows/Concurrent/Program.cs | 9 +- .../01_EdgeCondition/Program.cs | 11 +- .../ConditionalEdges/02_SwitchCase/Program.cs | 16 +- .../03_MultiSelection/Program.cs | 16 +- .../Workflows/Declarative/Program.cs | 4 +- .../HumanInTheLoopBasic/Program.cs | 8 +- .../HumanInTheLoopBasic/WorkflowHelper.cs | 7 +- .../GettingStarted/Workflows/Loop/Program.cs | 21 +- .../Workflows/SharedStates/Program.cs | 9 +- .../01_ExecutorsAndEdges/Program.cs | 12 +- .../_Foundational/02_Streaming/Program.cs | 12 +- .../03_AgentsInWorkflows/Program.cs | 2 +- .../04_AgentWorkflowPatterns/Program.cs | 6 +- .../05_MultiModelService/Program.cs | 4 +- .../DeclarativeWorkflowBuilder.cs | 6 +- .../Interpreter/DeclarativeWorkflowContext.cs | 6 + .../Interpreter/WorkflowActionVisitor.cs | 4 +- .../AgentWorkflowBuilder.cs | 83 ++-- .../AggregatingExecutor.cs | 52 +++ .../ChatProtocolExecutor.cs | 64 +++ .../Checkpointed.cs | 4 - .../Checkpointing/RepresentationExtensions.cs | 12 +- .../Checkpointing/WorkflowInfo.cs | 51 +-- .../Execution/IRunnerWithOutput.cs | 10 - .../Execution/ISuperStepRunner.cs | 2 + .../Execution/MessageRouter.cs | 35 +- .../Execution/OutputFilter.cs | 11 + .../Microsoft.Agents.Workflows/Executor.cs | 53 ++- .../Microsoft.Agents.Workflows/ExecutorIsh.cs | 22 +- .../ExecutorOptions.cs | 7 +- .../ExternalRequest.cs | 5 + .../FunctionExecutor.cs | 18 +- .../IWorkflowContext.cs | 18 + .../InProc/InProcessRunner.cs | 137 +++---- .../InProc/InProcessRunnerContext.cs | 26 +- .../InProcessExecution.cs | 388 ++++++++++-------- .../Reflection/ReflectingExecutor.cs | 4 +- .../Reflection/RouteBuilderExtensions.cs | 2 +- .../RequestHaltEvent.cs | 12 + .../RouteBuilder.cs | 151 ++++++- dotnet/src/Microsoft.Agents.Workflows/Run.cs | 50 +-- .../Specialized/AIAgentHostExecutor.cs | 35 +- .../Specialized/IOutputSink.cs | 8 - .../Specialized/OutputCollectorExecutor.cs | 37 -- .../Specialized/RequestInfoExecutor.cs | 58 ++- .../StreamingAggregators.cs | 69 ++-- .../StreamingRun.cs | 57 +-- .../Microsoft.Agents.Workflows/Workflow.cs | 88 ++-- .../WorkflowBuilder.cs | 104 ++--- .../WorkflowBuilderExtensions.cs | 37 -- .../WorkflowCompletedEvent.cs | 13 - .../WorkflowEvent.cs | 2 +- .../WorkflowHostAgent.cs | 2 +- .../WorkflowHostingExtensions.cs | 22 + .../WorkflowOutputEvent.cs | 51 +++ .../DeclarativeWorkflowTest.cs | 2 +- .../Framework/WorkflowHarness.cs | 2 +- .../DeclarativeWorkflowTest.cs | 2 +- .../ObjectModel/WorkflowActionExecutorTest.cs | 2 +- .../AgentWorkflowBuilderTests.cs | 10 +- .../ForwardMessageExecutor.cs | 2 +- .../InProcessStateTests.cs | 20 +- .../JsonSerializationTests.cs | 29 +- .../ReflectionSmokeTest.cs | 8 +- .../RepresentationTests.cs | 25 +- .../Sample/01_Simple_Workflow_Sequential.cs | 6 +- .../Sample/01a_Simple_Workflow_Sequential.cs | 2 +- .../Sample/02_Simple_Workflow_Condition.cs | 27 +- .../Sample/03_Simple_Workflow_Loop.cs | 21 +- .../04_Simple_Workflow_ExternalRequest.cs | 73 ++-- .../05_Simple_Workflow_Checkpointing.cs | 65 ++- .../Sample/06_GroupChat_Workflow.cs | 6 +- .../07_GroupChat_Workflow_HostAsAgent.cs | 5 +- .../SpecializedExecutorSmokeTests.cs | 6 + .../StreamingAggregatorsTests.cs | 21 +- .../TestRunContext.cs | 6 + .../TestingExecutor.cs | 2 +- .../ValidationExtensions.cs | 8 +- .../WorkflowBuilderSmokeTests.cs | 12 +- 89 files changed, 1413 insertions(+), 998 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Workflows/AggregatingExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows/ChatProtocolExecutor.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows/Execution/OutputFilter.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows/RequestHaltEvent.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Specialized/IOutputSink.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs delete mode 100644 dotnet/src/Microsoft.Agents.Workflows/WorkflowCompletedEvent.cs create mode 100644 dotnet/src/Microsoft.Agents.Workflows/WorkflowOutputEvent.cs diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs index 46f438fc83..53a7837229 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs @@ -40,14 +40,15 @@ public static class Program var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create the executors - var sloganWriter = new SloganWriterExecutor(chatClient); - var feedbackProvider = new FeedbackExecutor(chatClient); + 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) .AddEdge(sloganWriter, feedbackProvider) .AddEdge(feedbackProvider, sloganWriter) - .Build(); + .WithOutputFrom(feedbackProvider) + .Build(); // Execute the workflow StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Create a slogan for a new electric SUV that is affordable and fun to drive."); @@ -59,9 +60,9 @@ public static class Program Console.WriteLine($"{evt}"); } - if (evt is WorkflowCompletedEvent completedEvent) + if (evt is WorkflowOutputEvent outputEvent) { - Console.WriteLine($"{completedEvent}"); + Console.WriteLine($"{outputEvent}"); } } } @@ -119,8 +120,9 @@ internal sealed class SloganWriterExecutor /// /// Initializes a new instance of the class. /// + /// A unique identifier for the executor. /// The chat client to use for the AI agent. - public SloganWriterExecutor(IChatClient chatClient) + public SloganWriterExecutor(string id, IChatClient chatClient) : base(id) { ChatClientAgentOptions agentOptions = new(instructions: "You are a professional slogan writer. You will be given a task to create a slogan.") { @@ -189,8 +191,9 @@ internal sealed class FeedbackExecutor : ReflectingExecutor, I /// /// Initializes a new instance of the class. /// + /// A unique identifier for the executor. /// The chat client to use for the AI agent. - public FeedbackExecutor(IChatClient chatClient) + public FeedbackExecutor(string id, IChatClient chatClient) : base(id) { ChatClientAgentOptions agentOptions = new(instructions: "You are a professional editor. You will be given a slogan and the task it is meant to accomplish.") { @@ -219,13 +222,13 @@ internal sealed class FeedbackExecutor : ReflectingExecutor, I if (feedback.Rating >= this.MinimumRating) { - await context.AddEventAsync(new WorkflowCompletedEvent($"The following slogan was accepted:\n\n{message.Slogan}")); + await context.YieldOutputAsync($"The following slogan was accepted:\n\n{message.Slogan}"); return; } if (this._attempts >= this.MaxAttempts) { - await context.AddEventAsync(new WorkflowCompletedEvent($"The slogan was rejected after {this.MaxAttempts} attempts. Final slogan:\n\n{message.Slogan}")); + await context.YieldOutputAsync($"The slogan was rejected after {this.MaxAttempts} attempts. Final slogan:\n\n{message.Slogan}"); return; } diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs index e4de06a677..56dafd086f 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs @@ -37,7 +37,7 @@ public static class Program var workflow = new WorkflowBuilder(frenchAgent) .AddEdge(frenchAgent, spanishAgent) .AddEdge(spanishAgent, englishAgent) - .Build(); + .Build(); // Execute the workflow StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!")); diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs index d61415c38a..6da73134fe 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs @@ -38,7 +38,7 @@ public static class Program var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create the workflow and turn it into an agent - var workflow = WorkflowHelper.GetWorkflow(chatClient); + var workflow = await WorkflowHelper.GetWorkflowAsync(chatClient).ConfigureAwait(false); var agent = workflow.AsAgent("workflow-agent", "Workflow Agent"); var thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs index efedca6ca5..19ad44a4a8 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs @@ -17,7 +17,7 @@ internal static class WorkflowHelper /// /// The chat client to use for the agents /// A workflow that processes input using two language agents - internal static Workflow> GetWorkflow(IChatClient chatClient) + internal static ValueTask>> GetWorkflowAsync(IChatClient chatClient) { // Create executors var startExecutor = new ConcurrentStartExecutor(); @@ -29,7 +29,8 @@ internal static class WorkflowHelper return new WorkflowBuilder(startExecutor) .AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent]) .AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent]) - .Build>(); + .WithOutputFrom(aggregationExecutor) + .BuildAsync>(); } /// @@ -84,7 +85,7 @@ internal static class WorkflowHelper if (this._messages.Count == 2) { var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.Text}")); - await context.AddEventAsync(new WorkflowCompletedEvent(formattedMessages)); + await context.YieldOutputAsync(formattedMessages); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs index 4e34d4eb79..349047eb3c 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.Workflows; @@ -29,7 +28,7 @@ public static class Program private static async Task Main() { // Create the workflow - var workflow = WorkflowHelper.GetWorkflow(); + var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); // Create checkpoint manager var checkpointManager = CheckpointManager.Default; @@ -58,9 +57,9 @@ public static class Program } } - if (evt is WorkflowCompletedEvent workflowCompletedEvt) + if (evt is WorkflowOutputEvent outputEvent) { - Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + Console.WriteLine($"Workflow completed with result: {outputEvent.Data}"); } } @@ -71,15 +70,15 @@ public static class Program Console.WriteLine($"Number of checkpoints created: {checkpoints.Count}"); // Rehydrate a new workflow instance from a saved checkpoint and continue execution - var newWorkflow = WorkflowHelper.GetWorkflow(); + var newWorkflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); const int CheckpointIndex = 5; Console.WriteLine($"\n\nHydrating a new workflow instance from the {CheckpointIndex + 1}th checkpoint."); CheckpointInfo savedCheckpoint = checkpoints[CheckpointIndex]; - Checkpointed newCheckpointedRun = await InProcessExecution - .StreamAsync(newWorkflow, NumberSignal.Init, checkpointManager) - .ConfigureAwait(false); - await newCheckpointedRun.RestoreCheckpointAsync(savedCheckpoint, CancellationToken.None).ConfigureAwait(false); + Checkpointed newCheckpointedRun = + await InProcessExecution.ResumeStreamAsync(newWorkflow, savedCheckpoint, checkpointManager, checkpointedRun.Run.RunId) + .ConfigureAwait(false); + await foreach (WorkflowEvent evt in newCheckpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) { if (evt is ExecutorCompletedEvent executorCompletedEvt) @@ -87,9 +86,9 @@ public static class Program Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); } - if (evt is WorkflowCompletedEvent workflowCompletedEvt) + if (evt is WorkflowOutputEvent workflowOutputEvt) { - Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}"); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs index e1aa53dd16..6e2c587d21 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs @@ -16,7 +16,7 @@ internal static class WorkflowHelper /// 2. JudgeExecutor: Evaluates the guess and provides feedback. /// The workflow continues until the correct number is guessed. /// - internal static Workflow GetWorkflow() + internal static ValueTask> GetWorkflowAsync() { // Create the executors GuessNumberExecutor guessNumberExecutor = new(1, 100); @@ -26,7 +26,8 @@ internal static class WorkflowHelper return new WorkflowBuilder(guessNumberExecutor) .AddEdge(guessNumberExecutor, judgeExecutor) .AddEdge(judgeExecutor, guessNumberExecutor) - .Build(); + .WithOutputFrom(judgeExecutor) + .BuildAsync(); } } @@ -126,8 +127,7 @@ internal sealed class JudgeExecutor() : ReflectingExecutor("Judge this._tries++; if (message == this._targetNumber) { - await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) - .ConfigureAwait(false); + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!").ConfigureAwait(false); } else if (message < this._targetNumber) { diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs index 5aa3612101..16a0eb1bf7 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs @@ -28,7 +28,7 @@ public static class Program private static async Task Main() { // Create the workflow - var workflow = WorkflowHelper.GetWorkflow(); + var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); // Create checkpoint manager var checkpointManager = CheckpointManager.Default; @@ -57,9 +57,9 @@ public static class Program } } - if (evt is WorkflowCompletedEvent workflowCompletedEvt) + if (evt is WorkflowOutputEvent workflowOutputEvt) { - Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}"); } } @@ -82,9 +82,9 @@ public static class Program Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); } - if (evt is WorkflowCompletedEvent workflowCompletedEvt) + if (evt is WorkflowOutputEvent workflowOutputEvt) { - Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}"); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs index 63be96a306..e87171effe 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs @@ -16,7 +16,7 @@ internal static class WorkflowHelper /// 2. JudgeExecutor: Evaluates the guess and provides feedback. /// The workflow continues until the correct number is guessed. /// - internal static Workflow GetWorkflow() + internal static ValueTask> GetWorkflowAsync() { // Create the executors GuessNumberExecutor guessNumberExecutor = new(1, 100); @@ -26,7 +26,8 @@ internal static class WorkflowHelper return new WorkflowBuilder(guessNumberExecutor) .AddEdge(guessNumberExecutor, judgeExecutor) .AddEdge(judgeExecutor, guessNumberExecutor) - .Build(); + .WithOutputFrom(judgeExecutor) + .BuildAsync(); } } @@ -126,8 +127,7 @@ internal sealed class JudgeExecutor() : ReflectingExecutor("Judge this._tries++; if (message == this._targetNumber) { - await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) - .ConfigureAwait(false); + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!").ConfigureAwait(false); } else if (message < this._targetNumber) { diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs index aaa75f0e2d..0215fba8e9 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs @@ -31,7 +31,7 @@ public static class Program private static async Task Main() { // Create the workflow - var workflow = WorkflowHelper.GetWorkflow(); + var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); // Create checkpoint manager var checkpointManager = CheckpointManager.Default; @@ -63,8 +63,8 @@ public static class Program Console.WriteLine($"** Checkpoint created at step {checkpoints.Count}."); } break; - case WorkflowCompletedEvent workflowCompletedEvt: - Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + case WorkflowOutputEvent workflowOutputEvt: + Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}"); break; } } @@ -93,8 +93,8 @@ public static class Program case ExecutorCompletedEvent executorCompletedEvt: Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); break; - case WorkflowCompletedEvent workflowCompletedEvt: - Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + case WorkflowOutputEvent workflowOutputEvt: + Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}"); break; } } diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs index bfccebed54..b1fd63fef1 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs @@ -13,7 +13,7 @@ internal static class WorkflowHelper /// Get a workflow that plays a number guessing game with human-in-the-loop interaction. /// An input port allows the external world to provide inputs to the workflow upon requests. /// - internal static Workflow GetWorkflow() + internal static ValueTask> GetWorkflowAsync() { // Create the executors InputPort numberInputPort = InputPort.Create("GuessNumber"); @@ -23,7 +23,8 @@ internal static class WorkflowHelper return new WorkflowBuilder(numberInputPort) .AddEdge(numberInputPort, judgeExecutor) .AddEdge(judgeExecutor, numberInputPort) - .Build(); + .WithOutputFrom(judgeExecutor) + .BuildAsync(); } } @@ -75,7 +76,7 @@ internal sealed class JudgeExecutor() : ReflectingExecutor("Judge this._tries++; if (message == this._targetNumber) { - await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!") .ConfigureAwait(false); } else if (message < this._targetNumber) diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Program.cs index 9e4bd69b62..7f26ecd714 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Program.cs @@ -59,15 +59,16 @@ public static class Program var workflow = new WorkflowBuilder(startExecutor) .AddFanOutEdge(startExecutor, targets: [physicist, chemist]) .AddFanInEdge(aggregationExecutor, sources: [physicist, chemist]) - .Build(); + .WithOutputFrom(aggregationExecutor) + .Build(); // Execute the workflow in streaming mode StreamingRun run = await InProcessExecution.StreamAsync(workflow, "What is temperature?"); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is WorkflowCompletedEvent completed) + if (evt is WorkflowOutputEvent output) { - Console.WriteLine($"Workflow completed with results:\n{completed.Data}"); + Console.WriteLine($"Workflow completed with results:\n{output.Data}"); } } } @@ -118,7 +119,7 @@ internal sealed class ConcurrentAggregationExecutor() : if (this._messages.Count == 2) { var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.AuthorName}: {m.Text}")); - await context.AddEventAsync(new WorkflowCompletedEvent(formattedMessages)); + await context.YieldOutputAsync(formattedMessages); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs index 1488e93d60..2a28b83cef 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs @@ -57,7 +57,8 @@ public static class Program .AddEdge(spamDetectionExecutor, emailAssistantExecutor, condition: GetCondition(expectedResult: false)) .AddEdge(emailAssistantExecutor, sendEmailExecutor) .AddEdge(spamDetectionExecutor, handleSpamExecutor, condition: GetCondition(expectedResult: true)) - .Build(); + .WithOutputFrom(handleSpamExecutor, sendEmailExecutor) + .Build(); // Read a email from a text file string email = Resources.Read("spam.txt"); @@ -67,9 +68,9 @@ public static class Program await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is WorkflowCompletedEvent completedEvent) + if (evt is WorkflowOutputEvent outputEvent) { - Console.WriteLine($"{completedEvent}"); + Console.WriteLine($"{outputEvent}"); } } } @@ -234,7 +235,7 @@ internal sealed class SendEmailExecutor() : ReflectingExecutor public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) => - await context.AddEventAsync(new WorkflowCompletedEvent($"Email sent: {message.Response}")); + await context.YieldOutputAsync($"Email sent: {message.Response}"); } /// @@ -249,7 +250,7 @@ internal sealed class HandleSpamExecutor() : ReflectingExecutor(); + .AddEdge(emailAssistantExecutor, sendEmailExecutor) + .WithOutputFrom(handleSpamExecutor, sendEmailExecutor, handleUncertainExecutor); + + var workflow = builder.Build(); // Read a email from a text file string email = Resources.Read("ambiguous_email.txt"); @@ -82,9 +84,9 @@ public static class Program await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is WorkflowCompletedEvent completedEvent) + if (evt is WorkflowOutputEvent outputEvent) { - Console.WriteLine($"{completedEvent}"); + Console.WriteLine($"{outputEvent}"); } } } @@ -257,7 +259,7 @@ internal sealed class SendEmailExecutor() : ReflectingExecutor public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) => - await context.AddEventAsync(new WorkflowCompletedEvent($"Email sent: {message.Response}")); + await context.YieldOutputAsync($"Email sent: {message.Response}").ConfigureAwait(false); } /// @@ -272,7 +274,7 @@ internal sealed class HandleSpamExecutor() : ReflectingExecutor(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); - await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}")); + await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}"); } else { diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs index aba45a1d7b..63efacdc7f 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs @@ -80,8 +80,10 @@ public static class Program databaseAccessExecutor, condition: analysisResult => analysisResult?.EmailLength <= LongEmailThreshold) // Save the analysis result to the database with summary - .AddEdge(emailSummaryExecutor, databaseAccessExecutor); - var workflow = builder.Build(); + .AddEdge(emailSummaryExecutor, databaseAccessExecutor) + .WithOutputFrom(handleUncertainExecutor, handleSpamExecutor, sendEmailExecutor); + + var workflow = builder.Build(); // Read a email from a text file string email = Resources.Read("email.txt"); @@ -91,9 +93,9 @@ public static class Program await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is WorkflowCompletedEvent completedEvent) + if (evt is WorkflowOutputEvent outputEvent) { - Console.WriteLine($"{completedEvent}"); + Console.WriteLine($"{outputEvent}"); } if (evt is DatabaseEvent databaseEvent) @@ -315,7 +317,7 @@ internal sealed class SendEmailExecutor() : ReflectingExecutor public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) => - await context.AddEventAsync(new WorkflowCompletedEvent($"Email sent: {message.Response}")); + await context.YieldOutputAsync($"Email sent: {message.Response}"); } /// @@ -330,7 +332,7 @@ internal sealed class HandleSpamExecutor() : ReflectingExecutor(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); - await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}")); + await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}"); } else { diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs index 19c3c0321a..400f3bbf72 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs @@ -53,7 +53,7 @@ internal sealed class Program Stopwatch timer = Stopwatch.StartNew(); - Workflow workflow = this.CreateWorkflow(); + Workflow workflow = this.CreateWorkflow(); Notify($"\nWORKFLOW: Defined {timer.Elapsed}"); @@ -100,7 +100,7 @@ internal sealed class Program Notify("\nWORKFLOW: Done!\n"); } - private Workflow CreateWorkflow() + private Workflow CreateWorkflow() { // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. DeclarativeWorkflowOptions options = diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs index 7e56f6b7d5..68d151167b 100644 --- a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs @@ -26,7 +26,7 @@ public static class Program private static async Task Main() { // Create the workflow - var workflow = WorkflowHelper.GetWorkflow(); + var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); // Execute the workflow StreamingRun handle = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); @@ -40,9 +40,9 @@ public static class Program await handle.SendResponseAsync(response).ConfigureAwait(false); break; - case WorkflowCompletedEvent workflowCompleteEvt: - // The workflow has completed successfully - Console.WriteLine($"Workflow completed with result: {workflowCompleteEvt.Data}"); + case WorkflowOutputEvent outputEvt: + // The workflow has yielded output + Console.WriteLine($"Workflow completed with result: {outputEvt.Data}"); return; } } diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs index 7f87393545..5db097056d 100644 --- a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs @@ -12,7 +12,7 @@ internal static class WorkflowHelper /// Get a workflow that plays a number guessing game with human-in-the-loop interaction. /// An input port allows the external world to provide inputs to the workflow upon requests. /// - internal static Workflow GetWorkflow() + internal static ValueTask> GetWorkflowAsync() { // Create the executors InputPort numberInputPort = InputPort.Create("GuessNumber"); @@ -22,7 +22,8 @@ internal static class WorkflowHelper return new WorkflowBuilder(numberInputPort) .AddEdge(numberInputPort, judgeExecutor) .AddEdge(judgeExecutor, numberInputPort) - .Build(); + .WithOutputFrom(judgeExecutor) + .BuildAsync(); } } @@ -58,7 +59,7 @@ internal sealed class JudgeExecutor() : ReflectingExecutor("Judge this._tries++; if (message == this._targetNumber) { - await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!") .ConfigureAwait(false); } else if (message < this._targetNumber) diff --git a/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs b/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs index 4f03d501ed..d74e4a913d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs @@ -24,22 +24,23 @@ public static class Program private static async Task Main() { // Create the executors - GuessNumberExecutor guessNumberExecutor = new(1, 100); - JudgeExecutor judgeExecutor = new(42); + GuessNumberExecutor guessNumberExecutor = new("GuessNumber", 1, 100); + JudgeExecutor judgeExecutor = new("Judge", 42); // Build the workflow by connecting executors in a loop - var workflow = new WorkflowBuilder(guessNumberExecutor) + var workflow = await new WorkflowBuilder(guessNumberExecutor) .AddEdge(guessNumberExecutor, judgeExecutor) .AddEdge(judgeExecutor, guessNumberExecutor) - .Build(); + .WithOutputFrom(judgeExecutor) + .BuildAsync(); // Execute the workflow StreamingRun run = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is WorkflowCompletedEvent workflowCompleteEvt) + if (evt is WorkflowOutputEvent outputEvent) { - Console.WriteLine($"Result: {workflowCompleteEvt}"); + Console.WriteLine($"Result: {outputEvent}"); } } } @@ -73,9 +74,10 @@ internal sealed class GuessNumberExecutor : ReflectingExecutor /// Initializes a new instance of the class. /// + /// A unique identifier for the executor. /// The initial lower bound of the guessing range. /// The initial upper bound of the guessing range. - public GuessNumberExecutor(int lowerBound, int upperBound) + public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id) { this.LowerBound = lowerBound; this.UpperBound = upperBound; @@ -113,8 +115,9 @@ internal sealed class JudgeExecutor : ReflectingExecutor, IMessag /// /// Initializes a new instance of the class. /// + /// A unique identifier for the executor. /// The number to be guessed. - public JudgeExecutor(int targetNumber) + public JudgeExecutor(string id, int targetNumber) : base(id) { this._targetNumber = targetNumber; } @@ -124,7 +127,7 @@ internal sealed class JudgeExecutor : ReflectingExecutor, IMessag this._tries++; if (message == this._targetNumber) { - await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!") .ConfigureAwait(false); } else if (message < this._targetNumber) diff --git a/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs b/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs index a0c24b12b7..2f90e1fb0f 100644 --- a/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs @@ -33,15 +33,16 @@ public static class Program var workflow = new WorkflowBuilder(fileRead) .AddFanOutEdge(fileRead, targets: [wordCount, paragraphCount]) .AddFanInEdge(aggregate, sources: [wordCount, paragraphCount]) - .Build(); + .WithOutputFrom(aggregate) + .Build(); // Execute the workflow with input data Run run = await InProcessExecution.RunAsync(workflow, "Lorem_Ipsum.txt"); foreach (WorkflowEvent evt in run.NewEvents) { - if (evt is WorkflowCompletedEvent workflowCompleted) + if (evt is WorkflowOutputEvent outputEvent) { - Console.WriteLine(workflowCompleted.Data); + Console.WriteLine(outputEvent.Data); } } } @@ -116,7 +117,7 @@ internal sealed class AggregationExecutor() : ReflectingExecutor m.ParagraphCount); var totalWordCount = this._messages.Sum(m => m.WordCount); - await context.AddEventAsync(new WorkflowCompletedEvent($"Total Paragraphs: {totalParagraphCount}, Total Words: {totalWordCount}")); + await context.YieldOutputAsync($"Total Paragraphs: {totalParagraphCount}, Total Words: {totalWordCount}"); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs index 90d743c813..9ae605a7df 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs @@ -29,8 +29,8 @@ public static class Program // Build the workflow by connecting executors sequentially WorkflowBuilder builder = new(uppercase); - builder.AddEdge(uppercase, reverse); - var workflow = builder.Build(); + builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + var workflow = builder.Build(); // Execute the workflow with input data Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!"); @@ -72,11 +72,7 @@ internal sealed class ReverseTextExecutor() : ReflectingExecutorThe input text reversed public async ValueTask HandleAsync(string message, IWorkflowContext context) { - string result = string.Concat(message.Reverse()); - - // Signal that the workflow is complete - await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false); - - return result; + // Because we do not suppress it, the returned result will be yielded as an output from this executor. + return string.Concat(message.Reverse()); } } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs index 0b3f7c3712..482182190a 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs @@ -28,8 +28,8 @@ public static class Program // Build the workflow by connecting executors sequentially WorkflowBuilder builder = new(uppercase); - builder.AddEdge(uppercase, reverse); - var workflow = builder.Build(); + builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse); + var workflow = builder.Build(); // Execute the workflow in streaming mode StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Hello, World!"); @@ -71,11 +71,7 @@ internal sealed class ReverseTextExecutor() : ReflectingExecutorThe input text reversed public async ValueTask HandleAsync(string message, IWorkflowContext context) { - string result = string.Concat(message.Reverse()); - - // Signal that the workflow is complete - await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false); - - return result; + // Because we do not suppress it, the returned result will be yielded as an output from this executor. + return string.Concat(message.Reverse()); } } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs index 4be46288f2..b5d30eff2d 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs @@ -43,7 +43,7 @@ public static class Program var workflow = new WorkflowBuilder(frenchAgent) .AddEdge(frenchAgent, spanishAgent) .AddEdge(spanishAgent, englishAgent) - .Build(); + .Build(); // Execute the workflow StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!")); diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs index f936a8f495..1838aba48d 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs @@ -84,7 +84,7 @@ public static class Program throw new InvalidOperationException("Invalid workflow type."); } - static async Task> RunWorkflowAsync(Workflow> workflow, List messages) + static async Task> RunWorkflowAsync(Workflow workflow, List messages) { string? lastExecutorId = null; @@ -108,10 +108,10 @@ public static class Program Console.WriteLine($" [Calling function '{call.Name}' with arguments: {JsonSerializer.Serialize(call.Arguments)}]"); } } - else if (evt is WorkflowCompletedEvent completed) + else if (evt is WorkflowOutputEvent output) { Console.WriteLine(); - return (List)completed.Data!; + return output.As>()!; } } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/Program.cs index 66d6718be5..c6aef72e78 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/Program.cs @@ -58,7 +58,9 @@ AIAgent reporter = new ChatClientAgent(anthropic, description: "Summarize the researcher's essay into a single paragraph, focusing only on the fact checker's confirmed facts."); // Build a sequential workflow: Researcher -> Fact-Checker -> Reporter -AIAgent workflowAgent = AgentWorkflowBuilder.BuildSequential(researcher, factChecker, reporter).AsAgent(); +AIAgent workflowAgent = await AgentWorkflowBuilder.BuildSequential(researcher, factChecker, reporter) + .AsAgentAsync() + .ConfigureAwait(false); // Run the workflow, streaming the output as it arrives. string? lastAuthor = null; diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index d965f368de..c68713d4a3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -24,7 +24,7 @@ public static class DeclarativeWorkflowBuilder /// Configuration options for workflow execution. /// An optional function to transform the input message into a . /// - public static Workflow Build( + public static Workflow Build( string workflowFile, DeclarativeWorkflowOptions options, Func? inputTransform = null) @@ -42,7 +42,7 @@ public static class DeclarativeWorkflowBuilder /// Configuration options for workflow execution. /// An optional function to transform the input message into a . /// The that corresponds with the YAML object model. - public static Workflow Build( + public static Workflow Build( TextReader yamlReader, DeclarativeWorkflowOptions options, Func? inputTransform = null) @@ -68,7 +68,7 @@ public static class DeclarativeWorkflowBuilder WorkflowElementWalker walker = new(visitor); walker.Visit(rootElement); - return visitor.Complete(); + return visitor.Complete(); } private static ChatMessage DefaultTransform(object message) => diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs index b420f2065a..d3859ccb77 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs @@ -32,6 +32,12 @@ internal sealed class DeclarativeWorkflowContext : IWorkflowContext /// public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => this.Source.AddEventAsync(workflowEvent); + /// + public ValueTask YieldOutputAsync(object output) => this.Source.YieldOutputAsync(output); + + /// + public ValueTask RequestHaltAsync() => this.Source.RequestHaltAsync(); + /// public async ValueTask QueueClearScopeAsync(string? scopeName = null) { diff --git a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 224ccfcf40..b058c4dc36 100644 --- a/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -40,13 +40,13 @@ internal sealed class WorkflowActionVisitor : DialogActionVisitor public bool HasUnsupportedActions { get; private set; } - public Workflow Complete() + public Workflow Complete() { // Process the cached links this._workflowModel.ConnectNodes(this._workflowBuilder); // Build final workflow - return this._workflowBuilder.Build(); + return this._workflowBuilder.Build(); } protected override void Visit(ActionScope item) diff --git a/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs index b4ee59244f..4128c79330 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/AgentWorkflowBuilder.cs @@ -26,7 +26,7 @@ public static partial class AgentWorkflowBuilder /// /// The sequence of agents to compose into a sequential workflow. /// The built workflow composed of the supplied , in the order in which they were yielded from the source. - public static Workflow> BuildSequential(params IEnumerable agents) + public static Workflow BuildSequential(params IEnumerable agents) { Throw.IfNull(agents); @@ -59,9 +59,11 @@ public static partial class AgentWorkflowBuilder // Add an ending executor that batches up all messages from the last agent // so that it's published as a single list result. Debug.Assert(builder is not null); - builder.AddEdge(previous, new ConvertMessageListToCompletedEventExecutor()); - return builder.Build>(); + OutputMessagesExecutor end = new(); + return builder.AddEdge(previous, end) + .WithOutputFrom(end) + .Build(); } /// @@ -75,14 +77,14 @@ public static partial class AgentWorkflowBuilder /// from each agent that produced at least one message. /// /// The built workflow composed of the supplied concurrent . - public static Workflow> BuildConcurrent( + public static Workflow BuildConcurrent( IEnumerable agents, Func>, List>? aggregator = null) { Throw.IfNull(agents); // A workflow needs a starting executor, so we create one that forwards everything to each agent. - ForwardingExecutor start = new(); + ChatForwardingExecutor start = new("Start"); WorkflowBuilder builder = new(start); // For each agent, we create an executor to host it and an accumulator to batch up its output messages, @@ -90,7 +92,7 @@ public static partial class AgentWorkflowBuilder // accumulator would not be able to determine what came from what agent, as there's currently no // provenance tracking exposed in the workflow context passed to a handler. ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray(); - ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new BatchChatMessagesToListExecutor()]; + ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new BatchChatMessagesToListExecutor($"Batcher/{agent.Id}")]; builder.AddFanOutEdge(start, targets: agentExecutors); for (int i = 0; i < agentExecutors.Length; i++) { @@ -104,7 +106,7 @@ public static partial class AgentWorkflowBuilder ConcurrentEndExecutor end = new(agentExecutors.Length, aggregator); builder.AddFanInEdge(end, sources: accumulators); - return builder.Build>(); + return builder.WithOutputFrom(end).Build(); } /// Creates a new using as the starting agent in the workflow. @@ -189,49 +191,31 @@ public static partial class AgentWorkflowBuilder /// Provides an executor that batches received chat messages that it then publishes as the final result /// when receiving a . /// - private sealed class ConvertMessageListToCompletedEventExecutor : Executor + private sealed class OutputMessagesExecutor() : ChatProtocolExecutor("OutputMessages") { - private readonly List _pendingMessages = []; - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) - .AddHandler(async (token, context) => - { - var messages = new List(this._pendingMessages); - this._pendingMessages.Clear(); - await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false); - }); + protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellation = default) + => context.YieldOutputAsync(messages); } /// Executor that forwards all messages. - private sealed class ForwardingExecutor : Executor + private sealed class ChatForwardingExecutor(string id) : Executor(id) { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler((message, context) => context.SendMessageAsync(message)); + routeBuilder + .AddHandler((message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message))) + .AddHandler((message, context) => context.SendMessageAsync(message)) + .AddHandler>((messages, context) => context.SendMessageAsync(messages)) + .AddHandler((turnToken, context) => context.SendMessageAsync(turnToken)); } /// /// Provides an executor that batches received chat messages that it then releases when /// receiving a . /// - private sealed class BatchChatMessagesToListExecutor : Executor + private sealed class BatchChatMessagesToListExecutor(string id) : ChatProtocolExecutor(id) { - private readonly List _pendingMessages = []; - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) - .AddHandler(async (token, context) => - { - var messages = new List(this._pendingMessages); - this._pendingMessages.Clear(); - - await context.SendMessageAsync(messages).ConfigureAwait(false); - await context.SendMessageAsync(token).ConfigureAwait(false); - }); + protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellation = default) + => context.SendMessageAsync(messages); } /// @@ -245,7 +229,7 @@ public static partial class AgentWorkflowBuilder private List> _allResults; private int _remaining; - public ConcurrentEndExecutor(int expectedInputs, Func>, List> aggregator) + public ConcurrentEndExecutor(int expectedInputs, Func>, List> aggregator) : base("ConcurrentEnd") { this._expectedInputs = expectedInputs; this._aggregator = Throw.IfNull(aggregator); @@ -272,8 +256,7 @@ public static partial class AgentWorkflowBuilder var results = this._allResults; this._allResults = new List>(this._expectedInputs); - - await context.AddEventAsync(new WorkflowCompletedEvent(this._aggregator(results))).ConfigureAwait(false); + await context.YieldOutputAsync(this._aggregator(results)).ConfigureAwait(false); } }); } @@ -414,7 +397,7 @@ public static partial class AgentWorkflowBuilder /// agent to process messages selected by the current agent. /// /// The workflow built based on the handoffs in the builder. - public Workflow> Build() + public Workflow Build() { StartHandoffsExecutor start = new(); EndHandoffsExecutor end = new(); @@ -434,7 +417,7 @@ public static partial class AgentWorkflowBuilder } // Build the workflow. - return builder.Build>(); + return builder.WithOutputFrom(end).Build(); } /// Describes a handoff to a specific target . @@ -445,7 +428,7 @@ public static partial class AgentWorkflowBuilder } /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. - private sealed class StartHandoffsExecutor : Executor + private sealed class StartHandoffsExecutor() : Executor("HandoffStart") { private readonly List _pendingMessages = []; @@ -465,11 +448,11 @@ public static partial class AgentWorkflowBuilder } /// Executor used at the end of a handoff workflow to raise a final completed event. - private sealed class EndHandoffsExecutor : Executor + private sealed class EndHandoffsExecutor() : Executor("HandoffEnd") { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddHandler((handoff, context) => - context.AddEventAsync(new WorkflowCompletedEvent(handoff.Messages))); + context.YieldOutputAsync(handoff.Messages)); } /// Executor used to represent an agent in a handoffs workflow, responding to events. @@ -740,11 +723,11 @@ public static partial class AgentWorkflowBuilder } /// - /// Builds a composed of agents that operate via group chat, with the next + /// Builds a composed of agents that operate via group chat, with the next /// agent to process messages selected by the group chat manager. /// /// The workflow built based on the group chat in the builder. - public Workflow> Build() + public Workflow Build() { AIAgent[] agents = this._participants.ToArray(); Dictionary agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true)); @@ -760,10 +743,10 @@ public static partial class AgentWorkflowBuilder .AddEdge(participant, host); } - return builder.Build>(); + return builder.WithOutputFrom(host).Build(); } - private sealed class GroupChatHost(AIAgent[] agents, Dictionary agentMap, Func, GroupChatManager> managerFactory) : Executor + private sealed class GroupChatHost(AIAgent[] agents, Dictionary agentMap, Func, GroupChatManager> managerFactory) : Executor("GroupChatHost") { private readonly AIAgent[] _agents = agents; private readonly Dictionary _agentMap = agentMap; @@ -801,7 +784,7 @@ public static partial class AgentWorkflowBuilder } this._manager = null; - await context.AddEventAsync(new WorkflowCompletedEvent(messages)).ConfigureAwait(false); + await context.YieldOutputAsync(messages).ConfigureAwait(false); }); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/AggregatingExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/AggregatingExecutor.cs new file mode 100644 index 0000000000..de1975e59a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/AggregatingExecutor.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Workflows; + +/// +/// Executes a workflow step that incrementally aggregates input messages using a user-provided aggregation function. +/// +/// The aggregate state is persisted and restored automatically during workflow checkpointing. This +/// executor is suitable for scenarios where stateful, incremental aggregation of messages is required, such as running +/// totals or event accumulation. +/// The type of input messages to be processed and aggregated. +/// The type representing the aggregate state produced by the aggregator function. +/// The unique identifier for this executor instance. +/// A function that computes the new aggregate state from the previous aggregate and the current input message. The +/// function receives the current aggregate (or null if this is the first message) and the input message, and returns +/// the updated aggregate. +/// Optional configuration settings for the executor. If null, default options are used. +/// +public class AggregatingExecutor(string id, + Func aggregator, + ExecutorOptions? options = null) : Executor(id, options) +{ + private const string AggregateStateKey = "Aggregate"; + private TAggregate? _runningAggregate; + + /// + public override ValueTask HandleAsync(TInput message, IWorkflowContext context) + { + this._runningAggregate = aggregator(this._runningAggregate, message); + return new(this._runningAggregate); + } + + /// + protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + await context.QueueStateUpdateAsync(AggregateStateKey, this._runningAggregate).ConfigureAwait(false); + + await base.OnCheckpointingAsync(context, cancellation).ConfigureAwait(false); + } + + /// + protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + await base.OnCheckpointRestoredAsync(context, cancellation).ConfigureAwait(false); + + this._runningAggregate = await context.ReadStateAsync(AggregateStateKey).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/ChatProtocolExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/ChatProtocolExecutor.cs new file mode 100644 index 0000000000..3c86865d36 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/ChatProtocolExecutor.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.Workflows; + +internal class ChatProtocolExecutorOptions +{ + public ChatRole? StringMessageChatRole { get; set; } +} + +internal abstract class ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null) : Executor(id) +{ + private List _pendingMessages = []; + private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole; + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + if (this._stringMessageChatRole.HasValue) + { + routeBuilder = routeBuilder.AddHandler((message, _) => this._pendingMessages.Add(new(this._stringMessageChatRole.Value, message))); + } + + return routeBuilder.AddHandler((message, _) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) + .AddHandler(this.TakeTurnAsync); + } + + public async ValueTask TakeTurnAsync(TurnToken token, IWorkflowContext context) + { + await this.TakeTurnAsync(this._pendingMessages, context, token.EmitEvents).ConfigureAwait(false); + this._pendingMessages = new(); + await context.SendMessageAsync(token).ConfigureAwait(false); + } + + protected abstract ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellation = default); + + private const string PendingMessagesStateKey = nameof(_pendingMessages); + protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + Task messagesTask = Task.CompletedTask; + if (this._pendingMessages.Count > 0) + { + JsonElement messagesValue = this._pendingMessages.Serialize(); + messagesTask = context.QueueStateUpdateAsync(PendingMessagesStateKey, messagesValue).AsTask(); + } + + await messagesTask.ConfigureAwait(false); + } + + protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + JsonElement? messagesValue = await context.ReadStateAsync(PendingMessagesStateKey).ConfigureAwait(false); + if (messagesValue.HasValue) + { + List messages = messagesValue.Value.DeserializeMessages(); + this._pendingMessages.AddRange(messages); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Checkpointed.cs b/dotnet/src/Microsoft.Agents.Workflows/Checkpointed.cs index 9bebf7c405..05bd25ae9d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Checkpointed.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Checkpointed.cs @@ -13,9 +13,7 @@ namespace Microsoft.Agents.Workflows; /// /// The type of the underlying workflow run handle. /// -/// /// -/// public class Checkpointed { private readonly ICheckpointingRunner _runner; @@ -30,9 +28,7 @@ public class Checkpointed /// Gets the workflow run associated with this instance. /// /// - /// /// - /// public TRun Run { get; } /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/Checkpointing/RepresentationExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Checkpointing/RepresentationExtensions.cs index 79051fefc8..df6dbc98e9 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Checkpointing/RepresentationExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Checkpointing/RepresentationExtensions.cs @@ -33,7 +33,7 @@ internal static class RepresentationExtensions return new(new TypeId(port.Request), new TypeId(port.Response), port.Id); } - private static WorkflowInfo ToWorkflowInfo(this Workflow workflow, TypeId? outputType, string? outputExecutorId) + private static WorkflowInfo ToWorkflowInfo(this Workflow workflow, TypeId? inputType, TypeId? outputType, string? outputExecutorId) { Throw.IfNull(workflow); @@ -48,12 +48,12 @@ internal static class RepresentationExtensions HashSet inputPorts = new(workflow.Ports.Values.Select(ToPortInfo)); - return new WorkflowInfo(executors, edges, inputPorts, new TypeId(workflow.InputType), workflow.StartExecutorId, outputType, outputExecutorId); + return new WorkflowInfo(executors, edges, inputPorts, inputType, workflow.StartExecutorId, workflow.OutputExecutors); } - public static WorkflowInfo ToWorkflowInfo(this Workflow workflow) - => workflow.ToWorkflowInfo(outputType: null, outputExecutorId: null); + public static WorkflowInfo ToWorkflowInfo(this Workflow workflow) + => workflow.ToWorkflowInfo(inputType: null, outputType: null, outputExecutorId: null); - public static WorkflowInfo ToWorkflowInfo(this Workflow workflow) - => workflow.ToWorkflowInfo(outputType: new TypeId(typeof(TResult)), outputExecutorId: workflow.OutputCollectorId); + public static WorkflowInfo ToWorkflowInfo(this Workflow workflow) + => workflow.ToWorkflowInfo(inputType: new(workflow.InputType), outputType: null, outputExecutorId: null); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Checkpointing/WorkflowInfo.cs b/dotnet/src/Microsoft.Agents.Workflows/Checkpointing/WorkflowInfo.cs index 380ce7fe0e..8b44cf1ba2 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Checkpointing/WorkflowInfo.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Checkpointing/WorkflowInfo.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Linq; using System.Text.Json.Serialization; @@ -15,53 +14,35 @@ internal sealed class WorkflowInfo Dictionary executors, Dictionary> edges, HashSet inputPorts, - TypeId inputType, + TypeId? inputType, string startExecutorId, - TypeId? outputType, - string? outputCollectorId) + HashSet? outputExecutorIds) { this.Executors = Throw.IfNull(executors); this.Edges = Throw.IfNull(edges); this.InputPorts = Throw.IfNull(inputPorts); - this.InputType = Throw.IfNull(inputType); + this.InputType = inputType; this.StartExecutorId = Throw.IfNullOrEmpty(startExecutorId); - - if (outputType is not null && outputCollectorId is not null) - { - this.OutputType = outputType; - this.OutputCollectorId = outputCollectorId; - } - else if (outputCollectorId is not null) - { - throw new InvalidOperationException( - $"Either both or none of OutputType and OutputCollectorId must be set. ({nameof(outputType)}: {outputType} vs. {nameof(outputCollectorId)}: {outputCollectorId})" - ); - } + this.OutputExecutorIds = outputExecutorIds ?? []; } public Dictionary Executors { get; } public Dictionary> Edges { get; } public HashSet InputPorts { get; } - public TypeId InputType { get; } + public TypeId? InputType { get; } public string StartExecutorId { get; } - public TypeId? OutputType { get; } - public string? OutputCollectorId { get; } + public HashSet OutputExecutorIds { get; } - private bool IsMatch(Workflow workflow) + public bool IsMatch(Workflow workflow) { if (workflow is null) { return false; } - if (!this.InputType.IsMatch(workflow.InputType)) - { - return false; - } - if (this.StartExecutorId != workflow.StartExecutorId) { return false; @@ -101,13 +82,21 @@ internal sealed class WorkflowInfo return false; } + // Validate the outputs + if (workflow.OutputExecutors.Count != this.OutputExecutorIds.Count || + this.OutputExecutorIds.Any(id => !workflow.OutputExecutors.Contains(id))) + { + return false; + } + return true; } - public bool IsMatch(Workflow workflow) => this.IsMatch(workflow as Workflow); + public bool IsMatch(Workflow workflow) => + this.IsMatch(workflow as Workflow) && this.InputType?.IsMatch() == true; - public bool IsMatch(Workflow workflow) - => this.IsMatch(workflow as Workflow) - && this.OutputType?.IsMatch(typeof(TResult)) is true - && this.OutputCollectorId is not null && this.OutputCollectorId == workflow.OutputCollectorId; + //public bool IsMatch(WorkflowWithOutput workflow) + // => this.IsMatch(workflow as Workflow) + // && this.OutputType?.IsMatch(typeof(TResult)) is true + // && this.OutputCollectorId is not null && this.OutputCollectorId == workflow.OutputCollectorId; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs deleted file mode 100644 index 032fe9e944..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/IRunnerWithOutput.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows.Execution; - -internal interface IRunnerWithOutput -{ - ISuperStepRunner StepRunner { get; } - - TResult? RunningOutput { get; } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs index 4a1da0f3be..d56d10e602 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/ISuperStepRunner.cs @@ -8,6 +8,8 @@ namespace Microsoft.Agents.Workflows.Execution; internal interface ISuperStepRunner { + string RunId { get; } + bool HasUnservicedRequests { get; } bool HasUnprocessedMessages { get; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/MessageRouter.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/MessageRouter.cs index cabf5d6db9..507bf22c93 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Execution/MessageRouter.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/MessageRouter.cs @@ -2,11 +2,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Checkpointing; using Microsoft.Shared.Diagnostics; - +using CatchAllF = + System.Func< + Microsoft.Agents.Workflows.PortableValue, // message + Microsoft.Agents.Workflows.IWorkflowContext, // context + System.Threading.Tasks.ValueTask + >; using MessageHandlerF = System.Func< object, // message @@ -20,36 +26,44 @@ internal sealed class MessageRouter { private readonly Dictionary _typedHandlers; private readonly Dictionary _runtimeTypeMap; - private readonly MessageHandlerF? _catchAllHandler; - internal MessageRouter(Dictionary handlers) + private readonly CatchAllF? _catchAllFunc; + + internal MessageRouter(Dictionary handlers, HashSet outputTypes, CatchAllF? catchAllFunc) { Throw.IfNull(handlers); this._typedHandlers = handlers; this._runtimeTypeMap = handlers.Keys.ToDictionary(t => new TypeId(t), t => t); - this._catchAllHandler = handlers.FirstOrDefault(e => e.Key == typeof(object)).Value; + this._catchAllFunc = catchAllFunc; this.IncomingTypes = [.. handlers.Keys]; + this.DefaultOutputTypes = outputTypes; } public HashSet IncomingTypes { get; } + [MemberNotNullWhen(true, nameof(_catchAllFunc))] + internal bool HasCatchAll => this._catchAllFunc is not null; + public bool CanHandle(object message) => this.CanHandle(new TypeId(Throw.IfNull(message).GetType())); public bool CanHandle(Type candidateType) => this.CanHandle(new TypeId(Throw.IfNull(candidateType))); public bool CanHandle(TypeId candidateType) { - return this._catchAllHandler is not null || this._runtimeTypeMap.ContainsKey(candidateType); + return this.HasCatchAll || this._runtimeTypeMap.ContainsKey(candidateType); } + public HashSet DefaultOutputTypes { get; } + public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) { Throw.IfNull(message); CallResult? result = null; - if (message is PortableValue portableValue && + PortableValue? portableValue = message as PortableValue; + if (portableValue != null && this._runtimeTypeMap.TryGetValue(portableValue.TypeId, out Type? runtimeType)) { // If we found a runtime type, we can use it @@ -58,11 +72,16 @@ internal sealed class MessageRouter try { - if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler) || - (handler = this._catchAllHandler) is not null) + if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler)) { result = await handler(message, context).ConfigureAwait(false); } + else if (this.HasCatchAll) + { + portableValue ??= new PortableValue(message); + + result = await this._catchAllFunc(portableValue, context).ConfigureAwait(false); + } } catch (Exception e) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Execution/OutputFilter.cs b/dotnet/src/Microsoft.Agents.Workflows/Execution/OutputFilter.cs new file mode 100644 index 0000000000..3185db93c4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/Execution/OutputFilter.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows.Execution; + +internal sealed class OutputFilter(Workflow workflow) +{ + public bool CanOutput(string sourceExecutorId, object output) + { + return workflow.OutputExecutors.Contains(sourceExecutorId); + } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs index 41f7f8286b..85903c9c2f 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs @@ -28,11 +28,11 @@ public abstract class Executor : IIdentified /// /// Initialize the executor with a unique identifier /// - /// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. + /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. - protected Executor(string? id = null, ExecutorOptions? options = null) + protected Executor(string id, ExecutorOptions? options = null) { - this.Id = id ?? $"{this.GetType().Name}/{Guid.NewGuid():N}"; + this.Id = id; this._options = options ?? ExecutorOptions.Default; } @@ -41,6 +41,26 @@ public abstract class Executor : IIdentified /// protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder); + /// + /// Override this method to declare the types of messages this executor can send. + /// + /// + protected virtual ISet ConfigureSentTypes() => new HashSet([typeof(object)]); + + /// + /// Override this method to declare the types of messages this executor can yield as workflow outputs. + /// + /// + protected virtual ISet ConfigureYieldTypes() + { + if (this._options.AutoYieldOutputHandlerResultObject) + { + return this.Router.DefaultOutputTypes; + } + + return new HashSet(); + } + private MessageRouter? _router; internal MessageRouter Router { @@ -106,6 +126,10 @@ public abstract class Executor : IIdentified { await context.SendMessageAsync(result.Result).ConfigureAwait(false); } + if (result.Result is not null && this._options.AutoYieldOutputHandlerResultObject) + { + await context.YieldOutputAsync(result.Result).ConfigureAwait(false); + } return result.Result; } @@ -134,7 +158,7 @@ public abstract class Executor : IIdentified /// /// A set of s, representing the messages this executor can produce as output. /// - public virtual ISet OutputTypes { get; } = new HashSet([typeof(object)]); + public ISet OutputTypes { get; } = new HashSet([typeof(object)]); /// /// Checks if the executor can handle a specific message type. @@ -144,15 +168,28 @@ public abstract class Executor : IIdentified public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType); internal bool CanHandle(TypeId messageType) => this.Router.CanHandle(messageType); + + internal bool CanOutput(Type messageType) + { + foreach (Type type in this.OutputTypes) + { + if (type.IsAssignableFrom(messageType)) + { + return true; + } + } + + return false; + } } /// /// Provides a simple executor implementation that uses a single message handler function to process incoming messages. /// /// The type of input message. -/// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. +/// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. -public abstract class Executor(string? id = null, ExecutorOptions? options = null) +public abstract class Executor(string id, ExecutorOptions? options = null) : Executor(id, options), IMessageHandler { /// @@ -168,9 +205,9 @@ public abstract class Executor(string? id = null, ExecutorOptions? optio /// /// The type of input message. /// The type of output message. -/// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. +/// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. -public abstract class Executor(string? id = null, ExecutorOptions? options = null) +public abstract class Executor(string id, ExecutorOptions? options = null) : Executor(id, options ?? ExecutorOptions.Default), IMessageHandler { diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs index b834d59d5f..0c1fc40547 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorIsh.cs @@ -20,7 +20,7 @@ public static class ExecutorIshConfigurationExtensions /// /// /// Although this will generally result in a delay-instantiated once messages are available - /// for it, if this is used as a start node of a typed via , + /// for it, if this is used as a start node of a typed via , /// it will be instantiated as part of the workflow's construction, to validate that its input type matches the /// demanded TInput. /// @@ -55,11 +55,11 @@ public static class ExecutorIshConfigurationExtensions /// /// The type of input message. /// A delegate that defines the asynchronous function to execute for each input message. - /// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. + /// A optional unique identifier for the executor. If null, will use the function argument as an id. /// Configuration options for the executor. If null, default options will be used. /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. public static ExecutorIsh AsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null) - => new FunctionExecutor(messageHandlerAsync, id, options).ToExecutorIsh(messageHandlerAsync); + => new FunctionExecutor(id, messageHandlerAsync, options).ToExecutorIsh(messageHandlerAsync); /// /// Configures a function-based asynchronous message handler as an executor with the specified identifier and @@ -68,11 +68,23 @@ public static class ExecutorIshConfigurationExtensions /// The type of input message. /// The type of output message. /// A delegate that defines the asynchronous function to execute for each input message. - /// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. + /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. public static ExecutorIsh AsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null) - => new FunctionExecutor(messageHandlerAsync, id, options).ToExecutorIsh(messageHandlerAsync); + => new FunctionExecutor(Throw.IfNull(id), messageHandlerAsync, options).ToExecutorIsh(messageHandlerAsync); + + /// + /// Configures a function-based aggregating executor with the specified identifier and options. + /// + /// The type of input message. + /// The type of the accumulating object. + /// A delegate the defines the aggregation procedure + /// A unique identifier for the executor. + /// Configuration options for the executor. If null, default options will be used. + /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. + public static ExecutorIsh AsExecutor(this Func aggregatorFunc, string id, ExecutorOptions? options = null) + => new AggregatingExecutor(id, aggregatorFunc, options); } /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorOptions.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorOptions.cs index 02cadf601f..9ab55e3415 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorOptions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorOptions.cs @@ -15,7 +15,12 @@ public class ExecutorOptions internal ExecutorOptions() { } /// - /// If , the result of a message handler that returns a value will be sent as a message to the workflow. + /// If , the result of a message handler that returns a value will be sent as a message from the executor. /// public bool AutoSendMessageHandlerResultObject { get; set; } = true; + + /// + /// If , the result of a message handler that returns a value will be yielded as an output of the executor. + /// + public bool AutoYieldOutputHandlerResultObject { get; set; } = true; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExternalRequest.cs b/dotnet/src/Microsoft.Agents.Workflows/ExternalRequest.cs index 316a6edb75..edcb7fb4ea 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExternalRequest.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExternalRequest.cs @@ -77,6 +77,11 @@ public record ExternalRequest(InputPortInfo PortInfo, string RequestId, Portable return new ExternalResponse(this.PortInfo, this.RequestId, new PortableValue(data)); } + internal ExternalResponse RewrapResponse(ExternalResponse response) + { + return new ExternalResponse(this.PortInfo, this.RequestId, response.Data); + } + /// /// Creates a new corresponding to the request, with the speicified data payload. /// diff --git a/dotnet/src/Microsoft.Agents.Workflows/FunctionExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/FunctionExecutor.cs index 60b353e539..c54b2967a6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/FunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/FunctionExecutor.cs @@ -10,11 +10,11 @@ namespace Microsoft.Agents.Workflows; /// Executes a user-provided asynchronous function in response to workflow messages of the specified input type. /// /// The type of input message. +/// A unique identifier for the executor. /// A delegate that defines the asynchronous function to execute for each input message. -/// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. /// Configuration options for the executor. If null, default options will be used. -public class FunctionExecutor(Func handlerAsync, - string? id = null, +public class FunctionExecutor(string id, + Func handlerAsync, ExecutorOptions? options = null) : Executor(id, options) { internal static Func WrapAction(Action handlerSync) @@ -34,8 +34,9 @@ public class FunctionExecutor(Func /// Creates a new instance of the class. /// + /// A unique identifier for the executor. /// A synchronous function to execute for each input message and workflow context. - public FunctionExecutor(Action handlerSync) : this(WrapAction(handlerSync)) + public FunctionExecutor(string id, Action handlerSync) : this(id, WrapAction(handlerSync)) { } } @@ -45,11 +46,11 @@ public class FunctionExecutor(Func /// The type of input message. /// The type of output message. +/// A unique identifier for the executor. /// A delegate that defines the asynchronous function to execute for each input message. -/// A optional unique identifier for the executor. If null, a type-tagged UUID will be generated. /// Configuration options for the executor. If null, default options will be used. -public class FunctionExecutor(Func> handlerAsync, - string? id = null, +public class FunctionExecutor(string id, + Func> handlerAsync, ExecutorOptions? options = null) : Executor(id, options) { internal static Func> WrapFunc(Func handlerSync) @@ -69,8 +70,9 @@ public class FunctionExecutor(Func /// Creates a new instance of the class. /// + /// A unique identifier for the executor. /// A synchronous function to execute for each input message and workflow context. - public FunctionExecutor(Func handlerSync) : this(WrapFunc(handlerSync)) + public FunctionExecutor(string id, Func handlerSync) : this(id, WrapFunc(handlerSync)) { } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs index 39e6281577..e720935a38 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/IWorkflowContext.cs @@ -28,6 +28,24 @@ public interface IWorkflowContext /// A representing the asynchronous operation. ValueTask SendMessageAsync(object message, string? targetId = null); + /// + /// Adds an output value to the workflow's output queue. These outputs will be bubbled out of the workflow using the + /// + /// + /// + /// The type of the output message must match one of the output types declared by the Executor. By default, the return + /// types of registered message handlers are considered output types, unless otherwise specified using . + /// + /// The output value to be returned. + /// A representing the asynchronous operation. + ValueTask YieldOutputAsync(object output); + + /// + /// Adds a request to "halt" workflow execution at the end of the current SuperStep. + /// + /// + ValueTask RequestHaltAsync(); + /// /// Reads a state value from the workflow's state store. If no scope is provided, the executor's /// default scope is used. diff --git a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunner.cs index 56360ef29b..57ef528890 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunner.cs @@ -16,47 +16,53 @@ namespace Microsoft.Agents.Workflows.InProc; /// /// Provides a local, in-process runner for executing a workflow using the specified input type. /// -/// enables step-by-step execution of a workflow graph entirely +/// enables step-by-step execution of a workflow graph entirely /// within the current process, without distributed coordination. It is primarily intended for testing, debugging, or /// scenarios where workflow execution does not require executor distribution. -/// The type of input accepted by the workflow. Must be non-nullable. -internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner where TInput : notnull +internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingRunner { - public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null) + public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, params Type[] knownValidInputTypes) { this.Workflow = Throw.IfNull(workflow); - this.RunContext = new InProcessRunnerContext(workflow); + this.RunContext = new InProcessRunnerContext(workflow); this.CheckpointManager = checkpointManager; this.RunId = runId ?? Guid.NewGuid().ToString("N"); + this._knownValidInputTypes = [.. knownValidInputTypes]; + // Initialize the runners for each of the edges, along with the state for edges that // need it. this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.Ports.Values, this.Workflow.StartExecutorId, this.StepTracer); } + /// public string RunId { get; } - public async ValueTask IsValidInputAsync(TMessage message) + private readonly HashSet _knownValidInputTypes; + public async ValueTask IsValidInputTypeAsync(Type messageType) { - Throw.IfNull(message); - - Type type = typeof(TMessage); - - // Short circuit the logic if the type is the input type - if (type == typeof(TInput)) + if (this._knownValidInputTypes.Contains(messageType)) { return true; } Executor startingExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId, tracer: null).ConfigureAwait(false); - return startingExecutor.CanHandle(type); + if (startingExecutor.CanHandle(messageType)) + { + this._knownValidInputTypes.Add(messageType); + return true; + } + + return false; } - async ValueTask ISuperStepRunner.EnqueueMessageAsync(T message) + public async ValueTask EnqueueMessageAsync(T message) { + Throw.IfNull(message); + // Check that the type of the incoming message is compatible with the starting executor's // input type. - if (!await this.IsValidInputAsync(message).ConfigureAwait(false)) + if (!await this.IsValidInputTypeAsync(typeof(T)).ConfigureAwait(false)) { return false; } @@ -65,14 +71,30 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointing return true; } + public async ValueTask EnqueueMessageAsync(object message) + { + Throw.IfNull(message); + + // Check that the type of the incoming message is compatible with the starting executor's + // input type. + if (!await this.IsValidInputTypeAsync(message.GetType()).ConfigureAwait(false)) + { + return false; + } + + await this.RunContext.AddExternalMessageUntypedAsync(message).ConfigureAwait(false); + return true; + } + ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response) { + // TODO: Check that there exists a corresponding input port? return this.RunContext.AddExternalMessageAsync(response); } private InProcStepTracer StepTracer { get; } = new(); - private Workflow Workflow { get; init; } - private InProcessRunnerContext RunContext { get; init; } + private Workflow Workflow { get; init; } + private InProcessRunnerContext RunContext { get; init; } private ICheckpointManager? CheckpointManager { get; } private EdgeMap EdgeMap { get; init; } @@ -122,9 +144,16 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointing return new StreamingRun(this); } - public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) + public async ValueTask StreamAsync(object input, CancellationToken cancellation = default) { - await this.RunContext.AddExternalMessageAsync(input).ConfigureAwait(false); + await this.EnqueueMessageAsync(input).ConfigureAwait(false); + + return new StreamingRun(this); + } + + public async ValueTask StreamAsync(TInput input, CancellationToken cancellation = default) + { + await this.EnqueueMessageAsync(input).ConfigureAwait(false); return new StreamingRun(this); } @@ -137,7 +166,15 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointing return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); } - public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) + public async ValueTask RunAsync(object input, CancellationToken cancellation = default) + { + StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); + cancellation.ThrowIfCancellationRequested(); + + return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); + } + + public async ValueTask RunAsync(TInput input, CancellationToken cancellation = default) { StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); cancellation.ThrowIfCancellationRequested(); @@ -152,7 +189,10 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointing async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cancellation) { - cancellation.ThrowIfCancellationRequested(); + if (cancellation.IsCancellationRequested) + { + return false; + } StepContext currentStep = this.RunContext.Advance(); @@ -277,58 +317,3 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointing private bool CheckWorkflowMatch(Checkpoint checkpoint) => checkpoint.Workflow.IsMatch(this.Workflow); } - -internal sealed class InProcessRunner : IRunnerWithOutput, ICheckpointingRunner where TInput : notnull -{ - private readonly Workflow _workflow; - private readonly InProcessRunner _innerRunner; - - public InProcessRunner(Workflow workflow, CheckpointManager? checkpointManager, string? runId = null) - { - this._workflow = Throw.IfNull(workflow); - - this._innerRunner = new(workflow, checkpointManager, runId); - } - - internal async ValueTask> ResumeStreamAsync(CheckpointInfo checkpoint, CancellationToken cancellation = default) - { - await this._innerRunner.ResumeStreamAsync(checkpoint, cancellation).ConfigureAwait(false); - - return new StreamingRun(this); - } - - public async ValueTask> StreamAsync(TInput input, CancellationToken cancellation = default) - { - await ((ISuperStepRunner)this._innerRunner).EnqueueMessageAsync(input).ConfigureAwait(false); - - return new StreamingRun(this); - } - - public async ValueTask> ResumeAsync(CheckpointInfo checkpoint, CancellationToken cancellation = default) - { - StreamingRun streamingRun = await this.ResumeStreamAsync(checkpoint, cancellation).ConfigureAwait(false); - cancellation.ThrowIfCancellationRequested(); - - return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); - } - - public async ValueTask> RunAsync(TInput input, CancellationToken cancellation = default) - { - StreamingRun streamingRun = await this.StreamAsync(input, cancellation).ConfigureAwait(false); - cancellation.ThrowIfCancellationRequested(); - - return await Run.CaptureStreamAsync(streamingRun, cancellation).ConfigureAwait(false); - } - - public ValueTask RestoreCheckpointAsync(CheckpointInfo checkpointInfo, CancellationToken cancellation = default) - => this._innerRunner.RestoreCheckpointAsync(checkpointInfo, cancellation); - - internal ValueTask CheckpointAsync() => this._innerRunner.CheckpointAsync(); - - /// - public TResult? RunningOutput => this._workflow.RunningOutput; - - ISuperStepRunner IRunnerWithOutput.StepRunner => this._innerRunner; - - public IReadOnlyList Checkpoints => this._innerRunner.Checkpoints; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs index db8a271849..3804fb6a2a 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/InProc/InProcessRunnerContext.cs @@ -14,16 +14,18 @@ using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows.InProc; -internal sealed class InProcessRunnerContext : IRunnerContext +internal sealed class InProcessRunnerContext : IRunnerContext { private StepContext _nextStep = new(); private readonly Dictionary _executorRegistrations; private readonly Dictionary _executors = []; private readonly Dictionary _externalRequests = []; + private readonly OutputFilter _outputFilter; public InProcessRunnerContext(Workflow workflow, ILogger? logger = null) { this._executorRegistrations = Throw.IfNull(workflow).Registrations; + this._outputFilter = new(workflow); } public async ValueTask EnsureExecutorAsync(string executorId, IStepTracer? tracer) @@ -80,7 +82,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext return default; } - public IWorkflowContext Bind(string executorId) => new BoundContext(this, executorId); + public IWorkflowContext Bind(string executorId) => new BoundContext(this, executorId, this._outputFilter); public ValueTask PostAsync(ExternalRequest request) { @@ -94,11 +96,29 @@ internal sealed class InProcessRunnerContext : IRunnerContext internal StateManager StateManager { get; } = new(); - private sealed class BoundContext(InProcessRunnerContext RunnerContext, string ExecutorId) : IWorkflowContext + private sealed class BoundContext(InProcessRunnerContext RunnerContext, string ExecutorId, OutputFilter outputFilter) : IWorkflowContext { public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(workflowEvent); public ValueTask SendMessageAsync(object message, string? targetId = null) => RunnerContext.SendMessageAsync(ExecutorId, message, targetId); + public async ValueTask YieldOutputAsync(object output) + { + Throw.IfNull(output); + + Executor sourceExecutor = await RunnerContext.EnsureExecutorAsync(ExecutorId, tracer: null).ConfigureAwait(false); + if (!sourceExecutor.CanOutput(output.GetType())) + { + throw new InvalidOperationException($"Cannot output object of type {output.GetType().Name}. Expecting one of [{string.Join(", ", sourceExecutor.OutputTypes)}]."); + } + + if (outputFilter.CanOutput(ExecutorId, output)) + { + await this.AddEventAsync(new WorkflowOutputEvent(output, ExecutorId)).ConfigureAwait(false); + } + } + + public ValueTask RequestHaltAsync() => this.AddEventAsync(new RequestHaltEvent()); + public ValueTask ReadStateAsync(string key, string? scopeName = null) => RunnerContext.StateManager.ReadStateAsync(ExecutorId, scopeName, key); diff --git a/dotnet/src/Microsoft.Agents.Workflows/InProcessExecution.cs b/dotnet/src/Microsoft.Agents.Workflows/InProcessExecution.cs index 96819a4b56..403e55b0e7 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/InProcessExecution.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/InProcessExecution.cs @@ -12,25 +12,115 @@ namespace Microsoft.Agents.Workflows; /// public static class InProcessExecution { + internal static InProcessRunner CreateRunner(Workflow workflow, CheckpointManager? checkpointManager, string? runId) + => new(workflow, checkpointManager, runId); + + internal static InProcessRunner CreateRunner(Workflow checkedWorkflow, CheckpointManager? checkpointManager, string? runId) + where TInput : notnull + => new(checkedWorkflow, checkpointManager, runId, [typeof(TInput)]); + + private static ValueTask StreamAsync(InProcessRunner runner, TInput input, CancellationToken cancellation = default) + => runner.StreamAsync(input, cancellation); + + private static ValueTask StreamAsync(InProcessRunner runner, object input, CancellationToken cancellation = default) + => runner.StreamAsync(input, cancellation); + + private static ValueTask RunAsync(InProcessRunner runner, TInput input, CancellationToken cancellation = default) + => runner.RunAsync(input, cancellation); + + private static ValueTask RunAsync(InProcessRunner runner, object input, CancellationToken cancellation = default) + => runner.RunAsync(input, cancellation); + + private static async ValueTask> StreamCheckpointedAsync(InProcessRunner runner, TInput input, CancellationToken cancellation = default) + where TInput : notnull + { + StreamingRun run = await StreamAsync(runner, input, cancellation).ConfigureAwait(false); + await runner.CheckpointAsync(cancellation).ConfigureAwait(false); + + return new(run, runner); + } + + private static async ValueTask> StreamCheckpointedAsync(InProcessRunner runner, object input, CancellationToken cancellation = default) + { + StreamingRun run = await StreamAsync(runner, input, cancellation).ConfigureAwait(false); + await runner.CheckpointAsync(cancellation).ConfigureAwait(false); + + return new(run, runner); + } + + private static async ValueTask> RunCheckpointedAsync(InProcessRunner runner, TInput input, CancellationToken cancellation = default) + where TInput : notnull + { + Run run = await RunAsync(runner, input, cancellation).ConfigureAwait(false); + await runner.CheckpointAsync(cancellation).ConfigureAwait(false); + + return new(run, runner); + } + + private static async ValueTask> RunCheckpointedAsync(InProcessRunner runner, object input, CancellationToken cancellation = default) + { + Run run = await RunAsync(runner, input, cancellation).ConfigureAwait(false); + await runner.CheckpointAsync(cancellation).ConfigureAwait(false); + + return new(run, runner); + } + + private static async ValueTask> ResumeStreamCheckpointedAsync(InProcessRunner runner, CheckpointInfo fromCheckpoint, CancellationToken cancellation = default) + { + StreamingRun run = await runner.ResumeStreamAsync(fromCheckpoint, cancellation).ConfigureAwait(false); + return new(run, runner); + } + + private static async ValueTask> ResumeRunCheckpointedAsync(InProcessRunner runner, CheckpointInfo fromCheckpoint, CancellationToken cancellation = default) + { + Run run = await runner.ResumeAsync(fromCheckpoint, cancellation).ConfigureAwait(false); + return new(run, runner); + } + /// /// Initiates an asynchronous streaming execution using the specified input. /// /// The returned provides methods to observe and control /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or /// cancelled. - /// The type of input accepted by the workflow. Must be non-nullable. + /// A type of input accepted by the workflow. Must be non-nullable. /// The workflow to be executed. Must not be null. /// The input message to be processed as part of the streaming run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public static ValueTask StreamAsync( + Workflow workflow, + TInput input, + string? runId = null, + CancellationToken cancellation = default) where TInput : notnull + { + InProcessRunner runner = CreateRunner(workflow, checkpointManager: null, runId); + return StreamAsync(runner, (object)input, cancellation); + } + + /// + /// Initiates an asynchronous streaming execution using the specified input. + /// + /// The returned provides methods to observe and control + /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or + /// cancelled. + /// A type of input accepted by the workflow. Must be non-nullable. + /// The workflow to be executed. Must not be null. + /// The input message to be processed as part of the streaming run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. /// A that can be used to cancel the streaming operation. /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. public static ValueTask StreamAsync( Workflow workflow, TInput input, + string? runId = null, CancellationToken cancellation = default) where TInput : notnull { - InProcessRunner runner = new(workflow, checkpointManager: null); - return runner.StreamAsync(input, cancellation); + InProcessRunner runner = CreateRunner(workflow, checkpointManager: null, runId); + return StreamAsync(runner, input, cancellation); } /// @@ -43,122 +133,89 @@ public static class InProcessExecution /// The workflow to be executed. Must not be null. /// The input message to be processed as part of the streaming run. /// The to use with this run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. /// A that can be used to cancel the streaming operation. /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. - public static async ValueTask> StreamAsync( + public static ValueTask> StreamAsync( + Workflow workflow, + TInput input, + CheckpointManager checkpointManager, + string? runId = null, + CancellationToken cancellation = default) where TInput : notnull + { + InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId); + return StreamCheckpointedAsync(runner, (object)input, cancellation); + } + + /// + /// Initiates an asynchronous streaming execution using the specified input, with checkpointing. + /// + /// The returned provides methods to observe and control + /// the ongoing streaming execution. The operation will continue until the streaming execution is finished or + /// cancelled. + /// The type of input accepted by the workflow. Must be non-nullable. + /// The workflow to be executed. Must not be null. + /// The input message to be processed as part of the streaming run. + /// The to use with this run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public static ValueTask> StreamAsync( Workflow workflow, TInput input, CheckpointManager checkpointManager, + string? runId = null, CancellationToken cancellation = default) where TInput : notnull { - InProcessRunner runner = new(workflow, checkpointManager); - StreamingRun result = await runner.StreamAsync(input, cancellation).ConfigureAwait(false); - - await runner.CheckpointAsync(cancellation).ConfigureAwait(false); - - return new(result, runner); + InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId); + return StreamCheckpointedAsync(runner, input, cancellation); } /// /// Resumes an asynchronous streaming execution for the specified input from a checkpoint. /// - /// The returned can be used to retrieve results - /// as they become available. If the operation is cancelled via the token, the - /// streaming execution will be terminated. + /// If the operation is cancelled via the token, the streaming execution will + /// be terminated. + /// The workflow to be executed. Must not be null. + /// The corresponding to the checkpoint from which to resume. + /// The to use with this run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. + /// A that can be used to cancel the streaming operation. + /// A that provides access to the results of the streaming run. + public static ValueTask> ResumeStreamAsync( + Workflow workflow, + CheckpointInfo fromCheckpoint, + CheckpointManager checkpointManager, + string? runId = null, + CancellationToken cancellation = default) + { + InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId); + return ResumeStreamCheckpointedAsync(runner, fromCheckpoint, cancellation); + } + + /// + /// Resumes an asynchronous streaming execution for the specified input from a checkpoint. + /// + /// If the operation is cancelled via the token, the streaming execution will + /// be terminated. /// The type of input accepted by the workflow. Must be non-nullable. /// The workflow to be executed. Must not be null. /// The corresponding to the checkpoint from which to resume. /// The to use with this run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. /// A that can be used to cancel the streaming operation. - /// A that provides access to the results of the streaming - /// run. - public static async ValueTask> ResumeStreamAsync( + /// A that provides access to the results of the streaming run. + public static ValueTask> ResumeStreamAsync( Workflow workflow, CheckpointInfo fromCheckpoint, CheckpointManager checkpointManager, + string? runId = null, CancellationToken cancellation = default) where TInput : notnull { - InProcessRunner runner = new(workflow, checkpointManager, runId: fromCheckpoint.RunId); - StreamingRun result = await runner.ResumeStreamAsync(fromCheckpoint, cancellation).ConfigureAwait(false); - - return new(result, runner); - } - - /// - /// Initiates an asynchronous streaming execution for the specified input. - /// - /// The returned can be used to retrieve results - /// as they become available. If the operation is cancelled via the token, the - /// streaming execution will be terminated. - /// The type of input accepted by the workflow. Must be non-nullable. - /// The type of output produced by the workflow. - /// The workflow to be executed. Must not be null. - /// The input value to be processed by the streaming run. - /// A that can be used to cancel the streaming operation. - /// A that provides access to the results of the streaming - /// run. - public static ValueTask> StreamAsync( - Workflow workflow, - TInput input, - CancellationToken cancellation = default) where TInput : notnull - { - InProcessRunner runner = new(workflow, checkpointManager: null); - return runner.StreamAsync(input, cancellation); - } - - /// - /// Initiates an asynchronous streaming execution for the specified input, with checkpointing. - /// - /// The returned can be used to retrieve results - /// as they become available. If the operation is cancelled via the token, the - /// streaming execution will be terminated. - /// The type of input accepted by the workflow. Must be non-nullable. - /// The type of output produced by the workflow. - /// The workflow to be executed. Must not be null. - /// The input value to be processed by the streaming run. - /// The to use with this run. - /// A that can be used to cancel the streaming operation. - /// A that provides access to the results of the streaming - /// run. - public static async ValueTask>> StreamAsync( - Workflow workflow, - TInput input, - CheckpointManager checkpointManager, - CancellationToken cancellation = default) where TInput : notnull - { - InProcessRunner runner = new(workflow, checkpointManager); - StreamingRun result = await runner.StreamAsync(input, cancellation).ConfigureAwait(false); - - await runner.CheckpointAsync().ConfigureAwait(false); - - return new(result, runner); - } - - /// - /// Resumes an asynchronous streaming execution of the workflow from a checkpoint. - /// - /// The returned can be used to retrieve results - /// as they become available. If the operation is cancelled via the token, the - /// streaming execution will be terminated. - /// The type of input accepted by the workflow. Must be non-nullable. - /// The type of output produced by the workflow. - /// The workflow to be executed. Must not be null. - /// The corresponding to the checkpoint from which to resume. - /// The to use with this run. - /// A that can be used to cancel the streaming operation. - /// A that provides access to the results of the streaming - /// run. - public static async ValueTask>> ResumeStreamAsync( - Workflow workflow, - CheckpointInfo fromCheckpoint, - CheckpointManager checkpointManager, - CancellationToken cancellation = default) where TInput : notnull - { - InProcessRunner runner = new(workflow, checkpointManager, runId: fromCheckpoint.RunId); - StreamingRun result = await runner.ResumeStreamAsync(fromCheckpoint, cancellation).ConfigureAwait(false); - - return new(result, runner); + InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId); + return ResumeStreamCheckpointedAsync(runner, fromCheckpoint, cancellation); } /// @@ -169,16 +226,40 @@ public static class InProcessExecution /// The type of input accepted by the workflow. Must be non-nullable. /// The workflow to be executed. Must not be null. /// The input message to be processed as part of the run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public static ValueTask RunAsync( + Workflow workflow, + TInput input, + string? runId = null, + CancellationToken cancellation = default) where TInput : notnull + { + InProcessRunner runner = CreateRunner(workflow, checkpointManager: null, runId); + return RunAsync(runner, (object)input, cancellation); + } + + /// + /// Initiates a non-streaming execution of the workflow with the specified input. + /// + /// The workflow will run until its first halt, and the returned will capture + /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. + /// The type of input accepted by the workflow. Must be non-nullable. + /// The workflow to be executed. Must not be null. + /// The input message to be processed as part of the run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. /// A that can be used to cancel the streaming operation. /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. public static ValueTask RunAsync( Workflow workflow, TInput input, + string? runId = null, CancellationToken cancellation = default) where TInput : notnull { - InProcessRunner runner = new(workflow, checkpointManager: null); - return runner.RunAsync(input, cancellation); + InProcessRunner runner = CreateRunner(workflow, checkpointManager: null, runId); + return RunAsync(runner, input, cancellation); } /// @@ -190,66 +271,19 @@ public static class InProcessExecution /// The workflow to be executed. Must not be null. /// The input message to be processed as part of the run. /// The to use with this run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. /// A that can be used to cancel the streaming operation. /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. - public static async ValueTask> RunAsync( - Workflow workflow, + public static ValueTask> RunAsync( + Workflow workflow, TInput input, CheckpointManager checkpointManager, + string? runId = null, CancellationToken cancellation = default) where TInput : notnull { - InProcessRunner runner = new(workflow, checkpointManager); - Run result = await runner.RunAsync(input, cancellation).ConfigureAwait(false); - - await runner.CheckpointAsync(cancellation).ConfigureAwait(false); - - return new(result, runner); - } - - /// - /// Resumes a non-streaming execution of the workflow from a checkpoint. - /// - /// The workflow will run until its first halt, and the returned will capture - /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. - /// The type of input accepted by the workflow. Must be non-nullable. - /// The workflow to be executed. Must not be null. - /// The corresponding to the checkpoint from which to resume. - /// The to use with this run. - /// A that can be used to cancel the streaming operation. - /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. - public static async ValueTask> ResumeAsync( - Workflow workflow, - CheckpointInfo fromCheckpoint, - CheckpointManager checkpointManager, - CancellationToken cancellation = default) where TInput : notnull - { - InProcessRunner runner = new(workflow, checkpointManager, runId: fromCheckpoint.RunId); - Run result = await runner.ResumeAsync(fromCheckpoint, cancellation).ConfigureAwait(false); - - return new(result, runner); - } - - /// - /// Initiates a non-streaming execution of the workflow with the specified input. - /// - /// The workflow will run until its first halt, and the returned will capture - /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. - /// The type of input accepted by the workflow. Must be non-nullable. - /// The type of output produced by the workflow. - /// The workflow to be executed. Must not be null. - /// The input message to be processed as part of the run. - /// A that can be used to cancel the streaming operation. - /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. - public static ValueTask> RunAsync( - Workflow workflow, - TInput input, - CancellationToken cancellation = default) where TInput : notnull - { - InProcessRunner runner = new(workflow, checkpointManager: null); - return runner.RunAsync(input, cancellation); + InProcessRunner runner = CreateRunner(workflow, checkpointManager: checkpointManager, runId); + return RunCheckpointedAsync(runner, (object)input, cancellation); } /// @@ -258,25 +292,22 @@ public static class InProcessExecution /// The workflow will run until its first halt, and the returned will capture /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. /// The type of input accepted by the workflow. Must be non-nullable. - /// The type of output produced by the workflow. /// The workflow to be executed. Must not be null. /// The input message to be processed as part of the run. /// The to use with this run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. /// A that can be used to cancel the streaming operation. /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. - public static async ValueTask>> RunAsync( - Workflow workflow, + public static ValueTask> RunAsync( + Workflow workflow, TInput input, CheckpointManager checkpointManager, + string? runId = null, CancellationToken cancellation = default) where TInput : notnull { - InProcessRunner runner = new(workflow, checkpointManager); - Run result = await runner.RunAsync(input, cancellation).ConfigureAwait(false); - - await runner.CheckpointAsync().ConfigureAwait(false); - - return new(result, runner); + InProcessRunner runner = CreateRunner(workflow, checkpointManager: checkpointManager, runId); + return RunCheckpointedAsync(runner, input, cancellation); } /// @@ -284,23 +315,44 @@ public static class InProcessExecution /// /// The workflow will run until its first halt, and the returned will capture /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. - /// The type of input accepted by the workflow. Must be non-nullable. - /// The type of output produced by the workflow. /// The workflow to be executed. Must not be null. /// The corresponding to the checkpoint from which to resume. /// The to use with this run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. /// A that can be used to cancel the streaming operation. /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. - public static async ValueTask>> ResumeAsync( - Workflow workflow, + public static ValueTask> ResumeAsync( + Workflow workflow, CheckpointInfo fromCheckpoint, CheckpointManager checkpointManager, + string? runId = null, + CancellationToken cancellation = default) + { + InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId); + return ResumeRunCheckpointedAsync(runner, fromCheckpoint, cancellation); + } + + /// + /// Resumes a non-streaming execution of the workflow from a checkpoint. + /// + /// The workflow will run until its first halt, and the returned will capture + /// all outgoing events. Use the Run instance to resume execution with responses to outgoing events. + /// The workflow to be executed. Must not be null. + /// The corresponding to the checkpoint from which to resume. + /// The to use with this run. + /// An optional unique identifier for the run. If not provided, a new identifier will be generated. + /// A that can be used to cancel the streaming operation. + /// A that represents the asynchronous operation. The result contains a for managing and interacting with the streaming run. + public static ValueTask> ResumeAsync( + Workflow workflow, + CheckpointInfo fromCheckpoint, + CheckpointManager checkpointManager, + string? runId = null, CancellationToken cancellation = default) where TInput : notnull { - InProcessRunner runner = new(workflow, checkpointManager, runId: fromCheckpoint.RunId); - Run result = await runner.ResumeAsync(fromCheckpoint, cancellation).ConfigureAwait(false); - - return new(result, runner); + InProcessRunner runner = CreateRunner(workflow, checkpointManager, runId); + return ResumeRunCheckpointedAsync(runner, fromCheckpoint, cancellation); } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Reflection/ReflectingExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Reflection/ReflectingExecutor.cs index 388bc46c28..9213a25104 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Reflection/ReflectingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Reflection/ReflectingExecutor.cs @@ -16,8 +16,8 @@ public class ReflectingExecutor< ] TExecutor > : Executor where TExecutor : ReflectingExecutor { - /// - protected ReflectingExecutor(string? id = null, ExecutorOptions? options = null) : base(id, options) + /// + protected ReflectingExecutor(string id, ExecutorOptions? options = null) : base(id, options) { } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Reflection/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/Reflection/RouteBuilderExtensions.cs index ca0b9afce3..e8d67b8db7 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Reflection/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Reflection/RouteBuilderExtensions.cs @@ -91,7 +91,7 @@ internal static class RouteBuilderExtensions foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos()) { - builder = builder.AddHandler(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true)); + builder = builder.AddHandlerInternal(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true), handlerInfo.OutType); } return builder; diff --git a/dotnet/src/Microsoft.Agents.Workflows/RequestHaltEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/RequestHaltEvent.cs new file mode 100644 index 0000000000..c5d1d8e98d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/RequestHaltEvent.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.Workflows; + +/// +/// Event triggered when a workflow completes execution. +/// +internal sealed class RequestHaltEvent : WorkflowEvent +{ + internal RequestHaltEvent(object? result = null) : base(result) + { } +} diff --git a/dotnet/src/Microsoft.Agents.Workflows/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/RouteBuilder.cs index 2cee87fd98..22ae8eb092 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/RouteBuilder.cs @@ -2,10 +2,16 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Execution; using Microsoft.Shared.Diagnostics; - +using CatchAllF = + System.Func< + Microsoft.Agents.Workflows.PortableValue, // message + Microsoft.Agents.Workflows.IWorkflowContext, // context + System.Threading.Tasks.ValueTask + >; using MessageHandlerF = System.Func< object, // message @@ -24,16 +30,35 @@ namespace Microsoft.Agents.Workflows; public class RouteBuilder { private readonly Dictionary _typedHandlers = []; + private readonly Dictionary _outputTypes = []; + private CatchAllF? _catchAll; - internal RouteBuilder AddHandler(Type messageType, MessageHandlerF handler, bool overwrite = false) + internal RouteBuilder AddHandlerInternal(Type messageType, MessageHandlerF handler, Type? outputType, bool overwrite = false) { Throw.IfNull(messageType); Throw.IfNull(handler); + if (messageType == typeof(PortableValue)) + { + throw new InvalidOperationException("Cannot register a handler for PortableValue. Use AddCatchAll() instead."); + } + + Debug.Assert(typeof(CallResult) != outputType, "Must not double-wrap message handlers in the RouteBuilder. " + + "Use AddHandlerInternal() or do not wrap user-provided handler."); + // Overwrite must be false if the type is not registered. Overwrite must be true if the type is registered. if (this._typedHandlers.ContainsKey(messageType) == overwrite) { this._typedHandlers[messageType] = handler; + + if (outputType is not null) + { + this._outputTypes[messageType] = outputType; + } + else + { + this._outputTypes.Remove(messageType); + } } else if (overwrite) { @@ -52,7 +77,7 @@ public class RouteBuilder { Throw.IfNull(handler); - return this.AddHandler(type, WrappedHandlerAsync, overwrite); + return this.AddHandlerInternal(type, WrappedHandlerAsync, outputType: null, overwrite); async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) { @@ -65,7 +90,7 @@ public class RouteBuilder { Throw.IfNull(handler); - return this.AddHandler(type, WrappedHandlerAsync, overwrite); + return this.AddHandlerInternal(type, WrappedHandlerAsync, outputType: typeof(TResult), overwrite); async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) { @@ -91,7 +116,7 @@ public class RouteBuilder { Throw.IfNull(handler); - return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: null, overwrite); async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) { @@ -117,7 +142,7 @@ public class RouteBuilder { Throw.IfNull(handler); - return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: null, overwrite); async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) { @@ -143,7 +168,7 @@ public class RouteBuilder { Throw.IfNull(handler); - return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: typeof(TResult), overwrite); async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) { @@ -169,7 +194,7 @@ public class RouteBuilder { Throw.IfNull(handler); - return this.AddHandler(typeof(TInput), WrappedHandlerAsync, overwrite); + return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: typeof(TResult), overwrite); async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) { @@ -178,5 +203,113 @@ public class RouteBuilder } } - internal MessageRouter Build() => new(this._typedHandlers); + private RouteBuilder AddCatchAll(CatchAllF handler, bool overwrite = false) + { + if (!overwrite && this._catchAll != null) + { + throw new InvalidOperationException("A catch-all is already registered (overwrite = false)."); + } + + this._catchAll = handler; + + return this; + } + + /// + /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. + /// + /// If a catch-all handler for already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler receives the input message + /// wrapped as and workflow context, and returns a result asynchronously. + /// A function that processes messages wrapped as within the + /// workflow context. The delegate is invoked for each incoming message not otherwise handled. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddCatchAll(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddCatchAll(WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx) + { + await handler.Invoke(message, ctx).ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + } + + /// + /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. + /// + /// If a catch-all handler for already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler receives the input message + /// wrapped as and workflow context, and returns a result asynchronously. + /// A function that processes messages wrapped as within the + /// workflow context and returns a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddCatchAll(Func> handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddCatchAll(WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx) + { + TResult result = await handler.Invoke(message, ctx).ConfigureAwait(false); + return CallResult.ReturnResult(result); + } + } + + /// + /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. + /// + /// If a catch-all handler for already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler receives the input message + /// wrapped as and workflow context, and returns a result asynchronously. + /// A function that processes messages wrapped as within the + /// workflow context. The delegate is invoked for each incoming message not otherwise handled. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddCatchAll(Action handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddCatchAll(WrappedHandlerAsync, overwrite); + + ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx) + { + handler.Invoke(message, ctx); + return new(CallResult.ReturnVoid()); + } + } + + /// + /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. + /// + /// If a catch-all handler for already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler receives the input message + /// wrapped as and workflow context, and returns a result asynchronously. + /// A function that processes messages wrapped as within the + /// workflow context and returns a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddCatchAll(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddCatchAll(WrappedHandlerAsync, overwrite); + + ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx) + { + TResult result = handler.Invoke(message, ctx); + return new(CallResult.ReturnResult(result)); + } + } + + internal MessageRouter Build() => new(this._typedHandlers, [.. this._outputTypes.Values], this._catchAll); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Run.cs b/dotnet/src/Microsoft.Agents.Workflows/Run.cs index a3e8f66666..b468145952 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Run.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Run.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.Workflows; public enum RunStatus { /// - /// The run has halted, has no outstanding requets, but has not received a . + /// The run has halted, has no outstanding requets, but has not received a . /// Idle, @@ -22,10 +22,11 @@ public enum RunStatus /// PendingRequests, - /// - /// The run has halted after receiving a . - /// - Completed, + // TODO: Figure out if we want to have some way to have a true "converged" state + ///// + ///// The run has halted after converging. + ///// + //Completed, /// /// The workflow is currently running, and may receive events or requests. @@ -56,31 +57,28 @@ public class Run internal async ValueTask RunToNextHaltAsync(CancellationToken cancellation = default) { bool hadEvents = false; - bool hadCompletion = false; this.Status = RunStatus.Running; await foreach (WorkflowEvent evt in this._streamingRun.WatchStreamAsync(blockOnPendingRequest: false, cancellation).ConfigureAwait(false)) { hadEvents = true; - if (evt is WorkflowCompletedEvent) - { - hadCompletion = true; - } - this._eventSink.Add(evt); } // TODO: bookmark every halt for history visualization? this.Status = - hadCompletion - ? RunStatus.Completed - : this._streamingRun.HasUnservicedRequests + this._streamingRun.HasUnservicedRequests ? RunStatus.PendingRequests : RunStatus.Idle; return hadEvents; } + /// + /// A unique identifier for the run. Can be provided at the start of the run, or auto-generated. + /// + public string RunId => this._streamingRun.RunId; + /// /// Gets the current execution status of the workflow run. /// @@ -150,27 +148,3 @@ public class Run return await this.RunToNextHaltAsync(cancellation).ConfigureAwait(false); } } - -/// -/// Represents a workflow run that tracks execution status and emitted workflow events, supporting resumption -/// with responses to , and retrieval of the running output of the workflow. -/// -/// The type of the workflow output. -public sealed class Run : Run -{ - internal static async ValueTask> CaptureStreamAsync(StreamingRun run, CancellationToken cancellation = default) - { - Run result = new(run); - await result.RunToNextHaltAsync(cancellation).ConfigureAwait(false); - return result; - } - - private readonly StreamingRun _streamingRun; - private Run(StreamingRun streamingRun) : base(streamingRun) - { - this._streamingRun = streamingRun; - } - - /// - public TResult? RunningOutput => this._streamingRun.RunningOutput; -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 6065b3d5d1..40bf8bf66d 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -9,11 +9,10 @@ using Microsoft.Extensions.AI.Agents; namespace Microsoft.Agents.Workflows.Specialized; -internal sealed class AIAgentHostExecutor : Executor +internal sealed class AIAgentHostExecutor : ChatProtocolExecutor { private readonly bool _emitEvents; private readonly AIAgent _agent; - private readonly List _pendingMessages = []; private AgentThread? _thread; public AIAgentHostExecutor(AIAgent agent, bool emitEvents = false) : base(id: agent.Id) @@ -25,13 +24,7 @@ internal sealed class AIAgentHostExecutor : Executor private AgentThread EnsureThread(IWorkflowContext context) => this._thread ??= this._agent.GetNewThread(); - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler((message, _) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) - .AddHandler(this.TakeTurnAsync); - private const string ThreadStateKey = nameof(_thread); - private const string PendingMessagesStateKey = nameof(_pendingMessages); protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellation = default) { Task threadTask = Task.CompletedTask; @@ -41,14 +34,9 @@ internal sealed class AIAgentHostExecutor : Executor threadTask = context.QueueStateUpdateAsync(ThreadStateKey, threadValue).AsTask(); } - Task messagesTask = Task.CompletedTask; - if (this._pendingMessages.Count > 0) - { - JsonElement messagesValue = this._pendingMessages.Serialize(); - messagesTask = context.QueueStateUpdateAsync(PendingMessagesStateKey, messagesValue).AsTask(); - } + Task baseTask = base.OnCheckpointingAsync(context, cancellation).AsTask(); - await Task.WhenAll(threadTask, messagesTask).ConfigureAwait(false); + await Task.WhenAll(threadTask, baseTask).ConfigureAwait(false); } protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellation = default) @@ -59,18 +47,13 @@ internal sealed class AIAgentHostExecutor : Executor this._thread = this._agent.DeserializeThread(threadValue.Value); } - JsonElement? messagesValue = await context.ReadStateAsync(PendingMessagesStateKey).ConfigureAwait(false); - if (messagesValue.HasValue) - { - List messages = messagesValue.Value.DeserializeMessages(); - this._pendingMessages.AddRange(messages); - } + await base.OnCheckpointRestoredAsync(context, cancellation).ConfigureAwait(false); } - public async ValueTask TakeTurnAsync(TurnToken token, IWorkflowContext context) + protected override async ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellation = default) { - bool emitEvents = token.EmitEvents ?? this._emitEvents; - IAsyncEnumerable agentStream = this._agent.RunStreamingAsync(this._pendingMessages, this.EnsureThread(context)); + emitEvents ??= this._emitEvents; + IAsyncEnumerable agentStream = this._agent.RunStreamingAsync(messages, this.EnsureThread(context), cancellationToken: cancellation); List updates = []; ChatMessage? currentStreamingMessage = null; @@ -83,7 +66,7 @@ internal sealed class AIAgentHostExecutor : Executor continue; } - if (emitEvents) + if (emitEvents ?? this._emitEvents) { await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); } @@ -110,8 +93,6 @@ internal sealed class AIAgentHostExecutor : Executor } await PublishCurrentMessageAsync().ConfigureAwait(false); - this._pendingMessages.Clear(); - await context.SendMessageAsync(token).ConfigureAwait(false); async ValueTask PublishCurrentMessageAsync() { diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/IOutputSink.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/IOutputSink.cs deleted file mode 100644 index c066914122..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/IOutputSink.cs +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows.Specialized; - -internal interface IOutputSink : IIdentified -{ - TResult? Result { get; } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs deleted file mode 100644 index 3f6c1b241a..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/OutputCollectorExecutor.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.Workflows.Specialized; - -internal sealed class OutputCollectorExecutor : Executor, IOutputSink -{ - private readonly StreamingAggregator _aggregator; - private readonly Func? _completionCondition; - - public TResult? Result { get; private set; } - - public OutputCollectorExecutor(StreamingAggregator aggregator, Func? completionCondition = null, string? id = null) : base(id) - { - this._aggregator = Throw.IfNull(aggregator); - this._completionCondition = completionCondition; - } - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler(this.HandleAsync); - - public ValueTask HandleAsync(TInput message, IWorkflowContext context) - { - this.Result = this._aggregator(message, this.Result); - - if (this._completionCondition is not null && - this._completionCondition!(message, this.Result)) - { - return context.AddEventAsync(new WorkflowCompletedEvent(this.Result)); - } - - return default; - } -} diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInfoExecutor.cs index 29af39966a..4f0278d100 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInfoExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/RequestInfoExecutor.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Threading.Tasks; using Microsoft.Agents.Workflows.Execution; using Microsoft.Shared.Diagnostics; @@ -9,6 +11,7 @@ namespace Microsoft.Agents.Workflows.Specialized; internal sealed class RequestInfoExecutor : Executor { + private readonly Dictionary _wrappedRequests = new(); private InputPort Port { get; } private IExternalRequestSink? RequestSink { get; set; } @@ -32,12 +35,12 @@ internal sealed class RequestInfoExecutor : Executor routeBuilder = routeBuilder // Handle incoming requests (as raw request payloads) .AddHandler(this.Port.Request, this.HandleAsync) - .AddHandler(typeof(object), this.HandleAsync); + .AddCatchAll(this.HandleCatchAllAsync); if (this._allowWrapped) { routeBuilder = routeBuilder - .AddHandler((request, context) => this.HandleAsync(request.Data, context)); + .AddHandler(this.HandleAsync); } return routeBuilder @@ -47,9 +50,50 @@ internal sealed class RequestInfoExecutor : Executor internal void AttachRequestSink(IExternalRequestSink requestSink) => this.RequestSink = Throw.IfNull(requestSink); + public async ValueTask HandleCatchAllAsync(PortableValue message, IWorkflowContext context) + { + Throw.IfNull(message); + + object? maybeRequest = message.AsType(this.Port.Request); + if (maybeRequest != null) + { + Debug.Assert(this.Port.Request.IsAssignableFrom(maybeRequest.GetType())); + + ExternalRequest request = ExternalRequest.Create(this.Port, maybeRequest!); + await this.RequestSink!.PostAsync(request).ConfigureAwait(false); + } + + throw new InvalidOperationException($"Message type {message.TypeId} could not be interpreted as a value of Request Type {this.Port.Request}"); + } + + public async ValueTask HandleAsync(ExternalRequest message, IWorkflowContext context) + { + Debug.Assert(this._allowWrapped); + Throw.IfNull(message); + + if (!message.Data.IsType(this.Port.Request)) + { + throw new InvalidOperationException($"Message type {message.Data.TypeId} could not be interpreted as a value of Request Type {this.Port.Request}"); + } + + if (!message.PortInfo.ResponseType.IsMatchPolymorphic(this.Port.Response)) + { + throw new InvalidOperationException($"Response type {this.Port.Response} is not a valid response for original request, whose expected response is {message.PortInfo.ResponseType}"); + } + + ExternalRequest request = ExternalRequest.Create(this.Port, message); + + this._wrappedRequests.Add(request.RequestId, message); + + await this.RequestSink!.PostAsync(request).ConfigureAwait(false); + + return request; + } + public async ValueTask HandleAsync(object message, IWorkflowContext context) { Throw.IfNull(message); + Debug.Assert(this.Port.Request.IsAssignableFrom(message.GetType())); ExternalRequest request = ExternalRequest.Create(this.Port, message); await this.RequestSink!.PostAsync(request).ConfigureAwait(false); @@ -66,7 +110,15 @@ internal sealed class RequestInfoExecutor : Executor throw new InvalidOperationException( $"Message type {message.Data.TypeId} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}."); - await context.SendMessageAsync(message).ConfigureAwait(false); + if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest)) + { + await context.SendMessageAsync(originalRequest.RewrapResponse(message)).ConfigureAwait(false); + } + else + { + await context.SendMessageAsync(message).ConfigureAwait(false); + } + await context.SendMessageAsync(data).ConfigureAwait(false); return message; diff --git a/dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs b/dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs index cf62b7e64b..1a1c5614b1 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/StreamingAggregators.cs @@ -6,17 +6,6 @@ using System.Linq; namespace Microsoft.Agents.Workflows; -/// -/// Represents a function that incrementally aggregates a sequence of input values, producing an updated result for each -/// input. -/// -/// The type of the input value to be aggregated. -/// The type of the aggregation result produced by the function. -/// The current input value to be incorporated into the aggregation. -/// The current aggregated result, or null if this is the first input. -/// The updated aggregation result after processing the input value, or null if no result can be produced. -public delegate TResult? StreamingAggregator(TInput input, TResult? runningResult); - /// /// Provides a set of streaming aggregation functions for processing sequences of input values in a stateful, /// incremental manner. @@ -32,25 +21,17 @@ public static class StreamingAggregators /// once. /// The type of the input elements to be aggregated. /// The type of the result produced by the conversion function. - /// A function that converts an input value of type to a result of type . This function is applied to the first input received. - /// A that yields the converted result of the first input. - public static StreamingAggregator First(Func conversion) + /// A function that converts an input value of type to a result + /// of type . This function is applied to the first input received. + /// An aggregation function that yields the result of converting the first input using the specified function. + public static Func First(Func conversion) { - bool hasRun = false; - TResult? local = default; - return Aggregate; - TResult? Aggregate(TInput input, TResult? runningResult) + TResult? Aggregate(TResult? runningResult, TInput input) { - if (!hasRun) - { - local = conversion(input); - hasRun = true; - } - - return local; + runningResult ??= conversion(input); + return runningResult; } } @@ -58,8 +39,8 @@ public static class StreamingAggregators /// Creates a streaming aggregator that returns the first input element. /// /// The type of the input elements to aggregate. - /// A that yields the first input element. - public static StreamingAggregator First() => First(input => input); + /// A an aggrgation function that yields the first input element. + public static Func First() => First(input => input); /// /// Creates a streaming aggregator that returns the result of applying the specified conversion to the most recent @@ -68,17 +49,15 @@ public static class StreamingAggregators /// The type of the input elements to be aggregated. /// The type of the result produced by the conversion function. /// A function that converts each input value to a result. Cannot be null. - /// A streaming aggregator that yields the converted value of the last input received. - public static StreamingAggregator Last(Func conversion) + /// A aggregator function that yields the result of converting the last input received using the specified + /// function. + public static Func Last(Func conversion) { - TResult? local = default; - return Aggregate; - TResult? Aggregate(TInput input, TResult? runningResult) + TResult? Aggregate(TResult? runningResult, TInput input) { - local = conversion(input); - return local; + return conversion(input); } } @@ -86,8 +65,8 @@ public static class StreamingAggregators /// Creates a streaming aggregator that returns the last element in a sequence. /// /// The type of elements in the input sequence. - /// A that yields the last element of the sequence. - public static StreamingAggregator Last() => Last(input => input); + /// An aggregator function that yields the last element of the input. + public static Func Last() => Last(input => input); /// /// Creates a streaming aggregator that produces the union of results by applying a conversion function to each @@ -96,13 +75,13 @@ public static class StreamingAggregators /// The type of the input elements to be aggregated. /// The type of the result elements produced by the conversion function. /// A function that converts each input element to a result element to be included in the union. - /// A streaming aggregator that, for each input, returns an enumerable containing all result elements produced so - /// far. - public static StreamingAggregator> Union(Func conversion) + /// An aggregator function that, for each input, returns an enumerable containing the result of converting every + /// element produced so far. + public static Func?, TInput, IEnumerable?> Union(Func conversion) { return Aggregate; - IEnumerable Aggregate(TInput input, IEnumerable? runningResult) + IEnumerable Aggregate(IEnumerable? runningResult, TInput input) { return runningResult is not null ? runningResult.Append(conversion(input)) : [conversion(input)]; } @@ -114,13 +93,13 @@ public static class StreamingAggregators /// The resulting aggregator combines all input sequences into a single sequence containing /// distinct elements. The order of elements in the output sequence is not guaranteed. /// The type of the elements in the input sequences to be aggregated. - /// A StreamingAggregator that, when applied to multiple input sequences, returns an IEnumerable containing the - /// union of all elements from those sequences. - public static StreamingAggregator> Union() + /// An aggregator function, that, when applied to multiple input sequences, returns an + /// containing the union of all elements from those sequences. + public static Func?, TInput, IEnumerable?> Union() { return Aggregate; - static IEnumerable Aggregate(TInput input, IEnumerable? runningResult) + static IEnumerable Aggregate(IEnumerable? runningResult, TInput input) { return runningResult is not null ? runningResult.Append(input) : [input]; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/StreamingRun.cs b/dotnet/src/Microsoft.Agents.Workflows/StreamingRun.cs index 2c27c1bccc..9a64ec8096 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/StreamingRun.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/StreamingRun.cs @@ -31,6 +31,11 @@ public class StreamingRun this._stepRunner = Throw.IfNull(stepRunner); } + /// + /// A unique identifier for the run. Can be provided at the start of the run, or auto-generated. + /// + public string RunId => this._stepRunner.RunId; + /// /// Asynchronously sends the specified response to the external system and signals completion of the current /// response wait operation. @@ -72,7 +77,7 @@ public class StreamingRun /// Asynchronously streams workflow events as they occur during workflow execution. /// /// This method yields instances in real time as the workflow - /// progresses. The stream completes when a is encountered. Events are + /// progresses. The stream completes when a is encountered. Events are /// delivered in the order they are raised. /// A that can be used to cancel the streaming operation. If cancellation is /// requested, the stream will end and no further events will be yielded. @@ -104,18 +109,20 @@ public class StreamingRun bool hadCompletionEvent = false; foreach (WorkflowEvent raisedEvent in Interlocked.Exchange(ref eventSink, [])) { - yield return raisedEvent; - if (cancellation.IsCancellationRequested) { yield break; // Exit if cancellation is requested } // TODO: Do we actually want to interpret this as a termination request? - if (raisedEvent is WorkflowCompletedEvent) + if (raisedEvent is RequestHaltEvent) { hadCompletionEvent = true; } + else + { + yield return raisedEvent; + } } if (hadCompletionEvent) @@ -152,25 +159,6 @@ public class StreamingRun } } -/// -/// A run instance supporting a streaming form of receiving workflow events, providing -/// a mechanism to send responses back to the workflow, and retrieving the result of workflow execution. -/// -/// The type of the workflow output. -public class StreamingRun : StreamingRun -{ - private readonly IRunnerWithOutput _resultSource; - - internal StreamingRun(IRunnerWithOutput runner) - : base(Throw.IfNull(runner.StepRunner)) - { - this._resultSource = runner; - } - - /// - public TResult? RunningOutput => this._resultSource.RunningOutput; -} - /// /// Provides extension methods for processing and executing workflows using streaming runs. /// @@ -202,27 +190,4 @@ public static class StreamingRunExtensions } } } - - /// - /// Executes the workflow associated with the specified until it - /// completes and returns the final result. - /// - /// This method ensures that the workflow runs to completion before returning the result. If an - /// is provided, it will be invoked for each event emitted during the workflow's - /// execution, allowing for custom event handling. - /// The type of the result produced by the workflow. - /// The representing the workflow to execute. - /// An optional callback function that is invoked for each - /// emitted during execution. The callback can process the event and return an object, or - /// if no response is required. - /// A that can be used to cancel the workflow execution. - /// A that represents the asynchronous operation. The task's result is the final - /// result of the workflow execution. - public static async ValueTask RunToCompletionAsync(this StreamingRun handle, Func? eventCallback = null, CancellationToken cancellation = default) - { - Throw.IfNull(handle); - - await handle.RunToCompletionAsync(eventCallback, cancellation).ConfigureAwait(false); - return handle.RunningOutput!; - } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.Workflows/Workflow.cs index 6f64f9bcf9..4fab7abcd6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Workflow.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Microsoft.Agents.Workflows.Checkpointing; -using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -20,6 +20,7 @@ public class Workflow internal Dictionary Registrations { get; init; } = []; internal Dictionary> Edges { get; init; } = []; + internal HashSet OutputExecutors { get; init; } = []; /// /// Gets the collection of edges grouped by their source node identifier. @@ -53,21 +54,50 @@ public class Workflow /// public string StartExecutorId { get; } - /// - /// Gets the type of input expected by the starting executor of the workflow. - /// - public Type InputType { get; } - /// /// Initializes a new instance of the class with the specified starting executor identifier /// and input type. /// /// The unique identifier of the starting executor for the workflow. Cannot be null. - /// The representing the input data for the workflow. Cannot be null. - internal Workflow(string startExecutorId, Type type) + internal Workflow(string startExecutorId) { this.StartExecutorId = Throw.IfNull(startExecutorId); - this.InputType = Throw.IfNull(type); + } + + /// + /// Attempts to promote the current workflow to a type pre-checked instance that can handle input of type . + /// + /// The desired input type. + /// A type-parametrized workflow definitely able to process input of type or + /// if the workflow does not accept that type of input. + /// + internal async ValueTask?> TryPromoteAsync() + { + // Grab the start node, and make sure it has the right type? + if (!this.Registrations.TryGetValue(this.StartExecutorId, out ExecutorRegistration? startRegistration)) + { + // TODO: This should never be able to be hit + throw new InvalidOperationException($"Start executor with ID '{this.StartExecutorId}' is not bound."); + } + + // TODO: Can we cache this somehow to avoid having to instantiate a new one when running? + // Does that break some user expectations? + Executor startExecutor = await startRegistration.ProviderAsync().ConfigureAwait(false); + + if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(TInput)))) + { + // We have no handlers for the input type T, which means the built workflow will not be able to + // process messages of the desired type + return null; + } + + return new Workflow(this.StartExecutorId) + { + Registrations = this.Registrations, + Edges = this.Edges, + Ports = this.Ports, + OutputExecutors = this.OutputExecutors + }; } } @@ -81,46 +111,12 @@ public class Workflow : Workflow /// Initializes a new instance of the class with the specified starting executor identifier /// /// The unique identifier of the starting executor for the workflow. Cannot be null. - public Workflow(string startExecutorId) : base(startExecutorId, typeof(T)) + public Workflow(string startExecutorId) : base(startExecutorId) { } - internal Workflow Promote(IOutputSink outputSource) - { - Throw.IfNull(outputSource); - - return new Workflow(this.StartExecutorId, outputSource) - { - Registrations = this.Registrations, - Edges = this.Edges, - Ports = this.Ports - }; - } -} - -/// -/// Represents a workflow that operates on data of type , resulting in -/// . -/// -/// The type of input to the workflow. -/// The type of the output from the workflow. -public class Workflow : Workflow -{ - private readonly IOutputSink _output; - - internal Workflow(string startExecutorId, IOutputSink outputSource) - : base(startExecutorId) - { - this._output = Throw.IfNull(outputSource); - } - /// - /// Gets the unique identifier of the output collector. + /// Gets the type of input expected by the starting executor of the workflow. /// - public string OutputCollectorId => this._output.Id; - - /// - /// The running (partial) output of the workflow, if any. - /// - public TResult? RunningOutput => this._output.Result; + public Type InputType => typeof(T); } diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs index 4f53e09af4..626a293dbd 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilder.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -31,6 +30,7 @@ public class WorkflowBuilder private readonly HashSet _unboundExecutors = []; private readonly HashSet _conditionlessConnections = []; private readonly Dictionary _inputPorts = []; + private readonly HashSet _outputExecutors = []; private readonly string _startExecutorId; @@ -91,6 +91,23 @@ public class WorkflowBuilder return executorish; } + /// + /// Register executors as an output source. Executors can use to yield output values. + /// By default, message handlers with a non-void return type will also be yielded, unless + /// is set to . + /// + /// + /// + public WorkflowBuilder WithOutputFrom(params ExecutorIsh[] executors) + { + foreach (ExecutorIsh executor in executors) + { + this._outputExecutors.Add(this.Track(executor).Id); + } + + return this; + } + /// /// Binds the specified executor to the workflow, allowing it to participate in workflow execution. /// @@ -303,41 +320,7 @@ public class WorkflowBuilder return this; } - [SuppressMessage("Reliability", "CA2008:Do not create tasks without passing a TaskScheduler", - Justification = "We explicitly set the TaskScheduler when we create the TaskFactory")] - [SuppressMessage("Usage", "VSTHRD002:Avoid problematic synchronous waits", - Justification = "This runs the thread on the thread pool")] - private static TResult RunSync(Func> funcAsync) - { - TaskFactory factory = new(CancellationToken.None, TaskCreationOptions.None, TaskContinuationOptions.None, TaskScheduler.Default); - - // See ASP.Net.Identity's implementation of AsyncHelper - // https://github.com/aspnet/AspNetIdentity/blob/main/src/Microsoft.AspNet.Identity.Core/AsyncHelper.cs - - // Capture the current culture and UI culture - var culture = System.Globalization.CultureInfo.CurrentCulture; - var uiCulture = System.Globalization.CultureInfo.CurrentUICulture; - - return factory.StartNew(PropagateCultureAndInvokeAsync).Unwrap().GetAwaiter().GetResult(); - - Task PropagateCultureAndInvokeAsync() - { - // Set the culture and UI culture to the captured values - System.Globalization.CultureInfo.CurrentCulture = culture; - System.Globalization.CultureInfo.CurrentUICulture = uiCulture; - return funcAsync().AsTask(); - } - } - - /// - /// Builds and returns a workflow instance configured to process messages of the specified input type. - /// - /// The type of input messages that the workflow will accept and process. - /// A new instance of . - /// Thrown if there are unbound executors in the workflow definition, - /// if the start executor is not bound, or if the start executor does not contain a handler for the specified input - /// type . - public Workflow Build() + private void Validate() { if (this._unboundExecutors.Count > 0) { @@ -345,27 +328,44 @@ public class WorkflowBuilder $"Workflow cannot be built because there are unbound executors: {string.Join(", ", this._unboundExecutors)}."); } - // Grab the start node, and make sure it has the right type? - if (!this._executors.TryGetValue(this._startExecutorId, out ExecutorRegistration? startRegistration)) - { - // TODO: This should never be able to be hit - throw new InvalidOperationException($"Start executor with ID '{this._startExecutorId}' is not bound."); - } + // TODO: This is likely a pipe-dream, but can we do any type-checking on the edges? (Not without instantiating the executors...) + } - Executor startExecutor = RunSync(startRegistration.CreateInstanceAsync); - if (!startExecutor.InputTypes.Any(t => t.IsAssignableFrom(typeof(T)))) - { - // We have no handlers for the input type T, which means the built workflow will not be able to - // process messages of the desired type - throw new InvalidOperationException( - $"Workflow cannot be built because the starting executor {this._startExecutorId} does not contain a handler for the desired input type {typeof(T).Name}"); - } + /// + /// Builds and returns a workflow instance. + /// + /// Thrown if there are unbound executors in the workflow definition, + /// or if the start executor is not bound. + public Workflow Build() + { + this.Validate(); - return new Workflow(this._startExecutorId) // Why does it not see the default ctor? + return new Workflow(this._startExecutorId) { Registrations = this._executors, Edges = this._edges, - Ports = this._inputPorts + Ports = this._inputPorts, + OutputExecutors = this._outputExecutors }; } + + /// + /// Attempts to build a workflow instance configured to process messages of the specified input type. + /// + /// The desired input type for the workflow. + /// Thrown if the built workflow cannot process messages of the specified input type, + public async ValueTask> BuildAsync() where TInput : notnull + { + Workflow? maybeWorkflow = await this.Build() + .TryPromoteAsync() + .ConfigureAwait(false); + + if (maybeWorkflow is null) + { + throw new InvalidOperationException( + $"The built workflow cannot process input of type '{typeof(TInput).FullName}'."); + } + + return maybeWorkflow; + } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs index 686d016ee2..bae1a3ff87 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowBuilderExtensions.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Microsoft.Agents.Workflows.Specialized; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.Workflows; @@ -157,40 +156,4 @@ public static class WorkflowBuilderExtensions return switchBuilder.ReduceToFanOut(builder, source); } - - /// - /// Builds a workflow that collects output from the specified executor, aggregates results using the provided - /// streaming aggregator, and optionally completes based on a custom condition. - /// - /// The returned workflow promotes the output collector as its result source, allowing consumers - /// to access the aggregated output directly. The completion condition can be used to implement custom termination - /// logic, such as early stopping when a desired result is reached. - /// The type of input items processed by the workflow. - /// The type of items generated by the , - /// and aggregated by the . - /// The type of aggregated result produced by the workflow. - /// The workflow builder used to construct the workflow and define its execution graph. - /// The executor that produces output items to be collected and aggregated. Cannot be null. - /// The streaming aggregator that processes input items and produces aggregated results. Cannot be null. - /// An optional predicate that determines when the workflow should complete based on the current input and - /// aggregated result. If null, the workflow will not raise a . - /// A workflow that collects output from the specified executor, aggregates results, and exposes the aggregated - /// output. - public static Workflow BuildWithOutput( - this WorkflowBuilder builder, - ExecutorIsh outputSource, - StreamingAggregator aggregator, - Func? completionCondition = null) - { - Throw.IfNull(outputSource); - Throw.IfNull(aggregator); - - OutputCollectorExecutor outputSink = new(aggregator, completionCondition); - - // TODO: Check that the outputSource has a TResult output? - builder.AddEdge(outputSource, outputSink); - - Workflow workflow = builder.Build(); - return workflow.Promote(outputSink); - } } diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowCompletedEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowCompletedEvent.cs deleted file mode 100644 index 3a753725ee..0000000000 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowCompletedEvent.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Workflows; - -/// -/// Event triggered when a workflow completes execution. -/// -/// -/// The user is expected to raise this event from a terminating , or to build -/// the workflow with output capture using . -/// -/// The result of the execution of the workflow. -public sealed class WorkflowCompletedEvent(object? result = null) : WorkflowEvent(data: result); diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowEvent.cs index 0bc33e0739..23e0ee713b 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowEvent.cs @@ -10,9 +10,9 @@ namespace Microsoft.Agents.Workflows; [JsonDerivedType(typeof(ExecutorEvent))] [JsonDerivedType(typeof(SuperStepEvent))] [JsonDerivedType(typeof(WorkflowStartedEvent))] -[JsonDerivedType(typeof(WorkflowCompletedEvent))] [JsonDerivedType(typeof(WorkflowErrorEvent))] [JsonDerivedType(typeof(WorkflowWarningEvent))] +[JsonDerivedType(typeof(WorkflowOutputEvent))] [JsonDerivedType(typeof(RequestInfoEvent))] public class WorkflowEvent(object? data = null) { diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs index a445417ef7..90f703fa03 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostAgent.cs @@ -65,7 +65,7 @@ internal sealed class WorkflowHostAgent : AIAgent // in the case of new threads. if (!this._runningWorkflows.TryGetValue(runId, out StreamingRun? run)) { - run = await InProcessExecution.StreamAsync(this._workflow, messages, cancellation) + run = await InProcessExecution.StreamAsync(this._workflow, messages, cancellation: cancellation) .ConfigureAwait(false); this._runningWorkflows[runId] = run; } diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostingExtensions.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostingExtensions.cs index d1e6260e09..79ed8b392c 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostingExtensions.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowHostingExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; @@ -23,6 +25,26 @@ public static class WorkflowHostingExtensions return new WorkflowHostAgent(workflow, id, name); } + /// + /// Convert a workflow with the appropriate primary input type to an . + /// + /// + /// + /// + /// + public static async ValueTask AsAgentAsync(this Workflow workflow, string? id = null, string? name = null) + { + Workflow>? maybeTyped = await workflow.TryPromoteAsync>() + .ConfigureAwait(false); + + if (maybeTyped is null) + { + throw new InvalidOperationException("Cannot host a workflow that does not accept List as an input"); + } + + return maybeTyped.AsAgent(); + } + internal static FunctionCallContent ToFunctionCall(this ExternalRequest request) { Dictionary parameters = new() diff --git a/dotnet/src/Microsoft.Agents.Workflows/WorkflowOutputEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/WorkflowOutputEvent.cs new file mode 100644 index 0000000000..1cba1860fb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Workflows/WorkflowOutputEvent.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.Workflows; + +/// +/// Event triggered when a workflow executor yields output. +/// +public sealed class WorkflowOutputEvent : WorkflowEvent +{ + internal WorkflowOutputEvent(object data, string sourceId) : base(data) + { + this.SourceId = sourceId; + } + + /// + /// The unique identifier of the executor that yielded this output. + /// + public string SourceId { get; } + + /// + /// Determines whether the underlying data is of the specified type or a derived type. + /// + /// The type to compare with the type of the underlying data. + /// true if the underlying data is assignable to type T; otherwise, false. + public bool Is() => this.IsType(typeof(T)); + + /// + /// Determines whether the underlying data is of the specified type or a derived type. + /// + /// The type to compare with the type of the underlying data. + /// true if the underlying data is assignable to type T; otherwise, false. + public bool IsType(Type type) => this.Data == null + ? false + : type.IsAssignableFrom(this.Data.GetType()); + + /// + /// Attempts to retrieve the underlying data as the specified type. + /// + /// The type to which to cast. + /// The value of Data as to the target type. + public T? As() => this.Data is T value ? value : default; + + /// + /// Attempts to retrieve the underlying data as the specified type. + /// + /// The type to which to cast. + /// The value of Data as to the target type. + public object? AsType(Type type) => this.IsType(type) ? this.Data : null; +} diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs index 1ae36ca44d..9fede6797e 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs @@ -71,7 +71,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow Configuration = workflowConfig, LoggerFactory = this.Output }; - Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowEvents workflowEvents = await WorkflowHarness.RunAsync(workflow, (TInput)GetInput(testcase)); foreach (DeclarativeActionInvokedEvent actionInvokeEvent in workflowEvents.ActionInvokeEvents) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs index 3f2b6fc327..142cf0ebc2 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework; internal static class WorkflowHarness { - public static async Task RunAsync(Workflow workflow, TInput input) where TInput : notnull + public static async Task RunAsync(Workflow workflow, TInput input) where TInput : notnull { StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); IReadOnlyList workflowEvents = run.WatchStreamAsync().ToEnumerable().ToList(); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 5c1b7ac554..61bac9da8c 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -252,7 +252,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow Mock mockAgentProvider = new(MockBehavior.Strict); DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output }; - Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); StreamingRun run = await InProcessExecution.StreamAsync(workflow, workflowInput); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs index 54b3b6fdc9..a13bc8c2d2 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs @@ -29,7 +29,7 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor TestWorkflowExecutor workflowExecutor = new(); WorkflowBuilder workflowBuilder = new(workflowExecutor); workflowBuilder.AddEdge(workflowExecutor, executor); - StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), this.State); + StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), this.State); WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync(); Assert.Contains(events, e => e is DeclarativeActionInvokedEvent); Assert.Contains(events, e => e is DeclarativeActionCompletedEvent); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index fcc42a8184..0f43bfa512 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -382,28 +382,28 @@ public class AgentWorkflowBuilderTests } private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync( - Workflow> workflow, List input) + Workflow workflow, List input) { StringBuilder sb = new(); StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - WorkflowCompletedEvent? completed = null; + WorkflowOutputEvent? output = null; await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { if (evt is AgentRunUpdateEvent executorComplete) { sb.Append(executorComplete.Data); } - else if (evt is WorkflowCompletedEvent e) + else if (evt is WorkflowOutputEvent e) { - completed = e; + output = e; break; } } - return (sb.ToString(), completed?.Data as List); + return (sb.ToString(), output?.As>()); } private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox> barrier, StrongBox remaining) : DoubleEchoAgent(name) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ForwardMessageExecutor.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ForwardMessageExecutor.cs index 00f509d8df..083135c275 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ForwardMessageExecutor.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ForwardMessageExecutor.cs @@ -2,7 +2,7 @@ namespace Microsoft.Agents.Workflows.UnitTests; -internal sealed class ForwardMessageExecutor(string? id = null) : Executor(id) where TMessage : notnull +internal sealed class ForwardMessageExecutor(string id) : Executor(id) where TMessage : notnull { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddHandler((message, ctx) => ctx.SendMessageAsync(message)); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/InProcessStateTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/InProcessStateTests.cs index 397f0dddb7..3983d2b4a3 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/InProcessStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/InProcessStateTests.cs @@ -95,12 +95,12 @@ public class InProcessStateTests ValidateState(1) ); - Workflow workflow = + Workflow workflow = new WorkflowBuilder(writer) .AddEdge(writer, validator, MaxTurns(4)) - .AddEdge(validator, writer, MaxTurns(4)).Build(); + .AddEdge(validator, writer, MaxTurns(4)).Build(); - Run run = await InProcessExecution.RunAsync(workflow, new()); + Run run = await InProcessExecution.RunAsync(workflow, new()); run.Status.Should().Be(RunStatus.Idle); } @@ -122,12 +122,12 @@ public class InProcessStateTests ValidateState(1) ); - Workflow workflow = + Workflow workflow = new WorkflowBuilder(writer) .AddEdge(writer, validator, MaxTurns(4)) - .AddEdge(validator, writer, MaxTurns(4)).Build(); + .AddEdge(validator, writer, MaxTurns(4)).Build(); - Checkpointed checkpointed = await InProcessExecution.RunAsync(workflow, new(), CheckpointManager.Default); + Checkpointed checkpointed = await InProcessExecution.RunAsync(workflow, new(), CheckpointManager.Default); checkpointed.Checkpoints.Should().HaveCount(6); checkpointed.Run.Status.Should().Be(RunStatus.Idle); @@ -136,7 +136,7 @@ public class InProcessStateTests [Fact] public async Task InProcessRun_StateShouldError_TwoExecutorsAsync() { - ForwardMessageExecutor forward = new(); + ForwardMessageExecutor forward = new(nameof(ForwardMessageExecutor)); using StateTestExecutor testExecutor = new( new ScopeKey("StateTestExecutor", "TestScope", "TestKey"), loop: false, @@ -149,12 +149,12 @@ public class InProcessStateTests CreateOrIncrement() ); - Workflow workflow = + Workflow workflow = new WorkflowBuilder(forward) .AddFanOutEdge(forward, targets: [testExecutor, testExecutor2]) - .Build(); + .Build(); - var act = async () => await InProcessExecution.RunAsync(workflow, new()); + var act = async () => await InProcessExecution.RunAsync(workflow, new TurnToken()); var result = await act.Should() .ThrowAsync("multiple writers to the same shared scope key"); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/JsonSerializationTests.cs index 929eca98be..a93c775cb0 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/JsonSerializationTests.cs @@ -8,6 +8,7 @@ using System.Linq.Expressions; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using System.Threading; +using System.Threading.Tasks; using FluentAssertions; using Microsoft.Agents.Workflows.Checkpointing; using Microsoft.Agents.Workflows.Execution; @@ -154,7 +155,7 @@ public class JsonSerializationTests private static InputPortInfo IntToString => InputPort.Create(IntToStringId).ToPortInfo(); private static InputPortInfo StringToInt => InputPort.Create(StringToIntId).ToPortInfo(); - private static Workflow CreateTestWorkflow() + private static ValueTask> CreateTestWorkflowAsync() { ForwardMessageExecutor forwardString = new(ForwardStringId); ForwardMessageExecutor forwardInt = new(ForwardIntId); @@ -165,14 +166,17 @@ public class JsonSerializationTests WorkflowBuilder builder = new(forwardString); builder.AddEdge(forwardString, stringToInt) .AddEdge(stringToInt, forwardInt) - .AddEdge(forwardInt, intToString); + .AddEdge(forwardInt, intToString) + .AddEdge(intToString, StreamingAggregators.Last().AsExecutor("Aggregate")); - return builder.BuildWithOutput( - intToString, - StreamingAggregators.Last(), (_, __) => true); + return builder.BuildAsync(); } - private static WorkflowInfo TestWorkflowInfo => CreateTestWorkflow().ToWorkflowInfo(); + private static async ValueTask CreateTestWorkflowInfoAsync() + { + Workflow testWorkflow = await CreateTestWorkflowAsync().ConfigureAwait(false); + return testWorkflow.ToWorkflowInfo(); + } private static void ValidateWorkflowInfo(WorkflowInfo actual, WorkflowInfo prototype) { @@ -182,8 +186,8 @@ public class JsonSerializationTests actual.InputType.Should().Match(prototype.InputType.CreateValidator()); actual.StartExecutorId.Should().Be(prototype.StartExecutorId); - actual.OutputType.Should().NotBeNull().And.Match(prototype.OutputType!.CreateValidator()); - actual.OutputCollectorId.Should().NotBeNull().And.Be(prototype.OutputCollectorId); + actual.OutputExecutorIds.Should().HaveCount(prototype.OutputExecutorIds.Count) + .And.AllSatisfy(id => prototype.OutputExecutorIds.Contains(id)); void ValidateExecutorDictionary(Dictionary expected, Dictionary> expectedEdges, @@ -226,9 +230,9 @@ public class JsonSerializationTests } [Fact] - public void Test_WorkflowInfo_JsonRoundtrip() + public async Task Test_WorkflowInfo_JsonRoundtripAsync() { - WorkflowInfo prototype = TestWorkflowInfo; + WorkflowInfo prototype = await CreateTestWorkflowInfoAsync(); JsonMarshaller marshaller = new(); @@ -634,9 +638,10 @@ public class JsonSerializationTests private static CheckpointInfo TestParentCheckpointInfo => new(s_runId, s_parentCheckpointId); [Fact] - public void Test_Checkpoint_JsonRoundTrip() + public async Task Test_Checkpoint_JsonRoundTripAsync() { - Checkpoint prototype = new(12, TestWorkflowInfo, TestRunnerStateData, TestStateData, TestEdgeState, TestParentCheckpointInfo); + WorkflowInfo testWorkflowInfo = await CreateTestWorkflowInfoAsync(); + Checkpoint prototype = new(12, testWorkflowInfo, TestRunnerStateData, TestStateData, TestEdgeState, TestParentCheckpointInfo); Checkpoint result = RunJsonRoundtrip(prototype, TestCustomSerializedJsonOptions); result.Should().Match((Checkpoint checkpoint) => checkpoint.StepNumber == prototype.StepNumber); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs index 2bf32f71b3..114a5e7e45 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -8,7 +8,7 @@ using Moq; namespace Microsoft.Agents.Workflows.UnitTests; -public class BaseTestExecutor : ReflectingExecutor where TActual : ReflectingExecutor +public class BaseTestExecutor(string id) : ReflectingExecutor(id) where TActual : ReflectingExecutor { protected void OnInvokedHandler() => this.InvokedHandler = true; @@ -19,7 +19,7 @@ public class BaseTestExecutor : ReflectingExecutor where TActu } } -public class DefaultHandler : BaseTestExecutor, IMessageHandler +public class DefaultHandler() : BaseTestExecutor(nameof(DefaultHandler)), IMessageHandler { public ValueTask HandleAsync(object message, IWorkflowContext context) { @@ -34,7 +34,7 @@ public class DefaultHandler : BaseTestExecutor, IMessageHandler< } = (message, context) => default; } -public class TypedHandler : BaseTestExecutor>, IMessageHandler +public class TypedHandler() : BaseTestExecutor>(nameof(TypedHandler)), IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context) { @@ -49,7 +49,7 @@ public class TypedHandler : BaseTestExecutor>, IMes } = (message, context) => default; } -public class TypedHandlerWithOutput : BaseTestExecutor>, IMessageHandler +public class TypedHandlerWithOutput() : BaseTestExecutor>(nameof(TypedHandlerWithOutput)), IMessageHandler { public ValueTask HandleAsync(TInput message, IWorkflowContext context) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs index c7167a9ac0..c0da6db172 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/RepresentationTests.cs @@ -16,7 +16,7 @@ namespace Microsoft.Agents.Workflows.UnitTests; public class RepresentationTests { - private sealed class TestExecutor : Executor + private sealed class TestExecutor() : Executor("TestExecutor") { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder; } @@ -79,9 +79,6 @@ public class RepresentationTests { await RunExecutorishInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent())); await RunExecutorishInfoMatchTestAsync(new RequestInfoExecutor(TestInputPort)); - - OutputCollectorExecutor> outputCollector = new(StreamingAggregators.Union()); - await RunExecutorishInfoMatchTestAsync(outputCollector); } private static string Source(int id) => $"Source/{id}"; @@ -158,17 +155,23 @@ public class RepresentationTests } [Fact] - public void Test_Sample_WorkflowInfos() + public async Task Test_Sample_WorkflowInfosAsync() { - RunWorkflowInfoMatchTest(Step1EntryPoint.WorkflowInstance); - RunWorkflowInfoMatchTest(Step2EntryPoint.WorkflowInstance); - RunWorkflowInfoMatchTest(Step3EntryPoint.WorkflowInstance); - RunWorkflowInfoMatchTest(Step4EntryPoint.WorkflowInstance); + Workflow workflowStep1 = (await Step1EntryPoint.WorkflowInstance.TryPromoteAsync())!; + RunWorkflowInfoMatchTest(workflowStep1); + + Workflow workflowStep2 = (await Step2EntryPoint.WorkflowInstance.TryPromoteAsync())!; + RunWorkflowInfoMatchTest(workflowStep2); + + RunWorkflowInfoMatchTest((await Step3EntryPoint.WorkflowInstance.TryPromoteAsync())!); + + RunWorkflowInfoMatchTest((await Step4EntryPoint.WorkflowInstance.TryPromoteAsync())!); + // Step 5 reuses the workflow from Step 4, so we don't need to test it separately. - RunWorkflowInfoMatchTest(Step6EntryPoint.CreateWorkflow(2)); + RunWorkflowInfoMatchTest((await Step6EntryPoint.CreateWorkflow(2).TryPromoteAsync>())!); // Step 7 reuses the workflow from Step 6, so we don't need to test it separately. - RunWorkflowInfoMatchTest(Step1EntryPoint.WorkflowInstance, Step2EntryPoint.WorkflowInstance, expect: false); + RunWorkflowInfoMatchTest(workflowStep1, workflowStep2, expect: false); static void RunWorkflowInfoMatchTest(Workflow workflow, Workflow? comparator = null, bool expect = true) { diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index 2399571aac..ad6ea26ab1 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Sample; internal static class Step1EntryPoint { - public static Workflow WorkflowInstance + public static Workflow WorkflowInstance { get { @@ -19,7 +19,7 @@ internal static class Step1EntryPoint WorkflowBuilder builder = new(uppercase); builder.AddEdge(uppercase, reverse); - return builder.Build(); + return builder.Build(); } } @@ -49,7 +49,7 @@ internal sealed class ReverseTextExecutor() : ReflectingExecutor WorkflowInstance + public static Workflow WorkflowInstance { get { string[] spamKeywords = ["spam", "advertisement", "offer"]; - DetectSpamExecutor detectSpam = new(spamKeywords); - RespondToMessageExecutor respondToMessage = new(); - RemoveSpamExecutor removeSpam = new(); + DetectSpamExecutor detectSpam = new("DetectSpam", spamKeywords); + RespondToMessageExecutor respondToMessage = new("RespondToMessage"); + RemoveSpamExecutor removeSpam = new("RemoveSpam"); return new WorkflowBuilder(detectSpam) .AddEdge(detectSpam, respondToMessage, (bool isSpam) => !isSpam) // If not spam, respond .AddEdge(detectSpam, removeSpam, (bool isSpam) => isSpam) // If spam, remove - .Build(); + .WithOutputFrom(respondToMessage, removeSpam) + .Build(); } } @@ -34,9 +35,9 @@ internal static class Step2EntryPoint { switch (evt) { - case WorkflowCompletedEvent workflowCompleteEvt: + case WorkflowOutputEvent workflowOutputEvt: // The workflow has completed successfully, return the result - string workflowResult = workflowCompleteEvt.Data!.ToString()!; + string workflowResult = workflowOutputEvt.As()!; writer.WriteLine($"Result: {workflowResult}"); return workflowResult; case ExecutorCompletedEvent executorCompletedEvt: @@ -45,7 +46,7 @@ internal static class Step2EntryPoint } } - throw new InvalidOperationException("Workflow failed to yield the completion event."); + throw new InvalidOperationException("Workflow failed to yield an output."); } } @@ -53,7 +54,7 @@ internal sealed class DetectSpamExecutor : ReflectingExecutor, IMessageHandler +internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor(id), IMessageHandler { public const string ActionResult = "Message processed successfully."; @@ -84,12 +85,12 @@ internal sealed class RespondToMessageExecutor : ReflectingExecutor, IMessageHandler +internal sealed class RemoveSpamExecutor(string id) : ReflectingExecutor(id), IMessageHandler { public const string ActionResult = "Spam message removed."; @@ -103,7 +104,7 @@ internal sealed class RemoveSpamExecutor : ReflectingExecutor WorkflowInstance + public static Workflow WorkflowInstance { get { - GuessNumberExecutor guessNumber = new(1, 100); - JudgeExecutor judge = new(42); // Let's say the target number is 42 + GuessNumberExecutor guessNumber = new("GuessNumber", 1, 100); + JudgeExecutor judge = new("Judge", 42); // Let's say the target number is 42 return new WorkflowBuilder(guessNumber) .AddEdge(guessNumber, judge) .AddEdge(judge, guessNumber) - .Build(); + .WithOutputFrom(guessNumber) + .Build(); } } @@ -32,9 +33,9 @@ internal static class Step3EntryPoint { switch (evt) { - case WorkflowCompletedEvent workflowCompleteEvt: + case WorkflowOutputEvent workflowOutputEvt: // The workflow has completed successfully, return the result - string workflowResult = workflowCompleteEvt.Data!.ToString()!; + string workflowResult = workflowOutputEvt.As()!; writer.WriteLine($"Result: {workflowResult}"); return workflowResult; case ExecutorCompletedEvent executorCompletedEvt: @@ -43,7 +44,7 @@ internal static class Step3EntryPoint } } - throw new InvalidOperationException("Workflow failed to yield the completion event."); + throw new InvalidOperationException("Workflow failed to yield an output."); } } @@ -60,7 +61,7 @@ internal sealed class GuessNumberExecutor : ReflectingExecutor, IMessag internal int? Tries { get; private set; } - public JudgeExecutor(int targetNumber) + public JudgeExecutor(string id, int targetNumber) : base(id) { this._targetNumber = targetNumber; } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs index 83e2b31b38..4cc8286a86 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; @@ -8,18 +9,27 @@ namespace Microsoft.Agents.Workflows.Sample; internal static class Step4EntryPoint { - public static Workflow CreateWorkflowInstance(out JudgeExecutor judge) + internal const string JudgeId = "Judge"; + + public static Workflow CreateWorkflowInstance(out JudgeExecutor judge) { InputPort guessNumber = InputPort.Create("GuessNumber"); - judge = new(42); // Let's say the target number is 42 + judge = new(JudgeId, 42); // Let's say the target number is 42 return new WorkflowBuilder(guessNumber) .AddEdge(guessNumber, judge) .AddEdge(judge, guessNumber, (NumberSignal signal) => signal != NumberSignal.Matched) - .BuildWithOutput(judge, ComputeStreamingOutput, (s, _) => s is NumberSignal.Matched); + .WithOutputFrom(judge) + .Build(); } - public static Workflow WorkflowInstance + public static ValueTask?> GetPromotedWorklowInstanceAsync() + { + Workflow workflow = CreateWorkflowInstance(out _); + return workflow.TryPromoteAsync(); + } + + public static Workflow WorkflowInstance { get { @@ -29,30 +39,53 @@ internal static class Step4EntryPoint public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback) { - Workflow workflow = WorkflowInstance; - StreamingRun handle = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); + NumberSignal signal = NumberSignal.Init; + string? prompt = UpdatePrompt(null, signal); + Workflow workflow = WorkflowInstance; + StreamingRun handle = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); + + List requests = []; await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { switch (evt) { + case WorkflowOutputEvent outputEvent: + switch (outputEvent.SourceId) + { + case JudgeId: + if (!outputEvent.Is()) + { + throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}"); + } + + signal = outputEvent.As()!.Value; + prompt = UpdatePrompt(prompt, signal); + break; + } + + break; case RequestInfoEvent requestInputEvt: - ExternalResponse response = ExecuteExternalRequest(requestInputEvt.Request, userGuessCallback, workflow.RunningOutput); - await handle.SendResponseAsync(response).ConfigureAwait(false); + requests.Add(requestInputEvt.Request); + break; + + case SuperStepCompletedEvent stepCompletedEvent: + foreach (ExternalRequest request in requests) + { + ExternalResponse response = ExecuteExternalRequest(request, userGuessCallback, prompt); + await handle.SendResponseAsync(response).ConfigureAwait(false); + } + requests.Clear(); break; - case WorkflowCompletedEvent workflowCompleteEvt: - // The workflow has completed successfully, return the result - string workflowResult = workflowCompleteEvt.Data!.ToString()!; - writer.WriteLine($"Result: {workflowResult}"); - return workflowResult; case ExecutorCompletedEvent executorCompletedEvt: writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}"); break; } } - throw new InvalidOperationException("Workflow failed to yield the completion event."); + writer.WriteLine($"Result: {prompt}"); + return prompt!; } private static ExternalResponse ExecuteExternalRequest( @@ -73,16 +106,10 @@ internal static class Step4EntryPoint /// This converts the incoming from the judge to a status text that can be displayed /// to the user. /// - /// - /// This works correctly timing-wise because both the and the - /// are one edge from the (see the workflow definition in the - /// method). That means they will get the at the same time (one - /// SuperStep after the Judge has generated it.) - /// - /// /// + /// /// - private static string ComputeStreamingOutput(NumberSignal signal, string? runningResult) + internal static string? UpdatePrompt(string? runningResult, NumberSignal signal) { return signal switch { @@ -90,7 +117,7 @@ internal static class Step4EntryPoint NumberSignal.Above => "Your guess was too high. Try again.", NumberSignal.Below => "Your guess was too low. Try again.", - _ => runningResult ?? string.Empty + _ => runningResult }; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs index 1c5dadcdf5..c69618f4de 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs @@ -13,18 +13,23 @@ internal static class Step5EntryPoint { public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null) { + Dictionary checkpointedOutputs = new(); + + NumberSignal signal = NumberSignal.Init; + string? prompt = Step4EntryPoint.UpdatePrompt(null, signal); + checkpointManager ??= CheckpointManager.Default; - Workflow workflow = Step4EntryPoint.CreateWorkflowInstance(out JudgeExecutor judge); - Checkpointed> checkpointed = + Workflow workflow = Step4EntryPoint.CreateWorkflowInstance(out JudgeExecutor judge); + Checkpointed checkpointed = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init, checkpointManager) .ConfigureAwait(false); List checkpoints = []; CancellationTokenSource cancellationSource = new(); - StreamingRun handle = checkpointed.Run; - string? result = await RunStreamToHaltOrMaxStepAsync(6).ConfigureAwait(false); + StreamingRun handle = checkpointed.Run; + string? result = await RunStreamToHaltOrMaxStepAsync(maxStep: 6).ConfigureAwait(false); result.Should().BeNull(); checkpoints.Should().HaveCount(6, "we should have two checkpoints, one for each step"); @@ -34,7 +39,7 @@ internal static class Step5EntryPoint if (rehydrateToRestore) { - checkpointed = await InProcessExecution.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, CancellationToken.None) + checkpointed = await InProcessExecution.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, runId: handle.RunId, cancellation: CancellationToken.None) .ConfigureAwait(false); handle = checkpointed.Run; } @@ -43,6 +48,8 @@ internal static class Step5EntryPoint await checkpointed.RestoreCheckpointAsync(checkpoints[2], CancellationToken.None).ConfigureAwait(false); } + (signal, prompt) = checkpointedOutputs[targetCheckpoint]; + judge.Tries.Should().Be(1); cancellationSource.Dispose(); @@ -52,7 +59,7 @@ internal static class Step5EntryPoint result = await RunStreamToHaltOrMaxStepAsync().ConfigureAwait(false); result.Should().NotBeNull(); - checkpoints.Should().HaveCount(6); + checkpoints.Should().HaveCount(7); cancellationSource.Dispose(); @@ -60,31 +67,56 @@ internal static class Step5EntryPoint async ValueTask RunStreamToHaltOrMaxStepAsync(int? maxStep = null) { + List requests = []; await foreach (WorkflowEvent evt in handle.WatchStreamAsync(cancellationSource.Token).ConfigureAwait(false)) { switch (evt) { + case WorkflowOutputEvent outputEvent: + switch (outputEvent.SourceId) + { + case Step4EntryPoint.JudgeId: + if (!outputEvent.Is()) + { + throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}"); + } + + signal = outputEvent.As()!.Value; + prompt = Step4EntryPoint.UpdatePrompt(null, signal); + break; + } + + break; + + case RequestInfoEvent requestInputEvt: + requests.Add(requestInputEvt.Request); + break; + case SuperStepCompletedEvent stepCompletedEvt: CheckpointInfo? checkpoint = stepCompletedEvt.CompletionInfo!.Checkpoint; if (checkpoint is not null) { checkpoints.Add(checkpoint); + + checkpointedOutputs[checkpoint] = (signal, prompt); } if (maxStep.HasValue && stepCompletedEvt.StepNumber >= maxStep.Value - 1) { cancellationSource.Cancel(); } + else + { + foreach (ExternalRequest request in requests) + { + ExternalResponse response = ExecuteExternalRequest(request, userGuessCallback, prompt); + await handle.SendResponseAsync(response).ConfigureAwait(false); + } + + requests.Clear(); + } break; - case RequestInfoEvent requestInputEvt: - ExternalResponse response = ExecuteExternalRequest(requestInputEvt.Request, userGuessCallback, workflow.RunningOutput); - await handle.SendResponseAsync(response).ConfigureAwait(false); - break; - case WorkflowCompletedEvent workflowCompleteEvt: - // The workflow has completed successfully, return the result - string workflowResult = workflowCompleteEvt.Data!.ToString()!; - writer.WriteLine($"Result: {workflowResult}"); - return workflowResult; + case ExecutorCompletedEvent executorCompleteEvt: writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); break; @@ -96,7 +128,8 @@ internal static class Step5EntryPoint return null; } - throw new InvalidOperationException("Workflow failed to yield the completion event."); + writer.WriteLine($"Result: {prompt}"); + return prompt!; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs index 6684c09c0f..ed124363b7 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs @@ -17,7 +17,7 @@ namespace Microsoft.Agents.Workflows.Sample; internal static class Step6EntryPoint { - public static Workflow> CreateWorkflow(int maxTurns) => + public static Workflow CreateWorkflow(int maxTurns) => AgentWorkflowBuilder .CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns }) .AddParticipants(new HelloAgent(), new EchoAgent()) @@ -25,9 +25,9 @@ internal static class Step6EntryPoint public static async ValueTask RunAsync(TextWriter writer, int maxSteps = 2) { - Workflow> workflow = CreateWorkflow(maxSteps); + Workflow workflow = CreateWorkflow(maxSteps); - StreamingRun run = await InProcessExecution.StreamAsync(workflow, []) + StreamingRun run = await InProcessExecution.StreamAsync(workflow, Array.Empty()) .ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/07_GroupChat_Workflow_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/07_GroupChat_Workflow_HostAsAgent.cs index a46f1b6660..3387d1cf07 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/07_GroupChat_Workflow_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/07_GroupChat_Workflow_HostAsAgent.cs @@ -13,7 +13,10 @@ internal static class Step7EntryPoint { public static async ValueTask RunAsync(TextWriter writer, int maxSteps = 2) { - Workflow> workflow = Step6EntryPoint.CreateWorkflow(maxSteps); + Workflow> workflow = (await Step6EntryPoint.CreateWorkflow(maxSteps) + .TryPromoteAsync>() + .ConfigureAwait(false))!; + AIAgent agent = workflow.AsAgent("group-chat-agent", "Group Chat Agent"); AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs index ef084f2578..dc32bf8a81 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs @@ -119,6 +119,12 @@ public class SpecializedExecutorSmokeTests public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => default; + public ValueTask YieldOutputAsync(object output) => + default; + + public ValueTask RequestHaltAsync() => + default; + public ValueTask QueueClearScopeAsync(string? scopeName = null) => default; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StreamingAggregatorsTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StreamingAggregatorsTests.cs index 337c4681f0..c225870c8b 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StreamingAggregatorsTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/StreamingAggregatorsTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using FluentAssertions; @@ -9,13 +10,13 @@ namespace Microsoft.Agents.Workflows.UnitTests; public class StreamingAggregatorsTests { private static TResult? ApplyStreamingAggregator( - StreamingAggregator aggregator, + Func aggregator, IEnumerable inputs, TResult? runningResult = default) { foreach (TInput input in inputs) { - runningResult = aggregator(input, runningResult); + runningResult = aggregator(runningResult, input); } return runningResult!; @@ -24,8 +25,8 @@ public class StreamingAggregatorsTests [Fact] public void Test_StreamingAggregators_First() { - IEnumerable inputs = [1, 2, 3]; - StreamingAggregator aggregator = StreamingAggregators.First(); + IEnumerable inputs = [1, 2, 3]; + Func aggregator = StreamingAggregators.First(); int? runningResult = ApplyStreamingAggregator(aggregator, inputs); runningResult.Should().Be(1); @@ -39,8 +40,8 @@ public class StreamingAggregatorsTests [Fact] public void Test_StreamingAggregators_First_WithConversion() { - IEnumerable inputs = [2, 4, 6]; - StreamingAggregator aggregator = StreamingAggregators.First(input => input / 2); + IEnumerable inputs = [2, 4, 6]; + Func aggregator = StreamingAggregators.First(input => input / 2); int? runningResult = ApplyStreamingAggregator(aggregator, inputs); runningResult.Should().Be(1); @@ -55,7 +56,7 @@ public class StreamingAggregatorsTests public void Test_StreamingAggregators_Last() { IEnumerable inputs = [1, 2, 3]; - StreamingAggregator aggregator = StreamingAggregators.Last(); + Func aggregator = StreamingAggregators.Last(); int? runningResult = ApplyStreamingAggregator(aggregator, inputs); runningResult.Should().Be(3); @@ -70,7 +71,7 @@ public class StreamingAggregatorsTests public void Test_StreamingAggregators_Last_WithConversion() { IEnumerable inputs = [2, 4, 6]; - StreamingAggregator aggregator = StreamingAggregators.Last(input => input / 2); + Func aggregator = StreamingAggregators.Last(input => input / 2); int? runningResult = ApplyStreamingAggregator(aggregator, inputs); runningResult.Should().Be(3); @@ -85,7 +86,7 @@ public class StreamingAggregatorsTests public void Test_StreamingAggregators_Union() { IEnumerable inputs = [1, 2, 3]; - StreamingAggregator> aggregator = StreamingAggregators.Union(); + Func?, int, IEnumerable?> aggregator = StreamingAggregators.Union(); IEnumerable? runningResult = ApplyStreamingAggregator(aggregator, inputs); runningResult.Should().BeEquivalentTo([1, 2, 3], "Union should accumulate all inputs in order"); @@ -102,7 +103,7 @@ public class StreamingAggregatorsTests public void Test_StreamingAggregators_Union_WithConversion() { IEnumerable inputs = [2, 4, 6]; - StreamingAggregator> aggregator = StreamingAggregators.Union(input => input / 2); + Func?, int, IEnumerable?> aggregator = StreamingAggregators.Union(input => input / 2); IEnumerable? runningResult = ApplyStreamingAggregator(aggregator, inputs); runningResult.Should().BeEquivalentTo([1, 2, 3], diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/TestRunContext.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/TestRunContext.cs index 33160aa1d9..b3e8a07c82 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/TestRunContext.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/TestRunContext.cs @@ -14,6 +14,12 @@ public class TestRunContext : IRunnerContext public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => runnerContext.AddEventAsync(workflowEvent); + public ValueTask YieldOutputAsync(object output) + => this.AddEventAsync(new WorkflowOutputEvent(output, executorId)); + + public ValueTask RequestHaltAsync() + => this.AddEventAsync(new RequestHaltEvent()); + public ValueTask QueueClearScopeAsync(string? scopeName = null) => default; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/TestingExecutor.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/TestingExecutor.cs index 20873a4008..7eb534a796 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/TestingExecutor.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/TestingExecutor.cs @@ -15,7 +15,7 @@ internal abstract class TestingExecutor : Executor, IDisposable private readonly HashSet _linkedTokens = []; private CancellationTokenSource _internalCts = new(); - protected TestingExecutor(string? id = null, bool loop = false, params Func>[] actions) : base(id) + protected TestingExecutor(string id, bool loop = false, params Func>[] actions) : base(id) { this._loop = loop; this._actions = actions; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ValidationExtensions.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ValidationExtensions.cs index 7a39afedcd..fad9caed09 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ValidationExtensions.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/ValidationExtensions.cs @@ -20,10 +20,12 @@ internal static partial class ValidationExtensions prototype.SinkIds.SequenceEqual(actual.SinkIds); } - public static Expression> CreateValidator(this TypeId prototype) + public static Expression> CreateValidator(this TypeId? prototype) { - return actual => actual.AssemblyName == prototype.AssemblyName && - actual.TypeName == prototype.TypeName; + return actual => (prototype == null && actual == null) + || (prototype != null && actual != null + && actual.AssemblyName == prototype.AssemblyName + && actual.TypeName == prototype.TypeName); } public static Expression> CreateValidator(this ExecutorInfo prototype) diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs index 7e5fdcfb9f..c7924b4ea3 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/WorkflowBuilderSmokeTests.cs @@ -7,14 +7,14 @@ namespace Microsoft.Agents.Workflows.UnitTests; public partial class WorkflowBuilderSmokeTests { - private sealed class NoOpExecutor(string? id = null) : Executor(id) + private sealed class NoOpExecutor(string id) : Executor(id) { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddHandler( (msg, ctx) => ctx.SendMessageAsync(msg)); } - private sealed class SomeOtherNoOpExecutor(string? id = null) : Executor(id) + private sealed class SomeOtherNoOpExecutor(string id) : Executor(id) { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddHandler( @@ -26,7 +26,7 @@ public partial class WorkflowBuilderSmokeTests { Workflow workflow = new WorkflowBuilder("start") .BindExecutor(new NoOpExecutor("start")) - .Build(); + .Build(); workflow.StartExecutorId.Should().Be("start"); @@ -41,7 +41,7 @@ public partial class WorkflowBuilderSmokeTests NoOpExecutor start = new("start"); Workflow workflow = new WorkflowBuilder("start") .AddEdge(start, start) - .Build(); + .Build(); workflow.StartExecutorId.Should().Be("start"); @@ -60,7 +60,7 @@ public partial class WorkflowBuilderSmokeTests { return new WorkflowBuilder("start") .AddEdge(executor1, executor2) - .Build(); + .Build(); }; act.Should().Throw(); @@ -73,7 +73,7 @@ public partial class WorkflowBuilderSmokeTests Workflow workflow = new WorkflowBuilder("start") .AddEdge(executor1, executor1) - .Build(); + .Build(); workflow.StartExecutorId.Should().Be("start");