From 94f00666eb6c1e1d2a2f1002528bd39c0d95bddb Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:32:27 +0000 Subject: [PATCH 1/8] Add Azure AI Foundry Memory Context Provider with unit tests --- dotnet/agent-framework-dotnet.slnx | 2 + .../AIProjectClientExtensions.cs | 113 ++++++ .../AIProjectClientMemoryOperations.cs | 50 +++ .../Core/Models/DeleteScopeRequest.cs | 17 + .../Core/Models/MemoryInputMessage.cs | 23 ++ .../Core/Models/MemoryItem.cs | 29 ++ .../Core/Models/MemorySearchResult.cs | 23 ++ .../Core/Models/SearchMemoriesOptions.cs | 17 + .../Core/Models/SearchMemoriesRequest.cs | 29 ++ .../Core/Models/SearchMemoriesResponse.cs | 23 ++ .../Core/Models/UpdateMemoriesRequest.cs | 35 ++ .../Core/Models/UpdateMemoriesResponse.cs | 23 ++ .../FoundryMemoryJsonUtilities.cs | 48 +++ .../FoundryMemoryProvider.cs | 314 +++++++++++++++ .../FoundryMemoryProviderOptions.cs | 45 +++ .../FoundryMemoryProviderScope.cs | 41 ++ .../IFoundryMemoryOperations.cs | 42 ++ .../Microsoft.Agents.AI.FoundryMemory.csproj | 37 ++ .../FoundryMemoryProviderTests.cs | 369 ++++++++++++++++++ ...t.Agents.AI.FoundryMemory.UnitTests.csproj | 16 + 20 files changed, 1296 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientMemoryOperations.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/DeleteScopeRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryInputMessage.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryItem.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemorySearchResult.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/SearchMemoriesResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProviderScope.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/IFoundryMemoryOperations.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 280632bc4b..027f453493 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -411,6 +411,7 @@ + @@ -454,6 +455,7 @@ + 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..4ee323bc5a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs @@ -0,0 +1,113 @@ +// 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; + +/// +/// Extension methods for to provide MemoryStores operations +/// using the SDK's HTTP pipeline until the SDK releases convenience methods. +/// +internal static class AIProjectClientExtensions +{ + /// + /// Searches for relevant memories from a memory store based on conversation context. + /// + /// The AI Project client. + /// The name of the memory store to search. + /// The namespace that logically groups and isolates memories, such as a user ID. + /// The conversation messages to use for the search query. + /// Maximum number of memories to return. + /// Cancellation token. + /// Enumerable of memory content strings. + public static async Task> SearchMemoriesAsync( + this AIProjectClient client, + string memoryStoreName, + string scope, + IEnumerable messages, + int maxMemories, + CancellationToken cancellationToken) + { + var request = new SearchMemoriesRequest + { + Scope = scope, + Items = messages.ToArray(), + Options = new SearchMemoriesOptions { MaxMemories = maxMemories } + }; + + var json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.SearchMemoriesRequest); + var content = BinaryContent.Create(BinaryData.FromString(json)); + + var requestOptions = new RequestOptions { CancellationToken = cancellationToken }; + ClientResult result = await client.MemoryStores.SearchMemoriesAsync(memoryStoreName, content, requestOptions).ConfigureAwait(false); + + var response = JsonSerializer.Deserialize( + result.GetRawResponse().Content.ToString(), + FoundryMemoryJsonContext.Default.SearchMemoriesResponse); + + return response?.Memories?.Select(m => m.MemoryItem?.Content ?? string.Empty) + .Where(c => !string.IsNullOrWhiteSpace(c)) ?? []; + } + + /// + /// Updates memory store with conversation memories. + /// + /// The AI Project client. + /// The name of the memory store to update. + /// The namespace that logically groups and isolates memories, such as a user ID. + /// The conversation messages to extract memories from. + /// Delay in seconds before processing the update. + /// Cancellation token. + public static async Task UpdateMemoriesAsync( + this AIProjectClient client, + string memoryStoreName, + string scope, + IEnumerable messages, + int updateDelay, + CancellationToken cancellationToken) + { + var request = new UpdateMemoriesRequest + { + Scope = scope, + Items = messages.ToArray(), + UpdateDelay = updateDelay + }; + + var json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.UpdateMemoriesRequest); + var content = BinaryContent.Create(BinaryData.FromString(json)); + + var requestOptions = new RequestOptions { CancellationToken = cancellationToken }; + await client.MemoryStores.UpdateMemoriesAsync(memoryStoreName, content, requestOptions).ConfigureAwait(false); + } + + /// + /// Deletes all memories associated with a specific scope from a memory store. + /// + /// The AI Project client. + /// The name of the memory store. + /// The namespace that logically groups and isolates memories to delete, such as a user ID. + /// Cancellation token. + public static async Task DeleteScopeAsync( + this AIProjectClient client, + string memoryStoreName, + string scope, + CancellationToken cancellationToken) + { + var request = new DeleteScopeRequest { Scope = scope }; + + var json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.DeleteScopeRequest); + var content = BinaryContent.Create(BinaryData.FromString(json)); + + var requestOptions = new RequestOptions { CancellationToken = cancellationToken }; + await client.MemoryStores.DeleteScopeAsync(memoryStoreName, content, requestOptions).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientMemoryOperations.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientMemoryOperations.cs new file mode 100644 index 0000000000..3a3cecaac9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientMemoryOperations.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Microsoft.Agents.AI.FoundryMemory.Core.Models; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Implementation of using . +/// +internal sealed class AIProjectClientMemoryOperations : IFoundryMemoryOperations +{ + private readonly AIProjectClient _client; + + public AIProjectClientMemoryOperations(AIProjectClient client) + { + this._client = client; + } + + public Task> SearchMemoriesAsync( + string memoryStoreName, + string scope, + IEnumerable messages, + int maxMemories, + CancellationToken cancellationToken) + { + return this._client.SearchMemoriesAsync(memoryStoreName, scope, messages, maxMemories, cancellationToken); + } + + public Task UpdateMemoriesAsync( + string memoryStoreName, + string scope, + IEnumerable messages, + int updateDelay, + CancellationToken cancellationToken) + { + return this._client.UpdateMemoriesAsync(memoryStoreName, scope, messages, updateDelay, cancellationToken); + } + + public Task DeleteScopeAsync( + string memoryStoreName, + string scope, + CancellationToken cancellationToken) + { + return this._client.DeleteScopeAsync(memoryStoreName, scope, cancellationToken); + } +} 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/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/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..f69474d89d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.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 update memories API. +/// +internal sealed class UpdateMemoriesResponse +{ + /// + /// 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. + /// + [JsonPropertyName("status")] + public string? Status { 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..8250b12a3f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs @@ -0,0 +1,48 @@ +// 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(DeleteScopeRequest))] +[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..ef0afda93d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +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 IFoundryMemoryOperations _operations; + private readonly ILogger? _logger; + + private readonly FoundryMemoryProviderScope _scope; + + /// + /// 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) + : this(new AIProjectClientMemoryOperations(Throw.IfNull(client)), scope, options, loggerFactory) + { + } + + /// + /// 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) + : this(new AIProjectClientMemoryOperations(Throw.IfNull(client)), serializedState, jsonSerializerOptions, options, loggerFactory) + { + } + + /// + /// Initializes a new instance of the class with a custom operations implementation. + /// + /// This constructor enables testability by allowing injection of mock operations. + internal FoundryMemoryProvider( + IFoundryMemoryOperations operations, + FoundryMemoryProviderScope scope, + FoundryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + { + Throw.IfNull(operations); + Throw.IfNull(scope); + + if (string.IsNullOrWhiteSpace(scope.Scope)) + { + throw new ArgumentException("The Scope property must be provided.", nameof(scope)); + } + + var 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._operations = operations; + + 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. + /// + /// This constructor enables testability by allowing injection of mock operations. + internal FoundryMemoryProvider( + IFoundryMemoryOperations operations, + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + FoundryMemoryProviderOptions? options = null, + ILoggerFactory? loggerFactory = null) + { + Throw.IfNull(operations); + + var 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._operations = operations; + + this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt; + this._memoryStoreName = effectiveOptions.MemoryStoreName; + this._maxMemories = effectiveOptions.MaxMemories; + this._updateDelay = effectiveOptions.UpdateDelay; + this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData; + + var jso = jsonSerializerOptions ?? FoundryMemoryJsonUtilities.DefaultOptions; + var 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 + var 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 + { + var memories = (await this._operations.SearchMemoriesAsync( + this._memoryStoreName, + this._scope.Scope!, + messageItems, + this._maxMemories, + cancellationToken).ConfigureAwait(false)).ToList(); + + var 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 + var 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; + } + + await this._operations.UpdateMemoriesAsync( + this._memoryStoreName, + this._scope.Scope!, + messageItems, + this._updateDelay, + cancellationToken).ConfigureAwait(false); + + if (this._logger?.IsEnabled(LogLevel.Information) is true) + { + this._logger.LogInformation( + "FoundryMemoryProvider: Sent {Count} messages to update memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + messageItems.Length, + this._memoryStoreName, + this.SanitizeLogData(this._scope.Scope)); + } + } + 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)); + } + } + } + + /// + /// Clears all stored memories for the configured scope. + /// + /// Cancellation token. + public Task ClearStoredMemoriesAsync(CancellationToken cancellationToken = default) => + this._operations.DeleteScopeAsync(this._memoryStoreName, this._scope.Scope!, cancellationToken); + + /// + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + var state = new FoundryMemoryState(this._scope); + + var 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/IFoundryMemoryOperations.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/IFoundryMemoryOperations.cs new file mode 100644 index 0000000000..af7fd6822c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/IFoundryMemoryOperations.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.FoundryMemory.Core.Models; + +namespace Microsoft.Agents.AI.FoundryMemory; + +/// +/// Interface for Foundry Memory operations, enabling testability. +/// +internal interface IFoundryMemoryOperations +{ + /// + /// Searches for relevant memories from a memory store based on conversation context. + /// + Task> SearchMemoriesAsync( + string memoryStoreName, + string scope, + IEnumerable messages, + int maxMemories, + CancellationToken cancellationToken); + + /// + /// Updates memory store with conversation memories. + /// + Task UpdateMemoriesAsync( + string memoryStoreName, + string scope, + IEnumerable messages, + int updateDelay, + CancellationToken cancellationToken); + + /// + /// Deletes all memories associated with a specific scope from a memory store. + /// + Task DeleteScopeAsync( + string memoryStoreName, + string scope, + CancellationToken cancellationToken); +} 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..0aa2dbb3e6 --- /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/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs new file mode 100644 index 0000000000..a9ac8c585c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs @@ -0,0 +1,369 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.FoundryMemory.Core.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; + +/// +/// Tests for . +/// +public sealed class FoundryMemoryProviderTests +{ + private readonly Mock _operationsMock; + private readonly Mock> _loggerMock; + private readonly Mock _loggerFactoryMock; + + public FoundryMemoryProviderTests() + { + this._operationsMock = new(); + this._loggerMock = new(); + this._loggerFactoryMock = new(); + this._loggerFactoryMock + .Setup(f => f.CreateLogger(It.IsAny())) + .Returns(this._loggerMock.Object); + this._loggerFactoryMock + .Setup(f => f.CreateLogger(typeof(FoundryMemoryProvider).FullName!)) + .Returns(this._loggerMock.Object); + + this._loggerMock + .Setup(f => f.IsEnabled(It.IsAny())) + .Returns(true); + } + + [Fact] + public void Constructor_Throws_WhenOperationsIsNull() + { + // Act & Assert + var ex = Assert.Throws(() => new FoundryMemoryProvider( + (IFoundryMemoryOperations)null!, + new FoundryMemoryProviderScope { Scope = "test" }, + new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.Equal("operations", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenScopeIsNull() + { + // Act & Assert + var ex = Assert.Throws(() => new FoundryMemoryProvider( + this._operationsMock.Object, +<<<<<<< TODO: Unmerged change from project 'Microsoft.Agents.AI.FoundryMemory.UnitTests(net472)', Before: + (FoundryMemoryProviderScope)null!, +======= + null!, +>>>>>>> After + + null!, + new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.Equal("scope", ex.ParamName); + } + + [Fact] + public void Constructor_Throws_WhenScopeValueIsEmpty() + { + // Act & Assert + var ex = Assert.Throws(() => new FoundryMemoryProvider( + this._operationsMock.Object, + new FoundryMemoryProviderScope(), + new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.StartsWith("The Scope property must be provided.", ex.Message); + } + + [Fact] + public void Constructor_Throws_WhenMemoryStoreNameIsMissing() + { + // Act & Assert + var ex = Assert.Throws(() => new FoundryMemoryProvider( + this._operationsMock.Object, + new FoundryMemoryProviderScope { Scope = "test" }, + new FoundryMemoryProviderOptions())); + Assert.StartsWith("The MemoryStoreName option must be provided.", ex.Message); + } + + [Fact] + public void DeserializingConstructor_Throws_WithEmptyJsonElement() + { + // Arrange + JsonElement jsonElement = JsonSerializer.SerializeToElement(new object(), FoundryMemoryJsonUtilities.DefaultOptions); + + // Act & Assert + var ex = Assert.Throws(() => new FoundryMemoryProvider( + this._operationsMock.Object, + jsonElement, + options: new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.StartsWith("The FoundryMemoryProvider state did not contain the required scope property.", ex.Message); + } + + [Fact] + public async Task InvokingAsync_PerformsSearch_AndReturnsContextMessageAsync() + { + // Arrange + this._operationsMock + .Setup(o => o.SearchMemoriesAsync( + "my-store", + "user-123", + It.IsAny>(), + 5, + It.IsAny())) + .ReturnsAsync(["User prefers dark roast coffee", "User is from Seattle"]); + + FoundryMemoryProviderScope scope = new() { Scope = "user-123" }; + FoundryMemoryProviderOptions options = new() + { + MemoryStoreName = "my-store", + EnableSensitiveTelemetryData = true + }; + + FoundryMemoryProvider sut = new(this._operationsMock.Object, scope, options); + AIContextProvider.InvokingContext invokingContext = new([new ChatMessage(ChatRole.User, "What are my coffee preferences?")]); + + // Act + AIContext aiContext = await sut.InvokingAsync(invokingContext); + + // Assert + this._operationsMock.Verify( + o => o.SearchMemoriesAsync("my-store", "user-123", It.IsAny>(), 5, It.IsAny()), + Times.Once); + + Assert.NotNull(aiContext.Messages); + ChatMessage contextMessage = Assert.Single(aiContext.Messages); + Assert.Equal(ChatRole.User, contextMessage.Role); + Assert.Contains("User prefers dark roast coffee", contextMessage.Text); + Assert.Contains("User is from Seattle", contextMessage.Text); + } + + [Fact] + public async Task InvokingAsync_ReturnsEmptyContext_WhenNoMemoriesFoundAsync() + { + // Arrange + this._operationsMock + .Setup(o => o.SearchMemoriesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + AIContextProvider.InvokingContext invokingContext = new([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + AIContext aiContext = await sut.InvokingAsync(invokingContext); + + // Assert + Assert.NotNull(aiContext.Messages); + ChatMessage contextMessage = Assert.Single(aiContext.Messages); + Assert.True(string.IsNullOrEmpty(contextMessage.Text)); // Text is null or empty when no memories found + } + + [Fact] + public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync() + { + // Arrange + this._operationsMock + .Setup(o => o.SearchMemoriesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Search failed")); + + FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }, this._loggerFactoryMock.Object); + AIContextProvider.InvokingContext invokingContext = new([new ChatMessage(ChatRole.User, "Q?")]); + + // Act + AIContext aiContext = await sut.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + Assert.Null(aiContext.Messages); + Assert.Null(aiContext.Tools); + this._loggerMock.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("FoundryMemoryProvider: Failed to search for memories due to error")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task InvokedAsync_PersistsAllowedMessagesAsync() + { + // Arrange + IEnumerable? capturedMessages = null; + this._operationsMock + .Setup(o => o.UpdateMemoriesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, int, CancellationToken>((_, _, msgs, _, _) => capturedMessages = msgs) + .Returns(Task.CompletedTask); + + FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + + List requestMessages = + [ + new(ChatRole.User, "User text"), + new(ChatRole.System, "System text"), + new(ChatRole.Tool, "Tool text should be ignored") + ]; + List responseMessages = [new(ChatRole.Assistant, "Assistant text")]; + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages }); + + // Assert + this._operationsMock.Verify( + o => o.UpdateMemoriesAsync("my-store", "user-123", It.IsAny>(), 0, It.IsAny()), + Times.Once); + + Assert.NotNull(capturedMessages); + List messagesList = [.. capturedMessages]; + Assert.Equal(3, messagesList.Count); // user, system, assistant (tool excluded) + Assert.Contains(messagesList, m => m.Role == "user" && m.Content == "User text"); + Assert.Contains(messagesList, m => m.Role == "system" && m.Content == "System text"); + Assert.Contains(messagesList, m => m.Role == "assistant" && m.Content == "Assistant text"); + Assert.DoesNotContain(messagesList, m => m.Content == "Tool text should be ignored"); + } + + [Fact] + public async Task InvokedAsync_PersistsNothingForFailedRequestAsync() + { + // Arrange + FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + + List requestMessages = + [ + new(ChatRole.User, "User text"), + new(ChatRole.System, "System text") + ]; + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) + { + ResponseMessages = null, + InvokeException = new InvalidOperationException("Request Failed") + }); + + // Assert + this._operationsMock.Verify( + o => o.UpdateMemoriesAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync() + { + // Arrange + this._operationsMock + .Setup(o => o.UpdateMemoriesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Storage failed")); + + FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }, this._loggerFactoryMock.Object); + + List requestMessages = [new(ChatRole.User, "User text")]; + List responseMessages = [new(ChatRole.Assistant, "Assistant text")]; + + // Act + await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages }); + + // Assert + this._loggerMock.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("FoundryMemoryProvider: Failed to send messages to update memories due to error")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ClearStoredMemoriesAsync_SendsDeleteRequestAsync() + { + // Arrange + FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + + // Act + await sut.ClearStoredMemoriesAsync(); + + // Assert + this._operationsMock.Verify( + o => o.DeleteScopeAsync("my-store", "user-123", It.IsAny()), + Times.Once); + } + + [Fact] + public void Serialize_RoundTripsScope() + { + // Arrange + FoundryMemoryProviderScope scope = new() { Scope = "user-456" }; + FoundryMemoryProvider sut = new(this._operationsMock.Object, 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()); + } + + [Theory] + [InlineData(true, "user-123")] + [InlineData(false, "")] + public async Task InvokingAsync_LogsScopeBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, string expectedScopeInLog) + { + // Arrange + this._operationsMock + .Setup(o => o.SearchMemoriesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(["test memory"]); + + FoundryMemoryProviderOptions options = new() + { + MemoryStoreName = "my-store", + EnableSensitiveTelemetryData = enableSensitiveTelemetryData + }; + FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, options, this._loggerFactoryMock.Object); + + AIContextProvider.InvokingContext invokingContext = new([new ChatMessage(ChatRole.User, "test")]); + + // Act + await sut.InvokingAsync(invokingContext, CancellationToken.None); + + // Assert + this._loggerMock.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => + v.ToString()!.Contains("Retrieved 1 memories") && + v.ToString()!.Contains($"Scope: '{expectedScopeInLog}'")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} 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..9973e6f247 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + false + + + + + + + + + + + + From dfde827c1fd67e8fce22044e836dc4a8f35b28a3 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:26:26 +0000 Subject: [PATCH 2/8] Add FoundryMemory integration tests and sample application --- dotnet/agent-framework-dotnet.slnx | 2 + ...ithMemory_Step04_MemoryUsingFoundry.csproj | 23 +++ .../Program.cs | 61 ++++++ .../README.md | 64 ++++++ .../GettingStarted/AgentWithMemory/README.md | 1 + .../FoundryMemoryProvider.cs | 1 - .../FoundryMemoryConfiguration.cs | 12 ++ .../FoundryMemoryProviderTests.cs | 190 ++++++++++++++++++ ...s.AI.FoundryMemory.IntegrationTests.csproj | 20 ++ .../FoundryMemoryProviderTests.cs | 8 +- 10 files changed, 374 insertions(+), 8 deletions(-) create mode 100644 dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj create mode 100644 dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs create mode 100644 dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md create mode 100644 dotnet/src/Shared/IntegrationTests/FoundryMemoryConfiguration.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 027f453493..50cadefae1 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -137,6 +137,7 @@ + @@ -433,6 +434,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..7a576946ae --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj @@ -0,0 +1,23 @@ + + + + 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..537891af5b --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -0,0 +1,61 @@ +// 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. + +using System.Text.Json; +using Azure.AI.OpenAI; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.FoundryMemory; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; + +string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string memoryStoreName = Environment.GetEnvironmentVariable("FOUNDRY_MEMORY_STORE_NAME") ?? throw new InvalidOperationException("FOUNDRY_MEMORY_STORE_NAME is not set."); + +// Create an AIProjectClient for Foundry Memory with Azure Identity authentication. +AzureCliCredential credential = new(); +AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential); + +AIAgent agent = new AzureOpenAIClient(new Uri(openAiEndpoint), credential) + .GetChatClient(deploymentName) + .AsAIAgent(new ChatClientAgentOptions() + { + 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 not JsonValueKind.Null and not 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.GetNewSessionAsync(); + +// Clear any existing memories for this scope to demonstrate fresh behavior. +FoundryMemoryProvider memoryProvider = session.GetService()!; +await memoryProvider.ClearStoredMemoriesAsync(); + +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)); + +Console.WriteLine("\nWaiting briefly for Foundry Memory to index the new memories...\n"); +await Task.Delay(TimeSpan.FromSeconds(3)); + +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"); +AgentSession newSession = await agent.GetNewSessionAsync(); +Console.WriteLine(await agent.RunAsync("Summarize what you already know about me.", newSession)); diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md new file mode 100644 index 0000000000..d170497a46 --- /dev/null +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md @@ -0,0 +1,64 @@ +# 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 +- 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. Memory store created in your Foundry project (see setup below) +3. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) +4. .NET 10.0 SDK +5. Azure CLI logged in (`az login`) + +## Setup Memory Store + +1. Navigate to your [Azure AI Foundry project](https://ai.azure.com/) +2. Go to **Agents** > **Memory stores** +3. Create a new memory store with: + - A chat model deployment for memory extraction + - An embedding model deployment for semantic search + - Enable user profile memory and/or chat summary memory + +## Environment Variables + +```bash +# Azure OpenAI endpoint and deployment +export AZURE_OPENAI_ENDPOINT="https://your-openai.openai.azure.com/" +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" + +# Azure AI Foundry project endpoint and memory store name +export AZURE_AI_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" +export FOUNDRY_MEMORY_STORE_NAME="my_memory_store" +``` + +## Run the Sample + +```bash +dotnet run +``` + +## Expected Output + +The agent will: +1. Learn your name (Taylor), travel destination (Patagonia), timing (November), companions (sister), and interests (scenic viewpoints) +2. Wait for Foundry Memory to index the memories +3. Recall those details when asked about the trip +4. Demonstrate memory persistence across session serialization/deserialization +5. 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 | 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/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs index ef0afda93d..d41770e283 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; 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..fce3f58bb4 --- /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.ClearStoredMemoriesAsync(); + 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.ClearStoredMemoriesAsync(); + 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.ClearStoredMemoriesAsync(); + 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.ClearStoredMemoriesAsync(); + 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.ClearStoredMemoriesAsync(); + await sut2.ClearStoredMemoriesAsync(); + + 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.ClearStoredMemoriesAsync(); + await sut2.ClearStoredMemoriesAsync(); + } + + [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.ClearStoredMemoriesAsync(); + + // 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.ClearStoredMemoriesAsync(); + 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..d4cabd7687 --- /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 index a9ac8c585c..7a314eb867 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -55,12 +55,6 @@ public void Constructor_Throws_WhenScopeIsNull() // Act & Assert var ex = Assert.Throws(() => new FoundryMemoryProvider( this._operationsMock.Object, -<<<<<<< TODO: Unmerged change from project 'Microsoft.Agents.AI.FoundryMemory.UnitTests(net472)', Before: - (FoundryMemoryProviderScope)null!, -======= - null!, ->>>>>>> After - null!, new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); Assert.Equal("scope", ex.ParamName); From 372f885624b984da282f5718144e08ec39a58f65 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:38:57 +0000 Subject: [PATCH 3/8] Fix ClearStoredMemoriesAsync to handle 404 gracefully and rename to EnsureStoredMemoriesDeletedAsync --- ...ithMemory_Step04_MemoryUsingFoundry.csproj | 1 - .../Program.cs | 18 ++-- .../README.md | 9 +- .../FoundryMemoryProvider.cs | 24 +++++- .../FoundryMemoryProviderTests.cs | 20 ++--- .../FoundryMemoryProviderTests.cs | 84 ++++++++++++++++++- 6 files changed, 125 insertions(+), 31 deletions(-) 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 index 7a576946ae..12d585e418 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj @@ -9,7 +9,6 @@ - diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index 537891af5b..7a7382a5d4 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -5,30 +5,28 @@ // memories for subsequent invocations, even across new sessions. using System.Text.Json; -using Azure.AI.OpenAI; using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.FoundryMemory; using Microsoft.Extensions.AI; -using OpenAI.Chat; -string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); string memoryStoreName = Environment.GetEnvironmentVariable("FOUNDRY_MEMORY_STORE_NAME") ?? throw new InvalidOperationException("FOUNDRY_MEMORY_STORE_NAME is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -// Create an AIProjectClient for Foundry Memory with Azure Identity authentication. +// Create an AIProjectClient for Foundry with Azure Identity authentication. AzureCliCredential credential = new(); AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential); -AIAgent agent = new AzureOpenAIClient(new Uri(openAiEndpoint), credential) +// Get the ChatClient from the AIProjectClient's OpenAI property using the deployment name. +AIAgent agent = projectClient.OpenAI .GetChatClient(deploymentName) + .AsIChatClient() .AsAIAgent(new ChatClientAgentOptions() { 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 not JsonValueKind.Null and not JsonValueKind.Undefined + 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. @@ -41,7 +39,7 @@ // Clear any existing memories for this scope to demonstrate fresh behavior. FoundryMemoryProvider memoryProvider = session.GetService()!; -await memoryProvider.ClearStoredMemoriesAsync(); +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)); diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md index d170497a46..d2cbd843c2 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md @@ -30,13 +30,12 @@ This sample demonstrates how to create and run an agent that uses Azure AI Found ## Environment Variables ```bash -# Azure OpenAI endpoint and deployment -export AZURE_OPENAI_ENDPOINT="https://your-openai.openai.azure.com/" -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" - # Azure AI Foundry project endpoint and memory store name -export AZURE_AI_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" +export AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" export FOUNDRY_MEMORY_STORE_NAME="my_memory_store" + +# Azure OpenAI deployment name (model deployed in your Foundry project) +export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" ``` ## Run the Sample diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs index d41770e283..d7919f3ca9 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.ClientModel; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -280,11 +281,28 @@ await this._operations.UpdateMemoriesAsync( } /// - /// Clears all stored memories for the configured 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 Task ClearStoredMemoriesAsync(CancellationToken cancellationToken = default) => - this._operations.DeleteScopeAsync(this._memoryStoreName, this._scope.Scope!, cancellationToken); + public async Task EnsureStoredMemoriesDeletedAsync(CancellationToken cancellationToken = default) + { + try + { + await this._operations.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)); + } + } + } /// public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs index fce3f58bb4..2a5e154cbc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.IntegrationTests/FoundryMemoryProviderTests.cs @@ -52,14 +52,14 @@ public async Task CanAddAndRetrieveUserMemoriesAsync() var options = new FoundryMemoryProviderOptions { MemoryStoreName = this._memoryStoreName! }; var sut = new FoundryMemoryProvider(this._client!, storageScope, options); - await sut.ClearStoredMemoriesAsync(); + 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.ClearStoredMemoriesAsync(); + await sut.EnsureStoredMemoriesDeletedAsync(); var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question])); // Assert @@ -77,14 +77,14 @@ public async Task CanAddAndRetrieveAssistantMemoriesAsync() var options = new FoundryMemoryProviderOptions { MemoryStoreName = this._memoryStoreName! }; var sut = new FoundryMemoryProvider(this._client!, storageScope, options); - await sut.ClearStoredMemoriesAsync(); + 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.ClearStoredMemoriesAsync(); + await sut.EnsureStoredMemoriesDeletedAsync(); var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question])); // Assert @@ -102,8 +102,8 @@ public async Task DoesNotLeakMemoriesAcrossScopesAsync() 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.ClearStoredMemoriesAsync(); - await sut2.ClearStoredMemoriesAsync(); + await sut1.EnsureStoredMemoriesDeletedAsync(); + await sut2.EnsureStoredMemoriesDeletedAsync(); var ctxBefore1 = await sut1.InvokingAsync(new AIContextProvider.InvokingContext([question])); var ctxBefore2 = await sut2.InvokingAsync(new AIContextProvider.InvokingContext([question])); @@ -120,8 +120,8 @@ public async Task DoesNotLeakMemoriesAcrossScopesAsync() Assert.DoesNotContain("Caoimhe", ctxAfterAdding2.Messages?[0].Text ?? string.Empty); // Cleanup - await sut1.ClearStoredMemoriesAsync(); - await sut2.ClearStoredMemoriesAsync(); + await sut1.EnsureStoredMemoriesDeletedAsync(); + await sut2.EnsureStoredMemoriesDeletedAsync(); } [Fact(Skip = SkipReason)] @@ -135,14 +135,14 @@ public async Task ClearStoredMemoriesRemovesAllMemoriesAsync() var options = new FoundryMemoryProviderOptions { MemoryStoreName = this._memoryStoreName! }; var sut = new FoundryMemoryProvider(this._client!, storageScope, options); - await sut.ClearStoredMemoriesAsync(); + 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.ClearStoredMemoriesAsync(); + await sut.EnsureStoredMemoriesDeletedAsync(); var ctxAfterClear = await sut.InvokingAsync(new AIContextProvider.InvokingContext([question])); // Assert diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs index 7a314eb867..310da61ba5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.ClientModel; +using System.ClientModel.Primitives; using System.Collections.Generic; +using System.IO; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -291,13 +294,13 @@ public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync() } [Fact] - public async Task ClearStoredMemoriesAsync_SendsDeleteRequestAsync() + public async Task EnsureStoredMemoriesDeletedAsync_SendsDeleteRequestAsync() { // Arrange FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); // Act - await sut.ClearStoredMemoriesAsync(); + await sut.EnsureStoredMemoriesDeletedAsync(); // Assert this._operationsMock.Verify( @@ -305,6 +308,20 @@ public async Task ClearStoredMemoriesAsync_SendsDeleteRequestAsync() Times.Once); } + [Fact] + public async Task EnsureStoredMemoriesDeletedAsync_Handles404GracefullyAsync() + { + // Arrange + this._operationsMock + .Setup(o => o.DeleteScopeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new ClientResultException(new MockPipelineResponse(404))); + + FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + + // Act & Assert - should not throw + await sut.EnsureStoredMemoriesDeletedAsync(); + } + [Fact] public void Serialize_RoundTripsScope() { @@ -360,4 +377,67 @@ public async Task InvokingAsync_LogsScopeBasedOnEnableSensitiveTelemetryDataAsyn It.IsAny>()), Times.Once); } + + private sealed class MockPipelineResponse : PipelineResponse + { + private readonly int _status; + private readonly MockPipelineResponseHeaders _headers; + + public MockPipelineResponse(int status) + { + this._status = status; + this.Content = BinaryData.Empty; + this._headers = new MockPipelineResponseHeaders(); + } + + public override int Status => this._status; + + public override string ReasonPhrase => this._status == 404 ? "Not Found" : "OK"; + + public override Stream? ContentStream + { + get => null; + set { } + } + + public override BinaryData Content { get; } + + protected override PipelineResponseHeaders HeadersCore => this._headers; + + public override BinaryData BufferContent(CancellationToken cancellationToken = default) => this.Content; + + public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) => + new(this.Content); + + public override void Dispose() + { + } + + private sealed class MockPipelineResponseHeaders : PipelineResponseHeaders + { + private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); + + public override bool TryGetValue(string name, out string? value) + { + return this._headers.TryGetValue(name, out value); + } + + public override bool TryGetValues(string name, out IEnumerable? values) + { + if (this._headers.TryGetValue(name, out string? value)) + { + values = [value]; + return true; + } + + values = null; + return false; + } + + public override IEnumerator> GetEnumerator() + { + return this._headers.GetEnumerator(); + } + } + } } From b3c8acfb16e1115908cdb5c0ef9cfec168649cef Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:09:49 +0000 Subject: [PATCH 4/8] Refactor FoundryMemory: simplify architecture and add memory store creation - Remove IFoundryMemoryOperations interface (was only for test mocking) - Remove AIProjectClientMemoryOperations wrapper class - Provider now directly uses AIProjectClient with internal extension methods - Extension methods return actual response models instead of extracted values - Remove WaitForUpdateCompletionAsync from provider (sample uses delay) - Simplify EnsureMemoryStoreCreatedAsync to return Task instead of Task - Add memory store creation with chat_model and embedding_model - Add UpdateMemoriesResponse with SupersededBy and Error fields - Simplify unit tests to focus on constructor validation and serialization - Update sample to use simple delay for memory processing wait --- ...ithMemory_Step04_MemoryUsingFoundry.csproj | 5 +- .../Program.cs | 79 +++- .../README.md | 38 +- .../AIProjectClientExtensions.cs | 120 +++-- .../AIProjectClientMemoryOperations.cs | 50 -- .../Core/Models/CreateMemoryStoreRequest.cs | 29 ++ .../Core/Models/MemoryStoreDefinition.cs | 29 ++ .../Core/Models/MemoryStoreResponse.cs | 29 ++ .../Core/Models/UpdateMemoriesError.cs | 23 + .../Core/Models/UpdateMemoriesResponse.cs | 13 + .../FoundryMemoryJsonUtilities.cs | 4 + .../FoundryMemoryProvider.cs | 128 +++--- .../IFoundryMemoryOperations.cs | 42 -- .../FoundryMemoryProviderTests.cs | 430 ++++-------------- .../TestableAIProjectClient.cs | 215 +++++++++ 15 files changed, 661 insertions(+), 573 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientMemoryOperations.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/CreateMemoryStoreRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryStoreDefinition.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/MemoryStoreResponse.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesError.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.FoundryMemory/IFoundryMemoryOperations.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs 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 index 12d585e418..0b6c06a5a8 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj @@ -1,4 +1,4 @@ - + Exe @@ -11,11 +11,10 @@ - - + diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index 7a7382a5d4..b4f4270bee 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -3,28 +3,39 @@ // 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; -using Microsoft.Extensions.AI; -string foundryEndpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -string memoryStoreName = Environment.GetEnvironmentVariable("FOUNDRY_MEMORY_STORE_NAME") ?? throw new InvalidOperationException("FOUNDRY_MEMORY_STORE_NAME is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +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(); -AIProjectClient projectClient = new(new Uri(foundryEndpoint), credential); + +// 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 = projectClient.OpenAI - .GetChatClient(deploymentName) - .AsIChatClient() - .AsAIAgent(new ChatClientAgentOptions() +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: @@ -37,15 +48,30 @@ AgentSession session = await agent.GetNewSessionAsync(); -// Clear any existing memories for this scope to demonstrate fresh behavior. 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)); -Console.WriteLine("\nWaiting briefly for Foundry Memory to index the new memories...\n"); -await Task.Delay(TimeSpan.FromSeconds(3)); +// Memory extraction in Azure AI Foundry is asynchronous and takes time to process. +// In production scenarios, you would: +// 1. Track the UpdateId returned from each memory update operation +// 2. Poll the GetUpdateResultAsync API to check completion status +// 3. Wait for status to become "completed" before querying memories +// +// For simplicity, this sample uses a fixed delay. For production code, implement proper polling +// using the Azure.AI.Projects SDK's MemoryStores.GetUpdateResultAsync method. +Console.WriteLine("\nWaiting for Foundry Memory to process updates..."); +await Task.Delay(TimeSpan.FromSeconds(10)); +Console.WriteLine("Continuing after delay.\n"); Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", session)); @@ -57,3 +83,32 @@ Console.WriteLine("\n>> Start a new session that shares the same Foundry Memory scope\n"); AgentSession newSession = await agent.GetNewSessionAsync(); 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) + { + // Uncomment to debug HTTP traffic: + // Console.WriteLine("\n=== HTTP REQUEST ==="); + // Console.WriteLine($"Method: {request.Method}"); + // Console.WriteLine($"URI: {request.RequestUri}"); + // + // if (request.Content != null) + // { + // string body = await request.Content.ReadAsStringAsync(cancellationToken); + // Console.WriteLine("Body: " + body); + // } + // + // Console.WriteLine("====================\n"); + + // Uncomment to debug HTTP traffic: + // 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 await base.SendAsync(request, cancellationToken); + } +} diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md index d2cbd843c2..dfea386d82 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/README.md @@ -5,6 +5,7 @@ This sample demonstrates how to create and run an agent that uses Azure AI Found ## 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 @@ -13,29 +14,20 @@ This sample demonstrates how to create and run an agent that uses Azure AI Found ## Prerequisites 1. Azure subscription with Azure AI Foundry project -2. Memory store created in your Foundry project (see setup below) -3. Azure OpenAI resource with a chat model deployment (e.g., gpt-4o-mini) -4. .NET 10.0 SDK -5. Azure CLI logged in (`az login`) - -## Setup Memory Store - -1. Navigate to your [Azure AI Foundry project](https://ai.azure.com/) -2. Go to **Agents** > **Memory stores** -3. Create a new memory store with: - - A chat model deployment for memory extraction - - An embedding model deployment for semantic search - - Enable user profile memory and/or chat summary memory +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 AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" -export FOUNDRY_MEMORY_STORE_NAME="my_memory_store" +export FOUNDRY_PROJECT_ENDPOINT="https://your-account.services.ai.azure.com/api/projects/your-project" +export FOUNDRY_PROJECT_MEMORY_STORE_NAME="my_memory_store" -# Azure OpenAI deployment name (model deployed in your Foundry project) -export AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" +# 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 @@ -47,11 +39,12 @@ dotnet run ## Expected Output The agent will: -1. Learn your name (Taylor), travel destination (Patagonia), timing (November), companions (sister), and interests (scenic viewpoints) -2. Wait for Foundry Memory to index the memories -3. Recall those details when asked about the trip -4. Demonstrate memory persistence across session serialization/deserialization -5. Show that a brand new session can still access the same memories +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 @@ -61,3 +54,4 @@ The agent will: | 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/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs index 4ee323bc5a..6107a01cc7 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientExtensions.cs @@ -14,22 +14,58 @@ namespace Microsoft.Agents.AI.FoundryMemory; /// -/// Extension methods for to provide MemoryStores operations +/// 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. /// - /// The AI Project client. - /// The name of the memory store to search. - /// The namespace that logically groups and isolates memories, such as a user ID. - /// The conversation messages to use for the search query. - /// Maximum number of memories to return. - /// Cancellation token. - /// Enumerable of memory content strings. - public static async Task> SearchMemoriesAsync( + internal static async Task SearchMemoriesAsync( this AIProjectClient client, string memoryStoreName, string scope, @@ -37,37 +73,28 @@ public static async Task> SearchMemoriesAsync( int maxMemories, CancellationToken cancellationToken) { - var request = new SearchMemoriesRequest + SearchMemoriesRequest request = new() { Scope = scope, Items = messages.ToArray(), Options = new SearchMemoriesOptions { MaxMemories = maxMemories } }; - var json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.SearchMemoriesRequest); - var content = BinaryContent.Create(BinaryData.FromString(json)); + string json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.SearchMemoriesRequest); + BinaryContent content = BinaryContent.Create(BinaryData.FromString(json)); - var requestOptions = new RequestOptions { CancellationToken = cancellationToken }; + RequestOptions requestOptions = new() { CancellationToken = cancellationToken }; ClientResult result = await client.MemoryStores.SearchMemoriesAsync(memoryStoreName, content, requestOptions).ConfigureAwait(false); - var response = JsonSerializer.Deserialize( + return JsonSerializer.Deserialize( result.GetRawResponse().Content.ToString(), FoundryMemoryJsonContext.Default.SearchMemoriesResponse); - - return response?.Memories?.Select(m => m.MemoryItem?.Content ?? string.Empty) - .Where(c => !string.IsNullOrWhiteSpace(c)) ?? []; } /// /// Updates memory store with conversation memories. /// - /// The AI Project client. - /// The name of the memory store to update. - /// The namespace that logically groups and isolates memories, such as a user ID. - /// The conversation messages to extract memories from. - /// Delay in seconds before processing the update. - /// Cancellation token. - public static async Task UpdateMemoriesAsync( + internal static async Task UpdateMemoriesAsync( this AIProjectClient client, string memoryStoreName, string scope, @@ -75,39 +102,56 @@ public static async Task UpdateMemoriesAsync( int updateDelay, CancellationToken cancellationToken) { - var request = new UpdateMemoriesRequest + UpdateMemoriesRequest request = new() { Scope = scope, Items = messages.ToArray(), UpdateDelay = updateDelay }; - var json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.UpdateMemoriesRequest); - var content = BinaryContent.Create(BinaryData.FromString(json)); + 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); - var requestOptions = new RequestOptions { CancellationToken = cancellationToken }; - await client.MemoryStores.UpdateMemoriesAsync(memoryStoreName, content, 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. /// - /// The AI Project client. - /// The name of the memory store. - /// The namespace that logically groups and isolates memories to delete, such as a user ID. - /// Cancellation token. - public static async Task DeleteScopeAsync( + internal static async Task DeleteScopeAsync( this AIProjectClient client, string memoryStoreName, string scope, CancellationToken cancellationToken) { - var request = new DeleteScopeRequest { Scope = scope }; + DeleteScopeRequest request = new() { Scope = scope }; - var json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.DeleteScopeRequest); - var content = BinaryContent.Create(BinaryData.FromString(json)); + string json = JsonSerializer.Serialize(request, FoundryMemoryJsonContext.Default.DeleteScopeRequest); + BinaryContent content = BinaryContent.Create(BinaryData.FromString(json)); - var requestOptions = new RequestOptions { CancellationToken = cancellationToken }; + RequestOptions requestOptions = new() { CancellationToken = cancellationToken }; await client.MemoryStores.DeleteScopeAsync(memoryStoreName, content, requestOptions).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientMemoryOperations.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientMemoryOperations.cs deleted file mode 100644 index 3a3cecaac9..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/AIProjectClientMemoryOperations.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Azure.AI.Projects; -using Microsoft.Agents.AI.FoundryMemory.Core.Models; - -namespace Microsoft.Agents.AI.FoundryMemory; - -/// -/// Implementation of using . -/// -internal sealed class AIProjectClientMemoryOperations : IFoundryMemoryOperations -{ - private readonly AIProjectClient _client; - - public AIProjectClientMemoryOperations(AIProjectClient client) - { - this._client = client; - } - - public Task> SearchMemoriesAsync( - string memoryStoreName, - string scope, - IEnumerable messages, - int maxMemories, - CancellationToken cancellationToken) - { - return this._client.SearchMemoriesAsync(memoryStoreName, scope, messages, maxMemories, cancellationToken); - } - - public Task UpdateMemoriesAsync( - string memoryStoreName, - string scope, - IEnumerable messages, - int updateDelay, - CancellationToken cancellationToken) - { - return this._client.UpdateMemoriesAsync(memoryStoreName, scope, messages, updateDelay, cancellationToken); - } - - public Task DeleteScopeAsync( - string memoryStoreName, - string scope, - CancellationToken cancellationToken) - { - return this._client.DeleteScopeAsync(memoryStoreName, scope, cancellationToken); - } -} 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/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/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/UpdateMemoriesResponse.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs index f69474d89d..7ab9bdf68f 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs @@ -17,7 +17,20 @@ internal sealed class UpdateMemoriesResponse /// /// 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 index 8250b12a3f..f63d4a52e8 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryJsonUtilities.cs @@ -39,7 +39,11 @@ internal static class FoundryMemoryJsonUtilities [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))] diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs index d7919f3ca9..5ef25d49aa 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -34,7 +34,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider private readonly int _updateDelay; private readonly bool _enableSensitiveTelemetryData; - private readonly IFoundryMemoryOperations _operations; + private readonly AIProjectClient _client; private readonly ILogger? _logger; private readonly FoundryMemoryProviderScope _scope; @@ -51,39 +51,8 @@ public FoundryMemoryProvider( FoundryMemoryProviderScope scope, FoundryMemoryProviderOptions? options = null, ILoggerFactory? loggerFactory = null) - : this(new AIProjectClientMemoryOperations(Throw.IfNull(client)), scope, options, loggerFactory) { - } - - /// - /// 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) - : this(new AIProjectClientMemoryOperations(Throw.IfNull(client)), serializedState, jsonSerializerOptions, options, loggerFactory) - { - } - - /// - /// Initializes a new instance of the class with a custom operations implementation. - /// - /// This constructor enables testability by allowing injection of mock operations. - internal FoundryMemoryProvider( - IFoundryMemoryOperations operations, - FoundryMemoryProviderScope scope, - FoundryMemoryProviderOptions? options = null, - ILoggerFactory? loggerFactory = null) - { - Throw.IfNull(operations); + Throw.IfNull(client); Throw.IfNull(scope); if (string.IsNullOrWhiteSpace(scope.Scope)) @@ -91,7 +60,7 @@ internal FoundryMemoryProvider( throw new ArgumentException("The Scope property must be provided.", nameof(scope)); } - var effectiveOptions = options ?? new FoundryMemoryProviderOptions(); + FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions(); if (string.IsNullOrWhiteSpace(effectiveOptions.MemoryStoreName)) { @@ -99,7 +68,7 @@ internal FoundryMemoryProvider( } this._logger = loggerFactory?.CreateLogger(); - this._operations = operations; + this._client = client; this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt; this._memoryStoreName = effectiveOptions.MemoryStoreName; @@ -112,17 +81,21 @@ internal FoundryMemoryProvider( /// /// Initializes a new instance of the class, with existing state from a serialized JSON element. /// - /// This constructor enables testability by allowing injection of mock operations. - internal FoundryMemoryProvider( - IFoundryMemoryOperations operations, + /// 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(operations); + Throw.IfNull(client); - var effectiveOptions = options ?? new FoundryMemoryProviderOptions(); + FoundryMemoryProviderOptions effectiveOptions = options ?? new FoundryMemoryProviderOptions(); if (string.IsNullOrWhiteSpace(effectiveOptions.MemoryStoreName)) { @@ -130,7 +103,7 @@ internal FoundryMemoryProvider( } this._logger = loggerFactory?.CreateLogger(); - this._operations = operations; + this._client = client; this._contextPrompt = effectiveOptions.ContextPrompt ?? DefaultContextPrompt; this._memoryStoreName = effectiveOptions.MemoryStoreName; @@ -138,8 +111,8 @@ internal FoundryMemoryProvider( this._updateDelay = effectiveOptions.UpdateDelay; this._enableSensitiveTelemetryData = effectiveOptions.EnableSensitiveTelemetryData; - var jso = jsonSerializerOptions ?? FoundryMemoryJsonUtilities.DefaultOptions; - var state = serializedState.Deserialize(jso.GetTypeInfo(typeof(FoundryMemoryState))) as FoundryMemoryState; + JsonSerializerOptions jso = jsonSerializerOptions ?? FoundryMemoryJsonUtilities.DefaultOptions; + FoundryMemoryState? state = serializedState.Deserialize(jso.GetTypeInfo(typeof(FoundryMemoryState))) as FoundryMemoryState; if (state?.Scope == null || string.IsNullOrWhiteSpace(state.Scope.Scope)) { @@ -155,7 +128,7 @@ public override async ValueTask InvokingAsync(InvokingContext context Throw.IfNull(context); #pragma warning disable CA1308 // Lowercase required by service - var messageItems = context.RequestMessages + MemoryInputMessage[] messageItems = context.RequestMessages .Where(m => !string.IsNullOrWhiteSpace(m.Text)) .Select(m => new MemoryInputMessage { @@ -172,14 +145,19 @@ public override async ValueTask InvokingAsync(InvokingContext context try { - var memories = (await this._operations.SearchMemoriesAsync( + SearchMemoriesResponse? response = await this._client.SearchMemoriesAsync( this._memoryStoreName, this._scope.Scope!, messageItems, this._maxMemories, - cancellationToken).ConfigureAwait(false)).ToList(); + cancellationToken).ConfigureAwait(false); + + var memories = response?.Memories? + .Select(m => m.MemoryItem?.Content ?? string.Empty) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .ToList() ?? []; - var outputMessageText = memories.Count == 0 + string? outputMessageText = memories.Count == 0 ? null : $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}"; @@ -220,6 +198,7 @@ public override async ValueTask InvokingAsync(InvokingContext context this._memoryStoreName, this.SanitizeLogData(this._scope.Scope)); } + return new AIContext(); } } @@ -235,7 +214,7 @@ public override async ValueTask InvokedAsync(InvokedContext context, Cancellatio try { #pragma warning disable CA1308 // Lowercase required by service - var messageItems = context.RequestMessages + MemoryInputMessage[] messageItems = context.RequestMessages .Concat(context.ResponseMessages ?? []) .Where(m => IsAllowedRole(m.Role) && !string.IsNullOrWhiteSpace(m.Text)) .Select(m => new MemoryInputMessage @@ -251,7 +230,7 @@ public override async ValueTask InvokedAsync(InvokedContext context, Cancellatio return; } - await this._operations.UpdateMemoriesAsync( + UpdateMemoriesResponse? response = await this._client.UpdateMemoriesAsync( this._memoryStoreName, this._scope.Scope!, messageItems, @@ -261,10 +240,11 @@ await this._operations.UpdateMemoriesAsync( if (this._logger?.IsEnabled(LogLevel.Information) is true) { this._logger.LogInformation( - "FoundryMemoryProvider: Sent {Count} messages to update memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}'.", + "FoundryMemoryProvider: Sent {Count} messages to update memories. MemoryStore: '{MemoryStoreName}', Scope: '{Scope}', UpdateId: '{UpdateId}'.", messageItems.Length, this._memoryStoreName, - this.SanitizeLogData(this._scope.Scope)); + this.SanitizeLogData(this._scope.Scope), + response?.UpdateId); } } catch (Exception ex) @@ -289,7 +269,7 @@ public async Task EnsureStoredMemoriesDeletedAsync(CancellationToken cancellatio { try { - await this._operations.DeleteScopeAsync(this._memoryStoreName, this._scope.Scope!, cancellationToken).ConfigureAwait(false); + await this._client.DeleteScopeAsync(this._memoryStoreName, this._scope.Scope!, cancellationToken).ConfigureAwait(false); } catch (ClientResultException ex) when (ex.Status == 404) { @@ -304,12 +284,52 @@ public async Task EnsureStoredMemoriesDeletedAsync(CancellationToken cancellatio } } + /// + /// 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); + } + } + } + /// public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) { - var state = new FoundryMemoryState(this._scope); + FoundryMemoryState state = new(this._scope); - var jso = jsonSerializerOptions ?? FoundryMemoryJsonUtilities.DefaultOptions; + JsonSerializerOptions jso = jsonSerializerOptions ?? FoundryMemoryJsonUtilities.DefaultOptions; return JsonSerializer.SerializeToElement(state, jso.GetTypeInfo(typeof(FoundryMemoryState))); } diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/IFoundryMemoryOperations.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/IFoundryMemoryOperations.cs deleted file mode 100644 index af7fd6822c..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/IFoundryMemoryOperations.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.FoundryMemory.Core.Models; - -namespace Microsoft.Agents.AI.FoundryMemory; - -/// -/// Interface for Foundry Memory operations, enabling testability. -/// -internal interface IFoundryMemoryOperations -{ - /// - /// Searches for relevant memories from a memory store based on conversation context. - /// - Task> SearchMemoriesAsync( - string memoryStoreName, - string scope, - IEnumerable messages, - int maxMemories, - CancellationToken cancellationToken); - - /// - /// Updates memory store with conversation memories. - /// - Task UpdateMemoriesAsync( - string memoryStoreName, - string scope, - IEnumerable messages, - int updateDelay, - CancellationToken cancellationToken); - - /// - /// Deletes all memories associated with a specific scope from a memory store. - /// - Task DeleteScopeAsync( - string memoryStoreName, - string scope, - CancellationToken cancellationToken); -} diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs index 310da61ba5..12307c24ca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/FoundryMemoryProviderTests.cs @@ -1,63 +1,41 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.ClientModel; -using System.ClientModel.Primitives; -using System.Collections.Generic; -using System.IO; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.FoundryMemory.Core.Models; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Moq; namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; /// -/// Tests for . +/// 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 { - private readonly Mock _operationsMock; - private readonly Mock> _loggerMock; - private readonly Mock _loggerFactoryMock; - - public FoundryMemoryProviderTests() - { - this._operationsMock = new(); - this._loggerMock = new(); - this._loggerFactoryMock = new(); - this._loggerFactoryMock - .Setup(f => f.CreateLogger(It.IsAny())) - .Returns(this._loggerMock.Object); - this._loggerFactoryMock - .Setup(f => f.CreateLogger(typeof(FoundryMemoryProvider).FullName!)) - .Returns(this._loggerMock.Object); - - this._loggerMock - .Setup(f => f.IsEnabled(It.IsAny())) - .Returns(true); - } - [Fact] - public void Constructor_Throws_WhenOperationsIsNull() + public void Constructor_Throws_WhenClientIsNull() { // Act & Assert - var ex = Assert.Throws(() => new FoundryMemoryProvider( - (IFoundryMemoryOperations)null!, + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + null!, new FoundryMemoryProviderScope { Scope = "test" }, new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); - Assert.Equal("operations", ex.ParamName); + Assert.Equal("client", ex.ParamName); } [Fact] public void Constructor_Throws_WhenScopeIsNull() { + // Arrange + using TestableAIProjectClient testClient = new(); + // Act & Assert - var ex = Assert.Throws(() => new FoundryMemoryProvider( - this._operationsMock.Object, + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, null!, new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); Assert.Equal("scope", ex.ParamName); @@ -66,9 +44,12 @@ public void Constructor_Throws_WhenScopeIsNull() [Fact] public void Constructor_Throws_WhenScopeValueIsEmpty() { + // Arrange + using TestableAIProjectClient testClient = new(); + // Act & Assert - var ex = Assert.Throws(() => new FoundryMemoryProvider( - this._operationsMock.Object, + ArgumentException ex = Assert.Throws(() => new FoundryMemoryProvider( + testClient.Client, new FoundryMemoryProviderScope(), new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); Assert.StartsWith("The Scope property must be provided.", ex.Message); @@ -77,257 +58,86 @@ public void Constructor_Throws_WhenScopeValueIsEmpty() [Fact] public void Constructor_Throws_WhenMemoryStoreNameIsMissing() { + // Arrange + using TestableAIProjectClient testClient = new(); + // Act & Assert - var ex = Assert.Throws(() => new FoundryMemoryProvider( - this._operationsMock.Object, + 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 DeserializingConstructor_Throws_WithEmptyJsonElement() + public void Constructor_Throws_WhenMemoryStoreNameIsNull() { // Arrange - JsonElement jsonElement = JsonSerializer.SerializeToElement(new object(), FoundryMemoryJsonUtilities.DefaultOptions); + using TestableAIProjectClient testClient = new(); // Act & Assert - var ex = Assert.Throws(() => new FoundryMemoryProvider( - this._operationsMock.Object, - jsonElement, - options: new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); - Assert.StartsWith("The FoundryMemoryProvider state did not contain the required scope property.", ex.Message); - } - - [Fact] - public async Task InvokingAsync_PerformsSearch_AndReturnsContextMessageAsync() - { - // Arrange - this._operationsMock - .Setup(o => o.SearchMemoriesAsync( - "my-store", - "user-123", - It.IsAny>(), - 5, - It.IsAny())) - .ReturnsAsync(["User prefers dark roast coffee", "User is from Seattle"]); - - FoundryMemoryProviderScope scope = new() { Scope = "user-123" }; - FoundryMemoryProviderOptions options = new() - { - MemoryStoreName = "my-store", - EnableSensitiveTelemetryData = true - }; - - FoundryMemoryProvider sut = new(this._operationsMock.Object, scope, options); - AIContextProvider.InvokingContext invokingContext = new([new ChatMessage(ChatRole.User, "What are my coffee preferences?")]); - - // Act - AIContext aiContext = await sut.InvokingAsync(invokingContext); - - // Assert - this._operationsMock.Verify( - o => o.SearchMemoriesAsync("my-store", "user-123", It.IsAny>(), 5, It.IsAny()), - Times.Once); - - Assert.NotNull(aiContext.Messages); - ChatMessage contextMessage = Assert.Single(aiContext.Messages); - Assert.Equal(ChatRole.User, contextMessage.Role); - Assert.Contains("User prefers dark roast coffee", contextMessage.Text); - Assert.Contains("User is from Seattle", contextMessage.Text); - } - - [Fact] - public async Task InvokingAsync_ReturnsEmptyContext_WhenNoMemoriesFoundAsync() - { - // Arrange - this._operationsMock - .Setup(o => o.SearchMemoriesAsync( - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(Array.Empty()); - - FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); - AIContextProvider.InvokingContext invokingContext = new([new ChatMessage(ChatRole.User, "Hello")]); - - // Act - AIContext aiContext = await sut.InvokingAsync(invokingContext); - - // Assert - Assert.NotNull(aiContext.Messages); - ChatMessage contextMessage = Assert.Single(aiContext.Messages); - Assert.True(string.IsNullOrEmpty(contextMessage.Text)); // Text is null or empty when no memories found - } - - [Fact] - public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync() - { - // Arrange - this._operationsMock - .Setup(o => o.SearchMemoriesAsync( - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .ThrowsAsync(new InvalidOperationException("Search failed")); - - FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }, this._loggerFactoryMock.Object); - AIContextProvider.InvokingContext invokingContext = new([new ChatMessage(ChatRole.User, "Q?")]); - - // Act - AIContext aiContext = await sut.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - Assert.Null(aiContext.Messages); - Assert.Null(aiContext.Tools); - this._loggerMock.Verify( - l => l.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("FoundryMemoryProvider: Failed to search for memories due to error")), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - [Fact] - public async Task InvokedAsync_PersistsAllowedMessagesAsync() - { - // Arrange - IEnumerable? capturedMessages = null; - this._operationsMock - .Setup(o => o.UpdateMemoriesAsync( - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .Callback, int, CancellationToken>((_, _, msgs, _, _) => capturedMessages = msgs) - .Returns(Task.CompletedTask); - - FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); - - List requestMessages = - [ - new(ChatRole.User, "User text"), - new(ChatRole.System, "System text"), - new(ChatRole.Tool, "Tool text should be ignored") - ]; - List responseMessages = [new(ChatRole.Assistant, "Assistant text")]; - - // Act - await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages }); - - // Assert - this._operationsMock.Verify( - o => o.UpdateMemoriesAsync("my-store", "user-123", It.IsAny>(), 0, It.IsAny()), - Times.Once); - - Assert.NotNull(capturedMessages); - List messagesList = [.. capturedMessages]; - Assert.Equal(3, messagesList.Count); // user, system, assistant (tool excluded) - Assert.Contains(messagesList, m => m.Role == "user" && m.Content == "User text"); - Assert.Contains(messagesList, m => m.Role == "system" && m.Content == "System text"); - Assert.Contains(messagesList, m => m.Role == "assistant" && m.Content == "Assistant text"); - Assert.DoesNotContain(messagesList, m => m.Content == "Tool text should be ignored"); - } - - [Fact] - public async Task InvokedAsync_PersistsNothingForFailedRequestAsync() - { - // Arrange - FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); - - List requestMessages = - [ - new(ChatRole.User, "User text"), - new(ChatRole.System, "System text") - ]; - - // Act - await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) - { - ResponseMessages = null, - InvokeException = new InvalidOperationException("Request Failed") - }); - - // Assert - this._operationsMock.Verify( - o => o.UpdateMemoriesAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), - Times.Never); + 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 async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync() + public void DeserializingConstructor_Throws_WhenClientIsNull() { - // Arrange - this._operationsMock - .Setup(o => o.UpdateMemoriesAsync( - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .ThrowsAsync(new InvalidOperationException("Storage failed")); - - FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }, this._loggerFactoryMock.Object); + // Arrange - use source-generated JSON context + JsonElement jsonElement = JsonSerializer.SerializeToElement( + new TestState { Scope = new TestScope { Scope = "test" } }, + TestJsonContext.Default.TestState); - List requestMessages = [new(ChatRole.User, "User text")]; - List responseMessages = [new(ChatRole.Assistant, "Assistant text")]; - - // Act - await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages }); - - // Assert - this._loggerMock.Verify( - l => l.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("FoundryMemoryProvider: Failed to send messages to update memories due to error")), - It.IsAny(), - It.IsAny>()), - Times.Once); + // Act & Assert + ArgumentNullException ex = Assert.Throws(() => new FoundryMemoryProvider( + null!, + jsonElement, + options: new FoundryMemoryProviderOptions { MemoryStoreName = "store" })); + Assert.Equal("client", ex.ParamName); } [Fact] - public async Task EnsureStoredMemoriesDeletedAsync_SendsDeleteRequestAsync() + public void DeserializingConstructor_Throws_WithEmptyJsonElement() { // Arrange - FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); - - // Act - await sut.EnsureStoredMemoriesDeletedAsync(); + using TestableAIProjectClient testClient = new(); + JsonElement jsonElement = JsonDocument.Parse("{}").RootElement; - // Assert - this._operationsMock.Verify( - o => o.DeleteScopeAsync("my-store", "user-123", It.IsAny()), - Times.Once); + // 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 async Task EnsureStoredMemoriesDeletedAsync_Handles404GracefullyAsync() + public void DeserializingConstructor_Throws_WithMissingScopeValue() { // Arrange - this._operationsMock - .Setup(o => o.DeleteScopeAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new ClientResultException(new MockPipelineResponse(404))); - - FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + using TestableAIProjectClient testClient = new(); + JsonElement jsonElement = JsonSerializer.SerializeToElement( + new TestState { Scope = new TestScope() }, + TestJsonContext.Default.TestState); - // Act & Assert - should not throw - await sut.EnsureStoredMemoriesDeletedAsync(); + // 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(this._operationsMock.Object, scope, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); + FoundryMemoryProvider sut = new(testClient.Client, scope, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); // Act JsonElement stateElement = sut.Serialize(); @@ -338,106 +148,22 @@ public void Serialize_RoundTripsScope() Assert.Equal("user-456", scopeElement.GetProperty("scope").GetString()); } - [Theory] - [InlineData(true, "user-123")] - [InlineData(false, "")] - public async Task InvokingAsync_LogsScopeBasedOnEnableSensitiveTelemetryDataAsync(bool enableSensitiveTelemetryData, string expectedScopeInLog) + [Fact] + public void DeserializingConstructor_RestoresScope() { // Arrange - this._operationsMock - .Setup(o => o.SearchMemoriesAsync( - It.IsAny(), - It.IsAny(), - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(["test memory"]); - - FoundryMemoryProviderOptions options = new() - { - MemoryStoreName = "my-store", - EnableSensitiveTelemetryData = enableSensitiveTelemetryData - }; - FoundryMemoryProvider sut = new(this._operationsMock.Object, new FoundryMemoryProviderScope { Scope = "user-123" }, options, this._loggerFactoryMock.Object); - - AIContextProvider.InvokingContext invokingContext = new([new ChatMessage(ChatRole.User, "test")]); + using TestableAIProjectClient testClient = new(); + FoundryMemoryProviderScope originalScope = new() { Scope = "restored-user-789" }; + FoundryMemoryProvider original = new(testClient.Client, originalScope, new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); // Act - await sut.InvokingAsync(invokingContext, CancellationToken.None); - - // Assert - this._loggerMock.Verify( - l => l.Log( - LogLevel.Information, - It.IsAny(), - It.Is((v, t) => - v.ToString()!.Contains("Retrieved 1 memories") && - v.ToString()!.Contains($"Scope: '{expectedScopeInLog}'")), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - private sealed class MockPipelineResponse : PipelineResponse - { - private readonly int _status; - private readonly MockPipelineResponseHeaders _headers; - - public MockPipelineResponse(int status) - { - this._status = status; - this.Content = BinaryData.Empty; - this._headers = new MockPipelineResponseHeaders(); - } + JsonElement serializedState = original.Serialize(); + FoundryMemoryProvider restored = new(testClient.Client, serializedState, options: new FoundryMemoryProviderOptions { MemoryStoreName = "my-store" }); - public override int Status => this._status; - - public override string ReasonPhrase => this._status == 404 ? "Not Found" : "OK"; - - public override Stream? ContentStream - { - get => null; - set { } - } - - public override BinaryData Content { get; } - - protected override PipelineResponseHeaders HeadersCore => this._headers; - - public override BinaryData BufferContent(CancellationToken cancellationToken = default) => this.Content; - - public override ValueTask BufferContentAsync(CancellationToken cancellationToken = default) => - new(this.Content); - - public override void Dispose() - { - } - - private sealed class MockPipelineResponseHeaders : PipelineResponseHeaders - { - private readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); - - public override bool TryGetValue(string name, out string? value) - { - return this._headers.TryGetValue(name, out value); - } - - public override bool TryGetValues(string name, out IEnumerable? values) - { - if (this._headers.TryGetValue(name, out string? value)) - { - values = [value]; - return true; - } - - values = null; - return false; - } - - public override IEnumerator> GetEnumerator() - { - return this._headers.GetEnumerator(); - } - } + // 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/TestableAIProjectClient.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs new file mode 100644 index 0000000000..48d022d49a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs @@ -0,0 +1,215 @@ +// 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; +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 +<<<<<<< TODO: Unmerged change from project 'Microsoft.Agents.AI.FoundryMemory.UnitTests(net472)', Before: + private readonly MockHttpMessageHandler _handler; + private readonly HttpClient _httpClient; +======= + private readonly HttpClient _httpClient; +>>>>>>> After + +{ + 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); + +<<<<<<< TODO: Unmerged change from project 'Microsoft.Agents.AI.FoundryMemory.UnitTests(net472)', Before: + this._httpClient = new HttpClient(this._handler); +======= + this._httpClient = new HttpClient(this.Handler); +>>>>>>> After + 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; } + + +<<<<<<< TODO: Unmerged change from project 'Microsoft.Agents.AI.FoundryMemory.UnitTests(net472)', Before: + public MockHttpMessageHandler Handler => this._handler; +======= + public MockHttpMessageHandler Handler { get; } +>>>>>>> After + public MockHttpMessageHandler Handler { get; } + + public void Dispose() + { + this._httpClient.Dispose(); + this.Handler.Dispose(); + } +} + +/// +/// Mock HTTP message handler for testing. +/// +internal sealed class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly string? _searchMemoriesResponse; + private readonly string? _updateMemoriesResponse; + private readonly HttpStatusCode _searchStatusCode; + private readonly HttpStatusCode _updateStatusCode; + private readonly HttpStatusCode _deleteStatusCode; + private readonly HttpStatusCode _createStoreStatusCode; + private readonly HttpStatusCode _getStoreStatusCode; + + public MockHttpMessageHandler( + string? searchMemoriesResponse = null, + string? updateMemoriesResponse = null, + HttpStatusCode? searchStatusCode = null, + HttpStatusCode? updateStatusCode = null, + HttpStatusCode? deleteStatusCode = null, + HttpStatusCode? createStoreStatusCode = null, + HttpStatusCode? getStoreStatusCode = null) + { + this._searchMemoriesResponse = searchMemoriesResponse ?? """{"memories":[]}"""; + this._updateMemoriesResponse = updateMemoriesResponse ?? """{"update_id":"test-update-id","status":"queued"}"""; + this._searchStatusCode = searchStatusCode ?? HttpStatusCode.OK; + this._updateStatusCode = updateStatusCode ?? HttpStatusCode.OK; + this._deleteStatusCode = deleteStatusCode ?? HttpStatusCode.NoContent; + this._createStoreStatusCode = createStoreStatusCode ?? HttpStatusCode.Created; + this._getStoreStatusCode = getStoreStatusCode ?? HttpStatusCode.NotFound; + } + + public string? LastRequestUri { get; private set; } + public string? LastRequestBody { get; private set; } + public HttpMethod? LastRequestMethod { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequestUri = request.RequestUri?.ToString(); + this.LastRequestMethod = request.Method; + + if (request.Content != null) + { +#if NET472 + this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); +#else + this.LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +#endif + } + + string path = request.RequestUri?.AbsolutePath ?? ""; + + // Route based on path and method + if (path.Contains("/memory-stores/") && path.Contains("/search") && request.Method == HttpMethod.Post) + { + return CreateResponse(this._searchStatusCode, this._searchMemoriesResponse); + } + + if (path.Contains("/memory-stores/") && path.Contains("/memories") && request.Method == HttpMethod.Post) + { + return CreateResponse(this._updateStatusCode, this._updateMemoriesResponse); + } + + if (path.Contains("/memory-stores/") && path.Contains("/scopes") && request.Method == HttpMethod.Delete) + { + return CreateResponse(this._deleteStatusCode, ""); + } + + if (path.Contains("/memory-stores") && request.Method == HttpMethod.Post) + { + return CreateResponse(this._createStoreStatusCode, """{"name":"test-store","status":"active"}"""); + } + + if (path.Contains("/memory-stores/") && request.Method == HttpMethod.Get) + { + return CreateResponse(this._getStoreStatusCode, """{"name":"test-store","status":"active"}"""); + } + + // Default response + return CreateResponse(HttpStatusCode.NotFound, "{}"); + } + + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string? content) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(content ?? "{}", Encoding.UTF8, "application/json") + }; + } +} + +/// +/// Mock token credential for testing. +/// +internal sealed class MockTokenCredential : TokenCredential +{ + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); + } +} + +/// +/// Source-generated JSON serializer context for unit test types. +/// +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(TestState))] +[JsonSerializable(typeof(TestScope))] +internal sealed partial class TestJsonContext : JsonSerializerContext +{ +} + +/// +/// Test state class for deserialization tests. +/// +internal sealed class TestState +{ + public TestScope? Scope { get; set; } +} + +/// +/// Test scope class for deserialization tests. +/// +internal sealed class TestScope +{ + public string? Scope { get; set; } +} From 96fc1ce0bdd5d8e35bd242b3d18c1a5d841db7ef Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:25:25 +0000 Subject: [PATCH 5/8] Add waiting operation for memory store updates --- .../Program.cs | 69 +++++++++------- .../Core/Models/UpdateMemoriesResponse.cs | 15 ++++ .../FoundryMemoryProvider.cs | 78 +++++++++++++++++++ .../TestableAIProjectClient.cs | 19 ----- 4 files changed, 132 insertions(+), 49 deletions(-) diff --git a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index b4f4270bee..3604c46698 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -62,16 +62,11 @@ 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. -// In production scenarios, you would: -// 1. Track the UpdateId returned from each memory update operation -// 2. Poll the GetUpdateResultAsync API to check completion status -// 3. Wait for status to become "completed" before querying memories -// -// For simplicity, this sample uses a fixed delay. For production code, implement proper polling -// using the Azure.AI.Projects SDK's MemoryStores.GetUpdateResultAsync method. +// WhenUpdatesCompletedAsync polls all pending updates and waits for them to complete. Console.WriteLine("\nWaiting for Foundry Memory to process updates..."); -await Task.Delay(TimeSpan.FromSeconds(10)); -Console.WriteLine("Continuing after delay.\n"); +await memoryProvider.WhenUpdatesCompletedAsync(); + +Console.WriteLine("Updates completed.\n"); Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", session)); @@ -81,6 +76,10 @@ 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.GetNewSessionAsync(); Console.WriteLine(await agent.RunAsync("Summarize what you already know about me.", newSession)); @@ -89,26 +88,36 @@ internal sealed class DebugHttpClientHandler : HttpClientHandler { protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - // Uncomment to debug HTTP traffic: - // Console.WriteLine("\n=== HTTP REQUEST ==="); - // Console.WriteLine($"Method: {request.Method}"); - // Console.WriteLine($"URI: {request.RequestUri}"); - // - // if (request.Content != null) - // { - // string body = await request.Content.ReadAsStringAsync(cancellationToken); - // Console.WriteLine("Body: " + body); - // } - // - // Console.WriteLine("====================\n"); - - // Uncomment to debug HTTP traffic: - // 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 await base.SendAsync(request, 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/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs index 7ab9bdf68f..9f6cd9651b 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Core/Models/UpdateMemoriesResponse.cs @@ -9,6 +9,21 @@ namespace Microsoft.Agents.AI.FoundryMemory.Core.Models; /// 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. /// diff --git a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs index 5ef25d49aa..24c604ce8e 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/FoundryMemoryProvider.cs @@ -2,6 +2,8 @@ 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; @@ -38,6 +40,7 @@ public sealed class FoundryMemoryProvider : AIContextProvider private readonly ILogger? _logger; private readonly FoundryMemoryProviderScope _scope; + private readonly ConcurrentQueue _pendingUpdateIds = new(); /// /// Initializes a new instance of the class. @@ -237,6 +240,11 @@ public override async ValueTask InvokedAsync(InvokedContext context, Cancellatio 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( @@ -324,6 +332,76 @@ public async Task EnsureMemoryStoreCreatedAsync( } } + /// + /// 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) { diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs index 48d022d49a..01df256a30 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs @@ -5,7 +5,6 @@ using System.Net; using System.Net.Http; using System.Text; -using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -18,13 +17,6 @@ namespace Microsoft.Agents.AI.FoundryMemory.UnitTests; /// Creates a testable AIProjectClient with a mock HTTP handler. /// internal sealed class TestableAIProjectClient : IDisposable -<<<<<<< TODO: Unmerged change from project 'Microsoft.Agents.AI.FoundryMemory.UnitTests(net472)', Before: - private readonly MockHttpMessageHandler _handler; - private readonly HttpClient _httpClient; -======= - private readonly HttpClient _httpClient; ->>>>>>> After - { private readonly HttpClient _httpClient; @@ -46,11 +38,6 @@ public TestableAIProjectClient( createStoreStatusCode, getStoreStatusCode); -<<<<<<< TODO: Unmerged change from project 'Microsoft.Agents.AI.FoundryMemory.UnitTests(net472)', Before: - this._httpClient = new HttpClient(this._handler); -======= - this._httpClient = new HttpClient(this.Handler); ->>>>>>> After this._httpClient = new HttpClient(this.Handler); AIProjectClientOptions options = new() @@ -67,12 +54,6 @@ public TestableAIProjectClient( public AIProjectClient Client { get; } - -<<<<<<< TODO: Unmerged change from project 'Microsoft.Agents.AI.FoundryMemory.UnitTests(net472)', Before: - public MockHttpMessageHandler Handler => this._handler; -======= - public MockHttpMessageHandler Handler { get; } ->>>>>>> After public MockHttpMessageHandler Handler { get; } public void Dispose() From f22a8c574911ac64224b34b915f7c3c7f831ffcd Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:57:24 +0000 Subject: [PATCH 6/8] Fix UTF-8 BOM encoding for FoundryMemory csproj files --- .../Microsoft.Agents.AI.FoundryMemory.csproj | 2 +- .../Microsoft.Agents.AI.FoundryMemory.IntegrationTests.csproj | 2 +- .../Microsoft.Agents.AI.FoundryMemory.UnitTests.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 index 0aa2dbb3e6..bc3baac152 100644 --- a/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj +++ b/dotnet/src/Microsoft.Agents.AI.FoundryMemory/Microsoft.Agents.AI.FoundryMemory.csproj @@ -1,4 +1,4 @@ - + preview 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 index d4cabd7687..652178aef8 100644 --- 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 @@ -1,4 +1,4 @@ - + True 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 index 9973e6f247..1fe8dc57bd 100644 --- 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 @@ -1,4 +1,4 @@ - + false From 43aa0533cf602dad909a84fa23582333f7566f42 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:38:01 +0000 Subject: [PATCH 7/8] Update copilot instructions for UTF-8 BOM and fix sample API rename --- .github/copilot-instructions.md | 1 + .../AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) 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/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs index 3604c46698..8125ffcb0b 100644 --- a/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/Program.cs @@ -46,7 +46,7 @@ : new FoundryMemoryProvider(projectClient, ctx.SerializedState, ctx.JsonSerializerOptions, new FoundryMemoryProviderOptions() { MemoryStoreName = memoryStoreName })) }); -AgentSession session = await agent.GetNewSessionAsync(); +AgentSession session = await agent.CreateSessionAsync(); FoundryMemoryProvider memoryProvider = session.GetService()!; @@ -80,7 +80,7 @@ Console.WriteLine("\nWaiting for Foundry Memory to process updates..."); await memoryProvider.WhenUpdatesCompletedAsync(); -AgentSession newSession = await agent.GetNewSessionAsync(); +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) From 74f48f8dc65c7e9d2ad5447a4a7db3cabf2c9390 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:36:48 +0000 Subject: [PATCH 8/8] Fix UTF-8 BOM encoding for TestableAIProjectClient.cs --- .../TestableAIProjectClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs index 01df256a30..25c041f754 100644 --- a/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs +++ b/dotnet/tests/Microsoft.Agents.AI.FoundryMemory.UnitTests/TestableAIProjectClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.ClientModel.Primitives;