From cd0587c5f6e05997803b7baf86aeeaa690624682 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Fri, 5 Sep 2025 12:10:00 -0700 Subject: [PATCH] .NET: Workflow getting started samples in .Net (First batch) (#554) * First batch of workflow getting started samples in .Net * Add Switch Case Edge group * Add multi selection group * Add comments * Add readme * Fix formatting * clean project file * WIP: Revise sample structures * Update readme * Update readme * Fix file encoding * Update sample format * Address comments * Fix formatting * Remove unwanted file * Fix unit tests * Remove unwanted file --- dotnet/agent-framework-dotnet.slnx | 23 +- .../Workflow/Concurrent/Concurrent.csproj | 24 + .../Workflow/Concurrent/Program.cs | 124 +++++ .../01_EdgeCondition/01_EdgeCondition.csproj | 31 ++ .../01_EdgeCondition/Program.cs | 280 +++++++++++ .../01_EdgeCondition/Resources.cs | 15 + .../02_SwitchCase/02_SwitchCase.csproj | 31 ++ .../ConditionalEdges/02_SwitchCase/Program.cs | 326 +++++++++++++ .../02_SwitchCase/Resources.cs | 15 + .../03_MultiSelection.csproj | 31 ++ .../03_MultiSelection/Program.cs | 450 ++++++++++++++++++ .../03_MultiSelection/Resources.cs | 15 + .../01_ExecutorsAndEdges.csproj | 16 + .../01_ExecutorsAndEdges/Program.cs | 88 ++++ .../02_Streaming/02_Streaming.csproj | 16 + .../Foundational/02_Streaming/Program.cs | 87 ++++ .../03_AgentsInWorkflows.csproj | 24 + .../03_AgentsInWorkflows/Program.cs | 74 +++ .../samples/GettingStarted/Workflow/README.md | 41 ++ .../Workflow/Resources/Lorem_Ipsum.txt | 9 + .../Workflow/Resources/ambiguous_email.txt | 19 + .../Workflow/Resources/email.txt | 18 + .../Workflow/Resources/spam.txt | 25 + .../Workflow/SharedStates/Program.cs | 122 +++++ .../Workflow/SharedStates/Resources.cs | 15 + .../Workflow/SharedStates/SharedStates.csproj | 23 + .../Specialized/AIAgentHostExecutor.cs | 6 + .../Sample/06_GroupChat_Workflow.cs | 2 + .../SpecializedExecutorSmokeTests.cs | 7 +- 29 files changed, 1952 insertions(+), 5 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflow/Concurrent/Concurrent.csproj create mode 100644 dotnet/samples/GettingStarted/Workflow/Concurrent/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj create mode 100644 dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/Resources.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj create mode 100644 dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/Resources.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj create mode 100644 dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/Resources.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj create mode 100644 dotnet/samples/GettingStarted/Workflow/Foundational/01_ExecutorsAndEdges/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/Foundational/02_Streaming/02_Streaming.csproj create mode 100644 dotnet/samples/GettingStarted/Workflow/Foundational/02_Streaming/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj create mode 100644 dotnet/samples/GettingStarted/Workflow/Foundational/03_AgentsInWorkflows/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/README.md create mode 100644 dotnet/samples/GettingStarted/Workflow/Resources/Lorem_Ipsum.txt create mode 100644 dotnet/samples/GettingStarted/Workflow/Resources/ambiguous_email.txt create mode 100644 dotnet/samples/GettingStarted/Workflow/Resources/email.txt create mode 100644 dotnet/samples/GettingStarted/Workflow/Resources/spam.txt create mode 100644 dotnet/samples/GettingStarted/Workflow/SharedStates/Program.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/SharedStates/Resources.cs create mode 100644 dotnet/samples/GettingStarted/Workflow/SharedStates/SharedStates.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index cd77622784..8d550c3031 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -48,10 +48,29 @@ - + + + + + + + + + + + + + + + + + + + + - + diff --git a/dotnet/samples/GettingStarted/Workflow/Concurrent/Concurrent.csproj b/dotnet/samples/GettingStarted/Workflow/Concurrent/Concurrent.csproj new file mode 100644 index 0000000000..a9b76d86ce --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/Concurrent/Concurrent.csproj @@ -0,0 +1,24 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflow/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflow/Concurrent/Program.cs new file mode 100644 index 0000000000..2c3e1594c6 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/Concurrent/Program.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +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 WorkflowConcurrentSample; + +/// +/// This sample introduces concurrent execution using "fan-out" and "fan-in" patterns. +/// +/// Unlike sequential workflows where executors run one after another, this workflow +/// runs multiple executors in parallel to process the same input simultaneously. +/// +/// The workflow structure: +/// 1. StartExecutor sends the same question to two AI agents concurrently (fan-out) +/// 2. Physicist Agent and Chemist Agent answer independently and in parallel +/// 3. AggregationExecutor collects both responses and combines them (fan-in) +/// +/// This pattern is useful when you want multiple perspectives on the same input, +/// or when you can break work into independent parallel tasks for better performance. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - An Azure OpenAI chat completion deployment 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 + ChatClientAgent physicist = new( + chatClient, + name: "Physicist", + instructions: "You are an expert in physics. You answer questions from a physics perspective." + ); + ChatClientAgent chemist = new( + chatClient, + name: "Chemist", + instructions: "You are an expert in chemistry. You answer questions from a chemistry perspective." + ); + var startExecutor = new ConcurrentStartExecutor(); + var aggregationExecutor = new ConcurrentAggregationExecutor(); + + // Build the workflow by adding executors and connecting them + WorkflowBuilder builder = new(startExecutor); + builder.AddFanOutEdge(startExecutor, targets: [physicist, chemist]); + builder.AddFanInEdge(aggregationExecutor, sources: [physicist, chemist]); + var workflow = builder.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) + { + Console.WriteLine($"Workflow completed with results:\n{completed.Data}"); + } + } + } +} + +/// +/// Executor that starts the concurrent processing by sending messages to the agents. +/// +internal 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(string 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(new ChatMessage(ChatRole.User, 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. +/// +internal 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.AuthorName}: {m.Text}")); + await context.AddEventAsync(new WorkflowCompletedEvent(formattedMessages)); + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj new file mode 100644 index 0000000000..dcee9dcd7f --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/01_EdgeCondition.csproj @@ -0,0 +1,31 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + + + + + + + + + + + Always + Resources\%(Filename)%(Extension) + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/Program.cs b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/Program.cs new file mode 100644 index 0000000000..045228f68e --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/Program.cs @@ -0,0 +1,280 @@ +// 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 WorkflowEdgeConditionSample; + +/// +/// This sample introduces conditional routing using edge conditions to create decision-based workflows. +/// +/// This workflow creates an automated email response system that routes emails down different paths based +/// on spam detection results: +/// +/// 1. Spam Detection Agent analyzes incoming emails and classifies them as spam or legitimate +/// 2. Based on the classification: +/// - Legitimate emails → Email Assistant Agent → Send Email Executor +/// - Spam emails → Handle Spam Executor (marks as spam) +/// +/// Edge conditions enable workflows to make intelligent routing decisions, allowing you to +/// build sophisticated automation that responds differently based on the data being processed. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - Shared state is used in this sample to persist email data between executors. +/// - 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 agents + AIAgent spamDetectionAgent = GetSpamDetectionAgent(chatClient); + AIAgent emailAssistantAgent = GetEmailAssistantAgent(chatClient); + + // Create executors + var spamDetectionExecutor = new SpamDetectionExecutor(spamDetectionAgent); + var emailAssistantExecutor = new EmailAssistantExecutor(emailAssistantAgent); + var sendEmailExecutor = new SendEmailExecutor(); + var handleSpamExecutor = new HandleSpamExecutor(); + + // Build the workflow by adding executors and connecting them + WorkflowBuilder builder = new(spamDetectionExecutor); + builder.AddEdge(spamDetectionExecutor, emailAssistantExecutor, condition: GetCondition(expectedResult: false)); + builder.AddEdge(emailAssistantExecutor, sendEmailExecutor); + builder.AddEdge(spamDetectionExecutor, handleSpamExecutor, condition: GetCondition(expectedResult: true)); + var workflow = builder.Build(); + + // Read a email from a text file + string email = Resources.Read("spam.txt"); + + // Execute the workflow + StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, email)); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is WorkflowCompletedEvent completedEvent) + { + Console.WriteLine($"{completedEvent}"); + } + } + } + + /// + /// Creates a condition for routing messages based on the expected spam detection result. + /// + /// The expected spam detection result + /// A function that evaluates whether a message meets the expected result + private static Func GetCondition(bool expectedResult) + { + return detectionResult => + { + return detectionResult is DetectionResult result && result.IsSpam == expectedResult; + }; + } + + /// + /// Creates a spam detection agent. + /// + /// A ChatClientAgent configured for spam detection + private static ChatClientAgent GetSpamDetectionAgent(IChatClient chatClient) + { + string instructions = "You are a spam detection assistant that identifies spam emails."; + var agentOptions = new ChatClientAgentOptions(instructions: instructions) + { + ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(DetectionResult)) + ) + } + }; + + return new ChatClientAgent(chatClient, agentOptions); + } + + /// + /// Creates an email assistant agent. + /// + /// A ChatClientAgent configured for email assistance + private static ChatClientAgent GetEmailAssistantAgent(IChatClient chatClient) + { + string instructions = "You are an email assistant that helps users draft responses to emails with professionalism."; + var agentOptions = new ChatClientAgentOptions(instructions: instructions) + { + ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(EmailResponse)) + ) + } + }; + + return new ChatClientAgent(chatClient, agentOptions); + } +} + +/// +/// Constants for shared state scopes. +/// +internal static class EmailStateConstants +{ + public const string EmailStateScope = "EmailState"; +} + +/// +/// Represents the result of spam detection. +/// +public sealed class DetectionResult +{ + [JsonPropertyName("is_spam")] + public bool IsSpam { get; set; } + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; + + // Email ID is generated by the executor not the agent + [JsonIgnore] + public string EmailId { get; set; } = string.Empty; +} + +/// +/// Represents an email. +/// +internal sealed class Email +{ + [JsonPropertyName("email_id")] + public string EmailId { get; set; } = string.Empty; + + [JsonPropertyName("email_content")] + public string EmailContent { get; set; } = string.Empty; +} + +/// +/// Executor that detects spam using an AI agent. +/// +internal sealed class SpamDetectionExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly AIAgent _spamDetectionAgent; + + /// + /// Creates a new instance of the class. + /// + /// The AI agent used for spam detection + public SpamDetectionExecutor(AIAgent spamDetectionAgent) : base("SpamDetectionExecutor") + { + this._spamDetectionAgent = spamDetectionAgent; + } + + public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) + { + // Generate a random email ID and store the email content to the shared state + var newEmail = new Email + { + EmailId = Guid.NewGuid().ToString(), + EmailContent = message.Text + }; + await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope); + + // Invoke the agent + var response = await this._spamDetectionAgent.RunAsync(message); + var detectionResult = JsonSerializer.Deserialize(response.Text); + + detectionResult!.EmailId = newEmail.EmailId; + + return detectionResult; + } +} + +/// +/// Represents the response from the email assistant. +/// +public sealed class EmailResponse +{ + [JsonPropertyName("response")] + public string Response { get; set; } = string.Empty; +} + +/// +/// Executor that assists with email responses using an AI agent. +/// +internal sealed class EmailAssistantExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly AIAgent _emailAssistantAgent; + + /// + /// Creates a new instance of the class. + /// + /// The AI agent used for email assistance + public EmailAssistantExecutor(AIAgent emailAssistantAgent) : base("EmailAssistantExecutor") + { + this._emailAssistantAgent = emailAssistantAgent; + } + + public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + { + if (message.IsSpam) + { + throw new InvalidOperationException("This executor should only handle non-spam messages."); + } + + // Retrieve the email content from the shared state + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope) + ?? throw new InvalidOperationException("Email not found."); + + // Invoke the agent + var response = await this._emailAssistantAgent.RunAsync(email.EmailContent); + var emailResponse = JsonSerializer.Deserialize(response.Text); + + return emailResponse!; + } +} + +/// +/// Executor that sends emails. +/// +internal sealed class SendEmailExecutor() : ReflectingExecutor("SendEmailExecutor"), IMessageHandler +{ + /// + /// Simulate the sending of an email. + /// + public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"Email sent: {message.Response}")); + } +} + +/// +/// Executor that handles spam messages. +/// +internal sealed class HandleSpamExecutor() : ReflectingExecutor("HandleSpamExecutor"), IMessageHandler +{ + /// + /// Simulate the handling of a spam message. + /// + public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + { + if (message.IsSpam) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as spam: {message.Reason}")); + } + else + { + throw new InvalidOperationException("This executor should only handle spam messages."); + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/Resources.cs b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/Resources.cs new file mode 100644 index 0000000000..3d54953adc --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/01_EdgeCondition/Resources.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; + +namespace WorkflowEdgeConditionSample; + +/// +/// Resource helper to load resources. +/// +internal static class Resources +{ + private const string ResourceFolder = "Resources"; + + public static string Read(string fileName) => File.ReadAllText($"{ResourceFolder}/{fileName}"); +} diff --git a/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj new file mode 100644 index 0000000000..dcee9dcd7f --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/02_SwitchCase.csproj @@ -0,0 +1,31 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + + + + + + + + + + + Always + Resources\%(Filename)%(Extension) + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/Program.cs b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/Program.cs new file mode 100644 index 0000000000..c8596d4c61 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/Program.cs @@ -0,0 +1,326 @@ +// 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 WorkflowSwitchCaseSample; + +/// +/// This sample introduces conditional routing using switch-case logic for complex decision trees. +/// +/// Building on the previous email automation examples, this workflow adds a third decision path +/// to handle ambiguous cases where spam detection is uncertain. Now the workflow can route emails +/// three ways based on the detection result: +/// +/// 1. Not Spam → Email Assistant → Send Email +/// 2. Spam → Handle Spam Executor +/// 3. Uncertain → Handle Uncertain Executor (default case) +/// +/// The switch-case pattern provides cleaner syntax than multiple individual edge conditions, +/// especially when dealing with multiple possible outcomes. This approach scales well for +/// workflows that need to handle many different scenarios. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - Shared state is used in this sample to persist email data between executors. +/// - 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 agents + AIAgent spamDetectionAgent = GetSpamDetectionAgent(chatClient); + AIAgent emailAssistantAgent = GetEmailAssistantAgent(chatClient); + + // Create executors + var spamDetectionExecutor = new SpamDetectionExecutor(spamDetectionAgent); + var emailAssistantExecutor = new EmailAssistantExecutor(emailAssistantAgent); + var sendEmailExecutor = new SendEmailExecutor(); + var handleSpamExecutor = new HandleSpamExecutor(); + var handleUncertainExecutor = new HandleUncertainExecutor(); + + // Build the workflow by adding executors and connecting them + WorkflowBuilder builder = new(spamDetectionExecutor); + builder.AddSwitch(spamDetectionExecutor, switchBuilder => + switchBuilder + .AddCase( + GetCondition(expectedDecision: SpamDecision.NotSpam), + emailAssistantExecutor + ) + .AddCase( + GetCondition(expectedDecision: SpamDecision.Spam), + handleSpamExecutor + ) + .WithDefault( + handleUncertainExecutor + ) + ) + // After the email assistant writes a response, it will be sent to the send email executor + .AddEdge(emailAssistantExecutor, sendEmailExecutor); + var workflow = builder.Build(); + + // Read a email from a text file + string email = Resources.Read("ambiguous_email.txt"); + + // Execute the workflow + StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, email)); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is WorkflowCompletedEvent completedEvent) + { + Console.WriteLine($"{completedEvent}"); + } + } + } + + /// + /// Creates a condition for routing messages based on the expected spam detection result. + /// + /// The expected spam detection decision + /// A function that evaluates whether a message meets the expected result + private static Func GetCondition(SpamDecision expectedDecision) + { + return detectionResult => + { + return detectionResult is DetectionResult result && result.spamDecision == expectedDecision; + }; + } + + /// + /// Creates a spam detection agent. + /// + /// A ChatClientAgent configured for spam detection + private static ChatClientAgent GetSpamDetectionAgent(IChatClient chatClient) + { + string instructions = "You are a spam detection assistant that identifies spam emails. Be less confident in your assessments."; + var agentOptions = new ChatClientAgentOptions(instructions: instructions) + { + ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(DetectionResult)) + ) + } + }; + + return new ChatClientAgent(chatClient, agentOptions); + } + + /// + /// Creates an email assistant agent. + /// + /// A ChatClientAgent configured for email assistance + private static ChatClientAgent GetEmailAssistantAgent(IChatClient chatClient) + { + string instructions = "You are an email assistant that helps users draft responses to emails with professionalism."; + var agentOptions = new ChatClientAgentOptions(instructions: instructions) + { + ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(EmailResponse)) + ) + } + }; + + return new ChatClientAgent(chatClient, agentOptions); + } +} + +/// +/// Constants for shared email state. +/// +internal static class EmailStateConstants +{ + public const string EmailStateScope = "EmailState"; +} + +/// +/// Represents the possible decisions for spam detection. +/// +public enum SpamDecision +{ + NotSpam, + Spam, + Uncertain +} + +/// +/// Represents the result of spam detection. +/// +public sealed class DetectionResult +{ + [JsonPropertyName("spam_decision")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public SpamDecision spamDecision { get; set; } + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; + + [JsonIgnore] + public string EmailId { get; set; } = string.Empty; +} + +/// +/// Represents an email. +/// +internal sealed class Email +{ + [JsonPropertyName("email_id")] + public string EmailId { get; set; } = string.Empty; + + [JsonPropertyName("email_content")] + public string EmailContent { get; set; } = string.Empty; +} + +/// +/// Executor that detects spam using an AI agent. +/// +internal sealed class SpamDetectionExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly AIAgent _spamDetectionAgent; + + /// + /// Creates a new instance of the class. + /// + /// The AI agent used for spam detection + public SpamDetectionExecutor(AIAgent spamDetectionAgent) : base("SpamDetectionExecutor") + { + this._spamDetectionAgent = spamDetectionAgent; + } + + public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) + { + // Generate a random email ID and store the email content + var newEmail = new Email + { + EmailId = Guid.NewGuid().ToString(), + EmailContent = message.Text + }; + await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope); + + // Invoke the agent + var response = await this._spamDetectionAgent.RunAsync(message); + var detectionResult = JsonSerializer.Deserialize(response.Text); + + detectionResult!.EmailId = newEmail.EmailId; + + return detectionResult; + } +} + +/// +/// Represents the response from the email assistant. +/// +public sealed class EmailResponse +{ + [JsonPropertyName("response")] + public string Response { get; set; } = string.Empty; +} + +/// +/// Executor that assists with email responses using an AI agent. +/// +internal sealed class EmailAssistantExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly AIAgent _emailAssistantAgent; + + /// + /// Creates a new instance of the class. + /// + /// The AI agent used for email assistance + public EmailAssistantExecutor(AIAgent emailAssistantAgent) : base("EmailAssistantExecutor") + { + this._emailAssistantAgent = emailAssistantAgent; + } + + public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + { + if (message.spamDecision == SpamDecision.Spam) + { + throw new InvalidOperationException("This executor should only handle non-spam messages."); + } + + // Retrieve the email content from the context + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); + + // Invoke the agent + var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent); + var emailResponse = JsonSerializer.Deserialize(response.Text); + + return emailResponse!; + } +} + +/// +/// Executor that sends emails. +/// +internal sealed class SendEmailExecutor() : ReflectingExecutor("SendEmailExecutor"), IMessageHandler +{ + /// + /// Simulate the sending of an email. + /// + public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"Email sent: {message.Response}")); + } +} + +/// +/// Executor that handles spam messages. +/// +internal sealed class HandleSpamExecutor() : ReflectingExecutor("HandleSpamExecutor"), IMessageHandler +{ + /// + /// Simulate the handling of a spam message. + /// + public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + { + if (message.spamDecision == SpamDecision.Spam) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as spam: {message.Reason}")); + } + else + { + throw new InvalidOperationException("This executor should only handle spam messages."); + } + } +} + +/// +/// Executor that handles uncertain emails. +/// +internal sealed class HandleUncertainExecutor() : ReflectingExecutor("HandleUncertainExecutor"), IMessageHandler +{ + /// + /// Simulate the handling of an uncertain spam decision. + /// + public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + { + if (message.spamDecision == SpamDecision.Uncertain) + { + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); + await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}")); + } + else + { + throw new InvalidOperationException("This executor should only handle uncertain spam decisions."); + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/Resources.cs b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/Resources.cs new file mode 100644 index 0000000000..c709bc2156 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/02_SwitchCase/Resources.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; + +namespace WorkflowSwitchCaseSample; + +/// +/// Resource helper to load resources. +/// +internal static class Resources +{ + private const string ResourceFolder = "Resources"; + + public static string Read(string fileName) => File.ReadAllText($"{ResourceFolder}/{fileName}"); +} diff --git a/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj new file mode 100644 index 0000000000..dcee9dcd7f --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/03_MultiSelection.csproj @@ -0,0 +1,31 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + + + + + + + + + + + Always + Resources\%(Filename)%(Extension) + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/Program.cs b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/Program.cs new file mode 100644 index 0000000000..4a0ae93763 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/Program.cs @@ -0,0 +1,450 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +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 WorkflowMultiSelectionSample; + +/// +/// This sample introduces multi-selection routing where one executor can trigger multiple downstream executors. +/// +/// Extending the switch-case pattern from the previous sample, the workflow can now +/// trigger multiple executors simultaneously when certain conditions are met. +/// +/// Key features: +/// - For legitimate emails: triggers Email Assistant (always) + Email Summary (if email is long) +/// - For spam emails: triggers Handle Spam executor only +/// - For uncertain emails: triggers Handle Uncertain executor only +/// - Database logging happens for both short emails and summarized long emails +/// +/// This pattern is powerful for workflows that need parallel processing based on data characteristics, +/// such as triggering different analytics pipelines or multiple notification systems. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - Shared state is used in this sample to persist email data between executors. +/// - An Azure OpenAI chat completion deployment that supports structured outputs must be configured. +/// +public static class Program +{ + private const int LongEmailThreshold = 100; + + 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 agents + AIAgent emailAnalysisAgent = GetEmailAnalysisAgent(chatClient); + AIAgent emailAssistantAgent = GetEmailAssistantAgent(chatClient); + AIAgent emailSummaryAgent = GetEmailSummaryAgent(chatClient); + + // Create executors + var emailAnalysisExecutor = new EmailAnalysisExecutor(emailAnalysisAgent); + var emailAssistantExecutor = new EmailAssistantExecutor(emailAssistantAgent); + var emailSummaryExecutor = new EmailSummaryExecutor(emailSummaryAgent); + var sendEmailExecutor = new SendEmailExecutor(); + var handleSpamExecutor = new HandleSpamExecutor(); + var handleUncertainExecutor = new HandleUncertainExecutor(); + var databaseAccessExecutor = new DatabaseAccessExecutor(); + + // Build the workflow by adding executors and connecting them + WorkflowBuilder builder = new(emailAnalysisExecutor); + builder.AddFanOutEdge( + emailAnalysisExecutor, + targets: [ + handleSpamExecutor, + emailAssistantExecutor, + emailSummaryExecutor, + handleUncertainExecutor, + ], + partitioner: GetPartitioner() + ) + // After the email assistant writes a response, it will be sent to the send email executor + .AddEdge(emailAssistantExecutor, sendEmailExecutor) + // Save the analysis result to the database if summary is not needed + .AddEdge( + emailAnalysisExecutor, + databaseAccessExecutor, + condition: analysisResult => analysisResult is AnalysisResult result && result.EmailLength <= LongEmailThreshold) + // Save the analysis result to the database with summary + .AddEdge(emailSummaryExecutor, databaseAccessExecutor); + var workflow = builder.Build(); + + // Read a email from a text file + string email = Resources.Read("email.txt"); + + // Execute the workflow + StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, email)); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is WorkflowCompletedEvent completedEvent) + { + Console.WriteLine($"{completedEvent}"); + } + + if (evt is DatabaseEvent databaseEvent) + { + Console.WriteLine($"{databaseEvent}"); + } + } + } + + /// + /// Creates a partitioner for routing messages based on the analysis result. + /// + /// A function that takes an analysis result and returns the target partitions. + private static Func> GetPartitioner() + { + return (analysisResult, targetCount) => + { + if (analysisResult is AnalysisResult result) + { + if (result.spamDecision == SpamDecision.Spam) + { + return [0]; // Route to spam handler + } + else if (result.spamDecision == SpamDecision.NotSpam) + { + List targets = [1]; // Route to the email assistant + + if (result.EmailLength > LongEmailThreshold) + { + targets.Add(2); // Route to the email summarizer too + } + + return targets; + } + else + { + return [3]; + } + } + throw new InvalidOperationException("Invalid analysis result."); + }; + } + + /// + /// Create an email analysis agent. + /// + /// A ChatClientAgent configured for email analysis + private static ChatClientAgent GetEmailAnalysisAgent(IChatClient chatClient) + { + string instructions = "You are a spam detection assistant that identifies spam emails."; + var agentOptions = new ChatClientAgentOptions(instructions: instructions) + { + ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(AnalysisResult)) + ) + } + }; + + return new ChatClientAgent(chatClient, agentOptions); + } + + /// + /// Creates an email assistant agent. + /// + /// A ChatClientAgent configured for email assistance + private static ChatClientAgent GetEmailAssistantAgent(IChatClient chatClient) + { + string instructions = "You are an email assistant that helps users draft responses to emails with professionalism."; + var agentOptions = new ChatClientAgentOptions(instructions: instructions) + { + ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(EmailResponse)) + ) + } + }; + + return new ChatClientAgent(chatClient, agentOptions); + } + + /// + /// Creates an agent that summarizes emails. + /// + /// A ChatClientAgent configured for email summarization + private static ChatClientAgent GetEmailSummaryAgent(IChatClient chatClient) + { + string instructions = "You are an assistant that helps users summarize emails."; + var agentOptions = new ChatClientAgentOptions(instructions: instructions) + { + ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(EmailSummary)) + ) + } + }; + + return new ChatClientAgent(chatClient, agentOptions); + } +} + +internal static class EmailStateConstants +{ + public const string EmailStateScope = "EmailState"; +} + +/// +/// Represents the possible decisions for spam detection. +/// +public enum SpamDecision +{ + NotSpam, + Spam, + Uncertain +} + +/// +/// Represents the result of email analysis. +/// +public sealed class AnalysisResult +{ + [JsonPropertyName("spam_decision")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public SpamDecision spamDecision { get; set; } + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; + + [JsonIgnore] + public int EmailLength { get; set; } + + [JsonIgnore] + public string EmailSummary { get; set; } = string.Empty; + + [JsonIgnore] + public string EmailId { get; set; } = string.Empty; +} + +/// +/// Represents an email. +/// +internal sealed class Email +{ + [JsonPropertyName("email_id")] + public string EmailId { get; set; } = string.Empty; + + [JsonPropertyName("email_content")] + public string EmailContent { get; set; } = string.Empty; +} + +/// +/// Executor that analyzes emails using an AI agent. +/// +internal sealed class EmailAnalysisExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly AIAgent _emailAnalysisAgent; + + /// + /// Creates a new instance of the class. + /// + /// The AI agent used for email analysis + public EmailAnalysisExecutor(AIAgent emailAnalysisAgent) : base("EmailAnalysisExecutor") + { + this._emailAnalysisAgent = emailAnalysisAgent; + } + + public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) + { + // Generate a random email ID and store the email content + var newEmail = new Email + { + EmailId = Guid.NewGuid().ToString(), + EmailContent = message.Text + }; + await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope); + + // Invoke the agent + var response = await this._emailAnalysisAgent.RunAsync(message); + var AnalysisResult = JsonSerializer.Deserialize(response.Text); + + AnalysisResult!.EmailId = newEmail.EmailId; + AnalysisResult!.EmailLength = newEmail.EmailContent.Length; + + return AnalysisResult; + } +} + +/// +/// Represents the response from the email assistant. +/// +public sealed class EmailResponse +{ + [JsonPropertyName("response")] + public string Response { get; set; } = string.Empty; +} + +/// +/// Executor that assists with email responses using an AI agent. +/// +internal sealed class EmailAssistantExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly AIAgent _emailAssistantAgent; + + /// + /// Creates a new instance of the class. + /// + /// The AI agent used for email assistance + public EmailAssistantExecutor(AIAgent emailAssistantAgent) : base("EmailAssistantExecutor") + { + this._emailAssistantAgent = emailAssistantAgent; + } + + public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + { + if (message.spamDecision == SpamDecision.Spam) + { + throw new InvalidOperationException("This executor should only handle non-spam messages."); + } + + // Retrieve the email content from the context + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); + + // Invoke the agent + var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent); + var emailResponse = JsonSerializer.Deserialize(response.Text); + + return emailResponse!; + } +} + +/// +/// Executor that sends emails. +/// +internal sealed class SendEmailExecutor() : ReflectingExecutor("SendEmailExecutor"), IMessageHandler +{ + /// + /// Simulate the sending of an email. + /// + public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"Email sent: {message.Response}")); + } +} + +/// +/// Executor that handles spam messages. +/// +internal sealed class HandleSpamExecutor() : ReflectingExecutor("HandleSpamExecutor"), IMessageHandler +{ + /// + /// Simulate the handling of a spam message. + /// + public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + { + if (message.spamDecision == SpamDecision.Spam) + { + await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as spam: {message.Reason}")); + } + else + { + throw new InvalidOperationException("This executor should only handle spam messages."); + } + } +} + +/// +/// Executor that handles uncertain messages. +/// +internal sealed class HandleUncertainExecutor() : ReflectingExecutor("HandleUncertainExecutor"), IMessageHandler +{ + /// + /// Simulate the handling of an uncertain spam decision. + /// + public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + { + if (message.spamDecision == SpamDecision.Uncertain) + { + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); + await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}")); + } + else + { + throw new InvalidOperationException("This executor should only handle uncertain spam decisions."); + } + } +} + +/// +/// Represents the response from the email summary agent. +/// +public sealed class EmailSummary +{ + [JsonPropertyName("summary")] + public string Summary { get; set; } = string.Empty; +} + +/// +/// Executor that summarizes emails using an AI agent. +/// +internal sealed class EmailSummaryExecutor : ReflectingExecutor, IMessageHandler +{ + private readonly AIAgent _emailSummaryAgent; + + /// + /// Creates a new instance of the class. + /// + /// The AI agent used for email summarization + public EmailSummaryExecutor(AIAgent emailSummaryAgent) : base("EmailSummaryExecutor") + { + this._emailSummaryAgent = emailSummaryAgent; + } + + public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + { + // Read the email content from the shared states + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); + + // Invoke the agent + var response = await this._emailSummaryAgent.RunAsync(email!.EmailContent); + var emailSummary = JsonSerializer.Deserialize(response.Text); + message.EmailSummary = emailSummary!.Summary; + + return message; + } +} + +/// +/// A custom workflow event for database operations. +/// +/// The message associated with the event +internal sealed class DatabaseEvent(string message) : WorkflowEvent(message) { } + +/// +/// Executor that handles database access. +/// +internal sealed class DatabaseAccessExecutor() : ReflectingExecutor("DatabaseAccessExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + { + // 1. Save the email content + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); + await Task.Delay(100); // Simulate database access delay + + // 2. Save the analysis result + await Task.Delay(100); // Simulate database access delay + + // Not using the `WorkflowCompletedEvent` because this is not the end of the workflow. + // The end of the workflow is signaled by the `SendEmailExecutor` or the `HandleUnknownExecutor`. + await context.AddEventAsync(new DatabaseEvent($"Email {message.EmailId} saved to database.")); + } +} diff --git a/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/Resources.cs b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/Resources.cs new file mode 100644 index 0000000000..e965afd01d --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/ConditionalEdges/03_MultiSelection/Resources.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; + +namespace WorkflowMultiSelectionSample; + +/// +/// Resource helper to load resources. +/// +internal static class Resources +{ + private const string ResourceFolder = "Resources"; + + public static string Read(string fileName) => File.ReadAllText($"{ResourceFolder}/{fileName}"); +} diff --git a/dotnet/samples/GettingStarted/Workflow/Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj b/dotnet/samples/GettingStarted/Workflow/Foundational/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj new file mode 100644 index 0000000000..bbee070910 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/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/Workflow/Foundational/01_ExecutorsAndEdges/Program.cs b/dotnet/samples/GettingStarted/Workflow/Foundational/01_ExecutorsAndEdges/Program.cs new file mode 100644 index 0000000000..00682a60ef --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/Foundational/01_ExecutorsAndEdges/Program.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; + +namespace WorkflowExecutorsAndEdgesSample; + +/// +/// This sample introduces the concepts of executors and edges in a workflow. +/// +/// Workflows are built from executors (processing units) connected by edges (data flow paths). +/// In this example, we create a simple text processing pipeline that: +/// 1. Takes input text and converts it to uppercase using an UppercaseExecutor +/// 2. Takes the uppercase text and reverses it using a ReverseTextExecutor +/// +/// The executors are connected sequentially, so data flows from one to the next in order. +/// For input "Hello, World!", the workflow produces "!DLROW ,OLLEH". +/// +public static class Program +{ + private static async Task Main() + { + // Create the executors + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + // Build the workflow by connecting executors sequentially + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + var workflow = builder.Build(); + + // Execute the workflow with input data + Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!"); + foreach (WorkflowEvent evt in run.NewEvents) + { + if (evt is ExecutorCompleteEvent executorComplete) + { + Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + } +} + +/// +/// First executor: converts input text to uppercase. +/// +internal sealed class UppercaseExecutor() : ReflectingExecutor("UppercaseExecutor"), IMessageHandler +{ + /// + /// Processes the input message by converting it to uppercase. + /// + /// The input text to convert + /// Workflow context for accessing workflow services and adding events + /// The input text converted to uppercase + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + string result = message.ToUpperInvariant(); + + // The return value will be sent as a message along an edge to subsequent executors + return result; + } +} + +/// +/// Second executor: reverses the input text and completes the workflow. +/// +internal sealed class ReverseTextExecutor() : ReflectingExecutor("ReverseTextExecutor"), IMessageHandler +{ + /// + /// Processes the input message by reversing the text. + /// + /// The input text to reverse + /// Workflow context for accessing workflow services and adding events + /// The input text reversed + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + char[] charArray = message.ToCharArray(); + System.Array.Reverse(charArray); + string result = new(charArray); + + // Signal that the workflow is complete + await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false); + + return result; + } +} diff --git a/dotnet/samples/GettingStarted/Workflow/Foundational/02_Streaming/02_Streaming.csproj b/dotnet/samples/GettingStarted/Workflow/Foundational/02_Streaming/02_Streaming.csproj new file mode 100644 index 0000000000..bbee070910 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/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/Workflow/Foundational/02_Streaming/Program.cs b/dotnet/samples/GettingStarted/Workflow/Foundational/02_Streaming/Program.cs new file mode 100644 index 0000000000..01bd694a5d --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/Foundational/02_Streaming/Program.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; + +namespace WorkflowStreamingSample; + +/// +/// This sample introduces streaming output in workflows. +/// +/// While 01_Executors_And_Edges waits for the entire workflow to complete before showing results, +/// this example streams events back to you in real-time as each executor finishes processing. +/// This is useful for monitoring long-running workflows or providing live feedback to users. +/// +/// The workflow logic is identical: uppercase text, then reverse it. The difference is in +/// how we observe the execution - we see intermediate results as they happen. +/// +public static class Program +{ + private static async Task Main() + { + // Create the executors + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + // Build the workflow by connecting executors sequentially + WorkflowBuilder builder = new(uppercase); + builder.AddEdge(uppercase, reverse); + var workflow = builder.Build(); + + // Execute the workflow in streaming mode + StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Hello, World!"); + await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + { + if (evt is ExecutorCompleteEvent executorComplete) + { + Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + } +} + +/// +/// First executor: converts input text to uppercase. +/// +internal sealed class UppercaseExecutor() : ReflectingExecutor("UppercaseExecutor"), IMessageHandler +{ + /// + /// Processes the input message by converting it to uppercase. + /// + /// The input text to convert + /// Workflow context for accessing workflow services and adding events + /// The input text converted to uppercase + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + string result = message.ToUpperInvariant(); + + // The return value will be sent as a message along an edge to subsequent executors + return result; + } +} + +/// +/// Second executor: reverses the input text and completes the workflow. +/// +internal sealed class ReverseTextExecutor() : ReflectingExecutor("ReverseTextExecutor"), IMessageHandler +{ + /// + /// Processes the input message by reversing the text. + /// + /// The input text to reverse + /// Workflow context for accessing workflow services and adding events + /// The input text reversed + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + char[] charArray = message.ToCharArray(); + System.Array.Reverse(charArray); + string result = new(charArray); + + // Signal that the workflow is complete + await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false); + + return result; + } +} diff --git a/dotnet/samples/GettingStarted/Workflow/Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj b/dotnet/samples/GettingStarted/Workflow/Foundational/03_AgentsInWorkflows/03_AgentsInWorkflows.csproj new file mode 100644 index 0000000000..b1f90ee600 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/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/Workflow/Foundational/03_AgentsInWorkflows/Program.cs b/dotnet/samples/GettingStarted/Workflow/Foundational/03_AgentsInWorkflows/Program.cs new file mode 100644 index 0000000000..0456037eaf --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/Foundational/03_AgentsInWorkflows/Program.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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 WorkflowAgentsInWorkflowsSample; + +/// +/// This sample introduces the use of AI agents as executors within a workflow. +/// +/// Instead of simple text processing executors, this workflow uses three translation agents: +/// 1. French Agent - translates input text to French +/// 2. Spanish Agent - translates French text to Spanish +/// 3. English Agent - translates Spanish text back to English +/// +/// The agents are connected sequentially, creating a translation chain that demonstrates +/// how AI-powered components can be seamlessly integrated into workflow pipelines. +/// +/// +/// Pre-requisites: +/// - An Azure OpenAI chat completion deployment 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 agents + AIAgent frenchAgent = GetTranslationAgent("French", chatClient); + AIAgent spanishAgent = GetTranslationAgent("Spanish", chatClient); + AIAgent englishAgent = GetTranslationAgent("English", chatClient); + + // 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}"); + } + } + } + + /// + /// Creates a translation 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 GetTranslationAgent(string targetLanguage, IChatClient chatClient) + { + string instructions = $"You are a translation assistant that translates the provided text to {targetLanguage}."; + return new ChatClientAgent(chatClient, instructions); + } +} diff --git a/dotnet/samples/GettingStarted/Workflow/README.md b/dotnet/samples/GettingStarted/Workflow/README.md new file mode 100644 index 0000000000..05476e7b8b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/README.md @@ -0,0 +1,41 @@ +# Workflow Getting Started Samples + +The getting started with workflow samples demonstrate the fundamental concepts and functionalities of workflows in Agent Framework. + +## Samples Overview + +### 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. + +| 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 | + +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. + +### Concurrent Execution + +| Sample | Concepts | +|--------|----------| +| [Fan-Out and Fan-In](./Concurrent) | Introduces parallel processing with fan-out and fan-in patterns | + +### Workflow Shared States + +| Sample | Concepts | +|--------|----------| +| [Shared States](./SharedStates) | Demonstrates shared states between executors for data sharing and coordination | + +### Conditional Edges + +| Sample | Concepts | +|--------|----------| +| [Edge Conditions](./ConditionalEdges/01_EdgeCondition) | Introduces conditional edges for dynamic routing based on executor outputs | +| [Switch-Case Routing](./ConditionalEdges/02_SwitchCase) | Extends conditional edges with switch-case routing for multiple paths | +| [Multi-Selection Routing](./ConditionalEdges/03_MultiSelection) | Demonstrates multi-selection routing where one executor can trigger multiple downstream executors | + +> These 3 samples build upon each other. It's recommended to explore them in sequence to fully grasp the concepts. diff --git a/dotnet/samples/GettingStarted/Workflow/Resources/Lorem_Ipsum.txt b/dotnet/samples/GettingStarted/Workflow/Resources/Lorem_Ipsum.txt new file mode 100644 index 0000000000..68ee660d0f --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/Resources/Lorem_Ipsum.txt @@ -0,0 +1,9 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tortor leo, congue id congue sit amet, interdum nec est. Duis egestas ipsum at leo imperdiet, eu convallis tellus scelerisque. Duis dictum eget quam a efficitur. Curabitur congue tellus id libero molestie dignissim. Phasellus euismod lacus vel arcu mollis viverra. Vivamus consequat mauris sollicitudin euismod consequat. Phasellus at pellentesque elit. Proin pretium commodo varius. In dolor urna, interdum sed mollis at, interdum a libero. Pellentesque quis venenatis orci. Aenean blandit sapien id eros sodales, a porta lacus varius. + +Sed et tortor vulputate, aliquet mauris sit amet, laoreet arcu. Integer libero purus, placerat eget ligula quis, lobortis consectetur dui. Cras a congue nisi. Sed enim dui, vehicula ut lectus varius, rhoncus maximus neque. Suspendisse imperdiet ultrices pharetra. Donec vehicula imperdiet quam sit amet tempor. Maecenas ut nunc in enim fringilla semper. Aliquam vitae dolor blandit ex ullamcorper rhoncus. Nunc odio est, pulvinar ullamcorper tincidunt eget, lobortis eu odio. Integer suscipit vestibulum justo, ac vestibulum lorem vulputate sit amet. Curabitur id nisl neque. Nulla non odio et nulla blandit posuere a ut diam. Aliquam erat volutpat. + +Suspendisse tempor urna id nunc varius blandit. Mauris rhoncus massa nec sapien egestas venenatis. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nam efficitur lorem a purus sollicitudin semper. Donec non arcu sed massa tincidunt vestibulum. Sed justo risus, tincidunt eget neque sed, venenatis bibendum magna. Vestibulum sapien nunc, lacinia vitae purus posuere, aliquet congue ligula. Nulla eget dictum lacus, eu scelerisque tortor. + +Aliquam erat volutpat. Mauris a suscipit massa. Sed elementum hendrerit ullamcorper. Vivamus dictum urna nisl, vel malesuada sapien varius congue. Cras orci diam, gravida in dolor ac, maximus eleifend velit. Proin finibus sit amet diam quis dignissim. Vivamus commodo dapibus tellus, ut pulvinar nunc aliquet eget. Vivamus feugiat pharetra est sit amet molestie. Aenean orci massa, fermentum id scelerisque vel, varius at odio. Nulla convallis felis at erat vehicula, quis fermentum metus fringilla. + +Ut commodo erat sit amet nulla eleifend semper. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Mauris ligula augue, pharetra in odio vel, bibendum blandit lacus. Etiam placerat maximus lacinia. Nunc malesuada ullamcorper tristique. Vestibulum mattis leo ac risus rutrum, vitae rhoncus ex pulvinar. Pellentesque in ultrices mauris. Mauris a metus eu lectus faucibus dictum nec quis dui. Cras vel magna tempor, porta mi et, molestie libero. \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflow/Resources/ambiguous_email.txt b/dotnet/samples/GettingStarted/Workflow/Resources/ambiguous_email.txt new file mode 100644 index 0000000000..a9668280bd --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/Resources/ambiguous_email.txt @@ -0,0 +1,19 @@ +Subject: Action Required: Verify Your Account + +Dear Valued Customer, + +We have detected unusual activity on your account and need to verify your identity to ensure your security. + +To maintain access to your account, please login to your account and complete the verification process. + +Account Details: +- User: johndoe@contoso.com +- Last Login: 08/15/2025 +- Location: Seattle, WA +- Device: Mobile + +This is an automated security measure. If you believe this email was sent in error, please contact our support team immediately. + +Best regards, +Security Team +Customer Service Department \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflow/Resources/email.txt b/dotnet/samples/GettingStarted/Workflow/Resources/email.txt new file mode 100644 index 0000000000..3ab05c36ac --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/Resources/email.txt @@ -0,0 +1,18 @@ +Subject: Team Meeting Follow-up - Action Items + +Hi Sarah, + +I wanted to follow up on our team meeting this morning and share the action items we discussed: + +1. Update the project timeline by Friday +2. Schedule client presentation for next week +3. Review the budget allocation for Q4 + +Please let me know if you have any questions or if I missed anything from our discussion. + +Best regards, +Alex Johnson +Project Manager +Tech Solutions Inc. +alex.johnson@techsolutions.com +(555) 123-4567 \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflow/Resources/spam.txt b/dotnet/samples/GettingStarted/Workflow/Resources/spam.txt new file mode 100644 index 0000000000..e25f62fd40 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/Resources/spam.txt @@ -0,0 +1,25 @@ +Subject: 🎉 CONGRATULATIONS! You've WON $1,000,000 - CLAIM NOW! 🎉 + +Dear Valued Customer, + +URGENT NOTICE: You have been selected as our GRAND PRIZE WINNER! + +🏆 YOU HAVE WON $1,000,000 USD 🏆 + +This is NOT a joke! You are one of only 5 lucky winners selected from millions of email addresses worldwide. + +To claim your prize, you MUST respond within 24 HOURS or your winnings will be forfeited! + +CLICK HERE NOW: http://win-claim.com + +What you need to do: +1. Reply with your full name +2. Provide your bank account details +3. Send a processing fee of $500 via wire transfer + +ACT FAST! This offer expires TONIGHT at midnight! + +Best regards, +Dr. Johnson Williams +International Lottery Commission +Phone: +1-555-999-1234 \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflow/SharedStates/Program.cs b/dotnet/samples/GettingStarted/Workflow/SharedStates/Program.cs new file mode 100644 index 0000000000..eee080c744 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/SharedStates/Program.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.Workflows; +using Microsoft.Agents.Workflows.Reflection; + +namespace WorkflowSharedStatesSample; + +/// +/// This sample introduces the concept of shared states within a workflow. +/// It demonstrates how multiple executors can read from and write to shared states, +/// allowing for more complex data sharing and coordination between tasks. +/// +/// +/// Pre-requisites: +/// - Foundational samples should be completed first. +/// - This sample also uses the fan-out and fan-in patterns to achieve parallel processing. +/// +public static class Program +{ + private static async Task Main() + { + // Create the executors + var fileRead = new FileReadExecutor(); + var wordCount = new WordCountingExecutor(); + var paragraphCount = new ParagraphCountingExecutor(); + var aggregate = new AggregationExecutor(); + + // Build the workflow by connecting executors sequentially + WorkflowBuilder builder = new(fileRead); + builder.AddFanOutEdge(fileRead, targets: [wordCount, paragraphCount]); + builder.AddFanInEdge(aggregate, sources: [wordCount, paragraphCount]); + var workflow = builder.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) + { + Console.WriteLine(workflowCompleted.Data); + } + } + } +} + +/// +/// Constants for shared state scopes. +/// +internal static class FileContentStateConstants +{ + public const string FileContentStateScope = "FileContentState"; +} + +internal sealed class FileReadExecutor() : ReflectingExecutor("FileReadExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + // Read file content from embedded resource + string fileContent = Resources.Read(message); + // Store file content in a shared state for access by other executors + string fileID = Guid.NewGuid().ToString(); + await context.QueueStateUpdateAsync(fileID, fileContent, scopeName: FileContentStateConstants.FileContentStateScope); + + return fileID; + } +} + +internal sealed class FileStats +{ + public int ParagraphCount { get; set; } + public int WordCount { get; set; } +} + +internal sealed class WordCountingExecutor() : ReflectingExecutor("WordCountingExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + // Retrieve the file content from the shared state + var fileContent = await context.ReadStateAsync(message, scopeName: FileContentStateConstants.FileContentStateScope) + ?? throw new InvalidOperationException("File content state not found"); + + int wordCount = fileContent.Split([' ', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries).Length; + + return new FileStats { WordCount = wordCount }; + } +} + +internal sealed class ParagraphCountingExecutor() : ReflectingExecutor("ParagraphCountingExecutor"), IMessageHandler +{ + public async ValueTask HandleAsync(string message, IWorkflowContext context) + { + // Retrieve the file content from the shared state + var fileContent = await context.ReadStateAsync(message, scopeName: FileContentStateConstants.FileContentStateScope) + ?? throw new InvalidOperationException("File content state not found"); + + int paragraphCount = fileContent.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries).Length; + + return new FileStats { ParagraphCount = paragraphCount }; + } +} + +internal sealed class AggregationExecutor() : ReflectingExecutor("AggregationExecutor"), IMessageHandler +{ + private readonly List _messages = []; + + public async ValueTask HandleAsync(FileStats message, IWorkflowContext context) + { + this._messages.Add(message); + + if (this._messages.Count == 2) + { + // Aggregate the results from both executors + var totalParagraphCount = this._messages.Sum(m => m.ParagraphCount); + var totalWordCount = this._messages.Sum(m => m.WordCount); + await context.AddEventAsync(new WorkflowCompletedEvent($"Total Paragraphs: {totalParagraphCount}, Total Words: {totalWordCount}")); + } + } +} diff --git a/dotnet/samples/GettingStarted/Workflow/SharedStates/Resources.cs b/dotnet/samples/GettingStarted/Workflow/SharedStates/Resources.cs new file mode 100644 index 0000000000..245deb6410 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/SharedStates/Resources.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.IO; + +namespace WorkflowSharedStatesSample; + +/// +/// Resource helper to load resources. +/// +internal static class Resources +{ + private const string ResourceFolder = "Resources"; + + public static string Read(string fileName) => File.ReadAllText($"{ResourceFolder}/{fileName}"); +} diff --git a/dotnet/samples/GettingStarted/Workflow/SharedStates/SharedStates.csproj b/dotnet/samples/GettingStarted/Workflow/SharedStates/SharedStates.csproj new file mode 100644 index 0000000000..d96f028c4b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflow/SharedStates/SharedStates.csproj @@ -0,0 +1,23 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + + + Always + Resources\%(Filename)%(Extension) + + + + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs index 17a5fbe6ab..3302593ab0 100644 --- a/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.Workflows/Specialized/AIAgentHostExecutor.cs @@ -99,6 +99,12 @@ internal class AIAgentHostExecutor : Executor await foreach (AgentRunResponseUpdate update in agentStream.ConfigureAwait(false)) { + if (string.IsNullOrEmpty(update.MessageId)) + { + // Ignore updates that don't have a message ID. + continue; + } + if (emitEvents) { await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); 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 0bcf319562..df818a932c 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 @@ -111,6 +111,7 @@ internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent { AgentId = this.Id, AuthorName = this.Name, + MessageId = Guid.NewGuid().ToString("N"), }; yield return response; @@ -153,6 +154,7 @@ internal sealed class EchoAgent(string id = nameof(EchoAgent)) : AIAgent { AgentId = this.Id, AuthorName = this.Name, + MessageId = Guid.NewGuid().ToString("N"), }; yield return result; diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs index c3eb79a815..959ec2c39c 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs @@ -179,15 +179,16 @@ public class SpecializedExecutorSmokeTests await host.TakeTurnAsync(new TurnToken(emitEvents: false), collectingContext); - collectingContext.Updates.Should().HaveCount(4); + // The first empty message is skipped. + collectingContext.Updates.Should().HaveCount(MessageStrings.Length - 1); - for (int i = 0; i < MessageStrings.Length; i++) + for (int i = 1; i < MessageStrings.Length; i++) { string expectedText = MessageStrings[i]; string[] expectedSplits = splits[i]; ChatMessage equivalent = expected[i]; - List collected = collectingContext.Updates[i]; + List collected = collectingContext.Updates[i - 1]; collected.Should().HaveCount(1); collected[0].Text.Should().Be(expectedText);