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..8125ffcb0b --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -0,0 +1,123 @@ +// 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.ClientModel.Primitives; +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") ?? "sample-memory-store-name"; +string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_MODEL") ?? "gpt-4o-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(); + +// Add a debug handler to log all HTTP requests +DebugHttpClientHandler debugHandler = new() { CheckCertificateRevocationList = true }; +HttpClient httpClient = new(debugHandler); +AIProjectClientOptions clientOptions = new() +{ + Transport = new HttpClientPipelineTransport(httpClient) +}; +AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential, clientOptions); + +// 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)); + +// Debug HTTP handler to log all requests (commented out by default) +internal sealed class DebugHttpClientHandler : HttpClientHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!request.RequestUri?.PathAndQuery.Contains("sample-memory-store-name") ?? false) + { + return await base.SendAsync(request, cancellationToken); + } + + Console.WriteLine("\n=== HTTP REQUEST ==="); + Console.WriteLine($"Method: {request.Method}"); + Console.WriteLine($"URI: {request.RequestUri}"); + Console.WriteLine("Headers:"); + foreach (var header in request.Headers) + { + Console.WriteLine($" {header.Key}: {string.Join(", ", header.Value)}"); + } + + if (request.Content != null) + { + string body = await request.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine("Body: " + body); + } + + HttpResponseMessage response = await base.SendAsync(request, cancellationToken); + + Console.WriteLine("====================\n"); + + Console.WriteLine("\n=== HTTP RESPONSE ==="); + Console.WriteLine($"Status: {(int)response.StatusCode} {response.StatusCode}"); + string responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + Console.WriteLine("Body: " + responseBody); + Console.WriteLine("=====================\n"); + + return response; + } +} 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..6107a01cc7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Microsoft.Agents.AI.FoundryMemory.Core.Models; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Internal extension methods for to provide MemoryStores operations +/// using the SDK's HTTP pipeline until the SDK releases convenience methods. +/// +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) + { + // First try to get the store to see if it exists + try + { + RequestOptions requestOptions = new() { CancellationToken = cancellationToken }; + await client.MemoryStores.GetMemoryStoreAsync(memoryStoreName, requestOptions).ConfigureAwait(false); + return false; // Store already exists + } + catch (ClientResultException ex) when (ex.Status == 404) + { + // Store doesn't exist, create it + } + + CreateMemoryStoreRequest request = new() + { + Name = memoryStoreName, + Description = description, + Definition = new MemoryStoreDefinitionRequest + { + Kind = "default", + ChatModel = chatModel, + EmbeddingModel = embeddingModel + } + }; + + string json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.CreateMemoryStoreRequest); + BinaryContent content = BinaryContent.Create(BinaryData.FromString(json)); + + RequestOptions createOptions = new() { CancellationToken = cancellationToken }; + await client.MemoryStores.CreateMemoryStoreAsync(content, createOptions).ConfigureAwait(false); + return true; + } + + /// + /// Searches for relevant memories from a memory store based on conversation context. + /// + internal static async Task SearchMemoriesAsync( + this AIProjectClient client, + string memoryStoreName, + string scope, + IEnumerable messages, + int maxMemories, + CancellationToken cancellationToken) + { + SearchMemoriesRequest request = new() + { + Scope = scope, + Items = messages.ToArray(), + Options = new SearchMemoriesOptions { MaxMemories = maxMemories } + }; + + string json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.SearchMemoriesRequest); + BinaryContent content = BinaryContent.Create(BinaryData.FromString(json)); + + RequestOptions requestOptions = new() { CancellationToken = cancellationToken }; + ClientResult result = await client.MemoryStores.SearchMemoriesAsync(memoryStoreName, content, requestOptions).ConfigureAwait(false); + + return JsonSerializer.Deserialize( + result.GetRawResponse().Content.ToString(), + FoundryMemoryJsonContext.Default.SearchMemoriesResponse); + } + + /// + /// Updates memory store with conversation memories. + /// + internal static async Task UpdateMemoriesAsync( + this AIProjectClient client, + string memoryStoreName, + string scope, + IEnumerable messages, + int updateDelay, + CancellationToken cancellationToken) + { + UpdateMemoriesRequest request = new() + { + Scope = scope, + Items = messages.ToArray(), + UpdateDelay = updateDelay + }; + + string json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.UpdateMemoriesRequest); + BinaryContent content = BinaryContent.Create(BinaryData.FromString(json)); + + RequestOptions requestOptions = new() { CancellationToken = cancellationToken }; + ClientResult result = await client.MemoryStores.UpdateMemoriesAsync(memoryStoreName, content, requestOptions).ConfigureAwait(false); + + return JsonSerializer.Deserialize( + result.GetRawResponse().Content.ToString(), + FoundryMemoryJsonContext.Default.UpdateMemoriesResponse); + } + + /// + /// Gets the status of a memory update operation. + /// + internal static async Task GetUpdateStatusAsync( + this AIProjectClient client, + string memoryStoreName, + string updateId, + CancellationToken cancellationToken) + { + RequestOptions requestOptions = new() { CancellationToken = cancellationToken }; + ClientResult result = await client.MemoryStores.GetUpdateResultAsync(memoryStoreName, updateId, requestOptions).ConfigureAwait(false); + + return JsonSerializer.Deserialize( + result.GetRawResponse().Content.ToString(), + FoundryMemoryJsonContext.Default.UpdateMemoriesResponse); + } + + /// + /// Deletes all memories associated with a specific scope from a memory store. + /// + internal static async Task DeleteScopeAsync( + this AIProjectClient client, + string memoryStoreName, + string scope, + CancellationToken cancellationToken) + { + DeleteScopeRequest request = new() { Scope = scope }; + + string json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.DeleteScopeRequest); + BinaryContent content = BinaryContent.Create(BinaryData.FromString(json)); + + RequestOptions requestOptions = new() { CancellationToken = cancellationToken }; + await client.MemoryStores.DeleteScopeAsync(memoryStoreName, content, requestOptions).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/CreateMemoryStoreRequest.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/CreateMemoryStoreRequest.cs new file mode 100644 index 0000000000..76264e7a06 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/CreateMemoryStoreRequest.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Request body for creating a memory store. +/// +internal sealed class CreateMemoryStoreRequest +{ + /// + /// Gets or sets the name of the memory store. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets an optional description for the memory store. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Gets or sets the definition for the memory store. + /// + [JsonPropertyName("definition")] + public MemoryStoreDefinitionRequest? Definition { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/DeleteScopeRequest.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/DeleteScopeRequest.cs new file mode 100644 index 0000000000..48e1363efe --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/DeleteScopeRequest.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Request body for the delete scope API. +/// +internal sealed class DeleteScopeRequest +{ + /// + /// Gets or sets the scope to delete. + /// + [JsonPropertyName("scope")] + public string Scope { get; set; } = string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryInputMessage.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryInputMessage.cs new file mode 100644 index 0000000000..e34a3d8f74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryInputMessage.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Represents an input message for the memory API. +/// +internal sealed class MemoryInputMessage +{ + /// + /// Gets or sets the role of the message (e.g., "user", "assistant", "system"). + /// + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + /// + /// Gets or sets the content of the message. + /// + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryItem.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryItem.cs new file mode 100644 index 0000000000..04e2c413f6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryItem.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Represents a memory item. +/// +internal sealed class MemoryItem +{ + /// + /// Gets or sets the unique identifier of the memory. + /// + [JsonPropertyName("memory_id")] + public string? MemoryId { get; set; } + + /// + /// Gets or sets the content of the memory. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Gets or sets the type of the memory. + /// + [JsonPropertyName("memory_type")] + public string? MemoryType { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemorySearchResult.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemorySearchResult.cs new file mode 100644 index 0000000000..0e841060ba --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemorySearchResult.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Represents a memory search result. +/// +internal sealed class MemorySearchResult +{ + /// + /// Gets or sets the memory item. + /// + [JsonPropertyName("memory_item")] + public MemoryItem? MemoryItem { get; set; } + + /// + /// Gets or sets the relevance score. + /// + [JsonPropertyName("score")] + public double Score { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryStoreDefinition.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryStoreDefinition.cs new file mode 100644 index 0000000000..8b4328b796 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryStoreDefinition.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Definition for a memory store specifying the models to use. +/// +internal sealed class MemoryStoreDefinitionRequest +{ + /// + /// Gets or sets the kind of memory store definition. + /// + [JsonPropertyName("kind")] + public string Kind { get; set; } = "default"; + + /// + /// Gets or sets the deployment name of the chat model for memory processing. + /// + [JsonPropertyName("chat_model")] + public string? ChatModel { get; set; } + + /// + /// Gets or sets the deployment name of the embedding model for memory search. + /// + [JsonPropertyName("embedding_model")] + public string? EmbeddingModel { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryStoreResponse.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryStoreResponse.cs new file mode 100644 index 0000000000..6df9c31d2a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryStoreResponse.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Response from creating or getting a memory store. +/// +internal sealed class MemoryStoreResponse +{ + /// + /// Gets or sets the unique identifier of the memory store. + /// + [JsonPropertyName("id")] + public string? Id { get; set; } + + /// + /// Gets or sets the name of the memory store. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the description of the memory store. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesOptions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesOptions.cs new file mode 100644 index 0000000000..cf12335083 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesOptions.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Options for searching memories. +/// +internal sealed class SearchMemoriesOptions +{ + /// + /// Gets or sets the maximum number of memories to return. + /// + [JsonPropertyName("max_memories")] + public int MaxMemories { get; set; } = 5; +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesRequest.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesRequest.cs new file mode 100644 index 0000000000..74ee62d9bc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesRequest.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Request body for the search memories API. +/// +internal sealed class SearchMemoriesRequest +{ + /// + /// Gets or sets the namespace that logically groups and isolates memories, such as a user ID. + /// + [JsonPropertyName("scope")] + public string Scope { get; set; } = string.Empty; + + /// + /// Gets or sets the conversation messages to use for the search query. + /// + [JsonPropertyName("items")] + public MemoryInputMessage[] Items { get; set; } = []; + + /// + /// Gets or sets the search options. + /// + [JsonPropertyName("options")] + public SearchMemoriesOptions? Options { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesResponse.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesResponse.cs new file mode 100644 index 0000000000..f3811c1461 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesResponse.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Response from the search memories API. +/// +internal sealed class SearchMemoriesResponse +{ + /// + /// Gets or sets the unique identifier for the search operation. + /// + [JsonPropertyName("search_id")] + public string? SearchId { get; set; } + + /// + /// Gets or sets the list of retrieved memories. + /// + [JsonPropertyName("memories")] + public MemorySearchResult[]? Memories { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesError.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesError.cs new file mode 100644 index 0000000000..6d62469da4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesError.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Error information for a failed update operation. +/// +internal sealed class UpdateMemoriesError +{ + /// + /// Gets or sets the error code. + /// + [JsonPropertyName("code")] + public string? Code { get; set; } + + /// + /// Gets or sets the error message. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesRequest.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesRequest.cs new file mode 100644 index 0000000000..a356059e19 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesRequest.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Request body for the update memories API. +/// +internal sealed class UpdateMemoriesRequest +{ + /// + /// Gets or sets the namespace that logically groups and isolates memories, such as a user ID. + /// + [JsonPropertyName("scope")] + public string Scope { get; set; } = string.Empty; + + /// + /// Gets or sets the conversation messages to extract memories from. + /// + [JsonPropertyName("items")] + public MemoryInputMessage[] Items { get; set; } = []; + + /// + /// Gets or sets the delay in seconds before processing the update. + /// + [JsonPropertyName("update_delay")] + public int UpdateDelay { get; set; } + + /// + /// Gets or sets the ID of a previous update operation to chain with. + /// + [JsonPropertyName("previous_update_id")] + public string? PreviousUpdateId { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs new file mode 100644 index 0000000000..9f6cd9651b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; + +/// +/// Response from the update memories API. +/// +internal sealed class UpdateMemoriesResponse +{ + /// Status indicating the update is waiting to be processed. + internal const string StatusQueued = "queued"; + + /// Status indicating the update is currently being processed. + internal const string StatusInProgress = "in_progress"; + + /// Status indicating the update completed successfully. + internal const string StatusCompleted = "completed"; + + /// Status indicating the update failed. + internal const string StatusFailed = "failed"; + + /// Status indicating the update was superseded by a newer update. + internal const string StatusSuperseded = "superseded"; + + /// + /// Gets or sets the unique identifier of the update operation. + /// + [JsonPropertyName("update_id")] + public string? UpdateId { get; set; } + + /// + /// Gets or sets the status of the update operation. + /// Known values are: "queued", "in_progress", "completed", "failed", "superseded". + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// Gets or sets the update_id that superseded this operation when status is "superseded". + /// + [JsonPropertyName("superseded_by")] + public string? SupersededBy { get; set; } + + /// + /// Gets or sets the error information when status is "failed". + /// + [JsonPropertyName("error")] + public UpdateMemoriesError? Error { get; set; } +} 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..f63d4a52e8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.FoundryMemory.Core.Models; + +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))] +[JsonSerializable(typeof(SearchMemoriesRequest))] +[JsonSerializable(typeof(SearchMemoriesResponse))] +[JsonSerializable(typeof(SearchMemoriesOptions))] +[JsonSerializable(typeof(UpdateMemoriesRequest))] +[JsonSerializable(typeof(UpdateMemoriesResponse))] +[JsonSerializable(typeof(UpdateMemoriesError))] +[JsonSerializable(typeof(DeleteScopeRequest))] +[JsonSerializable(typeof(CreateMemoryStoreRequest))] +[JsonSerializable(typeof(MemoryStoreDefinitionRequest))] +[JsonSerializable(typeof(MemoryStoreResponse))] +[JsonSerializable(typeof(MemoryInputMessage))] +[JsonSerializable(typeof(MemoryInputMessage[]))] +[JsonSerializable(typeof(MemorySearchResult))] +[JsonSerializable(typeof(MemorySearchResult[]))] +[JsonSerializable(typeof(MemoryItem))] +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..24c604ce8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -0,0 +1,429 @@ +// 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.Agents.AI.FoundryMemory.Core.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +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); + +#pragma warning disable CA1308 // Lowercase required by service + MemoryInputMessage[] messageItems = context.RequestMessages + .Where(m => !string.IsNullOrWhiteSpace(m.Text)) + .Select(m => new MemoryInputMessage + { + Role = m.Role.Value.ToLowerInvariant(), + Content = m.Text! + }) + .ToArray(); +#pragma warning restore CA1308 + + if (messageItems.Length == 0) + { + return new AIContext(); + } + + try + { + SearchMemoriesResponse? response = await this._client.SearchMemoriesAsync( + this._memoryStoreName, + this._scope.Scope!, + messageItems, + this._maxMemories, + cancellationToken).ConfigureAwait(false); + + var 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 + { +#pragma warning disable CA1308 // Lowercase required by service + MemoryInputMessage[] messageItems = context.RequestMessages + .Concat(context.ResponseMessages ?? []) + .Where(m => IsAllowedRole(m.Role) && !string.IsNullOrWhiteSpace(m.Text)) + .Select(m => new MemoryInputMessage + { + Role = m.Role.Value.ToLowerInvariant(), + Content = m.Text! + }) + .ToArray(); +#pragma warning restore CA1308 + + if (messageItems.Length == 0) + { + return; + } + + UpdateMemoriesResponse? response = await this._client.UpdateMemoriesAsync( + this._memoryStoreName, + this._scope.Scope!, + messageItems, + this._updateDelay, + cancellationToken).ConfigureAwait(false); + + 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.Length, + 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.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(); + + UpdateMemoriesResponse? response = await this._client.GetUpdateStatusAsync( + this._memoryStoreName, + updateId, + cancellationToken).ConfigureAwait(false); + + string status = response?.Status ?? "unknown"; + + if (this._logger?.IsEnabled(LogLevel.Debug) is true) + { + this._logger.LogDebug( + "FoundryMemoryProvider: Update status for '{UpdateId}': {Status}", + updateId, + status); + } + + switch (status) + { + case UpdateMemoriesResponse.StatusCompleted: + case UpdateMemoriesResponse.StatusSuperseded: + return; + case UpdateMemoriesResponse.StatusFailed: + throw new InvalidOperationException($"Memory update operation '{updateId}' failed: {response?.Error?.Message}"); + case UpdateMemoriesResponse.StatusQueued: + case UpdateMemoriesResponse.StatusInProgress: + await Task.Delay(interval, cancellationToken).ConfigureAwait(false); + break; + default: + 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 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..bc3baac152 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj @@ -0,0 +1,37 @@ + + + + preview + + + + 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; } +}