diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5866f1f895..2c57bd5071 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,6 +12,7 @@ When contributing to this repository, please follow these guidelines: Here are some general guidelines that apply to all code. +- All new files must be saved with UTF-8 encoding with BOM (Byte Order Mark). This is required for `dotnet format` to work correctly. - The top of all *.cs files should have a copyright notice: `// Copyright (c) Microsoft. All rights reserved.` - All public methods and classes should have XML documentation comments. - After adding, modifying or deleting code, run `dotnet build`, and then fix any reported build errors. diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 630afbd6a5..26a4eba95b 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -137,6 +137,7 @@ + @@ -411,6 +412,7 @@ + @@ -432,6 +434,7 @@ + @@ -454,6 +457,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..98d8c95aeb --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -0,0 +1,75 @@ +// 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. +AzureCliCredential credential = new(); +AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential); + +// Get the ChatClient from the AIProjectClient's OpenAI property using the deployment name. +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." }, + AIContextProviderFactory = (ctx, ct) => new ValueTask(ctx.SerializedState.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + // If each session should have its own scope, you can create a new id per session here: + // ? new FoundryMemoryProvider(projectClient, new FoundryMemoryProviderScope() { Scope = Guid.NewGuid().ToString() }, new FoundryMemoryProviderOptions() { MemoryStoreName = memoryStoreName }) + // In this case we are storing memories scoped by user so that memories are retained across sessions. + ? new FoundryMemoryProvider(projectClient, new FoundryMemoryProviderScope() { Scope = "sample-user-123" }, new FoundryMemoryProviderOptions() { MemoryStoreName = memoryStoreName }) + // For cases where we are restoring from serialized state: + : new FoundryMemoryProvider(projectClient, ctx.SerializedState, ctx.JsonSerializerOptions, new FoundryMemoryProviderOptions() { MemoryStoreName = memoryStoreName })) + }); + +AgentSession session = await agent.CreateSessionAsync(); + +FoundryMemoryProvider memoryProvider = session.GetService()!; + +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(); + +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 = session.Serialize(); +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..2c9cfd740b --- /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.FoundryMemoryState))] +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..fb263e3d56 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -0,0 +1,457 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +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.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. +/// +public sealed class FoundryMemoryProvider : AIContextProvider +{ + private const string DefaultContextPrompt = "## Memories\nConsider the following memories when answering user questions:"; + + 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 readonly FoundryMemoryProviderScope _scope; + private readonly ConcurrentQueue _pendingUpdateIds = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The Azure AI Project client configured for your Foundry project. + /// The scope configuration for memory storage and retrieval. + /// Provider options including memory store name. + /// Optional logger factory. + public FoundryMemoryProvider( + AIProjectClient client, + FoundryMemoryProviderScope scope, + FoundryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + { + Throw.IfNull(client); + Throw.IfNull(scope); + + if (string.IsNullOrWhiteSpace(scope.Scope)) + { + throw new ArgumentException("The Scope property must be provided.", nameof(scope)); + } + + FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions(); + + if (string.IsNullOrWhiteSpace(effectiveOptions.MemoryStoreName)) + { + throw new ArgumentException("The MemoryStoreName option must be provided.", nameof(options)); + } + + this._logger = loggerFactory?.CreateLogger(); + this._client = client; + + this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt; + this._memoryStoreName = effectiveOptions.MemoryStoreName; + this._maxMemories = effectiveOptions.MaxMemories; + this._updateDelay = effectiveOptions.UpdateDelay; + this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData; + this._scope = new FoundryMemoryProviderScope(scope); + } + + /// + /// Initializes a new instance of the class, with existing state from a serialized JSON element. + /// + /// The Azure AI Project client configured for your Foundry project. + /// A representing the serialized state of the provider. + /// Optional settings for customizing the JSON deserialization process. + /// Provider options including memory store name. + /// Optional logger factory. + public FoundryMemoryProvider( + AIProjectClient client, + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + FoundryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + { + Throw.IfNull(client); + + FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions(); + + if (string.IsNullOrWhiteSpace(effectiveOptions.MemoryStoreName)) + { + throw new ArgumentException("The MemoryStoreName option must be provided.", nameof(options)); + } + + this._logger = loggerFactory?.CreateLogger(); + this._client = client; + + this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt; + this._memoryStoreName = effectiveOptions.MemoryStoreName; + this._maxMemories = effectiveOptions.MaxMemories; + this._updateDelay = effectiveOptions.UpdateDelay; + this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData; + + JsonSerializerOptions jso = jsonSerializerOptions ?? FoundryMemoryJsonUtilities.DefaultOptions; + FoundryMemoryState? state = serializedState.Deserialize(jso.GetTypeInfo(typeof(FoundryMemoryState))) as FoundryMemoryState; + + if (state?.Scope == null || string.IsNullOrWhiteSpace(state.Scope.Scope)) + { + throw new InvalidOperationException("The FoundryMemoryProvider state did not contain the required scope property."); + } + + this._scope = state.Scope; + } + + /// + public override async ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + Throw.IfNull(context); + + List messageItems = context.RequestMessages + .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(this._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(this._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(this._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(this._scope.Scope)); + } + + return new AIContext(); + } + } + + /// + public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default) + { + if (context.InvokeException is not null) + { + return; // Do not update memory on failed invocations. + } + + 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(this._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) + { + this._pendingUpdateIds.Enqueue(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(this._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(this._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). + /// + /// Cancellation token. + public async Task EnsureStoredMemoriesDeletedAsync(CancellationToken cancellationToken = default) + { + try + { + await this._client.MemoryStores.DeleteScopeAsync(this._memoryStoreName, this._scope.Scope!, cancellationToken).ConfigureAwait(false); + } + 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(this._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 all pending updates + /// in parallel and returns when all have completed, failed, or been superseded. + /// + /// The interval between status checks. Defaults to 5 seconds. + /// Cancellation token. + /// Thrown if any update operation failed, containing all failures. + public async Task WhenUpdatesCompletedAsync( + TimeSpan? pollingInterval = null, + CancellationToken cancellationToken = default) + { + TimeSpan interval = pollingInterval ?? TimeSpan.FromSeconds(5); + + // Collect all pending update IDs + List updateIds = []; + while (this._pendingUpdateIds.TryDequeue(out string? updateId)) + { + updateIds.Add(updateId); + } + + if (updateIds.Count == 0) + { + return; + } + + // Poll all updates in parallel + await Task.WhenAll(updateIds.Select(updateId => this.WaitForUpdateAsync(updateId, interval, cancellationToken))).ConfigureAwait(false); + } + + 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}'."); + } + } + } + + /// + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + FoundryMemoryState state = new(this._scope); + + JsonSerializerOptions jso = jsonSerializerOptions ?? FoundryMemoryJsonUtilities.DefaultOptions; + return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(FoundryMemoryState))); + } + + 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 : ""; + + internal sealed class FoundryMemoryState + { + [JsonConstructor] + public FoundryMemoryState(FoundryMemoryProviderScope scope) + { + this.Scope = scope; + } + + public FoundryMemoryProviderScope Scope { get; set; } + } +} 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..b6b157431d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Options for configuring the . +/// +public sealed class FoundryMemoryProviderOptions +{ + /// + /// Gets or sets the name of the pre-existing memory store in Azure AI Foundry. + /// + /// + /// The memory store must be created in your Azure AI Foundry project before using this provider. + /// + public string? MemoryStoreName { get; set; } + + /// + /// 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; } +} 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..542e5bf995 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +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. + /// + public FoundryMemoryProviderScope() { } + + /// + /// Initializes a new instance of the class by cloning an existing scope. + /// + /// The scope to clone. + public FoundryMemoryProviderScope(FoundryMemoryProviderScope sourceScope) + { + Throw.IfNull(sourceScope); + this.Scope = sourceScope.Scope; + } + + /// + /// Gets or sets 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; set; } +} 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..419a5b22f0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj @@ -0,0 +1,39 @@ + + + + preview + $(NoWarn);OPENAI001 + + + + 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..b752754e59 --- /dev/null +++ b/dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs @@ -0,0 +1,12 @@ +// 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; } +} 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..2a5e154cbc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Shared.IntegrationTests; + +namespace Microsoft.Agents.AI.FoundryMemory.IntegrationTests; + +/// +/// Integration tests for against a configured Azure AI Foundry Memory 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 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; + } + } + + [Fact(Skip = SkipReason)] + public async Task CanAddAndRetrieveUserMemoriesAsync() + { + // Arrange + var question = new ChatMessage(ChatRole.User, "What is my name?"); + var input = new ChatMessage(ChatRole.User, "Hello, my name is Caoimhe."); + var storageScope = new FoundryMemoryProviderScope { Scope = "it-user-1" }; + var options = new FoundryMemoryProviderOptions { MemoryStoreName = this._memoryStoreName! }; + var sut = new FoundryMemoryProvider(this._client!, storageScope, options); + + await sut.EnsureStoredMemoriesDeletedAsync(); + var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question])); + Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty); + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext([input], aiContextProviderMessages: null)); + var ctxAfterAdding = await GetContextWithRetryAsync(sut, question); + await sut.EnsureStoredMemoriesDeletedAsync(); + var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question])); + + // Assert + Assert.Contains("Caoimhe", ctxAfterAdding.Messages?[0].Text ?? string.Empty); + Assert.DoesNotContain("Caoimhe", ctxAfterClearing.Messages?[0].Text ?? string.Empty); + } + + [Fact(Skip = SkipReason)] + public async Task CanAddAndRetrieveAssistantMemoriesAsync() + { + // Arrange + var question = new ChatMessage(ChatRole.User, "What is your name?"); + var assistantIntro = new ChatMessage(ChatRole.Assistant, "Hello, I'm a friendly assistant and my name is Caoimhe."); + var storageScope = new FoundryMemoryProviderScope { Scope = "it-agent-1" }; + var options = new FoundryMemoryProviderOptions { MemoryStoreName = this._memoryStoreName! }; + var sut = new FoundryMemoryProvider(this._client!, storageScope, options); + + await sut.EnsureStoredMemoriesDeletedAsync(); + var ctxBefore = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question])); + Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty); + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext([assistantIntro], aiContextProviderMessages: null)); + var ctxAfterAdding = await GetContextWithRetryAsync(sut, question); + await sut.EnsureStoredMemoriesDeletedAsync(); + var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question])); + + // Assert + Assert.Contains("Caoimhe", ctxAfterAdding.Messages?[0].Text ?? string.Empty); + Assert.DoesNotContain("Caoimhe", ctxAfterClearing.Messages?[0].Text ?? string.Empty); + } + + [Fact(Skip = SkipReason)] + public async Task DoesNotLeakMemoriesAcrossScopesAsync() + { + // Arrange + var question = new ChatMessage(ChatRole.User, "What is your name?"); + var assistantIntro = new ChatMessage(ChatRole.Assistant, "I'm an AI tutor and my name is Caoimhe."); + var options = new FoundryMemoryProviderOptions { MemoryStoreName = this._memoryStoreName! }; + var sut1 = new FoundryMemoryProvider(this._client!, new FoundryMemoryProviderScope { Scope = "it-scope-a" }, options); + var sut2 = new FoundryMemoryProvider(this._client!, new FoundryMemoryProviderScope { Scope = "it-scope-b" }, options); + + await sut1.EnsureStoredMemoriesDeletedAsync(); + await sut2.EnsureStoredMemoriesDeletedAsync(); + + var ctxBefore1 = await sut1.InvokingAsync(new AIContextProvider.InvokingContext([question])); + var ctxBefore2 = await sut2.InvokingAsync(new AIContextProvider.InvokingContext([question])); + Assert.DoesNotContain("Caoimhe", ctxBefore1.Messages?[0].Text ?? string.Empty); + Assert.DoesNotContain("Caoimhe", ctxBefore2.Messages?[0].Text ?? string.Empty); + + // Act + await sut1.InvokedAsync(new AIContextProvider.InvokedContext([assistantIntro], aiContextProviderMessages: null)); + var ctxAfterAdding1 = await GetContextWithRetryAsync(sut1, question); + var ctxAfterAdding2 = await GetContextWithRetryAsync(sut2, question); + + // Assert + Assert.Contains("Caoimhe", ctxAfterAdding1.Messages?[0].Text ?? string.Empty); + Assert.DoesNotContain("Caoimhe", ctxAfterAdding2.Messages?[0].Text ?? string.Empty); + + // Cleanup + await sut1.EnsureStoredMemoriesDeletedAsync(); + await sut2.EnsureStoredMemoriesDeletedAsync(); + } + + [Fact(Skip = SkipReason)] + public async Task ClearStoredMemoriesRemovesAllMemoriesAsync() + { + // Arrange + var input1 = new ChatMessage(ChatRole.User, "My favorite color is blue."); + var input2 = new ChatMessage(ChatRole.User, "My favorite food is pizza."); + var question = new ChatMessage(ChatRole.User, "What do you know about my preferences?"); + var storageScope = new FoundryMemoryProviderScope { Scope = "it-clear-test" }; + var options = new FoundryMemoryProviderOptions { MemoryStoreName = this._memoryStoreName! }; + var sut = new FoundryMemoryProvider(this._client!, storageScope, options); + + await sut.EnsureStoredMemoriesDeletedAsync(); + + // Act - Add multiple memories + await sut.InvokedAsync(new AIContextProvider.InvokedContext([input1], aiContextProviderMessages: null)); + await sut.InvokedAsync(new AIContextProvider.InvokedContext([input2], aiContextProviderMessages: null)); + var ctxBeforeClear = await GetContextWithRetryAsync(sut, question, searchTerms: ["blue", "pizza"]); + + await sut.EnsureStoredMemoriesDeletedAsync(); + var ctxAfterClear = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question])); + + // Assert + var textBefore = ctxBeforeClear.Messages?[0].Text ?? string.Empty; + var textAfter = ctxAfterClear.Messages?[0].Text ?? string.Empty; + + Assert.True(textBefore.Contains("blue") || textBefore.Contains("pizza"), "Should contain at least one preference before clear"); + Assert.DoesNotContain("blue", textAfter); + Assert.DoesNotContain("pizza", textAfter); + } + + private static async Task GetContextWithRetryAsync( + FoundryMemoryProvider provider, + ChatMessage question, + string[]? searchTerms = null, + int attempts = 5, + int delayMs = 2000) + { + searchTerms ??= ["Caoimhe"]; + AIContext? ctx = null; + + for (int i = 0; i < attempts; i++) + { + ctx = await provider.InvokingAsync(new AIContextProvider.InvokingContext([question]), CancellationToken.None); + var text = ctx.Messages?[0].Text ?? string.Empty; + + if (Array.Exists(searchTerms, term => text.Contains(term, StringComparison.OrdinalIgnoreCase))) + { + break; + } + + await Task.Delay(delayMs); + } + + return ctx!; + } + + 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..652178aef8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj @@ -0,0 +1,20 @@ + + + + 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..12307c24ca --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; + +namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; + +/// +/// Tests for constructor validation and serialization. +/// +/// +/// Since directly uses , +/// integration tests are used to verify the memory operations. These unit tests focus on: +/// - Constructor parameter validation +/// - Serialization and deserialization of provider state +/// +public sealed class FoundryMemoryProviderTests +{ + [Fact] + public void Constructor_Throws_WhenClientIsNull() + { + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + null!, + new FoundryMemoryProviderScope { Scope = "test" }, + new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.Equal("client", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenScopeIsNull() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + null!, + new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.Equal("scope", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenScopeValueIsEmpty() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + new FoundryMemoryProviderScope(), + new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.StartsWith("The Scope property must be provided.", ex.Message); + } + + [Fact] + public void Constructor_Throws_WhenMemoryStoreNameIsMissing() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + new FoundryMemoryProviderScope { Scope = "test" }, + new FoundryMemoryProviderOptions())); + Assert.StartsWith("The MemoryStoreName option must be provided.", ex.Message); + } + + [Fact] + public void Constructor_Throws_WhenMemoryStoreNameIsNull() + { + // Arrange + using TestableAIProjectClient testClient = new(); + + // Act & Assert + ArgumentException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + new FoundryMemoryProviderScope { Scope = "test" }, + null)); + Assert.StartsWith("The MemoryStoreName option must be provided.", ex.Message); + } + + [Fact] + public void DeserializingConstructor_Throws_WhenClientIsNull() + { + // Arrange - use source-generated JSON context + JsonElement jsonElement = JsonSerializer.SerializeToElement( + new TestState { Scope = new TestScope { Scope = "test" } }, + TestJsonContext.Default.TestState); + + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + null!, + jsonElement, + options: new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.Equal("client", ex.ParamName); + } + + [Fact] + public void DeserializingConstructor_Throws_WithEmptyJsonElement() + { + // Arrange + using TestableAIProjectClient testClient = new(); + JsonElement jsonElement = JsonDocument.Parse("{}").RootElement; + + // Act & Assert + InvalidOperationException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + jsonElement, + options: new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.StartsWith("The FoundryMemoryProvider state did not contain the required scope property.", ex.Message); + } + + [Fact] + public void DeserializingConstructor_Throws_WithMissingScopeValue() + { + // Arrange + using TestableAIProjectClient testClient = new(); + JsonElement jsonElement = JsonSerializer.SerializeToElement( + new TestState { Scope = new TestScope() }, + TestJsonContext.Default.TestState); + + // Act & Assert + InvalidOperationException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, + jsonElement, + options: new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.StartsWith("The FoundryMemoryProvider state did not contain the required scope property.", ex.Message); + } + + [Fact] + public void Serialize_RoundTripsScope() + { + // Arrange + using TestableAIProjectClient testClient = new(); + FoundryMemoryProviderScope scope = new() { Scope = "user-456" }; + FoundryMemoryProvider sut = new(testClient.Client, scope, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + + // Act + JsonElement stateElement = sut.Serialize(); + using JsonDocument doc = JsonDocument.Parse(stateElement.GetRawText()); + + // Assert (JSON uses camelCase naming policy) + Assert.True(doc.RootElement.TryGetProperty("scope", out JsonElement scopeElement)); + Assert.Equal("user-456", scopeElement.GetProperty("scope").GetString()); + } + + [Fact] + public void DeserializingConstructor_RestoresScope() + { + // Arrange + using TestableAIProjectClient testClient = new(); + FoundryMemoryProviderScope originalScope = new() { Scope = "restored-user-789" }; + FoundryMemoryProvider original = new(testClient.Client, originalScope, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + + // Act + JsonElement serializedState = original.Serialize(); + FoundryMemoryProvider restored = new(testClient.Client, serializedState, options: new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + + // Assert - serialize again to verify scope was restored + JsonElement restoredState = restored.Serialize(); + using JsonDocument doc = JsonDocument.Parse(restoredState.GetRawText()); + Assert.True(doc.RootElement.TryGetProperty("scope", out JsonElement scopeElement)); + Assert.Equal("restored-user-789", scopeElement.GetProperty("scope").GetString()); + } +} 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; } +}