diff --git a/dotnet/claude/ClaudeSampleAgent.sln b/dotnet/claude/ClaudeSampleAgent.sln new file mode 100644 index 00000000..8ce4f4f7 --- /dev/null +++ b/dotnet/claude/ClaudeSampleAgent.sln @@ -0,0 +1,19 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClaudeSampleAgent", "sample-agent\ClaudeSampleAgent.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/dotnet/claude/sample-agent/Agent-Code-Walkthrough.md b/dotnet/claude/sample-agent/Agent-Code-Walkthrough.md new file mode 100644 index 00000000..d210aef3 --- /dev/null +++ b/dotnet/claude/sample-agent/Agent-Code-Walkthrough.md @@ -0,0 +1,125 @@ +# Agent Code Walkthrough + +This document provides a detailed walkthrough of the code for this agent. The +agent is designed to perform specific tasks autonomously using Anthropic Claude +as the AI backbone, interacting with the user as needed. + +## Key Files in this Solution + +- `Program.cs`: + - This is the entry point for the application. It sets up the necessary services + and middleware for the agent. + - Registers the Anthropic Claude `IChatClient` using the Anthropic.SDK NuGet package: + ```csharp + builder.Services.AddSingleton(sp => + { + var apiKey = confSvc["AIServices:Anthropic:ApiKey"] ?? string.Empty; + return new AnthropicClient(apiKey) + .Messages + .AsBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry(sourceName: AgentMetrics.SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) + .Build(); + }); + ``` + - Configures A365 observability with tracing and the Agent Framework integration: + ```csharp + builder.Services.AddAgenticTracingExporter(clusterCategory: "production"); + builder.AddA365Tracing(config => + { + config.WithAgentFramework(); + }); + ``` + - Registers MCP tooling services for Model Context Protocol tool integration: + ```csharp + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + ``` + - Maps the `/api/messages` endpoint wrapped with observability: + ```csharp + app.MapPost("/api/messages", async (...) => + { + await AgentMetrics.InvokeObservedHttpOperation("agent.process_message", async () => + { + await adapter.ProcessAsync(request, response, agent, cancellationToken); + }); + }); + ``` + +- `Agent/MyAgent.cs`: + - This file contains the implementation of the agent's core logic, including how + it registers handling of activities. + - The constructor registers the agent's activity handlers: + - `OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync)`: + - This registers a handler for when new members are added to the conversation, + sending a welcome message. + - `OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: true, autoSignInHandlers: agenticHandlers)`: + - This registers the `InstallationUpdate` activity type for agentic requests, + triggered when the agent is installed ("hired") or uninstalled ("offboarded"). + - `OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: false)`: + - Same handler registered for non-agentic requests (Playground / WebChat testing). + - `OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: true, autoSignInHandlers: agenticHandlers)`: + - This registers a handler for messages in agentic mode with auto sign-in. + - `OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: false, autoSignInHandlers: oboHandlers)`: + - Same handler for non-agentic messages with OBO auth. + - The `GetClientAgent` method builds the `ChatClientAgent` with tools and options: + - Acquires an access token via agentic auth, OBO auth, or the `BEARER_TOKEN` environment variable (development). + - Creates local tools (e.g., `DateTimeFunctionTool.getDate`). + - Loads MCP tools from the A365 platform via `IMcpToolRegistrationService.GetMcpToolsAsync(...)`. + - Constructs `ChatOptions` with `ModelId`, `Temperature`, `Tools`, and `Instructions`. + - Returns a `ChatClientAgent` backed by the Claude `IChatClient`. + - The `GetConversationSessionAsync` method manages conversation session state: + - Reads serialized session from turn state (`conversation.threadInfo`). + - Creates a new `AgentSession` if none exists, or deserializes the existing one. + +- `Tools/DateTimeFunctionTool.cs`: + - This file contains a local tool that provides the current date and time to the agent. + - Registered as an `AITool` via `AIFunctionFactory.Create(DateTimeFunctionTool.getDate)`. + +- `telemetry/AgentMetrics.cs`: + - Defines the OpenTelemetry `ActivitySource` and `Meter` for the agent (source name: `A365.Claude`). + - Provides counters, histograms, and helper methods for instrumented agent operations. + +- `telemetry/A365OtelWrapper.cs`: + - Wraps agent operations with A365 observability, resolving tenant/agent identity and + propagating baggage via `BaggageBuilder`. + - Registers the observability token cache for the A365 tracing exporter. + +- `telemetry/AgentOTELExtensions.cs`: + - Configures OpenTelemetry for ASP.NET Core, HTTP client, and runtime instrumentation. + - Sets up health check endpoints and service discovery. + +- `ToolingManifest.json`: + - Declares the MCP servers (e.g., `mcp_MailTools`) the agent connects to, including + their URL, required scope, and audience for token acquisition. + +- `appPackage/manifest.json`: + - Teams app manifest defining the agent as a custom engine agent. + +## Activities Handled by the Agent + +### ConversationUpdate Activity (MembersAdded) + +- This activity is triggered when new members join the conversation. +- The `WelcomeMessageAsync` method in `MyAgent.cs` handles this activity: + - It sends a welcome message to each new member (excluding the agent itself). + +### InstallationUpdate Activity + +- This activity is triggered when the agent is installed or uninstalled. +- The `OnInstallationUpdateAsync` method in `MyAgent.cs` handles this activity: + - If the agent is installed (`Add`), it sends a welcome message to the user. + - If the agent is uninstalled (`Remove`), it sends a farewell message to the user. + - The handler logs the action, display name, and user ID for observability. + +### Message Activity + +- This activity is triggered when the agent receives a message from the user. +- The `OnMessageAsync` method in `MyAgent.cs` handles this activity: + - Sends an immediate acknowledgment ("Got it — working on it…"). + - Starts a background typing indicator loop (refreshes every ~4 seconds). + - Resolves the `ChatClientAgent` with MCP tools and Claude model options. + - Streams the Claude response back to the user via `StreamingResponse.QueueTextChunk`. + - Serializes the conversation session to turn state for multi-turn continuity. + - The entire operation is wrapped in `A365OtelWrapper.InvokeObservedAgentOperation` + for full observability with tenant/agent baggage. diff --git a/dotnet/claude/sample-agent/Agent/MyAgent.cs b/dotnet/claude/sample-agent/Agent/MyAgent.cs new file mode 100644 index 00000000..a900d77a --- /dev/null +++ b/dotnet/claude/sample-agent/Agent/MyAgent.cs @@ -0,0 +1,401 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Agent365ClaudeSampleAgent.telemetry; +using Agent365ClaudeSampleAgent.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 Agent365ClaudeSampleAgent.Agent +{ + public class MyAgent : AgentApplication + { + private const string AgentWelcomeMessage = "Hello! I'm a Claude-powered agent. I can help you find information based on what I can access."; + private const string AgentHireMessage = "Thank you for hiring me! I'm powered by Claude and ready to assist you."; + 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 one of the tools: {{mcp_graph_getMyProfile}}, {{mcp_graph_getUserProfile}}, {{mcp_graph_getMyManager}}, {{mcp_graph_getUsersManager}}. + + You should use the {{DateTimeFunctionTool.getDate}} to get the current date and time when needed. + + 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. + string safe = string.IsNullOrWhiteSpace(userName) ? "unknown" : userName.Trim(); + safe = System.Text.RegularExpressions.Regex.Replace(safe, @"[\p{Cc}\p{Cf}]", " ").Trim(); + 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? _agentTokenCache = null; + private readonly ILogger? _logger = null; + private readonly IMcpToolRegistrationService? _toolService = null; + private readonly string? AgenticAuthHandlerName; + private readonly string? OboAuthHandlerName; + 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. + /// + private static bool ShouldSkipToolingOnErrors() + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? + Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? + "Production"; + + var skipToolingOnErrors = Environment.GetEnvironmentVariable("SKIP_TOOLING_ON_ERRORS"); + + return environment.Equals("Development", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(skipToolingOnErrors) && + skipToolingOnErrors.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + public MyAgent(AgentApplicationOptions options, + IChatClient chatClient, + IConfiguration configuration, + IExporterTokenCache agentTokenCache, + IMcpToolRegistrationService toolService, + ILogger logger) : base(options) + { + _chatClient = chatClient; + _configuration = configuration; + _agentTokenCache = agentTokenCache; + _logger = logger; + _toolService = toolService; + + AgenticAuthHandlerName = _configuration.GetValue("AgentApplication:AgenticAuthHandlerName"); + OboAuthHandlerName = _configuration.GetValue("AgentApplication:OboAuthHandlerName"); + + OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); + + var agenticHandlers = !string.IsNullOrEmpty(AgenticAuthHandlerName) ? [AgenticAuthHandlerName] : Array.Empty(); + var oboHandlers = !string.IsNullOrEmpty(OboAuthHandlerName) ? [OboAuthHandlerName] : 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 paths + OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: true, autoSignInHandlers: agenticHandlers); + 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); + } + } + }); + } + + 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)"); + + // Select appropriate auth handler based on request type + string? ObservabilityAuthHandlerName; + string? ToolAuthHandlerName; + if (turnContext.IsAgenticRequest()) + { + ObservabilityAuthHandlerName = ToolAuthHandlerName = AgenticAuthHandlerName; + } + else + { + ObservabilityAuthHandlerName = ToolAuthHandlerName = OboAuthHandlerName; + } + + await A365OtelWrapper.InvokeObservedAgentOperation( + "MessageProcessor", + turnContext, + turnState, + _agentTokenCache, + UserAuthorization, + ObservabilityAuthHandlerName ?? string.Empty, + _logger, + async () => + { + // Send 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); + + 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 session for this conversation. + AgentSession session = await GetConversationSessionAsync(_agent, turnState, cancellationToken).ConfigureAwait(false); + + 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 + await foreach (var response in _agent!.RunStreamingAsync(userText, session, cancellationToken: cancellationToken)) + { + if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) + { + turnContext?.StreamingResponse.QueueTextChunk(response.Text); + } + } + JsonElement serializedSession = await _agent!.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); + turnState.Conversation.SetValue("conversation.threadInfo", ProtocolJsonSerializer.ToJson(serializedSession)); + } + finally + { + typingCts.Cancel(); + try + { + await typingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected + } + await turnContext.StreamingResponse.EndStreamAsync(cancellationToken).ConfigureAwait(false); + } + }); + } + + /// + /// Resolve the ChatClientAgent with tools and options for this turn. + /// Uses the IChatClient backed by Anthropic.SDK 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."); + } + + var displayName = context.Activity.From?.Name; + + // Create local tools + var toolList = new List(); + toolList.Add(AIFunctionFactory.Create(DateTimeFunctionTool.getDate)); + + // Load MCP tools from A365 platform + 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..."); + + 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. In Microsoft.Agents.AI 1.0.0-rc4 the + // ChatClientAgentOptions no longer exposes an "Instructions" property; + // the system instructions are supplied via ChatOptions.Instructions. + // ModelId is required by Anthropic.SDK — it maps to the "model" field + // in the Anthropic Messages API request (e.g. "claude-sonnet-4-20250514"). + var modelId = _configuration?["AIServices:Anthropic:ModelId"] ?? "claude-sonnet-4-20250514"; + var toolOptions = new ChatOptions + { + ModelId = modelId, + Temperature = (float?)0.2, + Tools = toolList, + Instructions = GetAgentInstructions(displayName) + }; + + // Create the ChatClientAgent with Claude-backed IChatClient. + // In rc4 the former ChatMessageStoreFactory was replaced by ChatHistoryProvider; + // conversation state is driven by AgentSession (serialized by the agent). + return new ChatClientAgent(_chatClient!, + new ChatClientAgentOptions + { + ChatOptions = toolOptions, + ChatHistoryProvider = new InMemoryChatHistoryProvider() + }) + .AsBuilder() + .UseOpenTelemetry(sourceName: AgentMetrics.SourceName, (cfg) => cfg.EnableSensitiveData = true) + .Build(); + } + + private static async Task GetConversationSessionAsync(AIAgent? agent, ITurnState turnState, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(agent); + string? agentThreadInfo = turnState.Conversation.GetValue("conversation.threadInfo", () => null); + if (string.IsNullOrEmpty(agentThreadInfo)) + { + return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + } + + JsonElement ele = ProtocolJsonSerializer.ToObject(agentThreadInfo); + return await agent.DeserializeSessionAsync(ele, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static 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; + } + + } +} diff --git a/dotnet/claude/sample-agent/AspNetExtensions.cs b/dotnet/claude/sample-agent/AspNetExtensions.cs new file mode 100644 index 00000000..bd4c5c55 --- /dev/null +++ b/dotnet/claude/sample-agent/AspNetExtensions.cs @@ -0,0 +1,180 @@ +// 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 Agent365ClaudeSampleAgent; + +public static class AspNetExtensions +{ + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + 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/claude/sample-agent/ClaudeSampleAgent.csproj b/dotnet/claude/sample-agent/ClaudeSampleAgent.csproj new file mode 100644 index 00000000..96005910 --- /dev/null +++ b/dotnet/claude/sample-agent/ClaudeSampleAgent.csproj @@ -0,0 +1,39 @@ + + + net8.0 + enable + enable + Agent365ClaudeSampleAgent + b3e7f1a2-c4d5-6789-abcd-ef0123456789 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/claude/sample-agent/Program.cs b/dotnet/claude/sample-agent/Program.cs new file mode 100644 index 00000000..cc16dada --- /dev/null +++ b/dotnet/claude/sample-agent/Program.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Agent365ClaudeSampleAgent; +using Agent365ClaudeSampleAgent.Agent; +using Agent365ClaudeSampleAgent.telemetry; +using Anthropic.SDK; +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.Extensions.AI; +using System.Reflection; + +var builder = WebApplication.CreateBuilder(args); + +// Setup OpenTelemetry, Service Discovery, Resilience, and Health Checks +builder.ConfigureOpenTelemetry(); + +builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly()); +builder.Services.AddControllers(); +builder.Services.AddHttpClient("WebClient", client => client.Timeout = TimeSpan.FromSeconds(600)); +builder.Services.AddHttpContextAccessor(); +builder.Logging.AddConsole(); + +// ********** Configure A365 Services ********** +// Configure observability +builder.Services.AddAgenticTracingExporter(clusterCategory: "production"); + +// Add A365 tracing with Agent Framework integration +builder.AddA365Tracing(config => +{ + config.WithAgentFramework(); +}); + +// Add A365 Tooling Server integration (MCP) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +// ********** END Configure A365 Services ********** + +// Add AspNet token validation +builder.Services.AddAgentAspNetAuthentication(builder.Configuration); + +// Register IStorage (MemoryStorage for development; use persisted storage in production) +builder.Services.AddSingleton(); + +// Add AgentApplicationOptions from config +builder.AddAgentApplicationOptions(); + +// Add the agent (transient) +builder.AddAgent(); + +// Uncomment to add transcript logging middleware to log all conversations to files +// builder.Services.AddSingleton([new TranscriptLoggerMiddleware(new FileTranscriptLogger())]); + +// Register IChatClient backed by Anthropic Claude via Anthropic.SDK +builder.Services.AddSingleton(sp => +{ + var confSvc = sp.GetRequiredService(); + var apiKey = confSvc["AIServices:Anthropic:ApiKey"] ?? string.Empty; + var modelId = confSvc["AIServices:Anthropic:ModelId"] ?? "claude-sonnet-4-20250514"; + + AssertionHelpers.ThrowIfNullOrEmpty(apiKey, "AIServices:Anthropic:ApiKey configuration is missing and required."); + + // Create the Anthropic.SDK client → IChatClient via .Messages + // .AsBuilder() → UseFunctionInvocation (auto-invokes tools) → UseOpenTelemetry → Build + return new AnthropicClient(apiKey) + .Messages + .AsBuilder() + .UseFunctionInvocation() + .UseOpenTelemetry(sourceName: AgentMetrics.SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) + .Build(); +}); + +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 = System.DateTime.UtcNow })); + +if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Playground") +{ + app.MapGet("/", () => "Claude Sample Agent - Powered by Anthropic SDK"); + app.UseDeveloperExceptionPage(); + app.MapControllers().AllowAnonymous(); + + app.Urls.Add($"http://localhost:3978"); +} +else +{ + app.MapControllers(); +} + +app.Run(); diff --git a/dotnet/claude/sample-agent/README.md b/dotnet/claude/sample-agent/README.md new file mode 100644 index 00000000..e6af05a9 --- /dev/null +++ b/dotnet/claude/sample-agent/README.md @@ -0,0 +1,156 @@ +# Claude (Anthropic SDK) Sample Agent + +## Overview +This is a sample showing how to use [Anthropic Claude](https://docs.anthropic.com/) as the AI backbone in an agent using the Microsoft Agent 365 SDK and Microsoft 365 Agents SDK, via the [Anthropic.SDK](https://github.com/tghamm/Anthropic.SDK) NuGet package. + +It covers: + +- **Observability**: End-to-end tracing, caching, and monitoring for agent applications +- **Notifications**: Services and models for managing user notifications +- **Tools**: Model Context Protocol tools for building advanced agent solutions +- **Hosting Patterns**: Hosting with Microsoft 365 Agents SDK + +This sample uses the [Microsoft Agent 365 SDK for .NET](https://github.com/microsoft/Agent365-dotnet). + +For comprehensive documentation and guidance on building agents with the Microsoft Agent 365 SDK, including how to add tooling, observability, and notifications, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/). + +## Prerequisites + +- .NET 8.0 or higher +- Microsoft Agent 365 SDK +- Anthropic API Key ([console.anthropic.com](https://console.anthropic.com/)) + +## 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 Claude's system instructions for personalized responses, with prompt injection sanitization: + +```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 — 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 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. + +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) +- [Anthropic Claude documentation](https://docs.anthropic.com/) +- [Anthropic.SDK NuGet package](https://github.com/tghamm/Anthropic.SDK) +- [.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/claude/sample-agent/ToolingManifest.json b/dotnet/claude/sample-agent/ToolingManifest.json new file mode 100644 index 00000000..3086499d --- /dev/null +++ b/dotnet/claude/sample-agent/ToolingManifest.json @@ -0,0 +1,12 @@ +{ + "mcpServers": [ + { + "mcpServerName": "mcp_MailTools", + "mcpServerUniqueName": "mcp_MailTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools", + "scope": "McpServers.Mail.All", + "audience": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" + } + + ] +} diff --git a/dotnet/claude/sample-agent/Tools/DateTimeFunctionTool.cs b/dotnet/claude/sample-agent/Tools/DateTimeFunctionTool.cs new file mode 100644 index 00000000..2c3fe6a3 --- /dev/null +++ b/dotnet/claude/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 Agent365ClaudeSampleAgent.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/claude/sample-agent/appPackage/color.png b/dotnet/claude/sample-agent/appPackage/color.png new file mode 100644 index 00000000..01aa37e3 Binary files /dev/null and b/dotnet/claude/sample-agent/appPackage/color.png differ diff --git a/dotnet/claude/sample-agent/appPackage/manifest.json b/dotnet/claude/sample-agent/appPackage/manifest.json new file mode 100644 index 00000000..bce06935 --- /dev/null +++ b/dotnet/claude/sample-agent/appPackage/manifest.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.22/MicrosoftTeams.schema.json", + "manifestVersion": "1.22", + "version": "1.0.0", + "id": "${{TEAMS_APP_ID}}", + "developer": { + "name": "Microsoft, Inc.", + "websiteUrl": "https://www.example.com", + "privacyUrl": "https://www.example.com/privacy", + "termsOfUseUrl": "https://www.example.com/termofuse" + }, + "icons": { + "color": "color.png", + "outline": "outline.png" + }, + "name": { + "short": "Agent 365 Claude Sample Agent", + "full": "Agent 365 Claude (Anthropic SDK) Sample Agent" + }, + "description": { + "short": "Sample demonstrating Agent 365 SDK, Teams, and Anthropic Claude", + "full": "Sample demonstrating Agent 365 SDK with Anthropic Claude via Anthropic.SDK NuGet package" + }, + "accentColor": "#FFFFFF", + "copilotAgents": { + "customEngineAgents": [ + { + "id": "${{AAD_APP_CLIENT_ID}}", + "type": "bot" + } + ] + }, + "bots": [ + { + "botId": "${{AAD_APP_CLIENT_ID}}", + "scopes": [ + "personal" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "permissions": [ + "identity", + "messageTeamMembers" + ], + "validDomains": [ + "<>" + ] +} diff --git a/dotnet/claude/sample-agent/appPackage/outline.png b/dotnet/claude/sample-agent/appPackage/outline.png new file mode 100644 index 00000000..f7a4c864 Binary files /dev/null and b/dotnet/claude/sample-agent/appPackage/outline.png differ diff --git a/dotnet/claude/sample-agent/appsettings.Playground.json b/dotnet/claude/sample-agent/appsettings.Playground.json new file mode 100644 index 00000000..fef8e27b --- /dev/null +++ b/dotnet/claude/sample-agent/appsettings.Playground.json @@ -0,0 +1,28 @@ +{ + "TokenValidation": { + "Enabled": false, + "Audiences": [ + "---" + ], + "TenantId": "---" + }, + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "ClientSecret", + "ClientId": "---", + "ClientSecret": "---", + "AuthorityEndpoint": "https://login.microsoftonline.com/{{BOT_TENANT_ID}}", + "Scopes": [ + "https://api.botframework.com/.default" + ] + } + } + }, + "AIServices": { + "Anthropic": { + "ApiKey": "---", + "ModelId": "claude-sonnet-4-20250514" + } + } +} diff --git a/dotnet/claude/sample-agent/appsettings.json b/dotnet/claude/sample-agent/appsettings.json new file mode 100644 index 00000000..ce8b117a --- /dev/null +++ b/dotnet/claude/sample-agent/appsettings.json @@ -0,0 +1,61 @@ +{ + "AgentApplication": { + "StartTypingTimer": false, + "RemoveRecipientMention": false, + "NormalizeMentions": false, + "AgenticAuthHandlerName": "agentic", + "UserAuthorization": { + "AutoSignin": false, + "Handlers": { + "agentic": { + "Type": "AgenticUserAuthorization", + "Settings": { + "Scopes": [ + "https://graph.microsoft.com/.default" + ], + "AlternateBlueprintConnectionName": "ServiceConnection" + } + } + } + } + }, + "TokenValidation": { + "Audiences": [ + "{{ClientId}}" + ] + }, + "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/{{BOT_TENANT_ID}}", + "ClientId": "{{BOT_ID}}", + "ClientSecret": "{{BOT_SECRET}}", + "Scopes": [ + "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default" + ] + } + } + }, + "ConnectionsMap": [ + { + "ServiceUrl": "*", + "Connection": "ServiceConnection" + } + ], + "AIServices": { + "Anthropic": { + "ApiKey": "----", + "ModelId": "claude-sonnet-4-20250514" + } + } +} \ No newline at end of file diff --git a/dotnet/claude/sample-agent/m365agents.playground.yml b/dotnet/claude/sample-agent/m365agents.playground.yml new file mode 100644 index 00000000..9a9fc24d --- /dev/null +++ b/dotnet/claude/sample-agent/m365agents.playground.yml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://aka.ms/m365-agents-toolkits/v1.11/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.11 + +environmentFolderPath: ./env diff --git a/dotnet/claude/sample-agent/m365agents.yml b/dotnet/claude/sample-agent/m365agents.yml new file mode 100644 index 00000000..50d15fc3 --- /dev/null +++ b/dotnet/claude/sample-agent/m365agents.yml @@ -0,0 +1,4 @@ +# yaml-language-server: $schema=https://aka.ms/m365-agents-toolkits/v1.11/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.11 diff --git a/dotnet/claude/sample-agent/telemetry/A365OtelWrapper.cs b/dotnet/claude/sample-agent/telemetry/A365OtelWrapper.cs new file mode 100644 index 00000000..06f73e26 --- /dev/null +++ b/dotnet/claude/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 Agent365ClaudeSampleAgent.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("There was an error registering for observability: {Message}", ex.Message); + } + + 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 = string.IsNullOrEmpty(agentId) ? Guid.Empty.ToString() : agentId; + string? tempTenantId = turnContext?.Activity?.Conversation?.TenantId ?? turnContext?.Activity?.Recipient?.TenantId; + string tenantId = string.IsNullOrEmpty(tempTenantId) ? Guid.Empty.ToString() : tempTenantId; + + return (agentId, tenantId); + } + } +} diff --git a/dotnet/claude/sample-agent/telemetry/AgentMetrics.cs b/dotnet/claude/sample-agent/telemetry/AgentMetrics.cs new file mode 100644 index 00000000..c8b18c80 --- /dev/null +++ b/dotnet/claude/sample-agent/telemetry/AgentMetrics.cs @@ -0,0 +1,136 @@ +// 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 Agent365ClaudeSampleAgent.telemetry +{ + public static class AgentMetrics + { + public static readonly string SourceName = "A365.Claude"; + + public static readonly ActivitySource ActivitySource = new(SourceName); + + private static readonly Meter Meter = new("A365.Claude", "1.0.0"); + + public static readonly Counter MessageProcessedCounter = Meter.CreateCounter( + "agent.messages.processed", + "messages", + "Number of messages processed by the agent"); + + public static readonly Counter RouteExecutedCounter = Meter.CreateCounter( + "agent.routes.executed", + "routes", + "Number of routes executed by the agent"); + + public static readonly Histogram MessageProcessingDuration = Meter.CreateHistogram( + "agent.message.processing.duration", + "ms", + "Duration of message processing in milliseconds"); + + public static readonly Histogram RouteExecutionDuration = Meter.CreateHistogram( + "agent.route.execution.duration", + "ms", + "Duration of route execution in milliseconds"); + + public static readonly UpDownCounter ActiveConversations = Meter.CreateUpDownCounter( + "agent.conversations.active", + "conversations", + "Number of active conversations"); + + public static Activity InitializeMessageHandlingActivity(string handlerName, ITurnContext context) + { + var activity = ActivitySource.StartActivity(handlerName); + activity?.SetTag("Activity.Type", context.Activity.Type.ToString()); + activity?.SetTag("Agent.IsAgentic", context.IsAgenticRequest()); + activity?.SetTag("Caller.Id", context.Activity.From?.Id); + activity?.SetTag("Conversation.Id", context.Activity.Conversation?.Id); + activity?.SetTag("Channel.Id", context.Activity.ChannelId?.ToString()); + activity?.SetTag("Message.Text.Length", context.Activity.Text?.Length ?? 0); + + activity?.AddEvent(new ActivityEvent("Message.Processed", DateTimeOffset.UtcNow, new() + { + ["Agent.IsAgentic"] = context.IsAgenticRequest(), + ["Caller.Id"] = context.Activity.From?.Id, + ["Channel.Id"] = context.Activity.ChannelId?.ToString(), + ["Message.Id"] = context.Activity.Id, + ["Message.Text"] = context.Activity.Text + })); + return activity!; + } + + public static void FinalizeMessageHandlingActivity(Activity activity, ITurnContext context, long duration, bool success) + { + MessageProcessingDuration.Record(duration, + new("Conversation.Id", context.Activity.Conversation?.Id ?? "unknown"), + new("Channel.Id", context.Activity.ChannelId?.ToString() ?? "unknown")); + + RouteExecutedCounter.Add(1, + new("Route.Type", "message_handler"), + new("Conversation.Id", context.Activity.Conversation?.Id ?? "unknown")); + + if (success) + { + activity?.SetStatus(ActivityStatusCode.Ok); + } + else + { + activity?.SetStatus(ActivityStatusCode.Error); + } + activity?.Stop(); + activity?.Dispose(); + } + + public static Task InvokeObservedHttpOperation(string operationName, Action func) + { + using var activity = ActivitySource.StartActivity(operationName); + try + { + func(); + activity?.SetStatus(ActivityStatusCode.Ok); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, new() + { + ["exception.type"] = ex.GetType().FullName, + ["exception.message"] = ex.Message, + ["exception.stacktrace"] = ex.StackTrace + })); + throw; + } + return Task.CompletedTask; + } + + public static Task InvokeObservedAgentOperation(string operationName, ITurnContext context, Func func) + { + MessageProcessedCounter.Add(1); + 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/claude/sample-agent/telemetry/AgentOTELExtensions.cs b/dotnet/claude/sample-agent/telemetry/AgentOTELExtensions.cs new file mode 100644 index 00000000..d37ec345 --- /dev/null +++ b/dotnet/claude/sample-agent/telemetry/AgentOTELExtensions.cs @@ -0,0 +1,161 @@ +// 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 Agent365ClaudeSampleAgent.telemetry +{ + 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 => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + 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.Claude", + 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.Claude", + "Microsoft.Agents.Builder", + "Microsoft.Agents.Hosting", + "A365.Claude.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 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(); + } + + return builder; + } + } +}