diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 24b596509e..a9112fcb15 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -243,6 +243,9 @@ + + + @@ -288,7 +291,7 @@ - + diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs b/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs new file mode 100644 index 0000000000..3a21dd8d28 --- /dev/null +++ b/dotnet/samples/03-workflows/Orchestration/Handoff/AgentRegistry.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +/// +/// The registry of agents used in the workflow. +/// +/// The to use as the agent backend. +internal sealed class AgentRegistry(IChatClient chatClient) +{ + internal const string IntakeAgentName = "Assistant"; + public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent( + instructions: + """ + You receive a user request and are responsible for routing to the correct initial expert agent. + """, + IntakeAgentName + ); + + internal const string LiquidityAnalysisAgentName = "Liquidity Analysis"; + public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent( + instructions: + """ + You are responsible for Liquidity Analysis. + """, + LiquidityAnalysisAgentName + ); + + internal const string TaxAnalysisAgentName = "Tax Analysis"; + public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent( + instructions: + """ + You are responsible for Tax Analysis. + """, + TaxAnalysisAgentName + ); + + internal const string ForeignExchangeAgentName = "Foreign Exchange Analysis"; + public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent( + instructions: + """ + You are responsible for Foreign Exchange Analysis. + """, + ForeignExchangeAgentName + ); + + internal const string EquityAgentName = "Equity Analysis"; + public AIAgent EquityAgent { get; } = chatClient.AsAIAgent( + instructions: + """ + You are responsible for Equity Analysis. + """, + EquityAgentName + ); + + public IEnumerable Experts => [this.LiquidityAnalysisAgent, this.TaxAnalysisAgent, this.ForeignExchangeAgent, this.EquityAgent]; + + public HashSet All + { + get + { + if (field == null) + { + field = [this.IntakeAgent, .. this.Experts]; + } + + return field; + } + } +} diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.csproj b/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.csproj new file mode 100644 index 0000000000..5fe709e505 --- /dev/null +++ b/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.csproj @@ -0,0 +1,29 @@ + + + + Exe + net10.0 + + enable + enable + + MAAIW001 + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.sln b/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.sln new file mode 100644 index 0000000000..4bd42ea64e --- /dev/null +++ b/dotnet/samples/03-workflows/Orchestration/Handoff/Handoff.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Handoff", "Handoff.csproj", "{2BF9227D-128C-52CD-C43E-E51BD268AB65}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2BF9227D-128C-52CD-C43E-E51BD268AB65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BF9227D-128C-52CD-C43E-E51BD268AB65}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BF9227D-128C-52CD-C43E-E51BD268AB65}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BF9227D-128C-52CD-C43E-E51BD268AB65}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CF541D9A-AD24-4553-A5A6-2F9FFEDD6C56} + EndGlobalSection +EndGlobal diff --git a/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs b/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs new file mode 100644 index 0000000000..6990e049ff --- /dev/null +++ b/dotnet/samples/03-workflows/Orchestration/Handoff/Program.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIProjectClient projectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +IChatClient chatClient = projectClient.ProjectOpenAIClient + .GetChatClient(deploymentName) + .AsIChatClient(); + +Workflow workflow = CreateWorkflow(chatClient); + +await RunWorkflowAsync(workflow).ConfigureAwait(false); + +static Workflow CreateWorkflow(IChatClient chatClient) +{ + AgentRegistry agents = new(chatClient); + + HandoffWorkflowBuilder handoffBuilder = AgentWorkflowBuilder.CreateHandoffBuilderWith(agents.IntakeAgent); + + // Add a handoff to each of the experts from every agent in the registry (experts + Intake) + foreach (AIAgent expert in agents.Experts) + { + handoffBuilder.WithHandoffs(agents.All.Except([expert]), expert); + } + + // Let agents request more user information and return to the asking agent (rather than going back to the intake agent) + handoffBuilder.EnableReturnToPrevious(); + + return handoffBuilder.Build(); +} + +static async Task RunWorkflowAsync(Workflow workflow) +{ + using CancellationTokenSource cts = CreateConsoleCancelKeySource(); + await using StreamingRun run = await InProcessExecution.OpenStreamingAsync(workflow, cancellationToken: cts.Token) + .ConfigureAwait(false); + + bool hadError = false; + do + { + Console.Write("> "); + string userInput = Console.ReadLine() ?? string.Empty; + + if (userInput.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + await run.TrySendMessageAsync(userInput); + string? speakingAgent = null; + await foreach (WorkflowEvent evt in run.WatchStreamAsync(cts.Token).WithCancellation(cts.Token)) + { + switch (evt) + { + case AgentResponseUpdateEvent update: + { + if (speakingAgent == null || speakingAgent != update.Update.AuthorName) + { + speakingAgent = update.Update.AuthorName; + Console.Write($"\n{speakingAgent}: "); + } + + Console.Write(update.Update.Text); + break; + } + + case WorkflowErrorEvent workflowError: + { + Console.ForegroundColor = ConsoleColor.Red; + + if (workflowError.Exception != null) + { + Console.WriteLine($"\nWorkflow error: {workflowError.Exception}"); + } + else + { + Console.WriteLine("\nUnknown workflow error occurred."); + } + + Console.ResetColor(); + + hadError = true; + break; + } + + case WorkflowWarningEvent workflowWarning when workflowWarning.Data is string message: + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(message); + Console.ResetColor(); + break; + } + } + } + } while (!hadError); +} + +static CancellationTokenSource CreateConsoleCancelKeySource() +{ + CancellationTokenSource cts = new(); + + // Normally, support a way to detach events, but in this case this is a termination signal, so cleanup will happen + // as part of application shutdown. + Console.CancelKeyPress += (s, args) => + { + cts.Cancel(); + + // We handle cleanup + termination ourselves + args.Cancel = true; + }; + + return cts; +}