diff --git a/.github/workflows/ci-dotnet-agentframework-sampleagent.yml b/.github/workflows/ci-dotnet-agentframework-sampleagent.yml new file mode 100644 index 00000000..cccdd9d0 --- /dev/null +++ b/.github/workflows/ci-dotnet-agentframework-sampleagent.yml @@ -0,0 +1,40 @@ +name: CI - Build .NET Agent Framework Sample Agent +permissions: + contents: read + +on: + push: + branches: [ main, master ] + paths: + - 'dotnet/agent-framework/sample-agent/**/*' + pull_request: + branches: [ main, master ] + paths: + - 'dotnet/agent-framework/sample-agent/**/*' + +jobs: + dotnet-agentframework-sampleagent: + name: .NET Agent Framework Sample Agent + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./dotnet/agent-framework + + strategy: + matrix: + dotnet-version: ['8.0.x'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: Restore dependencies + run: dotnet restore AgentFrameworkSample.sln + + - name: Build solution + run: dotnet build AgentFrameworkSample.sln --no-restore --configuration Release diff --git a/.github/workflows/ci-dotnet-semantickernel-sampleagent.yml b/.github/workflows/ci-dotnet-semantickernel-sampleagent.yml index c9c4834d..270bc088 100644 --- a/.github/workflows/ci-dotnet-semantickernel-sampleagent.yml +++ b/.github/workflows/ci-dotnet-semantickernel-sampleagent.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: ./dotnet/semantic-kernel/sample-agent + working-directory: ./dotnet/semantic-kernel strategy: matrix: diff --git a/dotnet/agent-framework/AgentFrameworkSample.sln b/dotnet/agent-framework/AgentFrameworkSample.sln new file mode 100644 index 00000000..8d6b4bf6 --- /dev/null +++ b/dotnet/agent-framework/AgentFrameworkSample.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36623.8 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentFrameworkSampleAgent", "sample-agent\AgentFrameworkSampleAgent.csproj", "{C05BF552-56C0-8F74-98D5-F51053881902}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C05BF552-56C0-8F74-98D5-F51053881902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C05BF552-56C0-8F74-98D5-F51053881902}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C05BF552-56C0-8F74-98D5-F51053881902}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C05BF552-56C0-8F74-98D5-F51053881902}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A13DF873-5DE4-4F7D-9734-FA05F32F218E} + EndGlobalSection +EndGlobal diff --git a/dotnet/agent-framework/sample-agent/.gitignore b/dotnet/agent-framework/sample-agent/.gitignore new file mode 100644 index 00000000..572d3606 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/.gitignore @@ -0,0 +1,234 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates +target/ + +# Cake +/.cake +/version.txt +/PSRunCmds*.ps1 + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +/bin/ +/binSigned/ +/obj/ +Drop/ +target/ +Symbols/ +objd/ +.config/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +#nodeJS stuff +/node_modules/ + +#local development +appsettings.local.json +appsettings.Development.json +appsettings.Development* +appsettings.Production.json +**/[Aa]ppManifest/*.zip +.deployment + +# JetBrains Rider +*.sln.iml +.idea + +# Mac files +.DS_Store + +# VS Code files +.vscode +src/samples/ModelContextProtocol/GitHubMCPServer/Properties/ServiceDependencies/GitHubMCPServer20250311143114 - Web Deploy/profile.arm.json + +# Agent SDK generated files +*.transcript \ No newline at end of file diff --git a/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs new file mode 100644 index 00000000..d147c768 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/Agent/MyAgent.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Agent365AgentFrameworkSampleAgent.telemetry; +using Agent365AgentFrameworkSampleAgent.Tools; +using Microsoft.Agents.A365.Observability.Caching; +using Microsoft.Agents.A365.Runtime.Utils; +using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; +using Microsoft.Agents.AI; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.Core; +using Microsoft.Agents.Core.Models; +using Microsoft.Agents.Core.Serialization; +using Microsoft.Extensions.AI; +using System.Collections.Concurrent; +using System.Text.Json; + +namespace Agent365AgentFrameworkSampleAgent.Agent +{ + public class MyAgent : AgentApplication + { + private readonly string AgentWelcomeMessage = "Hello! I can help you find information based on what I can access"; + + private readonly string AgentInstructions = """ + You will speak like a friendly and professional virtual assistant. + + For questions about yourself, you should use the one of the tools: {{mcp_graph_getMyProfile}}, {{mcp_graph_getUserProfile}}, {{mcp_graph_getMyManager}}, {{mcp_graph_getUsersManager}}. + + If you are working with weather information, the following instructions apply: + Location is a city name, 2 letter US state codes should be resolved to the full name of the United States State. + You may ask follow up questions until you have enough information to answer the customers question, but once you have the current weather or a forecast, make sure to format it nicely in text. + - For current weather, Use the {{WeatherLookupTool.GetCurrentWeatherForLocation}}, you should include the current temperature, low and high temperatures, wind speed, humidity, and a short description of the weather. + - For forecast's, Use the {{WeatherLookupTool.GetWeatherForecastForLocation}}, you should report on the next 5 days, including the current day, and include the date, high and low temperatures, and a short description of the weather. + - You should use the {{DateTimePlugin.GetDateTime}} to get the current date and time. + + Otherwise you should use the tools available to you to help answer the user's questions. + """; + + private readonly IChatClient? _chatClient = null; + private readonly IConfiguration? _configuration = null; + private readonly IExporterTokenCache? _agentTokenCache = null; + private readonly ILogger? _logger = null; + private readonly IMcpToolRegistrationService? _toolService = null; + // Setup reusable auto sign-in handlers + private readonly string AgenticIdAuthHanlder = "agentic"; + private readonly string MyAuthHanlder = "me"; + // Temp + private static readonly ConcurrentDictionary> _agentToolCache = new(); + + public MyAgent(AgentApplicationOptions options, + IChatClient chatClient, + IConfiguration configuration, + IExporterTokenCache agentTokenCache, + IMcpToolRegistrationService toolService, + ILogger logger) : base(options) + { + _chatClient = chatClient; + _configuration = configuration; + _agentTokenCache = agentTokenCache; + _logger = logger; + _toolService = toolService; + + // Greet when members are added to the conversation + OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); + + // Handle A365 Notification Messages. + + // Listen for ANY message to be received. MUST BE AFTER ANY OTHER MESSAGE HANDLERS + OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: true, autoSignInHandlers: new[] { AgenticIdAuthHanlder }); + OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: false , autoSignInHandlers: new[] { MyAuthHanlder }); + } + + protected async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + await AgentMetrics.InvokeObservedAgentOperation( + "WelcomeMessage", + turnContext, + async () => + { + foreach (ChannelAccount member in turnContext.Activity.MembersAdded) + { + if (member.Id != turnContext.Activity.Recipient.Id) + { + await turnContext.SendActivityAsync(AgentWelcomeMessage); + } + } + }); + } + + /// + /// General Message process for Teams and other channels. + /// + /// + /// + /// + /// + protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + string ObservabilityAuthHandlerName = ""; + string ToolAuthHandlerName = ""; + if (turnContext.IsAgenticRequest()) + ObservabilityAuthHandlerName = ToolAuthHandlerName = AgenticIdAuthHanlder; + else + ObservabilityAuthHandlerName = ToolAuthHandlerName = MyAuthHanlder; + + + await A365OtelWrapper.InvokeObservedAgentOperation( + "MessageProcessor", + turnContext, + turnState, + _agentTokenCache, + UserAuthorization, + ObservabilityAuthHandlerName, + _logger, + async () => + { + // Start a Streaming Process to let clients that support streaming know that we are processing the request. + await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Just a moment please..").ConfigureAwait(false); + try + { + var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; + var _agent = await GetClientAgent(turnContext, turnState, _toolService, ToolAuthHandlerName); + + // Read or Create the conversation thread for this conversation. + AgentThread? thread = GetConversationThread(_agent, turnState); + + if (turnContext?.Activity?.Attachments?.Count > 0) + { + foreach (var attachment in turnContext.Activity.Attachments) + { + if (attachment.ContentType == "application/vnd.microsoft.teams.file.download.info" && !string.IsNullOrEmpty(attachment.ContentUrl)) + { + userText += $"\n\n[User has attached a file: {attachment.Name}. The file can be downloaded from {attachment.ContentUrl}]"; + } + } + } + + // Stream the response back to the user as we receive it from the agent. + await foreach (var response in _agent!.RunStreamingAsync(userText, thread, cancellationToken: cancellationToken)) + { + if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) + { + turnContext?.StreamingResponse.QueueTextChunk(response.Text); + } + } + turnState.Conversation.SetValue("conversation.threadInfo", ProtocolJsonSerializer.ToJson(thread.Serialize())); + } + finally + { + await turnContext.StreamingResponse.EndStreamAsync(cancellationToken).ConfigureAwait(false); // End the streaming response + } + }); + } + + + /// + /// Resolve the ChatClientAgent with tools and options for this turn operation. + /// This will use the IChatClient registered in DI. + /// + /// + /// + private async Task GetClientAgent(ITurnContext context, ITurnState turnState, IMcpToolRegistrationService? toolService, string authHandlerName) + { + AssertionHelpers.ThrowIfNull(_configuration!, nameof(_configuration)); + AssertionHelpers.ThrowIfNull(context, nameof(context)); + AssertionHelpers.ThrowIfNull(_chatClient!, nameof(_chatClient)); + + // Create the local tools we want to register with the agent: + var toolList = new List(); + + // Setup the local tool to be able to access the AgentSDK current context,UserAuthorization and other services can be accessed from here as well. + WeatherLookupTool weatherLookupTool = new(context, _configuration!); + + // Setup the tools for the agent: + toolList.Add(AIFunctionFactory.Create(DateTimeFunctionTool.getDate)); + toolList.Add(AIFunctionFactory.Create(weatherLookupTool.GetCurrentWeatherForLocation)); + toolList.Add(AIFunctionFactory.Create(weatherLookupTool.GetWeatherForecastForLocation)); + + if (toolService != null) + { + string toolCacheKey = GetToolCacheKey(turnState); + if (_agentToolCache.ContainsKey(toolCacheKey)) + { + var cachedTools = _agentToolCache[toolCacheKey]; + if (cachedTools != null && cachedTools.Count > 0) + { + toolList.AddRange(cachedTools); + } + } + else + { + // Notify the user we are loading tools + await context.StreamingResponse.QueueInformativeUpdateAsync("Loading tools..."); + + string agentId = Utility.ResolveAgentIdentity(context, await UserAuthorization.GetTurnTokenAsync(context, authHandlerName)); + var a365Tools = await toolService.GetMcpToolsAsync(agentId, UserAuthorization, authHandlerName, context).ConfigureAwait(false); + + // Add the A365 tools to the tool options + if (a365Tools != null && a365Tools.Count > 0) + { + toolList.AddRange(a365Tools); + _agentToolCache.TryAdd(toolCacheKey, [.. a365Tools]); + } + } + } + + // Create Chat Options with tools: + var toolOptions = new ChatOptions + { + Temperature = (float?)0.2, + Tools = toolList + }; + + // Create the chat Client passing in agent instructions and tools: + return new ChatClientAgent(_chatClient!, + new ChatClientAgentOptions + { + Instructions = AgentInstructions, + ChatOptions = toolOptions, + ChatMessageStoreFactory = ctx => + { +#pragma warning disable MEAI001 // MessageCountingChatReducer is for evaluation purposes only and is subject to change or removal in future updates + return new InMemoryChatMessageStore(new MessageCountingChatReducer(10), ctx.SerializedState, ctx.JsonSerializerOptions); +#pragma warning restore MEAI001 // MessageCountingChatReducer is for evaluation purposes only and is subject to change or removal in future updates + } + }) + .AsBuilder() + .UseOpenTelemetry(sourceName: AgentMetrics.SourceName, (cfg) => cfg.EnableSensitiveData = true) + .Build(); + } + + /// + /// Manage Agent threads against the conversation state. + /// + /// ChatAgent + /// State Manager for the Agent. + /// + private static AgentThread GetConversationThread(AIAgent? agent, ITurnState turnState) + { + ArgumentNullException.ThrowIfNull(agent); + AgentThread thread; + string? agentThreadInfo = turnState.Conversation.GetValue("conversation.threadInfo", () => null); + if (string.IsNullOrEmpty(agentThreadInfo)) + { + thread = agent.GetNewThread(); + } + else + { + JsonElement ele = ProtocolJsonSerializer.ToObject(agentThreadInfo); + thread = agent.DeserializeThread(ele); + } + return thread; + } + + private string GetToolCacheKey(ITurnState turnState) + { + string userToolCacheKey = turnState.User.GetValue("user.toolCacheKey", () => null) ?? ""; + if (string.IsNullOrEmpty(userToolCacheKey)) + { + userToolCacheKey = Guid.NewGuid().ToString(); + turnState.User.SetValue("user.toolCacheKey", userToolCacheKey); + return userToolCacheKey; + } + return userToolCacheKey; + } + } +} \ No newline at end of file diff --git a/dotnet/agent-framework/sample-agent/AgentFrameworkSampleAgent.csproj b/dotnet/agent-framework/sample-agent/AgentFrameworkSampleAgent.csproj new file mode 100644 index 00000000..44c4db37 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/AgentFrameworkSampleAgent.csproj @@ -0,0 +1,48 @@ + + + + net8.0 + enable + 7a8f9d79-5c4c-495f-8d56-1db8168ef8bd + enable + + + + $(DefineConstants);UseStreaming + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/agent-framework/sample-agent/AspNetExtensions.cs b/dotnet/agent-framework/sample-agent/AspNetExtensions.cs new file mode 100644 index 00000000..847cd886 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/AspNetExtensions.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Core; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; +using System.Collections.Concurrent; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; + +namespace Agent365AgentFrameworkSampleAgent; + +public static class AspNetExtensions +{ + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + /// + /// Adds token validation typical for ABS/SMBA and Bot-to-bot. + /// default to Azure Public Cloud. + /// + /// + /// + /// Name of the config section to read. + /// Optional logger to use for authentication event logging. + /// + /// Configuration: + /// + /// "TokenValidation": { + /// "Audiences": [ + /// "{required:bot-appid}" + /// ], + /// "TenantId": "{recommended:tenant-id}", + /// "ValidIssuers": [ + /// "{default:Public-AzureBotService}" + /// ], + /// "IsGov": {optional:false}, + /// "AzureBotServiceOpenIdMetadataUrl": optional, + /// "OpenIdMetadataUrl": optional, + /// "AzureBotServiceTokenHandling": "{optional:true}" + /// "OpenIdMetadataRefresh": "optional-12:00:00" + /// } + /// + /// + /// `IsGov` can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used. + /// `ValidIssuers` can be omitted, in which case the Public Azure Bot Service issuers are used. + /// `TenantId` can be omitted if the Agent is not being called by another Agent. Otherwise it is used to add other known issuers. Only when `ValidIssuers` is omitted. + /// `AzureBotServiceOpenIdMetadataUrl` can be omitted. In which case default values in combination with `IsGov` is used. + /// `OpenIdMetadataUrl` can be omitted. In which case default values in combination with `IsGov` is used. + /// `AzureBotServiceTokenHandling` defaults to true and should always be true until Azure Bot Service sends Entra ID token. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation") + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + + if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true)) + { + // Noop if TokenValidation section missing or disabled. + System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled"); + return; + } + + services.AddAgentAspNetAuthentication(tokenValidationSection.Get()!); + } + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions) + { + AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions)); + + // Must have at least one Audience. + if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId"); + } + + // Audience values must be GUID's + foreach (var audience in validationOptions.Audiences) + { + if (!Guid.TryParse(audience, out _)) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID"); + } + } + + // If ValidIssuers is empty, default for ABS Public Cloud + if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0) + { + validationOptions.ValidIssuers = + [ + "https://api.botframework.com", + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", + "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", + ]; + + if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _)) + { + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, validationOptions.TenantId)); + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, validationOptions.TenantId)); + } + } + + // If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens. + if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl)) + { + validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl; + } + + // If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens. + if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl)) + { + validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl; + } + + var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval; + + _ = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + ValidIssuers = validationOptions.ValidIssuers, + ValidAudiences = validationOptions.Audiences, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + }; + + // Using Microsoft.IdentityModel.Validators + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + options.Events = new JwtBearerEvents + { + // Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens. + OnMessageReceived = async context => + { + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + string[] parts = authorizationHeader?.Split(' ')!; + if (parts.Length != 2 || parts[0] != "Bearer") + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + JwtSecurityToken token = new(parts[1]); + string issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value!; + + if (validationOptions.AzureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer)) + { + // Use the Azure Bot authority for this configuration manager + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.AzureBotServiceOpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.AzureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + else + { + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.OpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.OpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + + await Task.CompletedTask.ConfigureAwait(false); + }, + + OnTokenValidated = context => + { + return Task.CompletedTask; + }, + OnForbidden = context => + { + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + return Task.CompletedTask; + } + }; + }); + } + + public class TokenValidationOptions + { + public IList? Audiences { get; set; } + + /// + /// TenantId of the Azure Bot. Optional but recommended. + /// + public string? TenantId { get; set; } + + /// + /// Additional valid issuers. Optional, in which case the Public Azure Bot Service issuers are used. + /// + public IList? ValidIssuers { get; set; } + + /// + /// Can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used. + /// + public bool IsGov { get; set; } = false; + + /// + /// Azure Bot Service OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? AzureBotServiceOpenIdMetadataUrl { get; set; } + + /// + /// Entra OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? OpenIdMetadataUrl { get; set; } + + /// + /// Determines if Azure Bot Service tokens are handled. Defaults to true and should always be true until Azure Bot Service sends Entra ID token. + /// + public bool AzureBotServiceTokenHandling { get; set; } = true; + + /// + /// OpenIdMetadata refresh interval. Defaults to 12 hours. + /// + public TimeSpan? OpenIdMetadataRefresh { get; set; } + } +} diff --git a/dotnet/agent-framework/sample-agent/Program.cs b/dotnet/agent-framework/sample-agent/Program.cs new file mode 100644 index 00000000..00d58233 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/Program.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Agent365AgentFrameworkSampleAgent; +using Agent365AgentFrameworkSampleAgent.Agent; +using Agent365AgentFrameworkSampleAgent.telemetry; +using Azure; +using Azure.AI.OpenAI; +using Microsoft.Agents.A365.Observability; +using Microsoft.Agents.A365.Observability.Extensions.AgentFramework; +using Microsoft.Agents.A365.Observability.Runtime; +using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; +using Microsoft.Agents.A365.Tooling.Services; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Storage; +using Microsoft.Agents.Storage.Transcript; +using Microsoft.Extensions.AI; +using System.Reflection; + + + +var builder = WebApplication.CreateBuilder(args); + +// Setup Aspire service defaults, including OpenTelemetry, Service Discovery, Resilience, and Health Checks +builder.ConfigureOpenTelemetry(); + +builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly()); +builder.Services.AddControllers(); +builder.Services.AddHttpClient("WebClient", client => client.Timeout = TimeSpan.FromSeconds(600)); +builder.Services.AddHttpContextAccessor(); +builder.Logging.AddConsole(); + +// ********** Configure A365 Services ********** +// Configure observability. +builder.Services.AddAgenticTracingExporter(clusterCategory: "production"); + +// Add A365 tracing with Agent Framework integration +builder.AddA365Tracing(config => +{ + config.WithAgentFramework(); +}); + +// Add A365 Tooling Server integration +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +// ********** END Configure A365 Services ********** + +// Add AspNet token validation +builder.Services.AddAgentAspNetAuthentication(builder.Configuration); + +// Register IStorage. For development, MemoryStorage is suitable. +// For production Agents, persisted storage should be used so +// that state survives Agent restarts, and operate correctly +// in a cluster of Agent instances. +builder.Services.AddSingleton(); + +// Add AgentApplicationOptions from config. +builder.AddAgentApplicationOptions(); + +// Add the bot (which is transient) +builder.AddAgent(); + +// Register IChatClient with correct types +builder.Services.AddSingleton(sp => { + + var confSvc = sp.GetRequiredService(); + var endpoint = confSvc["AIServices:AzureOpenAI:Endpoint"] ?? string.Empty; + var apiKey = confSvc["AIServices:AzureOpenAI:ApiKey"] ?? string.Empty; + var deployment = confSvc["AIServices:AzureOpenAI:DeploymentName"] ?? string.Empty; + + // Validate OpenWeatherAPI key. + var openWeatherApiKey = confSvc["OpenWeatherApiKey"] ?? string.Empty; + + AssertionHelpers.ThrowIfNullOrEmpty(endpoint, "AIServices:AzureOpenAI:Endpoint configuration is missing and required."); + AssertionHelpers.ThrowIfNullOrEmpty(apiKey, "AIServices:AzureOpenAI:ApiKey configuration is missing and required."); + AssertionHelpers.ThrowIfNullOrEmpty(deployment, "AIServices:AzureOpenAI:DeploymentName configuration is missing and required."); + AssertionHelpers.ThrowIfNullOrEmpty(openWeatherApiKey, "OpenWeatherApiKey configuration is missing and required."); + + // Convert endpoint to Uri + var endpointUri = new Uri(endpoint); + + // Convert apiKey to ApiKeyCredential + var apiKeyCredential = new AzureKeyCredential(apiKey); + + // Create and return the AzureOpenAIClient's ChatClient + return new AzureOpenAIClient(endpointUri, apiKeyCredential) + .GetChatClient(deployment) + .AsIChatClient() + .AsBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry(sourceName: AgentMetrics.SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) + .Build(); +}); + +// Uncomment to add transcript logging middleware to log all conversations to files +builder.Services.AddSingleton([new TranscriptLoggerMiddleware(new FileTranscriptLogger())]); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseDeveloperExceptionPage(); +} + +app.UseRouting(); +app.UseAuthentication(); +app.UseAuthorization(); + + +// Map the /api/messages endpoint to the AgentApplication +app.MapPost("/api/messages", async (HttpRequest request, HttpResponse response, IAgentHttpAdapter adapter, IAgent agent, CancellationToken cancellationToken) => +{ + await AgentMetrics.InvokeObservedHttpOperation("agent.process_message", async () => + { + await adapter.ProcessAsync(request, response, agent, cancellationToken); + }).ConfigureAwait(false); +}); + + +if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Playground") +{ + app.MapGet("/", () => "Agent Framework Example Weather Agent"); + app.UseDeveloperExceptionPage(); + app.MapControllers().AllowAnonymous(); + + // Hard coded for brevity and ease of testing. + // In production, this should be set in configuration. + app.Urls.Add($"http://localhost:3978"); +} +else +{ + app.MapControllers(); +} + +app.Run(); \ No newline at end of file diff --git a/dotnet/agent-framework/sample-agent/README.md b/dotnet/agent-framework/sample-agent/README.md new file mode 100644 index 00000000..12f31960 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/README.md @@ -0,0 +1,61 @@ +# Agent Framework (Simple) Sample + +## Overview +This is a simple sample showing how to use the [Agent Framework](https://github.com/microsoft/agent-framework) as the orchestrator in an agent using the Microsoft Agent 365 SDK and Microsoft 365 Agents SDK +It covers: + +- **Observability**: End-to-end tracing, caching, and monitoring for agent applications +- **Notifications**: Services and models for managing user notifications +- **Tools**: Model Context Protocol tools for building advanced agent solutions +- **Hosting Patterns**: Hosting with Microsoft 365 Agents SDK + +This sample uses the [Microsoft Agent 365 SDK for .NET](https://github.com/microsoft/Agent365-dotnet). + +For comprehensive documentation and guidance on building agents with the Microsoft Agent 365 SDK, including how to add tooling, observability, and notifications, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/). + +## Prerequisites + +- .NET 8.0 or higher +- Microsoft Agent 365 SDK +- Azure/OpenAI API credentials +- OpenWeather Credentials (if using the OpenWeather Tool) + - see: https://openweathermap.org/price - You will need to create a free account to get an API key (its at the bottom of the page). + +## Running the Agent + +To set up and test this agent, refer to the [Configure Agent Testing](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/testing?tabs=dotnet) guide for complete instructions. + +For a detailed explanation of the agent code and implementation, see the [Agent Code Walkthrough](Agent-Code-Walkthrough.md). + +## Support + +For issues, questions, or feedback: + +- **Issues**: Please file issues in the [GitHub Issues](https://github.com/microsoft/Agent365-dotnet/issues) section +- **Documentation**: See the [Microsoft Agents 365 Developer documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) +- **Security**: For security issues, please see [SECURITY.md](SECURITY.md) + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit . + +When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. + +## Additional Resources + +- [Microsoft Agent 365 SDK - .NET repository](https://github.com/microsoft/Agent365-dotnet) +- [Microsoft 365 Agents SDK - .NET repository](https://github.com/Microsoft/Agents-for-net) +- [Semantic Kernel documentation](https://learn.microsoft.com/semantic-kernel/) +- [.NET API documentation](https://learn.microsoft.com/dotnet/api/?view=m365-agents-sdk&preserve-view=true) + +## Trademarks + +*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.* + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details. diff --git a/dotnet/agent-framework/sample-agent/ToolingManifest.json b/dotnet/agent-framework/sample-agent/ToolingManifest.json new file mode 100644 index 00000000..68f99e35 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/ToolingManifest.json @@ -0,0 +1,25 @@ +{ + "mcpServers": [ + { + "mcpServerName": "mcp_MailTools" + }, + { + "mcpServerName": "mcp_CalendarTools" + }, + { + "mcpServerName": "OneDriveMCPServer" + }, + { + "mcpServerName": "mcp_NLWeb" + }, + { + "mcpServerName": "mcp_KnowledgeTools" + }, + { + "mcpServerName": "mcp_MeServer" + }, + { + "mcpServerName": "mcp_WordServer" + } + ] +} \ No newline at end of file diff --git a/dotnet/agent-framework/sample-agent/Tools/DateTimeFunctionTool.cs b/dotnet/agent-framework/sample-agent/Tools/DateTimeFunctionTool.cs new file mode 100644 index 00000000..983cf264 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/Tools/DateTimeFunctionTool.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; + +namespace Agent365AgentFrameworkSampleAgent.Tools +{ + public static class DateTimeFunctionTool + { + [Description("Use this tool to get the current date and time")] + public static string getDate(string input) + { + string date = DateTimeOffset.Now.ToString("D", null); + return date; + } + } +} diff --git a/dotnet/agent-framework/sample-agent/Tools/WeatherLookupTool.cs b/dotnet/agent-framework/sample-agent/Tools/WeatherLookupTool.cs new file mode 100644 index 00000000..a30f8fbc --- /dev/null +++ b/dotnet/agent-framework/sample-agent/Tools/WeatherLookupTool.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core; +using Microsoft.Agents.Core.Models; +using OpenWeatherMapSharp; +using OpenWeatherMapSharp.Models; +using System.ComponentModel; + +namespace Agent365AgentFrameworkSampleAgent.Tools +{ + public class WeatherLookupTool(ITurnContext turnContext, IConfiguration configuration) + { + /// + /// Retrieves the current weather for a specified location. + /// This method uses the OpenWeatherMap API to fetch the current weather data for a given city and state. + /// + /// The name of the city for which to retrieve the weather. + /// The name of the state where the city is located. + /// + /// A object containing the current weather details for the specified location, + /// or null if the weather data could not be retrieved. + /// + /// + /// The method performs the following steps: + /// 1. Notifies the user that the weather lookup is in progress. + /// 2. Retrieves the OpenWeather API key from the configuration. + /// 3. Uses the OpenWeatherMap API to find the location by city and state. + /// 4. Fetches the current weather data for the location's latitude and longitude. + /// 5. Returns the weather data if successful, or null if the operation fails. + /// + /// + /// Thrown if the OpenWeather API key is not configured or if the location cannot be found. + /// + + [Description("Retrieves the Current weather for a location, location is a city name")] + public async Task GetCurrentWeatherForLocation(string location, string state) + { + AssertionHelpers.ThrowIfNull(turnContext, nameof(turnContext)); + + // Notify the user that we are looking up the weather + Console.WriteLine($"Looking up the Current Weather in {location}"); + + // Notify the user that we are looking up the weather + if (!turnContext.Activity.ChannelId.Channel!.Contains(Channels.Webchat)) + await turnContext.StreamingResponse.QueueInformativeUpdateAsync($"Looking up the Current Weather in {location}"); + else + await turnContext.SendActivityAsync(MessageFactory.CreateMessageActivity().Text = $"Looking up the Current Weather in {location}").ConfigureAwait(false); + + var openAPIKey = configuration.GetValue("OpenWeatherApiKey", string.Empty); + OpenWeatherMapService openWeather = new OpenWeatherMapService(openAPIKey); + var openWeatherLocation = await openWeather.GetLocationByNameAsync(string.Format("{0},{1}", location, state)); + if (openWeatherLocation != null && openWeatherLocation.IsSuccess) + { + var locationInfo = openWeatherLocation.Response.FirstOrDefault(); + if (locationInfo == null) + { + if (!turnContext.Activity.ChannelId.Channel.Contains(Channels.Webchat)) + turnContext.StreamingResponse.QueueTextChunk($"Unable to resolve location from provided information {location}, {state}"); + else + await turnContext.SendActivityAsync( + MessageFactory.CreateMessageActivity().Text = "Sorry, I couldn't get the weather forecast at the moment.") + .ConfigureAwait(false); + + throw new ArgumentException($"Unable to resolve location from provided information {location}, {state}"); + } + + // Notify the user that we are fetching the weather + Console.WriteLine($"Fetching Current Weather for {location}"); + + if (!turnContext.Activity.ChannelId.Channel.Contains(Channels.Webchat)) + // Notify the user that we are looking up the weather + await turnContext.StreamingResponse.QueueInformativeUpdateAsync($"Fetching Current Weather for {location}"); + else + await turnContext.SendActivityAsync(MessageFactory.CreateMessageActivity().Text = $"Fetching Current Weather for {location}").ConfigureAwait(false); + + + var weather = await openWeather.GetWeatherAsync(locationInfo.Latitude, locationInfo.Longitude, unit: OpenWeatherMapSharp.Models.Enums.Unit.Imperial); + if (weather.IsSuccess) + { + WeatherRoot wInfo = weather.Response; + return wInfo; + } + } + else + { + System.Diagnostics.Trace.WriteLine($"Failed to complete API Call to OpenWeather: {openWeatherLocation!.Error}"); + } + return null; + } + + /// + /// Retrieves the weather forecast for a specified location. + /// This method uses the OpenWeatherMap API to fetch the weather forecast data for a given city and state. + /// + /// The name of the city for which to retrieve the weather forecast. + /// The name of the state where the city is located. + /// + /// A list of objects containing the weather forecast details for the specified location, + /// or null if the forecast data could not be retrieved. + /// + /// + /// The method performs the following steps: + /// 1. Notifies the user that the weather forecast lookup is in progress. + /// 2. Retrieves the OpenWeather API key from the configuration. + /// 3. Uses the OpenWeatherMap API to find the location by city and state. + /// 4. Fetches the weather forecast data for the location's latitude and longitude. + /// 5. Returns the forecast data if successful, or null if the operation fails. + /// + /// + /// Thrown if the OpenWeather API key is not configured or if the location cannot be found. + /// + + [Description("Retrieves the Weather forecast for a location, location is a city name")] + public async Task?> GetWeatherForecastForLocation(string location, string state) + { + // Notify the user that we are looking up the weather + Console.WriteLine($"Looking up the Weather Forecast in {location}"); + + var openAPIKey = configuration.GetValue("OpenWeatherApiKey", string.Empty); + OpenWeatherMapService openWeather = new OpenWeatherMapService(openAPIKey); + var openWeatherLocation = await openWeather.GetLocationByNameAsync(string.Format("{0},{1}", location, state)); + if (openWeatherLocation != null && openWeatherLocation.IsSuccess) + { + var locationInfo = openWeatherLocation.Response.FirstOrDefault(); + if (locationInfo == null) + { + + if (!turnContext.Activity.ChannelId.Channel!.Contains(Channels.Webchat)) + turnContext.StreamingResponse.QueueTextChunk($"Unable to resolve location from provided information {location}, {state}"); + else + await turnContext.SendActivityAsync( + MessageFactory.CreateMessageActivity().Text = "Sorry, I couldn't get the weather forecast at the moment.") + .ConfigureAwait(false); + + + throw new ArgumentException($"Unable to resolve location from provided information {location}, {state}"); + } + + // Notify the user that we are fetching the weather + Console.WriteLine($"Fetching Weather Forecast for {location}"); + + var weather = await openWeather.GetForecastAsync(locationInfo.Latitude, locationInfo.Longitude, unit: OpenWeatherMapSharp.Models.Enums.Unit.Imperial); + if (weather.IsSuccess) + { + var result = weather.Response.Items; + return result; + } + } + else + { + System.Diagnostics.Trace.WriteLine($"Failed to complete API Call to OpenWeather: {openWeatherLocation!.Error}"); + } + return null; + } + } +} diff --git a/dotnet/agent-framework/sample-agent/appPackage/color.png b/dotnet/agent-framework/sample-agent/appPackage/color.png new file mode 100644 index 00000000..01aa37e3 Binary files /dev/null and b/dotnet/agent-framework/sample-agent/appPackage/color.png differ diff --git a/dotnet/agent-framework/sample-agent/appPackage/manifest.json b/dotnet/agent-framework/sample-agent/appPackage/manifest.json new file mode 100644 index 00000000..127ddd94 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/appPackage/manifest.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.22/MicrosoftTeams.schema.json", + "manifestVersion": "1.22", + "version": "1.0.0", + "id": "${{TEAMS_APP_ID}}", + "developer": { + "name": "Microsoft, Inc.", + "websiteUrl": "https://www.example.com", + "privacyUrl": "https://www.example.com/privacy", + "termsOfUseUrl": "https://www.example.com/termofuse" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "Agent 365 AIF Sample Agent", + "full": "Agent 365 SDK Agent Framework Sample Agent" + }, + "description": { + "short": "Sample demonstrating Agent 365 SDK, Teams, and Agent Framework", + "full": "Sample demonstrating Agent 365 SDK, Teams, and Agent Framework" + }, + "accentColor": "#FFFFFF", + "copilotAgents": { + "customEngineAgents": [ + { + "id": "${{AAD_APP_CLIENT_ID}}", + "type": "bot" + } + ] + }, + "bots": [ + { + "botId": "${{AAD_APP_CLIENT_ID}}", + "scopes": [ + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [ + "<>" + ] +} \ No newline at end of file diff --git a/dotnet/agent-framework/sample-agent/appPackage/outline.png b/dotnet/agent-framework/sample-agent/appPackage/outline.png new file mode 100644 index 00000000..f7a4c864 Binary files /dev/null and b/dotnet/agent-framework/sample-agent/appPackage/outline.png differ diff --git a/dotnet/agent-framework/sample-agent/appsettings.Playground.json b/dotnet/agent-framework/sample-agent/appsettings.Playground.json new file mode 100644 index 00000000..3097e295 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/appsettings.Playground.json @@ -0,0 +1,32 @@ +{ + "TokenValidation": { + "Enabled": false, + "Audiences": [ + "---" // this is the Client ID used for the Azure Bot + ], + "TenantId": "---" + }, + "Connections": { + "ServiceConnection": { + "Settings": { + // this is the AuthType for the connection, valid values can be found in Microsoft.Agents.Authentication.Msal.Model.AuthTypes. The default is ClientSecret. + "AuthType": "ClientSecret", + "ClientId": "---", // this is the Client ID used for the Azure Bot + "ClientSecret": "---", + "AuthorityEndpoint": "https://login.microsoftonline.com/{{BOT_TENANT_ID}}", + "Scopes": [ + "https://api.botframework.com/.default" + ] + // Other properties dependent on the authorization type the Azure Bot uses. + } + } + }, + "AIServices": { + "AzureOpenAI": { + "DeploymentName": "---", // This is the Deployment (as opposed to model) Name of the Azure OpenAI model + "Endpoint": "---", // This is the Endpoint of the Azure OpenAI model deployment + "ApiKey": "---" // This is the API Key of the Azure OpenAI model deployment + } + }, + "OpenWeatherApiKey": "---" //https://openweathermap.org/api - You will need to create a free account to get an API key. +} \ No newline at end of file diff --git a/dotnet/agent-framework/sample-agent/appsettings.json b/dotnet/agent-framework/sample-agent/appsettings.json new file mode 100644 index 00000000..9e346c48 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/appsettings.json @@ -0,0 +1,64 @@ +{ + "AgentApplication": { + "StartTypingTimer": false, + "RemoveRecipientMention": false, + "NormalizeMentions": false, + "UserAuthorization": { + "AutoSignin": false, + "Handlers": { + "agentic": { + "Type": "AgenticUserAuthorization", + "Settings": { + "Scopes": [ + "https://graph.microsoft.com/.default" + ] + } + } + } + } + }, + + + "TokenValidation": { + "Audiences": [ + "{{ClientId}}" // this is the Client ID used for the Azure Bot + ] + }, + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Agents": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.SemanticKernel*": "Warning" + } + }, + "AllowedHosts": "*", + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "UserManagedIdentity", // this is the AuthType for the connection, valid values can be found in Microsoft.Agents.Authentication.Msal.Model.AuthTypes. + "AuthorityEndpoint": "https://login.microsoftonline.com/{{BOT_TENANT_ID}}", + "ClientId": "{{BOT_ID}}", // this is the BluePrint Client ID used for the connection. + "Scopes": [ + "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default" + ] + } + } + }, + "ConnectionsMap": [ + { + "ServiceUrl": "*", + "Connection": "ServiceConnection" + } + ], + "AIServices": { + "AzureOpenAI": { + "DeploymentName": "----", // This is the Deployment (as opposed to model) Name of the Azure OpenAI model + "Endpoint": "----", // This is the Endpoint of the Azure OpenAI model deployment + "ApiKey": "----" // This is the API Key of the Azure OpenAI model deployment + } + }, + "OpenWeatherApiKey": "----" //https://openweathermap.org/price - You will need to create a free account to get an API key (its at the bottom of the page). +} diff --git a/dotnet/agent-framework/sample-agent/telemetry/A365OtelWrapper.cs b/dotnet/agent-framework/sample-agent/telemetry/A365OtelWrapper.cs new file mode 100644 index 00000000..5eaa8150 --- /dev/null +++ b/dotnet/agent-framework/sample-agent/telemetry/A365OtelWrapper.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.Observability.Caching; +using Microsoft.Agents.A365.Observability.Runtime.Common; +using Microsoft.Agents.A365.Runtime.Utils; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App.UserAuth; +using Microsoft.Agents.Builder.State; + +namespace Agent365AgentFrameworkSampleAgent.telemetry +{ + public static class A365OtelWrapper + { + public static async Task InvokeObservedAgentOperation( + string operationName, + ITurnContext turnContext, + ITurnState turnState, + IExporterTokenCache? agentTokenCache, + UserAuthorization authSystem, + string authHandlerName, + ILogger? logger, + Func func + ) + { + // Wrap the operation with AgentSDK observability. + await AgentMetrics.InvokeObservedAgentOperation( + operationName, + turnContext, + async () => + { + // Resolve the tenant and agent id being used to communicate with A365 services. + (string agentId, string tenantId) = await ResolveTenantAndAgentId(turnContext, authSystem, authHandlerName); + + using var baggageScope = new BaggageBuilder() + .TenantId(tenantId) + .AgentId(agentId) + .Build(); + + try + { + agentTokenCache?.RegisterObservability(agentId, tenantId, new AgenticTokenStruct + { + UserAuthorization = authSystem, + TurnContext = turnContext, + AuthHandlerName = authHandlerName + }, EnvironmentUtils.GetObservabilityAuthenticationScope()); + } + catch (Exception ex) + { + logger?.LogWarning($"There was an error registering for observability: {ex.Message}"); + } + + // Invoke the actual operation. + await func().ConfigureAwait(false); + }).ConfigureAwait(false); + } + + /// + /// Resolve Tenant and Agent Id from the turn context. + /// + /// + /// + private static async Task<(string agentId, string tenantId)> ResolveTenantAndAgentId(ITurnContext turnContext, UserAuthorization authSystem, string authHandlerName) + { + string agentId = ""; + if (turnContext.Activity.IsAgenticRequest()) + { + agentId = turnContext.Activity.GetAgenticInstanceId(); + } + else + { + if (authSystem != null && !string.IsNullOrEmpty(authHandlerName)) + agentId = Utility.ResolveAgentIdentity(turnContext, await authSystem.GetTurnTokenAsync(turnContext, authHandlerName)); + } + agentId = agentId ?? Guid.Empty.ToString(); + string? tempTenantId = turnContext?.Activity?.Conversation?.TenantId ?? turnContext?.Activity?.Recipient?.TenantId; + string tenantId = tempTenantId ?? Guid.Empty.ToString(); + + return (agentId, tenantId); + } + + } +} diff --git a/dotnet/agent-framework/sample-agent/telemetry/AgentMetrics.cs b/dotnet/agent-framework/sample-agent/telemetry/AgentMetrics.cs new file mode 100644 index 00000000..a43af9fb --- /dev/null +++ b/dotnet/agent-framework/sample-agent/telemetry/AgentMetrics.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Agent365AgentFrameworkSampleAgent.telemetry +{ + public static class AgentMetrics + { + public static readonly string SourceName = "A365.AgentFramework"; + + public static readonly ActivitySource ActivitySource = new(SourceName); + + private static readonly Meter Meter = new ("A365.AgentFramework", "1.0.0"); + + public static readonly Counter MessageProcessedCounter = Meter.CreateCounter( + "agent.messages.processed", + "messages", + "Number of messages processed by the agent"); + + public static readonly Counter RouteExecutedCounter = Meter.CreateCounter( + "agent.routes.executed", + "routes", + "Number of routes executed by the agent"); + + public static readonly Histogram MessageProcessingDuration = Meter.CreateHistogram( + "agent.message.processing.duration", + "ms", + "Duration of message processing in milliseconds"); + + public static readonly Histogram RouteExecutionDuration = Meter.CreateHistogram( + "agent.route.execution.duration", + "ms", + "Duration of route execution in milliseconds"); + + public static readonly UpDownCounter ActiveConversations = Meter.CreateUpDownCounter( + "agent.conversations.active", + "conversations", + "Number of active conversations"); + + + public static Activity InitializeMessageHandlingActivity(string handlerName, ITurnContext context) + { + var activity = ActivitySource.StartActivity(handlerName); + activity?.SetTag("Activity.Type", context.Activity.Type.ToString()); + activity?.SetTag("Agent.IsAgentic", context.IsAgenticRequest()); + activity?.SetTag("Caller.Id", context.Activity.From?.Id); + activity?.SetTag("Conversation.Id", context.Activity.Conversation?.Id); + activity?.SetTag("Channel.Id", context.Activity.ChannelId?.ToString()); + activity?.SetTag("Message.Text.Length", context.Activity.Text?.Length ?? 0); + + activity?.AddEvent(new ActivityEvent("Message.Processed", DateTimeOffset.UtcNow, new() + { + ["Agent.IsAgentic"] = context.IsAgenticRequest(), + ["Caller.Id"] = context.Activity.From?.Id, + ["Channel.Id"] = context.Activity.ChannelId?.ToString(), + ["Message.Id"] = context.Activity.Id, + ["Message.Text"] = context.Activity.Text + })); + return activity!; + } + + public static void FinalizeMessageHandlingActivity(Activity activity, ITurnContext context, long duration, bool success) + { + MessageProcessingDuration.Record(duration, + new("Conversation.Id", context.Activity.Conversation?.Id ?? "unknown"), + new("Channel.Id", context.Activity.ChannelId?.ToString() ?? "unknown")); + + RouteExecutedCounter.Add(1, + new("Route.Type", "message_handler"), + new("Conversation.Id", context.Activity.Conversation?.Id ?? "unknown")); + + if (success) + { + activity?.SetStatus(ActivityStatusCode.Ok); + } + else + { + activity?.SetStatus(ActivityStatusCode.Error); + } + activity?.Stop(); + activity?.Dispose(); + } + + public static Task InvokeObservedHttpOperation(string operationName, Action func) + { + using var activity = ActivitySource.StartActivity(operationName); + try + { + func(); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, new() + { + ["exception.type"] = ex.GetType().FullName, + ["exception.message"] = ex.Message, + ["exception.stacktrace"] = ex.StackTrace + })); + throw; + } + return Task.CompletedTask; + } + + public static Task InvokeObservedAgentOperation(string operationName, ITurnContext context, Func func) + { + MessageProcessedCounter.Add(1); + // Init the activity for observability + var activity = InitializeMessageHandlingActivity(operationName, context); + var routeStopwatch = Stopwatch.StartNew(); + try + { + return func(); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, new() + { + ["exception.type"] = ex.GetType().FullName, + ["exception.message"] = ex.Message, + ["exception.stacktrace"] = ex.StackTrace + })); + throw; + } + finally + { + routeStopwatch.Stop(); + FinalizeMessageHandlingActivity(activity, context, routeStopwatch.ElapsedMilliseconds, true); + } + } + } +} diff --git a/dotnet/agent-framework/sample-agent/telemetry/AgentOTELExtensions.cs b/dotnet/agent-framework/sample-agent/telemetry/AgentOTELExtensions.cs new file mode 100644 index 00000000..6e8fa9ae --- /dev/null +++ b/dotnet/agent-framework/sample-agent/telemetry/AgentOTELExtensions.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Agent365AgentFrameworkSampleAgent.telemetry +{ + // Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. + // This can be used by ASP.NET Core apps, Azure Functions, and other .NET apps using the Generic Host. + // This allows you to use the local aspire desktop and monitor Agents SDK operations. + // To learn more about using the local aspire desktop, see https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone?tabs=bash + public static class AgentOTELExtensions + { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .ConfigureResource(r => r + .Clear() + .AddService( + serviceName: "A365.AgentFramework", + serviceVersion: "1.0.0", + serviceInstanceId: Environment.MachineName) + .AddAttributes(new Dictionary + { + ["deployment.environment"] = builder.Environment.EnvironmentName, + ["service.namespace"] = "Microsoft.Agents" + })) + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter("agent.messages.processed", + "agent.routes.executed", + "agent.conversations.active", + "agent.route.execution.duration", + "agent.message.processing.duration"); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddSource( + "A365.AgentFramework", + "Microsoft.Agents.Builder", + "Microsoft.Agents.Hosting", + "A365.AgentFramework.MyAgent", + "Microsoft.AspNetCore", + "System.Net.Http" + ) + .AddAspNetCoreInstrumentation(tracing => + { + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath); + tracing.RecordException = true; + tracing.EnrichWithHttpRequest = (activity, request) => + { + activity.SetTag("http.request.body.size", request.ContentLength); + activity.SetTag("user_agent", request.Headers.UserAgent); + }; + tracing.EnrichWithHttpResponse = (activity, response) => + { + activity.SetTag("http.response.body.size", response.ContentLength); + }; + }) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(o => + { + o.RecordException = true; + // Enrich outgoing request/response with extra tags + o.EnrichWithHttpRequestMessage = (activity, request) => + { + activity.SetTag("http.request.method", request.Method); + activity.SetTag("http.request.host", request.RequestUri?.Host); + activity.SetTag("http.request.useragent", request.Headers?.UserAgent); + }; + o.EnrichWithHttpResponseMessage = (activity, response) => + { + activity.SetTag("http.response.status_code", (int)response.StatusCode); + //activity.SetTag("http.response.headers", response.Content.Headers); + // Convert response.Content.Headers to a string array: "HeaderName=val1,val2" + var headerList = response.Content?.Headers? + .Select(h => $"{h.Key}={string.Join(",", h.Value)}") + .ToArray(); + + if (headerList is { Length: > 0 }) + { + // Set as an array tag (preferred for OTEL exporters supporting array-of-primitive attributes) + activity.SetTag("http.response.headers", headerList); + + // (Optional) Also emit individual header tags (comment out if too high-cardinality) + // foreach (var h in response.Content.Headers) + // { + // activity.SetTag($"http.response.header.{h.Key.ToLowerInvariant()}", string.Join(",", h.Value)); + // } + } + + }; + // Example filter: suppress telemetry for health checks + o.FilterHttpRequestMessage = request => + !request.RequestUri?.AbsolutePath.Contains("health", StringComparison.OrdinalIgnoreCase) ?? true; + }); + }); + + //builder.AddOpenTelemetryExporters(); + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + } +} diff --git a/dotnet/semantic-kernel/sample-agent/SemanticKernelSampleAgent.sln b/dotnet/semantic-kernel/SemanticKernelSampleAgent.sln similarity index 66% rename from dotnet/semantic-kernel/sample-agent/SemanticKernelSampleAgent.sln rename to dotnet/semantic-kernel/SemanticKernelSampleAgent.sln index a6cd1206..d4504969 100644 --- a/dotnet/semantic-kernel/sample-agent/SemanticKernelSampleAgent.sln +++ b/dotnet/semantic-kernel/SemanticKernelSampleAgent.sln @@ -1,25 +1,25 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36414.22 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SemanticKernelSampleAgent", "SemanticKernelSampleAgent.csproj", "{6BE5ABEC-BEAF-9CF4-A98A-6C73FD961C90}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {6BE5ABEC-BEAF-9CF4-A98A-6C73FD961C90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6BE5ABEC-BEAF-9CF4-A98A-6C73FD961C90}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6BE5ABEC-BEAF-9CF4-A98A-6C73FD961C90}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6BE5ABEC-BEAF-9CF4-A98A-6C73FD961C90}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {4BB18351-B926-45B2-918B-D5E337BA126F} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36414.22 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SemanticKernelSampleAgent", "sample-agent\SemanticKernelSampleAgent.csproj", "{8CBB159F-2929-49A8-C300-E6F8194FB636}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {8CBB159F-2929-49A8-C300-E6F8194FB636}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CBB159F-2929-49A8-C300-E6F8194FB636}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CBB159F-2929-49A8-C300-E6F8194FB636}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CBB159F-2929-49A8-C300-E6F8194FB636}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4BB18351-B926-45B2-918B-D5E337BA126F} + EndGlobalSection +EndGlobal diff --git a/dotnet/semantic-kernel/sample-agent/.gitignore b/dotnet/semantic-kernel/sample-agent/.gitignore new file mode 100644 index 00000000..572d3606 --- /dev/null +++ b/dotnet/semantic-kernel/sample-agent/.gitignore @@ -0,0 +1,234 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates +target/ + +# Cake +/.cake +/version.txt +/PSRunCmds*.ps1 + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +/bin/ +/binSigned/ +/obj/ +Drop/ +target/ +Symbols/ +objd/ +.config/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +#nodeJS stuff +/node_modules/ + +#local development +appsettings.local.json +appsettings.Development.json +appsettings.Development* +appsettings.Production.json +**/[Aa]ppManifest/*.zip +.deployment + +# JetBrains Rider +*.sln.iml +.idea + +# Mac files +.DS_Store + +# VS Code files +.vscode +src/samples/ModelContextProtocol/GitHubMCPServer/Properties/ServiceDependencies/GitHubMCPServer20250311143114 - Web Deploy/profile.arm.json + +# Agent SDK generated files +*.transcript \ No newline at end of file diff --git a/dotnet/semantic-kernel/sample-agent/Agents/Agent365Agent.cs b/dotnet/semantic-kernel/sample-agent/Agents/Agent365Agent.cs index 263b19e1..37332a85 100644 --- a/dotnet/semantic-kernel/sample-agent/Agents/Agent365Agent.cs +++ b/dotnet/semantic-kernel/sample-agent/Agents/Agent365Agent.cs @@ -1,20 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; -using System.Text; -using System.Text.Json.Nodes; -using System.Threading.Tasks; using Agent365SemanticKernelSampleAgent.Plugins; using Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services; using Microsoft.Agents.Builder; using Microsoft.Agents.Builder.App.UserAuth; -using Microsoft.Agents.Builder.UserAuth; using Microsoft.Extensions.Configuration; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Agents; using Microsoft.SemanticKernel.ChatCompletion; using Microsoft.SemanticKernel.Connectors.OpenAI; +using System; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Tasks; namespace Agent365SemanticKernelSampleAgent.Agents; @@ -38,31 +37,42 @@ You are a friendly assistant that helps office workers with their daily tasks. }} "; - /// - /// Initializes a new instance of the class. - /// - private Agent365Agent() - { - } - + private string AgentInstructions_Streaming() => $@" + You are a friendly assistant that helps office workers with their daily tasks. + {(MyAgent.TermsAndConditionsAccepted ? TermsAndConditionsAcceptedInstructions : TermsAndConditionsNotAcceptedInstructions)} + + Respond in Markdown format + "; + public static async Task CreateA365AgentWrapper(Kernel kernel, IServiceProvider service, IMcpToolRegistrationService toolService, string authHandlerName, UserAuthorization userAuthorization, ITurnContext turnContext, IConfiguration configuration) { var _agent = new Agent365Agent(); - await _agent.InitializeAgent365Agent(kernel, service, toolService, userAuthorization, authHandlerName, turnContext, configuration).ConfigureAwait(false); + await _agent.InitializeAgent365Agent(kernel, service, toolService, userAuthorization, authHandlerName, turnContext, configuration).ConfigureAwait(false); return _agent; } - public async Task InitializeAgent365Agent(Kernel kernel, IServiceProvider service, IMcpToolRegistrationService toolService, UserAuthorization userAuthorization, string authHandlerName, ITurnContext turnContext, IConfiguration configuration) - { + /// + /// + /// + public Agent365Agent(){} + + /// + /// Initializes a new instance of the class. + /// + /// The service provider to use for dependency injection. + public async Task InitializeAgent365Agent(Kernel kernel, IServiceProvider service, IMcpToolRegistrationService toolService, UserAuthorization userAuthorization , string authHandlerName, ITurnContext turnContext, IConfiguration configuration) + { this._kernel = kernel; - - // Only add the A365 tools if the user has accepted the terms and conditions + + // Only add the A365 tools if the user has accepted the terms and conditions if (MyAgent.TermsAndConditionsAccepted) { // Provide the tool service with necessary parameters to connect to A365 this._kernel.ImportPluginFromType(); - await toolService.AddToolServersToAgentAsync(kernel, userAuthorization, authHandlerName, turnContext).ConfigureAwait(false); + await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Loading tools..."); + + await toolService.AddToolServersToAgentAsync(kernel, userAuthorization, authHandlerName, turnContext); } else { @@ -75,7 +85,7 @@ public async Task InitializeAgent365Agent(Kernel kernel, IServiceProvider servic new() { Id = turnContext.Activity.Recipient.AgenticAppId ?? Guid.NewGuid().ToString(), - Instructions = AgentInstructions(), + Instructions = turnContext.StreamingResponse.IsStreamingChannel ? AgentInstructions_Streaming() : AgentInstructions(), Name = AgentName, Kernel = this._kernel, Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() @@ -83,9 +93,9 @@ public async Task InitializeAgent365Agent(Kernel kernel, IServiceProvider servic #pragma warning disable SKEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(options: new() { RetainArgumentTypes = true }), #pragma warning restore SKEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - ResponseFormat = "json_object", + ResponseFormat = turnContext.StreamingResponse.IsStreamingChannel ? "text" : "json_object", }), - }; + }; } /// @@ -93,35 +103,59 @@ public async Task InitializeAgent365Agent(Kernel kernel, IServiceProvider servic /// /// A message to process. /// An instance of - public async Task InvokeAgentAsync(string input, ChatHistory chatHistory) + public async Task InvokeAgentAsync(string input, ChatHistory chatHistory, ITurnContext? context = null) { ArgumentNullException.ThrowIfNull(chatHistory); AgentThread thread = new ChatHistoryAgentThread(); ChatMessageContent message = new(AuthorRole.User, input); chatHistory.Add(message); - StringBuilder sb = new(); - await foreach (ChatMessageContent response in this._agent.InvokeAsync(chatHistory, thread: thread)) - { - chatHistory.Add(response); - sb.Append(response.Content); + if (context!.StreamingResponse.IsStreamingChannel) + { + await foreach (var response in _agent!.InvokeStreamingAsync(chatHistory, thread: thread)) + { + if (!string.IsNullOrEmpty(response.Message.Content)) + { + context?.StreamingResponse.QueueTextChunk(response.Message.Content); + } + } + return new Agent365AgentResponse() + { + Content = "Boo", + ContentType = Enum.Parse("text", true) + }; ; } + else + { + StringBuilder sb = new(); + await foreach (ChatMessageContent response in _agent!.InvokeAsync(chatHistory, thread: thread)) + { + if (!string.IsNullOrEmpty(response.Content)) + { + var jsonNode = JsonNode.Parse(response.Content); + context?.StreamingResponse.QueueTextChunk(jsonNode!["content"]!.ToString()); + } + + chatHistory.Add(response); + sb.Append(response.Content); + } - // Make sure the response is in the correct format and retry if necessary - try - { - string resultContent = sb.ToString(); - var jsonNode = JsonNode.Parse(resultContent); - Agent365AgentResponse result = new() - { - Content = jsonNode!["content"]!.ToString(), - ContentType = Enum.Parse(jsonNode["contentType"]!.ToString(), true) - }; - return result; - } - catch (Exception je) - { - return await InvokeAgentAsync($"That response did not match the expected format. Please try again. Error: {je.Message}", chatHistory); + // Make sure the response is in the correct format and retry if necessary + try + { + string resultContent = sb.ToString(); + var jsonNode = JsonNode.Parse(resultContent); + Agent365AgentResponse result = new() + { + Content = jsonNode!["content"]!.ToString(), + ContentType = Enum.Parse(jsonNode["contentType"]!.ToString(), true) + }; + return result; + } + catch (Exception je) + { + return await InvokeAgentAsync($"That response did not match the expected format. Please try again. Error: {je.Message}", chatHistory); + } } } } diff --git a/dotnet/semantic-kernel/sample-agent/Agents/MyAgent.cs b/dotnet/semantic-kernel/sample-agent/Agents/MyAgent.cs new file mode 100644 index 00000000..fcecf898 --- /dev/null +++ b/dotnet/semantic-kernel/sample-agent/Agents/MyAgent.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Agent365SemanticKernelSampleAgent.Agents; +using Agent365SemanticKernelSampleAgent.telemetry; +using AgentNotification; +using Microsoft.Agents.A365.Notifications.Models; +using Microsoft.Agents.A365.Observability.Caching; +using Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.Core.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Agent365SemanticKernelSampleAgent.Agents; + +public class MyAgent : AgentApplication +{ + private readonly Kernel _kernel; + private readonly IMcpToolRegistrationService _toolsService; + private readonly IExporterTokenCache _agentTokenCache; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + // Setup reusable auto sign-in handlers + private readonly string AgenticIdAuthHanlder = "agentic"; + private readonly string MyAuthHanlder = "me"; + + + internal static bool IsApplicationInstalled { get; set; } = false; + internal static bool TermsAndConditionsAccepted { get; set; } = false; + + public MyAgent(AgentApplicationOptions options, IConfiguration configuration, Kernel kernel, IMcpToolRegistrationService toolService, IExporterTokenCache agentTokenCache, ILogger logger) : base(options) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + _kernel = kernel ?? throw new ArgumentNullException(nameof(kernel)); + _toolsService = toolService ?? throw new ArgumentNullException(nameof(toolService)); + _agentTokenCache = agentTokenCache ?? throw new ArgumentNullException(nameof(agentTokenCache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + + // Disable for development purpose. In production, you would typically want to have the user accept the terms and conditions on first use and then store that in a retrievable location. + TermsAndConditionsAccepted = true; + + + // Register Agentic specific Activity routes. These will only be used if the incoming Activity is Agentic. + this.OnAgentNotification("*", AgentNotificationActivityAsync, RouteRank.Last, autoSignInHandlers: new[] { AgenticIdAuthHanlder }); + OnActivity(ActivityTypes.InstallationUpdate, OnHireMessageAsync, isAgenticOnly: true, autoSignInHandlers: new[] { AgenticIdAuthHanlder }); + OnActivity(ActivityTypes.Message, MessageActivityAsync, rank: RouteRank.Last, isAgenticOnly: true, autoSignInHandlers: new[] { AgenticIdAuthHanlder }); + OnActivity(ActivityTypes.Message, MessageActivityAsync, rank: RouteRank.Last, isAgenticOnly: false, autoSignInHandlers: new[] { MyAuthHanlder }); + } + + /// + /// This processes messages sent to the agent from chat clients. + /// + /// + /// + /// + /// + protected async Task MessageActivityAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + string ObservabilityAuthHandlerName = ""; + string ToolAuthHandlerName = ""; + if (turnContext.IsAgenticRequest()) + { + ObservabilityAuthHandlerName = AgenticIdAuthHanlder; + ToolAuthHandlerName = AgenticIdAuthHanlder; + } + else + { + ObservabilityAuthHandlerName = MyAuthHanlder; + ToolAuthHandlerName = MyAuthHanlder; + } + // Init the activity for observability + + await A365OtelWrapper.InvokeObservedAgentOperation( + "MessageProcessor", + turnContext, + turnState, + _agentTokenCache, + UserAuthorization, + ObservabilityAuthHandlerName, + _logger, + async () => + { + + // Setup local service connection + ServiceCollection serviceCollection = [ + new ServiceDescriptor(typeof(ITurnState), turnState), + new ServiceDescriptor(typeof(ITurnContext), turnContext), + new ServiceDescriptor(typeof(Kernel), _kernel), + ]; + + // Disabled for development purposes. + //if (!IsApplicationInstalled) + //{ + // await turnContext.SendActivityAsync(MessageFactory.Text("Please install the application before sending messages."), cancellationToken); + // return; + //} + + var agent365Agent = await GetAgent365Agent(serviceCollection, turnContext, ToolAuthHandlerName); + if (!TermsAndConditionsAccepted) + { + if (turnContext.Activity.ChannelId.Channel == Channels.Msteams) + { + var response = await agent365Agent.InvokeAgentAsync(turnContext.Activity.Text, new ChatHistory()); + await OutputResponseAsync(turnContext, turnState, response, cancellationToken); + return; + } + } + + if (turnContext.Activity.ChannelId.IsParentChannel(Channels.Msteams)) + { + await TeamsMessageActivityAsync(agent365Agent, turnContext, turnState, cancellationToken); + } + else + { + await turnContext.SendActivityAsync(MessageFactory.Text($"Sorry, I do not know how to respond to messages from channel '{turnContext.Activity.ChannelId}'."), cancellationToken); + } + }).ConfigureAwait(false); + } + + /// + /// This processes A365 Agent Notification Activities sent to the agent. + /// + /// + /// + /// + /// + /// + private async Task AgentNotificationActivityAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity agentNotificationActivity, CancellationToken cancellationToken) + { + + string ObservabilityAuthHandlerName = ""; + string ToolAuthHandlerName = ""; + if (turnContext.IsAgenticRequest()) + { + ObservabilityAuthHandlerName = AgenticIdAuthHanlder; + ToolAuthHandlerName = AgenticIdAuthHanlder; + } + else + { + ObservabilityAuthHandlerName = MyAuthHanlder; + ToolAuthHandlerName = MyAuthHanlder; + } + // Init the activity for observability + await A365OtelWrapper.InvokeObservedAgentOperation( + "AgentNotificationActivityAsync", + turnContext, + turnState, + _agentTokenCache, + UserAuthorization, + ObservabilityAuthHandlerName, + _logger, + async () => + { + // Setup local service connection + ServiceCollection serviceCollection = [ + new ServiceDescriptor(typeof(ITurnState), turnState), + new ServiceDescriptor(typeof(ITurnContext), turnContext), + new ServiceDescriptor(typeof(Kernel), _kernel), + ]; + + //if (!IsApplicationInstalled) + //{ + // await turnContext.SendActivityAsync(MessageFactory.Text("Please install the application before sending notifications."), cancellationToken); + // return; + //} + + var agent365Agent = await GetAgent365Agent(serviceCollection, turnContext, ToolAuthHandlerName); + if (!TermsAndConditionsAccepted) + { + var response = await agent365Agent.InvokeAgentAsync(turnContext.Activity.Text, new ChatHistory()); + await OutputResponseAsync(turnContext, turnState, response, cancellationToken); + return; + } + + switch (agentNotificationActivity.NotificationType) + { + case NotificationTypeEnum.EmailNotification: + // Streaming response is not useful for this as this is a notification + + if (agentNotificationActivity.EmailNotification == null) + { + var responseEmailActivity = EmailResponse.CreateEmailResponseActivity("I could not find the email notification details."); + await turnContext.SendActivityAsync(responseEmailActivity, cancellationToken); + return; + } + + try + { + var chatHistory = new ChatHistory(); + var emailContent = await agent365Agent.InvokeAgentAsync($"You have a new email from {agentNotificationActivity.From.Name} with id '{agentNotificationActivity.EmailNotification.Id}', ConversationId '{agentNotificationActivity.EmailNotification.ConversationId}'. Please retrieve this message and return it in text format.", chatHistory); + var response = await agent365Agent.InvokeAgentAsync($"You have received the following email. Please follow any instructions in it. {emailContent.Content}", chatHistory); + response ??= new Agent365AgentResponse + { + Content = "I have processed your email but do not have a response at this time.", + ContentType = Agent365AgentResponseContentType.Text + }; + var responseEmailActivity = EmailResponse.CreateEmailResponseActivity(response.Content!); + await turnContext.SendActivityAsync(responseEmailActivity, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError($"There was an error processing the email notification: {ex.Message}"); + var responseEmailActivity = EmailResponse.CreateEmailResponseActivity("Unable to process your email at this time."); + await turnContext.SendActivityAsync(responseEmailActivity, cancellationToken); + } + return; + case NotificationTypeEnum.WpxComment: + try + { + await turnContext.StreamingResponse.QueueInformativeUpdateAsync($"Thanks for the Word notification! Working on a response...", cancellationToken); + if (agentNotificationActivity.WpxCommentNotification == null) + { + turnContext.StreamingResponse.QueueTextChunk("I could not find the Word notification details."); + await turnContext.StreamingResponse.EndStreamAsync(cancellationToken); + return; + } + var driveId = "default"; + var chatHistory = new ChatHistory(); + var wordContent = await agent365Agent.InvokeAgentAsync($"You have a new comment on the Word document with id '{agentNotificationActivity.WpxCommentNotification.DocumentId}', comment id '{agentNotificationActivity.WpxCommentNotification.ParentCommentId}', drive id '{driveId}'. Please retrieve the Word document as well as the comments in the Word document and return it in text format.", chatHistory); + + var commentToAgent = agentNotificationActivity.Text; + var response = await agent365Agent.InvokeAgentAsync($"You have received the following Word document content and comments. Please follow refer to these when responding to comment '{commentToAgent}'. {wordContent.Content}", chatHistory); + var responseWpxActivity = MessageFactory.Text(response.Content!); + await turnContext.SendActivityAsync(responseWpxActivity, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError($"There was an error processing the mention notification: {ex.Message}"); + var responseWpxActivity = MessageFactory.Text("Unable to process your mention comment at this time."); + await turnContext.SendActivityAsync(responseWpxActivity, cancellationToken); + } + return; + } + }).ConfigureAwait(false); + } + + + /// + /// Process Agent Onboard Event. + /// + /// + /// + /// + /// + protected async Task OnHireMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + string ObservabilityAuthHandlerName = ""; + if (turnContext.IsAgenticRequest()) + { + ObservabilityAuthHandlerName = AgenticIdAuthHanlder; + } + else + { + ObservabilityAuthHandlerName = MyAuthHanlder; + } + // Init the activity for observability + await A365OtelWrapper.InvokeObservedAgentOperation( + "OnHireMessageAsync", + turnContext, + turnState, + _agentTokenCache, + UserAuthorization, + ObservabilityAuthHandlerName, + _logger, + async () => + { + + if (turnContext.Activity.Action == InstallationUpdateActionTypes.Add) + { + IsApplicationInstalled = true; + TermsAndConditionsAccepted = turnContext.IsAgenticRequest() ? true : false; + + string message = $"Thank you for hiring me! Looking forward to assisting you in your professional journey!"; + if (!turnContext.IsAgenticRequest()) + { + message += "Before I begin, could you please confirm that you accept the terms and conditions?"; + } + + await turnContext.SendActivityAsync(MessageFactory.Text(message), cancellationToken); + } + else if (turnContext.Activity.Action == InstallationUpdateActionTypes.Remove) + { + IsApplicationInstalled = false; + TermsAndConditionsAccepted = false; + await turnContext.SendActivityAsync(MessageFactory.Text("Thank you for your time, I enjoyed working with you."), cancellationToken); + } + }).ConfigureAwait(false); + } + + /// + /// This is the specific handler for teams messages sent to the agent from Teams chat clients. + /// + /// + /// + /// + /// + /// + protected async Task TeamsMessageActivityAsync(Agent365Agent agent365Agent, ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + + // Start a Streaming Process + await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Working on a response for you", cancellationToken); + try + { + ChatHistory chatHistory = turnState.GetValue("conversation.chatHistory", () => new ChatHistory()); + + // Invoke the Agent365Agent to process the message + Agent365AgentResponse response = await agent365Agent.InvokeAgentAsync(turnContext.Activity.Text, chatHistory, turnContext); + } + finally + { + await turnContext.StreamingResponse.EndStreamAsync(cancellationToken); + } + } + + protected async Task OutputResponseAsync(ITurnContext turnContext, ITurnState turnState, Agent365AgentResponse response, CancellationToken cancellationToken) + { + if (response == null) + { + await turnContext.SendActivityAsync("Sorry, I couldn't get an answer at the moment."); + return; + } + + // Create a response message based on the response content type from the Agent365Agent + // Send the response message back to the user. + switch (response.ContentType) + { + case Agent365AgentResponseContentType.Text: + await turnContext.SendActivityAsync(response.Content!); + break; + default: + break; + } + } + + /// + /// Sets up an in context instance of the Agent365Agent.. + /// + /// + /// + /// + /// + private async Task GetAgent365Agent(ServiceCollection serviceCollection, ITurnContext turnContext, string authHandlerName) + { + return await Agent365Agent.CreateA365AgentWrapper(_kernel, serviceCollection.BuildServiceProvider(), _toolsService, authHandlerName, UserAuthorization, turnContext, _configuration).ConfigureAwait(false); + } +} diff --git a/dotnet/semantic-kernel/sample-agent/MyAgent.cs b/dotnet/semantic-kernel/sample-agent/MyAgent.cs deleted file mode 100644 index aa806d32..00000000 --- a/dotnet/semantic-kernel/sample-agent/MyAgent.cs +++ /dev/null @@ -1,313 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Configuration; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; -using Agent365SemanticKernelSampleAgent.Agents; -using AgentNotification; -using Microsoft.Agents.A365.Notifications.Models; -using Microsoft.Agents.A365.Observability.Caching; -using Microsoft.Agents.A365.Observability.Runtime.Common; -using Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services; -using Microsoft.Agents.Builder; -using Microsoft.Agents.Builder.App; -using Microsoft.Agents.Builder.State; -using Microsoft.Agents.Core.Models; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.ChatCompletion; - -namespace Agent365SemanticKernelSampleAgent; - -public class MyAgent : AgentApplication -{ - private const string primaryAuthHandler = "agentic"; - private readonly IConfiguration _configuration; - private readonly Kernel _kernel; - private readonly IMcpToolRegistrationService _toolsService; - private readonly IExporterTokenCache _agentTokenCache; - private readonly ILogger _logger; - - public MyAgent(AgentApplicationOptions options, IConfiguration configuration, Kernel kernel, IMcpToolRegistrationService toolService, IExporterTokenCache agentTokenCache, ILogger logger) : base(options) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _kernel = kernel ?? throw new ArgumentNullException(nameof(kernel)); - _toolsService = toolService ?? throw new ArgumentNullException(nameof(toolService)); - _agentTokenCache = agentTokenCache ?? throw new ArgumentNullException(nameof(agentTokenCache)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - var autoSignInHandlers = new[] { primaryAuthHandler }; - - // Register Agentic specific Activity routes. These will only be used if the incoming Activity is Agentic. - this.OnAgentNotification("*", AgentNotificationActivityAsync,RouteRank.Last, autoSignInHandlers: autoSignInHandlers); - - OnActivity(ActivityTypes.InstallationUpdate, OnHireMessageAsync); - OnActivity(ActivityTypes.Message, MessageActivityAsync, rank: RouteRank.Last, autoSignInHandlers: autoSignInHandlers); - } - - internal static bool IsApplicationInstalled { get; set; } = false; - internal static bool TermsAndConditionsAccepted { get; set; } = false; - - protected async Task MessageActivityAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) - { - // Resolve the tenant and agent id being used to communicate with A365 services. - (string agentId, string tenantId) = await ResolveTenantAndAgentId(turnContext).ConfigureAwait(false); - - using var baggageScope = new BaggageBuilder() - .TenantId(tenantId) - .AgentId(agentId) - .Build(); - try - { - _agentTokenCache.RegisterObservability(agentId, tenantId, new AgenticTokenStruct - { - UserAuthorization = UserAuthorization, - TurnContext = turnContext, - AuthHandlerName = primaryAuthHandler - }, EnvironmentUtils.GetObservabilityAuthenticationScope()); - } - catch (Exception ex) - { - _logger.LogWarning($"There was an error registering for observability: {ex.Message}"); - } - - // Setup local service connection - ServiceCollection serviceCollection = [ - new ServiceDescriptor(typeof(ITurnState), turnState), - new ServiceDescriptor(typeof(ITurnContext), turnContext), - new ServiceDescriptor(typeof(Kernel), _kernel), - ]; - - if (!IsApplicationInstalled) - { - await turnContext.SendActivityAsync(MessageFactory.Text("Please install the application before sending messages."), cancellationToken); - return; - } - - var agent365Agent = await this.GetAgent365AgentAsync(serviceCollection, turnContext, primaryAuthHandler); - if (!TermsAndConditionsAccepted) - { - if (turnContext.Activity.ChannelId.Channel == Channels.Msteams) - { - var response = await agent365Agent.InvokeAgentAsync(turnContext.Activity.Text, new ChatHistory()); - await OutputResponseAsync(turnContext, turnState, response, cancellationToken); - return; - } - } - if (turnContext.Activity.ChannelId.Channel == Channels.Msteams) - { - await TeamsMessageActivityAsync(agent365Agent, turnContext, turnState, cancellationToken); - } - else - { - await turnContext.SendActivityAsync(MessageFactory.Text($"Sorry, I do not know how to respond to messages from channel '{turnContext.Activity.ChannelId}'."), cancellationToken); - } - } - - private async Task AgentNotificationActivityAsync(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity activity, CancellationToken cancellationToken) - { - // Resolve the tenant and agent id being used to communicate with A365 services. - (string agentId, string tenantId) = await ResolveTenantAndAgentId(turnContext).ConfigureAwait(false); - - using var baggageScope = new BaggageBuilder() - .TenantId(tenantId) - .AgentId(agentId) - .Build(); - - try - { - _agentTokenCache.RegisterObservability(agentId, tenantId, new AgenticTokenStruct - { - UserAuthorization = UserAuthorization, - TurnContext = turnContext, - AuthHandlerName = primaryAuthHandler - }, EnvironmentUtils.GetObservabilityAuthenticationScope()); - } - catch (Exception ex) - { - _logger.LogWarning($"There was an error registering for observability: {ex.Message}"); - } - - // Setup local service connection - ServiceCollection serviceCollection = [ - new ServiceDescriptor(typeof(ITurnState), turnState), - new ServiceDescriptor(typeof(ITurnContext), turnContext), - new ServiceDescriptor(typeof(Kernel), _kernel), - ]; - - if (!IsApplicationInstalled) - { - await turnContext.SendActivityAsync(MessageFactory.Text("Please install the application before sending notifications."), cancellationToken); - return; - } - - var agent365Agent = await this.GetAgent365AgentAsync(serviceCollection, turnContext, primaryAuthHandler); - if (!TermsAndConditionsAccepted) - { - var response = await agent365Agent.InvokeAgentAsync(turnContext.Activity.Text, new ChatHistory()); - await OutputResponseAsync(turnContext, turnState, response, cancellationToken); - return; - } - - switch (activity.NotificationType) - { - case NotificationTypeEnum.EmailNotification: - await turnContext.StreamingResponse.QueueInformativeUpdateAsync($"Thanks for the email notification! Working on a response..."); - if (activity.EmailNotification == null) - { - turnContext.StreamingResponse.QueueTextChunk("I could not find the email notification details."); - await turnContext.StreamingResponse.EndStreamAsync(cancellationToken); - return; - } - - var chatHistory = new ChatHistory(); - var emailContent = await agent365Agent.InvokeAgentAsync($"You have a new email from {activity.From.Name} with id '{activity.EmailNotification.Id}', ConversationId '{activity.EmailNotification.ConversationId}'. Please retrieve this message and return it in text format.", chatHistory); - var response = await agent365Agent.InvokeAgentAsync($"You have received the following email. Please follow any instructions in it. {emailContent.Content}", chatHistory); - var responseEmailActivity = MessageFactory.Text(""); - responseEmailActivity.Entities.Add(new EmailResponse(response.Content)); - await turnContext.SendActivityAsync(responseEmailActivity, cancellationToken); - //await OutputResponseAsync(turnContext, turnState, response, cancellationToken); - return; - case NotificationTypeEnum.WpxComment: - await turnContext.StreamingResponse.QueueInformativeUpdateAsync($"Thanks for the Word notification! Working on a response...", cancellationToken); - if (activity.WpxCommentNotification == null) - { - turnContext.StreamingResponse.QueueTextChunk("I could not find the Word notification details."); - await turnContext.StreamingResponse.EndStreamAsync(cancellationToken); - return; - } - var driveId = "default"; - chatHistory = new ChatHistory(); - var wordContent = await agent365Agent.InvokeAgentAsync($"You have a new comment on the Word document with id '{activity.WpxCommentNotification.DocumentId}', comment id '{activity.WpxCommentNotification.ParentCommentId}', drive id '{driveId}'. Please retrieve the Word document as well as the comments in the Word document and return it in text format.", chatHistory); - - var commentToAgent = activity.Text; - response = await agent365Agent.InvokeAgentAsync($"You have received the following Word document content and comments. Please follow refer to these when responding to comment '{commentToAgent}'. {wordContent.Content}", chatHistory); - var responseWpxActivity = MessageFactory.Text(response.Content!); - await turnContext.SendActivityAsync(responseWpxActivity, cancellationToken); - //await OutputResponseAsync(turnContext, turnState, response, cancellationToken); - return; - } - - throw new NotImplementedException(); - } - - protected async Task TeamsMessageActivityAsync(Agent365Agent agent365Agent, ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) - { - // Start a Streaming Process - await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Working on a response for you", cancellationToken); - - ChatHistory chatHistory = turnState.GetValue("conversation.chatHistory", () => new ChatHistory()); - - // Invoke the Agent365Agent to process the message - Agent365AgentResponse response = await agent365Agent.InvokeAgentAsync(turnContext.Activity.Text, chatHistory); - await OutputResponseAsync(turnContext, turnState, response, cancellationToken); - } - - protected async Task OutputResponseAsync(ITurnContext turnContext, ITurnState turnState, Agent365AgentResponse response, CancellationToken cancellationToken) - { - if (response == null) - { - turnContext.StreamingResponse.QueueTextChunk("Sorry, I couldn't get an answer at the moment."); - await turnContext.StreamingResponse.EndStreamAsync(cancellationToken); - return; - } - - // Create a response message based on the response content type from the Agent365Agent - // Send the response message back to the user. - switch (response.ContentType) - { - case Agent365AgentResponseContentType.Text: - turnContext.StreamingResponse.QueueTextChunk(response.Content!); - break; - default: - break; - } - await turnContext.StreamingResponse.EndStreamAsync(cancellationToken); // End the streaming response - } - - protected async Task OnHireMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) - { - // Resolve the tenant and agent id being used to communicate with A365 services. - (string agentId, string tenantId) = await ResolveTenantAndAgentId(turnContext).ConfigureAwait(false); - - using var baggageScope = new BaggageBuilder() - .TenantId(tenantId) - .AgentId(agentId) - .Build(); - - try - { - _agentTokenCache.RegisterObservability(agentId, tenantId, new AgenticTokenStruct - { - UserAuthorization = UserAuthorization, - TurnContext = turnContext, - AuthHandlerName = primaryAuthHandler - }, EnvironmentUtils.GetObservabilityAuthenticationScope()); - } - catch (Exception ex) - { - _logger.LogWarning($"There was an error registering for observability: {ex.Message}"); - } - - if (turnContext.Activity.Action == InstallationUpdateActionTypes.Add) - { - bool useAgenticAuth = Environment.GetEnvironmentVariable("USE_AGENTIC_AUTH") == "true"; - - IsApplicationInstalled = true; - TermsAndConditionsAccepted = useAgenticAuth ? true : false; - - string message = $"Thank you for hiring me! Looking forward to assisting you in your professional journey!"; - if (!useAgenticAuth) - { - message += "Before I begin, could you please confirm that you accept the terms and conditions?"; - } - - await turnContext.SendActivityAsync(MessageFactory.Text(message), cancellationToken); - } - else if (turnContext.Activity.Action == InstallationUpdateActionTypes.Remove) - { - IsApplicationInstalled = false; - TermsAndConditionsAccepted = false; - await turnContext.SendActivityAsync(MessageFactory.Text("Thank you for your time, I enjoyed working with you."), cancellationToken); - } - } - - /// - /// Resolve Tenant and Agent Id from the turn context. - /// - /// - /// - private async Task<(string agentId, string tenantId)> ResolveTenantAndAgentId(ITurnContext turnContext) - { - string agentId = ""; - if (turnContext.Activity.IsAgenticRequest()) - { - agentId = turnContext.Activity.GetAgenticInstanceId(); - } - else - { - agentId = Microsoft.Agents.A365.Runtime.Utils.Utility.GetAppIdFromToken(await UserAuthorization.GetTurnTokenAsync(turnContext, primaryAuthHandler)); - } - string tenantId = turnContext.Activity.Conversation.TenantId ?? turnContext.Activity.Recipient.TenantId; - return (agentId, tenantId); - } - - private async Task GetAgent365AgentAsync(ServiceCollection serviceCollection, ITurnContext turnContext, string authHandlerName) - { - return await Agent365Agent.CreateA365AgentWrapper( - _kernel, - serviceCollection.BuildServiceProvider(), - _toolsService, - authHandlerName, - UserAuthorization, - turnContext, - _configuration).ConfigureAwait(false); - } -} diff --git a/dotnet/semantic-kernel/sample-agent/Plugins/TermsAndConditionsAcceptedPlugin.cs b/dotnet/semantic-kernel/sample-agent/Plugins/TermsAndConditionsAcceptedPlugin.cs index 15d7e22a..1bb32d4e 100644 --- a/dotnet/semantic-kernel/sample-agent/Plugins/TermsAndConditionsAcceptedPlugin.cs +++ b/dotnet/semantic-kernel/sample-agent/Plugins/TermsAndConditionsAcceptedPlugin.cs @@ -1,4 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + using Microsoft.SemanticKernel; +using Agent365SemanticKernelSampleAgent.Agents; using System.ComponentModel; using System.Threading.Tasks; diff --git a/dotnet/semantic-kernel/sample-agent/Plugins/TermsAndConditionsNotAcceptedPlugin.cs b/dotnet/semantic-kernel/sample-agent/Plugins/TermsAndConditionsNotAcceptedPlugin.cs index ae38c94b..76188200 100644 --- a/dotnet/semantic-kernel/sample-agent/Plugins/TermsAndConditionsNotAcceptedPlugin.cs +++ b/dotnet/semantic-kernel/sample-agent/Plugins/TermsAndConditionsNotAcceptedPlugin.cs @@ -1,4 +1,8 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + using Microsoft.SemanticKernel; +using Agent365SemanticKernelSampleAgent.Agents; using System.ComponentModel; using System.Threading.Tasks; diff --git a/dotnet/semantic-kernel/sample-agent/Program.cs b/dotnet/semantic-kernel/sample-agent/Program.cs index e5a1767c..497177b3 100644 --- a/dotnet/semantic-kernel/sample-agent/Program.cs +++ b/dotnet/semantic-kernel/sample-agent/Program.cs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using Agent365SemanticKernelSampleAgent; +using Agent365SemanticKernelSampleAgent.Agents; +using Agent365SemanticKernelSampleAgent.telemetry; using Microsoft.Agents.A365.Observability; using Microsoft.Agents.A365.Observability.Extensions.SemanticKernel; using Microsoft.Agents.A365.Observability.Runtime; @@ -10,17 +11,21 @@ using Microsoft.Agents.Builder; using Microsoft.Agents.Hosting.AspNetCore; using Microsoft.Agents.Storage; +using Microsoft.Agents.Storage.Transcript; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.SemanticKernel; -using System; using System.Threading; + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +// Setup Aspire service defaults, including OpenTelemetry, Service Discovery, Resilience, and Health Checks + builder.ConfigureOpenTelemetry(); + if (builder.Environment.IsDevelopment()) { builder.Configuration.AddUserSecrets(); @@ -53,20 +58,15 @@ } // Configure observability. -if (Environment.GetEnvironmentVariable("EnableKairoS2S") == "true") -{ - builder.Services.AddServiceTracingExporter(clusterCategory: builder.Environment.IsDevelopment() ? "preprod" : "production"); -} -else -{ - builder.Services.AddAgenticTracingExporter(clusterCategory: builder.Environment.IsDevelopment() ? "preprod" : "production"); -} +builder.Services.AddAgenticTracingExporter(clusterCategory: builder.Environment.IsDevelopment() ? "preprod" : "production"); -builder.AddA365Tracing(config => +// Add A365 tracing with Semantic Kernel integration +builder.AddA365Tracing(config => { - config.WithSemanticKernel(); + config.WithSemanticKernel(); }); + // Add AgentApplicationOptions from appsettings section "AgentApplication". builder.AddAgentApplicationOptions(); @@ -84,28 +84,39 @@ builder.Services.AddSingleton(); // Configure the HTTP request pipeline. - -// Add AspNet token validation for Azure Bot Service and Entra. Authentication is -// configured in the appsettings.json "TokenValidation" section. +// Add AspNet token validation for Azure Bot Service and Entra. Authentication is configured in the appsettings.json "TokenValidation" section. builder.Services.AddControllers(); builder.Services.AddAgentAspNetAuthentication(builder.Configuration); +builder.Services.AddSingleton([new TranscriptLoggerMiddleware(new FileTranscriptLogger())]); + WebApplication app = builder.Build(); // Enable AspNet authentication and authorization app.UseAuthentication(); app.UseAuthorization(); -app.MapGet("/", () => "Microsoft Agents SDK Sample"); - // This receives incoming messages from Azure Bot Service or other SDK Agents var incomingRoute = app.MapPost("/api/messages", async (HttpRequest request, HttpResponse response, IAgentHttpAdapter adapter, IAgent agent, CancellationToken cancellationToken) => -{ - await adapter.ProcessAsync(request, response, agent, cancellationToken); +{ + await AgentMetrics.InvokeObservedHttpOperation("agent.process_message", async () => + { + await adapter.ProcessAsync(request, response, agent, cancellationToken); + }).ConfigureAwait(false); }); -// Hardcoded for brevity and ease of testing. -// In production, this should be set in configuration. -app.Urls.Add($"http://localhost:3978"); - +if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Playground") +{ + app.MapGet("/", () => "Agent 365 Semantic Kernel Example Agent"); + app.UseDeveloperExceptionPage(); + app.MapControllers().AllowAnonymous(); + + // Hard coded for brevity and ease of testing. + // In production, this should be set in configuration. + app.Urls.Add($"http://localhost:3978"); +} +else +{ + app.MapControllers(); +} app.Run(); diff --git a/dotnet/semantic-kernel/sample-agent/Properties/launchSettings.json b/dotnet/semantic-kernel/sample-agent/Properties/launchSettings.json index 73ebfe32..8ce01c9e 100644 --- a/dotnet/semantic-kernel/sample-agent/Properties/launchSettings.json +++ b/dotnet/semantic-kernel/sample-agent/Properties/launchSettings.json @@ -5,7 +5,7 @@ "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", - "USE_AGENTIC_AUTH": "false", + "USE_AGENTIC_AUTH": "false" }, "applicationUrl": "https://localhost:64896;http://localhost:64897" } diff --git a/dotnet/semantic-kernel/sample-agent/SemanticKernelSampleAgent.csproj b/dotnet/semantic-kernel/sample-agent/SemanticKernelSampleAgent.csproj index 8dac665e..af011466 100644 --- a/dotnet/semantic-kernel/sample-agent/SemanticKernelSampleAgent.csproj +++ b/dotnet/semantic-kernel/sample-agent/SemanticKernelSampleAgent.csproj @@ -18,9 +18,6 @@ - - - @@ -31,12 +28,18 @@ - - + + - + + + + + + + diff --git a/dotnet/semantic-kernel/sample-agent/ToolingManifest.json b/dotnet/semantic-kernel/sample-agent/ToolingManifest.json index 7d64ac5f..fb64e3fe 100644 --- a/dotnet/semantic-kernel/sample-agent/ToolingManifest.json +++ b/dotnet/semantic-kernel/sample-agent/ToolingManifest.json @@ -14,6 +14,9 @@ }, { "mcpServerName": "mcp_KnowledgeTools" + }, + { + "mcpServerName": "mcp_MeServer" } ] } \ No newline at end of file diff --git a/dotnet/semantic-kernel/sample-agent/appsettings.json b/dotnet/semantic-kernel/sample-agent/appsettings.json index 0b6b4d26..e7a60fa5 100644 --- a/dotnet/semantic-kernel/sample-agent/appsettings.json +++ b/dotnet/semantic-kernel/sample-agent/appsettings.json @@ -1,4 +1,11 @@ { + + "EnableAgent365Exporter": "true", + //"EnableOtlpExporter": "false", // Enabled to use local OTLP exporter for testing + //"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + + + "TokenValidation": { "Enabled": false, "Audiences": [ diff --git a/dotnet/semantic-kernel/sample-agent/telemetry/A365OtelWrapper.cs b/dotnet/semantic-kernel/sample-agent/telemetry/A365OtelWrapper.cs new file mode 100644 index 00000000..e716e942 --- /dev/null +++ b/dotnet/semantic-kernel/sample-agent/telemetry/A365OtelWrapper.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.Observability.Caching; +using Microsoft.Agents.A365.Observability.Runtime.Common; +using Microsoft.Agents.A365.Runtime.Utils; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App.UserAuth; +using Microsoft.Agents.Builder.State; +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; + +namespace Agent365SemanticKernelSampleAgent.telemetry +{ + public static class A365OtelWrapper + { + public static async Task InvokeObservedAgentOperation( + string operationName, + ITurnContext turnContext, + ITurnState turnState, + IExporterTokenCache? agentTokenCache, + UserAuthorization authSystem, + string authHandlerName, + ILogger? logger, + Func func + ) + { + // Wrap the operation with AgentSDK observability. + await AgentMetrics.InvokeObservedAgentOperation( + operationName, + turnContext, + async () => + { + // Resolve the tenant and agent id being used to communicate with A365 services. + (string agentId, string tenantId) = await ResolveTenantAndAgentId(turnContext, authSystem, authHandlerName); + + using var baggageScope = new BaggageBuilder() + .TenantId(tenantId) + .AgentId(agentId) + .Build(); + + try + { + agentTokenCache?.RegisterObservability(agentId, tenantId, new AgenticTokenStruct + { + UserAuthorization = authSystem, + TurnContext = turnContext, + AuthHandlerName = authHandlerName + }, EnvironmentUtils.GetObservabilityAuthenticationScope()); + } + catch (Exception ex) + { + logger?.LogWarning($"There was an error registering for observability: {ex.Message}"); + } + + // Invoke the actual operation. + await func().ConfigureAwait(false); + }).ConfigureAwait(false); + } + + /// + /// Resolve Tenant and Agent Id from the turn context. + /// + /// + /// + private static async Task<(string agentId, string tenantId)> ResolveTenantAndAgentId(ITurnContext turnContext, UserAuthorization authSystem, string authHandlerName) + { + string agentId = ""; + if (turnContext.Activity.IsAgenticRequest()) + { + agentId = turnContext.Activity.GetAgenticInstanceId(); + } + else + { + if (authSystem != null && !string.IsNullOrEmpty(authHandlerName)) + agentId = Utility.ResolveAgentIdentity(turnContext, await authSystem.GetTurnTokenAsync(turnContext, authHandlerName)); + } + agentId = agentId ?? Guid.Empty.ToString(); + string? tempTenantId = turnContext?.Activity?.Conversation?.TenantId ?? turnContext?.Activity?.Recipient?.TenantId; + string tenantId = tempTenantId ?? Guid.Empty.ToString(); + + return (agentId, tenantId); + } + + } +} diff --git a/dotnet/semantic-kernel/sample-agent/telemetry/AgentMetrics.cs b/dotnet/semantic-kernel/sample-agent/telemetry/AgentMetrics.cs new file mode 100644 index 00000000..0b9c7a65 --- /dev/null +++ b/dotnet/semantic-kernel/sample-agent/telemetry/AgentMetrics.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Threading.Tasks; + +namespace Agent365SemanticKernelSampleAgent.telemetry +{ + public static class AgentMetrics + { + public static readonly string SourceName = "A365.SemanticKernel"; + + public static readonly ActivitySource ActivitySource = new(SourceName); + + private static readonly Meter Meter = new ("A365.SemanticKernel", "1.0.0"); + + public static readonly Counter MessageProcessedCounter = Meter.CreateCounter( + "agent.messages.processed", + "messages", + "Number of messages processed by the agent"); + + public static readonly Counter RouteExecutedCounter = Meter.CreateCounter( + "agent.routes.executed", + "routes", + "Number of routes executed by the agent"); + + public static readonly Histogram MessageProcessingDuration = Meter.CreateHistogram( + "agent.message.processing.duration", + "ms", + "Duration of message processing in milliseconds"); + + public static readonly Histogram RouteExecutionDuration = Meter.CreateHistogram( + "agent.route.execution.duration", + "ms", + "Duration of route execution in milliseconds"); + + public static readonly UpDownCounter ActiveConversations = Meter.CreateUpDownCounter( + "agent.conversations.active", + "conversations", + "Number of active conversations"); + + + public static Activity InitializeMessageHandlingActivity(string handlerName, ITurnContext context) + { + var activity = ActivitySource.StartActivity(handlerName); + activity?.SetTag("Activity.Type", context.Activity.Type.ToString()); + activity?.SetTag("Agent.IsAgentic", context.IsAgenticRequest()); + activity?.SetTag("Caller.Id", context.Activity.From?.Id); + activity?.SetTag("Conversation.Id", context.Activity.Conversation?.Id); + activity?.SetTag("Channel.Id", context.Activity.ChannelId?.ToString()); + activity?.SetTag("Message.Text.Length", context.Activity.Text?.Length ?? 0); + + activity?.AddEvent(new ActivityEvent("Message.Processed", DateTimeOffset.UtcNow, new() + { + ["Agent.IsAgentic"] = context.IsAgenticRequest(), + ["Caller.Id"] = context.Activity.From?.Id, + ["Channel.Id"] = context.Activity.ChannelId?.ToString(), + ["Message.Id"] = context.Activity.Id, + ["Message.Text"] = context.Activity.Text + })); + return activity!; + } + + public static void FinalizeMessageHandlingActivity(Activity activity, ITurnContext context, long duration, bool success) + { + MessageProcessingDuration.Record(duration, + new("Conversation.Id", context.Activity.Conversation?.Id ?? "unknown"), + new("Channel.Id", context.Activity.ChannelId?.ToString() ?? "unknown")); + + RouteExecutedCounter.Add(1, + new("Route.Type", "message_handler"), + new("Conversation.Id", context.Activity.Conversation?.Id ?? "unknown")); + + if (success) + { + activity?.SetStatus(ActivityStatusCode.Ok); + } + else + { + activity?.SetStatus(ActivityStatusCode.Error); + } + activity?.Stop(); + activity?.Dispose(); + } + + public static Task InvokeObservedHttpOperation(string operationName, Action func) + { + using var activity = ActivitySource.StartActivity(operationName); + try + { + func(); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, new() + { + ["exception.type"] = ex.GetType().FullName, + ["exception.message"] = ex.Message, + ["exception.stacktrace"] = ex.StackTrace + })); + throw; + } + return Task.CompletedTask; + } + + public static Task InvokeObservedAgentOperation(string operationName, ITurnContext context, Func func) + { + MessageProcessedCounter.Add(1); + // Init the activity for observability + var activity = InitializeMessageHandlingActivity(operationName, context); + var routeStopwatch = Stopwatch.StartNew(); + try + { + return func(); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, new() + { + ["exception.type"] = ex.GetType().FullName, + ["exception.message"] = ex.Message, + ["exception.stacktrace"] = ex.StackTrace + })); + throw; + } + finally + { + routeStopwatch.Stop(); + FinalizeMessageHandlingActivity(activity, context, routeStopwatch.ElapsedMilliseconds, true); + } + } + } +} diff --git a/dotnet/semantic-kernel/sample-agent/telemetry/AgentOTELExtensions.cs b/dotnet/semantic-kernel/sample-agent/telemetry/AgentOTELExtensions.cs new file mode 100644 index 00000000..173067d5 --- /dev/null +++ b/dotnet/semantic-kernel/sample-agent/telemetry/AgentOTELExtensions.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Agent365SemanticKernelSampleAgent.telemetry +{ + // Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. + // This can be used by ASP.NET Core apps, Azure Functions, and other .NET apps using the Generic Host. + // This allows you to use the local aspire desktop and monitor Agents SDK operations. + // To learn more about using the local aspire desktop, see https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone?tabs=bash + public static class AgentOTELExtensions + { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .ConfigureResource(r => r + .Clear() + .AddService( + serviceName: "A365.SemanticKernel", + serviceVersion: "1.0.0", + serviceInstanceId: Environment.MachineName) + .AddAttributes(new Dictionary + { + ["deployment.environment"] = builder.Environment.EnvironmentName, + ["service.namespace"] = "Microsoft.Agents" + })) + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter("agent.messages.processed", + "agent.routes.executed", + "agent.conversations.active", + "agent.route.execution.duration", + "agent.message.processing.duration"); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddSource( + "A365.SemanticKernel", + "A365.SemanticKernel.MyAgent", + "Microsoft.Agents.Builder", + "Microsoft.Agents.Hosting", + "Microsoft.AspNetCore", + "System.Net.Http" + ) + .AddAspNetCoreInstrumentation(tracing => + { + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath); + tracing.RecordException = true; + tracing.EnrichWithHttpRequest = (activity, request) => + { + activity.SetTag("http.request.body.size", request.ContentLength); + activity.SetTag("user_agent", request.Headers.UserAgent); + }; + tracing.EnrichWithHttpResponse = (activity, response) => + { + activity.SetTag("http.response.body.size", response.ContentLength); + }; + }) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(o => + { + o.RecordException = true; + // Enrich outgoing request/response with extra tags + o.EnrichWithHttpRequestMessage = (activity, request) => + { + activity.SetTag("http.request.method", request.Method); + activity.SetTag("http.request.host", request.RequestUri?.Host); + activity.SetTag("http.request.useragent", request.Headers?.UserAgent); + }; + o.EnrichWithHttpResponseMessage = (activity, response) => + { + activity.SetTag("http.response.status_code", (int)response.StatusCode); + //activity.SetTag("http.response.headers", response.Content.Headers); + // Convert response.Content.Headers to a string array: "HeaderName=val1,val2" + var headerList = response.Content?.Headers? + .Select(h => $"{h.Key}={string.Join(",", h.Value)}") + .ToArray(); + + if (headerList is { Length: > 0 }) + { + // Set as an array tag (preferred for OTEL exporters supporting array-of-primitive attributes) + activity.SetTag("http.response.headers", headerList); + + // (Optional) Also emit individual header tags (comment out if too high-cardinality) + // foreach (var h in response.Content.Headers) + // { + // activity.SetTag($"http.response.header.{h.Key.ToLowerInvariant()}", string.Join(",", h.Value)); + // } + } + + }; + // Example filter: suppress telemetry for health checks + o.FilterHttpRequestMessage = request => + !request.RequestUri?.AbsolutePath.Contains("health", StringComparison.OrdinalIgnoreCase) ?? true; + }); + }); + + //builder.AddOpenTelemetryExporters(); + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + } +}