diff --git a/dotnet/copilot-studio/sample-agent/.gitignore b/dotnet/copilot-studio/sample-agent/.gitignore new file mode 100644 index 00000000..45cc1e95 --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/.gitignore @@ -0,0 +1,72 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio cache/options directory +.vs/ + +# NuGet Packages +*.nupkg +**/packages/* +*.nuget.props +*.nuget.targets + +# Misc +*.swp +*.*~ +project.lock.json +.DS_Store +*.pdf + +# User secrets +secrets.json + +# Environment files +.env + +# A365 CLI generated config (contains secrets) +a365.config.json +a365.generated.config.json + +# Azure exported settings (contains secrets) +azure-appsettings.json + +# Deployment artifacts +app.zip +publish/ +manifest/manifest.zip + +# Runtime logs +log.txt +*.log +webapp-logs*/ +webapp-logs*.zip + +# Emulator transcripts +emulator/ + +# DevTools logs +devTools/*.log + +# Temporary JSON files +role-grant*.json +scope-update*.json +required-access.json + +# Playground config (with real secrets — the template in repo uses "---" placeholders) +# appsettings.Playground.json diff --git a/dotnet/copilot-studio/sample-agent/Agent/MyAgent.cs b/dotnet/copilot-studio/sample-agent/Agent/MyAgent.cs new file mode 100644 index 00000000..32ca1baa --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/Agent/MyAgent.cs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Agent365CopilotStudioSampleAgent.Client; +using Agent365CopilotStudioSampleAgent.telemetry; +using Microsoft.Agents.A365.Observability.Caching; +using Microsoft.Agents.A365.Runtime.Utils; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.Core; +using Microsoft.Agents.Core.Models; + +namespace Agent365CopilotStudioSampleAgent.Agent +{ + /// + /// MyAgent - Agent 365 sample that integrates with Microsoft Copilot Studio. + /// + /// This agent demonstrates how to: + /// - Receive messages and notifications from Agent 365 (email, Teams, etc.) + /// - Forward messages to a Copilot Studio agent + /// - Return responses through the Agent 365 SDK + /// - Integrate with Agent 365 observability + /// + public class MyAgent : AgentApplication + { + private const string AgentWelcomeMessage = "Hello! I'm connected to Copilot Studio. Send me a message and I'll forward it to the agent!"; + 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."; + + private readonly IConfiguration _configuration; + private readonly IExporterTokenCache? _agentTokenCache; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + private readonly string? AgenticAuthHandlerName; + + public MyAgent( + AgentApplicationOptions options, + IConfiguration configuration, + IExporterTokenCache agentTokenCache, + IHttpClientFactory httpClientFactory, + ILogger logger) : base(options) + { + _configuration = configuration; + _agentTokenCache = agentTokenCache; + _httpClientFactory = httpClientFactory; + _logger = logger; + + AgenticAuthHandlerName = _configuration.GetValue("AgentApplication:AgenticAuthHandlerName"); + + // Greet when members are added to the conversation + OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); + + var agenticHandlers = !string.IsNullOrEmpty(AgenticAuthHandlerName) ? [AgenticAuthHandlerName] : Array.Empty(); + + // Handle agent install / uninstall events + OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: true, autoSignInHandlers: agenticHandlers); + OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: false); + + // Listen for messages — agentic and non-agentic + OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: true, autoSignInHandlers: agenticHandlers); + OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: false); + } + + 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); + } + } + }); + } + + 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); + } + }); + } + + protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + if (turnContext is null) + { + throw new ArgumentNullException(nameof(turnContext)); + } + + 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)"); + + var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; + + if (string.IsNullOrEmpty(userText)) + { + await turnContext.SendActivityAsync("Please send me a message and I'll forward it to Copilot Studio!"); + return; + } + + // Select the appropriate auth handler + string? authHandlerName; + if (turnContext.IsAgenticRequest()) + { + authHandlerName = AgenticAuthHandlerName; + } + else + { + authHandlerName = _configuration.GetValue("AgentApplication:OboAuthHandlerName") ?? AgenticAuthHandlerName; + } + + await A365OtelWrapper.InvokeObservedAgentOperation( + "MessageProcessor", + turnContext, + turnState, + _agentTokenCache, + UserAuthorization, + authHandlerName ?? string.Empty, + _logger, + async () => + { + // Send an immediate acknowledgment + await turnContext.SendActivityAsync(MessageFactory.Text("Got it — working on it…"), cancellationToken).ConfigureAwait(false); + + // Send typing indicator + await turnContext.SendActivityAsync(Activity.CreateTypingActivity(), cancellationToken).ConfigureAwait(false); + + // Background typing loop + 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); + + try + { + // Create the Copilot Studio client and invoke it + var client = await CopilotStudioClientFactory.CreateAsync( + UserAuthorization, + authHandlerName ?? string.Empty, + turnContext, + _configuration, + _httpClientFactory, + _logger); + + var response = await client.InvokeAgentAsync(userText); + + await turnContext.SendActivityAsync(MessageFactory.Text(response), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Copilot Studio query error"); + await turnContext.SendActivityAsync( + MessageFactory.Text($"Error communicating with Copilot Studio: {ex.Message}"), + cancellationToken).ConfigureAwait(false); + } + finally + { + typingCts.Cancel(); + try + { + await typingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected + } + } + }); + } + } +} diff --git a/dotnet/copilot-studio/sample-agent/AspNetExtensions.cs b/dotnet/copilot-studio/sample-agent/AspNetExtensions.cs new file mode 100644 index 00000000..764fec9b --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/AspNetExtensions.cs @@ -0,0 +1,184 @@ +// 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 Agent365CopilotStudioSampleAgent; + +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. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation") + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + + if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true)) + { + System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled"); + return; + } + + services.AddAgentAspNetAuthentication(tokenValidationSection.Get()!); + } + + public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions) + { + AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions)); + + if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId"); + } + + foreach (var audience in validationOptions.Audiences) + { + if (!Guid.TryParse(audience, out _)) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID"); + } + } + + 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 (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl)) + { + validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl; + } + + 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, + }; + + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + options.Events = new JwtBearerEvents + { + OnMessageReceived = async context => + { + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + 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") + { + 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)) + { + 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; } + public string? TenantId { get; set; } + public IList? ValidIssuers { get; set; } + public bool IsGov { get; set; } = false; + public string? AzureBotServiceOpenIdMetadataUrl { get; set; } + public string? OpenIdMetadataUrl { get; set; } + public bool AzureBotServiceTokenHandling { get; set; } = true; + public TimeSpan? OpenIdMetadataRefresh { get; set; } + } +} diff --git a/dotnet/copilot-studio/sample-agent/Client/CopilotStudioAgentClient.cs b/dotnet/copilot-studio/sample-agent/Client/CopilotStudioAgentClient.cs new file mode 100644 index 00000000..9dbeae1b --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/Client/CopilotStudioAgentClient.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App.UserAuth; +using Microsoft.Agents.Core.Models; +using Microsoft.Agents.CopilotStudio.Client; + +namespace Agent365CopilotStudioSampleAgent.Client +{ + /// + /// Client interface for interacting with Copilot Studio agents. + /// + public interface ICopilotStudioAgentClient + { + /// + /// Sends a message to the Copilot Studio agent and returns the response. + /// + Task InvokeAgentAsync(string message); + } + + /// + /// Copilot Studio client wrapper that manages conversation lifecycle + /// and sends/receives messages via the CopilotClient APIs. + /// + public class CopilotStudioAgentClient : ICopilotStudioAgentClient + { + private readonly CopilotClient _client; + private readonly ILogger _logger; + private string _conversationId = string.Empty; + + public CopilotStudioAgentClient(CopilotClient client, ILogger logger) + { + _client = client; + _logger = logger; + } + + /// + /// Sends a message to the Copilot Studio agent and collects the response. + /// + public async Task InvokeAgentAsync(string message) + { + var responses = new List(); + + try + { + // If no conversation started yet, start one + if (string.IsNullOrEmpty(_conversationId)) + { + await foreach (var activity in _client.StartConversationAsync(false)) + { + if (activity.Conversation?.Id is not null) + { + _conversationId = activity.Conversation.Id; + } + + if (activity.Type == ActivityTypes.Message && !string.IsNullOrEmpty(activity.Text)) + { + responses.Add(activity.Text); + } + } + } + + // Ask the question and collect streamed responses + await foreach (var activity in _client.AskQuestionAsync(message, _conversationId)) + { + if (activity.Type == ActivityTypes.Message && !string.IsNullOrEmpty(activity.Text)) + { + responses.Add(activity.Text); + } + } + + return responses.Count > 0 + ? string.Join("\n", responses) + : "No response from Copilot Studio agent."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending message to Copilot Studio"); + throw; + } + } + } + + /// + /// Factory for creating configured Copilot Studio client instances. + /// Acquires an OBO token and initializes the CopilotClient. + /// + public static class CopilotStudioClientFactory + { + /// + /// Creates a configured Copilot Studio client with the appropriate auth token. + /// + public static Task CreateAsync( + UserAuthorization authorization, + string authHandlerName, + ITurnContext turnContext, + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + // Load connection settings from the "CopilotStudio" config section + var settings = LoadConnectionSettings(configuration); + + logger.LogInformation( + "CopilotStudio settings — EnvironmentId: [{EnvironmentId}], SchemaName: [{SchemaName}], Cloud: [{Cloud}], DirectConnectUrl: [{DirectConnectUrl}]", + settings.EnvironmentId, settings.SchemaName, settings.Cloud, settings.DirectConnectUrl); + + // Create a token provider that acquires tokens via the A365 agentic OBO exchange. + // This requires testing via A365 Playground (a365 develop-mcp), not the Bot Framework Emulator, + // because the Playground provides the user token needed for the OBO exchange. + Func> tokenProvider = async (scope) => + { + var token = await authorization.GetTurnTokenAsync(turnContext, authHandlerName); + if (!string.IsNullOrEmpty(token)) + { + return token; + } + + throw new InvalidOperationException( + "Failed to acquire token for Copilot Studio. " + + "This sample requires testing via A365 Playground (a365 develop-mcp), not the Bot Framework Emulator. " + + "The agentic OBO flow needs a user token that only the Playground provides."); + }; + + var copilotClient = new CopilotClient(settings, httpClientFactory, tokenProvider, logger, "WebClient"); + return Task.FromResult(new CopilotStudioAgentClient(copilotClient, logger)); + } + + private static ConnectionSettings LoadConnectionSettings(IConfiguration configuration) + { + var section = configuration.GetSection("CopilotStudio"); + + // Use the IConfigurationSection constructor which handles binding automatically + var settings = new ConnectionSettings(section); + + // Validate that we have enough configuration + if (string.IsNullOrEmpty(settings.DirectConnectUrl) && + (string.IsNullOrEmpty(settings.EnvironmentId) || string.IsNullOrEmpty(settings.SchemaName))) + { + throw new InvalidOperationException( + "Copilot Studio configuration is missing. Provide either 'DirectConnectUrl' or both 'EnvironmentId' and 'SchemaName' in the 'CopilotStudio' configuration section."); + } + + return settings; + } + } +} diff --git a/dotnet/copilot-studio/sample-agent/CopilotStudioSampleAgent.csproj b/dotnet/copilot-studio/sample-agent/CopilotStudioSampleAgent.csproj new file mode 100644 index 00000000..4ee49a9c --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/CopilotStudioSampleAgent.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + a1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e + enable + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/copilot-studio/sample-agent/Program.cs b/dotnet/copilot-studio/sample-agent/Program.cs new file mode 100644 index 00000000..79255db5 --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/Program.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Agent365CopilotStudioSampleAgent; +using Agent365CopilotStudioSampleAgent.Agent; +using Agent365CopilotStudioSampleAgent.telemetry; +using Microsoft.Agents.A365.Observability; +using Microsoft.Agents.A365.Observability.Extensions.AgentFramework; +using Microsoft.Agents.A365.Observability.Runtime; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Storage; +using Microsoft.Agents.Storage.Transcript; +using System.Reflection; + +var builder = WebApplication.CreateBuilder(args); + +// Setup OpenTelemetry +builder.ConfigureOpenTelemetry(); + +builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly()); +builder.Services.AddControllers(); +builder.Services.AddHttpClient("WebClient", client => client.Timeout = TimeSpan.FromSeconds(600)); +builder.Services.AddHttpContextAccessor(); +builder.Logging.AddConsole(); + +// ********** Configure A365 Services ********** +// Configure observability. +builder.Services.AddAgenticTracingExporter(clusterCategory: "production"); + +// Add A365 tracing with Agent Framework integration +builder.AddA365Tracing(config => +{ + config.WithAgentFramework(); +}); +// ********** END Configure A365 Services ********** + +// Add AspNet token validation +builder.Services.AddAgentAspNetAuthentication(builder.Configuration); + +// Register IStorage. For development, MemoryStorage is suitable. +builder.Services.AddSingleton(); + +// Add AgentApplicationOptions from config. +builder.AddAgentApplicationOptions(); + +// Add the agent (which is transient) +builder.AddAgent(); + +// Uncomment to add transcript logging middleware (Development/Playground only — logs full conversation content to disk) +// if (builder.Environment.IsDevelopment() || builder.Environment.EnvironmentName == "Playground") +// { +// 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 +app.MapGet("/api/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow })); + +if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Playground") +{ + app.MapGet("/", () => "Copilot Studio Sample Agent"); + app.MapControllers().AllowAnonymous(); + app.Urls.Add($"http://localhost:3978"); +} +else +{ + app.MapControllers(); +} + +app.Run(); diff --git a/dotnet/copilot-studio/sample-agent/Properties/launchSettings.json b/dotnet/copilot-studio/sample-agent/Properties/launchSettings.json new file mode 100644 index 00000000..4157851d --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "profiles": { + "Sample Agent": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "SKIP_TOOLING_ON_ERRORS": "true" + }, + "applicationUrl": "http://localhost:3978" + }, + "Sample Agent (Playground)": { + "commandName": "Project", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Playground", + "BEARER_TOKEN": "", + "SKIP_TOOLING_ON_ERRORS": "true" + }, + "applicationUrl": "http://localhost:3978" + } + } +} \ No newline at end of file diff --git a/dotnet/copilot-studio/sample-agent/README.md b/dotnet/copilot-studio/sample-agent/README.md new file mode 100644 index 00000000..2150a4a3 --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/README.md @@ -0,0 +1,336 @@ +# Copilot Studio Sample Agent (.NET) + +This sample demonstrates how to integrate a **Microsoft Copilot Studio** agent with the **Microsoft 365 Agents SDK** and **Agent 365 SDK** in .NET. It enables enterprise developers to bridge low-code Copilot Studio agents into Agent 365 managed environments with full feature parity. + +## Why use this integration? + +| Copilot Studio | Agent 365 SDK | Together | +|----------------|---------------|----------| +| Low-code agent building | Enterprise identity (Entra ID) | Best of both worlds | +| Visual flow designer | Microsoft 365 notifications | Low-code agents with enterprise features | +| Built-in connectors | OpenTelemetry observability | Full compliance and monitoring | +| Quick prototyping | Secure Graph access (MCP) | Production-ready deployment | + +## Demonstrates + +This sample demonstrates: + +- **Copilot Studio Integration**: Forward messages to your low-code Copilot Studio agent +- **Observability**: End-to-end tracing with OpenTelemetry spans for Copilot Studio calls +- **Notifications**: Handle email notifications from Agent 365 and return responses +- **Hosting Patterns**: Hosting with Microsoft 365 Agents SDK + +This sample uses the [Microsoft.Agents.CopilotStudio.Client](https://github.com/Microsoft/Agents-for-net) package and the [Microsoft Agent 365 SDK for .NET](https://github.com/microsoft/Agent365-dotnet). + +For comprehensive documentation and guidance on building agents with the Microsoft Agent 365 SDK, including how to add tooling, observability, and notifications, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/). + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ Agent 365 SDK │ │ This Sample │ │ Copilot Studio │ +│ (Notifications)│─────>│ (MyAgent) │─────>│ (Your Low-code │ +└─────────────────┘ └──────────────────┘ │ Agent) │ + │ │ └─────────────────────┘ + │ Email notification │ │ + │───────────────────────>│ Forward message │ + │ │─────────────────────────>│ + │ │ │ + │ │ Agent response │ + │ │<─────────────────────────│ + │ Email response │ │ + │<───────────────────────│ │ +``` + +The agent does **not** call an LLM directly. Instead, it delegates all reasoning to a published Copilot Studio agent via the Power Platform API. + +## Prerequisites + +- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) or later +- Microsoft Agent 365 SDK +- Access to **Microsoft Copilot Studio** (Frontier preview program) +- A published Copilot Studio agent with Web channel enabled +- Azure/Microsoft 365 tenant with administrative permissions +- A **Microsoft 365 Copilot license** (required to publish agents in Copilot Studio) + +## Required Setup Steps + +This sample requires additional configuration beyond the standard Agent 365 setup: + +### 1. Add CopilotStudio.Copilots.Invoke API Permission + +The `CopilotStudio.Copilots.Invoke` scope must be added to your Microsoft Entra ID app registration and blueprint: + +1. Go to [Azure Portal](https://portal.azure.com) → **Microsoft Entra ID** → **App registrations** +2. Select your agent's app registration +3. Go to **API permissions** → **Add a permission** +4. Select **APIs my organization uses** → search for **Power Platform API** +5. Add the `CopilotStudio.Copilots.Invoke` delegated permission +6. Grant admin consent for the permission +7. Update your blueprint to include this scope in the allowed permissions + +### 2. Grant User Access to the Copilot Studio Agent + +Users must have access to chat with your Copilot Studio agent: + +- **Option A: Organization-wide access** (used in this sample) + 1. Open your agent in Copilot Studio + 2. Click **… (three dots)** → **Share** + 3. Select the option to share with everyone in your organization + +- **Option B: Security group access** + 1. Create a security group in Microsoft Entra ID + 2. Add users who need access to the group + 3. Share the agent with that security group in Copilot Studio + +> **Note:** Individual users cannot be granted access directly — you must use security groups or organization-wide sharing. Authentication must be configured with **Microsoft Entra ID** and **"Require users to sign in"** enabled. + +For more details, see [Share agents with other users](https://learn.microsoft.com/en-us/microsoft-copilot-studio/admin-share-bots). + +### 3. Agentic Authentication with Power Platform Audience + +This sample uses the agentic authentication flow to request a token with the **Power Platform audience** (`https://api.powerplatform.com/.default`). This token is required to access and invoke Copilot Studio agents. + +```csharp +// Token provider acquires the agentic OBO token for Copilot Studio API +Func> tokenProvider = async (scope) => +{ + var token = await authorization.GetTurnTokenAsync(turnContext, authHandlerName); + return token; +}; +``` + +## Copilot Studio Setup + +Before running this sample, you need a Copilot Studio agent: + +1. Go to [Copilot Studio](https://copilotstudio.microsoft.com) +2. Create a new agent (or use an existing one) +3. **Publish** your agent +4. Go to **Settings > Advanced > Metadata** and copy: + - **Environment ID** + - **Schema Name** (agent identifier) + + Or copy the **Direct Connect URL** from the agent's channel settings. + +## Configuration + +### Option 1: Direct Connect URL (Recommended) + +Get the Direct Connect URL from Copilot Studio → Settings → Advanced → Metadata. + +In `appsettings.json`: +```json +{ + "CopilotStudio": { + "DirectConnectUrl": "https://your-direct-connect-url" + } +} +``` + +### Option 2: Environment ID + Schema Name + +```json +{ + "CopilotStudio": { + "EnvironmentId": "Default-your-tenant-id", + "SchemaName": "your_agent_schema_name", + "Cloud": "Prod" + } +} +``` + +### Authentication + +The agent uses agentic authentication to acquire tokens scoped to `https://api.powerplatform.com/.default` for calling the Copilot Studio API. + +Configure the blueprint connection in `appsettings.json` under `Connections` > `ServiceConnection`. + +## 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)): + +```csharp +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)"); +``` + +## Handling Agent Install and Uninstall + +When a user installs (hires) or uninstalls (removes) the agent, the A365 platform sends an `InstallationUpdate` activity. 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. + +## 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 Copilot Studio response: + +```csharp +// Message 1: immediate ack — reaches the user right away +await turnContext.SendActivityAsync(MessageFactory.Text("Got it — working on it…"), cancellationToken); + +// ... Copilot Studio processing ... + +// Message 2: the Copilot Studio response +await turnContext.SendActivityAsync(MessageFactory.Text(response), 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 Task.Delay(TimeSpan.FromSeconds(4), typingCts.Token); + await turnContext.SendActivityAsync(Activity.CreateTypingActivity(), 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. + +## 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. + +## Project Structure + +``` +sample-agent/ +├── Agent/ +│ └── MyAgent.cs # Main agent — routes messages to Copilot Studio +├── Client/ +│ └── CopilotStudioAgentClient.cs # Copilot Studio client wrapper + factory +├── telemetry/ +│ ├── AgentOTELExtensions.cs # OpenTelemetry setup +│ ├── AgentMetrics.cs # Custom metrics and tracing +│ └── A365OtelWrapper.cs # A365 observability wrapper +├── Program.cs # Entry point and DI configuration +├── AspNetExtensions.cs # JWT token validation +├── appsettings.json # Production configuration (fill in your values) +├── appsettings.Playground.json # Local development configuration +└── CopilotStudioSampleAgent.csproj +``` + +## Key Dependencies + +| Package | Purpose | +|---------|---------| +| `Microsoft.Agents.CopilotStudio.Client` | Core Copilot Studio client SDK | +| `Microsoft.Agents.Hosting.AspNetCore` | Agent 365 hosting infrastructure | +| `Microsoft.Agents.Authentication.Msal` | MSAL-based authentication | +| `Microsoft.Agents.A365.Notifications` | A365 notification support | +| `Microsoft.Agents.A365.Observability.Extensions.AgentFramework` | A365 observability | + +## Troubleshooting + +### Authentication errors + +**Error:** `401 Unauthorized` when connecting to Copilot Studio + +**Solution:** +- Verify that `CopilotStudio.Copilots.Invoke` permission is added to your Entra ID app registration +- Ensure the permission is granted admin consent +- Check that your agent ID and environment ID are correct +- Verify you have shared access to your Copilot to other users in your organization + +### Consent errors + +**Error:** `AADSTS65001: consent_required` + +**Solution:** +- Grant admin consent for the `CopilotStudio.Copilots.Invoke` delegated permission on the Power Platform API +- Ensure oauth2PermissionGrants include `CopilotStudio.Copilots.Invoke` for all relevant service principals + +### Wrong Environment ID + +**Error:** `404` or empty responses from Copilot Studio + +**Solution:** +- Use the Power Platform environment ID (e.g., `Default-your-tenant-id`), not just the tenant ID +- Verify the schema name matches your published bot exactly + +## Support + +For issues, questions, or feedback: + +- **Issues**: Please file issues in the [GitHub Issues](https://github.com/microsoft/Agent365-Samples/issues) section +- **Documentation**: See the [Microsoft Agents 365 Developer documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/) +- **Copilot Studio**: See [Copilot Studio documentation](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) +- **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) +- [Microsoft.Agents.CopilotStudio.Client package](https://github.com/Microsoft/Agents-for-net) +- [Copilot Studio documentation](https://learn.microsoft.com/en-us/microsoft-copilot-studio/) +- [.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/copilot-studio/sample-agent/appsettings.Playground.json b/dotnet/copilot-studio/sample-agent/appsettings.Playground.json new file mode 100644 index 00000000..22663515 --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/appsettings.Playground.json @@ -0,0 +1,32 @@ +{ + "TokenValidation": { + "Enabled": false, + "Audiences": [ + "---" // this is the Agent Blueprint Client ID + ], + "TenantId": "---" + }, + "CopilotStudio": { + // Option 1: Direct Connect URL (recommended) + // "DirectConnectUrl": "https://your-direct-connect-url", + + // Option 2: Environment ID + Schema Name + "EnvironmentId": "---", + "SchemaName": "---", + "Cloud": "Prod" + }, + "Connections": { + "ServiceConnection": { + "Settings": { + // this is the AuthType for the connection... + "AuthType": "ClientSecret", + "ClientId": "---", + "ClientSecret": "---", + "AuthorityEndpoint": "https://login.microsoftonline.com/{{BOT_TENANT_ID}}", + "Scopes": [ + "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default" + ] + } + } + } +} diff --git a/dotnet/copilot-studio/sample-agent/appsettings.json b/dotnet/copilot-studio/sample-agent/appsettings.json new file mode 100644 index 00000000..b9b9be90 --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/appsettings.json @@ -0,0 +1,73 @@ +{ + "AgentApplication": { + "StartTypingTimer": false, + "RemoveRecipientMention": false, + "NormalizeMentions": false, + "AgenticAuthHandlerName": "agentic", + "UserAuthorization": { + "AutoSignin": false, + "Handlers": { + "agentic": { + "Type": "AgenticUserAuthorization", + "Settings": { + "Scopes": [ + "https://api.powerplatform.com/.default" + ], + "AlternateBlueprintConnectionName": "ServiceConnection" + } + } + } + } + }, + "TokenValidation": { + "Audiences": [ + "" + ], + "Enabled": false, + "TenantId": "" + }, + "CopilotStudio": { + "EnvironmentId": "", + "SchemaName": "", + "Cloud": "Prod" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Agents": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "ClientSecret", + "AuthorityEndpoint": "https://login.microsoftonline.com/", + "ClientId": "", + "ClientSecret": "", + "Scopes": [ + "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default" + ], + "AgentId": "" + } + } + }, + "ConnectionsMap": [ + { + "ServiceUrl": "*", + "Connection": "ServiceConnection" + } + ], + "EnableAgent365Exporter": true, + "Agent365Observability": { + "AgentId": "", + "AgentName": "", + "AgentDescription": "", + "TenantId": "", + "AgentBlueprintId": "", + "ClientId": "", + "ClientSecret": "" + } +} \ No newline at end of file diff --git a/dotnet/copilot-studio/sample-agent/telemetry/A365OtelWrapper.cs b/dotnet/copilot-studio/sample-agent/telemetry/A365OtelWrapper.cs new file mode 100644 index 00000000..595fb3b7 --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/telemetry/A365OtelWrapper.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.Observability.Caching; +using Microsoft.Agents.A365.Observability.Runtime.Common; +using Microsoft.Agents.A365.Runtime.Utils; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App.UserAuth; +using Microsoft.Agents.Builder.State; + +namespace Agent365CopilotStudioSampleAgent.telemetry +{ + public static class A365OtelWrapper + { + public static async Task InvokeObservedAgentOperation( + string operationName, + ITurnContext turnContext, + ITurnState turnState, + IExporterTokenCache? agentTokenCache, + UserAuthorization authSystem, + string authHandlerName, + ILogger? logger, + Func func + ) + { + await AgentMetrics.InvokeObservedAgentOperation( + operationName, + turnContext, + async () => + { + (string agentId, string tenantId) = await ResolveTenantAndAgentId(turnContext, authSystem, authHandlerName); + + using var baggageScope = new BaggageBuilder() + .TenantId(tenantId) + .AgentId(agentId) + .Build(); + + try + { + agentTokenCache?.RegisterObservability(agentId, tenantId, new AgenticTokenStruct + { + UserAuthorization = authSystem, + TurnContext = turnContext, + AuthHandlerName = authHandlerName + }, EnvironmentUtils.GetObservabilityAuthenticationScope()); + } + catch (Exception ex) + { + logger?.LogWarning(ex, "There was an error registering for observability."); + } + + await func().ConfigureAwait(false); + }).ConfigureAwait(false); + } + + private static async Task<(string agentId, string tenantId)> ResolveTenantAndAgentId(ITurnContext turnContext, UserAuthorization authSystem, string authHandlerName) + { + string agentId = ""; + if (turnContext.Activity.IsAgenticRequest()) + { + agentId = turnContext.Activity.GetAgenticInstanceId(); + } + else + { + if (authSystem != null && !string.IsNullOrEmpty(authHandlerName)) + agentId = Utility.ResolveAgentIdentity(turnContext, await authSystem.GetTurnTokenAsync(turnContext, authHandlerName)); + } + agentId = agentId ?? Guid.Empty.ToString(); + string? tempTenantId = turnContext?.Activity?.Conversation?.TenantId ?? turnContext?.Activity?.Recipient?.TenantId; + string tenantId = tempTenantId ?? Guid.Empty.ToString(); + + return (agentId, tenantId); + } + } +} diff --git a/dotnet/copilot-studio/sample-agent/telemetry/AgentMetrics.cs b/dotnet/copilot-studio/sample-agent/telemetry/AgentMetrics.cs new file mode 100644 index 00000000..0cb87ff3 --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/telemetry/AgentMetrics.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core; +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Agent365CopilotStudioSampleAgent.telemetry +{ + public static class AgentMetrics + { + public static readonly string SourceName = "A365.CopilotStudio"; + + public static readonly ActivitySource ActivitySource = new(SourceName); + + private static readonly Meter Meter = new("A365.CopilotStudio", "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.Length"] = context.Activity.Text?.Length ?? 0 + })); + 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 async Task InvokeObservedAgentOperation(string operationName, ITurnContext context, Func func) + { + MessageProcessedCounter.Add(1); + var activity = InitializeMessageHandlingActivity(operationName, context); + var routeStopwatch = Stopwatch.StartNew(); + bool success = false; + try + { + await func(); + success = true; + } + 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, success); + } + } + } +} diff --git a/dotnet/copilot-studio/sample-agent/telemetry/AgentOTELExtensions.cs b/dotnet/copilot-studio/sample-agent/telemetry/AgentOTELExtensions.cs new file mode 100644 index 00000000..5c023935 --- /dev/null +++ b/dotnet/copilot-studio/sample-agent/telemetry/AgentOTELExtensions.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Agent365CopilotStudioSampleAgent.telemetry +{ + public static class AgentOTELExtensions + { + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + 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.CopilotStudio", + 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("A365.CopilotStudio"); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddSource( + "A365.CopilotStudio", + "Microsoft.Agents.Builder", + "Microsoft.Agents.Hosting", + "A365.CopilotStudio.MyAgent", + "Microsoft.AspNetCore", + "System.Net.Http" + ) + .AddAspNetCoreInstrumentation(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); + }; + }) + .AddHttpClientInstrumentation(o => + { + o.RecordException = true; + 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); + var headerList = response.Content?.Headers? + .Select(h => $"{h.Key}={string.Join(",", h.Value)}") + .ToArray(); + + if (headerList is { Length: > 0 }) + { + activity.SetTag("http.response.headers", headerList); + } + }; + o.FilterHttpRequestMessage = request => + !request.RequestUri?.AbsolutePath.Contains("health", StringComparison.OrdinalIgnoreCase) ?? true; + }); + }); + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks(HealthEndpointPath); + + app.MapHealthChecks(AlivenessEndpointPath, new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } + } +}