diff --git a/community/adner/Agent365_Notifications/.gitignore b/community/adner/Agent365_Notifications/.gitignore new file mode 100644 index 00000000..f4176af6 --- /dev/null +++ b/community/adner/Agent365_Notifications/.gitignore @@ -0,0 +1,55 @@ +# Visual Studio temporary files, build results, and +# files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# NuGet Packages +*.nupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ + +# VS Code files +.vscode/ + +# JetBrains Rider +*.sln.iml +.idea/ + +# Mac files +.DS_Store + +.claude/ diff --git a/community/adner/Agent365_Notifications/AgentFrameworkNotificationAgent.sln b/community/adner/Agent365_Notifications/AgentFrameworkNotificationAgent.sln new file mode 100644 index 00000000..2b73632d --- /dev/null +++ b/community/adner/Agent365_Notifications/AgentFrameworkNotificationAgent.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36623.8 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentFrameworkNotificationAgent", "notification-agent\AgentFrameworkNotificationAgent.csproj", "{C05BF552-56C0-8F74-98D5-F51053881902}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C05BF552-56C0-8F74-98D5-F51053881902}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C05BF552-56C0-8F74-98D5-F51053881902}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C05BF552-56C0-8F74-98D5-F51053881902}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C05BF552-56C0-8F74-98D5-F51053881902}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A13DF873-5DE4-4F7D-9734-FA05F32F218E} + EndGlobalSection +EndGlobal diff --git a/community/adner/Agent365_Notifications/README.md b/community/adner/Agent365_Notifications/README.md new file mode 100644 index 00000000..2b6527b6 --- /dev/null +++ b/community/adner/Agent365_Notifications/README.md @@ -0,0 +1,15 @@ +# Agent365 notification Agent + +This is a sample that demonstrates how to implement Agent 365 notification functionality, that allows an agent to respond to comments in a Word document, as well as responding to emails. Check out [this video](https://www.youtube.com/watch?v=xKd1awTemiU) that shows how you can use Agent 365 notifications to collaborate with an agent when reviewing a Word document. + +In addition to responding to the notifications mentioned above, the agent can also respond to Teams messages. In this [blog post](https://nullpointer.se/agent-365-notifications.html) I provide more details on how the sample was implemented. + +## Overview + +This project showcases the notification system that is part of the Agent365 architecture. Before running the sample, you need to setup the infrastructure needed for deploying the agent in Agent 365. Here is a great resource that summarizes the steps necessary: + +[https://learn.microsoft.com/en-us/microsoft-agent-365/developer/a365-dev-lifecycle](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/a365-dev-lifecycle) + +Also check out my [blog post](https://nullpointer.se/exploring-agent-365-cli.html) (written in December 2025, might be dated when you read this) that describes how to use the Agent 365 CLI to provision the infrastructure. + +Ping me LinkedIn if you have any questions: [https://www.linkedin.com/in/andreasadner/](https://www.linkedin.com/in/andreasadner/) diff --git a/community/adner/Agent365_Notifications/notification-agent/.gitignore b/community/adner/Agent365_Notifications/notification-agent/.gitignore new file mode 100644 index 00000000..3c98326e --- /dev/null +++ b/community/adner/Agent365_Notifications/notification-agent/.gitignore @@ -0,0 +1,244 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates +target/ + +# Cake +/.cake +/version.txt +/PSRunCmds*.ps1 + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +/bin/ +/binSigned/ +/obj/ +Drop/ +target/ +Symbols/ +objd/ +.config/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +#nodeJS stuff +/node_modules/ + +#local development +appsettings.local.json +appsettings.Development.json +appsettings.Development* +appsettings.Production.json +**/[Aa]ppManifest/*.zip +.deployment + +# JetBrains Rider +*.sln.iml +.idea + +# Mac files +.DS_Store + +# VS Code files +.vscode +src/samples/ModelContextProtocol/GitHubMCPServer/Properties/ServiceDependencies/GitHubMCPServer20250311143114 - Web Deploy/profile.arm.json + +# VS Files +.vs/ + +# Agent SDK generated files +*.transcript +a365.config.json +a365.generated.config.json +msteams/ +appPackage/ +agents_email/ +agents_Excel/ +manifest/ diff --git a/community/adner/Agent365_Notifications/notification-agent/Agent/MyAgent.cs b/community/adner/Agent365_Notifications/notification-agent/Agent/MyAgent.cs new file mode 100644 index 00000000..7f94c071 --- /dev/null +++ b/community/adner/Agent365_Notifications/notification-agent/Agent/MyAgent.cs @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using NotificationAgent.Tools; +using Microsoft.Agents.A365.Notifications.Models; +using AgentNotification; +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 NotificationAgent.Agent +{ + public class MyAgent : AgentApplication + { + private readonly string AgentInstructions = """ + You are a helpful assistant. + """; + + private readonly IChatClient? _chatClient = null; + private readonly IConfiguration? _configuration = null; + private readonly ILogger? _logger = null; + private readonly IMcpToolRegistrationService? _toolService = null; + private readonly IHttpClientFactory _httpClientFactory; + + // Setup reusable auto sign-in handler for agentic requests + private readonly string AgenticIdAuthHandler = "agentic"; + + private static readonly ConcurrentDictionary> _agentToolCache = new(); + + public MyAgent(AgentApplicationOptions options, + IChatClient chatClient, + IConfiguration configuration, + IMcpToolRegistrationService toolService, + IHttpClientFactory httpClientFactory, + ILogger logger) : base(options) + { + _chatClient = chatClient; + _configuration = configuration; + _logger = logger; + _toolService = toolService; + _httpClientFactory = httpClientFactory; + + // Handle A365 Notification Messages. + this.OnAgenticWordNotification(HandleWordCommentNotificationAsync, autoSignInHandlers: new[] { AgenticIdAuthHandler }); + this.OnAgenticEmailNotification(HandleEmailNotificationAsync, autoSignInHandlers: new[] { AgenticIdAuthHandler }); + + // Handles all messages, regardless of channel - needs to be the last route in order not to hijack A365 nofification handlers. + this.OnActivity(ActivityTypes.Message, OnMessageAsync, autoSignInHandlers: new[] { AgenticIdAuthHandler }); + } + + private async Task HandleEmailNotificationAsync( + ITurnContext turnContext, + ITurnState turnState, + AgentNotificationActivity activity, + CancellationToken cancellationToken) + { + var email = activity.EmailNotification; + + if (email == null) + { + await turnContext.SendActivityAsync("No email data found", + cancellationToken: cancellationToken); + return; + } + + if (string.IsNullOrEmpty(email.Id)) + { + await turnContext.SendActivityAsync("Email ID is missing", + cancellationToken: cancellationToken); + return; + } + + var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; + var agent = await GetClientAgent(turnContext, turnState, _toolService, AgenticIdAuthHandler, "You are a helpful assistant."); + + if (agent == null) + { + await turnContext.SendActivityAsync("Failed to initialize agent", + cancellationToken: cancellationToken); + return; + } + + var response = await agent.RunAsync( + $""" + You have received a mail and your task is to reply to it. Please respond to the + mail using the ReplyToMessageAsync tool using HTML formatted content. The ID of + the email is {email.Id}. This is the content of the mail you received: {userText} + """); + + _logger?.LogInformation("Agent response: {Response}", response.ToString()); + } + + private async Task HandleWordCommentNotificationAsync( + ITurnContext turnContext, + ITurnState turnState, + AgentNotificationActivity activity, + CancellationToken cancellationToken) + { + var comment = activity.WpxCommentNotification; + + var attachments = turnContext.Activity.Attachments; + if (attachments == null || attachments.Count == 0) + { + await turnContext.SendActivityAsync("No attachments found", + cancellationToken: cancellationToken); + return; + } + + var contentUrl = attachments[0].ContentUrl; + if (string.IsNullOrEmpty(contentUrl)) + { + await turnContext.SendActivityAsync("No content URL found in attachment", + cancellationToken: cancellationToken); + return; + } + + if (comment == null) + { + await turnContext.SendActivityAsync("No comment data found", + cancellationToken: cancellationToken); + return; + } + + var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; + var agent = await GetClientAgent(turnContext, turnState, _toolService, AgenticIdAuthHandler); + + if (agent == null) + { + await turnContext.SendActivityAsync("Failed to initialize agent", + cancellationToken: cancellationToken); + return; + } + + var response = await agent.RunAsync( + $""" + Your task is to respond to a comment in a word file. First, get the full content + of the word file to understand the context and find out what the comment is + referring to. Use the tool WordGetDocumentContent for this purpose. The URL to + the document is {contentUrl}. Then find the text the comment with id + {comment.CommentId} is referring to and respond with an answer. + """); + + _logger?.LogInformation("Agent response: {Response}", response.ToString()); + + //Note that we don't respond at the end of this method - we instead let the Word MCP Server handle the reply to the comment. + + } + + /// + /// General Message process for Teams and other channels. + /// + /// + /// + /// + /// + protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + + var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; + var agent = await GetClientAgent(turnContext, turnState, _toolService, AgenticIdAuthHandler); + + // Read or Create the conversation thread for this conversation. + AgentThread? thread = GetConversationThread(agent, turnState); + + var response = await agent!.RunAsync(userText, thread, cancellationToken: cancellationToken); + + await turnContext.SendActivityAsync(response.ToString()); + + turnState.Conversation.SetValue("conversation.threadInfo", ProtocolJsonSerializer.ToJson(thread.Serialize())); + } + + /// + /// Resolve the ChatClientAgent with tools and options for this turn operation. + /// This will use the IChatClient registered in DI. + /// + /// + /// + private async Task GetClientAgent(ITurnContext context, ITurnState turnState, IMcpToolRegistrationService? toolService, string authHandlerName, string agentInstructions = null) + { + AssertionHelpers.ThrowIfNull(_configuration!, nameof(_configuration)); + AssertionHelpers.ThrowIfNull(context, nameof(context)); + AssertionHelpers.ThrowIfNull(_chatClient!, nameof(_chatClient)); + + // Create the local tools we want to register with the agent: + var toolList = new List(); + + // Setup the tools for the agent: + toolList.Add(AIFunctionFactory.Create(DateTimeFunctionTool.getDate)); + + if (toolService != null) + { + try + { + string toolCacheKey = GetToolCacheKey(turnState); + if (_agentToolCache.TryGetValue(toolCacheKey, out var cachedTools) && cachedTools?.Count > 0) + { + toolList.AddRange(cachedTools); + } + else + { + // Notify the user we are loading tools + await context.StreamingResponse.QueueInformativeUpdateAsync("Loading tools..."); + + string agentId = Utility.ResolveAgentIdentity(context, await UserAuthorization.GetTurnTokenAsync(context, authHandlerName)); + var a365Tools = await toolService.GetMcpToolsAsync(agentId, UserAuthorization, authHandlerName, context).ConfigureAwait(false); + + // Add the A365 tools to the tool options + if (a365Tools != null && a365Tools.Count > 0) + { + toolList.AddRange(a365Tools); + _agentToolCache.TryAdd(toolCacheKey, [.. a365Tools]); + } + } + } + catch (Exception ex) + { + // Log error and rethrow - MCP tool registration is required + _logger?.LogError(ex, "Failed to register MCP tool servers. Ensure MCP servers are configured correctly or use mock MCP servers for local testing."); + throw; + } + } + + // Create Chat Options with tools: + var toolOptions = new ChatOptions + { + Tools = toolList + }; + + if(agentInstructions == null) + { + agentInstructions = AgentInstructions; + } + + // Create the chat Client passing in agent instructions and tools: + var chatClientAgent = new ChatClientAgent(_chatClient!, + new ChatClientAgentOptions + { + ChatOptions = toolOptions, + ChatMessageStoreFactory = ctx => + { +#pragma warning disable MEAI001 // MessageCountingChatReducer is for evaluation purposes only and is subject to change or removal in future updates + return new InMemoryChatMessageStore(new MessageCountingChatReducer(10), ctx.SerializedState, ctx.JsonSerializerOptions); +#pragma warning restore MEAI001 // MessageCountingChatReducer is for evaluation purposes only and is subject to change or removal in future updates + } + }) + .AsBuilder() + .Build(); + + return chatClientAgent; + } + + /// + /// Manage Agent threads against the conversation state. + /// + /// ChatAgent + /// State Manager for the Agent. + /// + private static AgentThread GetConversationThread(AIAgent? agent, ITurnState turnState) + { + ArgumentNullException.ThrowIfNull(agent); + AgentThread thread; + string? agentThreadInfo = turnState.Conversation.GetValue("conversation.threadInfo", () => null); + if (string.IsNullOrEmpty(agentThreadInfo)) + { + thread = agent.GetNewThread(); + } + else + { + JsonElement ele = ProtocolJsonSerializer.ToObject(agentThreadInfo); + thread = agent.DeserializeThread(ele); + } + return thread; + } + + private string GetToolCacheKey(ITurnState turnState) + { + string userToolCacheKey = turnState.User.GetValue("user.toolCacheKey", () => null) ?? ""; + if (string.IsNullOrEmpty(userToolCacheKey)) + { + userToolCacheKey = Guid.NewGuid().ToString(); + turnState.User.SetValue("user.toolCacheKey", userToolCacheKey); + return userToolCacheKey; + } + return userToolCacheKey; + } + } +} diff --git a/community/adner/Agent365_Notifications/notification-agent/AgentFrameworkNotificationAgent.csproj b/community/adner/Agent365_Notifications/notification-agent/AgentFrameworkNotificationAgent.csproj new file mode 100644 index 00000000..24297f3e --- /dev/null +++ b/community/adner/Agent365_Notifications/notification-agent/AgentFrameworkNotificationAgent.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + 7a8f9d79-5c4c-495f-8d56-1db8168ef8bd + enable + + + + $(DefineConstants);UseStreaming + + + + + + + + + + + + + + + + + + + + diff --git a/community/adner/Agent365_Notifications/notification-agent/AspNetExtensions.cs b/community/adner/Agent365_Notifications/notification-agent/AspNetExtensions.cs new file mode 100644 index 00000000..cb23cce7 --- /dev/null +++ b/community/adner/Agent365_Notifications/notification-agent/AspNetExtensions.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Core; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; +using System.Collections.Concurrent; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; + +namespace NotificationAgent; + +public static class AspNetExtensions +{ + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + /// + /// Adds token validation typical for ABS/SMBA and Bot-to-bot. + /// default to Azure Public Cloud. + /// + /// + /// + /// Name of the config section to read. + /// Optional logger to use for authentication event logging. + /// + /// Configuration: + /// + /// "TokenValidation": { + /// "Audiences": [ + /// "{required:bot-appid}" + /// ], + /// "TenantId": "{recommended:tenant-id}", + /// "ValidIssuers": [ + /// "{default:Public-AzureBotService}" + /// ], + /// "IsGov": {optional:false}, + /// "AzureBotServiceOpenIdMetadataUrl": optional, + /// "OpenIdMetadataUrl": optional, + /// "AzureBotServiceTokenHandling": "{optional:true}" + /// "OpenIdMetadataRefresh": "optional-12:00:00" + /// } + /// + /// + /// `IsGov` can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used. + /// `ValidIssuers` can be omitted, in which case the Public Azure Bot Service issuers are used. + /// `TenantId` can be omitted if the Agent is not being called by another Agent. Otherwise it is used to add other known issuers. Only when `ValidIssuers` is omitted. + /// `AzureBotServiceOpenIdMetadataUrl` can be omitted. In which case default values in combination with `IsGov` is used. + /// `OpenIdMetadataUrl` can be omitted. In which case default values in combination with `IsGov` is used. + /// `AzureBotServiceTokenHandling` defaults to true and should always be true until Azure Bot Service sends Entra ID token. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation") + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + + if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true)) + { + // Noop if TokenValidation section missing or disabled. + System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled"); + return; + } + + services.AddAgentAspNetAuthentication(tokenValidationSection.Get()!); + } + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions) + { + AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions)); + + // Must have at least one Audience. + if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId"); + } + + // Audience values must be GUID's + foreach (var audience in validationOptions.Audiences) + { + if (!Guid.TryParse(audience, out _)) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID"); + } + } + + // If ValidIssuers is empty, default for ABS Public Cloud + if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0) + { + validationOptions.ValidIssuers = + [ + "https://api.botframework.com", + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", + "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", + ]; + + if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _)) + { + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, validationOptions.TenantId)); + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, validationOptions.TenantId)); + } + } + + // If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens. + if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl)) + { + validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl; + } + + // If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens. + if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl)) + { + validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl; + } + + var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval; + + _ = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + ValidIssuers = validationOptions.ValidIssuers, + ValidAudiences = validationOptions.Audiences, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + }; + + // Using Microsoft.IdentityModel.Validators + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + options.Events = new JwtBearerEvents + { + // Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens. + OnMessageReceived = async context => + { + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + string[] parts = authorizationHeader.Split(' '); + if (parts.Length != 2 || parts[0] != "Bearer") + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + JwtSecurityToken token = new(parts[1]); + string issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value!; + + if (validationOptions.AzureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer)) + { + // Use the Azure Bot authority for this configuration manager + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.AzureBotServiceOpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.AzureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + else + { + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.OpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.OpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + + await Task.CompletedTask.ConfigureAwait(false); + }, + + OnTokenValidated = context => + { + return Task.CompletedTask; + }, + OnForbidden = context => + { + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + return Task.CompletedTask; + } + }; + }); + } + + public class TokenValidationOptions + { + public IList? Audiences { get; set; } + + /// + /// TenantId of the Azure Bot. Optional but recommended. + /// + public string? TenantId { get; set; } + + /// + /// Additional valid issuers. Optional, in which case the Public Azure Bot Service issuers are used. + /// + public IList? ValidIssuers { get; set; } + + /// + /// Can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used. + /// + public bool IsGov { get; set; } = false; + + /// + /// Azure Bot Service OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? AzureBotServiceOpenIdMetadataUrl { get; set; } + + /// + /// Entra OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? OpenIdMetadataUrl { get; set; } + + /// + /// Determines if Azure Bot Service tokens are handled. Defaults to true and should always be true until Azure Bot Service sends Entra ID token. + /// + public bool AzureBotServiceTokenHandling { get; set; } = true; + + /// + /// OpenIdMetadata refresh interval. Defaults to 12 hours. + /// + public TimeSpan? OpenIdMetadataRefresh { get; set; } + } +} diff --git a/community/adner/Agent365_Notifications/notification-agent/Program.cs b/community/adner/Agent365_Notifications/notification-agent/Program.cs new file mode 100644 index 00000000..76ae3e31 --- /dev/null +++ b/community/adner/Agent365_Notifications/notification-agent/Program.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using NotificationAgent; +using NotificationAgent.Agent; +using Azure; +using Azure.AI.OpenAI; +using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; +using Microsoft.Agents.A365.Tooling.Services; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Storage; +using Microsoft.Agents.Storage.Transcript; +using Microsoft.Extensions.AI; +using System.Reflection; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly()); +builder.Services.AddControllers(); +builder.Services.AddHttpClient("WebClient", client => client.Timeout = TimeSpan.FromSeconds(600)); +builder.Services.AddHttpContextAccessor(); +builder.Logging.AddConsole(); + +// Add A365 Tooling Server integration +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Add AspNet token validation +builder.Services.AddAgentAspNetAuthentication(builder.Configuration); + +// Register IStorage. For development, MemoryStorage is suitable. +// For production Agents, persisted storage should be used so +// that state survives Agent restarts, and operate correctly +// in a cluster of Agent instances. +builder.Services.AddSingleton(); + +// Add AgentApplicationOptions from config. +builder.AddAgentApplicationOptions(); + +// Add the bot (which is transient) +builder.AddAgent(); + +// Register IChatClient with correct types +builder.Services.AddSingleton(sp => { + + var confSvc = sp.GetRequiredService(); + var endpoint = confSvc["AIServices:AzureOpenAI:Endpoint"] ?? string.Empty; + var apiKey = confSvc["AIServices:AzureOpenAI:ApiKey"] ?? string.Empty; + var deployment = confSvc["AIServices:AzureOpenAI:DeploymentName"] ?? string.Empty; + + AssertionHelpers.ThrowIfNullOrEmpty(endpoint, "AIServices:AzureOpenAI:Endpoint configuration is missing and required."); + AssertionHelpers.ThrowIfNullOrEmpty(apiKey, "AIServices:AzureOpenAI:ApiKey configuration is missing and required."); + AssertionHelpers.ThrowIfNullOrEmpty(deployment, "AIServices:AzureOpenAI:DeploymentName configuration is missing and required."); + + // Convert endpoint to Uri + var endpointUri = new Uri(endpoint); + + // Convert apiKey to ApiKeyCredential + var apiKeyCredential = new AzureKeyCredential(apiKey); + + // Create and return the AzureOpenAIClient's ChatClient + return new AzureOpenAIClient(endpointUri, apiKeyCredential) + .GetChatClient(deployment) + .AsIChatClient() + .AsBuilder() + .UseFunctionInvocation() + .Build(); +}); + +var app = builder.Build(); + +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 adapter.ProcessAsync(request, response, agent, cancellationToken); +}); + +if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Playground") +{ + app.MapGet("/", () => "Agent Framework Notification Agent"); + app.UseDeveloperExceptionPage(); + app.MapControllers().AllowAnonymous(); + + app.Urls.Add($"http://localhost:3978"); +} +else +{ + app.MapControllers(); +} + +app.Run(); \ No newline at end of file diff --git a/community/adner/Agent365_Notifications/notification-agent/ToolingManifest.json b/community/adner/Agent365_Notifications/notification-agent/ToolingManifest.json new file mode 100644 index 00000000..e4ad75c9 --- /dev/null +++ b/community/adner/Agent365_Notifications/notification-agent/ToolingManifest.json @@ -0,0 +1,12 @@ +{ + "mcpServers": [ + { + "mcpServerName": "mcp_MailTools", + "mcpServerUniqueName": "mcp_MailTools" + }, + { + "mcpServerName": "mcp_WordServer", + "mcpServerUniqueName": "mcp_WordServer" + } + ] +} \ No newline at end of file diff --git a/community/adner/Agent365_Notifications/notification-agent/Tools/DateTimeFunctionTool.cs b/community/adner/Agent365_Notifications/notification-agent/Tools/DateTimeFunctionTool.cs new file mode 100644 index 00000000..f21a7d1f --- /dev/null +++ b/community/adner/Agent365_Notifications/notification-agent/Tools/DateTimeFunctionTool.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; + +namespace NotificationAgent.Tools +{ + public static class DateTimeFunctionTool + { + [Description("Use this tool to get the current date and time")] + public static string getDate() + { + string date = DateTimeOffset.Now.ToString("F", null); + return date; + } + } +} diff --git a/community/adner/Agent365_Notifications/notification-agent/appsettings.json b/community/adner/Agent365_Notifications/notification-agent/appsettings.json new file mode 100644 index 00000000..9518bbfe --- /dev/null +++ b/community/adner/Agent365_Notifications/notification-agent/appsettings.json @@ -0,0 +1,65 @@ +{ + "AgentApplication": { + "StartTypingTimer": false, + "RemoveRecipientMention": false, + "NormalizeMentions": false, + "UserAuthorization": { + "AutoSignin": false, + "Handlers": { + "agentic": { + "Type": "AgenticUserAuthorization", + "Settings": { + "Scopes": [ + "https://graph.microsoft.com/.default" + ], + "AlternateBlueprintConnectionName": "ServiceConnection" + } + } + } + } + }, + "TokenValidation": { + "Audiences": [ + "{{ClientId}}" + ], + "Enabled": false, + "TenantId": "{{TenantId}}" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Agents": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.SemanticKernel*": "Warning" + } + }, + "AllowedHosts": "*", + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "ClientSecret", + "AuthorityEndpoint": "https://login.microsoftonline.com/{{TenantId}}", + "ClientId": "{{BOT_ID}}", + "Scopes": [ + "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default" + ], + "ClientSecret": "{{ClientSecret}}", + "AgentId": "{{ClientId}}" + } + } + }, + "ConnectionsMap": [ + { + "ServiceUrl": "*", + "Connection": "ServiceConnection" + } + ], + "AIServices": { + "AzureOpenAI": { + "DeploymentName": "<>", + "Endpoint": "<>", + "ApiKey": "<>" + } + } +} \ No newline at end of file diff --git a/community/adner/Agent365_Notifications/notification-agent/images/thumbnail.png b/community/adner/Agent365_Notifications/notification-agent/images/thumbnail.png new file mode 100644 index 00000000..a1a1c1bc Binary files /dev/null and b/community/adner/Agent365_Notifications/notification-agent/images/thumbnail.png differ