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");