From 7f22a87a2430f7b5b2ee81ea9b2c0c8fa058dd7c Mon Sep 17 00:00:00 2001 From: Shyju Krishnankutty Date: Wed, 14 Jan 2026 12:33:54 -0800 Subject: [PATCH] WIP. runs all executors/agents in workflow sequantially. --- .../AzureFunctions/01_SingleAgent/demo.http | 2 +- .../AzureFunctions/09_Workflow/Program.cs | 42 ++++++--- .../09_Workflow/ResponseRouterExecutor.cs | 27 ++++++ .../SurveyResponseParserExecutor.cs | 82 +++++++++++++++++ .../AzureFunctions/09_Workflow/demo.http | 4 +- .../BuiltInFunctionExecutor.cs | 5 +- .../DurableWorkflowRunner.cs | 33 ++++++- .../MinimalActivityContext.cs | 90 +++++++++++++++++++ .../Microsoft.Agents.AI.Workflows/Workflow.cs | 21 +++++ 9 files changed, 288 insertions(+), 18 deletions(-) create mode 100644 dotnet/samples/AzureFunctions/09_Workflow/ResponseRouterExecutor.cs create mode 100644 dotnet/samples/AzureFunctions/09_Workflow/SurveyResponseParserExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/MinimalActivityContext.cs diff --git a/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http b/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http index aaf939c6cd..0f63dabe1b 100644 --- a/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http +++ b/dotnet/samples/AzureFunctions/01_SingleAgent/demo.http @@ -2,7 +2,7 @@ @authority=http://localhost:7071 ### Prompt the agent -POST {{authority}}/api/workflows/MyTestWorkflow/run +POST {{authority}}/api/agents/Joker/run Content-Type: text/plain Hello world diff --git a/dotnet/samples/AzureFunctions/09_Workflow/Program.cs b/dotnet/samples/AzureFunctions/09_Workflow/Program.cs index cab0b996bb..aa4d41cd96 100644 --- a/dotnet/samples/AzureFunctions/09_Workflow/Program.cs +++ b/dotnet/samples/AzureFunctions/09_Workflow/Program.cs @@ -9,6 +9,7 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.Hosting; using OpenAI.Chat; +using SingleAgent; // Get the Azure OpenAI endpoint and deployment name from environment variables. string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") @@ -25,21 +26,40 @@ AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey) // Set up an AI agent following the standard Microsoft Agent Framework pattern. const string JokerName = "Joker"; const string JokerInstructions = "You are good at telling jokes."; +const string AnalysisInstructions = @"You are a Customer Feedback Analyzer. Your task is to analyze customer survey responses and categorize them accurately. +INPUT: You will receive customer feedback text that may include a rating and comments. + +OUTPUT: Return ONLY ONE category from this list: +- Bug Report +- General Feedback +- Billing Question +- Support Incident Status + +CATEGORIZATION RULES: +- ""Bug Report"": Technical issues, errors, crashes, features not working as expected +- ""General Feedback"": Suggestions, compliments, general comments about the product or service +- ""Billing Question"": Payment issues, subscription inquiries, pricing questions, refund requests +- ""Support Incident Status"": Follow-ups on existing tickets, status inquiries about previous issues + +RESPONSE FORMAT: Return only the category name exactly as shown above, with no additional text or explanation. + +Examples: +- ""The app crashes when I try to export"" → Bug Report +- ""Love the new design! Great work"" → General Feedback +- ""Why was I charged twice this month?"" → Billing Question +- ""What's the status of ticket #12345?"" → Support Incident Status"; AIAgent agent = client.GetChatClient(deploymentName).CreateAIAgent(JokerInstructions, JokerName); -AIAgent agent2 = client.GetChatClient(deploymentName).CreateAIAgent("You are good at telling inspirational quotes.", "InspirationBot"); +AIAgent agent2 = client.GetChatClient(deploymentName).CreateAIAgent(AnalysisInstructions, "FeedbackAnalysisBot"); -Func uppercaseFunc = s => s.ToUpperInvariant(); -var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor"); +SurveyResponseParserExecutor surveyResponseParserExecutor = new(); +ResponseRouterExecutor responseRouterExecutor = new(); -Func reverseTextFunc = s => s.ToUpperInvariant(); -var reverse = reverseTextFunc.BindAsExecutor("ReverseTextExecutor"); +WorkflowBuilder builder = new(surveyResponseParserExecutor); +builder.AddEdge(surveyResponseParserExecutor, agent2); +builder.AddEdge(agent2, responseRouterExecutor).WithOutputFrom(responseRouterExecutor); -WorkflowBuilder builder = new(uppercase); -builder.AddEdge(uppercase, agent2); -builder.AddEdge(agent2, reverse).WithOutputFrom(agent2); - -var workflow = builder.WithName("MyTestWorkflow").Build(); +var workflow = builder.WithName("HandleSurveyResponse").Build(); // Configure the function app to host AI agents and workflows in a unified way. // This will automatically generate HTTP API endpoints for agents and workflows. @@ -49,7 +69,7 @@ using IHost app = FunctionsApplication //.ConfigureDurableAgents(op => op.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1))) .ConfigureDurableOptions(options => { - // Configure workflows - agents referenced in workflows are automatically registered! + // Configure workflows options.Workflows.AddWorkflow(workflow); }) .Build(); diff --git a/dotnet/samples/AzureFunctions/09_Workflow/ResponseRouterExecutor.cs b/dotnet/samples/AzureFunctions/09_Workflow/ResponseRouterExecutor.cs new file mode 100644 index 0000000000..fdf1090ec2 --- /dev/null +++ b/dotnet/samples/AzureFunctions/09_Workflow/ResponseRouterExecutor.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace SingleAgent; + +/// +/// Routes survey responses to appropriate teams based on rating and category. +/// +public sealed class ResponseRouterExecutor() : Executor("ResponseRouterExecutor") +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + if (message.Contains("billing", StringComparison.OrdinalIgnoreCase)) + { + return ValueTask.FromResult("Routed to Billing Team"); + } + else if (message.Contains("technical", StringComparison.OrdinalIgnoreCase)) + { + return ValueTask.FromResult("Routed to Technical Support Team"); + } + else + { + return ValueTask.FromResult("Routed to General Support Team"); + } + } +} diff --git a/dotnet/samples/AzureFunctions/09_Workflow/SurveyResponseParserExecutor.cs b/dotnet/samples/AzureFunctions/09_Workflow/SurveyResponseParserExecutor.cs new file mode 100644 index 0000000000..f6a66c9038 --- /dev/null +++ b/dotnet/samples/AzureFunctions/09_Workflow/SurveyResponseParserExecutor.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Agents.AI.Workflows; + +namespace SingleAgent; + +/// +/// This executor parses survey responses and produces structured output. +/// Example input: "Rating: 8. The app is good but checkout process is confusing." +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by workflow framework")] +internal sealed partial class SurveyResponseParserExecutor() : Executor("SurveyResponseParserExecutor") +{ + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = true + }; + + [GeneratedRegex(@"Rating:\s*(\d+)", RegexOptions.IgnoreCase)] + private static partial Regex RatingRegex(); + + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + SurveyResponse response = this.ParseSurveyResponse(message); + string jsonResult = JsonSerializer.Serialize(response, s_jsonOptions); + return ValueTask.FromResult(jsonResult); + } + + private SurveyResponse ParseSurveyResponse(string message) + { + // Parse the message to extract rating and comment + int? rating = null; + string comment = message; + + // Try to extract rating using pattern "Rating: {number}" + Match ratingMatch = RatingRegex().Match(message); + if (ratingMatch.Success && int.TryParse(ratingMatch.Groups[1].Value, out int parsedRating)) + { + rating = parsedRating; + + // Remove the rating part from the message to get the comment + // Find the position after the rating number + int ratingEndIndex = ratingMatch.Index + ratingMatch.Length; + + // Skip any separators (period, comma, dash, etc.) and whitespace + while (ratingEndIndex < message.Length && + (char.IsWhiteSpace(message[ratingEndIndex]) || + message[ratingEndIndex] == '.' || + message[ratingEndIndex] == ',' || + message[ratingEndIndex] == '-')) + { + ratingEndIndex++; + } + + if (ratingEndIndex < message.Length) + { + comment = message[ratingEndIndex..].Trim(); + } + else + { + comment = string.Empty; + } + } + + // Create and return the structured response + return new SurveyResponse + { + Rating = rating, + Comment = comment, + OriginalMessage = message + }; + } + + private sealed class SurveyResponse + { + public int? Rating { get; set; } + public string Comment { get; set; } = string.Empty; + public string OriginalMessage { get; set; } = string.Empty; + } +} diff --git a/dotnet/samples/AzureFunctions/09_Workflow/demo.http b/dotnet/samples/AzureFunctions/09_Workflow/demo.http index 9f4a088117..a14218c2f7 100644 --- a/dotnet/samples/AzureFunctions/09_Workflow/demo.http +++ b/dotnet/samples/AzureFunctions/09_Workflow/demo.http @@ -2,7 +2,7 @@ @authority=http://localhost:7071 ### Prompt the agent -POST {{authority}}/api/workflows/MyTestWorkflow/run +POST {{authority}}/api/workflows/HandleSurveyResponse/run Content-Type: text/plain -Tell me a joke about a pirate. +Rating: 5. Why was I charged $99 when my plan should be $49? I need a refund for the overcharge diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs index 6733615875..c4f14a6da4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/BuiltInFunctionExecutor.cs @@ -27,7 +27,10 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint) { - context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync("aa", context); + var binding = context.FunctionDefinition.InputBindings.Values.FirstOrDefault(a => a.Name == "input"); + var input = await context.BindInputAsync(binding!); + var val = input.Value; + context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync(val!, context); return; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunner.cs index 43e4483ef7..52869fb473 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableWorkflowRunner.cs @@ -152,10 +152,37 @@ internal sealed class DurableWorkflowRunner this._logger.LogExecutingActivity(executorPair.Key, executorPair.Value.ExecutorType.TypeName); - const string result = "Many types are internal."; + // Attempt to invoke the executor using Executor.ExecuteAsync + // This allows the executor to handle its own execution logic + try + { + // Create the executor instance + Executor executor = await workflow.CreateExecutorInstanceAsync( + executorPair.Key, + "activity-run", + CancellationToken.None).ConfigureAwait(false); - this._logger.LogActivityExecuted(executorPair.Key, result); + // Create a minimal workflow context for the executor + MinimalActivityContext context = new(executorPair.Key); - return result; + // Execute the executor with the input + // The executor handles its own routing logic internally + object? result = await executor.ExecuteAsync( + input, + new TypeId(typeof(string)), + context, + CancellationToken.None).ConfigureAwait(false); + + // Convert result to string + string resultString = result?.ToString() ?? string.Empty; + + this._logger.LogActivityExecuted(executorPair.Key, resultString); + return resultString; + } + catch (Exception ex) + { + this._logger.LogError(ex, "Error executing executor '{ExecutorId}' in activity", executorPair.Key); + throw; + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/MinimalActivityContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/MinimalActivityContext.cs new file mode 100644 index 0000000000..557173c498 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/MinimalActivityContext.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions; + +/// +/// A minimal implementation of for use in Azure Functions activities. +/// This provides basic context support for simple executors that don't require full workflow infrastructure. +/// +internal sealed class MinimalActivityContext : IWorkflowContext +{ + public MinimalActivityContext(string executorId) + { + // executorId is provided but not stored since this minimal context doesn't use it + _ = executorId; + } + + /// + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) + { + // In activity context, events are not propagated to the workflow + // They would need to be returned as part of the activity result + return default; + } + + /// + public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) + { + // In activity context, messages cannot be routed to other executors + // The orchestration handles message routing between executors + return default; + } + + /// + public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) + { + // In activity context, outputs are not yielded to the workflow + // They would need to be returned as part of the activity result + return default; + } + + /// + public ValueTask RequestHaltAsync() + { + // Halt requests are not supported in activity context + return default; + } + + /// + public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) + { + // No state available in activity context + return new ValueTask(default(T)); + } + + /// + public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + { + // Initialize with factory value since no state is available + return new ValueTask(initialStateFactory()); + } + + /// + public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) + { + // No state keys in activity context + return new ValueTask>([]); + } + + /// + public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) + { + // State updates are not persisted in activity context + return default; + } + + /// + public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) + { + // No state to clear in activity context + return default; + } + + /// + public IReadOnlyDictionary? TraceContext => null; + + /// + public bool ConcurrentRunsEnabled => false; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index 47009f8d9d..b1cde6dd8c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -224,4 +224,25 @@ public class Workflow } } } + + /// + /// Creates an instance of the specified executor. + /// + /// The identifier of the executor to create. + /// A unique identifier for the run context. + /// The to monitor for cancellation requests. + /// A representing the asynchronous operation. + /// + /// This method is useful for Azure Functions scenarios where you need to create executor instances + /// outside of the normal workflow execution flow. + /// + public async ValueTask CreateExecutorInstanceAsync(string executorId, string runId, CancellationToken cancellationToken = default) + { + if (!this.ExecutorBindings.TryGetValue(executorId, out ExecutorBinding? binding)) + { + throw new InvalidOperationException($"Executor '{executorId}' not found in workflow."); + } + + return await binding.CreateInstanceAsync(runId).ConfigureAwait(false); + } }