diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/AgentFrameworkSample.sln b/dotnet/obo-auth-samples/agent-framework-appRegistration/AgentFrameworkSample.sln new file mode 100644 index 00000000..bc0bc36d --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/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/obo-auth-samples/agent-framework-appRegistration/sample-agent/.gitignore b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/.gitignore new file mode 100644 index 00000000..572d3606 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/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/obo-auth-samples/agent-framework-appRegistration/sample-agent/Agent/MyAgent.cs b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Agent/MyAgent.cs new file mode 100644 index 00000000..177338b7 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Agent/MyAgent.cs @@ -0,0 +1,452 @@ +// 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 const string AgentWelcomeMessage = "Hello! I can help you find information based on what I can access."; + private const string AgentHireMessage = "Thank you for hiring me! Looking forward to assisting you in your professional journey!"; + private const string AgentFarewellMessage = "Thank you for your time, I enjoyed working with you."; + + // Non-interpolated raw string so {{ToolName}} placeholders are preserved as literal text. + // {userName} is the only dynamic token and is injected via string.Replace in GetAgentInstructions. + private static readonly string AgentInstructionsTemplate = """ + You will speak like a friendly and professional virtual assistant. + + The user's name is {userName}. Use their name naturally where appropriate — for example when greeting them, confirming actions, or making responses feel personal. Do not overuse it. + + 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 static string GetAgentInstructions(string? userName) + { + // Sanitize the display name before injecting into the system prompt to prevent prompt injection. + // Activity.From.Name is channel-provided and therefore untrusted user-controlled text. + string safe = string.IsNullOrWhiteSpace(userName) ? "unknown" : userName.Trim(); + // Strip control characters (newlines, tabs, etc.) that could break prompt structure + safe = System.Text.RegularExpressions.Regex.Replace(safe, @"[\p{Cc}\p{Cf}]", " ").Trim(); + // Enforce a reasonable max length + if (safe.Length > 64) safe = safe[..64].TrimEnd(); + if (string.IsNullOrWhiteSpace(safe)) safe = "unknown"; + return AgentInstructionsTemplate.Replace("{userName}", safe, StringComparison.Ordinal); + } + + private readonly IChatClient? _chatClient = null; + private readonly IConfiguration? _configuration = null; + private readonly IExporterTokenCache? _serviceTokenCache = null; + private readonly ILogger? _logger = null; + private readonly IMcpToolRegistrationService? _toolService = null; + // Setup reusable auto sign-in handlers for user authorization (configurable via appsettings.json) + private readonly string? AgenticAuthHandlerName; + private readonly string? OboAuthHandlerName; + // Temp + private static readonly ConcurrentDictionary> _agentToolCache = new(); + + /// + /// Check if a bearer token is available in the environment for development/testing. + /// + public static bool TryGetBearerTokenForDevelopment(out string? bearerToken) + { + bearerToken = Environment.GetEnvironmentVariable("BEARER_TOKEN"); + return !string.IsNullOrEmpty(bearerToken); + } + + /// + /// Checks if graceful fallback to bare LLM mode is enabled when MCP tools fail to load. + /// This is only allowed in Development environment AND when SKIP_TOOLING_ON_ERRORS is explicitly set to "true". + /// + private static bool ShouldSkipToolingOnErrors() + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? + Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? + "Production"; + + var skipToolingOnErrors = Environment.GetEnvironmentVariable("SKIP_TOOLING_ON_ERRORS"); + + // Only allow skipping tooling errors in Development mode AND when explicitly enabled + return environment.Equals("Development", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(skipToolingOnErrors) && + skipToolingOnErrors.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + public MyAgent(AgentApplicationOptions options, + IChatClient chatClient, + IConfiguration configuration, + IExporterTokenCache serviceTokenCache, + IMcpToolRegistrationService toolService, + ILogger logger) : base(options) + { + _chatClient = chatClient; + _configuration = configuration; + _serviceTokenCache = serviceTokenCache; + _logger = logger; + _toolService = toolService; + + // Read auth handler names from configuration (can be empty/null to disable) + AgenticAuthHandlerName = _configuration.GetValue("AgentApplication:AgenticAuthHandlerName"); + OboAuthHandlerName = _configuration.GetValue("AgentApplication:OboAuthHandlerName"); + + // Greet when members are added to the conversation + OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); + + // Compute auth handler arrays once; reused for all agentic/OBO activity registrations below. + var agenticHandlers = !string.IsNullOrEmpty(AgenticAuthHandlerName) ? [AgenticAuthHandlerName] : Array.Empty(); + var oboHandlers = !string.IsNullOrEmpty(OboAuthHandlerName) ? [OboAuthHandlerName] : Array.Empty(); + + // Handle agent install / uninstall events (agentInstanceCreated / InstallationUpdate). + // Dual registration: agentic (A365 production) and non-agentic (Playground / WebChat). + OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: true, autoSignInHandlers: agenticHandlers); + OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: false); + + // Listen for ANY message to be received. MUST BE AFTER ANY OTHER MESSAGE HANDLERS + // Agentic requests use the agentic auth handler (if configured) + OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: true, autoSignInHandlers: agenticHandlers); + // Non-agentic requests (Playground, WebChat) use OBO auth handler (if configured) + OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: false, autoSignInHandlers: oboHandlers); + } + + 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); + } + } + }); + } + + /// + /// Handles agent install and uninstall events (agentInstanceCreated / InstallationUpdate). + /// Sends a welcome message on install and a farewell on uninstall. + /// + protected async Task OnInstallationUpdateAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + await AgentMetrics.InvokeObservedAgentOperation( + "InstallationUpdate", + turnContext, + async () => + { + _logger?.LogInformation( + "InstallationUpdate received — Action: '{Action}', DisplayName: '{Name}', UserId: '{Id}'", + turnContext.Activity.Action ?? "(none)", + turnContext.Activity.From?.Name ?? "(unknown)", + turnContext.Activity.From?.Id ?? "(unknown)"); + + if (turnContext.Activity.Action == InstallationUpdateActionTypes.Add) + { + await turnContext.SendActivityAsync(MessageFactory.Text(AgentHireMessage), cancellationToken); + } + else if (turnContext.Activity.Action == InstallationUpdateActionTypes.Remove) + { + await turnContext.SendActivityAsync(MessageFactory.Text(AgentFarewellMessage), cancellationToken); + } + }); + } + + /// + /// General Message process for Teams and other channels. + /// + /// + /// + /// + /// + protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + if (turnContext is null) + { + throw new ArgumentNullException(nameof(turnContext)); + } + + // Log the user identity from Activity.From — set by the A365 platform on every message. + var fromAccount = turnContext.Activity.From; + _logger?.LogDebug( + "Turn received from user — DisplayName: '{Name}', UserId: '{Id}', AadObjectId: '{AadObjectId}'", + fromAccount?.Name ?? "(unknown)", + fromAccount?.Id ?? "(unknown)", + fromAccount?.AadObjectId ?? "(none)"); + + // Select the appropriate auth handler based on request type + // For agentic requests, use the agentic auth handler + // For non-agentic requests, use OBO auth handler (supports bearer token or configured auth) + string? ToolAuthHandlerName; + if (turnContext.IsAgenticRequest()) + { + ToolAuthHandlerName = AgenticAuthHandlerName; + } + else + { + // Non-agentic: use OBO auth handler if configured + ToolAuthHandlerName = OboAuthHandlerName; + } + + await A365OtelWrapper.InvokeObservedAgentOperation( + "MessageProcessor", + turnContext, + turnState, + _serviceTokenCache, + _configuration, + _logger, + async () => + { + // Send an immediate acknowledgment — this arrives as a separate message before the LLM response. + // Each SendActivityAsync call produces a discrete Teams message, enabling the multiple-messages pattern. + // NOTE: For Teams agentic identities, streaming is buffered into a single message by the SDK; + // use SendActivityAsync for any messages that must arrive immediately. + await turnContext.SendActivityAsync(MessageFactory.Text("Got it — working on it…"), cancellationToken).ConfigureAwait(false); + + // Send typing indicator immediately on the main thread (awaited so it arrives before the LLM call starts). + await turnContext.SendActivityAsync(Activity.CreateTypingActivity(), cancellationToken).ConfigureAwait(false); + + // Background loop refreshes the "..." animation every ~4s (it times out after ~5s). + // Only visible in 1:1 and small group chats. + using var typingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var typingTask = Task.Run(async () => + { + try + { + while (!typingCts.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(4), typingCts.Token).ConfigureAwait(false); + await turnContext.SendActivityAsync(Activity.CreateTypingActivity(), typingCts.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { /* expected on cancel */ } + }, typingCts.Token); + + // StreamingResponse is best-effort: in Teams with agentic identity the SDK may buffer/downscale it. + // The ack + typing loop above handle the immediate UX; streaming remains for non-Teams / WebChat clients. + 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 + { + typingCts.Cancel(); + try + { + await typingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected: typingTask is canceled when typingCts is canceled; no further action required. + } + await turnContext.StreamingResponse.EndStreamAsync(cancellationToken).ConfigureAwait(false); + } + }); + } + + + /// + /// 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)); + + // Acquire the access token once for this turn — used for MCP tool loading. + string? accessToken = null; + string? agentId = null; + if (!string.IsNullOrEmpty(authHandlerName)) + { + accessToken = await UserAuthorization.GetTurnTokenAsync(context, authHandlerName); + agentId = Utility.ResolveAgentIdentity(context, accessToken); + } + else if (TryGetBearerTokenForDevelopment(out var bearerToken)) + { + _logger?.LogInformation("Using bearer token from environment. Length: {Length}", bearerToken?.Length ?? 0); + accessToken = bearerToken; + agentId = Utility.ResolveAgentIdentity(context, accessToken!); + _logger?.LogInformation("Resolved agentId: '{AgentId}'", agentId ?? "(null)"); + } + else + { + _logger?.LogWarning("No auth handler or bearer token available. MCP tools will not be loaded."); + } + + if (!string.IsNullOrEmpty(accessToken) && string.IsNullOrEmpty(agentId)) + { + _logger?.LogWarning("Access token was acquired but agent identity could not be resolved. MCP tools will not be loaded."); + } + + // Activity.From.Name is always available — no API call needed. + var displayName = context.Activity.From?.Name; + + // Create the local tools: + var toolList = new List(); + WeatherLookupTool weatherLookupTool = new(context, _configuration!); + toolList.Add(AIFunctionFactory.Create(DateTimeFunctionTool.getDate)); + toolList.Add(AIFunctionFactory.Create(weatherLookupTool.GetCurrentWeatherForLocation)); + toolList.Add(AIFunctionFactory.Create(weatherLookupTool.GetWeatherForecastForLocation)); + + if (toolService != null && !string.IsNullOrEmpty(agentId)) + { + try + { + string toolCacheKey = GetToolCacheKey(turnState); + if (_agentToolCache.ContainsKey(toolCacheKey)) + { + var cachedTools = _agentToolCache[toolCacheKey]; + if (cachedTools != null && cachedTools.Count > 0) + { + toolList.AddRange(cachedTools); + } + } + else + { + await context.StreamingResponse.QueueInformativeUpdateAsync("Loading tools..."); + + // For the bearer token (development) flow, pass the token as an override and + // use OboAuthHandlerName (or fall back to AgenticAuthHandlerName) as the handler. + var handlerForMcp = !string.IsNullOrEmpty(authHandlerName) + ? authHandlerName + : OboAuthHandlerName ?? AgenticAuthHandlerName ?? string.Empty; + var tokenOverride = string.IsNullOrEmpty(authHandlerName) ? accessToken : null; + + var a365Tools = await toolService.GetMcpToolsAsync(agentId, UserAuthorization, handlerForMcp, context, tokenOverride).ConfigureAwait(false); + + if (a365Tools != null && a365Tools.Count > 0) + { + toolList.AddRange(a365Tools); + _agentToolCache.TryAdd(toolCacheKey, [.. a365Tools]); + } + } + } + catch (Exception ex) + { + if (ShouldSkipToolingOnErrors()) + { + _logger?.LogWarning(ex, "Failed to register MCP tool servers. Continuing without MCP tools (SKIP_TOOLING_ON_ERRORS=true)."); + } + else + { + _logger?.LogError(ex, "Failed to register MCP tool servers."); + throw; + } + } + } + + // 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 = GetAgentInstructions(displayName), + 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; + } + } +} diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/AgentFrameworkSampleAgent.csproj b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/AgentFrameworkSampleAgent.csproj new file mode 100644 index 00000000..6c7c46be --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/AgentFrameworkSampleAgent.csproj @@ -0,0 +1,48 @@ + + + + net8.0 + enable + 7a8f9d79-5c4c-495f-8d56-1db8168ef8bd + enable + + + + $(DefineConstants);UseStreaming + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/AspNetExtensions.cs b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/AspNetExtensions.cs new file mode 100644 index 00000000..847cd886 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/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/obo-auth-samples/agent-framework-appRegistration/sample-agent/Dockerfile b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Dockerfile new file mode 100644 index 00000000..5d4f63b1 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY ["AgentFrameworkSampleAgent.csproj", "."] +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "AgentFrameworkSampleAgent.dll"] diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Program.cs b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Program.cs new file mode 100644 index 00000000..48531ac7 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Program.cs @@ -0,0 +1,139 @@ +// 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 (Service exporter for non-digital-worker agents). +builder.Services.AddServiceTracingExporter(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); +}); + +// Health check endpoint for CI/CD pipelines and monitoring +app.MapGet("/api/health", () => Results.Ok(new { status = "healthy", timestamp = System.DateTime.UtcNow })); + +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/obo-auth-samples/agent-framework-appRegistration/sample-agent/README.md b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/README.md new file mode 100644 index 00000000..7c5c44eb --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/README.md @@ -0,0 +1,201 @@ +# Agent Framework Sample — App Registration Setup + +## Overview + +This sample demonstrates how to set up a Custom Engine Agent using the [Agent Framework](https://github.com/microsoft/agent-framework) with the Microsoft Agent 365 SDK and Microsoft 365 Agents SDK, deployed as a Docker container on Azure App Service. + +It covers: + +- **App Registration**: Manual Azure AD app registration for bot authentication (SingleTenant) +- **Docker Deployment**: Containerized deployment to Azure App Service (Linux) +- **Teams SSO**: Single Sign-On with On-Behalf-Of (OBO) token flow +- **MCP Tooling**: Agent 365 Tools integration via Model Context Protocol +- **Observability**: End-to-end tracing via OpenTelemetry and A365 Service Exporter +- **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, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/). + +## Prerequisites + +- .NET 8.0 or higher +- Docker Desktop +- Azure CLI (`az`) +- Azure subscription with: + - Azure Container Registry (ACR) + - Azure App Service (Linux, B1 or higher) + - Azure Bot resource (F0 or S1) +- Azure AD app registration (SingleTenant) +- Azure OpenAI resource with a deployed model (e.g., `gpt-4.1`) +- OpenWeather API key (free tier) — see: https://openweathermap.org/price + +## Setup Guide + +For step-by-step deployment instructions, see [claude.md](claude.md). + +## Architecture + +``` +Teams Client + │ + ▼ +Bot Framework Service (MsTeams Channel) + │ + ▼ +Azure App Service (Docker container, port 8080) + │ POST /api/messages + ▼ +ASP.NET Core → CloudAdapter → MyAgent + │ + ├── Teams SSO → OBO Token Exchange → MCP Tools (Agent 365) + ├── Azure OpenAI (gpt-4.1) — Chat completions with function calling + └── Local Tools (Weather, DateTime) +``` + +## Working with User Identity + +On every incoming message, the A365 platform populates `Activity.From` with basic user information — always available with no API calls or token acquisition: + +| Field | Description | +|---|---| +| `Activity.From.Id` | Channel-specific user ID (e.g., `29:1AbcXyz...` in Teams) | +| `Activity.From.Name` | Display name as known to the channel | +| `Activity.From.AadObjectId` | Azure AD Object ID — use this to call Microsoft Graph | + +The sample logs these fields at the start of every turn in `OnMessageAsync` ([MyAgent.cs](Agent/MyAgent.cs)) and injects `Activity.From.Name` into the LLM system instructions for personalized responses: + +```csharp +var fromAccount = turnContext.Activity.From; +_logger?.LogInformation( + "Turn received from user — DisplayName: '{Name}', UserId: '{Id}', AadObjectId: '{AadObjectId}'", + fromAccount?.Name ?? "(unknown)", + fromAccount?.Id ?? "(unknown)", + fromAccount?.AadObjectId ?? "(none)"); +``` + +## Handling Agent Install and Uninstall + +When a user installs (hires) or uninstalls (removes) the agent, the A365 platform sends an `InstallationUpdate` activity — also referred to as the `agentInstanceCreated` event. The sample handles this in `OnInstallationUpdateAsync` ([MyAgent.cs](Agent/MyAgent.cs)): + +| Action | Description | +|---|---| +| `add` | Agent was installed — send a welcome message | +| `remove` | Agent was uninstalled — send a farewell message | + +```csharp +if (turnContext.Activity.Action == InstallationUpdateActionTypes.Add) +{ + await turnContext.SendActivityAsync(MessageFactory.Text(AgentHireMessage), cancellationToken); +} +else if (turnContext.Activity.Action == InstallationUpdateActionTypes.Remove) +{ + await turnContext.SendActivityAsync(MessageFactory.Text(AgentFarewellMessage), cancellationToken); +} +``` + +The handler is registered twice in the constructor — once for agentic (A365 production) requests and once for non-agentic (Agents Playground / WebChat) requests, enabling local testing without a full A365 deployment. + +To test with Agents Playground, use **Mock an Activity → Install application** to send a simulated `installationUpdate` activity. + +## Sending Multiple Messages in Teams + +Agent365 agents can send multiple discrete messages in response to a single user prompt in Teams. This is achieved by calling `SendActivityAsync` multiple times within a single turn. + +> **Important**: Streaming responses are not supported for agentic identities in Teams. The SDK detects agentic identity and buffers the stream into a single message. Use `SendActivityAsync` directly to send immediate, discrete messages to the user. + +The sample demonstrates this in `OnMessageAsync` ([MyAgent.cs](Agent/MyAgent.cs)) by sending an immediate acknowledgment before the LLM response: + +```csharp +// Message 1: immediate ack — reaches the user right away +await turnContext.SendActivityAsync(MessageFactory.Text("Got it — working on it…"), cancellationToken); + +// ... LLM processing ... + +// Message 2: the LLM response (via StreamingResponse, buffered into one message for Teams agentic) +await turnContext.StreamingResponse.EndStreamAsync(cancellationToken); +``` + +Each `SendActivityAsync` call produces a separate Teams message. You can call it as many times as needed to send progress updates, partial results, or a final answer. + +### Typing Indicators + +For long-running operations, send a typing indicator to show a "..." progress animation in Teams: + +```csharp +// Typing indicator loop — refreshes every ~4s for long-running operations. +using var typingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); +var typingTask = Task.Run(async () => +{ + try + { + while (!typingCts.IsCancellationRequested) + { + await turnContext.SendActivityAsync(Activity.CreateTypingActivity(), typingCts.Token); + await Task.Delay(TimeSpan.FromSeconds(4), typingCts.Token); + } + } + catch (OperationCanceledException) { /* expected on cancel */ } +}, typingCts.Token); + +try { /* ... do work ... */ } +finally +{ + typingCts.Cancel(); + try { await typingTask; } catch (OperationCanceledException) { } +} +``` + +> **Note**: Typing indicators are only visible in 1:1 chats and small group chats — not in channels. + +## Key Files + +| File | Purpose | +|---|---| +| `Program.cs` | ASP.NET host setup, DI registration, `/api/messages` endpoint | +| `Agent/MyAgent.cs` | Agent logic — message handling, LLM orchestration, tool loading | +| `appsettings.json` | Bot auth, Azure OpenAI, and agent configuration (use placeholders for secrets) | +| `appPackage/manifest.json` | Teams app manifest for sideloading | +| `telemetry/AgentMetrics.cs` | OpenTelemetry instrumentation for HTTP and agent operations | +| `telemetry/A365OtelWrapper.cs` | A365 observability wrapper with baggage propagation | +| `ToolingManifest.json` | MCP tool server configuration | +| `Dockerfile` | Multi-stage Docker build for Linux container deployment | + +## 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/obo-auth-samples/agent-framework-appRegistration/sample-agent/ToolingManifest.json b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/ToolingManifest.json new file mode 100644 index 00000000..7c94ba62 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/ToolingManifest.json @@ -0,0 +1,7 @@ +{ + "mcpServers": [ + { + "mcpServerName": "mcp_MailTools" + } + ] +} \ No newline at end of file diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Tools/DateTimeFunctionTool.cs b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Tools/DateTimeFunctionTool.cs new file mode 100644 index 00000000..983cf264 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/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/obo-auth-samples/agent-framework-appRegistration/sample-agent/Tools/WeatherLookupTool.cs b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/Tools/WeatherLookupTool.cs new file mode 100644 index 00000000..a59a63f0 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/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/obo-auth-samples/agent-framework-appRegistration/sample-agent/app.zip b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/app.zip new file mode 100644 index 00000000..733facb8 Binary files /dev/null and b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/app.zip differ diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appPackage/color.png b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appPackage/color.png new file mode 100644 index 00000000..01aa37e3 Binary files /dev/null and b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appPackage/color.png differ diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appPackage/manifest.json b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appPackage/manifest.json new file mode 100644 index 00000000..a56344d9 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appPackage/manifest.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.22/MicrosoftTeams.schema.json", + "manifestVersion": "1.22", + "version": "1.0.3", + "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-a365-app", + "full": "Agent 365 SDK Agent Framework Sample Agent (App Registration)" + }, + "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": "", + "type": "bot" + } + ] + }, + "bots": [ + { + "botId": "", + "scopes": [ + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [ + "token.botframework.com", + ".azurewebsites.net" + ], + "webApplicationInfo": { + "id": "", + "resource": "api://botid-" + } +} \ No newline at end of file diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appPackage/outline.png b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appPackage/outline.png new file mode 100644 index 00000000..f7a4c864 Binary files /dev/null and b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appPackage/outline.png differ diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appsettings.Playground.json b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appsettings.Playground.json new file mode 100644 index 00000000..3097e295 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/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/obo-auth-samples/agent-framework-appRegistration/sample-agent/appsettings.json b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appsettings.json new file mode 100644 index 00000000..3891e8a8 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/appsettings.json @@ -0,0 +1,70 @@ +{ + "AgentApplication": { + "StartTypingTimer": false, + "RemoveRecipientMention": false, + "NormalizeMentions": false, + "OboAuthHandlerName": "me", + "UserAuthorization": { + "AutoSignin": true, + "DefaultHandlerName": "me", + "Handlers": { + "me": { + "Settings": { + "AzureBotOAuthConnectionName": "GraphOBoConnection", + "OBOConnectionName": "ServiceConnection" + } + } + } + } + }, + "TokenValidation": { + "Enabled": false, + "Audiences": [ + "" + ], + "TenantId": "" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Agent365AgentFrameworkSampleAgent": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Agents": "Information", + "Microsoft.Agents.A365.Observability": "Debug", + "Microsoft.Agents.A365.Tooling": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning", + "OpenTelemetry": "Debug" + } + }, + "EnableAgent365Exporter": true, + "AllowedHosts": "*", + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "ClientSecret", + "ClientId": "", + "ClientSecret": "", + "AuthorityEndpoint": "https://login.microsoftonline.com/", + "TenantId": "", + "Scopes": [ + "https://api.botframework.com/.default" + ] + } + } + }, + "ConnectionsMap": [ + { + "ServiceUrl": "*", + "Connection": "ServiceConnection" + } + ], + "AIServices": { + "AzureOpenAI": { + "DeploymentName": "gpt-4.1", + "Endpoint": "", + "ApiKey": "" + } + }, + "OpenWeatherApiKey": "" +} \ No newline at end of file diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/claude.md b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/claude.md new file mode 100644 index 00000000..b145abf0 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/claude.md @@ -0,0 +1,452 @@ +# Agent Setup Guide — App Registration Approach + +This document captures every step required to deploy a Custom Engine Agent using manual Azure AD app registration, Docker on Azure App Service, and the Microsoft Agent 365 SDK. + +## Table of Contents + +1. [Azure AD App Registration](#1-azure-ad-app-registration) +2. [Azure Bot Resource](#2-azure-bot-resource) +3. [Azure Container Registry & Docker](#3-azure-container-registry--docker) +4. [Azure App Service (Linux Container)](#4-azure-app-service-linux-container) +5. [Application Configuration](#5-application-configuration) +6. [Teams App Manifest & Sideloading](#6-teams-app-manifest--sideloading) +7. [Common Issues & Fixes](#7-common-issues--fixes) +8. [Verification Checklist](#8-verification-checklist) + +--- + +## 1. Azure AD App Registration + +### 1.1 Create the App Registration + +```bash +az ad app create \ + --display-name "" \ + --sign-in-audience AzureADMyOrg +``` + +Record the `appId` (client ID) from the output. + +### 1.2 Create a Client Secret + +```bash +az ad app credential reset \ + --id \ + --display-name "BotSecret" \ + --years 2 +``` + +Save the `password` — this is your `ClientSecret`. + +### 1.3 Set the Application ID URI + +```bash +az ad app update --id \ + --identifier-uris "api://botid-" +``` + +### 1.4 Expose an API Scope (`access_as_user`) + +```bash +# Get the object ID +OBJECT_ID=$(az ad app show --id --query id -o tsv) + +# Add the oauth2PermissionScopes via Graph API +az rest --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body '{ + "api": { + "oauth2PermissionScopes": [{ + "adminConsentDescription": "Access as user", + "adminConsentDisplayName": "Access as user", + "id": "", + "isEnabled": true, + "type": "User", + "userConsentDescription": "Access as user", + "userConsentDisplayName": "Access as user", + "value": "access_as_user" + }] + } + }' +``` + +### 1.5 Pre-authorize Teams Client IDs + +Teams needs to be pre-authorized to obtain tokens silently for SSO. Add both Teams client IDs: + +- **Teams Web**: `5e3ce6c0-2b1f-4285-8d4b-75ee78787346` +- **Teams Desktop/Mobile**: `1fec8e78-bce4-4aaf-ab1b-5451cc387264` + +```bash +az rest --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body '{ + "api": { + "preAuthorizedApplications": [ + { + "appId": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346", + "delegatedPermissionIds": [""] + }, + { + "appId": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", + "delegatedPermissionIds": [""] + } + ] + } + }' +``` + +### 1.6 Configure Redirect URIs & Implicit Grant + +```bash +az rest --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body '{ + "web": { + "redirectUris": [ + "https://token.botframework.com/.auth/web/redirect" + ], + "implicitGrantSettings": { + "enableAccessTokenIssuance": true, + "enableIdTokenIssuance": true + } + } + }' +``` + +### 1.7 Set Sign-In Audience + +Must be `AzureADMyOrg` (SingleTenant): + +```bash +az ad app update --id --sign-in-audience AzureADMyOrg +``` + +### 1.8 Add API Permissions + +**Required Graph permissions** (delegated): +- `User.Read`, `Mail.Read`, `Mail.ReadWrite`, `Mail.Send`, `Mail.Send.Shared` +- `Directory.Read.All`, `Directory.ReadWrite.All` +- `Application.Read.All`, `Application.ReadWrite.All` +- `AgentIdentityBlueprint.ReadWrite.All`, `AgentIdentityBlueprint.UpdateAuthProperties.All` +- `AgentIdentityBlueprintPrincipal.Create`, `AgentIdentityBlueprintPrincipal.ReadWrite.All` + +**Required Agent 365 Tools permissions** (resource: `ea9ffc3e-8a23-4a7d-836d-234d7c7565c1`): +- `McpServersMetadata.Read.All`, `AgentTools.ListMCPServers.All`, `McpServers.Mail.All` +- Plus all other `McpServers.*` and `AgentTools.*` scopes — see working agent for full list. + +### 1.9 Create a Service Principal + +```bash +az ad sp create --id +``` + +--- + +## 2. Azure Bot Resource + +### 2.1 Create the Bot + +```bash +az bot create \ + --resource-group \ + --name \ + --app-type SingleTenant \ + --appid \ + --tenant-id \ + --endpoint "https://.azurewebsites.net/api/messages" \ + --sku F0 +``` + +### 2.2 Enable the Teams Channel + +```bash +az bot msteams create --name --resource-group +``` + +### 2.3 Create the OAuth Connection (`GraphOBoConnection`) + +```bash +az bot authsetting create \ + --resource-group \ + --name \ + --setting-name "GraphOBoConnection" \ + --provider-scope-string "User.Read" \ + --client-id \ + --client-secret "" \ + --service "Aadv2" \ + --parameters \ + clientId= \ + clientSecret= \ + tenantID= \ + scopes=User.Read \ + tokenExchangeUrl=api://botid- +``` + +> **Critical**: The `--provider-scope-string` and `scopes` parameter must be `User.Read` (NOT `api://botid-.../access_as_user`). The `tokenExchangeUrl` must match the app's `identifierUris`. + +--- + +## 3. Azure Container Registry & Docker + +### 3.1 Build the Docker Image + +```bash +cd sample-agent +docker build -t .azurecr.io/: . +``` + +### 3.2 Push to ACR + +```bash +az acr login --name +docker push .azurecr.io/: +``` + +--- + +## 4. Azure App Service (Linux Container) + +### 4.1 Create the Web App + +```bash +az webapp create \ + --resource-group \ + --name \ + --plan \ + --container-image-name ".azurecr.io/:" \ + --container-registry-url "https://.azurecr.io" \ + --container-registry-user \ + --container-registry-password +``` + +### 4.2 Configure App Settings + +```bash +az webapp config appsettings set --name --resource-group --settings \ + ASPNETCORE_ENVIRONMENT=Production \ + WEBSITES_PORT=8080 \ + Connections__ServiceConnection__Settings__ClientSecret="" \ + AIServices__AzureOpenAI__ApiKey="" +``` + +### 4.3 Enable Always On + +```bash +az webapp config set --name --resource-group --always-on true +``` + +### 4.4 Enable Application Logging + +```bash +az webapp log config --name --resource-group \ + --docker-container-logging filesystem +``` + +--- + +## 5. Application Configuration + +### 5.1 `appsettings.json` + +All secrets should use placeholders in source and be overridden via App Settings or environment variables: + +```json +{ + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "ClientSecret", + "ClientId": "", + "ClientSecret": "", + "AuthorityEndpoint": "https://login.microsoftonline.com/", + "TenantId": "", + "Scopes": ["https://api.botframework.com/.default"] + } + } + }, + "TokenValidation": { + "Enabled": false, + "Audiences": [""], + "TenantId": "" + }, + "AIServices": { + "AzureOpenAI": { + "DeploymentName": "gpt-4.1", + "Endpoint": "", + "ApiKey": "" + } + }, + "OpenWeatherApiKey": "" +} +``` + +### 5.2 Port Configuration + +The Dockerfile exposes port `8080`. The app binds to `http://+:8080` via `ASPNETCORE_URLS`. +The Azure App Service `WEBSITES_PORT` must also be `8080`. + +The `Program.cs` only binds to `localhost:3978` in Development mode — **in Production, it uses the default `ASPNETCORE_URLS`**. + +--- + +## 6. Teams App Manifest & Sideloading + +### 6.1 `appPackage/manifest.json` + +All ID fields must use the same ``: + +```json +{ + "id": "", + "copilotAgents": { + "customEngineAgents": [{ "id": "", "type": "bot" }] + }, + "bots": [{ "botId": "", "scopes": ["personal"] }], + "validDomains": [ + "token.botframework.com", + ".azurewebsites.net" + ], + "webApplicationInfo": { + "id": "", + "resource": "api://botid-" + } +} +``` + +### 6.2 Create the Sideload Package + +```powershell +Compress-Archive -Path appPackage\manifest.json, appPackage\color.png, appPackage\outline.png ` + -DestinationPath appPackage\agent-app.zip +``` + +### 6.3 Sideload to Teams + +1. Open **Teams → Apps → Manage your apps → Upload a custom app** +2. Select the `.zip` file +3. Open the installed app and send a message + +### 6.4 Updating the App + +When re-sideloading after changes: +1. **Bump the `version`** in `manifest.json` (e.g., `1.0.3` → `1.0.4`) +2. Remove the old app from Teams (**Apps → Manage your apps → Remove**) +3. Delete any stale chat conversations with the bot +4. Upload the new ZIP + +--- + +## 7. Common Issues & Fixes + +### 7.1 `async void` Bug in `AgentMetrics.InvokeObservedHttpOperation` + +**Symptom**: Container crashes with `ObjectDisposedException` (exit code 139). DirectLine may partially work. + +**Cause**: The method signature takes `Action func` but is called with `async () => { await ... }`, creating an async void fire-and-forget. The HTTP response stream is disposed before the adapter finishes. + +**Fix**: Change the method signature: + +```csharp +// Before (broken): +public static Task InvokeObservedHttpOperation(string operationName, Action func) + +// After (fixed): +public static async Task InvokeObservedHttpOperation(string operationName, Func func) +``` + +And `await func()` instead of `func()`. + +### 7.2 Port Mismatch (`localhost:3978` vs `8080`) + +**Symptom**: Container starts but returns 502/timeout. + +**Cause**: `ASPNETCORE_ENVIRONMENT=Development` causes `Program.cs` to bind only to `localhost:3978`, which doesn't match `WEBSITES_PORT=8080`. + +**Fix**: Set `ASPNETCORE_ENVIRONMENT=Production` in App Settings. + +### 7.3 OAuth Scopes Self-Referential + +**Symptom**: Sign-in fails; SSO token exchange doesn't work. + +**Cause**: OAuth connection `scopes` set to `api://botid-.../access_as_user` instead of `User.Read`. + +**Fix**: Delete and recreate the `GraphOBoConnection` with `scopes=User.Read`. + +### 7.4 Missing Redirect URIs + +**Symptom**: OAuth sign-in popup fails with redirect error. + +**Fix**: Add `https://token.botframework.com/.auth/web/redirect` to the app registration's redirect URIs. + +### 7.5 Implicit Grant Disabled + +**Symptom**: Token exchange returns empty tokens. + +**Fix**: Enable both `enableAccessTokenIssuance` and `enableIdTokenIssuance` in the app registration's `web.implicitGrantSettings`. + +### 7.6 `signInAudience` Mismatch + +**Symptom**: Authentication fails with audience validation errors. + +**Cause**: Bot is `SingleTenant` but app registration is `AzureADMultipleOrgs`. + +**Fix**: `az ad app update --id --sign-in-audience AzureADMyOrg` + +### 7.7 Missing API Permissions + +**Symptom**: MCP tools fail to load; agent responds with basic weather/datetime only. + +**Fix**: Copy the full `requiredResourceAccess` from a working agent and grant admin consent. See Section 1.8. + +### 7.8 Teams "Not Found" Error + +**Symptom**: Clicking the bot in Teams shows "Not found" — messages never reach the backend. + +**Cause**: The MsTeams channel registration is in a corrupted state from prior configuration changes. + +**Fix**: Delete and recreate the Teams channel: + +```bash +az bot msteams delete --name --resource-group +az bot msteams create --name --resource-group +``` + +Then remove the app from Teams and re-sideload with a bumped manifest version. + +### 7.9 Wrong App Setting Name + +**Symptom**: Auth fails despite correct credentials. + +**Cause**: Using `BotServiceConnection` instead of the correct `ServiceConnection` name. + +**Fix**: Ensure the connection name in `appsettings.json` matches what's referenced in `ConnectionsMap`. + +--- + +## 8. Verification Checklist + +Run these checks before declaring the agent ready: + +| # | Check | Command / Action | +|---|---|---| +| 1 | App registration exists | `az ad app show --id ` | +| 2 | `signInAudience` = `AzureADMyOrg` | `az ad app show --id --query signInAudience` | +| 3 | `identifierUris` = `api://botid-` | `az ad app show --id --query identifierUris` | +| 4 | `access_as_user` scope exposed | `az ad app show --id --query api.oauth2PermissionScopes` | +| 5 | Teams clients pre-authorized | `az ad app show --id --query api.preAuthorizedApplications` | +| 6 | Redirect URI set | `az ad app show --id --query web.redirectUris` | +| 7 | Implicit grant enabled | `az ad app show --id --query web.implicitGrantSettings` | +| 8 | Service principal exists | `az ad sp show --id ` | +| 9 | API permissions granted | `az ad app permission admin-consent --id ` | +| 10 | Bot endpoint correct | `az bot show --name --rg --query properties.endpoint` | +| 11 | Teams channel enabled | `az bot msteams show --name --rg ` | +| 12 | OAuth connection configured | `az bot authsetting show --name --rg -c GraphOBoConnection` | +| 13 | Container listens on 8080 | Check logs: `Now listening on: http://[::]:8080` | +| 14 | `ASPNETCORE_ENVIRONMENT=Production` | `az webapp config appsettings list ...` | +| 15 | Health endpoint responds | `curl https://.azurewebsites.net/api/health` | +| 16 | DirectLine test passes | Start conversation, send message, receive OAuthCard | +| 17 | Teams sideload works | Upload ZIP, open chat, receive sign-in prompt or response | diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/docs/design.md b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/docs/design.md new file mode 100644 index 00000000..c3e43533 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/docs/design.md @@ -0,0 +1,245 @@ +# Agent Framework Sample Agent Design + +## Overview + +This sample demonstrates a weather-focused agent built using the Microsoft Agent Framework orchestrator. It showcases the core patterns for building production-ready agents with local tools, MCP server integration, and Microsoft Agent 365 observability. + +## What This Sample Demonstrates + +- Agent Framework integration with Azure OpenAI +- Local tool implementation (weather lookup, datetime) +- MCP server tool registration and invocation +- Streaming responses to clients +- Conversation thread management +- Dual authentication (agentic and OBO handlers) +- Microsoft Agent 365 observability integration + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Program.cs │ +│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │ +│ │ OpenTelemetry│ │ A365 Tracing│ │ ASP.NET Authentication │ │ +│ └─────────────┘ └─────────────┘ └──────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Dependency Injection Container ││ +│ │ ┌─────────┐ ┌───────────┐ ┌──────────┐ ┌───────────────┐ ││ +│ │ │IChatClient│ │IMcpToolSvc│ │IStorage │ │ITokenCache │ ││ +│ │ └─────────┘ └───────────┘ └──────────┘ └───────────────┘ ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ MyAgent │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Event Handlers ││ +│ │ ┌────────────────┐ ┌────────────────┐ ││ +│ │ │MembersAdded │ │Message (Agentic│ ││ +│ │ │→ Welcome │ │& Non-Agentic) │ ││ +│ │ └────────────────┘ └────────────────┘ ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Tool Management ││ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ││ +│ │ │DateTime Tool │ │Weather Tool │ │MCP Tools │ ││ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ ChatClientAgent (LLM) ││ +│ │ Instructions + Tools → Streaming Response ││ +│ └─────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Key Components + +### Program.cs +Entry point that configures: +- OpenTelemetry and Agent 365 tracing +- MCP tool services (`IMcpToolRegistrationService`, `IMcpToolServerConfigurationService`) +- Authentication middleware +- IChatClient with Azure OpenAI +- Memory storage for conversation state + +### Agent/MyAgent.cs +Main agent class that: +- Extends `AgentApplication` +- Registers handlers for conversation updates and messages +- Manages conversation threads +- Coordinates tool registration and LLM invocation + +### Tools/WeatherLookupTool.cs +Local tool implementation for weather queries: +- `GetCurrentWeatherForLocation` - Current weather data +- `GetWeatherForecastForLocation` - 5-day forecast + +### Tools/DateTimeFunctionTool.cs +Utility tool for date/time queries. + +### telemetry/AgentMetrics.cs +Custom observability helpers for tracing agent operations. + +## Message Flow + +``` +1. HTTP POST /api/messages + │ +2. AgentMetrics.InvokeObservedHttpOperation() + │ +3. adapter.ProcessAsync() → MyAgent.OnMessageAsync() + │ +4. A365OtelWrapper.InvokeObservedAgentOperation() + │ └── Observability context setup + │ +5. StreamingResponse.QueueInformativeUpdateAsync("Just a moment...") + │ +6. GetClientAgent() + │ ├── Create local tools (DateTime, Weather) + │ ├── GetMcpToolsAsync() from MCP servers + │ └── Build ChatClientAgent with instructions + │ +7. GetConversationThread() - Load or create thread + │ +8. agent.RunStreamingAsync() + │ └── Stream responses to client + │ +9. Save thread state + │ +10. StreamingResponse.EndStreamAsync() +``` + +## Tool Integration + +### Local Tools +```csharp +var toolList = new List(); + +// Static function tool +toolList.Add(AIFunctionFactory.Create(DateTimeFunctionTool.getDate)); + +// Instance method tools (with context access) +WeatherLookupTool weatherLookupTool = new(context, _configuration!); +toolList.Add(AIFunctionFactory.Create(weatherLookupTool.GetCurrentWeatherForLocation)); +toolList.Add(AIFunctionFactory.Create(weatherLookupTool.GetWeatherForecastForLocation)); +``` + +### MCP Tools +```csharp +// With auth handler +var a365Tools = await toolService.GetMcpToolsAsync( + agentId, + UserAuthorization, + authHandlerName, + context +); + +// With bearer token (development) +var a365Tools = await toolService.GetMcpToolsAsync( + agentId, + UserAuthorization, + handlerForBearerToken, + context, + bearerToken // Override token +); +``` + +## Configuration + +### appsettings.json +```json +{ + "AIServices": { + "AzureOpenAI": { + "Endpoint": "https://your-resource.openai.azure.com", + "ApiKey": "your-api-key", + "DeploymentName": "gpt-4o" + } + }, + "OpenWeatherApiKey": "your-weather-api-key", + "AgentApplication": { + "AgenticAuthHandlerName": "agentic", + "OboAuthHandlerName": "me" + } +} +``` + +### Environment Variables +```bash +ASPNETCORE_ENVIRONMENT=Development +BEARER_TOKEN=your-bearer-token # Development only +SKIP_TOOLING_ON_ERRORS=true # Development fallback +``` + +## Observability + +### Tracing Setup +```csharp +builder.ConfigureOpenTelemetry(); +builder.Services.AddAgenticTracingExporter(clusterCategory: "production"); +builder.AddA365Tracing(config => { + config.WithAgentFramework(); +}); +``` + +### Observed Operations +- `agent.process_message` - HTTP endpoint +- `MessageProcessor` - Message handling +- `WelcomeMessage` - Welcome flow + +## Authentication + +Dual handler support: +- **Agentic Handler**: For requests from Agent 365 orchestration +- **OBO Handler**: For direct user requests (Playground, WebChat) + +```csharp +if (turnContext.IsAgenticRequest()) +{ + authHandlerName = AgenticAuthHandlerName; // "agentic" +} +else +{ + authHandlerName = OboAuthHandlerName; // "me" +} +``` + +## Conversation Management + +Thread state persisted in conversation storage: +```csharp +// Load existing thread +string? agentThreadInfo = turnState.Conversation.GetValue("conversation.threadInfo"); +if (!string.IsNullOrEmpty(agentThreadInfo)) +{ + thread = agent.DeserializeThread(ele); +} +else +{ + thread = agent.GetNewThread(); +} + +// Save thread after processing +turnState.Conversation.SetValue("conversation.threadInfo", ProtocolJsonSerializer.ToJson(thread.Serialize())); +``` + +## Extension Points + +1. **Add New Local Tools**: Create tool classes, register with `AIFunctionFactory` +2. **Custom MCP Servers**: Configure in tool manifest, automatic registration +3. **Custom Middleware**: Add to `IMiddleware[]` array +4. **Response Formatting**: Customize in agent instructions +5. **Authentication**: Configure additional auth handlers + +## Dependencies + +```xml + + + + + + +``` diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/images/thumbnail.png b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/images/thumbnail.png new file mode 100644 index 00000000..a1a1c1bc Binary files /dev/null and b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/images/thumbnail.png differ diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/telemetry/A365OtelWrapper.cs b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/telemetry/A365OtelWrapper.cs new file mode 100644 index 00000000..03d3e0ac --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/telemetry/A365OtelWrapper.cs @@ -0,0 +1,72 @@ +// 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.Builder; +using Microsoft.Agents.Builder.State; +using Microsoft.Identity.Client; + +namespace Agent365AgentFrameworkSampleAgent.telemetry +{ + public static class A365OtelWrapper + { + public static async Task InvokeObservedAgentOperation( + string operationName, + ITurnContext turnContext, + ITurnState turnState, + IExporterTokenCache? serviceTokenCache, + IConfiguration? configuration, + ILogger? logger, + Func func + ) + { + // Wrap the operation with AgentSDK observability. + await AgentMetrics.InvokeObservedAgentOperation( + operationName, + turnContext, + async () => + { + // Resolve agent and tenant IDs from the turn context. + string rawAgentId = turnContext?.Activity?.Recipient?.Id ?? Guid.Empty.ToString(); + // Strip Teams bot framework prefix (e.g. "28:") to get the raw GUID + string agentId = rawAgentId.Contains(':') ? rawAgentId.Substring(rawAgentId.IndexOf(':') + 1) : rawAgentId; + string tenantId = turnContext?.Activity?.Conversation?.TenantId + ?? turnContext?.Activity?.Recipient?.TenantId + ?? Guid.Empty.ToString(); + + using var baggageScope = new BaggageBuilder() + .TenantId(tenantId) + .AgentId(agentId) + .Build(); + + try + { + // Acquire a client credentials token for the observability endpoint. + var clientId = configuration?["Connections:ServiceConnection:Settings:ClientId"] ?? string.Empty; + var clientSecret = configuration?["Connections:ServiceConnection:Settings:ClientSecret"] ?? string.Empty; + var authority = configuration?["Connections:ServiceConnection:Settings:AuthorityEndpoint"] ?? string.Empty; + var observabilityScope = "https://api.powerplatform.com/.default"; + + var cca = ConfidentialClientApplicationBuilder + .Create(clientId) + .WithClientSecret(clientSecret) + .WithAuthority(authority) + .Build(); + + var tokenResult = await cca.AcquireTokenForClient(new[] { observabilityScope }).ExecuteAsync(); + var token = tokenResult.AccessToken; + + serviceTokenCache?.RegisterObservability(agentId, tenantId, token, new[] { observabilityScope }); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "There was an error registering for observability: {Message}", ex.Message); + } + + // Invoke the actual operation. + await func().ConfigureAwait(false); + }).ConfigureAwait(false); + } + } +} diff --git a/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/telemetry/AgentMetrics.cs b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/telemetry/AgentMetrics.cs new file mode 100644 index 00000000..e9feff46 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/telemetry/AgentMetrics.cs @@ -0,0 +1,141 @@ +// 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 async Task InvokeObservedHttpOperation(string operationName, Func func) + { + using var activity = ActivitySource.StartActivity(operationName); + try + { + await 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; + } + } + + 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/obo-auth-samples/agent-framework-appRegistration/sample-agent/telemetry/AgentOTELExtensions.cs b/dotnet/obo-auth-samples/agent-framework-appRegistration/sample-agent/telemetry/AgentOTELExtensions.cs new file mode 100644 index 00000000..fc6927a6 --- /dev/null +++ b/dotnet/obo-auth-samples/agent-framework-appRegistration/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; + } + + } +}