.NET: Add Foundry Memory Context Provider (#3522)

* Add Azure AI Foundry Memory Context Provider with unit tests

* Add FoundryMemory integration tests and sample application

* Fix ClearStoredMemoriesAsync to handle 404 gracefully and rename to EnsureStoredMemoriesDeletedAsync

* Refactor FoundryMemory: simplify architecture and add memory store creation

- Remove IFoundryMemoryOperations interface (was only for test mocking)
- Remove AIProjectClientMemoryOperations wrapper class
- Provider now directly uses AIProjectClient with internal extension methods
- Extension methods return actual response models instead of extracted values
- Remove WaitForUpdateCompletionAsync from provider (sample uses delay)
- Simplify EnsureMemoryStoreCreatedAsync to return Task instead of Task<bool>
- Add memory store creation with chat_model and embedding_model
- Add UpdateMemoriesResponse with SupersededBy and Error fields
- Simplify unit tests to focus on constructor validation and serialization
- Update sample to use simple delay for memory processing wait

* Add waiting operation for memory store updates

* Fix UTF-8 BOM encoding for FoundryMemory csproj files

* Update copilot instructions for UTF-8 BOM and fix sample API rename

* Fix UTF-8 BOM encoding for TestableAIProjectClient.cs

* Add missing response headers for TS

* Changing default embedding

* Using the SDK Models

* Program update

* Remove debugging code from sample

* Adapt FoundryMemoryProvider to new AIContextProvider API and add UTF-8 BOM instruction

- Override ProvideAIContextAsync/StoreAIContextAsync instead of removed virtual InvokingAsync/InvokedAsync
- Use ProviderSessionState<State> for session-scoped state management (matching Mem0Provider pattern)
- Replace constructor-based scope with stateInitializer delegate
- Remove Serialize method (no longer on base class)
- Add SearchInputMessageFilter, StorageInputMessageFilter, StateKey to options
- Update sample to use AIContextProviders list instead of AIContextProviderFactory
- Update unit and integration tests for new API
- Add UTF-8 BOM encoding and --tl:off instructions to dotnet/AGENTS.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Use DefaultAzureCredential in Foundry Memory sample

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review comments for FoundryMemoryProvider

- Move memoryStoreName from options to required constructor parameter
- Make FoundryMemoryProviderScope require non-null/whitespace scope in constructor
- Make Scope property read-only (getter only)
- Replace ConcurrentQueue with single last update ID to fix memory leak
- Only clear pending update ID after successful completion
- Add delete success logging
- Mark FoundryMemoryProvider with [Experimental] attribute
- Update unit tests for new API signatures

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Use Throw.IfNullOrWhitespace for scope and memoryStoreName validation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Roger Barreto
2026-02-20 11:25:06 +00:00
committed by GitHub
Unverified
parent 0086d38f58
commit 0e2fcb1c7f
18 changed files with 1331 additions and 0 deletions
+1
View File
@@ -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`
+4
View File
@@ -138,6 +138,7 @@
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step03_CustomMemory/AgentWithMemory_Step03_CustomMemory.csproj" />
<Project Path="samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
<File Path="samples/GettingStarted/AgentWithOpenAI/README.md" />
@@ -424,6 +425,7 @@
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj" />
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
@@ -445,6 +447,7 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
<Project Path="tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletion.IntegrationTests.csproj" />
@@ -467,6 +470,7 @@
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
</ItemGroup>
</Project>
@@ -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));
@@ -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` |
@@ -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.|
@@ -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;
/// <summary>
/// Internal extension methods for <see cref="AIProjectClient"/> to provide MemoryStores helper operations.
/// </summary>
internal static class AIProjectClientExtensions
{
/// <summary>
/// Creates a memory store if it doesn't already exist.
/// </summary>
internal static async Task<bool> 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;
}
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Provides JSON serialization utilities for the Foundry Memory provider.
/// </summary>
internal static class FoundryMemoryJsonUtilities
{
/// <summary>
/// Gets the default JSON serializer options for Foundry Memory operations.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
TypeInfoResolver = FoundryMemoryJsonContext.Default
};
}
/// <summary>
/// Source-generated JSON serialization context for Foundry Memory types.
/// </summary>
[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;
@@ -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;
/// <summary>
/// Provides an Azure AI Foundry Memory backed <see cref="AIContextProvider"/> that persists conversation messages as memories
/// and retrieves related memories to augment the agent invocation context.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<State> _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<FoundryMemoryProvider>? _logger;
private string? _lastPendingUpdateId;
/// <summary>
/// Initializes a new instance of the <see cref="FoundryMemoryProvider"/> class.
/// </summary>
/// <param name="client">The Azure AI Project client configured for your Foundry project.</param>
/// <param name="memoryStoreName">The name of the memory store in Azure AI Foundry.</param>
/// <param name="stateInitializer">A delegate that initializes the provider state on the first invocation, providing the scope for memory storage and retrieval.</param>
/// <param name="options">Provider options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="stateInitializer"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="memoryStoreName"/> is null or whitespace.</exception>
public FoundryMemoryProvider(
AIProjectClient client,
string memoryStoreName,
Func<AgentSession?, State> stateInitializer,
FoundryMemoryProviderOptions? options = null,
ILoggerFactory? loggerFactory = null)
: base(options?.SearchInputMessageFilter, options?.StorageInputMessageFilter)
{
Throw.IfNull(client);
Throw.IfNullOrWhitespace(memoryStoreName);
this._sessionState = new ProviderSessionState<State>(
ValidateStateInitializer(Throw.IfNull(stateInitializer)),
options?.StateKey ?? this.GetType().Name,
FoundryMemoryJsonUtilities.DefaultOptions);
FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions();
this._logger = loggerFactory?.CreateLogger<FoundryMemoryProvider>();
this._client = client;
this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt;
this._memoryStoreName = memoryStoreName;
this._maxMemories = effectiveOptions.MaxMemories;
this._updateDelay = effectiveOptions.UpdateDelay;
this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData;
}
/// <inheritdoc />
public override string StateKey => this._sessionState.StateKey;
private static Func<AgentSession?, State> ValidateStateInitializer(Func<AgentSession?, State> stateInitializer) =>
session =>
{
State state = stateInitializer(session);
if (state is null)
{
throw new InvalidOperationException("State initializer must return a non-null state.");
}
return state;
};
/// <inheritdoc />
protected override async ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
Throw.IfNull(context);
State state = this._sessionState.GetOrInitializeState(context.Session);
FoundryMemoryProviderScope scope = state.Scope;
List<ResponseItem> 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<MemoryStoreSearchResponse> result = await this._client.MemoryStores.SearchMemoriesAsync(
this._memoryStoreName,
searchOptions,
cancellationToken).ConfigureAwait(false);
MemoryStoreSearchResponse response = result.Value;
List<string> 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();
}
}
/// <inheritdoc />
protected override async ValueTask StoreAIContextAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
State state = this._sessionState.GetOrInitializeState(context.Session);
FoundryMemoryProviderScope scope = state.Scope;
try
{
List<ResponseItem> 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<MemoryUpdateResult> 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));
}
}
}
/// <summary>
/// Ensures all stored memories for the configured scope are deleted.
/// This method handles cases where the scope doesn't exist (no memories stored yet).
/// </summary>
/// <param name="session">The session containing the scope state to clear memories for.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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));
}
}
}
/// <summary>
/// Ensures the memory store exists, creating it if necessary.
/// </summary>
/// <param name="chatModel">The deployment name of the chat model for memory processing.</param>
/// <param name="embeddingModel">The deployment name of the embedding model for memory search.</param>
/// <param name="description">Optional description for the memory store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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);
}
}
}
/// <summary>
/// Waits for all pending memory update operations to complete.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="pollingInterval">The interval between status checks. Defaults to 5 seconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="InvalidOperationException">Thrown if the update operation failed.</exception>
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<MemoryUpdateResult> 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 : "<redacted>";
/// <summary>
/// Represents the state of a <see cref="FoundryMemoryProvider"/> stored in the <see cref="AgentSession.StateBag"/>.
/// </summary>
public sealed class State
{
/// <summary>
/// Initializes a new instance of the <see cref="State"/> class with the specified scope.
/// </summary>
/// <param name="scope">The scope to use for memory storage and retrieval.</param>
[JsonConstructor]
public State(FoundryMemoryProviderScope scope)
{
this.Scope = Throw.IfNull(scope);
}
/// <summary>
/// Gets the scope used for memory storage and retrieval.
/// </summary>
public FoundryMemoryProviderScope Scope { get; }
}
}
@@ -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;
/// <summary>
/// Options for configuring the <see cref="FoundryMemoryProvider"/>.
/// </summary>
public sealed class FoundryMemoryProviderOptions
{
/// <summary>
/// When providing memories to the model, this string is prefixed to the retrieved memories to supply context.
/// </summary>
/// <value>Defaults to "## Memories\nConsider the following memories when answering user questions:".</value>
public string? ContextPrompt { get; set; }
/// <summary>
/// Gets or sets the maximum number of memories to retrieve during search.
/// </summary>
/// <value>Defaults to 5.</value>
public int MaxMemories { get; set; } = 5;
/// <summary>
/// Gets or sets the delay in seconds before memory updates are processed.
/// </summary>
/// <remarks>
/// Setting to 0 triggers updates immediately without waiting for inactivity.
/// Higher values allow the service to batch multiple updates together.
/// </remarks>
/// <value>Defaults to 0 (immediate).</value>
public int UpdateDelay { get; set; }
/// <summary>
/// Gets or sets a value indicating whether sensitive data such as user ids and user messages may appear in logs.
/// </summary>
/// <value>Defaults to <see langword="false"/>.</value>
public bool EnableSensitiveTelemetryData { get; set; }
/// <summary>
/// Gets or sets the key used to store the provider state in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
/// <value>Defaults to the provider's type name.</value>
public string? StateKey { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to request messages when building the search text to use when
/// searching for relevant memories during <see cref="AIContextProvider.InvokingAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider defaults to including only
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? SearchInputMessageFilter { get; set; }
/// <summary>
/// Gets or sets an optional filter function applied to request messages when determining which messages to
/// extract memories from during <see cref="AIContextProvider.InvokedAsync"/>.
/// </summary>
/// <value>
/// When <see langword="null"/>, the provider defaults to including only
/// <see cref="AgentRequestMessageSourceType.External"/> messages.
/// </value>
public Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>>? StorageInputMessageFilter { get; set; }
}
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.FoundryMemory;
/// <summary>
/// Allows scoping of memories for the <see cref="FoundryMemoryProvider"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class FoundryMemoryProviderScope
{
/// <summary>
/// Initializes a new instance of the <see cref="FoundryMemoryProviderScope"/> class with the specified scope identifier.
/// </summary>
/// <param name="scope">The scope identifier used to partition memories. Must not be null or whitespace.</param>
/// <exception cref="ArgumentException">Thrown when <paramref name="scope"/> is null or whitespace.</exception>
public FoundryMemoryProviderScope(string scope)
{
Throw.IfNullOrWhitespace(scope);
this.Scope = scope;
}
/// <summary>
/// Gets the scope identifier used to partition memories.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public string Scope { get; }
}
@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- Disable packing until we are ready to release this as a nuget -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="OpenAI" />
</ItemGroup>
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework - Azure AI Foundry Memory integration</Title>
<Description>Provides Azure AI Foundry Memory integration for Microsoft Agent Framework.</Description>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.FoundryMemory.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
@@ -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; }
}
@@ -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;
/// <summary>
/// Integration tests for <see cref="FoundryMemoryProvider"/> against a configured Azure AI Foundry Memory service.
/// </summary>
/// <remarks>
/// 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 <see cref="SkipReason"/> to null to enable them after configuring the service.
/// </remarks>
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<FoundryMemoryProviderTests>(optional: true)
.Build();
var foundrySettings = configuration.GetSection("FoundryMemory").Get<FoundryMemoryConfiguration>();
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;
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<InjectSharedIntegrationTestCode>True</InjectSharedIntegrationTestCode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
</ItemGroup>
</Project>
@@ -0,0 +1,130 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.FoundryMemory.UnitTests;
/// <summary>
/// Tests for <see cref="FoundryMemoryProvider"/> constructor validation.
/// </summary>
/// <remarks>
/// Since <see cref="FoundryMemoryProvider"/> directly uses <see cref="Azure.AI.Projects.AIProjectClient"/>,
/// integration tests are used to verify the memory operations. These unit tests focus on:
/// - Constructor parameter validation
/// - State initializer validation
/// </remarks>
public sealed class FoundryMemoryProviderTests
{
[Fact]
public void Constructor_Throws_WhenClientIsNull()
{
// Act & Assert
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() => 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<ArgumentNullException>(() => 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<ArgumentException>(() => 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<ArgumentNullException>(() => 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<ArgumentNullException>(() => new FoundryMemoryProviderScope(null!));
}
[Fact]
public void Scope_Throws_WhenScopeIsEmpty()
{
// Act & Assert
Assert.Throws<ArgumentException>(() => 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<ArgumentNullException>(() =>
{
// 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);
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.FoundryMemory\Microsoft.Agents.AI.FoundryMemory.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// Creates a testable AIProjectClient with a mock HTTP handler.
/// </summary>
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();
}
}
/// <summary>
/// Mock HTTP message handler for testing.
/// </summary>
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<HttpResponseMessage> 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")
};
}
}
/// <summary>
/// Mock token credential for testing.
/// </summary>
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<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
{
return new ValueTask<AccessToken>(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)));
}
}
/// <summary>
/// Source-generated JSON serializer context for unit test types.
/// </summary>
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(TestState))]
[JsonSerializable(typeof(TestScope))]
internal sealed partial class TestJsonContext : JsonSerializerContext
{
}
/// <summary>
/// Test state class for deserialization tests.
/// </summary>
internal sealed class TestState
{
public TestScope? Scope { get; set; }
}
/// <summary>
/// Test scope class for deserialization tests.
/// </summary>
internal sealed class TestScope
{
public string? Scope { get; set; }
}