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; } +}