From 0e2fcb1c7fc76347a2d661842c50bfcc46f6ea21 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:25:06 +0000 Subject: [PATCH 1/6] .NET: Add Foundry Memory Context Provider (#3522) * Add Azure AI Foundry Memory Context Provider with unit tests * Add FoundryMemory integration tests and sample application * Fix ClearStoredMemoriesAsync to handle 404 gracefully and rename to EnsureStoredMemoriesDeletedAsync * Refactor FoundryMemory: simplify architecture and add memory store creation - Remove IFoundryMemoryOperations interface (was only for test mocking) - Remove AIProjectClientMemoryOperations wrapper class - Provider now directly uses AIProjectClient with internal extension methods - Extension methods return actual response models instead of extracted values - Remove WaitForUpdateCompletionAsync from provider (sample uses delay) - Simplify EnsureMemoryStoreCreatedAsync to return Task instead of Task - Add memory store creation with chat_model and embedding_model - Add UpdateMemoriesResponse with SupersededBy and Error fields - Simplify unit tests to focus on constructor validation and serialization - Update sample to use simple delay for memory processing wait * Add waiting operation for memory store updates * Fix UTF-8 BOM encoding for FoundryMemory csproj files * Update copilot instructions for UTF-8 BOM and fix sample API rename * Fix UTF-8 BOM encoding for TestableAIProjectClient.cs * Add missing response headers for TS * Changing default embedding * Using the SDK Models * Program update * Remove debugging code from sample * Adapt FoundryMemoryProvider to new AIContextProvider API and add UTF-8 BOM instruction - Override ProvideAIContextAsync/StoreAIContextAsync instead of removed virtual InvokingAsync/InvokedAsync - Use ProviderSessionState for session-scoped state management (matching Mem0Provider pattern) - Replace constructor-based scope with stateInitializer delegate - Remove Serialize method (no longer on base class) - Add SearchInputMessageFilter, StorageInputMessageFilter, StateKey to options - Update sample to use AIContextProviders list instead of AIContextProviderFactory - Update unit and integration tests for new API - Add UTF-8 BOM encoding and --tl:off instructions to dotnet/AGENTS.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use DefaultAzureCredential in Foundry Memory sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments for FoundryMemoryProvider - Move memoryStoreName from options to required constructor parameter - Make FoundryMemoryProviderScope require non-null/whitespace scope in constructor - Make Scope property read-only (getter only) - Replace ConcurrentQueue with single last update ID to fix memory leak - Only clear pending update ID after successful completion - Add delete success logging - Mark FoundryMemoryProvider with [Experimental] attribute - Update unit tests for new API signatures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use Throw.IfNullOrWhitespace for scope and memoryStoreName validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/AGENTS.md | 1 + dotnet/agent-framework-dotnet.slnx | 4 + ...ithMemory_Step04_MemoryUsingFoundry.csproj | 21 + .../Program.cs | 77 +++ .../README.md | 57 +++ .../GettingStarted/AgentWithMemory/README.md | 1 + .../AIProjectClientExtensions.cs | 40 ++ .../FoundryMemoryJsonUtilities.cs | 36 ++ .../FoundryMemoryProvider.cs | 440 ++++++++++++++++++ .../FoundryMemoryProviderOptions.cs | 67 +++ .../FoundryMemoryProviderScope.cs | 38 ++ .../Microsoft.Agents.AI.FoundryMemory.csproj | 41 ++ .../FoundryMemoryConfiguration.cs | 13 + .../FoundryMemoryProviderTests.cs | 132 ++++++ ...s.AI.FoundryMemory.IntegrationTests.csproj | 21 + .../FoundryMemoryProviderTests.cs | 130 ++++++ ...t.Agents.AI.FoundryMemory.UnitTests.csproj | 16 + .../TestableAIProjectClient.cs | 196 ++++++++ 18 files changed, 1331 insertions(+) create mode 100644 dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj create mode 100644 dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs create mode 100644 dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj create mode 100644 dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs diff --git a/dotnet/AGENTS.md b/dotnet/AGENTS.md index 03a015f2f7..4cb4b67e5f 100644 --- a/dotnet/AGENTS.md +++ b/dotnet/AGENTS.md @@ -29,6 +29,7 @@ using types like `IChatClient`, `FunctionInvokingChatClient`, `AITool`, `AIFunct ## Key Conventions +- **Encoding**: All new files must be saved with UTF-8 encoding with BOM (Byte Order Mark). This is required for `dotnet format` to work correctly. - **Copyright header**: `// Copyright (c) Microsoft. All rights reserved.` at top of all `.cs` files - **XML docs**: Required for all public methods and classes - **Async**: Use `Async` suffix for methods returning `Task`/`ValueTask` diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b3a1fd81f3..f01cd409d6 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -138,6 +138,7 @@ + @@ -424,6 +425,7 @@ + @@ -445,6 +447,7 @@ + @@ -467,6 +470,7 @@ + diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj new file mode 100644 index 0000000000..0b6c06a5a8 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs new file mode 100644 index 0000000000..b3533e6d1d --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use the FoundryMemoryProvider to persist and recall memories for an agent. +// The sample stores conversation messages in an Azure AI Foundry memory store and retrieves relevant +// memories for subsequent invocations, even across new sessions. +// +// Note: Memory extraction in Azure AI Foundry is asynchronous and takes time. This sample demonstrates +// a simple polling approach to wait for memory updates to complete before querying. + +using System.Text.Json; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.FoundryMemory; + +string foundryEndpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."); +string memoryStoreName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MEMORY_STORE_NAME") ?? "memory-store-sample"; +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MODEL") ?? "gpt-4.1-mini"; +string embeddingModelName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_EMBEDDING_MODEL") ?? "text-embedding-ada-002"; + +// Create an AIProjectClient for Foundry with Azure Identity authentication. +DefaultAzureCredential credential = new(); +AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential); + +// Get the ChatClient from the AIProjectClient's OpenAI property using the deployment name. +// The stateInitializer can be used to customize the Foundry Memory scope per session and it will be called each time a session +// is encountered by the FoundryMemoryProvider that does not already have state stored on the session. +// If each session should have its own scope, you can create a new id per session via the stateInitializer, e.g.: +// new FoundryMemoryProvider(projectClient, memoryStoreName, stateInitializer: _ => new(new FoundryMemoryProviderScope(Guid.NewGuid().ToString())), ...) +// In our case we are storing memories scoped by user so that memories are retained across sessions. +FoundryMemoryProvider memoryProvider = new( + projectClient, + memoryStoreName, + stateInitializer: _ => new(new FoundryMemoryProviderScope("sample-user-123"))); + +AIAgent agent = await projectClient.CreateAIAgentAsync(deploymentName, + options: new ChatClientAgentOptions() + { + Name = "TravelAssistantWithFoundryMemory", + ChatOptions = new() { Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details." }, + AIContextProviders = [memoryProvider] + }); + +AgentSession session = await agent.CreateSessionAsync(); + +Console.WriteLine("\n>> Setting up Foundry Memory Store\n"); + +// Ensure the memory store exists (creates it with the specified models if needed). +await memoryProvider.EnsureMemoryStoreCreatedAsync(deploymentName, embeddingModelName, "Sample memory store for travel assistant"); + +// Clear any existing memories for this scope to demonstrate fresh behavior. +await memoryProvider.EnsureStoredMemoriesDeletedAsync(session); + +Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", session)); +Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", session)); + +// Memory extraction in Azure AI Foundry is asynchronous and takes time to process. +// WhenUpdatesCompletedAsync polls all pending updates and waits for them to complete. +Console.WriteLine("\nWaiting for Foundry Memory to process updates..."); +await memoryProvider.WhenUpdatesCompletedAsync(); + +Console.WriteLine("Updates completed.\n"); + +Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", session)); + +Console.WriteLine("\n>> Serialize and deserialize the session to demonstrate persisted state\n"); +JsonElement serializedSession = await agent.SerializeSessionAsync(session); +AgentSession restoredSession = await agent.DeserializeSessionAsync(serializedSession); +Console.WriteLine(await agent.RunAsync("Can you recap the personal details you remember?", restoredSession)); + +Console.WriteLine("\n>> Start a new session that shares the same Foundry Memory scope\n"); + +Console.WriteLine("\nWaiting for Foundry Memory to process updates..."); +await memoryProvider.WhenUpdatesCompletedAsync(); + +AgentSession newSession = await agent.CreateSessionAsync(); +Console.WriteLine(await agent.RunAsync("Summarize what you already know about me.", newSession)); diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md new file mode 100644 index 0000000000..dfea386d82 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md @@ -0,0 +1,57 @@ +# Agent with Memory Using Azure AI Foundry + +This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories across sessions. + +## Features Demonstrated + +- Creating a `FoundryMemoryProvider` with Azure Identity authentication +- Automatic memory store creation if it doesn't exist +- Multi-turn conversations with automatic memory extraction +- Memory retrieval to inform agent responses +- Session serialization and deserialization +- Memory persistence across completely new sessions + +## Prerequisites + +1. Azure subscription with Azure AI Foundry project +2. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) and an embedding model deployment (e.g., text-embedding-ada-002) +3. .NET 10.0 SDK +4. Azure CLI logged in (`az login`) + +## Environment Variables + +```bash +# Azure AI Foundry project endpoint and memory store name +export FOUNDRY_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" +export FOUNDRY_PROJECT_MEMORY_STORE_NAME="my_memory_store" + +# Model deployment names (models deployed in your Foundry project) +export FOUNDRY_PROJECT_MODEL="gpt-4o-mini" +export FOUNDRY_PROJECT_EMBEDDING_MODEL="text-embedding-ada-002" +``` + +## Run the Sample + +```bash +dotnet run +``` + +## Expected Output + +The agent will: +1. Create the memory store if it doesn't exist (using the specified chat and embedding models) +2. Learn your name (Taylor), travel destination (Patagonia), timing (November), companions (sister), and interests (scenic viewpoints) +3. Wait for Foundry Memory to index the memories +4. Recall those details when asked about the trip +5. Demonstrate memory persistence across session serialization/deserialization +6. Show that a brand new session can still access the same memories + +## Key Differences from Mem0 + +| Aspect | Mem0 | Azure AI Foundry Memory | +|--------|------|------------------------| +| Authentication | API Key | Azure Identity (DefaultAzureCredential) | +| Scope | ApplicationId, UserId, AgentId, ThreadId | Single `Scope` string | +| Memory Types | Single memory store | User Profile + Chat Summary | +| Hosting | Mem0 cloud or self-hosted | Azure AI Foundry managed service | +| Store Creation | N/A (automatic) | Explicit via `EnsureMemoryStoreCreatedAsync` | diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/README.md b/dotnet/samples/GettingStarted/AgentWithMemory/README.md index 903fcf1b78..6e36ba0511 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/README.md +++ b/dotnet/samples/GettingStarted/AgentWithMemory/README.md @@ -7,3 +7,4 @@ These samples show how to create an agent with the Agent Framework that uses Mem |[Chat History memory](./AgentWithMemory_Step01_ChatHistoryMemory/)|This sample demonstrates how to enable an agent to remember messages from previous conversations.| |[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.| |[Custom Memory Implementation](./AgentWithMemory_Step03_CustomMemory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.| +|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.| diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs new file mode 100644 index 0000000000..9e24703d92 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Internal extension methods for to provide MemoryStores helper operations. +/// +internal static class AIProjectClientExtensions +{ + /// + /// Creates a memory store if it doesn't already exist. + /// + internal static async Task CreateMemoryStoreIfNotExistsAsync( + this AIProjectClient client, + string memoryStoreName, + string? description, + string chatModel, + string embeddingModel, + CancellationToken cancellationToken) + { + try + { + await client.MemoryStores.GetMemoryStoreAsync(memoryStoreName, cancellationToken).ConfigureAwait(false); + return false; // Store already exists + } + catch (ClientResultException ex) when (ex.Status == 404) + { + // Store doesn't exist, create it + } + + MemoryStoreDefaultDefinition definition = new(chatModel, embeddingModel); + await client.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, description, cancellationToken: cancellationToken).ConfigureAwait(false); + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs new file mode 100644 index 0000000000..1a0dd4f4e2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Provides JSON serialization utilities for the Foundry Memory provider. +/// +internal static class FoundryMemoryJsonUtilities +{ + /// + /// Gets the default JSON serializer options for Foundry Memory operations. + /// + public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + TypeInfoResolver = FoundryMemoryJsonContext.Default + }; +} + +/// +/// Source-generated JSON serialization context for Foundry Memory types. +/// +[JsonSourceGenerationOptions( + JsonSerializerDefaults.General, + UseStringEnumConverter = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = false)] +[JsonSerializable(typeof(FoundryMemoryProviderScope))] +[JsonSerializable(typeof(FoundryMemoryProvider.State))] +internal partial class FoundryMemoryJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs new file mode 100644 index 0000000000..9ffeda3fb5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Provides an Azure AI Foundry Memory backed that persists conversation messages as memories +/// and retrieves related memories to augment the agent invocation context. +/// +/// +/// The provider stores user, assistant and system messages as Foundry memories and retrieves relevant memories +/// for new invocations using the memory search endpoint. Retrieved memories are injected as user messages +/// to the model, prefixed by a configurable context prompt. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public sealed class FoundryMemoryProvider : AIContextProvider +{ + private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; + + private readonly ProviderSessionState _sessionState; + private readonly string _contextPrompt; + private readonly string _memoryStoreName; + private readonly int _maxMemories; + private readonly int _updateDelay; + private readonly bool _enableSensitiveTelemetryData; + + private readonly AIProjectClient _client; + private readonly ILogger? _logger; + + private string? _lastPendingUpdateId; + + /// + /// Initializes a new instance of the class. + /// + /// The Azure AI Project client configured for your Foundry project. + /// The name of the memory store in Azure AI Foundry. + /// A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval. + /// Provider options. + /// Optional logger factory. + /// Thrown when or is . + /// Thrown when is null or whitespace. + public FoundryMemoryProvider( + AIProjectClient client, + string memoryStoreName, + Func stateInitializer, + FoundryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + : base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter) + { + Throw.IfNull(client); + Throw.IfNullOrWhitespace(memoryStoreName); + + this._sessionState = new ProviderSessionState( + ValidateStateInitializer(Throw.IfNull(stateInitializer)), + options?.StateKey ?? this.GetType().Name, + FoundryMemoryJsonUtilities.DefaultOptions); + + FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions(); + + this._logger = loggerFactory?.CreateLogger(); + this._client = client; + + this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt; + this._memoryStoreName = memoryStoreName; + this._maxMemories = effectiveOptions.MaxMemories; + this._updateDelay = effectiveOptions.UpdateDelay; + this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData; + } + + /// + public override string StateKey => this._sessionState.StateKey; + + private static Func ValidateStateInitializer(Func stateInitializer) => + session => + { + State state = stateInitializer(session); + + if (state is null) + { + throw new InvalidOperationException("State initializer must return a non-null state."); + } + + return state; + }; + + /// + protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + Throw.IfNull(context); + + State state = this._sessionState.GetOrInitializeState(context.Session); + FoundryMemoryProviderScope scope = state.Scope; + + List messageItems = (context.AIContext.Messages ?? []) + .Where(m => !string.IsNullOrWhiteSpace(m.Text)) + .Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!)) + .ToList(); + + if (messageItems.Count == 0) + { + return new AIContext(); + } + + try + { + MemorySearchOptions searchOptions = new(scope.Scope) + { + ResultOptions = new MemorySearchResultOptions { MaxMemories = this._maxMemories } + }; + + foreach (ResponseItem item in messageItems) + { + searchOptions.Items.Add(item); + } + + ClientResult result = await this._client.MemoryStores.SearchMemoriesAsync( + this._memoryStoreName, + searchOptions, + cancellationToken).ConfigureAwait(false); + + MemoryStoreSearchResponse response = result.Value; + + List memories = response.Memories + .Select(m => m.MemoryItem?.Content ?? string.Empty) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .ToList(); + + string? outputMessageText = memories.Count == 0 + ? null + : $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}"; + + if (this._logger?.IsEnabled(LogLevel.Information) is true) + { + this._logger.LogInformation( + "FoundryMemoryProvider: Retrieved {Count} memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + memories.Count, + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + + if (outputMessageText is not null && this._logger.IsEnabled(LogLevel.Trace)) + { + this._logger.LogTrace( + "FoundryMemoryProvider: Search Results\nOutput:{MessageText}\nMemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this.SanitizeLogData(outputMessageText), + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + } + + return new AIContext + { + Messages = [new ChatMessage(ChatRole.User, outputMessageText)] + }; + } + catch (ArgumentException) + { + throw; + } + catch (Exception ex) + { + if (this._logger?.IsEnabled(LogLevel.Error) is true) + { + this._logger.LogError( + ex, + "FoundryMemoryProvider: Failed to search for memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + + return new AIContext(); + } + } + + /// + protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + State state = this._sessionState.GetOrInitializeState(context.Session); + FoundryMemoryProviderScope scope = state.Scope; + + try + { + List messageItems = context.RequestMessages + .Concat(context.ResponseMessages ?? []) + .Where(m => IsAllowedRole(m.Role) && !string.IsNullOrWhiteSpace(m.Text)) + .Select(m => (ResponseItem)ToResponseItem(m.Role, m.Text!)) + .ToList(); + + if (messageItems.Count == 0) + { + return; + } + + MemoryUpdateOptions updateOptions = new(scope.Scope) + { + UpdateDelay = this._updateDelay + }; + + foreach (ResponseItem item in messageItems) + { + updateOptions.Items.Add(item); + } + + ClientResult result = await this._client.MemoryStores.UpdateMemoriesAsync( + this._memoryStoreName, + updateOptions, + cancellationToken).ConfigureAwait(false); + + MemoryUpdateResult response = result.Value; + + if (response.UpdateId is not null) + { + Interlocked.Exchange(ref this._lastPendingUpdateId, response.UpdateId); + } + + if (this._logger?.IsEnabled(LogLevel.Information) is true) + { + this._logger.LogInformation( + "FoundryMemoryProvider: Sent {Count} messages to update memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}', UpdateId: '{UpdateId}'.", + messageItems.Count, + this._memoryStoreName, + this.SanitizeLogData(scope.Scope), + response.UpdateId); + } + } + catch (Exception ex) + { + if (this._logger?.IsEnabled(LogLevel.Error) is true) + { + this._logger.LogError( + ex, + "FoundryMemoryProvider: Failed to send messages to update memories due to error. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + } + } + + /// + /// Ensures all stored memories for the configured scope are deleted. + /// This method handles cases where the scope doesn't exist (no memories stored yet). + /// + /// The session containing the scope state to clear memories for. + /// Cancellation token. + public async Task EnsureStoredMemoriesDeletedAsync(AgentSession session, CancellationToken cancellationToken = default) + { + Throw.IfNull(session); + State state = this._sessionState.GetOrInitializeState(session); + FoundryMemoryProviderScope scope = state.Scope; + + try + { + await this._client.MemoryStores.DeleteScopeAsync(this._memoryStoreName, scope.Scope, cancellationToken).ConfigureAwait(false); + + if (this._logger?.IsEnabled(LogLevel.Information) is true) + { + this._logger.LogInformation( + "FoundryMemoryProvider: Deleted stored memories for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + } + catch (ClientResultException ex) when (ex.Status == 404) + { + // Scope doesn't exist (no memories stored yet), nothing to delete + if (this._logger?.IsEnabled(LogLevel.Debug) is true) + { + this._logger.LogDebug( + "FoundryMemoryProvider: No memories to delete for scope. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + this._memoryStoreName, + this.SanitizeLogData(scope.Scope)); + } + } + } + + /// + /// Ensures the memory store exists, creating it if necessary. + /// + /// The deployment name of the chat model for memory processing. + /// The deployment name of the embedding model for memory search. + /// Optional description for the memory store. + /// Cancellation token. + public async Task EnsureMemoryStoreCreatedAsync( + string chatModel, + string embeddingModel, + string? description = null, + CancellationToken cancellationToken = default) + { + bool created = await this._client.CreateMemoryStoreIfNotExistsAsync( + this._memoryStoreName, + description, + chatModel, + embeddingModel, + cancellationToken).ConfigureAwait(false); + + if (created) + { + if (this._logger?.IsEnabled(LogLevel.Information) is true) + { + this._logger.LogInformation( + "FoundryMemoryProvider: Created memory store '{MemoryStoreName}'.", + this._memoryStoreName); + } + } + else + { + if (this._logger?.IsEnabled(LogLevel.Debug) is true) + { + this._logger.LogDebug( + "FoundryMemoryProvider: Memory store '{MemoryStoreName}' already exists.", + this._memoryStoreName); + } + } + } + + /// + /// Waits for all pending memory update operations to complete. + /// + /// + /// Memory extraction in Azure AI Foundry is asynchronous. This method polls the latest pending update + /// and returns when it has completed, failed, or been superseded. Since updates are processed in order, + /// completion of the latest update implies all prior updates have also been processed. + /// + /// The interval between status checks. Defaults to 5 seconds. + /// Cancellation token. + /// Thrown if the update operation failed. + public async Task WhenUpdatesCompletedAsync( + TimeSpan? pollingInterval = null, + CancellationToken cancellationToken = default) + { + string? updateId = Volatile.Read(ref this._lastPendingUpdateId); + if (updateId is null) + { + return; + } + + TimeSpan interval = pollingInterval ?? TimeSpan.FromSeconds(5); + await this.WaitForUpdateAsync(updateId, interval, cancellationToken).ConfigureAwait(false); + + // Only clear the pending update ID after successful completion + Interlocked.CompareExchange(ref this._lastPendingUpdateId, null, updateId); + } + + private async Task WaitForUpdateAsync(string updateId, TimeSpan interval, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + ClientResult result = await this._client.MemoryStores.GetUpdateResultAsync( + this._memoryStoreName, + updateId, + cancellationToken).ConfigureAwait(false); + + MemoryUpdateResult response = result.Value; + MemoryStoreUpdateStatus status = response.Status; + + if (this._logger?.IsEnabled(LogLevel.Debug) is true) + { + this._logger.LogDebug( + "FoundryMemoryProvider: Update status for '{UpdateId}': {Status}", + updateId, + status); + } + + if (status == MemoryStoreUpdateStatus.Completed || status == MemoryStoreUpdateStatus.Superseded) + { + return; + } + + if (status == MemoryStoreUpdateStatus.Failed) + { + throw new InvalidOperationException($"Memory update operation '{updateId}' failed: {response.ErrorDetails}"); + } + + if (status == MemoryStoreUpdateStatus.Queued || status == MemoryStoreUpdateStatus.InProgress) + { + await Task.Delay(interval, cancellationToken).ConfigureAwait(false); + } + else + { + throw new InvalidOperationException($"Unknown update status '{status}' for update '{updateId}'."); + } + } + } + + private static MessageResponseItem ToResponseItem(ChatRole role, string text) + { + if (role == ChatRole.Assistant) + { + return ResponseItem.CreateAssistantMessageItem(text); + } + + if (role == ChatRole.System) + { + return ResponseItem.CreateSystemMessageItem(text); + } + + return ResponseItem.CreateUserMessageItem(text); + } + + private static bool IsAllowedRole(ChatRole role) => + role == ChatRole.User || role == ChatRole.Assistant || role == ChatRole.System; + + private string? SanitizeLogData(string? data) => this._enableSensitiveTelemetryData ? data : ""; + + /// + /// Represents the state of a stored in the . + /// + public sealed class State + { + /// + /// Initializes a new instance of the class with the specified scope. + /// + /// The scope to use for memory storage and retrieval. + [JsonConstructor] + public State(FoundryMemoryProviderScope scope) + { + this.Scope = Throw.IfNull(scope); + } + + /// + /// Gets the scope used for memory storage and retrieval. + /// + public FoundryMemoryProviderScope Scope { get; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs new file mode 100644 index 0000000000..482e14db82 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Options for configuring the . +/// +public sealed class FoundryMemoryProviderOptions +{ + /// + /// When providing memories to the model, this string is prefixed to the retrieved memories to supply context. + /// + /// Defaults to "## Memories\nConsider the following memories when answering user questions:". + public string? ContextPrompt { get; set; } + + /// + /// Gets or sets the maximum number of memories to retrieve during search. + /// + /// Defaults to 5. + public int MaxMemories { get; set; } = 5; + + /// + /// Gets or sets the delay in seconds before memory updates are processed. + /// + /// + /// Setting to 0 triggers updates immediately without waiting for inactivity. + /// Higher values allow the service to batch multiple updates together. + /// + /// Defaults to 0 (immediate). + public int UpdateDelay { get; set; } + + /// + /// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs. + /// + /// Defaults to . + public bool EnableSensitiveTelemetryData { get; set; } + + /// + /// Gets or sets the key used to store the provider state in the session's . + /// + /// Defaults to the provider's type name. + public string? StateKey { get; set; } + + /// + /// Gets or sets an optional filter function applied to request messages when building the search text to use when + /// searching for relevant memories during . + /// + /// + /// When , the provider defaults to including only + /// messages. + /// + public Func, IEnumerable>? SearchInputMessageFilter { get; set; } + + /// + /// Gets or sets an optional filter function applied to request messages when determining which messages to + /// extract memories from during . + /// + /// + /// When , the provider defaults to including only + /// messages. + /// + public Func, IEnumerable>? StorageInputMessageFilter { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs new file mode 100644 index 0000000000..717df1d12b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Allows scoping of memories for the . +/// +/// +/// Azure AI Foundry memories are scoped by a single string identifier that you control. +/// Common patterns include using a user ID, team ID, or other unique identifier +/// to partition memories across different contexts. +/// +public sealed class FoundryMemoryProviderScope +{ + /// + /// Initializes a new instance of the class with the specified scope identifier. + /// + /// The scope identifier used to partition memories. Must not be null or whitespace. + /// Thrown when is null or whitespace. + public FoundryMemoryProviderScope(string scope) + { + Throw.IfNullOrWhitespace(scope); + this.Scope = scope; + } + + /// + /// Gets the scope identifier used to partition memories. + /// + /// + /// This value controls how memory is partitioned in the memory store. + /// Each unique scope maintains its own isolated collection of memory items. + /// For example, use a user ID to ensure each user has their own individual memory. + /// + public string Scope { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj new file mode 100644 index 0000000000..75da2bccc5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj @@ -0,0 +1,41 @@ + + + + preview + $(NoWarn);OPENAI001 + + + + true + true + true + true + + + + + + false + + + + + + + + + + + + + + Microsoft Agent Framework - Azure AI Foundry Memory integration + Provides Azure AI Foundry Memory integration for Microsoft Agent Framework. + + + + + + + + diff --git a/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs b/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs new file mode 100644 index 0000000000..957f1bfa4c --- /dev/null +++ b/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Shared.IntegrationTests; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +#pragma warning disable CA1812 // Internal class that is apparently never instantiated. + +internal sealed class FoundryMemoryConfiguration +{ + public string Endpoint { get; set; } + public string MemoryStoreName { get; set; } + public string? DeploymentName { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs new file mode 100644 index 0000000000..d89001d3b9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Shared.IntegrationTests; + +namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests; + +/// +/// Integration tests for against a configured Azure AI Foundry Memory service. +/// +/// +/// These integration tests are skipped by default and require a live Azure AI Foundry Memory service. +/// The tests need to be updated to use the new AIAgent-based API pattern. +/// Set to null to enable them after configuring the service. +/// +public sealed class FoundryMemoryProviderTests : IDisposable +{ + private const string SkipReason = "Requires an Azure AI Foundry Memory service configured"; // Set to null to enable. + + private readonly AIProjectClient? _client; + private readonly string? _memoryStoreName; + private readonly string? _deploymentName; + private bool _disposed; + + public FoundryMemoryProviderTests() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddJsonFile(path: "testsettings.json", optional: true, reloadOnChange: true) + .AddJsonFile(path: "testsettings.development.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddUserSecrets(optional: true) + .Build(); + + var foundrySettings = configuration.GetSection("FoundryMemory").Get(); + + if (foundrySettings is not null && + !string.IsNullOrWhiteSpace(foundrySettings.Endpoint) && + !string.IsNullOrWhiteSpace(foundrySettings.MemoryStoreName)) + { + this._client = new AIProjectClient(new Uri(foundrySettings.Endpoint), new AzureCliCredential()); + this._memoryStoreName = foundrySettings.MemoryStoreName; + this._deploymentName = foundrySettings.DeploymentName ?? "gpt-4.1-mini"; + } + } + + [Fact(Skip = SkipReason)] + public async Task CanAddAndRetrieveUserMemoriesAsync() + { + // Arrange + FoundryMemoryProvider memoryProvider = new( + this._client!, + this._memoryStoreName!, + stateInitializer: _ => new(new FoundryMemoryProviderScope("it-user-1"))); + + AIAgent agent = await this._client!.CreateAIAgentAsync(this._deploymentName!, + options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider] }); + + AgentSession session = await agent.CreateSessionAsync(); + + await memoryProvider.EnsureStoredMemoriesDeletedAsync(session); + + // Act + AgentResponse resultBefore = await agent.RunAsync("What is my name?", session); + Assert.DoesNotContain("Caoimhe", resultBefore.Text); + + await agent.RunAsync("Hello, my name is Caoimhe.", session); + await memoryProvider.WhenUpdatesCompletedAsync(); + await Task.Delay(2000); + + AgentResponse resultAfter = await agent.RunAsync("What is my name?", session); + + // Cleanup + await memoryProvider.EnsureStoredMemoriesDeletedAsync(session); + + // Assert + Assert.Contains("Caoimhe", resultAfter.Text); + } + + [Fact(Skip = SkipReason)] + public async Task DoesNotLeakMemoriesAcrossScopesAsync() + { + // Arrange + FoundryMemoryProvider memoryProvider1 = new( + this._client!, + this._memoryStoreName!, + stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-a"))); + + FoundryMemoryProvider memoryProvider2 = new( + this._client!, + this._memoryStoreName!, + stateInitializer: _ => new(new FoundryMemoryProviderScope("it-scope-b"))); + + AIAgent agent1 = await this._client!.CreateAIAgentAsync(this._deploymentName!, + options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider1] }); + AIAgent agent2 = await this._client!.CreateAIAgentAsync(this._deploymentName!, + options: new ChatClientAgentOptions { AIContextProviders = [memoryProvider2] }); + + AgentSession session1 = await agent1.CreateSessionAsync(); + AgentSession session2 = await agent2.CreateSessionAsync(); + + await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1); + await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2); + + // Act - add memory only to scope A + await agent1.RunAsync("Hello, I'm an AI tutor and my name is Caoimhe.", session1); + await memoryProvider1.WhenUpdatesCompletedAsync(); + await Task.Delay(2000); + + AgentResponse result1 = await agent1.RunAsync("What is your name?", session1); + AgentResponse result2 = await agent2.RunAsync("What is your name?", session2); + + // Assert + Assert.Contains("Caoimhe", result1.Text); + Assert.DoesNotContain("Caoimhe", result2.Text); + + // Cleanup + await memoryProvider1.EnsureStoredMemoriesDeletedAsync(session1); + await memoryProvider2.EnsureStoredMemoriesDeletedAsync(session2); + } + + public void Dispose() + { + if (!this._disposed) + { + this._disposed = true; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj new file mode 100644 index 0000000000..a28fea3490 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj @@ -0,0 +1,21 @@ + + + + True + + + + + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs new file mode 100644 index 0000000000..226596a374 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; + +/// +/// Tests for constructor validation. +/// +/// +/// Since directly uses , +/// integration tests are used to verify the memory operations. These unit tests focus on: +/// - Constructor parameter validation +/// - State initializer validation +/// +public sealed class FoundryMemoryProviderTests +{ + [Fact] + public void Constructor_Throws_WhenClientIsNull() + { + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + null!, + "store", + stateInitializer: _ => new(new FoundryMemoryProviderScope("test")))); + Assert.Equal("client", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenStateInitializerIsNull() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + "store", + stateInitializer: null!)); + Assert.Equal("stateInitializer", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenMemoryStoreNameIsEmpty() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + "", + stateInitializer: _ => new(new FoundryMemoryProviderScope("test")))); + Assert.Equal("memoryStoreName", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenMemoryStoreNameIsNull() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + null!, + stateInitializer: _ => new(new FoundryMemoryProviderScope("test")))); + Assert.Equal("memoryStoreName", ex.ParamName); + } + + [Fact] + public void Scope_Throws_WhenScopeIsNull() + { + // Act & Assert + Assert.Throws(() => new FoundryMemoryProviderScope(null!)); + } + + [Fact] + public void Scope_Throws_WhenScopeIsEmpty() + { + // Act & Assert + Assert.Throws(() => new FoundryMemoryProviderScope("")); + } + + [Fact] + public void StateInitializer_Throws_WhenScopeIsNull() + { + // Arrange + using TestableAIProjectClient testClient = new(); + FoundryMemoryProvider sut = new( + testClient.Client, + "store", + stateInitializer: _ => new(null!)); + + // Act & Assert - state initializer validation is deferred to first use + Assert.Throws(() => + { + // Force state initialization by creating a session-like scenario + // The validation happens inside the ValidateStateInitializer wrapper + try + { + // The stateInitializer wraps with validation, so calling it will throw + var field = typeof(FoundryMemoryProvider).GetField("_sessionState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var sessionState = field!.GetValue(sut); + var method = sessionState!.GetType().GetMethod("GetOrInitializeState"); + method!.Invoke(sessionState, [null]); + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException is not null) + { + throw tie.InnerException; + } + }); + } + + [Fact] + public void Constructor_Succeeds_WithValidParameters() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act + FoundryMemoryProvider sut = new( + testClient.Client, + "my-store", + stateInitializer: _ => new(new FoundryMemoryProviderScope("user-456"))); + + // Assert + Assert.NotNull(sut); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj new file mode 100644 index 0000000000..1fe8dc57bd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + false + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs new file mode 100644 index 0000000000..25c041f754 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.Core; + +namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; + +/// +/// Creates a testable AIProjectClient with a mock HTTP handler. +/// +internal sealed class TestableAIProjectClient : IDisposable +{ + private readonly HttpClient _httpClient; + + public TestableAIProjectClient( + string? searchMemoriesResponse = null, + string? updateMemoriesResponse = null, + HttpStatusCode? searchStatusCode = null, + HttpStatusCode? updateStatusCode = null, + HttpStatusCode? deleteStatusCode = null, + HttpStatusCode? createStoreStatusCode = null, + HttpStatusCode? getStoreStatusCode = null) + { + this.Handler = new MockHttpMessageHandler( + searchMemoriesResponse, + updateMemoriesResponse, + searchStatusCode, + updateStatusCode, + deleteStatusCode, + createStoreStatusCode, + getStoreStatusCode); + + this._httpClient = new HttpClient(this.Handler); + + AIProjectClientOptions options = new() + { + Transport = new HttpClientPipelineTransport(this._httpClient) + }; + + // Using a valid format endpoint + this.Client = new AIProjectClient( + new Uri("https://test.services.ai.azure.com/api/projects/test-project"), + new MockTokenCredential(), + options); + } + + public AIProjectClient Client { get; } + + public MockHttpMessageHandler Handler { get; } + + public void Dispose() + { + this._httpClient.Dispose(); + this.Handler.Dispose(); + } +} + +/// +/// Mock HTTP message handler for testing. +/// +internal sealed class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly string? _searchMemoriesResponse; + private readonly string? _updateMemoriesResponse; + private readonly HttpStatusCode _searchStatusCode; + private readonly HttpStatusCode _updateStatusCode; + private readonly HttpStatusCode _deleteStatusCode; + private readonly HttpStatusCode _createStoreStatusCode; + private readonly HttpStatusCode _getStoreStatusCode; + + public MockHttpMessageHandler( + string? searchMemoriesResponse = null, + string? updateMemoriesResponse = null, + HttpStatusCode? searchStatusCode = null, + HttpStatusCode? updateStatusCode = null, + HttpStatusCode? deleteStatusCode = null, + HttpStatusCode? createStoreStatusCode = null, + HttpStatusCode? getStoreStatusCode = null) + { + this._searchMemoriesResponse = searchMemoriesResponse ?? """{"memories":[]}"""; + this._updateMemoriesResponse = updateMemoriesResponse ?? """{"update_id":"test-update-id","status":"queued"}"""; + this._searchStatusCode = searchStatusCode ?? HttpStatusCode.OK; + this._updateStatusCode = updateStatusCode ?? HttpStatusCode.OK; + this._deleteStatusCode = deleteStatusCode ?? HttpStatusCode.NoContent; + this._createStoreStatusCode = createStoreStatusCode ?? HttpStatusCode.Created; + this._getStoreStatusCode = getStoreStatusCode ?? HttpStatusCode.NotFound; + } + + public string? LastRequestUri { get; private set; } + public string? LastRequestBody { get; private set; } + public HttpMethod? LastRequestMethod { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequestUri = request.RequestUri?.ToString(); + this.LastRequestMethod = request.Method; + + if (request.Content != null) + { +#if NET472 + this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); +#else + this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#endif + } + + string path = request.RequestUri?.AbsolutePath ?? ""; + + // Route based on path and method + if (path.Contains("/memory-stores/") && path.Contains("/search") && request.Method == HttpMethod.Post) + { + return CreateResponse(this._searchStatusCode, this._searchMemoriesResponse); + } + + if (path.Contains("/memory-stores/") && path.Contains("/memories") && request.Method == HttpMethod.Post) + { + return CreateResponse(this._updateStatusCode, this._updateMemoriesResponse); + } + + if (path.Contains("/memory-stores/") && path.Contains("/scopes") && request.Method == HttpMethod.Delete) + { + return CreateResponse(this._deleteStatusCode, ""); + } + + if (path.Contains("/memory-stores") && request.Method == HttpMethod.Post) + { + return CreateResponse(this._createStoreStatusCode, """{"name":"test-store","status":"active"}"""); + } + + if (path.Contains("/memory-stores/") && request.Method == HttpMethod.Get) + { + return CreateResponse(this._getStoreStatusCode, """{"name":"test-store","status":"active"}"""); + } + + // Default response + return CreateResponse(HttpStatusCode.NotFound, "{}"); + } + + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string? content) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(content ?? "{}", Encoding.UTF8, "application/json") + }; + } +} + +/// +/// Mock token credential for testing. +/// +internal sealed class MockTokenCredential : TokenCredential +{ + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); + } +} + +/// +/// Source-generated JSON serializer context for unit test types. +/// +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(TestState))] +[JsonSerializable(typeof(TestScope))] +internal sealed partial class TestJsonContext : JsonSerializerContext +{ +} + +/// +/// Test state class for deserialization tests. +/// +internal sealed class TestState +{ + public TestScope? Scope { get; set; } +} + +/// +/// Test scope class for deserialization tests. +/// +internal sealed class TestScope +{ + public string? Scope { get; set; } +} From b3ac4777bad5326e828124a1f255afbacea5100d Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:00:21 +0000 Subject: [PATCH 2/6] Replace inline string literals with constants in ChatHistoryMemoryProvider (#4096) Extract 11 private const string fields for vector store property names (Key, Role, MessageId, AuthorName, ApplicationId, AgentId, UserId, SessionId, Content, CreatedAt, ContentEmbedding) and replace all inline usages across the collection definition, store dictionary, search result access, and filter expressions. Fixes #3801 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Memory/ChatHistoryMemoryProvider.cs | 66 +++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs index c6ca35951e..7905db74b8 100644 --- a/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Memory/ChatHistoryMemoryProvider.cs @@ -41,6 +41,18 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo private const string DefaultFunctionToolName = "Search"; private const string DefaultFunctionToolDescription = "Allows searching for related previous chat history to help answer the user question."; + private const string KeyField = "Key"; + private const string RoleField = "Role"; + private const string MessageIdField = "MessageId"; + private const string AuthorNameField = "AuthorName"; + private const string ApplicationIdField = "ApplicationId"; + private const string AgentIdField = "AgentId"; + private const string UserIdField = "UserId"; + private const string SessionIdField = "SessionId"; + private const string ContentField = "Content"; + private const string CreatedAtField = "CreatedAt"; + private const string ContentEmbeddingField = "ContentEmbedding"; + private readonly ProviderSessionState _sessionState; #pragma warning disable CA2213 // VectorStore is not owned by this class - caller is responsible for disposal @@ -98,17 +110,17 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo { Properties = [ - new VectorStoreKeyProperty("Key", typeof(Guid)), - new VectorStoreDataProperty("Role", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("MessageId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("AuthorName", typeof(string)), - new VectorStoreDataProperty("ApplicationId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("AgentId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("UserId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("SessionId", typeof(string)) { IsIndexed = true }, - new VectorStoreDataProperty("Content", typeof(string)) { IsFullTextIndexed = true }, - new VectorStoreDataProperty("CreatedAt", typeof(string)) { IsIndexed = true }, - new VectorStoreVectorProperty("ContentEmbedding", typeof(string), Throw.IfLessThan(vectorDimensions, 1)) + new VectorStoreKeyProperty(KeyField, typeof(Guid)), + new VectorStoreDataProperty(RoleField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(MessageIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(AuthorNameField, typeof(string)), + new VectorStoreDataProperty(ApplicationIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(AgentIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(UserIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(SessionIdField, typeof(string)) { IsIndexed = true }, + new VectorStoreDataProperty(ContentField, typeof(string)) { IsFullTextIndexed = true }, + new VectorStoreDataProperty(CreatedAtField, typeof(string)) { IsIndexed = true }, + new VectorStoreVectorProperty(ContentEmbeddingField, typeof(string), Throw.IfLessThan(vectorDimensions, 1)) ] }; @@ -233,17 +245,17 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo .Concat(context.ResponseMessages ?? []) .Select(message => new Dictionary { - ["Key"] = Guid.NewGuid(), - ["Role"] = message.Role.ToString(), - ["MessageId"] = message.MessageId, - ["AuthorName"] = message.AuthorName, - ["ApplicationId"] = storageScope.ApplicationId, - ["AgentId"] = storageScope.AgentId, - ["UserId"] = storageScope.UserId, - ["SessionId"] = storageScope.SessionId, - ["Content"] = message.Text, - ["CreatedAt"] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"), - ["ContentEmbedding"] = message.Text, + [KeyField] = Guid.NewGuid(), + [RoleField] = message.Role.ToString(), + [MessageIdField] = message.MessageId, + [AuthorNameField] = message.AuthorName, + [ApplicationIdField] = storageScope.ApplicationId, + [AgentIdField] = storageScope.AgentId, + [UserIdField] = storageScope.UserId, + [SessionIdField] = storageScope.SessionId, + [ContentField] = message.Text, + [CreatedAtField] = message.CreatedAt?.ToString("O") ?? DateTimeOffset.UtcNow.ToString("O"), + [ContentEmbeddingField] = message.Text, }) .ToList(); @@ -288,7 +300,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo } // Format the results as a single context message - var outputResultsText = string.Join("\n", results.Select(x => (string?)x["Content"]).Where(c => !string.IsNullOrWhiteSpace(c))); + var outputResultsText = string.Join("\n", results.Select(x => (string?)x[ContentField]).Where(c => !string.IsNullOrWhiteSpace(c))); if (string.IsNullOrWhiteSpace(outputResultsText)) { return string.Empty; @@ -340,12 +352,12 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo Expression, bool>>? filter = null; if (applicationId != null) { - filter = x => (string?)x["ApplicationId"] == applicationId; + filter = x => (string?)x[ApplicationIdField] == applicationId; } if (agentId != null) { - Expression, bool>> agentIdFilter = x => (string?)x["AgentId"] == agentId; + Expression, bool>> agentIdFilter = x => (string?)x[AgentIdField] == agentId; filter = filter == null ? agentIdFilter : Expression.Lambda, bool>>( Expression.AndAlso(filter.Body, agentIdFilter.Body), filter.Parameters); @@ -353,7 +365,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo if (userId != null) { - Expression, bool>> userIdFilter = x => (string?)x["UserId"] == userId; + Expression, bool>> userIdFilter = x => (string?)x[UserIdField] == userId; filter = filter == null ? userIdFilter : Expression.Lambda, bool>>( Expression.AndAlso(filter.Body, userIdFilter.Body), filter.Parameters); @@ -361,7 +373,7 @@ public sealed class ChatHistoryMemoryProvider : MessageAIContextProvider, IDispo if (sessionId != null) { - Expression, bool>> sessionIdFilter = x => (string?)x["SessionId"] == sessionId; + Expression, bool>> sessionIdFilter = x => (string?)x[SessionIdField] == sessionId; filter = filter == null ? sessionIdFilter : Expression.Lambda, bool>>( Expression.AndAlso(filter.Body, sessionIdFilter.Body), filter.Parameters); From 06c6ec052e622663362a1257d27f161fb9ce1753 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:15:26 +0000 Subject: [PATCH 3/6] .NET: Fix failing vision integration tests by using local test files (#4128) * Initial plan * Fix failing vision integration tests by using local test files Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Fix net472 build error: replace File.ReadAllBytesAsync with compatible helper using AppContext.BaseDirectory Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> * Simplify ReadLocalFile: return byte[] directly instead of Task Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com> --- .../MediaInputTest.cs | 35 ++++++++-------- ...kflows.Declarative.IntegrationTests.csproj | 3 ++ .../TestFiles/basic-text.pdf | 39 ++++++++++++++++++ .../TestFiles/test-image.jpg | Bin 0 -> 148 bytes 4 files changed, 59 insertions(+), 18 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/basic-text.pdf create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/test-image.jpg diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs index 5400628ba3..da30db6f98 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs @@ -2,7 +2,6 @@ using System; using System.IO; -using System.Net.Http; using System.Threading.Tasks; using Azure.AI.Projects; using Azure.Identity; @@ -21,12 +20,13 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o { private const string WorkflowWithConversationFileName = "MediaInputConversation.yaml"; private const string WorkflowWithAutoSendFileName = "MediaInputAutoSend.yaml"; - private const string ImageReference = "https://sample-files.com/downloads/images/jpg/web_optimized_1200x800_97kb.jpg"; - private const string PdfReference = "https://sample-files.com/downloads/documents/pdf/basic-text.pdf"; + private const string ImageReferenceUrl = "https://sample-files.com/downloads/images/jpg/web_optimized_1200x800_97kb.jpg"; + private const string PdfLocalFile = "TestFiles/basic-text.pdf"; + private const string ImageLocalFile = "TestFiles/test-image.jpg"; [Theory] - [InlineData(ImageReference, "image/jpeg", true)] - [InlineData(ImageReference, "image/jpeg", false)] + [InlineData(ImageReferenceUrl, "image/jpeg", true)] + [InlineData(ImageReferenceUrl, "image/jpeg", false)] public async Task ValidateFileUrlAsync(string fileSource, string mediaType, bool useConversation) { // Arrange @@ -39,12 +39,12 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o // Temporarily disabled [Theory] [Trait("Category", "IntegrationDisabled")] - [InlineData(ImageReference, "image/jpeg", true)] - [InlineData(ImageReference, "image/jpeg", false)] + [InlineData(ImageLocalFile, "image/jpeg", true)] + [InlineData(ImageLocalFile, "image/jpeg", false)] public async Task ValidateImageFileDataAsync(string fileSource, string mediaType, bool useConversation) { // Arrange - byte[] fileData = await DownloadFileAsync(fileSource); + byte[] fileData = ReadLocalFile(fileSource); string encodedData = Convert.ToBase64String(fileData); string fileUrl = $"data:{mediaType};base64,{encodedData}"; this.Output.WriteLine($"Content: {fileUrl.Substring(0, Math.Min(112, fileUrl.Length))}..."); @@ -54,12 +54,12 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o } [Theory] - [InlineData(PdfReference, "application/pdf", true)] - [InlineData(PdfReference, "application/pdf", false)] + [InlineData(PdfLocalFile, "application/pdf", true)] + [InlineData(PdfLocalFile, "application/pdf", false)] public async Task ValidateFileDataAsync(string fileSource, string mediaType, bool useConversation) { // Arrange - byte[] fileData = await DownloadFileAsync(fileSource); + byte[] fileData = ReadLocalFile(fileSource); string encodedData = Convert.ToBase64String(fileData); string fileUrl = $"data:{mediaType};base64,{encodedData}"; this.Output.WriteLine($"Content: {fileUrl.Substring(0, Math.Min(112, fileUrl.Length))}..."); @@ -71,12 +71,12 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o // Temporarily disabled [Theory] [Trait("Category", "IntegrationDisabled")] - [InlineData(PdfReference, "doc.pdf", true)] - [InlineData(PdfReference, "doc.pdf", false)] + [InlineData(PdfLocalFile, "doc.pdf", true)] + [InlineData(PdfLocalFile, "doc.pdf", false)] public async Task ValidateFileUploadAsync(string fileSource, string documentName, bool useConversation) { // Arrange - byte[] fileData = await DownloadFileAsync(fileSource); + byte[] fileData = ReadLocalFile(fileSource); AIProjectClient client = new(this.TestEndpoint, new AzureCliCredential()); using MemoryStream contentStream = new(fileData); OpenAIFileClient fileClient = client.GetProjectOpenAIClient().GetOpenAIFileClient(); @@ -94,11 +94,10 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o } } - private static async Task DownloadFileAsync(string uri) + private static byte[] ReadLocalFile(string relativePath) { - using HttpClient client = new(); - client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0"); - return await client.GetByteArrayAsync(new Uri(uri)); + string fullPath = Path.Combine(AppContext.BaseDirectory, relativePath); + return File.ReadAllBytes(fullPath); } private async Task ValidateFileAsync(AIContent fileContent, bool useConversation) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj index 985086a56e..309a590b83 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj @@ -36,6 +36,9 @@ Always + + Always + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/basic-text.pdf b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/basic-text.pdf new file mode 100644 index 0000000000..a4fb8a4509 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/basic-text.pdf @@ -0,0 +1,39 @@ +%PDF-1.4 +%âãÏÓ +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj + +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj + +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj + +4 0 obj +<< /Length 44 >> +stream +BT /F1 12 Tf 100 700 Td (Hello World) Tj ET + +endstream +endobj + +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj + +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000065 00000 n +0000000123 00000 n +0000000250 00000 n +0000000345 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +416 +%%EOF diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/test-image.jpg b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/TestFiles/test-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8bb5fc31c4b221a3f69ad88d93e0aefd22247747 GIT binary patch literal 148 zcmex=)Zf(-wU mFvtT9X9aSAfB^~^nV4Bv+1NQaxw!w|V&DKt*fae2e-i*tgC2MQ literal 0 HcmV?d00001 From 44aec2009f01f5075653623acc79fd8d296b19c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:32:25 -0800 Subject: [PATCH 4/6] Bump flask from 3.1.2 to 3.1.3 in /python (#4126) Bumps [flask](https://github.com/pallets/flask) from 3.1.2 to 3.1.3. - [Release notes](https://github.com/pallets/flask/releases) - [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/flask/compare/3.1.2...3.1.3) --- updated-dependencies: - dependency-name: flask dependency-version: 3.1.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 8f65b2b4c6..9189368833 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1962,7 +1962,7 @@ wheels = [ [[package]] name = "flask" -version = "3.1.2" +version = "3.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "blinker", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1972,9 +1972,9 @@ dependencies = [ { name = "markupsafe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "werkzeug", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] [[package]] From 7ba636d6429c047cdf45016e5ff1016adceb7a2d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 20 Feb 2026 21:05:56 +0000 Subject: [PATCH 5/6] .NET: Support Agent Skills (#4122) * support agent skills * make the new agent skill provider experimental * Fix file encoding: add UTF-8 BOM to .cs files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix final newline and simplify new expressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix broken links in Agent Skills sample README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add null check for skillPaths parameter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Normalize references * normilize skill path * address comments regarding symlink check * address comments * fix failing test + regex improvements * small optimizations and improvments * address pr review comments * Update dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> * address pr review comments * address pr review comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 4 + .../Agent_Step01_BasicSkills.csproj | 28 + .../Agent_Step01_BasicSkills/Program.cs | 49 ++ .../Agent_Step01_BasicSkills/README.md | 63 ++ .../skills/expense-report/SKILL.md | 40 ++ .../assets/expense-report-template.md | 5 + .../expense-report/references/POLICY_FAQ.md | 55 ++ .../GettingStarted/AgentSkills/README.md | 7 + dotnet/samples/GettingStarted/README.md | 1 + .../Microsoft.Agents.AI.csproj | 4 +- .../Skills/FileAgentSkill.cs | 56 ++ .../Skills/FileAgentSkillLoader.cs | 407 +++++++++++++ .../Skills/FileAgentSkillsProvider.cs | 213 +++++++ .../Skills/FileAgentSkillsProviderOptions.cs | 20 + .../Skills/SkillFrontmatter.cs | 32 + .../AgentSkills/FileAgentSkillLoaderTests.cs | 561 ++++++++++++++++++ .../FileAgentSkillsProviderTests.cs | 228 +++++++ .../Microsoft.Agents.AI.UnitTests.csproj | 4 + 18 files changed, 1776 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md create mode 100644 dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md create mode 100644 dotnet/samples/GettingStarted/AgentSkills/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index f01cd409d6..adc941d582 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -96,6 +96,10 @@ + + + + diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj new file mode 100644 index 0000000000..2a503bbfb2 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Agent_Step01_BasicSkills.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + + enable + enable + $(NoWarn);MAAI001 + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs new file mode 100644 index 0000000000..290c3f9b6b --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/Program.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to use Agent Skills with a ChatClientAgent. +// Agent Skills are modular packages of instructions and resources that extend an agent's capabilities. +// Skills follow the progressive disclosure pattern: advertise -> load -> read resources. +// +// This sample includes the expense-report skill: +// - Policy-based expense filing with references and assets + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +// --- Configuration --- +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +// --- Skills Provider --- +// Discovers skills from the 'skills' directory and makes them available to the agent +var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppContext.BaseDirectory, "skills")); + +// --- Agent Setup --- +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetResponsesClient(deploymentName) + .AsAIAgent(new ChatClientAgentOptions + { + Name = "SkillsAgent", + ChatOptions = new() + { + Instructions = "You are a helpful assistant.", + }, + AIContextProviders = [skillsProvider], + }); + +// --- Example 1: Expense policy question (loads FAQ resource) --- +Console.WriteLine("Example 1: Checking expense policy FAQ"); +Console.WriteLine("---------------------------------------"); +AgentResponse response1 = await agent.RunAsync("Are tips reimbursable? I left a 25% tip on a taxi ride and want to know if that's covered."); +Console.WriteLine($"Agent: {response1.Text}\n"); + +// --- Example 2: Filing an expense report (multi-turn with template asset) --- +Console.WriteLine("Example 2: Filing an expense report"); +Console.WriteLine("---------------------------------------"); +AgentSession session = await agent.CreateSessionAsync(); +AgentResponse response2 = await agent.RunAsync("I had 3 client dinners and a $1,200 flight last week. Return a draft expense report and ask about any missing details.", + session); +Console.WriteLine($"Agent: {response2.Text}\n"); diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md new file mode 100644 index 0000000000..78099fa8a5 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/README.md @@ -0,0 +1,63 @@ +# Agent Skills Sample + +This sample demonstrates how to use **Agent Skills** with a `ChatClientAgent` in the Microsoft Agent Framework. + +## What are Agent Skills? + +Agent Skills are modular packages of instructions and resources that enable AI agents to perform specialized tasks. They follow the [Agent Skills specification](https://agentskills.io/) and implement the progressive disclosure pattern: + +1. **Advertise**: Skills are advertised with name + description (~100 tokens per skill) +2. **Load**: Full instructions are loaded on-demand via `load_skill` tool +3. **Resources**: References and other files loaded via `read_skill_resource` tool + +## Skills Included + +### expense-report +Policy-based expense filing with spending limits, receipt requirements, and approval workflows. +- `references/POLICY_FAQ.md` — Detailed expense policy Q&A +- `assets/expense-report-template.md` — Submission template + +## Project Structure + +``` +Agent_Step01_BasicSkills/ +├── Program.cs +├── Agent_Step01_BasicSkills.csproj +└── skills/ + └── expense-report/ + ├── SKILL.md + ├── references/ + │ └── POLICY_FAQ.md + └── assets/ + └── expense-report-template.md +``` + +## Running the Sample + +### Prerequisites +- .NET 10.0 SDK +- Azure OpenAI endpoint with a deployed model + +### Setup +1. Set environment variables: + ```bash + export AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/" + export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" + ``` + +2. Run the sample: + ```bash + dotnet run + ``` + +### Examples + +The sample runs two examples: + +1. **Expense policy FAQ** — Asks about tip reimbursement; the agent loads the expense-report skill and reads the FAQ resource +2. **Filing an expense report** — Multi-turn conversation to draft an expense report using the template asset + +## Learn More + +- [Agent Skills Specification](https://agentskills.io/) +- [Microsoft Agent Framework Documentation](../../../../../docs/) diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md new file mode 100644 index 0000000000..fc6c83cf30 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/SKILL.md @@ -0,0 +1,40 @@ +--- +name: expense-report +description: File and validate employee expense reports according to Contoso company policy. Use when asked about expense submissions, reimbursement rules, receipt requirements, spending limits, or expense categories. +metadata: + author: contoso-finance + version: "2.1" +--- + +# Expense Report + +## Categories and Limits + +| Category | Limit | Receipt | Approval | +|---|---|---|---| +| Meals — solo | $50/day | >$25 | No | +| Meals — team/client | $75/person | Always | Manager if >$200 total | +| Lodging | $250/night | Always | Manager if >3 nights | +| Ground transport | $100/day | >$15 | No | +| Airfare | Economy | Always | Manager; VP if >$1,500 | +| Conference/training | $2,000/event | Always | Manager + L&D | +| Office supplies | $100 | Yes | No | +| Software/subscriptions | $50/month | Yes | Manager if >$200/year | + +## Filing Process + +1. Collect receipts — must show vendor, date, amount, payment method. +2. Categorize per table above. +3. Use template: [assets/expense-report-template.md](assets/expense-report-template.md). +4. For client/team meals: list attendee names and business purpose. +5. Submit — auto-approved if <$500; manager if $500–$2,000; VP if >$2,000. +6. Reimbursement: 10 business days via direct deposit. + +## Policy Rules + +- Submit within 30 days of transaction. +- Alcohol is never reimbursable. +- Foreign currency: convert to USD at transaction-date rate; note original currency and amount. +- Mixed personal/business travel: only business portion reimbursable; provide comparison quotes. +- Lost receipts (>$25): file Lost Receipt Affidavit from Finance. Max 2 per quarter. +- For policy questions not covered above, consult the FAQ: [references/POLICY_FAQ.md](references/POLICY_FAQ.md). Answers should be based on what this document and the FAQ state. diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md new file mode 100644 index 0000000000..3f7c7dc36c --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/assets/expense-report-template.md @@ -0,0 +1,5 @@ +# Expense Report Template + +| Date | Category | Vendor | Description | Amount (USD) | Original Currency | Original Amount | Attendees | Business Purpose | Receipt Attached | +|------|----------|--------|-------------|--------------|-------------------|-----------------|-----------|------------------|------------------| +| | | | | | | | | | Yes or No | diff --git a/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md new file mode 100644 index 0000000000..8e971192f8 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/Agent_Step01_BasicSkills/skills/expense-report/references/POLICY_FAQ.md @@ -0,0 +1,55 @@ +# Expense Policy — Frequently Asked Questions + +## Meals + +**Q: Can I expense coffee or snacks during the workday?** +A: Daily coffee/snacks under $10 are not reimbursable (considered personal). Coffee purchased during a client meeting or team working session is reimbursable as a team meal. + +**Q: What if a team dinner exceeds the per-person limit?** +A: The $75/person limit applies as a guideline. Overages up to 20% are accepted with a written justification (e.g., "client dinner at venue chosen by client"). Overages beyond 20% require pre-approval from your VP. + +**Q: Do I need to list every attendee?** +A: Yes. For client meals, list the client's name and company. For team meals, list all employee names. For groups over 10, you may attach a separate attendee list. + +## Travel + +**Q: Can I book a premium economy or business class flight?** +A: Economy class is the standard. Premium economy is allowed for flights over 6 hours. Business class requires VP pre-approval and is generally reserved for flights over 10 hours or medical accommodation. + +**Q: What about ride-sharing (Uber/Lyft) vs. rental cars?** +A: Use ride-sharing for trips under 30 miles round-trip. Rent a car for multi-day travel or when ride-sharing would exceed $100/day. Always choose the compact/standard category unless traveling with 3+ people. + +**Q: Are tips reimbursable?** +A: Tips up to 20% are reimbursable for meals, taxi/ride-share, and hotel housekeeping. Tips above 20% require justification. + +## Lodging + +**Q: What if the $250/night limit isn't enough for the city I'm visiting?** +A: For high-cost cities (New York, San Francisco, London, Tokyo, Sydney), the limit is automatically increased to $350/night. No additional approval is needed. For other locations where rates are unusually high (e.g., during a major conference), request a per-trip exception from your manager before booking. + +**Q: Can I stay with friends/family instead and get a per-diem?** +A: No. Contoso reimburses actual lodging costs only, not per-diems. + +## Subscriptions and Software + +**Q: Can I expense a personal productivity tool?** +A: Software must be directly related to your job function. Tools like IDE licenses, design software, or project management apps are reimbursable. General productivity apps (note-taking, personal calendar) are not, unless your manager confirms a business need in writing. + +**Q: What about annual subscriptions?** +A: Annual subscriptions over $200 require manager approval before purchase. Submit the approval email with your expense report. + +## Receipts and Documentation + +**Q: My receipt is faded/damaged. What do I do?** +A: Try to obtain a duplicate from the vendor. If not possible, submit a Lost Receipt Affidavit (available from the Finance SharePoint site). You're limited to 2 affidavits per quarter. + +**Q: Do I need a receipt for parking meters or tolls?** +A: For amounts under $15, no receipt is required — just note the date, location, and amount. For $15 and above, a receipt or bank/credit card statement excerpt is required. + +## Approval and Reimbursement + +**Q: My manager is on leave. Who approves my report?** +A: Expense reports can be approved by your skip-level manager or any manager designated as an alternate approver in the expense system. + +**Q: Can I submit expenses from a previous quarter?** +A: The standard 30-day window applies. Expenses older than 30 days require a written explanation and VP approval. Expenses older than 90 days are not reimbursable except in extraordinary circumstances (extended leave, medical emergency) with CFO approval. diff --git a/dotnet/samples/GettingStarted/AgentSkills/README.md b/dotnet/samples/GettingStarted/AgentSkills/README.md new file mode 100644 index 0000000000..8488ec9eed --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentSkills/README.md @@ -0,0 +1,7 @@ +# AgentSkills Samples + +Samples demonstrating Agent Skills capabilities. + +| Sample | Description | +|--------|-------------| +| [Agent_Step01_BasicSkills](Agent_Step01_BasicSkills/) | Using Agent Skills with a ChatClientAgent, including progressive disclosure and skill resources | diff --git a/dotnet/samples/GettingStarted/README.md b/dotnet/samples/GettingStarted/README.md index 7a46d81a62..6fe68fc94f 100644 --- a/dotnet/samples/GettingStarted/README.md +++ b/dotnet/samples/GettingStarted/README.md @@ -18,3 +18,4 @@ of the agent framework. |[Agent With Anthropic](./AgentWithAnthropic/README.md)|Getting started with agents using Anthropic Claude| |[Workflow](./Workflows/README.md)|Getting started with Workflow| |[Model Context Protocol](./ModelContextProtocol/README.md)|Getting started with Model Context Protocol| +|[Agent Skills](./AgentSkills/README.md)|Getting started with Agent Skills| diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj index a994afe75c..f036812900 100644 --- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj +++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj @@ -2,12 +2,14 @@ true - $(NoWarn);MEAI001 + $(NoWarn);MEAI001;MAAI001 true + true true + true true true diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs new file mode 100644 index 0000000000..f28bad3ab0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkill.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Represents a loaded Agent Skill discovered from a filesystem directory. +/// +/// +/// Each skill is backed by a SKILL.md file containing YAML frontmatter (name and description) +/// and a markdown body with instructions. Resource files referenced in the body are validated at +/// discovery time and read from disk on demand. +/// +internal sealed class FileAgentSkill +{ + /// + /// Initializes a new instance of the class. + /// + /// Parsed YAML frontmatter (name and description). + /// The SKILL.md content after the closing --- delimiter. + /// Absolute path to the directory containing this skill. + /// Relative paths of resource files referenced in the skill body. + public FileAgentSkill( + SkillFrontmatter frontmatter, + string body, + string sourcePath, + IReadOnlyList? resourceNames = null) + { + this.Frontmatter = Throw.IfNull(frontmatter); + this.Body = Throw.IfNull(body); + this.SourcePath = Throw.IfNullOrWhitespace(sourcePath); + this.ResourceNames = resourceNames ?? []; + } + + /// + /// Gets the parsed YAML frontmatter (name and description). + /// + public SkillFrontmatter Frontmatter { get; } + + /// + /// Gets the SKILL.md body content (without the YAML frontmatter). + /// + public string Body { get; } + + /// + /// Gets the directory path where the skill was discovered. + /// + public string SourcePath { get; } + + /// + /// Gets the relative paths of resource files referenced in the skill body (e.g., "references/FAQ.md"). + /// + public IReadOnlyList ResourceNames { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs new file mode 100644 index 0000000000..8c034b3122 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillLoader.cs @@ -0,0 +1,407 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI; + +/// +/// Discovers, parses, and validates SKILL.md files from filesystem directories. +/// +/// +/// Searches directories recursively (up to levels) for SKILL.md files. +/// Each file is validated for YAML frontmatter and resource integrity. Invalid skills are excluded +/// with logged warnings. Resource paths are checked against path traversal and symlink escape attacks. +/// +internal sealed partial class FileAgentSkillLoader +{ + private const string SkillFileName = "SKILL.md"; + private const int MaxSearchDepth = 2; + private const int MaxNameLength = 64; + private const int MaxDescriptionLength = 1024; + + // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. + // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. + // The \uFEFF? prefix allows an optional UTF-8 BOM that some editors prepend. + // Example: "---\nname: foo\n---\nBody" → Group 1: "name: foo\n" + private static readonly Regex s_frontmatterRegex = new(@"\A\uFEFF?^---\s*$(.+?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + // Matches markdown links to local resource files. Group 1 = relative file path. + // Supports optional ./ or ../ prefixes; excludes URLs (no ":" in the path character class). + // Intentionally conservative: only matches paths with word characters, hyphens, dots, + // and forward slashes. Paths with spaces or special characters are not supported. + // Examples: [doc](refs/FAQ.md) → "refs/FAQ.md", [s](./s.json) → "./s.json", + // [p](../shared/doc.txt) → "../shared/doc.txt" + private static readonly Regex s_resourceLinkRegex = new(@"\[.*?\]\((\.?\.?/?[\w][\w\-./]*\.\w+)\)", RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + // Matches YAML "key: value" lines. Group 1 = key, Group 2 = quoted value, Group 3 = unquoted value. + // Accepts single or double quotes; the lazy quantifier trims trailing whitespace on unquoted values. + // Examples: "name: foo" → (name, _, foo), "name: 'foo bar'" → (name, foo bar, _), + // "description: \"A skill\"" → (description, A skill, _) + private static readonly Regex s_yamlKeyValueRegex = new(@"^\s*(\w+)\s*:\s*(?:[""'](.+?)[""']|(.+?))\s*$", RegexOptions.Multiline | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + + // Validates skill names: lowercase letters, numbers, and hyphens only; must not start or end with a hyphen. + // Examples: "my-skill" ✓, "skill123" ✓, "-bad" ✗, "bad-" ✗, "Bad" ✗ + private static readonly Regex s_validNameRegex = new(@"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$", RegexOptions.Compiled); + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + internal FileAgentSkillLoader(ILogger logger) + { + this._logger = logger; + } + + /// + /// Discovers skill directories and loads valid skills from them. + /// + /// Paths to search for skills. Each path can point to an individual skill folder or a parent folder. + /// A dictionary of loaded skills keyed by skill name. + internal Dictionary DiscoverAndLoadSkills(IEnumerable skillPaths) + { + var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var discoveredPaths = DiscoverSkillDirectories(skillPaths); + + LogSkillsDiscovered(this._logger, discoveredPaths.Count); + + foreach (string skillPath in discoveredPaths) + { + FileAgentSkill? skill = this.ParseSkillFile(skillPath); + if (skill is null) + { + continue; + } + + if (skills.TryGetValue(skill.Frontmatter.Name, out FileAgentSkill? existing)) + { + LogDuplicateSkillName(this._logger, skill.Frontmatter.Name, skillPath, existing.SourcePath); + + // Skip duplicate skill names, keeping the first one found. + continue; + } + + skills[skill.Frontmatter.Name] = skill; + + LogSkillLoaded(this._logger, skill.Frontmatter.Name); + } + + LogSkillsLoadedTotal(this._logger, skills.Count); + + return skills; + } + + /// + /// Reads a resource file from disk with path traversal and symlink guards. + /// + /// The skill that owns the resource. + /// Relative path of the resource within the skill directory. + /// Cancellation token. + /// The UTF-8 text content of the resource file. + /// + /// The resource is not registered, resolves outside the skill directory, or does not exist. + /// + internal async Task ReadSkillResourceAsync(FileAgentSkill skill, string resourceName, CancellationToken cancellationToken = default) + { + resourceName = NormalizeResourcePath(resourceName); + + if (!skill.ResourceNames.Any(r => r.Equals(resourceName, StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException($"Resource '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); + } + + string fullPath = Path.GetFullPath(Path.Combine(skill.SourcePath, resourceName)); + string normalizedSourcePath = Path.GetFullPath(skill.SourcePath) + Path.DirectorySeparatorChar; + + if (!IsPathWithinDirectory(fullPath, normalizedSourcePath)) + { + throw new InvalidOperationException($"Resource file '{resourceName}' references a path outside the skill directory."); + } + + if (!File.Exists(fullPath)) + { + throw new InvalidOperationException($"Resource file '{resourceName}' not found in skill '{skill.Frontmatter.Name}'."); + } + + if (HasSymlinkInPath(fullPath, normalizedSourcePath)) + { + throw new InvalidOperationException($"Resource file '{resourceName}' is a symlink that resolves outside the skill directory."); + } + + LogResourceReading(this._logger, resourceName, skill.Frontmatter.Name); + +#if NET + return await File.ReadAllTextAsync(fullPath, Encoding.UTF8, cancellationToken).ConfigureAwait(false); +#else + return await Task.FromResult(File.ReadAllText(fullPath, Encoding.UTF8)).ConfigureAwait(false); +#endif + } + + private static List DiscoverSkillDirectories(IEnumerable skillPaths) + { + var discoveredPaths = new List(); + + foreach (string rootDirectory in skillPaths) + { + if (string.IsNullOrWhiteSpace(rootDirectory) || !Directory.Exists(rootDirectory)) + { + continue; + } + + SearchDirectoriesForSkills(rootDirectory, discoveredPaths, currentDepth: 0); + } + + return discoveredPaths; + } + + private static void SearchDirectoriesForSkills(string directory, List results, int currentDepth) + { + string skillFilePath = Path.Combine(directory, SkillFileName); + if (File.Exists(skillFilePath)) + { + results.Add(Path.GetFullPath(directory)); + } + + if (currentDepth >= MaxSearchDepth) + { + return; + } + + foreach (string subdirectory in Directory.EnumerateDirectories(directory)) + { + SearchDirectoriesForSkills(subdirectory, results, currentDepth + 1); + } + } + + private FileAgentSkill? ParseSkillFile(string skillDirectoryPath) + { + string skillFilePath = Path.Combine(skillDirectoryPath, SkillFileName); + + string content = File.ReadAllText(skillFilePath, Encoding.UTF8); + + if (!this.TryParseSkillDocument(content, skillFilePath, out SkillFrontmatter frontmatter, out string body)) + { + return null; + } + + List resourceNames = ExtractResourcePaths(body); + + if (!this.ValidateResources(skillDirectoryPath, resourceNames, frontmatter.Name)) + { + return null; + } + + return new FileAgentSkill( + frontmatter: frontmatter, + body: body, + sourcePath: skillDirectoryPath, + resourceNames: resourceNames); + } + + private bool TryParseSkillDocument(string content, string skillFilePath, out SkillFrontmatter frontmatter, out string body) + { + frontmatter = null!; + body = null!; + + Match match = s_frontmatterRegex.Match(content); + if (!match.Success) + { + LogInvalidFrontmatter(this._logger, skillFilePath); + return false; + } + + string? name = null; + string? description = null; + + string yamlContent = match.Groups[1].Value.Trim(); + + foreach (Match kvMatch in s_yamlKeyValueRegex.Matches(yamlContent)) + { + string key = kvMatch.Groups[1].Value; + string value = kvMatch.Groups[2].Success ? kvMatch.Groups[2].Value : kvMatch.Groups[3].Value; + + if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase)) + { + name = value; + } + else if (string.Equals(key, "description", StringComparison.OrdinalIgnoreCase)) + { + description = value; + } + } + + if (string.IsNullOrWhiteSpace(name)) + { + LogMissingFrontmatterField(this._logger, skillFilePath, "name"); + return false; + } + + if (name.Length > MaxNameLength || !s_validNameRegex.IsMatch(name)) + { + LogInvalidFieldValue(this._logger, skillFilePath, "name", $"Must be {MaxNameLength} characters or fewer, using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen."); + return false; + } + + if (string.IsNullOrWhiteSpace(description)) + { + LogMissingFrontmatterField(this._logger, skillFilePath, "description"); + return false; + } + + if (description.Length > MaxDescriptionLength) + { + LogInvalidFieldValue(this._logger, skillFilePath, "description", $"Must be {MaxDescriptionLength} characters or fewer."); + return false; + } + + frontmatter = new SkillFrontmatter(name, description); + body = content.Substring(match.Index + match.Length).TrimStart(); + + return true; + } + + private bool ValidateResources(string skillDirectoryPath, List resourceNames, string skillName) + { + string normalizedSkillPath = Path.GetFullPath(skillDirectoryPath) + Path.DirectorySeparatorChar; + + foreach (string resourceName in resourceNames) + { + string fullPath = Path.GetFullPath(Path.Combine(skillDirectoryPath, resourceName)); + + if (!IsPathWithinDirectory(fullPath, normalizedSkillPath)) + { + LogResourcePathTraversal(this._logger, skillName, resourceName); + return false; + } + + if (!File.Exists(fullPath)) + { + LogMissingResource(this._logger, skillName, resourceName); + return false; + } + + if (HasSymlinkInPath(fullPath, normalizedSkillPath)) + { + LogResourceSymlinkEscape(this._logger, skillName, resourceName); + return false; + } + } + + return true; + } + + /// + /// Checks that is under , + /// guarding against path traversal attacks. + /// + private static bool IsPathWithinDirectory(string fullPath, string normalizedDirectoryPath) + { + return fullPath.StartsWith(normalizedDirectoryPath, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Checks whether any segment in (relative to + /// ) is a symlink (reparse point). + /// Uses which is available on all target frameworks. + /// + private static bool HasSymlinkInPath(string fullPath, string normalizedDirectoryPath) + { + string relativePath = fullPath.Substring(normalizedDirectoryPath.Length); + string[] segments = relativePath.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + + string currentPath = normalizedDirectoryPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + + if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0) + { + return true; + } + } + + return false; + } + + private static List ExtractResourcePaths(string content) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var paths = new List(); + foreach (Match m in s_resourceLinkRegex.Matches(content)) + { + string path = NormalizeResourcePath(m.Groups[1].Value); + if (seen.Add(path)) + { + paths.Add(path); + } + } + + return paths; + } + + /// + /// Normalizes a relative resource path by trimming a leading ./ prefix and replacing + /// backslashes with forward slashes so that ./refs/doc.md and refs/doc.md are + /// treated as the same resource. + /// + private static string NormalizeResourcePath(string path) + { + if (path.IndexOf('\\') >= 0) + { + path = path.Replace('\\', '/'); + } + + if (path.StartsWith("./", StringComparison.Ordinal)) + { + path = path.Substring(2); + } + + return path; + } + + [LoggerMessage(LogLevel.Information, "Discovered {Count} potential skills")] + private static partial void LogSkillsDiscovered(ILogger logger, int count); + + [LoggerMessage(LogLevel.Information, "Loaded skill: {SkillName}")] + private static partial void LogSkillLoaded(ILogger logger, string skillName); + + [LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills")] + private static partial void LogSkillsLoadedTotal(ILogger logger, int count); + + [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' does not contain valid YAML frontmatter delimited by '---'")] + private static partial void LogInvalidFrontmatter(ILogger logger, string skillFilePath); + + [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' is missing a '{FieldName}' field in frontmatter")] + private static partial void LogMissingFrontmatterField(ILogger logger, string skillFilePath, string fieldName); + + [LoggerMessage(LogLevel.Error, "SKILL.md at '{SkillFilePath}' has an invalid '{FieldName}' value: {Reason}")] + private static partial void LogInvalidFieldValue(ILogger logger, string skillFilePath, string fieldName, string reason); + + [LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': referenced resource '{ResourceName}' does not exist")] + private static partial void LogMissingResource(ILogger logger, string skillName, string resourceName); + + [LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' references a path outside the skill directory")] + private static partial void LogResourcePathTraversal(ILogger logger, string skillName, string resourceName); + + [LoggerMessage(LogLevel.Warning, "Duplicate skill name '{SkillName}': skill from '{NewPath}' skipped in favor of existing skill from '{ExistingPath}'")] + private static partial void LogDuplicateSkillName(ILogger logger, string skillName, string newPath, string existingPath); + + [LoggerMessage(LogLevel.Warning, "Excluding skill '{SkillName}': resource '{ResourceName}' is a symlink that resolves outside the skill directory")] + private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourceName); + + [LoggerMessage(LogLevel.Information, "Reading resource '{FileName}' from skill '{SkillName}'")] + private static partial void LogResourceReading(ILogger logger, string fileName, string skillName); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs new file mode 100644 index 0000000000..847bf36a52 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProvider.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// An that discovers and exposes Agent Skills from filesystem directories. +/// +/// +/// +/// This provider implements the progressive disclosure pattern from the +/// Agent Skills specification: +/// +/// +/// Advertise — skill names and descriptions are injected into the system prompt (~100 tokens per skill). +/// Load — the full SKILL.md body is returned via the load_skill tool. +/// Read resources — supplementary files are read from disk on demand via the read_skill_resource tool. +/// +/// +/// Skills are discovered by searching the configured directories for SKILL.md files. +/// Referenced resources are validated at initialization; invalid skills are excluded and logged. +/// +/// +/// Security: this provider only reads static content. Skill metadata is XML-escaped +/// before prompt embedding, and resource reads are guarded against path traversal and symlink escape. +/// Only use skills from trusted sources. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed partial class FileAgentSkillsProvider : AIContextProvider +{ + private const string DefaultSkillsInstructionPrompt = + """ + You have access to skills containing domain-specific knowledge and capabilities. + Each skill provides specialized instructions, reference documents, and assets for specific tasks. + + + {0} + + + When a task aligns with a skill's domain: + 1. Use `load_skill` to retrieve the skill's instructions + 2. Follow the provided guidance + 3. Use `read_skill_resource` to read any references or other files mentioned by the skill + + Only load what is needed, when it is needed. + """; + + private readonly Dictionary _skills; + private readonly ILogger _logger; + private readonly FileAgentSkillLoader _loader; + private readonly AITool[] _tools; + private readonly string? _skillsInstructionPrompt; + + /// + /// Initializes a new instance of the class that searches a single directory for skills. + /// + /// Path to an individual skill folder (containing a SKILL.md file) or a parent folder with skill subdirectories. + /// Optional configuration for prompt customization. + /// Optional logger factory. + public FileAgentSkillsProvider(string skillPath, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + : this([skillPath], options, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class that searches multiple directories for skills. + /// + /// Paths to search. Each can be an individual skill folder or a parent folder with skill subdirectories. + /// Optional configuration for prompt customization. + /// Optional logger factory. + public FileAgentSkillsProvider(IEnumerable skillPaths, FileAgentSkillsProviderOptions? options = null, ILoggerFactory? loggerFactory = null) + { + _ = Throw.IfNull(skillPaths); + + this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); + + this._loader = new FileAgentSkillLoader(this._logger); + this._skills = this._loader.DiscoverAndLoadSkills(skillPaths); + + this._skillsInstructionPrompt = BuildSkillsInstructionPrompt(options, this._skills); + + this._tools = + [ + AIFunctionFactory.Create( + this.LoadSkill, + name: "load_skill", + description: "Loads the full instructions for a specific skill."), + AIFunctionFactory.Create( + this.ReadSkillResourceAsync, + name: "read_skill_resource", + description: "Reads a file associated with a skill, such as references or assets."), + ]; + } + + /// + protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + if (this._skills.Count == 0) + { + return base.ProvideAIContextAsync(context, cancellationToken); + } + + return new ValueTask(new AIContext + { + Instructions = this._skillsInstructionPrompt, + Tools = this._tools + }); + } + + private string LoadSkill(string skillName) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) + { + return $"Error: Skill '{skillName}' not found."; + } + + LogSkillLoading(this._logger, skillName); + + return skill.Body; + } + + private async Task ReadSkillResourceAsync(string skillName, string resourceName, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(skillName)) + { + return "Error: Skill name cannot be empty."; + } + + if (string.IsNullOrWhiteSpace(resourceName)) + { + return "Error: Resource name cannot be empty."; + } + + if (!this._skills.TryGetValue(skillName, out FileAgentSkill? skill)) + { + return $"Error: Skill '{skillName}' not found."; + } + + try + { + return await this._loader.ReadSkillResourceAsync(skill, resourceName, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogResourceReadError(this._logger, skillName, resourceName, ex); + return $"Error: Failed to read resource '{resourceName}' from skill '{skillName}'."; + } + } + + private static string? BuildSkillsInstructionPrompt(FileAgentSkillsProviderOptions? options, Dictionary skills) + { + string promptTemplate = DefaultSkillsInstructionPrompt; + + if (options?.SkillsInstructionPrompt is { } optionsInstructions) + { + try + { + promptTemplate = string.Format(optionsInstructions, string.Empty); + } + catch (FormatException ex) + { + throw new ArgumentException( + "The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').", + nameof(options), + ex); + } + } + + if (skills.Count == 0) + { + return null; + } + + var sb = new StringBuilder(); + + // Order by name for deterministic prompt output across process restarts + // (Dictionary enumeration order is not guaranteed and varies with hash randomization). + foreach (var skill in skills.Values.OrderBy(s => s.Frontmatter.Name, StringComparer.Ordinal)) + { + sb.AppendLine(" "); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Name)}"); + sb.AppendLine($" {SecurityElement.Escape(skill.Frontmatter.Description)}"); + sb.AppendLine(" "); + } + + return string.Format(promptTemplate, sb.ToString().TrimEnd()); + } + + [LoggerMessage(LogLevel.Information, "Loading skill: {SkillName}")] + private static partial void LogSkillLoading(ILogger logger, string skillName); + + [LoggerMessage(LogLevel.Error, "Failed to read resource '{ResourceName}' from skill '{SkillName}'")] + private static partial void LogResourceReadError(ILogger logger, string skillName, string resourceName, Exception exception); +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs new file mode 100644 index 0000000000..a47841c260 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/FileAgentSkillsProviderOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Configuration options for . +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class FileAgentSkillsProviderOptions +{ + /// + /// Gets or sets a custom system prompt template for advertising skills. + /// Use {0} as the placeholder for the generated skills list. + /// When , a default template is used. + /// + public string? SkillsInstructionPrompt { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs new file mode 100644 index 0000000000..123a6c43f4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/SkillFrontmatter.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Parsed YAML frontmatter from a SKILL.md file, containing the skill's name and description. +/// +internal sealed class SkillFrontmatter +{ + /// + /// Initializes a new instance of the class. + /// + /// Skill name. + /// Skill description. + public SkillFrontmatter(string name, string description) + { + this.Name = Throw.IfNullOrWhitespace(name); + this.Description = Throw.IfNullOrWhitespace(description); + } + + /// + /// Gets the skill name. Lowercase letters, numbers, and hyphens only. + /// + public string Name { get; } + + /// + /// Gets the skill description. Used for discovery in the system prompt. + /// + public string Description { get; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs new file mode 100644 index 0000000000..c34eb6d7f2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -0,0 +1,561 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for the class. +/// +public sealed class FileAgentSkillLoaderTests : IDisposable +{ + private static readonly string[] s_traversalResource = new[] { "../secret.txt" }; + + private readonly string _testRoot; + private readonly FileAgentSkillLoader _loader; + + public FileAgentSkillLoaderTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "agent-skills-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + this._loader = new FileAgentSkillLoader(NullLogger.Instance); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public void DiscoverAndLoadSkills_ValidSkill_ReturnsSkill() + { + // Arrange + _ = this.CreateSkillDirectory("my-skill", "A test skill", "Use this skill to do things."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.True(skills.ContainsKey("my-skill")); + Assert.Equal("A test skill", skills["my-skill"].Frontmatter.Description); + Assert.Equal("Use this skill to do things.", skills["my-skill"].Body); + } + + [Fact] + public void DiscoverAndLoadSkills_QuotedFrontmatterValues_ParsesCorrectly() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "quoted-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: 'quoted-skill'\ndescription: \"A quoted description\"\n---\nBody text."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.Equal("quoted-skill", skills["quoted-skill"].Frontmatter.Name); + Assert.Equal("A quoted description", skills["quoted-skill"].Frontmatter.Description); + } + + [Fact] + public void DiscoverAndLoadSkills_MissingFrontmatter_ExcludesSkill() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "bad-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), "No frontmatter here."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_MissingNameField_ExcludesSkill() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "no-name"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\ndescription: A skill without a name\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_MissingDescriptionField_ExcludesSkill() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "no-desc"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: no-desc\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Theory] + [InlineData("BadName")] + [InlineData("-leading-hyphen")] + [InlineData("trailing-hyphen-")] + [InlineData("has spaces")] + public void DiscoverAndLoadSkills_InvalidName_ExcludesSkill(string invalidName) + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "invalid-name-test"); + if (Directory.Exists(skillDir)) + { + Directory.Delete(skillDir, recursive: true); + } + + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {invalidName}\ndescription: A skill\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_DuplicateNames_KeepsFirstOnly() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "skill-a"); + string dir2 = Path.Combine(this._testRoot, "skill-b"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + File.WriteAllText( + Path.Combine(dir1, "SKILL.md"), + "---\nname: dupe\ndescription: First\n---\nFirst body."); + File.WriteAllText( + Path.Combine(dir2, "SKILL.md"), + "---\nname: dupe\ndescription: Second\n---\nSecond body."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert – filesystem enumeration order is not guaranteed, so we only + // verify that exactly one of the two duplicates was kept. + Assert.Single(skills); + string desc = skills["dupe"].Frontmatter.Description; + Assert.True(desc == "First" || desc == "Second", $"Unexpected description: {desc}"); + } + + [Fact] + public void DiscoverAndLoadSkills_WithValidResourceLinks_ExtractsResourceNames() + { + // Arrange + string skillDir = Path.Combine(this._testRoot, "resource-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: resource-skill\ndescription: Has resources\n---\nSee [FAQ](refs/FAQ.md) for details."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + var skill = skills["resource-skill"]; + Assert.Single(skill.ResourceNames); + Assert.Equal("refs/FAQ.md", skill.ResourceNames[0]); + } + + [Fact] + public void DiscoverAndLoadSkills_PathTraversal_ExcludesSkill() + { + // Arrange — resource links outside the skill directory + string skillDir = Path.Combine(this._testRoot, "traversal-skill"); + Directory.CreateDirectory(skillDir); + + // Create a file outside the skill dir that the traversal would resolve to + File.WriteAllText(Path.Combine(this._testRoot, "secret.txt"), "secret"); + + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: traversal-skill\ndescription: Traversal attempt\n---\nSee [doc](../secret.txt)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_EmptyPaths_ReturnsEmptyDictionary() + { + // Act + var skills = this._loader.DiscoverAndLoadSkills(Enumerable.Empty()); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_NonExistentPath_ReturnsEmptyDictionary() + { + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { Path.Combine(this._testRoot, "does-not-exist") }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_NestedSkillDirectory_DiscoveredWithinDepthLimit() + { + // Arrange — nested 1 level deep (MaxSearchDepth = 2, so depth 0 = testRoot, depth 1 = level1) + string nestedDir = Path.Combine(this._testRoot, "level1", "nested-skill"); + Directory.CreateDirectory(nestedDir); + File.WriteAllText( + Path.Combine(nestedDir, "SKILL.md"), + "---\nname: nested-skill\ndescription: Nested\n---\nNested body."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.True(skills.ContainsKey("nested-skill")); + } + + [Fact] + public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() + { + // Arrange + _ = this.CreateSkillDirectoryWithResource("read-skill", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content here."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["read-skill"]; + + // Act + string content = await this._loader.ReadSkillResourceAsync(skill, "refs/doc.md"); + + // Assert + Assert.Equal("Document content here.", content); + } + + [Fact] + public async Task ReadSkillResourceAsync_UnregisteredResource_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + string skillDir = this.CreateSkillDirectory("simple-skill", "A skill", "No resources."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["simple-skill"]; + + // Act & Assert + await Assert.ThrowsAsync( + () => this._loader.ReadSkillResourceAsync(skill, "unknown.md")); + } + + [Fact] + public async Task ReadSkillResourceAsync_PathTraversal_ThrowsInvalidOperationExceptionAsync() + { + // Arrange — skill with a legitimate resource, then try to read a traversal path at read time + _ = this.CreateSkillDirectoryWithResource("traverse-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "legit"); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["traverse-read"]; + + // Manually construct a skill with the traversal resource in its list to bypass discovery validation + var tampered = new FileAgentSkill( + skill.Frontmatter, + skill.Body, + skill.SourcePath, + s_traversalResource); + + // Act & Assert + await Assert.ThrowsAsync( + () => this._loader.ReadSkillResourceAsync(tampered, "../secret.txt")); + } + + [Fact] + public void DiscoverAndLoadSkills_NameExceedsMaxLength_ExcludesSkill() + { + // Arrange — name longer than 64 characters + string longName = new('a', 65); + string skillDir = Path.Combine(this._testRoot, "long-name"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {longName}\ndescription: A skill\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_DescriptionExceedsMaxLength_ExcludesSkill() + { + // Arrange — description longer than 1024 characters + string longDesc = new('x', 1025); + string skillDir = Path.Combine(this._testRoot, "long-desc"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: long-desc\ndescription: {longDesc}\n---\nBody."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Empty(skills); + } + + [Fact] + public void DiscoverAndLoadSkills_DuplicateResourceLinks_DeduplicatesResources() + { + // Arrange — body references the same resource twice + string skillDir = Path.Combine(this._testRoot, "dedup-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: dedup-skill\ndescription: Dedup test\n---\nSee [doc](refs/doc.md) and [again](refs/doc.md)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.Single(skills["dedup-skill"].ResourceNames); + } + + [Fact] + public void DiscoverAndLoadSkills_DotSlashPrefix_NormalizesToBarePath() + { + // Arrange — body references a resource with ./ prefix + string skillDir = Path.Combine(this._testRoot, "dotslash-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: dotslash-skill\ndescription: Dot-slash test\n---\nSee [doc](./refs/doc.md)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + var skill = skills["dotslash-skill"]; + Assert.Single(skill.ResourceNames); + Assert.Equal("refs/doc.md", skill.ResourceNames[0]); + } + + [Fact] + public void DiscoverAndLoadSkills_DotSlashAndBarePath_DeduplicatesResources() + { + // Arrange — body references the same resource with and without ./ prefix + string skillDir = Path.Combine(this._testRoot, "mixed-prefix-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(refsDir); + File.WriteAllText(Path.Combine(refsDir, "doc.md"), "content"); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: mixed-prefix-skill\ndescription: Mixed prefix test\n---\nSee [a](./refs/doc.md) and [b](refs/doc.md)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + var skill = skills["mixed-prefix-skill"]; + Assert.Single(skill.ResourceNames); + Assert.Equal("refs/doc.md", skill.ResourceNames[0]); + } + + [Fact] + public async Task ReadSkillResourceAsync_DotSlashPrefix_MatchesNormalizedResourceAsync() + { + // Arrange — skill loaded with bare path, caller uses ./ prefix + _ = this.CreateSkillDirectoryWithResource("dotslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Document content."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["dotslash-read"]; + + // Act — caller passes ./refs/doc.md which should match refs/doc.md + string content = await this._loader.ReadSkillResourceAsync(skill, "./refs/doc.md"); + + // Assert + Assert.Equal("Document content.", content); + } + + [Fact] + public async Task ReadSkillResourceAsync_BackslashSeparator_MatchesNormalizedResourceAsync() + { + // Arrange — skill loaded with forward-slash path, caller uses backslashes + _ = this.CreateSkillDirectoryWithResource("backslash-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Backslash content."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["backslash-read"]; + + // Act — caller passes refs\doc.md which should match refs/doc.md + string content = await this._loader.ReadSkillResourceAsync(skill, "refs\\doc.md"); + + // Assert + Assert.Equal("Backslash content.", content); + } + + [Fact] + public async Task ReadSkillResourceAsync_DotSlashWithBackslash_MatchesNormalizedResourceAsync() + { + // Arrange — skill loaded with forward-slash path, caller uses .\ prefix with backslashes + _ = this.CreateSkillDirectoryWithResource("mixed-sep-read", "A skill", "See [doc](refs/doc.md).", "refs/doc.md", "Mixed separator content."); + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + var skill = skills["mixed-sep-read"]; + + // Act — caller passes .\refs\doc.md which should match refs/doc.md + string content = await this._loader.ReadSkillResourceAsync(skill, ".\\refs\\doc.md"); + + // Assert + Assert.Equal("Mixed separator content.", content); + } + +#if NET + private static readonly string[] s_symlinkResource = ["refs/data.md"]; + + [Fact] + public void DiscoverAndLoadSkills_SymlinkInPath_ExcludesSkill() + { + // Arrange — a "refs" subdirectory is a symlink pointing outside the skill directory + string skillDir = Path.Combine(this._testRoot, "symlink-escape-skill"); + Directory.CreateDirectory(skillDir); + + string outsideDir = Path.Combine(this._testRoot, "outside"); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "secret.md"), "secret content"); + + string refsLink = Path.Combine(skillDir, "refs"); + try + { + Directory.CreateSymbolicLink(refsLink, outsideDir); + } + catch (IOException) + { + // Symlink creation requires elevation on some platforms; skip gracefully. + return; + } + + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: symlink-escape-skill\ndescription: Symlinked directory escape\n---\nSee [doc](refs/secret.md)."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert — skill should be excluded because refs/ is a symlink (reparse point) + Assert.False(skills.ContainsKey("symlink-escape-skill")); + } + + [Fact] + public async Task ReadSkillResourceAsync_SymlinkInPath_ThrowsInvalidOperationExceptionAsync() + { + // Arrange — build a skill with a symlinked subdirectory + string skillDir = Path.Combine(this._testRoot, "symlink-read-skill"); + string refsDir = Path.Combine(skillDir, "refs"); + Directory.CreateDirectory(skillDir); + + string outsideDir = Path.Combine(this._testRoot, "outside-read"); + Directory.CreateDirectory(outsideDir); + File.WriteAllText(Path.Combine(outsideDir, "data.md"), "external data"); + + try + { + Directory.CreateSymbolicLink(refsDir, outsideDir); + } + catch (IOException) + { + // Symlink creation requires elevation on some platforms; skip gracefully. + return; + } + + // Manually construct a skill that bypasses discovery validation + var frontmatter = new SkillFrontmatter("symlink-read-skill", "A skill"); + var skill = new FileAgentSkill( + frontmatter: frontmatter, + body: "See [doc](refs/data.md).", + sourcePath: skillDir, + resourceNames: s_symlinkResource); + + // Act & Assert + await Assert.ThrowsAsync( + () => this._loader.ReadSkillResourceAsync(skill, "refs/data.md")); + } +#endif + + [Fact] + public void DiscoverAndLoadSkills_FileWithUtf8Bom_ParsesSuccessfully() + { + // Arrange — prepend a UTF-8 BOM (\uFEFF) before the frontmatter + _ = this.CreateSkillDirectoryWithRawContent( + "bom-skill", + "\uFEFF---\nname: bom-skill\ndescription: Skill with BOM\n---\nBody content."); + + // Act + var skills = this._loader.DiscoverAndLoadSkills(new[] { this._testRoot }); + + // Assert + Assert.Single(skills); + Assert.True(skills.ContainsKey("bom-skill")); + Assert.Equal("Skill with BOM", skills["bom-skill"].Frontmatter.Description); + Assert.Equal("Body content.", skills["bom-skill"].Body); + } + + private string CreateSkillDirectory(string name, string description, string body) + { + string skillDir = Path.Combine(this._testRoot, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + return skillDir; + } + + private string CreateSkillDirectoryWithRawContent(string directoryName, string rawContent) + { + string skillDir = Path.Combine(this._testRoot, directoryName); + Directory.CreateDirectory(skillDir); + File.WriteAllText(Path.Combine(skillDir, "SKILL.md"), rawContent); + return skillDir; + } + + private string CreateSkillDirectoryWithResource(string name, string description, string body, string resourceRelativePath, string resourceContent) + { + string skillDir = this.CreateSkillDirectory(name, description, body); + string resourcePath = Path.Combine(skillDir, resourceRelativePath); + Directory.CreateDirectory(Path.GetDirectoryName(resourcePath)!); + File.WriteAllText(resourcePath, resourceContent); + return skillDir; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs new file mode 100644 index 0000000000..6bfaf1b546 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillsProviderTests.cs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for the class. +/// +public sealed class FileAgentSkillsProviderTests : IDisposable +{ + private readonly string _testRoot; + private readonly TestAIAgent _agent = new(); + + public FileAgentSkillsProviderTests() + { + this._testRoot = Path.Combine(Path.GetTempPath(), "skills-provider-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this._testRoot); + } + + public void Dispose() + { + if (Directory.Exists(this._testRoot)) + { + Directory.Delete(this._testRoot, recursive: true); + } + } + + [Fact] + public async Task InvokingCoreAsync_NoSkills_ReturnsInputContextUnchangedAsync() + { + // Arrange + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext { Instructions = "Original instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Equal("Original instructions", result.Instructions); + Assert.Null(result.Tools); + } + + [Fact] + public async Task InvokingCoreAsync_WithSkills_AppendsInstructionsAndToolsAsync() + { + // Arrange + this.CreateSkill("provider-skill", "Provider skill test", "Skill instructions body."); + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext { Instructions = "Base instructions" }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("Base instructions", result.Instructions); + Assert.Contains("provider-skill", result.Instructions); + Assert.Contains("Provider skill test", result.Instructions); + + // Should have load_skill and read_skill_resource tools + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("load_skill", toolNames); + Assert.Contains("read_skill_resource", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_NullInputInstructions_SetsInstructionsAsync() + { + // Arrange + this.CreateSkill("null-instr-skill", "Null instruction test", "Body."); + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("null-instr-skill", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_CustomPromptTemplate_UsesCustomTemplateAsync() + { + // Arrange + this.CreateSkill("custom-prompt-skill", "Custom prompt", "Body."); + var options = new FileAgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Custom template: {0}" + }; + var provider = new FileAgentSkillsProvider(this._testRoot, options); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.StartsWith("Custom template:", result.Instructions); + } + + [Fact] + public void Constructor_InvalidPromptTemplate_ThrowsArgumentException() + { + // Arrange — template with unescaped braces and no valid {0} placeholder + var options = new FileAgentSkillsProviderOptions + { + SkillsInstructionPrompt = "Bad template with {unescaped} braces" + }; + + // Act & Assert + var ex = Assert.Throws(() => new FileAgentSkillsProvider(this._testRoot, options)); + Assert.Contains("SkillsInstructionPrompt", ex.Message); + Assert.Equal("options", ex.ParamName); + } + + [Fact] + public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync() + { + // Arrange — description with XML-sensitive characters + string skillDir = Path.Combine(this._testRoot, "xml-skill"); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + "---\nname: xml-skill\ndescription: Uses & \"quotes\"\n---\nBody."); + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.NotNull(result.Instructions); + Assert.Contains("<tags>", result.Instructions); + Assert.Contains("&", result.Instructions); + } + + [Fact] + public async Task Constructor_WithMultiplePaths_LoadsFromAllAsync() + { + // Arrange + string dir1 = Path.Combine(this._testRoot, "dir1"); + string dir2 = Path.Combine(this._testRoot, "dir2"); + CreateSkillIn(dir1, "skill-a", "Skill A", "Body A."); + CreateSkillIn(dir2, "skill-b", "Skill B", "Body B."); + + // Act + var provider = new FileAgentSkillsProvider(new[] { dir1, dir2 }); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext()); + + // Assert + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + Assert.NotNull(result.Instructions); + Assert.Contains("skill-a", result.Instructions); + Assert.Contains("skill-b", result.Instructions); + } + + [Fact] + public async Task InvokingCoreAsync_PreservesExistingInputToolsAsync() + { + // Arrange + this.CreateSkill("tools-skill", "Tools test", "Body."); + var provider = new FileAgentSkillsProvider(this._testRoot); + + var existingTool = AIFunctionFactory.Create(() => "test", name: "existing_tool", description: "An existing tool."); + var inputContext = new AIContext { Tools = new[] { existingTool } }; + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — existing tool should be preserved alongside the new skill tools + Assert.NotNull(result.Tools); + var toolNames = result.Tools!.Select(t => t.Name).ToList(); + Assert.Contains("existing_tool", toolNames); + Assert.Contains("load_skill", toolNames); + Assert.Contains("read_skill_resource", toolNames); + } + + [Fact] + public async Task InvokingCoreAsync_SkillsListIsSortedByNameAsync() + { + // Arrange — create skills in reverse alphabetical order + this.CreateSkill("zulu-skill", "Zulu skill", "Body Z."); + this.CreateSkill("alpha-skill", "Alpha skill", "Body A."); + this.CreateSkill("mike-skill", "Mike skill", "Body M."); + var provider = new FileAgentSkillsProvider(this._testRoot); + var inputContext = new AIContext(); + var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, inputContext); + + // Act + var result = await provider.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert — skills should appear in alphabetical order in the prompt + Assert.NotNull(result.Instructions); + int alphaIndex = result.Instructions!.IndexOf("alpha-skill", StringComparison.Ordinal); + int mikeIndex = result.Instructions.IndexOf("mike-skill", StringComparison.Ordinal); + int zuluIndex = result.Instructions.IndexOf("zulu-skill", StringComparison.Ordinal); + Assert.True(alphaIndex < mikeIndex, "alpha-skill should appear before mike-skill"); + Assert.True(mikeIndex < zuluIndex, "mike-skill should appear before zulu-skill"); + } + + private void CreateSkill(string name, string description, string body) + { + CreateSkillIn(this._testRoot, name, description, body); + } + + private static void CreateSkillIn(string root, string name, string description, string body) + { + string skillDir = Path.Combine(root, name); + Directory.CreateDirectory(skillDir); + File.WriteAllText( + Path.Combine(skillDir, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n{body}"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj index cf16b00b34..7fa417b184 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj @@ -1,5 +1,9 @@ + + $(NoWarn);MAAI001 + + false From 75ff4f486f5370ce36717dda4cc37052cfc5d35c Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:33:22 -0800 Subject: [PATCH 6/6] Added new GitHub action for manual integration test run based on PR (#4135) * Added new GitHub action for manual integration test run based on PR * Addressed comments * Added branch name as input * Small improvements --- .github/workflows/dotnet-build-and-test.yml | 10 ++ .../workflows/integration-tests-manual.yml | 106 ++++++++++++++++++ .github/workflows/python-merge-tests.yml | 13 +++ 3 files changed, 129 insertions(+) create mode 100644 .github/workflows/integration-tests-manual.yml diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 95842e703a..2d079f488e 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -7,6 +7,13 @@ name: dotnet-build-and-test on: workflow_dispatch: + workflow_call: + inputs: + checkout-ref: + description: "Git ref to checkout (e.g., a commit SHA from a PR)" + required: false + type: string + default: "" pull_request: branches: ["main", "feature*"] merge_group: @@ -39,6 +46,8 @@ jobs: cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }} steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} - uses: dorny/paths-filter@v3 id: filter with: @@ -76,6 +85,7 @@ jobs: steps: - uses: actions/checkout@v6 with: + ref: ${{ inputs.checkout-ref }} persist-credentials: false sparse-checkout: | . diff --git a/.github/workflows/integration-tests-manual.yml b/.github/workflows/integration-tests-manual.yml new file mode 100644 index 0000000000..eb7c9859b4 --- /dev/null +++ b/.github/workflows/integration-tests-manual.yml @@ -0,0 +1,106 @@ +# +# This workflow allows manually running integration tests against an open PR or a branch. +# Go to Actions → "Integration Tests (Manual)" → Run workflow → enter a PR number or branch name. +# +# It reuses the existing dotnet-build-and-test and python-merge-tests workflows, +# passing a ref so they check out and test the correct code. +# + +name: Integration Tests (Manual) + +on: + workflow_dispatch: + inputs: + pr-number: + description: "PR number to run integration tests against (leave empty if using branch)" + required: false + type: string + default: "" + branch: + description: "Branch name to run integration tests against (leave empty if using PR number)" + required: false + type: string + default: "" + +permissions: + contents: read + pull-requests: read + id-token: write + +concurrency: + group: integration-tests-manual-${{ github.event.inputs.pr-number || github.event.inputs.branch }} + cancel-in-progress: true + +jobs: + resolve-ref: + name: Resolve ref + runs-on: ubuntu-latest + outputs: + checkout-ref: ${{ steps.resolve.outputs.checkout-ref }} + steps: + - name: Resolve checkout ref + id: resolve + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.inputs.pr-number }} + BRANCH: ${{ github.event.inputs.branch }} + REPO: ${{ github.repository }} + REPO_OWNER: ${{ github.repository_owner }} + run: | + if [ -n "$PR_NUMBER" ] && [ -n "$BRANCH" ]; then + echo "::error::Please provide either a PR number or a branch name, not both." + exit 1 + fi + + if [ -z "$PR_NUMBER" ] && [ -z "$BRANCH" ]; then + echo "::error::Please provide either a PR number or a branch name." + exit 1 + fi + + if [ -n "$PR_NUMBER" ]; then + if ! echo "$PR_NUMBER" | grep -Eq '^[0-9]+$'; then + echo "::error::Invalid PR number. Only numeric values are allowed." + exit 1 + fi + + PR_DATA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json state,headRepository,headRepositoryOwner) + PR_STATE=$(echo "$PR_DATA" | jq -r '.state') + HEAD_OWNER=$(echo "$PR_DATA" | jq -r '.headRepositoryOwner.login') + + if [ "$PR_STATE" != "OPEN" ]; then + echo "::error::PR #$PR_NUMBER is not open (state: $PR_STATE)" + exit 1 + fi + + if [ "$HEAD_OWNER" != "$REPO_OWNER" ]; then + echo "::error::PR #$PR_NUMBER is from a fork ($HEAD_OWNER). Running integration tests against fork PRs is not allowed for security reasons." + exit 1 + fi + + echo "checkout-ref=refs/pull/$PR_NUMBER/head" >> "$GITHUB_OUTPUT" + echo "Running integration tests for PR #$PR_NUMBER" + else + if ! echo "$BRANCH" | grep -Eq '^[a-zA-Z0-9_./-]+$'; then + echo "::error::Invalid branch name. Only alphanumeric characters, hyphens, underscores, dots, and slashes are allowed." + exit 1 + fi + + echo "checkout-ref=$BRANCH" >> "$GITHUB_OUTPUT" + echo "Running integration tests for branch $BRANCH" + fi + + dotnet-integration-tests: + name: .NET Integration Tests + needs: resolve-ref + uses: ./.github/workflows/dotnet-build-and-test.yml + with: + checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }} + secrets: inherit + + python-integration-tests: + name: Python Integration Tests + needs: resolve-ref + uses: ./.github/workflows/python-merge-tests.yml + with: + checkout-ref: ${{ needs.resolve-ref.outputs.checkout-ref }} + secrets: inherit diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 7572b0379b..8704ec56c1 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -2,6 +2,13 @@ name: Python - Merge - Tests on: workflow_dispatch: + workflow_call: + inputs: + checkout-ref: + description: "Git ref to checkout (e.g., a commit SHA from a PR)" + required: false + type: string + default: "" pull_request: branches: ["main"] merge_group: @@ -29,6 +36,8 @@ jobs: pythonChanges: ${{ steps.filter.outputs.python}} steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} - uses: dorny/paths-filter@v3 id: filter with: @@ -76,6 +85,8 @@ jobs: working-directory: python steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} - name: Set up python and install the project id: python-setup uses: ./.github/actions/python-setup @@ -135,6 +146,8 @@ jobs: working-directory: python steps: - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} - name: Set up python and install the project id: python-setup uses: ./.github/actions/python-setup