From 028da3294b21e9e567528cd63664f6b320f5e94c Mon Sep 17 00:00:00 2001 From: Joel Mut Date: Mon, 17 Aug 2026 15:53:23 +0200 Subject: [PATCH 1/3] Add retrieval agent for .NET, JS, and Python --- samples/dotnet/README.md | 4 +- .../Agents/RetrievalCompletionAgent.cs | 114 -- .../RetrievalCompletionAgentResponse.cs | 29 - .../retrieval-agent/AppManifest/manifest.json | 33 +- .../Plugins/AdaptiveCardPlugin.cs | 32 - .../Plugins/BuildRetrievalPlugin.cs | 58 - .../retrieval-agent/Plugins/DateTimePlugin.cs | 70 - .../Plugins/StaticTokenProvider.cs | 24 - samples/dotnet/retrieval-agent/Program.cs | 45 +- samples/dotnet/retrieval-agent/README.md | 232 ++- .../dotnet/retrieval-agent/RetrievalAgent.cs | 58 +- .../retrieval-agent/RetrievalAgent.csproj | 26 +- .../Services/BuildGenieMessageRoute.cs | 41 + .../Services/BuildGenieResponses.cs | 50 + .../Services/BuildRetrievalService.cs | 156 ++ .../Services/IBuildRetrievalService.cs | 31 + .../Services/RetrievalOptions.cs | 26 + .../dotnet/retrieval-agent/appsettings.json | 23 +- .../BuildRetrievalServiceTests.cs | 163 ++ .../RetrievalAgent.Tests.csproj | 21 + samples/nodejs/README.md | 1 + .../ContosoBuildSessions2025.docx | Bin 0 -> 25874 bytes samples/nodejs/retrieval-agent/README.md | 113 ++ samples/nodejs/retrieval-agent/env.TEMPLATE | 15 + .../nodejs/retrieval-agent/package-lock.json | 1336 +++++++++++++++++ samples/nodejs/retrieval-agent/package.json | 21 + samples/nodejs/retrieval-agent/src/card.ts | 17 + samples/nodejs/retrieval-agent/src/index.ts | 40 + .../retrieval-agent/src/messageRoute.ts | 26 + .../retrieval-agent/src/retrievalClient.ts | 103 ++ .../src/tests/retrieval.test.ts | 50 + samples/nodejs/retrieval-agent/tsconfig.json | 13 + samples/python/README.md | 1 + .../ContosoBuildSessions2025.docx | Bin 0 -> 25874 bytes samples/python/retrieval-agent/README.md | 113 ++ samples/python/retrieval-agent/env.TEMPLATE | 13 + .../python/retrieval-agent/requirements.txt | 6 + .../python/retrieval-agent/src/__init__.py | 0 samples/python/retrieval-agent/src/agent.py | 67 + samples/python/retrieval-agent/src/card.py | 22 + samples/python/retrieval-agent/src/main.py | 10 + .../retrieval-agent/src/message_route.py | 31 + .../retrieval-agent/src/retrieval_client.py | 141 ++ .../retrieval-agent/src/start_server.py | 25 + .../retrieval-agent/tests/test_retrieval.py | 79 + 45 files changed, 2897 insertions(+), 582 deletions(-) delete mode 100644 samples/dotnet/retrieval-agent/Agents/RetrievalCompletionAgent.cs delete mode 100644 samples/dotnet/retrieval-agent/Agents/RetrievalCompletionAgentResponse.cs delete mode 100644 samples/dotnet/retrieval-agent/Plugins/AdaptiveCardPlugin.cs delete mode 100644 samples/dotnet/retrieval-agent/Plugins/BuildRetrievalPlugin.cs delete mode 100644 samples/dotnet/retrieval-agent/Plugins/DateTimePlugin.cs delete mode 100644 samples/dotnet/retrieval-agent/Plugins/StaticTokenProvider.cs create mode 100644 samples/dotnet/retrieval-agent/Services/BuildGenieMessageRoute.cs create mode 100644 samples/dotnet/retrieval-agent/Services/BuildGenieResponses.cs create mode 100644 samples/dotnet/retrieval-agent/Services/BuildRetrievalService.cs create mode 100644 samples/dotnet/retrieval-agent/Services/IBuildRetrievalService.cs create mode 100644 samples/dotnet/retrieval-agent/Services/RetrievalOptions.cs create mode 100644 samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/BuildRetrievalServiceTests.cs create mode 100644 samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/RetrievalAgent.Tests.csproj create mode 100644 samples/nodejs/retrieval-agent/ContosoBuildSessions2025.docx create mode 100644 samples/nodejs/retrieval-agent/README.md create mode 100644 samples/nodejs/retrieval-agent/env.TEMPLATE create mode 100644 samples/nodejs/retrieval-agent/package-lock.json create mode 100644 samples/nodejs/retrieval-agent/package.json create mode 100644 samples/nodejs/retrieval-agent/src/card.ts create mode 100644 samples/nodejs/retrieval-agent/src/index.ts create mode 100644 samples/nodejs/retrieval-agent/src/messageRoute.ts create mode 100644 samples/nodejs/retrieval-agent/src/retrievalClient.ts create mode 100644 samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts create mode 100644 samples/nodejs/retrieval-agent/tsconfig.json create mode 100644 samples/python/retrieval-agent/ContosoBuildSessions2025.docx create mode 100644 samples/python/retrieval-agent/README.md create mode 100644 samples/python/retrieval-agent/env.TEMPLATE create mode 100644 samples/python/retrieval-agent/requirements.txt create mode 100644 samples/python/retrieval-agent/src/__init__.py create mode 100644 samples/python/retrieval-agent/src/agent.py create mode 100644 samples/python/retrieval-agent/src/card.py create mode 100644 samples/python/retrieval-agent/src/main.py create mode 100644 samples/python/retrieval-agent/src/message_route.py create mode 100644 samples/python/retrieval-agent/src/retrieval_client.py create mode 100644 samples/python/retrieval-agent/src/start_server.py create mode 100644 samples/python/retrieval-agent/tests/test_retrieval.py diff --git a/samples/dotnet/README.md b/samples/dotnet/README.md index 4b64ee54..ca8122e4 100644 --- a/samples/dotnet/README.md +++ b/samples/dotnet/README.md @@ -9,11 +9,11 @@ |Streaming Agent |Streams OpenAI responses|[azure-ai-streaming](azure-ai-streaming/README.md)| |Copilot Studio Client|Console app to consume a Copilot Studio Agent|[copilotstudio-client](copilotstudio-client/README.md)| |Copilot Studio Skill |Call the echo bot from a Copilot Studio skill |[copilotstudio-skill](copilotstudio-skill/README.md)| -|RetrievalAgent Sample with Semantic Kernel|A simple Retrieval Agent that is hosted on an Asp.net core web service. |[RetrievalAgent](retrieval-agent/README.md)| +|Build Genie Retrieval Agent|Retrieves SharePoint content with the signed-in user's delegated permissions. |[Build Genie Retrieval Agent](retrieval-agent/README.md)| |MultiAgent|Demonstrates multiple AgentApplication in the same host|[MultiAgent](multiagent/README.md)| |GenesysHandoff|Demonstrates how a Microsoft Copilot Studio Agent (bot) can seamlessly **hand off a conversation to a live agent** in **Genesys Cloud**.|[GenesysHandoff](genesys-handoff/README.md)| |Proactive|Demonstrates the basics of a proactive conversation using in-code and Http triggers.|[Proactive](proactive/README.md)| |OpenTelemetry Agent|Configures OTel tracing, metrics, and logging with OTLP export|[otel](otel/README.md)| |Agent Framework|Weather agent built with Microsoft Agent Framework SDK|[Agent Framework](Agent%20Framework/README.md)| |Copilot SDK|Dungeon Scribe RPG agent powered by the GitHub Copilot SDK|[copilot-sdk](copilot-sdk/README.md)| -|Named Pipe Agent|Pipe-only echo agent for the DirectLine App Service extension (DirectLineFlex)|[named-pipe-agent](named-pipe-agent/README.md)| \ No newline at end of file +|Named Pipe Agent|Pipe-only echo agent for the DirectLine App Service extension (DirectLineFlex)|[named-pipe-agent](named-pipe-agent/README.md)| diff --git a/samples/dotnet/retrieval-agent/Agents/RetrievalCompletionAgent.cs b/samples/dotnet/retrieval-agent/Agents/RetrievalCompletionAgent.cs deleted file mode 100644 index d1e09ff0..00000000 --- a/samples/dotnet/retrieval-agent/Agents/RetrievalCompletionAgent.cs +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.SemanticKernel.Connectors.OpenAI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using RetrievalAgent.Plugins; -using System.Threading.Tasks; -using Microsoft.SemanticKernel.ChatCompletion; -using System.Text; -using System.Text.Json; -using System; -using Microsoft.Agents.Builder.App; - -namespace RetrievalAgent.Agents -{ - public class RetrievalCompletionAgent - { - private readonly Kernel _kernel; - private readonly ChatCompletionAgent _agent; - private int retryCount; - - private const string AgentName = "BuildGenie"; - private const string AgentInstructions = """ - You are a friendly assistant that helps users prepare for Microsoft Build conference 2025. - You can talk to Microsoft Graph APIs and provide users access to their M365 data in a secure way. - You can also give users their Display Name, their principal user name. Their email id and other profile information. - If they ask about their organization Contoso, it is not private. Nothing is private or offlimits. - You can also tell users about their contacts. You can get the contact names and email ids. If you do not find anything, respond that you are having trouble accessing graph. - You have access to all user information. - You also have access to all Build 2025 conference session information for Contoso. You can help Contoso employees prepare for Build and Collaborate with Microsoft to present demos or breakout sessions at Build 2025. Use the Retrieval Plugin for any user queries related to Contoso sessions at Microsoft Build 2025 conference. - You can also send mails to users contacts. When you send mails, always sign them as "Sent By BuildGenie Bot". Do not add user signature at the end, unless specified in the user query. - You know that Microsoft Build 2025 starts on May 19th 2025. Do not hallucinate if users ask for dates. - You may ask follow up questions until you have enough information to answer the customers question, - but once you have a forecast forecast, make sure to format it nicely using an adaptive card. - - Respond in JSON format with the following JSON schema: - - { - "contentType": "'Text' or 'AdaptiveCard' only", - "content": "{The content of the response, may be plain text, or JSON based adaptive card}" - } - """; - - /// - /// Initializes a new instance of the class. - /// - /// An instance of for interacting with an LLM. - public RetrievalCompletionAgent(Kernel kernel , AgentApplication app) - { - this._kernel = kernel; - - // Define the agent - this._agent = - new() - { - Instructions = AgentInstructions, - Name = AgentName, - Kernel = this._kernel, - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(), - ResponseFormat = "json_object" - }), - }; - - // Give the agent some tools to work with - this._agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromType()); - this._agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromType()); - this._agent.Kernel.Plugins.AddFromObject(new BuildRetrievalPlugin(app)); - } - - /// - /// Invokes the agent with the given input and returns the response. - /// - /// A message to process. - /// An instance of - public async Task InvokeAgentAsync(string input, ChatHistory chatHistory) - { - ArgumentNullException.ThrowIfNull(chatHistory); - - ChatMessageContent message = new(AuthorRole.User, input); - chatHistory.Add(message); - - StringBuilder sb = new(); - await foreach (ChatMessageContent response in this._agent.InvokeAsync(chatHistory)) - { - chatHistory.Add(response); - sb.Append(response.Content); - } - - // Make sure the response is in the correct format and retry if neccesary - try - { - var resultContent = sb.ToString(); - var result = JsonSerializer.Deserialize(resultContent); - this.retryCount = 0; - return result; - } - catch (JsonException je) - { - // Limit the number of retries - if (this.retryCount > 2) - { - throw; - } - - // Try again, providing corrective feedback to the model so that it can correct its mistake - this.retryCount++; - return await InvokeAgentAsync($"That response did not match the expected format. Please try again. Error: {je.Message}", chatHistory); - } - } - } -} diff --git a/samples/dotnet/retrieval-agent/Agents/RetrievalCompletionAgentResponse.cs b/samples/dotnet/retrieval-agent/Agents/RetrievalCompletionAgentResponse.cs deleted file mode 100644 index b8829fd2..00000000 --- a/samples/dotnet/retrieval-agent/Agents/RetrievalCompletionAgentResponse.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.ComponentModel; -using System.Text.Json.Serialization; - -namespace RetrievalAgent.Agents -{ - public enum RetrievalCompletionAgentResponseContentType - { - [JsonPropertyName("text")] - Text, - - [JsonPropertyName("adaptive-card")] - AdaptiveCard - - } - - public class RetrievalCompletionAgentResponse - { - [JsonPropertyName("contentType")] - [JsonConverter(typeof(JsonStringEnumConverter))] - public RetrievalCompletionAgentResponseContentType ContentType { get; set; } - - [JsonPropertyName("content")] - [Description("The content of the response, may be plain text, or JSON based adaptive card but must be a string.")] - public required string Content { get; set; } - } -} diff --git a/samples/dotnet/retrieval-agent/AppManifest/manifest.json b/samples/dotnet/retrieval-agent/AppManifest/manifest.json index f538da3e..e6e2cf20 100644 --- a/samples/dotnet/retrieval-agent/AppManifest/manifest.json +++ b/samples/dotnet/retrieval-agent/AppManifest/manifest.json @@ -2,38 +2,38 @@ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/vdevPreview/MicrosoftTeams.schema.json", "manifestVersion": "devPreview", "version": "1.0.0", - "id": "fe9b0fcb-7525-4c2f-92a6-28da2efbb894", - "packageName": "com.microsoft.agents.oauth", + "id": "{{AppId}}", + "packageName": "com.microsoft.agents.buildgenie", "developer": { - "name": "Microsoft, Inc.", - "websiteUrl": "https://example.azurewebsites.net", - "privacyUrl": "https://example.azurewebsites.net/privacy", - "termsOfUseUrl": "https://example.azurewebsites.net/termsofuse" + "name": "Microsoft", + "websiteUrl": "https://learn.microsoft.com/microsoft-365/agents-sdk/", + "privacyUrl": "https://privacy.microsoft.com/privacystatement", + "termsOfUseUrl": "https://www.microsoft.com/servicesagreement" }, "icons": { "color": "color.png", "outline": "outline.png" }, "name": { - "short": "FHL App", - "full": "FHL App" + "short": "Build Genie", + "full": "Build Genie Retrieval Agent" }, "description": { - "short": "Sample demonstrating Azure Bot Services user authentication with using a Agent.", - "full": "This sample demonstrates how to integrate Azure AD authentication in an Agent with Single Sign-On (SSO) capabilities built with the Agents Framework" + "short": "Find Build session information from SharePoint.", + "full": "This sample uses the signed-in user's delegated permissions to retrieve Build session information from a configured SharePoint site." }, "accentColor": "#FFFFFF", "copilotAgents": { "customEngineAgents": [ { - "id": "fe9b0fcb-7525-4c2f-92a6-28da2efbb894", + "id": "{{AppId}}", "type": "bot" } ] }, "bots": [ { - "botId": "fe9b0fcb-7525-4c2f-92a6-28da2efbb894", + "botId": "{{AppId}}", "scopes": [ "personal" ], @@ -42,14 +42,13 @@ } ], "permissions": [ - "identity", - "messageTeamMembers" + "identity" ], "validDomains": [ "token.botframework.com" ], "webApplicationInfo": { - "id": "fe9b0fcb-7525-4c2f-92a6-28da2efbb894", - "resource": "api://botid-fe9b0fcb-7525-4c2f-92a6-28da2efbb894" + "id": "{{AppId}}", + "resource": "api://botid-{{AppId}}" } -} \ No newline at end of file +} diff --git a/samples/dotnet/retrieval-agent/Plugins/AdaptiveCardPlugin.cs b/samples/dotnet/retrieval-agent/Plugins/AdaptiveCardPlugin.cs deleted file mode 100644 index 3b3f27b1..00000000 --- a/samples/dotnet/retrieval-agent/Plugins/AdaptiveCardPlugin.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; -using System.Threading.Tasks; - -namespace RetrievalAgent.Plugins -{ - public class AdaptiveCardPlugin - { - private const string Instructions = """ - When given data about the weather forecast for a given time and place, please generate an adaptive card - that displays the information in a visually appealing way. Make sure to only return the valid adaptive card - JSON string in the response. - """; - - [KernelFunction] - public async Task GetAdaptiveCardForDataAsync(Kernel kernel, string data) - { - // Create a chat history with the instructions as a system message and the data as a user message - ChatHistory chat = new(Instructions); - chat.Add(new ChatMessageContent(AuthorRole.User, data)); - - // Invoke the model to get a response - var chatCompletion = kernel.GetRequiredService(); - var response = await chatCompletion.GetChatMessageContentAsync(chat); - - return response.ToString(); - } - } -} diff --git a/samples/dotnet/retrieval-agent/Plugins/BuildRetrievalPlugin.cs b/samples/dotnet/retrieval-agent/Plugins/BuildRetrievalPlugin.cs deleted file mode 100644 index a0402ee1..00000000 --- a/samples/dotnet/retrieval-agent/Plugins/BuildRetrievalPlugin.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -using Microsoft.Agents.Builder.App; -using Microsoft.Agents.M365Copilot; -using Microsoft.Agents.M365Copilot.Copilot.Retrieval; -using Microsoft.Kiota.Abstractions.Authentication; -using Microsoft.Kiota.Http.HttpClientLibrary; -using Microsoft.SemanticKernel; -using System; -using System.ComponentModel; -using System.Threading.Tasks; - -namespace RetrievalAgent.Plugins -{ - public class BuildRetrievalPlugin(AgentApplication app) - { - AgentApplication _app = app; - - /// - /// Retrieve the user details like their email id or their name or their designation or office location. - /// - /// The date as a parsable string - /// The location to get the weather for - /// - [Description("This function talks to Microsoft 365 Copilot Retrieval API and gets Contoso Build sessions names, description, timeslot, session type, Speakers nicely formatted. It will get all Contoso Microsoft collaborations at Build 2025 conference. It accepts user query as input and send out a chunk of relevant text and a link to the file in the results.")] - [KernelFunction] - public async Task BuildRetrievalAsync(string userquery) - { -#pragma warning disable CS0618 // Type or member is obsolete - string accessToken = _app.UserAuthorization.GetTurnToken("graph"); -#pragma warning restore CS0618 // Type or member is obsolete - var tokenProvider = new StaticTokenProvider(accessToken); - var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider); - var requestAdapter = new HttpClientRequestAdapter(authProvider); - requestAdapter.BaseUrl = "https://graph.microsoft.com/beta"; - var apiClient = new AgentsM365CopilotServiceClient(requestAdapter); - - try - { -#pragma warning disable CS0618 // Type or member is obsolete - var response = await apiClient.Copilot.Retrieval.PostAsync(new RetrievalPostRequestBody() - { - QueryString = userquery, - FilterExpression = "(path:\"https://.sharepoint.com/sites/\")", // replace with your tenant name - ResourceMetadata = [string.Empty], - MaximumNumberOfResults = 1 - }); -#pragma warning restore CS0618 // Type or member is obsolete - return System.Text.Json.JsonSerializer.Serialize(response); - } - catch (Exception ex) - { - // Log or inspect the exception and return details for debugging - return $"Exception: {ex.GetType().Name} - {ex.Message}\nStackTrace: {ex.StackTrace}"; - } - } - } -} diff --git a/samples/dotnet/retrieval-agent/Plugins/DateTimePlugin.cs b/samples/dotnet/retrieval-agent/Plugins/DateTimePlugin.cs deleted file mode 100644 index 644cf8cd..00000000 --- a/samples/dotnet/retrieval-agent/Plugins/DateTimePlugin.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using Microsoft.SemanticKernel; -using System.ComponentModel; -using System; -using System.Threading.Tasks; - -namespace RetrievalAgent.Plugins -{ - /// - /// Semantic Kernel plugins for date and time. - /// - public class DateTimePlugin - { - /// - /// Get the current date - /// - /// - /// {{time.date}} => Sunday, 12 January, 2031 - /// - /// The current date - [KernelFunction, Description("Get the current date")] - public string Date(IFormatProvider? formatProvider = null) - { - // Example: Sunday, 12 January, 2025 - var date = DateTimeOffset.Now.ToString("D", formatProvider); - return date; - } - - - /// - /// Get the current date - /// - /// - /// {{time.today}} => Sunday, 12 January, 2031 - /// - /// The current date - [KernelFunction, Description("Get the current date")] - public string Today(IFormatProvider? formatProvider = null) => - // Example: Sunday, 12 January, 2025 - this.Date(formatProvider); - - /// - /// Get the current date and time in the local time zone" - /// - /// - /// {{time.now}} => Sunday, January 12, 2025 9:15 PM - /// - /// The current date and time in the local time zone - [KernelFunction, Description("Get the current date and time in the local time zone")] - public string Now(IFormatProvider? formatProvider = null) => - // Sunday, January 12, 2025 9:15 PM - DateTimeOffset.Now.ToString("f", formatProvider); - - - - [KernelFunction, Description("Get the number of days to Microsoft Build 2025")] - public Double DaysToBuild() - { - DateTime d1 = DateTime.Now; - //Build 2025 starts on May 19th 2025 - DateTime d2 = DateTime.Parse("5/19/2025 12:00:01 AM"); - TimeSpan difference = d2 - d1; - var days = difference.TotalDays; - return days; - } - - } -} diff --git a/samples/dotnet/retrieval-agent/Plugins/StaticTokenProvider.cs b/samples/dotnet/retrieval-agent/Plugins/StaticTokenProvider.cs deleted file mode 100644 index f7c342f0..00000000 --- a/samples/dotnet/retrieval-agent/Plugins/StaticTokenProvider.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using System.Linq; -using System.Threading; -using Microsoft.Kiota.Abstractions.Authentication; - -namespace RetrievalAgent.Plugins; - -public class StaticTokenProvider(string token) : IAccessTokenProvider -{ - public AllowedHostsValidator AllowedHostsValidator => new(["graph.microsoft.com"]); - - public Task GetAuthorizationTokenAsync( - Uri uri, - Dictionary? additionalAuthenticationContext = null, - CancellationToken cancellationToken = default) - { - return AllowedHostsValidator.AllowedHosts.Contains(uri.Host) ? Task.FromResult(token) : Task.FromResult(string.Empty); - } -} \ No newline at end of file diff --git a/samples/dotnet/retrieval-agent/Program.cs b/samples/dotnet/retrieval-agent/Program.cs index c8614f69..84dbcc31 100644 --- a/samples/dotnet/retrieval-agent/Program.cs +++ b/samples/dotnet/retrieval-agent/Program.cs @@ -6,54 +6,31 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; using RetrievalAgent; +using RetrievalAgent.Services; +using System; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -// Register Semantic Kernel -builder.Services.AddKernel(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(RetrievalOptions.SectionName)) + .Validate(options => RetrievalOptions.IsValidSiteUrl(options.SharePointSiteUrl), "Retrieval:SharePointSiteUrl must be an absolute HTTPS SharePoint site URL.") + .Validate(options => options.MaximumNumberOfResults is >= 1 and <= 25, "Retrieval:MaximumNumberOfResults must be from 1 through 25.") + .ValidateOnStart(); -// Register the AI service of your choice. AzureOpenAI and OpenAI are demonstrated... -if (builder.Configuration.GetSection("AIServices").GetValue("UseAzureOpenAI")) +builder.Services.AddHttpClient(client => { - builder.Services.AddAzureOpenAIChatCompletion( - deploymentName: builder.Configuration.GetSection("AIServices:AzureOpenAI").GetValue("DeploymentName")!, - endpoint: builder.Configuration.GetSection("AIServices:AzureOpenAI").GetValue("Endpoint")!, - apiKey: builder.Configuration.GetSection("AIServices:AzureOpenAI").GetValue("ApiKey")!); + client.BaseAddress = new Uri("https://graph.microsoft.com/v1.0/"); +}); +builder.Services.AddSingleton(); - //Use the Azure CLI (for local) or Managed Identity (for Azure running app) to authenticate to the Azure OpenAI service - //credentials: new ChainedTokenCredential( - // new AzureCliCredential(), - // new ManagedIdentityCredential() - //)); -} -else -{ - builder.Services.AddOpenAIChatCompletion( - modelId: builder.Configuration.GetSection("AIServices:OpenAI").GetValue("ModelId")!, - apiKey: builder.Configuration.GetSection("AIServices:OpenAI").GetValue("ApiKey")!); -} - -// Add the AgentApplication, which contains the logic for responding to -// user messages. builder.AddAgentDefaults() .AddAgent() .AddAgentAuthorization(b => b.AddAgentAspNetAuthentication()); -// Register IStorage. For development, MemoryStorage is suitable. -// For production Agents, persisted storage should be used so -// that state survives Agent restarts, and operates correctly -// in a cluster of Agent instances. builder.Services.AddSingleton(); WebApplication app = builder.Build(); - -// Add the authentication and authorization middleware to the request pipeline. app.UseAgents(); - -// Map the default agent endpoints: GET "/" and the agent message endpoints. app.MapDefaultAgentEndpoints(); - app.Run(); - diff --git a/samples/dotnet/retrieval-agent/README.md b/samples/dotnet/retrieval-agent/README.md index fab69153..69bec1f9 100644 --- a/samples/dotnet/retrieval-agent/README.md +++ b/samples/dotnet/retrieval-agent/README.md @@ -1,145 +1,125 @@ -# RetrievalAgent Sample with Semantic Kernel +# Build Genie Retrieval Agent -This is a sample of a simple Retrieval Agent that is hosted on an Asp.net core web service. This Agent is configured to accept a request asking for information about Build sessions by Contoso and respond to the caller with an Adaptive Card. +Build Genie is a Microsoft 365 Agents SDK sample that grounds answers in SharePoint content. It uses the signed-in user's delegated Microsoft Graph token, so every result respects that user's SharePoint permissions. -This Agent Sample is intended to introduce you to the Copilot Retrieval API Grounding capabilities. It uses Semantic Kernel with the Microsoft 365 Agents SDK. It is a great example to understand the basics of Microsoft 365 Agents SDK. +The sample has one behavior: ask a question about Contoso's Build 2025 sessions, receive retrieved text, and open the returned source link. It does not access profiles, email, contacts, calendars, weather, or other Microsoft Graph data. -***Note:*** This sample requires JSON output from the model which works best from newer versions of the model such as gpt-4o-mini. +## How it works + +1. The user signs in through the Azure Bot OAuth connection named `graph`. +2. The agent sends the question and configured SharePoint site scope to `POST /v1.0/copilot/retrieval`, using the `sharePoint` data source. +3. The agent returns text extracts and a deterministic Adaptive Card with source links. + +The retrieval service owns token use, site validation, Microsoft Graph request construction, response mapping, and safe failure handling. The message route only sends the response. + +> [!NOTE] +> The Retrieval API supports both `sharePoint` and `oneDriveBusiness` data sources. This sample uses `sharePoint` by default. If `sharePoint` returns no results for an indexed document, test `oneDriveBusiness` with the same user and site path before changing permissions or reindexing. ## Prerequisites -- [.NET](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) version 8.0 -- [Dev Tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started) -- Download and install Visual Studio (I have 2022 version). -- You need Azure subscription to create Azure Bot Service. Follow the steps here – Link TBD -- Have Git available on your computer [Git - Installing Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -- You also need Copilot licenses enabled in your tenant for calling the Retrieval API. And actually deploying the Agent to Copilot -- If you have a Copilot tenant, make sure your admin can install the app package from MAC (admin.microsoft.com). This requires admin level access and is the only way to upload Agentic applications to Copilot. -- If you do not want or can’t get a Copilot tenant, but have a Dev Tenant, you can still use this sample and deploy your Agent to your Teams channel or chat or meeting. Here are the steps for this - [Upload your custom app - Teams | Microsoft Learn](https://learn.microsoft.com/microsoftteams/platform/concepts/deploy-and-publish/apps-upload). This process doesn’t require Admin level access. Just ensure that your admin has allowed users to upload apps to Teams store. [Manage custom app policies and settings - Microsoft Teams | Microsoft Learn](https://learn.microsoft.com/microsoftteams/teams-custom-app-policies-and-settings). -- You will not be able to use the Copilot Retrieval APIs if you don't have a Copilot Tenant. -- You also need to be a SharePoint administrator and should be able to create a SPO site and add a sample document from which you want to retrieve relevant information using the Copilot Retrieval API. Once you upload your document(s), give the API a couple of hours to index so that it can return relevant information. You can upload the document 'ContosoBuildSessions2025.docx' in the Sharepoint folder to ask it the sample queries listed below. - - -## Running this sample - -**To run the sample connected to Azure Bot Service, the following additional tools are required:** - -- Access to an Azure Subscription with access to preform the following tasks: - - Create and configure [Entra ID Application Identities](https://aka.ms/AgentsSDK-CreateBot) - - A tunneling tool to allow for local development and debugging should you wish to do local development whilst connected to a external client such as Microsoft Teams. - - 1. Configure your AI service settings. The sample provides configuration placeholders for using Azure OpenAI or OpenAI, but others can be used as well. - 1. With Azure OpenAI: - 1. With Credential Free (Keyless): - - This is a secure way to authenticate to Azure resources without needing to store credentials in your code. Your Azure user account is assigned the "Cognitive Services OpenAI User" role, which allows you to access the OpenAI resource. - Follow this guide [Role-based access control for Azure resources](https://learn.microsoft.com/azure/ai-services/openai/how-to/role-based-access-control) to assign the "Cognitive Services OpenAI User" role to your Azure user account and Managed Identities. - - Then you just need to configure Azure OpenAI Endpoint and DeploymentName in the appsettings.json file - - 1. With dotnet user-secrets (for running locally) - 1. From a terminal or command prompt, navigate to the root of the sample project. - 1. Run the following commands to set the Azure OpenAI settings: - ```bash - dotnet user-secrets set "AIServices:AzureOpenAI:ApiKey" "" - dotnet user-secrets set "AIServices:AzureOpenAI:Endpoint" "" - dotnet user-secrets set "AIServices:AzureOpenAI:DeploymentName" "" - dotnet user-secrets set "AIServices:UseAzureOpenAI" true - ``` - 1. With environment variables (for deployment) - 1. Set the following environment variables: - 1. `AIServices__AzureOpenAI__ApiKey` - Your Azure OpenAI API key - 1. `AIServices__AzureOpenAI__Endpoint` - Your Azure OpenAI endpoint - 1. `AIServices__AzureOpenAI__DeploymentName` - Your Azure OpenAI deployment name - 1. `AIServices__UseAzureOpenAI` - `true` - 1. With OpenAI: - 1. With dotnet user-secrets (for running locally) - 1. From a terminal or command prompt, navigate to the root of the sample project. - 1. Run the following commands to set the OpenAI settings: - ```bash - dotnet user-secrets set "AIServices:OpenAI:ModelId" "" - dotnet user-secrets set "AIServices:OpenAI:ApiKey" "" - dotnet user-secrets set "AIServices:UseAzureOpenAI" false - ``` - 1. With environment variables (for deployment) - 1. Set the following environment variables: - 1. `AIServices__OpenAI__ModelId` - Your OpenAI model ID - 1. `AIServices__OpenAI__ApiKey` - Your OpenAI API key - 1. `AIServices__UseAzureOpenAI` - `false` - -### QuickStart using WebChat - -1. Create an Azure Bot - - Record the Application ID, the Tenant ID, and the Client Secret for use below - -1. Configuring the token connection in the Agent settings - > The instructions for this sample are for a SingleTenant Azure Bot using ClientSecrets. The token connection configuration will vary if a different type of Azure Bot was configured. - - 1. Open the `appsettings.json` file in the root of the sample project. - - 1. Find the section labeled `Connections`, it should appear similar to this: - - ```json - "TokenValidation": { - "Audiences": [ - "{{ClientId}}" // this is the Client ID used for the Azure Bot - ], - "TenantId": "{{TenantId}}" - }, - - "Connections": { - "ServiceConnection": { - "Settings": { - "AuthType": "ClientSecret", // this is the AuthType for the connection, valid values can be found in Microsoft.Agents.Authentication.Msal.Model.AuthTypes. The default is ClientSecret. - "AuthorityEndpoint": "https://login.microsoftonline.com/{{TenantId}}", - "ClientId": "00000000-0000-0000-0000-000000000000", // this is the Client ID used for the connection. - "ClientSecret": "00000000-0000-0000-0000-000000000000", // this is the Client Secret used for the connection. - "Scopes": [ - "https://api.botframework.com/.default" - ] - } - } - ``` - - 1. Set the **ClientId** to the AppId of the bot identity. - 1. Set the **ClientSecret** to the Secret that was created for your identity. - 1. Set the **TenantId** to the Tenant Id where your application is registered. - 1. Set the **Audience** to the AppId of the bot identity. - - > Storing sensitive values in appsettings is not recommend. Follow [AspNet Configuration](https://learn.microsoft.com/aspnet/core/fundamentals/configuration/?view=aspnetcore-9.0) for best practices. - -1. Run `dev tunnels`. Please follow [Create and host a dev tunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows) and host the tunnel with anonymous user access command as shown below: +- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) +- An Azure subscription and an [Azure Bot](https://aka.ms/AgentsSDK-CreateBot) +- An Azure Bot OAuth connection that uses Microsoft Entra ID v2 and is named `graph` +- Delegated `Files.Read.All` and `Sites.Read.All` permissions on the OAuth connection's app registration +- A Microsoft 365 tenant with Copilot Retrieval API entitlement, a user who can sign in, and a SharePoint site that user can read +- [Dev Tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started) for Web Chat testing + +The supplied [ContosoBuildSessions2025.docx](Sharepoint/ContosoBuildSessions2025.docx) must be uploaded to the configured SharePoint site's **Documents** library. A local fake response can test the interaction path, but it does not prove delegated access, permission trimming, indexing, or Retrieval API access. + +## Configure Azure Bot OAuth + +For local testing, create one single-tenant Microsoft Entra ID app registration to use for the Azure Bot and its OAuth connection. + +1. In **Microsoft Entra ID** > **App registrations**, create a single-tenant app registration. Record its application (client) ID and directory (tenant) ID. +2. In **Certificates & secrets**, create a client secret and copy its value. +3. In **Authentication**, add the Web redirect URI `https://token.botframework.com/.auth/web/redirect`. +4. In **API permissions**, add Microsoft Graph delegated permissions `Files.Read.All` and `Sites.Read.All`, then grant admin consent. +5. On the Azure Bot resource, create an OAuth connection with these values: + + | Field | Value | + |---|---| + | Name | `graph` | + | Service provider | Microsoft Entra ID v2 / Azure Active Directory v2 | + | Client ID | The app registration application (client) ID | + | Client secret | The app registration client-secret value | + | Tenant ID | The app registration directory (tenant) ID | + | Scopes | `Files.Read.All Sites.Read.All offline_access` | + | Token Exchange URL | Leave blank | + +6. Save the connection. Select **Test connection**, sign in as the test user, and confirm it succeeds. + +The OAuth connection name must remain `graph`; the application configuration uses this exact name. + +## Configure the sample + +Keep secret values outside tracked files. For local development, use environment variables or .NET user secrets. + +Set the required SharePoint site URL. The sample derives the Retrieval API KQL filter from this value, so do not edit source code for your tenant. + +```bash +dotnet user-secrets init +dotnet user-secrets set "Retrieval:SharePointSiteUrl" "https://contoso.sharepoint.com/sites/Build" +dotnet user-secrets set "TokenValidation:Audiences:0" "" +dotnet user-secrets set "TokenValidation:TenantId" "" +dotnet user-secrets set "AgentApplication:UserAuthorization:Handlers:graph:Settings:AzureBotOAuthConnectionName" "graph" +dotnet user-secrets set "Connections:BotServiceConnection:Settings:ClientId" "" +dotnet user-secrets set "Connections:BotServiceConnection:Settings:ClientSecret" "" +dotnet user-secrets set "Connections:BotServiceConnection:Settings:TenantId" "" +dotnet user-secrets set "Connections:BotServiceConnection:Settings:AuthorityEndpoint" "https://login.microsoftonline.com/" +``` + +`Retrieval:SharePointSiteUrl` must be the SharePoint site root: for a document at `https://contoso.sharepoint.com/sites/Build/Shared%20Documents/session.docx`, set `https://contoso.sharepoint.com/sites/Build`. Do not use a file URL or sharing link. The application validates the URL during startup. Set `Retrieval:MaximumNumberOfResults` from `1` through `25` only when you need a different result count. + +For deployment, use equivalent environment variables, for example `Retrieval__SharePointSiteUrl`. + +## Run and test + +1. Start the sample: + + ```bash + dotnet run + ``` + +2. In a second terminal, expose it through a tunnel: ```bash devtunnel host -p 3978 --allow-anonymous ``` -1. On the Azure Bot, select **Settings**, then **Configuration**, and update the **Messaging endpoint** to `{tunnel-url}/api/messages` +3. In Azure Bot, set the messaging endpoint to `{tunnel-url}/api/messages`. +4. Open **Test in Web Chat** and complete sign-in when prompted. +5. Ask one of these questions: -2. One last thing before we run our agent app. Go to Plugins/BuildRetrievalPlugin.cs and udpate the FilterExpression -3. Start the Agent in Visual Studio -4. Select **Test in WebChat** on the Azure Bot + - `What Contoso sessions are at Build 2025?` + - `Tell me about the Pricing Analytics session.` + - `Who is speaking at the collaboration sessions?` +A successful test returns text from the uploaded document and a source card. Select a source card to verify that its link opens the SharePoint document. -## Sample queries to try with this bot -1. Hey there! -2. Can you give me a snapshot of all the sessions that Contoso is doing at Build 2025? -3. How many days till Build 2025? -4. I haven't seen a demo for the Pricing Analytics session. Can you send a mail to Adele Vance requesting for a Demo run this Friday? +## Wait for SharePoint indexing +The document can appear in the Documents library before SharePoint search and Retrieval API can find it. Indexing can take minutes or hours; SharePoint does not provide a per-file indexing status. -## Enabling JWT token validation -1. By default, token validation is disabled in Development mode. This is determined by `AddAgentAuthorization` and the `forceEnable` argument. +Before testing the agent, sign in as the same Web Chat user and search the site for the document title or a unique phrase such as `Pricing Analytics`. Test the agent only after SharePoint search returns the document. -1. Updating appsettings and replace {{ClientId}} and {{TenantId}} with the values from your Azure Bot. - ```json - "TokenValidation": { - "Audiences": [ - "{{ClientId}}" - ], - "TenantId": "{{TenantId}}" - }, - ``` +If the document is still not searchable after a reasonable wait, first confirm that the library permits search results. In the current SharePoint UI, go to **Site contents**, select the **three dots** for **Documents**, then select **Settings** > **Advanced settings**. Under **Search**, set **Allow items from this document library to appear in search results** to **Yes**. Select **Reindex Document Library** on the same page if needed. Reindexing adds the library to the next crawl; it does not complete immediately. Request it once only. See [Microsoft's reindex guidance](https://learn.microsoft.com/en-us/sharepoint/crawl-site-content#reindex-a-site). + +## Troubleshooting + +| Symptom | Action | +|---|---| +| Startup fails with `Retrieval:SharePointSiteUrl` | Set an absolute HTTPS SharePoint site URL in user secrets or environment variables. | +| Sign-in does not complete | Confirm the Azure Bot OAuth connection name is `graph`, then check its app registration and delegated permissions. | +| No results | Confirm the user can open the document, the configured URL is the site root, and SharePoint site search finds the document. If needed, request one library reindex. | +| Retrieval is unavailable | Confirm tenant entitlement, Microsoft Graph permissions, and service availability; retry later. | + +## Security notes + +- The application never writes delegated tokens, Microsoft Graph response bodies, or stack traces to chat. +- Microsoft Graph applies the signed-in user's SharePoint permissions to Retrieval API requests. +- Keep client secrets in user secrets, environment variables, or a managed secret store. ## Further reading -To learn more about building Agents, see [Microsoft 365 Agents SDK](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/). \ No newline at end of file + +- [Microsoft 365 Agents SDK](https://learn.microsoft.com/microsoft-365/agents-sdk/) +- [Microsoft 365 Copilot Retrieval API](https://learn.microsoft.com/microsoft-365/copilot/extensibility/api/ai-services/retrieval/copilotroot-retrieval) diff --git a/samples/dotnet/retrieval-agent/RetrievalAgent.cs b/samples/dotnet/retrieval-agent/RetrievalAgent.cs index 66519754..65331067 100644 --- a/samples/dotnet/retrieval-agent/RetrievalAgent.cs +++ b/samples/dotnet/retrieval-agent/RetrievalAgent.cs @@ -1,25 +1,26 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using Microsoft.Agents.Builder; using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.App.UserAuth; using Microsoft.Agents.Builder.State; -using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.UserAuth; using Microsoft.Agents.Core.Models; -using System.Threading.Tasks; +using RetrievalAgent.Services; using System.Threading; -using RetrievalAgent.Agents; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel; +using System.Threading.Tasks; namespace RetrievalAgent { - public class Retrieval: AgentApplication + public class Retrieval : AgentApplication { - private readonly Kernel _kernel; + private readonly IBuildGenieMessageRoute _messageRoute; - public Retrieval(AgentApplicationOptions options, Kernel kernel) : base (options) + public Retrieval(AgentApplicationOptions options, IBuildGenieMessageRoute messageRoute) : base(options) { - _kernel = kernel; + _messageRoute = messageRoute; + UserAuthorization.OnUserSignInFailure(OnUserSignInFailureAsync); } [MessageRoute] @@ -27,31 +28,12 @@ protected async Task MessageActivityAsync(ITurnContext turnContext, ITurnState t { await turnContext.SendActivityAsync(new Activity { Type = ActivityTypes.Typing }, cancellationToken); - var chatHistory = turnState.GetValue("conversation.chatHistory", () => new ChatHistory()); - - RetrievalCompletionAgent retrievalAgent = new RetrievalCompletionAgent(_kernel, this); - - // Invoke the RetrievalCompletionAgent to process the message - var forecastResponse = await retrievalAgent.InvokeAgentAsync(turnContext.Activity.Text, chatHistory); - if (forecastResponse == null) - { - await turnContext.SendActivityAsync(MessageFactory.Text("Sorry, I couldn't get the information you are looking for, at the moment."), cancellationToken); - return; - } - - // Create a response message based on the response content type from the RetrievalCompletionAgent - IActivity response = forecastResponse.ContentType switch - { - RetrievalCompletionAgentResponseContentType.AdaptiveCard => MessageFactory.Attachment(new Attachment() - { - ContentType = "application/vnd.microsoft.card.adaptive", - Content = forecastResponse.Content, - }), - _ => MessageFactory.Text(forecastResponse.Content), - }; - - // Send the response message back to the user. - await turnContext.SendActivityAsync(response, cancellationToken); + await _messageRoute.HandleAsync( + turnContext.Activity.Text ?? string.Empty, + _ => UserAuthorization.GetTurnTokenAsync(turnContext, "graph"), + (text, token) => turnContext.SendActivityAsync(MessageFactory.Text(text), token), + (activity, token) => turnContext.SendActivityAsync(activity, token), + cancellationToken); } [MembersAddedRoute] @@ -61,10 +43,12 @@ protected async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState tu { if (member.Id != turnContext.Activity.Recipient.Id) { - // welcome the user to the bot - await turnContext.SendActivityAsync(MessageFactory.Text("Hello! I am Build Genie! I can help you prepare for Build Conference 2025!"), cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.Text("Hello! I am Build Genie. Ask me about Build 2025 sessions in the configured SharePoint site. I only search content you can access."), cancellationToken); } } } + + private static Task OnUserSignInFailureAsync(ITurnContext turnContext, ITurnState turnState, string handlerName, SignInResponse response, IActivity initiatingActivity, CancellationToken cancellationToken) => + turnContext.SendActivityAsync(MessageFactory.Text(BuildGenieResponses.For(RetrievalStatus.NotSignedIn)), cancellationToken); } } diff --git a/samples/dotnet/retrieval-agent/RetrievalAgent.csproj b/samples/dotnet/retrieval-agent/RetrievalAgent.csproj index 3d035180..e82fb914 100644 --- a/samples/dotnet/retrieval-agent/RetrievalAgent.csproj +++ b/samples/dotnet/retrieval-agent/RetrievalAgent.csproj @@ -4,7 +4,6 @@ net8.0 latest disable - $(NoWarn);SKEXP0110;SKEXP0010 false false enable @@ -12,35 +11,18 @@ + + + + - - - - - - - - - - - - - - - - diff --git a/samples/dotnet/retrieval-agent/Services/BuildGenieMessageRoute.cs b/samples/dotnet/retrieval-agent/Services/BuildGenieMessageRoute.cs new file mode 100644 index 00000000..4bc90efa --- /dev/null +++ b/samples/dotnet/retrieval-agent/Services/BuildGenieMessageRoute.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Core.Models; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RetrievalAgent.Services; + +public interface IBuildGenieMessageRoute +{ + Task HandleAsync( + string question, + Func> getAccessTokenAsync, + Func sendTextAsync, + Func sendActivityAsync, + CancellationToken cancellationToken); +} + +public sealed class BuildGenieMessageRoute(IBuildRetrievalService retrievalService) : IBuildGenieMessageRoute +{ + public async Task HandleAsync( + string question, + Func> getAccessTokenAsync, + Func sendTextAsync, + Func sendActivityAsync, + CancellationToken cancellationToken) + { + RetrievalResult result = await retrievalService.RetrieveAsync(question, getAccessTokenAsync, cancellationToken); + + if (result.Status != RetrievalStatus.Success) + { + await sendTextAsync(BuildGenieResponses.For(result.Status), cancellationToken); + return; + } + + await sendTextAsync(BuildGenieResponses.GroundedAnswer(result.Items), cancellationToken); + await sendActivityAsync(BuildGenieSourceCard.Create(result.Items), cancellationToken); + } +} diff --git a/samples/dotnet/retrieval-agent/Services/BuildGenieResponses.cs b/samples/dotnet/retrieval-agent/Services/BuildGenieResponses.cs new file mode 100644 index 00000000..5d3c7fb2 --- /dev/null +++ b/samples/dotnet/retrieval-agent/Services/BuildGenieResponses.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Core.Models; +using System.Collections.Generic; +using System.Linq; + +namespace RetrievalAgent.Services; + +public static class BuildGenieResponses +{ + public static string For(RetrievalStatus status) => status switch + { + RetrievalStatus.NotSignedIn => "Please sign in to Microsoft 365, then ask your Build question again.", + RetrievalStatus.NoResults => "I couldn't find Build session information in the configured SharePoint site. Check the site URL, document permissions, and indexing, then try a more specific question.", + _ => "I couldn't retrieve Build session information right now. Please try again later.", + }; + + public static string GroundedAnswer(IReadOnlyList items) => + "Here is what I found in the configured SharePoint site:\n\n" + string.Join("\n\n", items.Select(item => $"{item.Extract}\nSource: {item.SourceUrl}")); +} + +public static class BuildGenieSourceCard +{ + public static IActivity Create(IReadOnlyList items) + { + List body = []; + foreach (RetrievalItem item in items) + { + body.Add(new + { + type = "Container", + separator = true, + items = new object[] + { + new { type = "TextBlock", text = item.Title, weight = "Bolder", wrap = true }, + new { type = "TextBlock", text = item.Extract, wrap = true }, + }, + selectAction = new { type = "Action.OpenUrl", title = "Open source", url = item.SourceUrl }, + }); + } + + return MessageFactory.Attachment(new Attachment + { + ContentType = "application/vnd.microsoft.card.adaptive", + Content = new { type = "AdaptiveCard", version = "1.5", body }, + }); + } +} diff --git a/samples/dotnet/retrieval-agent/Services/BuildRetrievalService.cs b/samples/dotnet/retrieval-agent/Services/BuildRetrievalService.cs new file mode 100644 index 00000000..4d1945fa --- /dev/null +++ b/samples/dotnet/retrieval-agent/Services/BuildRetrievalService.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace RetrievalAgent.Services; + +public sealed class BuildRetrievalService : IBuildRetrievalService +{ + private readonly HttpClient _httpClient; + private readonly RetrievalOptions _options; + private readonly ILogger _logger; + + public BuildRetrievalService(HttpClient httpClient, IOptions options, ILogger logger) + { + _httpClient = httpClient; + _options = options.Value; + _logger = logger; + } + + public async Task RetrieveAsync(string question, Func> getAccessTokenAsync, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(question)) + { + return RetrievalResult.Failure(RetrievalStatus.NoResults); + } + + string accessToken; + try + { + accessToken = await getAccessTokenAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "The delegated Microsoft Graph token was not available."); + return RetrievalResult.Failure(RetrievalStatus.NotSignedIn); + } + + if (string.IsNullOrWhiteSpace(accessToken)) + { + return RetrievalResult.Failure(RetrievalStatus.NotSignedIn); + } + + using HttpRequestMessage request = new(HttpMethod.Post, "copilot/retrieval") + { + Content = JsonContent.Create(new + { + queryString = question, + // SharePoint is the default data source. See the README for the OneDrive for Business fallback. + dataSource = "sharePoint", + filterExpression = RetrievalOptions.CreateFilterExpression(_options.SharePointSiteUrl), + resourceMetadata = new[] { "title", "author" }, + maximumNumberOfResults = _options.MaximumNumberOfResults, + }), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + try + { + using HttpResponseMessage response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Copilot Retrieval API returned status code {StatusCode}.", (int)response.StatusCode); + return RetrievalResult.Failure(RetrievalStatus.ServiceUnavailable); + } + + using JsonDocument payload = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken)); + IReadOnlyList items = MapItems(payload.RootElement); + return items.Count == 0 + ? RetrievalResult.Failure(RetrievalStatus.NoResults) + : RetrievalResult.Success(items); + } + catch (JsonException exception) + { + _logger.LogError(exception, "Copilot Retrieval API returned an invalid response."); + return RetrievalResult.Failure(RetrievalStatus.ServiceUnavailable); + } + catch (HttpRequestException exception) + { + _logger.LogError(exception, "Copilot Retrieval API request failed."); + return RetrievalResult.Failure(RetrievalStatus.ServiceUnavailable); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogError(exception, "Copilot Retrieval API processing failed."); + return RetrievalResult.Failure(RetrievalStatus.ServiceUnavailable); + } + } + + private static IReadOnlyList MapItems(JsonElement payload) + { + if (!payload.TryGetProperty("retrievalHits", out JsonElement hits) || hits.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + List items = []; + foreach (JsonElement hit in hits.EnumerateArray()) + { + string? sourceUrl = GetString(hit, "webUrl"); + string? extract = GetFirstExtract(hit); + if (string.IsNullOrWhiteSpace(sourceUrl) || string.IsNullOrWhiteSpace(extract)) + { + continue; + } + + string title = GetString(hit, "resourceMetadata", "title") ?? "Build session information"; + items.Add(new RetrievalItem(title, extract, sourceUrl)); + } + + return items; + } + + private static string? GetFirstExtract(JsonElement hit) + { + if (!hit.TryGetProperty("extracts", out JsonElement extracts) || extracts.ValueKind != JsonValueKind.Array) + { + return null; + } + + return extracts.EnumerateArray() + .Select(extract => GetString(extract, "text")) + .FirstOrDefault(text => !string.IsNullOrWhiteSpace(text)); + } + + private static string? GetString(JsonElement element, params string[] path) + { + foreach (string property in path) + { + if (!element.TryGetProperty(property, out element)) + { + return null; + } + } + + return element.ValueKind == JsonValueKind.String ? element.GetString() : null; + } +} diff --git a/samples/dotnet/retrieval-agent/Services/IBuildRetrievalService.cs b/samples/dotnet/retrieval-agent/Services/IBuildRetrievalService.cs new file mode 100644 index 00000000..de35a4b4 --- /dev/null +++ b/samples/dotnet/retrieval-agent/Services/IBuildRetrievalService.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace RetrievalAgent.Services; + +public interface IBuildRetrievalService +{ + Task RetrieveAsync(string question, Func> getAccessTokenAsync, CancellationToken cancellationToken); +} + +public enum RetrievalStatus +{ + Success, + NotSignedIn, + NoResults, + ServiceUnavailable, +} + +public sealed record RetrievalItem(string Title, string Extract, string SourceUrl); + +public sealed record RetrievalResult(RetrievalStatus Status, IReadOnlyList Items) +{ + public static RetrievalResult Failure(RetrievalStatus status) => new(status, Array.Empty()); + + public static RetrievalResult Success(IReadOnlyList items) => new(RetrievalStatus.Success, items); +} diff --git a/samples/dotnet/retrieval-agent/Services/RetrievalOptions.cs b/samples/dotnet/retrieval-agent/Services/RetrievalOptions.cs new file mode 100644 index 00000000..c2de87e0 --- /dev/null +++ b/samples/dotnet/retrieval-agent/Services/RetrievalOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace RetrievalAgent.Services; + +public sealed class RetrievalOptions +{ + public const string SectionName = "Retrieval"; + + public string SharePointSiteUrl { get; set; } = string.Empty; + + public int MaximumNumberOfResults { get; set; } = 3; + + public static bool IsValidSiteUrl(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) + && uri.Scheme == Uri.UriSchemeHttps + && uri.Host.EndsWith(".sharepoint.com", StringComparison.OrdinalIgnoreCase); + + public static string CreateFilterExpression(string siteUrl) + { + Uri siteUri = new(siteUrl, UriKind.Absolute); + return $"path:\"{siteUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/\""; + } +} diff --git a/samples/dotnet/retrieval-agent/appsettings.json b/samples/dotnet/retrieval-agent/appsettings.json index b0ce7869..1164f62f 100644 --- a/samples/dotnet/retrieval-agent/appsettings.json +++ b/samples/dotnet/retrieval-agent/appsettings.json @@ -23,6 +23,11 @@ } }, + "Retrieval": { + "SharePointSiteUrl": "", // SharePoint site root, for example https://contoso.sharepoint.com/sites/Build. Do not use a file URL or sharing link. + "MaximumNumberOfResults": 3 + }, + "Connections": { "BotServiceConnection": { "Assembly": "Microsoft.Agents.Authentication.Msal", @@ -50,23 +55,7 @@ "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", - "Microsoft.Copilot": "Warning", "Microsoft.Hosting.Lifetime": "Information" } - }, - - // This is the configuration for the AI services, use environeent variables or user secrets to store sensitive information. - // Do not store sensitive information in this file - "AIServices": { - "AzureOpenAI": { - "DeploymentName": "", // This is the Deployment (as opposed to model) Name of the Azure OpenAI model - "Endpoint": "", // This is the Endpoint of the Azure OpenAI model deployment - "ApiKey": "" // This is the API Key of the Azure OpenAI model deployment - }, - "OpenAI": { - "ModelId": "", // This is the Model ID of the OpenAI model - "ApiKey": "" // This is the API Key of the OpenAI model - }, - "UseAzureOpenAI": false // This is a flag to determine whether to use the Azure OpenAI model or the OpenAI model } -} \ No newline at end of file +} diff --git a/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/BuildRetrievalServiceTests.cs b/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/BuildRetrievalServiceTests.cs new file mode 100644 index 00000000..72ece8e5 --- /dev/null +++ b/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/BuildRetrievalServiceTests.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Agents.Core.Models; +using RetrievalAgent.Services; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace RetrievalAgent.Tests; + +public class BuildRetrievalServiceTests +{ + [Fact] + public void SiteUrlCreatesConfiguredPathFilter() + { + string filter = RetrievalOptions.CreateFilterExpression("https://contoso.sharepoint.com/sites/Build"); + + Assert.Equal("path:\"https://contoso.sharepoint.com/sites/Build/\"", filter); + Assert.True(RetrievalOptions.IsValidSiteUrl("https://contoso.sharepoint.com/sites/Build")); + Assert.False(RetrievalOptions.IsValidSiteUrl("http://contoso.sharepoint.com/sites/Build")); + } + + [Fact] + public async Task RetrievesItemsUsingDelegatedTokenAndConfiguredSite() + { + StubHandler handler = new(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { "retrievalHits": [{ "webUrl": "https://contoso.sharepoint.com/sites/Build/session.docx", "extracts": [{ "text": "Pricing Analytics is a Build session." }], "resourceMetadata": { "title": "Pricing Analytics" } }] } + """, Encoding.UTF8, "application/json"), + }); + BuildRetrievalService service = CreateService(handler); + + RetrievalResult result = await service.RetrieveAsync("Tell me about Pricing Analytics", _ => Task.FromResult("delegated-token"), CancellationToken.None); + + Assert.Equal(RetrievalStatus.Success, result.Status); + Assert.Single(result.Items); + Assert.Equal("Pricing Analytics", result.Items[0].Title); + Assert.Equal("Bearer", handler.Request!.Headers.Authorization!.Scheme); + Assert.Equal("delegated-token", handler.Request.Headers.Authorization.Parameter); + Assert.Equal("https://graph.microsoft.com/v1.0/copilot/retrieval", handler.Request.RequestUri!.ToString()); + Assert.Contains("\"dataSource\":\"sharePoint\"", handler.RequestBody); + Assert.Contains("https://contoso.sharepoint.com/sites/Build/", handler.RequestBody); + } + + [Fact] + public async Task ReturnsNoResultsWhenRetrievalHasNoHits() + { + StubHandler handler = new(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{ \"retrievalHits\": [] }", Encoding.UTF8, "application/json"), + }); + + RetrievalResult result = await CreateService(handler).RetrieveAsync("Build sessions", _ => Task.FromResult("token"), CancellationToken.None); + + Assert.Equal(RetrievalStatus.NoResults, result.Status); + Assert.Equal(BuildGenieResponses.For(RetrievalStatus.NoResults), BuildGenieResponses.For(result.Status)); + } + + [Fact] + public async Task ReturnsSafeSignInFailureWhenTokenIsUnavailable() + { + RetrievalResult result = await CreateService(new StubHandler(new HttpResponseMessage(HttpStatusCode.OK))).RetrieveAsync( + "Build sessions", + _ => throw new InvalidOperationException("token details must not reach chat"), + CancellationToken.None); + + Assert.Equal(RetrievalStatus.NotSignedIn, result.Status); + Assert.DoesNotContain("token details", BuildGenieResponses.For(result.Status), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ReturnsSafeServiceFailureWhenGraphFails() + { + RetrievalResult result = await CreateService(new StubHandler(new HttpResponseMessage(HttpStatusCode.BadGateway))).RetrieveAsync( + "Build sessions", + _ => Task.FromResult("token"), + CancellationToken.None); + + Assert.Equal(RetrievalStatus.ServiceUnavailable, result.Status); + Assert.DoesNotContain("BadGateway", BuildGenieResponses.For(result.Status), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GroundedAnswerIncludesTheRetrievedSourceLink() + { + string response = BuildGenieResponses.GroundedAnswer([new RetrievalItem("Pricing Analytics", "Session details", "https://contoso.sharepoint.com/session.docx")]); + + Assert.Contains("Session details", response); + Assert.Contains("https://contoso.sharepoint.com/session.docx", response); + } + + [Fact] + public async Task MessageRouteSendsGroundedTextAndSourceCardForSuccessfulRetrieval() + { + FakeRetrievalService retrieval = new(RetrievalResult.Success([new RetrievalItem("Pricing Analytics", "Session details", "https://contoso.sharepoint.com/session.docx")])); + BuildGenieMessageRoute route = new(retrieval); + List messages = []; + List activities = []; + + await route.HandleAsync("Pricing Analytics", _ => Task.FromResult("token"), (text, _) => { messages.Add(text); return Task.CompletedTask; }, (activity, _) => { activities.Add(activity); return Task.CompletedTask; }, CancellationToken.None); + + Assert.Equal("Pricing Analytics", retrieval.Question); + Assert.Single(messages); + Assert.Contains("Session details", messages[0]); + Assert.Contains("https://contoso.sharepoint.com/session.docx", messages[0]); + Assert.Single(activities); + Assert.NotNull(activities[0].Attachments); + Assert.Single(activities[0].Attachments); + Assert.Equal("application/vnd.microsoft.card.adaptive", activities[0].Attachments[0].ContentType); + } + + [Theory] + [InlineData(RetrievalStatus.NotSignedIn)] + [InlineData(RetrievalStatus.NoResults)] + [InlineData(RetrievalStatus.ServiceUnavailable)] + public async Task MessageRouteSendsSafeMessageForFailedRetrieval(RetrievalStatus status) + { + BuildGenieMessageRoute route = new(new FakeRetrievalService(RetrievalResult.Failure(status))); + List messages = []; + List activities = []; + + await route.HandleAsync("Build sessions", _ => Task.FromResult("token"), (text, _) => { messages.Add(text); return Task.CompletedTask; }, (activity, _) => { activities.Add(activity); return Task.CompletedTask; }, CancellationToken.None); + + Assert.Equal([BuildGenieResponses.For(status)], messages); + Assert.Empty(activities); + } + + private static BuildRetrievalService CreateService(StubHandler handler) => new( + new HttpClient(handler) { BaseAddress = new Uri("https://graph.microsoft.com/v1.0/") }, + Options.Create(new RetrievalOptions { SharePointSiteUrl = "https://contoso.sharepoint.com/sites/Build" }), + NullLogger.Instance); + + private sealed class StubHandler(HttpResponseMessage response) : HttpMessageHandler + { + public HttpRequestMessage? Request { get; private set; } + + public string RequestBody { get; private set; } = string.Empty; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + RequestBody = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); + return response; + } + } + + private sealed class FakeRetrievalService(RetrievalResult result) : IBuildRetrievalService + { + public string? Question { get; private set; } + + public Task RetrieveAsync(string question, Func> getAccessTokenAsync, CancellationToken cancellationToken) + { + Question = question; + return Task.FromResult(result); + } + } +} diff --git a/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/RetrievalAgent.Tests.csproj b/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/RetrievalAgent.Tests.csproj new file mode 100644 index 00000000..b9fce92b --- /dev/null +++ b/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/RetrievalAgent.Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + disable + enable + false + + + + + + + all + + + + + + + diff --git a/samples/nodejs/README.md b/samples/nodejs/README.md index c860cfca..e13e3723 100644 --- a/samples/nodejs/README.md +++ b/samples/nodejs/README.md @@ -16,3 +16,4 @@ |Copilot SDK|Dungeon Scribe RPG agent powered by the GitHub Copilot SDK|[copilot-sdk](copilot-sdk/README.md)| |OTel Agent|Agent with OTel instrumentation |[otel](otel/README.md)| |Named Pipe Agent|Pipe-only echo agent for the DirectLine App Service extension (DirectLineFlex)|[named-pipe-agent](named-pipe-agent/README.md)| +|Build Genie Retrieval Agent|Retrieves SharePoint content with the signed-in user's delegated permissions.|[Build Genie Retrieval Agent](retrieval-agent/README.md)| diff --git a/samples/nodejs/retrieval-agent/ContosoBuildSessions2025.docx b/samples/nodejs/retrieval-agent/ContosoBuildSessions2025.docx new file mode 100644 index 0000000000000000000000000000000000000000..159bbce4a7fe884e8631562c5bea164be238e562 GIT binary patch literal 25874 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 literal 0 HcmV?d00001 diff --git a/samples/nodejs/retrieval-agent/README.md b/samples/nodejs/retrieval-agent/README.md new file mode 100644 index 00000000..dbeb91f7 --- /dev/null +++ b/samples/nodejs/retrieval-agent/README.md @@ -0,0 +1,113 @@ +# Build Genie Retrieval Agent + +Build Genie is a Microsoft 365 Agents SDK sample that grounds answers in SharePoint content. It uses the signed-in user's delegated Microsoft Graph token, so every result respects that user's SharePoint permissions. + +The sample has one behavior: ask a question about Contoso's Build 2025 sessions, receive retrieved text, and open the returned source link. It does not access profiles, email, contacts, calendars, weather, or other Microsoft Graph data. + +## How it works + +1. The user signs in through the Azure Bot OAuth connection named `graph`. +2. The agent sends the question and configured SharePoint site scope to `POST /v1.0/copilot/retrieval`, using the `sharePoint` data source. +3. The agent returns text extracts and a deterministic Adaptive Card with source links. + +The retrieval client owns token use, site validation, Microsoft Graph request construction, response mapping, and safe failure handling. The message route only sends the response. + +> [!NOTE] +> The Retrieval API supports both `sharePoint` and `oneDriveBusiness` data sources. This sample uses `sharePoint` by default. If `sharePoint` returns no results for an indexed document, test `oneDriveBusiness` with the same user and site path before changing permissions or reindexing. + +## Prerequisites + +- Node.js 20 or later +- An Azure subscription and an [Azure Bot](https://aka.ms/AgentsSDK-CreateBot) +- A Microsoft 365 tenant with Copilot Retrieval API entitlement, a user who can sign in, and a SharePoint site that user can read +- [Dev Tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started) for Web Chat testing + +Upload [ContosoBuildSessions2025.docx](ContosoBuildSessions2025.docx) to the configured SharePoint site's **Documents** library. + +## Configure Azure Bot OAuth + +For local testing, create one single-tenant Microsoft Entra ID app registration to use for the Azure Bot and its OAuth connection. + +1. In **Microsoft Entra ID** > **App registrations**, create a single-tenant app registration. Record its application (client) ID and directory (tenant) ID. +2. In **Certificates & secrets**, create a client secret and copy its value. +3. In **Authentication**, add the Web redirect URI `https://token.botframework.com/.auth/web/redirect`. +4. In **API permissions**, add Microsoft Graph delegated permissions `Files.Read.All` and `Sites.Read.All`, then grant admin consent. +5. On the Azure Bot resource, create an OAuth connection with these values: + + | Field | Value | + |---|---| + | Name | `graph` | + | Service provider | Microsoft Entra ID v2 / Azure Active Directory v2 | + | Client ID | The app registration application (client) ID | + | Client secret | The app registration client-secret value | + | Tenant ID | The app registration directory (tenant) ID | + | Scopes | `Files.Read.All Sites.Read.All offline_access` | + | Token Exchange URL | Leave blank | + +6. Save the connection. Select **Test connection**, sign in as the test user, and confirm it succeeds. + +## Configure the sample + +Copy `env.TEMPLATE` to `.env`. Keep `.env` outside source control. + +```bash +connections__serviceConnection__settings__clientId= +connections__serviceConnection__settings__clientSecret= +connections__serviceConnection__settings__tenantId= +connectionsMap__0__connection=serviceConnection +connectionsMap__0__serviceUrl=* +AgentApplication__UserAuthorization__Handlers__graph__Settings__azureBotOAuthConnectionName=graph +RETRIEVAL_SHAREPOINT_SITE_URL=https://contoso.sharepoint.com/sites/Build +RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS=3 +``` + +`RETRIEVAL_SHAREPOINT_SITE_URL` must be the SharePoint site root. For a document at `https://contoso.sharepoint.com/sites/Build/Shared%20Documents/session.docx`, set `https://contoso.sharepoint.com/sites/Build`. Do not use a file URL or sharing link. `RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS` must be from `1` through `25`. + +## Run and test + +1. Install dependencies and start the sample: + + ```bash + npm install + npm start + ``` + +2. In a second terminal, expose it through a tunnel: + + ```bash + devtunnel host -p 3978 --allow-anonymous + ``` + +3. In Azure Bot, set the messaging endpoint to `{tunnel-url}/api/messages`. +4. Open **Test in Web Chat** and complete sign-in when prompted. +5. Ask `Tell me about the Pricing Analytics session.` + +A successful test returns text from the uploaded document and a source card. Select a source card to verify that its link opens the SharePoint document. + +## Wait for SharePoint indexing + +The document can appear in the Documents library before SharePoint search and Retrieval API can find it. Indexing can take minutes or hours; SharePoint does not provide a per-file indexing status. + +Before testing the agent, sign in as the same Web Chat user and search the site for the document title or a unique phrase such as `Pricing Analytics`. Test the agent only after SharePoint search returns the document. + +If the document is still not searchable after a reasonable wait, first confirm that the library permits search results. In the current SharePoint UI, go to **Site contents**, select the **three dots** for **Documents**, then select **Settings** > **Advanced settings**. Under **Search**, set **Allow items from this document library to appear in search results** to **Yes**. Select **Reindex Document Library** on the same page if needed. Reindexing adds the library to the next crawl; it does not complete immediately. Request it once only. See [Microsoft's reindex guidance](https://learn.microsoft.com/en-us/sharepoint/crawl-site-content#reindex-a-site). + +## Troubleshooting + +| Symptom | Action | +|---|---| +| Startup fails with `RETRIEVAL_SHAREPOINT_SITE_URL` | Set an absolute HTTPS SharePoint site-root URL in `.env`. | +| Sign-in does not complete | Confirm the Azure Bot OAuth connection name is `graph`, then check its app registration and delegated permissions. | +| No results | Confirm the user can open the document, the configured URL is the site root, and SharePoint site search finds the document. If needed, request one library reindex. | +| Retrieval is unavailable | Confirm tenant entitlement, Microsoft Graph permissions, and service availability; retry later. | + +## Security notes + +- The application never writes delegated tokens, Microsoft Graph response bodies, or stack traces to chat. +- Microsoft Graph applies the signed-in user's SharePoint permissions to Retrieval API requests. +- Keep client secrets in `.env`, environment variables, or a managed secret store. + +## Further reading + +- [Microsoft 365 Agents SDK](https://learn.microsoft.com/microsoft-365/agents-sdk/) +- [Microsoft 365 Copilot Retrieval API](https://learn.microsoft.com/microsoft-365/copilot/extensibility/api/ai-services/retrieval/copilotroot-retrieval) diff --git a/samples/nodejs/retrieval-agent/env.TEMPLATE b/samples/nodejs/retrieval-agent/env.TEMPLATE new file mode 100644 index 00000000..ce6ad072 --- /dev/null +++ b/samples/nodejs/retrieval-agent/env.TEMPLATE @@ -0,0 +1,15 @@ +# Rename to .env. Do not commit secrets. +connections__serviceConnection__settings__clientId= +connections__serviceConnection__settings__clientSecret= +connections__serviceConnection__settings__tenantId= +connectionsMap__0__connection=serviceConnection +connectionsMap__0__serviceUrl=* + +# Name of the Azure Bot OAuth connection. It must grant delegated Files.Read.All and Sites.Read.All. +AgentApplication__UserAuthorization__Handlers__graph__Settings__azureBotOAuthConnectionName= + +# SharePoint site root, for example https://contoso.sharepoint.com/sites/Build. Do not use a file URL or sharing link. +RETRIEVAL_SHAREPOINT_SITE_URL= +RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS=3 + +DEBUG=agents:*:error,agents:*:info,agents:*:warn \ No newline at end of file diff --git a/samples/nodejs/retrieval-agent/package-lock.json b/samples/nodejs/retrieval-agent/package-lock.json new file mode 100644 index 00000000..7c6a8291 --- /dev/null +++ b/samples/nodejs/retrieval-agent/package-lock.json @@ -0,0 +1,1336 @@ +{ + "name": "retrieval-agent", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "retrieval-agent", + "version": "1.0.0", + "dependencies": { + "@microsoft/agents-hosting-express": "^1.5.1" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.24", + "@types/node": "^22.15.18", + "typescript": "^5.8.3" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.5.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.2.tgz", + "integrity": "sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.5.tgz", + "integrity": "sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.5.2", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@microsoft/agents-activity": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@microsoft/agents-activity/-/agents-activity-1.7.2.tgz", + "integrity": "sha512-aMPZFuyIkdAdKxscyEZ6o6m60rB+JbWE4n02HzP3QOmILWBfddRMxYlK6gFLd86Wpf1hYGvfJBdpx8Av5ZRBIA==", + "license": "MIT", + "dependencies": { + "zod": "3.25.75" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/agents-hosting": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting/-/agents-hosting-1.7.2.tgz", + "integrity": "sha512-wRCiLusxFMkx27rN3+Iq+1lClkrcyflZVorGOLLN5y0/itacE6BaHjRsfpvxVnIKYZ2c4lQ3zCHyLScvZNumVg==", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "1.10.1", + "@azure/msal-node": "5.1.5", + "@microsoft/agents-activity": "1.7.2", + "@microsoft/agents-telemetry": "1.7.2", + "jsonwebtoken": "9.0.3", + "jwks-rsa": "4.0.1", + "zod": "3.25.75" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/agents-hosting-express": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting-express/-/agents-hosting-express-1.7.2.tgz", + "integrity": "sha512-tqPf2+sPl+rylcZ9vbKjWDyuLC9w5iIw0s03sGTGe/zo9W7ObtiEe9vc8fN4Q30DAVASQ5YHsyQIiNtQ2RE1aw==", + "license": "MIT", + "dependencies": { + "@microsoft/agents-hosting": "1.7.2", + "@microsoft/agents-telemetry": "1.7.2", + "express": "5.2.1", + "express-rate-limit": "8.5.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@microsoft/agents-telemetry": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@microsoft/agents-telemetry/-/agents-telemetry-1.7.2.tgz", + "integrity": "sha512-iJBAm1MmCUaSoPE62GKp4OnlOSe+F+j8o/fwKuqiJEcedbgceTh6G5m8iqe/qDGwKSsBWuIu3Kt0comRrc140A==", + "license": "MIT", + "dependencies": { + "@microsoft/agents-activity": "1.7.2", + "debug": "^4.4.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "1", + "@opentelemetry/api-logs": ">=0.214.0 <1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/api-logs": { + "optional": true + } + } + }, + "node_modules/@microsoft/m365agentsplayground": { + "version": "0.2.28", + "resolved": "https://registry.npmjs.org/@microsoft/m365agentsplayground/-/m365agentsplayground-0.2.28.tgz", + "integrity": "sha512-EevhYW8xtEATwNg5eiFqTgZT0P5gjfCsLBwBWESSrB6+0lr9Yi9R27HJwjNGUlT3JQlxoWMgnvEQunUuyI/+2Q==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "bin": { + "agentsplayground": "cli.js", + "teamsapptester": "cli.js" + } + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.0.1.tgz", + "integrity": "sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^6.1.3", + "limiter": "^1.1.5", + "lru-memoizer": "^3.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lru-memoizer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz", + "integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^11.0.1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.75", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.75.tgz", + "integrity": "sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/samples/nodejs/retrieval-agent/package.json b/samples/nodejs/retrieval-agent/package.json new file mode 100644 index 00000000..4344efe2 --- /dev/null +++ b/samples/nodejs/retrieval-agent/package.json @@ -0,0 +1,21 @@ +{ + "name": "retrieval-agent", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --build", + "prestart": "npm run build", + "start": "node --env-file .env dist/index.js", + "test-tool": "agentsplayground", + "test": "npm run build && node --test dist/tests/retrieval.test.js" + }, + "dependencies": { + "@microsoft/agents-hosting-express": "^1.5.1" + }, + "devDependencies": { + "@microsoft/m365agentsplayground": "^0.2.24", + "@types/node": "^22.15.18", + "typescript": "^5.8.3" + } +} diff --git a/samples/nodejs/retrieval-agent/src/card.ts b/samples/nodejs/retrieval-agent/src/card.ts new file mode 100644 index 00000000..bbf55a7b --- /dev/null +++ b/samples/nodejs/retrieval-agent/src/card.ts @@ -0,0 +1,17 @@ +import type { RetrievalItem } from './retrievalClient.js' + +export function createSourceCard (items: RetrievalItem[]): object { + return { + type: 'AdaptiveCard', + $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', + version: '1.5', + body: items.map(item => ({ + type: 'Container', + items: [ + { type: 'TextBlock', text: item.title, weight: 'Bolder', wrap: true }, + { type: 'TextBlock', text: item.extract, wrap: true, spacing: 'Small' } + ], + selectAction: { type: 'Action.OpenUrl', title: 'Open source', url: item.webUrl } + })) + } +} diff --git a/samples/nodejs/retrieval-agent/src/index.ts b/samples/nodejs/retrieval-agent/src/index.ts new file mode 100644 index 00000000..59b0c72f --- /dev/null +++ b/samples/nodejs/retrieval-agent/src/index.ts @@ -0,0 +1,40 @@ +import { startServer } from '@microsoft/agents-hosting-express' +import { AgentApplication, MemoryStorage, MessageFactory, TurnContext, TurnState } from '@microsoft/agents-hosting' +import { Activity } from '@microsoft/agents-activity' +import { handleBuildGenieMessage, buildGenieResponses } from './messageRoute.js' +import { getRetrievalOptions, retrieveSharePoint } from './retrievalClient.js' + +class RetrievalAgent extends AgentApplication { + constructor () { + super({ storage: new MemoryStorage() }) + getRetrievalOptions() + this.authorization.onSignInFailure(this.signInFailure) + this.onConversationUpdate('membersAdded', this.welcome) + this.onActivity('message', this.message, ['graph']) + } + + private welcome = async (context: TurnContext): Promise => { + if (context.activity.membersAdded?.some(member => member.id !== context.activity.recipient?.id)) { + await context.sendActivity(MessageFactory.text('Hello! I am Build Genie. Ask me about Build 2025 sessions in the configured SharePoint site. I only search content you can access.')) + } + } + + private message = async (context: TurnContext, state: TurnState): Promise => { + await context.sendActivity(Activity.fromObject({ type: 'typing' })) + const result = await retrieveSharePoint( + context.activity.text ?? '', + async () => (await this.authorization.getToken(context, 'graph'))?.token + ) + await handleBuildGenieMessage( + result, + async text => await context.sendActivity(MessageFactory.text(text)), + async card => await context.sendActivity(MessageFactory.attachment({ contentType: 'application/vnd.microsoft.card.adaptive', content: card })) + ) + } + + private signInFailure = async (context: TurnContext): Promise => { + await context.sendActivity(MessageFactory.text(buildGenieResponses.notSignedIn)) + } +} + +startServer(new RetrievalAgent()) diff --git a/samples/nodejs/retrieval-agent/src/messageRoute.ts b/samples/nodejs/retrieval-agent/src/messageRoute.ts new file mode 100644 index 00000000..a437af72 --- /dev/null +++ b/samples/nodejs/retrieval-agent/src/messageRoute.ts @@ -0,0 +1,26 @@ +import { createSourceCard } from './card.js' +import type { RetrievalResult, RetrievalStatus } from './retrievalClient.js' + +export const buildGenieResponses: Record, string> = { + notSignedIn: 'Please sign in to Microsoft 365, then ask your Build question again.', + noResults: "I couldn't find Build session information in the configured SharePoint site. Check the site URL, document permissions, and indexing, then try a more specific question.", + serviceUnavailable: "I couldn't retrieve Build session information right now. Please try again later." +} + +export function groundedAnswer (result: RetrievalResult): string { + return `Here is what I found in the configured SharePoint site:\n\n${result.items.map(item => `${item.title}\n${item.extract}\nSource: ${item.webUrl}`).join('\n\n')}` +} + +export async function handleBuildGenieMessage ( + result: RetrievalResult, + sendText: (text: string) => Promise, + sendSourceCard: (card: object) => Promise +): Promise { + if (result.status !== 'success') { + await sendText(buildGenieResponses[result.status]) + return + } + + await sendText(groundedAnswer(result)) + await sendSourceCard(createSourceCard(result.items)) +} diff --git a/samples/nodejs/retrieval-agent/src/retrievalClient.ts b/samples/nodejs/retrieval-agent/src/retrievalClient.ts new file mode 100644 index 00000000..a7f92f13 --- /dev/null +++ b/samples/nodejs/retrieval-agent/src/retrievalClient.ts @@ -0,0 +1,103 @@ +export type RetrievalStatus = 'success' | 'notSignedIn' | 'noResults' | 'serviceUnavailable' + +export type RetrievalItem = { + title: string + extract: string + webUrl: string +} + +export type RetrievalResult = { + status: RetrievalStatus + items: RetrievalItem[] +} + +export type RetrievalOptions = { + sharePointSiteUrl: string + maximumNumberOfResults: number +} + +type FetchFunction = (input: string, init?: RequestInit) => Promise +type GetAccessToken = () => Promise +type RetrievalResponse = { + retrievalHits?: Array<{ + webUrl?: string + extracts?: Array<{ text?: string }> + resourceMetadata?: { title?: string } + }> +} + +export function getRetrievalOptions (environment = process.env): RetrievalOptions { + const sharePointSiteUrl = environment.RETRIEVAL_SHAREPOINT_SITE_URL ?? '' + let url: URL + try { + url = new URL(sharePointSiteUrl) + } catch { + throw new Error('RETRIEVAL_SHAREPOINT_SITE_URL must be an absolute HTTPS SharePoint site URL.') + } + if (url.protocol !== 'https:' || !url.hostname.endsWith('.sharepoint.com')) throw new Error('RETRIEVAL_SHAREPOINT_SITE_URL must be an absolute HTTPS SharePoint site URL.') + + const maximumNumberOfResults = Number(environment.RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS ?? '3') + if (!Number.isInteger(maximumNumberOfResults) || maximumNumberOfResults < 1 || maximumNumberOfResults > 25) { + throw new Error('RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS must be from 1 through 25.') + } + + return { sharePointSiteUrl, maximumNumberOfResults } +} + +export function createFilterExpression (sharePointSiteUrl: string): string { + return `path:"${sharePointSiteUrl.replace(/\/+$/, '')}/"` +} + +export async function retrieveSharePoint ( + question: string, + getAccessToken: GetAccessToken, + options: RetrievalOptions = getRetrievalOptions(), + fetchFunction: FetchFunction = fetch +): Promise { + if (!question.trim()) return { status: 'noResults', items: [] } + + let accessToken: string | undefined + try { + accessToken = await getAccessToken() + } catch { + console.warn('The delegated Microsoft Graph token was not available.') + return { status: 'notSignedIn', items: [] } + } + + if (!accessToken) return { status: 'notSignedIn', items: [] } + + try { + const response = await fetchFunction('https://graph.microsoft.com/v1.0/copilot/retrieval', { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + queryString: question, + // SharePoint is the default data source. See the README for the OneDrive for Business fallback. + dataSource: 'sharePoint', + filterExpression: createFilterExpression(options.sharePointSiteUrl), + resourceMetadata: ['title', 'author'], + maximumNumberOfResults: options.maximumNumberOfResults + }) + }) + + if (!response.ok) { + console.warn(`Copilot Retrieval API returned status code ${response.status}.`) + return { status: 'serviceUnavailable', items: [] } + } + + const payload = await response.json() as RetrievalResponse + const items = mapRetrievalItems(payload) + return items.length === 0 ? { status: 'noResults', items: [] } : { status: 'success', items } + } catch { + console.error('Copilot Retrieval API request failed.') + return { status: 'serviceUnavailable', items: [] } + } +} + +export function mapRetrievalItems (payload: RetrievalResponse): RetrievalItem[] { + return (payload.retrievalHits ?? []).flatMap((hit) => { + const extract = hit.extracts?.find(item => item.text?.trim())?.text?.trim() + if (!hit.webUrl || !extract) return [] + return [{ title: hit.resourceMetadata?.title?.trim() || 'Build session information', extract, webUrl: hit.webUrl }] + }) +} diff --git a/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts b/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts new file mode 100644 index 00000000..9f78316b --- /dev/null +++ b/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { buildGenieResponses, handleBuildGenieMessage } from '../messageRoute.js' +import { createFilterExpression, getRetrievalOptions, retrieveSharePoint, type RetrievalResult } from '../retrievalClient.js' + +const options = { sharePointSiteUrl: 'https://contoso.sharepoint.com/sites/Build', maximumNumberOfResults: 3 } + +test('creates a configured site filter', () => { + assert.equal(createFilterExpression(options.sharePointSiteUrl), 'path:"https://contoso.sharepoint.com/sites/Build/"') + assert.throws(() => getRetrievalOptions({ RETRIEVAL_SHAREPOINT_SITE_URL: 'http://contoso.sharepoint.com/sites/Build' })) +}) + +test('retrieves mapped items with a delegated token', async () => { + let request: RequestInit | undefined + const result = await retrieveSharePoint('Pricing Analytics', async () => 'delegated-token', options, async (_input, init) => { + request = init + return new Response(JSON.stringify({ retrievalHits: [{ webUrl: 'https://contoso.sharepoint.com/session.docx', extracts: [{ text: 'Session details' }], resourceMetadata: { title: 'Pricing Analytics' } }] })) + }) + + assert.equal(result.status, 'success') + assert.equal(result.items[0].title, 'Pricing Analytics') + assert.equal((request?.headers as Record).Authorization, 'Bearer delegated-token') + assert.match(String(request?.body), /"dataSource":"sharePoint"/) +}) + +test('maps token and service failures to safe statuses', async () => { + assert.equal((await retrieveSharePoint('Build', async () => { throw new Error('secret') }, options)).status, 'notSignedIn') + assert.equal((await retrieveSharePoint('Build', async () => 'token', options, async () => new Response('', { status: 502 }))).status, 'serviceUnavailable') + assert.equal((await retrieveSharePoint('Build', async () => 'token', options, async () => new Response(JSON.stringify({ retrievalHits: [] })))).status, 'noResults') +}) + +test('message route sends grounded text and a source card', async () => { + const result: RetrievalResult = { status: 'success', items: [{ title: 'Pricing Analytics', extract: 'Session details', webUrl: 'https://contoso.sharepoint.com/session.docx' }] } + const messages: string[] = [] + const cards: object[] = [] + await handleBuildGenieMessage(result, async text => { messages.push(text) }, async card => { cards.push(card) }) + assert.match(messages[0], /https:\/\/contoso.sharepoint.com\/session.docx/) + assert.equal(cards.length, 1) + assert.match(JSON.stringify(cards[0]), /https:\/\/contoso.sharepoint.com\/session.docx/) +}) + +for (const status of ['notSignedIn', 'noResults', 'serviceUnavailable'] as const) { + test(`message route sends one safe message for ${status}`, async () => { + const messages: string[] = [] + const cards: object[] = [] + await handleBuildGenieMessage({ status, items: [] }, async text => { messages.push(text) }, async card => { cards.push(card) }) + assert.deepEqual(messages, [buildGenieResponses[status]]) + assert.equal(cards.length, 0) + }) +} diff --git a/samples/nodejs/retrieval-agent/tsconfig.json b/samples/nodejs/retrieval-agent/tsconfig.json new file mode 100644 index 00000000..82810353 --- /dev/null +++ b/samples/nodejs/retrieval-agent/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/samples/python/README.md b/samples/python/README.md index 7819b4a8..e546e3e0 100644 --- a/samples/python/README.md +++ b/samples/python/README.md @@ -13,6 +13,7 @@ |OpenTelemetry|Instrument an agent and consume telemetry via the Aspire dashboard|[opentelemetry](otel/README.md)| |Agent Framework|Weather agent built with Microsoft Agent Framework SDK|[agent-framework](agent-framework/README.md)| |Copilot SDK|Dungeon Scribe RPG agent powered by the GitHub Copilot SDK|[copilot-sdk](copilot-sdk/README.md)| +|Build Genie Retrieval Agent|Retrieves SharePoint content with the signed-in user's delegated permissions.|[Build Genie Retrieval Agent](retrieval-agent/README.md)| ## Important Notice - Import Changes diff --git a/samples/python/retrieval-agent/ContosoBuildSessions2025.docx b/samples/python/retrieval-agent/ContosoBuildSessions2025.docx new file mode 100644 index 0000000000000000000000000000000000000000..159bbce4a7fe884e8631562c5bea164be238e562 GIT binary patch literal 25874 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 literal 0 HcmV?d00001 diff --git a/samples/python/retrieval-agent/README.md b/samples/python/retrieval-agent/README.md new file mode 100644 index 00000000..5b534d17 --- /dev/null +++ b/samples/python/retrieval-agent/README.md @@ -0,0 +1,113 @@ +# Build Genie Retrieval Agent + +Build Genie is a Microsoft 365 Agents SDK sample that grounds answers in SharePoint content. It uses the signed-in user's delegated Microsoft Graph token, so every result respects that user's SharePoint permissions. + +The sample has one behavior: ask a question about Contoso's Build 2025 sessions, receive retrieved text, and open the returned source link. It does not access profiles, email, contacts, calendars, weather, or other Microsoft Graph data. + +## How it works + +1. The user signs in through the Azure Bot OAuth connection named `graph`. +2. The agent sends the question and configured SharePoint site scope to `POST /v1.0/copilot/retrieval`, using the `sharePoint` data source. +3. The agent returns text extracts and a deterministic Adaptive Card with source links. + +The retrieval client owns token use, site validation, Microsoft Graph request construction, response mapping, and safe failure handling. The message route only sends the response. + +> [!NOTE] +> The Retrieval API supports both `sharePoint` and `oneDriveBusiness` data sources. This sample uses `sharePoint` by default. If `sharePoint` returns no results for an indexed document, test `oneDriveBusiness` with the same user and site path before changing permissions or reindexing. + +## Prerequisites + +- Python 3.10 or later +- An Azure subscription and an [Azure Bot](https://aka.ms/AgentsSDK-CreateBot) +- A Microsoft 365 tenant with Copilot Retrieval API entitlement, a user who can sign in, and a SharePoint site that user can read +- [Dev Tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started) for Web Chat testing + +Upload [ContosoBuildSessions2025.docx](ContosoBuildSessions2025.docx) to the configured SharePoint site's **Documents** library. + +## Configure Azure Bot OAuth + +For local testing, create one single-tenant Microsoft Entra ID app registration to use for the Azure Bot and its OAuth connection. + +1. In **Microsoft Entra ID** > **App registrations**, create a single-tenant app registration. Record its application (client) ID and directory (tenant) ID. +2. In **Certificates & secrets**, create a client secret and copy its value. +3. In **Authentication**, add the Web redirect URI `https://token.botframework.com/.auth/web/redirect`. +4. In **API permissions**, add Microsoft Graph delegated permissions `Files.Read.All` and `Sites.Read.All`, then grant admin consent. +5. On the Azure Bot resource, create an OAuth connection with these values: + + | Field | Value | + |---|---| + | Name | `graph` | + | Service provider | Microsoft Entra ID v2 / Azure Active Directory v2 | + | Client ID | The app registration application (client) ID | + | Client secret | The app registration client-secret value | + | Tenant ID | The app registration directory (tenant) ID | + | Scopes | `Files.Read.All Sites.Read.All offline_access` | + | Token Exchange URL | Leave blank | + +6. Save the connection. Select **Test connection**, sign in as the test user, and confirm it succeeds. + +## Configure the sample + +Copy `env.TEMPLATE` to `.env`. Keep `.env` outside source control. + +```bash +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=graph +RETRIEVAL_SHAREPOINT_SITE_URL=https://contoso.sharepoint.com/sites/Build +RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS=3 +``` + +`RETRIEVAL_SHAREPOINT_SITE_URL` must be the SharePoint site root. For a document at `https://contoso.sharepoint.com/sites/Build/Shared%20Documents/session.docx`, set `https://contoso.sharepoint.com/sites/Build`. Do not use a file URL or sharing link. `RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS` must be from `1` through `25`. + +## Run and test + +1. Create an environment, install dependencies, and start the sample: + + ```bash + python -m venv .venv + .venv\Scripts\activate + pip install -r requirements.txt + python -m src.main + ``` + +2. In a second terminal, expose it through a tunnel: + + ```bash + devtunnel host -p 3978 --allow-anonymous + ``` + +3. In Azure Bot, set the messaging endpoint to `{tunnel-url}/api/messages`. +4. Open **Test in Web Chat** and complete sign-in when prompted. +5. Ask `Tell me about the Pricing Analytics session.` + +A successful test returns text from the uploaded document and a source card. Select a source card to verify that its link opens the SharePoint document. + +## Wait for SharePoint indexing + +The document can appear in the Documents library before SharePoint search and Retrieval API can find it. Indexing can take minutes or hours; SharePoint does not provide a per-file indexing status. + +Before testing the agent, sign in as the same Web Chat user and search the site for the document title or a unique phrase such as `Pricing Analytics`. Test the agent only after SharePoint search returns the document. + +If the document is still not searchable after a reasonable wait, first confirm that the library permits search results. In the current SharePoint UI, go to **Site contents**, select the **three dots** for **Documents**, then select **Settings** > **Advanced settings**. Under **Search**, set **Allow items from this document library to appear in search results** to **Yes**. Select **Reindex Document Library** on the same page if needed. Reindexing adds the library to the next crawl; it does not complete immediately. Request it once only. See [Microsoft's reindex guidance](https://learn.microsoft.com/en-us/sharepoint/crawl-site-content#reindex-a-site). + +## Troubleshooting + +| Symptom | Action | +|---|---| +| Startup fails with `RETRIEVAL_SHAREPOINT_SITE_URL` | Set an absolute HTTPS SharePoint site-root URL in `.env`. | +| Sign-in does not complete | Confirm the Azure Bot OAuth connection name is `graph`, then check its app registration and delegated permissions. | +| No results | Confirm the user can open the document, the configured URL is the site root, and SharePoint site search finds the document. If needed, request one library reindex. | +| Retrieval is unavailable | Confirm tenant entitlement, Microsoft Graph permissions, and service availability; retry later. | + +## Security notes + +- The application never writes delegated tokens, Microsoft Graph response bodies, or stack traces to chat. +- Microsoft Graph applies the signed-in user's SharePoint permissions to Retrieval API requests. +- Keep client secrets in `.env`, environment variables, or a managed secret store. + +## Further reading + +- [Microsoft 365 Agents SDK](https://learn.microsoft.com/microsoft-365/agents-sdk/) +- [Microsoft 365 Copilot Retrieval API](https://learn.microsoft.com/microsoft-365/copilot/extensibility/api/ai-services/retrieval/copilotroot-retrieval) diff --git a/samples/python/retrieval-agent/env.TEMPLATE b/samples/python/retrieval-agent/env.TEMPLATE new file mode 100644 index 00000000..96d3a9ad --- /dev/null +++ b/samples/python/retrieval-agent/env.TEMPLATE @@ -0,0 +1,13 @@ +# Rename to .env. Do not commit secrets. +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +# Name of the Azure Bot OAuth connection. It must grant delegated Files.Read.All and Sites.Read.All. +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME= + +# SharePoint site root, for example https://contoso.sharepoint.com/sites/Build. Do not use a file URL or sharing link. +RETRIEVAL_SHAREPOINT_SITE_URL= +RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS=3 + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO \ No newline at end of file diff --git a/samples/python/retrieval-agent/requirements.txt b/samples/python/retrieval-agent/requirements.txt new file mode 100644 index 00000000..044e2a73 --- /dev/null +++ b/samples/python/retrieval-agent/requirements.txt @@ -0,0 +1,6 @@ +aiohttp +python-dotenv +microsoft-agents-activity +microsoft-agents-hosting-core +microsoft-agents-hosting-aiohttp +microsoft-agents-authentication-msal diff --git a/samples/python/retrieval-agent/src/__init__.py b/samples/python/retrieval-agent/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/python/retrieval-agent/src/agent.py b/samples/python/retrieval-agent/src/agent.py new file mode 100644 index 00000000..e7ee2c83 --- /dev/null +++ b/samples/python/retrieval-agent/src/agent.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import logging +import re +from os import environ + +from dotenv import load_dotenv +from microsoft_agents.activity import Activity, ActivityTypes, Attachment, load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + MemoryStorage, + MessageFactory, + TurnContext, + TurnState, +) + +from .message_route import handle_build_genie_message +from .retrieval_client import get_retrieval_options, retrieve_sharepoint + +logger = logging.getLogger(__name__) +load_dotenv() +configuration = load_configuration_from_env(environ) +storage = MemoryStorage() +connection_manager = MsalConnectionManager(**configuration) +adapter = CloudAdapter(connection_manager=connection_manager) +authorization = Authorization(storage, connection_manager, **configuration) +get_retrieval_options() +AGENT_APP = AgentApplication[TurnState]( + storage=storage, adapter=adapter, authorization=authorization, **configuration +) + + +@AGENT_APP.conversation_update("membersAdded") +async def welcome(context: TurnContext, _state: TurnState) -> None: + for member in context.activity.members_added: + if member.id != context.activity.recipient.id: + await context.send_activity( + MessageFactory.text( + "Hello! I am Build Genie. Ask me about Build 2025 sessions in the configured SharePoint site. I only search content you can access." + ) + ) + + +@AGENT_APP.message(re.compile(r".*"), auth_handlers=["GRAPH"]) +async def message(context: TurnContext, _state: TurnState) -> None: + await context.send_activity(Activity(type=ActivityTypes.typing)) + + async def get_access_token() -> str | None: + token_response = await AGENT_APP.auth.get_token(context, "GRAPH") + return token_response.token if token_response else None + + result = await retrieve_sharepoint(context.activity.text or "", get_access_token) + await handle_build_genie_message( + result, + lambda text: context.send_activity(MessageFactory.text(text)), + lambda card: context.send_activity( + MessageFactory.attachment( + Attachment( + content_type="application/vnd.microsoft.card.adaptive", + content=card, + ) + ) + ), + ) diff --git a/samples/python/retrieval-agent/src/card.py b/samples/python/retrieval-agent/src/card.py new file mode 100644 index 00000000..0c1b87aa --- /dev/null +++ b/samples/python/retrieval-agent/src/card.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from .retrieval_client import RetrievalItem + + +def create_source_card(items: list[RetrievalItem]) -> dict: + return { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "Container", + "items": [ + {"type": "TextBlock", "text": item.title, "weight": "Bolder", "wrap": True}, + {"type": "TextBlock", "text": item.extract, "wrap": True, "spacing": "Small"}, + ], + "selectAction": {"type": "Action.OpenUrl", "title": "Open source", "url": item.web_url}, + } + for item in items + ], + } diff --git a/samples/python/retrieval-agent/src/main.py b/samples/python/retrieval-agent/src/main.py new file mode 100644 index 00000000..b7e4a2ad --- /dev/null +++ b/samples/python/retrieval-agent/src/main.py @@ -0,0 +1,10 @@ +import logging + +from .agent import AGENT_APP, connection_manager +from .start_server import start_server + +logging.basicConfig(level=logging.INFO) +start_server( + agent_application=AGENT_APP, + auth_configuration=connection_manager.get_default_connection_configuration(), +) diff --git a/samples/python/retrieval-agent/src/message_route.py b/samples/python/retrieval-agent/src/message_route.py new file mode 100644 index 00000000..dbc3cb4f --- /dev/null +++ b/samples/python/retrieval-agent/src/message_route.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Awaitable, Callable + +from .card import create_source_card +from .retrieval_client import RetrievalResult, RetrievalStatus + +BUILD_GENIE_RESPONSES = { + RetrievalStatus.NOT_SIGNED_IN: "Please sign in to Microsoft 365, then ask your Build question again.", + RetrievalStatus.NO_RESULTS: "I couldn't find Build session information in the configured SharePoint site. Check the site URL, document permissions, and indexing, then try a more specific question.", + RetrievalStatus.SERVICE_UNAVAILABLE: "I couldn't retrieve Build session information right now. Please try again later.", +} + + +def grounded_answer(result: RetrievalResult) -> str: + sources = "\n\n".join( + f"{item.title}\n{item.extract}\nSource: {item.web_url}" for item in result.items + ) + return f"Here is what I found in the configured SharePoint site:\n\n{sources}" + + +async def handle_build_genie_message( + result: RetrievalResult, + send_text: Callable[[str], Awaitable[object]], + send_source_card: Callable[[dict], Awaitable[object]], +) -> None: + if result.status is not RetrievalStatus.SUCCESS: + await send_text(BUILD_GENIE_RESPONSES[result.status]) + return + await send_text(grounded_answer(result)) + await send_source_card(create_source_card(result.items)) diff --git a/samples/python/retrieval-agent/src/retrieval_client.py b/samples/python/retrieval-agent/src/retrieval_client.py new file mode 100644 index 00000000..7facf0c3 --- /dev/null +++ b/samples/python/retrieval-agent/src/retrieval_client.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum +from os import environ +from typing import Awaitable, Callable +from urllib.parse import urlparse + +import aiohttp + +logger = logging.getLogger(__name__) + + +class RetrievalStatus(str, Enum): + SUCCESS = "success" + NOT_SIGNED_IN = "not_signed_in" + NO_RESULTS = "no_results" + SERVICE_UNAVAILABLE = "service_unavailable" + + +@dataclass(frozen=True) +class RetrievalItem: + title: str + extract: str + web_url: str + + +@dataclass(frozen=True) +class RetrievalResult: + status: RetrievalStatus + items: list[RetrievalItem] + + +@dataclass(frozen=True) +class RetrievalOptions: + sharepoint_site_url: str + maximum_number_of_results: int + + +GetAccessToken = Callable[[], Awaitable[str | None]] +PostRetrievalRequest = Callable[[str, dict], Awaitable[tuple[int, dict]]] + + +def get_retrieval_options(environment: dict[str, str] = environ) -> RetrievalOptions: + site_url = environment.get("RETRIEVAL_SHAREPOINT_SITE_URL", "") + parsed_url = urlparse(site_url) + if parsed_url.scheme != "https" or not parsed_url.netloc.endswith(".sharepoint.com"): + raise ValueError( + "RETRIEVAL_SHAREPOINT_SITE_URL must be an absolute HTTPS SharePoint site URL." + ) + + try: + maximum_number_of_results = int( + environment.get("RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS", "3") + ) + except ValueError as exception: + raise ValueError( + "RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS must be from 1 through 25." + ) from exception + if not 1 <= maximum_number_of_results <= 25: + raise ValueError("RETRIEVAL_MAXIMUM_NUMBER_OF_RESULTS must be from 1 through 25.") + + return RetrievalOptions(site_url, maximum_number_of_results) + + +def create_filter_expression(sharepoint_site_url: str) -> str: + return f'path:"{sharepoint_site_url.rstrip("/")}/"' + + +async def retrieve_sharepoint( + question: str, + get_access_token: GetAccessToken, + options: RetrievalOptions | None = None, + post_request: PostRetrievalRequest | None = None, +) -> RetrievalResult: + if not question.strip(): + return RetrievalResult(RetrievalStatus.NO_RESULTS, []) + + try: + access_token = await get_access_token() + except Exception: + logger.warning("The delegated Microsoft Graph token was not available.") + return RetrievalResult(RetrievalStatus.NOT_SIGNED_IN, []) + if not access_token: + return RetrievalResult(RetrievalStatus.NOT_SIGNED_IN, []) + + options = options or get_retrieval_options() + body = { + "queryString": question, + # SharePoint is the default data source. See the README for the OneDrive for Business fallback. + "dataSource": "sharePoint", + "filterExpression": create_filter_expression(options.sharepoint_site_url), + "resourceMetadata": ["title", "author"], + "maximumNumberOfResults": options.maximum_number_of_results, + } + try: + status, payload = await (post_request or _post_retrieval_request)(access_token, body) + if not 200 <= status < 300: + logger.warning("Copilot Retrieval API returned status code %s.", status) + return RetrievalResult(RetrievalStatus.SERVICE_UNAVAILABLE, []) + items = map_retrieval_items(payload) + return ( + RetrievalResult(RetrievalStatus.SUCCESS, items) + if items + else RetrievalResult(RetrievalStatus.NO_RESULTS, []) + ) + except Exception: + logger.error("Copilot Retrieval API request failed.") + return RetrievalResult(RetrievalStatus.SERVICE_UNAVAILABLE, []) + + +async def _post_retrieval_request(access_token: str, body: dict) -> tuple[int, dict]: + async with aiohttp.ClientSession() as session: + async with session.post( + "https://graph.microsoft.com/v1.0/copilot/retrieval", + headers={"Authorization": f"Bearer {access_token}"}, + json=body, + ) as response: + if not 200 <= response.status < 300: + return response.status, {} + return response.status, await response.json() + + +def map_retrieval_items(payload: dict) -> list[RetrievalItem]: + items: list[RetrievalItem] = [] + for hit in payload.get("retrievalHits", []): + web_url = hit.get("webUrl") + extract = next( + ( + item.get("text", "").strip() + for item in hit.get("extracts", []) + if item.get("text", "").strip() + ), + None, + ) + if not web_url or not extract: + continue + title = hit.get("resourceMetadata", {}).get("title", "").strip() + items.append(RetrievalItem(title or "Build session information", extract, web_url)) + return items diff --git a/samples/python/retrieval-agent/src/start_server.py b/samples/python/retrieval-agent/src/start_server.py new file mode 100644 index 00000000..c83a419f --- /dev/null +++ b/samples/python/retrieval-agent/src/start_server.py @@ -0,0 +1,25 @@ +from os import environ + +from aiohttp.web import Application, Request, Response, run_app +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration + + +def start_server( + agent_application: AgentApplication, auth_configuration: AgentAuthConfiguration +) -> None: + async def entry_point(request: Request) -> Response: + return await start_agent_process( + request, request.app["agent_app"], request.app["adapter"] + ) + + app = Application(middlewares=[jwt_authorization_middleware]) + app.router.add_post("/api/messages", entry_point) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + run_app(app, host="localhost", port=int(environ.get("PORT", "3978"))) diff --git a/samples/python/retrieval-agent/tests/test_retrieval.py b/samples/python/retrieval-agent/tests/test_retrieval.py new file mode 100644 index 00000000..5da91d40 --- /dev/null +++ b/samples/python/retrieval-agent/tests/test_retrieval.py @@ -0,0 +1,79 @@ +import unittest + +from src.message_route import BUILD_GENIE_RESPONSES, handle_build_genie_message +from src.retrieval_client import ( + RetrievalItem, + RetrievalOptions, + RetrievalResult, + RetrievalStatus, + create_filter_expression, + get_retrieval_options, + retrieve_sharepoint, +) + + +OPTIONS = RetrievalOptions("https://contoso.sharepoint.com/sites/Build", 3) + + +class RetrievalTests(unittest.IsolatedAsyncioTestCase): + def test_creates_configured_site_filter(self): + self.assertEqual( + create_filter_expression(OPTIONS.sharepoint_site_url), + 'path:"https://contoso.sharepoint.com/sites/Build/"', + ) + with self.assertRaises(ValueError): + get_retrieval_options({"RETRIEVAL_SHAREPOINT_SITE_URL": "http://contoso.sharepoint.com/sites/Build"}) + + async def test_retrieves_mapped_items_with_a_delegated_token(self): + captured: dict = {} + + async def post_request(token: str, body: dict): + captured["token"] = token + captured["body"] = body + return 200, {"retrievalHits": [{"webUrl": "https://contoso.sharepoint.com/session.docx", "extracts": [{"text": "Session details"}], "resourceMetadata": {"title": "Pricing Analytics"}}]} + + result = await retrieve_sharepoint("Pricing Analytics", lambda: _token("delegated-token"), OPTIONS, post_request) + self.assertEqual(result.status, RetrievalStatus.SUCCESS) + self.assertEqual(result.items[0].title, "Pricing Analytics") + self.assertEqual(captured["token"], "delegated-token") + self.assertEqual(captured["body"]["dataSource"], "sharePoint") + + async def test_maps_token_and_service_failures_to_safe_statuses(self): + async def no_token(): + raise ValueError("secret") + + async def failed_request(_token: str, _body: dict): + return 502, {} + + self.assertEqual((await retrieve_sharepoint("Build", no_token, OPTIONS)).status, RetrievalStatus.NOT_SIGNED_IN) + self.assertEqual((await retrieve_sharepoint("Build", lambda: _token("token"), OPTIONS, failed_request)).status, RetrievalStatus.SERVICE_UNAVAILABLE) + self.assertEqual((await retrieve_sharepoint("Build", lambda: _token("token"), OPTIONS, lambda _token, _body: _response(200, {"retrievalHits": []}))).status, RetrievalStatus.NO_RESULTS) + + async def test_message_route_sends_grounded_text_and_source_card(self): + messages: list[str] = [] + cards: list[dict] = [] + result = RetrievalResult(RetrievalStatus.SUCCESS, [RetrievalItem("Pricing Analytics", "Session details", "https://contoso.sharepoint.com/session.docx")]) + await handle_build_genie_message(result, lambda text: _append(messages, text), lambda card: _append(cards, card)) + self.assertIn("https://contoso.sharepoint.com/session.docx", messages[0]) + self.assertEqual(len(cards), 1) + self.assertIn("https://contoso.sharepoint.com/session.docx", str(cards[0])) + + async def test_message_route_sends_safe_messages_for_failures(self): + for status in (RetrievalStatus.NOT_SIGNED_IN, RetrievalStatus.NO_RESULTS, RetrievalStatus.SERVICE_UNAVAILABLE): + messages: list[str] = [] + cards: list[dict] = [] + await handle_build_genie_message(RetrievalResult(status, []), lambda text: _append(messages, text), lambda card: _append(cards, card)) + self.assertEqual(messages, [BUILD_GENIE_RESPONSES[status]]) + self.assertEqual(cards, []) + + +async def _token(value: str) -> str: + return value + + +async def _append(items: list, value): + items.append(value) + + +async def _response(status: int, payload: dict): + return status, payload From 3a5c759823b2cc3aa48e54759de968fd0465bc91 Mon Sep 17 00:00:00 2001 From: Joel Mut Date: Mon, 31 Aug 2026 12:44:40 +0200 Subject: [PATCH 2/3] Apply feedback --- .../RetrievalAgent.Tests.csproj | 4 +- samples/nodejs/retrieval-agent/README.md | 2 +- .../ContosoBuildSessions2025.docx | Bin .../nodejs/retrieval-agent/package-lock.json | 454 ++++++++++++++++-- samples/nodejs/retrieval-agent/package.json | 8 +- samples/nodejs/retrieval-agent/src/index.ts | 7 +- .../src/{ => services}/card.ts | 3 + .../src/{ => services}/messageRoute.ts | 3 + .../src/{ => services}/retrievalClient.ts | 3 + .../src/tests/retrieval.test.ts | 7 +- samples/python/retrieval-agent/README.md | 2 +- .../ContosoBuildSessions2025.docx | Bin .../python/retrieval-agent/src/__init__.py | 2 + samples/python/retrieval-agent/src/agent.py | 7 +- samples/python/retrieval-agent/src/main.py | 3 + .../retrieval-agent/src/services/__init__.py | 2 + .../src/{ => services}/card.py | 3 + .../src/{ => services}/message_route.py | 3 + .../src/{ => services}/retrieval_client.py | 8 + .../retrieval-agent/src/start_server.py | 4 +- .../retrieval-agent/tests/test_retrieval.py | 23 +- 21 files changed, 491 insertions(+), 57 deletions(-) rename samples/nodejs/retrieval-agent/{ => Sharepoint}/ContosoBuildSessions2025.docx (100%) rename samples/nodejs/retrieval-agent/src/{ => services}/card.ts (85%) rename samples/nodejs/retrieval-agent/src/{ => services}/messageRoute.ts (92%) rename samples/nodejs/retrieval-agent/src/{ => services}/retrievalClient.ts (97%) rename samples/python/retrieval-agent/{ => Sharepoint}/ContosoBuildSessions2025.docx (100%) create mode 100644 samples/python/retrieval-agent/src/services/__init__.py rename samples/python/retrieval-agent/src/{ => services}/card.py (88%) rename samples/python/retrieval-agent/src/{ => services}/message_route.py (93%) rename samples/python/retrieval-agent/src/{ => services}/retrieval_client.py (95%) diff --git a/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/RetrievalAgent.Tests.csproj b/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/RetrievalAgent.Tests.csproj index b9fce92b..a464bd68 100644 --- a/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/RetrievalAgent.Tests.csproj +++ b/samples/dotnet/retrieval-agent/tests/RetrievalAgent.Tests/RetrievalAgent.Tests.csproj @@ -8,9 +8,9 @@ - + - + all diff --git a/samples/nodejs/retrieval-agent/README.md b/samples/nodejs/retrieval-agent/README.md index dbeb91f7..665678cb 100644 --- a/samples/nodejs/retrieval-agent/README.md +++ b/samples/nodejs/retrieval-agent/README.md @@ -22,7 +22,7 @@ The retrieval client owns token use, site validation, Microsoft Graph request co - A Microsoft 365 tenant with Copilot Retrieval API entitlement, a user who can sign in, and a SharePoint site that user can read - [Dev Tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started) for Web Chat testing -Upload [ContosoBuildSessions2025.docx](ContosoBuildSessions2025.docx) to the configured SharePoint site's **Documents** library. +Upload [ContosoBuildSessions2025.docx](Sharepoint/ContosoBuildSessions2025.docx) to the configured SharePoint site's **Documents** library. ## Configure Azure Bot OAuth diff --git a/samples/nodejs/retrieval-agent/ContosoBuildSessions2025.docx b/samples/nodejs/retrieval-agent/Sharepoint/ContosoBuildSessions2025.docx similarity index 100% rename from samples/nodejs/retrieval-agent/ContosoBuildSessions2025.docx rename to samples/nodejs/retrieval-agent/Sharepoint/ContosoBuildSessions2025.docx diff --git a/samples/nodejs/retrieval-agent/package-lock.json b/samples/nodejs/retrieval-agent/package-lock.json index 7c6a8291..53fdeb87 100644 --- a/samples/nodejs/retrieval-agent/package-lock.json +++ b/samples/nodejs/retrieval-agent/package-lock.json @@ -8,12 +8,12 @@ "name": "retrieval-agent", "version": "1.0.0", "dependencies": { - "@microsoft/agents-hosting-express": "^1.5.1" + "@microsoft/agents-hosting-express": "^1.8.1" }, "devDependencies": { - "@microsoft/m365agentsplayground": "^0.2.24", - "@types/node": "^22.15.18", - "typescript": "^5.8.3" + "@microsoft/m365agentsplayground": "^0.2.28", + "@types/node": "^26.4.0", + "typescript": "^7.0.2" } }, "node_modules/@azure/abort-controller": { @@ -79,9 +79,9 @@ } }, "node_modules/@microsoft/agents-activity": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@microsoft/agents-activity/-/agents-activity-1.7.2.tgz", - "integrity": "sha512-aMPZFuyIkdAdKxscyEZ6o6m60rB+JbWE4n02HzP3QOmILWBfddRMxYlK6gFLd86Wpf1hYGvfJBdpx8Av5ZRBIA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-activity/-/agents-activity-1.8.1.tgz", + "integrity": "sha512-aChclMg9zMMnosb05YW0Yn2BH8ZRpPuUkm9Q6t1XTKHIjO6HtKXQNawfeNtutF4LhWRJWSFcWBe1H/ZHSrlxcg==", "license": "MIT", "dependencies": { "zod": "3.25.75" @@ -91,15 +91,15 @@ } }, "node_modules/@microsoft/agents-hosting": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting/-/agents-hosting-1.7.2.tgz", - "integrity": "sha512-wRCiLusxFMkx27rN3+Iq+1lClkrcyflZVorGOLLN5y0/itacE6BaHjRsfpvxVnIKYZ2c4lQ3zCHyLScvZNumVg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting/-/agents-hosting-1.8.1.tgz", + "integrity": "sha512-202JTbSmU5haonQicEVZeYx4hJIY+LJwSd6QeYnVPLGC++cO+MMksqg/uCryWJN9OVNyCg4I7AOBFD4N6z74Hw==", "license": "MIT", "dependencies": { "@azure/core-auth": "1.10.1", "@azure/msal-node": "5.1.5", - "@microsoft/agents-activity": "1.7.2", - "@microsoft/agents-telemetry": "1.7.2", + "@microsoft/agents-activity": "1.8.1", + "@microsoft/agents-telemetry": "1.8.1", "jsonwebtoken": "9.0.3", "jwks-rsa": "4.0.1", "zod": "3.25.75" @@ -109,13 +109,13 @@ } }, "node_modules/@microsoft/agents-hosting-express": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting-express/-/agents-hosting-express-1.7.2.tgz", - "integrity": "sha512-tqPf2+sPl+rylcZ9vbKjWDyuLC9w5iIw0s03sGTGe/zo9W7ObtiEe9vc8fN4Q30DAVASQ5YHsyQIiNtQ2RE1aw==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-hosting-express/-/agents-hosting-express-1.8.1.tgz", + "integrity": "sha512-q06waqyuYjEgjHd6Id2svEo75WPDTPdcDLi+8QmbMdsP2+FJuaJZMoYShMq1VoJhWWbMk0JBbpXSzClmbuXs9A==", "license": "MIT", "dependencies": { - "@microsoft/agents-hosting": "1.7.2", - "@microsoft/agents-telemetry": "1.7.2", + "@microsoft/agents-hosting": "1.8.1", + "@microsoft/agents-telemetry": "1.8.1", "express": "5.2.1", "express-rate-limit": "8.5.2" }, @@ -124,13 +124,14 @@ } }, "node_modules/@microsoft/agents-telemetry": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@microsoft/agents-telemetry/-/agents-telemetry-1.7.2.tgz", - "integrity": "sha512-iJBAm1MmCUaSoPE62GKp4OnlOSe+F+j8o/fwKuqiJEcedbgceTh6G5m8iqe/qDGwKSsBWuIu3Kt0comRrc140A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@microsoft/agents-telemetry/-/agents-telemetry-1.8.1.tgz", + "integrity": "sha512-RMymUnHffoXgHBu4rqNhjaKZMu9cPL1ssmghm4NW8mjIhq2RchXHtJxHakQkV2L2gf2BaQXGqm7ZXvjl90uVQQ==", "license": "MIT", "dependencies": { - "@microsoft/agents-activity": "1.7.2", - "debug": "^4.4.3" + "@microsoft/agents-activity": "1.8.1", + "@noble/hashes": "1.8.0", + "debug": "4.4.3" }, "engines": { "node": ">=20.0.0" @@ -159,6 +160,18 @@ "teamsapptester": "cli.js" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -176,12 +189,352 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" } }, "node_modules/@typespec/ts-http-runtime": { @@ -725,9 +1078,9 @@ "license": "MIT" }, "node_modules/jose": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", - "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -1280,23 +1633,44 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unpipe": { diff --git a/samples/nodejs/retrieval-agent/package.json b/samples/nodejs/retrieval-agent/package.json index 4344efe2..ff7bfd87 100644 --- a/samples/nodejs/retrieval-agent/package.json +++ b/samples/nodejs/retrieval-agent/package.json @@ -11,11 +11,11 @@ "test": "npm run build && node --test dist/tests/retrieval.test.js" }, "dependencies": { - "@microsoft/agents-hosting-express": "^1.5.1" + "@microsoft/agents-hosting-express": "^1.8.1" }, "devDependencies": { - "@microsoft/m365agentsplayground": "^0.2.24", - "@types/node": "^22.15.18", - "typescript": "^5.8.3" + "@microsoft/m365agentsplayground": "^0.2.28", + "@types/node": "^26.4.0", + "typescript": "^7.0.2" } } diff --git a/samples/nodejs/retrieval-agent/src/index.ts b/samples/nodejs/retrieval-agent/src/index.ts index 59b0c72f..1bc1f5ee 100644 --- a/samples/nodejs/retrieval-agent/src/index.ts +++ b/samples/nodejs/retrieval-agent/src/index.ts @@ -1,8 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + import { startServer } from '@microsoft/agents-hosting-express' import { AgentApplication, MemoryStorage, MessageFactory, TurnContext, TurnState } from '@microsoft/agents-hosting' import { Activity } from '@microsoft/agents-activity' -import { handleBuildGenieMessage, buildGenieResponses } from './messageRoute.js' -import { getRetrievalOptions, retrieveSharePoint } from './retrievalClient.js' +import { handleBuildGenieMessage, buildGenieResponses } from './services/messageRoute.js' +import { getRetrievalOptions, retrieveSharePoint } from './services/retrievalClient.js' class RetrievalAgent extends AgentApplication { constructor () { diff --git a/samples/nodejs/retrieval-agent/src/card.ts b/samples/nodejs/retrieval-agent/src/services/card.ts similarity index 85% rename from samples/nodejs/retrieval-agent/src/card.ts rename to samples/nodejs/retrieval-agent/src/services/card.ts index bbf55a7b..6ad8c953 100644 --- a/samples/nodejs/retrieval-agent/src/card.ts +++ b/samples/nodejs/retrieval-agent/src/services/card.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + import type { RetrievalItem } from './retrievalClient.js' export function createSourceCard (items: RetrievalItem[]): object { diff --git a/samples/nodejs/retrieval-agent/src/messageRoute.ts b/samples/nodejs/retrieval-agent/src/services/messageRoute.ts similarity index 92% rename from samples/nodejs/retrieval-agent/src/messageRoute.ts rename to samples/nodejs/retrieval-agent/src/services/messageRoute.ts index a437af72..7a38c80b 100644 --- a/samples/nodejs/retrieval-agent/src/messageRoute.ts +++ b/samples/nodejs/retrieval-agent/src/services/messageRoute.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + import { createSourceCard } from './card.js' import type { RetrievalResult, RetrievalStatus } from './retrievalClient.js' diff --git a/samples/nodejs/retrieval-agent/src/retrievalClient.ts b/samples/nodejs/retrieval-agent/src/services/retrievalClient.ts similarity index 97% rename from samples/nodejs/retrieval-agent/src/retrievalClient.ts rename to samples/nodejs/retrieval-agent/src/services/retrievalClient.ts index a7f92f13..808e1721 100644 --- a/samples/nodejs/retrieval-agent/src/retrievalClient.ts +++ b/samples/nodejs/retrieval-agent/src/services/retrievalClient.ts @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + export type RetrievalStatus = 'success' | 'notSignedIn' | 'noResults' | 'serviceUnavailable' export type RetrievalItem = { diff --git a/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts b/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts index 9f78316b..a9daea14 100644 --- a/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts +++ b/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts @@ -1,7 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + import assert from 'node:assert/strict' import test from 'node:test' -import { buildGenieResponses, handleBuildGenieMessage } from '../messageRoute.js' -import { createFilterExpression, getRetrievalOptions, retrieveSharePoint, type RetrievalResult } from '../retrievalClient.js' +import { buildGenieResponses, handleBuildGenieMessage } from '../services/messageRoute.js' +import { createFilterExpression, getRetrievalOptions, retrieveSharePoint, type RetrievalResult } from '../services/retrievalClient.js' const options = { sharePointSiteUrl: 'https://contoso.sharepoint.com/sites/Build', maximumNumberOfResults: 3 } diff --git a/samples/python/retrieval-agent/README.md b/samples/python/retrieval-agent/README.md index 5b534d17..e60399f6 100644 --- a/samples/python/retrieval-agent/README.md +++ b/samples/python/retrieval-agent/README.md @@ -22,7 +22,7 @@ The retrieval client owns token use, site validation, Microsoft Graph request co - A Microsoft 365 tenant with Copilot Retrieval API entitlement, a user who can sign in, and a SharePoint site that user can read - [Dev Tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started) for Web Chat testing -Upload [ContosoBuildSessions2025.docx](ContosoBuildSessions2025.docx) to the configured SharePoint site's **Documents** library. +Upload [ContosoBuildSessions2025.docx](Sharepoint/ContosoBuildSessions2025.docx) to the configured SharePoint site's **Documents** library. ## Configure Azure Bot OAuth diff --git a/samples/python/retrieval-agent/ContosoBuildSessions2025.docx b/samples/python/retrieval-agent/Sharepoint/ContosoBuildSessions2025.docx similarity index 100% rename from samples/python/retrieval-agent/ContosoBuildSessions2025.docx rename to samples/python/retrieval-agent/Sharepoint/ContosoBuildSessions2025.docx diff --git a/samples/python/retrieval-agent/src/__init__.py b/samples/python/retrieval-agent/src/__init__.py index e69de29b..5b7f7a92 100644 --- a/samples/python/retrieval-agent/src/__init__.py +++ b/samples/python/retrieval-agent/src/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/samples/python/retrieval-agent/src/agent.py b/samples/python/retrieval-agent/src/agent.py index e7ee2c83..aa3d182f 100644 --- a/samples/python/retrieval-agent/src/agent.py +++ b/samples/python/retrieval-agent/src/agent.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations import logging @@ -17,8 +20,8 @@ TurnState, ) -from .message_route import handle_build_genie_message -from .retrieval_client import get_retrieval_options, retrieve_sharepoint +from .services.message_route import handle_build_genie_message +from .services.retrieval_client import get_retrieval_options, retrieve_sharepoint logger = logging.getLogger(__name__) load_dotenv() diff --git a/samples/python/retrieval-agent/src/main.py b/samples/python/retrieval-agent/src/main.py index b7e4a2ad..42dc0546 100644 --- a/samples/python/retrieval-agent/src/main.py +++ b/samples/python/retrieval-agent/src/main.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import logging from .agent import AGENT_APP, connection_manager diff --git a/samples/python/retrieval-agent/src/services/__init__.py b/samples/python/retrieval-agent/src/services/__init__.py new file mode 100644 index 00000000..5b7f7a92 --- /dev/null +++ b/samples/python/retrieval-agent/src/services/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/samples/python/retrieval-agent/src/card.py b/samples/python/retrieval-agent/src/services/card.py similarity index 88% rename from samples/python/retrieval-agent/src/card.py rename to samples/python/retrieval-agent/src/services/card.py index 0c1b87aa..e724d98d 100644 --- a/samples/python/retrieval-agent/src/card.py +++ b/samples/python/retrieval-agent/src/services/card.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations from .retrieval_client import RetrievalItem diff --git a/samples/python/retrieval-agent/src/message_route.py b/samples/python/retrieval-agent/src/services/message_route.py similarity index 93% rename from samples/python/retrieval-agent/src/message_route.py rename to samples/python/retrieval-agent/src/services/message_route.py index dbc3cb4f..8bf2eaa3 100644 --- a/samples/python/retrieval-agent/src/message_route.py +++ b/samples/python/retrieval-agent/src/services/message_route.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations from typing import Awaitable, Callable diff --git a/samples/python/retrieval-agent/src/retrieval_client.py b/samples/python/retrieval-agent/src/services/retrieval_client.py similarity index 95% rename from samples/python/retrieval-agent/src/retrieval_client.py rename to samples/python/retrieval-agent/src/services/retrieval_client.py index 7facf0c3..73f6f08c 100644 --- a/samples/python/retrieval-agent/src/retrieval_client.py +++ b/samples/python/retrieval-agent/src/services/retrieval_client.py @@ -1,5 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from __future__ import annotations +import asyncio import logging from dataclasses import dataclass from enum import Enum @@ -79,6 +83,8 @@ async def retrieve_sharepoint( try: access_token = await get_access_token() + except asyncio.CancelledError: + raise except Exception: logger.warning("The delegated Microsoft Graph token was not available.") return RetrievalResult(RetrievalStatus.NOT_SIGNED_IN, []) @@ -105,6 +111,8 @@ async def retrieve_sharepoint( if items else RetrievalResult(RetrievalStatus.NO_RESULTS, []) ) + except asyncio.CancelledError: + raise except Exception: logger.error("Copilot Retrieval API request failed.") return RetrievalResult(RetrievalStatus.SERVICE_UNAVAILABLE, []) diff --git a/samples/python/retrieval-agent/src/start_server.py b/samples/python/retrieval-agent/src/start_server.py index c83a419f..8a5e4b7b 100644 --- a/samples/python/retrieval-agent/src/start_server.py +++ b/samples/python/retrieval-agent/src/start_server.py @@ -1,8 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from os import environ from aiohttp.web import Application, Request, Response, run_app from microsoft_agents.hosting.aiohttp import ( - CloudAdapter, jwt_authorization_middleware, start_agent_process, ) diff --git a/samples/python/retrieval-agent/tests/test_retrieval.py b/samples/python/retrieval-agent/tests/test_retrieval.py index 5da91d40..4ab6f314 100644 --- a/samples/python/retrieval-agent/tests/test_retrieval.py +++ b/samples/python/retrieval-agent/tests/test_retrieval.py @@ -1,7 +1,11 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio import unittest -from src.message_route import BUILD_GENIE_RESPONSES, handle_build_genie_message -from src.retrieval_client import ( +from src.services.message_route import BUILD_GENIE_RESPONSES, handle_build_genie_message +from src.services.retrieval_client import ( RetrievalItem, RetrievalOptions, RetrievalResult, @@ -49,6 +53,21 @@ async def failed_request(_token: str, _body: dict): self.assertEqual((await retrieve_sharepoint("Build", lambda: _token("token"), OPTIONS, failed_request)).status, RetrievalStatus.SERVICE_UNAVAILABLE) self.assertEqual((await retrieve_sharepoint("Build", lambda: _token("token"), OPTIONS, lambda _token, _body: _response(200, {"retrievalHits": []}))).status, RetrievalStatus.NO_RESULTS) + async def test_propagates_cancellation(self): + async def cancelled_token(): + raise asyncio.CancelledError + + with self.assertRaises(asyncio.CancelledError): + await retrieve_sharepoint("Build", cancelled_token, OPTIONS) + + async def cancelled_request(_token: str, _body: dict): + raise asyncio.CancelledError + + with self.assertRaises(asyncio.CancelledError): + await retrieve_sharepoint( + "Build", lambda: _token("token"), OPTIONS, cancelled_request + ) + async def test_message_route_sends_grounded_text_and_source_card(self): messages: list[str] = [] cards: list[dict] = [] From 7eac7da1651acd8ba381dff485df451a66fa16f7 Mon Sep 17 00:00:00 2001 From: Joel Mut Date: Tue, 1 Sep 2026 09:48:09 +0200 Subject: [PATCH 3/3] Apply feedback --- samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts b/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts index a9daea14..f57a1ab7 100644 --- a/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts +++ b/samples/nodejs/retrieval-agent/src/tests/retrieval.test.ts @@ -37,9 +37,9 @@ test('message route sends grounded text and a source card', async () => { const messages: string[] = [] const cards: object[] = [] await handleBuildGenieMessage(result, async text => { messages.push(text) }, async card => { cards.push(card) }) - assert.match(messages[0], /https:\/\/contoso.sharepoint.com\/session.docx/) + assert.match(messages[0], /https:\/\/contoso\.sharepoint\.com\/session\.docx/) assert.equal(cards.length, 1) - assert.match(JSON.stringify(cards[0]), /https:\/\/contoso.sharepoint.com\/session.docx/) + assert.match(JSON.stringify(cards[0]), /https:\/\/contoso\.sharepoint\.com\/session\.docx/) }) for (const status of ['notSignedIn', 'noResults', 'serviceUnavailable'] as const) {