From 89c8418705e8f29b1c16cde99f3a0d78e6da7b30 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Wed, 10 Sep 2025 11:47:13 -0700 Subject: [PATCH] .NET: Workflow getting started samples in .Net (Second batch) (#650) * Add loop and agents in workflows samples * Add foundry agent and workflow as agent samples * Checkpoint sample WIP * Checkpoint sample 1 Done * Add HIL samples * Fix formatting * Force folder name change step 1 * Force folder name change step 2 * Fix formatting * Fix formatting * Add checkpoint and rehydrate sample * _Foundational * Fix formatting --- dotnet/agent-framework-dotnet.slnx | 26 +- .../CustomAgentExecutors.csproj} | 0 .../Agents/CustomAgentExecutors/Program.cs | 246 ++++++++++++++++++ .../Agents/FoundryAgent/FoundryAgent.csproj | 23 ++ .../Workflows/Agents/FoundryAgent/Program.cs | 82 ++++++ .../Agents/WorkflowAsAnAgent/Program.cs | 90 +++++++ .../WorkflowAsAnAgent.csproj | 24 ++ .../WorkflowAsAnAgent/WorkflowHelper.cs | 95 +++++++ .../CheckpointAndRehydrate.csproj} | 0 .../CheckpointAndRehydrate/Program.cs | 96 +++++++ .../CheckpointAndRehydrate/WorkflowHelper.cs | 165 ++++++++++++ .../CheckpointAndResume.csproj | 16 ++ .../Checkpoint/CheckpointAndResume/Program.cs | 91 +++++++ .../CheckpointAndResume/WorkflowHelper.cs | 165 ++++++++++++ .../CheckpointWithHumanInTheLoop.csproj} | 0 .../CheckpointWithHumanInTheLoop/Program.cs | 138 ++++++++++ .../WorkflowHelper.cs | 110 ++++++++ .../Workflows/Concurrent/Program.cs | 4 +- .../Workflows/Declarative/Program.cs | 6 +- .../HumanInTheLoopBasic.csproj | 16 ++ .../HumanInTheLoopBasic/Program.cs | 86 ++++++ .../HumanInTheLoopBasic/WorkflowHelper.cs | 75 ++++++ .../GettingStarted/Workflows/Loop/Loop.csproj | 16 ++ .../GettingStarted/Workflows/Loop/Program.cs | 139 ++++++++++ .../GettingStarted/Workflows/README.md | 38 ++- .../01_ExecutorsAndEdges.csproj | 16 ++ .../01_ExecutorsAndEdges/Program.cs | 2 +- .../02_Streaming/02_Streaming.csproj | 16 ++ .../02_Streaming/Program.cs | 4 +- .../03_AgentsInWorkflows.csproj | 24 ++ .../03_AgentsInWorkflows/Program.cs | 0 .../Microsoft.Agents.Workflows/Executor.cs | 4 +- ...leteEvent.cs => ExecutorCompletedEvent.cs} | 2 +- ...InvokeEvent.cs => ExecutorInvokedEvent.cs} | 2 +- .../DeclarativeWorkflowTest.cs | 14 +- .../Sample/01_Simple_Workflow_Sequential.cs | 4 +- .../Sample/01a_Simple_Workflow_Sequential.cs | 4 +- .../Sample/02_Simple_Workflow_Condition.cs | 4 +- .../Sample/03_Simple_Workflow_Loop.cs | 4 +- .../04_Simple_Workflow_ExternalRequest.cs | 4 +- .../05_Simple_Workflow_Checkpointing.cs | 2 +- .../Sample/06_GroupChat_Workflow.cs | 4 +- 42 files changed, 1815 insertions(+), 42 deletions(-) rename dotnet/samples/GettingStarted/Workflows/{Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj => Agents/CustomAgentExecutors/CustomAgentExecutors.csproj} (100%) create mode 100644 dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs rename dotnet/samples/GettingStarted/Workflows/{Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj => Checkpoint/CheckpointAndRehydrate/CheckpointAndRehydrate.csproj} (100%) create mode 100644 dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs rename dotnet/samples/GettingStarted/Workflows/{Foundational/02_Streaming/02_Streaming.csproj => Checkpoint/CheckpointWithHumanInTheLoop/CheckpointWithHumanInTheLoop.csproj} (100%) create mode 100644 dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/Loop/Loop.csproj create mode 100644 dotnet/samples/GettingStarted/Workflows/Loop/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj rename dotnet/samples/GettingStarted/Workflows/{Foundational => _Foundational}/01_ExecutorsAndEdges/Program.cs (98%) create mode 100644 dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/02_Streaming.csproj rename dotnet/samples/GettingStarted/Workflows/{Foundational => _Foundational}/02_Streaming/Program.cs (95%) create mode 100644 dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj rename dotnet/samples/GettingStarted/Workflows/{Foundational => _Foundational}/03_AgentsInWorkflows/Program.cs (100%) rename dotnet/src/Microsoft.Agents.Workflows/{ExecutorCompleteEvent.cs => ExecutorCompletedEvent.cs} (76%) rename dotnet/src/Microsoft.Agents.Workflows/{ExecutorInvokeEvent.cs => ExecutorInvokedEvent.cs} (73%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c26e25487e..f7a78e5535 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -83,14 +83,30 @@ - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/Workflows/Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj similarity index 100% rename from dotnet/samples/GettingStarted/Workflows/Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj rename to dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/CustomAgentExecutors.csproj diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs new file mode 100644 index 0000000000..fe1b612603 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs @@ -0,0 +1,246 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace WorkflowCustomAgentExecutorsSample; + +/// +/// This sample demonstrates how to create custom executors for AI agents. +/// This is useful when you want more control over the agent's behaviors in a workflow. +/// +/// In this example, we create two custom executors: +/// 1. SloganWriterExecutor: An AI agent that generates slogans based on a given task. +/// 2. FeedbackExecutor: An AI agent that provides feedback on the generated slogans. +/// (These two executors manage the agent instances and their conversation threads.) +/// +/// The workflow alternates between these two executors until the slogan meets a certain +/// quality threshold or a maximum number of attempts is reached. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - An Azure OpenAI chat completion deployment that supports structured outputs must be configured. +/// +public static class Program +{ + private static async Task Main() + { + // Set up the Azure OpenAI client + var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + + // Create the executors + var sloganWriter = new SloganWriterExecutor(chatClient); + var feedbackProvider = new FeedbackExecutor(chatClient); + + // Build the workflow by adding executors and connecting them + WorkflowBuilder builder = new(sloganWriter); + builder.AddEdge(sloganWriter, feedbackProvider); + builder.AddEdge(feedbackProvider, sloganWriter); + var workflow = builder.Build(); + + // Execute the workflow + var ask = "Create a slogan for a new electric SUV that is affordable and fun to drive."; + StreamingRun run = await InProcessExecution.StreamAsync(workflow, ask); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is SloganGeneratedEvent || evt is FeedbackEvent) + { + // Custom events to allow us to monitor the progress of the workflow. + Console.WriteLine($"{evt}"); + } + + if (evt is WorkflowCompletedEvent completedEvent) + { + Console.WriteLine($"{completedEvent}"); + } + } + } +} + +/// +/// A class representing the output of the slogan writer agent. +/// +public sealed class SloganResult +{ + [JsonPropertyName("task")] + public required string Task { get; set; } + + [JsonPropertyName("slogan")] + public required string Slogan { get; set; } +} + +/// +/// A class representing the output of the feedback agent. +/// +public sealed class FeedbackResult +{ + [JsonPropertyName("comments")] + public string Comments { get; set; } = string.Empty; + + [JsonPropertyName("rating")] + public int Rating { get; set; } + + [JsonPropertyName("actions")] + public string Actions { get; set; } = string.Empty; +} + +/// +/// A custom event to indicate that a slogan has been generated. +/// +internal sealed class SloganGeneratedEvent(SloganResult sloganResult) : WorkflowEvent(sloganResult) +{ + public override string ToString() => $"Slogan: {sloganResult.Slogan}"; +} + +/// +/// A custom executor that uses an AI agent to generate slogans based on a given task. +/// Note that this executor has two message handlers: +/// 1. HandleAsync(string message): Handles the initial task to create a slogan. +/// 2. HandleAsync(Feedback message): Handles feedback to improve the slogan. +/// +internal sealed class SloganWriterExecutor + : ReflectingExecutor, + IMessageHandler, + IMessageHandler +{ + private const string Instruction = """ + You are a professional slogan writer. You will be given a task to create a slogan. + """; + + private readonly AIAgent _agent; + private readonly AgentThread _thread; + + /// + /// Initializes a new instance of the class. + /// + /// The chat client to use for the AI agent. + public SloganWriterExecutor(IChatClient chatClient) + { + var agentOptions = new ChatClientAgentOptions(instructions: Instruction) + { + ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(SloganResult)) + ) + } + }; + + this._agent = new ChatClientAgent(chatClient, agentOptions); + this._thread = this._agent.GetNewThread(); + } + + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + var result = await this._agent.RunAsync(message, this._thread); + + var sloganResult = JsonSerializer.Deserialize(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result."); + + await context.AddEventAsync(new SloganGeneratedEvent(sloganResult)); + return sloganResult; + } + + public async ValueTask HandleAsync(FeedbackResult message, IWorkflowContext context) + { + var feedbackMessage = $""" + Here is the feedback on your previous slogan: + Comments: {message.Comments} + Rating: {message.Rating} + Suggested Actions: {message.Actions} + + Please use this feedback to improve your slogan. + """; + + var result = await this._agent.RunAsync(feedbackMessage, this._thread); + var sloganResult = JsonSerializer.Deserialize(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result."); + + await context.AddEventAsync(new SloganGeneratedEvent(sloganResult)); + return sloganResult; + } +} + +/// +/// A custom event to indicate that feedback has been provided. +/// +internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEvent(feedbackResult) +{ + private readonly JsonSerializerOptions _options = new() { WriteIndented = true }; + public override string ToString() => $"Feedback:\n{JsonSerializer.Serialize(feedbackResult, this._options)}"; +} + +/// +/// A custom executor that uses an AI agent to provide feedback on a slogan. +/// +internal sealed class FeedbackExecutor : ReflectingExecutor, IMessageHandler +{ + private const string Instruction = """ + You are a professional editor. You will be given a slogan and the task it is meant to accomplish. + """; + + private readonly AIAgent _agent; + private readonly AgentThread _thread; + + public int MinimumRating { get; init; } = 8; + + public int MaxAttempts { get; init; } = 3; + + private int _attempts = 0; + + /// + /// Initializes a new instance of the class. + /// + /// The chat client to use for the AI agent. + public FeedbackExecutor(IChatClient chatClient) + { + var agentOptions = new ChatClientAgentOptions(instructions: Instruction) + { + ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(FeedbackResult)) + ) + } + }; + + this._agent = new ChatClientAgent(chatClient, agentOptions); + this._thread = this._agent.GetNewThread(); + } + + public async ValueTask HandleAsync(SloganResult message, IWorkflowContext context) + { + var sloganMessage = $""" + Here is a slogan for the task '{message.Task}': + Slogan: {message.Slogan} + Please provide feedback on this slogan, including comments, a rating from 1 to 10, and suggested actions for improvement. + """; + + var response = await this._agent.RunAsync(sloganMessage, this._thread); + var feedback = JsonSerializer.Deserialize(response.Text) ?? throw new InvalidOperationException("Failed to deserialize feedback."); + + await context.AddEventAsync(new FeedbackEvent(feedback)); + if (feedback.Rating >= this.MinimumRating) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"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}")); + return; + } + + await context.SendMessageAsync(feedback); + this._attempts++; + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj new file mode 100644 index 0000000000..564e214dac --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/FoundryAgent.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs new file mode 100644 index 0000000000..586780f1f3 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Agents.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace WorkflowFoundryAgentSample; + +/// +/// This sample shows how to use Azure Foundry Agents within a workflow. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - An Azure Foundry project endpoint and model id. +/// +public static class Program +{ + private static async Task Main() + { + // Set up the Azure OpenAI client + var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); + var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_MODEL_ID") ?? "gpt-4o-mini"; + var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); + + // Create agents + AIAgent frenchAgent = await GetTranslationAgentAsync("French", persistentAgentsClient, model); + AIAgent spanishAgent = await GetTranslationAgentAsync("Spanish", persistentAgentsClient, model); + AIAgent englishAgent = await GetTranslationAgentAsync("English", persistentAgentsClient, model); + + // Build the workflow by adding executors and connecting them + WorkflowBuilder builder = new(frenchAgent); + builder.AddEdge(frenchAgent, spanishAgent); + builder.AddEdge(spanishAgent, englishAgent); + var workflow = builder.Build(); + + // Execute the workflow + StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!")); + // Must send the turn token to trigger the agents. + // The agents are wrapped as executors. When they receive messages, + // they will cache the messages and only start processing when they receive a TurnToken. + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is AgentRunUpdateEvent executorComplete) + { + Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + + // Cleanup the agents created for the sample. + await persistentAgentsClient.Administration.DeleteAgentAsync(frenchAgent.Id); + await persistentAgentsClient.Administration.DeleteAgentAsync(spanishAgent.Id); + await persistentAgentsClient.Administration.DeleteAgentAsync(englishAgent.Id); + } + + /// + /// Creates a translation agent for the specified target language. + /// + /// The target language for translation + /// The PersistentAgentsClient to create the agent + /// The model to use for the agent + /// A ChatClientAgent configured for the specified language + private static async Task GetTranslationAgentAsync( + string targetLanguage, + PersistentAgentsClient persistentAgentsClient, + string model) + { + string instructions = $"You are a translation assistant that translates the provided text to {targetLanguage}."; + var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync( + model: model, + name: $"{targetLanguage} Translator", + instructions: instructions); + + return await persistentAgentsClient.GetAIAgentAsync(agentMetadata.Value.Id); + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs new file mode 100644 index 0000000000..7611b14984 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace WorkflowAsAnAgentsSample; + +/// +/// This sample introduces the concepts workflows as agents, where a workflow can be +/// treated as an . This allows you to interact with a workflow +/// as if it were a single agent. +/// +/// In this example, we create a workflow that uses two language agents to process +/// input concurrently, one that responds in French and another that responds in English. +/// +/// You will interact with the workflow in an interactive loop, sending messages and receiving +/// streaming responses from the workflow as if it were an agent who responds in both languages. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - This sample uses concurrent processing. +/// - An Azure OpenAI endpoint and deployment name. +/// +public static class Program +{ + private static async Task Main() + { + // Set up the Azure OpenAI client + var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); + var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); + + // Create the workflow and turn it into an agent + var workflow = WorkflowHelper.GetWorkflow(chatClient); + var agent = workflow.AsAgent("workflow-agent", "Workflow Agent"); + var thread = agent.GetNewThread(); + + // Start an interactive loop to interact with the workflow as if it were an agent + while (true) + { + Console.WriteLine(); + Console.Write("User (or 'exit' to quit): "); + string? input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + await ProcessInputAsync(agent, thread, input); + } + + // Helper method to process user input and display streaming responses. To display + // multiple interleaved responses correctly, we buffer updates by message ID and + // re-render all messages on each update. + static async Task ProcessInputAsync(AIAgent agent, AgentThread thread, string input) + { + Dictionary> buffer = []; + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread).ConfigureAwait(false)) + { + if (update.MessageId == null) + { + // skip updates that don't have a message ID + continue; + } + Console.Clear(); + + if (!buffer.TryGetValue(update.MessageId, out List? value)) + { + value = []; + buffer[update.MessageId] = value; + } + value.Add(update); + + foreach (var (messageId, segments) in buffer) + { + string combinedText = string.Concat(segments); + Console.WriteLine($"{segments[0].AuthorName}: {combinedText}"); + Console.WriteLine(); + } + } + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj new file mode 100644 index 0000000000..b1f90ee600 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowAsAnAgent.csproj @@ -0,0 +1,24 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs new file mode 100644 index 0000000000..9470e22c10 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace WorkflowAsAnAgentsSample; + +internal static class WorkflowHelper +{ + /// + /// Creates a workflow that uses two language agents to process input concurrently. + /// + /// The chat client to use for the agents + /// A workflow that processes input using two language agents + internal static Workflow> GetWorkflow(IChatClient chatClient) + { + // Create executors + var startExecutor = new ConcurrentStartExecutor(); + var aggregationExecutor = new ConcurrentAggregationExecutor(); + AIAgent frenchAgent = GetLanguageAgent("French", chatClient); + AIAgent englishAgent = GetLanguageAgent("English", chatClient); + + // Build the workflow by adding executors and connecting them + WorkflowBuilder builder = new(startExecutor); + builder.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent]); + builder.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent]); + + return builder.Build>(); + } + + /// + /// Creates a language agent for the specified target language. + /// + /// The target language for translation + /// The chat client to use for the agent + /// A ChatClientAgent configured for the specified language + private static ChatClientAgent GetLanguageAgent(string targetLanguage, IChatClient chatClient) + { + string instructions = $"You're a helpful assistant who always responds in {targetLanguage}."; + return new ChatClientAgent(chatClient, instructions, name: $"{targetLanguage}Agent"); + } + + /// + /// Executor that starts the concurrent processing by sending messages to the agents. + /// + private sealed class ConcurrentStartExecutor() : + ReflectingExecutor("ConcurrentStartExecutor"), + IMessageHandler> + { + /// + /// Starts the concurrent processing by sending messages to the agents. + /// + /// The user message to process + /// Workflow context for accessing workflow services and adding events + public async ValueTask HandleAsync(List message, IWorkflowContext context) + { + // Broadcast the message to all connected agents. Receiving agents will queue + // the message but will not start processing until they receive a turn token. + await context.SendMessageAsync(message); + // Broadcast the turn token to kick off the agents. + await context.SendMessageAsync(new TurnToken(emitEvents: true)); + } + } + + /// + /// Executor that aggregates the results from the concurrent agents. + /// + private sealed class ConcurrentAggregationExecutor() : + ReflectingExecutor("ConcurrentAggregationExecutor"), + IMessageHandler + { + private readonly List _messages = []; + + /// + /// Handles incoming messages from the agents and aggregates their responses. + /// + /// The message from the agent + /// Workflow context for accessing workflow services and adding events + public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) + { + this._messages.Add(message); + + if (this._messages.Count == 2) + { + var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.Text}")); + await context.AddEventAsync(new WorkflowCompletedEvent(formattedMessages)); + } + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/CheckpointAndRehydrate.csproj similarity index 100% rename from dotnet/samples/GettingStarted/Workflows/Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj rename to dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/CheckpointAndRehydrate.csproj diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs new file mode 100644 index 0000000000..d039b889b8 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; + +namespace WorkflowCheckpointAndRehydrateSample; + +/// +/// This sample introduces the concepts of check points and shows how to save and restore +/// the state of a workflow using checkpoints. +/// This sample demonstrates checkpoints, which allow you to save and restore a workflow's state. +/// Key concepts: +/// - Super Steps: A workflow executes in stages called "super steps". Each super step runs +/// one or more executors and completes when all those executors finish their work. +/// - Checkpoints: The system automatically saves the workflow's state at the end of each +/// super step. You can use these checkpoints to resume the workflow from any saved point. +/// - Rehydration: You can rehydrate a new workflow instance from a saved checkpoint, allowing +/// you to continue execution from that point. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// +public static class Program +{ + private static async Task Main() + { + // Create the workflow + var workflow = WorkflowHelper.GetWorkflow(); + + // Create checkpoint manager + var checkpointManager = new CheckpointManager(); + var checkpoints = new List(); + + // Execute the workflow and save checkpoints + Checkpointed checkpointedRun = await InProcessExecution + .StreamAsync(workflow, NumberSignal.Init, checkpointManager) + .ConfigureAwait(false); + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorCompletedEvent executorCompletedEvt) + { + Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); + } + + if (evt is SuperStepCompletedEvent superStepCompletedEvt) + { + // Checkpoints are automatically created at the end of each super step when a + // checkpoint manager is provided. You can store the checkpoint info for later use. + CheckpointInfo? checkpoint = superStepCompletedEvt.CompletionInfo!.Checkpoint; + if (checkpoint != null) + { + checkpoints.Add(checkpoint); + Console.WriteLine($"** Checkpoint created at step {checkpoints.Count}."); + } + } + + if (evt is WorkflowCompletedEvent workflowCompletedEvt) + { + Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + } + } + + if (checkpoints.Count == 0) + { + throw new InvalidOperationException("No checkpoints were created during the workflow execution."); + } + 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 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); + await foreach (WorkflowEvent evt in newCheckpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorCompletedEvent executorCompletedEvt) + { + Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); + } + + if (evt is WorkflowCompletedEvent workflowCompletedEvt) + { + Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + } + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs new file mode 100644 index 0000000000..09ffc8db74 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; + +namespace WorkflowCheckpointAndRehydrateSample; + +internal static class WorkflowHelper +{ + /// + /// Get a workflow that plays a number guessing game with checkpointing support. + /// The workflow consists of two executors that are connected in a feedback loop: + /// 1. GuessNumberExecutor: Makes a guess based on the current known bounds. + /// 2. JudgeExecutor: Evaluates the guess and provides feedback. + /// The workflow continues until the correct number is guessed. + /// + internal static Workflow GetWorkflow() + { + // Create the executors + GuessNumberExecutor guessNumberExecutor = new(1, 100); + JudgeExecutor judgeExecutor = new(42); + + // Build the workflow by connecting executors in a loop + var workflow = new WorkflowBuilder(guessNumberExecutor) + .AddEdge(guessNumberExecutor, judgeExecutor) + .AddEdge(judgeExecutor, guessNumberExecutor) + .Build(); + + return workflow; + } +} + +/// +/// Signals used for communication between GuessNumberExecutor and JudgeExecutor. +/// +internal enum NumberSignal +{ + Init, + Above, + Below, +} + +/// +/// Executor that makes a guess based on the current bounds. +/// +internal sealed class GuessNumberExecutor() : ReflectingExecutor("Guess"), IMessageHandler +{ + /// + /// The lower bound of the guessing range. + /// + public int LowerBound { get; private set; } + + /// + /// The upper bound of the guessing range. + /// + public int UpperBound { get; private set; } + + private const string StateKey = "GuessNumberExecutorState"; + + /// + /// Initializes a new instance of the class. + /// + /// The initial lower bound of the guessing range. + /// The initial upper bound of the guessing range. + public GuessNumberExecutor(int lowerBound, int upperBound) : this() + { + this.LowerBound = lowerBound; + this.UpperBound = upperBound; + } + + private int NextGuess => (this.LowerBound + this.UpperBound) / 2; + + public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) + { + switch (message) + { + case NumberSignal.Init: + await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + break; + case NumberSignal.Above: + this.UpperBound = this.NextGuess - 1; + await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + break; + case NumberSignal.Below: + this.LowerBound = this.NextGuess + 1; + await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + break; + } + } + + /// + /// Checkpoint the current state of the executor. + /// This must be overridden to save any state that is needed to resume the executor. + /// + protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + return context.QueueStateUpdateAsync(StateKey, (this.LowerBound, this.UpperBound)); + } + + /// + /// Restore the state of the executor from a checkpoint. + /// This must be overridden to restore any state that was saved during checkpointing. + /// + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + (this.LowerBound, this.UpperBound) = await context.ReadStateAsync<(int, int)>(StateKey).ConfigureAwait(false); + } +} + +/// +/// Executor that judges the guess and provides feedback. +/// +internal sealed class JudgeExecutor() : ReflectingExecutor("Judge"), IMessageHandler +{ + private readonly int _targetNumber; + private int _tries = 0; + private const string StateKey = "JudgeExecutorState"; + + /// + /// Initializes a new instance of the class. + /// + /// The number to be guessed. + public JudgeExecutor(int targetNumber) : this() + { + this._targetNumber = targetNumber; + } + + public async ValueTask HandleAsync(int message, IWorkflowContext context) + { + this._tries++; + if (message == this._targetNumber) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) + .ConfigureAwait(false); + } + else if (message < this._targetNumber) + { + await context.SendMessageAsync(NumberSignal.Below).ConfigureAwait(false); + } + else + { + await context.SendMessageAsync(NumberSignal.Above).ConfigureAwait(false); + } + } + + /// + /// Checkpoint the current state of the executor. + /// This must be overridden to save any state that is needed to resume the executor. + /// + protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + return context.QueueStateUpdateAsync(StateKey, this._tries); + } + + /// + /// Restore the state of the executor from a checkpoint. + /// This must be overridden to restore any state that was saved during checkpointing. + /// + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + this._tries = await context.ReadStateAsync(StateKey).ConfigureAwait(false); + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj new file mode 100644 index 0000000000..1025cb45c9 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/CheckpointAndResume.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs new file mode 100644 index 0000000000..ebabba2141 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; + +namespace WorkflowCheckpointAndResumeSample; + +/// +/// This sample introduces the concepts of check points and shows how to save and restore +/// the state of a workflow using checkpoints. +/// This sample demonstrates checkpoints, which allow you to save and restore a workflow's state. +/// Key concepts: +/// - Super Steps: A workflow executes in stages called "super steps". Each super step runs +/// one or more executors and completes when all those executors finish their work. +/// - Checkpoints: The system automatically saves the workflow's state at the end of each +/// super step. You can use these checkpoints to resume the workflow from any saved point. +/// - Resume: If needed, you can restore a checkpoint and continue execution from that state. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// +public static class Program +{ + private static async Task Main() + { + // Create the workflow + var workflow = WorkflowHelper.GetWorkflow(); + + // Create checkpoint manager + var checkpointManager = new CheckpointManager(); + var checkpoints = new List(); + + // Execute the workflow and save checkpoints + Checkpointed checkpointedRun = await InProcessExecution + .StreamAsync(workflow, NumberSignal.Init, checkpointManager) + .ConfigureAwait(false); + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorCompletedEvent executorCompletedEvt) + { + Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); + } + + if (evt is SuperStepCompletedEvent superStepCompletedEvt) + { + // Checkpoints are automatically created at the end of each super step when a + // checkpoint manager is provided. You can store the checkpoint info for later use. + CheckpointInfo? checkpoint = superStepCompletedEvt.CompletionInfo!.Checkpoint; + if (checkpoint != null) + { + checkpoints.Add(checkpoint); + Console.WriteLine($"** Checkpoint created at step {checkpoints.Count}."); + } + } + + if (evt is WorkflowCompletedEvent workflowCompletedEvt) + { + Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + } + } + + if (checkpoints.Count == 0) + { + throw new InvalidOperationException("No checkpoints were created during the workflow execution."); + } + Console.WriteLine($"Number of checkpoints created: {checkpoints.Count}"); + + // Restoring from a checkpoint and resuming execution + var checkpointIndex = 5; + Console.WriteLine($"\n\nRestoring from the {checkpointIndex + 1}th checkpoint."); + CheckpointInfo savedCheckpoint = checkpoints[checkpointIndex]; + // Note that we are restoring the state directly to the same run instance. + await checkpointedRun.RestoreCheckpointAsync(savedCheckpoint, CancellationToken.None).ConfigureAwait(false); + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorCompletedEvent executorCompletedEvt) + { + Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); + } + + if (evt is WorkflowCompletedEvent workflowCompletedEvt) + { + Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + } + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs new file mode 100644 index 0000000000..aa4f5506ac --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; + +namespace WorkflowCheckpointAndResumeSample; + +internal static class WorkflowHelper +{ + /// + /// Get a workflow that plays a number guessing game with checkpointing support. + /// The workflow consists of two executors that are connected in a feedback loop: + /// 1. GuessNumberExecutor: Makes a guess based on the current known bounds. + /// 2. JudgeExecutor: Evaluates the guess and provides feedback. + /// The workflow continues until the correct number is guessed. + /// + internal static Workflow GetWorkflow() + { + // Create the executors + GuessNumberExecutor guessNumberExecutor = new(1, 100); + JudgeExecutor judgeExecutor = new(42); + + // Build the workflow by connecting executors in a loop + var workflow = new WorkflowBuilder(guessNumberExecutor) + .AddEdge(guessNumberExecutor, judgeExecutor) + .AddEdge(judgeExecutor, guessNumberExecutor) + .Build(); + + return workflow; + } +} + +/// +/// Signals used for communication between GuessNumberExecutor and JudgeExecutor. +/// +internal enum NumberSignal +{ + Init, + Above, + Below, +} + +/// +/// Executor that makes a guess based on the current bounds. +/// +internal sealed class GuessNumberExecutor() : ReflectingExecutor("Guess"), IMessageHandler +{ + /// + /// The lower bound of the guessing range. + /// + public int LowerBound { get; private set; } + + /// + /// The upper bound of the guessing range. + /// + public int UpperBound { get; private set; } + + private const string StateKey = "GuessNumberExecutorState"; + + /// + /// Initializes a new instance of the class. + /// + /// The initial lower bound of the guessing range. + /// The initial upper bound of the guessing range. + public GuessNumberExecutor(int lowerBound, int upperBound) : this() + { + this.LowerBound = lowerBound; + this.UpperBound = upperBound; + } + + private int NextGuess => (this.LowerBound + this.UpperBound) / 2; + + public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) + { + switch (message) + { + case NumberSignal.Init: + await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + break; + case NumberSignal.Above: + this.UpperBound = this.NextGuess - 1; + await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + break; + case NumberSignal.Below: + this.LowerBound = this.NextGuess + 1; + await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + break; + } + } + + /// + /// Checkpoint the current state of the executor. + /// This must be overridden to save any state that is needed to resume the executor. + /// + protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + return context.QueueStateUpdateAsync(StateKey, (this.LowerBound, this.UpperBound)); + } + + /// + /// Restore the state of the executor from a checkpoint. + /// This must be overridden to restore any state that was saved during checkpointing. + /// + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + (this.LowerBound, this.UpperBound) = await context.ReadStateAsync<(int, int)>(StateKey).ConfigureAwait(false); + } +} + +/// +/// Executor that judges the guess and provides feedback. +/// +internal sealed class JudgeExecutor() : ReflectingExecutor("Judge"), IMessageHandler +{ + private readonly int _targetNumber; + private int _tries = 0; + private const string StateKey = "JudgeExecutorState"; + + /// + /// Initializes a new instance of the class. + /// + /// The number to be guessed. + public JudgeExecutor(int targetNumber) : this() + { + this._targetNumber = targetNumber; + } + + public async ValueTask HandleAsync(int message, IWorkflowContext context) + { + this._tries++; + if (message == this._targetNumber) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) + .ConfigureAwait(false); + } + else if (message < this._targetNumber) + { + await context.SendMessageAsync(NumberSignal.Below).ConfigureAwait(false); + } + else + { + await context.SendMessageAsync(NumberSignal.Above).ConfigureAwait(false); + } + } + + /// + /// Checkpoint the current state of the executor. + /// This must be overridden to save any state that is needed to resume the executor. + /// + protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + return context.QueueStateUpdateAsync(StateKey, this._tries); + } + + /// + /// Restore the state of the executor from a checkpoint. + /// This must be overridden to restore any state that was saved during checkpointing. + /// + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + this._tries = await context.ReadStateAsync(StateKey).ConfigureAwait(false); + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Foundational/02_Streaming/02_Streaming.csproj b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/CheckpointWithHumanInTheLoop.csproj similarity index 100% rename from dotnet/samples/GettingStarted/Workflows/Foundational/02_Streaming/02_Streaming.csproj rename to dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/CheckpointWithHumanInTheLoop.csproj diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs new file mode 100644 index 0000000000..167cc760d3 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; + +namespace WorkflowCheckpointWithHumanInTheLoopSample; + +/// +/// This sample demonstrates how to create a workflow with human-in-the-loop interaction and +/// checkpointing support. The workflow plays a number guessing game where the user provides +/// guesses based on feedback from the workflow. The workflow state is checkpointed at the end +/// of each super step, allowing it to be restored and resumed later. +/// Each InputPort request and response cycle takes two super steps: +/// 1. The InputPort sends a RequestInfoEvent to request input from the external world. +/// 2. The external world sends a response back to the InputPort. +/// Thus, two checkpoints are created for each human-in-the-loop interaction. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - This sample builds upon the HumanInTheLoopBasic sample. It's recommended to go through that +/// sample first to understand the basics of human-in-the-loop workflows. +/// - This sample also builds upon the CheckpointAndResume sample. It's recommended to +/// go through that sample first to understand the basics of checkpointing and resuming workflows. +/// +public static class Program +{ + private static async Task Main() + { + // Create the workflow + var workflow = WorkflowHelper.GetWorkflow(); + + // Create checkpoint manager + var checkpointManager = new CheckpointManager(); + var checkpoints = new List(); + + // Execute the workflow and save checkpoints + Checkpointed checkpointedRun = await InProcessExecution + .StreamAsync(workflow, new SignalWithNumber(NumberSignal.Init), checkpointManager) + .ConfigureAwait(false); + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case RequestInfoEvent requestInputEvt: + // Handle `RequestInfoEvent` from the workflow + ExternalResponse response = HandleExternalRequest(requestInputEvt.Request); + await checkpointedRun.Run.SendResponseAsync(response).ConfigureAwait(false); + break; + case ExecutorCompletedEvent executorCompletedEvt: + Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); + break; + case SuperStepCompletedEvent superStepCompletedEvt: + // Checkpoints are automatically created at the end of each super step when a + // checkpoint manager is provided. You can store the checkpoint info for later use. + CheckpointInfo? checkpoint = superStepCompletedEvt.CompletionInfo!.Checkpoint; + if (checkpoint != null) + { + checkpoints.Add(checkpoint); + Console.WriteLine($"** Checkpoint created at step {checkpoints.Count}."); + } + break; + case WorkflowCompletedEvent workflowCompletedEvt: + Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + break; + } + } + + if (checkpoints.Count == 0) + { + throw new InvalidOperationException("No checkpoints were created during the workflow execution."); + } + Console.WriteLine($"Number of checkpoints created: {checkpoints.Count}"); + + // Restoring from a checkpoint and resuming execution + var checkpointIndex = 1; + Console.WriteLine($"\n\nRestoring from the {checkpointIndex + 1}th checkpoint."); + CheckpointInfo savedCheckpoint = checkpoints[checkpointIndex]; + // Note that we are restoring the state directly to the same run instance. + await checkpointedRun.RestoreCheckpointAsync(savedCheckpoint, CancellationToken.None).ConfigureAwait(false); + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case RequestInfoEvent requestInputEvt: + // Handle `RequestInfoEvent` from the workflow + ExternalResponse response = HandleExternalRequest(requestInputEvt.Request); + await checkpointedRun.Run.SendResponseAsync(response).ConfigureAwait(false); + break; + case ExecutorCompletedEvent executorCompletedEvt: + Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); + break; + case WorkflowCompletedEvent workflowCompletedEvt: + Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}"); + break; + } + } + } + + private static ExternalResponse HandleExternalRequest(ExternalRequest request) + { + if (request.Port.Request == typeof(SignalWithNumber)) + { + var signal = (SignalWithNumber)request.Data; + switch (signal.Signal) + { + case NumberSignal.Init: + int initialGuess = ReadIntegerFromConsole("Please provide your initial guess: "); + return request.CreateResponse(initialGuess); + case NumberSignal.Above: + int lowerGuess = ReadIntegerFromConsole($"You previously guessed {signal.Number} too large. Please provide a new guess: "); + return request.CreateResponse(lowerGuess); + case NumberSignal.Below: + int higherGuess = ReadIntegerFromConsole($"You previously guessed {signal.Number} too small. Please provide a new guess: "); + return request.CreateResponse(higherGuess); + } + } + + throw new NotSupportedException($"Request {request.Port.Request} is not supported"); + } + + private static int ReadIntegerFromConsole(string prompt) + { + while (true) + { + Console.Write(prompt); + string? input = Console.ReadLine(); + if (int.TryParse(input, out int value)) + { + return value; + } + Console.WriteLine("Invalid input. Please enter a valid integer."); + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs new file mode 100644 index 0000000000..a3e81914b7 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; + +namespace WorkflowCheckpointWithHumanInTheLoopSample; + +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() + { + // Create the executors + InputPort numberInputPort = InputPort.Create("GuessNumber"); + JudgeExecutor judgeExecutor = new(42); + + // Build the workflow by connecting executors in a loop + var workflow = new WorkflowBuilder(numberInputPort) + .AddEdge(numberInputPort, judgeExecutor) + .AddEdge(judgeExecutor, numberInputPort) + .Build(); + + return workflow; + } +} + +/// +/// Signals indicating if the guess was too high, too low, or an initial guess. +/// +internal enum NumberSignal +{ + Init, + Above, + Below, +} + +/// +/// Signals used for communication between guesses and the JudgeExecutor. +/// +internal sealed class SignalWithNumber +{ + public NumberSignal Signal { get; } + public int? Number { get; } + + public SignalWithNumber(NumberSignal signal, int? number = null) + { + this.Signal = signal; + this.Number = number; + } +} + +/// +/// Executor that judges the guess and provides feedback. +/// +internal sealed class JudgeExecutor() : ReflectingExecutor("Judge"), IMessageHandler +{ + private readonly int _targetNumber; + private int _tries = 0; + private const string StateKey = "JudgeExecutorState"; + + /// + /// Initializes a new instance of the class. + /// + /// The number to be guessed. + public JudgeExecutor(int targetNumber) : this() + { + this._targetNumber = targetNumber; + } + + public async ValueTask HandleAsync(int message, IWorkflowContext context) + { + this._tries++; + if (message == this._targetNumber) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) + .ConfigureAwait(false); + } + else if (message < this._targetNumber) + { + await context.SendMessageAsync(new SignalWithNumber(NumberSignal.Below, message)).ConfigureAwait(false); + } + else + { + await context.SendMessageAsync(new SignalWithNumber(NumberSignal.Above, message)).ConfigureAwait(false); + } + } + + /// + /// Checkpoint the current state of the executor. + /// This must be overridden to save any state that is needed to resume the executor. + /// + protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + return context.QueueStateUpdateAsync(StateKey, this._tries); + } + + /// + /// Restore the state of the executor from a checkpoint. + /// This must be overridden to restore any state that was saved during checkpointing. + /// + protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellation = default) + { + this._tries = await context.ReadStateAsync(StateKey).ConfigureAwait(false); + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Program.cs index 2c3e1594c6..b6e6cfcc00 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Program.cs @@ -85,7 +85,7 @@ internal sealed class ConcurrentStartExecutor() : /// /// The user message to process /// Workflow context for accessing workflow services and adding events - /// + /// A task representing the asynchronous operation public async ValueTask HandleAsync(string message, IWorkflowContext context) { // Broadcast the message to all connected agents. Receiving agents will queue @@ -110,7 +110,7 @@ internal sealed class ConcurrentAggregationExecutor() : /// /// The message from the agent /// Workflow context for accessing workflow services and adding events - /// + /// A task representing the asynchronous operation public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) { this._messages.Add(message); diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs index 78e582dfc9..ffacf3123a 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/Program.cs @@ -92,13 +92,13 @@ internal sealed class Program await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is ExecutorInvokeEvent executorInvoked) + if (evt is ExecutorInvokedEvent executorInvoked) { Debug.WriteLine($"EXECUTOR ENTER #{executorInvoked.ExecutorId}"); } - else if (evt is ExecutorCompleteEvent executorComplete) + else if (evt is ExecutorCompletedEvent executorCompleted) { - Debug.WriteLine($"EXECUTOR EXIT #{executorComplete.ExecutorId}"); + Debug.WriteLine($"EXECUTOR EXIT #{executorCompleted.ExecutorId}"); } if (evt is DeclarativeActionInvokeEvent actionInvoked) { diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj new file mode 100644 index 0000000000..bbee070910 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/HumanInTheLoopBasic.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs new file mode 100644 index 0000000000..07ffe8c06d --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; + +namespace WorkflowHumanInTheLoopBasicSample; + +/// +/// This sample introduces the concept of InputPort and ExternalRequest to enable +/// human-in-the-loop interaction scenarios. +/// An input port can be used as if it were an executor in the workflow graph. Upon receiving +/// a message, the input port generates an RequestInfoEvent that gets emitted to the external world. +/// The external world can then respond to the request by sending an ExternalResponse back to +/// the workflow. +/// The sample implements a simple number guessing game where the external user tries to guess +/// a pre-defined target number. The workflow consists of a single JudgeExecutor that judges +/// the user's guesses and provides feedback. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// +public static class Program +{ + private static async Task Main() + { + // Create the workflow + var workflow = WorkflowHelper.GetWorkflow(); + + // Execute the workflow + StreamingRun handle = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); + await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + { + switch (evt) + { + case RequestInfoEvent requestInputEvt: + // Handle `RequestInfoEvent` from the workflow + ExternalResponse response = HandleExternalRequest(requestInputEvt.Request); + await handle.SendResponseAsync(response).ConfigureAwait(false); + break; + + case WorkflowCompletedEvent workflowCompleteEvt: + // The workflow has completed successfully + Console.WriteLine($"Workflow completed with result: {workflowCompleteEvt.Data}"); + return; + } + } + } + + private static ExternalResponse HandleExternalRequest(ExternalRequest request) + { + if (request.Port.Request == typeof(NumberSignal)) + { + var signal = (NumberSignal)request.Data; + switch (signal) + { + case NumberSignal.Init: + int initialGuess = ReadIntegerFromConsole("Please provide your initial guess: "); + return request.CreateResponse(initialGuess); + case NumberSignal.Above: + int lowerGuess = ReadIntegerFromConsole("You previously guessed too large. Please provide a new guess: "); + return request.CreateResponse(lowerGuess); + case NumberSignal.Below: + int higherGuess = ReadIntegerFromConsole("You previously guessed too small. Please provide a new guess: "); + return request.CreateResponse(higherGuess); + } + } + + throw new NotSupportedException($"Request {request.Port.Request} is not supported"); + } + + private static int ReadIntegerFromConsole(string prompt) + { + while (true) + { + Console.Write(prompt); + string? input = Console.ReadLine(); + if (int.TryParse(input, out int value)) + { + return value; + } + Console.WriteLine("Invalid input. Please enter a valid integer."); + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs new file mode 100644 index 0000000000..7e11a4b39d --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; + +namespace WorkflowHumanInTheLoopBasicSample; + +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() + { + // Create the executors + InputPort numberInputPort = InputPort.Create("GuessNumber"); + JudgeExecutor judgeExecutor = new(42); + + // Build the workflow by connecting executors in a loop + var workflow = new WorkflowBuilder(numberInputPort) + .AddEdge(numberInputPort, judgeExecutor) + .AddEdge(judgeExecutor, numberInputPort) + .Build(); + + return workflow; + } +} + +/// +/// Signals used for communication between guesses and the JudgeExecutor. +/// +internal enum NumberSignal +{ + Init, + Above, + Below, +} + +/// +/// Executor that judges the guess and provides feedback. +/// +internal sealed class JudgeExecutor() : ReflectingExecutor("Judge"), IMessageHandler +{ + private readonly int _targetNumber; + private int _tries = 0; + + /// + /// Initializes a new instance of the class. + /// + /// The number to be guessed. + public JudgeExecutor(int targetNumber) : this() + { + this._targetNumber = targetNumber; + } + + public async ValueTask HandleAsync(int message, IWorkflowContext context) + { + this._tries++; + if (message == this._targetNumber) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) + .ConfigureAwait(false); + } + else if (message < this._targetNumber) + { + await context.SendMessageAsync(NumberSignal.Below).ConfigureAwait(false); + } + else + { + await context.SendMessageAsync(NumberSignal.Above).ConfigureAwait(false); + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/Loop/Loop.csproj b/dotnet/samples/GettingStarted/Workflows/Loop/Loop.csproj new file mode 100644 index 0000000000..75b48d7b65 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Loop/Loop.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs b/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs new file mode 100644 index 0000000000..97a21b8268 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; + +namespace WorkflowLoopSample; + +/// +/// This sample demonstrates a simple number guessing game using a workflow with looping behavior. +/// +/// The workflow consists of two executors that are connected in a feedback loop: +/// 1. GuessNumberExecutor: Makes a guess based on the current known bounds. +/// 2. JudgeExecutor: Evaluates the guess and provides feedback. +/// The workflow continues until the correct number is guessed. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// +public static class Program +{ + private static async Task Main() + { + // Create the executors + GuessNumberExecutor guessNumberExecutor = new(1, 100); + JudgeExecutor judgeExecutor = new(42); + + // Build the workflow by connecting executors in a loop + var workflow = new WorkflowBuilder(guessNumberExecutor) + .AddEdge(guessNumberExecutor, judgeExecutor) + .AddEdge(judgeExecutor, guessNumberExecutor) + .Build(); + + // 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) + { + Console.WriteLine($"Result: {workflowCompleteEvt}"); + } + } + } +} + +/// +/// Signals used for communication between GuessNumberExecutor and JudgeExecutor. +/// +internal enum NumberSignal +{ + Init, + Above, + Below, +} + +/// +/// Executor that makes a guess based on the current bounds. +/// +internal sealed class GuessNumberExecutor : ReflectingExecutor, IMessageHandler +{ + /// + /// The lower bound of the guessing range. + /// + public int LowerBound { get; private set; } + + /// + /// The upper bound of the guessing range. + /// + public int UpperBound { get; private set; } + + /// + /// Initializes a new instance of the class. + /// + /// The initial lower bound of the guessing range. + /// The initial upper bound of the guessing range. + public GuessNumberExecutor(int lowerBound, int upperBound) + { + this.LowerBound = lowerBound; + this.UpperBound = upperBound; + } + + private int NextGuess => (this.LowerBound + this.UpperBound) / 2; + + public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) + { + switch (message) + { + case NumberSignal.Init: + await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + break; + case NumberSignal.Above: + this.UpperBound = this.NextGuess - 1; + await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + break; + case NumberSignal.Below: + this.LowerBound = this.NextGuess + 1; + await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + break; + } + } +} + +/// +/// Executor that judges the guess and provides feedback. +/// +internal sealed class JudgeExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly int _targetNumber; + private int _tries = 0; + + /// + /// Initializes a new instance of the class. + /// + /// The number to be guessed. + public JudgeExecutor(int targetNumber) + { + this._targetNumber = targetNumber; + } + + public async ValueTask HandleAsync(int message, IWorkflowContext context) + { + this._tries++; + if (message == this._targetNumber) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!")) + .ConfigureAwait(false); + } + else if (message < this._targetNumber) + { + await context.SendMessageAsync(NumberSignal.Below).ConfigureAwait(false); + } + else + { + await context.SendMessageAsync(NumberSignal.Above).ConfigureAwait(false); + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflows/README.md b/dotnet/samples/GettingStarted/Workflows/README.md index 13dda87e01..55ffa1a2df 100644 --- a/dotnet/samples/GettingStarted/Workflows/README.md +++ b/dotnet/samples/GettingStarted/Workflows/README.md @@ -6,24 +6,40 @@ The getting started with workflow samples demonstrate the fundamental concepts a ### Foundational Concepts - Start Here -Please begin with the [Foundational](./Foundational) samples in order. These three samples introduce the core concepts of executors, edges, agents in workflows, streaming, and workflow construction. +Please begin with the [Foundational](./_Foundational) samples in order. These three samples introduce the core concepts of executors, edges, agents in workflows, streaming, and workflow construction. + +> The folder name starts with an underscore (`_Foundational`) to ensure it appears first in the explorer view. | Sample | Concepts | |--------|----------| -| [Executors and Edges](./Foundational/01_ExecutorsAndEdges) | Minimal workflow with basic executors and edges | -| [Streaming](./Foundational/02_Streaming) | Extends workflows with event streaming | -| [Agents](./Foundational/03_AgentsInWorkflows) | Use agents in workflows | +| [Executors and Edges](./_Foundational/01_ExecutorsAndEdges) | Minimal workflow with basic executors and edges | +| [Streaming](./_Foundational/02_Streaming) | Extends workflows with event streaming | +| [Agents](./_Foundational/03_AgentsInWorkflows) | Use agents in workflows | Once completed, please proceed to other samples listed below. > Note that you don't need to follow a strict order after the foundational samples. However, some samples build upon concepts from previous ones, so it's beneficial to be aware of the dependencies. +### Agents + +| Sample | Concepts | +|--------|----------| +| [Foundry Agents in Workflows](./Agents/FoundryAgent) | Demonstrates using Azure Foundry Agents within a workflow | +| [Custom Agent Executors](./Agents/CustomAgentExecutors) | Shows how to create a custom agent executor for more complex scenarios | +| [Workflow as an Agent](./Agents/WorkflowAsAgent) | Illustrates how to encapsulate a workflow as an agent | + ### Concurrent Execution | Sample | Concepts | |--------|----------| | [Fan-Out and Fan-In](./Concurrent) | Introduces parallel processing with fan-out and fan-in patterns | +### Loop + +| Sample | Concepts | +|--------|----------| +| [Looping](./Loop) | Shows how to create a loop within a workflow | + ### Workflow Shared States | Sample | Concepts | @@ -40,10 +56,22 @@ Once completed, please proceed to other samples listed below. > These 3 samples build upon each other. It's recommended to explore them in sequence to fully grasp the concepts. - ### Declarative Workflows | Sample | Concepts | |--------|----------| | [DeclarativeWorkflow](./DeclarativeWorkflow) | Demonstrates execution of declartive workflows. | +### Checkpointing + +| Sample | Concepts | +|--------|----------| +| [Checkpoint and Resume](./Checkpoint/CheckpointAndResume) | Introduces checkpoints for saving and restoring workflow state for time travel purposes | +| [Checkpoint and Rehydrate](./Checkpoint/CheckpointAndRehydrate) | Demonstrates hydrating a new workflow instance from a saved checkpoint | +| [Checkpoint with Human-in-the-Loop](./Checkpoint/CheckpointWithHumanInTheLoop) | Combines checkpointing with human-in-the-loop interactions | + +### Human-in-the-Loop + +| Sample | Concepts | +|--------|----------| +| [Basic Human-in-the-Loop](./HumanInTheLoop/HumanIntheLoopBasic) | Introduces human-in-the-loop interaction using input ports and external requests | diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj new file mode 100644 index 0000000000..bbee070910 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Foundational/01_ExecutorsAndEdges/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs similarity index 98% rename from dotnet/samples/GettingStarted/Workflows/Foundational/01_ExecutorsAndEdges/Program.cs rename to dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs index 00682a60ef..85fd569605 100644 --- a/dotnet/samples/GettingStarted/Workflows/Foundational/01_ExecutorsAndEdges/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs @@ -35,7 +35,7 @@ public static class Program Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!"); foreach (WorkflowEvent evt in run.NewEvents) { - if (evt is ExecutorCompleteEvent executorComplete) + if (evt is ExecutorCompletedEvent executorComplete) { Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/02_Streaming.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/02_Streaming.csproj new file mode 100644 index 0000000000..bbee070910 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/02_Streaming.csproj @@ -0,0 +1,16 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Foundational/02_Streaming/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs similarity index 95% rename from dotnet/samples/GettingStarted/Workflows/Foundational/02_Streaming/Program.cs rename to dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs index 01bd694a5d..ce0db4a7fd 100644 --- a/dotnet/samples/GettingStarted/Workflows/Foundational/02_Streaming/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs @@ -34,9 +34,9 @@ public static class Program StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Hello, World!"); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is ExecutorCompleteEvent executorComplete) + if (evt is ExecutorCompletedEvent executorCompleted) { - Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + Console.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}"); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj new file mode 100644 index 0000000000..b1f90ee600 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj @@ -0,0 +1,24 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Foundational/03_AgentsInWorkflows/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs similarity index 100% rename from dotnet/samples/GettingStarted/Workflows/Foundational/03_AgentsInWorkflows/Program.cs rename to dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs diff --git a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs index c3e704ed69..8b813c08c6 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Executor.cs @@ -67,7 +67,7 @@ public abstract class Executor : IIdentified /// An exception is generated while handling the message. public async ValueTask ExecuteAsync(object message, Type messageType, IWorkflowContext context) { - await context.AddEventAsync(new ExecutorInvokeEvent(this.Id, message)).ConfigureAwait(false); + await context.AddEventAsync(new ExecutorInvokedEvent(this.Id, message)).ConfigureAwait(false); CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) .ConfigureAwait(false); @@ -75,7 +75,7 @@ public abstract class Executor : IIdentified ExecutorEvent executionResult; if (result == null || result.IsSuccess) { - executionResult = new ExecutorCompleteEvent(this.Id, result?.Result); + executionResult = new ExecutorCompletedEvent(this.Id, result?.Result); } else { diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorCompleteEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorCompletedEvent.cs similarity index 76% rename from dotnet/src/Microsoft.Agents.Workflows/ExecutorCompleteEvent.cs rename to dotnet/src/Microsoft.Agents.Workflows/ExecutorCompletedEvent.cs index 01a4de058e..43472af7e0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorCompleteEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorCompletedEvent.cs @@ -7,4 +7,4 @@ namespace Microsoft.Agents.Workflows; /// /// The unique identifier of the executor that has completed. /// The result produced by the executor upon completion, or null if no result is available. -public sealed class ExecutorCompleteEvent(string executorId, object? result) : ExecutorEvent(executorId, data: result); +public sealed class ExecutorCompletedEvent(string executorId, object? result) : ExecutorEvent(executorId, data: result); diff --git a/dotnet/src/Microsoft.Agents.Workflows/ExecutorInvokeEvent.cs b/dotnet/src/Microsoft.Agents.Workflows/ExecutorInvokedEvent.cs similarity index 73% rename from dotnet/src/Microsoft.Agents.Workflows/ExecutorInvokeEvent.cs rename to dotnet/src/Microsoft.Agents.Workflows/ExecutorInvokedEvent.cs index 022245924b..444da140e3 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/ExecutorInvokeEvent.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/ExecutorInvokedEvent.cs @@ -7,4 +7,4 @@ namespace Microsoft.Agents.Workflows; /// /// The unique identifier of the executor being invoked. /// The invocation message. -public sealed class ExecutorInvokeEvent(string executorId, object message) : ExecutorEvent(executorId, data: message); +public sealed class ExecutorInvokedEvent(string executorId, object message) : ExecutorEvent(executorId, data: message); diff --git a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 701bdf04d1..fc7ee92cbf 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -208,20 +208,20 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow private void AssertExecutionCount(int expectedCount) { - Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorInvokeEvent)]); - Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorCompleteEvent)]); + Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorInvokedEvent)]); + Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorCompletedEvent)]); } private void AssertNotExecuted(string executorId) { - Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); - Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + Assert.DoesNotContain(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); } private void AssertExecuted(string executorId) { - Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); - Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); + Assert.Contains(this.WorkflowEvents.OfType(), e => e.ExecutorId == executorId); } private void AssertMessage(string message) @@ -244,7 +244,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow this.WorkflowEvents = run.WatchStreamAsync().ToEnumerable().ToImmutableList(); foreach (WorkflowEvent workflowEvent in this.WorkflowEvents) { - if (workflowEvent is ExecutorInvokeEvent invokeEvent) + if (workflowEvent is ExecutorInvokedEvent invokeEvent) { DeclarativeExecutorResult? message = invokeEvent.Data as DeclarativeExecutorResult; this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]"); 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 229e6c3b39..169c805348 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 @@ -28,9 +28,9 @@ internal static class Step1EntryPoint await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is ExecutorCompleteEvent executorComplete) + if (evt is ExecutorCompletedEvent executorCompleted) { - writer.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + writer.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}"); } } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs index cf78998bf5..62b7ead009 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs @@ -17,9 +17,9 @@ internal static class Step1aEntryPoint foreach (WorkflowEvent evt in run.NewEvents) { - if (evt is ExecutorCompleteEvent executorComplete) + if (evt is ExecutorCompletedEvent executorCompleted) { - writer.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + writer.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}"); } } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 25a726c893..9a5cbf64c0 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -39,8 +39,8 @@ internal static class Step2EntryPoint string workflowResult = workflowCompleteEvt.Data!.ToString()!; writer.WriteLine($"Result: {workflowResult}"); return workflowResult; - case ExecutorCompleteEvent executorCompleteEvt: - writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + case ExecutorCompletedEvent executorCompletedEvt: + writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}"); break; } } diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index d886f2a6c7..b56efaff50 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -37,8 +37,8 @@ internal static class Step3EntryPoint string workflowResult = workflowCompleteEvt.Data!.ToString()!; writer.WriteLine($"Result: {workflowResult}"); return workflowResult; - case ExecutorCompleteEvent executorCompleteEvt: - writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + case ExecutorCompletedEvent executorCompletedEvt: + writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}"); break; } } 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 1f078ecd29..5f94475cb3 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 @@ -46,8 +46,8 @@ internal static class Step4EntryPoint string workflowResult = workflowCompleteEvt.Data!.ToString()!; writer.WriteLine($"Result: {workflowResult}"); return workflowResult; - case ExecutorCompleteEvent executorCompleteEvt: - writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); + case ExecutorCompletedEvent executorCompletedEvt: + writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}"); break; } } 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 7658871f79..9093645810 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 @@ -85,7 +85,7 @@ internal static class Step5EntryPoint string workflowResult = workflowCompleteEvt.Data!.ToString()!; writer.WriteLine($"Result: {workflowResult}"); return workflowResult; - case ExecutorCompleteEvent executorCompleteEvt: + case ExecutorCompletedEvent executorCompleteEvt: writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}"); break; } 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 df818a932c..47e5128cd9 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 @@ -37,9 +37,9 @@ internal static class Step6EntryPoint await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { - if (evt is ExecutorCompleteEvent executorComplete) + if (evt is ExecutorCompletedEvent executorCompleted) { - Debug.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + Debug.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}"); } else if (evt is AgentRunUpdateEvent update) {